@pramen/cms-editor 0.0.41 → 0.0.43

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/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,8 +32,7 @@ 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 ?? [] : [];
@@ -31,26 +40,37 @@ export default function RootLayout() {
31
40
  const hidePages = typeof window !== "undefined" ? window.PRAMEN_CMS_EDITOR?.hidePages === true : false;
32
41
 
33
42
  return (
34
- <>
35
- <div className="sticky top-0 z-10 flex items-center gap-4 bg-surface px-7 py-4">
36
- <span className="text-[15px] font-bold tracking-[0.01em] text-fg">
37
- pramen <span className="font-normal text-fg-subtle">· cms</span>
38
- </span>
39
- <span className="flex-1" />
40
- <nav className="flex items-center gap-0.5">
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">
41
52
  {hidePages ? null : (
42
- <Button variant="ghost" size="sm" className={tabCls("pages")} onPress={() => navigate("home")}>Pages</Button>
53
+ <Button variant="ghost" size="sm" className={tabCls("pages")} onPress={() => navigate("home")}>
54
+ Pages
55
+ </Button>
43
56
  )}
44
57
  {collections.map((c) => (
45
58
  <Button key={c.slug} variant="ghost" size="sm" className={tabCls(`col:${c.slug}`)} onPress={() => navigate("collection", { params: { slug: c.slug } })}>
46
- {c.icon ? `${c.icon} ` : ""}{c.pluralLabel}
59
+ {c.icon ? `${c.icon} ` : ""}
60
+ {c.pluralLabel}
47
61
  </Button>
48
62
  ))}
49
- <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>
50
66
  {isAdmin ? (
51
- <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>
52
70
  ) : null}
53
- <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>
54
74
  {extraNav.map((l) => (
55
75
  // Companion tools live OUTSIDE this SPA (a separate static page/worker route), so
56
76
  // open them in a new tab. A same-tab click would be caught by the client router
@@ -60,19 +80,35 @@ export default function RootLayout() {
60
80
  href={l.href}
61
81
  target="_blank"
62
82
  rel="noopener noreferrer"
63
- 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"
64
84
  >
65
85
  {l.label}
66
86
  </a>
67
87
  ))}
68
- </nav>
69
- <span className="ml-3 text-fg-subtle">{cfg.tenant}</span>
70
- <Button variant="ghost" size="sm" onPress={reconfigure}>sign out</Button>
71
- </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>
72
104
  {error ? (
73
- <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>
74
110
  ) : null}
75
111
  <Outlet />
76
- </>
112
+ </div>
77
113
  );
78
114
  }