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 { TranslationProgress, useTranslationRun } from "./translator/runner"; 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[]; coverHash?: string | null; galleryHashes?: 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 ?? [], cover_hash: input.coverHash ?? null, gallery_hashes: input.galleryHashes ?? [], }); 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(); const { task: translation } = useTranslationRun(); const [collapsed, setCollapsed] = useState(false); // on the upload page the uploads are listed in the page itself const uploads = tasks.length > 0 && pathname.replace(/\/$/, "") !== "/games/new"; if (!uploads && !translation) return null; const active = uploads ? tasks.find(task => ["creating", "uploading", "processing"].includes(task.status)) : undefined; const finished = uploads ? tasks.find(task => task.status === "failed") ?? tasks[0] : undefined; const current = active ?? finished; const translationActive = translation?.status === "running" || translation?.status === "stopping"; const showTranslation = !active && translationActive || !current; const failed = showTranslation ? translation?.status === "failed" : current?.status === "failed"; const done = showTranslation ? translation?.status === "done" || translation?.status === "stopped" : current?.status === "ready"; const unknown = showTranslation ? !!translationActive && !translation?.progress.total : current?.status === "creating" || current?.status === "processing" || current?.status === "uploading" && current.progress >= 1; const percentage = showTranslation && translation ? Math.min(100, Math.round((translation.progress.done + translation.progress.failed) / (translation.progress.total || 1) * 100)) : Math.max(0, Math.min(100, Math.round((current?.progress ?? 0) * 100))); const label = failed ? "Ошибка фоновой задачи" : done ? "Фоновые задачи завершены" : unknown ? "Обработка на сервере" : `Прогресс: ${percentage}%`; const count = (uploads ? tasks.length : 0) + (translation ? 1 : 0); const hasError = tasks.some(task => task.status === "failed") || translation?.status === "failed"; const circumference = 2 * Math.PI * 25; return ; }