d3ro-voice/apps/admin/src/app/(admin)/releases/page.tsx
2026-08-29 18:33:45 +09:00

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 &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
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 &amp; 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>
)
}