Initial TTRPG character meta-builder with visual sheet editor.
Offline Electron app seeds 10 system presets, supports create-from-scratch designer with palette/drag/resize/bricks, and exports .ttrpg-system.json for the player. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { CharacterSheet, SystemDefinition } from '../shared/types.ts';
|
||||
import { FORMAT_VERSION, SYSTEM_FORMAT, CHARACTER_FORMAT } from '../shared/types.ts';
|
||||
import { computeDerived } from '../shared/derived.ts';
|
||||
import { validateCreationRules } from '../shared/creation.ts';
|
||||
import { SheetRenderer } from './SheetRenderer.tsx';
|
||||
import { DesignerWorkspace } from './DesignerWorkspace.tsx';
|
||||
import { t, type Locale } from './i18n.ts';
|
||||
|
||||
type Tab = 'systems' | 'designer' | 'fill';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ttrpgBuilder: {
|
||||
listSystems: () => Promise<Array<{ id: string; name: string; revision: number; schemaVersion: number }>>;
|
||||
getSystem: (id: string) => Promise<SystemDefinition | null>;
|
||||
upsertSystem: (system: SystemDefinition) => Promise<SystemDefinition>;
|
||||
deleteSystem: (id: string) => Promise<boolean>;
|
||||
exportSystem: (id: string) => Promise<string | null>;
|
||||
importSystem: () => Promise<SystemDefinition | null>;
|
||||
listPresets: () => Promise<string[]>;
|
||||
loadPreset: (filePath: string) => Promise<SystemDefinition>;
|
||||
seedPresets: () => Promise<number>;
|
||||
listCharacters: (systemId?: string) => Promise<Array<{ id: string; name: string; systemId: string }>>;
|
||||
getCharacter: (id: string) => Promise<CharacterSheet | null>;
|
||||
upsertCharacter: (character: CharacterSheet) => Promise<CharacterSheet>;
|
||||
deleteCharacter: (id: string) => Promise<boolean>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function blankSystem(): SystemDefinition {
|
||||
return {
|
||||
format: SYSTEM_FORMAT,
|
||||
formatVersion: FORMAT_VERSION,
|
||||
id: `custom_${Date.now().toString(36)}`,
|
||||
name: 'Новая система',
|
||||
description: 'Система, созданная с нуля в визуальном редакторе.',
|
||||
edition: 'custom',
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
bricks: {
|
||||
attributes: [
|
||||
{ key: 'str', label: 'STR', scale: 'numeric', min: 3, max: 18, default: 10, category: 'physical' },
|
||||
{ key: 'dex', label: 'DEX', scale: 'numeric', min: 3, max: 18, default: 10, category: 'physical' },
|
||||
{ key: 'con', label: 'CON', scale: 'numeric', min: 3, max: 18, default: 10, category: 'physical' },
|
||||
{ key: 'int', label: 'INT', scale: 'numeric', min: 3, max: 18, default: 10, category: 'mental' },
|
||||
{ key: 'wis', label: 'WIS', scale: 'numeric', min: 3, max: 18, default: 10, category: 'mental' },
|
||||
{ key: 'cha', label: 'CHA', scale: 'numeric', min: 3, max: 18, default: 10, category: 'social' },
|
||||
],
|
||||
skills: [],
|
||||
resources: [{ key: 'hp', label: 'HP', min: 0, default: 10, track: true }],
|
||||
derived: [
|
||||
{
|
||||
key: 'strMod',
|
||||
label: 'STR Mod',
|
||||
formula: 'floor((attr.str - 10) / 2)',
|
||||
dependsOn: ['attr.str'],
|
||||
},
|
||||
],
|
||||
traits: [],
|
||||
},
|
||||
creationRules: [
|
||||
{
|
||||
id: 'pb',
|
||||
type: 'point_buy',
|
||||
appliesTo: 'attribute',
|
||||
params: { budget: 27, keys: ['str', 'dex', 'con', 'int', 'wis', 'cha'] },
|
||||
},
|
||||
],
|
||||
sheetLayout: {
|
||||
pageSize: 'fluid',
|
||||
grid: { size: 8 },
|
||||
pages: [
|
||||
{
|
||||
id: 'main',
|
||||
label: 'Main',
|
||||
widgets: [
|
||||
{
|
||||
id: 'sec',
|
||||
type: 'section',
|
||||
frame: { x: 16, y: 16, w: 240, h: 40 },
|
||||
props: { title: 'Attributes' },
|
||||
},
|
||||
{
|
||||
id: 'w-str',
|
||||
type: 'number',
|
||||
bind: 'attr.str',
|
||||
frame: { x: 16, y: 72, w: 100, h: 72 },
|
||||
props: { label: 'STR' },
|
||||
},
|
||||
{
|
||||
id: 'w-dex',
|
||||
type: 'number',
|
||||
bind: 'attr.dex',
|
||||
frame: { x: 128, y: 72, w: 100, h: 72 },
|
||||
props: { label: 'DEX' },
|
||||
},
|
||||
{
|
||||
id: 'w-mod',
|
||||
type: 'modBadge',
|
||||
bind: 'derived.strMod',
|
||||
frame: { x: 240, y: 72, w: 100, h: 72 },
|
||||
props: { label: 'STR Mod' },
|
||||
},
|
||||
{
|
||||
id: 'w-hp',
|
||||
type: 'track',
|
||||
bind: 'resource.hp',
|
||||
frame: { x: 16, y: 168, w: 240, h: 80 },
|
||||
props: { label: 'HP', max: 20 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defaultValues(system: SystemDefinition) {
|
||||
const attributes: Record<string, number | string | boolean> = {};
|
||||
for (const a of system.bricks.attributes ?? []) {
|
||||
attributes[a.key] = typeof a.default === 'number' ? a.default : 10;
|
||||
}
|
||||
const resources: Record<string, number> = {};
|
||||
for (const r of system.bricks.resources ?? []) {
|
||||
resources[r.key] = r.default ?? 0;
|
||||
}
|
||||
const skills: Record<string, number | string | boolean> = {};
|
||||
for (const s of system.bricks.skills ?? []) {
|
||||
skills[s.key] = typeof s.default === 'number' ? s.default : 0;
|
||||
}
|
||||
return { attributes, resources, skills, meta: {} as Record<string, unknown> };
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [locale, setLocale] = useState<Locale>('ru');
|
||||
const [tab, setTab] = useState<Tab>('systems');
|
||||
const [systems, setSystems] = useState<
|
||||
Array<{ id: string; name: string; revision: number; schemaVersion: number }>
|
||||
>([]);
|
||||
const [current, setCurrent] = useState<SystemDefinition | null>(null);
|
||||
const [characterName, setCharacterName] = useState('Герой');
|
||||
const [values, setValues] = useState(defaultValues(blankSystem()));
|
||||
const [status, setStatus] = useState('');
|
||||
const [booting, setBooting] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!window.ttrpgBuilder) return;
|
||||
setSystems(await window.ttrpgBuilder.listSystems());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
if (!window.ttrpgBuilder) {
|
||||
setBooting(false);
|
||||
return;
|
||||
}
|
||||
await window.ttrpgBuilder.seedPresets();
|
||||
await refresh();
|
||||
setBooting(false);
|
||||
setStatus(locale === 'ru' ? 'Пресеты готовы' : 'Presets ready');
|
||||
})();
|
||||
}, [refresh, locale]);
|
||||
|
||||
const validation = useMemo(() => {
|
||||
if (!current) return null;
|
||||
const strategy = current.creationRules[0]?.type;
|
||||
return validateCreationRules(current, values, strategy);
|
||||
}, [current, values]);
|
||||
|
||||
const derived = useMemo(() => {
|
||||
if (!current) return {};
|
||||
try {
|
||||
return computeDerived(current.bricks.derived ?? [], values);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [current, values]);
|
||||
|
||||
async function openSystem(id: string) {
|
||||
const s = await window.ttrpgBuilder.getSystem(id);
|
||||
if (!s) return;
|
||||
setCurrent(s);
|
||||
setValues(defaultValues(s));
|
||||
setTab('designer');
|
||||
setStatus(`${s.name}`);
|
||||
}
|
||||
|
||||
async function saveSystem() {
|
||||
if (!current) return;
|
||||
const saved = await window.ttrpgBuilder.upsertSystem(current);
|
||||
setCurrent(saved);
|
||||
setStatus(
|
||||
locale === 'ru'
|
||||
? `Сохранено: ${saved.name} (r${saved.revision})`
|
||||
: `Saved: ${saved.name} (r${saved.revision})`,
|
||||
);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function onChangeValue(path: string, value: unknown) {
|
||||
setValues((prev) => {
|
||||
const next = {
|
||||
...prev,
|
||||
attributes: { ...prev.attributes },
|
||||
skills: { ...prev.skills },
|
||||
resources: { ...prev.resources },
|
||||
meta: { ...prev.meta },
|
||||
};
|
||||
const [scope, key] = path.split('.');
|
||||
if (scope === 'attr' && key) next.attributes![key] = value as number | string | boolean;
|
||||
if (scope === 'skill' && key) next.skills![key] = value as number | string | boolean;
|
||||
if (scope === 'resource' && key) next.resources![key] = value as number;
|
||||
if (scope === 'meta' && key) (next.meta as Record<string, unknown>)[key] = value;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function saveCharacter() {
|
||||
if (!current) return;
|
||||
const sheet: CharacterSheet = {
|
||||
format: CHARACTER_FORMAT,
|
||||
formatVersion: FORMAT_VERSION,
|
||||
id: `char_${Date.now().toString(36)}`,
|
||||
name: characterName,
|
||||
systemId: current.id,
|
||||
schemaVersion: current.schemaVersion,
|
||||
revisionAtCreate: current.revision,
|
||||
values: { ...values, derived },
|
||||
creation: { strategy: current.creationRules[0]?.type ?? 'point_buy' },
|
||||
};
|
||||
if (validation && !validation.ok) {
|
||||
setStatus(t(locale, 'validationFail'));
|
||||
return;
|
||||
}
|
||||
await window.ttrpgBuilder.upsertCharacter(sheet);
|
||||
setStatus(
|
||||
locale === 'ru' ? `Персонаж сохранён: ${sheet.name}` : `Character saved: ${sheet.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
function createFromScratch() {
|
||||
const s = blankSystem();
|
||||
setCurrent(s);
|
||||
setValues(defaultValues(s));
|
||||
setTab('designer');
|
||||
setStatus(locale === 'ru' ? 'Новая система — сохраните, когда будете готовы' : 'New system — save when ready');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="appShell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand" data-testid="brand">
|
||||
<span>TTRPG</span> Builder
|
||||
</div>
|
||||
<div className="row">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="locale-ru"
|
||||
className={locale === 'ru' ? 'primary' : ''}
|
||||
onClick={() => setLocale('ru')}
|
||||
>
|
||||
RU
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="locale-en"
|
||||
className={locale === 'en' ? 'primary' : ''}
|
||||
onClick={() => setLocale('en')}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`navBtn${tab === 'systems' ? ' active' : ''}`}
|
||||
data-testid="tab-systems"
|
||||
onClick={() => setTab('systems')}
|
||||
>
|
||||
{t(locale, 'systems')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`navBtn${tab === 'designer' ? ' active' : ''}`}
|
||||
data-testid="tab-designer"
|
||||
onClick={() => setTab('designer')}
|
||||
disabled={!current}
|
||||
>
|
||||
{t(locale, 'designer')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`navBtn${tab === 'fill' ? ' active' : ''}`}
|
||||
data-testid="tab-fill"
|
||||
onClick={() => setTab('fill')}
|
||||
disabled={!current}
|
||||
>
|
||||
{t(locale, 'fill')}
|
||||
</button>
|
||||
<div className="muted statusLine" data-testid="status">
|
||||
{booting ? (locale === 'ru' ? 'Загрузка…' : 'Loading…') : status}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
{tab === 'systems' && (
|
||||
<div>
|
||||
<div className="heroPanel">
|
||||
<h1>{locale === 'ru' ? 'Системы и пресеты' : 'Systems & presets'}</h1>
|
||||
<p className="muted">
|
||||
{locale === 'ru'
|
||||
? 'Откройте любой из 10 пресетов в визуальном редакторе или создайте свою схему с нуля.'
|
||||
: 'Open any of the 10 presets in the visual editor, or create a schema from scratch.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="panel row">
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
data-testid="new-system"
|
||||
onClick={createFromScratch}
|
||||
>
|
||||
{t(locale, 'newSystem')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="import-system"
|
||||
onClick={async () => {
|
||||
const s = await window.ttrpgBuilder.importSystem();
|
||||
if (s) {
|
||||
setCurrent(s);
|
||||
setValues(defaultValues(s));
|
||||
await refresh();
|
||||
setTab('designer');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(locale, 'import')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="load-presets"
|
||||
onClick={async () => {
|
||||
const n = await window.ttrpgBuilder.seedPresets();
|
||||
const files = await window.ttrpgBuilder.listPresets();
|
||||
for (const f of files) await window.ttrpgBuilder.loadPreset(f);
|
||||
await refresh();
|
||||
setStatus(
|
||||
locale === 'ru'
|
||||
? `Пресеты обновлены (${files.length}, новых ${n})`
|
||||
: `Presets refreshed (${files.length}, new ${n})`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t(locale, 'loadPresets')}
|
||||
</button>
|
||||
<button type="button" onClick={() => void refresh()}>
|
||||
{t(locale, 'refresh')}
|
||||
</button>
|
||||
<span className="muted" data-testid="systems-count">
|
||||
{systems.length} / 10+
|
||||
</span>
|
||||
</div>
|
||||
<div className="panel systemsGrid">
|
||||
{booting && <div className="muted">{locale === 'ru' ? 'Загрузка…' : 'Loading…'}</div>}
|
||||
{!booting && systems.length === 0 && (
|
||||
<div className="muted">{t(locale, 'noSystem')}</div>
|
||||
)}
|
||||
{systems.map((s) => (
|
||||
<div key={s.id} className="systemCard" data-testid={`system-${s.id}`}>
|
||||
<div>
|
||||
<strong>{s.name}</strong>
|
||||
<div className="muted">
|
||||
{s.id} · r{s.revision}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button type="button" className="primary" onClick={() => void openSystem(s.id)}>
|
||||
{locale === 'ru' ? 'Редактор' : 'Editor'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await openSystem(s.id);
|
||||
setTab('fill');
|
||||
}}
|
||||
>
|
||||
{locale === 'ru' ? 'Лист' : 'Sheet'}
|
||||
</button>
|
||||
<button type="button" onClick={() => void window.ttrpgBuilder.exportSystem(s.id)}>
|
||||
{t(locale, 'export')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await window.ttrpgBuilder.deleteSystem(s.id);
|
||||
if (current?.id === s.id) setCurrent(null);
|
||||
await refresh();
|
||||
}}
|
||||
>
|
||||
{t(locale, 'delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'designer' && current && (
|
||||
<div>
|
||||
<div className="panel row stickyBar">
|
||||
<input
|
||||
data-testid="system-name"
|
||||
className="nameInput"
|
||||
value={current.name}
|
||||
onChange={(e) => setCurrent({ ...current, name: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
data-testid="save-system"
|
||||
onClick={() => void saveSystem()}
|
||||
>
|
||||
{t(locale, 'save')}
|
||||
</button>
|
||||
<button type="button" onClick={() => setTab('fill')}>
|
||||
{t(locale, 'fill')}
|
||||
</button>
|
||||
</div>
|
||||
<DesignerWorkspace
|
||||
system={current}
|
||||
locale={locale}
|
||||
onChange={(next) => {
|
||||
setCurrent(next);
|
||||
setValues(defaultValues(next));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'fill' && current && (
|
||||
<div>
|
||||
<div className="panel row stickyBar">
|
||||
<input
|
||||
data-testid="character-name"
|
||||
className="nameInput"
|
||||
value={characterName}
|
||||
onChange={(e) => setCharacterName(e.target.value)}
|
||||
placeholder={t(locale, 'characterName')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
data-testid="save-character"
|
||||
onClick={() => void saveCharacter()}
|
||||
>
|
||||
{t(locale, 'createCharacter')}
|
||||
</button>
|
||||
<button type="button" onClick={() => setTab('designer')}>
|
||||
{t(locale, 'designer')}
|
||||
</button>
|
||||
{validation && (
|
||||
<span
|
||||
className={validation.ok ? 'muted' : 'error'}
|
||||
data-testid="validation-status"
|
||||
>
|
||||
{validation.ok
|
||||
? t(locale, 'validationOk')
|
||||
: `${t(locale, 'validationFail')}: ${validation.errors[0]?.message}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="panel">
|
||||
<div className="muted">{t(locale, 'derived')}</div>
|
||||
<pre data-testid="derived-json">{JSON.stringify(derived, null, 2)}</pre>
|
||||
</div>
|
||||
<SheetRenderer
|
||||
system={current}
|
||||
values={values}
|
||||
mode="fill"
|
||||
onChangeValue={(path, value) => onChangeValue(path, value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
|
||||
import type { SheetWidget, SystemDefinition, WidgetType } from '../shared/types.ts';
|
||||
import { WidgetPreview } from './WidgetPreview.tsx';
|
||||
|
||||
const PALETTE: Array<{ type: WidgetType; label: string; defaultBind?: string; w: number; h: number }> = [
|
||||
{ type: 'section', label: 'Section', w: 220, h: 40 },
|
||||
{ type: 'number', label: 'Number', defaultBind: 'attr.str', w: 110, h: 72 },
|
||||
{ type: 'modBadge', label: 'Mod badge', defaultBind: 'derived.strMod', w: 110, h: 72 },
|
||||
{ type: 'dots', label: 'Dots', defaultBind: 'attr.str', w: 160, h: 72 },
|
||||
{ type: 'track', label: 'Track / HP', defaultBind: 'resource.hp', w: 220, h: 80 },
|
||||
{ type: 'skillList', label: 'Skill list', w: 280, h: 240 },
|
||||
{ type: 'notes', label: 'Notes', defaultBind: 'meta.notes', w: 280, h: 140 },
|
||||
{ type: 'locationBar', label: 'Armor zones', w: 360, h: 90 },
|
||||
{ type: 'text', label: 'Text', defaultBind: 'meta.title', w: 200, h: 60 },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
system: SystemDefinition;
|
||||
onChange: (next: SystemDefinition) => void;
|
||||
locale: 'ru' | 'en';
|
||||
};
|
||||
|
||||
function snap(n: number, grid = 8): number {
|
||||
return Math.round(n / grid) * grid;
|
||||
}
|
||||
|
||||
export function DesignerWorkspace({ system, onChange, locale }: Props) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const dragRef = useRef<{
|
||||
id: string;
|
||||
mode: 'move' | 'resize';
|
||||
startX: number;
|
||||
startY: number;
|
||||
orig: { x: number; y: number; w: number; h: number };
|
||||
} | null>(null);
|
||||
|
||||
const page = system.sheetLayout.pages[pageIndex] ?? system.sheetLayout.pages[0];
|
||||
const selected = page?.widgets.find((w) => w.id === selectedId) ?? null;
|
||||
|
||||
function updateWidgets(mutator: (widgets: SheetWidget[]) => SheetWidget[]) {
|
||||
if (!page) return;
|
||||
const pages = system.sheetLayout.pages.map((p, i) =>
|
||||
i === pageIndex ? { ...p, widgets: mutator(p.widgets) } : p,
|
||||
);
|
||||
onChange({ ...system, sheetLayout: { ...system.sheetLayout, pages } });
|
||||
}
|
||||
|
||||
function addWidget(item: (typeof PALETTE)[number]) {
|
||||
if (!page) return;
|
||||
const id = `w_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const widget: SheetWidget = {
|
||||
id,
|
||||
type: item.type,
|
||||
bind: item.defaultBind,
|
||||
frame: {
|
||||
x: snap(24 + (page.widgets.length % 5) * 16),
|
||||
y: snap(24 + (page.widgets.length % 7) * 16),
|
||||
w: item.w,
|
||||
h: item.h,
|
||||
},
|
||||
props: {
|
||||
label: item.label,
|
||||
title: item.label,
|
||||
max: item.type === 'dots' ? 5 : 20,
|
||||
},
|
||||
};
|
||||
updateWidgets((ws) => [...ws, widget]);
|
||||
setSelectedId(id);
|
||||
}
|
||||
|
||||
function onPointerDownWidget(e: ReactPointerEvent, id: string, mode: 'move' | 'resize') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const w = page?.widgets.find((x) => x.id === id);
|
||||
if (!w) return;
|
||||
setSelectedId(id);
|
||||
dragRef.current = {
|
||||
id,
|
||||
mode,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
orig: { ...w.frame },
|
||||
};
|
||||
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
}
|
||||
|
||||
function onPointerMove(e: ReactPointerEvent) {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
const dx = e.clientX - d.startX;
|
||||
const dy = e.clientY - d.startY;
|
||||
updateWidgets((ws) =>
|
||||
ws.map((w) => {
|
||||
if (w.id !== d.id) return w;
|
||||
if (d.mode === 'move') {
|
||||
return {
|
||||
...w,
|
||||
frame: {
|
||||
...w.frame,
|
||||
x: snap(Math.max(0, d.orig.x + dx)),
|
||||
y: snap(Math.max(0, d.orig.y + dy)),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...w,
|
||||
frame: {
|
||||
...w.frame,
|
||||
w: snap(Math.max(48, d.orig.w + dx)),
|
||||
h: snap(Math.max(36, d.orig.h + dy)),
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragRef.current = null;
|
||||
}
|
||||
|
||||
function patchSelected(patch: Partial<SheetWidget> & { props?: Record<string, unknown> }) {
|
||||
if (!selected) return;
|
||||
updateWidgets((ws) =>
|
||||
ws.map((w) =>
|
||||
w.id === selected.id
|
||||
? {
|
||||
...w,
|
||||
...patch,
|
||||
props: { ...w.props, ...patch.props },
|
||||
}
|
||||
: w,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function removeSelected() {
|
||||
if (!selected) return;
|
||||
updateWidgets((ws) => ws.filter((w) => w.id !== selected.id));
|
||||
setSelectedId(null);
|
||||
}
|
||||
|
||||
function addBrick(kind: 'attributes' | 'skills' | 'resources' | 'derived') {
|
||||
const key = `new_${Date.now().toString(36).slice(-4)}`;
|
||||
const bricks = { ...system.bricks };
|
||||
if (kind === 'attributes') {
|
||||
bricks.attributes = [
|
||||
...(bricks.attributes ?? []),
|
||||
{ key, label: key.toUpperCase(), scale: 'numeric', min: 1, max: 20, default: 10 },
|
||||
];
|
||||
} else if (kind === 'skills') {
|
||||
bricks.skills = [
|
||||
...(bricks.skills ?? []),
|
||||
{ key, label: key, scale: 'numeric', default: 0 },
|
||||
];
|
||||
} else if (kind === 'resources') {
|
||||
bricks.resources = [
|
||||
...(bricks.resources ?? []),
|
||||
{ key, label: key, min: 0, default: 0, track: true },
|
||||
];
|
||||
} else {
|
||||
bricks.derived = [
|
||||
...(bricks.derived ?? []),
|
||||
{
|
||||
key,
|
||||
label: key,
|
||||
formula: 'floor((attr.str - 10) / 2)',
|
||||
dependsOn: ['attr.str'],
|
||||
},
|
||||
];
|
||||
}
|
||||
onChange({ ...system, bricks });
|
||||
}
|
||||
|
||||
function removeBrick(kind: 'attributes' | 'skills' | 'resources' | 'derived', key: string) {
|
||||
const bricks = { ...system.bricks };
|
||||
if (kind === 'attributes') bricks.attributes = (bricks.attributes ?? []).filter((a) => a.key !== key);
|
||||
if (kind === 'skills') bricks.skills = (bricks.skills ?? []).filter((a) => a.key !== key);
|
||||
if (kind === 'resources') bricks.resources = (bricks.resources ?? []).filter((a) => a.key !== key);
|
||||
if (kind === 'derived') bricks.derived = (bricks.derived ?? []).filter((a) => a.key !== key);
|
||||
onChange({ ...system, bricks });
|
||||
}
|
||||
|
||||
const bindOptions: string[] = [];
|
||||
for (const a of system.bricks.attributes ?? []) bindOptions.push(`attr.${a.key}`);
|
||||
for (const s of system.bricks.skills ?? []) bindOptions.push(`skill.${s.key}`);
|
||||
for (const r of system.bricks.resources ?? []) bindOptions.push(`resource.${r.key}`);
|
||||
for (const d of system.bricks.derived ?? []) bindOptions.push(`derived.${d.key}`);
|
||||
bindOptions.push('meta.notes', 'meta.title');
|
||||
|
||||
return (
|
||||
<div className="designerGrid" data-testid="designer-workspace">
|
||||
<div className="panel designerSide">
|
||||
<div className="sideTitle">{locale === 'ru' ? 'Палитра' : 'Palette'}</div>
|
||||
<div className="palette">
|
||||
{PALETTE.map((item) => (
|
||||
<button
|
||||
key={item.type + item.label}
|
||||
type="button"
|
||||
className="paletteItem"
|
||||
data-testid={`palette-${item.type}`}
|
||||
onClick={() => addWidget(item)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sideTitle">{locale === 'ru' ? 'Кирпичики' : 'Bricks'}</div>
|
||||
<div className="row wrap">
|
||||
<button type="button" onClick={() => addBrick('attributes')}>+ Attr</button>
|
||||
<button type="button" onClick={() => addBrick('skills')}>+ Skill</button>
|
||||
<button type="button" onClick={() => addBrick('resources')}>+ Resource</button>
|
||||
<button type="button" onClick={() => addBrick('derived')}>+ Derived</button>
|
||||
</div>
|
||||
<div className="brickList">
|
||||
{(system.bricks.attributes ?? []).map((a) => (
|
||||
<div key={a.key} className="brickRow">
|
||||
<code>attr.{a.key}</code>
|
||||
<button type="button" onClick={() => removeBrick('attributes', a.key)}>×</button>
|
||||
</div>
|
||||
))}
|
||||
{(system.bricks.skills ?? []).map((a) => (
|
||||
<div key={a.key} className="brickRow">
|
||||
<code>skill.{a.key}</code>
|
||||
<button type="button" onClick={() => removeBrick('skills', a.key)}>×</button>
|
||||
</div>
|
||||
))}
|
||||
{(system.bricks.resources ?? []).map((a) => (
|
||||
<div key={a.key} className="brickRow">
|
||||
<code>resource.{a.key}</code>
|
||||
<button type="button" onClick={() => removeBrick('resources', a.key)}>×</button>
|
||||
</div>
|
||||
))}
|
||||
{(system.bricks.derived ?? []).map((a) => (
|
||||
<div key={a.key} className="brickRow">
|
||||
<code>derived.{a.key}</code>
|
||||
<button type="button" onClick={() => removeBrick('derived', a.key)}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="designerCanvasHost"
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onClick={() => setSelectedId(null)}
|
||||
>
|
||||
<div className="row" style={{ marginBottom: 8 }}>
|
||||
{system.sheetLayout.pages.map((p, i) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={i === pageIndex ? 'primary' : ''}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPageIndex(i);
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const id = `page_${Date.now().toString(36)}`;
|
||||
onChange({
|
||||
...system,
|
||||
sheetLayout: {
|
||||
...system.sheetLayout,
|
||||
pages: [
|
||||
...system.sheetLayout.pages,
|
||||
{ id, label: `Page ${system.sheetLayout.pages.length + 1}`, widgets: [] },
|
||||
],
|
||||
},
|
||||
});
|
||||
setPageIndex(system.sheetLayout.pages.length);
|
||||
}}
|
||||
>
|
||||
+ Page
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sheetCanvas designMode" data-testid="sheet-canvas">
|
||||
{(page?.widgets ?? []).map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
className={`widget${selectedId === w.id ? ' selected' : ''}`}
|
||||
data-testid={`widget-${w.id}`}
|
||||
style={{ left: w.frame.x, top: w.frame.y, width: w.frame.w, height: w.frame.h }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedId(w.id);
|
||||
}}
|
||||
onPointerDown={(e) => onPointerDownWidget(e, w.id, 'move')}
|
||||
>
|
||||
<div className="widgetChrome">{w.type}{w.bind ? ` · ${w.bind}` : ''}</div>
|
||||
<div className="widgetBody">
|
||||
<WidgetPreview widget={w} system={system} />
|
||||
</div>
|
||||
<div
|
||||
className="resizeHandle"
|
||||
onPointerDown={(e) => onPointerDownWidget(e, w.id, 'resize')}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel designerSide">
|
||||
<div className="sideTitle">{locale === 'ru' ? 'Свойства' : 'Properties'}</div>
|
||||
{!selected ? (
|
||||
<div className="muted">{locale === 'ru' ? 'Выберите виджет' : 'Select a widget'}</div>
|
||||
) : (
|
||||
<div className="propsForm">
|
||||
<label>
|
||||
Type
|
||||
<input value={selected.type} disabled />
|
||||
</label>
|
||||
<label>
|
||||
Bind
|
||||
<select
|
||||
data-testid="widget-bind"
|
||||
value={selected.bind ?? ''}
|
||||
onChange={(e) => patchSelected({ bind: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{bindOptions.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Label
|
||||
<input
|
||||
value={String(selected.props?.label ?? selected.props?.title ?? '')}
|
||||
onChange={(e) => patchSelected({ props: { label: e.target.value, title: e.target.value } })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Max
|
||||
<input
|
||||
type="number"
|
||||
value={Number(selected.props?.max ?? 0)}
|
||||
onChange={(e) => patchSelected({ props: { max: Number(e.target.value) } })}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" data-testid="remove-widget" onClick={removeSelected}>
|
||||
{locale === 'ru' ? 'Удалить виджет' : 'Remove widget'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sideTitle" style={{ marginTop: 16 }}>
|
||||
System
|
||||
</div>
|
||||
<label className="propsForm">
|
||||
ID
|
||||
<input
|
||||
data-testid="system-id"
|
||||
value={system.id}
|
||||
onChange={(e) => onChange({ ...system, id: e.target.value.trim() || system.id })}
|
||||
/>
|
||||
</label>
|
||||
<label className="propsForm">
|
||||
Edition
|
||||
<input
|
||||
value={system.edition ?? ''}
|
||||
onChange={(e) => onChange({ ...system, edition: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="propsForm">
|
||||
Description
|
||||
<textarea
|
||||
rows={4}
|
||||
value={system.description ?? ''}
|
||||
onChange={(e) => onChange({ ...system, description: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import type { SystemDefinition, CharacterValues, SheetWidget } from '../shared/types.ts';
|
||||
import { computeDerived } from '../shared/derived.ts';
|
||||
|
||||
type Props = {
|
||||
system: SystemDefinition;
|
||||
values: CharacterValues;
|
||||
mode: 'design' | 'fill' | 'preview';
|
||||
selectedWidgetId?: string | null;
|
||||
onSelectWidget?: (id: string) => void;
|
||||
onChangeValue?: (path: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
function widgetContent(
|
||||
widget: SheetWidget,
|
||||
system: SystemDefinition,
|
||||
values: CharacterValues,
|
||||
mode: Props['mode'],
|
||||
onChangeValue?: Props['onChangeValue'],
|
||||
) {
|
||||
const bind = widget.bind ?? '';
|
||||
const [scope, key] = bind.split('.');
|
||||
const editable = mode === 'fill';
|
||||
|
||||
if (widget.type === 'section') {
|
||||
return <strong>{String(widget.props?.title ?? widget.id)}</strong>;
|
||||
}
|
||||
|
||||
if (widget.type === 'modBadge' || widget.type === 'number' || bind.startsWith('derived.')) {
|
||||
const derived = values.derived ?? {};
|
||||
let display: string | number = '—';
|
||||
if (scope === 'attr') display = (values.attributes?.[key!] as number) ?? '—';
|
||||
if (scope === 'resource') display = values.resources?.[key!] ?? '—';
|
||||
if (scope === 'derived') display = derived[key!] ?? '—';
|
||||
if (editable && scope === 'attr' && typeof display === 'number') {
|
||||
return (
|
||||
<label>
|
||||
<div className="muted">{key}</div>
|
||||
<input
|
||||
data-testid={`field-${bind}`}
|
||||
type="number"
|
||||
value={display}
|
||||
onChange={(e) => onChangeValue?.(bind, Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div data-testid={`field-${bind}`}>
|
||||
<div className="muted">{String(widget.props?.label ?? key ?? widget.type)}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700 }}>{display}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.type === 'dots') {
|
||||
const max = Number(widget.props?.max ?? 5);
|
||||
const current =
|
||||
scope === 'attr'
|
||||
? Number(values.attributes?.[key!] ?? 0)
|
||||
: scope === 'skill'
|
||||
? Number(values.skills?.[key!] ?? 0)
|
||||
: 0;
|
||||
return (
|
||||
<div>
|
||||
<div className="muted">{String(widget.props?.label ?? key)}</div>
|
||||
<div className="row" data-testid={`field-${bind}`}>
|
||||
{Array.from({ length: max }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={!editable}
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: '50%',
|
||||
padding: 0,
|
||||
background: i < current ? 'var(--accent2)' : 'transparent',
|
||||
}}
|
||||
onClick={() => onChangeValue?.(bind, i + 1 === current ? i : i + 1)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.type === 'track' || widget.type === 'resourceBar') {
|
||||
const v = values.resources?.[key!] ?? 0;
|
||||
const max = Number(widget.props?.max ?? 10);
|
||||
return (
|
||||
<label>
|
||||
<div className="muted">{String(widget.props?.label ?? key)}</div>
|
||||
<input
|
||||
data-testid={`field-${bind}`}
|
||||
type="number"
|
||||
disabled={!editable}
|
||||
min={0}
|
||||
max={max}
|
||||
value={v}
|
||||
onChange={(e) => onChangeValue?.(bind, Number(e.target.value))}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 6,
|
||||
height: 8,
|
||||
background: 'var(--stroke)',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.min(100, (v / max) * 100)}%`,
|
||||
height: '100%',
|
||||
background: 'var(--accent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.type === 'skillList') {
|
||||
const skills = system.bricks.skills ?? [];
|
||||
return (
|
||||
<div data-testid="skill-list">
|
||||
{skills.map((sk) => (
|
||||
<div key={sk.key} className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<span>{sk.label}</span>
|
||||
<input
|
||||
data-testid={`field-skill.${sk.key}`}
|
||||
type="number"
|
||||
disabled={!editable}
|
||||
style={{ width: 72 }}
|
||||
value={Number(values.skills?.[sk.key] ?? sk.default ?? 0)}
|
||||
onChange={(e) => onChangeValue?.(`skill.${sk.key}`, Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.type === 'notes' || widget.type === 'text') {
|
||||
const metaKey = key || 'notes';
|
||||
const text = String(values.meta?.[metaKey] ?? '');
|
||||
return (
|
||||
<textarea
|
||||
data-testid={`field-meta.${metaKey}`}
|
||||
disabled={!editable}
|
||||
value={text}
|
||||
style={{ width: '100%', height: '100%', resize: 'none' }}
|
||||
onChange={(e) => onChangeValue?.(`meta.${metaKey}`, e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.type === 'locationBar') {
|
||||
const zones = (widget.props?.zones as string[]) ?? ['head', 'body', 'arms', 'legs'];
|
||||
return (
|
||||
<div className="row" data-testid="location-bar">
|
||||
{zones.map((z) => (
|
||||
<label key={z}>
|
||||
<div className="muted">{z}</div>
|
||||
<input
|
||||
type="number"
|
||||
disabled={!editable}
|
||||
style={{ width: 64 }}
|
||||
value={Number((values.meta?.armor as Record<string, number> | undefined)?.[z] ?? 0)}
|
||||
onChange={(e) => {
|
||||
const armor = {
|
||||
...((values.meta?.armor as Record<string, number>) ?? {}),
|
||||
[z]: Number(e.target.value),
|
||||
};
|
||||
onChangeValue?.('meta.armor', armor);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="muted">{widget.type}</div>;
|
||||
}
|
||||
|
||||
export function SheetRenderer({
|
||||
system,
|
||||
values,
|
||||
mode,
|
||||
selectedWidgetId,
|
||||
onSelectWidget,
|
||||
onChangeValue,
|
||||
}: Props) {
|
||||
const page = system.sheetLayout.pages[0];
|
||||
const derived = computeDerived(system.bricks.derived ?? [], values);
|
||||
const merged: CharacterValues = { ...values, derived };
|
||||
|
||||
if (!page) return <div className="muted">No pages</div>;
|
||||
|
||||
return (
|
||||
<div className="sheetCanvas" data-testid="sheet-canvas">
|
||||
{page.widgets.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
className={`widget${selectedWidgetId === w.id ? ' selected' : ''}`}
|
||||
data-testid={`widget-${w.id}`}
|
||||
style={{ left: w.frame.x, top: w.frame.y, width: w.frame.w, height: w.frame.h }}
|
||||
onClick={() => onSelectWidget?.(w.id)}
|
||||
>
|
||||
{widgetContent(w, system, merged, mode, onChangeValue)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { CharacterValues, SheetWidget, SystemDefinition } from '../shared/types.ts';
|
||||
import { computeDerived } from '../shared/derived.ts';
|
||||
|
||||
type Props = {
|
||||
widget: SheetWidget;
|
||||
system: SystemDefinition;
|
||||
};
|
||||
|
||||
/** Compact in-widget preview for design mode (no nested canvas). */
|
||||
export function WidgetPreview({ widget, system }: Props) {
|
||||
const values: CharacterValues = {
|
||||
attributes: Object.fromEntries(
|
||||
(system.bricks.attributes ?? []).map((a) => [a.key, typeof a.default === 'number' ? a.default : 10]),
|
||||
),
|
||||
skills: Object.fromEntries(
|
||||
(system.bricks.skills ?? []).map((a) => [a.key, typeof a.default === 'number' ? a.default : 0]),
|
||||
),
|
||||
resources: Object.fromEntries((system.bricks.resources ?? []).map((a) => [a.key, a.default ?? 0])),
|
||||
meta: { notes: '…', title: system.name },
|
||||
};
|
||||
const derived = computeDerived(system.bricks.derived ?? [], values);
|
||||
const bind = widget.bind ?? '';
|
||||
const [, key] = bind.split('.');
|
||||
|
||||
if (widget.type === 'section') {
|
||||
return <strong>{String(widget.props?.title ?? 'Section')}</strong>;
|
||||
}
|
||||
if (widget.type === 'dots') {
|
||||
const max = Number(widget.props?.max ?? 5);
|
||||
const cur = Number(values.attributes?.[key!] ?? 2);
|
||||
return (
|
||||
<div>
|
||||
<div className="muted">{String(widget.props?.label ?? key)}</div>
|
||||
<div className="row">
|
||||
{Array.from({ length: max }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
border: '1px solid var(--stroke2)',
|
||||
background: i < cur ? 'var(--accent2)' : 'transparent',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (widget.type === 'track' || widget.type === 'resourceBar') {
|
||||
const v = Number(values.resources?.[key!] ?? 5);
|
||||
const max = Number(widget.props?.max ?? 10);
|
||||
return (
|
||||
<div>
|
||||
<div className="muted">{String(widget.props?.label ?? key)}</div>
|
||||
<div style={{ height: 8, background: 'var(--stroke)', borderRadius: 4 }}>
|
||||
<div style={{ width: `${(v / max) * 100}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (widget.type === 'skillList') {
|
||||
return (
|
||||
<div className="muted">
|
||||
{(system.bricks.skills ?? []).slice(0, 4).map((s) => (
|
||||
<div key={s.key}>
|
||||
{s.label}: {String(values.skills?.[s.key] ?? 0)}
|
||||
</div>
|
||||
))}
|
||||
…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (widget.type === 'notes' || widget.type === 'text') {
|
||||
return <div className="muted">{String(values.meta?.notes ?? 'Notes')}</div>;
|
||||
}
|
||||
if (bind.startsWith('derived.')) {
|
||||
return (
|
||||
<div>
|
||||
<div className="muted">{String(widget.props?.label ?? key)}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700 }}>{derived[key!] ?? 0}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (bind.startsWith('attr.')) {
|
||||
return (
|
||||
<div>
|
||||
<div className="muted">{String(widget.props?.label ?? key)}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700 }}>{String(values.attributes?.[key!] ?? 10)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="muted">{widget.type}</div>;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export type Locale = 'ru' | 'en';
|
||||
|
||||
const messages = {
|
||||
en: {
|
||||
brand: 'TTRPG Character Builder',
|
||||
systems: 'Systems',
|
||||
characters: 'Characters',
|
||||
designer: 'Sheet designer',
|
||||
fill: 'Fill character',
|
||||
import: 'Import',
|
||||
export: 'Export',
|
||||
loadPresets: 'Load presets',
|
||||
newSystem: 'New system',
|
||||
save: 'Save',
|
||||
delete: 'Delete',
|
||||
refresh: 'Refresh',
|
||||
noSystem: 'Select or create a system',
|
||||
attributes: 'Attributes',
|
||||
derived: 'Derived',
|
||||
validationOk: 'Validation passed',
|
||||
validationFail: 'Validation failed',
|
||||
createCharacter: 'Create character',
|
||||
characterName: 'Character name',
|
||||
},
|
||||
ru: {
|
||||
brand: 'Конструктор персонажей НРИ',
|
||||
systems: 'Системы',
|
||||
characters: 'Персонажи',
|
||||
designer: 'Дизайнер листа',
|
||||
fill: 'Заполнение',
|
||||
import: 'Импорт',
|
||||
export: 'Экспорт',
|
||||
loadPresets: 'Загрузить пресеты',
|
||||
newSystem: 'Новая система',
|
||||
save: 'Сохранить',
|
||||
delete: 'Удалить',
|
||||
refresh: 'Обновить',
|
||||
noSystem: 'Выберите или создайте систему',
|
||||
attributes: 'Атрибуты',
|
||||
derived: 'Производные',
|
||||
validationOk: 'Валидация пройдена',
|
||||
validationFail: 'Ошибки валидации',
|
||||
createCharacter: 'Создать персонажа',
|
||||
characterName: 'Имя персонажа',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type MessageKey = keyof typeof messages.en;
|
||||
|
||||
export function t(locale: Locale, key: MessageKey): string {
|
||||
return messages[locale][key] ?? messages.en[key];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TTRPG Character Builder</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.tsx';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
@@ -0,0 +1,188 @@
|
||||
:root {
|
||||
--bg0: #09090b;
|
||||
--color-panel: rgba(255, 255, 255, 0.04);
|
||||
--color-panel-2: rgba(255, 255, 255, 0.06);
|
||||
--stroke: rgba(255, 255, 255, 0.08);
|
||||
--stroke2: rgba(255, 255, 255, 0.12);
|
||||
--text0: rgba(255, 255, 255, 0.92);
|
||||
--text1: rgba(255, 255, 255, 0.72);
|
||||
--text2: rgba(255, 255, 255, 0.52);
|
||||
--accent: #7c3aed;
|
||||
--accent2: #a78bfa;
|
||||
--accent-border: rgba(124, 58, 237, 0.55);
|
||||
--accent-fill-solid: rgba(124, 58, 237, 0.92);
|
||||
--accent-fill-soft: rgba(124, 58, 237, 0.1);
|
||||
--color-danger: rgba(248, 113, 113, 0.95);
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
|
||||
font-family: 'Segoe UI', 'IBM Plex Sans', system-ui, sans-serif;
|
||||
color: var(--text0);
|
||||
background: var(--bg0);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { margin: 0; height: 100%; }
|
||||
|
||||
button {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-panel);
|
||||
color: var(--text0);
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary {
|
||||
border: 1px solid var(--accent-border);
|
||||
background: var(--accent-fill-solid);
|
||||
}
|
||||
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
input, select, textarea {
|
||||
background: var(--color-panel-2);
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text0);
|
||||
padding: 8px 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.appShell {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
height: 100%;
|
||||
}
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--stroke);
|
||||
background: #18181b;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.brand { font-size: 18px; font-weight: 700; }
|
||||
.brand span { color: var(--accent2); }
|
||||
.navBtn { text-align: left; width: 100%; }
|
||||
.navBtn.active {
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-fill-soft);
|
||||
}
|
||||
.statusLine { margin-top: auto; font-size: 12px; min-height: 2.5em; }
|
||||
.main { padding: 20px; overflow: auto; }
|
||||
|
||||
.panel {
|
||||
background: var(--color-panel);
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.heroPanel {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.heroPanel h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.row.wrap { flex-wrap: wrap; }
|
||||
.muted { color: var(--text2); }
|
||||
.error { color: var(--color-danger); font-size: 13px; }
|
||||
|
||||
.systemsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.systemCard {
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-panel-2);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.nameInput { max-width: 360px; }
|
||||
.stickyBar { position: sticky; top: 0; z-index: 5; backdrop-filter: blur(8px); }
|
||||
|
||||
.sheetCanvas {
|
||||
position: relative;
|
||||
min-height: 720px;
|
||||
background:
|
||||
linear-gradient(var(--stroke) 1px, transparent 1px) 0 0 / 16px 16px,
|
||||
linear-gradient(90deg, var(--stroke) 1px, transparent 1px) 0 0 / 16px 16px,
|
||||
#0c0c10;
|
||||
border: 1px solid var(--stroke2);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.widget {
|
||||
position: absolute;
|
||||
border: 1px solid var(--stroke2);
|
||||
background: rgba(10, 10, 14, 0.94);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
.widget.selected {
|
||||
border-color: var(--accent2);
|
||||
box-shadow: 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
.widgetChrome {
|
||||
font-size: 10px;
|
||||
color: var(--text2);
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.widgetBody { pointer-events: none; }
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent-fill-soft);
|
||||
border: 1px solid var(--accent-border);
|
||||
cursor: nwse-resize;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.designerGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr 240px;
|
||||
gap: 12px;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
.designerSide {
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow: auto;
|
||||
}
|
||||
.sideTitle {
|
||||
font-weight: 700;
|
||||
margin: 8px 0;
|
||||
color: var(--text1);
|
||||
}
|
||||
.palette { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.paletteItem { width: 100%; text-align: left; }
|
||||
.brickList { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.brickRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.brickRow button { height: 24px; padding: 0 8px; }
|
||||
.propsForm { display: flex; flex-direction: column; gap: 10px; }
|
||||
.propsForm label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text1); }
|
||||
.designerCanvasHost { min-width: 0; }
|
||||
Reference in New Issue
Block a user