@aeratic/pagetailor 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ import { useState } from "react";
2
+ import { Select, Toggle, ListEditor, YamlPane, SaveBar, useFileDraft, danger, section, h } from "../ui.jsx";
3
+ import { notationToProp } from "./ComponentsTab.jsx";
4
+ import { validateTree, typeAccepts, splitRef } from "../../validator.js";
5
+
6
+ // A path addresses a node from the root: [] is the root, [0, "chart"] is child "chart" of the first child.
7
+ export function nodeAt(tree, path) {
8
+ let n = tree;
9
+ for (const k of path) { n = n?.children?.[k]; if (n === undefined) return undefined; }
10
+ return n;
11
+ }
12
+ // Copy of tree with the node at path replaced by fn(node) (fn may also mutate and return nothing).
13
+ export function updateAt(tree, path, fn) {
14
+ if (!path.length) { const n = structuredClone(tree); const r = fn(n); return r === undefined ? n : r; }
15
+ const copy = structuredClone(tree), parent = nodeAt(copy, path.slice(0, -1)), k = path.at(-1);
16
+ const r = fn(parent.children[k]);
17
+ if (r !== undefined) parent.children[k] = r;
18
+ return copy;
19
+ }
20
+ export const schemaOf = (registry, ref) => registry?.[splitRef(ref ?? "")[0]]?.props;
21
+
22
+ // Catalog sources whose returned type the component accepts; none for containers or components without `accepts`.
23
+ export function acceptedSources(catalog, component) {
24
+ if (!component?.accepts || component.container) return [];
25
+ return Object.keys(catalog ?? {}).filter(s => catalog[s]?.returns && typeAccepts(component.accepts, catalog[s].returns));
26
+ }
27
+
28
+ // Keep the props the new schema still declares; without a new schema everything is kept.
29
+ export function migrateProps(props, oldSchema, newSchema) {
30
+ if (!newSchema) return { ...(props ?? {}) };
31
+ return Object.fromEntries(Object.entries(props ?? {}).filter(([k]) => k in newSchema));
32
+ }
33
+
34
+ // areas: text of rows, cells separated by spaces, "." is an empty cell.
35
+ const rows = areas => String(areas ?? "").split("\n").map(r => r.trim().split(/\s+/).filter(Boolean)).filter(r => r.length);
36
+ const text = rs => rs.length ? rs.map(r => r.join(" ")).join("\n") + "\n" : "";
37
+ export const appendAreaRow = (areas, name) => {
38
+ const rs = rows(areas), cols = Math.max(1, ...rs.map(r => r.length));
39
+ return text([...rs, Array(cols).fill(name)]);
40
+ };
41
+ // The name's cells become "."; rows left all empty are dropped.
42
+ export const removeAreaName = (areas, name) => text(rows(areas).map(r => r.map(c => c === name ? "." : c)).filter(r => r.some(c => c !== ".")));
43
+ export const renameAreaName = (areas, from, to) => text(rows(areas).map(r => r.map(c => c === from ? to : c)));
44
+
45
+ const defaultFor = (key, t) => t.type === "number" ? 0 : t.type === "boolean" ? false : t.type === "enum" ? (t.values?.[0] ?? "")
46
+ : t.type.endsWith("[]") ? [] : key === "areas" && t.type === "text" ? ".\n" : "";
47
+ // New node with the required props filled in; containers get children ({} when they place them by areas).
48
+ export function newNode(name, version, entry) {
49
+ const node = { ref: `${name}@${version}` };
50
+ const props = Object.fromEntries(Object.entries(entry?.props ?? {}).map(([k, s]) => [k, notationToProp(s)]).filter(([, t]) => !t.optional).map(([k, t]) => [k, defaultFor(k, t)]));
51
+ if (Object.keys(props).length) node.props = props;
52
+ if (entry?.container) node.children = "areas" in (entry.props ?? {}) ? {} : [];
53
+ return node;
54
+ }
55
+
56
+ export const refOptions = registry => Object.entries(registry ?? {}).flatMap(([n, e]) => Object.keys(e.versions ?? {}).map(v => `${n}@${v}`));
57
+ const pathKey = p => JSON.stringify(p);
58
+ const listText = v => [].concat(v ?? []).join(", ");
59
+
60
+ function Tree({ node, path, sel, onSel, name }) {
61
+ if (!node) return null;
62
+ const on = pathKey(path) === pathKey(sel);
63
+ const kids = Array.isArray(node.children) ? node.children.map((c, i) => [i, c, null]) : Object.entries(node.children ?? {}).map(([k, c]) => [k, c, k]);
64
+ return <div style={{ paddingLeft: path.length ? 14 : 0 }}>
65
+ <button onClick={() => onSel(path)} style={{ border: "none", background: on ? "var(--pt-color-surface)" : "none", cursor: "pointer", padding: "2px 4px", fontSize: 13, textAlign: "left", fontWeight: on ? 700 : 400 }}>
66
+ {name && <span style={{ color: "var(--pt-color-muted)" }}>{name}: </span>}{node.ref}
67
+ {node.data?.source && <span style={{ color: "var(--pt-color-muted)" }}> ← {node.data.source}</span>}
68
+ </button>
69
+ {kids.map(([k, c, n]) => <Tree key={k} node={c} path={[...path, k]} sel={sel} onSel={onSel} name={n} />)}
70
+ </div>;
71
+ }
72
+
73
+ function PropInput({ k, t, value, onChange }) {
74
+ const lab = { display: "flex", flexDirection: "column", gap: 4, fontSize: 13, marginBottom: 12 };
75
+ const clear = t.optional && value !== undefined && <button onClick={() => onChange(undefined)} style={{ alignSelf: "flex-start", fontSize: 11 }}>clear</button>;
76
+ const title = <span>{k}{t.optional ? "" : " *"}</span>;
77
+ if (t.type === "boolean") return <div style={{ marginBottom: 12, display: "flex", gap: 8 }}><Toggle label={k} checked={value} onChange={onChange} />{clear}</div>;
78
+ if (t.type === "enum") return <label style={lab}>{title}<select value={value ?? ""} onChange={e => onChange(e.target.value || undefined)} style={{ maxWidth: 320 }}>
79
+ <option value="">{t.optional ? "(none)" : "choose…"}</option>{t.values.map(v => <option key={v}>{v}</option>)}</select></label>;
80
+ const input = t.type === "text" ? <textarea rows={4} value={value ?? ""} onChange={e => onChange(e.target.value)} style={{ maxWidth: 420, fontFamily: "monospace" }} />
81
+ : t.type === "number" ? <input type="number" value={value ?? ""} onChange={e => onChange(e.target.value === "" ? undefined : Number(e.target.value))} style={{ maxWidth: 320 }} />
82
+ : t.type.endsWith("[]") ? <input value={listText(value)} placeholder="comma separated" onChange={e => onChange(e.target.value.split(",").map(s => s.trim()).filter(Boolean).map(s => t.type === "number[]" ? Number(s) : s))} style={{ maxWidth: 320 }} />
83
+ : <input value={value ?? ""} onChange={e => onChange(e.target.value)} style={{ maxWidth: 320 }} />;
84
+ return <label style={lab}>{title}{input}{clear}</label>;
85
+ }
86
+
87
+ // Free JSON for components without a props schema; applied only when it parses.
88
+ function JsonProps({ value, onChange }) {
89
+ const [txt, setTxt] = useState(JSON.stringify(value ?? {}, null, 2)), [bad, setBad] = useState(false);
90
+ return <label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13, marginBottom: 12 }}>props (JSON)
91
+ <textarea rows={6} value={txt} style={{ maxWidth: 420, fontFamily: "monospace" }} onChange={e => {
92
+ setTxt(e.target.value);
93
+ try { const v = JSON.parse(e.target.value || "{}"); setBad(false); onChange(v); } catch { setBad(true); }
94
+ }} />{bad && <span style={danger}>not valid JSON</span>}
95
+ </label>;
96
+ }
97
+
98
+ // pages/<slug>.yaml: the tree of components, their props, data bindings and grid areas.
99
+ export function PagesTab({ project }) {
100
+ const pages = project.pages ?? [];
101
+ const [slug, setSlug] = useState(() => { const p = new URLSearchParams(location.search).get("page"); return pages.includes(p) ? p : pages[0]; });
102
+ const pick = s => { const q = new URLSearchParams(location.search); q.set("page", s); history.replaceState(null, "", "?" + q); setSlug(s); };
103
+ if (!slug) return <main style={{ padding: 16 }}>no pages yet, add one in the App tab</main>;
104
+ return <PageEditor key={slug} slug={slug} pages={pages} pick={pick} project={project} />;
105
+ }
106
+
107
+ function PageEditor({ slug, pages, pick, project }) {
108
+ const file = useFileDraft(`pages/${slug}`);
109
+ const [sel, setSel] = useState([]);
110
+ const page = file.draft;
111
+ if (!page) return null;
112
+ const { registry = {}, catalog = {} } = project;
113
+ const tree = page.layout;
114
+ const node = nodeAt(tree, sel) ?? tree;
115
+ const path = nodeAt(tree, sel) ? sel : [];
116
+ const errors = tree ? validateTree(tree, { registry, catalog }) : [{ path: "root", message: "page has no layout" }];
117
+ const edit = fn => file.update(d => { d.layout = updateAt(d.layout, path, fn); });
118
+ const [name] = splitRef(node?.ref ?? "");
119
+ const entry = registry[name], schema = entry?.props;
120
+ const refs = refOptions(registry);
121
+
122
+ const setRef = ref => edit(n => {
123
+ const [nn, v] = splitRef(ref), next = registry[nn];
124
+ if (nn === name) { n.ref = ref; return; }
125
+ const fresh = newNode(nn, v, next), props = { ...(fresh.props ?? {}), ...migrateProps(n.props, schema, next?.props) };
126
+ n.ref = ref;
127
+ if (Object.keys(props).length) n.props = props; else delete n.props;
128
+ if (next?.container) { delete n.data; if (!n.children) n.children = fresh.children; }
129
+ else { delete n.children; if (n.data && !acceptedSources(catalog, next).includes(n.data.source)) delete n.data; }
130
+ });
131
+ const setProp = (k, v) => edit(n => { n.props = { ...(n.props ?? {}) }; if (v === undefined) delete n.props[k]; else n.props[k] = v; if (!Object.keys(n.props).length) delete n.props; });
132
+ const removeNode = () => {
133
+ const parentPath = path.slice(0, -1), k = path.at(-1);
134
+ file.update(d => { d.layout = updateAt(d.layout, parentPath, p => {
135
+ if (Array.isArray(p.children)) p.children.splice(k, 1);
136
+ else { delete p.children[k]; if (p.props?.areas !== undefined) p.props.areas = removeAreaName(p.props.areas, k); }
137
+ }); });
138
+ setSel(parentPath);
139
+ };
140
+ const named = node?.children && !Array.isArray(node.children);
141
+ const sources = acceptedSources(catalog, entry), src = catalog[node?.data?.source];
142
+ const newRef = r => { const [n, v] = splitRef(r); return newNode(n, v, registry[n]); };
143
+
144
+ return <div style={{ display: "flex", flexDirection: "column", minHeight: "calc(100vh - 90px)" }}>
145
+ <main style={{ padding: 16, maxWidth: 1000, width: "100%", boxSizing: "border-box", margin: "0 auto", flex: 1 }}>
146
+ <Select label="page" value={slug} options={pages} onChange={pick} />
147
+ <div style={{ display: "flex", gap: 24, alignItems: "flex-start", flexWrap: "wrap" }}>
148
+ <div style={{ ...section, flex: "1 1 300px" }}>
149
+ <h3 style={h}>Tree</h3>
150
+ <Tree node={tree} path={[]} sel={path} onSel={setSel} />
151
+ {errors.length > 0 && <ul style={{ ...danger, fontSize: 12, paddingLeft: 16 }}>{errors.map((e, i) => <li key={i}>{e.path}: {e.message}</li>)}</ul>}
152
+ </div>
153
+ {node && <div style={{ ...section, flex: "1 1 360px" }}>
154
+ <h3 style={h}>Node {path.length ? path.join(" / ") : "(root)"}</h3>
155
+ <Select label="component" value={node.ref} options={refs.includes(node.ref) ? refs : [node.ref, ...refs]} onChange={setRef} />
156
+ {schema ? Object.entries(schema).map(([k, s]) => <PropInput key={pathKey(path) + k} k={k} t={notationToProp(s)} value={node.props?.[k]} onChange={v => setProp(k, v)} />)
157
+ : <JsonProps key={pathKey(path) + node.ref} value={node.props} onChange={v => edit(n => { if (v && Object.keys(v).length) n.props = v; else delete n.props; })} />}
158
+ {entry?.accepts && !entry.container && <>
159
+ <Select label={`data (${entry.accepts})`} value={node.data?.source} options={sources} empty="(none)"
160
+ onChange={s => edit(n => { if (s) n.data = { source: s }; else delete n.data; })} />
161
+ {src && Object.entries(src.params ?? {}).map(([k, allowed]) => {
162
+ const v = node.data.params?.[k];
163
+ const set = val => edit(n => { n.data.params = { ...(n.data.params ?? {}) }; if (val === "" || val === undefined) delete n.data.params[k]; else n.data.params[k] = val; if (!Object.keys(n.data.params).length) delete n.data.params; });
164
+ return Array.isArray(allowed) ? <Select key={k} label={`param ${k}`} value={v} options={allowed.map(String)} empty="(default)" onChange={set} />
165
+ : <label key={k} style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13, marginBottom: 12 }}>param {k}<input value={v ?? ""} onChange={e => set(e.target.value)} style={{ maxWidth: 320 }} /></label>;
166
+ })}
167
+ </>}
168
+ {named && <NamedChildren node={node} refs={refs} edit={edit} newRef={newRef} />}
169
+ {Array.isArray(node.children) && <div style={{ marginBottom: 12 }}>
170
+ <span style={{ fontSize: 13 }}>children</span>
171
+ <ListEditor items={node.children} onRemove={i => edit(n => { n.children.splice(i, 1); })}
172
+ render={(c, i) => <>
173
+ <span style={{ flex: 1 }}>{c.ref}</span>
174
+ <button disabled={i === 0} onClick={() => edit(n => { [n.children[i - 1], n.children[i]] = [n.children[i], n.children[i - 1]]; })}>↑</button>
175
+ <button disabled={i === node.children.length - 1} onClick={() => edit(n => { [n.children[i + 1], n.children[i]] = [n.children[i], n.children[i + 1]]; })}>↓</button>
176
+ </>} />
177
+ <select value="" onChange={e => e.target.value && edit(n => { n.children.push(newRef(e.target.value)); })} style={{ marginTop: 8 }}>
178
+ <option value="">+ child…</option>{refs.map(r => <option key={r}>{r}</option>)}
179
+ </select>
180
+ </div>}
181
+ {path.length > 0 && <button onClick={removeNode} style={danger}>remove node</button>}
182
+ </div>}
183
+ </div>
184
+ </main>
185
+ <div style={{ position: "sticky", bottom: 0, background: "var(--pt-color-background)" }}>
186
+ <SaveBar file={file} />
187
+ <YamlPane name={slug} value={page} />
188
+ </div>
189
+ </div>;
190
+ }
191
+
192
+ // Children placed by name (grid areas): rename, remove, add with a new full-width row.
193
+ function NamedChildren({ node, refs, edit, newRef }) {
194
+ const [nm, setNm] = useState(""), [ref, setRef] = useState(""), [note, setNote] = useState(null);
195
+ const names = Object.keys(node.children), hasAreas = node.props?.areas !== undefined;
196
+ const ok = n => /^[A-Za-z_][\w-]*$/.test(n) && !names.includes(n);
197
+ const rename = (from, to) => {
198
+ if (!ok(to)) { setNote(`"${to}": not a valid or free name`); return; }
199
+ setNote(null);
200
+ edit(n => { n.children = Object.fromEntries(Object.entries(n.children).map(([k, c]) => [k === from ? to : k, c])); if (hasAreas) n.props.areas = renameAreaName(n.props.areas, from, to); });
201
+ };
202
+ const add = e => {
203
+ e.preventDefault();
204
+ if (!ok(nm)) { setNote(`"${nm}": not a valid or free name`); return; }
205
+ if (!ref) { setNote("choose a component"); return; }
206
+ setNote(null);
207
+ edit(n => { n.children[nm] = newRef(ref); n.props = { ...(n.props ?? {}), areas: appendAreaRow(n.props?.areas, nm) }; });
208
+ setNm(""); setRef("");
209
+ };
210
+ return <div style={{ marginBottom: 12 }}>
211
+ <span style={{ fontSize: 13 }}>children by name</span>
212
+ {note && <p style={{ ...danger, fontSize: 12 }}>{note}</p>}
213
+ <ListEditor items={names} onRemove={i => edit(n => { delete n.children[names[i]]; if (hasAreas) n.props.areas = removeAreaName(n.props.areas, names[i]); })}
214
+ render={k => <><strong style={{ minWidth: 80 }}>{k}</strong><span style={{ flex: 1 }}>{node.children[k].ref}</span>
215
+ <button onClick={() => { const to = prompt("New name", k); if (to && to !== k) rename(k, to); }}>rename</button></>} />
216
+ <form onSubmit={add} style={{ display: "flex", gap: 6, marginTop: 8 }}>
217
+ <input value={nm} placeholder="name" onChange={e => setNm(e.target.value)} style={{ width: 100 }} />
218
+ <select value={ref} onChange={e => setRef(e.target.value)}><option value="">component…</option>{refs.map(r => <option key={r}>{r}</option>)}</select>
219
+ <button type="submit">+ child</button>
220
+ </form>
221
+ </div>;
222
+ }
@@ -0,0 +1,211 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Field, Select, ListEditor, YamlPane, SaveBar, useFileDraft, api, errorText, danger, section, h } from "../ui.jsx";
3
+
4
+ const SLUG = /^[a-z0-9-]+$/;
5
+ export const OPS = ["set-areas", "set-props", "set-data", "replace", "remove", "insert", "move"];
6
+ const FIELDS = {
7
+ "set-areas": ["target", "areas"], "set-props": ["target", "props"], "set-data": ["target", "data"],
8
+ replace: ["target", "with"], remove: ["target"], insert: ["target", "name", "node", "before", "area"], move: ["target", "to"],
9
+ };
10
+ const JSON_FIELDS = ["props", "data", "with", "node"];
11
+
12
+ // The fields a patch with this op needs, in display order.
13
+ export const patchFields = op => FIELDS[op] ?? ["target"];
14
+
15
+ // "set-areas layout/grid, remove kpi"
16
+ export const summarizePatches = patches => (patches ?? []).map(p => [p.op, p.target].filter(Boolean).join(" ")).join(", ");
17
+
18
+ // Child names and component names (ref without @version) found in a page tree, sorted and unique.
19
+ export function collectTargets(tree) {
20
+ const out = new Set();
21
+ const walk = node => {
22
+ if (!node || typeof node !== "object") return;
23
+ if (typeof node.ref === "string") out.add(node.ref.split("@")[0]);
24
+ const kids = node.children;
25
+ if (Array.isArray(kids)) kids.forEach(walk);
26
+ else if (kids && typeof kids === "object") for (const [k, v] of Object.entries(kids)) { out.add(k); walk(v); }
27
+ };
28
+ walk(tree?.layout ?? tree);
29
+ return [...out].sort();
30
+ }
31
+
32
+ const overrideNames = project => {
33
+ const o = project?.overrides ?? [];
34
+ return (Array.isArray(o) ? o.map(x => typeof x === "string" ? x : x.name) : Object.keys(o)).filter(Boolean).sort();
35
+ };
36
+ const small = { fontSize: 12, fontFamily: "monospace" };
37
+ const cell = { padding: "4px 6px", borderBottom: "1px solid var(--pt-color-border)", textAlign: "left", verticalAlign: "top" };
38
+
39
+ function JsonBox({ value, onChange }) {
40
+ const [text, setText] = useState(JSON.stringify(value ?? {}));
41
+ const [bad, setBad] = useState(false);
42
+ useEffect(() => { setText(JSON.stringify(value ?? {})); setBad(false); }, [JSON.stringify(value)]);
43
+ return <textarea rows={2} cols={40} value={text} style={{ ...small, borderColor: bad ? "var(--pt-color-danger)" : undefined }}
44
+ onChange={e => { setText(e.target.value); try { onChange(JSON.parse(e.target.value)); setBad(false); } catch { setBad(true); } }} />;
45
+ }
46
+
47
+ function PatchRow({ patch, onChange, listId }) {
48
+ const set = (k, v) => onChange({ ...patch, [k]: v });
49
+ const changeOp = op => { const keep = patchFields(op); onChange(Object.fromEntries([["op", op], ...Object.entries(patch).filter(([k]) => keep.includes(k))])); };
50
+ return <>
51
+ <select value={patch.op} onChange={e => changeOp(e.target.value)}>{OPS.map(o => <option key={o}>{o}</option>)}</select>
52
+ {patchFields(patch.op).map(f => <label key={f} style={{ display: "inline-flex", gap: 4, alignItems: "center" }}>
53
+ <span style={{ color: "var(--pt-color-muted)" }}>{f}</span>
54
+ {f === "areas" ? <textarea rows={3} cols={30} value={patch.areas ?? ""} onChange={e => set("areas", e.target.value)} style={small} />
55
+ : JSON_FIELDS.includes(f) ? <JsonBox value={patch[f]} onChange={v => set(f, v)} />
56
+ : f === "to" ? <input type="number" value={patch.to ?? 0} onChange={e => set("to", Number(e.target.value))} style={{ width: 60 }} />
57
+ : <input value={patch[f] ?? ""} list={f === "target" || f === "before" ? listId : undefined} style={{ width: 120 }}
58
+ onChange={e => { const v = e.target.value; if (v || f === "target") set(f, v); else { const n = { ...patch }; delete n[f]; onChange(n); } }} />}
59
+ </label>)}
60
+ </>;
61
+ }
62
+
63
+ function OverrideEditor({ name, project, reload, onRemoved }) {
64
+ const file = useFileDraft(`overrides/${name}`);
65
+ const [targets, setTargets] = useState([]);
66
+ const [note, setNote] = useState(null);
67
+ const o = file.draft;
68
+ const ext = o?.extends;
69
+ useEffect(() => {
70
+ if (!ext) { setTargets([]); return; }
71
+ api(`/api/files/pages/${ext}`).then(r => setTargets(collectTargets(r.value))).catch(() => setTargets([]));
72
+ }, [ext]);
73
+ if (!o) return null;
74
+ const patches = o.patches ?? [];
75
+ const listId = `pt-targets-${name}`;
76
+ const remove = async () => {
77
+ if (!confirm(`Delete override "${name}"?`)) return;
78
+ const r = await api(`/api/files/overrides/${name}`, "DELETE");
79
+ if (!r.ok) { setNote(errorText(r)); return; }
80
+ reload(); onRemoved();
81
+ };
82
+ return <div style={{ display: "flex", flexDirection: "column" }}>
83
+ <div style={section}>
84
+ <h3 style={h}>{name} <button onClick={remove} style={{ marginLeft: 8 }}>remove</button></h3>
85
+ <Select label="extends" value={o.extends} options={project.manifest?.pages ?? []} onChange={v => file.update(d => { d.extends = v; })} />
86
+ <Field label="why" value={o.why} onChange={v => file.update(d => { d.why = v; })} />
87
+ {note && <p style={{ ...danger, fontSize: 13 }}>{note}</p>}
88
+ <datalist id={listId}>{targets.map(t => <option key={t} value={t} />)}</datalist>
89
+ <ListEditor items={patches} addLabel="+ patch"
90
+ onAdd={() => file.update(d => { d.patches = [...(d.patches ?? []), { op: "remove", target: "" }]; })}
91
+ onRemove={i => file.update(d => { d.patches.splice(i, 1); if (!d.patches.length) delete d.patches; })}
92
+ render={(p, i) => <PatchRow patch={p} listId={listId} onChange={np => file.update(d => { d.patches[i] = np; })} />} />
93
+ </div>
94
+ <SaveBar file={file} />
95
+ <YamlPane name={`overrides/${name}`} value={o} />
96
+ </div>;
97
+ }
98
+
99
+ function OverridesPanel({ project, reload, selected, setSelected }) {
100
+ const names = overrideNames(project);
101
+ const roles = project.manifest?.roles ?? [];
102
+ const [adding, setAdding] = useState(false), [kind, setKind] = useState("role"), [who, setWho] = useState("");
103
+ const [note, setNote] = useState(null);
104
+ const create = async () => {
105
+ const id = kind === "role" ? who : who.trim();
106
+ if (!id || !SLUG.test(id)) { setNote(`"${id}": use lowercase letters, digits and dashes`); return; }
107
+ const name = `${kind}-${id}`;
108
+ if (names.includes(name)) { setNote(`override "${name}" already exists`); return; }
109
+ const r = await api(`/api/files/overrides/${name}`, "PUT", { value: { extends: project.manifest?.home ?? "home" }, why: `new override ${name}` });
110
+ if (!r.ok) { setNote(errorText(r)); return; }
111
+ setNote(null); setAdding(false); setWho(""); reload(); setSelected(name);
112
+ };
113
+ const group = (title, prefix) => <div style={{ marginBottom: 8 }}>
114
+ <div style={{ color: "var(--pt-color-muted)", fontSize: 12 }}>{title}</div>
115
+ {names.filter(n => n.startsWith(prefix)).map(n => <div key={n}>
116
+ <button onClick={() => setSelected(n)} style={{ border: "none", background: "none", cursor: "pointer", fontWeight: n === selected ? 700 : 400, padding: "2px 0" }}>{n}</button>
117
+ </div>)}
118
+ </div>;
119
+ return <div style={section}>
120
+ <h3 style={h}>Overrides</h3>
121
+ <div style={{ display: "flex", gap: 24, alignItems: "flex-start" }}>
122
+ <div style={{ minWidth: 160, fontSize: 13 }}>
123
+ {group("roles", "role-")}
124
+ {group("users", "user-")}
125
+ {!adding ? <button onClick={() => setAdding(true)}>+ override</button> : <div>
126
+ <Select label="kind" value={kind} options={["role", "user"]} onChange={v => { setKind(v); setWho(""); }} />
127
+ {kind === "role" ? <Select label="role" value={who} options={roles} empty="choose…" onChange={setWho} /> : <Field label="user" value={who} onChange={setWho} />}
128
+ <button onClick={create}>create</button> <button onClick={() => { setAdding(false); setNote(null); }}>cancel</button>
129
+ </div>}
130
+ {note && <p style={{ ...danger, fontSize: 13 }}>{note}</p>}
131
+ </div>
132
+ <div style={{ flex: 1 }}>
133
+ {selected && names.includes(selected)
134
+ ? <OverrideEditor key={selected} name={selected} project={project} reload={reload} onRemoved={() => setSelected(null)} />
135
+ : <p style={{ color: "var(--pt-color-muted)", fontSize: 13 }}>select an override</p>}
136
+ </div>
137
+ </div>
138
+ </div>;
139
+ }
140
+
141
+ function PendingPanel({ reload }) {
142
+ const params = typeof location !== "undefined" ? new URLSearchParams(location.search) : new URLSearchParams();
143
+ const [user, setUser] = useState(params.get("user") || "admin"), [role, setRole] = useState(params.get("role") || "admin");
144
+ const [rows, setRows] = useState([]), [note, setNote] = useState(null);
145
+ const load = () => api("/api/pending").then(r => setRows(Array.isArray(r) ? r : [])).catch(() => setRows([]));
146
+ useEffect(() => { load(); }, []);
147
+ const act = async (e, kind) => {
148
+ const body = { name: e.name, version: e.version, approver: user, approverRole: role };
149
+ if (kind === "reject") { const reason = prompt("Reason for rejecting?"); if (reason === null) return; body.reason = reason; }
150
+ const r = await api(`/api/${kind}`, "POST", body);
151
+ setNote(r.ok ? { ok: true, text: `${kind === "approve" ? "approved" : "rejected"} ${e.name} v${e.version}` } : { ok: false, text: errorText(r) });
152
+ load(); reload();
153
+ };
154
+ return <div style={section}>
155
+ <h3 style={h}>Pending proposals</h3>
156
+ <div style={{ display: "flex", gap: 12 }}>
157
+ <Field label="acting as user" value={user} onChange={setUser} />
158
+ <Field label="role" value={role} onChange={setRole} />
159
+ </div>
160
+ {note && <p style={{ ...(note.ok ? {} : danger), fontSize: 13 }}>{note.text}</p>}
161
+ {rows.length === 0 ? <p style={{ color: "var(--pt-color-muted)", fontSize: 13 }}>nothing pending</p> :
162
+ <table style={{ borderCollapse: "collapse", fontSize: 13, width: "100%" }}>
163
+ <thead><tr>{["name", "version", "author", "why", "at", "patches", ""].map(c => <th key={c} style={cell}>{c}</th>)}</tr></thead>
164
+ <tbody>{rows.map(e => <tr key={`${e.name}-${e.version}`}>
165
+ <td style={cell}>{e.name}</td><td style={cell}>{e.version}</td><td style={cell}>{e.author}</td>
166
+ <td style={cell}>{e.override?.why ?? e.why}</td><td style={cell}>{e.at}</td>
167
+ <td style={cell}>{summarizePatches(e.override?.patches)}</td>
168
+ <td style={{ ...cell, whiteSpace: "nowrap" }}><button onClick={() => act(e, "approve")}>Approve</button> <button onClick={() => act(e, "reject")}>Reject</button></td>
169
+ </tr>)}</tbody>
170
+ </table>}
171
+ </div>;
172
+ }
173
+
174
+ function HistoryPanel({ project, reload }) {
175
+ const names = overrideNames(project);
176
+ const [name, setName] = useState(""), [rows, setRows] = useState([]), [note, setNote] = useState(null);
177
+ const load = n => n ? api(`/api/history/${n}`).then(r => setRows(Array.isArray(r) ? [...r].sort((a, b) => b.version - a.version) : [])) : setRows([]);
178
+ useEffect(() => { load(name); }, [name]);
179
+ const roll = async v => {
180
+ const r = await api("/api/rollback", "POST", { name, version: v });
181
+ setNote(r.ok === false ? { ok: false, text: errorText(r) } : { ok: true, text: `rolled ${name} back to v${v}` });
182
+ load(name); reload(); window.dispatchEvent(new Event("pt:files-changed"));
183
+ };
184
+ return <div style={section}>
185
+ <h3 style={h}>History</h3>
186
+ <Select label="override" value={name} options={names} empty="choose…" onChange={setName} />
187
+ {note && <p style={{ ...(note.ok ? {} : danger), fontSize: 13 }}>{note.text}</p>}
188
+ {name && (rows.length === 0 ? <p style={{ color: "var(--pt-color-muted)", fontSize: 13 }}>no history</p> :
189
+ <table style={{ borderCollapse: "collapse", fontSize: 13, width: "100%" }}>
190
+ <thead><tr>{["version", "at", "author", "status", "why", "approvedFrom", ""].map(c => <th key={c} style={cell}>{c}</th>)}</tr></thead>
191
+ <tbody>{rows.map(e => <tr key={e.version}>
192
+ <td style={cell}>{e.version}</td><td style={cell}>{e.at}</td><td style={cell}>{e.author}</td>
193
+ <td style={cell}>{e.status ?? "applied"}</td><td style={cell}>{e.override?.why ?? e.why}</td><td style={cell}>{e.approvedFrom ?? ""}</td>
194
+ <td style={cell}>{(e.status ?? "applied") === "applied" && <button onClick={() => roll(e.version)}>Roll back to this</button>}</td>
195
+ </tr>)}</tbody>
196
+ </table>)}
197
+ </div>;
198
+ }
199
+
200
+ // Overrides per role and user, proposals waiting for approval, and the history of each override.
201
+ export function PeopleTab({ project, reload }) {
202
+ const [selected, setSelected] = useState(null);
203
+ if (!project) return null;
204
+ return <div style={{ display: "flex", flexDirection: "column", minHeight: "calc(100vh - 90px)" }}>
205
+ <main style={{ padding: 16, maxWidth: 1000, width: "100%", boxSizing: "border-box", margin: "0 auto", flex: 1 }}>
206
+ <OverridesPanel project={project} reload={reload} selected={selected} setSelected={setSelected} />
207
+ <PendingPanel reload={reload} />
208
+ <HistoryPanel project={project} reload={reload} />
209
+ </main>
210
+ </div>;
211
+ }
@@ -0,0 +1,97 @@
1
+ import { useEffect } from "react";
2
+ import { Field, ListEditor, YamlPane, SaveBar, useFileDraft, section, h } from "../ui.jsx";
3
+ import { tokensToCss } from "../../tokens.js";
4
+
5
+ const NAME = /^[a-z0-9-]+$/;
6
+ const FONT_STACKS = [
7
+ "Inter, system-ui, sans-serif",
8
+ "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
9
+ "Helvetica Neue, Arial, sans-serif",
10
+ "Georgia, Cambria, Times New Roman, serif",
11
+ "ui-monospace, SFMono-Regular, Menlo, monospace",
12
+ ];
13
+
14
+ export const isHex = s => typeof s === "string" && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s);
15
+
16
+ // Immutable set: returns a copy of obj with value at path ("a.b" or ["a","b"]).
17
+ export function setPath(obj, path, value) {
18
+ const keys = Array.isArray(path) ? path : String(path).split(".");
19
+ if (!keys.length) return value;
20
+ const [k, ...rest] = keys;
21
+ const base = obj && typeof obj === "object" ? obj : {};
22
+ return { ...base, [k]: rest.length ? setPath(base[k], rest, value) : value };
23
+ }
24
+
25
+ // #abc -> #aabbcc, so <input type="color"> accepts it
26
+ const fullHex = s => isHex(s) ? (s.length === 4 ? "#" + [...s.slice(1)].map(c => c + c).join("") : s) : "#000000";
27
+
28
+ // tokens.yaml: colors, spacing, radius and font, previewed live on the whole Studio.
29
+ export function TokensTab() {
30
+ const file = useFileDraft("tokens");
31
+ const t = file.draft;
32
+
33
+ useEffect(() => {
34
+ if (!t) return;
35
+ let el = document.getElementById("pt-tokens-draft");
36
+ if (!el) { el = document.createElement("style"); el.id = "pt-tokens-draft"; document.head.appendChild(el); }
37
+ el.textContent = tokensToCss(t);
38
+ }, [t]);
39
+ useEffect(() => () => document.getElementById("pt-tokens-draft")?.remove(), []);
40
+
41
+ if (!t) return null;
42
+ const colors = Object.keys(t.color ?? {}), spacing = Object.keys(t.spacing ?? {});
43
+ const set = (path, v) => file.setDraft(d => setPath(d, path, v));
44
+ const drop = (group, key) => file.update(d => { delete d[group][key]; });
45
+ const add = (group, initial) => name => {
46
+ if (!NAME.test(name) || (t[group] ?? {})[name] !== undefined) return false;
47
+ set([group, name], initial);
48
+ };
49
+
50
+ return <div style={{ display: "flex", flexDirection: "column", minHeight: "calc(100vh - 90px)" }}>
51
+ <main style={{ padding: 16, maxWidth: 800, width: "100%", boxSizing: "border-box", margin: "0 auto", flex: 1 }}>
52
+ <div style={{ ...section, display: "flex", gap: 8, flexWrap: "wrap" }}>
53
+ {colors.map(c => <div key={c} style={{ textAlign: "center", fontSize: 11 }}>
54
+ <div style={{ width: 48, height: 32, background: t.color[c], border: "1px solid var(--pt-color-border)", borderRadius: 4 }} />{c}
55
+ </div>)}
56
+ </div>
57
+ <div style={{ ...section, background: "var(--pt-color-surface)", border: "1px solid var(--pt-color-border)", borderRadius: "var(--pt-radius)", padding: "var(--pt-spacing-md)", fontFamily: "var(--pt-font-base)", fontSize: "var(--pt-font-size)", color: "var(--pt-color-text)", maxWidth: 360 }}>
58
+ <strong>Sample card</strong>
59
+ <p style={{ color: "var(--pt-color-muted)", margin: "var(--pt-spacing-sm) 0" }}>A muted line of secondary text.</p>
60
+ <button style={{ background: "var(--pt-color-primary)", color: "#fff", border: "none", borderRadius: "var(--pt-radius)", padding: "var(--pt-spacing-sm) var(--pt-spacing-md)" }}>Primary</button>
61
+ </div>
62
+ <div style={section}>
63
+ <h3 style={h}>Colors</h3>
64
+ <ListEditor items={colors} addLabel="+ color" addPlaceholder="name" onAdd={add("color", "#000000")} onRemove={i => drop("color", colors[i])}
65
+ render={c => <>
66
+ <strong style={{ minWidth: 120 }}>{c}</strong>
67
+ <input type="color" value={fullHex(t.color[c])} onChange={e => set(["color", c], e.target.value)} />
68
+ <input value={t.color[c] ?? ""} onChange={e => set(["color", c], e.target.value)} style={{ width: 100, ...(isHex(t.color[c]) ? {} : { borderColor: "var(--pt-color-danger)" }) }} />
69
+ </>} />
70
+ </div>
71
+ <div style={section}>
72
+ <h3 style={h}>Spacing</h3>
73
+ <ListEditor items={spacing} addLabel="+ spacing" addPlaceholder="name" onAdd={add("spacing", 8)} onRemove={i => drop("spacing", spacing[i])}
74
+ render={s => <>
75
+ <strong style={{ minWidth: 120 }}>{s}</strong>
76
+ <input type="number" min={0} value={t.spacing[s]} onChange={e => set(["spacing", s], Number(e.target.value))} style={{ width: 80 }} /> px
77
+ </>} />
78
+ </div>
79
+ <div style={section}>
80
+ <h3 style={h}>Radius</h3>
81
+ <Field label="radius (px)" type="number" value={t.radius} onChange={v => set(["radius"], v)} />
82
+ </div>
83
+ <div style={section}>
84
+ <h3 style={h}>Font</h3>
85
+ <label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13, marginBottom: 12 }}>base family
86
+ <input list="pt-font-stacks" value={t.font?.base ?? ""} onChange={e => set(["font", "base"], e.target.value)} style={{ maxWidth: 320 }} />
87
+ <datalist id="pt-font-stacks">{FONT_STACKS.map(f => <option key={f} value={f} />)}</datalist>
88
+ </label>
89
+ <Field label="size (px)" type="number" value={t.font?.size} onChange={v => set(["font", "size"], v)} />
90
+ </div>
91
+ </main>
92
+ <div style={{ position: "sticky", bottom: 0, background: "var(--pt-color-background)" }}>
93
+ <SaveBar file={file} />
94
+ <YamlPane name="tokens" value={t} />
95
+ </div>
96
+ </div>;
97
+ }