@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,118 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Document, visit } from "yaml";
3
+
4
+ // Shared pieces of the Studio tabs. Plain inline styles, same look as the preview header.
5
+ export const bar = { display: "flex", gap: 12, alignItems: "center", padding: "10px 16px", borderBottom: "1px solid var(--pt-color-border)", fontSize: 13, flexWrap: "wrap" };
6
+ export const danger = { color: "var(--pt-color-danger)" };
7
+ const label = { display: "flex", flexDirection: "column", gap: 4, fontSize: 13, marginBottom: 12 };
8
+ export const section = { marginBottom: 20 };
9
+ export const h = { fontSize: 14, margin: "0 0 8px" };
10
+
11
+ export const api = async (path, method = "GET", body) => {
12
+ const r = await fetch(path, { method, headers: body ? { "content-type": "application/json" } : undefined, body: body ? JSON.stringify(body) : undefined });
13
+ return r.json();
14
+ };
15
+ export const errorText = r => (r.errors ?? [r.error ?? "request failed"]).join("; ");
16
+
17
+ export function Field({ label: text, value, onChange, placeholder, type = "text", hint }) {
18
+ return <label style={label}>{text}
19
+ <input type={type} value={value ?? ""} placeholder={placeholder} onChange={e => onChange(type === "number" ? Number(e.target.value) : e.target.value)} style={{ maxWidth: 320 }} />
20
+ {hint && <span style={{ color: "var(--pt-color-muted)", fontSize: 12 }}>{hint}</span>}
21
+ </label>;
22
+ }
23
+
24
+ export function Select({ label: text, value, options, onChange, empty }) {
25
+ return <label style={label}>{text}
26
+ <select value={value ?? ""} onChange={e => onChange(e.target.value)} style={{ maxWidth: 320 }}>
27
+ {empty !== undefined && <option value="">{empty}</option>}
28
+ {options.map(o => typeof o === "string" ? <option key={o} value={o}>{o}</option> : <option key={o.value} value={o.value}>{o.label}</option>)}
29
+ </select>
30
+ </label>;
31
+ }
32
+
33
+ export function Toggle({ label: text, checked, onChange }) {
34
+ return <label style={{ display: "inline-flex", gap: 6, alignItems: "center", fontSize: 13 }}>
35
+ <input type="checkbox" checked={!!checked} onChange={e => onChange(e.target.checked)} />{text}
36
+ </label>;
37
+ }
38
+
39
+ // items: array; render(item, i) -> row content; onRemove(i); onAdd() or addForm (input + button).
40
+ export function ListEditor({ items, render, onRemove, onAdd, addLabel = "+ add", addPlaceholder }) {
41
+ const [text, setText] = useState("");
42
+ const add = e => { e.preventDefault(); if (addPlaceholder !== undefined) { if (!text.trim()) return; if (onAdd(text.trim()) !== false) setText(""); } else onAdd(); };
43
+ return <div style={{ fontSize: 13 }}>
44
+ {items.map((item, i) => <div key={typeof item === "string" ? item : i} style={{ display: "flex", gap: 8, alignItems: "center", padding: "4px 0", borderBottom: "1px solid var(--pt-color-border)" }}>
45
+ <div style={{ flex: 1, display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>{render ? render(item, i) : String(item)}</div>
46
+ {onRemove && <button onClick={() => onRemove(i)} title="remove">remove</button>}
47
+ </div>)}
48
+ {onAdd && <form onSubmit={add} style={{ display: "flex", gap: 6, marginTop: 8 }}>
49
+ {addPlaceholder !== undefined && <input value={text} placeholder={addPlaceholder} onChange={e => setText(e.target.value)} />}
50
+ <button type="submit">{addLabel}</button>
51
+ </form>}
52
+ </div>;
53
+ }
54
+
55
+ export function toYaml(value) {
56
+ if (value == null) return "";
57
+ const doc = new Document(value);
58
+ visit(doc, { Scalar(_, node) { if (typeof node.value === "string" && node.value.includes("\n")) node.type = "BLOCK_LITERAL"; } });
59
+ return doc.toString();
60
+ }
61
+
62
+ // Read-only YAML of the draft, collapsible, fixed at the bottom of the tab.
63
+ export function YamlPane({ name, value }) {
64
+ const [open, setOpen] = useState(true);
65
+ return <div style={{ borderTop: "1px solid var(--pt-color-border)", background: "var(--pt-color-surface)", fontSize: 12 }}>
66
+ <button onClick={() => setOpen(!open)} style={{ border: "none", background: "none", cursor: "pointer", padding: "6px 16px", fontSize: 12 }}>{open ? "▾" : "▸"} {name}.yaml</button>
67
+ {open && <pre style={{ margin: 0, padding: "0 16px 12px", maxHeight: 260, overflow: "auto" }}>{toYaml(value)}</pre>}
68
+ </div>;
69
+ }
70
+
71
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
72
+
73
+ // One project file as an editable draft: load, edit in memory, save with a why, undo, optional auto-save.
74
+ export function useFileDraft(name) {
75
+ const [saved, setSaved] = useState(undefined), [draft, setDraft] = useState(undefined);
76
+ const [status, setStatus] = useState(""), [errors, setErrors] = useState([]);
77
+ const [why, setWhy] = useState("edited in studio"), [autoSave, setAutoSave] = useState(false);
78
+ const dirty = saved !== undefined && !same(saved, draft);
79
+ const dirtyRef = useRef(false); dirtyRef.current = dirty;
80
+ const load = useCallback(async () => {
81
+ const r = await api(`/api/files/${name}`);
82
+ setSaved(r.value); setDraft(structuredClone(r.value)); setErrors([]);
83
+ }, [name]);
84
+ useEffect(() => { load(); }, [load]);
85
+ // someone else changed the files: reload only when there is nothing unsaved here
86
+ useEffect(() => { const on = () => { if (!dirtyRef.current) load(); }; window.addEventListener("pt:files-changed", on); return () => window.removeEventListener("pt:files-changed", on); }, [load]);
87
+ const save = useCallback(async (value = draft) => {
88
+ setStatus("saving…");
89
+ const r = await api(`/api/files/${name}`, "PUT", { value, why: why || "edited in studio" });
90
+ if (r.ok) { setSaved(structuredClone(value)); setErrors([]); setStatus(`saved v${r.entry.version}`); }
91
+ else { setErrors(r.errors ?? [r.error ?? "save failed"]); setStatus(""); }
92
+ return r;
93
+ }, [name, draft, why]);
94
+ const undo = useCallback(async () => {
95
+ const r = await api(`/api/files/${name}/undo`, "POST");
96
+ if (r.ok) { await load(); setStatus("undone"); } else setErrors(r.errors ?? [r.error]);
97
+ return r;
98
+ }, [name, load]);
99
+ useEffect(() => {
100
+ if (!autoSave || !dirty) return;
101
+ const t = setTimeout(() => save(), 1500);
102
+ return () => clearTimeout(t);
103
+ }, [autoSave, dirty, draft, save]);
104
+ // edit with a function of the current draft: update(d => { d.app = "x"; })
105
+ const update = useCallback(fn => setDraft(d => { const n = structuredClone(d); fn(n); return n; }), []);
106
+ return { name, saved, draft, setDraft, update, dirty, save, undo, reload: load, status, errors, setErrors, why, setWhy, autoSave, setAutoSave };
107
+ }
108
+
109
+ export function SaveBar({ file }) {
110
+ return <div style={{ ...bar, borderBottom: "none", borderTop: "1px solid var(--pt-color-border)" }}>
111
+ <button onClick={() => file.save()} disabled={!file.dirty}>Save</button>
112
+ <button onClick={() => file.undo()}>Undo</button>
113
+ <input value={file.why} onChange={e => file.setWhy(e.target.value)} placeholder="why" style={{ width: 200 }} />
114
+ <Toggle label="auto-save" checked={file.autoSave} onChange={file.setAutoSave} />
115
+ {file.dirty ? <span>unsaved changes</span> : <span style={{ color: "var(--pt-color-muted)" }}>{file.status}</span>}
116
+ {file.errors.length > 0 && <span style={danger}>{file.errors.join("; ")}</span>}
117
+ </div>;
118
+ }
@@ -0,0 +1,45 @@
1
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { Document, parseDocument, visit } from "yaml";
4
+
5
+ // Serialise a plain object as YAML. When the previous text of the file is given, top-level keys are
6
+ // set on the parsed document so the file's comments and key order survive the rewrite.
7
+ export function toYaml(value, previous) {
8
+ let doc;
9
+ if (typeof previous === "string" && previous.trim()) {
10
+ doc = parseDocument(previous);
11
+ if (doc.errors.length || !doc.contents || doc.contents.items === undefined) doc = new Document(value);
12
+ else {
13
+ for (const key of Object.keys(value)) {
14
+ const old = doc.get(key, true), node = doc.createNode(value[key]);
15
+ if (old && typeof old === "object") for (const c of ["comment", "commentBefore", "spaceBefore", "flow"]) if (old[c] !== undefined) node[c] = old[c];
16
+ doc.set(key, node);
17
+ }
18
+ for (const item of [...doc.contents.items]) { const k = item.key?.value ?? item.key; if (!(k in value)) doc.delete(k); }
19
+ }
20
+ } else doc = new Document(value);
21
+ visit(doc, { Scalar(_, node) { if (typeof node.value === "string" && node.value.includes("\n")) node.type = "BLOCK_LITERAL"; } });
22
+ return doc.toString();
23
+ }
24
+
25
+ // Rewrite a YAML file keeping its comments; a missing file is created.
26
+ export async function writeYaml(path, value) {
27
+ let previous; try { previous = await readFile(path, "utf8"); } catch {}
28
+ await writeFile(path, toYaml(value, previous));
29
+ }
30
+
31
+ export const pad = n => String(n).padStart(4, "0");
32
+
33
+ // Append a numbered JSON version to dir: the number is max+1 and the file is created exclusively,
34
+ // so two concurrent writers never share a version (the loser retries with the next number).
35
+ export async function appendVersion(dir, build) {
36
+ await mkdir(dir, { recursive: true });
37
+ for (let tries = 0; tries < 20; tries++) {
38
+ const nums = (await readdir(dir)).filter(f => /^\d+\.json$/.test(f)).map(f => Number(f.slice(0, -5)));
39
+ const version = (nums.length ? Math.max(...nums) : 0) + 1;
40
+ const entry = build(version);
41
+ try { await writeFile(join(dir, `${pad(version)}.json`), JSON.stringify(entry, null, 2), { flag: "wx" }); return entry; }
42
+ catch (e) { if (e.code !== "EEXIST") throw e; }
43
+ }
44
+ throw new Error(`${dir}: could not allocate a version number`);
45
+ }