import { useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import DOMPurify from 'dompurify';
import { adminUpdatesApi, ReleaseItem, ProjectReleasesInfo } from '../api/adminUpdates';
declare const __APP_VERSION__: string;
// ============ Icons ============
const BackIcon = () => (
);
const RefreshIcon = () => (
);
const BotIcon = () => (
);
const CabinetIcon = () => (
);
const TagIcon = () => (
);
const CalendarIcon = () => (
);
const ExternalLinkIcon = () => (
);
// ============ Helpers ============
function formatDate(iso: string): string {
try {
const date = new Date(iso);
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
} catch {
return iso;
}
}
function stripVPrefix(tag: string): string {
return tag.replace(/^v/, '');
}
function renderMarkdown(md: string): string {
const html = md
// Headers: ### Title ->
Title
.replace(/^#### (.+)$/gm, '$1 ')
.replace(/^### (.+)$/gm, '$1 ')
.replace(/^## (.+)$/gm, '$1 ')
.replace(/^# (.+)$/gm, '$1 ')
// Bold: **text** -> text
.replace(/\*\*(.+?)\*\*/g, '$1 ')
// Inline code: `text` -> text
.replace(/`([^`]+)`/g, '$1')
// Links: [text](url) -> text
.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'$1 ',
);
// Process lines into blocks
const lines = html.split('\n');
const blocks: string[] = [];
let listItems: string[] = [];
const flushList = () => {
if (listItems.length > 0) {
blocks.push(``);
listItems = [];
}
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
flushList();
continue;
}
// List item: * text or - text
const listMatch = trimmed.match(/^[*-]\s+(.+)$/);
if (listMatch) {
listItems.push(`${listMatch[1]} `);
continue;
}
// Already an HTML block element from header replacement
if (/^/.test(trimmed)) {
flushList();
blocks.push(trimmed);
continue;
}
// Regular line
flushList();
blocks.push(`${trimmed}
`);
}
flushList();
return DOMPurify.sanitize(blocks.join(''), {
ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'p', 'ul', 'li', 'strong', 'em', 'code', 'a', 'br'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
});
}
// ============ Components ============
function VersionBadge({ hasUpdate }: { hasUpdate: boolean }) {
const { t } = useTranslation();
if (hasUpdate) {
return (
{t('adminUpdates.updateAvailable')}
);
}
return (
{t('adminUpdates.upToDate')}
);
}
function ReleaseCard({ release }: { release: ReleaseItem }) {
const { t } = useTranslation();
// Content is sanitized via DOMPurify in renderMarkdown — safe for innerHTML
const bodyHtml = useMemo(
() => (release.body ? renderMarkdown(release.body) : ''),
[release.body],
);
return (
{release.tag_name}
{release.name !== release.tag_name && (
{release.name}
)}
{release.prerelease && (
{t('adminUpdates.prerelease')}
)}
{formatDate(release.published_at)}
{bodyHtml ? (
) : null}
);
}
function ProjectSection({
icon,
title,
info,
currentVersion,
hasUpdate,
repoUrl,
}: {
icon: React.ReactNode;
title: string;
info: ProjectReleasesInfo;
currentVersion: string;
hasUpdate: boolean;
repoUrl: string;
}) {
const { t } = useTranslation();
return (
{/* Header */}
{icon}
{title}
{t('adminUpdates.currentVersion')}:{' '}
{currentVersion || '—'}
GitHub
{/* Releases list */}
{info.releases.length > 0 ? (
{info.releases.map((release) => (
))}
) : (
{t('adminUpdates.noReleases')}
)}
);
}
// ============ Page ============
export default function AdminUpdates() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ['admin', 'releases'],
queryFn: adminUpdatesApi.getReleases,
staleTime: 60_000,
});
// Cabinet has_updates: compare __APP_VERSION__ with latest release
const cabinetHasUpdate = (() => {
if (!data?.cabinet.releases.length) return false;
try {
const latestTag = data.cabinet.releases.find((r) => !r.prerelease)?.tag_name;
if (!latestTag) return false;
return stripVPrefix(latestTag) !== stripVPrefix(__APP_VERSION__);
} catch {
return false;
}
})();
return (
{/* Top bar */}
navigate('/admin')}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-dark-700/50 bg-dark-800/40 transition-colors hover:border-dark-600 hover:bg-dark-800"
>
{t('adminUpdates.title')}
{t('adminUpdates.subtitle')}
refetch()}
disabled={isFetching}
className="flex items-center gap-1.5 rounded-lg border border-dark-700/50 bg-dark-800/40 px-3 py-2 text-xs text-dark-300 transition-colors hover:border-dark-600 hover:bg-dark-800 disabled:opacity-50"
>
{t('adminUpdates.refresh')}
{/* Loading skeleton */}
{isLoading && (
)}
{/* Content */}
{data && (
}
title={t('adminUpdates.bot')}
info={data.bot}
currentVersion={data.bot.current_version}
hasUpdate={data.bot.has_updates}
repoUrl={data.bot.repo_url}
/>
}
title={t('adminUpdates.cabinet')}
info={data.cabinet}
currentVersion={__APP_VERSION__}
hasUpdate={cabinetHasUpdate}
repoUrl={data.cabinet.repo_url}
/>
)}
);
}