Files
IdP/apps/frontend/app/admin/releases/page.tsx
2026-07-07 10:14:37 +03:00

320 lines
13 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, Monitor, 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,
AppReleasePlatform,
deleteAdminAppRelease,
fetchAdminAppReleases,
getApiErrorMessage,
updateAdminAppRelease,
uploadAdminAppRelease
} from '@/lib/api';
import { cn } from '@/lib/utils';
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));
}
const platformMeta: Record<AppReleasePlatform, { label: string; icon: typeof Smartphone; accept: string }> = {
ANDROID: { label: 'Android', icon: Smartphone, accept: '.apk,application/vnd.android.package-archive' },
WINDOWS: { label: 'Windows', icon: Monitor, accept: '.exe,application/vnd.microsoft.portable-executable' }
};
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 [platform, setPlatform] = useState<AppReleasePlatform>('ANDROID');
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);
setReleases(response.releases ?? []);
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось загрузить релизы') ?? 'Ошибка');
} finally {
setLoading(false);
}
}, [canManage, showToast, token]);
useEffect(() => {
void loadReleases();
}, [loadReleases]);
const grouped = useMemo(() => {
const android = releases.filter((item) => item.platform === 'ANDROID');
const windows = releases.filter((item) => item.platform === 'WINDOWS');
return { android, windows };
}, [releases]);
async function handleUpload(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !file) {
showToast('Выберите файл релиза');
return;
}
const parsedVersionCode = Number(versionCode);
if (!version.trim() || !Number.isInteger(parsedVersionCode) || parsedVersionCode < 1) {
showToast('Укажите версию и положительный код версии');
return;
}
setUploading(true);
try {
const created = await uploadAdminAppRelease(token, {
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} (${platformMeta[release.platform].label})?`)) 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 Icon = platformMeta[release.platform].icon;
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]">
<Icon 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/${release.platform === 'ANDROID' ? 'android' : 'windows'}`} 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>
);
}
const PlatformIcon = platformMeta[platform].icon;
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">Новая версия</h2>
<p className="text-sm text-[#667085]">Загрузите APK или EXE с подписью SHA-256</p>
</div>
</div>
<form className="space-y-4" onSubmit={(event) => void handleUpload(event)}>
<div className="grid grid-cols-2 gap-2">
{(['ANDROID', 'WINDOWS'] as AppReleasePlatform[]).map((item) => {
const meta = platformMeta[item];
const Icon = meta.icon;
return (
<button
key={item}
type="button"
className={cn(
'flex items-center justify-center gap-2 rounded-2xl border px-3 py-3 text-sm font-medium transition',
platform === item
? 'border-[#3390ec] bg-[#eef4ff] text-[#1f2430]'
: 'border-[#eceef4] bg-[#f8f9fb] text-[#667085] hover:bg-[#f1f2f6]'
)}
onClick={() => setPlatform(item)}
>
<Icon className="h-4 w-4" />
{meta.label}
</button>
);
})}
</div>
<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]">
<PlatformIcon className="mb-3 h-8 w-8 text-[#3390ec]" />
<span className="text-sm font-medium text-[#1f2430]">
{file ? file.name : `Выберите файл ${platform === 'ANDROID' ? '.apk' : '.exe'}`}
</span>
<span className="mt-1 text-xs text-[#667085]">До 350 МБ</span>
<input
type="file"
className="hidden"
accept={platformMeta[platform].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>
<div className="space-y-8">
<section>
<h2 className="mb-4 text-xl font-semibold">Android</h2>
{renderReleaseList(grouped.android, 'Релизы Android пока не загружены')}
</section>
<section>
<h2 className="mb-4 text-xl font-semibold">Windows</h2>
{renderReleaseList(grouped.windows, 'Релизы Windows пока не загружены')}
</section>
</div>
</div>
</AdminShell>
);
}