feat: add mod editing, revision merges and secure game storage
Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
This commit is contained in:
1 parent
06ff19ac4a
commit
c8d83b3fe7
65 files changed
+3825
-68
No files matched your search
@@ -85,7 +85,10 @@ export function GamePage() {
|
||||
text: "Форк делит с оригиналом все ассеты и почти не занимает места.",
|
||||
label: "Название версии", initial: `${v.name} (${me?.username})`, confirmLabel: "Создать форк",
|
||||
});
|
||||
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }), "Форк создан");
|
||||
if (name) void run(async () => {
|
||||
const fork = await api.post<Version>(`/api/versions/${v.id}/fork`, { name });
|
||||
navigate(`/versions/${fork.id}/edit`);
|
||||
}, "Форк создан");
|
||||
};
|
||||
|
||||
const translate = async (v: Version) => {
|
||||
@@ -275,6 +278,7 @@ function VersionRow(props: {
|
||||
<div className="actions">
|
||||
{v.status === "ready" && <button className="btn btn-primary btn-sm" onClick={props.onPlay}><Icon name="play" size={16} />Играть</button>}
|
||||
{v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>}
|
||||
{v.status === "ready" && v.kind === "mod" && <Link className="btn btn-sm" to={`/versions/${v.id}/edit`}>Редактор мода</Link>}
|
||||
{v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? (
|
||||
<Link className="btn btn-sm" to={`/versions/${v.id}/translate`}><Icon name="globe" size={16} />Редактор перевода</Link>
|
||||
) : v.status === "ready" && props.canTranslate && v.visibility === "published" && (
|
||||
@@ -307,7 +311,17 @@ function VersionRow(props: {
|
||||
|
||||
function Report({ report }: { report: Record<string, any> | null }) {
|
||||
if (!report) return <div className="report muted">Отчёта нет</div>;
|
||||
if (report.error) return <div className="report"><ErrorNote>{report.error}</ErrorNote></div>;
|
||||
if (report.error) return <div className="report">
|
||||
<ErrorNote>{report.error}</ErrorNote>
|
||||
{report.engine && <p className="muted">Движок: {report.engine}</p>}
|
||||
{report.issues?.length > 0 && <details>
|
||||
<summary>Причины остановки ({report.issues.length})</summary>
|
||||
<ul>{report.issues.map((issue: any, index: number) => <li key={index}>
|
||||
{issue.message}{issue.count > 1 ? ` · ${issue.count}×` : ""}
|
||||
{issue.location && <span className="muted"> — {issue.location}</span>}
|
||||
</li>)}</ul>
|
||||
</details>}
|
||||
</div>;
|
||||
const stat = (value: string | number, label: string) => (
|
||||
<div className="stat" key={label}><b>{value}</b><span>{label}</span></div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { api, uploadRaw, type GameDetail, type Version } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import { ErrorNote, Spinner } from "../ui/Bits";
|
||||
import { useUi } from "../ui/Ui";
|
||||
import "./mods.css";
|
||||
|
||||
type Obj = Record<string, any>;
|
||||
interface Document { game: Obj; scripts: Record<string, { file: string; labels: Obj[] }> }
|
||||
interface Editor { revision: string; baseRevision: string | null; document: Document;
|
||||
files: Record<string, { hash: string; size: number }>; editable: boolean; changes: string[];
|
||||
diff: { path: string; before: unknown; after: unknown }[] }
|
||||
interface Conflict { path: string; base: unknown; left: unknown; right: unknown; leftDeleted: boolean; rightDeleted: boolean }
|
||||
interface Unmatched { id: string; module: string; label: string; text: string; candidates: { id: string; text: string }[] }
|
||||
interface Preview { conflicts: Conflict[]; unmatched: Unmatched[]; baseRevision: string }
|
||||
|
||||
function uid() {
|
||||
return globalThis.crypto?.randomUUID?.() ?? "mod-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function makeNode(op: string): Obj {
|
||||
const id = uid();
|
||||
const fields: Record<string, Obj> = {
|
||||
say: { who: null, what: "Новая реплика", tid: "mod_" + id },
|
||||
jump: { target: "start" }, call: { target: "start" },
|
||||
scene: { img: null, at: [] }, show: { img: { name: ["bg", "room"] }, at: [] },
|
||||
hide: { tag: "bg" }, pause: { d: { k: "const", v: 1 } },
|
||||
if: { branches: [{ id: uid(), c: { k: "const", v: true }, body: [] }] },
|
||||
while: { c: { k: "const", v: false }, body: [] },
|
||||
menu: { items: [{ id: uid(), text: "Вариант", if: null, body: [] }] },
|
||||
exec: { code: [] }, play: { ch: "music", files: [{ k: "const", v: "audio/music.ogg" }], loop: true },
|
||||
stop: { ch: "music" }, window: { mode: "show" },
|
||||
};
|
||||
return { id, op, ...fields[op] };
|
||||
}
|
||||
|
||||
function JsonField({ value, onChange, label }: { value: any; onChange: (v: any) => void; label: string }) {
|
||||
const [text, setText] = useState(JSON.stringify(value ?? null, null, 2));
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => setText(JSON.stringify(value ?? null, null, 2)), [value]);
|
||||
return <label className="mod-field">{label}<textarea value={text} rows={Math.min(12, text.split("\n").length + 1)}
|
||||
aria-invalid={!!error} onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
try { const v = JSON.parse(e.target.value); setError(""); e.target.setCustomValidity(""); onChange(v); }
|
||||
catch { setError("Некорректный JSON"); e.target.setCustomValidity("Некорректный JSON"); }
|
||||
}} />{error && <span role="alert">{error}</span>}</label>;
|
||||
}
|
||||
|
||||
function Commands({ nodes, onChange, versionId }: { nodes: Obj[]; onChange: (n: Obj[]) => void; versionId: string }) {
|
||||
const ui = useUi();
|
||||
const [op, setOp] = useState("say");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const patch = (i: number, node: Obj) => onChange(nodes.map((n, k) => k === i ? node : n));
|
||||
const move = (i: number, delta: number) => {
|
||||
const next = [...nodes]; [next[i], next[i + delta]] = [next[i + delta], next[i]]; onChange(next);
|
||||
};
|
||||
return <div className="mod-commands">
|
||||
{nodes.map((n, i) => <details className="mod-command" key={n.id} open={expanded.has(n.id)} onToggle={(e) => {
|
||||
const open = e.currentTarget.open;
|
||||
setExpanded((previous) => {
|
||||
if (previous.has(n.id) === open) return previous;
|
||||
const next = new Set(previous); if (open) next.add(n.id); else next.delete(n.id); return next;
|
||||
});
|
||||
}}>
|
||||
<summary>{i + 1}. {n.op} {n.what ?? n.target ?? n.name ?? ""}</summary>
|
||||
{expanded.has(n.id) && <>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-sm" disabled={i === 0} onClick={() => move(i, -1)}>Выше</button>
|
||||
<button type="button" className="btn btn-sm" disabled={i === nodes.length - 1} onClick={() => move(i, 1)}>Ниже</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => { const next = [...nodes]; next.splice(i + 1, 0, makeNode(op)); onChange(next); }}>Вставить после</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={async () => {
|
||||
if (await ui.confirm({ title: "Удалить команду вместе с вложенными ветками?", danger: true })) onChange(nodes.filter((_, k) => i !== k));
|
||||
}}>Удалить</button>
|
||||
<Link className="btn btn-sm" target="_blank" to={`/play/${versionId}?at=${encodeURIComponent(n.id)}`}>Играть отсюда после сохранения</Link>
|
||||
</div>
|
||||
{n.op === "say" && <>
|
||||
<label className="mod-field">Персонаж<input value={n.who ?? ""} onChange={(e) => patch(i, { ...n, who: e.target.value || null })} /></label>
|
||||
<label className="mod-field">Реплика<textarea value={n.what} onChange={(e) => patch(i, { ...n, what: e.target.value })} /></label>
|
||||
</>}
|
||||
{typeof n.target === "string" && <label className="mod-field">Метка перехода<input value={n.target} onChange={(e) => patch(i, { ...n, target: e.target.value })} /></label>}
|
||||
{(n.op === "scene" || n.op === "show") && <label className="mod-field">Имя изображения<input value={n.img?.name?.join(" ") ?? ""}
|
||||
onChange={(e) => patch(i, { ...n, img: e.target.value ? { name: e.target.value.split(/\s+/) } : null })} /></label>}
|
||||
{n.op === "while" && <JsonField label="Условие (PGVN)" value={n.c} onChange={(c) => patch(i, { ...n, c })} />}
|
||||
{n.op === "while" && <Commands nodes={n.body} versionId={versionId} onChange={(body) => patch(i, { ...n, body })} />}
|
||||
{(n.op === "menu" || n.op === "if") && (() => {
|
||||
const key = n.op === "menu" ? "items" : "branches";
|
||||
const condKey = n.op === "menu" ? "if" : "c";
|
||||
return <div>{n[key].map((b: Obj, j: number) => <section className="mod-branch" key={b.id}>
|
||||
{n.op === "menu" && <label className="mod-field">Вариант выбора<input value={b.text} onChange={(e) => patch(i, { ...n, [key]: n[key].map((x: Obj, k: number) => k === j ? { ...b, text: e.target.value } : x) })} /></label>}
|
||||
<JsonField label="Условие (null — без условия)" value={b[condKey]} onChange={(c) => patch(i, { ...n, [key]: n[key].map((x: Obj, k: number) => k === j ? { ...b, [condKey]: c } : x) })} />
|
||||
<Commands nodes={b.body} versionId={versionId} onChange={(body) => patch(i, { ...n, [key]: n[key].map((x: Obj, k: number) => k === j ? { ...b, body } : x) })} />
|
||||
<button className="btn btn-sm btn-danger" type="button" onClick={async () => {
|
||||
if (await ui.confirm({ title: "Удалить ветку?", danger: true })) patch(i, { ...n, [key]: n[key].filter((_: Obj, k: number) => k !== j) });
|
||||
}}>Удалить ветку</button>
|
||||
</section>)}<button className="btn btn-sm" type="button" onClick={() => patch(i, { ...n, [key]: [...n[key], { id: uid(), [condKey]: null, body: [], ...(n.op === "menu" ? { text: "Вариант" } : {}) }] })}>Добавить ветку</button></div>;
|
||||
})()}
|
||||
<details><summary>Другие параметры команды (PGVN)</summary><JsonField label="Параметры" value={Object.fromEntries(Object.entries(n).filter(([k]) => !["id", "tid", "op", "body", "items", "branches"].includes(k)))}
|
||||
onChange={(fields) => { if (fields && !Array.isArray(fields) && typeof fields === "object") {
|
||||
const keep = Object.fromEntries(Object.entries(n).filter(([k]) => ["id", "tid", "op", "body", "items", "branches"].includes(k)));
|
||||
patch(i, { ...fields, ...keep });
|
||||
} }} /></details></>}
|
||||
</details>)}
|
||||
<div className="actions"><select aria-label="Тип новой команды" value={op} onChange={(e) => setOp(e.target.value)}>
|
||||
{["say", "scene", "show", "hide", "jump", "call", "return", "pause", "menu", "if", "while", "exec", "play", "stop", "window", "noop"].map((v) => <option key={v}>{v}</option>)}
|
||||
</select><button className="btn btn-sm" type="button" onClick={() => onChange([...nodes, makeNode(op)])}>Добавить команду</button></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function ModEditor() {
|
||||
const { versionId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const ui = useUi();
|
||||
const { can } = useAuth();
|
||||
const [data, setData] = useState<Editor | null>(null);
|
||||
const [version, setVersion] = useState<Version | null>(null);
|
||||
const [versions, setVersions] = useState<Version[]>([]);
|
||||
const [history, setHistory] = useState<{ id: string; created_at: string }[]>([]);
|
||||
const [module, setModule] = useState("");
|
||||
const [label, setLabel] = useState(0);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [target, setTarget] = useState("");
|
||||
const [mode, setMode] = useState("merge");
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [choices, setChoices] = useState<Record<string, string>>({});
|
||||
const [mapping, setMapping] = useState<Record<string, string>>({});
|
||||
const load = async () => {
|
||||
const [next, v, revisions] = await Promise.all([api.get<Editor>(`/api/versions/${versionId}/editor`), api.get<Version>(`/api/versions/${versionId}`), api.get<typeof history>(`/api/versions/${versionId}/revisions`)]);
|
||||
const game = await api.get<GameDetail>(`/api/games/${v.game_id}`);
|
||||
setData(next); setVersion(v); setHistory(revisions); setVersions(game.versions.filter((x) => x.id !== v.id && x.status === "ready"));
|
||||
setModule((old) => next.document.scripts[old] ? old : Object.keys(next.document.scripts).find((p) => next.document.scripts[p].labels.length) ?? next.document.game.scripts[0]); setLabel(0); setDirty(false); setPreview(null);
|
||||
setChoices({}); setMapping({}); setTarget(""); setError("");
|
||||
};
|
||||
useEffect(() => { setData(null); void load().catch((e) => setError(e.message)); }, [versionId]);
|
||||
useEffect(() => {
|
||||
const guard = (e: BeforeUnloadEvent) => { if (dirty) { e.preventDefault(); e.returnValue = ""; } };
|
||||
window.addEventListener("beforeunload", guard); return () => window.removeEventListener("beforeunload", guard);
|
||||
}, [dirty]);
|
||||
const run = async (fn: () => Promise<void>) => {
|
||||
setBusy(true); setError(""); try { await fn(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
if (!data || !version) return <div className="page">{error ? <ErrorNote>{error}</ErrorNote> : <Spinner />}</div>;
|
||||
const change = (document: Document) => { setData({ ...data, document }); setDirty(true); setPreview(null); };
|
||||
const mod = data.document.scripts[module];
|
||||
const selected = mod?.labels[label];
|
||||
const setLabelValue = (value: Obj) => change({ ...data.document, scripts: { ...data.document.scripts,
|
||||
[module]: { ...mod, labels: mod.labels.map((l, i) => i === label ? value : l) } } });
|
||||
const merge = async (apply: boolean) => {
|
||||
const t = versions.find((v) => v.id === Number(target));
|
||||
if (!t) throw new Error("Выберите целевую версию");
|
||||
const body = { target_id: t.id, source_revision: data.revision, target_revision: t.revision_id, mode, choices, mapping, apply,
|
||||
name: `${version.name} + ${t.name}`.slice(0, 255) };
|
||||
if (apply) {
|
||||
const result = await api.post<{ id: number }>(`/api/versions/${versionId}/merge`, body);
|
||||
navigate(`/versions/${result.id}/edit`);
|
||||
} else setPreview(await api.post<Preview>(`/api/versions/${versionId}/merge`, body));
|
||||
};
|
||||
return <div className="page mod-editor"><div className="section-title"><h1>{version.name}</h1>
|
||||
<Link className="btn" to={`/games/${version.game_id}`} onClick={(e) => { if (dirty && !window.confirm("Уйти без сохранения?")) e.preventDefault(); }}>К игре</Link>
|
||||
<Link className="btn" target="_blank" to={`/play/${versionId}`}>Предпросмотр сохранённой версии</Link>
|
||||
</div>{error && <ErrorNote>{error}</ErrorNote>}
|
||||
<p className="muted">{dirty ? "Есть несохранённые изменения" : "Изменения сохранены"}. Ревизия {data.revision.slice(0, 8)}. {data.baseRevision ? `База ${data.baseRevision.slice(0, 8)}` : "Историческая база неизвестна"}.</p>
|
||||
{!data.editable && <p>Для редактирования создайте свой черновик мода через кнопку «Форк».</p>}
|
||||
<form onSubmit={(e) => { e.preventDefault(); void run(async () => {
|
||||
const result = await api.put<{ revision: string; files: Editor["files"] }>(`/api/versions/${versionId}/editor`, { revision: data.revision, document: data.document });
|
||||
setData({ ...data, revision: result.revision, files: result.files }); setDirty(false); ui.toast("Мод сохранён", "ok");
|
||||
const [fresh, revisions] = await Promise.all([api.get<Editor>(`/api/versions/${versionId}/editor`), api.get<typeof history>(`/api/versions/${versionId}/revisions`)]);
|
||||
setData(fresh); setHistory(revisions);
|
||||
}); }}><fieldset disabled={busy || !data.editable}>
|
||||
<div className="mod-toolbar"><label>Модуль<select value={module} onChange={(e) => { setModule(e.target.value); setLabel(0); }}>{Object.keys(data.document.scripts).map((p) => <option key={p}>{p}</option>)}</select></label>
|
||||
<label>Метка<select value={label} onChange={(e) => setLabel(Number(e.target.value))}>{mod?.labels.map((l, i) => <option key={l.id} value={i}>{l.name}</option>)}</select></label>
|
||||
<button className="btn" type="button" onClick={async () => {
|
||||
const name = await ui.prompt({ title: "Новая метка", label: "Имя" });
|
||||
if (name) { change({ ...data.document, scripts: { ...data.document.scripts, [module]: { ...mod, labels: [...mod.labels, { id: uid(), name, body: [] }] } } }); setLabel(mod.labels.length); }
|
||||
}}>Добавить метку</button>
|
||||
</div>
|
||||
{selected && <><label className="mod-field">Имя метки<input value={selected.name} onChange={(e) => setLabelValue({ ...selected, name: e.target.value })} /></label>
|
||||
<Commands nodes={selected.body} versionId={versionId} onChange={(body) => setLabelValue({ ...selected, body })} />
|
||||
<button className="btn btn-danger" type="button" onClick={async () => {
|
||||
if (await ui.confirm({ title: "Удалить метку со всеми командами?", danger: true })) {
|
||||
change({ ...data.document, scripts: { ...data.document.scripts, [module]: { ...mod, labels: mod.labels.filter((_, i) => i !== label) } } }); setLabel(0);
|
||||
}
|
||||
}}>Удалить метку</button></>}
|
||||
<details><summary>Настройки игры (PGVN)</summary><JsonField label="game.json" value={data.document.game} onChange={(game) => { if (game && typeof game === "object" && !Array.isArray(game)) change({ ...data.document, game }); }} /></details>
|
||||
<button className="btn btn-primary" disabled={!dirty} type="submit">{busy ? "Сохранение…" : "Сохранить мод"}</button>
|
||||
</fieldset></form>
|
||||
<details><summary>Отличия от базы ({data.changes?.length ?? 0} файлов)</summary><ul>{data.changes?.map((p) => <li key={p}>{p}</li>)}</ul>
|
||||
{data.diff.map((d, i) => <details key={i}><summary>{d.path}</summary><div className="mod-conflict-values"><div>Было<pre>{JSON.stringify(d.before, null, 2)}</pre></div><div>Стало<pre>{JSON.stringify(d.after, null, 2)}</pre></div></div></details>)}
|
||||
<button className="btn btn-sm" disabled={dirty || busy} onClick={() => void run(load)}>Обновить отличия</button></details>
|
||||
<details><summary>Замена ассетов</summary><p>Заменяется файл только в этом моде. Сначала сохраните скрипт.</p>
|
||||
<div className="mod-assets">{Object.entries(data.files).filter(([p]) => !p.startsWith("script/") && !p.startsWith("locales/") && p !== "game.json").map(([path, f]) => <div key={path}>
|
||||
<a href={`/api/versions/${versionId}/files/${f.hash}`} target="_blank" rel="noreferrer">{path}</a>
|
||||
<input aria-label={`Заменить ${path}`} type="file" disabled={busy || dirty || !data.editable} onChange={(e) => {
|
||||
const file = e.target.files?.[0]; e.target.value = "";
|
||||
if (file) void run(async () => {
|
||||
const result = await uploadRaw<{ revision: string; files: Editor["files"] }>("PUT", `/api/versions/${versionId}/asset?${new URLSearchParams({ path, revision: data.revision, filename: file.name })}`, file, () => {});
|
||||
setData({ ...data, ...result }); ui.toast("Ассет заменён", "ok");
|
||||
const [fresh, revisions] = await Promise.all([api.get<Editor>(`/api/versions/${versionId}/editor`), api.get<typeof history>(`/api/versions/${versionId}/revisions`)]);
|
||||
setData(fresh); setHistory(revisions);
|
||||
});
|
||||
}} /></div>)}</div>
|
||||
</details>
|
||||
<details><summary>История и откат</summary>{history.map((h) => <div className="actions" key={h.id}><span>{new Date(h.created_at).toLocaleString()} · {h.id.slice(0, 8)}</span>
|
||||
<button className="btn btn-sm" disabled={busy || dirty || !data.editable || h.id === data.revision} onClick={() => void run(async () => {
|
||||
if (await ui.confirm({ title: "Восстановить эту ревизию?", text: "Текущее состояние останется в истории." })) { await api.post(`/api/versions/${versionId}/revisions/restore`, { revision: data.revision, restore: h.id }); await load(); }
|
||||
})}>Восстановить</button></div>)}</details>
|
||||
{can("versions.fork") && version.kind === "mod" && <section><h2>Слияние и перенос</h2><p>Результат создаётся отдельным черновиком. Исходные версии сохраняются.</p>
|
||||
<div className="mod-toolbar"><select aria-label="Операция" value={mode} onChange={(e) => { setMode(e.target.value); setPreview(null); setChoices({}); setMapping({}); }}><option value="merge">Объединить моды</option><option value="rebase">Перенести на новый оригинал</option></select>
|
||||
<select aria-label="Целевая версия" value={target} onChange={(e) => { setTarget(e.target.value); setPreview(null); setChoices({}); setMapping({}); }}><option value="">Выберите версию</option>{versions.map((v) => <option value={v.id} key={v.id}>{v.name}</option>)}</select>
|
||||
<button className="btn" disabled={busy || dirty || !target} onClick={() => void run(() => merge(false))}>Проверить совместимость</button></div>
|
||||
{preview && <div><p>База {preview.baseRevision.slice(0, 8)}. Конфликтов: {preview.conflicts.length}.</p>
|
||||
{preview.unmatched.length > 0 && <details open><summary>Не сопоставленные узлы ({preview.unmatched.length})</summary><p>Выберите соответствие для изменённых команд и повторите проверку. Без соответствия команда считается удалённой в новом оригинале; изменённые модом команды потребуют разрешения конфликта.</p>
|
||||
{preview.unmatched.map((n) => <label className="mod-field" key={n.id}>{n.module} / {n.label}: {n.text}<select value={mapping[n.id] ?? ""} onChange={(e) => { const next = { ...mapping }; if (e.target.value) next[n.id] = e.target.value; else delete next[n.id]; setMapping(next); setChoices({}); }}><option value="">Нет соответствия</option>{n.candidates.map((c) => <option value={c.id} key={c.id}>{c.text} · {c.id.slice(0, 8)}</option>)}</select></label>)}</details>}
|
||||
{preview.conflicts.map((c) => <section className="mod-conflict" key={c.path}><b>{c.path}</b><div className="mod-conflict-values">{(["base", "left", "right"] as const).map((side) => <div key={side}><p>{side === "base" ? "База" : side === "left" ? "Этот мод" : "Целевая версия"}</p><pre>{JSON.stringify(c[side], null, 2)}</pre></div>)}</div>
|
||||
<select aria-label={`Решение ${c.path}`} value={choices[c.path] ?? ""} onChange={(e) => setChoices({ ...choices, [c.path]: e.target.value })}><option value="">Выберите решение</option><option value="left">Этот мод{c.leftDeleted ? " (удалить)" : ""}</option><option value="right">Целевая версия{c.rightDeleted ? " (удалить)" : ""}</option></select></section>)}
|
||||
<button className="btn" disabled={busy || dirty} onClick={() => void run(() => merge(false))}>Повторить проверку с решениями</button>
|
||||
<button className="btn btn-primary" disabled={busy || dirty || preview.conflicts.some((c) => !choices[c.path])} onClick={() => void run(() => merge(true))}>Создать черновик результата</button>
|
||||
</div>}
|
||||
</section>}
|
||||
</div>;
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function NewGame() {
|
||||
return (
|
||||
<div className="page medium">
|
||||
<PageHeader title="Новая игра"
|
||||
subtitle="Загрузите архив с игрой на Ren'Py. Сервер распакует его, сконвертирует скрипты и ассеты в формат PGVN и удалит исходники." />
|
||||
subtitle="Загрузите архив игры. Сервер определит движок и проверит сценарии. Неподдерживаемые игры будут отклонены с объяснением причины." />
|
||||
<UploadProgress />
|
||||
<form className="card form" onSubmit={submit}>
|
||||
<label className={`dropzone ${over ? "over" : ""} ${file ? "has-file" : ""}`}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.mod-editor fieldset { border: 0; padding: 0; min-width: 0; }
|
||||
.mod-editor > .section-title { flex-wrap: wrap; }
|
||||
.mod-editor > .section-title h1 { flex: 1 1 100%; min-width: 0; overflow-wrap: anywhere; }
|
||||
.mod-editor > .section-title .btn { max-width: 100%; white-space: normal; height: auto; min-height: var(--control-h); }
|
||||
.mod-editor details { margin: 1rem 0; }
|
||||
.mod-editor summary { cursor: pointer; overflow-wrap: anywhere; }
|
||||
.mod-editor select, .mod-editor input, .mod-editor textarea { max-width: 100%; }
|
||||
.mod-field { display: grid; gap: .4rem; margin: .8rem 0; }
|
||||
.mod-field textarea { width: 100%; min-height: 4rem; font: inherit; }
|
||||
.mod-toolbar { display: flex; flex-wrap: wrap; gap: .8rem; align-items: end; margin: 1rem 0; }
|
||||
.mod-toolbar label { display: grid; gap: .4rem; }
|
||||
.mod-command, .mod-conflict { border: 1px solid var(--border); padding: .8rem; border-radius: .4rem; }
|
||||
.mod-command > summary { padding-bottom: .5rem; }
|
||||
.mod-branch { border-left: 2px solid var(--border); padding-left: .8rem; margin: 1rem 0; }
|
||||
.mod-assets > div { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .6rem; padding: .6rem 0; }
|
||||
.mod-conflict-values { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .8rem; }
|
||||
.mod-conflict pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 20rem; overflow: auto; }
|
||||
@media (max-width: 700px) { .mod-conflict-values { grid-template-columns: 1fr; } }
|
||||
Reference in new issue
Block a user