Add .gitattributes: LF in the repository, CRLF for batch files

Normalize the files that had been committed with CRLF (line endings only,
no content changes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 03:41:24 +03:00
1 parent e1d7b29f77
commit 51a7e77e56
14 files changed
+1461 -1447

No files matched your search

+233 -233
View File
@@ -1,233 +1,233 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { api, formatSize, uploadRaw, type GameDetail, type Version } from "../api";
import { useAuth } from "../auth";
const STATUS: Record<Version["status"], string> = {
uploading: "загружается",
queued: "в очереди",
converting: "конвертируется",
importing: "импортируется",
ready: "готова",
failed: "ошибка",
};
export function GamePage() {
const { gameId } = useParams();
const navigate = useNavigate();
const { me, can } = useAuth();
const [game, setGame] = useState<GameDetail | null>(null);
const [error, setError] = useState("");
const [editing, setEditing] = useState(false);
const load = useCallback(async () => {
try {
setGame(await api.get<GameDetail>(`/api/games/${gameId}`));
} catch (e) {
setError((e as Error).message);
}
}, [gameId]);
useEffect(() => {
void load();
}, [load]);
// poll while something is being converted
const busy = game?.versions.some((v) => ["uploading", "queued", "converting", "importing"].includes(v.status));
useEffect(() => {
if (!busy) return;
const t = setInterval(() => void load(), 2000);
return () => clearInterval(t);
}, [busy, load]);
if (error) return <div className="page"><div className="error">{error}</div></div>;
if (!game) return <div className="page muted">Загрузка…</div>;
// the backend also lets uploaders edit only games they created
const canEdit = can("games.edit") || can("games.upload");
const run = async (fn: () => Promise<unknown>) => {
setError("");
try {
await fn();
await load();
} catch (e) {
setError((e as Error).message);
}
};
const fork = (v: Version) => {
const name = prompt("Название новой версии", `${v.name} (${me?.username})`);
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }));
};
const uploadCover = (file: File) =>
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}));
const uploadVersion = (file: File) => {
const name = prompt("Название версии", file.name);
if (name)
void run(() => uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}&name=${encodeURIComponent(name)}`,
file, () => {}));
};
const byParent = new Map<number | null, Version[]>();
for (const v of game.versions) {
const parent = game.versions.some((p) => p.id === v.parent_id) ? v.parent_id : null;
byParent.set(parent, [...(byParent.get(parent) ?? []), v]);
}
const renderTree = (parent: number | null, depth: number): JSX.Element[] =>
(byParent.get(parent) ?? []).flatMap((v) => [
<VersionRow key={v.id} v={v} depth={depth} canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
canFork={can("versions.fork")} onPlay={() => navigate(`/play/${v.id}`)} onFork={() => fork(v)}
onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))}
onDelete={() => confirm(`Удалить версию «${v.name}»?`) && void run(() => api.del(`/api/versions/${v.id}`))} />,
...renderTree(v.id, depth + 1),
]);
return (
<div className="page">
<Link to="/" className="muted">← Библиотека</Link>
<div className="game-head">
<div className="cover big">
{game.cover_hash ? <img src={`/blobs/${game.cover_hash}`} alt="" /> : <span>{game.title[0]}</span>}
</div>
<div className="game-info">
{editing ? (
<GameEditor game={game} onDone={() => { setEditing(false); void load(); }} />
) : (
<>
<h1>{game.title}</h1>
<div className="tags">{game.tags.map((t) => <span key={t} className="tag">{t}</span>)}</div>
<p className="description">{game.description || <span className="muted">Нет описания</span>}</p>
<div className="actions">
{canEdit && <button onClick={() => setEditing(true)}>Редактировать</button>}
{canEdit && (
<label className="button">
Обложка
<input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && void uploadCover(e.target.files[0])} />
</label>
)}
{can("games.upload") && (
<label className="button">
Загрузить ещё архив
<input type="file" hidden onChange={(e) => e.target.files?.[0] && uploadVersion(e.target.files[0])} />
</label>
)}
{can("games.delete") && (
<button className="danger" onClick={() => confirm("Удалить игру со всеми версиями?") &&
void api.del(`/api/games/${game.id}`).then(() => navigate("/"))}>Удалить игру</button>
)}
</div>
</>
)}
</div>
</div>
{error && <div className="error">{error}</div>}
<h2>Версии</h2>
<div className="versions">{renderTree(null, 0)}</div>
</div>
);
}
function VersionRow(props: {
v: Version; depth: number; canManage: boolean; canFork: boolean;
onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
}) {
const { v } = props;
const [report, setReport] = useState<Version["report"] | undefined>(undefined);
const toggleReport = async () => {
if (report !== undefined) setReport(undefined);
else setReport((await api.get<Version>(`/api/versions/${v.id}`)).report ?? null);
};
return (
<div className="version" style={{ marginLeft: props.depth * 24 }}>
<div className="version-main">
<div>
<b>{props.depth > 0 && "↳ "}{v.name}</b>
<span className={`status status-${v.status}`}>{STATUS[v.status]}</span>
<div className="muted small">
{v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)}
{v.progress && ` · ${v.progress}`}
</div>
</div>
<div className="actions">
{v.status === "ready" && <button className="primary" onClick={props.onPlay}>Играть</button>}
{v.status === "ready" && props.canFork && <button onClick={props.onFork}>Форк</button>}
{(v.status === "ready" || v.status === "failed") && <button onClick={() => void toggleReport()}>Отчёт</button>}
{v.status === "failed" && props.canManage && <button onClick={props.onRetry}>Повторить</button>}
{props.canManage && !["converting", "importing"].includes(v.status) && <button className="danger" onClick={props.onDelete}>Удалить</button>}
</div>
</div>
{report !== undefined && <Report report={report} />}
</div>
);
}
function Report({ report }: { report: Record<string, any> | null }) {
if (!report) return <div className="report muted">Отчёта нет</div>;
if (report.error) return <div className="report error">{report.error}</div>;
return (
<div className="report">
<div className="report-stats">
<span>Скриптов: {report.scripts} (декомпилировано {report.decompiled})</span>
<span>Меток: {report.labels}</span>
<span>Реплик: {report.stats?.say ?? 0}</span>
<span>Меню: {report.stats?.menu ?? 0}</span>
<span>Картинок: {report.media?.image}, аудио: {report.media?.audio}, видео: {report.media?.video}</span>
<span>Размер: {formatSize(report.sourceSize)} → {formatSize(report.outputSize)}</span>
<span>Время: {report.durationSec} с</span>
</div>
{report.missingFiles?.length > 0 && (
<details>
<summary>Не найдены файлы ({report.missingFiles.length})</summary>
<ul>{report.missingFiles.map((f: string) => <li key={f}>{f}</li>)}</ul>
</details>
)}
{report.warnings?.length > 0 && (
<details>
<summary>Предупреждения ({report.warnings.reduce((s: number, w: any) => s + w.count, 0)})</summary>
<table>
<tbody>
{report.warnings.map((w: any) => (
<tr key={w.message}>
<td>{w.count}×</td>
<td>{w.message}</td>
<td className="muted small">{w.example}</td>
</tr>
))}
</tbody>
</table>
</details>
)}
</div>
);
}
function GameEditor({ game, onDone }: { game: GameDetail; onDone: () => void }) {
const [title, setTitle] = useState(game.title);
const [description, setDescription] = useState(game.description);
const [tags, setTags] = useState(game.tags.join(", "));
const [error, setError] = useState("");
const save = async () => {
try {
await api.patch(`/api/games/${game.id}`, {
title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
});
onDone();
} catch (e) {
setError((e as Error).message);
}
};
return (
<div className="editor">
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} />
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="теги через запятую" />
{error && <div className="error">{error}</div>}
<div className="actions">
<button className="primary" onClick={() => void save()}>Сохранить</button>
<button onClick={onDone}>Отмена</button>
</div>
</div>
);
}
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { api, formatSize, uploadRaw, type GameDetail, type Version } from "../api";
import { useAuth } from "../auth";
const STATUS: Record<Version["status"], string> = {
uploading: "загружается",
queued: "в очереди",
converting: "конвертируется",
importing: "импортируется",
ready: "готова",
failed: "ошибка",
};
export function GamePage() {
const { gameId } = useParams();
const navigate = useNavigate();
const { me, can } = useAuth();
const [game, setGame] = useState<GameDetail | null>(null);
const [error, setError] = useState("");
const [editing, setEditing] = useState(false);
const load = useCallback(async () => {
try {
setGame(await api.get<GameDetail>(`/api/games/${gameId}`));
} catch (e) {
setError((e as Error).message);
}
}, [gameId]);
useEffect(() => {
void load();
}, [load]);
// poll while something is being converted
const busy = game?.versions.some((v) => ["uploading", "queued", "converting", "importing"].includes(v.status));
useEffect(() => {
if (!busy) return;
const t = setInterval(() => void load(), 2000);
return () => clearInterval(t);
}, [busy, load]);
if (error) return <div className="page"><div className="error">{error}</div></div>;
if (!game) return <div className="page muted">Загрузка…</div>;
// the backend also lets uploaders edit only games they created
const canEdit = can("games.edit") || can("games.upload");
const run = async (fn: () => Promise<unknown>) => {
setError("");
try {
await fn();
await load();
} catch (e) {
setError((e as Error).message);
}
};
const fork = (v: Version) => {
const name = prompt("Название новой версии", `${v.name} (${me?.username})`);
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }));
};
const uploadCover = (file: File) =>
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}));
const uploadVersion = (file: File) => {
const name = prompt("Название версии", file.name);
if (name)
void run(() => uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}&name=${encodeURIComponent(name)}`,
file, () => {}));
};
const byParent = new Map<number | null, Version[]>();
for (const v of game.versions) {
const parent = game.versions.some((p) => p.id === v.parent_id) ? v.parent_id : null;
byParent.set(parent, [...(byParent.get(parent) ?? []), v]);
}
const renderTree = (parent: number | null, depth: number): JSX.Element[] =>
(byParent.get(parent) ?? []).flatMap((v) => [
<VersionRow key={v.id} v={v} depth={depth} canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
canFork={can("versions.fork")} onPlay={() => navigate(`/play/${v.id}`)} onFork={() => fork(v)}
onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))}
onDelete={() => confirm(`Удалить версию «${v.name}»?`) && void run(() => api.del(`/api/versions/${v.id}`))} />,
...renderTree(v.id, depth + 1),
]);
return (
<div className="page">
<Link to="/" className="muted">← Библиотека</Link>
<div className="game-head">
<div className="cover big">
{game.cover_hash ? <img src={`/blobs/${game.cover_hash}`} alt="" /> : <span>{game.title[0]}</span>}
</div>
<div className="game-info">
{editing ? (
<GameEditor game={game} onDone={() => { setEditing(false); void load(); }} />
) : (
<>
<h1>{game.title}</h1>
<div className="tags">{game.tags.map((t) => <span key={t} className="tag">{t}</span>)}</div>
<p className="description">{game.description || <span className="muted">Нет описания</span>}</p>
<div className="actions">
{canEdit && <button onClick={() => setEditing(true)}>Редактировать</button>}
{canEdit && (
<label className="button">
Обложка
<input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && void uploadCover(e.target.files[0])} />
</label>
)}
{can("games.upload") && (
<label className="button">
Загрузить ещё архив
<input type="file" hidden onChange={(e) => e.target.files?.[0] && uploadVersion(e.target.files[0])} />
</label>
)}
{can("games.delete") && (
<button className="danger" onClick={() => confirm("Удалить игру со всеми версиями?") &&
void api.del(`/api/games/${game.id}`).then(() => navigate("/"))}>Удалить игру</button>
)}
</div>
</>
)}
</div>
</div>
{error && <div className="error">{error}</div>}
<h2>Версии</h2>
<div className="versions">{renderTree(null, 0)}</div>
</div>
);
}
function VersionRow(props: {
v: Version; depth: number; canManage: boolean; canFork: boolean;
onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
}) {
const { v } = props;
const [report, setReport] = useState<Version["report"] | undefined>(undefined);
const toggleReport = async () => {
if (report !== undefined) setReport(undefined);
else setReport((await api.get<Version>(`/api/versions/${v.id}`)).report ?? null);
};
return (
<div className="version" style={{ marginLeft: props.depth * 24 }}>
<div className="version-main">
<div>
<b>{props.depth > 0 && "↳ "}{v.name}</b>
<span className={`status status-${v.status}`}>{STATUS[v.status]}</span>
<div className="muted small">
{v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)}
{v.progress && ` · ${v.progress}`}
</div>
</div>
<div className="actions">
{v.status === "ready" && <button className="primary" onClick={props.onPlay}>Играть</button>}
{v.status === "ready" && props.canFork && <button onClick={props.onFork}>Форк</button>}
{(v.status === "ready" || v.status === "failed") && <button onClick={() => void toggleReport()}>Отчёт</button>}
{v.status === "failed" && props.canManage && <button onClick={props.onRetry}>Повторить</button>}
{props.canManage && !["converting", "importing"].includes(v.status) && <button className="danger" onClick={props.onDelete}>Удалить</button>}
</div>
</div>
{report !== undefined && <Report report={report} />}
</div>
);
}
function Report({ report }: { report: Record<string, any> | null }) {
if (!report) return <div className="report muted">Отчёта нет</div>;
if (report.error) return <div className="report error">{report.error}</div>;
return (
<div className="report">
<div className="report-stats">
<span>Скриптов: {report.scripts} (декомпилировано {report.decompiled})</span>
<span>Меток: {report.labels}</span>
<span>Реплик: {report.stats?.say ?? 0}</span>
<span>Меню: {report.stats?.menu ?? 0}</span>
<span>Картинок: {report.media?.image}, аудио: {report.media?.audio}, видео: {report.media?.video}</span>
<span>Размер: {formatSize(report.sourceSize)} → {formatSize(report.outputSize)}</span>
<span>Время: {report.durationSec} с</span>
</div>
{report.missingFiles?.length > 0 && (
<details>
<summary>Не найдены файлы ({report.missingFiles.length})</summary>
<ul>{report.missingFiles.map((f: string) => <li key={f}>{f}</li>)}</ul>
</details>
)}
{report.warnings?.length > 0 && (
<details>
<summary>Предупреждения ({report.warnings.reduce((s: number, w: any) => s + w.count, 0)})</summary>
<table>
<tbody>
{report.warnings.map((w: any) => (
<tr key={w.message}>
<td>{w.count}×</td>
<td>{w.message}</td>
<td className="muted small">{w.example}</td>
</tr>
))}
</tbody>
</table>
</details>
)}
</div>
);
}
function GameEditor({ game, onDone }: { game: GameDetail; onDone: () => void }) {
const [title, setTitle] = useState(game.title);
const [description, setDescription] = useState(game.description);
const [tags, setTags] = useState(game.tags.join(", "));
const [error, setError] = useState("");
const save = async () => {
try {
await api.patch(`/api/games/${game.id}`, {
title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
});
onDone();
} catch (e) {
setError((e as Error).message);
}
};
return (
<div className="editor">
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} />
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="теги через запятую" />
{error && <div className="error">{error}</div>}
<div className="actions">
<button className="primary" onClick={() => void save()}>Сохранить</button>
<button onClick={onDone}>Отмена</button>
</div>
</div>
);
}