@pramen/cms-editor 0.0.40 → 0.0.42

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.40",
3
+ "version": "0.0.42",
4
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": {
@@ -20,17 +20,26 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@buzola/router": "^0.0.12",
23
- "@podoba/react": "^0.0.3",
24
- "@podoba/tokens": "^0.0.3",
25
- "@podoba/tailwind": "^0.0.3",
23
+ "@podoba/react": "^0.0.32",
24
+ "@podoba/tokens": "^0.0.32",
25
+ "@podoba/tailwind": "^0.0.32",
26
+ "@tiptap/react": "^3.27.4",
27
+ "@tiptap/pm": "^3.27.4",
28
+ "@tiptap/starter-kit": "^3.27.4",
29
+ "@tiptap/extension-placeholder": "^3.27.4",
30
+ "@tiptap/extension-highlight": "^3.27.4",
31
+ "@tiptap/extension-task-list": "^3.27.4",
32
+ "@tiptap/extension-task-item": "^3.27.4",
26
33
  "react": "^19.0.0",
27
34
  "react-dom": "^19.0.0"
28
35
  },
29
36
  "devDependencies": {
30
37
  "@buzola/bun-plugin": "^0.0.12",
31
38
  "@buzola/codegen": "^0.0.12",
39
+ "@tailwindcss/cli": "^4.0.0",
40
+ "@tailwindcss/typography": "^0.5.16",
32
41
  "@types/react": "^19.0.0",
33
42
  "@types/react-dom": "^19.0.0",
34
- "tailwindcss": "^3.4.19"
43
+ "tailwindcss": "^4.0.0"
35
44
  }
36
45
  }
@@ -13,6 +13,10 @@ declare global {
13
13
  /** Runtime config set by the host's /config.js (see @pramen/cms-editor build). */
14
14
  PRAMEN_CMS_EDITOR?: {
15
15
  signInUrl?: string;
16
+ /** Hide the Pages tab for deployments that use collections only — no block/page
17
+ * building. The tab is otherwise always shown and lands on an empty list, which
18
+ * reads as "the CMS is broken" rather than "this site has no pages". */
19
+ hidePages?: boolean;
16
20
  /** Extra top-nav links to companion tools the host serves (e.g. a curation page).
17
21
  * Rendered as plain external `<a>` links after the built-in tabs. */
18
22
  extraNav?: { label: string; href: string }[];
package/src/app.css CHANGED
@@ -1,3 +1,12 @@
1
- @tailwind base;
2
- @tailwind components;
3
- @tailwind utilities;
1
+ /* Design system = podoba (Tailwind v4, CSS-first). Order matters: Tailwind first,
2
+ * then @podoba/tokens' CSS variables, then @podoba/tailwind's `@theme inline` that
3
+ * binds those vars to utilities (bg-surface, text-fg-muted, rounded-panel, …). */
4
+ @import "tailwindcss";
5
+ @import "@podoba/tokens/variables.css";
6
+ @import "@podoba/tailwind";
7
+
8
+ /* v4 auto-detects this app's sources, but node_modules is ignored by default —
9
+ * @podoba/react ships TS source with class strings the compiler must scan, so name
10
+ * it explicitly (paths are relative to this file). */
11
+ @source "./**/*.{ts,tsx}";
12
+ @source "../node_modules/@podoba/react/src/**/*.{ts,tsx}";
@@ -2,7 +2,7 @@
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, Input, Textarea } from "@podoba/react";
5
+ import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, 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 { FieldForm } from "./fields";
@@ -33,7 +33,7 @@ function Hero({ lead, em, children }: { lead: string; em: string; children?: Rea
33
33
  function Cta({ text, em, children }: { text: string; em: string; children: ReactNode }) {
34
34
  return (
35
35
  <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">
36
- <span className="text-lg text-[#0f3a25]">
36
+ <span className="text-callout text-fg-on-brand">
37
37
  {text} <span className="font-semibold">{em}</span>
38
38
  </span>
39
39
  {children}
@@ -46,32 +46,38 @@ function Section({ children }: { children: ReactNode }) {
46
46
  }
47
47
 
48
48
  function Pill({ status, children }: { status?: string; children: ReactNode }) {
49
+ // Token-only colors (no hardcoded hex) so status pills flip correctly under the
50
+ // podoba dark theme. `fg-on-brand` is the AA-safe ink on the fixed brand surfaces.
49
51
  const tone =
50
52
  status === "published" || status === "active"
51
- ? "bg-brand-green text-[#0f3a25] border-transparent"
53
+ ? "bg-brand-green text-fg-on-brand border-transparent"
52
54
  : status === "in_review" || status === "review"
53
- ? "bg-accent-yellow text-[#4a3512] border-transparent"
55
+ ? "bg-accent-yellow text-fg-on-brand border-transparent"
54
56
  : status === "rejected" || status === "archived" || status === "inactive"
55
57
  ? "border-danger text-danger bg-surface-card"
56
58
  : "border-border text-fg-muted bg-surface-card";
57
- return <span className={`inline-block whitespace-nowrap rounded-full border px-2.5 py-0.5 text-[11px] font-medium ${tone}`}>{children}</span>;
59
+ return <span className={`inline-block whitespace-nowrap rounded-full border px-2.5 py-0.5 text-caption font-medium ${tone}`}>{children}</span>;
58
60
  }
59
61
 
62
+ // Modal chrome on podoba's shared overlay: focus trap, Esc-to-close, backdrop
63
+ // dismiss, the token blur-scrim and enter/exit animation — all for free. The caller
64
+ // API (conditionally mounted + `onClose`) is preserved, so call sites don't change.
60
65
  function Modal({ onClose, wide, children }: { onClose: () => void; wide?: boolean; children: ReactNode }) {
61
66
  return (
62
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(20,15,5,0.28)] p-6" onClick={onClose}>
63
- <div
64
- className={`max-h-[86vh] w-full overflow-auto rounded-panel border border-border bg-surface-card px-9 py-8 shadow-[0_24px_60px_rgba(30,20,10,0.12)] ${wide ? "max-w-[680px]" : "max-w-[520px]"}`}
65
- onClick={(e) => e.stopPropagation()}
66
- >
67
- {children}
68
- </div>
69
- </div>
67
+ <ModalOverlay isOpen isDismissable onOpenChange={(open) => !open && onClose()}>
68
+ <ModalSurface className={`w-full px-9 py-8 ${wide ? "max-w-[680px]" : "max-w-[520px]"}`}>
69
+ <ModalDialog className="max-h-[86vh] overflow-auto outline-none">{children}</ModalDialog>
70
+ </ModalSurface>
71
+ </ModalOverlay>
70
72
  );
71
73
  }
72
74
 
73
75
  function ModalTitle({ children }: { children: ReactNode }) {
74
- return <h2 className="mb-5 text-[28px] font-normal leading-[1.15] text-fg">{children}</h2>;
76
+ return (
77
+ <Heading level="1" className="mb-5 font-normal">
78
+ {children}
79
+ </Heading>
80
+ );
75
81
  }
76
82
 
77
83
  function KV({ children, className }: { children: ReactNode; className?: string }) {
@@ -79,12 +85,14 @@ function KV({ children, className }: { children: ReactNode; className?: string }
79
85
  }
80
86
 
81
87
  function Card({ children }: { children: ReactNode }) {
82
- return <div className="overflow-auto rounded-panel border border-border bg-surface-card p-6">{children}</div>;
88
+ // rounded-lg is the unified podoba card radius (the 1.1rem soft-panel radius was retired).
89
+ return <div className="overflow-auto rounded-lg border border-border bg-surface-card p-6">{children}</div>;
83
90
  }
84
91
 
85
92
  function Banner({ ok, children }: { ok?: boolean; children: ReactNode }) {
93
+ // Token-only colors so the banner reads correctly in dark mode too.
86
94
  return (
87
- <div className={`my-2 rounded-lg border px-3.5 py-2.5 text-[13px] ${ok ? "border-brand-green bg-brand-green/20 text-[#0f3a25]" : "border-danger bg-surface-card text-danger"}`}>{children}</div>
95
+ <div className={`my-2 rounded-lg border px-3.5 py-2.5 text-small ${ok ? "border-brand-green bg-brand-green/20 text-fg" : "border-danger bg-surface-card text-danger"}`}>{children}</div>
88
96
  );
89
97
  }
90
98
 
@@ -187,23 +195,45 @@ function cellText(v: unknown): string {
187
195
  return String(v);
188
196
  }
189
197
 
198
+ /** Page size for collection lists. collectionList defaults to 100 server-side, so a
199
+ * request without an explicit limit silently truncates — and the header then reports the
200
+ * truncated count as if it were the total, which reads as "that is all there is". */
201
+ const COLLECTION_PAGE_SIZE = 50;
202
+
190
203
  export function CollectionList({ api, def, onOpen, onNew, onError }: { api: Api; def: CollectionMeta; onOpen: (id: string) => void; onNew: () => void; onError: (s: string) => void }) {
191
204
  const [rows, setRows] = useState<Record<string, unknown>[]>([]);
205
+ const [offset, setOffset] = useState(0);
206
+ const [hasMore, setHasMore] = useState(false);
192
207
  const [loading, setLoading] = useState(true);
208
+
209
+ const load = useCallback(
210
+ (off: number) => {
211
+ setLoading(true);
212
+ return api
213
+ .call<Record<string, unknown>[]>("collectionList", { collection: def.slug, limit: COLLECTION_PAGE_SIZE, offset: off })
214
+ .then((r) => {
215
+ setRows((prev) => (off === 0 ? r : [...prev, ...r]));
216
+ // A full page means there is probably more; a short one is definitely the end.
217
+ setHasMore(r.length === COLLECTION_PAGE_SIZE);
218
+ setOffset(off + r.length);
219
+ })
220
+ .catch((e) => onError(errMsg(e)))
221
+ .finally(() => setLoading(false));
222
+ },
223
+ [api, def.slug, onError],
224
+ );
225
+
193
226
  useEffect(() => {
194
- let live = true;
195
- setLoading(true);
196
- api.call<Record<string, unknown>[]>("collectionList", { collection: def.slug })
197
- .then((r) => { if (live) setRows(r); })
198
- .catch((e) => onError(errMsg(e)))
199
- .finally(() => { if (live) setLoading(false); });
200
- return () => { live = false; };
201
- }, [api, def.slug, onError]);
227
+ setRows([]);
228
+ setOffset(0);
229
+ setHasMore(false);
230
+ void load(0);
231
+ }, [load]);
202
232
 
203
233
  const labelOf = (col: string) => def.fields.find((f) => f.name === col)?.label ?? col;
204
234
  return (
205
235
  <>
206
- <Hero lead={def.pluralLabel} em={rows.length === 0 ? "None yet" : rows.length === 1 ? `1 ${def.label.toLowerCase()}` : `${rows.length} ${def.pluralLabel.toLowerCase()}`}>
236
+ <Hero lead={def.pluralLabel} em={rows.length === 0 ? "None yet" : rows.length === 1 ? `1 ${def.label.toLowerCase()}` : `${rows.length}${hasMore ? "+" : ""} ${def.pluralLabel.toLowerCase()}`}>
207
237
  <Cta text="Let's" em={`add a ${def.label.toLowerCase()}`}>
208
238
  <Button className="shrink-0" onPress={onNew}>+ New {def.label.toLowerCase()}</Button>
209
239
  </Cta>
@@ -225,6 +255,11 @@ export function CollectionList({ api, def, onOpen, onNew, onError }: { api: Api;
225
255
  {!loading && rows.length === 0 ? <p className="text-fg-subtle">No {def.pluralLabel.toLowerCase()} yet. Create one.</p> : null}
226
256
  {loading ? <p className="text-fg-subtle">Loading…</p> : null}
227
257
  </div>
258
+ {hasMore && !loading ? (
259
+ <div className="mt-3.5 text-center">
260
+ <Button variant="secondary" size="sm" onPress={() => void load(offset)}>Load more</Button>
261
+ </div>
262
+ ) : null}
228
263
  </div>
229
264
  </>
230
265
  );
package/src/fields.tsx CHANGED
@@ -1,8 +1,9 @@
1
1
  // Schema-driven field forms: one input per FieldDefinition type, recursively composed for
2
2
  // group/repeater. Media fields open a picker (upload + choose from the library).
3
3
 
4
- import { Button, Input, Textarea } from "@podoba/react";
5
- import { useEffect, useRef, useState, type ReactNode } from "react";
4
+ import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Text, Textarea } from "@podoba/react";
5
+ import { BlockEditor } from "@podoba/react/editor";
6
+ import { useEffect, useState, type ReactNode } from "react";
6
7
  import type { Api } from "./api";
7
8
  import type { FieldDefinition, Media } from "./types";
8
9
 
@@ -98,104 +99,14 @@ function FieldInput({ def, value, onChange, api }: { def: FieldDefinition; value
98
99
  }
99
100
 
100
101
  // --- rich text (WYSIWYG) --------------------------------------------------------------
101
- // A dependency-free contentEditable editor. Emits an HTML string, so it round-trips with
102
- // existing rich_text content and every set:html renderer no value migration, no bundled
103
- // ProseMirror. TipTap is the upgrade path if tables/embeds are ever needed.
104
-
105
- const RT_TOOLS: Array<{ label: string; title: string; run: (exec: (c: string, a?: string) => void) => void }> = [
106
- { label: "B", title: "Bold", run: (x) => x("bold") },
107
- { label: "I", title: "Italic", run: (x) => x("italic") },
108
- { label: "H2", title: "Heading", run: (x) => x("formatBlock", "H2") },
109
- { label: "H3", title: "Subheading", run: (x) => x("formatBlock", "H3") },
110
- { label: "¶", title: "Paragraph", run: (x) => x("formatBlock", "P") },
111
- { label: "• List", title: "Bulleted list", run: (x) => x("insertUnorderedList") },
112
- { label: "1. List", title: "Numbered list", run: (x) => x("insertOrderedList") },
113
- { label: "Link", title: "Add link", run: (x) => {
114
- const raw = window.prompt("Link URL (https://, mailto:, /path)", "https://");
115
- if (!raw) return;
116
- const url = safeLinkUrl(raw);
117
- if (!url) { window.alert("Only http(s), mailto, tel, or relative (/, #) links are allowed."); return; }
118
- x("createLink", url);
119
- } },
120
- { label: "Unlink", title: "Remove link", run: (x) => x("unlink") },
121
- { label: "Clear", title: "Clear formatting", run: (x) => x("removeFormat") },
122
- ];
123
-
124
- /** Allow-list for a link href: http(s), mailto, tel, or a relative/anchor path.
125
- * The prefix allow-list inherently rejects `javascript:`/`data:`/`vbscript:` (they
126
- * don't match), so a bare script URL never reaches execCommand. */
127
- function safeLinkUrl(raw: string): string | null {
128
- const url = raw.trim();
129
- return /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(url) ? url : null;
130
- }
131
-
132
- /** NOTE: this is cosmetic paste-cleaning, NOT a security boundary. It cannot be relied
133
- * on for XSS defense — an editor-role caller can POST any `richtext` value straight to
134
- * the RPC handler, never touching this editor. Stored-XSS is prevented on the SERVER by
135
- * sanitizeRichText() in @pramen/cms on write (which also covers raw writes / imports).
136
- * Here we just tidy obviously-unwanted markup so the editor doesn't render junk. */
137
- function scrubHtml(html: string): string {
138
- return html
139
- .replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, "")
140
- .replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "")
141
- .replace(/(href|src)\s*=\s*("javascript:[^"]*"|'javascript:[^']*')/gi, '$1="#"');
142
- }
102
+ // The `richtext` field is podoba's Notion-style BlockEditor (Tiptap): `/` slash palette,
103
+ // block conversion, inline bubble toolbar. Still an HTML string in/out, so it round-trips
104
+ // with existing rich_text content + every set:html renderer no value migration. The
105
+ // server's sanitizeRichText() (@pramen/cms) remains the XSS boundary on write; ProseMirror
106
+ // parses HTML to its schema on load, so scripts never survive into the editor either.
143
107
 
144
108
  export function RichText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
145
- const ref = useRef<HTMLDivElement>(null);
146
- const last = useRef<string>("");
147
-
148
- // Sync only EXTERNAL changes (e.g. switching blocks) into the DOM — never on our own
149
- // keystrokes, or the caret jumps to the start on every character.
150
- useEffect(() => {
151
- const el = ref.current;
152
- if (el && value !== last.current) {
153
- // Scrub on load too — content may have entered the store via raw writes or imports,
154
- // not just this editor. contentEditable requires innerHTML; scrubHtml strips
155
- // scripts / inline handlers / javascript: URLs first.
156
- el.innerHTML = scrubHtml(value || "");
157
- last.current = value || "";
158
- }
159
- }, [value]);
160
-
161
- const emit = () => {
162
- const html = scrubHtml(ref.current?.innerHTML ?? "");
163
- last.current = html;
164
- onChange(html);
165
- };
166
- const exec = (command: string, arg?: string) => {
167
- ref.current?.focus();
168
- document.execCommand(command, false, arg);
169
- emit();
170
- };
171
- // Paste as plain text — avoids importing Word/Docs style-junk into the HTML.
172
- const onPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
173
- e.preventDefault();
174
- document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
175
- };
176
-
177
- return (
178
- <div className="overflow-hidden rounded-lg border border-border bg-surface-card">
179
- <div className="flex flex-wrap gap-0.5 border-b border-border bg-surface-muted px-2 py-1.5">
180
- {RT_TOOLS.map((t) => (
181
- // preventDefault on mousedown keeps the editor's selection while the button is clicked.
182
- <button key={t.label} type="button" className="rounded border-0 bg-transparent px-2.5 py-0.5 text-xs text-fg-muted hover:bg-surface-card hover:text-fg" title={t.title} onMouseDown={(e) => e.preventDefault()} onClick={() => t.run(exec)}>
183
- {t.label}
184
- </button>
185
- ))}
186
- </div>
187
- <div
188
- ref={ref}
189
- className="prose prose-sm min-h-[180px] max-w-none px-4 py-3.5 text-sm leading-relaxed text-fg outline-none empty:before:text-fg-subtle empty:before:content-[attr(data-placeholder)]"
190
- contentEditable
191
- suppressContentEditableWarning
192
- data-placeholder="Write…"
193
- onInput={emit}
194
- onBlur={emit}
195
- onPaste={onPaste}
196
- />
197
- </div>
198
- );
109
+ return <BlockEditor value={value} onChange={onChange} minHeight={180} placeholder="Write, or press '/' for blocks…" />;
199
110
  }
200
111
 
201
112
  function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value: Record<string, unknown>[]; onChange: (v: unknown[]) => void; api: Api; label: ReactNode }) {
@@ -310,26 +221,36 @@ export function MediaPicker({ api, onClose, onPick }: { api: Api; onClose: () =>
310
221
  }
311
222
  };
312
223
  return (
313
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(20,15,5,0.28)] p-6" onClick={onClose}>
314
- <div className="max-h-[86vh] w-full max-w-[680px] overflow-auto rounded-panel border border-border bg-surface-card px-9 py-8 shadow-[0_24px_60px_rgba(30,20,10,0.12)]" onClick={(e) => e.stopPropagation()}>
315
- <h2 className="mb-5 text-[28px] font-normal text-fg">Choose <span className="text-fg-subtle">a file</span> from the library</h2>
316
- {err ? <div className="my-2 rounded-lg border border-danger bg-surface-card px-3.5 py-2.5 text-[13px] text-danger">{err}</div> : null}
317
- <label className="mb-4 flex w-full flex-col gap-2">
318
- <span className="text-sm font-medium text-fg">Upload a new file</span>
319
- <input type="file" className="text-sm text-fg-muted" disabled={busy} onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />
320
- </label>
321
- <div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2.5">
322
- {media.map((m) => (
323
- <div key={m.id} className="cursor-pointer overflow-hidden rounded-lg border border-border bg-surface-card" onClick={() => onPick(m.id)}>
324
- {(m.file.contentType ?? "").startsWith("image/") ? <img className="block h-[130px] w-full object-cover" src={api.resolve(`/media/${m.file.key}`)} alt="" /> : <div className="h-[130px] bg-surface-muted" />}
325
- <div className="truncate px-2 py-1.5 text-[11px] text-fg-muted">{m.file.filename ?? m.id}</div>
326
- </div>
327
- ))}
328
- </div>
329
- <div className="mt-3 text-right">
330
- <Button variant="ghost" onPress={onClose}>close</Button>
331
- </div>
332
- </div>
333
- </div>
224
+ <ModalOverlay isOpen isDismissable onOpenChange={(open) => !open && onClose()}>
225
+ <ModalSurface className="w-full max-w-[680px] px-9 py-8">
226
+ <ModalDialog className="max-h-[86vh] overflow-auto outline-none">
227
+ <Heading level="1" className="mb-5 font-normal">
228
+ Choose <span className="text-fg-subtle">a file</span> from the library
229
+ </Heading>
230
+ {err ? (
231
+ <div className="my-2 rounded-lg border border-danger bg-surface-card px-3.5 py-2.5 text-small text-danger">{err}</div>
232
+ ) : null}
233
+ <label className="mb-4 flex w-full flex-col gap-2">
234
+ <Text size="small" weight="medium">
235
+ Upload a new file
236
+ </Text>
237
+ <input type="file" className="text-small text-fg-muted" disabled={busy} onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />
238
+ </label>
239
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2.5">
240
+ {media.map((m) => (
241
+ <div key={m.id} className="cursor-pointer overflow-hidden rounded-lg border border-border bg-surface-card" onClick={() => onPick(m.id)}>
242
+ {(m.file.contentType ?? "").startsWith("image/") ? <img className="block h-[130px] w-full object-cover" src={api.resolve(`/media/${m.file.key}`)} alt="" /> : <div className="h-[130px] bg-surface-muted" />}
243
+ <div className="truncate px-2 py-1.5 text-caption text-fg-muted">{m.file.filename ?? m.id}</div>
244
+ </div>
245
+ ))}
246
+ </div>
247
+ <div className="mt-3 text-right">
248
+ <Button variant="ghost" onPress={onClose}>
249
+ close
250
+ </Button>
251
+ </div>
252
+ </ModalDialog>
253
+ </ModalSurface>
254
+ </ModalOverlay>
334
255
  );
335
256
  }
@@ -1,16 +1,26 @@
1
- // Root layout: the persistent chrome (top bar + tab nav + global error banner) wrapped
2
- // around every route via <Outlet />. Tab highlighting is derived from the current path,
3
- // so a deep link or refresh lands with the right tab lit.
1
+ // Root layout: the persistent chrome (podoba Topbar + tab nav + global error banner)
2
+ // wrapped around every route via <Outlet />. Tab highlighting is derived from the
3
+ // current path, so a deep link or refresh lands with the right tab lit.
4
4
 
5
5
  import { Outlet, useNavigate, useRoute } from "@buzola/router";
6
- import { Button } from "@podoba/react";
6
+ import { Badge, Button, Card, MoonIcon, SunIcon, Text, Topbar } from "@podoba/react";
7
+ import { useEffect, useState } from "react";
7
8
  import { useApp } from "../app-context";
8
9
 
10
+ const THEME_KEY = "pramen.cms.theme";
11
+
9
12
  export default function RootLayout() {
10
13
  const { cfg, isAdmin, collections, error, reconfigure } = useApp();
11
14
  const navigate = useNavigate();
12
15
  const { pathname } = useRoute();
13
16
 
17
+ // Dark mode: podoba tokens flip under `[data-theme="dark"]` — no `dark:` prefixes.
18
+ const [theme, setTheme] = useState(() => (typeof localStorage !== "undefined" ? localStorage.getItem(THEME_KEY) ?? "light" : "light"));
19
+ useEffect(() => {
20
+ document.documentElement.dataset.theme = theme === "dark" ? "dark" : "light";
21
+ localStorage.setItem(THEME_KEY, theme);
22
+ }, [theme]);
23
+
14
24
  // The active collection slug, if we're under /collections/:slug(/...).
15
25
  const collectionSlug = pathname.startsWith("/collections/") ? pathname.split("/")[2] : undefined;
16
26
 
@@ -22,31 +32,45 @@ export default function RootLayout() {
22
32
  : pathname.startsWith("/settings") ? "settings"
23
33
  : "";
24
34
 
25
- const tabCls = (key: string) =>
26
- active === key ? "bg-surface-muted text-fg" : "text-fg-muted";
35
+ const tabCls = (key: string) => (active === key ? "bg-surface-muted text-fg" : "text-fg-muted");
27
36
 
28
37
  // Host-configured links to companion tools (e.g. a curation page), from /config.js.
29
38
  const extraNav = typeof window !== "undefined" ? window.PRAMEN_CMS_EDITOR?.extraNav ?? [] : [];
39
+ // Collections-only deployments hide the block/page builder entirely.
40
+ const hidePages = typeof window !== "undefined" ? window.PRAMEN_CMS_EDITOR?.hidePages === true : false;
30
41
 
31
42
  return (
32
- <>
33
- <div className="sticky top-0 z-10 flex items-center gap-4 bg-surface px-7 py-4">
34
- <span className="text-[15px] font-bold tracking-[0.01em] text-fg">
35
- pramen <span className="font-normal text-fg-subtle">· cms</span>
36
- </span>
37
- <span className="flex-1" />
38
- <nav className="flex items-center gap-0.5">
39
- <Button variant="ghost" size="sm" className={tabCls("pages")} onPress={() => navigate("home")}>Pages</Button>
43
+ // Page-level surface so the whole viewport (not just the topbar + cards) flips
44
+ // under `[data-theme="dark"]` otherwise the body stays white in dark mode.
45
+ <div className="min-h-screen bg-surface text-fg">
46
+ <Topbar className="sticky top-0 z-10 bg-surface px-7">
47
+ <Topbar.Brand>
48
+ <span className="text-callout font-bold tracking-[0.01em] text-fg">pramen</span>
49
+ <span className="text-fg-subtle">· cms</span>
50
+ </Topbar.Brand>
51
+ <Topbar.Nav aria-label="Primary">
52
+ {hidePages ? null : (
53
+ <Button variant="ghost" size="sm" className={tabCls("pages")} onPress={() => navigate("home")}>
54
+ Pages
55
+ </Button>
56
+ )}
40
57
  {collections.map((c) => (
41
58
  <Button key={c.slug} variant="ghost" size="sm" className={tabCls(`col:${c.slug}`)} onPress={() => navigate("collection", { params: { slug: c.slug } })}>
42
- {c.icon ? `${c.icon} ` : ""}{c.pluralLabel}
59
+ {c.icon ? `${c.icon} ` : ""}
60
+ {c.pluralLabel}
43
61
  </Button>
44
62
  ))}
45
- <Button variant="ghost" size="sm" className={tabCls("media")} onPress={() => navigate("media")}>Media</Button>
63
+ <Button variant="ghost" size="sm" className={tabCls("media")} onPress={() => navigate("media")}>
64
+ Media
65
+ </Button>
46
66
  {isAdmin ? (
47
- <Button variant="ghost" size="sm" className={tabCls("users")} onPress={() => navigate("users")}>Users</Button>
67
+ <Button variant="ghost" size="sm" className={tabCls("users")} onPress={() => navigate("users")}>
68
+ Users
69
+ </Button>
48
70
  ) : null}
49
- <Button variant="ghost" size="sm" className={tabCls("settings")} onPress={() => navigate("settings")}>Settings</Button>
71
+ <Button variant="ghost" size="sm" className={tabCls("settings")} onPress={() => navigate("settings")}>
72
+ Settings
73
+ </Button>
50
74
  {extraNav.map((l) => (
51
75
  // Companion tools live OUTSIDE this SPA (a separate static page/worker route), so
52
76
  // open them in a new tab. A same-tab click would be caught by the client router
@@ -56,19 +80,35 @@ export default function RootLayout() {
56
80
  href={l.href}
57
81
  target="_blank"
58
82
  rel="noopener noreferrer"
59
- className="rounded-md px-2.5 py-1.5 text-sm text-fg-muted transition-colors hover:bg-surface-muted hover:text-fg"
83
+ className="rounded-md px-2.5 py-1.5 text-small text-fg-muted transition-colors hover:bg-surface-muted hover:text-fg"
60
84
  >
61
85
  {l.label}
62
86
  </a>
63
87
  ))}
64
- </nav>
65
- <span className="ml-3 text-fg-subtle">{cfg.tenant}</span>
66
- <Button variant="ghost" size="sm" onPress={reconfigure}>sign out</Button>
67
- </div>
88
+ </Topbar.Nav>
89
+ <Topbar.Actions>
90
+ <Badge color="grey" label={cfg.tenant} />
91
+ <Button
92
+ variant="ghost"
93
+ size="sm"
94
+ aria-label={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"}
95
+ onPress={() => setTheme(theme === "dark" ? "light" : "dark")}
96
+ >
97
+ {theme === "dark" ? <SunIcon className="h-4 w-4" /> : <MoonIcon className="h-4 w-4" />}
98
+ </Button>
99
+ <Button variant="ghost" size="sm" onPress={reconfigure}>
100
+ sign out
101
+ </Button>
102
+ </Topbar.Actions>
103
+ </Topbar>
68
104
  {error ? (
69
- <div className="mx-7 mt-2 rounded-lg border border-danger bg-surface-card px-4 py-2.5 text-sm text-danger">{error}</div>
105
+ <Card variant="outlined" padding="none" className="mx-7 mt-2 border-danger px-4 py-2.5">
106
+ <Text size="small" className="text-danger">
107
+ {error}
108
+ </Text>
109
+ </Card>
70
110
  ) : null}
71
111
  <Outlet />
72
- </>
112
+ </div>
73
113
  );
74
114
  }
@@ -9,17 +9,31 @@ import type { BlockType, Page } from "../types";
9
9
  export default createPage()
10
10
  .route("/")
11
11
  .render(function Home() {
12
- const { api, setError } = useApp();
12
+ const { api, setError, collections } = useApp();
13
13
  const navigate = useNavigate();
14
14
  const [pages, setPages] = useState<Page[]>([]);
15
15
  const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
16
16
 
17
+ // Collections-only deployment: `/` is the Pages list, so land on the first
18
+ // collection instead. Without this, hiding the tab still leaves the landing page
19
+ // showing an empty page list — and still fetching pages, which errors outright on a
20
+ // deployment that never spread cmsSchema.
21
+ const hidePages = typeof window !== "undefined" && window.PRAMEN_CMS_EDITOR?.hidePages === true;
22
+ const firstCollection = collections[0]?.slug;
23
+
24
+ useEffect(() => {
25
+ if (hidePages && firstCollection) navigate("collection", { params: { slug: firstCollection }, replace: true });
26
+ }, [hidePages, firstCollection, navigate]);
27
+
17
28
  const refreshPages = () => api.listPages().then(setPages).catch((e) => setError(errMsg(e)));
18
29
  useEffect(() => {
30
+ if (hidePages) return;
19
31
  refreshPages();
20
32
  api.listBlockTypes().then(setBlockTypes).catch((e) => setError(errMsg(e)));
21
33
  // eslint-disable-next-line react-hooks/exhaustive-deps
22
- }, [api]);
34
+ }, [api, hidePages]);
35
+
36
+ if (hidePages && firstCollection) return null;
23
37
 
24
38
  return (
25
39
  <PageList