Files
IdP/apps/frontend/app/admin/releases/page.tsx
2026-07-07 15:42:45 +03:00

285 lines
12 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';
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 [file, setFile] = useState<File | null>(null);
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]
);
async function handleUpload(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !file) {
showToast('Выберите файл релиза');
return;
}
if (file.size > MAX_RELEASE_FILE_BYTES) {
showToast('APK не должен превышать 350 МБ');
return;
}
const parsedVersionCode = Number(versionCode);
if (!version.trim() || !Number.isInteger(parsedVersionCode) || parsedVersionCode < 1) {
showToast('Укажите версию и положительный код версии');
return;
}
setUploading(true);
try {
const created = await uploadAdminAppRelease(token, {
platform: ANDROID_PLATFORM,
version: version.trim(),
versionCode: parsedVersionCode,
releaseNotes: releaseNotes.trim() || undefined,
file
});
setReleases((current) => [created, ...current]);
setVersion('');
setVersionCode('');
setReleaseNotes('');
setFile(null);
showToast('Релиз загружен и опубликован');
} catch (error) {
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(`Удалить версию ${release.version} (Android)?`)) return;
try {
await deleteAdminAppRelease(token, release.id);
setReleases((current) => current.filter((item) => item.id !== release.id));
showToast('Релиз удалён');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось удалить релиз') ?? 'Ошибка');
}
}
function renderReleaseList(items: AppRelease[], emptyLabel: string) {
if (loading) {
return <div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем релизы...</div>;
}
if (!items.length) {
return <div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">{emptyLabel}</div>;
}
return (
<div className="space-y-3">
{items.map((release, index) => {
const isLatest = index === 0 && release.isPublished;
return (
<div key={release.id} className="rounded-[20px] border border-[#eceef4] bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[#f4f5f8]">
<Smartphone className="h-5 w-5 text-[#3390ec]" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold">v{release.version}</h3>
<span className="rounded-full bg-[#eef4ff] px-2 py-0.5 text-xs font-medium text-[#3390ec]">
build {release.versionCode}
</span>
{isLatest ? (
<span className="rounded-full bg-[#e8f8ee] px-2 py-0.5 text-xs font-medium text-[#1a7f37]">
Последняя
</span>
) : null}
{!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>
{release.releaseNotes ? (
<p className="mt-3 whitespace-pre-wrap text-sm leading-relaxed text-[#1f2430]">{release.releaseNotes}</p>
) : null}
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button variant="outline" size="sm" className="rounded-xl" asChild>
<a href="/downloads/android" 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>
);
}
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 с подписью SHA-256</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]">{file ? file.name : 'Выберите файл .apk'}</span>
<span className="mt-1 text-xs text-[#667085]">До 350 МБ</span>
<input
type="file"
className="hidden"
accept={APK_ACCEPT}
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
</label>
<Button type="submit" className="w-full rounded-xl" disabled={uploading || !file}>
{uploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
{uploading ? 'Загружаем...' : 'Загрузить релиз'}
</Button>
</form>
</section>
<section>
<h2 className="mb-4 text-xl font-semibold">Версии Android</h2>
{renderReleaseList(androidReleases, 'Релизы Android пока не загружены')}
</section>
</div>
</AdminShell>
);
}