Files
pgames-manager/frontend/src/uploads.tsx
T
mrleo1nid db52a2b589
CI / frontend (push) Failing after 29s
CI / backend (push) Failing after 37s
feat: collapse background uploads into a progress circle and fit hero covers
2026-10-26 18:00:26 +03:00

205 lines
10 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.
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { Link, useLocation } from "react-router-dom";
import { api, ApiError, formatSize, uploadRaw, type Game, type Version } from "./api";
import { TranslationProgress, useTranslationRun } from "./translator/runner";
import { ErrorNote } from "./ui/Bits";
import { Icon } from "./ui/Icon";
interface UploadTask {
id: number;
filename: string;
size: number;
gameId?: number;
version?: Version;
progress: number;
status: "creating" | "uploading" | "processing" | "ready" | "failed";
error?: string;
syncError?: string;
}
interface UploadInput {
file: File;
gameId?: number;
name?: string;
title?: string;
description?: string;
tags?: string[];
coverHash?: string | null;
galleryHashes?: string[];
}
interface UploadContextValue {
tasks: UploadTask[];
start: (input: UploadInput) => void;
dismiss: (id: number) => void;
}
const UploadContext = createContext<UploadContextValue | null>(null);
export function UploadProvider({ children }: { children: ReactNode }) {
const [tasks, setTasks] = useState<UploadTask[]>([]);
const sequence = useRef(0);
const update = (id: number, patch: Partial<UploadTask>) =>
setTasks((items) => items.map((item) => item.id === id ? { ...item, ...patch } : item));
const start = (input: UploadInput) => {
const id = ++sequence.current;
const { file } = input;
setTasks((items) => [...items, {
id, filename: file.name, size: file.size, gameId: input.gameId,
progress: 0, status: input.gameId ? "uploading" : "creating",
}]);
// The request belongs to the provider, so route changes cannot lose its state.
void (async () => {
let gameId = input.gameId;
try {
if (gameId === undefined) {
const game = await api.post<Game>("/api/games", {
title: input.title?.trim() || file.name.replace(/\.(zip|7z|rar|tar.*)$/i, ""),
description: input.description ?? "", tags: input.tags ?? [],
cover_hash: input.coverHash ?? null,
gallery_hashes: input.galleryHashes ?? [],
});
gameId = game.id;
}
update(id, { gameId, status: "uploading" });
const query = new URLSearchParams({ filename: file.name });
if (input.name) query.set("name", input.name);
const version = await uploadRaw<Version>("POST", `/api/games/${gameId}/upload?${query}`,
file, (progress) => update(id, { progress }));
update(id, { version, progress: 1, status: version.status === "ready" ? "ready" : "processing" });
} catch (err) {
let removed = false;
if (input.gameId === undefined && gameId !== undefined) {
// Remove only the empty game created by this unsuccessful upload.
removed = await api.del(`/api/games/${gameId}`).then(() => true, () => false);
}
update(id, { status: "failed", error: (err as Error).message, ...(removed ? { gameId: undefined } : {}) });
}
})();
};
const pending = tasks.filter((task) => task.status === "processing").map((task) => `${task.id}:${task.version!.id}`).join(",");
useEffect(() => {
if (!pending) return;
let disposed = false;
let timer: ReturnType<typeof setTimeout>;
const poll = async () => {
await Promise.all(pending.split(",").map(async (entry) => {
const [id, versionId] = entry.split(":").map(Number);
try {
const version = await api.get<Version>(`/api/versions/${versionId}`);
if (!disposed) update(id, {
version, syncError: undefined,
status: version.status === "ready" ? "ready" : version.status === "failed" ? "failed" : "processing",
error: version.status === "failed" ? version.progress || "Не удалось обработать архив" : undefined,
});
} catch (err) {
if (disposed) return;
if (err instanceof ApiError && [403, 404].includes(err.status)) {
update(id, { status: "failed", error: err.message });
} else {
update(id, { syncError: "Не удалось обновить статус. Повторяем подключение…" });
}
}
}));
if (!disposed) timer = setTimeout(() => void poll(), 2000);
};
void poll();
return () => { disposed = true; clearTimeout(timer); };
}, [pending]);
return <UploadContext.Provider value={{ tasks, start, dismiss: (id) => setTasks((items) => items.filter((item) => item.id !== id)) }}>
{children}
<FloatingUploads />
</UploadContext.Provider>;
}
export function useUploads() {
const context = useContext(UploadContext);
if (!context) throw new Error("UploadProvider is missing");
return context;
}
export function UploadProgress() {
const { tasks, dismiss } = useUploads();
if (!tasks.length) return null;
return <section className="upload-progress card" aria-label="Прогресс загрузок">
<h2>Загрузки</h2>
{tasks.map((task) => {
const pct = Math.round(task.progress * 100);
const finished = task.status === "ready" || task.status === "failed";
const text = task.status === "creating" ? "Подготовка загрузки…"
: task.status === "uploading" ? (pct < 100 ? `Загрузка архива… ${pct}%` : "Архив отправлен, ожидаем ответ сервера…")
: task.status === "ready" ? "Готово"
: task.status === "failed" ? "Ошибка"
: task.version?.progress || "В очереди на конвертацию";
return <div className="upload-task" key={task.id}>
<div className="upload-task-heading">
<b>{task.filename}</b>
{finished && <button className="btn btn-ghost btn-icon" onClick={() => dismiss(task.id)} aria-label={`Убрать ${task.filename}`}><Icon name="x" size={16} /></button>}
</div>
<div className="muted small">{formatSize(task.size)}</div>
{task.status !== "failed" && <div className="progress" role="progressbar" aria-label={`Загрузка ${task.filename}`}
aria-valuemin={0} aria-valuemax={100} aria-valuenow={task.status === "processing" ? undefined : pct} aria-valuetext={text}>
<div style={{ width: `${pct}%`, opacity: task.status === "processing" ? 0.5 : 1 }} />
</div>}
<p className="small" role="status">{text}</p>
{task.error && <ErrorNote>{task.error}</ErrorNote>}
{task.syncError && <p className="muted small">{task.syncError}</p>}
{task.gameId && <Link to={`/games/${task.gameId}`} className="small">Открыть игру</Link>}
</div>;
})}
</section>;
}
function FloatingUploads() {
const { pathname } = useLocation();
const { tasks } = useUploads();
const { task: translation } = useTranslationRun();
const [collapsed, setCollapsed] = useState(false);
// on the upload page the uploads are listed in the page itself
const uploads = tasks.length > 0 && pathname.replace(/\/$/, "") !== "/games/new";
if (!uploads && !translation) return null;
const active = uploads ? tasks.find(task => ["creating", "uploading", "processing"].includes(task.status)) : undefined;
const finished = uploads ? tasks.find(task => task.status === "failed") ?? tasks[0] : undefined;
const current = active ?? finished;
const translationActive = translation?.status === "running" || translation?.status === "stopping";
const showTranslation = !active && translationActive || !current;
const failed = showTranslation ? translation?.status === "failed" : current?.status === "failed";
const done = showTranslation ? translation?.status === "done" || translation?.status === "stopped" : current?.status === "ready";
const unknown = showTranslation
? !!translationActive && !translation?.progress.total
: current?.status === "creating" || current?.status === "processing" || current?.status === "uploading" && current.progress >= 1;
const percentage = showTranslation && translation
? Math.min(100, Math.round((translation.progress.done + translation.progress.failed) / (translation.progress.total || 1) * 100))
: Math.max(0, Math.min(100, Math.round((current?.progress ?? 0) * 100)));
const label = failed ? "Ошибка фоновой задачи" : done ? "Фоновые задачи завершены"
: unknown ? "Обработка на сервере" : `Прогресс: ${percentage}%`;
const count = (uploads ? tasks.length : 0) + (translation ? 1 : 0);
const hasError = tasks.some(task => task.status === "failed") || translation?.status === "failed";
const circumference = 2 * Math.PI * 25;
return <aside className={`upload-floating${collapsed ? " upload-floating-collapsed" : ""}`} aria-label="Фоновые задачи">
<button type="button" className={collapsed ? `upload-bubble${failed ? " failed" : done ? " done" : ""}` : "btn btn-ghost btn-icon upload-minimize"}
aria-expanded={!collapsed} aria-label={collapsed ? `Развернуть фоновые задачи. ${label}` : "Свернуть фоновые задачи"}
title={collapsed ? `${label}. Нажмите, чтобы развернуть` : "Свернуть в круг прогресса"}
onClick={() => setCollapsed(value => !value)}>
{collapsed ? <>
<svg viewBox="0 0 64 64" className={`upload-ring${unknown ? " indeterminate" : ""}`} aria-hidden="true">
<circle cx="32" cy="32" r="25" className="upload-ring-track" />
<circle cx="32" cy="32" r="25" className="upload-ring-value"
strokeDasharray={`${circumference * (unknown ? 0.25 : percentage / 100)} ${circumference}`} />
</svg>
<span className="upload-bubble-value">{failed ? <Icon name="alert" size={20} /> : done ? <Icon name="check" size={22} />
: unknown ? <Icon name="clock" size={20} /> : `${percentage}%`}</span>
{(count > 1 || hasError && !failed) && <span className={`upload-bubble-count${hasError ? " has-error" : ""}`}>{count}</span>}
</> : <Icon name="minus" size={18} />}
</button>
{!collapsed && <>
{uploads && <UploadProgress />}
<TranslationProgress />
{uploads && <Link to="/games/new" className="btn btn-primary">К загрузкам</Link>}
</>}
</aside>;
}