@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.60",
4
- "description": "Visual block/page editor for @pramen/cms \u2014 a standalone React SPA that talks to the CMS handlers over HTTP.",
3
+ "version": "0.0.63",
4
+ "description": "Visual block/page editor for @pramen/cms a standalone React SPA that talks to the CMS handlers over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@buzola/router": "^0.0.12",
30
+ "@phosphor-icons/react": "^2.1.10",
30
31
  "@podoba/react": "^0.0.34",
31
32
  "@podoba/tailwind": "^0.0.34",
32
33
  "@podoba/tokens": "^0.0.34",
package/src/api.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // same transport shape as @pramen/admin's api.ts. Config is persisted in localStorage.
3
3
 
4
4
  import type { AdminPageMeta, AdminPageResponse, AssembledPage, AuditEntry, BlockType, ContentType, Media, Menu, MenuItem, Page, Redirect, Taxonomy, Term, Widget, WidgetArea } from "./types";
5
- import type { DefaultBlockDefinition, FieldDefinition, RegionDefinition, RpcInput } from "./types";
5
+ import type { DefaultBlockDefinition, FieldDefinition, MediaKind, MediaSort, RegionDefinition, RpcInput, TaxonomyTarget } from "./types";
6
6
 
7
7
  /** The payload `createBlockType` / `updateBlockType` take. `fieldsSchema` is the whole
8
8
  * point: a block type IS its field schema. */
@@ -173,7 +173,13 @@ export class Api {
173
173
  listPageAudit = (pageId: string) => this.call<AuditEntry[]>("listPageAudit", { pageId });
174
174
 
175
175
  // --- media ---
176
- listMedia = (limit = 50, offset = 0) => this.call<Media[]>("listMedia", { limit, offset });
176
+ // `undefined` values are simply not serialized, so the absent narrowings need no
177
+ // conditional spreading — an omitted key and a key set to undefined reach the server the same.
178
+ /** Options rather than positionals: the library narrows by four independent things, and
179
+ * `listMedia(60, 0, sort, undefined, undefined, term)` is a call site nobody can read and
180
+ * everybody can get one argument out of step. */
181
+ listMedia = (opts: { limit?: number; offset?: number; sort?: MediaSort; kind?: MediaKind; q?: string; term?: string } = {}) =>
182
+ this.call<Media[]>("listMedia", { limit: opts.limit ?? 50, offset: opts.offset ?? 0, sort: opts.sort, kind: opts.kind, q: opts.q, term: opts.term });
177
183
  getMedia = (id: string) => this.call<Media | null>("getMedia", { id });
178
184
  updateMedia = (id: string, alt: string | null) => this.call<Media>("updateMedia", { id, alt });
179
185
  deleteMedia = (id: string) => this.call<{ ok: true }>("deleteMedia", { id });
@@ -209,10 +215,14 @@ export class Api {
209
215
  this.call<Redirect>("updateRedirect", { id, ...patch } as unknown as RpcInput);
210
216
  deleteRedirect = (id: string) => this.call<{ ok: true }>("deleteRedirect", { id });
211
217
 
212
- listTaxonomies = () => this.call<Taxonomy[]>("listTaxonomies");
213
- createTaxonomy = (input: { slug: string; label: string; pluralLabel?: string; description?: string; hierarchical?: boolean }) =>
214
- this.call<Taxonomy>("createTaxonomy", input);
215
- updateTaxonomy = (id: string, patch: { label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean }) =>
218
+ /** Every vocabulary, or only those that classify `target`. The narrowing is the SERVER's —
219
+ * the page panel, the media panel and the write-side guard read one answer, so they cannot
220
+ * disagree about what a vocabulary applies to. The Taxonomies screen passes nothing: the one
221
+ * place that edits `appliesTo` has to see a vocabulary it has narrowed away. */
222
+ listTaxonomies = (target?: TaxonomyTarget) => this.call<Taxonomy[]>("listTaxonomies", { target });
223
+ createTaxonomy = (input: { slug: string; label: string; pluralLabel?: string; description?: string; hierarchical?: boolean; appliesTo?: TaxonomyTarget[] | null }) =>
224
+ this.call<Taxonomy>("createTaxonomy", { ...input, appliesTo: input.appliesTo ?? null });
225
+ updateTaxonomy = (id: string, patch: { label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean; appliesTo?: TaxonomyTarget[] | null }) =>
216
226
  this.call<Taxonomy>("updateTaxonomy", { id, ...patch } as unknown as RpcInput);
217
227
  deleteTaxonomy = (id: string) => this.call<{ ok: true }>("deleteTaxonomy", { id });
218
228
 
@@ -224,9 +234,16 @@ export class Api {
224
234
  this.call<Term>("updateTerm", { id, ...patch } as unknown as RpcInput);
225
235
  deleteTerm = (id: string) => this.call<{ ok: true }>("deleteTerm", { id });
226
236
 
237
+ /** Mint a signed, self-expiring preview link for one page. Editor-gated to MINT; anyone
238
+ * holding the result can redeem it with no account, which is the point. */
239
+ signPagePreview = (pageId: string) => this.call<{ url: string; token: string; expiresAt: number }>("signPagePreview", { pageId });
240
+
227
241
  listPageTerms = (pageId: string) => this.call<Term[]>("listPageTerms", { pageId });
228
242
  setPageTerms = (pageId: string, termIds: string[]) => this.call<{ ok: true }>("setPageTerms", { pageId, termIds });
229
243
 
244
+ listMediaTerms = (mediaId: string) => this.call<Term[]>("listMediaTerms", { mediaId });
245
+ setMediaTerms = (mediaId: string, termIds: string[]) => this.call<{ ok: true }>("setMediaTerms", { mediaId, termIds });
246
+
230
247
  // --- Block Kit: custom admin pages ---
231
248
  /** The pages THIS caller may open. Filtered server-side, so a nav entry that 403s when
232
249
  * clicked cannot happen. An older server has no such handler; the caller treats a failure
@@ -36,6 +36,10 @@ declare global {
36
36
  * own CMS was greeted by "pramen". Set `name` (and optionally `suffix`) to the
37
37
  * deployment's own; `suffix: null` drops the "· cms" half entirely. */
38
38
  brand?: BrandConfig;
39
+ /** Where THIS SITE renders a page preview (e.g. `/preview`). The editor appends
40
+ * `?token=…`. Unset, a preview link points at the CMS's own redeem endpoint, which
41
+ * answers with JSON — see `preview.ts`. */
42
+ previewUrl?: string;
39
43
  };
40
44
  }
41
45
  }
package/src/app.css CHANGED
@@ -10,3 +10,51 @@
10
10
  * it explicitly (paths are relative to this file). */
11
11
  @source "./**/*.{ts,tsx}";
12
12
  @source "../node_modules/@podoba/react/src/**/*.{ts,tsx}";
13
+
14
+ /* Buttons look clickable.
15
+ *
16
+ * podoba's `Button` sets no `cursor`, and a real `<button>` gets the default arrow from the
17
+ * UA sheet — so every button in this app has been showing an arrow, which reads as "not
18
+ * interactive" to anyone who has used the web. It went unnoticed while the header's Upload
19
+ * control was a hand-styled `<label>` carrying its own `cursor-pointer`; swapping that for the
20
+ * design system's Button is what surfaced it.
21
+ *
22
+ * In the BASE layer on purpose: Tailwind emits utilities after base, so a component that means
23
+ * something else by its cursor still wins — podoba's own `data-[pending]:cursor-progress` on a
24
+ * submitting button, for one. Scoped away from disabled controls, where an arrow is the
25
+ * correct signal.
26
+ *
27
+ * The right home for this is @podoba/react's Button. Until it lands there, one rule here
28
+ * covers the app rather than every call site remembering. */
29
+ @layer base {
30
+ button:not(:disabled):not([aria-disabled="true"]),
31
+ [role="button"]:not([aria-disabled="true"]),
32
+ label:has(> input[type="file"]) {
33
+ cursor: pointer;
34
+ }
35
+ }
36
+
37
+ /* Turn OFF scroll anchoring for the page scroller.
38
+ *
39
+ * This is the fix for a self-referential loop, not a preference. The screen header condenses
40
+ * once the page is scrolled, which removes ~134px of layout ABOVE the viewport — and scroll
41
+ * anchoring exists precisely to compensate for that, so the browser silently subtracts the
42
+ * same 134px from `scrollY` to keep the visible content still. That new scroll position is an
43
+ * input to the condition that condensed the header, so it flips back, restores the 134px, and
44
+ * the browser gives the scroll offset back: the header oscillates while the reader is not
45
+ * touching anything. Hysteresis alone cannot fix it — the compensation is the same size as the
46
+ * change, so the dead band would have to be wider than the collapse itself, which just moves
47
+ * the loop further down the page.
48
+ *
49
+ * What anchoring buys HERE is close to nothing, which is what makes this trade cheap: the
50
+ * lists load in explicit "load more" pages appended BELOW the viewport, and every media cell is
51
+ * a fixed 130px box, so no image settling reflows content above where you are reading.
52
+ *
53
+ * On the scroller, because that is the only place it works — `overflow-anchor: none` on an
54
+ * element merely excludes THAT element from being chosen as the anchor, and the header is not
55
+ * the anchor here; the list below it is. */
56
+ @layer base {
57
+ html {
58
+ overflow-anchor: none;
59
+ }
60
+ }
@@ -0,0 +1,48 @@
1
+ // Where you are, for the app bar.
2
+ //
3
+ // The trail has two parts and they come from different places, which is the whole design
4
+ // here. The SECTION is derivable — the layout already knows which nav entry is lit, and that
5
+ // entry carries both a label and the route back to its list. The DETAIL is not: `/pages/:id`
6
+ // carries an opaque id, and the page's title only exists inside the editor that fetched it.
7
+ // So the section is computed in the layout and the detail is PUBLISHED by whatever is on
8
+ // screen, through the hook below.
9
+ //
10
+ // It matters most exactly where the screen header is absent. A list screen already says
11
+ // "Media" in 56px immediately under the bar, so the crumb there is a mild echo; a page editor
12
+ // has no header at all — three columns of panels and a "← all pages" button — and the trail is
13
+ // the only thing naming what is open.
14
+ //
15
+ // A LEAF module: `components.tsx` and `furniture.tsx` both publish into it, and
16
+ // `components.tsx` already imports from `furniture.tsx`, so anything they share has to sit
17
+ // below both.
18
+
19
+ import { createContext, useContext, useEffect, type ReactNode } from "react";
20
+
21
+ /** Only the SETTER travels down. The value lives in the layout, which is also what renders
22
+ * the bar — handing the value back down as well would be a second copy of state that exists
23
+ * to be read by the component that owns it. */
24
+ const PublishCrumb = createContext<(label: string | null) => void>(() => {});
25
+
26
+ export function BreadcrumbProvider({ publish, children }: { publish: (label: string | null) => void; children: ReactNode }) {
27
+ return <PublishCrumb.Provider value={publish}>{children}</PublishCrumb.Provider>;
28
+ }
29
+
30
+ /**
31
+ * Publish the trailing crumb for the screen that is mounted.
32
+ *
33
+ * Pass `undefined` while the record is still loading and the bar simply shows the section —
34
+ * better than a crumb that says "Loading…" and then changes, which draws the eye to the one
35
+ * place on screen that should be still.
36
+ *
37
+ * The cleanup is not optional: without it a crumb outlives its screen, so leaving a page
38
+ * editor for a list would leave the page's title sitting in the bar next to a different
39
+ * section. Effect-based rather than render-time, because publishing during render is a state
40
+ * update in another component's render pass.
41
+ */
42
+ export function useCrumb(label: string | null | undefined): void {
43
+ const publish = useContext(PublishCrumb);
44
+ useEffect(() => {
45
+ publish(label ?? null);
46
+ return () => publish(null);
47
+ }, [label, publish]);
48
+ }
package/src/chrome.ts CHANGED
@@ -14,3 +14,30 @@ export const WRAP = "mx-auto max-w-[1200px] px-7 pb-8 pt-2";
14
14
 
15
15
  /** One row in a list — a card-surfaced strip with the standard inset. */
16
16
  export const ROW = "flex items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5";
17
+
18
+ // --- the app bar, and what has to clear it -----------------------------------------------
19
+ //
20
+ // Two classes for ONE number. The app bar (the rail toggle + the account menu) is sticky at
21
+ // the top of the content column, and the screen header is sticky BELOW it — so the header's
22
+ // offset has to be the bar's height, exactly. They live here, together, because the two are
23
+ // rendered by different modules (`routes/_layout.tsx` and `page-header.tsx`) and a magic `44`
24
+ // written out in each is a one-pixel gap or overlap waiting for the next edit. Tailwind needs
25
+ // literal class names, so this is a pair of strings rather than a length.
26
+
27
+ /** The app bar's height. */
28
+ export const APP_BAR_H = "h-11";
29
+
30
+ /** …and the sticky offset anything pinned beneath it must use. Same 44px. */
31
+ export const BELOW_APP_BAR = "top-11";
32
+
33
+ // --- the page editor's own toolbar ---------------------------------------------------------
34
+ //
35
+ // Same "two classes for one number" rule as the app bar above, one level deeper. The editor's
36
+ // toolbar is sticky BELOW the app bar, and the inspector column is sticky below THAT — three
37
+ // elements, two of which need to know the height of what is above them.
38
+
39
+ /** The page editor's toolbar height. */
40
+ export const PAGE_TOOLBAR_H = "h-14";
41
+
42
+ /** …and the offset for anything pinned beneath it: the app bar (44px) plus the toolbar (56). */
43
+ export const BELOW_PAGE_TOOLBAR = "top-[6.25rem]";