feat: local browser saves with opt-in cloud sync behind saves.cloud permission
CI / frontend (push) Successful in 21s
CI / backend (push) Successful in 47s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 12:44:42 +03:00
1 parent 4418da47eb
commit 47fc121e7d
8 files changed
+377 -31

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
import { useRef, useState } from "react";
import { useAuth } from "../auth";
import { cloudEnabled, exportSaves, importSaves, setCloudEnabled } from "../player/saves";
import { PageHeader } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import { useUi } from "../ui/Ui";
export function Saves() {
const { can } = useAuth();
const ui = useUi();
const allowed = can("saves.cloud");
const [cloud, setCloud] = useState(cloudEnabled());
const file = useRef<HTMLInputElement>(null);
const toggle = (on: boolean) => {
setCloudEnabled(on);
setCloud(on);
ui.toast(on ? "Облачные сохранения включены на этом устройстве" : "Облачные сохранения выключены", "ok");
};
const download = async () => {
const url = URL.createObjectURL(new Blob([await exportSaves()], { type: "application/json" }));
const a = document.createElement("a");
a.href = url;
a.download = "pgames-saves.json";
a.click();
URL.revokeObjectURL(url);
};
const upload = async (f: File | undefined) => {
if (!f) return;
try {
const n = await importSaves(await f.text());
ui.toast(n ? `Загружено сохранений: ${n}` : "Новых сохранений в файле нет", "ok");
} catch (e) {
ui.toast((e as Error).message, "error");
}
if (file.current) file.current.value = "";
};
return (
<div className="page">
<PageHeader title="Сохранения" subtitle="По умолчанию хранятся только в этом браузере" />
<section className="card">
<h2>Облако</h2>
<p className="muted">
Облачные сохранения копируются на сервер и доступны с других устройств.
Включаются отдельно на каждом устройстве; если сервер недоступен, игра сохраняется локально.
</p>
{allowed ? (
<label className="row">
<input type="checkbox" checked={cloud} onChange={(e) => toggle(e.target.checked)} />
Синхронизировать сохранения с сервером
</label>
) : (
<p className="muted">У вашей роли нет права «Облачные сохранения» — обратитесь к администратору.</p>
)}
</section>
<section className="card">
<h2>Перенос файлом</h2>
<p className="muted">Скачайте все локальные сохранения или загрузите файл с другого устройства.</p>
<div className="actions">
<button className="btn" onClick={() => void download()}><Icon name="download" size={18} />Скачать</button>
<button className="btn" onClick={() => file.current?.click()}><Icon name="upload" size={18} />Загрузить</button>
<input ref={file} type="file" accept="application/json,.json" hidden
onChange={(e) => void upload(e.target.files?.[0])} />
</div>
</section>
</div>
);
}