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 */}

    {t('adminUpdates.title')}

    {t('adminUpdates.subtitle')}

    {/* Loading skeleton */} {isLoading && (
    {[0, 1].map((i) => (
    ))}
    )} {/* 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} />
    )}
    ); }