@pramen/cms-editor 0.0.16 → 0.0.18
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/index.html +1 -1
- package/dist/main.wxwhtpa4.js +404 -0
- package/package.json +1 -1
- package/src/app.tsx +63 -40
- package/src/fields.tsx +105 -3
- package/src/styles.ts +144 -77
- package/dist/main.gk183kkq.js +0 -339
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -64,21 +64,21 @@ function Editor({ api, cfg, onReconfigure }: { api: Api; cfg: Config; onReconfig
|
|
|
64
64
|
<span className="brand">
|
|
65
65
|
pramen <span className="dim">· cms</span>
|
|
66
66
|
</span>
|
|
67
|
-
<nav className="tabs" style={{ margin: 0 }}>
|
|
68
|
-
<button className={view === "pages" ? "on" : "ghost"} onClick={() => { setView("pages"); setCurrent(null); }}>
|
|
69
|
-
Pages
|
|
70
|
-
</button>
|
|
71
|
-
<button className={view === "media" ? "on" : "ghost"} onClick={() => setView("media")}>
|
|
72
|
-
Media
|
|
73
|
-
</button>
|
|
74
|
-
</nav>
|
|
75
67
|
{view === "pages" && current ? (
|
|
76
68
|
<span className="crumb">
|
|
77
69
|
<a onClick={() => setCurrent(null)}>Pages</a> / <b>{current.title}</b>
|
|
78
70
|
</span>
|
|
79
71
|
) : null}
|
|
80
72
|
<span className="grow" />
|
|
81
|
-
<
|
|
73
|
+
<nav className="tabs nav" style={{ margin: 0 }}>
|
|
74
|
+
<button className={view === "pages" ? "on" : ""} onClick={() => { setView("pages"); setCurrent(null); }}>
|
|
75
|
+
Pages
|
|
76
|
+
</button>
|
|
77
|
+
<button className={view === "media" ? "on" : ""} onClick={() => setView("media")}>
|
|
78
|
+
Media
|
|
79
|
+
</button>
|
|
80
|
+
</nav>
|
|
81
|
+
<span className="muted" style={{ marginLeft: 12 }}>{cfg.tenant}</span>
|
|
82
82
|
<button className="ghost sm" onClick={onReconfigure}>
|
|
83
83
|
sign out
|
|
84
84
|
</button>
|
|
@@ -98,26 +98,36 @@ function Editor({ api, cfg, onReconfigure }: { api: Api; cfg: Config; onReconfig
|
|
|
98
98
|
function PageList({ api, pages, blockTypes, onOpen, onCreated, onError }: { api: Api; pages: Page[]; blockTypes: BlockType[]; onOpen: (p: Page) => void; onCreated: () => void; onError: (s: string) => void }) {
|
|
99
99
|
const [creating, setCreating] = useState(false);
|
|
100
100
|
return (
|
|
101
|
-
|
|
102
|
-
<div
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
</
|
|
101
|
+
<>
|
|
102
|
+
<div className="hero">
|
|
103
|
+
<h1 className="hero-h">
|
|
104
|
+
<span className="lead">Pages</span>
|
|
105
|
+
<span className="em">{pages.length === 0 ? "None yet" : pages.length === 1 ? "1 page total" : `${pages.length} pages total`}</span>
|
|
106
|
+
</h1>
|
|
107
|
+
<div className="cta">
|
|
108
|
+
<span className="cta-text">
|
|
109
|
+
Let's <span className="em">create</span> something
|
|
110
|
+
</span>
|
|
111
|
+
<button className="primary" onClick={() => setCreating(true)}>
|
|
112
|
+
+ New page
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
107
115
|
</div>
|
|
108
|
-
<div className="list">
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
<div className="list-wrap">
|
|
117
|
+
<div className="list">
|
|
118
|
+
{pages.map((p) => (
|
|
119
|
+
<div className="row" key={p.id} onClick={() => onOpen(p)}>
|
|
120
|
+
<span className="grow">{p.title}</span>
|
|
121
|
+
<span className="muted">/{p.slug}</span>
|
|
122
|
+
<span className="muted">{p.locale}</span>
|
|
123
|
+
<span className={`pill ${p.status}`}>{p.status}</span>
|
|
124
|
+
</div>
|
|
125
|
+
))}
|
|
126
|
+
{pages.length === 0 ? <p className="muted">No pages yet. {blockTypes.length === 0 ? "Define block types + a content type first (via the API/admin)." : "Create one."}</p> : null}
|
|
127
|
+
</div>
|
|
118
128
|
</div>
|
|
119
129
|
{creating ? <CreatePage api={api} onClose={() => setCreating(false)} onCreated={() => { setCreating(false); onCreated(); }} onError={onError} /> : null}
|
|
120
|
-
|
|
130
|
+
</>
|
|
121
131
|
);
|
|
122
132
|
}
|
|
123
133
|
|
|
@@ -140,7 +150,9 @@ function CreatePage({ api, onClose, onCreated, onError }: { api: Api; onClose: (
|
|
|
140
150
|
return (
|
|
141
151
|
<div className="scrim" onClick={onClose}>
|
|
142
152
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
143
|
-
<h2>
|
|
153
|
+
<h2>
|
|
154
|
+
Create a <span className="dim">new page</span> and define the essentials<span className="dim">.</span>
|
|
155
|
+
</h2>
|
|
144
156
|
<label className="field">
|
|
145
157
|
<span className="lbl">Content type</span>
|
|
146
158
|
<select value={typeId} onChange={(e) => setTypeId(e.target.value)}>
|
|
@@ -334,10 +346,9 @@ function BlockInspector({ api, block, blockType, onClose, onSaved, onError }: {
|
|
|
334
346
|
};
|
|
335
347
|
return (
|
|
336
348
|
<div>
|
|
337
|
-
<div style={{ display: "flex", alignItems: "center" }}>
|
|
338
|
-
<
|
|
339
|
-
|
|
340
|
-
</b>
|
|
349
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
350
|
+
<span className="btype">{block.block_type}</span>
|
|
351
|
+
<span style={{ flex: 1 }} />
|
|
341
352
|
<button className="ghost sm" onClick={onClose}>
|
|
342
353
|
done
|
|
343
354
|
</button>
|
|
@@ -537,14 +548,23 @@ function MediaLibrary({ api, onError }: { api: Api; onError: (s: string) => void
|
|
|
537
548
|
};
|
|
538
549
|
|
|
539
550
|
return (
|
|
540
|
-
|
|
541
|
-
<div className="
|
|
542
|
-
<
|
|
543
|
-
|
|
544
|
-
{
|
|
545
|
-
|
|
546
|
-
|
|
551
|
+
<>
|
|
552
|
+
<div className="hero">
|
|
553
|
+
<h1 className="hero-h">
|
|
554
|
+
<span className="lead">Media</span>
|
|
555
|
+
<span className="em">{media.length === 0 ? "None yet" : media.length === 1 ? "1 file" : `${media.length}${hasMore ? "+" : ""} files`}</span>
|
|
556
|
+
</h1>
|
|
557
|
+
<div className="cta">
|
|
558
|
+
<span className="cta-text">
|
|
559
|
+
Let's <span className="em">upload</span> something
|
|
560
|
+
</span>
|
|
561
|
+
<label className={`btn primary${busy ? " disabled" : ""}`}>
|
|
562
|
+
{busy ? "Uploading…" : "+ Upload"}
|
|
563
|
+
<input type="file" multiple hidden disabled={busy} onChange={(e) => { upload(e.target.files); e.target.value = ""; }} />
|
|
564
|
+
</label>
|
|
565
|
+
</div>
|
|
547
566
|
</div>
|
|
567
|
+
<div className="media-lib">
|
|
548
568
|
{media.length === 0 ? (
|
|
549
569
|
<p className="muted">No media yet. Upload images to use them in blocks and SEO.</p>
|
|
550
570
|
) : (
|
|
@@ -574,7 +594,8 @@ function MediaLibrary({ api, onError }: { api: Api; onError: (s: string) => void
|
|
|
574
594
|
onError={onError}
|
|
575
595
|
/>
|
|
576
596
|
) : null}
|
|
577
|
-
|
|
597
|
+
</div>
|
|
598
|
+
</>
|
|
578
599
|
);
|
|
579
600
|
}
|
|
580
601
|
|
|
@@ -610,7 +631,9 @@ function MediaDetail({ api, media, onClose, onSaved, onDeleted, onError }: { api
|
|
|
610
631
|
return (
|
|
611
632
|
<div className="scrim" onClick={onClose}>
|
|
612
633
|
<div className="modal media-detail" onClick={(e) => e.stopPropagation()}>
|
|
613
|
-
<h2>
|
|
634
|
+
<h2>
|
|
635
|
+
<span className="dim">Media</span> {media.file.filename ?? ""}
|
|
636
|
+
</h2>
|
|
614
637
|
{isImage(media) ? <img src={url} alt={media.alt ?? ""} /> : <div className="ext lg">{ext(media)}</div>}
|
|
615
638
|
<label className="field">
|
|
616
639
|
<span className="lbl">Alt text (for accessibility & SEO)</span>
|
package/src/fields.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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 { useEffect, useState } from "react";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
5
|
import type { Api } from "./api";
|
|
6
6
|
import type { FieldDefinition, Media } from "./types";
|
|
7
7
|
|
|
@@ -32,13 +32,21 @@ function FieldInput({ def, value, onChange, api }: { def: FieldDefinition; value
|
|
|
32
32
|
</label>
|
|
33
33
|
);
|
|
34
34
|
case "textarea":
|
|
35
|
-
case "richtext":
|
|
36
35
|
return (
|
|
37
36
|
<label className="field">
|
|
38
37
|
{label}
|
|
39
38
|
<textarea value={typeof value === "string" ? value : value == null ? "" : JSON.stringify(value)} onChange={(e) => onChange(e.target.value)} />
|
|
40
39
|
</label>
|
|
41
40
|
);
|
|
41
|
+
case "richtext":
|
|
42
|
+
// A rich-text value is an HTML string (round-trips with the site's set:html
|
|
43
|
+
// renderers). A legacy object value isn't editable here — fall back to raw text.
|
|
44
|
+
return (
|
|
45
|
+
<div className="field">
|
|
46
|
+
{label}
|
|
47
|
+
<RichText value={typeof value === "string" ? value : ""} onChange={onChange as (v: string) => void} />
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
42
50
|
case "number":
|
|
43
51
|
return (
|
|
44
52
|
<label className="field">
|
|
@@ -98,6 +106,98 @@ function FieldInput({ def, value, onChange, api }: { def: FieldDefinition; value
|
|
|
98
106
|
}
|
|
99
107
|
}
|
|
100
108
|
|
|
109
|
+
// --- rich text (WYSIWYG) --------------------------------------------------------------
|
|
110
|
+
// A dependency-free contentEditable editor. Emits an HTML string, so it round-trips with
|
|
111
|
+
// existing rich_text content and every set:html renderer — no value migration, no bundled
|
|
112
|
+
// ProseMirror. TipTap is the upgrade path if tables/embeds are ever needed.
|
|
113
|
+
|
|
114
|
+
const RT_TOOLS: Array<{ label: string; title: string; run: (exec: (c: string, a?: string) => void) => void }> = [
|
|
115
|
+
{ label: "B", title: "Bold", run: (x) => x("bold") },
|
|
116
|
+
{ label: "I", title: "Italic", run: (x) => x("italic") },
|
|
117
|
+
{ label: "H2", title: "Heading", run: (x) => x("formatBlock", "H2") },
|
|
118
|
+
{ label: "H3", title: "Subheading", run: (x) => x("formatBlock", "H3") },
|
|
119
|
+
{ label: "¶", title: "Paragraph", run: (x) => x("formatBlock", "P") },
|
|
120
|
+
{ label: "• List", title: "Bulleted list", run: (x) => x("insertUnorderedList") },
|
|
121
|
+
{ label: "1. List", title: "Numbered list", run: (x) => x("insertOrderedList") },
|
|
122
|
+
{ label: "Link", title: "Add link", run: (x) => {
|
|
123
|
+
const raw = window.prompt("Link URL (https://, mailto:, /path)", "https://");
|
|
124
|
+
if (!raw) return;
|
|
125
|
+
const url = safeLinkUrl(raw);
|
|
126
|
+
if (!url) { window.alert("Only http(s), mailto, tel, or relative (/, #) links are allowed."); return; }
|
|
127
|
+
x("createLink", url);
|
|
128
|
+
} },
|
|
129
|
+
{ label: "Unlink", title: "Remove link", run: (x) => x("unlink") },
|
|
130
|
+
{ label: "Clear", title: "Clear formatting", run: (x) => x("removeFormat") },
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
/** Allow-list for a link href: http(s), mailto, tel, or a relative/anchor path.
|
|
134
|
+
* The prefix allow-list inherently rejects `javascript:`/`data:`/`vbscript:` (they
|
|
135
|
+
* don't match), so a bare script URL never reaches execCommand. */
|
|
136
|
+
function safeLinkUrl(raw: string): string | null {
|
|
137
|
+
const url = raw.trim();
|
|
138
|
+
return /^(https?:\/\/|mailto:|tel:|\/|#)/i.test(url) ? url : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** NOTE: this is cosmetic paste-cleaning, NOT a security boundary. It cannot be relied
|
|
142
|
+
* on for XSS defense — an editor-role caller can POST any `richtext` value straight to
|
|
143
|
+
* the RPC handler, never touching this editor. Stored-XSS is prevented on the SERVER by
|
|
144
|
+
* sanitizeRichText() in @pramen/cms on write (which also covers raw writes / imports).
|
|
145
|
+
* Here we just tidy obviously-unwanted markup so the editor doesn't render junk. */
|
|
146
|
+
function scrubHtml(html: string): string {
|
|
147
|
+
return html
|
|
148
|
+
.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, "")
|
|
149
|
+
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "")
|
|
150
|
+
.replace(/(href|src)\s*=\s*("javascript:[^"]*"|'javascript:[^']*')/gi, '$1="#"');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function RichText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
154
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
155
|
+
const last = useRef<string>("");
|
|
156
|
+
|
|
157
|
+
// Sync only EXTERNAL changes (e.g. switching blocks) into the DOM — never on our own
|
|
158
|
+
// keystrokes, or the caret jumps to the start on every character.
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
const el = ref.current;
|
|
161
|
+
if (el && value !== last.current) {
|
|
162
|
+
// Scrub on load too — content may have entered the store via raw writes or imports,
|
|
163
|
+
// not just this editor. contentEditable requires innerHTML; scrubHtml strips
|
|
164
|
+
// scripts / inline handlers / javascript: URLs first.
|
|
165
|
+
el.innerHTML = scrubHtml(value || "");
|
|
166
|
+
last.current = value || "";
|
|
167
|
+
}
|
|
168
|
+
}, [value]);
|
|
169
|
+
|
|
170
|
+
const emit = () => {
|
|
171
|
+
const html = scrubHtml(ref.current?.innerHTML ?? "");
|
|
172
|
+
last.current = html;
|
|
173
|
+
onChange(html);
|
|
174
|
+
};
|
|
175
|
+
const exec = (command: string, arg?: string) => {
|
|
176
|
+
ref.current?.focus();
|
|
177
|
+
document.execCommand(command, false, arg);
|
|
178
|
+
emit();
|
|
179
|
+
};
|
|
180
|
+
// Paste as plain text — avoids importing Word/Docs style-junk into the HTML.
|
|
181
|
+
const onPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<div className="rt">
|
|
188
|
+
<div className="rt-toolbar">
|
|
189
|
+
{RT_TOOLS.map((t) => (
|
|
190
|
+
// preventDefault on mousedown keeps the editor's selection while the button is clicked.
|
|
191
|
+
<button key={t.label} type="button" className="sm ghost" title={t.title} onMouseDown={(e) => e.preventDefault()} onClick={() => t.run(exec)}>
|
|
192
|
+
{t.label}
|
|
193
|
+
</button>
|
|
194
|
+
))}
|
|
195
|
+
</div>
|
|
196
|
+
<div ref={ref} className="rt-editor" contentEditable suppressContentEditableWarning data-placeholder="Write…" onInput={emit} onBlur={emit} onPaste={onPaste} />
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
101
201
|
function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value: Record<string, unknown>[]; onChange: (v: unknown[]) => void; api: Api; label: React.ReactNode }) {
|
|
102
202
|
const items = Array.isArray(value) ? value : [];
|
|
103
203
|
const upd = (i: number, v: Record<string, unknown>) => onChange(items.map((it, j) => (j === i ? v : it)));
|
|
@@ -195,7 +295,9 @@ export function MediaPicker({ api, onClose, onPick }: { api: Api; onClose: () =>
|
|
|
195
295
|
return (
|
|
196
296
|
<div className="scrim" onClick={onClose}>
|
|
197
297
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
198
|
-
<h2>
|
|
298
|
+
<h2>
|
|
299
|
+
Choose <span className="dim">a file</span> from the library
|
|
300
|
+
</h2>
|
|
199
301
|
{err ? <div className="banner err">{err}</div> : null}
|
|
200
302
|
<label className="field">
|
|
201
303
|
<span className="lbl">Upload a new file</span>
|
package/src/styles.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
// Inline stylesheet for the CMS editor.
|
|
2
|
-
//
|
|
1
|
+
// Inline stylesheet for the CMS editor. Cream light theme, mint accent, serif-italic
|
|
2
|
+
// display headings — the "graphic standard" visual language, adapted for an editor
|
|
3
|
+
// (canvas, palette, forms). Same class names as before; only paint changed and a
|
|
4
|
+
// handful of new hero / tile / cta classes added.
|
|
3
5
|
|
|
4
6
|
export const css = `
|
|
5
7
|
:root {
|
|
6
|
-
--bg:#
|
|
7
|
-
--border:#
|
|
8
|
-
--
|
|
9
|
-
--
|
|
8
|
+
--bg:#f7f3ea; --surface:#efe9dc; --surface-2:#ffffff; --raise:#efe9dc;
|
|
9
|
+
--border:#e5dfd0; --border-2:#d9d1bd;
|
|
10
|
+
--ink:#111111; --ink-2:#6b6a63; --ink-3:#a4a29a;
|
|
11
|
+
--spring:#7ed4a6; --spring-dim:#52b380; --spring-bg:#a3e2c0; --spring-ink:#0f3a25;
|
|
12
|
+
--amber:#b8863b; --amber-bg:#f1e6cf;
|
|
13
|
+
--danger:#b04141; --danger-bg:#f5e2e2;
|
|
14
|
+
--radius:14px; --radius-sm:10px; --radius-pill:999px;
|
|
15
|
+
--serif:"Iowan Old Style","Palatino",Georgia,"Times New Roman",serif;
|
|
10
16
|
--mono:ui-monospace,"SF Mono","JetBrains Mono","Menlo",monospace;
|
|
11
17
|
--sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
|
|
12
18
|
}
|
|
@@ -14,95 +20,156 @@ export const css = `
|
|
|
14
20
|
html,body { margin:0; height:100%; }
|
|
15
21
|
body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-size:14px; line-height:1.5; -webkit-font-smoothing:antialiased; }
|
|
16
22
|
#app { min-height:100%; }
|
|
17
|
-
a { color:var(--
|
|
23
|
+
a { color:var(--ink); text-decoration:underline; text-underline-offset:2px; text-decoration-color:var(--ink-3); cursor:pointer; }
|
|
24
|
+
a:hover { text-decoration-color:var(--ink); }
|
|
18
25
|
|
|
19
|
-
|
|
26
|
+
/* --- top bar ------------------------------------------------------------ */
|
|
27
|
+
.bar { display:flex; align-items:center; gap:16px; padding:16px 28px; background:var(--bg); border-bottom:1px solid transparent; position:sticky; top:0; z-index:10; }
|
|
20
28
|
.bar .grow { flex:1; }
|
|
21
|
-
.brand { font-weight:700; letter-spacing:.
|
|
29
|
+
.brand { font-weight:700; font-size:15px; letter-spacing:.01em; }
|
|
22
30
|
.brand .dim { color:var(--ink-3); font-weight:400; }
|
|
23
|
-
.crumb { color:var(--ink-2); }
|
|
24
|
-
.crumb b { color:var(--ink); }
|
|
31
|
+
.crumb { color:var(--ink-2); font-size:13px; }
|
|
32
|
+
.crumb b { color:var(--ink); font-weight:600; }
|
|
33
|
+
.crumb a { text-decoration:none; color:var(--ink-2); }
|
|
34
|
+
.crumb a:hover { color:var(--ink); }
|
|
25
35
|
|
|
26
|
-
|
|
27
|
-
button
|
|
28
|
-
button
|
|
36
|
+
/* --- buttons: pill-shaped, black-filled primary ------------------------- */
|
|
37
|
+
button, .btn { font:inherit; color:var(--ink); background:var(--surface); border:1px solid var(--border-2); border-radius:var(--radius-pill); padding:9px 20px; cursor:pointer; font-weight:500; transition:background .12s, border-color .12s, color .12s; }
|
|
38
|
+
button:hover { background:#e6dfcd; }
|
|
39
|
+
button.primary { background:#111111; border-color:#111111; color:#ffffff; }
|
|
40
|
+
button.primary:hover { background:#000000; }
|
|
29
41
|
button.ghost { background:transparent; border-color:transparent; color:var(--ink-2); }
|
|
30
|
-
button.ghost:hover { color:var(--ink);
|
|
42
|
+
button.ghost:hover { color:var(--ink); background:var(--surface); }
|
|
31
43
|
button.danger { color:var(--danger); border-color:var(--danger); background:var(--danger-bg); }
|
|
32
|
-
button:
|
|
33
|
-
button
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
textarea {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
button.danger:hover { background:#eeced0; }
|
|
45
|
+
button:disabled { opacity:.45; cursor:not-allowed; }
|
|
46
|
+
button.sm { padding:4px 11px; font-size:12px; font-weight:500; }
|
|
47
|
+
|
|
48
|
+
/* --- inputs ------------------------------------------------------------- */
|
|
49
|
+
input, textarea, select { font:inherit; color:var(--ink); background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--radius-sm); padding:10px 14px; width:100%; }
|
|
50
|
+
input:focus, textarea:focus, select:focus { outline:none; border-color:var(--ink); }
|
|
51
|
+
input::placeholder, textarea::placeholder { color:var(--ink-3); }
|
|
52
|
+
textarea { min-height:88px; font-family:var(--mono); font-size:13px; resize:vertical; }
|
|
53
|
+
label.field { display:block; margin:14px 0; }
|
|
54
|
+
label.field > .lbl { display:block; color:var(--ink); font-weight:600; font-size:13px; margin-bottom:6px; }
|
|
55
|
+
label.field > .lbl .req { color:var(--danger); font-weight:400; }
|
|
41
56
|
.checkbox { display:flex; align-items:center; gap:8px; }
|
|
42
57
|
.checkbox input { width:auto; }
|
|
43
58
|
|
|
44
|
-
|
|
45
|
-
.
|
|
46
|
-
.
|
|
47
|
-
.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
.
|
|
51
|
-
.
|
|
52
|
-
.row
|
|
53
|
-
.row
|
|
54
|
-
.row
|
|
55
|
-
.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
.pill
|
|
59
|
-
|
|
60
|
-
.
|
|
61
|
-
.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
.
|
|
65
|
-
.
|
|
66
|
-
.
|
|
59
|
+
/* --- editor 3-col layout ------------------------------------------------ */
|
|
60
|
+
.layout { display:grid; grid-template-columns:260px 1fr 400px; min-height:calc(100vh - 68px); gap:20px; padding:8px 28px 28px; }
|
|
61
|
+
.side { background:var(--surface); border-radius:var(--radius); padding:18px; overflow:auto; }
|
|
62
|
+
.canvas { padding:6px 0; overflow:auto; }
|
|
63
|
+
.inspect { background:var(--surface-2); border:1px solid var(--border); border-radius:var(--radius); padding:20px 22px; overflow:auto; }
|
|
64
|
+
|
|
65
|
+
.sect { color:var(--ink-3); font-family:var(--serif); font-style:italic; font-size:14px; letter-spacing:0; margin:18px 0 8px; }
|
|
66
|
+
.list { display:flex; flex-direction:column; gap:8px; }
|
|
67
|
+
.row { display:flex; align-items:center; gap:12px; padding:14px 18px; border:1px solid transparent; border-radius:var(--radius); background:var(--surface); cursor:pointer; transition:background .12s, border-color .12s; }
|
|
68
|
+
.row:hover { background:#e6dfcd; }
|
|
69
|
+
.row.active { border-color:var(--ink); background:var(--surface-2); }
|
|
70
|
+
.row .grow { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:500; }
|
|
71
|
+
|
|
72
|
+
/* --- status pills ------------------------------------------------------- */
|
|
73
|
+
.pill { font-size:11px; padding:3px 10px; border-radius:var(--radius-pill); border:1px solid var(--border-2); color:var(--ink-2); background:var(--surface-2); font-weight:500; letter-spacing:.02em; }
|
|
74
|
+
.pill.published { color:var(--spring-ink); border-color:transparent; background:var(--spring); }
|
|
75
|
+
.pill.review, .pill.in_review { color:#4a3512; border-color:transparent; background:var(--amber-bg); }
|
|
76
|
+
.pill.rejected, .pill.archived { color:#5c1e1e; border-color:transparent; background:var(--danger-bg); }
|
|
77
|
+
|
|
78
|
+
/* --- editor regions / blocks ------------------------------------------- */
|
|
79
|
+
.region { margin-bottom:24px; }
|
|
80
|
+
.region > h3 { font-family:var(--serif); font-size:22px; color:var(--ink); margin:0 0 12px; display:flex; align-items:baseline; gap:12px; font-weight:400; }
|
|
81
|
+
.region > h3 .allow { font-family:var(--sans); font-size:11px; color:var(--ink-3); font-weight:400; }
|
|
82
|
+
.block { border:1px solid var(--border); border-radius:var(--radius); background:var(--surface-2); padding:14px 16px; margin-bottom:10px; transition:border-color .12s; }
|
|
83
|
+
.block.selected { border-color:var(--ink); }
|
|
84
|
+
.block .bhead { display:flex; align-items:center; gap:10px; }
|
|
85
|
+
.block .btype { font-family:var(--mono); font-size:12px; color:var(--ink-2); background:var(--surface); padding:2px 8px; border-radius:var(--radius-pill); }
|
|
67
86
|
.block .grow { flex:1; }
|
|
68
87
|
.block .shared { font-size:11px; color:var(--amber); }
|
|
69
|
-
.dropzone { border:1px dashed var(--border-2); border-radius:var(--radius); padding:
|
|
70
|
-
.dropzone.over { border-color:var(--
|
|
88
|
+
.dropzone { border:1px dashed var(--border-2); border-radius:var(--radius); padding:14px; text-align:center; color:var(--ink-3); font-size:13px; }
|
|
89
|
+
.dropzone.over { border-color:var(--ink); color:var(--ink); }
|
|
71
90
|
|
|
72
|
-
.palette { display:flex; flex-wrap:wrap; gap:6px; }
|
|
73
|
-
.palette button { font-size:12px; }
|
|
74
|
-
.repeater-item, .group { border:1px solid var(--border); border-radius:var(--radius-sm); padding:
|
|
75
|
-
.repeater-item > .ih { display:flex; justify-content:flex-end; }
|
|
91
|
+
.palette { display:flex; flex-wrap:wrap; gap:6px; margin-top:10px; }
|
|
92
|
+
.palette button { font-size:12px; padding:6px 14px; }
|
|
93
|
+
.repeater-item, .group { border:1px solid var(--border); border-radius:var(--radius-sm); padding:12px 14px; margin:8px 0; background:var(--surface); }
|
|
94
|
+
.repeater-item > .ih { display:flex; justify-content:flex-end; gap:4px; }
|
|
76
95
|
|
|
77
|
-
|
|
78
|
-
.media-
|
|
96
|
+
/* --- media library ------------------------------------------------------ */
|
|
97
|
+
.media-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; }
|
|
98
|
+
.media-cell { border:1px solid var(--border); border-radius:var(--radius-sm); overflow:hidden; cursor:pointer; background:var(--surface-2); transition:border-color .12s; }
|
|
79
99
|
.media-cell:hover { border-color:var(--border-2); }
|
|
80
|
-
.media-cell.sel { border-color:var(--
|
|
81
|
-
.media-cell img { width:100%; height:
|
|
82
|
-
.media-cell .fn { font-size:
|
|
83
|
-
.media-cell .ext { height:
|
|
84
|
-
|
|
85
|
-
.media-lib { padding:
|
|
86
|
-
.media-lib .media-grid { grid-template-columns:repeat(auto-fill,minmax(
|
|
87
|
-
.media-lib .media-cell img, .media-lib .media-cell .ext { height:
|
|
88
|
-
.media-toolbar { display:
|
|
100
|
+
.media-cell.sel { border-color:var(--ink); }
|
|
101
|
+
.media-cell img { width:100%; height:80px; object-fit:cover; display:block; }
|
|
102
|
+
.media-cell .fn { font-size:11px; padding:6px 8px; color:var(--ink-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
103
|
+
.media-cell .ext { height:80px; display:flex; align-items:center; justify-content:center; font-family:var(--mono); font-size:12px; color:var(--ink-3); background:var(--surface); }
|
|
104
|
+
|
|
105
|
+
.media-lib { padding:0 28px 28px; max-width:1200px; margin:0 auto; }
|
|
106
|
+
.media-lib .media-grid { grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); }
|
|
107
|
+
.media-lib .media-cell img, .media-lib .media-cell .ext { height:130px; }
|
|
108
|
+
.media-toolbar { display:none; }
|
|
89
109
|
label.btn { display:inline-flex; align-items:center; }
|
|
90
110
|
.btn.disabled { opacity:.5; cursor:not-allowed; }
|
|
91
|
-
.media-detail img { max-width:100%; max-height:340px; object-fit:contain; display:block; margin:0 auto
|
|
92
|
-
.media-detail .ext.lg { height:
|
|
111
|
+
.media-detail img { max-width:100%; max-height:340px; object-fit:contain; display:block; margin:0 auto 14px; background:var(--surface); border-radius:var(--radius-sm); }
|
|
112
|
+
.media-detail .ext.lg { height:200px; display:flex; align-items:center; justify-content:center; font-family:var(--mono); color:var(--ink-3); background:var(--surface); border-radius:var(--radius-sm); margin-bottom:14px; }
|
|
93
113
|
.media-detail .kv a { word-break:break-all; }
|
|
94
114
|
|
|
95
|
-
|
|
96
|
-
.
|
|
97
|
-
.modal
|
|
98
|
-
.
|
|
115
|
+
/* --- modals ------------------------------------------------------------- */
|
|
116
|
+
.scrim { position:fixed; inset:0; background:rgba(20,15,5,.28); display:flex; align-items:center; justify-content:center; z-index:50; padding:24px; }
|
|
117
|
+
.modal { background:var(--surface-2); border:1px solid var(--border); border-radius:var(--radius); padding:32px 36px; width:min(680px,92vw); max-height:86vh; overflow:auto; box-shadow:0 24px 60px rgba(30,20,10,.12); }
|
|
118
|
+
.modal h2 { margin:0 0 20px; font-family:var(--serif); font-weight:400; font-size:28px; line-height:1.15; color:var(--ink); }
|
|
119
|
+
.modal h2 .dim { color:var(--ink-3); font-style:italic; }
|
|
120
|
+
.banner { padding:10px 14px; border-radius:var(--radius-sm); margin:8px 0; font-size:13px; }
|
|
99
121
|
.banner.err { background:var(--danger-bg); border:1px solid var(--danger); color:var(--danger); }
|
|
100
|
-
.banner.ok { background:var(--spring
|
|
122
|
+
.banner.ok { background:var(--spring); border:1px solid var(--spring-dim); color:var(--spring-ink); }
|
|
101
123
|
.muted { color:var(--ink-3); }
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
.tabs
|
|
105
|
-
.
|
|
106
|
-
.
|
|
107
|
-
.
|
|
124
|
+
|
|
125
|
+
/* --- tabs: pill nav (top bar) + pill sub-tabs (inspector) --------------- */
|
|
126
|
+
.tabs { display:flex; gap:4px; margin-bottom:12px; align-items:center; }
|
|
127
|
+
.tabs button { border-radius:var(--radius-pill); font-size:13px; padding:7px 16px; background:transparent; border-color:transparent; color:var(--ink); font-weight:500; }
|
|
128
|
+
.tabs button:hover { background:var(--surface); }
|
|
129
|
+
.tabs button.on { background:var(--surface); color:var(--ink); border-color:transparent; }
|
|
130
|
+
.tabs.nav { gap:2px; }
|
|
131
|
+
.tabs.nav button { font-size:14px; padding:8px 18px; }
|
|
132
|
+
|
|
133
|
+
.setup { max-width:520px; margin:14vh auto; padding:32px 40px; background:var(--surface-2); border:1px solid var(--border); border-radius:var(--radius); box-shadow:0 24px 60px rgba(30,20,10,.08); }
|
|
134
|
+
.setup h1 { font-family:var(--serif); font-weight:400; font-size:32px; margin:0 0 16px; color:var(--ink); }
|
|
135
|
+
.setup h1 .dim { color:var(--ink-3); font-style:italic; }
|
|
136
|
+
.setup .primary { width:100%; margin-top:8px; }
|
|
137
|
+
.kv { display:grid; grid-template-columns:auto 1fr; gap:8px 14px; font-size:13px; color:var(--ink-2); }
|
|
138
|
+
.kv > span:nth-child(odd) { color:var(--ink-3); font-family:var(--serif); font-style:italic; }
|
|
139
|
+
|
|
140
|
+
/* --- hero band (list views): big serif split heading + mint CTA card ---- */
|
|
141
|
+
.hero { display:grid; grid-template-columns:1fr auto; gap:24px; align-items:center; padding:32px 28px 24px; max-width:1200px; margin:0 auto; }
|
|
142
|
+
.hero-h { font-family:var(--serif); font-weight:400; font-size:56px; line-height:1.05; letter-spacing:-.01em; margin:0; }
|
|
143
|
+
.hero-h .lead { color:var(--ink-3); font-style:italic; display:block; }
|
|
144
|
+
.hero-h .em { color:var(--ink); font-style:normal; display:block; }
|
|
145
|
+
.cta { display:flex; align-items:center; gap:18px; background:var(--spring); border-radius:var(--radius-pill); padding:12px 12px 12px 28px; min-width:360px; }
|
|
146
|
+
.cta .cta-text { font-family:var(--serif); font-style:italic; font-size:18px; color:var(--spring-ink); }
|
|
147
|
+
.cta .cta-text .em { font-style:normal; font-weight:600; }
|
|
148
|
+
.cta button.primary { flex-shrink:0; }
|
|
149
|
+
.list-wrap { max-width:1200px; margin:0 auto; padding:8px 28px 32px; }
|
|
150
|
+
.list-wrap .sect { margin-top:0; }
|
|
151
|
+
|
|
152
|
+
/* --- rich text (WYSIWYG) ------------------------------------------------ */
|
|
153
|
+
.rt { border:1px solid var(--border-2); border-radius:var(--radius-sm); overflow:hidden; background:var(--surface-2); }
|
|
154
|
+
.rt-toolbar { display:flex; flex-wrap:wrap; gap:3px; padding:6px 8px; border-bottom:1px solid var(--border); background:var(--surface); }
|
|
155
|
+
.rt-toolbar button { padding:3px 10px; font-size:12px; border-radius:var(--radius-sm); background:transparent; border-color:transparent; color:var(--ink-2); }
|
|
156
|
+
.rt-toolbar button:hover { background:var(--surface-2); color:var(--ink); }
|
|
157
|
+
.rt-editor { min-height:180px; padding:14px 16px; font-family:var(--sans); font-size:14px; line-height:1.65; color:var(--ink); outline:none; }
|
|
158
|
+
.rt-editor:empty:before { content:attr(data-placeholder); color:var(--ink-3); pointer-events:none; }
|
|
159
|
+
.rt-editor:focus { box-shadow:inset 0 0 0 2px var(--ink); border-radius:0 0 var(--radius-sm) var(--radius-sm); }
|
|
160
|
+
.rt-editor h2 { font-family:var(--serif); font-weight:400; font-size:24px; line-height:1.2; margin:.7em 0 .3em; }
|
|
161
|
+
.rt-editor h3 { font-family:var(--serif); font-weight:400; font-size:19px; line-height:1.25; margin:.7em 0 .3em; }
|
|
162
|
+
.rt-editor p { margin:.55em 0; }
|
|
163
|
+
.rt-editor ul, .rt-editor ol { margin:.55em 0; padding-left:1.5em; }
|
|
164
|
+
.rt-editor li { margin:.2em 0; }
|
|
165
|
+
.rt-editor a { color:var(--ink); text-decoration:underline; text-underline-offset:2px; }
|
|
166
|
+
.rt-editor:first-child { margin-top:0; }
|
|
167
|
+
|
|
168
|
+
@media (max-width:820px) {
|
|
169
|
+
.hero { grid-template-columns:1fr; padding:20px 20px 16px; }
|
|
170
|
+
.hero-h { font-size:40px; }
|
|
171
|
+
.cta { min-width:0; }
|
|
172
|
+
.layout { grid-template-columns:1fr; padding:8px 20px 20px; }
|
|
173
|
+
.list-wrap, .media-lib { padding-left:20px; padding-right:20px; }
|
|
174
|
+
}
|
|
108
175
|
`;
|