@pramen/cms-editor 0.0.61 → 0.0.63
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 +18 -3
- package/dist/editor.css +1 -1
- package/dist/editor.js +155 -155
- package/package.json +3 -2
- package/src/api.ts +23 -6
- package/src/app-context.tsx +4 -0
- package/src/app.css +48 -0
- package/src/breadcrumb.tsx +48 -0
- package/src/chrome.ts +27 -0
- package/src/components.tsx +624 -183
- package/src/cover.tsx +163 -0
- package/src/furniture.tsx +121 -27
- package/src/icons.tsx +97 -0
- package/src/nav.ts +149 -15
- package/src/page-header.tsx +128 -0
- package/src/preview.ts +65 -0
- package/src/routes/_layout.tsx +511 -93
- package/src/routes/page.tsx +4 -0
- package/src/types.ts +62 -0
package/src/cover.tsx
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// The page header's generated cover art.
|
|
2
|
+
//
|
|
3
|
+
// A Notion page opens with a cover, and a CMS screen has the same problem a Notion page has:
|
|
4
|
+
// six list screens whose only difference is a word at the top read as one screen you keep
|
|
5
|
+
// landing on. The cover is what makes "Media" and "Menus" recognisable before the type is.
|
|
6
|
+
//
|
|
7
|
+
// So it is DERIVED, not chosen: a hash of the screen's name seeds a PRNG, and the PRNG lays
|
|
8
|
+
// out a Truchet field — a grid of quarter-circle arcs whose per-tile orientation is the only
|
|
9
|
+
// random thing about it. Same name, same picture, forever; a new collection gets its own
|
|
10
|
+
// without anyone drawing one. That is the whole appeal over a stock image: nothing to author,
|
|
11
|
+
// nothing to upload, and no screen that looks like another.
|
|
12
|
+
//
|
|
13
|
+
// Two rules govern every choice below, and both come from what this art sits UNDER:
|
|
14
|
+
//
|
|
15
|
+
// LEGIBILITY. The header carries 56px type and the primary action. Line work is
|
|
16
|
+
// `currentColor` at low alpha — a tint of the foreground, so it is dark on the light theme
|
|
17
|
+
// and light on the dark one BY CONSTRUCTION. That matters here specifically: podoba does
|
|
18
|
+
// not redefine its accent tokens per theme, so a fixed accent stroke would be near-white
|
|
19
|
+
// mint on a white ground in light, or near-black blue on a dark ground in dark. Colour
|
|
20
|
+
// therefore arrives as a WASH at ~0.14 alpha, which survives either ground, and the left
|
|
21
|
+
// third fades to the panel colour so the title never sits on pattern.
|
|
22
|
+
//
|
|
23
|
+
// NO DEPENDENCY, NO CANVAS, NO NETWORK. It is one inline SVG built during render: ~50
|
|
24
|
+
// paths, no measuring, no effects, no images to load, and it prints and scales.
|
|
25
|
+
|
|
26
|
+
import type { ReactElement } from "react";
|
|
27
|
+
|
|
28
|
+
/** Tiles across and down. The banner is wide and short and the SVG is `slice`-fitted, so
|
|
29
|
+
* these are a RATIO more than a count: the field fits by WIDTH and is cropped vertically,
|
|
30
|
+
* which is what makes it bleed to the panel edges at any header height.
|
|
31
|
+
*
|
|
32
|
+
* Tuned by looking at it. A 14×3 field puts ~80px tiles in a 1140px panel, and at that size a
|
|
33
|
+
* Truchet arc stops being a woven line and becomes a big soft loop — organic, which is the
|
|
34
|
+
* wrong register next to Swiss type. Finer tiles read as the precise, drawn-by-a-machine
|
|
35
|
+
* pattern this is meant to be. */
|
|
36
|
+
const COLS = 20;
|
|
37
|
+
const ROWS = 5;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* FNV-1a, 32-bit.
|
|
41
|
+
*
|
|
42
|
+
* Not for security — for a stable, well-mixed number from a short string. Stability is the
|
|
43
|
+
* requirement that rules out `Math.random` and anything else stateful: the same screen has to
|
|
44
|
+
* draw the same picture on every render, in every browser, after every deploy.
|
|
45
|
+
*
|
|
46
|
+
* `>>> 0` after the multiply keeps it in unsigned 32-bit; JS bitwise ops work on int32, so
|
|
47
|
+
* without it the accumulator goes negative and the mixing degrades.
|
|
48
|
+
*/
|
|
49
|
+
export function hashSeed(input: string): number {
|
|
50
|
+
let h = 0x811c9dc5;
|
|
51
|
+
for (let i = 0; i < input.length; i++) {
|
|
52
|
+
h ^= input.charCodeAt(i);
|
|
53
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
54
|
+
}
|
|
55
|
+
return h >>> 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** mulberry32 — a tiny, well-distributed PRNG. Seeded once per cover, so the sequence (and
|
|
59
|
+
* therefore the picture) is a pure function of the name. */
|
|
60
|
+
export function seededRandom(seed: number): () => number {
|
|
61
|
+
let a = seed >>> 0;
|
|
62
|
+
return () => {
|
|
63
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
64
|
+
let t = a;
|
|
65
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
66
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
67
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The accents a wash may use.
|
|
73
|
+
*
|
|
74
|
+
* A CLOSED list of podoba tokens, not a hue rotation: the point is a page that looks like it
|
|
75
|
+
* belongs to this product, and a free hue would put colours in the chrome that exist nowhere
|
|
76
|
+
* else in it. Every entry is mid-toned, which is what lets one alpha work on both the white
|
|
77
|
+
* and the near-black ground — the very light tokens (`accent-mint`) vanish on white and the
|
|
78
|
+
* very dark one (`accent-blue`) vanishes on the dark theme, so neither is in here.
|
|
79
|
+
*/
|
|
80
|
+
export const COVER_ACCENTS: readonly string[] = [
|
|
81
|
+
"var(--color-accent-strong)",
|
|
82
|
+
"var(--color-brand-green)",
|
|
83
|
+
"var(--color-accent-green-lighter)",
|
|
84
|
+
"var(--color-accent-yellow)",
|
|
85
|
+
"var(--color-accent-pink)",
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/** Which accent a given name gets. Exported because it is the one part of the picture worth
|
|
89
|
+
* asserting on directly: it must be stable, and it must be one of the five. */
|
|
90
|
+
export function accentFor(seed: string): string {
|
|
91
|
+
return COVER_ACCENTS[hashSeed(`${seed}:accent`) % COVER_ACCENTS.length] as string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One Truchet tile: two quarter arcs joining opposite edge midpoints, in one of two
|
|
95
|
+
* orientations. Both are drawn at unit scale and translated into place, so the whole field is
|
|
96
|
+
* resolution-independent and the stroke scales with the banner. */
|
|
97
|
+
function tilePath(col: number, row: number, flipped: boolean): string {
|
|
98
|
+
const x = col;
|
|
99
|
+
const y = row;
|
|
100
|
+
return flipped
|
|
101
|
+
? `M${x + 0.5} ${y} A0.5 0.5 0 0 0 ${x + 1} ${y + 0.5} M${x} ${y + 0.5} A0.5 0.5 0 0 0 ${x + 0.5} ${y + 1}`
|
|
102
|
+
: `M${x} ${y + 0.5} A0.5 0.5 0 0 0 ${x + 0.5} ${y} M${x + 1} ${y + 0.5} A0.5 0.5 0 0 0 ${x + 0.5} ${y + 1}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The cover for one screen, seeded by its name.
|
|
107
|
+
*
|
|
108
|
+
* Absolutely positioned to fill its (relative, `overflow-hidden`) parent — it is a
|
|
109
|
+
* background, and giving it its own box would make the header's height depend on the art
|
|
110
|
+
* rather than on the type in it.
|
|
111
|
+
*
|
|
112
|
+
* `aria-hidden`: it carries no information a reader could act on. The screen's name is in the
|
|
113
|
+
* `<h1>` right next to it, so announcing "image" here would add a stop on the way to the
|
|
114
|
+
* thing that is actually being labelled.
|
|
115
|
+
*/
|
|
116
|
+
export function CoverArt({ seed }: { seed: string }): ReactElement {
|
|
117
|
+
const rand = seededRandom(hashSeed(seed));
|
|
118
|
+
const accent = accentFor(seed);
|
|
119
|
+
// The wash's origin, kept to the RIGHT half: the title is set left, and colour pooling
|
|
120
|
+
// under it is exactly the contrast the header cannot afford.
|
|
121
|
+
const washX = 55 + rand() * 40;
|
|
122
|
+
const washY = 20 + rand() * 60;
|
|
123
|
+
|
|
124
|
+
const tiles: string[] = [];
|
|
125
|
+
for (let row = 0; row < ROWS; row++) {
|
|
126
|
+
for (let col = 0; col < COLS; col++) tiles.push(tilePath(col, row, rand() > 0.5));
|
|
127
|
+
}
|
|
128
|
+
// A few filled nodes where arcs meet — the one place the accent appears at full strength,
|
|
129
|
+
// and what stops a page's identity from being carried by geometry alone (two names can land
|
|
130
|
+
// on similar-looking fields; they will not also land on the same colour).
|
|
131
|
+
const dots = Array.from({ length: 5 }, () => ({
|
|
132
|
+
cx: 1 + rand() * (COLS - 2),
|
|
133
|
+
cy: 0.5 + rand() * (ROWS - 1),
|
|
134
|
+
r: 0.09 + rand() * 0.11,
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<svg
|
|
139
|
+
aria-hidden="true"
|
|
140
|
+
className="pointer-events-none absolute inset-0 h-full w-full text-fg"
|
|
141
|
+
viewBox={`0 0 ${COLS} ${ROWS}`}
|
|
142
|
+
preserveAspectRatio="xMidYMid slice"
|
|
143
|
+
>
|
|
144
|
+
<defs>
|
|
145
|
+
<radialGradient id={`wash-${hashSeed(seed)}`} cx={`${washX}%`} cy={`${washY}%`} r="70%">
|
|
146
|
+
<stop offset="0%" stopColor={accent} stopOpacity="0.34" />
|
|
147
|
+
<stop offset="100%" stopColor={accent} stopOpacity="0" />
|
|
148
|
+
</radialGradient>
|
|
149
|
+
</defs>
|
|
150
|
+
<rect width={COLS} height={ROWS} fill={`url(#wash-${hashSeed(seed)})`} />
|
|
151
|
+
<g fill="none" stroke="currentColor" strokeWidth="0.035" strokeLinecap="round" opacity="0.28">
|
|
152
|
+
{tiles.map((d) => (
|
|
153
|
+
<path key={d} d={d} />
|
|
154
|
+
))}
|
|
155
|
+
</g>
|
|
156
|
+
<g fill={accent} opacity="0.5">
|
|
157
|
+
{dots.map((c) => (
|
|
158
|
+
<circle key={`${c.cx}-${c.cy}`} cx={c.cx} cy={c.cy} r={c.r} />
|
|
159
|
+
))}
|
|
160
|
+
</g>
|
|
161
|
+
</svg>
|
|
162
|
+
);
|
|
163
|
+
}
|
package/src/furniture.tsx
CHANGED
|
@@ -11,29 +11,25 @@
|
|
|
11
11
|
|
|
12
12
|
import { Button, Heading, Input } from "@podoba/react";
|
|
13
13
|
import { useCallback, useEffect, useState } from "react";
|
|
14
|
-
import { useUnsavedGuard } from "./app-context";
|
|
14
|
+
import { useApp, useUnsavedGuard } from "./app-context";
|
|
15
15
|
import type { Api } from "./api";
|
|
16
16
|
import { CONTROL, RichText, slugify } from "./fields";
|
|
17
17
|
import { ROW, WRAP } from "./chrome";
|
|
18
|
+
import { useCrumb } from "./breadcrumb";
|
|
19
|
+
import { PageHeader } from "./page-header";
|
|
18
20
|
import type { CollectionMeta, Menu, MenuItem, MenuItemKind, Page, Redirect, RichTextDoc, Taxonomy, Term, Widget, WidgetArea } from "./types";
|
|
19
|
-
import { MAX_MENU_DEPTH, REDIRECT_STATUSES } from "./types";
|
|
21
|
+
import { MAX_MENU_DEPTH, REDIRECT_STATUSES, TAXONOMY_TARGET_LABELS, TAXONOMY_TARGETS } from "./types";
|
|
22
|
+
import type { TaxonomyTarget } from "./types";
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
export function errText(e: unknown): string {
|
|
23
26
|
return String((e as Error)?.message ?? e);
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
<span className="block text-fg-subtle">{lead}</span>
|
|
31
|
-
<span className="block text-fg">{em}</span>
|
|
32
|
-
</h1>
|
|
33
|
-
{children}
|
|
34
|
-
</div>
|
|
35
|
-
);
|
|
36
|
-
}
|
|
29
|
+
/** The site-furniture screens' header. `Head` stays as the local name the four call sites
|
|
30
|
+
* below already use; it is the same component, at the same scale, as every other screen —
|
|
31
|
+
* see the note in `page-header.tsx` about why the smaller variant went away. */
|
|
32
|
+
const Head = PageHeader;
|
|
37
33
|
|
|
38
34
|
function Saved() {
|
|
39
35
|
return <div className="rounded-lg border border-brand-green bg-brand-green/20 px-3.5 py-2.5 text-small text-fg">saved</div>;
|
|
@@ -84,9 +80,10 @@ export function MenusView({ api, onOpen, onError, canEdit }: { api: Api; onOpen:
|
|
|
84
80
|
};
|
|
85
81
|
|
|
86
82
|
return (
|
|
87
|
-
|
|
83
|
+
<>
|
|
88
84
|
<Head lead="Navigation" em={menus === null ? "Menus" : menus.length === 1 ? "1 menu" : `${menus.length} menus`} />
|
|
89
|
-
<div className=
|
|
85
|
+
<div className={WRAP}>
|
|
86
|
+
<div className="flex flex-col gap-2">
|
|
90
87
|
{menus === null ? <p className="text-fg-subtle">Loading…</p> : null}
|
|
91
88
|
{menus?.length === 0 ? <p className="text-fg-subtle">No menus yet. A menu is read by name — <code>getMenu("primary")</code> — from your layout.</p> : null}
|
|
92
89
|
{(menus ?? []).map((m) => (
|
|
@@ -110,7 +107,8 @@ export function MenusView({ api, onOpen, onError, canEdit }: { api: Api; onOpen:
|
|
|
110
107
|
<Button className="mt-3" onPress={create} isDisabled={busy || !label.trim() || !name.trim()}>Create menu</Button>
|
|
111
108
|
</div>
|
|
112
109
|
) : null}
|
|
113
|
-
|
|
110
|
+
</div>
|
|
111
|
+
</>
|
|
114
112
|
);
|
|
115
113
|
}
|
|
116
114
|
|
|
@@ -166,6 +164,9 @@ export function MenuEditor({ api, name, collections, onBack, onDeleted, onError,
|
|
|
166
164
|
}) {
|
|
167
165
|
const [menu, setMenu] = useState<Menu | null>(null);
|
|
168
166
|
const [items, setItems] = useState<MenuItem[]>([]);
|
|
167
|
+
// The app bar's trailing crumb — the menu's LABEL once it has loaded, not the `name` key in
|
|
168
|
+
// the URL, which is what a layout gets to publish and is not what an editor calls it.
|
|
169
|
+
useCrumb(menu?.label);
|
|
169
170
|
const [label, setLabel] = useState("");
|
|
170
171
|
// The whole tree is edited locally and written only by "Save menu", so leaving the screen
|
|
171
172
|
// discards it. Nothing prompted before this — `PageEditor` was the only screen that ever
|
|
@@ -440,9 +441,10 @@ export function RedirectsView({ api, onError, canEdit }: { api: Api; onError: (s
|
|
|
440
441
|
};
|
|
441
442
|
|
|
442
443
|
return (
|
|
443
|
-
|
|
444
|
+
<>
|
|
444
445
|
<Head lead="Old URLs, kept alive" em={rows === null ? "Redirects" : rows.length === 1 ? "1 redirect" : `${rows.length} redirects`} />
|
|
445
|
-
<
|
|
446
|
+
<div className={WRAP}>
|
|
447
|
+
<p className="mb-4 max-w-[62ch] text-sm text-fg-muted">
|
|
446
448
|
Changing a page's slug changes a live URL and breaks every link to it. A redirect is how the old one keeps working.
|
|
447
449
|
Disabling one keeps the record of what the old URL was, which deleting it does not.
|
|
448
450
|
</p>
|
|
@@ -486,21 +488,76 @@ export function RedirectsView({ api, onError, canEdit }: { api: Api; onError: (s
|
|
|
486
488
|
<Button className="mt-3" onPress={create} isDisabled={busy || !from.trim() || !to.trim()}>Add redirect</Button>
|
|
487
489
|
</div>
|
|
488
490
|
) : null}
|
|
489
|
-
|
|
491
|
+
</div>
|
|
492
|
+
</>
|
|
490
493
|
);
|
|
491
494
|
}
|
|
492
495
|
|
|
493
496
|
// --- taxonomies -----------------------------------------------------------------------
|
|
494
497
|
|
|
498
|
+
/** What a vocabulary classifies. `null` is EVERYTHING, and it is a real state rather than a
|
|
499
|
+
* shorthand for "all boxes ticked": a vocabulary that was never narrowed keeps applying to
|
|
500
|
+
* whatever the CMS grows next, where an explicit `["page","media"]` freezes it at today's two.
|
|
501
|
+
* So "Everything" is its own option, not the all-checked case. */
|
|
502
|
+
function AppliesToField({ value, onChange, disabled }: { value: TaxonomyTarget[] | null; onChange: (v: TaxonomyTarget[] | null) => void; disabled?: boolean }) {
|
|
503
|
+
const toggle = (t: TaxonomyTarget) => {
|
|
504
|
+
const next = (value ?? []).includes(t) ? (value ?? []).filter((x) => x !== t) : [...(value ?? []), t];
|
|
505
|
+
// Unchecking the last one would mean a vocabulary nothing can use, which the server
|
|
506
|
+
// refuses — so it lands back on "Everything", the nearest thing the person meant.
|
|
507
|
+
onChange(next.length === 0 ? null : next);
|
|
508
|
+
};
|
|
509
|
+
return (
|
|
510
|
+
<div className="mt-3">
|
|
511
|
+
<span className="text-caption text-fg-subtle">Applies to</span>
|
|
512
|
+
{/* "Everything" gets its own line rather than sitting in the row as a third peer: a radio
|
|
513
|
+
beside two checkboxes reads as one group with mismatched controls, when it is actually
|
|
514
|
+
the choice ABOVE them — pick everything, or pick which. */}
|
|
515
|
+
<div className="mt-1 flex flex-col gap-1">
|
|
516
|
+
<label className="flex items-center gap-2">
|
|
517
|
+
<input type="radio" checked={value === null} disabled={disabled} onChange={() => onChange(null)} />
|
|
518
|
+
<span className="text-sm text-fg">Everything</span>
|
|
519
|
+
</label>
|
|
520
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 pl-5">
|
|
521
|
+
{TAXONOMY_TARGETS.map((t) => (
|
|
522
|
+
<label key={t} className="flex items-center gap-2">
|
|
523
|
+
<input type="checkbox" checked={(value ?? []).includes(t)} disabled={disabled} onChange={() => toggle(t)} />
|
|
524
|
+
<span className="text-sm text-fg">{TAXONOMY_TARGET_LABELS[t]}</span>
|
|
525
|
+
</label>
|
|
526
|
+
))}
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** How a vocabulary's scope reads in a list row. */
|
|
534
|
+
function appliesToText(t: Taxonomy): string {
|
|
535
|
+
const list = t.appliesTo;
|
|
536
|
+
if (!Array.isArray(list) || list.length === 0) return "everything";
|
|
537
|
+
return list.map((x) => TAXONOMY_TARGET_LABELS[x] ?? x).join(" + ").toLowerCase();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
|
|
495
541
|
export function TaxonomiesView({ api, onOpen, onError, canEdit }: { api: Api; onOpen: (slug: string) => void; onError: (s: string) => void; canEdit: boolean }) {
|
|
496
542
|
const [taxa, setTaxa] = useState<Taxonomy[] | null>(null);
|
|
497
543
|
const [label, setLabel] = useState("");
|
|
498
544
|
const [slug, setSlug] = useState("");
|
|
499
545
|
const [slugTouched, setSlugTouched] = useState(false);
|
|
500
546
|
const [hierarchical, setHierarchical] = useState(false);
|
|
547
|
+
// `null` is "everything", which is also what an un-narrowed vocabulary stores — so the
|
|
548
|
+
// default here is the same value the server would have written anyway.
|
|
549
|
+
const [appliesTo, setAppliesTo] = useState<TaxonomyTarget[] | null>(null);
|
|
501
550
|
const [busy, setBusy] = useState(false);
|
|
551
|
+
// Scoping a vocabulary and media tagging shipped together, and the flag is the same one for
|
|
552
|
+
// that reason: a server either has both or neither, and a second capability for the same
|
|
553
|
+
// release would be one that can never be false on its own. On an older server the field is
|
|
554
|
+
// hidden rather than offered and silently dropped.
|
|
555
|
+
const { cms: { mediaTerms: scopable } } = useApp();
|
|
502
556
|
|
|
503
557
|
const refresh = useCallback(() => {
|
|
558
|
+
// No target: this is the screen that EDITS the scope, so it has to show a vocabulary it
|
|
559
|
+
// has narrowed away — otherwise narrowing one to Media would remove it from the only
|
|
560
|
+
// place that could widen it again.
|
|
504
561
|
api.listTaxonomies().then(setTaxa).catch((e) => { setTaxa([]); onError(errText(e)); });
|
|
505
562
|
}, [api, onError]);
|
|
506
563
|
useEffect(refresh, [refresh]);
|
|
@@ -508,16 +565,19 @@ export function TaxonomiesView({ api, onOpen, onError, canEdit }: { api: Api; on
|
|
|
508
565
|
const create = async () => {
|
|
509
566
|
setBusy(true);
|
|
510
567
|
try {
|
|
511
|
-
|
|
512
|
-
|
|
568
|
+
// On a server that cannot scope a vocabulary, `null` is what it would store anyway —
|
|
569
|
+
// so this sends the same value the field's default means rather than a conditional key.
|
|
570
|
+
const created = await api.createTaxonomy({ slug, label, hierarchical, appliesTo: scopable ? appliesTo : null });
|
|
571
|
+
setLabel(""); setSlug(""); setSlugTouched(false); setHierarchical(false); setAppliesTo(null);
|
|
513
572
|
onOpen(created.slug);
|
|
514
573
|
} catch (e) { onError(errText(e)); } finally { setBusy(false); }
|
|
515
574
|
};
|
|
516
575
|
|
|
517
576
|
return (
|
|
518
|
-
|
|
577
|
+
<>
|
|
519
578
|
<Head lead="How this site" em="is classified" />
|
|
520
|
-
<
|
|
579
|
+
<div className={WRAP}>
|
|
580
|
+
<p className="mb-4 max-w-[62ch] text-sm text-fg-muted">
|
|
521
581
|
A vocabulary is a way of grouping pages — categories, tags, regions. There are no built-in ones:
|
|
522
582
|
a deployment declares what it sorts by, the same way it declares its content types.
|
|
523
583
|
</p>
|
|
@@ -529,6 +589,7 @@ export function TaxonomiesView({ api, onOpen, onError, canEdit }: { api: Api; on
|
|
|
529
589
|
<span className="min-w-0 flex-1 truncate font-medium">{t.label}</span>
|
|
530
590
|
<span className="shrink-0 truncate text-fg-subtle">{t.slug}</span>
|
|
531
591
|
<span className="shrink-0 text-caption text-fg-subtle">{t.hierarchical ? "nested" : "flat"}</span>
|
|
592
|
+
{scopable ? <span className="shrink-0 text-caption text-fg-subtle">{appliesToText(t)}</span> : null}
|
|
532
593
|
</div>
|
|
533
594
|
))}
|
|
534
595
|
</div>
|
|
@@ -546,10 +607,12 @@ export function TaxonomiesView({ api, onOpen, onError, canEdit }: { api: Api; on
|
|
|
546
607
|
<input type="checkbox" checked={hierarchical} onChange={(e) => setHierarchical(e.target.checked)} />
|
|
547
608
|
<span className="text-sm text-fg">Terms can nest (categories rather than tags)</span>
|
|
548
609
|
</label>
|
|
610
|
+
{scopable ? <AppliesToField value={appliesTo} onChange={setAppliesTo} /> : null}
|
|
549
611
|
<Button className="mt-3" onPress={create} isDisabled={busy || !label.trim() || !slug.trim()}>Create vocabulary</Button>
|
|
550
612
|
</div>
|
|
551
613
|
) : null}
|
|
552
|
-
|
|
614
|
+
</div>
|
|
615
|
+
</>
|
|
553
616
|
);
|
|
554
617
|
}
|
|
555
618
|
|
|
@@ -569,6 +632,7 @@ export function TaxonomyEditor({ api, slug, onBack, onDeleted, onError, canEdit
|
|
|
569
632
|
const [termSlugTouched, setTermSlugTouched] = useState(false);
|
|
570
633
|
const [parentId, setParentId] = useState("");
|
|
571
634
|
const [busy, setBusy] = useState(false);
|
|
635
|
+
const { cms: { mediaTerms: scopable } } = useApp();
|
|
572
636
|
|
|
573
637
|
const refreshTerms = useCallback(() => {
|
|
574
638
|
api.getTermTree(slug).then(setTree).catch((e) => { setTree([]); onError(errText(e)); });
|
|
@@ -606,6 +670,23 @@ export function TaxonomyEditor({ api, slug, onBack, onDeleted, onError, canEdit
|
|
|
606
670
|
if (label === t.label) return;
|
|
607
671
|
try { await api.updateTerm(t.id, { label }); refreshTerms(); } catch (e) { onError(errText(e)); }
|
|
608
672
|
};
|
|
673
|
+
/** Saved on change rather than behind a Save button, because a narrowing can be REFUSED —
|
|
674
|
+
* the server rejects one that would strand existing assignments — and a refusal has to be
|
|
675
|
+
* visible while the choice that caused it is still on screen. On refusal the field goes back
|
|
676
|
+
* to what is stored, so it never shows a scope the server did not accept. */
|
|
677
|
+
const setAppliesTo = async (next: TaxonomyTarget[] | null) => {
|
|
678
|
+
if (!tax) return;
|
|
679
|
+
const previous = tax.appliesTo ?? null;
|
|
680
|
+
setTax({ ...tax, appliesTo: next });
|
|
681
|
+
try {
|
|
682
|
+
onError("");
|
|
683
|
+
await api.updateTaxonomy(tax.id, { appliesTo: next });
|
|
684
|
+
} catch (e) {
|
|
685
|
+
setTax({ ...tax, appliesTo: previous });
|
|
686
|
+
onError(errText(e));
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
|
|
609
690
|
const delTaxonomy = async () => {
|
|
610
691
|
if (!tax || !confirm(`Delete the vocabulary “${tax.label}”? Every term in it goes too, along with every page's assignments.`)) return;
|
|
611
692
|
try { await api.deleteTaxonomy(tax.id); onDeleted(); } catch (e) { onError(errText(e)); }
|
|
@@ -665,6 +746,17 @@ export function TaxonomyEditor({ api, slug, onBack, onDeleted, onError, canEdit
|
|
|
665
746
|
</div>
|
|
666
747
|
) : null}
|
|
667
748
|
|
|
749
|
+
{canEdit && scopable ? (
|
|
750
|
+
<div className="rounded-lg border border-border bg-surface-muted p-4">
|
|
751
|
+
<Heading level="2" className="mb-1 font-normal">Where this vocabulary is offered</Heading>
|
|
752
|
+
<p className="max-w-[62ch] text-caption text-fg-subtle">
|
|
753
|
+
Narrowing is refused while the vocabulary is still assigned to something it would stop applying to —
|
|
754
|
+
remove those assignments first, so nothing is left tagged with a vocabulary you can no longer see.
|
|
755
|
+
</p>
|
|
756
|
+
<AppliesToField value={tax.appliesTo ?? null} onChange={setAppliesTo} />
|
|
757
|
+
</div>
|
|
758
|
+
) : null}
|
|
759
|
+
|
|
668
760
|
{canEdit ? <Button variant="ghost" className="self-start text-danger" onPress={delTaxonomy}>Delete this vocabulary</Button> : null}
|
|
669
761
|
</div>
|
|
670
762
|
</div>
|
|
@@ -706,9 +798,10 @@ export function WidgetAreasView({ api, onOpen, onError, canEdit }: { api: Api; o
|
|
|
706
798
|
};
|
|
707
799
|
|
|
708
800
|
return (
|
|
709
|
-
|
|
801
|
+
<>
|
|
710
802
|
<Head lead="Parts of the layout" em="you can fill in" />
|
|
711
|
-
<
|
|
803
|
+
<div className={WRAP}>
|
|
804
|
+
<p className="mb-4 max-w-[62ch] text-sm text-fg-muted">
|
|
712
805
|
A widget area is a named slot in your layout — a sidebar, a footer column — that an editor fills without touching code.
|
|
713
806
|
Your layout reads one by name: <code>getWidgetArea("sidebar")</code>.
|
|
714
807
|
</p>
|
|
@@ -736,7 +829,8 @@ export function WidgetAreasView({ api, onOpen, onError, canEdit }: { api: Api; o
|
|
|
736
829
|
<Button className="mt-3" onPress={create} isDisabled={busy || !label.trim() || !name.trim()}>Create widget area</Button>
|
|
737
830
|
</div>
|
|
738
831
|
) : null}
|
|
739
|
-
|
|
832
|
+
</div>
|
|
833
|
+
</>
|
|
740
834
|
);
|
|
741
835
|
}
|
|
742
836
|
|
package/src/icons.tsx
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The editor's icon family: Phosphor, regular weight.
|
|
2
|
+
//
|
|
3
|
+
// ONE family, chosen for its plainness — at 15px in a nav rail an icon has to survive as a
|
|
4
|
+
// silhouette, and Phosphor's regular weight is the simplest set that still reads at that
|
|
5
|
+
// size. It replaces two half-families: podoba's own line set (which does not cover a CMS's
|
|
6
|
+
// nouns — no taxonomy, no widget area, no redirect) and, next to it, the glyphs this file
|
|
7
|
+
// used to draw by hand to fill those gaps. Two sets of hand-fitted curves side by side is
|
|
8
|
+
// the one outcome worse than either.
|
|
9
|
+
//
|
|
10
|
+
// Everything the editor draws comes from HERE, aliased to a name that says what it means in
|
|
11
|
+
// this app rather than what it depicts. That is not ceremony: it is what makes the family a
|
|
12
|
+
// decision recorded in one file. Swapping it (or moving it into @podoba/react, where it
|
|
13
|
+
// belongs once the design system adopts a set) is then this module's imports and nothing
|
|
14
|
+
// else — no call site names a vendor.
|
|
15
|
+
//
|
|
16
|
+
// A HOST's own icon is a separate thing and stays a separate thing: a collection or a Block
|
|
17
|
+
// Kit page declares `icon: "🎓"` and that string goes in the icon column verbatim (see
|
|
18
|
+
// `NavIcon` in nav.ts). Resolving such a string against Phosphor BY NAME is deliberately not
|
|
19
|
+
// offered — a by-name lookup needs the whole 3000-icon registry in the bundle, which is
|
|
20
|
+
// megabytes to let a deployment name one glyph it can already supply directly.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
ArrowBendUpRightIcon,
|
|
24
|
+
ArrowSquareOutIcon,
|
|
25
|
+
CaretDownIcon,
|
|
26
|
+
CaretRightIcon,
|
|
27
|
+
FileTextIcon,
|
|
28
|
+
FolderIcon,
|
|
29
|
+
GearIcon,
|
|
30
|
+
type Icon,
|
|
31
|
+
ImageIcon,
|
|
32
|
+
LayoutIcon,
|
|
33
|
+
ListDashesIcon,
|
|
34
|
+
ListIcon,
|
|
35
|
+
MoonIcon,
|
|
36
|
+
SidebarSimpleIcon,
|
|
37
|
+
SignOutIcon,
|
|
38
|
+
SquaresFourIcon,
|
|
39
|
+
StackIcon,
|
|
40
|
+
SunIcon,
|
|
41
|
+
TagIcon,
|
|
42
|
+
UsersIcon,
|
|
43
|
+
} from "@phosphor-icons/react";
|
|
44
|
+
import type { NavGlyph } from "./nav";
|
|
45
|
+
|
|
46
|
+
/** A drawable glyph. Phosphor's own component type, re-exported so a call site can hold one
|
|
47
|
+
* without importing the vendor. */
|
|
48
|
+
export type { Icon };
|
|
49
|
+
|
|
50
|
+
// --- the chrome's own controls -----------------------------------------------------------
|
|
51
|
+
//
|
|
52
|
+
// Named for the JOB, not the picture. `MenuToggleIcon` is the hamburger that opens the rail
|
|
53
|
+
// below `md`; calling it `ListIcon` at the call site would put it one careless edit away from
|
|
54
|
+
// standing in for the Menus SECTION, which is a destination and a different glyph.
|
|
55
|
+
|
|
56
|
+
/** Opens the collapsed rail below `md`. */
|
|
57
|
+
export const MenuToggleIcon = ListIcon;
|
|
58
|
+
/** Collapses the rail to icons, and brings it back. ONE glyph for both directions: the
|
|
59
|
+
* button is a toggle, and swapping the picture per state makes the reader decide what the
|
|
60
|
+
* new picture means before they can decide whether to press it. `aria-expanded` carries the
|
|
61
|
+
* state, which is where a state belongs. */
|
|
62
|
+
export const RailToggleIcon = SidebarSimpleIcon;
|
|
63
|
+
/** A group that is open — points down, as every file tree has agreed. */
|
|
64
|
+
export const GroupOpenIcon = CaretDownIcon;
|
|
65
|
+
/** …and one that is folded. */
|
|
66
|
+
export const GroupFoldedIcon = CaretRightIcon;
|
|
67
|
+
/** Switch to the light theme (shown while dark is active). */
|
|
68
|
+
export const LightThemeIcon = SunIcon;
|
|
69
|
+
/** …and the reverse. */
|
|
70
|
+
export const DarkThemeIcon = MoonIcon;
|
|
71
|
+
/** Drop the session. */
|
|
72
|
+
export { SignOutIcon };
|
|
73
|
+
/** The account menu's settings entry — the same glyph the nav's Settings row uses. */
|
|
74
|
+
export { GearIcon as SettingsIcon };
|
|
75
|
+
|
|
76
|
+
// --- the nav's destinations ---------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/** Glyph name -> component. The layout renders `NAV_GLYPHS[entry.icon.name]`; `satisfies`
|
|
79
|
+
* rather than an annotation, so the mapping stays exhaustive over `NavGlyph` (a glyph with no
|
|
80
|
+
* drawing is a compile error, not a blank square in the rail) without widening every value to
|
|
81
|
+
* the shared signature. */
|
|
82
|
+
export const NAV_GLYPHS = {
|
|
83
|
+
pages: FileTextIcon,
|
|
84
|
+
collection: FolderIcon,
|
|
85
|
+
media: ImageIcon,
|
|
86
|
+
// NOT the hamburger: that is the control that opens this rail, and one glyph cannot mean
|
|
87
|
+
// both "open the nav" and "the Menus section".
|
|
88
|
+
menus: ListDashesIcon,
|
|
89
|
+
taxonomies: TagIcon,
|
|
90
|
+
widgets: LayoutIcon,
|
|
91
|
+
redirects: ArrowBendUpRightIcon,
|
|
92
|
+
app: SquaresFourIcon,
|
|
93
|
+
types: StackIcon,
|
|
94
|
+
users: UsersIcon,
|
|
95
|
+
settings: GearIcon,
|
|
96
|
+
link: ArrowSquareOutIcon,
|
|
97
|
+
} satisfies Record<NavGlyph, Icon>;
|