feat(admin): 예전/최신 어드민 통합 — 실데이터 복원 + 인증 아키텍처 정리

예전 배포본(HEAD)의 상세 기능을 새 아키텍처(인증=.NET 백엔드, 데이터=Supabase)
위에 실데이터로 복원. 예전 HEAD는 서명 쿠키를 거부하고 위조 쿠키는 통과시키는
인증 결함이 있었고, 삭제된 페이지 다수는 백엔드 호출 0인 하드코딩 목업이었음.

인증/세션
- 로그인 이메일 전용화(username 폐지), 에러 키별 안내 메시지
- ADMIN_COOKIE_SECURE 옵션: TLS 없는 LAN HTTP 배포에서 Secure 쿠키 유실로
  로그인이 유지되지 않던 문제 해결 (login/logout route, admin-session, compose, .env.example)
- Supabase 미설정 시 우아한 저하: isSupabaseAdminConfigured + UnavailableAdminPanel

기능 복원 (실데이터)
- Release Hub: Forgejo API 실데이터(다운로드 수/SHA-256 체크섬/릴리스 이력)
- Ad Monetization: 데스크톱 미디에이션 10개 어댑터 로스터(fail-closed) + ad_reward_claims 통계
- License Issuer: 서버사이드 Ed25519 서명(/api/admin/license, super_admin 전용),
  개인키는 ADMIN_LICENSE_PRIVATE_KEY env로만, 발급 감사를 .NET AdminAuditEntries에 기록
- Service Models: STT 7종/LLM 5종 프리셋 드롭다운 + 자동채움
- 대시보드 ARR/MRR KPI: Supabase 구독 실집계(티어 월단가 기반)
- 사용자 상세 티어별 기능 배지(pro_plus 조건부)

.NET
- SuperAdminOnly 정책 추가, /api/admin/license-audit 엔드포인트, LicenseAuditDto
This commit is contained in:
Yun Chan 2026-08-23 23:38:08 +09:00
parent a9c9a1ca6e
commit 5a34f66981
66 changed files with 4471 additions and 3501 deletions

View file

@ -1,397 +1,353 @@
// apps/admin/src/app/(admin)/releases/page.tsx
// D3RO Voice Admin CRM — Release & Distribution Management Hub
// D3RO Voice Admin CRM — Release & Distribution Hub (Forgejo live feed)
'use client'
import { Box, Typography, Button } from '@mui/material'
import Link from 'next/link'
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
import { requireManager } from '@/lib/admin-guard'
import { fetchReleaseHub, type ReleaseAssetPlatform, type ReleaseHub } from '@/lib/forgejo-releases'
import { ChecksumCopy } from '@/components/checksum-copy'
import React, { useState } from 'react'
import { Box, Typography, Button, LinearProgress, Switch } from '@mui/material'
import {
CheckCircle2,
Copy,
ExternalLink,
ArrowUpRight,
} from 'lucide-react'
import { C, FONT_SANS, FONT_MONO } from '@/lib/console-theme'
export const dynamic = 'force-dynamic'
interface ReleaseAsset {
id: string
name: string
os: 'Windows' | 'macOS' | 'Linux' | 'Feed'
version: string
sizeMb: number
downloads: number
sha256: string
status: 'active' | 'deprecated' | 'archived'
url: string
const PLATFORM_LABEL: Record<ReleaseAssetPlatform, string> = {
windows: 'Windows',
macos: 'macOS',
android: 'Android',
linux: 'Linux',
feed: 'Update Feed',
other: 'Other'
}
const INITIAL_ASSETS: ReleaseAsset[] = [
{
id: '1',
name: 'D3RO-Voice-Setup-1.0.0-x64.exe',
os: 'Windows',
version: '1.0.0',
sizeMb: 102.1,
downloads: 1420,
sha256: 'b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2',
status: 'active',
url: '/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe',
},
{
id: '2',
name: 'D3RO-Voice-1.0.0-arm64.dmg',
os: 'macOS',
version: '1.0.0',
sizeMb: 98.4,
downloads: 890,
sha256: 'd9f28a391c49b1a03982e0192847192837491823749182374918237491823749',
status: 'active',
url: '/releases/1.0.0/D3RO-Voice-1.0.0-arm64.dmg',
},
{
id: '3',
name: 'latest.yml',
os: 'Feed',
version: '1.0.0',
sizeMb: 0.01,
downloads: 4820,
sha256: '795b7230bec047284785f480026d51b9247d8618e083d88cfda786d1894ca367',
status: 'active',
url: '/releases/latest.yml',
},
{
id: '4',
name: 'D3RO-Voice-Setup-0.2.1-alpha-x64.exe',
os: 'Windows',
version: '0.2.1-alpha',
sizeMb: 99.8,
downloads: 620,
sha256: '1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b',
status: 'deprecated',
url: '/releases/0.2.1-alpha/D3RO-Voice-Setup-0.2.1-alpha-x64.exe',
},
]
const PLATFORM_BADGE: Record<ReleaseAssetPlatform, 'blue' | 'purple' | 'green' | 'orange' | 'cyan'> = {
windows: 'blue',
macos: 'purple',
android: 'green',
linux: 'orange',
feed: 'cyan',
other: 'orange'
}
export default function ReleasesManagementPage(): React.ReactElement {
const [assets] = useState<ReleaseAsset[]>(INITIAL_ASSETS)
const [rolloutPercent, setRolloutPercent] = useState<number>(100)
const [forceUpdateEnabled, setForceUpdateEnabled] = useState<boolean>(false)
const [copiedId, setCopiedId] = useState<string | null>(null)
function formatSize(sizeBytes: number): string {
if (sizeBytes <= 0) return '—'
if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(1)} KB`
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`
}
const copyToClipboard = (text: string, id: string): void => {
navigator.clipboard.writeText(text).then(() => {
setCopiedId(id)
setTimeout(() => setCopiedId(null), 2000)
})
}
function formatDate(value: string): string {
if (!value) return '—'
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? '—' : parsed.toISOString().slice(0, 10)
}
const totalDownloads = assets.reduce((acc, curr) => acc + curr.downloads, 0)
function HeaderBar({ feedLive, repoHtmlUrl }: { feedLive: boolean; repoHtmlUrl: string }): React.ReactElement {
return (
<Box
sx={{
...panelSx,
minHeight: 84,
px: { xs: 2.5, md: 4 },
py: 2,
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box
sx={{
width: 4,
height: 32,
borderRadius: '999px',
background: `linear-gradient(180deg, ${C.cyan} 0%, ${C.accent} 50%, ${C.purple} 100%)`
}}
/>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography
component="h1"
sx={{ fontFamily: FONT_SANS, fontSize: '20px', fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0 }}
>
Release &amp; Distribution Hub
</Typography>
{feedLive ? (
<TactileBadge ledColor="green" ledPulse tone="success" mono>
LIVE FORGEJO FEED
</TactileBadge>
) : (
<TactileBadge tone="warning" mono>
FEED UNREACHABLE
</TactileBadge>
)}
</Box>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, letterSpacing: '0.04em', mt: 0.25 }}>
Desktop &amp; mobile installers, SHA-256 integrity, download telemetry
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Button
component={Link}
href={`${repoHtmlUrl}/releases`}
target="_blank"
variant="outlined"
sx={{
fontFamily: FONT_SANS,
fontSize: '12px',
fontWeight: 500,
color: C.text,
borderColor: C.border,
textTransform: 'none',
borderRadius: '10px',
'&:hover': { borderColor: C.accentLight, color: C.bright }
}}
>
Forgejo Releases
</Button>
<Button
component={Link}
href="https://d3ro.chanpaca.net/download.html"
target="_blank"
variant="outlined"
sx={{
fontFamily: FONT_SANS,
fontSize: '12px',
fontWeight: 500,
color: C.cyanLight,
borderColor: C.border,
textTransform: 'none',
borderRadius: '10px',
'&:hover': { borderColor: C.cyanLight, color: C.bright }
}}
>
Public Download Page
</Button>
</Box>
</Box>
)
}
function KpiCards({ hub }: { hub: ReleaseHub }): React.ReactElement {
const latest = hub.latestStable
const latestPlatforms = [...new Set(latest?.assets.map((asset) => asset.platform) ?? [])].filter(
(platform) => platform !== 'other'
)
const cards = [
{
title: 'Total Asset Downloads',
value: hub.totalDownloads.toLocaleString(),
subtext: 'Forgejo attachment download counters'
},
{
title: 'Latest Stable Release',
value: latest?.tagName ?? '—',
subtext: latest ? `Published ${formatDate(latest.publishedAt)}` : 'No stable release published'
},
{
title: 'Published Releases',
value: hub.releases.length.toLocaleString(),
subtext: `${hub.prereleaseCount.toLocaleString()} pre-release channel builds`
},
{
title: 'Latest Platform Coverage',
value: latestPlatforms.length ? latestPlatforms.map((platform) => PLATFORM_LABEL[platform]).join(' · ') : '—',
subtext: latest ? `${latest.assets.length.toLocaleString()} packaged artifacts` : 'Awaiting first artifact upload'
}
]
return (
<Box sx={{ p: { xs: 2, md: 3 }, display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 0.5 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '24px', fontWeight: 800, color: C.bright }}>
Release & Distribution Hub
</Typography>
<Box
sx={{
px: 1.2,
py: 0.3,
borderRadius: '6px',
bgcolor: 'rgba(56, 189, 248, 0.15)',
border: '1px solid rgba(56, 189, 248, 0.3)',
color: C.cyanLight,
fontFamily: FONT_MONO,
fontSize: '11px',
fontWeight: 700,
}}
>
v1.0.0 STABLE
</Box>
</Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
Manage desktop installer distribution, multi-platform binaries, SHA-256 integrity checks, and auto-update feeds.
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Button
href="https://git.chanpaca.net/yunchan/d3ro-voice/releases"
target="_blank"
variant="outlined"
startIcon={<ExternalLink size={14} />}
sx={{
fontFamily: FONT_SANS,
fontSize: '12px',
fontWeight: 600,
color: C.text,
borderColor: C.border,
textTransform: 'none',
borderRadius: '10px',
'&:hover': { borderColor: C.accentLight, bgcolor: 'rgba(255, 255, 255, 0.05)' },
}}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' }, gap: 2 }}>
{cards.map((card) => (
<Box key={card.title} sx={{ ...panelSx, p: 2.5 }}>
<Typography
sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mb: 1, textTransform: 'uppercase', letterSpacing: '0.08em' }}
>
Git Release Tags
</Button>
<Button
href="http://localhost:3000/download.html"
target="_blank"
variant="contained"
startIcon={<ArrowUpRight size={14} />}
sx={{
fontFamily: FONT_SANS,
fontSize: '12px',
fontWeight: 700,
color: '#000',
bgcolor: C.cyanLight,
textTransform: 'none',
borderRadius: '10px',
'&:hover': { bgcolor: '#7dd3fc' },
}}
>
View Public Download Page
</Button>
{card.title}
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '22px', fontWeight: 700, color: C.bright, lineHeight: 1.25 }}>
{card.value}
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>{card.subtext}</Typography>
</Box>
))}
</Box>
)
}
function LatestAssetsPanel({ latest }: { latest: ReleaseHub['latestStable'] }): React.ReactElement {
return (
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1.5, mb: 2 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright }}>
Latest Binary Packages &amp; Checksums
</Typography>
{latest && (
<Box sx={statusBadgeSx('cyan')}>{latest.tagName}</Box>
)}
</Box>
{/* KPI Cards */}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: 'repeat(4, 1fr)' }, gap: 2 }}>
<Box sx={{ p: 2.5, borderRadius: '16px', bgcolor: C.card, border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 1, textTransform: 'uppercase' }}>
Total App Downloads
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 800, color: C.bright }}>
{totalDownloads.toLocaleString()}
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: '#34d399', mt: 0.5 }}>
+18.4% WoW (Free & Pro Installs)
</Typography>
</Box>
<Box sx={{ p: 2.5, borderRadius: '16px', bgcolor: C.card, border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 1, textTransform: 'uppercase' }}>
Auto-Update Feed Health
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={20} color="#34d399" />
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 800, color: C.bright }}>
200 OK (Live)
</Typography>
</Box>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight, mt: 0.5 }}>
latest.yml generic feed
</Typography>
</Box>
<Box sx={{ p: 2.5, borderRadius: '16px', bgcolor: C.card, border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 1, textTransform: 'uppercase' }}>
Synology NAS Storage
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 800, color: C.bright }}>
2.4 GB / 8 TB
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
/volume1/docker/d3ro-voice
</Typography>
</Box>
<Box sx={{ p: 2.5, borderRadius: '16px', bgcolor: C.card, border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 1, textTransform: 'uppercase' }}>
Active Release Version
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 800, color: C.purple400 }}>
1.0.0
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
Deployed: 2026-08-20
</Typography>
</Box>
</Box>
{/* Phased Rollout & Control Card */}
<Box
sx={{
p: 3,
borderRadius: '20px',
bgcolor: 'rgba(17, 26, 48, 0.7)',
backdropFilter: 'blur(16px)',
border: `1px solid ${C.border}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2, mb: 3 }}>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
Phased Rollout & Auto-Update Policy
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim }}>
Control automatic background update delivery to client desktop installations.
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.text }}>
Force Update:
</Typography>
<Switch
checked={forceUpdateEnabled}
onChange={(e) => setForceUpdateEnabled(e.target.checked)}
size="small"
/>
</Box>
<Box
sx={{
px: 1.5,
py: 0.5,
borderRadius: '8px',
bgcolor: 'rgba(34, 197, 94, 0.12)',
border: '1px solid rgba(34, 197, 94, 0.3)',
color: '#34d399',
fontFamily: FONT_MONO,
fontSize: '12px',
fontWeight: 700,
}}
>
Rollout: {rolloutPercent}%
</Box>
</Box>
</Box>
<Box sx={{ mb: 2 }}>
<LinearProgress
variant="determinate"
value={rolloutPercent}
sx={{
height: 8,
borderRadius: '4px',
bgcolor: 'rgba(255, 255, 255, 0.05)',
'& .MuiLinearProgress-bar': { bgcolor: C.cyanLight },
}}
/>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{[10, 25, 50, 100].map((pct) => (
<Button
key={pct}
size="small"
onClick={() => setRolloutPercent(pct)}
sx={{
fontFamily: FONT_MONO,
fontSize: '11px',
fontWeight: 600,
color: rolloutPercent === pct ? '#000' : C.text,
bgcolor: rolloutPercent === pct ? C.cyanLight : 'rgba(255, 255, 255, 0.04)',
border: `1px solid ${rolloutPercent === pct ? C.cyanLight : C.border}`,
borderRadius: '8px',
textTransform: 'none',
'&:hover': { bgcolor: rolloutPercent === pct ? '#7dd3fc' : 'rgba(255, 255, 255, 0.08)' },
}}
>
Set {pct}%
</Button>
))}
</Box>
</Box>
{/* Release Assets Table */}
<Box
sx={{
borderRadius: '20px',
bgcolor: 'rgba(17, 26, 48, 0.7)',
backdropFilter: 'blur(16px)',
border: `1px solid ${C.border}`,
overflow: 'hidden',
}}
>
<Box sx={{ p: 2.5, borderBottom: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
Published Binary Packages & Checksums
</Typography>
</Box>
{!latest || latest.assets.length === 0 ? (
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
.
</Typography>
) : (
<Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
<Box component="table" sx={tableSx}>
<Box component="thead">
<Box component="tr" sx={{ borderBottom: `1px solid ${C.border}`, bgcolor: 'rgba(0, 0, 0, 0.2)' }}>
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Artifact Name</Box>
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Platform</Box>
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Version</Box>
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Size</Box>
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Downloads</Box>
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>SHA-256 Checksum</Box>
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Status</Box>
<Box component="tr">
<Box component="th">Artifact</Box>
<Box component="th">Platform</Box>
<Box component="th">Size</Box>
<Box component="th">Downloads</Box>
<Box component="th">SHA-256</Box>
<Box component="th">Link</Box>
</Box>
</Box>
<Box component="tbody">
{assets.map((asset) => (
<Box
component="tr"
key={asset.id}
sx={{
borderBottom: `1px solid ${C.border}`,
'&:hover': { bgcolor: 'rgba(255, 255, 255, 0.02)' },
}}
>
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 600, color: C.bright }}>
{latest.assets.map((asset) => (
<Box component="tr" key={asset.id}>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
{asset.name}
</Box>
<Box component="td" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '12px', color: C.text }}>
{asset.os}
<Box component="td">
<Box sx={statusBadgeSx(PLATFORM_BADGE[asset.platform])}>{PLATFORM_LABEL[asset.platform]}</Box>
</Box>
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
{asset.version}
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>{formatSize(asset.sizeBytes)}</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
{asset.downloadCount.toLocaleString()}
</Box>
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
{asset.sizeMb} MB
<Box component="td">
{asset.sha256 ? (
<ChecksumCopy value={asset.sha256} />
) : (
<Typography component="span" sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.muted }}>
not published
</Typography>
)}
</Box>
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 700, color: C.bright }}>
{asset.downloads.toLocaleString()}
</Box>
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<span style={{ maxWidth: '160px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{asset.sha256}
</span>
<Box
onClick={() => copyToClipboard(asset.sha256, asset.id)}
sx={{ cursor: 'pointer', color: copiedId === asset.id ? '#34d399' : C.cyanLight, '&:hover': { color: '#fff' } }}
>
{copiedId === asset.id ? <CheckCircle2 size={13} /> : <Copy size={13} />}
</Box>
</Box>
</Box>
<Box component="td" sx={{ p: 2 }}>
<Box
sx={{
display: 'inline-block',
px: 1,
py: 0.3,
borderRadius: '6px',
fontSize: '10px',
fontFamily: FONT_MONO,
fontWeight: 700,
textTransform: 'uppercase',
bgcolor: asset.status === 'active' ? 'rgba(34, 197, 94, 0.15)' : 'rgba(245, 158, 11, 0.15)',
color: asset.status === 'active' ? '#34d399' : '#fbbf24',
border: `1px solid ${asset.status === 'active' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(245, 158, 11, 0.3)'}`,
}}
<Box component="td">
<Typography
component="a"
href={asset.browserDownloadUrl}
target="_blank"
rel="noreferrer"
sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.cyanLight, textDecoration: 'none', '&:hover': { color: C.bright } }}
>
{asset.status}
</Box>
Download
</Typography>
</Box>
</Box>
))}
</Box>
</Box>
</Box>
)}
</DoubleBezelCard>
)
}
function ReleaseHistoryPanel({ hub }: { hub: ReleaseHub }): React.ReactElement {
return (
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
Release Channel History
</Typography>
{hub.releases.length === 0 ? (
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}> .</Typography>
) : (
<Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={tableSx}>
<Box component="thead">
<Box component="tr">
<Box component="th">Tag</Box>
<Box component="th">Release</Box>
<Box component="th">Published</Box>
<Box component="th">Channel</Box>
<Box component="th">Assets</Box>
<Box component="th">Downloads</Box>
<Box component="th">Source</Box>
</Box>
</Box>
<Box component="tbody">
{hub.releases.map((release) => (
<Box component="tr" key={release.id}>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
{release.tagName}
</Box>
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.bright }}>
{release.name}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>{formatDate(release.publishedAt)}</Box>
<Box component="td">
<Box sx={statusBadgeSx(release.isPrerelease ? 'orange' : 'green')}>
{release.isPrerelease ? 'PRE-RELEASE' : 'STABLE'}
</Box>
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
{release.assets.length.toLocaleString()}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
{release.downloadCount.toLocaleString()}
</Box>
<Box component="td">
<Typography
component="a"
href={release.htmlUrl}
target="_blank"
rel="noreferrer"
sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.cyanLight, textDecoration: 'none', '&:hover': { color: C.bright } }}
>
Forgejo
</Typography>
</Box>
</Box>
))}
</Box>
</Box>
</Box>
)}
</DoubleBezelCard>
)
}
export default async function ReleasesManagementPage(): Promise<React.ReactElement> {
await requireManager()
let hub: ReleaseHub | null = null
let feedError: string | null = null
try {
hub = await fetchReleaseHub()
} catch (error) {
feedError = error instanceof Error ? error.message : 'Unknown release feed error'
}
if (!hub) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<HeaderBar feedLive={false} repoHtmlUrl="https://git.chanpaca.net/yunchan/d3ro-voice" />
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '14px', fontWeight: 600, color: C.orange400, mb: 1 }}>
Forgejo .
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>{feedError}</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mt: 1.5 }}>
RELEASE_REPO_URL . .
</Typography>
</DoubleBezelCard>
</Box>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<HeaderBar feedLive repoHtmlUrl={hub.repoHtmlUrl} />
<KpiCards hub={hub} />
<LatestAssetsPanel latest={hub.latestStable} />
<ReleaseHistoryPanel hub={hub} />
</Box>
)
}