'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Download, Loader2, Smartphone, Trash2, Upload } from 'lucide-react'; import { useAuth } from '@/components/id/auth-provider'; import { AdminShell } from '@/components/id/admin-shell'; import { useToast } from '@/components/id/toast-provider'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { AppRelease, deleteAdminAppRelease, fetchAdminAppReleases, getApiErrorMessage, updateAdminAppRelease, uploadAdminAppRelease } from '@/lib/api'; import { detectVariantFromFileName, formatReleaseVariantLabel, groupReleasesByVersion } from '@/lib/app-release-variants'; const ANDROID_PLATFORM = 'ANDROID' as const; const APK_ACCEPT = '.apk,application/vnd.android.package-archive'; const MAX_RELEASE_FILE_BYTES = 350 * 1024 * 1024; function formatBytes(value: string) { const size = Number(value); if (!Number.isFinite(size) || size <= 0) return '—'; const units = ['Б', 'КБ', 'МБ', 'ГБ']; let amount = size; let unit = 0; while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit += 1; } return `${amount.toFixed(amount >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`; } function formatDate(value: string) { return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(value)); } export default function AdminReleasesPage() { const { token, user } = useAuth(); const { showToast } = useToast(); const [releases, setReleases] = useState([]); const [loading, setLoading] = useState(true); const [uploading, setUploading] = useState(false); const [version, setVersion] = useState(''); const [versionCode, setVersionCode] = useState(''); const [releaseNotes, setReleaseNotes] = useState(''); const [files, setFiles] = useState([]); const canManage = Boolean(user?.canManageSettings); const loadReleases = useCallback(async () => { if (!token || !canManage) return; setLoading(true); try { const response = await fetchAdminAppReleases(token, ANDROID_PLATFORM); setReleases(response.releases ?? []); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось загрузить релизы') ?? 'Ошибка'); } finally { setLoading(false); } }, [canManage, showToast, token]); useEffect(() => { void loadReleases(); }, [loadReleases]); const androidReleases = useMemo( () => releases.filter((item) => item.platform === ANDROID_PLATFORM), [releases] ); const groupedReleases = useMemo(() => groupReleasesByVersion(androidReleases), [androidReleases]); const detectedVariants = useMemo( () => files.map((file) => ({ file, variant: detectVariantFromFileName(file.name) })), [files] ); async function handleUpload(event: React.FormEvent) { event.preventDefault(); if (!token || !files.length) { showToast('Выберите один или несколько APK'); return; } const oversized = files.find((file) => file.size > MAX_RELEASE_FILE_BYTES); if (oversized) { showToast(`Файл ${oversized.name} превышает лимит 350 МБ`); return; } const parsedVersionCode = Number(versionCode); if (!version.trim() || !Number.isInteger(parsedVersionCode) || parsedVersionCode < 1) { showToast('Укажите версию и положительный код версии'); return; } setUploading(true); const created: AppRelease[] = []; try { for (const file of files) { const item = await uploadAdminAppRelease(token, { platform: ANDROID_PLATFORM, version: version.trim(), versionCode: parsedVersionCode, variant: detectVariantFromFileName(file.name), releaseNotes: releaseNotes.trim() || undefined, file }); created.push(item); } setReleases((current) => [...created, ...current]); setVersion(''); setVersionCode(''); setReleaseNotes(''); setFiles([]); showToast( created.length === 1 ? `Загружена сборка ${formatReleaseVariantLabel(created[0].variant)}` : `Загружено сборок: ${created.length}` ); } catch (error) { if (created.length) { setReleases((current) => [...created, ...current]); } showToast(getApiErrorMessage(error, 'Не удалось загрузить релиз') ?? 'Ошибка'); } finally { setUploading(false); } } async function togglePublished(release: AppRelease) { if (!token) return; try { const updated = await updateAdminAppRelease(token, release.id, { isPublished: !release.isPublished }); setReleases((current) => current.map((item) => (item.id === release.id ? updated : item))); showToast(updated.isPublished ? 'Релиз опубликован' : 'Релиз скрыт'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось обновить релиз') ?? 'Ошибка'); } } async function removeRelease(release: AppRelease) { if (!token) return; if (!window.confirm(`Удалить сборку ${formatReleaseVariantLabel(release.variant)} для v${release.version}?`)) return; try { await deleteAdminAppRelease(token, release.id); setReleases((current) => current.filter((item) => item.id !== release.id)); showToast('Сборка удалена'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось удалить релиз') ?? 'Ошибка'); } } function renderReleaseList(emptyLabel: string) { if (loading) { return
Загружаем релизы...
; } if (!groupedReleases.length) { return
{emptyLabel}
; } return (
{groupedReleases.map((group, groupIndex) => { const publishedVariants = group.variants.filter((item) => item.isPublished); const isLatest = groupIndex === 0 && publishedVariants.length > 0; const notes = group.variants.find((item) => item.releaseNotes)?.releaseNotes; return (

v{group.version}

build {group.versionCode} {group.variants.length} {group.variants.length === 1 ? 'сборка' : 'сборки'} {isLatest ? ( Последняя версия ) : null}
{notes ? (

{notes}

) : null}
{group.variants.map((release) => (
{formatReleaseVariantLabel(release.variant)} {!release.isPublished ? ( Скрыта ) : null}

{release.fileName} · {formatBytes(release.fileSize)} · {formatDate(release.createdAt)}

SHA-256: {release.sha256}

))}
); })}
); } if (!canManage) { return (
Недостаточно прав для управления релизами.
); } return (

Новая версия Android

Можно загрузить несколько APK одной версии

void handleUpload(event)}> setVersion(event.target.value)} required /> setVersionCode(event.target.value)} required />