- Show a readable message when a reverse proxy rejects the archive with 413 - Delete the freshly created game if its archive upload fails - Turn ClientDisconnect during upload into a short 400 instead of a traceback Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
73 lines
2.9 KiB
TypeScript
73 lines
2.9 KiB
TypeScript
import { useState, type FormEvent } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { api, uploadRaw, type Game } from "../api";
|
||
|
||
export function NewGame() {
|
||
const navigate = useNavigate();
|
||
const [title, setTitle] = useState("");
|
||
const [description, setDescription] = useState("");
|
||
const [tags, setTags] = useState("");
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [progress, setProgress] = useState<number | null>(null);
|
||
const [error, setError] = useState("");
|
||
|
||
const submit = async (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);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="page narrow">
|
||
<form className="card" onSubmit={submit}>
|
||
<h1>Новая игра</h1>
|
||
<p className="muted">
|
||
Загрузите архив с игрой Ren'Py (zip, 7z, rar, tar). После загрузки сервер распакует его,
|
||
сконвертирует скрипты и ассеты в формат PGVN и удалит исходники.
|
||
</p>
|
||
<label>
|
||
Архив
|
||
<input type="file" accept=".zip,.7z,.rar,.tar,.gz,.tgz,.bz2,.xz"
|
||
onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||
</label>
|
||
<label>
|
||
Название
|
||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="По имени архива" />
|
||
</label>
|
||
<label>
|
||
Описание
|
||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={4} />
|
||
</label>
|
||
<label>
|
||
Теги через запятую
|
||
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="романтика, драма" />
|
||
</label>
|
||
{error && <div className="error">{error}</div>}
|
||
{progress !== null && (
|
||
<div className="progress"><div style={{ width: `${Math.round(progress * 100)}%` }} /></div>
|
||
)}
|
||
<button className="primary" disabled={!file || progress !== null}>
|
||
{progress !== null ? `Загрузка ${Math.round(progress * 100)}%` : "Загрузить"}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|