fix: preserve upload progress across page navigation
This commit is contained in:
1 parent
ae8e175376
commit
fa82c99a52
5 files changed
+197
-37
No files matched your search
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user