@contentrail/editor 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +120 -2
- package/dist/api/client.d.ts +52 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +92 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/types.d.ts +72 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/fields/defaults.d.ts +11 -0
- package/dist/fields/defaults.d.ts.map +1 -0
- package/dist/fields/defaults.js +93 -0
- package/dist/fields/defaults.js.map +1 -0
- package/dist/fields/types.d.ts +62 -0
- package/dist/fields/types.d.ts.map +1 -0
- package/dist/fields/types.js +6 -0
- package/dist/fields/types.js.map +1 -0
- package/dist/form/FieldControl.d.ts +11 -0
- package/dist/form/FieldControl.d.ts.map +1 -0
- package/dist/form/FieldControl.js +72 -0
- package/dist/form/FieldControl.js.map +1 -0
- package/dist/form/SchemaForm.d.ts +14 -0
- package/dist/form/SchemaForm.d.ts.map +1 -0
- package/dist/form/SchemaForm.js +33 -0
- package/dist/form/SchemaForm.js.map +1 -0
- package/dist/import/tabular.d.ts +35 -0
- package/dist/import/tabular.d.ts.map +1 -0
- package/dist/import/tabular.js +191 -0
- package/dist/import/tabular.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/state/draft.d.ts +12 -0
- package/dist/state/draft.d.ts.map +1 -0
- package/dist/state/draft.js +32 -0
- package/dist/state/draft.js.map +1 -0
- package/dist/styles.css +322 -0
- package/dist/ui/EditorApp.d.ts +10 -0
- package/dist/ui/EditorApp.d.ts.map +1 -0
- package/dist/ui/EditorApp.js +352 -0
- package/dist/ui/EditorApp.js.map +1 -0
- package/package.json +60 -3
package/dist/styles.css
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--cr-bg: #f3efe6;
|
|
3
|
+
--cr-surface: #fffdf8;
|
|
4
|
+
--cr-ink: #1c2420;
|
|
5
|
+
--cr-muted: #5c675f;
|
|
6
|
+
--cr-line: #d7d0c2;
|
|
7
|
+
--cr-accent: #0f6b5c;
|
|
8
|
+
--cr-accent-ink: #ffffff;
|
|
9
|
+
--cr-danger: #9b2c2c;
|
|
10
|
+
--cr-warn: #8a5a00;
|
|
11
|
+
--cr-ok: #1f6b3a;
|
|
12
|
+
--cr-shadow: 0 10px 30px rgba(28, 36, 32, 0.08);
|
|
13
|
+
--cr-radius: 14px;
|
|
14
|
+
--cr-font:
|
|
15
|
+
"Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia,
|
|
16
|
+
serif;
|
|
17
|
+
--cr-sans: "Avenir Next", "Segoe UI", sans-serif;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.cr-editor {
|
|
21
|
+
color: var(--cr-ink);
|
|
22
|
+
background:
|
|
23
|
+
radial-gradient(
|
|
24
|
+
circle at top left,
|
|
25
|
+
rgba(15, 107, 92, 0.12),
|
|
26
|
+
transparent 40%
|
|
27
|
+
),
|
|
28
|
+
linear-gradient(180deg, #efe8da 0%, var(--cr-bg) 45%, #e7efe9 100%);
|
|
29
|
+
min-height: 100vh;
|
|
30
|
+
font-family: var(--cr-sans);
|
|
31
|
+
line-height: 1.5;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.cr-header,
|
|
35
|
+
.cr-nav,
|
|
36
|
+
.cr-main,
|
|
37
|
+
.cr-banner {
|
|
38
|
+
width: min(960px, calc(100% - 2rem));
|
|
39
|
+
margin-inline: auto;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.cr-header {
|
|
43
|
+
display: flex;
|
|
44
|
+
justify-content: space-between;
|
|
45
|
+
align-items: end;
|
|
46
|
+
padding: 2rem 0 1rem;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.cr-brand {
|
|
50
|
+
margin: 0;
|
|
51
|
+
letter-spacing: 0.08em;
|
|
52
|
+
text-transform: uppercase;
|
|
53
|
+
font-size: 0.75rem;
|
|
54
|
+
color: var(--cr-accent);
|
|
55
|
+
font-weight: 700;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.cr-header h1,
|
|
59
|
+
.cr-main h2,
|
|
60
|
+
.cr-main h3 {
|
|
61
|
+
font-family: var(--cr-font);
|
|
62
|
+
font-weight: 600;
|
|
63
|
+
margin: 0.15rem 0 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.cr-lede,
|
|
67
|
+
.cr-help {
|
|
68
|
+
color: var(--cr-muted);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.cr-nav {
|
|
72
|
+
display: flex;
|
|
73
|
+
flex-wrap: wrap;
|
|
74
|
+
gap: 0.5rem;
|
|
75
|
+
align-items: center;
|
|
76
|
+
margin-bottom: 1rem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.cr-link,
|
|
80
|
+
.cr-button,
|
|
81
|
+
.cr-card,
|
|
82
|
+
.cr-entry {
|
|
83
|
+
font: inherit;
|
|
84
|
+
cursor: pointer;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.cr-link {
|
|
88
|
+
border: 0;
|
|
89
|
+
background: transparent;
|
|
90
|
+
color: var(--cr-accent);
|
|
91
|
+
text-decoration: underline;
|
|
92
|
+
text-underline-offset: 0.15em;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.cr-main {
|
|
96
|
+
background: var(--cr-surface);
|
|
97
|
+
border: 1px solid var(--cr-line);
|
|
98
|
+
border-radius: var(--cr-radius);
|
|
99
|
+
box-shadow: var(--cr-shadow);
|
|
100
|
+
padding: 1.5rem;
|
|
101
|
+
margin-bottom: 2rem;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.cr-banner {
|
|
105
|
+
border-radius: 999px;
|
|
106
|
+
padding: 0.75rem 1rem;
|
|
107
|
+
margin-bottom: 1rem;
|
|
108
|
+
border: 1px solid var(--cr-line);
|
|
109
|
+
background: #fff;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.cr-banner--error {
|
|
113
|
+
border-color: #e4b4b4;
|
|
114
|
+
color: var(--cr-danger);
|
|
115
|
+
background: #fff5f5;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.cr-banner--success {
|
|
119
|
+
border-color: #b9dcc4;
|
|
120
|
+
color: var(--cr-ok);
|
|
121
|
+
background: #f2fbf5;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.cr-banner--info {
|
|
125
|
+
border-color: #c5ddd8;
|
|
126
|
+
color: var(--cr-accent);
|
|
127
|
+
background: #f2faf8;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.cr-card-list,
|
|
131
|
+
.cr-entry-list,
|
|
132
|
+
.cr-array {
|
|
133
|
+
list-style: none;
|
|
134
|
+
padding: 0;
|
|
135
|
+
margin: 1rem 0 0;
|
|
136
|
+
display: grid;
|
|
137
|
+
gap: 0.75rem;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.cr-card,
|
|
141
|
+
.cr-entry {
|
|
142
|
+
width: 100%;
|
|
143
|
+
text-align: left;
|
|
144
|
+
border: 1px solid var(--cr-line);
|
|
145
|
+
border-radius: 12px;
|
|
146
|
+
background: #fff;
|
|
147
|
+
padding: 1rem 1.1rem;
|
|
148
|
+
display: grid;
|
|
149
|
+
gap: 0.2rem;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.cr-card:hover,
|
|
153
|
+
.cr-entry:hover,
|
|
154
|
+
.cr-card:focus-visible,
|
|
155
|
+
.cr-entry:focus-visible,
|
|
156
|
+
.cr-button:focus-visible,
|
|
157
|
+
.cr-input:focus-visible,
|
|
158
|
+
.cr-link:focus-visible {
|
|
159
|
+
outline: 3px solid rgba(15, 107, 92, 0.35);
|
|
160
|
+
outline-offset: 2px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.cr-card span,
|
|
164
|
+
.cr-entry span {
|
|
165
|
+
color: var(--cr-muted);
|
|
166
|
+
font-size: 0.92rem;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.cr-toolbar,
|
|
170
|
+
.cr-actions {
|
|
171
|
+
display: flex;
|
|
172
|
+
flex-wrap: wrap;
|
|
173
|
+
gap: 0.75rem;
|
|
174
|
+
align-items: center;
|
|
175
|
+
justify-content: space-between;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.cr-actions {
|
|
179
|
+
justify-content: flex-end;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.cr-button {
|
|
183
|
+
border: 0;
|
|
184
|
+
border-radius: 999px;
|
|
185
|
+
background: var(--cr-accent);
|
|
186
|
+
color: var(--cr-accent-ink);
|
|
187
|
+
padding: 0.65rem 1.1rem;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.cr-button:disabled {
|
|
191
|
+
opacity: 0.55;
|
|
192
|
+
cursor: not-allowed;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.cr-button--secondary {
|
|
196
|
+
background: #e7f2ef;
|
|
197
|
+
color: var(--cr-accent);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.cr-button--ghost {
|
|
201
|
+
background: transparent;
|
|
202
|
+
color: var(--cr-muted);
|
|
203
|
+
border: 1px solid var(--cr-line);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.cr-field,
|
|
207
|
+
.cr-fieldset {
|
|
208
|
+
margin: 1rem 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.cr-label {
|
|
212
|
+
display: block;
|
|
213
|
+
font-weight: 600;
|
|
214
|
+
margin-bottom: 0.35rem;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.cr-required {
|
|
218
|
+
color: var(--cr-danger);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.cr-input,
|
|
222
|
+
.cr-search input {
|
|
223
|
+
width: 100%;
|
|
224
|
+
box-sizing: border-box;
|
|
225
|
+
border: 1px solid var(--cr-line);
|
|
226
|
+
border-radius: 10px;
|
|
227
|
+
padding: 0.7rem 0.8rem;
|
|
228
|
+
background: #fff;
|
|
229
|
+
font: inherit;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.cr-field--invalid .cr-input {
|
|
233
|
+
border-color: var(--cr-danger);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.cr-error {
|
|
237
|
+
color: var(--cr-danger);
|
|
238
|
+
margin: 0.35rem 0 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.cr-unsupported {
|
|
242
|
+
margin: 0;
|
|
243
|
+
padding: 0.75rem;
|
|
244
|
+
border-radius: 10px;
|
|
245
|
+
background: #f7f1e4;
|
|
246
|
+
color: var(--cr-warn);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.cr-fieldset {
|
|
250
|
+
border: 1px solid var(--cr-line);
|
|
251
|
+
border-radius: 12px;
|
|
252
|
+
padding: 0.75rem 1rem 1rem;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.cr-checkbox {
|
|
256
|
+
display: inline-flex;
|
|
257
|
+
gap: 0.5rem;
|
|
258
|
+
align-items: center;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.cr-empty {
|
|
262
|
+
padding: 1.5rem;
|
|
263
|
+
border: 1px dashed var(--cr-line);
|
|
264
|
+
border-radius: 12px;
|
|
265
|
+
color: var(--cr-muted);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.cr-pill {
|
|
269
|
+
display: inline-flex;
|
|
270
|
+
align-items: center;
|
|
271
|
+
border-radius: 999px;
|
|
272
|
+
padding: 0.2rem 0.7rem;
|
|
273
|
+
background: #fff3d6;
|
|
274
|
+
color: var(--cr-warn);
|
|
275
|
+
font-size: 0.85rem;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.cr-pill--warn {
|
|
279
|
+
background: #fde8e8;
|
|
280
|
+
color: var(--cr-danger);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.cr-preview,
|
|
284
|
+
.cr-publish-result,
|
|
285
|
+
.cr-versions {
|
|
286
|
+
margin-top: 1.5rem;
|
|
287
|
+
padding-top: 1rem;
|
|
288
|
+
border-top: 1px solid var(--cr-line);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.cr-preview pre {
|
|
292
|
+
margin: 0;
|
|
293
|
+
white-space: pre-wrap;
|
|
294
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
295
|
+
font-size: 0.85rem;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.cr-visually-hidden {
|
|
299
|
+
position: absolute;
|
|
300
|
+
width: 1px;
|
|
301
|
+
height: 1px;
|
|
302
|
+
padding: 0;
|
|
303
|
+
margin: -1px;
|
|
304
|
+
overflow: hidden;
|
|
305
|
+
clip: rect(0, 0, 0, 0);
|
|
306
|
+
border: 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
@media (max-width: 720px) {
|
|
310
|
+
.cr-toolbar,
|
|
311
|
+
.cr-actions {
|
|
312
|
+
align-items: stretch;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.cr-actions {
|
|
316
|
+
width: 100%;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.cr-button {
|
|
320
|
+
width: 100%;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ContentRailApiClient } from "../api/client.js";
|
|
2
|
+
import type { EditorSiteConfig } from "../fields/types.js";
|
|
3
|
+
export interface EditorAppProps {
|
|
4
|
+
readonly api: ContentRailApiClient;
|
|
5
|
+
/** Developer-supplied editor metadata for sites/collections/fields. */
|
|
6
|
+
readonly sites: readonly EditorSiteConfig[];
|
|
7
|
+
readonly title?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function EditorApp(props: EditorAppProps): import("preact").JSX.Element;
|
|
10
|
+
//# sourceMappingURL=EditorApp.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EditorApp.d.ts","sourceRoot":"","sources":["../../src/ui/EditorApp.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAM7D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAQ3D,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,oBAAoB,CAAC;IACnC,uEAAuE;IACvE,QAAQ,CAAC,KAAK,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAC5C,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AA0BD,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,gCAspB9C"}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useState } from "preact/hooks";
|
|
3
|
+
import { clientValidateFields, emptyValueForFields, } from "../fields/defaults.js";
|
|
4
|
+
import { applyFieldChange, SchemaForm } from "../form/SchemaForm.js";
|
|
5
|
+
import { clearSessionDraft, loadSessionDraft, saveSessionDraft, } from "../state/draft.js";
|
|
6
|
+
function titleOf(data, titleField, fallback) {
|
|
7
|
+
if (titleField === undefined) {
|
|
8
|
+
return fallback;
|
|
9
|
+
}
|
|
10
|
+
const value = data[titleField];
|
|
11
|
+
return typeof value === "string" && value.trim() !== "" ? value : fallback;
|
|
12
|
+
}
|
|
13
|
+
export function EditorApp(props) {
|
|
14
|
+
const { api, sites, title = "ContentRail Editor" } = props;
|
|
15
|
+
const [view, setView] = useState({ kind: "sites" });
|
|
16
|
+
const [allowedSiteIds, setAllowedSiteIds] = useState(null);
|
|
17
|
+
const [permissions, setPermissions] = useState([]);
|
|
18
|
+
const [entries, setEntries] = useState([]);
|
|
19
|
+
const [activeVersion, setActiveVersion] = useState(null);
|
|
20
|
+
const [filter, setFilter] = useState("");
|
|
21
|
+
const [entryId, setEntryId] = useState("");
|
|
22
|
+
const [data, setData] = useState({});
|
|
23
|
+
const [dirty, setDirty] = useState(false);
|
|
24
|
+
const [errors, setErrors] = useState({});
|
|
25
|
+
const [banner, setBanner] = useState(null);
|
|
26
|
+
const [busy, setBusy] = useState(false);
|
|
27
|
+
const [preview, setPreview] = useState(false);
|
|
28
|
+
const [status, setStatus] = useState(null);
|
|
29
|
+
const [lastPublish, setLastPublish] = useState(null);
|
|
30
|
+
const [authExpired, setAuthExpired] = useState(false);
|
|
31
|
+
const siteConfig = useMemo(() => {
|
|
32
|
+
if (view.kind === "sites") {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return sites.find((site) => site.siteId === view.siteId) ?? null;
|
|
36
|
+
}, [sites, view]);
|
|
37
|
+
const collectionConfig = useMemo(() => {
|
|
38
|
+
if (view.kind !== "entries" && view.kind !== "edit") {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return (siteConfig?.collections.find((c) => c.name === view.collection) ?? null);
|
|
42
|
+
}, [siteConfig, view]);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
void (async () => {
|
|
45
|
+
const result = await api.listSites();
|
|
46
|
+
if (!result.ok) {
|
|
47
|
+
if (result.status === 401) {
|
|
48
|
+
setAuthExpired(true);
|
|
49
|
+
setBanner({
|
|
50
|
+
tone: "error",
|
|
51
|
+
text: "Your session expired. Sign in again through Cloudflare Access.",
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
setBanner({ tone: "error", text: result.message });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
setAllowedSiteIds(result.data.sites.map((site) => site.siteId));
|
|
59
|
+
})();
|
|
60
|
+
}, [api]);
|
|
61
|
+
async function openSite(siteId) {
|
|
62
|
+
setBanner(null);
|
|
63
|
+
const config = await api.getSiteConfig(siteId);
|
|
64
|
+
if (!config.ok) {
|
|
65
|
+
handleApiFailure(config.status, config.message, config.code);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
setPermissions(config.data.permissions);
|
|
69
|
+
setView({ kind: "collections", siteId });
|
|
70
|
+
}
|
|
71
|
+
async function openCollection(siteId, collection) {
|
|
72
|
+
setBanner(null);
|
|
73
|
+
setBusy(true);
|
|
74
|
+
try {
|
|
75
|
+
const content = await api.getContent(siteId, collection);
|
|
76
|
+
if (!content.ok) {
|
|
77
|
+
handleApiFailure(content.status, content.message, content.code);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
setActiveVersion(content.data.version);
|
|
81
|
+
setEntries(content.data.entries.map((entry) => ({
|
|
82
|
+
id: entry.id,
|
|
83
|
+
data: entry.data !== null &&
|
|
84
|
+
typeof entry.data === "object" &&
|
|
85
|
+
!Array.isArray(entry.data)
|
|
86
|
+
? entry.data
|
|
87
|
+
: {},
|
|
88
|
+
})));
|
|
89
|
+
const st = await api.getStatus(siteId, collection);
|
|
90
|
+
setStatus(st.ok ? st.data : null);
|
|
91
|
+
setFilter("");
|
|
92
|
+
setView({ kind: "entries", siteId, collection });
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
setBusy(false);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function startCreate(siteId, collection) {
|
|
99
|
+
const col = siteConfig?.collections.find((item) => item.name === collection) ?? null;
|
|
100
|
+
if (col === null) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
setData(emptyValueForFields(col.fields));
|
|
104
|
+
setEntryId("");
|
|
105
|
+
setErrors({});
|
|
106
|
+
setDirty(false);
|
|
107
|
+
setPreview(false);
|
|
108
|
+
setLastPublish(null);
|
|
109
|
+
setView({ kind: "edit", siteId, collection, entryId: "", isNew: true });
|
|
110
|
+
}
|
|
111
|
+
function startEdit(siteId, collection, id, entryData) {
|
|
112
|
+
const draft = loadSessionDraft(siteId, collection, id);
|
|
113
|
+
if (draft !== null) {
|
|
114
|
+
setData(draft.data !== null &&
|
|
115
|
+
typeof draft.data === "object" &&
|
|
116
|
+
!Array.isArray(draft.data)
|
|
117
|
+
? draft.data
|
|
118
|
+
: entryData);
|
|
119
|
+
setDirty(true);
|
|
120
|
+
setBanner({
|
|
121
|
+
tone: "info",
|
|
122
|
+
text: "Restored an unsaved draft from this browser session.",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
setData(structuredClone(entryData));
|
|
127
|
+
setDirty(false);
|
|
128
|
+
}
|
|
129
|
+
setEntryId(id);
|
|
130
|
+
setErrors({});
|
|
131
|
+
setPreview(false);
|
|
132
|
+
setLastPublish(null);
|
|
133
|
+
setView({ kind: "edit", siteId, collection, entryId: id, isNew: false });
|
|
134
|
+
}
|
|
135
|
+
function handleApiFailure(status, message, code) {
|
|
136
|
+
if (status === 401) {
|
|
137
|
+
setAuthExpired(true);
|
|
138
|
+
setBanner({
|
|
139
|
+
tone: "error",
|
|
140
|
+
text: "Your session expired. Sign in again through Cloudflare Access.",
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (status === 403) {
|
|
145
|
+
setBanner({
|
|
146
|
+
tone: "error",
|
|
147
|
+
text: "You do not have permission for this site or action.",
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
setBanner({ tone: "error", text: `${code}: ${message}` });
|
|
152
|
+
}
|
|
153
|
+
function updateField(path, value) {
|
|
154
|
+
setData((prev) => applyFieldChange(prev, path, value));
|
|
155
|
+
setDirty(true);
|
|
156
|
+
if (view.kind === "edit" && entryId) {
|
|
157
|
+
saveSessionDraft({
|
|
158
|
+
siteId: view.siteId,
|
|
159
|
+
collection: view.collection,
|
|
160
|
+
entryId: entryId || "(new)",
|
|
161
|
+
data: applyFieldChange(data, path, value),
|
|
162
|
+
baseVersion: activeVersion,
|
|
163
|
+
updatedAt: new Date().toISOString(),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function confirmLeave() {
|
|
168
|
+
if (!dirty) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return window.confirm("You have unsaved changes. Discard them and leave this entry?");
|
|
172
|
+
}
|
|
173
|
+
async function runValidate() {
|
|
174
|
+
if (view.kind !== "edit" || !collectionConfig) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
const local = clientValidateFields(collectionConfig.fields, data);
|
|
178
|
+
if (entryId.trim() === "") {
|
|
179
|
+
local.push({ path: "(id)", message: "Entry ID is required." });
|
|
180
|
+
}
|
|
181
|
+
if (local.length > 0) {
|
|
182
|
+
const next = {};
|
|
183
|
+
for (const issue of local) {
|
|
184
|
+
next[issue.path.split(".")[0] === "(id)"
|
|
185
|
+
? "(id)"
|
|
186
|
+
: issue.path.split(".")[0]] = issue.message;
|
|
187
|
+
}
|
|
188
|
+
setErrors(next);
|
|
189
|
+
setBanner({
|
|
190
|
+
tone: "error",
|
|
191
|
+
text: "Fix the highlighted fields before publishing.",
|
|
192
|
+
});
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
setErrors({});
|
|
196
|
+
setBusy(true);
|
|
197
|
+
try {
|
|
198
|
+
const working = buildWorkingEntries(entries, entryId, data, view.isNew, view.entryId);
|
|
199
|
+
const result = await api.validate(view.siteId, view.collection, {
|
|
200
|
+
expectedVersion: activeVersion,
|
|
201
|
+
entries: working,
|
|
202
|
+
});
|
|
203
|
+
if (!result.ok) {
|
|
204
|
+
handleApiFailure(result.status, result.message, result.code);
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
setBanner({ tone: "success", text: "Validation passed." });
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
setBusy(false);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function runPublish() {
|
|
215
|
+
if (view.kind !== "edit" || !collectionConfig) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const valid = await runValidate();
|
|
219
|
+
if (!valid) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const idempotencyKey = `editor:${view.siteId}:${view.collection}:${entryId}:${activeVersion ?? "none"}:${hashPayload(data)}`;
|
|
223
|
+
setBusy(true);
|
|
224
|
+
setBanner({ tone: "info", text: "Publishing…" });
|
|
225
|
+
try {
|
|
226
|
+
const working = buildWorkingEntries(entries, entryId, data, view.isNew, view.entryId);
|
|
227
|
+
const result = await api.publish(view.siteId, view.collection, {
|
|
228
|
+
expectedVersion: activeVersion,
|
|
229
|
+
entries: working,
|
|
230
|
+
idempotencyKey,
|
|
231
|
+
});
|
|
232
|
+
if (!result.ok) {
|
|
233
|
+
if (result.code === "CONFLICT") {
|
|
234
|
+
setBanner({
|
|
235
|
+
tone: "error",
|
|
236
|
+
text: `Someone else published a newer version${result.currentVersion ? ` (${result.currentVersion})` : ""}. Reload the latest content, then re-apply your edits. Your working copy is still here.`,
|
|
237
|
+
});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
handleApiFailure(result.status, result.message, result.code);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
setLastPublish(result.data);
|
|
244
|
+
setActiveVersion(result.data.version);
|
|
245
|
+
setDirty(false);
|
|
246
|
+
clearSessionDraft(view.siteId, view.collection, entryId || "(new)");
|
|
247
|
+
const rebuildNote = result.data.rebuild.state === "failed"
|
|
248
|
+
? ` Content published, but site rebuild failed${result.data.rebuild.detail ? `: ${result.data.rebuild.detail}` : "."} Public static pages may still show the previous build.`
|
|
249
|
+
: result.data.rebuild.state === "not_requested"
|
|
250
|
+
? " Rebuild was not requested — content publication succeeded; public static HTML updates only after a site rebuild/deploy."
|
|
251
|
+
: ` Rebuild status: ${result.data.rebuild.state} (distinct from content publication).`;
|
|
252
|
+
setBanner({
|
|
253
|
+
tone: result.data.rebuild.state === "failed" ? "error" : "success",
|
|
254
|
+
text: `Published content version ${result.data.version}.${rebuildNote}`,
|
|
255
|
+
});
|
|
256
|
+
await openCollection(view.siteId, view.collection);
|
|
257
|
+
setView({
|
|
258
|
+
kind: "edit",
|
|
259
|
+
siteId: view.siteId,
|
|
260
|
+
collection: view.collection,
|
|
261
|
+
entryId,
|
|
262
|
+
isNew: false,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
setBusy(false);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async function runRollback(targetVersion) {
|
|
270
|
+
if (view.kind !== "entries" && view.kind !== "edit") {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (!permissions.includes("rollback")) {
|
|
274
|
+
setBanner({
|
|
275
|
+
tone: "error",
|
|
276
|
+
text: "Rollback is not permitted for your account.",
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (!window.confirm(`Roll back to version ${targetVersion}? This activates a previous publication.`)) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
setBusy(true);
|
|
284
|
+
try {
|
|
285
|
+
const result = await api.rollback(view.siteId, view.collection, {
|
|
286
|
+
targetVersion,
|
|
287
|
+
expectedVersion: activeVersion,
|
|
288
|
+
});
|
|
289
|
+
if (!result.ok) {
|
|
290
|
+
handleApiFailure(result.status, result.message, result.code);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
setBanner({
|
|
294
|
+
tone: "success",
|
|
295
|
+
text: `Rolled back to ${result.data.version}. Rebuild: ${result.data.rebuild.state}.`,
|
|
296
|
+
});
|
|
297
|
+
await openCollection(view.siteId, view.collection);
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
setBusy(false);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const visibleSites = sites.filter((site) => allowedSiteIds === null || allowedSiteIds.includes(site.siteId));
|
|
304
|
+
const filteredEntries = entries.filter((entry) => {
|
|
305
|
+
if (filter.trim() === "") {
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
const q = filter.toLowerCase();
|
|
309
|
+
const label = titleOf(entry.data, collectionConfig?.titleField, entry.id).toLowerCase();
|
|
310
|
+
return entry.id.toLowerCase().includes(q) || label.includes(q);
|
|
311
|
+
});
|
|
312
|
+
return (_jsxs("div", { class: "cr-editor", children: [_jsxs("header", { class: "cr-header", children: [_jsxs("div", { children: [_jsx("p", { class: "cr-brand", children: "ContentRail" }), _jsx("h1", { children: title })] }), authExpired ? (_jsx("p", { class: "cr-pill cr-pill--warn", role: "status", children: "Session expired" })) : null] }), banner ? (_jsx("div", { class: `cr-banner cr-banner--${banner.tone}`, role: "status", children: banner.text })) : null, _jsxs("nav", { class: "cr-nav", "aria-label": "Editor location", children: [_jsx("button", { type: "button", class: "cr-link", onClick: () => {
|
|
313
|
+
if (confirmLeave()) {
|
|
314
|
+
setDirty(false);
|
|
315
|
+
setView({ kind: "sites" });
|
|
316
|
+
}
|
|
317
|
+
}, children: "Sites" }), view.kind !== "sites" ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: "/" }), _jsx("button", { type: "button", class: "cr-link", onClick: () => {
|
|
318
|
+
if (confirmLeave()) {
|
|
319
|
+
setDirty(false);
|
|
320
|
+
void openSite(view.siteId);
|
|
321
|
+
}
|
|
322
|
+
}, children: siteConfig?.label ?? view.siteId })] })) : null, view.kind === "entries" || view.kind === "edit" ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: "/" }), _jsx("button", { type: "button", class: "cr-link", onClick: () => {
|
|
323
|
+
if (confirmLeave()) {
|
|
324
|
+
setDirty(false);
|
|
325
|
+
void openCollection(view.siteId, view.collection);
|
|
326
|
+
}
|
|
327
|
+
}, children: collectionConfig?.label ?? view.collection })] })) : null, dirty ? (_jsx("span", { class: "cr-pill", role: "status", children: "Unsaved changes" })) : null] }), _jsxs("main", { class: "cr-main", children: [view.kind === "sites" ? (_jsxs("section", { children: [_jsx("h2", { children: "Your sites" }), _jsx("p", { class: "cr-lede", children: "Choose a site you are authorised to manage. Credentials for Git and object storage stay on the server." }), visibleSites.length === 0 ? (_jsx("p", { class: "cr-empty", children: "No sites are available for your account." })) : (_jsx("ul", { class: "cr-card-list", children: visibleSites.map((site) => (_jsx("li", { children: _jsxs("button", { type: "button", class: "cr-card", onClick: () => void openSite(site.siteId), children: [_jsx("strong", { children: site.label }), _jsx("span", { children: site.siteId })] }) }, site.siteId))) }))] })) : null, view.kind === "collections" && siteConfig ? (_jsxs("section", { children: [_jsx("h2", { children: "Collections" }), _jsx("ul", { class: "cr-card-list", children: siteConfig.collections.map((collection) => (_jsx("li", { children: _jsxs("button", { type: "button", class: "cr-card", onClick: () => void openCollection(siteConfig.siteId, collection.name), children: [_jsx("strong", { children: collection.label }), _jsx("span", { children: collection.description ?? collection.name })] }) }, collection.name))) })] })) : null, view.kind === "entries" && collectionConfig ? (_jsxs("section", { children: [_jsxs("div", { class: "cr-toolbar", children: [_jsx("h2", { children: collectionConfig.label }), _jsx("button", { type: "button", class: "cr-button", disabled: busy || !permissions.includes("publish"), onClick: () => startCreate(view.siteId, view.collection), children: "New entry" })] }), _jsxs("label", { class: "cr-search", children: [_jsx("span", { class: "cr-visually-hidden", children: "Filter entries" }), _jsx("input", { class: "cr-input", type: "search", placeholder: "Filter by title or id", value: filter, onInput: (event) => setFilter(event.currentTarget.value) })] }), filteredEntries.length === 0 ? (_jsx("p", { class: "cr-empty", children: entries.length === 0
|
|
328
|
+
? "No published entries yet. Create the first one."
|
|
329
|
+
: "No entries match this filter." })) : (_jsx("ul", { class: "cr-entry-list", children: filteredEntries.map((entry) => (_jsx("li", { children: _jsxs("button", { type: "button", class: "cr-entry", onClick: () => startEdit(view.siteId, view.collection, entry.id, entry.data), children: [_jsx("strong", { children: titleOf(entry.data, collectionConfig.titleField, entry.id) }), _jsx("span", { children: entry.id })] }) }, entry.id))) })), status && status.versions.length > 0 ? (_jsxs("div", { class: "cr-versions", children: [_jsx("h3", { children: "Publication versions" }), _jsxs("p", { class: "cr-help", children: ["Active content version: ", activeVersion ?? "none", ". Publication success is not the same as a finished site rebuild or live public HTML."] }), _jsx("ul", { children: status.versions.map((version) => (_jsxs("li", { children: [_jsx("code", { children: version.version }), version.active ? " (active)" : "", permissions.includes("rollback") && !version.active ? (_jsx("button", { type: "button", class: "cr-button cr-button--ghost", disabled: busy, onClick: () => void runRollback(version.version), children: "Roll back" })) : null] }, version.version))) })] })) : null] })) : null, view.kind === "edit" && collectionConfig ? (_jsxs("section", { class: "cr-edit", children: [_jsxs("div", { class: "cr-toolbar", children: [_jsx("h2", { children: view.isNew ? "Create entry" : "Edit entry" }), _jsxs("div", { class: "cr-actions", children: [_jsx("button", { type: "button", class: "cr-button cr-button--secondary", disabled: busy, onClick: () => setPreview((value) => !value), children: preview ? "Edit" : "Preview" }), _jsx("button", { type: "button", class: "cr-button cr-button--secondary", disabled: busy || !permissions.includes("validate"), onClick: () => void runValidate(), children: "Validate" }), _jsx("button", { type: "button", class: "cr-button", disabled: busy || !permissions.includes("publish"), onClick: () => void runPublish(), children: "Publish" })] })] }), _jsxs("p", { class: "cr-help", children: ["Expected version: ", activeVersion ?? "none (first publish)", ". Drafts stay in this browser session only."] }), preview ? (_jsxs("div", { class: "cr-preview", "aria-live": "polite", children: [_jsx("h3", { children: "Preview" }), _jsx("p", { class: "cr-help", children: "Field values for this entry \u2014 not a full public-site render." }), _jsxs("dl", { children: [_jsxs("div", { children: [_jsx("dt", { children: "Entry ID" }), _jsx("dd", { children: entryId || "(missing)" })] }), Object.entries(data).map(([key, value]) => (_jsxs("div", { children: [_jsx("dt", { children: key }), _jsx("dd", { children: _jsx("pre", { children: JSON.stringify(value, null, 2) }) })] }, key)))] })] })) : (_jsx(SchemaForm, { collection: collectionConfig, entryId: entryId, data: data, errors: errors, entryIdReadOnly: !view.isNew, disabled: busy, onEntryIdChange: (id) => {
|
|
330
|
+
setEntryId(id);
|
|
331
|
+
setDirty(true);
|
|
332
|
+
}, onChange: updateField })), lastPublish ? (_jsxs("aside", { class: "cr-publish-result", children: [_jsx("h3", { children: "Last publish result" }), _jsxs("p", { children: ["Version ", _jsx("code", { children: lastPublish.version }), " \u00B7 Publication", " ", _jsx("code", { children: lastPublish.publicationId })] }), _jsxs("p", { children: ["Rebuild: ", _jsx("strong", { children: lastPublish.rebuild.state }), lastPublish.rebuild.detail
|
|
333
|
+
? ` — ${lastPublish.rebuild.detail}`
|
|
334
|
+
: ""] })] })) : null] })) : null] })] }));
|
|
335
|
+
}
|
|
336
|
+
function buildWorkingEntries(existing, entryId, data, isNew, previousId) {
|
|
337
|
+
const next = existing
|
|
338
|
+
.filter((entry) => !(isNew ? false : entry.id === previousId))
|
|
339
|
+
.map((entry) => ({ id: entry.id, data: entry.data }));
|
|
340
|
+
const withoutDup = next.filter((entry) => entry.id !== entryId);
|
|
341
|
+
withoutDup.push({ id: entryId, data });
|
|
342
|
+
return withoutDup;
|
|
343
|
+
}
|
|
344
|
+
function hashPayload(data) {
|
|
345
|
+
const json = JSON.stringify(data);
|
|
346
|
+
let hash = 0;
|
|
347
|
+
for (let i = 0; i < json.length; i += 1) {
|
|
348
|
+
hash = (hash * 31 + json.charCodeAt(i)) | 0;
|
|
349
|
+
}
|
|
350
|
+
return `h${Math.abs(hash)}`;
|
|
351
|
+
}
|
|
352
|
+
//# sourceMappingURL=EditorApp.js.map
|