Files
IdP/apps/frontend/app/admin/releases/page.tsx
2026-07-07 17:36:01 +03:00

354 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<AppRelease[]>([]);
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<File[]>([]);
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<HTMLFormElement>) {
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 <div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем релизы...</div>;
}
if (!groupedReleases.length) {
return <div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">{emptyLabel}</div>;
}
return (
<div className="space-y-4">
{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 (
<div key={group.key} className="rounded-[20px] border border-[#eceef4] bg-white p-4 shadow-sm">
<div className="mb-4 flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold">v{group.version}</h3>
<span className="rounded-full bg-[#eef4ff] px-2 py-0.5 text-xs font-medium text-[#3390ec]">
build {group.versionCode}
</span>
<span className="rounded-full bg-[#f4f5f8] px-2 py-0.5 text-xs font-medium text-[#667085]">
{group.variants.length} {group.variants.length === 1 ? 'сборка' : 'сборки'}
</span>
{isLatest ? (
<span className="rounded-full bg-[#e8f8ee] px-2 py-0.5 text-xs font-medium text-[#1a7f37]">
Последняя версия
</span>
) : null}
</div>
{notes ? (
<p className="mb-4 whitespace-pre-wrap rounded-2xl bg-[#f8f9fb] px-4 py-3 text-sm leading-relaxed text-[#1f2430]">
{notes}
</p>
) : null}
<div className="space-y-3">
{group.variants.map((release) => (
<div
key={release.id}
className="flex flex-wrap items-start justify-between gap-3 rounded-2xl bg-[#f8f9fb] px-4 py-3"
>
<div className="flex min-w-0 items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white">
<Smartphone className="h-4 w-4 text-[#3390ec]" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-[#1f2430]">
{formatReleaseVariantLabel(release.variant)}
</span>
{!release.isPublished ? (
<span className="rounded-full bg-[#fff4e5] px-2 py-0.5 text-xs font-medium text-[#b54708]">
Скрыта
</span>
) : null}
</div>
<p className="mt-1 text-sm text-[#667085]">
{release.fileName} · {formatBytes(release.fileSize)} · {formatDate(release.createdAt)}
</p>
<p className="mt-1 break-all font-mono text-xs text-[#8f92a0]">SHA-256: {release.sha256}</p>
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button variant="outline" size="sm" className="rounded-xl" asChild>
<a href={`/downloads/android/${release.id}`} target="_blank" rel="noreferrer">
<Download className="mr-2 h-4 w-4" />
Скачать
</a>
</Button>
<Button variant="outline" size="sm" className="rounded-xl" onClick={() => void togglePublished(release)}>
{release.isPublished ? 'Скрыть' : 'Опубликовать'}
</Button>
<Button
variant="ghost"
size="sm"
className="rounded-xl text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => void removeRelease(release)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
}
if (!canManage) {
return (
<AdminShell active="/admin/releases">
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-6 text-[#667085]">Недостаточно прав для управления релизами.</div>
</AdminShell>
);
}
return (
<AdminShell active="/admin/releases">
<div className="grid gap-8 xl:grid-cols-[380px_minmax(0,1fr)]">
<section className="rounded-[24px] border border-[#eceef4] bg-white p-6 shadow-sm">
<div className="mb-5 flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
<Upload className="h-6 w-6" />
</div>
<div>
<h2 className="text-xl font-semibold">Новая версия Android</h2>
<p className="text-sm text-[#667085]">Можно загрузить несколько APK одной версии</p>
</div>
</div>
<form className="space-y-4" onSubmit={(event) => void handleUpload(event)}>
<Input
placeholder="Версия, например 1.2.0"
value={version}
onChange={(event) => setVersion(event.target.value)}
required
/>
<Input
type="number"
min={1}
placeholder="Код версии (versionCode), например 120"
value={versionCode}
onChange={(event) => setVersionCode(event.target.value)}
required
/>
<textarea
className="min-h-[110px] w-full rounded-2xl border border-[#eceef4] bg-[#f8f9fb] px-4 py-3 text-sm outline-none transition focus:border-[#3390ec]"
placeholder="Что нового в этой версии"
value={releaseNotes}
onChange={(event) => setReleaseNotes(event.target.value)}
/>
<label className="flex cursor-pointer flex-col items-center justify-center rounded-[20px] border border-dashed border-[#c7d2fe] bg-[#f8faff] px-4 py-8 text-center transition hover:bg-[#eef4ff]">
<Smartphone className="mb-3 h-8 w-8 text-[#3390ec]" />
<span className="text-sm font-medium text-[#1f2430]">
{files.length ? `Выбрано файлов: ${files.length}` : 'Выберите один или несколько .apk'}
</span>
<span className="mt-1 text-xs text-[#667085]">
universal-release, arm64-v8a-release и другие варианты одной версии
</span>
<input
type="file"
className="hidden"
multiple
accept={APK_ACCEPT}
onChange={(event) => setFiles([...(event.target.files ?? [])])}
/>
</label>
{detectedVariants.length ? (
<div className="rounded-2xl bg-[#f8f9fb] px-4 py-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-[#667085]">Определённые варианты</p>
<div className="space-y-1">
{detectedVariants.map((item) => (
<p key={`${item.file.name}-${item.variant}`} className="text-sm text-[#1f2430]">
<span className="font-medium">{formatReleaseVariantLabel(item.variant)}</span>
<span className="text-[#667085]"> · {item.file.name}</span>
</p>
))}
</div>
</div>
) : null}
<Button type="submit" className="w-full rounded-xl" disabled={uploading || !files.length}>
{uploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
{uploading ? 'Загружаем...' : files.length > 1 ? `Загрузить ${files.length} сборки` : 'Загрузить релиз'}
</Button>
</form>
</section>
<section>
<h2 className="mb-4 text-xl font-semibold">Версии Android</h2>
{renderReleaseList('Релизы Android пока не загружены')}
</section>
</div>
</AdminShell>
);
}