@pramen/cms-editor 0.0.14
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/README.md +40 -0
- package/dist/index.html +12 -0
- package/dist/main.c43qkeax.js +339 -0
- package/package.json +28 -0
- package/src/api.ts +106 -0
- package/src/app.tsx +688 -0
- package/src/fields.tsx +212 -0
- package/src/main.tsx +11 -0
- package/src/styles.ts +108 -0
- package/src/types.ts +118 -0
package/src/fields.tsx
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Schema-driven field forms: one input per FieldDefinition type, recursively composed for
|
|
2
|
+
// group/repeater. Media fields open a picker (upload + choose from the library).
|
|
3
|
+
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import type { Api } from "./api";
|
|
6
|
+
import type { FieldDefinition, Media } from "./types";
|
|
7
|
+
|
|
8
|
+
export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefinition[]; value: Record<string, unknown>; onChange: (v: Record<string, unknown>) => void; api: Api }) {
|
|
9
|
+
const set = (name: string, v: unknown) => onChange({ ...value, [name]: v });
|
|
10
|
+
return (
|
|
11
|
+
<>
|
|
12
|
+
{schema.map((def) => (
|
|
13
|
+
<FieldInput key={def.name} def={def} value={value[def.name]} onChange={(v) => set(def.name, v)} api={api} />
|
|
14
|
+
))}
|
|
15
|
+
</>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function FieldInput({ def, value, onChange, api }: { def: FieldDefinition; value: unknown; onChange: (v: unknown) => void; api: Api }) {
|
|
20
|
+
const label = (
|
|
21
|
+
<span className="lbl">
|
|
22
|
+
{def.label ?? def.name} {def.required ? <span className="req">*</span> : null}
|
|
23
|
+
</span>
|
|
24
|
+
);
|
|
25
|
+
switch (def.type) {
|
|
26
|
+
case "text":
|
|
27
|
+
case "url":
|
|
28
|
+
return (
|
|
29
|
+
<label className="field">
|
|
30
|
+
{label}
|
|
31
|
+
<input value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value)} placeholder={def.type === "url" ? "https://…" : ""} />
|
|
32
|
+
</label>
|
|
33
|
+
);
|
|
34
|
+
case "textarea":
|
|
35
|
+
case "richtext":
|
|
36
|
+
return (
|
|
37
|
+
<label className="field">
|
|
38
|
+
{label}
|
|
39
|
+
<textarea value={typeof value === "string" ? value : value == null ? "" : JSON.stringify(value)} onChange={(e) => onChange(e.target.value)} />
|
|
40
|
+
</label>
|
|
41
|
+
);
|
|
42
|
+
case "number":
|
|
43
|
+
return (
|
|
44
|
+
<label className="field">
|
|
45
|
+
{label}
|
|
46
|
+
<input type="number" value={value == null ? "" : String(value)} onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))} />
|
|
47
|
+
</label>
|
|
48
|
+
);
|
|
49
|
+
case "boolean":
|
|
50
|
+
return (
|
|
51
|
+
<label className="field checkbox">
|
|
52
|
+
<input type="checkbox" checked={Boolean(value)} onChange={(e) => onChange(e.target.checked)} />
|
|
53
|
+
<span>{def.label ?? def.name}</span>
|
|
54
|
+
</label>
|
|
55
|
+
);
|
|
56
|
+
case "select":
|
|
57
|
+
return (
|
|
58
|
+
<label className="field">
|
|
59
|
+
{label}
|
|
60
|
+
<select value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value || null)}>
|
|
61
|
+
<option value="">—</option>
|
|
62
|
+
{(def.options ?? []).map((o) => (
|
|
63
|
+
<option key={o} value={o}>
|
|
64
|
+
{o}
|
|
65
|
+
</option>
|
|
66
|
+
))}
|
|
67
|
+
</select>
|
|
68
|
+
</label>
|
|
69
|
+
);
|
|
70
|
+
case "media":
|
|
71
|
+
return (
|
|
72
|
+
<label className="field">
|
|
73
|
+
{label}
|
|
74
|
+
<MediaField value={value as string | null} onChange={onChange} api={api} />
|
|
75
|
+
</label>
|
|
76
|
+
);
|
|
77
|
+
case "group":
|
|
78
|
+
return (
|
|
79
|
+
<div className="field">
|
|
80
|
+
{label}
|
|
81
|
+
<div className="group">
|
|
82
|
+
<FieldForm schema={def.fields ?? []} value={(value as Record<string, unknown>) ?? {}} onChange={onChange as (v: Record<string, unknown>) => void} api={api} />
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
case "repeater":
|
|
87
|
+
return <Repeater def={def} value={(value as Record<string, unknown>[]) ?? []} onChange={onChange as (v: unknown[]) => void} api={api} label={label} />;
|
|
88
|
+
default:
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value: Record<string, unknown>[]; onChange: (v: unknown[]) => void; api: Api; label: React.ReactNode }) {
|
|
94
|
+
const items = Array.isArray(value) ? value : [];
|
|
95
|
+
const upd = (i: number, v: Record<string, unknown>) => onChange(items.map((it, j) => (j === i ? v : it)));
|
|
96
|
+
const add = () => onChange([...items, {}]);
|
|
97
|
+
const del = (i: number) => onChange(items.filter((_, j) => j !== i));
|
|
98
|
+
const move = (i: number, d: number) => {
|
|
99
|
+
const j = i + d;
|
|
100
|
+
if (j < 0 || j >= items.length) return;
|
|
101
|
+
const next = items.slice();
|
|
102
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
103
|
+
onChange(next);
|
|
104
|
+
};
|
|
105
|
+
return (
|
|
106
|
+
<div className="field">
|
|
107
|
+
{label}
|
|
108
|
+
{items.map((it, i) => (
|
|
109
|
+
<div className="repeater-item" key={i}>
|
|
110
|
+
<div className="ih">
|
|
111
|
+
<button type="button" className="ghost sm" onClick={() => move(i, -1)}>
|
|
112
|
+
↑
|
|
113
|
+
</button>
|
|
114
|
+
<button type="button" className="ghost sm" onClick={() => move(i, 1)}>
|
|
115
|
+
↓
|
|
116
|
+
</button>
|
|
117
|
+
<button type="button" className="ghost sm danger" onClick={() => del(i)}>
|
|
118
|
+
✕
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
<FieldForm schema={def.fields ?? []} value={it} onChange={(v) => upd(i, v)} api={api} />
|
|
122
|
+
</div>
|
|
123
|
+
))}
|
|
124
|
+
<button type="button" className="sm" onClick={add} disabled={def.max != null && items.length >= def.max}>
|
|
125
|
+
+ add {def.label ?? def.name}
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function MediaField({ value, onChange, api }: { value: string | null; onChange: (v: string | null) => void; api: Api }) {
|
|
132
|
+
const [open, setOpen] = useState(false);
|
|
133
|
+
const [media, setMedia] = useState<Media | null>(null);
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (value) api.call<Media | null>("getMedia", { id: value }).then(setMedia).catch(() => setMedia(null));
|
|
136
|
+
else setMedia(null);
|
|
137
|
+
}, [value, api]);
|
|
138
|
+
return (
|
|
139
|
+
<div>
|
|
140
|
+
<div className="row" style={{ cursor: "default" }}>
|
|
141
|
+
{media ? <img src={api.resolve(`/media/${media.file.key}`)} alt="" style={{ width: 40, height: 40, objectFit: "cover", borderRadius: 4 }} /> : <span className="muted">no media</span>}
|
|
142
|
+
<span className="grow muted">{media?.file.filename ?? value ?? ""}</span>
|
|
143
|
+
<button type="button" className="sm" onClick={() => setOpen(true)}>
|
|
144
|
+
pick
|
|
145
|
+
</button>
|
|
146
|
+
{value ? (
|
|
147
|
+
<button type="button" className="sm ghost danger" onClick={() => onChange(null)}>
|
|
148
|
+
clear
|
|
149
|
+
</button>
|
|
150
|
+
) : null}
|
|
151
|
+
</div>
|
|
152
|
+
{open ? (
|
|
153
|
+
<MediaPicker
|
|
154
|
+
api={api}
|
|
155
|
+
onClose={() => setOpen(false)}
|
|
156
|
+
onPick={(id) => {
|
|
157
|
+
onChange(id);
|
|
158
|
+
setOpen(false);
|
|
159
|
+
}}
|
|
160
|
+
/>
|
|
161
|
+
) : null}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function MediaPicker({ api, onClose, onPick }: { api: Api; onClose: () => void; onPick: (id: string) => void }) {
|
|
167
|
+
const [media, setMedia] = useState<Media[]>([]);
|
|
168
|
+
const [busy, setBusy] = useState(false);
|
|
169
|
+
const [err, setErr] = useState("");
|
|
170
|
+
const refresh = () => api.listMedia().then(setMedia).catch((e) => setErr(String(e.message ?? e)));
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
refresh();
|
|
173
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
174
|
+
}, []);
|
|
175
|
+
const upload = async (file: File) => {
|
|
176
|
+
setBusy(true);
|
|
177
|
+
setErr("");
|
|
178
|
+
try {
|
|
179
|
+
const row = await api.uploadMedia(file);
|
|
180
|
+
onPick(row.id);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
setErr(String((e as Error).message ?? e));
|
|
183
|
+
} finally {
|
|
184
|
+
setBusy(false);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
return (
|
|
188
|
+
<div className="scrim" onClick={onClose}>
|
|
189
|
+
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
190
|
+
<h2>Media library</h2>
|
|
191
|
+
{err ? <div className="banner err">{err}</div> : null}
|
|
192
|
+
<label className="field">
|
|
193
|
+
<span className="lbl">Upload a new file</span>
|
|
194
|
+
<input type="file" disabled={busy} onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />
|
|
195
|
+
</label>
|
|
196
|
+
<div className="media-grid">
|
|
197
|
+
{media.map((m) => (
|
|
198
|
+
<div key={m.id} className="media-cell" onClick={() => onPick(m.id)}>
|
|
199
|
+
{(m.file.contentType ?? "").startsWith("image/") ? <img src={api.resolve(`/media/${m.file.key}`)} alt="" /> : <div style={{ height: 70 }} />}
|
|
200
|
+
<div className="fn">{m.file.filename ?? m.id}</div>
|
|
201
|
+
</div>
|
|
202
|
+
))}
|
|
203
|
+
</div>
|
|
204
|
+
<div style={{ marginTop: 12, textAlign: "right" }}>
|
|
205
|
+
<button className="ghost" onClick={onClose}>
|
|
206
|
+
close
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { App } from "./app";
|
|
4
|
+
import { css } from "./styles";
|
|
5
|
+
|
|
6
|
+
const style = document.createElement("style");
|
|
7
|
+
style.textContent = css;
|
|
8
|
+
document.head.appendChild(style);
|
|
9
|
+
|
|
10
|
+
const el = document.getElementById("app");
|
|
11
|
+
if (el) createRoot(el).render(<StrictMode><App /></StrictMode>);
|
package/src/styles.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Inline stylesheet for the CMS editor. Borders-only depth, spring-teal accent — the
|
|
2
|
+
// same design language as @pramen/admin, adapted for an editor (canvas, palette, forms).
|
|
3
|
+
|
|
4
|
+
export const css = `
|
|
5
|
+
:root {
|
|
6
|
+
--bg:#0e1116; --surface:#141921; --surface-2:#181e28; --raise:#1d2531;
|
|
7
|
+
--border:#232c3a; --border-2:#2e3a4d; --ink:#e6edf3; --ink-2:#aab7c7; --ink-3:#6b7a8d;
|
|
8
|
+
--spring:#3fd6c0; --spring-dim:#1c8d80; --spring-bg:#0f2723; --amber:#e0a458;
|
|
9
|
+
--danger:#e0606a; --danger-bg:#2a161a; --radius:7px; --radius-sm:5px;
|
|
10
|
+
--mono:ui-monospace,"SF Mono","JetBrains Mono","Menlo",monospace;
|
|
11
|
+
--sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
|
|
12
|
+
}
|
|
13
|
+
* { box-sizing:border-box; }
|
|
14
|
+
html,body { margin:0; height:100%; }
|
|
15
|
+
body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-size:14px; line-height:1.5; -webkit-font-smoothing:antialiased; }
|
|
16
|
+
#app { min-height:100%; }
|
|
17
|
+
a { color:var(--spring); }
|
|
18
|
+
|
|
19
|
+
.bar { display:flex; align-items:center; gap:10px; padding:10px 16px; background:var(--surface); border-bottom:1px solid var(--border); position:sticky; top:0; z-index:10; }
|
|
20
|
+
.bar .grow { flex:1; }
|
|
21
|
+
.brand { font-weight:700; letter-spacing:.02em; }
|
|
22
|
+
.brand .dim { color:var(--ink-3); font-weight:400; }
|
|
23
|
+
.crumb { color:var(--ink-2); }
|
|
24
|
+
.crumb b { color:var(--ink); }
|
|
25
|
+
|
|
26
|
+
button, .btn { font:inherit; color:var(--ink); background:var(--raise); border:1px solid var(--border-2); border-radius:var(--radius-sm); padding:5px 11px; cursor:pointer; }
|
|
27
|
+
button:hover { border-color:var(--spring-dim); }
|
|
28
|
+
button.primary { background:var(--spring-bg); border-color:var(--spring-dim); color:var(--spring); }
|
|
29
|
+
button.ghost { background:transparent; border-color:transparent; color:var(--ink-2); }
|
|
30
|
+
button.ghost:hover { color:var(--ink); border-color:var(--border); }
|
|
31
|
+
button.danger { color:var(--danger); border-color:var(--danger); background:var(--danger-bg); }
|
|
32
|
+
button:disabled { opacity:.5; cursor:not-allowed; }
|
|
33
|
+
button.sm { padding:3px 7px; font-size:12px; }
|
|
34
|
+
|
|
35
|
+
input, textarea, select { font:inherit; color:var(--ink); background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--radius-sm); padding:6px 9px; width:100%; }
|
|
36
|
+
input:focus, textarea:focus, select:focus { outline:none; border-color:var(--spring-dim); }
|
|
37
|
+
textarea { min-height:72px; font-family:var(--mono); font-size:13px; resize:vertical; }
|
|
38
|
+
label.field { display:block; margin:10px 0; }
|
|
39
|
+
label.field > .lbl { display:block; color:var(--ink-2); font-size:12px; margin-bottom:4px; }
|
|
40
|
+
label.field > .lbl .req { color:var(--amber); }
|
|
41
|
+
.checkbox { display:flex; align-items:center; gap:8px; }
|
|
42
|
+
.checkbox input { width:auto; }
|
|
43
|
+
|
|
44
|
+
.layout { display:grid; grid-template-columns:230px 1fr 380px; min-height:calc(100vh - 47px); }
|
|
45
|
+
.side { border-right:1px solid var(--border); background:var(--surface); padding:12px; overflow:auto; }
|
|
46
|
+
.canvas { padding:18px 20px; overflow:auto; }
|
|
47
|
+
.inspect { border-left:1px solid var(--border); background:var(--surface); padding:14px 16px; overflow:auto; }
|
|
48
|
+
|
|
49
|
+
.sect { color:var(--ink-3); font-size:11px; text-transform:uppercase; letter-spacing:.08em; margin:14px 0 6px; }
|
|
50
|
+
.list { display:flex; flex-direction:column; gap:4px; }
|
|
51
|
+
.row { display:flex; align-items:center; gap:8px; padding:7px 9px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--surface-2); cursor:pointer; }
|
|
52
|
+
.row:hover { border-color:var(--border-2); }
|
|
53
|
+
.row.active { border-color:var(--spring-dim); background:var(--spring-bg); }
|
|
54
|
+
.row .grow { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
55
|
+
.pill { font-size:11px; padding:1px 7px; border-radius:20px; border:1px solid var(--border-2); color:var(--ink-2); }
|
|
56
|
+
.pill.published { color:var(--spring); border-color:var(--spring-dim); }
|
|
57
|
+
.pill.review { color:var(--amber); border-color:var(--amber); }
|
|
58
|
+
.pill.rejected, .pill.archived { color:var(--danger); border-color:var(--danger); }
|
|
59
|
+
|
|
60
|
+
.region { margin-bottom:18px; }
|
|
61
|
+
.region > h3 { font-size:13px; color:var(--ink-2); margin:0 0 8px; display:flex; align-items:center; gap:8px; }
|
|
62
|
+
.region > h3 .allow { font-size:11px; color:var(--ink-3); font-weight:400; }
|
|
63
|
+
.block { border:1px solid var(--border); border-radius:var(--radius); background:var(--surface-2); padding:10px 12px; margin-bottom:8px; }
|
|
64
|
+
.block.selected { border-color:var(--spring-dim); }
|
|
65
|
+
.block .bhead { display:flex; align-items:center; gap:8px; }
|
|
66
|
+
.block .btype { font-family:var(--mono); font-size:12px; color:var(--spring); }
|
|
67
|
+
.block .grow { flex:1; }
|
|
68
|
+
.block .shared { font-size:11px; color:var(--amber); }
|
|
69
|
+
.dropzone { border:1px dashed var(--border-2); border-radius:var(--radius); padding:10px; text-align:center; color:var(--ink-3); font-size:12px; }
|
|
70
|
+
.dropzone.over { border-color:var(--spring); color:var(--spring); }
|
|
71
|
+
|
|
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:8px 10px; margin:6px 0; background:var(--surface-2); }
|
|
75
|
+
.repeater-item > .ih { display:flex; justify-content:flex-end; }
|
|
76
|
+
|
|
77
|
+
.media-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }
|
|
78
|
+
.media-cell { border:1px solid var(--border); border-radius:var(--radius-sm); overflow:hidden; cursor:pointer; background:var(--surface-2); }
|
|
79
|
+
.media-cell:hover { border-color:var(--border-2); }
|
|
80
|
+
.media-cell.sel { border-color:var(--spring); }
|
|
81
|
+
.media-cell img { width:100%; height:70px; object-fit:cover; display:block; }
|
|
82
|
+
.media-cell .fn { font-size:10px; padding:3px 5px; color:var(--ink-3); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
83
|
+
.media-cell .ext { height:70px; display:flex; align-items:center; justify-content:center; font-family:var(--mono); font-size:12px; color:var(--ink-3); background:var(--raise); }
|
|
84
|
+
|
|
85
|
+
.media-lib { padding:20px; max-width:1000px; margin:0 auto; }
|
|
86
|
+
.media-lib .media-grid { grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); }
|
|
87
|
+
.media-lib .media-cell img, .media-lib .media-cell .ext { height:110px; }
|
|
88
|
+
.media-toolbar { display:flex; align-items:center; margin-bottom:14px; }
|
|
89
|
+
label.btn { display:inline-flex; align-items:center; }
|
|
90
|
+
.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 12px; background:var(--surface-2); border-radius:var(--radius-sm); }
|
|
92
|
+
.media-detail .ext.lg { height:180px; display:flex; align-items:center; justify-content:center; font-family:var(--mono); color:var(--ink-3); background:var(--surface-2); border-radius:var(--radius-sm); margin-bottom:12px; }
|
|
93
|
+
.media-detail .kv a { word-break:break-all; }
|
|
94
|
+
|
|
95
|
+
.scrim { position:fixed; inset:0; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; z-index:50; }
|
|
96
|
+
.modal { background:var(--surface); border:1px solid var(--border-2); border-radius:var(--radius); padding:18px; width:min(680px,92vw); max-height:86vh; overflow:auto; }
|
|
97
|
+
.modal h2 { margin:0 0 12px; font-size:16px; }
|
|
98
|
+
.banner { padding:8px 12px; border-radius:var(--radius-sm); margin:8px 0; font-size:13px; }
|
|
99
|
+
.banner.err { background:var(--danger-bg); border:1px solid var(--danger); color:var(--danger); }
|
|
100
|
+
.banner.ok { background:var(--spring-bg); border:1px solid var(--spring-dim); color:var(--spring); }
|
|
101
|
+
.muted { color:var(--ink-3); }
|
|
102
|
+
.tabs { display:flex; gap:4px; margin-bottom:10px; }
|
|
103
|
+
.tabs button { border-radius:20px; font-size:12px; }
|
|
104
|
+
.tabs button.on { color:var(--spring); border-color:var(--spring-dim); }
|
|
105
|
+
.setup { max-width:440px; margin:12vh auto; padding:0 20px; }
|
|
106
|
+
.setup h1 { font-size:20px; }
|
|
107
|
+
.kv { display:grid; grid-template-columns:auto 1fr; gap:6px 10px; font-size:12px; color:var(--ink-2); }
|
|
108
|
+
`;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Local mirror of the @pramen/cms shapes the editor needs. Kept local (not imported from
|
|
2
|
+
// @pramen/cms) so the editor stays a self-contained browser app with no server-package
|
|
3
|
+
// dependency — it speaks to the CMS purely over HTTP.
|
|
4
|
+
|
|
5
|
+
export type FieldType =
|
|
6
|
+
| "text"
|
|
7
|
+
| "textarea"
|
|
8
|
+
| "richtext"
|
|
9
|
+
| "url"
|
|
10
|
+
| "number"
|
|
11
|
+
| "boolean"
|
|
12
|
+
| "media"
|
|
13
|
+
| "select"
|
|
14
|
+
| "repeater"
|
|
15
|
+
| "group";
|
|
16
|
+
|
|
17
|
+
export interface FieldDefinition {
|
|
18
|
+
name: string;
|
|
19
|
+
label?: string;
|
|
20
|
+
type: FieldType;
|
|
21
|
+
required?: boolean;
|
|
22
|
+
default?: unknown;
|
|
23
|
+
fields?: FieldDefinition[];
|
|
24
|
+
min?: number;
|
|
25
|
+
max?: number;
|
|
26
|
+
options?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RegionDefinition {
|
|
30
|
+
name: string;
|
|
31
|
+
label?: string;
|
|
32
|
+
allowedTypes?: string[] | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DefaultBlockDefinition {
|
|
36
|
+
region: string;
|
|
37
|
+
blockTypeSlug: string;
|
|
38
|
+
fields?: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BlockType {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
slug: string;
|
|
45
|
+
description?: string | null;
|
|
46
|
+
fieldsSchema?: FieldDefinition[] | null;
|
|
47
|
+
icon?: string | null;
|
|
48
|
+
category?: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ContentType {
|
|
52
|
+
id: string;
|
|
53
|
+
name: string;
|
|
54
|
+
slug: string;
|
|
55
|
+
regions?: RegionDefinition[] | null;
|
|
56
|
+
fieldsSchema?: FieldDefinition[] | null;
|
|
57
|
+
defaultBlocks?: DefaultBlockDefinition[] | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Page {
|
|
61
|
+
id: string;
|
|
62
|
+
typeId: string;
|
|
63
|
+
title: string;
|
|
64
|
+
slug: string;
|
|
65
|
+
status: string;
|
|
66
|
+
locale: string;
|
|
67
|
+
translationGroupId?: string | null;
|
|
68
|
+
metaTitle?: string | null;
|
|
69
|
+
metaDescription?: string | null;
|
|
70
|
+
canonicalUrl?: string | null;
|
|
71
|
+
robots?: string | null;
|
|
72
|
+
ogTitle?: string | null;
|
|
73
|
+
ogDescription?: string | null;
|
|
74
|
+
ogImage?: string | null;
|
|
75
|
+
scheduledAt?: string | null;
|
|
76
|
+
unpublishAt?: string | null;
|
|
77
|
+
updatedAt?: string | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ResolvedMedia {
|
|
81
|
+
id: string;
|
|
82
|
+
key: string;
|
|
83
|
+
url: string;
|
|
84
|
+
alt: string | null;
|
|
85
|
+
contentType: string | null;
|
|
86
|
+
filename: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface RenderedBlock {
|
|
90
|
+
id: string; // placement id (reorder/remove)
|
|
91
|
+
block_id: string; // block instance id (edit)
|
|
92
|
+
block_type: string;
|
|
93
|
+
title: string | null;
|
|
94
|
+
fields: Record<string, unknown>;
|
|
95
|
+
is_shared: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface AssembledPage {
|
|
99
|
+
page: Page & { translations?: { locale: string; slug: string }[]; seo?: Record<string, unknown> };
|
|
100
|
+
regions: Record<string, RenderedBlock[]>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface Media {
|
|
104
|
+
id: string;
|
|
105
|
+
file: { key: string; contentType?: string; filename?: string; size?: number; uploadedAt?: number };
|
|
106
|
+
alt?: string | null;
|
|
107
|
+
createdAt?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface AuditEntry {
|
|
111
|
+
id: string;
|
|
112
|
+
action: string;
|
|
113
|
+
fromStatus: string | null;
|
|
114
|
+
toStatus: string | null;
|
|
115
|
+
actor: string | null;
|
|
116
|
+
note: string | null;
|
|
117
|
+
createdAt: string;
|
|
118
|
+
}
|