205 lines
10 KiB
TypeScript
205 lines
10 KiB
TypeScript
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>;
|
||
}
|