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

+3
View File
@@ -14,6 +14,7 @@ import { useTheme } from "./theme";
import { Icon } from "./ui/Icon";
import { useSpatialNav } from "./ui/nav";
import { THEME_ICON, THEME_LABEL } from "./ui/ThemeButton";
import { UploadProvider } from "./uploads";
function RequirePerm({ perm, children }: { perm: string; children: JSX.Element }) {
const { can } = useAuth();
@@ -86,6 +87,7 @@ export function App() {
if (me.must_change_password || me.must_change_username) return <ChangeCredentials forced />;
return (
<UploadProvider key={me.id}>
<Routes>
<Route path="/play/:versionId" element={<RequirePerm perm="games.play"><Play /></RequirePerm>} />
<Route
@@ -127,5 +129,6 @@ export function App() {
}
/>
</Routes>
</UploadProvider>
);
}
+8 -2
View File
@@ -7,6 +7,7 @@ import { Cover, ErrorNote, Spinner } from "../ui/Bits";
import { F95Fetch } from "../ui/F95Fetch";
import { Icon } from "../ui/Icon";
import { useUi } from "../ui/Ui";
import { useUploads } from "../uploads";
const STATUS: Record<Version["status"], string> = {
uploading: "Загружается",
@@ -27,6 +28,7 @@ export function GamePage() {
const { gameId } = useParams();
const navigate = useNavigate();
const ui = useUi();
const { start: startUpload, tasks: uploads } = useUploads();
const { me, can } = useAuth();
const [game, setGame] = useState<GameDetail | null>(null);
const [error, setError] = useState("");
@@ -44,6 +46,11 @@ export function GamePage() {
void load();
}, [load]);
const uploadedVersions = uploads.filter((task) => task.gameId === Number(gameId) && task.version).map((task) => task.version!.id).join(",");
useEffect(() => {
if (uploadedVersions) void load();
}, [uploadedVersions, load]);
// poll while something is being converted
const busy = game?.versions.some((v) => BUSY.includes(v.status));
useEffect(() => {
@@ -122,8 +129,7 @@ export function GamePage() {
confirmLabel: "Загрузить",
});
if (name)
void run(() => uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}&name=${encodeURIComponent(name)}`,
file, () => {}), "Архив загружен, началась конвертация");
startUpload({ file, gameId: game.id, name });
};
const removeVersion = async (v: Version) => {
+15 -35
View File
@@ -1,6 +1,7 @@
import { useState, type DragEvent, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { api, formatSize, uploadRaw, type Game } from "../api";
import { formatSize } from "../api";
import { UploadProgress, useUploads } from "../uploads";
import { ErrorNote, PageHeader } from "../ui/Bits";
import { F95Fetch } from "../ui/F95Fetch";
import { Icon } from "../ui/Icon";
@@ -14,7 +15,7 @@ export function NewGame() {
const [tags, setTags] = useState("");
const [file, setFile] = useState<File | null>(null);
const [over, setOver] = useState(false);
const [progress, setProgress] = useState<number | null>(null);
const { start } = useUploads();
const [error, setError] = useState("");
const pick = (f: File | null | undefined) => {
@@ -32,40 +33,27 @@ export function NewGame() {
pick(e.dataTransfer.files[0]);
};
const submit = async (e: FormEvent) => {
const submit = (e: FormEvent) => {
e.preventDefault();
if (!file) return;
setError("");
setProgress(0);
let game: Game | null = null;
try {
game = await api.post<Game>("/api/games", {
title: title.trim() || file.name.replace(/\.(zip|7z|rar|tar.*)$/i, ""),
description,
tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
});
await uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}`,
file, setProgress);
navigate(`/games/${game.id}`);
} catch (err) {
// don't leave an empty game card behind when the archive never arrived
if (game) await api.del(`/api/games/${game.id}`).catch(() => {});
setError((err as Error).message);
setProgress(null);
}
start({ file, title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean) });
setFile(null);
setTitle("");
setDescription("");
setTags("");
};
const uploading = progress !== null;
const pct = Math.round((progress ?? 0) * 100);
return (
<div className="page medium">
<PageHeader title="Новая игра"
subtitle="Загрузите архив с игрой на Ren'Py. Сервер распакует его, сконвертирует скрипты и ассеты в формат PGVN и удалит исходники." />
<UploadProgress />
<form className="card form" onSubmit={submit}>
<label className={`dropzone ${over ? "over" : ""} ${file ? "has-file" : ""}`}
onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)} onDrop={onDrop}>
<input type="file" accept=".zip,.7z,.rar,.tar,.gz,.tgz,.bz2,.xz" disabled={uploading}
<input type="file" accept=".zip,.7z,.rar,.tar,.gz,.tgz,.bz2,.xz"
key={file ? "selected" : "empty"}
onChange={(e) => pick(e.target.files?.[0])} />
<Icon name={file ? "file" : "upload"} size={34} className="dz-icon" />
{file ? (
@@ -103,19 +91,11 @@ export function NewGame() {
</label>
{error && <ErrorNote>{error}</ErrorNote>}
{uploading && (
<div>
<div className="progress"><div style={{ width: `${pct}%` }} /></div>
<p className="muted small" style={{ marginTop: "0.4rem" }}>
{pct < 100 ? `Загрузка архива… ${pct}%` : "Архив загружен, передаём на конвертацию…"}
</p>
</div>
)}
<div className="actions">
<button className="btn btn-primary btn-lg" disabled={!file || uploading}>
<Icon name="upload" size={20} />{uploading ? `Загрузка ${pct}%` : "Загрузить"}
<button className="btn btn-primary btn-lg" disabled={!file}>
<Icon name="upload" size={20} />Загрузить
</button>
{!uploading && <button type="button" className="btn btn-ghost" onClick={() => navigate(-1)}>Отмена</button>}
<button type="button" className="btn btn-ghost" onClick={() => navigate(-1)}>Отмена</button>
</div>
</form>
</div>
+12
View File
@@ -670,6 +670,18 @@ input[type="file"] { padding: 0.4rem; }
.user-cards { display: none; }
/* ---- 9. feedback: modal, toasts ---------------------------------------- */
.upload-progress { display: flex; flex-direction: column; gap: 1rem; padding: var(--space-4); margin-bottom: var(--space-4); }
.upload-progress h2, .upload-task p { margin: 0; }
.upload-task { display: flex; flex-direction: column; gap: 0.45rem; min-width: 0; }
.upload-task + .upload-task { border-top: 1px solid var(--border); padding-top: 1rem; }
.upload-task-heading { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.upload-task-heading b { overflow-wrap: anywhere; min-width: 0; }
.upload-floating { position: fixed; right: var(--space-4); bottom: var(--space-4); z-index: 50; width: min(360px, calc(100vw - 2rem)); padding: var(--space-4); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-2); max-height: calc(100dvh - var(--nav-h) - 2rem); overflow-y: auto; }
.upload-floating .upload-progress { padding: 0; border: 0; box-shadow: none; }
@media (max-width: 700px) {
.upload-floating { bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom) + 1rem); max-height: calc(100dvh - var(--nav-h) - var(--tabbar-h) - env(safe-area-inset-bottom) - 2rem); }
}
.modal-backdrop {
position: fixed;
inset: 0;
+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>;
}