From fa82c99a52d606ed0c4252cd80543f4f2bafac03 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:13:11 +0300 Subject: [PATCH] fix: preserve upload progress across page navigation --- frontend/src/App.tsx | 3 + frontend/src/pages/GamePage.tsx | 10 +- frontend/src/pages/NewGame.tsx | 50 +++------- frontend/src/styles.css | 12 +++ frontend/src/uploads.tsx | 159 ++++++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 37 deletions(-) create mode 100644 frontend/src/uploads.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 529ed93..46e620c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ; return ( + } /> + ); } diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 15b0319..c0e5405 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -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 = { 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(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) => { diff --git a/frontend/src/pages/NewGame.tsx b/frontend/src/pages/NewGame.tsx index 6e2e9b6..f4bf1a7 100644 --- a/frontend/src/pages/NewGame.tsx +++ b/frontend/src/pages/NewGame.tsx @@ -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(null); const [over, setOver] = useState(false); - const [progress, setProgress] = useState(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("/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 (
+
{error && {error}} - {uploading && ( -
-
-

- {pct < 100 ? `Загрузка архива… ${pct}%` : "Архив загружен, передаём на конвертацию…"} -

-
- )}
- - {!uploading && } +
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 47f50b9..751e138 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; diff --git a/frontend/src/uploads.tsx b/frontend/src/uploads.tsx new file mode 100644 index 0000000..13eb6cf --- /dev/null +++ b/frontend/src/uploads.tsx @@ -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(null); + +export function UploadProvider({ children }: { children: ReactNode }) { + const [tasks, setTasks] = useState([]); + const sequence = useRef(0); + const update = (id: number, patch: Partial) => + 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("/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("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; + const poll = async () => { + await Promise.all(pending.split(",").map(async (entry) => { + const [id, versionId] = entry.split(":").map(Number); + try { + const version = await api.get(`/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 setTasks((items) => items.filter((item) => item.id !== id)) }}> + {children} + + ; +} + +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
+

Загрузки

+ {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
+
+ {task.filename} + {finished && } +
+
{formatSize(task.size)}
+ {task.status !== "failed" &&
+
+
} +

{text}

+ {task.error && {task.error}} + {task.syncError &&

{task.syncError}

} + {task.gameId && Открыть игру} +
; + })} +
; +} + +function FloatingUploads() { + const { pathname } = useLocation(); + const { tasks } = useUploads(); + if (pathname.replace(/\/$/, "") === "/games/new" || !tasks.length) return null; + return ; +}