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
@@ -14,6 +14,7 @@ import { useTheme } from "./theme";
|
|||||||
import { Icon } from "./ui/Icon";
|
import { Icon } from "./ui/Icon";
|
||||||
import { useSpatialNav } from "./ui/nav";
|
import { useSpatialNav } from "./ui/nav";
|
||||||
import { THEME_ICON, THEME_LABEL } from "./ui/ThemeButton";
|
import { THEME_ICON, THEME_LABEL } from "./ui/ThemeButton";
|
||||||
|
import { UploadProvider } from "./uploads";
|
||||||
|
|
||||||
function RequirePerm({ perm, children }: { perm: string; children: JSX.Element }) {
|
function RequirePerm({ perm, children }: { perm: string; children: JSX.Element }) {
|
||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
@@ -86,6 +87,7 @@ export function App() {
|
|||||||
if (me.must_change_password || me.must_change_username) return <ChangeCredentials forced />;
|
if (me.must_change_password || me.must_change_username) return <ChangeCredentials forced />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<UploadProvider key={me.id}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/play/:versionId" element={<RequirePerm perm="games.play"><Play /></RequirePerm>} />
|
<Route path="/play/:versionId" element={<RequirePerm perm="games.play"><Play /></RequirePerm>} />
|
||||||
<Route
|
<Route
|
||||||
@@ -127,5 +129,6 @@ export function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</UploadProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Cover, ErrorNote, Spinner } from "../ui/Bits";
|
|||||||
import { F95Fetch } from "../ui/F95Fetch";
|
import { F95Fetch } from "../ui/F95Fetch";
|
||||||
import { Icon } from "../ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
import { useUi } from "../ui/Ui";
|
import { useUi } from "../ui/Ui";
|
||||||
|
import { useUploads } from "../uploads";
|
||||||
|
|
||||||
const STATUS: Record<Version["status"], string> = {
|
const STATUS: Record<Version["status"], string> = {
|
||||||
uploading: "Загружается",
|
uploading: "Загружается",
|
||||||
@@ -27,6 +28,7 @@ export function GamePage() {
|
|||||||
const { gameId } = useParams();
|
const { gameId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const ui = useUi();
|
const ui = useUi();
|
||||||
|
const { start: startUpload, tasks: uploads } = useUploads();
|
||||||
const { me, can } = useAuth();
|
const { me, can } = useAuth();
|
||||||
const [game, setGame] = useState<GameDetail | null>(null);
|
const [game, setGame] = useState<GameDetail | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -44,6 +46,11 @@ export function GamePage() {
|
|||||||
void load();
|
void load();
|
||||||
}, [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
|
// poll while something is being converted
|
||||||
const busy = game?.versions.some((v) => BUSY.includes(v.status));
|
const busy = game?.versions.some((v) => BUSY.includes(v.status));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,8 +129,7 @@ export function GamePage() {
|
|||||||
confirmLabel: "Загрузить",
|
confirmLabel: "Загрузить",
|
||||||
});
|
});
|
||||||
if (name)
|
if (name)
|
||||||
void run(() => uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}&name=${encodeURIComponent(name)}`,
|
startUpload({ file, gameId: game.id, name });
|
||||||
file, () => {}), "Архив загружен, началась конвертация");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeVersion = async (v: Version) => {
|
const removeVersion = async (v: Version) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, type DragEvent, type FormEvent } from "react";
|
import { useState, type DragEvent, type FormEvent } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { ErrorNote, PageHeader } from "../ui/Bits";
|
||||||
import { F95Fetch } from "../ui/F95Fetch";
|
import { F95Fetch } from "../ui/F95Fetch";
|
||||||
import { Icon } from "../ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
@@ -14,7 +15,7 @@ export function NewGame() {
|
|||||||
const [tags, setTags] = useState("");
|
const [tags, setTags] = useState("");
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [over, setOver] = useState(false);
|
const [over, setOver] = useState(false);
|
||||||
const [progress, setProgress] = useState<number | null>(null);
|
const { start } = useUploads();
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const pick = (f: File | null | undefined) => {
|
const pick = (f: File | null | undefined) => {
|
||||||
@@ -32,40 +33,27 @@ export function NewGame() {
|
|||||||
pick(e.dataTransfer.files[0]);
|
pick(e.dataTransfer.files[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async (e: FormEvent) => {
|
const submit = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setError("");
|
setError("");
|
||||||
setProgress(0);
|
start({ file, title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean) });
|
||||||
let game: Game | null = null;
|
setFile(null);
|
||||||
try {
|
setTitle("");
|
||||||
game = await api.post<Game>("/api/games", {
|
setDescription("");
|
||||||
title: title.trim() || file.name.replace(/\.(zip|7z|rar|tar.*)$/i, ""),
|
setTags("");
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploading = progress !== null;
|
|
||||||
const pct = Math.round((progress ?? 0) * 100);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page medium">
|
<div className="page medium">
|
||||||
<PageHeader title="Новая игра"
|
<PageHeader title="Новая игра"
|
||||||
subtitle="Загрузите архив с игрой на Ren'Py. Сервер распакует его, сконвертирует скрипты и ассеты в формат PGVN и удалит исходники." />
|
subtitle="Загрузите архив с игрой на Ren'Py. Сервер распакует его, сконвертирует скрипты и ассеты в формат PGVN и удалит исходники." />
|
||||||
|
<UploadProgress />
|
||||||
<form className="card form" onSubmit={submit}>
|
<form className="card form" onSubmit={submit}>
|
||||||
<label className={`dropzone ${over ? "over" : ""} ${file ? "has-file" : ""}`}
|
<label className={`dropzone ${over ? "over" : ""} ${file ? "has-file" : ""}`}
|
||||||
onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)} onDrop={onDrop}>
|
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])} />
|
onChange={(e) => pick(e.target.files?.[0])} />
|
||||||
<Icon name={file ? "file" : "upload"} size={34} className="dz-icon" />
|
<Icon name={file ? "file" : "upload"} size={34} className="dz-icon" />
|
||||||
{file ? (
|
{file ? (
|
||||||
@@ -103,19 +91,11 @@ export function NewGame() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{error && <ErrorNote>{error}</ErrorNote>}
|
{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">
|
<div className="actions">
|
||||||
<button className="btn btn-primary btn-lg" disabled={!file || uploading}>
|
<button className="btn btn-primary btn-lg" disabled={!file}>
|
||||||
<Icon name="upload" size={20} />{uploading ? `Загрузка ${pct}%` : "Загрузить"}
|
<Icon name="upload" size={20} />Загрузить
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -670,6 +670,18 @@ input[type="file"] { padding: 0.4rem; }
|
|||||||
.user-cards { display: none; }
|
.user-cards { display: none; }
|
||||||
|
|
||||||
/* ---- 9. feedback: modal, toasts ---------------------------------------- */
|
/* ---- 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 {
|
.modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -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