@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/dist/app.css +2 -1
- package/dist/fonts/README.md +18 -0
- package/dist/fonts/nc-fontina-variable.woff2 +0 -0
- package/dist/fonts.css +45 -0
- package/dist/index.html +2 -2
- package/dist/main.xkz96gh2.js +396 -0
- package/package.json +14 -5
- package/src/app-context.tsx +4 -0
- package/src/app.css +12 -3
- package/src/components.tsx +60 -25
- package/src/fields.tsx +40 -119
- package/src/routes/_layout.tsx +65 -25
- package/src/routes/home.tsx +16 -2
- package/dist/main.v8ynk2fc.js +0 -251
- package/dist/tokens.css +0 -68
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/cms-editor",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
24
|
-
"@podoba/tokens": "^0.0.
|
|
25
|
-
"@podoba/tailwind": "^0.0.
|
|
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": "^
|
|
43
|
+
"tailwindcss": "^4.0.0"
|
|
35
44
|
}
|
|
36
45
|
}
|
package/src/app-context.tsx
CHANGED
|
@@ -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
|
-
|
|
2
|
-
@tailwind
|
|
3
|
-
|
|
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}";
|
package/src/components.tsx
CHANGED
|
@@ -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-
|
|
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-
|
|
53
|
+
? "bg-brand-green text-fg-on-brand border-transparent"
|
|
52
54
|
: status === "in_review" || status === "review"
|
|
53
|
-
? "bg-accent-yellow text-
|
|
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-
|
|
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
|
-
<
|
|
63
|
-
<
|
|
64
|
-
className=
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 {
|
|
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
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
314
|
-
<
|
|
315
|
-
<
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
<
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
}
|
package/src/routes/_layout.tsx
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
|
-
// Root layout: the persistent chrome (
|
|
2
|
-
// around every route via <Outlet />. Tab highlighting is derived from the
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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} ` : ""}
|
|
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")}>
|
|
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")}>
|
|
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")}>
|
|
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-
|
|
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
|
-
</
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
<
|
|
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
|
}
|
package/src/routes/home.tsx
CHANGED
|
@@ -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
|