@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.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/CHANGELOG.md +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- package/src/ui/MobileCarousel.tsx +289 -0
|
@@ -1,13 +1,59 @@
|
|
|
1
1
|
import { useState, type ReactNode } from "react";
|
|
2
2
|
import { toast } from "sonner";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
Plus,
|
|
5
|
+
Trash2,
|
|
6
|
+
Pencil,
|
|
7
|
+
Globe as GlobeIcon,
|
|
8
|
+
ExternalLink,
|
|
9
|
+
Undo2,
|
|
10
|
+
Copy,
|
|
11
|
+
Eye,
|
|
12
|
+
EyeOff,
|
|
13
|
+
} from "lucide-react";
|
|
14
|
+
import { ConfirmDialog } from "./media/ConfirmDialog";
|
|
15
|
+
import { PageStatusBadge, PublicationBadge } from "./StatusBadge";
|
|
16
|
+
import {
|
|
17
|
+
duplicateNameFor,
|
|
18
|
+
isValidPageKey,
|
|
19
|
+
normalizePageStatus,
|
|
20
|
+
slugifyPageKey,
|
|
21
|
+
statusChangeConfirmation,
|
|
22
|
+
uniquePageKey,
|
|
23
|
+
type PageStatus,
|
|
24
|
+
} from "../cms/page-status";
|
|
25
|
+
import {
|
|
26
|
+
isPubliclyVisible,
|
|
27
|
+
type PublicationState,
|
|
28
|
+
type PublicationTransitionKind,
|
|
29
|
+
} from "../cms/page-publication";
|
|
30
|
+
import { usePublicationBoundaryRefresh } from "../cms/use-publication-boundary";
|
|
4
31
|
|
|
5
32
|
/** A page row as listed by the host. */
|
|
6
33
|
export type PageListRow = {
|
|
7
34
|
id: string;
|
|
8
35
|
key: string;
|
|
9
36
|
name: string;
|
|
37
|
+
/**
|
|
38
|
+
* The editorial status an editor last set. Shown, but never used to decide
|
|
39
|
+
* what the screen says about visitors or which action to offer — see
|
|
40
|
+
* `publication` for that.
|
|
41
|
+
*/
|
|
10
42
|
status: string;
|
|
43
|
+
/**
|
|
44
|
+
* What a visitor actually gets, derived from the page's active publication
|
|
45
|
+
* rows by `listPagesCore`. A draft inside an open window is public; a
|
|
46
|
+
* published page past its end date is not.
|
|
47
|
+
*/
|
|
48
|
+
publication: PublicationState;
|
|
49
|
+
/** When `publication` changes next, ISO-8601, or null when nothing is due. */
|
|
50
|
+
publication_at?: string | null;
|
|
51
|
+
/**
|
|
52
|
+
* What happens then: a first publish, one version replacing another, or the
|
|
53
|
+
* page going away. A live page with an update queued behind it is still live,
|
|
54
|
+
* and a list that shows it as merely "Scheduled" is wrong about today.
|
|
55
|
+
*/
|
|
56
|
+
publication_transition?: PublicationTransitionKind | null;
|
|
11
57
|
updated_at: string;
|
|
12
58
|
is_system?: boolean | null;
|
|
13
59
|
};
|
|
@@ -35,6 +81,17 @@ export type PagesListDeps = {
|
|
|
35
81
|
hrefFor: (key: string) => string;
|
|
36
82
|
createPage: (input: { key: string; name: string }) => Promise<unknown>;
|
|
37
83
|
renamePage: (input: { pageId: string; name: string }) => Promise<unknown>;
|
|
84
|
+
/**
|
|
85
|
+
* Publish or unpublish, always behind a confirmation. Optional: a host that
|
|
86
|
+
* has not wired it up simply does not show the action.
|
|
87
|
+
*/
|
|
88
|
+
setPageStatus?: (input: { pageId: string; status: PageStatus }) => Promise<unknown>;
|
|
89
|
+
/** Copy a page into a new draft. Optional for the same reason. */
|
|
90
|
+
duplicatePage?: (input: {
|
|
91
|
+
pageId: string;
|
|
92
|
+
name: string;
|
|
93
|
+
key?: string;
|
|
94
|
+
}) => Promise<{ id: string; key: string; name: string }>;
|
|
38
95
|
deletePage: (pageId: string) => Promise<unknown>;
|
|
39
96
|
restorePage: (pageId: string) => Promise<unknown>;
|
|
40
97
|
purgePage: (pageId: string) => Promise<unknown>;
|
|
@@ -56,6 +113,8 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
56
113
|
hrefFor,
|
|
57
114
|
createPage,
|
|
58
115
|
renamePage,
|
|
116
|
+
setPageStatus,
|
|
117
|
+
duplicatePage,
|
|
59
118
|
deletePage,
|
|
60
119
|
restorePage,
|
|
61
120
|
purgePage,
|
|
@@ -69,9 +128,101 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
69
128
|
const [newName, setNewName] = useState("");
|
|
70
129
|
const [busy, setBusy] = useState(false);
|
|
71
130
|
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null);
|
|
131
|
+
const [statusChange, setStatusChange] = useState<{
|
|
132
|
+
page: PageListRow;
|
|
133
|
+
next: PageStatus;
|
|
134
|
+
} | null>(null);
|
|
135
|
+
const [duplicating, setDuplicating] = useState<{
|
|
136
|
+
page: PageListRow;
|
|
137
|
+
name: string;
|
|
138
|
+
key: string;
|
|
139
|
+
keyTouched: boolean;
|
|
140
|
+
} | null>(null);
|
|
72
141
|
|
|
73
142
|
const fail = (e: unknown) => toast.error(e instanceof Error ? e.message : "Failed");
|
|
74
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Re-read the list when the soonest scheduled transition falls due.
|
|
146
|
+
*
|
|
147
|
+
* This screen is a dashboard; it is left open. One timer for the earliest
|
|
148
|
+
* boundary is enough, because reaching it refetches every row and the next
|
|
149
|
+
* earliest becomes the new deadline. The waiting itself — including a
|
|
150
|
+
* boundary whose fresh data has not arrived yet — belongs to the hook.
|
|
151
|
+
*/
|
|
152
|
+
const nextBoundary = (pages ?? []).reduce<number | null>((soonest, page) => {
|
|
153
|
+
const at = page.publication_at ? Date.parse(page.publication_at) : NaN;
|
|
154
|
+
if (Number.isNaN(at)) return soonest;
|
|
155
|
+
return soonest === null || at < soonest ? at : soonest;
|
|
156
|
+
}, null);
|
|
157
|
+
usePublicationBoundaryRefresh(nextBoundary, refetchPages);
|
|
158
|
+
|
|
159
|
+
const takenKeys = (pages ?? []).map((p) => p.key);
|
|
160
|
+
const duplicateKey = duplicating
|
|
161
|
+
? duplicating.keyTouched
|
|
162
|
+
? slugifyPageKey(duplicating.key)
|
|
163
|
+
: uniquePageKey(duplicating.name || duplicating.page.name, takenKeys)
|
|
164
|
+
: "";
|
|
165
|
+
/*
|
|
166
|
+
* The two fields are validated separately so the message can be attached to
|
|
167
|
+
* the field it is actually about: a screen reader hears the problem on the
|
|
168
|
+
* control that has to change, not on whichever one comes first in the DOM.
|
|
169
|
+
*/
|
|
170
|
+
const duplicateNameProblem =
|
|
171
|
+
duplicating && !duplicating.name.trim() ? "A name is required." : null;
|
|
172
|
+
const duplicateKeyProblem =
|
|
173
|
+
!duplicating || duplicateNameProblem
|
|
174
|
+
? null
|
|
175
|
+
: !duplicateKey || !isValidPageKey(duplicateKey)
|
|
176
|
+
? "The address may only contain lowercase letters, numbers, dashes and underscores."
|
|
177
|
+
: takenKeys.includes(duplicateKey)
|
|
178
|
+
? `Another page already uses the address "${duplicateKey}".`
|
|
179
|
+
: null;
|
|
180
|
+
const duplicateProblem = duplicateNameProblem ?? duplicateKeyProblem;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Publishing and unpublishing are never one click: both directions open the
|
|
184
|
+
* dialog below and are only applied once it is confirmed.
|
|
185
|
+
*/
|
|
186
|
+
const onConfirmStatus = async () => {
|
|
187
|
+
if (!statusChange || !setPageStatus) return;
|
|
188
|
+
setBusy(true);
|
|
189
|
+
try {
|
|
190
|
+
await setPageStatus({ pageId: statusChange.page.id, status: statusChange.next });
|
|
191
|
+
toast.success(
|
|
192
|
+
statusChange.next === "published"
|
|
193
|
+
? `"${statusChange.page.name}" is now visible on the public website`
|
|
194
|
+
: `"${statusChange.page.name}" is no longer visible on the public website`,
|
|
195
|
+
);
|
|
196
|
+
setStatusChange(null);
|
|
197
|
+
refetchPages();
|
|
198
|
+
} catch (e) {
|
|
199
|
+
fail(e);
|
|
200
|
+
} finally {
|
|
201
|
+
setBusy(false);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const onConfirmDuplicate = async () => {
|
|
206
|
+
if (!duplicating || !duplicatePage || duplicateProblem) return;
|
|
207
|
+
setBusy(true);
|
|
208
|
+
try {
|
|
209
|
+
const copy = await duplicatePage({
|
|
210
|
+
pageId: duplicating.page.id,
|
|
211
|
+
name: duplicating.name.trim(),
|
|
212
|
+
key: duplicateKey,
|
|
213
|
+
});
|
|
214
|
+
setDuplicating(null);
|
|
215
|
+
refetchPages();
|
|
216
|
+
toast.success(`Copied as the draft "${copy?.name ?? duplicating.name}"`, {
|
|
217
|
+
action: { label: "Edit copy", onClick: () => openEditor(copy?.key ?? duplicateKey) },
|
|
218
|
+
});
|
|
219
|
+
} catch (e) {
|
|
220
|
+
fail(e);
|
|
221
|
+
} finally {
|
|
222
|
+
setBusy(false);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
75
226
|
const onRename = async () => {
|
|
76
227
|
if (!renaming || !renaming.name.trim()) return;
|
|
77
228
|
try {
|
|
@@ -166,8 +317,10 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
166
317
|
</div>
|
|
167
318
|
|
|
168
319
|
<p className="mt-4 max-w-2xl text-sm text-ink/60">
|
|
169
|
-
Compose each page from blocks and global blocks. Every save creates a new version
|
|
170
|
-
|
|
320
|
+
Compose each page from blocks and global blocks. Every save creates a new version. The
|
|
321
|
+
status column is what a visitor gets right now: a page can also be scheduled to appear or
|
|
322
|
+
disappear on its own, so it does not always match the editorial status underneath. Anything
|
|
323
|
+
that is not public stays editable and previewable by signed-in staff.
|
|
171
324
|
</p>
|
|
172
325
|
|
|
173
326
|
<div className="mt-8 border border-ink/10 bg-white">
|
|
@@ -177,9 +330,9 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
177
330
|
<th className="px-4 py-3">Name</th>
|
|
178
331
|
<th className="px-4 py-3">Key</th>
|
|
179
332
|
<th className="px-4 py-3">URL</th>
|
|
180
|
-
<th className="px-4 py-3">
|
|
333
|
+
<th className="px-4 py-3">Public status</th>
|
|
181
334
|
<th className="px-4 py-3">Updated</th>
|
|
182
|
-
<th className="px-4 py-3 w-
|
|
335
|
+
<th className="px-4 py-3 w-64 text-right">Actions</th>
|
|
183
336
|
</tr>
|
|
184
337
|
</thead>
|
|
185
338
|
<tbody>
|
|
@@ -236,23 +389,66 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
236
389
|
<ExternalLink className="size-3 text-ink/40" />
|
|
237
390
|
</a>
|
|
238
391
|
</td>
|
|
239
|
-
<td className="px-4 py-3
|
|
392
|
+
<td className="px-4 py-3">
|
|
393
|
+
<div className="flex flex-col items-start gap-1">
|
|
394
|
+
<PublicationBadge
|
|
395
|
+
state={p.publication}
|
|
396
|
+
at={p.publication_at}
|
|
397
|
+
transition={p.publication_transition}
|
|
398
|
+
/>
|
|
399
|
+
<PageStatusBadge status={normalizePageStatus(p.status)} />
|
|
400
|
+
</div>
|
|
401
|
+
</td>
|
|
240
402
|
<td className="px-4 py-3 text-ink/50">{new Date(p.updated_at).toLocaleString()}</td>
|
|
241
|
-
<td className="px-4 py-3
|
|
242
|
-
<
|
|
243
|
-
onClick={() => openEditor(p.key)}
|
|
244
|
-
className="inline-flex items-center gap-1 text-sea hover:underline mr-3"
|
|
245
|
-
>
|
|
246
|
-
<Pencil className="size-3.5" /> Edit
|
|
247
|
-
</button>
|
|
248
|
-
{!p.is_system && (
|
|
403
|
+
<td className="px-4 py-3">
|
|
404
|
+
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
|
249
405
|
<button
|
|
250
|
-
onClick={() =>
|
|
251
|
-
className="inline-flex items-center gap-1 text-
|
|
406
|
+
onClick={() => openEditor(p.key)}
|
|
407
|
+
className="inline-flex items-center gap-1 text-sea hover:underline"
|
|
252
408
|
>
|
|
253
|
-
<
|
|
409
|
+
<Pencil className="size-3.5" /> Edit
|
|
254
410
|
</button>
|
|
255
|
-
|
|
411
|
+
{setPageStatus ? (
|
|
412
|
+
isPubliclyVisible(p.publication) ? (
|
|
413
|
+
<button
|
|
414
|
+
onClick={() => setStatusChange({ page: p, next: "draft" })}
|
|
415
|
+
className="inline-flex items-center gap-1 text-ink/60 hover:text-ink hover:underline"
|
|
416
|
+
>
|
|
417
|
+
<EyeOff className="size-3.5" /> Unpublish
|
|
418
|
+
</button>
|
|
419
|
+
) : (
|
|
420
|
+
<button
|
|
421
|
+
onClick={() => setStatusChange({ page: p, next: "published" })}
|
|
422
|
+
className="inline-flex items-center gap-1 text-ink/60 hover:text-ink hover:underline"
|
|
423
|
+
>
|
|
424
|
+
<Eye className="size-3.5" /> Publish
|
|
425
|
+
</button>
|
|
426
|
+
)
|
|
427
|
+
) : null}
|
|
428
|
+
{duplicatePage ? (
|
|
429
|
+
<button
|
|
430
|
+
onClick={() =>
|
|
431
|
+
setDuplicating({
|
|
432
|
+
page: p,
|
|
433
|
+
name: duplicateNameFor(p.name),
|
|
434
|
+
key: "",
|
|
435
|
+
keyTouched: false,
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
className="inline-flex items-center gap-1 text-ink/60 hover:text-ink hover:underline"
|
|
439
|
+
>
|
|
440
|
+
<Copy className="size-3.5" /> Duplicate
|
|
441
|
+
</button>
|
|
442
|
+
) : null}
|
|
443
|
+
{!p.is_system && (
|
|
444
|
+
<button
|
|
445
|
+
onClick={() => onDelete(p.id, p.name)}
|
|
446
|
+
className="inline-flex items-center gap-1 text-red-600 hover:underline"
|
|
447
|
+
>
|
|
448
|
+
<Trash2 className="size-3.5" /> Delete
|
|
449
|
+
</button>
|
|
450
|
+
)}
|
|
451
|
+
</div>
|
|
256
452
|
</td>
|
|
257
453
|
</tr>
|
|
258
454
|
))}
|
|
@@ -333,7 +529,10 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
333
529
|
|
|
334
530
|
<div className="mt-10 border border-ink/10 bg-white p-6">
|
|
335
531
|
<div className="font-serif text-lg text-ink">New page</div>
|
|
336
|
-
<p className="text-sm text-ink/60 mt-1">
|
|
532
|
+
<p className="text-sm text-ink/60 mt-1">
|
|
533
|
+
Create a blank page — you can add blocks after. A new page starts as a draft and is not
|
|
534
|
+
visible on the public website until you publish it.
|
|
535
|
+
</p>
|
|
337
536
|
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
|
338
537
|
<label className="block">
|
|
339
538
|
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">Name</span>
|
|
@@ -364,6 +563,82 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
364
563
|
</button>
|
|
365
564
|
</div>
|
|
366
565
|
</div>
|
|
566
|
+
|
|
567
|
+
{statusChange ? (
|
|
568
|
+
<ConfirmDialog
|
|
569
|
+
title={statusChange.next === "published" ? "Publish page" : "Unpublish page"}
|
|
570
|
+
description={statusChangeConfirmation(
|
|
571
|
+
statusChange.next,
|
|
572
|
+
statusChange.page.publication,
|
|
573
|
+
statusChange.page.publication_transition,
|
|
574
|
+
)}
|
|
575
|
+
details={[`${statusChange.page.name} — ${pathFor(statusChange.page.key)}`]}
|
|
576
|
+
confirmLabel={statusChange.next === "published" ? "Publish" : "Unpublish"}
|
|
577
|
+
danger={statusChange.next !== "published"}
|
|
578
|
+
busy={busy}
|
|
579
|
+
onConfirm={onConfirmStatus}
|
|
580
|
+
onCancel={() => setStatusChange(null)}
|
|
581
|
+
/>
|
|
582
|
+
) : null}
|
|
583
|
+
|
|
584
|
+
{duplicating ? (
|
|
585
|
+
<ConfirmDialog
|
|
586
|
+
title="Duplicate page"
|
|
587
|
+
description={`A copy of "${duplicating.page.name}" is created as a new draft. The original is not changed.`}
|
|
588
|
+
confirmLabel="Create copy"
|
|
589
|
+
busy={busy}
|
|
590
|
+
onConfirm={onConfirmDuplicate}
|
|
591
|
+
onCancel={() => setDuplicating(null)}
|
|
592
|
+
>
|
|
593
|
+
<div className="space-y-3">
|
|
594
|
+
<label className="block">
|
|
595
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">
|
|
596
|
+
Name of the copy
|
|
597
|
+
</span>
|
|
598
|
+
<input
|
|
599
|
+
autoFocus
|
|
600
|
+
value={duplicating.name}
|
|
601
|
+
onChange={(e) => setDuplicating({ ...duplicating, name: e.target.value })}
|
|
602
|
+
aria-invalid={duplicateNameProblem ? true : undefined}
|
|
603
|
+
aria-describedby={duplicateNameProblem ? "duplicate-page-problem" : undefined}
|
|
604
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm"
|
|
605
|
+
/>
|
|
606
|
+
</label>
|
|
607
|
+
<div>
|
|
608
|
+
<label className="block">
|
|
609
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">
|
|
610
|
+
Address
|
|
611
|
+
</span>
|
|
612
|
+
<input
|
|
613
|
+
value={duplicateKey}
|
|
614
|
+
onChange={(e) =>
|
|
615
|
+
setDuplicating({ ...duplicating, key: e.target.value, keyTouched: true })
|
|
616
|
+
}
|
|
617
|
+
aria-invalid={duplicateKeyProblem ? true : undefined}
|
|
618
|
+
aria-describedby={`duplicate-page-hint${
|
|
619
|
+
duplicateKeyProblem ? " duplicate-page-problem" : ""
|
|
620
|
+
}`}
|
|
621
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm font-mono"
|
|
622
|
+
/>
|
|
623
|
+
</label>
|
|
624
|
+
{/* Outside the label: inside it, the hint would be read out as
|
|
625
|
+
part of the field's name every time the field is reached. */}
|
|
626
|
+
<span id="duplicate-page-hint" className="mt-1 block text-[0.7rem] text-ink/50">
|
|
627
|
+
Suggested from the name, and made unique automatically.
|
|
628
|
+
</span>
|
|
629
|
+
</div>
|
|
630
|
+
{duplicateProblem ? (
|
|
631
|
+
<p id="duplicate-page-problem" className="text-xs text-red-600">
|
|
632
|
+
{duplicateProblem}
|
|
633
|
+
</p>
|
|
634
|
+
) : (
|
|
635
|
+
<p className="text-xs text-ink/50">
|
|
636
|
+
The copy is a draft, so nothing appears on the public website until you publish it.
|
|
637
|
+
</p>
|
|
638
|
+
)}
|
|
639
|
+
</div>
|
|
640
|
+
</ConfirmDialog>
|
|
641
|
+
) : null}
|
|
367
642
|
</div>
|
|
368
643
|
);
|
|
369
644
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one editor for scheduled visibility.
|
|
3
|
+
*
|
|
4
|
+
* Menu items, footer columns and footer links all use this control, so the
|
|
5
|
+
* vocabulary, the timezone handling and the validation are identical wherever
|
|
6
|
+
* scheduling appears. Editors type wall-clock time in the site's timezone; what
|
|
7
|
+
* is stored is always a UTC instant, converted here and nowhere else.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useId, useState } from "react";
|
|
11
|
+
import {
|
|
12
|
+
EMPTY_VISIBILITY_SCHEDULE,
|
|
13
|
+
isoToLocalInput,
|
|
14
|
+
localInputToIso,
|
|
15
|
+
normalizeSchedule,
|
|
16
|
+
scheduleProblems,
|
|
17
|
+
visibilityStateAt,
|
|
18
|
+
type VisibilitySchedule,
|
|
19
|
+
} from "../cms/visibility-schedule";
|
|
20
|
+
import { zoneAbbreviation, zonedLocalExists } from "../cms/timezone";
|
|
21
|
+
import { siteTimezone } from "../config/site-config";
|
|
22
|
+
import { VisibilityBadge } from "./StatusBadge";
|
|
23
|
+
|
|
24
|
+
export type ScheduleFieldProps = {
|
|
25
|
+
value: VisibilitySchedule | null | undefined;
|
|
26
|
+
onChange: (next: VisibilitySchedule) => void;
|
|
27
|
+
/** IANA zone the wall-clock inputs are read in; the site's zone by default. */
|
|
28
|
+
timezone?: string | null;
|
|
29
|
+
/** Evaluation instant; passed in so previews and tests are deterministic. */
|
|
30
|
+
now?: number;
|
|
31
|
+
/** Heading shown above the controls. */
|
|
32
|
+
label?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Visible/hidden switch plus an optional publish window.
|
|
37
|
+
*
|
|
38
|
+
* Nonsensical ranges are reported as they are typed: the end field is marked
|
|
39
|
+
* invalid and the problem is spelled out under the fields, associated with them
|
|
40
|
+
* through `aria-describedby`. A wall-clock time the site's timezone never
|
|
41
|
+
* reaches — the hour a spring-forward transition skips — is refused outright
|
|
42
|
+
* rather than stored as the shifted instant it would resolve to.
|
|
43
|
+
*/
|
|
44
|
+
export function ScheduleField({
|
|
45
|
+
value,
|
|
46
|
+
onChange,
|
|
47
|
+
timezone: timezoneProp,
|
|
48
|
+
now = Date.now(),
|
|
49
|
+
label = "Visibility",
|
|
50
|
+
}: ScheduleFieldProps) {
|
|
51
|
+
const id = useId();
|
|
52
|
+
const timezone = timezoneProp || siteTimezone();
|
|
53
|
+
const schedule = normalizeSchedule(value) ?? EMPTY_VISIBILITY_SCHEDULE;
|
|
54
|
+
const enabled = schedule.enabled !== false;
|
|
55
|
+
const problems = scheduleProblems(schedule);
|
|
56
|
+
const [skipped, setSkipped] = useState<Partial<Record<"publishAt" | "unpublishAt", string>>>({});
|
|
57
|
+
const state = visibilityStateAt(schedule, now);
|
|
58
|
+
const zone = zoneAbbreviation(timezone);
|
|
59
|
+
|
|
60
|
+
const patch = (next: Partial<VisibilitySchedule>) => onChange({ ...schedule, ...next });
|
|
61
|
+
|
|
62
|
+
/*
|
|
63
|
+
* A time in the hour a spring-forward transition skips resolves to a real
|
|
64
|
+
* instant an hour away from what was typed, which would schedule the item at
|
|
65
|
+
* a time nobody chose. Refuse it instead: the field keeps its previous value
|
|
66
|
+
* and says why, so nothing is stored silently.
|
|
67
|
+
*/
|
|
68
|
+
const setInstant = (key: "publishAt" | "unpublishAt", local: string) => {
|
|
69
|
+
if (local && !zonedLocalExists(local, timezone)) {
|
|
70
|
+
setSkipped((prev) => ({ ...prev, [key]: local }));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
setSkipped((prev) => (prev[key] ? { ...prev, [key]: undefined } : prev));
|
|
74
|
+
patch({ [key]: local ? localInputToIso(local, timezone) : null });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const skippedProblems = (["publishAt", "unpublishAt"] as const)
|
|
78
|
+
.filter((key) => skipped[key])
|
|
79
|
+
.map(
|
|
80
|
+
(key) =>
|
|
81
|
+
`${key === "publishAt" ? "Start" : "End"} time ${skipped[key]?.replace("T", " ")} does not exist in ${zone} — the clocks jump forward that night. Pick another time.`,
|
|
82
|
+
);
|
|
83
|
+
const allProblems = [...problems, ...skippedProblems];
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<fieldset className="border border-ink/10 bg-ice/20 px-3 py-3">
|
|
87
|
+
<legend className="px-1 text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">{label}</legend>
|
|
88
|
+
|
|
89
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
90
|
+
<label className="flex items-center gap-2 text-sm text-ink/75">
|
|
91
|
+
<input
|
|
92
|
+
type="checkbox"
|
|
93
|
+
checked={enabled}
|
|
94
|
+
onChange={(e) => patch({ enabled: e.target.checked })}
|
|
95
|
+
className="size-4 accent-sea"
|
|
96
|
+
/>
|
|
97
|
+
Show this item
|
|
98
|
+
</label>
|
|
99
|
+
<VisibilityBadge state={state} />
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div
|
|
103
|
+
className={[
|
|
104
|
+
"mt-3 grid gap-3 sm:grid-cols-2",
|
|
105
|
+
enabled ? "" : "opacity-50",
|
|
106
|
+
].join(" ")}
|
|
107
|
+
>
|
|
108
|
+
<label className="block">
|
|
109
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">
|
|
110
|
+
Show from (optional)
|
|
111
|
+
</span>
|
|
112
|
+
<input
|
|
113
|
+
type="datetime-local"
|
|
114
|
+
disabled={!enabled}
|
|
115
|
+
value={isoToLocalInput(schedule.publishAt, timezone)}
|
|
116
|
+
onChange={(e) => setInstant("publishAt", e.target.value)}
|
|
117
|
+
aria-invalid={skipped.publishAt ? true : undefined}
|
|
118
|
+
aria-describedby={`${id}-zone${allProblems.length ? ` ${id}-problems` : ""}`}
|
|
119
|
+
className="w-full border border-ink/20 bg-white px-3 py-2 text-sm disabled:bg-ink/[0.03]"
|
|
120
|
+
/>
|
|
121
|
+
</label>
|
|
122
|
+
<label className="block">
|
|
123
|
+
<span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">
|
|
124
|
+
Hide again at (optional)
|
|
125
|
+
</span>
|
|
126
|
+
<input
|
|
127
|
+
type="datetime-local"
|
|
128
|
+
disabled={!enabled}
|
|
129
|
+
value={isoToLocalInput(schedule.unpublishAt, timezone)}
|
|
130
|
+
onChange={(e) => setInstant("unpublishAt", e.target.value)}
|
|
131
|
+
aria-invalid={problems.length || skipped.unpublishAt ? true : undefined}
|
|
132
|
+
aria-describedby={`${id}-zone${allProblems.length ? ` ${id}-problems` : ""}`}
|
|
133
|
+
className="w-full border border-ink/20 bg-white px-3 py-2 text-sm disabled:bg-ink/[0.03]"
|
|
134
|
+
/>
|
|
135
|
+
</label>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<p id={`${id}-zone`} className="mt-2 text-xs text-ink/50">
|
|
139
|
+
Times are in the site’s timezone ({zone}). Leave both empty to show the item all the
|
|
140
|
+
time.
|
|
141
|
+
</p>
|
|
142
|
+
{allProblems.length ? (
|
|
143
|
+
<ul id={`${id}-problems`} className="mt-1 space-y-0.5 text-xs text-red-600">
|
|
144
|
+
{allProblems.map((problem) => (
|
|
145
|
+
<li key={problem}>{problem}</li>
|
|
146
|
+
))}
|
|
147
|
+
</ul>
|
|
148
|
+
) : null}
|
|
149
|
+
</fieldset>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -37,6 +37,15 @@ import {
|
|
|
37
37
|
import { CSS } from "@dnd-kit/utilities";
|
|
38
38
|
|
|
39
39
|
import { MediaPickerDialog } from "./inspectors/fields";
|
|
40
|
+
import { ScheduleField } from "./ScheduleFields";
|
|
41
|
+
import { VisibilityBadge } from "./StatusBadge";
|
|
42
|
+
import {
|
|
43
|
+
isEmptySchedule,
|
|
44
|
+
normalizeSchedule,
|
|
45
|
+
visibilityStateAt,
|
|
46
|
+
type VisibilitySchedule,
|
|
47
|
+
type VisibilityState,
|
|
48
|
+
} from "../cms/visibility-schedule";
|
|
40
49
|
|
|
41
50
|
/**
|
|
42
51
|
* Generic recursive editor for CMS section values, shared by every site.
|
|
@@ -95,6 +104,11 @@ export type SectionFormDeps = {
|
|
|
95
104
|
FocusPickerComponent: ComponentType<SectionFormFocusPickerProps>;
|
|
96
105
|
/** Managed forms offered by the `formKey` picker. */
|
|
97
106
|
forms: SectionFormManagedForm[];
|
|
107
|
+
/**
|
|
108
|
+
* IANA zone the scheduling fields read wall-clock time in. Defaults to the
|
|
109
|
+
* platform's own default when a host does not pass one.
|
|
110
|
+
*/
|
|
111
|
+
timezone?: string;
|
|
98
112
|
};
|
|
99
113
|
|
|
100
114
|
const DepsContext = createContext<SectionFormDeps | null>(null);
|
|
@@ -184,6 +198,8 @@ function elementShape(...lists: (readonly unknown[] | undefined)[]): unknown {
|
|
|
184
198
|
|
|
185
199
|
/** Keys under which a nested list holds items of the parent's own shape. */
|
|
186
200
|
const TREE_KEYS = ["children", "items", "links"] as const;
|
|
201
|
+
/** Field holding a {@link VisibilitySchedule}; see `cms/visibility-schedule`. */
|
|
202
|
+
const SCHEDULE_KEY = "schedule";
|
|
187
203
|
|
|
188
204
|
/**
|
|
189
205
|
* Whether an object is a menu entry rather than a content row.
|
|
@@ -257,6 +273,24 @@ function Node({
|
|
|
257
273
|
const deps = useDeps();
|
|
258
274
|
const isLocalizedString = useIsLocalizedString();
|
|
259
275
|
|
|
276
|
+
// Scheduled visibility has one editor for the whole platform, so a menu item
|
|
277
|
+
// and a footer link cannot drift apart. Matched before anything else: the
|
|
278
|
+
// generic object field would show raw ISO strings, and the sibling-shape
|
|
279
|
+
// rule below would blank it to `enabled: false`, which is not what an
|
|
280
|
+
// unscheduled item means.
|
|
281
|
+
if (path.endsWith(`.${SCHEDULE_KEY}`) || path === SCHEDULE_KEY) {
|
|
282
|
+
if (value == null || isPlainObject(value)) {
|
|
283
|
+
return (
|
|
284
|
+
<ScheduleField
|
|
285
|
+
value={value as VisibilitySchedule | null}
|
|
286
|
+
onChange={(next) => onChange(next as unknown as AnyVal)}
|
|
287
|
+
timezone={deps.timezone}
|
|
288
|
+
label={fieldLabel ?? "Visibility"}
|
|
289
|
+
/>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
260
294
|
// A field a particular item is missing, or that was saved as an empty
|
|
261
295
|
// object, still has to be editable — and has to be edited with the right
|
|
262
296
|
// control. The sibling example says which one, without writing anything:
|
|
@@ -657,6 +691,7 @@ function ArrayField({
|
|
|
657
691
|
lastIndex={value.length - 1}
|
|
658
692
|
open={openIdx === i}
|
|
659
693
|
summary={itemSummary(item, deps)}
|
|
694
|
+
scheduleState={rowScheduleState(item)}
|
|
660
695
|
onToggle={() => setOpenIdx(openIdx === i ? null : i)}
|
|
661
696
|
onMoveUp={() => move(i, -1)}
|
|
662
697
|
onMoveDown={() => move(i, 1)}
|
|
@@ -695,12 +730,24 @@ function ArrayField({
|
|
|
695
730
|
);
|
|
696
731
|
}
|
|
697
732
|
|
|
733
|
+
/**
|
|
734
|
+
* The badge state for a collapsed row: only rows that actually carry a
|
|
735
|
+
* schedule get one, so an unscheduled menu stays as quiet as it is today.
|
|
736
|
+
*/
|
|
737
|
+
function rowScheduleState(item: unknown): VisibilityState | null {
|
|
738
|
+
if (!isPlainObject(item)) return null;
|
|
739
|
+
const schedule = normalizeSchedule(item[SCHEDULE_KEY]);
|
|
740
|
+
if (isEmptySchedule(schedule)) return null;
|
|
741
|
+
return visibilityStateAt(schedule, Date.now());
|
|
742
|
+
}
|
|
743
|
+
|
|
698
744
|
function SortableArrayRow({
|
|
699
745
|
id,
|
|
700
746
|
index,
|
|
701
747
|
lastIndex,
|
|
702
748
|
open,
|
|
703
749
|
summary,
|
|
750
|
+
scheduleState,
|
|
704
751
|
onToggle,
|
|
705
752
|
onMoveUp,
|
|
706
753
|
onMoveDown,
|
|
@@ -712,6 +759,8 @@ function SortableArrayRow({
|
|
|
712
759
|
lastIndex: number;
|
|
713
760
|
open: boolean;
|
|
714
761
|
summary: string;
|
|
762
|
+
/** Set only when this row carries a schedule; `null` renders no badge. */
|
|
763
|
+
scheduleState: VisibilityState | null;
|
|
715
764
|
onToggle: () => void;
|
|
716
765
|
onMoveUp: () => void;
|
|
717
766
|
onMoveDown: () => void;
|
|
@@ -751,6 +800,11 @@ function SortableArrayRow({
|
|
|
751
800
|
)}
|
|
752
801
|
<span className="text-ink/50 shrink-0">#{index + 1}</span>
|
|
753
802
|
<span className="text-ink truncate">{summary}</span>
|
|
803
|
+
{scheduleState ? (
|
|
804
|
+
<span className="shrink-0">
|
|
805
|
+
<VisibilityBadge state={scheduleState} />
|
|
806
|
+
</span>
|
|
807
|
+
) : null}
|
|
754
808
|
</button>
|
|
755
809
|
|
|
756
810
|
<button
|
|
@@ -853,6 +907,10 @@ function ObjectField({
|
|
|
853
907
|
const isTreeKey = (k: string) => (TREE_KEYS as readonly string[]).includes(k);
|
|
854
908
|
const isLink = Boolean(inList) && isLinkNode(value, deps);
|
|
855
909
|
if (isLink && !keys.some(isTreeKey)) keys.push(TREE_KEYS[0]);
|
|
910
|
+
// Every menu item and footer link can be scheduled, so the control sits with
|
|
911
|
+
// that item's own controls whether or not anything has been scheduled before.
|
|
912
|
+
// Like the submenu above, showing it writes nothing.
|
|
913
|
+
if (isLink && !keys.includes(SCHEDULE_KEY)) keys.push(SCHEDULE_KEY);
|
|
856
914
|
|
|
857
915
|
const content = (
|
|
858
916
|
<div className={depth === 0 ? "space-y-5" : "space-y-4"}>
|