smoodly 0.0.7 → 0.0.8
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.
- package/README.md +88 -2
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +64 -8
- package/dist/admin/index.d.ts +3 -2
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +84 -1
- package/dist/admin/ops.d.ts +22 -1
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +8 -0
- package/dist/admin/shared-items.d.ts +9 -0
- package/dist/admin/shared-items.js +61 -0
- package/dist/admin/shell/AdminApp.js +8 -2
- package/dist/admin/shell/SharedForm.d.ts +7 -0
- package/dist/admin/shell/SharedForm.js +146 -0
- package/dist/admin/shell/SharedList.d.ts +6 -0
- package/dist/admin/shell/SharedList.js +62 -0
- package/dist/admin/shell/shared-list.d.ts +14 -0
- package/dist/admin/shell/shared-list.js +17 -0
- package/dist/admin/tree-ops.d.ts +12 -5
- package/dist/admin/tree-ops.js +39 -8
- package/dist/collections.d.ts +4 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.js +35 -1
- package/dist/entry-store.d.ts +9 -1
- package/dist/entry-store.js +25 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -1
- package/dist/next/page-renderer.d.ts +4 -0
- package/dist/next/page-renderer.js +14 -1
- package/dist/page.js +5 -1
- package/dist/refs.js +8 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +38 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +4 -0
- package/dist/shared.d.ts +46 -0
- package/dist/shared.js +36 -0
- package/dist/site.d.ts +10 -0
- package/dist/site.js +28 -1
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +4 -9
- package/dist/sql.js +0 -18
- package/dist/supabase-entry-store.d.ts +1 -1
- package/dist/supabase-entry-store.js +23 -5
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -174,8 +174,9 @@ Implemented:
|
|
|
174
174
|
for self-host: the anon key now reads published rows straight off the
|
|
175
175
|
base tables (`pages` and `entries` published in any locale, a published
|
|
176
176
|
`page_locales`/`entry_locales` row, the `page_versions`/`entry_versions`
|
|
177
|
-
row a published locale points at
|
|
178
|
-
the generated
|
|
177
|
+
row a published locale points at — shared content included, since it
|
|
178
|
+
is stored as entries, spec 2026-09-07), not only through the generated
|
|
179
|
+
views.
|
|
179
180
|
|
|
180
181
|
- Admin login: `supabaseAdminAuth` + `gateAdminOp` around the ops seam;
|
|
181
182
|
`LoginView` + `useAdminSession` in the shell; `createSmoodlyAdmin({ auth })`.
|
|
@@ -301,6 +302,18 @@ Implemented:
|
|
|
301
302
|
the only way the node gets there — it also covers pages that existed
|
|
302
303
|
before a locked zone was added to their schema.
|
|
303
304
|
|
|
305
|
+
- Shared content (spec 2026-09-07): `smoodly.shared({ name, title, section })`
|
|
306
|
+
registers a visual item, `smoodly.shared({ name, title, fields })` an
|
|
307
|
+
object item; one registration materializes as a singleton entry in
|
|
308
|
+
every supported locale. `site.getShared(item, { locale })` and the Next
|
|
309
|
+
glue's `getSmoodlyShared(item, { locale })` fetch it code-side; the
|
|
310
|
+
editor places a visual item in a zone from the "+" picker as a linked
|
|
311
|
+
`{ type: "shared", fields: { item, ref } }` node the resolve pass
|
|
312
|
+
rewrites into the section, and `z.locked(footer)` binds a zone to one.
|
|
313
|
+
Edited on its own admin page under Shared content (`/admin/shared/<name>`),
|
|
314
|
+
with "used on N pages" from `refs`. Wired through the `shared` option
|
|
315
|
+
on the entry store and the site layer.
|
|
316
|
+
|
|
304
317
|
Not yet: richtext components (TipTap; `plainToRichtext`/
|
|
305
318
|
`richtextToPlain` exist but no rich editor widget), drag-and-drop
|
|
306
319
|
reordering (move-up/move-down buttons in the editor, Move up / Move down
|
|
@@ -323,6 +336,79 @@ Both packages move together. From the repo root:
|
|
|
323
336
|
`examples/site` in that build, so the template always matches the example
|
|
324
337
|
site at the tagged commit.
|
|
325
338
|
|
|
339
|
+
## Follow-ups (logged 2026-09-07, shared content spec)
|
|
340
|
+
|
|
341
|
+
- `f.shared(() => item)` — a ref field on a section that renders a shared
|
|
342
|
+
item inside the section's own markup (spec 2026-09-07 decision 3).
|
|
343
|
+
- Instance mode: a shared *type* with many editor-created items; today
|
|
344
|
+
the answer is a collection plus a section with `f.ref` (decision 1).
|
|
345
|
+
- Zone-shaped shared items (`smoodly.shared({ zone })`) (decision 2).
|
|
346
|
+
- Standalone live preview on a visual item's admin page — needs a
|
|
347
|
+
preview route in the glue; the page canvas shows placements today (§6).
|
|
348
|
+
- Built-in meta on shared items and on placements (anchor per placement
|
|
349
|
+
is the likely shape).
|
|
350
|
+
- Per-placement overrides (a shared band with a per-page heading) — out
|
|
351
|
+
of scope; use a normal section with `f.ref` fields.
|
|
352
|
+
- `fillLockedZones` for a zone bound to a shared item waits for
|
|
353
|
+
`ops.shared.list()`; a page opened before that resolves fills on the
|
|
354
|
+
next effect run. Not seen in the walk — the example binds no zone, so
|
|
355
|
+
the path was never exercised — but a double fill is unreachable by
|
|
356
|
+
construction: the effect depends on `sharedIds`, `record`, `mutate` and
|
|
357
|
+
`tree === null`, and `fillLockedZones` only touches EMPTY zones.
|
|
358
|
+
`z.locked(item)` still has no example usage.
|
|
359
|
+
- `SharedForm` lists linking pages by id; a title needs `ops.pages.get`
|
|
360
|
+
per page (or a usage op returning titles).
|
|
361
|
+
- `getSmoodlyShared` reads the store per request in draft mode and once
|
|
362
|
+
per (item, locale) otherwise; a layout calling it for several items
|
|
363
|
+
makes one round trip each — batch if it shows.
|
|
364
|
+
- The Supabase entry store's singleton check is SELECT-then-INSERT with
|
|
365
|
+
no unique index; two admin loads racing on an item's first
|
|
366
|
+
materialization can create two rows. `ensureSharedItems` reads the
|
|
367
|
+
first row either way, and the duplicate cannot be removed through the
|
|
368
|
+
API — `delete` refuses every shared row. Two ways to make the race
|
|
369
|
+
impossible: a deterministic row id (uuid v5 of the item name, so the
|
|
370
|
+
second insert fails on the primary key — no schema change), or a
|
|
371
|
+
per-item partial unique index emitted by the SQL generator the way
|
|
372
|
+
`collectionSQL` emits the rest.
|
|
373
|
+
- `SharedForm.tsx` is a near-verbatim fork of `EntryForm.tsx`
|
|
374
|
+
(`localeFromUrl`, `writeLocaleToUrl`, `applyResult`, `InfoRow`, the
|
|
375
|
+
ref-options effect) and `SharedList.tsx` is the third copy of the
|
|
376
|
+
remembered-locale block (`EntriesList`, `PagesList`); extract
|
|
377
|
+
`shell/locale-url.ts` and a `useFormLocale` hook when the next form
|
|
378
|
+
arrives.
|
|
379
|
+
- A placement selected in a bound locked zone shows the canvas toolbar's
|
|
380
|
+
"fields only" label though a placement has no page-level fields; the
|
|
381
|
+
inspector notice is the real message.
|
|
382
|
+
- A zone whose policy allows a shared item's section also offers that
|
|
383
|
+
section as an ordinary block, so the picker lists e.g. Testimonials
|
|
384
|
+
twice (once under Shared); decide whether a section used by a shared
|
|
385
|
+
item should be hidden from the ordinary list.
|
|
386
|
+
- A placement of an object item or an unregistered item gates the page's
|
|
387
|
+
publish (the refs edge is emitted without a registry lookup) yet never
|
|
388
|
+
renders; only a hand-edited tree can produce one.
|
|
389
|
+
- `ops.shared.get` runs `ensureSharedItems` and then lists the item
|
|
390
|
+
again; one round trip could go.
|
|
391
|
+
- The example's Testimonials `quotes` richtext is required, so the first
|
|
392
|
+
admin save of the seeded band asks for it; make it optional or seed it.
|
|
393
|
+
- `CollectionSchema.kind` was widened to carry `"shared"`, so a
|
|
394
|
+
`SharedSchema` typechecks where `registry.collections` is expected;
|
|
395
|
+
`defineConfig` should throw on a `kind === "shared"` entry listed there.
|
|
396
|
+
- `ensureSharedItems` runs on every `ops.shared.list()` — which is every
|
|
397
|
+
editor mount, not only the Shared content page — costing one
|
|
398
|
+
`entries.list` per registration per open.
|
|
399
|
+
- `fanOut` resolves every shared registration's row id (one
|
|
400
|
+
`entries.list` each) on every entry write, so it can expire
|
|
401
|
+
`shared:<name>` when something the item references changes; a row-id
|
|
402
|
+
cache would remove the reads but has to survive materialization.
|
|
403
|
+
- Because `fanOut` lists every shared registration, an entry store built
|
|
404
|
+
without `options.shared` now fails on every ordinary entry write with
|
|
405
|
+
"no collection named", not only on `ops.shared.*`; both scaffolds pass
|
|
406
|
+
the same `shared` array to the store and the config, so this bites only
|
|
407
|
+
a hand-wired app.
|
|
408
|
+
- `ops.entries.move` expires entry tags directly and does not `fanOut`,
|
|
409
|
+
so a reorder that changes what a shared item's `refList` renders does
|
|
410
|
+
not expire `shared:<name>`.
|
|
411
|
+
|
|
326
412
|
## Follow-ups (logged 2026-09-06)
|
|
327
413
|
|
|
328
414
|
- Drag-and-drop in the Pages list — the ops it needs exist
|
package/dist/admin/client-ops.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const GROUPS = {
|
|
2
2
|
pages: ["list", "get", "create", "saveDraft", "publish", "delete", "rename", "move", "setTemplate", "addLocale", "removeLocale"],
|
|
3
3
|
entries: ["list", "get", "create", "save", "setStatus", "delete", "move", "addLocale", "removeLocale"],
|
|
4
|
+
shared: ["list", "get", "save", "setStatus"],
|
|
4
5
|
preview: ["enable", "disable"],
|
|
5
6
|
};
|
|
6
7
|
export function makeClientOps(action, token) {
|
|
@@ -19,11 +19,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
19
19
|
// Core code: no next/* imports live here — the bridge is the only piece
|
|
20
20
|
// that knows about Next, and it ships to the site, not the admin.
|
|
21
21
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
22
|
+
import { SHARED_NODE_TYPE } from "../../shared.js";
|
|
22
23
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
23
24
|
import { tabsFor } from "../forms/tabs.js";
|
|
24
|
-
import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, newNodeId, nodeFromSample, patchNode, removeNode, zoneOf, } from "../tree-ops.js";
|
|
25
|
+
import { allowedSections, allowedShared, canInsert, fillLockedZones, findNode, insertNode, moveNode, newNodeId, nodeFromSample, patchNode, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
|
|
25
26
|
import { useDraftAutosave } from "./autosave.js";
|
|
26
27
|
import { PageSettings } from "./PageSettings.js";
|
|
28
|
+
import { BASE } from "../shell/base.js";
|
|
27
29
|
import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree.js";
|
|
28
30
|
import { entryTitle } from "../shell/entries-list.js";
|
|
29
31
|
import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
@@ -81,6 +83,9 @@ export function EditorView({ ops, registry, id }) {
|
|
|
81
83
|
const [banner, setBanner] = useState(null);
|
|
82
84
|
const [published, setPublished] = useState(false);
|
|
83
85
|
const [refOptions, setRefOptions] = useState({});
|
|
86
|
+
// Shared item name → row id, loaded once per editor mount: the picker's
|
|
87
|
+
// Shared group and a bound locked zone need the id to place a node.
|
|
88
|
+
const [sharedIds, setSharedIds] = useState(null);
|
|
84
89
|
const [hoverPlus, setHoverPlus] = useState(null);
|
|
85
90
|
const iframeRef = useRef(null);
|
|
86
91
|
const scrollerRef = useRef(null);
|
|
@@ -290,6 +295,12 @@ export function EditorView({ ops, registry, id }) {
|
|
|
290
295
|
const zoneOrder = useMemo(() => Object.keys(template?.zones ?? {}), [template]);
|
|
291
296
|
const policyOf = useCallback((zone) => (zone ? template?.zones[zone] : undefined), [template]);
|
|
292
297
|
const sectionOf = useCallback((type) => registry.sections.find((s) => s.name === type) ?? null, [registry]);
|
|
298
|
+
/** The shared item a placement node places, or null for any other node. */
|
|
299
|
+
const sharedOf = useCallback((node) => {
|
|
300
|
+
if (node.type !== SHARED_NODE_TYPE)
|
|
301
|
+
return null;
|
|
302
|
+
return registry.shared.find((s) => s.name === node.fields?.item) ?? null;
|
|
303
|
+
}, [registry]);
|
|
293
304
|
// ── ref fields inside sections: same option loading as EntryForm ──────
|
|
294
305
|
const refTargets = useMemo(() => {
|
|
295
306
|
const out = {};
|
|
@@ -329,6 +340,27 @@ export function EditorView({ ops, registry, id }) {
|
|
|
329
340
|
}
|
|
330
341
|
return () => { cancelled = true; };
|
|
331
342
|
}, [ops, refTargets, registry, locale]);
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
if (registryRef.current.shared.length === 0) {
|
|
345
|
+
setSharedIds({});
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
let cancelled = false;
|
|
349
|
+
// A failed load leaves the map empty: the picker's Shared group and
|
|
350
|
+
// any zone bound to an item degrade to nothing to place, not a stuck
|
|
351
|
+
// editor — so say why in the console, like the ref options above.
|
|
352
|
+
ops.shared.list()
|
|
353
|
+
.then((r) => {
|
|
354
|
+
if (cancelled)
|
|
355
|
+
return;
|
|
356
|
+
if (!r.ok)
|
|
357
|
+
console.error(r.message);
|
|
358
|
+
setSharedIds(r.ok ? Object.fromEntries(r.data.map((i) => [i.name, i.record.id])) : {});
|
|
359
|
+
})
|
|
360
|
+
.catch((e) => { console.error(e); if (!cancelled)
|
|
361
|
+
setSharedIds({}); });
|
|
362
|
+
return () => { cancelled = true; };
|
|
363
|
+
}, [ops]);
|
|
332
364
|
// ── canvas messaging ─────────────────────────────────────────────────
|
|
333
365
|
const postToCanvas = useCallback((m) => {
|
|
334
366
|
iframeRef.current?.contentWindow?.postMessage(m, "*");
|
|
@@ -386,6 +418,19 @@ export function EditorView({ ops, registry, id }) {
|
|
|
386
418
|
});
|
|
387
419
|
mark();
|
|
388
420
|
}, [mark]);
|
|
421
|
+
// A zone bound to a shared item needs the item's row id (ops.shared.list),
|
|
422
|
+
// which can land after the draft did. Fill then; nothing else changes.
|
|
423
|
+
useEffect(() => {
|
|
424
|
+
if (!tree || !sharedIds || !record)
|
|
425
|
+
return;
|
|
426
|
+
const template = registryRef.current.pages.find((p) => p.name === record.template);
|
|
427
|
+
const filled = fillLockedZones(tree, template?.zones ?? {}, registryRef.current.sections, sharedIds);
|
|
428
|
+
if (filled !== tree)
|
|
429
|
+
mutate(() => filled);
|
|
430
|
+
// `tree` is deliberately not a dependency: this runs when the ids land
|
|
431
|
+
// or a new draft loads, not on every keystroke.
|
|
432
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
433
|
+
}, [sharedIds, record, mutate, tree === null]);
|
|
389
434
|
// ── tree gestures ────────────────────────────────────────────────────
|
|
390
435
|
const nodesIn = useCallback((zone) => tree?.zones[zone] ?? [], [tree]);
|
|
391
436
|
const zoneCanTake = useCallback((zone) => canInsert(policyOf(zone), nodesIn(zone).length), [policyOf, nodesIn]);
|
|
@@ -395,6 +440,15 @@ export function EditorView({ ops, registry, id }) {
|
|
|
395
440
|
setPicker(null);
|
|
396
441
|
select(node.id);
|
|
397
442
|
};
|
|
443
|
+
const doInsertShared = (zone, index, item) => {
|
|
444
|
+
const ref = sharedIds?.[item.name];
|
|
445
|
+
if (!ref)
|
|
446
|
+
return;
|
|
447
|
+
const node = placementNode(item, ref, newNodeId(SHARED_NODE_TYPE));
|
|
448
|
+
mutate((t) => insertNode(t, zone, index, node));
|
|
449
|
+
setPicker(null);
|
|
450
|
+
select(node.id);
|
|
451
|
+
};
|
|
398
452
|
const doDuplicate = (node) => {
|
|
399
453
|
if (!tree)
|
|
400
454
|
return;
|
|
@@ -477,6 +531,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
477
531
|
}
|
|
478
532
|
const selected = tree && selectedId ? findNode(tree, selectedId) : null;
|
|
479
533
|
const selectedSection = selected ? sectionOf(selected.type) : null;
|
|
534
|
+
const selectedShared = selected ? sharedOf(selected) : null;
|
|
480
535
|
const selZone = tree && selected ? zoneOf(tree, selected.id) : null;
|
|
481
536
|
const selLocked = policyOf(selZone)?.kind === "locked";
|
|
482
537
|
const rectById = new Map(rects.map((r) => [r.id, r]));
|
|
@@ -534,12 +589,12 @@ export function EditorView({ ops, registry, id }) {
|
|
|
534
589
|
width: hoverRect.w, height: hoverRect.h,
|
|
535
590
|
outline: "1.5px solid color-mix(in srgb, var(--selection) 45%, transparent)",
|
|
536
591
|
outlineOffset: -1.5, borderRadius: "var(--radius-sm)",
|
|
537
|
-
} })), selRect && selected && selectedSection && (_jsxs(_Fragment, { children: [_jsx("div", { style: {
|
|
592
|
+
} })), selRect && selected && (selectedSection || selectedShared) && (_jsxs(_Fragment, { children: [_jsx("div", { style: {
|
|
538
593
|
position: "absolute", left: selRect.x, top: selRect.y,
|
|
539
594
|
width: selRect.w, height: selRect.h,
|
|
540
595
|
outline: "var(--outline-selection)", outlineOffset: -1.5,
|
|
541
596
|
borderRadius: "var(--radius-sm)",
|
|
542
|
-
} }), _jsxs("span", { className: "sm-blocktag", style: { position: "absolute", left: selRect.x, top: selRect.y }, children: [selectedSection
|
|
597
|
+
} }), _jsxs("span", { className: "sm-blocktag", style: { position: "absolute", left: selRect.x, top: selRect.y }, children: [selectedSection?.title ?? selectedShared?.title, selectedShared && " · shared", selLocked && " · locked"] }), _jsx("div", { className: "sm-blocktoolbar", style: {
|
|
543
598
|
position: "absolute", left: selRect.x + selRect.w - 8, top: selRect.y + 8,
|
|
544
599
|
transform: "translateX(-100%)", pointerEvents: "auto",
|
|
545
600
|
}, children: selLocked ? (_jsx("span", { className: "sm-mono", style: { fontSize: "var(--text-xs)", color: "#8C94A8", padding: "4px 8px" }, children: "fields only" })) : (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Move up", tone: "inverse", onClick: () => doMove(selected.id, -1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", tone: "inverse", onClick: () => doMove(selected.id, 1), children: "\u2193" }), _jsx(IconButton, { label: "Duplicate", tone: "inverse", onClick: () => doDuplicate(selected), children: "\u29C9" }), _jsx(IconButton, { label: "Remove", tone: "inverse", onClick: () => doRemove(selected.id), children: "\u00D7" })] })) })] })), plusButtons.map((p) => {
|
|
@@ -550,11 +605,11 @@ export function EditorView({ ops, registry, id }) {
|
|
|
550
605
|
display: "flex", flexDirection: "column", alignItems: "center", gap: 6,
|
|
551
606
|
pointerEvents: "auto",
|
|
552
607
|
}, children: [_jsx("button", { className: "sm-plus", "aria-label": `Add to ${p.zone}`, onMouseEnter: () => setHoverPlus(key), onMouseLeave: () => setHoverPlus((k) => (k === key ? null : k)), onClick: (e) => setPicker({ zone: p.zone, index: p.index, x: e.clientX - 150, y: e.clientY + 12 }), children: "+" }), hot && _jsxs("span", { className: "sm-tooltip", children: ["Add to ", p.zone] })] })] }, key));
|
|
553
|
-
})] })] }) })] }), _jsxs("aside", { className: "sm-inspector", children: [selected && selectedSection ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection
|
|
608
|
+
})] })] }) })] }), _jsxs("aside", { className: "sm-inspector", children: [selected && (selectedSection || selectedShared) ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? selectedShared?.title })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : (_jsx("div", { className: "sm-inspector__head", children: _jsx("span", { className: "sm-label", children: "Nothing selected \u2014 page settings below" }) })), _jsxs("div", { className: "sm-inspector__body", children: [!selected && record && (_jsxs(_Fragment, { children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
554
609
|
!isFixedNode(record, registry) &&
|
|
555
610
|
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
556
611
|
? () => { void doRemoveLocale(); }
|
|
557
|
-
: undefined }), _jsx(Divider, { bleed: 14 })] })), selected && selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), onChange: (value) => doPatch(selected.id, ns, key, value) }, `${selected.id}.${ns}.${key}`))), _jsx(Divider, { bleed: 14 }), _jsx(SectionLabel, { children: "Outline" }), zoneOrder.map((zone) => {
|
|
612
|
+
: undefined }), _jsx(Divider, { bleed: 14 })] })), selected && selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), onChange: (value) => doPatch(selected.id, ns, key, value) }, `${selected.id}.${ns}.${key}`))), selected && selectedShared && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, children: [_jsx("p", { className: "sm-hint", children: "This is shared content. Editing it changes every page it is placed on." }), _jsx("a", { className: "sm-nav-item", href: `${BASE}/shared/${encodeURIComponent(selectedShared.name)}?locale=${encodeURIComponent(locale)}`, children: "Edit shared item \u2192" })] })), _jsx(Divider, { bleed: 14 }), _jsx(SectionLabel, { children: "Outline" }), zoneOrder.map((zone) => {
|
|
558
613
|
const locked = policyOf(zone)?.kind === "locked";
|
|
559
614
|
const nodes = nodesIn(zone);
|
|
560
615
|
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { mono: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", zoneCanTake(zone) && "sm-outline-row--clickable"), onClick: () => {
|
|
@@ -567,15 +622,16 @@ export function EditorView({ ops, registry, id }) {
|
|
|
567
622
|
const section = sectionOf(node.type);
|
|
568
623
|
const isSelected = node.id === selectedId;
|
|
569
624
|
if (locked) {
|
|
570
|
-
return (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", onClick: () => select(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: section?.title ?? node.type }), _jsx("span", { style: { fontSize: "var(--text-xs)", fontWeight: 600, letterSpacing: ".06em" }, children: "LOCKED" })] }, node.id));
|
|
625
|
+
return (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", onClick: () => select(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: section?.title ?? (sharedOf(node) ? `${sharedOf(node).title} · shared` : node.type) }), _jsx("span", { style: { fontSize: "var(--text-xs)", fontWeight: 600, letterSpacing: ".06em" }, children: "LOCKED" })] }, node.id));
|
|
571
626
|
}
|
|
572
|
-
return (_jsxs("div", { onClick: () => select(node.id), className: cx("sm-outline-row", "sm-outline-row--clickable", isSelected && "sm-outline-row--selected"), children: [_jsx("span", { className: "sm-outline-row__label", children: section?.title ?? node.type }), _jsxs("span", { style: { display: "flex", gap: 2, flex: "none" }, children: [_jsx(IconButton, { label: "Move up", size: 22, disabled: i === 0, onClick: (e) => { e.stopPropagation(); doMove(node.id, -1); }, children: "\u2191" }), _jsx(IconButton, { label: "Move down", size: 22, disabled: i === nodes.length - 1, onClick: (e) => { e.stopPropagation(); doMove(node.id, 1); }, children: "\u2193" }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); doRemove(node.id); }, children: "\u00D7" })] })] }, node.id));
|
|
627
|
+
return (_jsxs("div", { onClick: () => select(node.id), className: cx("sm-outline-row", "sm-outline-row--clickable", isSelected && "sm-outline-row--selected"), children: [_jsx("span", { className: "sm-outline-row__label", children: section?.title ?? (sharedOf(node) ? `${sharedOf(node).title} · shared` : node.type) }), _jsxs("span", { style: { display: "flex", gap: 2, flex: "none" }, children: [_jsx(IconButton, { label: "Move up", size: 22, disabled: i === 0, onClick: (e) => { e.stopPropagation(); doMove(node.id, -1); }, children: "\u2191" }), _jsx(IconButton, { label: "Move down", size: 22, disabled: i === nodes.length - 1, onClick: (e) => { e.stopPropagation(); doMove(node.id, 1); }, children: "\u2193" }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); doRemove(node.id); }, children: "\u00D7" })] })] }, node.id));
|
|
573
628
|
})] }, zone));
|
|
574
629
|
})] })] })] }), picker && (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: () => setPicker(null), children: _jsxs("div", { className: "sm-picker", onClick: (e) => e.stopPropagation(), style: {
|
|
575
630
|
position: "absolute",
|
|
576
631
|
left: Math.max(12, Math.min(picker.x, window.innerWidth - 320)),
|
|
577
632
|
top: Math.min(picker.y, Math.max(12, window.innerHeight - 240)),
|
|
578
|
-
}, children: [_jsxs(SectionLabel, { mono: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", picker.zone] }), allowedSections(policyOf(picker.zone), registry.sections).map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => doInsert(picker.zone, picker.index, section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))),
|
|
633
|
+
}, children: [_jsxs(SectionLabel, { mono: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", picker.zone] }), allowedSections(policyOf(picker.zone), registry.sections).map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => doInsert(picker.zone, picker.index, section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), allowedShared(policyOf(picker.zone), registry.shared, sharedIds ?? {}).length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { mono: true, style: { padding: "8px 8px 4px" }, children: "Shared" }), allowedShared(policyOf(picker.zone), registry.shared, sharedIds ?? {}).map((item) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => doInsertShared(picker.zone, picker.index, item), children: [_jsx("span", { className: "sm-picker__icon", children: "\u26D3" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: item.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Shared \u00B7 edited in one place" })] })] }, `shared-${item.name}`)))] })), allowedSections(policyOf(picker.zone), registry.sections).length === 0 &&
|
|
634
|
+
allowedShared(policyOf(picker.zone), registry.shared, sharedIds ?? {}).length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }))] }));
|
|
579
635
|
}
|
|
580
636
|
/** The selected node's ref field (if any) resolves to its target
|
|
581
637
|
* collection's loaded options — the EntryForm shape, section-scoped. */
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult } from "./ops.ts";
|
|
1
|
+
export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, SharedListItem, SharedGetResult } from "./ops.ts";
|
|
2
2
|
export { createAdminOps, runAdminOp, type AdminEffects } from "./ops-impl.ts";
|
|
3
3
|
export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes, type FixedNode } from "./fixed-nodes.ts";
|
|
4
|
+
export { ensureSharedItems, initialSharedFields } from "./shared-items.ts";
|
|
4
5
|
export { makeClientOps } from "./client-ops.ts";
|
|
5
6
|
export { gateAdminOp, type AdminAuth } from "./auth.ts";
|
|
6
7
|
export { supabaseAdminAuth } from "./supabase-auth.ts";
|
|
7
8
|
export { serializeAdminConfig } from "./serialize.ts";
|
|
8
|
-
export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminField } from "./serialize.ts";
|
|
9
|
+
export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminShared, AdminField } from "./serialize.ts";
|
|
9
10
|
export * from "./tree-ops.ts";
|
|
10
11
|
export { validateFields, type FieldError } from "./validate.ts";
|
|
11
12
|
export { plainToRichtext, richtextToPlain } from "./richtext.ts";
|
package/dist/admin/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { createAdminOps, runAdminOp } from "./ops-impl.js";
|
|
2
2
|
export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes } from "./fixed-nodes.js";
|
|
3
|
+
export { ensureSharedItems, initialSharedFields } from "./shared-items.js";
|
|
3
4
|
export { makeClientOps } from "./client-ops.js";
|
|
4
5
|
export { gateAdminOp } from "./auth.js";
|
|
5
6
|
export { supabaseAdminAuth } from "./supabase-auth.js";
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { entryFieldsIn } from "../entry-store.js";
|
|
2
|
-
import { affectedTargets, entryTag, pageTag } from "../revalidate.js";
|
|
2
|
+
import { affectedTargets, entryTag, pageTag, sharedTag } from "../revalidate.js";
|
|
3
3
|
import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
|
|
4
|
+
import { ensureSharedItems } from "./shared-items.js";
|
|
4
5
|
import { validateFields } from "./validate.js";
|
|
5
6
|
const ok = (data) => ({ ok: true, data });
|
|
6
7
|
const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
|
|
@@ -43,6 +44,15 @@ const classify = (e) => {
|
|
|
43
44
|
}
|
|
44
45
|
if (message.includes("does not exist in locale"))
|
|
45
46
|
return err("not_found", message);
|
|
47
|
+
if (message.includes("cannot take a slug"))
|
|
48
|
+
return err("validation", message);
|
|
49
|
+
// Ahead of the generic "already exists" check below: the singleton
|
|
50
|
+
// refusal's message ("...already exists — it is a singleton.") contains
|
|
51
|
+
// "already exists" as a substring, so it must classify here first or it
|
|
52
|
+
// would be misread as a plain conflict.
|
|
53
|
+
if (message.includes("is owned by code") || message.includes("it is a singleton") || message.includes("exists in every locale")) {
|
|
54
|
+
return err("blocked", message);
|
|
55
|
+
}
|
|
46
56
|
if (message.includes("already exists"))
|
|
47
57
|
return err("conflict", message);
|
|
48
58
|
if (message.includes("is referenced by"))
|
|
@@ -75,6 +85,9 @@ export function createAdminOps(deps) {
|
|
|
75
85
|
const href = deps.href ?? ((path) => path);
|
|
76
86
|
const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
|
|
77
87
|
const sectionFields = (type) => config.registry.sections?.find((s) => s.schema.name === type)?.schema;
|
|
88
|
+
const sharedItem = (name) => config.registry.shared?.find((s) => s.name === name) ?? null;
|
|
89
|
+
/** One row per registration; null when materialization could not create it. */
|
|
90
|
+
const sharedRecord = async (name) => (await entries.list(name))[0] ?? null;
|
|
78
91
|
const fanOut = async (entryId) => {
|
|
79
92
|
if (!effects?.revalidate)
|
|
80
93
|
return;
|
|
@@ -83,6 +96,24 @@ export function createAdminOps(deps) {
|
|
|
83
96
|
effects.revalidate(entryTag(id));
|
|
84
97
|
for (const id of pageIds)
|
|
85
98
|
effects.revalidate(pageTag(id));
|
|
99
|
+
// A shared item's row is an entry like any other: when the item
|
|
100
|
+
// itself changes OR when something it REFERENCES does, the unit
|
|
101
|
+
// `getSmoodlyShared` caches under `shared:<name>` is stale too — and
|
|
102
|
+
// that unit is a layout, on every page. `affectedTargets` returns row
|
|
103
|
+
// ids, so resolve name → row id here. Once per fan-out, not memoized
|
|
104
|
+
// per ops instance: a row can be materialized between two calls, and
|
|
105
|
+
// an entry write is rare enough that one `entries.list` per
|
|
106
|
+
// registration beats reasoning about a stale cache. Last, so the
|
|
107
|
+
// existing entry-then-page order is untouched.
|
|
108
|
+
const registered = config.registry.shared ?? [];
|
|
109
|
+
if (registered.length === 0)
|
|
110
|
+
return;
|
|
111
|
+
const touched = new Set(entryIds);
|
|
112
|
+
for (const item of registered) {
|
|
113
|
+
const row = (await entries.list(item.name))[0];
|
|
114
|
+
if (row && touched.has(row.id))
|
|
115
|
+
effects.revalidate(sharedTag(item.name));
|
|
116
|
+
}
|
|
86
117
|
};
|
|
87
118
|
/** Expire the cached units a tree change touched, in order, once each. */
|
|
88
119
|
const expirePages = (ids) => {
|
|
@@ -404,6 +435,58 @@ export function createAdminOps(deps) {
|
|
|
404
435
|
return ok(entry);
|
|
405
436
|
}),
|
|
406
437
|
},
|
|
438
|
+
shared: {
|
|
439
|
+
list: () => guard(async () => {
|
|
440
|
+
const records = await ensureSharedItems(entries, config);
|
|
441
|
+
return ok((config.registry.shared ?? []).flatMap((s) => (records[s.name] ? [{ name: s.name, record: records[s.name] }] : [])));
|
|
442
|
+
}),
|
|
443
|
+
get: (name, locale) => guard(async () => {
|
|
444
|
+
const unsupported = supportedLocale(locale);
|
|
445
|
+
if (unsupported)
|
|
446
|
+
return unsupported;
|
|
447
|
+
const item = sharedItem(name);
|
|
448
|
+
if (!item)
|
|
449
|
+
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
450
|
+
await ensureSharedItems(entries, config);
|
|
451
|
+
const record = await sharedRecord(name);
|
|
452
|
+
if (!record?.locales[locale])
|
|
453
|
+
return err("not_found", `smoodly: shared item "${name}" does not exist in locale "${locale}".`);
|
|
454
|
+
const usage = await entries.referencesTo(name, record.id);
|
|
455
|
+
return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage });
|
|
456
|
+
}),
|
|
457
|
+
save: (name, locale, fields) => guard(async () => {
|
|
458
|
+
const unsupported = supportedLocale(locale);
|
|
459
|
+
if (unsupported)
|
|
460
|
+
return unsupported;
|
|
461
|
+
const item = sharedItem(name);
|
|
462
|
+
if (!item)
|
|
463
|
+
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
464
|
+
const fieldErrors = validateFields(fields, item.fields);
|
|
465
|
+
if (fieldErrors.length > 0)
|
|
466
|
+
return err("validation", "Some fields need attention.", fieldErrors);
|
|
467
|
+
const record = await sharedRecord(name);
|
|
468
|
+
if (!record)
|
|
469
|
+
return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
|
|
470
|
+
const saved = await entries.update(name, record.id, locale, fields);
|
|
471
|
+
// fanOut expires `shared:<name>` itself — this row IS the item's.
|
|
472
|
+
await fanOut(record.id);
|
|
473
|
+
return ok(saved);
|
|
474
|
+
}),
|
|
475
|
+
setStatus: (name, locale, status) => guard(async () => {
|
|
476
|
+
const unsupported = supportedLocale(locale);
|
|
477
|
+
if (unsupported)
|
|
478
|
+
return unsupported;
|
|
479
|
+
if (!sharedItem(name))
|
|
480
|
+
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
481
|
+
const record = await sharedRecord(name);
|
|
482
|
+
if (!record)
|
|
483
|
+
return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
|
|
484
|
+
const changed = await entries.setStatus(name, record.id, locale, status);
|
|
485
|
+
// see save: the fan-out carries the item's own tag.
|
|
486
|
+
await fanOut(record.id);
|
|
487
|
+
return ok(changed);
|
|
488
|
+
}),
|
|
489
|
+
},
|
|
407
490
|
preview: {
|
|
408
491
|
enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
|
|
409
492
|
disable: () => guard(async () => (await effects?.draft?.disable(), ok(null))),
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PageRecord } from "../store.ts";
|
|
2
2
|
import type { PageTree } from "../render.tsx";
|
|
3
|
-
import type { EntryRecord } from "../entry-store.ts";
|
|
3
|
+
import type { EntryRecord, EntryUsage } from "../entry-store.ts";
|
|
4
4
|
import type { FieldError } from "./validate.ts";
|
|
5
5
|
export type OpError = {
|
|
6
6
|
ok: false;
|
|
@@ -56,6 +56,20 @@ export type EntryGetResult = {
|
|
|
56
56
|
/** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
|
|
57
57
|
parentLocales: string[];
|
|
58
58
|
};
|
|
59
|
+
export type SharedListItem = {
|
|
60
|
+
name: string;
|
|
61
|
+
record: EntryRecord;
|
|
62
|
+
};
|
|
63
|
+
export type SharedGetResult = {
|
|
64
|
+
name: string;
|
|
65
|
+
/** The node with ALL its locale rows (every supported one, by construction). */
|
|
66
|
+
record: EntryRecord;
|
|
67
|
+
locale: string;
|
|
68
|
+
/** That locale's merged draft fields — a visual item's styles under `$styles`. */
|
|
69
|
+
fields: Record<string, unknown>;
|
|
70
|
+
/** Who links it: "used on N pages". */
|
|
71
|
+
usage: EntryUsage[];
|
|
72
|
+
};
|
|
59
73
|
export type AdminOps = {
|
|
60
74
|
pages: {
|
|
61
75
|
/** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
|
|
@@ -105,6 +119,13 @@ export type AdminOps = {
|
|
|
105
119
|
/** Deletes the locale's row, versions and paths; fans out like a save. */
|
|
106
120
|
removeLocale(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
|
|
107
121
|
};
|
|
122
|
+
shared: {
|
|
123
|
+
/** Materializes every registered item in every supported locale, then lists them in registry order. */
|
|
124
|
+
list(): Promise<OpResult<SharedListItem[]>>;
|
|
125
|
+
get(name: string, locale: string): Promise<OpResult<SharedGetResult>>;
|
|
126
|
+
save(name: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
|
|
127
|
+
setStatus(name: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
|
|
128
|
+
};
|
|
108
129
|
preview: {
|
|
109
130
|
enable(): Promise<OpResult<null>>;
|
|
110
131
|
disable(): Promise<OpResult<null>>;
|
|
@@ -33,10 +33,21 @@ export type AdminPageTemplate = {
|
|
|
33
33
|
slugs?: Record<string, string>;
|
|
34
34
|
zones: Record<string, ZonePolicy>;
|
|
35
35
|
};
|
|
36
|
+
export type AdminShared = {
|
|
37
|
+
name: string;
|
|
38
|
+
title: string;
|
|
39
|
+
/** The section a visual item renders through; absent = an object item. */
|
|
40
|
+
section?: string;
|
|
41
|
+
versions: boolean;
|
|
42
|
+
fields: Record<string, AdminField>;
|
|
43
|
+
/** Resolved (the config's standard set merged in) for a visual item; empty for an object item. */
|
|
44
|
+
styles: Record<string, AdminField>;
|
|
45
|
+
};
|
|
36
46
|
export type AdminRegistry = {
|
|
37
47
|
sections: AdminSection[];
|
|
38
48
|
collections: AdminCollection[];
|
|
39
49
|
pages: AdminPageTemplate[];
|
|
50
|
+
shared: AdminShared[];
|
|
40
51
|
locales: {
|
|
41
52
|
default: string;
|
|
42
53
|
supported: string[];
|
package/dist/admin/serialize.js
CHANGED
|
@@ -53,6 +53,14 @@ export function serializeAdminConfig(config) {
|
|
|
53
53
|
zones: p.zones ?? {},
|
|
54
54
|
};
|
|
55
55
|
}),
|
|
56
|
+
shared: (config.registry.shared ?? []).map((s) => ({
|
|
57
|
+
name: s.name,
|
|
58
|
+
title: s.title,
|
|
59
|
+
...(s.section !== undefined ? { section: s.section } : {}),
|
|
60
|
+
versions: s.versions === true,
|
|
61
|
+
fields: serializeMap(s.fields),
|
|
62
|
+
styles: serializeMap(s.section !== undefined ? config.resolvedStyles(s.section) : {}),
|
|
63
|
+
})),
|
|
56
64
|
locales: config.locales,
|
|
57
65
|
homePageSlug: config.homePageSlug,
|
|
58
66
|
pageDepth: config.pageDepth,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../config.ts";
|
|
2
|
+
import type { Descriptor } from "../fields.ts";
|
|
3
|
+
import type { EntryRecord, EntryStore } from "../entry-store.ts";
|
|
4
|
+
import { type SharedSchema } from "../shared.ts";
|
|
5
|
+
/** The merged fields a fresh item starts from: sampled field values, and
|
|
6
|
+
* for a visual item `$styles` = the resolved style defaults with the
|
|
7
|
+
* sample's style values on top. Unsampled fields stay absent. */
|
|
8
|
+
export declare function initialSharedFields(item: SharedSchema, resolvedStyles: Record<string, Descriptor>): Record<string, unknown>;
|
|
9
|
+
export declare function ensureSharedItems(entries: EntryStore, config: ResolvedConfig): Promise<Record<string, EntryRecord>>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { STYLES_KEY } from "../shared.js";
|
|
2
|
+
/** The merged fields a fresh item starts from: sampled field values, and
|
|
3
|
+
* for a visual item `$styles` = the resolved style defaults with the
|
|
4
|
+
* sample's style values on top. Unsampled fields stay absent. */
|
|
5
|
+
export function initialSharedFields(item, resolvedStyles) {
|
|
6
|
+
const out = {};
|
|
7
|
+
for (const key of Object.keys(item.fields)) {
|
|
8
|
+
const sampled = item.sample?.[key];
|
|
9
|
+
if (sampled !== undefined)
|
|
10
|
+
out[key] = sampled;
|
|
11
|
+
}
|
|
12
|
+
if (item.section !== undefined) {
|
|
13
|
+
const styles = {};
|
|
14
|
+
for (const [key, d] of Object.entries(resolvedStyles)) {
|
|
15
|
+
const value = item.sample?.[key] ?? d.default;
|
|
16
|
+
if (value !== undefined)
|
|
17
|
+
styles[key] = value;
|
|
18
|
+
}
|
|
19
|
+
out[STYLES_KEY] = styles;
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
export async function ensureSharedItems(entries, config) {
|
|
24
|
+
const { default: def, supported } = config.locales;
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const item of config.registry.shared ?? []) {
|
|
27
|
+
let [record] = await entries.list(item.name);
|
|
28
|
+
if (!record) {
|
|
29
|
+
try {
|
|
30
|
+
const styles = item.section !== undefined ? config.resolvedStyles(item.section) : {};
|
|
31
|
+
record = await entries.create(item.name, initialSharedFields(item, styles), { locale: def });
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
35
|
+
// Another admin load won the create: read theirs. Anything else is
|
|
36
|
+
// worth saying out loud, and must not take the other items down.
|
|
37
|
+
if (message.includes("it is a singleton"))
|
|
38
|
+
[record] = await entries.list(item.name);
|
|
39
|
+
else
|
|
40
|
+
console.warn(`smoodly: could not materialize shared item "${item.name}": ${message}`);
|
|
41
|
+
if (!record)
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const locale of supported) {
|
|
46
|
+
if (record.locales[locale])
|
|
47
|
+
continue;
|
|
48
|
+
try {
|
|
49
|
+
record = await entries.addLocale(item.name, record.id, locale, { from: def });
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
53
|
+
if (!message.includes("already exists in locale")) {
|
|
54
|
+
console.warn(`smoodly: could not materialize shared item "${item.name}" in "${locale}": ${message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
out[item.name] = record;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
@@ -14,6 +14,8 @@ import { makeClientOps } from "../client-ops.js";
|
|
|
14
14
|
import { PagesList } from "./PagesList.js";
|
|
15
15
|
import { EntriesList } from "./EntriesList.js";
|
|
16
16
|
import { EntryForm } from "./EntryForm.js";
|
|
17
|
+
import { SharedList } from "./SharedList.js";
|
|
18
|
+
import { SharedForm } from "./SharedForm.js";
|
|
17
19
|
import { EditorView } from "../editor/EditorView.js";
|
|
18
20
|
import { LoginView } from "./LoginView.js";
|
|
19
21
|
import { useAdminSession } from "./session.js";
|
|
@@ -22,12 +24,12 @@ import { BASE } from "./base.js";
|
|
|
22
24
|
import { ErrorPane } from "./ErrorPane.js";
|
|
23
25
|
/** Nav items the registry can't produce yet: shown, disabled, so the shape
|
|
24
26
|
* of the product is visible — the same set the design system's SideNav has. */
|
|
25
|
-
const PLANNED = ["
|
|
27
|
+
const PLANNED = ["Media", "Settings"];
|
|
26
28
|
function Nav({ registry, active, email, onSignOut }) {
|
|
27
29
|
const item = (href, label, key) => (_jsx("a", { className: "sm-nav-item", href: href ?? undefined, "aria-disabled": href ? undefined : true, "aria-current": href && key === active ? "page" : undefined, children: label }, key));
|
|
28
30
|
const name = email?.split("@")[0] ?? "Editor";
|
|
29
31
|
const initials = name.slice(0, 2).toUpperCase();
|
|
30
|
-
return (_jsxs("nav", { className: "sm-nav", children: [_jsx(Logo, {}), _jsxs("div", { className: "sm-nav__list", children: [item(`${BASE}/pages`, "Pages", "pages"), registry.collections.map((c) => item(`${BASE}/${c.name}`, c.title, c.name)), PLANNED.map((label) => item(null, label, label))] }), _jsxs("div", { className: "sm-nav__foot", children: [_jsx(ThemeToggle, {}), _jsxs("div", { className: "sm-nav__user", children: [_jsx("span", { className: "sm-avatar", children: initials }), _jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0 }, children: [_jsx("span", { style: { fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: name }), _jsx("span", { style: { fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Editor" })] })] }), _jsx("button", { className: "sm-nav-item", onClick: onSignOut, style: { color: "var(--text-muted)" }, children: "Sign out" })] })] }));
|
|
32
|
+
return (_jsxs("nav", { className: "sm-nav", children: [_jsx(Logo, {}), _jsxs("div", { className: "sm-nav__list", children: [item(`${BASE}/pages`, "Pages", "pages"), registry.collections.map((c) => item(`${BASE}/${c.name}`, c.title, c.name)), registry.shared.length > 0 && item(`${BASE}/shared`, "Shared content", "shared"), PLANNED.map((label) => item(null, label, label))] }), _jsxs("div", { className: "sm-nav__foot", children: [_jsx(ThemeToggle, {}), _jsxs("div", { className: "sm-nav__user", children: [_jsx("span", { className: "sm-avatar", children: initials }), _jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0 }, children: [_jsx("span", { style: { fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: name }), _jsx("span", { style: { fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Editor" })] })] }), _jsx("button", { className: "sm-nav-item", onClick: onSignOut, style: { color: "var(--text-muted)" }, children: "Sign out" })] })] }));
|
|
31
33
|
}
|
|
32
34
|
export function AdminApp({ registry, segments, op, auth }) {
|
|
33
35
|
const session = useAdminSession(auth);
|
|
@@ -49,6 +51,10 @@ export function AdminApp({ registry, segments, op, auth }) {
|
|
|
49
51
|
main = _jsx(EntriesList, { ops: ops, registry: registry, collection: head });
|
|
50
52
|
else if (isCollection && tail)
|
|
51
53
|
main = _jsx(EntryForm, { ops: ops, registry: registry, collection: head, id: tail === "new" ? "new" : decodeURIComponent(tail) });
|
|
54
|
+
else if (head === "shared" && !tail)
|
|
55
|
+
main = _jsx(SharedList, { ops: ops, registry: registry });
|
|
56
|
+
else if (head === "shared" && tail)
|
|
57
|
+
main = _jsx(SharedForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
|
|
52
58
|
else
|
|
53
59
|
main = _jsx(ErrorPane, { message: "Not found." });
|
|
54
60
|
return (_jsxs("div", { className: "sm-shell", children: [_jsx(Nav, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main })] }));
|