@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.
@@ -2,19 +2,74 @@
2
2
  // route modules under `routes/` are thin adapters: they pull `api`/`me`/`setError` from
3
3
  // the app context and wire URL params + navigation into these components.
4
4
 
5
- import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Textarea } from "@podoba/react";
5
+ import { Button, Dialog, type DialogSize, DropdownMenu, DropdownMenuItem, DropdownMenuTrigger, Input, SearchField, Textarea } from "@podoba/react";
6
6
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
7
7
  import { Api, ApiError } from "./api";
8
8
  import { CONTROL, FieldForm, formatWhen, fromLocalInput, slugify, toLocalInput } from "./fields";
9
- import { ROW, WRAP } from "./chrome";
9
+ import { BELOW_APP_BAR, BELOW_PAGE_TOOLBAR, PAGE_TOOLBAR_H, ROW, WRAP } from "./chrome";
10
+ import { useCrumb } from "./breadcrumb";
11
+ import { PageHeader } from "./page-header";
12
+ import { pagePreviewHref, sitePreviewUrl } from "./preview";
10
13
  import type { Config } from "./api";
11
14
  import { useApp, type Me } from "./app-context";
12
15
  import { isRichTextDoc, richTextToPlainText } from "./rich-text";
13
16
  import { flattenTerms } from "./furniture";
14
- import type { AssembledPage, AuditEntry, BlockType, CmsCapabilities, CollectionMeta, ContentType, FieldDefinition, FieldValue, FieldValues, Media, Page, RegionDefinition, RenderedBlock, Taxonomy, Term } from "./types";
17
+ import type { AssembledPage, AuditEntry, BlockType, CmsCapabilities, CollectionMeta, ContentType, FieldDefinition, FieldValue, FieldValues, Media, MediaKind, MediaSort, Page, RegionDefinition, RenderedBlock, Taxonomy, Term } from "./types";
18
+ import { MEDIA_KIND_LABELS, MEDIA_KINDS, MEDIA_SORT_LABELS, MEDIA_SORTS } from "./types";
19
+
20
+ export type InspectorTab = "settings" | "seo" | "i18n" | "terms" | "audit";
21
+ // `workflow` is deliberately NOT here any more. Publishing is what someone opened the editor
22
+ // to do, and it was a lowercase ghost button among five that looked like filter chips — you
23
+ // had to know the word "workflow" meant "publish". The transitions now live in the toolbar,
24
+ // where the state they act on is already shown.
25
+ export const INSPECTOR_TABS: InspectorTab[] = ["settings", "seo", "i18n", "terms", "audit"];
26
+
27
+ /** What each tab is called on screen. A table rather than a CSS `capitalize`, which renders
28
+ * "seo" as "Seo" and "i18n" as "I18n" — both wrong, and wrong in the one place a reader is
29
+ * scanning for the word they want. */
30
+ export const INSPECTOR_TAB_LABELS = {
31
+ settings: "Settings",
32
+ seo: "SEO",
33
+ i18n: "Translations",
34
+ terms: "Terms",
35
+ audit: "History",
36
+ } satisfies Record<InspectorTab, string>;
37
+
38
+ /** One workflow transition the page can make from where it is. */
39
+ export interface PageAction {
40
+ label: string;
41
+ /** The RPC handler name — `publishPage`, `approve`, … */
42
+ action: string;
43
+ }
15
44
 
16
- export type InspectorTab = "settings" | "seo" | "workflow" | "i18n" | "terms" | "audit";
17
- export const INSPECTOR_TABS: InspectorTab[] = ["settings", "seo", "workflow", "i18n", "terms", "audit"];
45
+ /**
46
+ * The transitions valid FROM `status`, most-expected first.
47
+ *
48
+ * The head of the list is the toolbar's primary button and the tail goes in its menu, so the
49
+ * ORDER is the contract: a draft's obvious next move is Publish, a page in review is waiting
50
+ * for Approve, and a published page's only move is to take it down. Showing transitions the
51
+ * server would reject (an "Approve" on a draft) is how a UI teaches someone that its buttons
52
+ * lie; the server still enforces the role gate on every one of these.
53
+ */
54
+ export function pageWorkflowActions(status: string): PageAction[] {
55
+ switch (status) {
56
+ case "review":
57
+ return [
58
+ { label: "Approve & publish", action: "approve" },
59
+ { label: "Reject", action: "reject" },
60
+ // The solo operator's escape from the two-actor pipeline — same endpoint the draft
61
+ // path offers, kept reachable so a reviewer is not forced through their own review.
62
+ { label: "Publish directly", action: "publishPage" },
63
+ ];
64
+ case "published":
65
+ return [{ label: "Unpublish", action: "unpublishPage" }];
66
+ default: // draft | rejected | archived
67
+ return [
68
+ { label: "Publish", action: "publishPage" },
69
+ { label: "Submit for review", action: "submitForReview" },
70
+ ];
71
+ }
72
+ }
18
73
 
19
74
  /** The tabs a deployment actually shows. ONE definition, used by the tab bar, the panel
20
75
  * switch and the route's deep-link fallback — three places that previously each re-derived
@@ -56,28 +111,25 @@ export function splitsByType(contentTypes: ContentType[] | null, cms: CmsCapabil
56
111
  // --- presentational primitives (podoba tokens; replaces styles.ts classes) ---
57
112
 
58
113
 
59
- function Hero({ lead, em, children }: { lead: string; em: string; children?: ReactNode }) {
60
- return (
61
- <div className="mx-auto grid max-w-[1200px] grid-cols-[1fr_auto] items-center gap-6 px-7 pb-6 pt-8 max-[820px]:grid-cols-1">
62
- <h1 className="m-0 text-[56px] font-normal leading-[1.05] tracking-[-0.01em] max-[820px]:text-[40px]">
63
- <span className="block text-fg-subtle">{lead}</span>
64
- <span className="block text-fg">{em}</span>
65
- </h1>
66
- {children}
67
- </div>
68
- );
69
- }
114
+ /** The library screens' header. See `page-header.tsx`; `Hero` stays as the local name the
115
+ * five call sites below already use. */
116
+ const Hero = PageHeader;
70
117
 
71
- function Cta({ text, em, children }: { text: string; em: string; children: ReactNode }) {
72
- return (
73
- <div className="flex min-w-[360px] items-center gap-4 rounded-full bg-brand-green py-3 pl-7 pr-3 max-[820px]:min-w-0">
74
- <span className="text-callout text-fg-on-brand">
75
- {text} <span className="font-semibold">{em}</span>
76
- </span>
77
- {children}
78
- </div>
79
- );
80
- }
118
+ // `Cta` used to live here: a 360px-wide mint pill carrying a sentence ("Let's upload
119
+ // something") wrapped around the actual button. It is gone, and the header's action is now
120
+ // just the button.
121
+ //
122
+ // The header grew a cover (see `Hero`), and a saturated pill on top of it was a panel inside
123
+ // a panel — a second filled surface competing with the artwork for the same corner. The
124
+ // sentence went with it because by then it was the third telling of one fact: the title above
125
+ // says "Media / None yet", the empty state below says "No media yet. Upload images to…", and
126
+ // the button itself says "+ Upload".
127
+ //
128
+ // It also carried a real bug, visible only on the dark theme. The wrapper was `bg-brand-green`
129
+ // (#75e7b8) and podoba maps `brand-primary` — what a default `Button` fills with — onto
130
+ // #75e7b8 in dark. Mint on mint: the button had no edge at all, and read as a run of text.
131
+ // Sitting directly on `surface-card` it has proper contrast in both themes, which is the
132
+ // contrast podoba designed for it.
81
133
 
82
134
  function Section({ children }: { children: ReactNode }) {
83
135
  return <div className="mb-2 mt-[18px] text-sm text-fg-subtle first:mt-0">{children}</div>;
@@ -97,24 +149,36 @@ function Pill({ status, children }: { status?: string; children: ReactNode }) {
97
149
  return <span className={`inline-block whitespace-nowrap rounded-full border px-2.5 py-0.5 text-caption font-medium ${tone}`}>{children}</span>;
98
150
  }
99
151
 
100
- // Modal chrome on podoba's shared overlay: focus trap, Esc-to-close, backdrop
101
- // dismiss, the token blur-scrim and enter/exit animation — all for free. The caller
102
- // API (conditionally mounted + `onClose`) is preserved, so call sites don't change.
103
- function Modal({ onClose, wide, children }: { onClose: () => void; wide?: boolean; children: ReactNode }) {
104
- return (
105
- <ModalOverlay isOpen isDismissable onOpenChange={(open) => !open && onClose()}>
106
- <ModalSurface className={`w-full px-9 py-8 ${wide ? "max-w-[680px]" : "max-w-[520px]"}`}>
107
- <ModalDialog className="max-h-[86vh] overflow-auto outline-none">{children}</ModalDialog>
108
- </ModalSurface>
109
- </ModalOverlay>
110
- );
111
- }
112
-
113
- function ModalTitle({ children }: { children: ReactNode }) {
152
+ /**
153
+ * A modal, on podoba's `Dialog`.
154
+ *
155
+ * It used to hand-compose `ModalOverlay` + `ModalSurface` + `ModalDialog` with its own
156
+ * max-widths — the raw primitives podoba documents for "edge-to-edge / split modal
157
+ * compositions", which a form dialog is not. What that cost was everything `Dialog` puts
158
+ * around the content: the (this app's modals could only be left by finding the word
159
+ * "cancel", or by guessing that Esc works), the labelling `<Heading slot="title">`, the
160
+ * `description` block, and the size presets — including `full`, the near-fullscreen canvas.
161
+ *
162
+ * `size` is passed straight through, so picking a modal's weight is one prop rather than a
163
+ * `wide` boolean that meant 680px and nothing else.
164
+ */
165
+ function Modal({
166
+ onClose,
167
+ size,
168
+ title,
169
+ description,
170
+ children,
171
+ }: {
172
+ onClose: () => void;
173
+ size?: DialogSize;
174
+ title?: ReactNode;
175
+ description?: ReactNode;
176
+ children: ReactNode;
177
+ }) {
114
178
  return (
115
- <Heading level="1" className="mb-5 font-normal">
179
+ <Dialog isOpen isDismissable size={size} title={title} description={description} onOpenChange={(open) => !open && onClose()}>
116
180
  {children}
117
- </Heading>
181
+ </Dialog>
118
182
  );
119
183
  }
120
184
 
@@ -218,9 +282,7 @@ export function PageList({ api, type, onOpen, onError }: { api: Api; type?: Cont
218
282
  return (
219
283
  <>
220
284
  <Hero lead={type?.name ?? "Pages"} em={loading && pages.length === 0 ? "Loading…" : count}>
221
- <Cta text="Let's" em="create something">
222
- <Button className="shrink-0" onPress={() => setCreating(true)}>+ New page</Button>
223
- </Cta>
285
+ <Button className="shrink-0" onPress={() => setCreating(true)}>+ New page</Button>
224
286
  </Hero>
225
287
  <div className={WRAP}>
226
288
  <div className="flex flex-col gap-2">
@@ -272,15 +334,27 @@ function CreatePage({ api, type, onClose, onCreated, onError }: { api: Api; type
272
334
  }
273
335
  };
274
336
  return (
275
- <Modal onClose={onClose}>
276
- {/* Name the type when the picker is hidden. Otherwise the whole modal says "page" and
277
- nothing on it says WHICH type the page is being filed under on a per-type list
278
- that is the one fact the screen is supposed to be carrying. */}
279
- <ModalTitle>
280
- Create a <Dim>new page</Dim>
281
- {type ? <> in <Dim>{type.name}</Dim></> : null} and define the essentials<Dim>.</Dim>
282
- </ModalTitle>
283
- <div className="flex flex-col gap-4">
337
+ // `full`: creating a page is the one thing this screen exists to start, and the header's
338
+ // action should open a room rather than a panel. The form is centred and capped inside it
339
+ // a canvas is what the takeover is for, not a reason to stretch two inputs across it.
340
+ //
341
+ // Name the type when the picker is hidden. Otherwise the whole modal says "page" and
342
+ // nothing on it says WHICH type the page is being filed under — on a per-type list that is
343
+ // the one fact the screen is supposed to be carrying.
344
+ <Modal
345
+ onClose={onClose}
346
+ size="full"
347
+ title={
348
+ <>
349
+ Create a <Dim>new page</Dim>
350
+ {type ? <> in <Dim>{type.name}</Dim></> : null} and define the essentials<Dim>.</Dim>
351
+ </>
352
+ }
353
+ >
354
+ {/* `m-auto`, not `mx-auto`: `size="full"` makes the dialog body a flex column, so auto
355
+ margins on both axes centre the form IN the canvas. Pinned to the top it read as a
356
+ small form that had lost its modal. */}
357
+ <div className="m-auto flex w-full max-w-[560px] flex-col gap-4">
284
358
  <div className={`w-full flex-col gap-2 ${type ? "hidden" : "flex"}`}>
285
359
  <span className="text-sm font-medium text-fg">Content type</span>
286
360
  {/* Visible cards, not a dropdown: the type is an easy-to-miss choice, and picking the
@@ -373,9 +447,7 @@ export function CollectionList({ api, def, onOpen, onNew, onError }: { api: Api;
373
447
  return (
374
448
  <>
375
449
  <Hero lead={def.pluralLabel} em={rows.length === 0 ? "None yet" : rows.length === 1 ? `1 ${def.label.toLowerCase()}` : `${rows.length}${hasMore ? "+" : ""} ${def.pluralLabel.toLowerCase()}`}>
376
- <Cta text="Let's" em={`add a ${def.label.toLowerCase()}`}>
377
- <Button className="shrink-0" onPress={onNew}>+ New {def.label.toLowerCase()}</Button>
378
- </Cta>
450
+ <Button className="shrink-0" onPress={onNew}>+ New {def.label.toLowerCase()}</Button>
379
451
  </Hero>
380
452
  <div className={WRAP}>
381
453
  <div className="flex flex-col gap-2">
@@ -588,6 +660,15 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
588
660
  const [values, setValues] = useState<FieldValues>({});
589
661
  const [loading, setLoading] = useState(!isNew);
590
662
  const [missing, setMissing] = useState(false);
663
+ // The app bar's trailing crumb. A new row is named before it exists, from the collection's
664
+ // own singular label; an existing one takes its title field, and `undefined` while that is
665
+ // still loading leaves the bar showing the section rather than a "Loading…" that then
666
+ // changes under the reader's eye.
667
+ // Through `cellText`, the same function the LIST renders this very column with — so the
668
+ // crumb and the row a reader clicked to get here say the same thing, rather than two
669
+ // renderings of one `FieldValue` that can disagree about a rich-text or array cell.
670
+ const title = cellText(values[def.titleField]);
671
+ useCrumb(isNew ? `New ${def.label.toLowerCase()}` : title || undefined);
591
672
  const [busy, setBusy] = useState(false);
592
673
  const [ok, setOk] = useState(false);
593
674
 
@@ -670,8 +751,130 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
670
751
 
671
752
  // --- page editor -------------------------------------------------------------
672
753
 
673
- export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange, registerGuard }: { api: Api; page: Page; blockTypes: BlockType[]; tab: InspectorTab; onTab: (t: InspectorTab) => void; onBack: () => void; onChange: (p: Page) => void; registerGuard: (fn: (() => boolean) | null) => void }) {
754
+ /**
755
+ * The page editor's toolbar: where you are, what state the page is in, and the three things
756
+ * you came to do.
757
+ *
758
+ * It exists because none of that was anywhere. The editor opened onto three columns of panels
759
+ * with no header; the status appeared twice (a rail card and an inspector row) and the actions
760
+ * that change it were behind a tab labelled "workflow", rendered as a lowercase ghost button
761
+ * among five that read as filter chips. Publishing — the point of the screen — required
762
+ * knowing that word.
763
+ *
764
+ * Sticky, because a long page scrolls away from it and the save state has to stay visible.
765
+ */
766
+ function PageToolbar({ page, dirtyCount, onBack, backLabel, onAct, busy }: {
767
+ page: Page;
768
+ dirtyCount: number;
769
+ onBack: () => void;
770
+ /** The list this page belongs to — "Pages", or the content type's own name on a
771
+ * per-type deployment, where back goes to that type's list and not the pooled one. */
772
+ backLabel: string;
773
+ onAct: (action: string) => void;
774
+ busy: boolean;
775
+ }) {
776
+ const [preview, setPreview] = useState<string | null>(null);
777
+ const [minting, setMinting] = useState(false);
778
+ const { api, setError } = useApp();
779
+ const actions = pageWorkflowActions(String(page.status));
780
+ const [primary, ...rest] = actions;
781
+
782
+ /**
783
+ * Mint a preview link, open it, and leave it on screen to be copied.
784
+ *
785
+ * The tab is opened SYNCHRONOUSLY, before the round trip, and pointed at the link
786
+ * afterwards. `window.open` called after an `await` has lost the user gesture that
787
+ * authorised it and is blocked by every browser's popup blocker — which is exactly why this
788
+ * used to only reveal a link and make you click it a second time.
789
+ *
790
+ * The link is still shown, and still copied: "look at my draft" and "send this to someone
791
+ * for comment" are both what the button is for, and only one of them ends in the new tab.
792
+ */
793
+ const mintPreview = async () => {
794
+ setMinting(true);
795
+ // `null` when the browser blocked it — the revealed link below is then the whole answer,
796
+ // which is the state this had before and is still a working one.
797
+ //
798
+ // No `noopener` FEATURE, deliberately: passing it makes `window.open` return null by
799
+ // spec, and the handle is the entire point here. The reference is severed after the
800
+ // navigation instead, which gets the same guarantee — the preview page can never reach
801
+ // back into the editor through `window.opener`.
802
+ const tab = window.open("", "_blank");
803
+ try {
804
+ const minted = await api.signPagePreview(page.id);
805
+ const href = pagePreviewHref(minted, { siteUrl: sitePreviewUrl(), origin: window.location.href, resolve: (path) => api.resolve(path) });
806
+ setPreview(href);
807
+ // `replace`, so the blank placeholder is not a history entry the new tab's Back button
808
+ // can return to.
809
+ if (tab) {
810
+ tab.opener = null;
811
+ tab.location.replace(href);
812
+ }
813
+ // Best-effort: the clipboard needs a secure context and a permission, and the link is
814
+ // rendered either way.
815
+ await navigator.clipboard?.writeText(href).catch(() => {});
816
+ } catch (e) {
817
+ // Close the placeholder rather than stranding an about:blank tab, and SAY what went
818
+ // wrong: minting fails closed when no preview secret is configured, and swallowing that
819
+ // left a button that looked like it did nothing.
820
+ tab?.close();
821
+ setPreview(null);
822
+ setError(errMsg(e));
823
+ } finally {
824
+ setMinting(false);
825
+ }
826
+ };
827
+
828
+ return (
829
+ <>
830
+ <div className={`sticky ${BELOW_APP_BAR} z-20 -mx-7 flex ${PAGE_TOOLBAR_H} items-center gap-3 border-b border-border bg-surface px-7`}>
831
+ <Button variant="ghost" size="sm" className="shrink-0" onPress={onBack}>← {backLabel}</Button>
832
+ <span className="min-w-0 truncate font-medium text-fg">{page.title}</span>
833
+ {/* Beside the title, because it is a fact ABOUT the page — among the buttons it read as
834
+ another control. */}
835
+ <Pill status={page.status}>{page.status}</Pill>
836
+ <span className="flex-1" />
837
+ {/* ONE line of truth about saving. Page fields and blocks autosave; this says so, and
838
+ says when they have not finished — replacing two identically-labelled "Save" buttons
839
+ that saved different halves of the screen and a third surface that saved silently. */}
840
+ <span className="shrink-0 text-caption text-fg-subtle">
841
+ {dirtyCount > 0 ? <span className="text-accent-strong">● saving {dirtyCount} change{dirtyCount === 1 ? "" : "s"}…</span> : "all changes saved"}
842
+ </span>
843
+ <Button variant="secondary" size="sm" className="shrink-0" isDisabled={minting} onPress={() => void mintPreview()}>
844
+ {minting ? "Minting…" : "Preview"}
845
+ </Button>
846
+ {primary ? <Button size="sm" className="shrink-0" isDisabled={busy} onPress={() => onAct(primary.action)}>{primary.label}</Button> : null}
847
+ {rest.length > 0 ? (
848
+ <DropdownMenuTrigger>
849
+ <Button variant="ghost" size="sm" className="shrink-0 px-2" aria-label="More actions">⋯</Button>
850
+ <DropdownMenu aria-label="More page actions" onAction={(k) => onAct(String(k))}>
851
+ {rest.map((a) => <DropdownMenuItem key={a.action} id={a.action}>{a.label}</DropdownMenuItem>)}
852
+ </DropdownMenu>
853
+ </DropdownMenuTrigger>
854
+ ) : null}
855
+ </div>
856
+ {/* The minted link is REVEALED rather than opened: the point of a preview link is to
857
+ SEND it, and a popup blocker eating the click that produced it would leave nothing to
858
+ copy. In flow rather than floating under the sticky bar, where it covered the first
859
+ thing on the canvas with no way to move it — and dismissible, because it is a
860
+ transient answer, not a permanent row. */}
861
+ {preview ? (
862
+ <div className="mt-3 flex items-center gap-2 rounded-lg bg-surface-muted px-3 py-2 text-caption">
863
+ <span className="shrink-0 text-fg-subtle">Preview opened in a new tab — link copied:</span>
864
+ <a className="min-w-0 flex-1 truncate underline" href={preview} target="_blank" rel="noreferrer">{preview}</a>
865
+ <Button variant="ghost" size="sm" className="shrink-0 px-2" aria-label="Hide the preview link" onPress={() => setPreview(null)}>✕</Button>
866
+ </div>
867
+ ) : null}
868
+ </>
869
+ );
870
+ }
871
+
872
+ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, backLabel, onChange, registerGuard }: { api: Api; page: Page; blockTypes: BlockType[]; tab: InspectorTab; onTab: (t: InspectorTab) => void; onBack: () => void; backLabel: string; onChange: (p: Page) => void; registerGuard: (fn: (() => boolean) | null) => void }) {
674
873
  const { cms: { multilingual, siteFurniture, canEdit } } = useApp();
874
+ // NO detail crumb, deliberately. The toolbar below names the page and carries its status,
875
+ // so publishing one put the title in the app bar 40px above where the toolbar already says
876
+ // it — the "title in three places" this redesign removed. The section crumb stays; it names
877
+ // the list you came from, which the toolbar's back button only points at.
675
878
  const [ct, setCt] = useState<ContentType | null>(null);
676
879
  const [assembled, setAssembled] = useState<AssembledPage | null>(null);
677
880
  const [err, setErr] = useState("");
@@ -820,46 +1023,38 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
820
1023
  reorder(region, ids);
821
1024
  };
822
1025
 
823
- // Shown wherever the back button is — including the no-regions layout, whose only editable
824
- // surface is PageFields, so the rail that used to carry this badge isn't rendered at all.
825
- const dirtyBadge = dirtyCount > 0
826
- ? <span className="text-[11px] text-accent-strong">● {dirtyCount} unsaved change{dirtyCount === 1 ? "" : "s"}</span>
827
- : null;
1026
+ const [acting, setActing] = useState(false);
1027
+ /** Run a workflow transition from the toolbar. */
1028
+ const act = async (name: string) => {
1029
+ setActing(true);
1030
+ try {
1031
+ const r = await api.call<{ page?: Page }>(name, { pageId: page.id });
1032
+ if (r?.page) onChange(r.page);
1033
+ } catch (e) {
1034
+ setErr(errMsg(e));
1035
+ } finally {
1036
+ setActing(false);
1037
+ }
1038
+ };
1039
+
828
1040
 
829
1041
  return (
830
- // With no regions the left rail would be a 260px column holding just a back button,
831
- // so it collapses and the content column takes the space.
832
- <div className={`grid min-h-[calc(100vh-68px)] gap-5 px-7 pb-7 pt-2 max-[820px]:grid-cols-1 ${regions.length ? "grid-cols-[260px_1fr_400px]" : "grid-cols-[1fr_400px]"}`}>
833
- {regions.length === 0 ? null : (
834
- <div className="overflow-auto rounded-panel bg-surface-muted p-[18px]">
835
- <Button variant="ghost" size="sm" onPress={() => { if (confirmLeave()) onBack(); }}>← all pages</Button>
836
- {dirtyBadge ? <p className="mt-2">{dirtyBadge}</p> : null}
837
- <Section>Regions</Section>
838
- {regions.map((r) => (
839
- <div key={r.name} className={`${ROW} mb-2`}>
840
- <span className="flex-1 truncate font-medium">{r.label ?? r.name}</span>
841
- <span className="text-fg-subtle">{(assembled?.regions[r.name] ?? []).length}</span>
842
- </div>
843
- ))}
844
- <Section>Status</Section>
845
- <div className={ROW}>
846
- <span className="flex-1 truncate font-medium">{page.title}</span>
847
- <Pill status={page.status}>{page.status}</Pill>
848
- </div>
849
- </div>
850
- )}
1042
+ <div className="px-7 pb-16">
1043
+ <PageToolbar page={page} dirtyCount={dirtyCount} busy={acting} onAct={act} backLabel={backLabel} onBack={() => { if (confirmLeave()) onBack(); }} />
1044
+ {err ? <Banner>{err}</Banner> : null}
1045
+
1046
+ {/* Two columns, always. There was a third — an outline listing each region and its block
1047
+ count and it was a table of contents for a document that is almost never longer
1048
+ than the screen: the canvas already prints the same region names, in the same order,
1049
+ a column away, and the count it added is what "is this region empty" looks like when
1050
+ you look at it. Its one real service, jumping to a far region, is scrolling on a page
1051
+ you are already scrolling. The region headings below are sticky instead, which is the
1052
+ orientation an outline was standing in for. */}
1053
+ <div className="mt-4 grid items-start gap-6 grid-cols-[minmax(0,1fr)_340px] max-[980px]:grid-cols-1">
851
1054
 
852
1055
  {/* The canvas: one inline document. `pl-8` reserves the left gutter that each
853
1056
  block's drag handle occupies on hover. Regions are titled sections. */}
854
- <div className="overflow-auto py-1.5 pl-8 pr-2">
855
- {err ? <Banner>{err}</Banner> : null}
856
- {regions.length === 0 ? (
857
- <div className="mb-2 flex items-center gap-2">
858
- <Button variant="ghost" size="sm" onPress={() => { if (confirmLeave()) onBack(); }}>← all pages</Button>
859
- <Pill status={page.status}>{page.status}</Pill>
860
- {dirtyBadge}
861
- </div>
862
- ) : null}
1057
+ <div className="min-w-0 py-1.5 pl-8 pr-2">
863
1058
  {/* The page's own fields are CONTENT, so they belong on the canvas at full width —
864
1059
  not in the inspector. For a content type with no regions (a fixed layout, all
865
1060
  of it page fields) this is the entire editor; the canvas is never empty. */}
@@ -871,7 +1066,10 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
871
1066
  const allowed = r.allowedTypes && r.allowedTypes.length ? r.allowedTypes : blockTypes.map((b) => b.slug);
872
1067
  return (
873
1068
  <div className="mb-10" key={r.name}>
874
- <div className="mb-1 text-caption font-medium uppercase tracking-wide text-fg-subtle">{r.label ?? r.name}</div>
1069
+ {/* Sticky, under the app bar + toolbar: on a long page the heading is the only
1070
+ thing that says which slot of the layout you are editing, and scrolling used
1071
+ to take it away. `bg-surface` because it now passes over content. */}
1072
+ <div className={`sticky ${BELOW_PAGE_TOOLBAR} z-10 -mx-2 mb-1 bg-surface px-2 py-1 text-caption font-medium uppercase tracking-wide text-fg-subtle`}>{r.label ?? r.name}</div>
875
1073
  {blocks.map((b, i) => (
876
1074
  <div key={b.id}>
877
1075
  {/* Between-blocks insert point — a hover "+" that adds AT index i. */}
@@ -908,23 +1106,34 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
908
1106
  : null}
909
1107
  </div>
910
1108
 
911
- <div className="overflow-auto rounded-panel border border-border bg-surface-card p-5">
912
- <div className="mb-3 flex gap-1">
1109
+ <aside className={`sticky ${BELOW_PAGE_TOOLBAR} max-h-[calc(100vh-8rem)] overflow-auto rounded-panel border border-border bg-surface-card p-5`}>
1110
+ {/* A real tab strip, not five ghost buttons that read as filter chips: the selected
1111
+ one is underlined and Capitalised, so which panel you are in is visible without
1112
+ comparing background tints. */}
1113
+ <div className="-mt-1 mb-4 flex gap-1 border-b border-border">
913
1114
  {visibleTabs(multilingual, siteFurniture).map((t) => (
914
- <Button key={t} variant="ghost" size="sm" className={tab === t ? "bg-surface-muted text-fg" : "text-fg-muted"} onPress={() => onTab(t)}>{t}</Button>
1115
+ <button
1116
+ key={t}
1117
+ type="button"
1118
+ className={`-mb-px border-b-2 px-2 pb-2 pt-1 text-sm ${tab === t ? "border-fg font-medium text-fg" : "border-transparent text-fg-muted hover:text-fg"}`}
1119
+ onClick={() => onTab(t)}
1120
+ >
1121
+ {INSPECTOR_TAB_LABELS[t]}
1122
+ </button>
915
1123
  ))}
916
1124
  </div>
917
1125
  {tab === "settings" ? <PageMeta api={api} page={page} onSaved={onChange} onError={setErr} /> : null}
918
1126
  {tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
919
- {tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
920
1127
  {tab === "i18n" && multilingual ? <I18n api={api} page={page} onError={setErr} /> : null}
921
1128
  {tab === "terms" ? <PageTerms api={api} pageId={page.id} canEdit={canEdit} onError={setErr} /> : null}
922
1129
  {tab === "audit" ? <AuditLog api={api} pageId={page.id} onError={setErr} /> : null}
1130
+ </aside>
923
1131
  </div>
924
1132
  </div>
925
1133
  );
926
1134
  }
927
1135
 
1136
+
928
1137
  // A single block, edited inline: the block IS its editor. Fields render in place (rich text
929
1138
  // as the WYSIWYG, media as a thumbnail picker). Edits are held locally and committed only on
930
1139
  // an explicit Save — the header + footer show an "unsaved" state until you do, and nothing is
@@ -1188,6 +1397,10 @@ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSav
1188
1397
 
1189
1398
  useEffect(() => { setTitle(page.title); setSlug(page.slug); setLocale(page.locale); }, [page.id, page.title, page.slug, page.locale]);
1190
1399
 
1400
+ // Disabled until something actually differs, so the button says whether there is anything
1401
+ // to save rather than inviting a no-op write on every visit to the tab.
1402
+ const changed = title !== page.title || slug.trim() !== page.slug || (multilingual && locale !== page.locale);
1403
+
1191
1404
  const save = async () => {
1192
1405
  setBusy(true);
1193
1406
  try {
@@ -1211,7 +1424,6 @@ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSav
1211
1424
 
1212
1425
  return (
1213
1426
  <div className="flex flex-col gap-4">
1214
- <Section>Page</Section>
1215
1427
  {ok ? <Banner ok>saved</Banner> : null}
1216
1428
  <Input label="Title" value={title} onChange={setTitle} />
1217
1429
  <Input label="Slug" value={slug} onChange={setSlug} />
@@ -1227,8 +1439,13 @@ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSav
1227
1439
  </select>
1228
1440
  </label>
1229
1441
  ) : null}
1230
- <Button onPress={save} isDisabled={busy || !title.trim() || !slug.trim()}>{busy ? "Saving…" : "Save"}</Button>
1231
- <KV><span>Status</span><span>{page.status}</span></KV>
1442
+ {/* Explicit, unlike the canvas — a slug is the page's URL, and autosaving one keystroke
1443
+ at a time would publish `/ab`, `/abo`, `/abou` as real addresses and race the
1444
+ uniqueness check on every one. Named for what it saves, since it is no longer the
1445
+ only Save on screen by accident. */}
1446
+ <Button onPress={save} isDisabled={busy || !changed || !title.trim() || !slug.trim()}>
1447
+ {busy ? "Saving…" : "Save settings"}
1448
+ </Button>
1232
1449
  </div>
1233
1450
  );
1234
1451
  }
@@ -1236,8 +1453,7 @@ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSav
1236
1453
  /** The page's own FIELDS — its content. Rendered in the canvas, at full width. */
1237
1454
  function PageFields({ api, page, schema, initialFields, onDirtyChange, onError }: { api: Api; page: Page; schema: FieldDefinition[]; initialFields: FieldValues; onDirtyChange: (id: string, dirty: boolean) => void; onError: (s: string) => void }) {
1238
1455
  const [fields, setFields] = useState<FieldValues>(initialFields);
1239
- const [ok, setOk] = useState(false);
1240
- const [busy, setBusy] = useState(false);
1456
+ const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle");
1241
1457
 
1242
1458
  // Dirty is DERIVED from a snapshot of what's persisted, the way BlockCard does it — not a
1243
1459
  // one-way flag set on every keystroke. Typing a character and deleting it again leaves the
@@ -1263,33 +1479,49 @@ function PageFields({ api, page, schema, initialFields, onDirtyChange, onError }
1263
1479
  useEffect(() => { onDirtyChange("page", dirty); }, [dirty, onDirtyChange]);
1264
1480
  useEffect(() => () => { onDirtyChange("page", false); }, [onDirtyChange]);
1265
1481
 
1266
- const save = async () => {
1482
+ // AUTOSAVED, on the same 800ms debounce a block uses — because this is the same thing a
1483
+ // block is: content on the canvas. It used to carry its own "Save" button, which sat a
1484
+ // column away from the inspector's identically-labelled one and saved the other half of the
1485
+ // screen, while the blocks between them saved silently. Three save models on one screen.
1486
+ const save = useCallback(async () => {
1267
1487
  // Snapshot BEFORE the round trip: an edit made while it's in flight must stay dirty.
1268
1488
  const snapshot = JSON.stringify(fields);
1269
- setBusy(true);
1489
+ if (snapshot === saved.current) return;
1490
+ setSaveState("saving");
1270
1491
  try {
1271
1492
  await api.call("updatePage", { pageId: page.id, fields });
1272
1493
  saved.current = snapshot;
1273
- setOk(true);
1274
- setTimeout(() => setOk(false), 1500);
1494
+ setSaveState("saved");
1495
+ setTimeout(() => setSaveState((st) => (st === "saved" ? "idle" : st)), 1500);
1275
1496
  } catch (e) {
1276
1497
  onError(errMsg(e));
1277
- } finally {
1278
- setBusy(false);
1498
+ setSaveState("idle");
1279
1499
  }
1280
- };
1500
+ }, [api, page.id, fields, onError]);
1501
+
1502
+ // Keyed on `fields`, so it fires only on an actual edit: a failed save leaves them
1503
+ // unchanged and does NOT auto-retry (no hot loop) — the next edit does. The leave guard
1504
+ // above still covers the debounce window.
1505
+ const saveRef = useRef(save);
1506
+ saveRef.current = save;
1507
+ useEffect(() => {
1508
+ if (!dirty) return;
1509
+ const t = setTimeout(() => void saveRef.current(), 800);
1510
+ return () => clearTimeout(t);
1511
+ }, [fields, dirty]);
1281
1512
 
1282
1513
  return (
1283
1514
  <div className="mb-10">
1284
1515
  <div className="mb-1 flex items-center gap-2">
1285
- <span className="text-caption font-medium uppercase tracking-wide text-fg-subtle">Content</span>
1286
- {dirty ? <span className="text-[11px] text-accent-strong">● unsaved</span> : null}
1516
+ {/* NOT "Content". A region is very often named `content`, and the two headings then
1517
+ sat one above the other on the same canvas, both reading CONTENT and meaning
1518
+ different things. These are the page's OWN fields. */}
1519
+ <span className="text-caption font-medium uppercase tracking-wide text-fg-subtle">Page fields</span>
1520
+ <span className={`text-caption ${dirty && saveState !== "saving" ? "text-accent-strong" : "text-fg-subtle"}`}>
1521
+ {saveState === "saving" ? "saving…" : saveState === "saved" ? "saved ✓" : dirty ? "● unsaved" : ""}
1522
+ </span>
1287
1523
  </div>
1288
- {ok ? <Banner ok>saved</Banner> : null}
1289
1524
  <FieldForm schema={schema} value={fields} onChange={setFields} api={api} />
1290
- <div className="mt-3">
1291
- <Button onPress={save} isDisabled={busy}>{busy ? "Saving…" : "Save"}</Button>
1292
- </div>
1293
1525
  </div>
1294
1526
  );
1295
1527
  }
@@ -1327,52 +1559,6 @@ function SeoPanel({ api, page, onError }: { api: Api; page: Page; onError: (s: s
1327
1559
  );
1328
1560
  }
1329
1561
 
1330
- function Workflow({ api, page, onChanged, onError }: { api: Api; page: Page; onChanged: (p: Page) => void; onError: (s: string) => void }) {
1331
- const act = async (name: string, input?: unknown) => {
1332
- try {
1333
- const r = await api.call<{ page?: Page }>(name, { pageId: page.id, ...(input as object) });
1334
- if (r?.page) onChanged(r.page);
1335
- } catch (e) {
1336
- onError(errMsg(e));
1337
- }
1338
- };
1339
- // Show only the transitions valid FROM the current status, so a draft never surfaces a
1340
- // primary "Approve" that the server rejects (approve requires status === "review"). The
1341
- // server still enforces role gates; this just stops the UI from offering dead-end actions.
1342
- // `publish` = publishPage (any → published, reviewer/admin) — the one-click path for a solo
1343
- // operator; `approve` is the reviewer step of the two-actor review pipeline.
1344
- const status = String(page.status);
1345
- const buttons: Array<{ label: string; action: string; variant?: "secondary" | "ghost" }> =
1346
- status === "review"
1347
- ? [
1348
- { label: "Approve (publish)", action: "approve" },
1349
- { label: "Reject", action: "reject", variant: "secondary" },
1350
- { label: "Publish directly", action: "publishPage", variant: "secondary" },
1351
- ]
1352
- : status === "published"
1353
- ? [{ label: "Unpublish", action: "unpublishPage", variant: "secondary" }]
1354
- : // draft | rejected | archived
1355
- [
1356
- { label: "Publish", action: "publishPage" },
1357
- { label: "Submit for review", action: "submitForReview", variant: "secondary" },
1358
- ];
1359
- return (
1360
- <div>
1361
- <Section>Workflow</Section>
1362
- <div className="mb-3 flex items-center gap-2 text-[13px] text-fg-muted">
1363
- <span className="text-fg-subtle">Status</span>
1364
- <Pill status={page.status}>{page.status}</Pill>
1365
- </div>
1366
- <div className="flex flex-col gap-2">
1367
- {buttons.map((b) => (
1368
- <Button key={b.action} variant={b.variant} onPress={() => act(b.action)}>{b.label}</Button>
1369
- ))}
1370
- </div>
1371
- <p className="mt-2.5 text-fg-subtle">Publish needs a reviewer/admin role; submit-for-review is editor-gated.</p>
1372
- </div>
1373
- );
1374
- }
1375
-
1376
1562
  function I18n({ api, page, onError }: { api: Api; page: Page; onError: (s: string) => void }) {
1377
1563
  const [translations, setTranslations] = useState<{ id: string; locale: string; slug: string; status: string }[]>([]);
1378
1564
  const [locale, setLocale] = useState("");
@@ -1428,7 +1614,9 @@ function PageTerms({ api, pageId, canEdit, onError }: { api: Api; pageId: string
1428
1614
  let live = true;
1429
1615
  (async () => {
1430
1616
  try {
1431
- const list = await api.listTaxonomies();
1617
+ // Only the vocabularies that classify PAGES. The narrowing is the server's, so this
1618
+ // panel and `setPageTerms`' own guard cannot disagree about what applies here.
1619
+ const list = await api.listTaxonomies("page");
1432
1620
  if (!live) return;
1433
1621
  setTaxa(list);
1434
1622
  // In parallel, and alongside the page's own assignments. Awaiting one vocabulary at
@@ -1466,7 +1654,10 @@ function PageTerms({ api, pageId, canEdit, onError }: { api: Api; pageId: string
1466
1654
  };
1467
1655
 
1468
1656
  if (taxa === null) return <p className="text-fg-subtle">Loading…</p>;
1469
- if (taxa.length === 0) return <p className="text-fg-subtle">No vocabularies defined yet.</p>;
1657
+ // Not "none defined": the list is narrowed to the vocabularies that apply to PAGES, so a
1658
+ // site whose only vocabulary is media-only would read as having none — and send an editor
1659
+ // off to create a duplicate of the one it already has.
1660
+ if (taxa.length === 0) return <p className="text-fg-subtle">No vocabularies apply to pages yet — set one up under Taxonomies.</p>;
1470
1661
 
1471
1662
  return (
1472
1663
  <div className="flex flex-col gap-4">
@@ -1515,24 +1706,107 @@ function AuditLog({ api, pageId, onError }: { api: Api; pageId: string; onError:
1515
1706
  );
1516
1707
  }
1517
1708
 
1709
+ /** One filter bucket. A toggle rather than a link: pressing the active one clears the filter,
1710
+ * which is the gesture a chip bar teaches — and `aria-pressed` says so, since the tint alone
1711
+ * is invisible to a screen reader and marginal to anyone who cannot see it. */
1712
+ function FilterChip({ active, onPress, children }: { active: boolean; onPress: () => void; children: ReactNode }) {
1713
+ return (
1714
+ <Button
1715
+ variant="ghost"
1716
+ size="sm"
1717
+ aria-pressed={active}
1718
+ className={`rounded-full px-3 py-1 text-compact ${active ? "bg-surface-muted font-medium text-fg" : "text-fg-muted hover:text-fg"}`}
1719
+ onPress={onPress}
1720
+ >
1721
+ {children}
1722
+ </Button>
1723
+ );
1724
+ }
1725
+
1518
1726
  // --- media library (a top-level view: browse, upload, edit alt, delete) ---
1519
1727
  const PAGE_SIZE = 60;
1728
+ /** How long typing has to pause before the library is re-queried. Long enough that a typed
1729
+ * word is one request rather than five, short enough that it still reads as live. */
1730
+ const SEARCH_DEBOUNCE_MS = 250;
1731
+ /** What the header says about the count.
1732
+ *
1733
+ * Split out because the zero case is two different sentences: an empty LIBRARY, and a filter
1734
+ * that matched nothing. The header used to say "None yet" for both, which reads as "this CMS
1735
+ * has no media" while sixty files sit one cleared chip away. */
1736
+ function mediaCountLabel(count: number, hasMore: boolean, narrowed: boolean): string {
1737
+ if (count === 0) return narrowed ? "No matches" : "None yet";
1738
+ const suffix = hasMore ? "+" : "";
1739
+ return count === 1 && !hasMore ? "1 file" : `${count}${suffix} files`;
1740
+ }
1741
+
1742
+ /** The tag menu's "no filter" row. A menu item needs an id and `null` is not one, so the
1743
+ * clear option carries a sentinel — safe against a real term id, which is a uuid. */
1744
+ const ALL_TAGS = "__all";
1520
1745
 
1521
1746
  export function MediaLibrary({ api, onError }: { api: Api; onError: (s: string) => void }) {
1747
+ const { cms: { mediaTerms, canEdit } } = useApp();
1522
1748
  const [media, setMedia] = useState<Media[]>([]);
1523
1749
  const [offset, setOffset] = useState(0);
1524
1750
  const [hasMore, setHasMore] = useState(false);
1525
1751
  const [selected, setSelected] = useState<Media | null>(null);
1526
1752
  const [busy, setBusy] = useState(false);
1753
+ // The file input the header's Upload button drives. It stays in the DOM (hidden) rather
1754
+ // than being created per click, so the picker's `change` handler is the ordinary React one.
1755
+ const fileInput = useRef<HTMLInputElement>(null);
1527
1756
  // Trashed files. Deleting no longer removes the R2 object, so without this the bytes stay
1528
1757
  // publicly fetchable with no way to reach purgeMedia — the case a takedown request needs.
1529
1758
  const [trash, setTrash] = useState<Media[]>([]);
1530
1759
  const [showTrash, setShowTrash] = useState(false);
1760
+ // Order and type filter. Both are SERVER-side: the library is paged, and sorting or
1761
+ // filtering the page that arrived would sort or filter 60 of however many there are, which
1762
+ // is not sorting and not filtering.
1763
+ const [sort, setSort] = useState<MediaSort>("newest");
1764
+ const [kind, setKind] = useState<MediaKind | null>(null);
1765
+ // Two states for one search box. `query` is what the field shows and must update on every
1766
+ // keystroke; `search` is what the server is asked for, and lags it by `SEARCH_DEBOUNCE_MS`
1767
+ // — without the split, either the field stutters or every letter is a round trip.
1768
+ const [query, setQuery] = useState("");
1769
+ const [search, setSearch] = useState("");
1770
+ useEffect(() => {
1771
+ const t = setTimeout(() => setSearch(query.trim()), SEARCH_DEBOUNCE_MS);
1772
+ return () => clearTimeout(t);
1773
+ }, [query]);
1774
+ // Tags. The vocabularies are loaded ONCE here rather than in the detail modal: the filter
1775
+ // above the grid and the checkboxes inside a file both need the same list, and fetching it
1776
+ // per opened file would be a round trip on every click for data that changes on the terms
1777
+ // screen. `term` filters server-side like `kind` — it is a relation traversal, not a
1778
+ // client-side pass over the page that happened to arrive.
1779
+ const [term, setTerm] = useState<string | null>(null);
1780
+ const [taxa, setTaxa] = useState<Taxonomy[]>([]);
1781
+ const [terms, setTerms] = useState<Record<string, Term[]>>({});
1782
+ useEffect(() => {
1783
+ if (!mediaTerms) return;
1784
+ let live = true;
1785
+ (async () => {
1786
+ try {
1787
+ const list = await api.listTaxonomies("media");
1788
+ const trees = await Promise.all(list.map(async (t) => [t.slug, flattenTerms(await api.getTermTree(t.slug)).map((f) => f.term)] as const));
1789
+ if (!live) return;
1790
+ setTaxa(list);
1791
+ setTerms(Object.fromEntries(trees));
1792
+ } catch {
1793
+ // Non-fatal, deliberately: tagging is one control on this screen, and a vocabulary
1794
+ // fetch that fails should cost the tag filter, not the media library.
1795
+ if (live) { setTaxa([]); setTerms({}); }
1796
+ }
1797
+ })();
1798
+ return () => { live = false; };
1799
+ }, [api, mediaTerms]);
1800
+ // Flattened for the filter menu, which is one list rather than a tree: a menu has no room
1801
+ // for indentation to read as hierarchy, so the vocabulary is spelled out per row instead.
1802
+ const taggable = taxa.flatMap((t) => (terms[t.slug] ?? []).map((x) => ({ id: x.id, label: x.label, group: t.label })));
1803
+ /** Whether what is on screen is a NARROWING of the library rather than the library. */
1804
+ const narrowed = kind !== null || term !== null || search !== "";
1531
1805
 
1532
1806
  const load = useCallback(
1533
1807
  (off: number) => {
1534
1808
  api
1535
- .listMedia(PAGE_SIZE, off)
1809
+ .listMedia({ limit: PAGE_SIZE, offset: off, sort, kind: kind ?? undefined, q: search || undefined, term: term ?? undefined })
1536
1810
  .then((rows) => {
1537
1811
  setMedia((prev) => (off === 0 ? rows : [...prev, ...rows]));
1538
1812
  setHasMore(rows.length === PAGE_SIZE);
@@ -1540,7 +1814,10 @@ export function MediaLibrary({ api, onError }: { api: Api; onError: (s: string)
1540
1814
  })
1541
1815
  .catch((e) => onError(errMsg(e)));
1542
1816
  },
1543
- [api, onError],
1817
+ // Changing any of them resets to page 0 through the effect below — appending a
1818
+ // differently ordered or narrowed page onto the one already on screen would interleave
1819
+ // two orderings, or show files the current filter excludes.
1820
+ [api, onError, sort, kind, search, term],
1544
1821
  );
1545
1822
  const loadTrash = useCallback(() => {
1546
1823
  api.listTrash().then((r) => setTrash(r.media ?? [])).catch((e) => onError(errMsg(e)));
@@ -1583,17 +1860,95 @@ export function MediaLibrary({ api, onError }: { api: Api; onError: (s: string)
1583
1860
 
1584
1861
  return (
1585
1862
  <>
1586
- <Hero lead="Media" em={media.length === 0 ? "None yet" : media.length === 1 ? "1 file" : `${media.length}${hasMore ? "+" : ""} files`}>
1587
- <Cta text="Let's" em="upload something">
1588
- <label className={`inline-flex shrink-0 cursor-pointer items-center rounded-full bg-surface-inverted px-6 py-3 text-compact text-fg-inverted ${busy ? "pointer-events-none opacity-50" : ""}`}>
1589
- {busy ? "Uploading…" : "+ Upload"}
1590
- <input type="file" multiple hidden disabled={busy} onChange={(e) => { upload(e.target.files); e.target.value = ""; }} />
1591
- </label>
1592
- </Cta>
1863
+ <Hero lead="Media" em={mediaCountLabel(media.length, hasMore, narrowed)}>
1864
+ {/* A real `Button` driving a hidden input, not a `<label>` painted to look like one.
1865
+ The lookalike had to restate podoba's primary fill by hand, and once the mint
1866
+ wrapper was gone the two "primary" actions in this app were visibly different
1867
+ colours on the dark theme `surface-inverted` (cream) here, `brand-primary`
1868
+ (mint) everywhere else. */}
1869
+ <input ref={fileInput} type="file" multiple hidden disabled={busy} onChange={(e) => { upload(e.target.files); e.target.value = ""; }} />
1870
+ <Button className="shrink-0" isDisabled={busy} onPress={() => fileInput.current?.click()}>
1871
+ {busy ? "Uploading…" : "+ Upload"}
1872
+ </Button>
1593
1873
  </Hero>
1594
1874
  <div className={WRAP}>
1875
+ {/* Order and type, above the grid rather than in the header: the header is the
1876
+ SCREEN's identity and its one primary action, and a row of controls in it would be
1877
+ the mint-pill mistake again — a second cluster competing with the artwork. They sit
1878
+ with the thing they act on. */}
1879
+ <div className="mb-4 flex flex-wrap items-center gap-2">
1880
+ {/* Matches the filename AND the alt text — the only human description a media row
1881
+ carries, so searching for "logo" finds the file somebody described as one even
1882
+ when the upload was called `IMG_2831.png`. */}
1883
+ <SearchField
1884
+ aria-label="Search media"
1885
+ placeholder="Search files…"
1886
+ value={query}
1887
+ onChange={setQuery}
1888
+ className="w-[220px] shrink-0"
1889
+ />
1890
+ {/* A menu, not podoba's `Select`: that is a form FIELD — a stacked visible label
1891
+ over a trigger, sized and spaced for a form — and in a toolbar it would stand a
1892
+ head taller than the chips beside it. A trigger showing the current order is the
1893
+ toolbar shape, and it is the same RAC menu pattern underneath. */}
1894
+ <DropdownMenuTrigger>
1895
+ <Button variant="secondary" size="sm" className="shrink-0 px-3 py-1 text-compact">
1896
+ {MEDIA_SORT_LABELS[sort]}
1897
+ </Button>
1898
+ <DropdownMenu aria-label="Sort media" onAction={(k) => setSort(k as MediaSort)}>
1899
+ {MEDIA_SORTS.map((v) => (
1900
+ <DropdownMenuItem key={v} id={v}>{MEDIA_SORT_LABELS[v]}</DropdownMenuItem>
1901
+ ))}
1902
+ </DropdownMenu>
1903
+ </DropdownMenuTrigger>
1904
+ {/* A menu rather than chips, unlike `kind`: the buckets are a closed set of five that
1905
+ fits on the row, where terms are however many an editor has authored. It sits
1906
+ BESIDE the sort menu rather than after the chips, so the row groups by control
1907
+ type — two triggers, then the chip bar — instead of trailing a seventh pill that
1908
+ reads as another type bucket. Drawn only once a vocabulary HAS a term: a filter
1909
+ offering nothing to filter by can only disappoint. */}
1910
+ {mediaTerms && taggable.length > 0 ? (
1911
+ <DropdownMenuTrigger>
1912
+ <Button variant="secondary" size="sm" className="shrink-0 px-3 py-1 text-compact">
1913
+ {term ? (taggable.find((t) => t.id === term)?.label ?? "Tag") : "All tags"}
1914
+ </Button>
1915
+ <DropdownMenu aria-label="Filter media by tag" onAction={(k) => setTerm(k === ALL_TAGS ? null : String(k))}>
1916
+ <DropdownMenuItem id={ALL_TAGS}>All tags</DropdownMenuItem>
1917
+ {/* Qualified by vocabulary here, where a flat menu gives hierarchy nowhere to
1918
+ show; the trigger stays the bare term, which is what the row is filtered by. */}
1919
+ {taggable.map((t) => (
1920
+ <DropdownMenuItem key={t.id} id={t.id}>{t.group} · {t.label}</DropdownMenuItem>
1921
+ ))}
1922
+ </DropdownMenu>
1923
+ </DropdownMenuTrigger>
1924
+ ) : null}
1925
+ {/* Buttons, not a second dropdown: five buckets is few enough to show, and a filter
1926
+ you can see the state of without opening it is the point of a filter bar. "All"
1927
+ is `null` rather than a sixth kind — the server's absent-means-everything, said
1928
+ once, on the side that has the state. */}
1929
+ <div className="flex flex-wrap items-center gap-1">
1930
+ <FilterChip active={kind === null} onPress={() => setKind(null)}>All</FilterChip>
1931
+ {MEDIA_KINDS.map((k) => (
1932
+ <FilterChip key={k} active={kind === k} onPress={() => setKind(kind === k ? null : k)}>
1933
+ {MEDIA_KIND_LABELS[k]}
1934
+ </FilterChip>
1935
+ ))}
1936
+ </div>
1937
+ </div>
1595
1938
  {media.length === 0 ? (
1596
- <p className="text-fg-subtle">No media yet. Upload images to use them in blocks and SEO.</p>
1939
+ // "No media yet" is a claim about the LIBRARY, and this list is a narrowing of it.
1940
+ // Search, type and tag are all new, so this is a state the screen could not reach
1941
+ // before: typing "logo" or picking Documents on an image-only library told you the
1942
+ // CMS was empty and asked you to upload, when the answer was that the filter matched
1943
+ // nothing — and the way out is to clear it, not to upload a file.
1944
+ narrowed ? (
1945
+ <div className="flex flex-wrap items-center gap-3">
1946
+ <p className="text-fg-subtle">No files match this filter.</p>
1947
+ <Button variant="secondary" size="sm" onPress={() => { setQuery(""); setSearch(""); setKind(null); setTerm(null); }}>Clear filters</Button>
1948
+ </div>
1949
+ ) : (
1950
+ <p className="text-fg-subtle">No media yet. Upload images to use them in blocks and SEO.</p>
1951
+ )
1597
1952
  ) : (
1598
1953
  <div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2.5">
1599
1954
  {media.map((m) => (
@@ -1637,9 +1992,16 @@ export function MediaLibrary({ api, onError }: { api: Api; onError: (s: string)
1637
1992
  <MediaDetail
1638
1993
  api={api}
1639
1994
  media={selected}
1995
+ taxa={mediaTerms ? taxa : []}
1996
+ terms={terms}
1997
+ canEdit={canEdit}
1640
1998
  onClose={() => setSelected(null)}
1641
1999
  onSaved={(m) => { setSelected(m); setMedia((prev) => prev.map((x) => (x.id === m.id ? m : x))); }}
1642
2000
  onDeleted={(id) => { setSelected(null); setMedia((prev) => prev.filter((x) => x.id !== id)); loadTrash(); }}
2001
+ // Only while a tag filter is on, and only then: retagging the open file can move
2002
+ // it out of (or into) the current narrowing, so leaving the grid alone would show
2003
+ // a file the filter excludes. Unfiltered, nothing on screen changed.
2004
+ onTermsSaved={() => { if (term) load(0); }}
1643
2005
  onError={onError}
1644
2006
  />
1645
2007
  ) : null}
@@ -1648,7 +2010,7 @@ export function MediaLibrary({ api, onError }: { api: Api; onError: (s: string)
1648
2010
  );
1649
2011
  }
1650
2012
 
1651
- function MediaDetail({ api, media, onClose, onSaved, onDeleted, onError }: { api: Api; media: Media; onClose: () => void; onSaved: (m: Media) => void; onDeleted: (id: string) => void; onError: (s: string) => void }) {
2013
+ function MediaDetail({ api, media, taxa, terms, canEdit, onClose, onSaved, onDeleted, onTermsSaved, onError }: { api: Api; media: Media; taxa: Taxonomy[]; terms: Record<string, Term[]>; canEdit: boolean; onClose: () => void; onSaved: (m: Media) => void; onDeleted: (id: string) => void; onTermsSaved: () => void; onError: (s: string) => void }) {
1652
2014
  const [alt, setAlt] = useState(media.alt ?? "");
1653
2015
  const [busy, setBusy] = useState(false);
1654
2016
  useEffect(() => setAlt(media.alt ?? ""), [media]);
@@ -1678,8 +2040,7 @@ function MediaDetail({ api, media, onClose, onSaved, onDeleted, onError }: { api
1678
2040
  };
1679
2041
 
1680
2042
  return (
1681
- <Modal onClose={onClose} wide>
1682
- <ModalTitle><Dim>Media</Dim> {media.file.filename ?? ""}</ModalTitle>
2043
+ <Modal onClose={onClose} size="lg" title={<><Dim>Media</Dim> {media.file.filename ?? ""}</>}>
1683
2044
  {isImage(media) ? (
1684
2045
  <img className="mx-auto mb-3.5 block max-h-[340px] max-w-full rounded-lg bg-surface-muted object-contain" src={url} alt={media.alt ?? ""} />
1685
2046
  ) : (
@@ -1700,6 +2061,7 @@ function MediaDetail({ api, media, onClose, onSaved, onDeleted, onError }: { api
1700
2061
  <a className="underline underline-offset-2" href={url} target="_blank" rel="noreferrer">/media/{media.file.key}</a>
1701
2062
  </span>
1702
2063
  </KV>
2064
+ {taxa.length > 0 ? <MediaTerms api={api} mediaId={media.id} taxa={taxa} terms={terms} canEdit={canEdit} onSaved={onTermsSaved} onError={onError} /> : null}
1703
2065
  <div className="mt-3.5 flex items-center gap-2">
1704
2066
  <Button onPress={save} isDisabled={busy || alt === (media.alt ?? "")}>Save</Button>
1705
2067
  <Button variant="secondary" size="sm" onPress={() => navigator.clipboard?.writeText(url)}>Copy URL</Button>
@@ -1711,6 +2073,85 @@ function MediaDetail({ api, media, onClose, onSaved, onDeleted, onError }: { api
1711
2073
  );
1712
2074
  }
1713
2075
 
2076
+ /** A file's taxonomy terms, inside the detail modal.
2077
+ *
2078
+ * The vocabularies arrive as props — the library loaded them once for its filter, and this
2079
+ * panel opens and closes per file. Only the ASSIGNMENTS are fetched here, because they are
2080
+ * the part that is per-file.
2081
+ *
2082
+ * Saved as a set, like `setPageTerms`: the panel holds the whole selection, and two calls
2083
+ * each patching one end of it race into a state neither asked for.
2084
+ */
2085
+ function MediaTerms({ api, mediaId, taxa, terms, canEdit, onSaved, onError }: { api: Api; mediaId: string; taxa: Taxonomy[]; terms: Record<string, Term[]>; canEdit: boolean; onSaved: () => void; onError: (s: string) => void }) {
2086
+ const [selected, setSelected] = useState<Set<string> | null>(null);
2087
+ const [busy, setBusy] = useState(false);
2088
+ const [ok, setOk] = useState(false);
2089
+
2090
+ useEffect(() => {
2091
+ let live = true;
2092
+ setSelected(null);
2093
+ api
2094
+ .listMediaTerms(mediaId)
2095
+ .then((rows) => { if (live) setSelected(new Set(rows.map((t) => t.id))); })
2096
+ .catch((e) => { if (live) { setSelected(new Set()); onError(errMsg(e)); } });
2097
+ return () => { live = false; };
2098
+ }, [api, mediaId, onError]);
2099
+
2100
+ const toggle = (id: string) => {
2101
+ setSelected((prev) => {
2102
+ const next = new Set(prev ?? []);
2103
+ if (next.has(id)) next.delete(id); else next.add(id);
2104
+ return next;
2105
+ });
2106
+ };
2107
+
2108
+ const save = async () => {
2109
+ setBusy(true);
2110
+ try {
2111
+ await api.setMediaTerms(mediaId, [...(selected ?? [])]);
2112
+ setOk(true);
2113
+ setTimeout(() => setOk(false), 1200);
2114
+ onSaved();
2115
+ } catch (e) { onError(errMsg(e)); } finally { setBusy(false); }
2116
+ };
2117
+
2118
+ // Until the assignments arrive the checkboxes would all read unchecked, and a save from
2119
+ // that state would silently clear every tag the file has.
2120
+ if (selected === null) return <div className="mt-4 border-t border-border pt-3.5 text-small text-fg-subtle">Loading tags…</div>;
2121
+
2122
+ return (
2123
+ <div className="mt-4 border-t border-border pt-3.5">
2124
+ <Section>Tags</Section>
2125
+ {ok ? <Banner ok>saved</Banner> : null}
2126
+ <div className="flex flex-col gap-2.5">
2127
+ {taxa.map((t) => {
2128
+ const list = terms[t.slug] ?? [];
2129
+ return (
2130
+ <div key={t.id}>
2131
+ <div className="mb-1 text-caption text-fg-subtle">{t.label}</div>
2132
+ {list.length === 0 ? (
2133
+ <span className="text-caption text-fg-subtle">No terms yet.</span>
2134
+ ) : (
2135
+ <div className="flex flex-wrap gap-x-4 gap-y-1">
2136
+ {list.map((term) => (
2137
+ <label key={term.id} className="flex items-center gap-2">
2138
+ {/* `setMediaTerms` is editor-gated, so without this a reviewer gets live
2139
+ checkboxes and a Save that 403s — the same gap `PageTerms` closed. */}
2140
+ <input type="checkbox" disabled={!canEdit} checked={selected.has(term.id)} onChange={() => toggle(term.id)} />
2141
+ <span className="text-sm text-fg">{term.label}</span>
2142
+ </label>
2143
+ ))}
2144
+ </div>
2145
+ )}
2146
+ </div>
2147
+ );
2148
+ })}
2149
+ </div>
2150
+ {canEdit ? <Button size="sm" variant="secondary" className="mt-2.5 self-start" onPress={save} isDisabled={busy}>{busy ? "Saving…" : "Save tags"}</Button> : null}
2151
+ </div>
2152
+ );
2153
+ }
2154
+
1714
2155
  // --- users management (admin) ------------------------------------------------
1715
2156
 
1716
2157
  interface UserRow {
@@ -1761,9 +2202,7 @@ export function UsersView({ api, me, onError }: { api: Api; me: Me | null; onErr
1761
2202
  return (
1762
2203
  <>
1763
2204
  <Hero lead="Users" em={users.length === 0 ? "None yet" : users.length === 1 ? "1 account" : `${users.length} accounts`}>
1764
- <Cta text="Let's" em="invite someone">
1765
- <Button className="shrink-0" onPress={() => setInviting(true)}>+ Invite</Button>
1766
- </Cta>
2205
+ <Button className="shrink-0" onPress={() => setInviting(true)}>+ Invite</Button>
1767
2206
  </Hero>
1768
2207
  <div className={WRAP}>
1769
2208
  <div className="flex flex-col gap-2">
@@ -1841,9 +2280,11 @@ function InviteUser({ api, onClose, onInvited, onError }: { api: Api; onClose: (
1841
2280
  }
1842
2281
  };
1843
2282
  return (
1844
- <Modal onClose={onClose}>
1845
- <ModalTitle>Invite an <Dim>editor</Dim> or teammate</ModalTitle>
1846
- <p className="-mt-2 mb-4 text-fg-subtle">They&apos;ll get a one-time magic link that logs them in and creates their account.</p>
2283
+ <Modal
2284
+ onClose={onClose}
2285
+ title={<>Invite an <Dim>editor</Dim> or teammate</>}
2286
+ description="They'll get a one-time magic link that logs them in and creates their account."
2287
+ >
1847
2288
  <div className="flex flex-col gap-4">
1848
2289
  <Input label="Email" type="email" autoFocus value={email} onChange={setEmail} placeholder="them@example.com" />
1849
2290
  <Input label="Roles (comma-separated — e.g. editor, reviewer, admin)" value={roles} onChange={setRoles} placeholder="editor" />