create-nextblock 0.14.2 → 0.14.5

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.
Files changed (68) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/[slug]/page.tsx +7 -2
  3. package/templates/nextblock-template/app/[slug]/page.utils.ts +8 -3
  4. package/templates/nextblock-template/app/actions/postActions.ts +3 -0
  5. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -0
  6. package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +83 -3
  7. package/templates/nextblock-template/app/actions/visualEditingActions.ts +34 -14
  8. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +45 -0
  9. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +736 -117
  10. package/templates/nextblock-template/app/api/view/route.ts +114 -0
  11. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -2
  12. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -2
  13. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +17 -22
  14. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +13 -19
  15. package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +45 -34
  16. package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +10 -0
  17. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
  18. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
  19. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
  20. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
  21. package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
  22. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
  23. package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
  24. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
  25. package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
  26. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
  27. package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
  28. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
  29. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
  30. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
  31. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
  32. package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
  33. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
  34. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -0
  35. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -0
  36. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -24
  37. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -0
  38. package/templates/nextblock-template/app/layout.tsx +49 -0
  39. package/templates/nextblock-template/app/lib/sitemap-utils.ts +6 -4
  40. package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
  41. package/templates/nextblock-template/app/page.tsx +6 -3
  42. package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
  43. package/templates/nextblock-template/app/providers.tsx +16 -4
  44. package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +7 -10
  45. package/templates/nextblock-template/components/theme-icon.tsx +78 -0
  46. package/templates/nextblock-template/components/theme-switcher.tsx +85 -90
  47. package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
  48. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -0
  49. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -0
  50. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +7 -2
  51. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +114 -0
  52. package/templates/nextblock-template/lib/blocks/blockColors.ts +132 -0
  53. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +17 -1
  54. package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
  55. package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
  56. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
  57. package/templates/nextblock-template/lib/search/server.ts +3 -0
  58. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +102 -87
  59. package/templates/nextblock-template/lib/themes/buildThemeCss.test.ts +163 -0
  60. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -0
  61. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -0
  62. package/templates/nextblock-template/lib/themes/tokens.ts +143 -0
  63. package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
  64. package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
  65. package/templates/nextblock-template/next-env.d.ts +2 -2
  66. package/templates/nextblock-template/package.json +1 -1
  67. package/templates/nextblock-template/scripts/verify-site-themes.ts +63 -0
  68. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,528 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Top-bar visibility control for pages, posts and products.
5
+ *
6
+ * This is the only place in the CMS that can change what the public sees. It is
7
+ * deliberately NOT part of the content form: editing settings autosaves into the
8
+ * Live Draft, while publishing writes straight to the row. Two decisions, two
9
+ * controls — see `app/actions/visibilityActions.ts`.
10
+ *
11
+ * The pill is read-only on purpose. A <select> implies every option is equivalent
12
+ * and instantly applied, which is exactly the behaviour this replaces; naming the
13
+ * transitions as verbs in a menu lets each one carry its own confirmation.
14
+ */
15
+
16
+ import * as React from "react";
17
+ import { useRouter } from "next/navigation";
18
+ import { toast } from "react-hot-toast";
19
+ import {
20
+ Button,
21
+ Checkbox,
22
+ Dialog,
23
+ DialogContent,
24
+ DialogDescription,
25
+ DialogFooter,
26
+ DialogHeader,
27
+ DialogTitle,
28
+ DropdownMenu,
29
+ DropdownMenuContent,
30
+ DropdownMenuItem,
31
+ DropdownMenuLabel,
32
+ DropdownMenuSeparator,
33
+ DropdownMenuTrigger,
34
+ Input,
35
+ Label,
36
+ } from "@nextblock-cms/ui";
37
+ import {
38
+ LIVE_STATUS,
39
+ resolveVisibilityState,
40
+ type PublishableType,
41
+ type VisibilityState,
42
+ } from "@nextblock-cms/utils";
43
+ import {
44
+ CalendarClock,
45
+ ChevronDown,
46
+ Link2,
47
+ Loader2,
48
+ Archive,
49
+ EyeOff,
50
+ Globe,
51
+ } from "lucide-react";
52
+ import {
53
+ getSiblingVisibility,
54
+ setContentVisibility,
55
+ type SiblingVisibility,
56
+ type VisibilityIntent,
57
+ } from "../../actions/visibilityActions";
58
+ import { publishVisualEditingDraft, publishVisualEditingProductDraft } from "../../actions/visualEditingActions";
59
+
60
+ interface LanguageOption {
61
+ id: number;
62
+ name: string;
63
+ code: string;
64
+ }
65
+
66
+ export interface VisibilityControlProps {
67
+ type: PublishableType;
68
+ id: number | string;
69
+ /** Live row status — never the draft overlay. */
70
+ status: string;
71
+ /** Live row published_at; a future value means "scheduled". */
72
+ publishedAt: string | null;
73
+ /** Root-relative public URL, e.g. "/about" or "/article/hello". */
74
+ publicPath: string;
75
+ /** Display name of the language this row is written in. */
76
+ languageName?: string;
77
+ translationGroupId?: string | null;
78
+ languages?: LanguageOption[];
79
+ /** True when unpublished content edits exist in the Live Draft. */
80
+ hasDraft?: boolean;
81
+ /**
82
+ * Why this content cannot go public yet, if it can't — e.g. a product whose
83
+ * payment provider isn't configured. Blocks publish and schedule, and is shown
84
+ * verbatim, so write it as an explanation the editor can act on.
85
+ */
86
+ publishBlockedReason?: string | null;
87
+ }
88
+
89
+ const STATE_LABEL: Record<VisibilityState, string> = {
90
+ draft: "Draft",
91
+ scheduled: "Scheduled",
92
+ published: "Published",
93
+ archived: "Archived",
94
+ };
95
+
96
+ const STATE_STYLE: Record<VisibilityState, string> = {
97
+ draft:
98
+ "border-border bg-muted text-muted-foreground",
99
+ scheduled:
100
+ "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
101
+ published:
102
+ "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
103
+ archived:
104
+ "border-dashed border-border bg-transparent text-muted-foreground",
105
+ };
106
+
107
+ const DOT_STYLE: Record<VisibilityState, string> = {
108
+ draft: "bg-muted-foreground/70",
109
+ scheduled: "bg-amber-500",
110
+ published: "bg-emerald-500",
111
+ archived: "bg-muted-foreground/50",
112
+ };
113
+
114
+ /** `datetime-local` wants "YYYY-MM-DDTHH:mm" in LOCAL time, not an ISO string. */
115
+ function toDateTimeLocal(value: string | null): string {
116
+ if (!value) return "";
117
+ const date = new Date(value);
118
+ if (Number.isNaN(date.getTime())) return "";
119
+ const pad = (n: number) => String(n).padStart(2, "0");
120
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
121
+ date.getHours()
122
+ )}:${pad(date.getMinutes())}`;
123
+ }
124
+
125
+ function defaultScheduleValue(): string {
126
+ const soon = new Date();
127
+ soon.setDate(soon.getDate() + 1);
128
+ soon.setMinutes(0, 0, 0);
129
+ return toDateTimeLocal(soon.toISOString());
130
+ }
131
+
132
+ export default function VisibilityControl({
133
+ type,
134
+ id,
135
+ status,
136
+ publishedAt,
137
+ publicPath,
138
+ languageName,
139
+ translationGroupId,
140
+ languages = [],
141
+ hasDraft = false,
142
+ publishBlockedReason = null,
143
+ }: VisibilityControlProps) {
144
+ const router = useRouter();
145
+ const liveStatus = LIVE_STATUS[type];
146
+ const state = resolveVisibilityState({ status, publishedAt, liveStatus });
147
+
148
+ const [dialog, setDialog] = React.useState<null | "publish" | "schedule" | "unpublish" | "archive">(null);
149
+ const [isBusy, setIsBusy] = React.useState(false);
150
+ const [error, setError] = React.useState<string | null>(null);
151
+ const [alsoPublishDraft, setAlsoPublishDraft] = React.useState(true);
152
+ const [scheduleValue, setScheduleValue] = React.useState(() => toDateTimeLocal(publishedAt) || defaultScheduleValue());
153
+ const [siblings, setSiblings] = React.useState<SiblingVisibility[] | null>(null);
154
+
155
+ // The schedule date is formatted in the viewer's timezone, which the server
156
+ // doesn't know — render it only after mount so the two never disagree.
157
+ const [mounted, setMounted] = React.useState(false);
158
+ React.useEffect(() => setMounted(true), []);
159
+
160
+ const scheduleLabel = React.useMemo(() => {
161
+ if (!mounted || !publishedAt) return null;
162
+ const date = new Date(publishedAt);
163
+ if (Number.isNaN(date.getTime())) return null;
164
+ return new Intl.DateTimeFormat(undefined, {
165
+ month: "short",
166
+ day: "numeric",
167
+ hour: "numeric",
168
+ minute: "2-digit",
169
+ timeZoneName: "short",
170
+ }).format(date);
171
+ }, [mounted, publishedAt]);
172
+
173
+ const label = state === "scheduled" && scheduleLabel ? `Goes live ${scheduleLabel}` : STATE_LABEL[state];
174
+
175
+ const languageNameById = React.useMemo(
176
+ () => new Map(languages.map((language) => [language.id, `${language.name} (${language.code.toUpperCase()})`])),
177
+ [languages]
178
+ );
179
+
180
+ const openPublishDialog = React.useCallback(() => {
181
+ setError(publishBlockedReason);
182
+ setAlsoPublishDraft(true);
183
+ setSiblings(null);
184
+ setDialog("publish");
185
+
186
+ if (translationGroupId) {
187
+ void getSiblingVisibility(type, translationGroupId, id).then(setSiblings);
188
+ }
189
+ }, [id, publishBlockedReason, translationGroupId, type]);
190
+
191
+ const run = async (intent: VisibilityIntent, opts?: { publishDraft?: boolean }) => {
192
+ setIsBusy(true);
193
+ setError(null);
194
+ try {
195
+ const result = await setContentVisibility(type, id, intent);
196
+ if (result.error) {
197
+ setError(result.error);
198
+ return;
199
+ }
200
+
201
+ // Publishing the row and publishing pending edits are separate operations;
202
+ // the dialog offers to do both so a never-live page doesn't go public
203
+ // showing content the editor has already replaced.
204
+ if (opts?.publishDraft && hasDraft) {
205
+ const draftResult =
206
+ type === "product"
207
+ ? await publishVisualEditingProductDraft(String(id))
208
+ : await publishVisualEditingDraft(type, Number(id));
209
+
210
+ if (draftResult && "error" in draftResult && draftResult.error) {
211
+ toast.error(`Visibility updated, but the edits didn't publish: ${draftResult.error}`);
212
+ setDialog(null);
213
+ router.refresh();
214
+ return;
215
+ }
216
+
217
+ // Partial success: the edits went live but the revision didn't record.
218
+ if (draftResult && "success" in draftResult && draftResult.warning) {
219
+ toast.error(draftResult.warning, { duration: 8000 });
220
+ }
221
+ }
222
+
223
+ toast.success(
224
+ intent.action === "publish"
225
+ ? "Published."
226
+ : intent.action === "schedule"
227
+ ? "Scheduled."
228
+ : intent.action === "unpublish"
229
+ ? "Unpublished."
230
+ : "Archived."
231
+ );
232
+ setDialog(null);
233
+ router.refresh();
234
+ } catch (err) {
235
+ setError(err instanceof Error ? err.message : "Something went wrong.");
236
+ } finally {
237
+ setIsBusy(false);
238
+ }
239
+ };
240
+
241
+ const copyPublicUrl = async () => {
242
+ const url = typeof window === "undefined" ? publicPath : new URL(publicPath, window.location.origin).toString();
243
+ try {
244
+ await navigator.clipboard.writeText(url);
245
+ toast.success("Public URL copied.");
246
+ } catch {
247
+ toast.error("Couldn't copy the URL.");
248
+ }
249
+ };
250
+
251
+ const typeLabel = type === "product" ? "product" : type;
252
+ const busySpinner = <Loader2 className="mr-2 h-4 w-4 animate-spin" />;
253
+
254
+ return (
255
+ <div className="flex items-center gap-2">
256
+ <span
257
+ className={`inline-flex items-center gap-2 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-semibold ${STATE_STYLE[state]}`}
258
+ title={
259
+ state === "published"
260
+ ? `Live at ${publicPath}`
261
+ : state === "scheduled"
262
+ ? "Not public yet — goes live automatically"
263
+ : "Not visible to the public"
264
+ }
265
+ >
266
+ <span className={`h-1.5 w-1.5 rounded-full ${DOT_STYLE[state]}`} aria-hidden="true" />
267
+ {label}
268
+ </span>
269
+
270
+ {hasDraft && (
271
+ <span className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border border-dashed border-amber-500/50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:text-amber-300">
272
+ <span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden="true" />
273
+ Unpublished edits
274
+ </span>
275
+ )}
276
+
277
+ <div className="flex items-center">
278
+ {state !== "published" && (
279
+ <Button
280
+ size="sm"
281
+ className="rounded-r-none"
282
+ onClick={openPublishDialog}
283
+ disabled={isBusy}
284
+ >
285
+ <Globe className="mr-2 h-4 w-4" />
286
+ {state === "scheduled" ? "Publish now" : "Publish…"}
287
+ </Button>
288
+ )}
289
+ <DropdownMenu>
290
+ <DropdownMenuTrigger asChild>
291
+ <Button
292
+ size="sm"
293
+ variant={state === "published" ? "outline" : "default"}
294
+ className={state === "published" ? "" : "-ml-px rounded-l-none px-2"}
295
+ aria-label="Visibility options"
296
+ disabled={isBusy}
297
+ >
298
+ {state === "published" && <span className="mr-1">Visibility</span>}
299
+ <ChevronDown className="h-4 w-4" />
300
+ </Button>
301
+ </DropdownMenuTrigger>
302
+ <DropdownMenuContent align="end" className="w-64">
303
+ <DropdownMenuLabel>Visibility</DropdownMenuLabel>
304
+ <DropdownMenuSeparator />
305
+ <DropdownMenuItem
306
+ onSelect={() => {
307
+ setError(publishBlockedReason);
308
+ setScheduleValue(toDateTimeLocal(publishedAt) || defaultScheduleValue());
309
+ setDialog("schedule");
310
+ }}
311
+ >
312
+ <CalendarClock className="mr-2 h-4 w-4" />
313
+ {state === "scheduled" ? "Reschedule…" : "Schedule…"}
314
+ </DropdownMenuItem>
315
+ <DropdownMenuItem onSelect={() => void copyPublicUrl()}>
316
+ <Link2 className="mr-2 h-4 w-4" />
317
+ Copy public URL
318
+ </DropdownMenuItem>
319
+ {(state === "published" || state === "scheduled") && (
320
+ <>
321
+ <DropdownMenuSeparator />
322
+ <DropdownMenuItem
323
+ className="text-destructive focus:text-destructive"
324
+ onSelect={() => {
325
+ setError(null);
326
+ setDialog("unpublish");
327
+ }}
328
+ >
329
+ <EyeOff className="mr-2 h-4 w-4" />
330
+ Unpublish
331
+ </DropdownMenuItem>
332
+ </>
333
+ )}
334
+ {state !== "archived" && (
335
+ <DropdownMenuItem
336
+ className="text-destructive focus:text-destructive"
337
+ onSelect={() => {
338
+ setError(null);
339
+ setDialog("archive");
340
+ }}
341
+ >
342
+ <Archive className="mr-2 h-4 w-4" />
343
+ Archive
344
+ </DropdownMenuItem>
345
+ )}
346
+ </DropdownMenuContent>
347
+ </DropdownMenu>
348
+ </div>
349
+
350
+ {/* ---- Publish ---- */}
351
+ <Dialog open={dialog === "publish"} onOpenChange={(open) => !open && setDialog(null)}>
352
+ <DialogContent>
353
+ <DialogHeader>
354
+ <DialogTitle>Make this {typeLabel} public?</DialogTitle>
355
+ <DialogDescription>
356
+ Anyone with the link will be able to see it, and search engines can index it.
357
+ </DialogDescription>
358
+ </DialogHeader>
359
+
360
+ <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-md border bg-muted/40 p-3 text-sm">
361
+ <dt className="text-xs uppercase tracking-wide text-muted-foreground">URL</dt>
362
+ <dd className="font-mono text-xs break-all">{publicPath}</dd>
363
+ {languageName && (
364
+ <>
365
+ <dt className="text-xs uppercase tracking-wide text-muted-foreground">Language</dt>
366
+ <dd>{languageName}</dd>
367
+ </>
368
+ )}
369
+ <dt className="text-xs uppercase tracking-wide text-muted-foreground">Goes live</dt>
370
+ <dd>Immediately</dd>
371
+ </dl>
372
+
373
+ {hasDraft && (
374
+ <label className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
375
+ <Checkbox
376
+ checked={alsoPublishDraft}
377
+ onCheckedChange={(checked) => setAlsoPublishDraft(checked === true)}
378
+ className="mt-0.5"
379
+ />
380
+ <span>
381
+ Publish your unpublished edits at the same time — otherwise the older live version
382
+ is what goes public.
383
+ </span>
384
+ </label>
385
+ )}
386
+
387
+ {siblings && siblings.some((sibling) => sibling.state !== "published") && (
388
+ <div className="space-y-1.5 text-sm text-muted-foreground">
389
+ {siblings
390
+ .filter((sibling) => sibling.state !== "published")
391
+ .map((sibling) => (
392
+ <p key={String(sibling.id)}>
393
+ <span className="font-semibold text-amber-600 dark:text-amber-400">!</span>{" "}
394
+ The {languageNameById.get(sibling.languageId) ?? "other language"} version is
395
+ still {STATE_LABEL[sibling.state].toLowerCase()}.
396
+ </p>
397
+ ))}
398
+ </div>
399
+ )}
400
+
401
+ {error && <p className="text-sm text-destructive">{error}</p>}
402
+
403
+ <DialogFooter className="mt-2 gap-2">
404
+ <Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
405
+ Cancel
406
+ </Button>
407
+ <Button
408
+ onClick={() => void run({ action: "publish" }, { publishDraft: alsoPublishDraft })}
409
+ disabled={isBusy || Boolean(publishBlockedReason)}
410
+ >
411
+ {isBusy && busySpinner}
412
+ Publish {typeLabel}
413
+ </Button>
414
+ </DialogFooter>
415
+ </DialogContent>
416
+ </Dialog>
417
+
418
+ {/* ---- Schedule ---- */}
419
+ <Dialog open={dialog === "schedule"} onOpenChange={(open) => !open && setDialog(null)}>
420
+ <DialogContent>
421
+ <DialogHeader>
422
+ <DialogTitle>Schedule this {typeLabel}</DialogTitle>
423
+ <DialogDescription>
424
+ It stays private until the moment you pick, then goes live on its own. Times are in
425
+ your own timezone.
426
+ </DialogDescription>
427
+ </DialogHeader>
428
+
429
+ <div className="space-y-2">
430
+ <Label htmlFor="visibility-schedule">Go live on</Label>
431
+ <Input
432
+ id="visibility-schedule"
433
+ type="datetime-local"
434
+ value={scheduleValue}
435
+ onChange={(event) => setScheduleValue(event.target.value)}
436
+ />
437
+ </div>
438
+
439
+ {error && <p className="text-sm text-destructive">{error}</p>}
440
+
441
+ <DialogFooter className="mt-2 gap-2">
442
+ <Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
443
+ Cancel
444
+ </Button>
445
+ <Button
446
+ onClick={() => {
447
+ if (publishBlockedReason) {
448
+ setError(publishBlockedReason);
449
+ return;
450
+ }
451
+ const local = new Date(scheduleValue);
452
+ if (Number.isNaN(local.getTime())) {
453
+ setError("That isn't a valid date and time.");
454
+ return;
455
+ }
456
+ void run({ action: "schedule", publishedAt: local.toISOString() });
457
+ }}
458
+ disabled={isBusy || !scheduleValue || Boolean(publishBlockedReason)}
459
+ >
460
+ {isBusy && busySpinner}
461
+ Schedule
462
+ </Button>
463
+ </DialogFooter>
464
+ </DialogContent>
465
+ </Dialog>
466
+
467
+ {/* ---- Unpublish ---- */}
468
+ <Dialog open={dialog === "unpublish"} onOpenChange={(open) => !open && setDialog(null)}>
469
+ <DialogContent>
470
+ <DialogHeader>
471
+ <DialogTitle>Take this {typeLabel} off the live site?</DialogTitle>
472
+ <DialogDescription>
473
+ Visitors will get a 404 at{" "}
474
+ <span className="font-mono text-xs">{publicPath}</span>. Your content and any
475
+ unpublished edits are kept — you can publish it again at any time. Check your
476
+ navigation menus for links pointing here.
477
+ </DialogDescription>
478
+ </DialogHeader>
479
+
480
+ {error && <p className="text-sm text-destructive">{error}</p>}
481
+
482
+ <DialogFooter className="mt-2 gap-2">
483
+ <Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
484
+ Cancel
485
+ </Button>
486
+ <Button
487
+ variant="destructive"
488
+ onClick={() => void run({ action: "unpublish" })}
489
+ disabled={isBusy}
490
+ >
491
+ {isBusy && busySpinner}
492
+ Unpublish
493
+ </Button>
494
+ </DialogFooter>
495
+ </DialogContent>
496
+ </Dialog>
497
+
498
+ {/* ---- Archive ---- */}
499
+ <Dialog open={dialog === "archive"} onOpenChange={(open) => !open && setDialog(null)}>
500
+ <DialogContent>
501
+ <DialogHeader>
502
+ <DialogTitle>Archive this {typeLabel}?</DialogTitle>
503
+ <DialogDescription>
504
+ It comes off the live site and drops out of the default {typeLabel} list. Nothing is
505
+ deleted, and you can publish it again later.
506
+ </DialogDescription>
507
+ </DialogHeader>
508
+
509
+ {error && <p className="text-sm text-destructive">{error}</p>}
510
+
511
+ <DialogFooter className="mt-2 gap-2">
512
+ <Button variant="outline" onClick={() => setDialog(null)} disabled={isBusy}>
513
+ Cancel
514
+ </Button>
515
+ <Button
516
+ variant="destructive"
517
+ onClick={() => void run({ action: "archive" })}
518
+ disabled={isBusy}
519
+ >
520
+ {isBusy && busySpinner}
521
+ Archive
522
+ </Button>
523
+ </DialogFooter>
524
+ </DialogContent>
525
+ </Dialog>
526
+ </div>
527
+ );
528
+ }
@@ -4,10 +4,10 @@ import Link from "next/link";
4
4
  import React from "react";
5
5
  import { Separator } from "@nextblock-cms/ui";
6
6
  import { Button } from "@nextblock-cms/ui";
7
- import { ViewLiveButton } from "@nextblock-cms/ui";
8
- import { ArrowLeft, FilePenLine } from "lucide-react";
9
- import { publishPage } from "../../actions";
7
+ import { ArrowLeft, Eye, FilePenLine } from "lucide-react";
10
8
  import PageForm from "../../components/PageForm";
9
+ import VisibilityControl from "../../../components/VisibilityControl";
10
+ import { buildViewUrl } from "../../../../../lib/publishing/viewUrl";
11
11
  import BlockEditorArea from "../../../blocks/components/BlockEditorArea";
12
12
  import ContentLanguageSwitcher from "../../../components/ContentLanguageSwitcher";
13
13
  import CopyContentFromLanguage from "../../../components/CopyContentFromLanguage";
@@ -33,8 +33,11 @@ interface EditPageClientProps {
33
33
  allSiteLanguages: Language[];
34
34
  updatePageAction: (formData: FormData) => Promise<{ error?: string } | void>;
35
35
  publicPageUrl: string;
36
- isLive: boolean;
36
+ liveStatus: string;
37
+ livePublishedAt: string | null;
37
38
  liveViewUrl: string;
39
+ languageCode: string | null;
40
+ languageName: string | null;
38
41
  isDraftModeEnabled: boolean;
39
42
  initialFeatureImageUrl?: string | null;
40
43
  initialFeatureImageId?: string | null;
@@ -47,14 +50,19 @@ export default function EditPageClient({
47
50
  allSiteLanguages,
48
51
  updatePageAction,
49
52
  publicPageUrl,
50
- isLive,
53
+ liveStatus,
54
+ livePublishedAt,
51
55
  liveViewUrl,
56
+ languageCode,
57
+ languageName,
52
58
  isDraftModeEnabled,
53
59
  initialFeatureImageUrl,
54
60
  initialFeatureImageId,
55
61
  hasDraft,
56
62
  }: EditPageClientProps) {
57
- const draftModeUrl = `/api/draft/start?path=${encodeURIComponent(publicPageUrl)}`;
63
+ const previewUrl = buildViewUrl({ path: publicPageUrl, languageCode, draft: true });
64
+ const liveUrl = buildViewUrl({ path: liveViewUrl, languageCode });
65
+ const isLive = liveStatus === "published";
58
66
 
59
67
  return (
60
68
  <UploadFolderProvider defaultFolder={`pages/${page.slug}/`}>
@@ -111,22 +119,30 @@ export default function EditPageClient({
111
119
  allSiteLanguages={allSiteLanguages}
112
120
  />
113
121
  )}
114
- <ViewLiveButton
115
- href={liveViewUrl}
116
- isLive={isLive}
117
- label="page"
118
- publishAction={() => publishPage(pageId)}
119
- />
120
122
  <Button variant="secondary" asChild>
121
- <a
122
- href={draftModeUrl}
123
- target="_blank"
124
- rel="noopener noreferrer"
125
- >
123
+ <a href={previewUrl} target="_blank" rel="noopener noreferrer">
126
124
  <FilePenLine className="mr-2 h-4 w-4" /> Preview
127
125
  </a>
128
126
  </Button>
127
+ {isLive && (
128
+ <Button variant="outline" asChild>
129
+ <a href={liveUrl} target="_blank" rel="noopener noreferrer">
130
+ <Eye className="mr-2 h-4 w-4" /> View Live
131
+ </a>
132
+ </Button>
133
+ )}
129
134
  <RevisionHistoryButton parentType="page" parentId={pageId} />
135
+ <VisibilityControl
136
+ type="page"
137
+ id={pageId}
138
+ status={liveStatus}
139
+ publishedAt={livePublishedAt}
140
+ publicPath={liveViewUrl}
141
+ languageName={languageName ?? undefined}
142
+ translationGroupId={page.translation_group_id}
143
+ languages={allSiteLanguages}
144
+ hasDraft={hasDraft}
145
+ />
130
146
  </div>
131
147
  </div>
132
148
 
@@ -20,7 +20,13 @@ interface PageWithBlocks extends Page {
20
20
  translation_group_id: string;
21
21
  }
22
22
 
23
- async function getPageDataWithBlocks(id: number): Promise<{ page: PageWithBlocks; hasDraft: boolean; liveStatus: string; liveSlug: string } | null> {
23
+ async function getPageDataWithBlocks(id: number): Promise<{
24
+ page: PageWithBlocks;
25
+ hasDraft: boolean;
26
+ liveStatus: string;
27
+ liveSlug: string;
28
+ livePublishedAt: string | null;
29
+ } | null> {
24
30
  const supabase = createClient();
25
31
  const { data: pageData, error: pageError } = await supabase
26
32
  .from("pages")
@@ -64,7 +70,9 @@ async function getPageDataWithBlocks(id: number): Promise<{ page: PageWithBlocks
64
70
  title: (draft.meta.title as string) ?? pageWithBlocks.title,
65
71
  slug: (draft.meta.slug as string) ?? pageWithBlocks.slug,
66
72
  language_id: (draft.meta.language_id as number) ?? pageWithBlocks.language_id,
67
- status: (draft.meta.status as any) ?? pageWithBlocks.status,
73
+ // `status`/`published_at` are intentionally NOT overlaid: visibility lives on
74
+ // the row, so the editor must show what is actually live — not what a stale
75
+ // draft remembers. Older drafts may still carry those keys.
68
76
  meta_title: (draft.meta.meta_title as string) ?? pageWithBlocks.meta_title,
69
77
  meta_description: (draft.meta.meta_description as string) ?? pageWithBlocks.meta_description,
70
78
  blocks: draft.blocks as any[],
@@ -74,10 +82,11 @@ async function getPageDataWithBlocks(id: number): Promise<{ page: PageWithBlocks
74
82
  return {
75
83
  page: pageWithBlocks,
76
84
  hasDraft,
77
- // The LIVE row's status/slug (before the draft overlay above) the "View
78
- // Live" button must reflect what is actually published, not the draft.
85
+ // The LIVE row's visibility/slug the top-bar control and the "View Live"
86
+ // link must reflect what is actually published, never the draft.
79
87
  liveStatus: pageData.status as string,
80
88
  liveSlug: pageData.slug as string,
89
+ livePublishedAt: (pageData as { published_at?: string | null }).published_at ?? null,
81
90
  };
82
91
  }
83
92
 
@@ -97,15 +106,15 @@ export default async function EditPage(props: { params: Promise<{ id: string }>
97
106
 
98
107
  const pageDataResult = await getPageDataWithBlocks(pageId);
99
108
  if (!pageDataResult) return notFound();
100
- const { page: pageWithBlocks, hasDraft, liveStatus, liveSlug } = pageDataResult;
109
+ const { page: pageWithBlocks, hasDraft, liveStatus, liveSlug, livePublishedAt } = pageDataResult;
101
110
 
102
111
  const allSiteLanguages = await getActiveLanguagesServerSide();
103
112
 
104
113
  const draft = await draftMode();
105
114
  const updatePageWithId = updatePage.bind(null, pageId);
106
115
  const publicPageUrl = `/${pageWithBlocks.slug}`;
107
- const isLive = liveStatus === "published";
108
116
  const liveViewUrl = liveSlug === "home" ? "/" : `/${liveSlug}`;
117
+ const language = allSiteLanguages.find((lang) => lang.id === pageWithBlocks.language_id);
109
118
  let initialFeatureImageUrl: string | null = null;
110
119
  let initialFeatureImageId: string | null = null;
111
120
 
@@ -132,8 +141,11 @@ export default async function EditPage(props: { params: Promise<{ id: string }>
132
141
  allSiteLanguages={allSiteLanguages}
133
142
  updatePageAction={updatePageWithId}
134
143
  publicPageUrl={publicPageUrl}
135
- isLive={isLive}
144
+ liveStatus={liveStatus}
145
+ livePublishedAt={livePublishedAt}
136
146
  liveViewUrl={liveViewUrl}
147
+ languageCode={language?.code ?? pageWithBlocks.language_code ?? null}
148
+ languageName={language ? `${language.name} (${language.code.toUpperCase()})` : null}
137
149
  isDraftModeEnabled={draft.isEnabled}
138
150
  initialFeatureImageUrl={initialFeatureImageUrl}
139
151
  initialFeatureImageId={initialFeatureImageId}