fix: preserve upload progress across page navigation

This commit is contained in:
mrleo1nid committed 2026-10-25 04:13:11 +03:00
1 parent ae8e175376
commit fa82c99a52
5 files changed
+197 -37

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
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 { 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[];
}
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 ?? [],
});
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();
if (pathname.replace(/\/$/, "") === "/games/new" || !tasks.length) return null;
return <aside className="upload-floating" aria-label="Фоновые загрузки">
<UploadProgress />
<Link to="/games/new" className="btn btn-primary">К загрузкам</Link>
</aside>;
}