@bygghammar/cms-platform 0.45.0 → 0.46.1
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/CHANGELOG.md +13 -0
- package/package.json +11 -3
- package/scripts/create-cms-site.mjs +153 -0
- package/src/backoffice/DashboardView.tsx +139 -0
- package/src/backoffice/PageBuilderView.tsx +587 -0
- package/src/backoffice/ResetPasswordView.tsx +78 -0
- package/src/backoffice/SectionEditorView.tsx +211 -0
- package/src/backoffice/VersionView.tsx +31 -0
- package/src/backoffice/routes/host.ts +67 -1
- package/src/backoffice/routes/pages.tsx +246 -0
- package/src/config/define-site.ts +102 -0
- package/src/server/schema-drift.ts +108 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page builder screen — host-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Owns the whole builder chrome: rename, public-URL preview, locale switch,
|
|
5
|
+
* save/publish, scheduling, version history, the block canvas slot and the SEO
|
|
6
|
+
* panel slot. A site supplies its server calls, its locale set and its editor
|
|
7
|
+
* components through `deps`; nothing in here is site-specific.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
11
|
+
import {
|
|
12
|
+
ArrowLeft,
|
|
13
|
+
Save,
|
|
14
|
+
Rocket,
|
|
15
|
+
History,
|
|
16
|
+
Clock,
|
|
17
|
+
Eye,
|
|
18
|
+
X,
|
|
19
|
+
ExternalLink,
|
|
20
|
+
Copy,
|
|
21
|
+
Check,
|
|
22
|
+
} from "lucide-react";
|
|
23
|
+
|
|
24
|
+
/** A page row as the builder needs it. */
|
|
25
|
+
export type BuilderPage = { id: string; key: string; name: string; status: string };
|
|
26
|
+
|
|
27
|
+
/** One saved version, with its computed change summary. */
|
|
28
|
+
export type BuilderVersion = {
|
|
29
|
+
id: string;
|
|
30
|
+
version_no: number;
|
|
31
|
+
created_at: string;
|
|
32
|
+
author: string;
|
|
33
|
+
block_count: number;
|
|
34
|
+
note?: string | null;
|
|
35
|
+
summary: string;
|
|
36
|
+
changes: { kind: string; blockType: string; detail?: string | null }[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** One scheduled publication row. */
|
|
40
|
+
export type BuilderPublication = {
|
|
41
|
+
id: string;
|
|
42
|
+
publish_at: string;
|
|
43
|
+
unpublish_at?: string | null;
|
|
44
|
+
note?: string | null;
|
|
45
|
+
is_active: boolean;
|
|
46
|
+
page_versions?: { version_no: number } | null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Everything the builder needs from its host site. */
|
|
50
|
+
export type PageBuilderDeps<TBlock> = {
|
|
51
|
+
pageKey: string;
|
|
52
|
+
page?: BuilderPage;
|
|
53
|
+
blocksFromServer?: TBlock[];
|
|
54
|
+
isLoading: boolean;
|
|
55
|
+
error: Error | null;
|
|
56
|
+
/** Global sections/blocks available in the picker. */
|
|
57
|
+
globalSections: { key: string; name: string; kind: "block" | "section"; blocks: TBlock[] }[];
|
|
58
|
+
/** Resolved global content keyed by global key. */
|
|
59
|
+
globals: Record<string, TBlock[]>;
|
|
60
|
+
|
|
61
|
+
locales: readonly string[];
|
|
62
|
+
localeLabels: Record<string, string>;
|
|
63
|
+
defaultLocale: string;
|
|
64
|
+
/** Public path for this page in the given locale. */
|
|
65
|
+
publicPath: (locale: string) => string;
|
|
66
|
+
/** Host name to build the absolute URL with, for the given locale. */
|
|
67
|
+
publicHost: (locale: string) => string;
|
|
68
|
+
|
|
69
|
+
versions: BuilderVersion[] | undefined;
|
|
70
|
+
publications: BuilderPublication[] | undefined;
|
|
71
|
+
/** Told when the History / Schedule panels open, so the host can enable queries. */
|
|
72
|
+
onPanelToggle?: (panel: "history" | "schedule", open: boolean) => void;
|
|
73
|
+
|
|
74
|
+
save: (input: {
|
|
75
|
+
pageId: string;
|
|
76
|
+
blocks: TBlock[];
|
|
77
|
+
publish: boolean;
|
|
78
|
+
note?: string;
|
|
79
|
+
}) => Promise<{ versionId: string }>;
|
|
80
|
+
/** Persists unsaved shared-content edits; returns the keys it saved. */
|
|
81
|
+
flushSharedDrafts?: () => Promise<string[]>;
|
|
82
|
+
schedule: (input: {
|
|
83
|
+
versionId: string;
|
|
84
|
+
publishAt: string;
|
|
85
|
+
unpublishAt: string | null;
|
|
86
|
+
note?: string;
|
|
87
|
+
}) => Promise<unknown>;
|
|
88
|
+
cancelPublication: (publicationId: string) => Promise<unknown>;
|
|
89
|
+
restoreVersion: (versionId: string) => Promise<unknown>;
|
|
90
|
+
rename: (input: { pageId: string; name: string }) => Promise<unknown>;
|
|
91
|
+
refetchPage: () => void;
|
|
92
|
+
refetchVersions: () => void;
|
|
93
|
+
refetchPublications: () => void;
|
|
94
|
+
|
|
95
|
+
goToPages: () => void;
|
|
96
|
+
/** Opens the draft preview for the given locale. */
|
|
97
|
+
openPreview: (locale: string) => void;
|
|
98
|
+
/** Rendered block canvas. */
|
|
99
|
+
renderEditor: (args: {
|
|
100
|
+
blocks: TBlock[];
|
|
101
|
+
setBlocks: (next: TBlock[]) => void;
|
|
102
|
+
locale: string;
|
|
103
|
+
setLocale: (next: string) => void;
|
|
104
|
+
setDirty: (next: boolean) => void;
|
|
105
|
+
}) => ReactNode;
|
|
106
|
+
/** Page SEO panel for this page. */
|
|
107
|
+
seoPanel?: ReactNode;
|
|
108
|
+
/** Footer hint, e.g. a link to the shared-sections screen. */
|
|
109
|
+
footer?: ReactNode;
|
|
110
|
+
notify: { success: (m: string) => void; error: (m: string) => void };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Host-agnostic page builder. */
|
|
114
|
+
export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock> }) {
|
|
115
|
+
const {
|
|
116
|
+
page,
|
|
117
|
+
blocksFromServer,
|
|
118
|
+
isLoading,
|
|
119
|
+
error,
|
|
120
|
+
locales,
|
|
121
|
+
localeLabels,
|
|
122
|
+
defaultLocale,
|
|
123
|
+
notify,
|
|
124
|
+
} = deps;
|
|
125
|
+
|
|
126
|
+
const [blocks, setBlocks] = useState<TBlock[]>([]);
|
|
127
|
+
const [dirty, setDirty] = useState(false);
|
|
128
|
+
const [busy, setBusy] = useState(false);
|
|
129
|
+
const [showHistory, setShowHistory] = useState(false);
|
|
130
|
+
const [expandedVersion, setExpandedVersion] = useState<string | null>(null);
|
|
131
|
+
const [showSchedule, setShowSchedule] = useState(false);
|
|
132
|
+
const [publishAt, setPublishAt] = useState("");
|
|
133
|
+
const [unpublishAt, setUnpublishAt] = useState("");
|
|
134
|
+
const [scheduleNote, setScheduleNote] = useState("");
|
|
135
|
+
const [renameValue, setRenameValue] = useState<string | null>(null);
|
|
136
|
+
const [locale, setLocale] = useState<string>(defaultLocale);
|
|
137
|
+
const [copied, setCopied] = useState(false);
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (blocksFromServer) {
|
|
141
|
+
setBlocks(blocksFromServer);
|
|
142
|
+
setDirty(false);
|
|
143
|
+
}
|
|
144
|
+
}, [blocksFromServer]);
|
|
145
|
+
|
|
146
|
+
const slugPath = deps.publicPath(locale);
|
|
147
|
+
const host = deps.publicHost(locale);
|
|
148
|
+
const absoluteUrl = host ? `https://${host}${slugPath}` : slugPath;
|
|
149
|
+
|
|
150
|
+
const togglePanel = (panel: "history" | "schedule") => {
|
|
151
|
+
if (panel === "history") {
|
|
152
|
+
const next = !showHistory;
|
|
153
|
+
setShowHistory(next);
|
|
154
|
+
deps.onPanelToggle?.("history", next);
|
|
155
|
+
} else {
|
|
156
|
+
const next = !showSchedule;
|
|
157
|
+
setShowSchedule(next);
|
|
158
|
+
deps.onPanelToggle?.("schedule", next);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const onCopyUrl = async () => {
|
|
163
|
+
try {
|
|
164
|
+
await navigator.clipboard.writeText(absoluteUrl);
|
|
165
|
+
setCopied(true);
|
|
166
|
+
setTimeout(() => setCopied(false), 1500);
|
|
167
|
+
} catch {
|
|
168
|
+
notify.error("Could not copy");
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const onRename = async () => {
|
|
173
|
+
if (!page || !renameValue?.trim()) return;
|
|
174
|
+
try {
|
|
175
|
+
await deps.rename({ pageId: page.id, name: renameValue.trim() });
|
|
176
|
+
notify.success("Page renamed");
|
|
177
|
+
setRenameValue(null);
|
|
178
|
+
deps.refetchPage();
|
|
179
|
+
} catch (e) {
|
|
180
|
+
notify.error(e instanceof Error ? e.message : "Failed");
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const onSave = async (publish: boolean) => {
|
|
185
|
+
if (!page) return;
|
|
186
|
+
setBusy(true);
|
|
187
|
+
try {
|
|
188
|
+
// Shared content-section edits made in the inspector are part of the page
|
|
189
|
+
// from the editor's point of view, so Save/Publish must cover them too.
|
|
190
|
+
await deps.flushSharedDrafts?.();
|
|
191
|
+
await deps.save({ pageId: page.id, blocks, publish });
|
|
192
|
+
notify.success(publish ? "Published" : "Draft saved");
|
|
193
|
+
setDirty(false);
|
|
194
|
+
deps.refetchPage();
|
|
195
|
+
} catch (e) {
|
|
196
|
+
notify.error(e instanceof Error ? e.message : "Failed");
|
|
197
|
+
} finally {
|
|
198
|
+
setBusy(false);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const onSchedule = async () => {
|
|
203
|
+
if (!page) return;
|
|
204
|
+
if (!publishAt) {
|
|
205
|
+
notify.error("Pick a publish date/time");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
setBusy(true);
|
|
209
|
+
try {
|
|
210
|
+
const res = await deps.save({
|
|
211
|
+
pageId: page.id,
|
|
212
|
+
blocks,
|
|
213
|
+
publish: false,
|
|
214
|
+
note: scheduleNote || "Scheduled draft",
|
|
215
|
+
});
|
|
216
|
+
await deps.schedule({
|
|
217
|
+
versionId: res.versionId,
|
|
218
|
+
publishAt: new Date(publishAt).toISOString(),
|
|
219
|
+
unpublishAt: unpublishAt ? new Date(unpublishAt).toISOString() : null,
|
|
220
|
+
note: scheduleNote || undefined,
|
|
221
|
+
});
|
|
222
|
+
notify.success("Scheduled");
|
|
223
|
+
setDirty(false);
|
|
224
|
+
setPublishAt("");
|
|
225
|
+
setUnpublishAt("");
|
|
226
|
+
setScheduleNote("");
|
|
227
|
+
deps.refetchPublications();
|
|
228
|
+
deps.refetchPage();
|
|
229
|
+
} catch (e) {
|
|
230
|
+
notify.error(e instanceof Error ? e.message : "Failed");
|
|
231
|
+
} finally {
|
|
232
|
+
setBusy(false);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const onCancelPub = async (id: string) => {
|
|
237
|
+
if (!confirm("Cancel this scheduled publication?")) return;
|
|
238
|
+
try {
|
|
239
|
+
await deps.cancelPublication(id);
|
|
240
|
+
notify.success("Cancelled");
|
|
241
|
+
deps.refetchPublications();
|
|
242
|
+
} catch (e) {
|
|
243
|
+
notify.error(e instanceof Error ? e.message : "Failed");
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const onRestore = async (versionId: string) => {
|
|
248
|
+
if (!confirm("Restore this version? This will publish it as the current live version.")) return;
|
|
249
|
+
try {
|
|
250
|
+
await deps.restoreVersion(versionId);
|
|
251
|
+
notify.success("Restored");
|
|
252
|
+
setShowHistory(false);
|
|
253
|
+
deps.refetchPage();
|
|
254
|
+
} catch (e) {
|
|
255
|
+
notify.error(e instanceof Error ? e.message : "Failed");
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
if (isLoading) return <div className="p-12 text-ink/60">Loading…</div>;
|
|
260
|
+
if (error) return <div className="p-12 text-red-600">Failed to load page.</div>;
|
|
261
|
+
if (!page) return null;
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div className="p-6 md:p-10 w-full">
|
|
265
|
+
<button
|
|
266
|
+
onClick={deps.goToPages}
|
|
267
|
+
className="inline-flex items-center gap-2 text-sm text-ink/60 hover:text-ink"
|
|
268
|
+
>
|
|
269
|
+
<ArrowLeft className="size-4" /> All pages
|
|
270
|
+
</button>
|
|
271
|
+
|
|
272
|
+
<div className="mt-3 flex flex-wrap items-end justify-between gap-4">
|
|
273
|
+
<div>
|
|
274
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Page builder</div>
|
|
275
|
+
{renameValue === null ? (
|
|
276
|
+
<h1 className="font-serif text-3xl md:text-4xl text-ink mt-1 flex items-center gap-3">
|
|
277
|
+
{page.name}
|
|
278
|
+
<button
|
|
279
|
+
onClick={() => setRenameValue(page.name)}
|
|
280
|
+
className="text-xs font-sans text-ink/50 hover:text-sea underline"
|
|
281
|
+
>
|
|
282
|
+
rename
|
|
283
|
+
</button>
|
|
284
|
+
</h1>
|
|
285
|
+
) : (
|
|
286
|
+
<div className="mt-1 flex items-center gap-2">
|
|
287
|
+
<input
|
|
288
|
+
autoFocus
|
|
289
|
+
value={renameValue}
|
|
290
|
+
onChange={(e) => setRenameValue(e.target.value)}
|
|
291
|
+
onKeyDown={(e) => {
|
|
292
|
+
if (e.key === "Enter") onRename();
|
|
293
|
+
if (e.key === "Escape") setRenameValue(null);
|
|
294
|
+
}}
|
|
295
|
+
className="border border-ink/20 px-3 py-2 text-lg"
|
|
296
|
+
/>
|
|
297
|
+
<button onClick={onRename} className="text-sm text-sea hover:underline">
|
|
298
|
+
Save
|
|
299
|
+
</button>
|
|
300
|
+
<button
|
|
301
|
+
onClick={() => setRenameValue(null)}
|
|
302
|
+
className="text-sm text-ink/50 hover:underline"
|
|
303
|
+
>
|
|
304
|
+
Cancel
|
|
305
|
+
</button>
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
309
|
+
<a
|
|
310
|
+
href={absoluteUrl}
|
|
311
|
+
target="_blank"
|
|
312
|
+
rel="noreferrer"
|
|
313
|
+
title={`Open ${absoluteUrl}`}
|
|
314
|
+
className="inline-flex items-center gap-1.5 border border-ink/15 bg-white px-2.5 py-1 font-mono text-xs text-ink hover:border-sea"
|
|
315
|
+
>
|
|
316
|
+
{slugPath}
|
|
317
|
+
<ExternalLink className="size-3 text-ink/40" />
|
|
318
|
+
</a>
|
|
319
|
+
<button
|
|
320
|
+
onClick={onCopyUrl}
|
|
321
|
+
title="Copy full URL"
|
|
322
|
+
className="inline-flex items-center gap-1.5 border border-ink/15 px-2 py-1 text-xs text-ink/60 hover:border-sea hover:text-ink"
|
|
323
|
+
>
|
|
324
|
+
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
|
325
|
+
{copied ? "Copied" : "Copy URL"}
|
|
326
|
+
</button>
|
|
327
|
+
{locales.length > 1 && (
|
|
328
|
+
<div className="flex items-center gap-1">
|
|
329
|
+
{locales.map((loc) => (
|
|
330
|
+
<button
|
|
331
|
+
key={loc}
|
|
332
|
+
onClick={() => setLocale(loc)}
|
|
333
|
+
className={`px-2 py-1 text-[0.7rem] uppercase tracking-widest border ${
|
|
334
|
+
locale === loc
|
|
335
|
+
? "border-sea bg-ice/50 text-ink"
|
|
336
|
+
: "border-ink/15 text-ink/50 hover:text-ink"
|
|
337
|
+
}`}
|
|
338
|
+
>
|
|
339
|
+
{localeLabels[loc] ?? loc}
|
|
340
|
+
</button>
|
|
341
|
+
))}
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
</div>
|
|
345
|
+
<div className="text-xs text-ink/50 font-mono mt-2">
|
|
346
|
+
{page.key} · {page.status}
|
|
347
|
+
{dirty && " · unsaved changes"}
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
351
|
+
<button
|
|
352
|
+
onClick={() => deps.openPreview(locale)}
|
|
353
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-4 py-2 text-sm hover:border-sea"
|
|
354
|
+
>
|
|
355
|
+
<Eye className="size-4" /> Preview
|
|
356
|
+
</button>
|
|
357
|
+
<button
|
|
358
|
+
onClick={() => togglePanel("schedule")}
|
|
359
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-4 py-2 text-sm hover:border-sea"
|
|
360
|
+
>
|
|
361
|
+
<Clock className="size-4" /> Schedule
|
|
362
|
+
</button>
|
|
363
|
+
<button
|
|
364
|
+
onClick={() => togglePanel("history")}
|
|
365
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-4 py-2 text-sm hover:border-sea"
|
|
366
|
+
>
|
|
367
|
+
<History className="size-4" /> History
|
|
368
|
+
</button>
|
|
369
|
+
<button
|
|
370
|
+
disabled={busy || !dirty}
|
|
371
|
+
onClick={() => onSave(false)}
|
|
372
|
+
className="inline-flex items-center gap-2 border border-ink/30 px-4 py-2 text-sm text-ink hover:border-sea disabled:opacity-40"
|
|
373
|
+
>
|
|
374
|
+
<Save className="size-4" /> Save draft
|
|
375
|
+
</button>
|
|
376
|
+
<button
|
|
377
|
+
disabled={busy}
|
|
378
|
+
onClick={() => onSave(true)}
|
|
379
|
+
className="inline-flex items-center gap-2 bg-ink text-white px-4 py-2 text-sm disabled:opacity-40"
|
|
380
|
+
>
|
|
381
|
+
<Rocket className="size-4" /> Publish now
|
|
382
|
+
</button>
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
|
|
386
|
+
{showSchedule && (
|
|
387
|
+
<div className="mt-6 border border-ink/10 bg-white p-4">
|
|
388
|
+
<div className="flex items-center justify-between mb-3">
|
|
389
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
|
|
390
|
+
Schedule a publication
|
|
391
|
+
</div>
|
|
392
|
+
<button onClick={() => togglePanel("schedule")} className="text-ink/40 hover:text-ink">
|
|
393
|
+
<X className="size-4" />
|
|
394
|
+
</button>
|
|
395
|
+
</div>
|
|
396
|
+
<div className="grid gap-3 md:grid-cols-4">
|
|
397
|
+
<label className="block md:col-span-1">
|
|
398
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">
|
|
399
|
+
Publish at
|
|
400
|
+
</span>
|
|
401
|
+
<input
|
|
402
|
+
type="datetime-local"
|
|
403
|
+
value={publishAt}
|
|
404
|
+
onChange={(e) => setPublishAt(e.target.value)}
|
|
405
|
+
className="w-full border border-ink/20 px-2 py-1.5 text-sm"
|
|
406
|
+
/>
|
|
407
|
+
</label>
|
|
408
|
+
<label className="block md:col-span-1">
|
|
409
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">
|
|
410
|
+
Unpublish at (optional)
|
|
411
|
+
</span>
|
|
412
|
+
<input
|
|
413
|
+
type="datetime-local"
|
|
414
|
+
value={unpublishAt}
|
|
415
|
+
onChange={(e) => setUnpublishAt(e.target.value)}
|
|
416
|
+
className="w-full border border-ink/20 px-2 py-1.5 text-sm"
|
|
417
|
+
/>
|
|
418
|
+
</label>
|
|
419
|
+
<label className="block md:col-span-1">
|
|
420
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">Note</span>
|
|
421
|
+
<input
|
|
422
|
+
value={scheduleNote}
|
|
423
|
+
onChange={(e) => setScheduleNote(e.target.value)}
|
|
424
|
+
className="w-full border border-ink/20 px-2 py-1.5 text-sm"
|
|
425
|
+
placeholder="Winter campaign"
|
|
426
|
+
/>
|
|
427
|
+
</label>
|
|
428
|
+
<div className="flex items-end">
|
|
429
|
+
<button
|
|
430
|
+
disabled={busy || !publishAt}
|
|
431
|
+
onClick={onSchedule}
|
|
432
|
+
className="w-full inline-flex items-center justify-center gap-2 bg-sea text-ink px-4 py-2 text-sm disabled:opacity-40 border border-ink/10"
|
|
433
|
+
>
|
|
434
|
+
<Clock className="size-4" /> Schedule current draft
|
|
435
|
+
</button>
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
<p className="mt-3 text-xs text-ink/60">
|
|
439
|
+
The current canvas is saved as a new draft version and scheduled to become live at that
|
|
440
|
+
time. The site auto-selects the newest active publication whose window is open.
|
|
441
|
+
</p>
|
|
442
|
+
|
|
443
|
+
<div className="mt-6">
|
|
444
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60 mb-2">
|
|
445
|
+
Publications
|
|
446
|
+
</div>
|
|
447
|
+
<div className="space-y-2">
|
|
448
|
+
{deps.publications?.map((p) => {
|
|
449
|
+
const now = Date.now();
|
|
450
|
+
const start = new Date(p.publish_at).getTime();
|
|
451
|
+
const end = p.unpublish_at ? new Date(p.unpublish_at).getTime() : null;
|
|
452
|
+
const status = !p.is_active
|
|
453
|
+
? "cancelled"
|
|
454
|
+
: start > now
|
|
455
|
+
? "scheduled"
|
|
456
|
+
: end && end <= now
|
|
457
|
+
? "expired"
|
|
458
|
+
: "active";
|
|
459
|
+
const badge = {
|
|
460
|
+
scheduled: "bg-blue-100 text-blue-800",
|
|
461
|
+
active: "bg-green-100 text-green-800",
|
|
462
|
+
expired: "bg-ink/10 text-ink/60",
|
|
463
|
+
cancelled: "bg-red-100 text-red-800",
|
|
464
|
+
}[status];
|
|
465
|
+
return (
|
|
466
|
+
<div
|
|
467
|
+
key={p.id}
|
|
468
|
+
className="flex items-center justify-between border border-ink/10 px-3 py-2 text-sm"
|
|
469
|
+
>
|
|
470
|
+
<div>
|
|
471
|
+
<span
|
|
472
|
+
className={`inline-block px-2 py-0.5 text-[0.65rem] uppercase tracking-wider ${badge}`}
|
|
473
|
+
>
|
|
474
|
+
{status}
|
|
475
|
+
</span>
|
|
476
|
+
<span className="ml-3 font-mono text-ink">
|
|
477
|
+
v{p.page_versions?.version_no ?? "?"}
|
|
478
|
+
</span>
|
|
479
|
+
<span className="ml-3 text-ink/70">
|
|
480
|
+
{new Date(p.publish_at).toLocaleString()}
|
|
481
|
+
</span>
|
|
482
|
+
{p.unpublish_at && (
|
|
483
|
+
<span className="ml-2 text-ink/50">
|
|
484
|
+
→ {new Date(p.unpublish_at).toLocaleString()}
|
|
485
|
+
</span>
|
|
486
|
+
)}
|
|
487
|
+
{p.note && <span className="ml-3 text-ink/60 italic">{p.note}</span>}
|
|
488
|
+
</div>
|
|
489
|
+
{p.is_active && status !== "expired" && (
|
|
490
|
+
<button
|
|
491
|
+
className="text-red-600 hover:underline"
|
|
492
|
+
onClick={() => onCancelPub(p.id)}
|
|
493
|
+
>
|
|
494
|
+
Cancel
|
|
495
|
+
</button>
|
|
496
|
+
)}
|
|
497
|
+
</div>
|
|
498
|
+
);
|
|
499
|
+
})}
|
|
500
|
+
{deps.publications?.length === 0 && (
|
|
501
|
+
<p className="text-sm text-ink/50">No publications yet.</p>
|
|
502
|
+
)}
|
|
503
|
+
</div>
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
)}
|
|
507
|
+
|
|
508
|
+
{showHistory && (
|
|
509
|
+
<div className="mt-6 border border-ink/10 bg-white p-4">
|
|
510
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60 mb-3">
|
|
511
|
+
Version history
|
|
512
|
+
</div>
|
|
513
|
+
<div className="space-y-2">
|
|
514
|
+
{deps.versions?.map((v) => {
|
|
515
|
+
const open = expandedVersion === v.id;
|
|
516
|
+
return (
|
|
517
|
+
<div key={v.id} className="border border-ink/10 px-3 py-2 text-sm">
|
|
518
|
+
<div className="flex items-start justify-between gap-4">
|
|
519
|
+
<div className="min-w-0">
|
|
520
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
521
|
+
<span className="font-mono text-ink">v{v.version_no}</span>
|
|
522
|
+
<span className="text-ink/50">
|
|
523
|
+
{new Date(v.created_at).toLocaleString()}
|
|
524
|
+
</span>
|
|
525
|
+
<span className="text-ink/70">by {v.author}</span>
|
|
526
|
+
<span className="text-ink/50">{v.block_count} blocks</span>
|
|
527
|
+
{v.note && <span className="text-ink/60 italic">{v.note}</span>}
|
|
528
|
+
</div>
|
|
529
|
+
<div className="mt-1 text-xs text-ink/60">{v.summary}</div>
|
|
530
|
+
</div>
|
|
531
|
+
<div className="flex shrink-0 items-center gap-3">
|
|
532
|
+
{v.changes.length > 0 && (
|
|
533
|
+
<button
|
|
534
|
+
className="text-ink/60 hover:underline"
|
|
535
|
+
onClick={() => setExpandedVersion(open ? null : v.id)}
|
|
536
|
+
>
|
|
537
|
+
{open ? "Hide changes" : "View changes"}
|
|
538
|
+
</button>
|
|
539
|
+
)}
|
|
540
|
+
<button className="text-sea hover:underline" onClick={() => onRestore(v.id)}>
|
|
541
|
+
Restore
|
|
542
|
+
</button>
|
|
543
|
+
</div>
|
|
544
|
+
</div>
|
|
545
|
+
{open && (
|
|
546
|
+
<ul className="mt-2 space-y-1 border-t border-ink/10 pt-2 text-xs text-ink/70">
|
|
547
|
+
{v.changes.map((c, i) => (
|
|
548
|
+
<li key={i}>
|
|
549
|
+
<span className="font-mono uppercase tracking-wider text-ink/50">
|
|
550
|
+
{c.kind}
|
|
551
|
+
</span>{" "}
|
|
552
|
+
<span className="text-ink">{c.blockType}</span>
|
|
553
|
+
{c.detail ? <span className="text-ink/50"> — {c.detail}</span> : null}
|
|
554
|
+
</li>
|
|
555
|
+
))}
|
|
556
|
+
</ul>
|
|
557
|
+
)}
|
|
558
|
+
</div>
|
|
559
|
+
);
|
|
560
|
+
})}
|
|
561
|
+
{deps.versions?.length === 0 && <p className="text-sm text-ink/50">No versions yet.</p>}
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
564
|
+
)}
|
|
565
|
+
|
|
566
|
+
<div className="mt-6">
|
|
567
|
+
{deps.renderEditor({ blocks, setBlocks, locale, setLocale, setDirty })}
|
|
568
|
+
</div>
|
|
569
|
+
|
|
570
|
+
{deps.seoPanel && (
|
|
571
|
+
<div className="mt-12 border-t border-ink/10 pt-8">
|
|
572
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
|
|
573
|
+
Page SEO & URL
|
|
574
|
+
</div>
|
|
575
|
+
<h2 className="mt-2 font-serif text-2xl text-ink">{page.name}</h2>
|
|
576
|
+
<p className="mt-2 text-sm text-ink/60 max-w-xl">
|
|
577
|
+
Slug, title, meta description and social preview for this page. Saved per language and
|
|
578
|
+
used by canonical, hreflang and Open Graph.
|
|
579
|
+
</p>
|
|
580
|
+
<div className="mt-6">{deps.seoPanel}</div>
|
|
581
|
+
</div>
|
|
582
|
+
)}
|
|
583
|
+
|
|
584
|
+
{deps.footer && <div className="mt-6 text-xs text-ink/50">{deps.footer}</div>}
|
|
585
|
+
</div>
|
|
586
|
+
);
|
|
587
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password-reset screen — host-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Waits for the recovery session the auth provider establishes from the emailed
|
|
5
|
+
* link, then sets a new password. The site supplies both calls.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useState } from "react";
|
|
9
|
+
|
|
10
|
+
/** Everything the reset screen needs from its host. */
|
|
11
|
+
export type ResetPasswordDeps = {
|
|
12
|
+
/** Subscribes to recovery/sign-in; call `ready()` once a session exists. */
|
|
13
|
+
onRecoverySession: (ready: () => void) => () => void;
|
|
14
|
+
updatePassword: (password: string) => Promise<void>;
|
|
15
|
+
onDone: () => void;
|
|
16
|
+
notify: { success: (m: string) => void; error: (m: string) => void };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Host-agnostic password-reset screen. */
|
|
20
|
+
export function ResetPasswordView({ deps }: { deps: ResetPasswordDeps }) {
|
|
21
|
+
const [ready, setReady] = useState(false);
|
|
22
|
+
const [password, setPassword] = useState("");
|
|
23
|
+
const [busy, setBusy] = useState(false);
|
|
24
|
+
|
|
25
|
+
useEffect(() => deps.onRecoverySession(() => setReady(true)), [deps]);
|
|
26
|
+
|
|
27
|
+
const onSubmit = async (e: React.FormEvent) => {
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
setBusy(true);
|
|
30
|
+
try {
|
|
31
|
+
await deps.updatePassword(password);
|
|
32
|
+
deps.notify.success("Password updated");
|
|
33
|
+
deps.onDone();
|
|
34
|
+
} catch (err) {
|
|
35
|
+
deps.notify.error(err instanceof Error ? err.message : "Could not update");
|
|
36
|
+
} finally {
|
|
37
|
+
setBusy(false);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<main className="min-h-screen bg-ink text-paper grid place-items-center px-6 py-16">
|
|
43
|
+
<div className="w-full max-w-sm">
|
|
44
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-paper/60">Backoffice</div>
|
|
45
|
+
<h1 className="mt-3 font-serif text-3xl">New password</h1>
|
|
46
|
+
{!ready ? (
|
|
47
|
+
<p className="mt-6 text-sm text-paper/60">
|
|
48
|
+
Waiting for the reset link… open the link from the email in the same browser.
|
|
49
|
+
</p>
|
|
50
|
+
) : (
|
|
51
|
+
<form onSubmit={onSubmit} className="mt-8 space-y-4">
|
|
52
|
+
<label className="block">
|
|
53
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-paper/60">
|
|
54
|
+
New password
|
|
55
|
+
</span>
|
|
56
|
+
<input
|
|
57
|
+
type="password"
|
|
58
|
+
minLength={8}
|
|
59
|
+
required
|
|
60
|
+
value={password}
|
|
61
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
62
|
+
className="mt-2 w-full bg-transparent border border-paper/25 px-4 py-3 text-paper focus:border-sea focus:outline-none"
|
|
63
|
+
autoComplete="new-password"
|
|
64
|
+
/>
|
|
65
|
+
</label>
|
|
66
|
+
<button
|
|
67
|
+
type="submit"
|
|
68
|
+
disabled={busy}
|
|
69
|
+
className="w-full inline-flex items-center justify-center border border-paper bg-paper text-ink px-6 py-3 text-xs uppercase tracking-[0.22em] hover:bg-transparent hover:text-paper transition-colors disabled:opacity-60"
|
|
70
|
+
>
|
|
71
|
+
{busy ? "Saving…" : "Update password"}
|
|
72
|
+
</button>
|
|
73
|
+
</form>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
</main>
|
|
77
|
+
);
|
|
78
|
+
}
|