350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
// apps/admin/src/app/(admin)/releases/page.tsx
|
|
// D3RO Voice Admin CRM — Release & Distribution Hub (Forgejo live feed)
|
|
|
|
import { Box, Typography, Button } from '@mui/material'
|
|
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'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
const PLATFORM_LABEL: Record<ReleaseAssetPlatform, string> = {
|
|
windows: 'Windows',
|
|
macos: 'macOS',
|
|
android: 'Android',
|
|
linux: 'Linux',
|
|
feed: 'Update Feed',
|
|
other: 'Other'
|
|
}
|
|
|
|
const PLATFORM_BADGE: Record<ReleaseAssetPlatform, 'blue' | 'purple' | 'green' | 'orange' | 'cyan'> = {
|
|
windows: 'blue',
|
|
macos: 'purple',
|
|
android: 'green',
|
|
linux: 'orange',
|
|
feed: 'cyan',
|
|
other: 'orange'
|
|
}
|
|
|
|
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`
|
|
}
|
|
|
|
function formatDate(value: string): string {
|
|
if (!value) return '—'
|
|
const parsed = new Date(value)
|
|
return Number.isNaN(parsed.getTime()) ? '—' : parsed.toISOString().slice(0, 10)
|
|
}
|
|
|
|
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 & 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 & mobile installers, SHA-256 integrity, download telemetry
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
|
<Button
|
|
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
|
|
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={{ 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' }}
|
|
>
|
|
{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 & Checksums
|
|
</Typography>
|
|
{latest && (
|
|
<Box sx={statusBadgeSx('cyan')}>{latest.tagName}</Box>
|
|
)}
|
|
</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={tableSx}>
|
|
<Box component="thead">
|
|
<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">
|
|
{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">
|
|
<Box sx={statusBadgeSx(PLATFORM_BADGE[asset.platform])}>{PLATFORM_LABEL[asset.platform]}</Box>
|
|
</Box>
|
|
<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">
|
|
{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">
|
|
<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 } }}
|
|
>
|
|
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>
|
|
)
|
|
}
|