@pramen/cms-editor 0.0.60 → 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/components.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
function
|
|
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
|
-
<
|
|
179
|
+
<Dialog isOpen isDismissable size={size} title={title} description={description} onOpenChange={(open) => !open && onClose()}>
|
|
116
180
|
{children}
|
|
117
|
-
</
|
|
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
|
-
<
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
const
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
-
<
|
|
912
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
1231
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1274
|
-
setTimeout(() =>
|
|
1494
|
+
setSaveState("saved");
|
|
1495
|
+
setTimeout(() => setSaveState((st) => (st === "saved" ? "idle" : st)), 1500);
|
|
1275
1496
|
} catch (e) {
|
|
1276
1497
|
onError(errMsg(e));
|
|
1277
|
-
|
|
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
|
-
|
|
1286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
<
|
|
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
|
|
1845
|
-
|
|
1846
|
-
|
|
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" />
|