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,114 @@
1
+ import { draftMode } from "next/headers";
2
+ import { NextRequest, NextResponse } from "next/server";
3
+ import { getCurrentUserCanEdit } from "../../../lib/visual-editing/draft-content";
4
+ import {
5
+ normalizeDraftRedirectPath,
6
+ resolveDraftPathTarget,
7
+ resolveRequestOrigin,
8
+ } from "../../../lib/visual-editing/draft-route";
9
+
10
+ export const runtime = "nodejs";
11
+ export const dynamic = "force-dynamic";
12
+
13
+ /**
14
+ * Entry point behind the CMS "Preview" and "View Live" buttons.
15
+ *
16
+ * The public site carries no locale in its URLs: `/[slug]`, `/article/[slug]`,
17
+ * `/product/[slug]` and `/` all resolve their language per-request from the
18
+ * `NEXT_USER_LOCALE` cookie (see proxy.ts). A link built from the slug alone
19
+ * therefore renders in whatever language the *editor's own* cookie says — which,
20
+ * for an admin working in the CMS, is almost always the default one. Opening the
21
+ * French version of a page landed you on the English one, three different ways:
22
+ *
23
+ * 1. `getPageDataBySlug(slug, cookieLocale)` prefers the row matching the
24
+ * cookie, so when two translations share a slug the cookie's language wins.
25
+ * 2. `PageClientContent` / `PostClientContent` navigate to
26
+ * `translatedSlugs[currentLocale]` whenever the rendered row's language
27
+ * differs from the cookie — bouncing distinct French slugs back to English.
28
+ * 3. `/` (the homepage) resolves its language from the cookie with no slug to
29
+ * go on at all, so the French homepage was unreachable by URL.
30
+ *
31
+ * Pinning the locale here fixes all three at once, because after the redirect the
32
+ * cookie *agrees* with the content: the server picks the right row, the client
33
+ * effect is a no-op, and the surrounding chrome (nav, footer, UI strings) renders
34
+ * in the same language as the body — so the preview isn't lying about the page.
35
+ *
36
+ * The alternative, a `?lang=` param read by each route, would leave a second
37
+ * cacheable variant of every public URL behind and can leak into shares and
38
+ * search indexes as duplicate content. The redirect lands on the clean canonical
39
+ * URL instead, and the param never reaches the public route.
40
+ */
41
+
42
+ const LANGUAGE_COOKIE_KEY = "NEXT_USER_LOCALE";
43
+
44
+ function redirectNoStore(url: URL) {
45
+ const response = NextResponse.redirect(url);
46
+ response.headers.set("Cache-Control", "no-store");
47
+ return response;
48
+ }
49
+
50
+ function redirectToSignIn(request: NextRequest, search: string) {
51
+ const origin = resolveRequestOrigin(request);
52
+ const signInUrl = new URL("/sign-in", origin);
53
+ signInUrl.searchParams.set("redirect", `${request.nextUrl.pathname}${search}`);
54
+ return redirectNoStore(signInUrl);
55
+ }
56
+
57
+ export async function GET(request: NextRequest) {
58
+ const params = request.nextUrl.searchParams;
59
+ const normalizedPath = normalizeDraftRedirectPath(params.get("path") ?? "/");
60
+
61
+ if (!normalizedPath) {
62
+ return NextResponse.json({ error: "Invalid path." }, { status: 400 });
63
+ }
64
+
65
+ const target = resolveDraftPathTarget(normalizedPath);
66
+ if (!target) {
67
+ return NextResponse.json({ error: "Unsupported target path." }, { status: 400 });
68
+ }
69
+
70
+ const wantsDraft = params.get("draft") === "1";
71
+
72
+ const auth = await getCurrentUserCanEdit();
73
+ if (!auth.user) {
74
+ return redirectToSignIn(request, request.nextUrl.search);
75
+ }
76
+ if (!auth.canEdit) {
77
+ return NextResponse.json(
78
+ { error: "You do not have permission to preview content." },
79
+ { status: 403 },
80
+ );
81
+ }
82
+
83
+ // Only ever write a language the CMS actually has configured — the value lands
84
+ // in a cookie every public request reads, so it must not be attacker-supplied.
85
+ let locale: string | null = null;
86
+ const requestedLang = params.get("lang")?.trim();
87
+ if (requestedLang) {
88
+ const { data: language } = await (auth.supabase as any)
89
+ .from("languages")
90
+ .select("code")
91
+ .eq("code", requestedLang)
92
+ .maybeSingle();
93
+ locale = (language as { code?: string } | null)?.code ?? null;
94
+ }
95
+
96
+ if (wantsDraft) {
97
+ const draft = await draftMode();
98
+ draft.enable();
99
+ }
100
+
101
+ const response = redirectNoStore(new URL(target.path, resolveRequestOrigin(request)));
102
+
103
+ if (locale) {
104
+ // Session-scoped on purpose: previewing French shouldn't pin the editor's own
105
+ // browsing language for a year. The proxy leaves a matching cookie alone, so
106
+ // this survives the preview and expires with the browser session.
107
+ response.cookies.set(LANGUAGE_COOKIE_KEY, locale, {
108
+ path: "/",
109
+ sameSite: "lax",
110
+ });
111
+ }
112
+
113
+ return response;
114
+ }
@@ -84,14 +84,13 @@ function applyDraftToPost(post: any, draft: ContentDraftRow) {
84
84
  slug: draftString(draft, "slug", post.slug),
85
85
  language_id: languageId,
86
86
  languages: languageId === post.language_id ? post.languages : null,
87
- status: draftString(draft, "status", post.status) as PostType["status"],
87
+ // Visibility is never taken from a draft it lives on the row.
88
88
  meta_title: draftNullableString(draft, "meta_title", post.meta_title),
89
89
  meta_description: draftNullableString(draft, "meta_description", post.meta_description),
90
90
  custom_canonical: draftNullableString(draft, "custom_canonical", post.custom_canonical),
91
91
  label: draftNullableString(draft, "label", post.label),
92
92
  excerpt: draftNullableString(draft, "excerpt", post.excerpt),
93
93
  subtitle: draftNullableString(draft, "subtitle", post.subtitle),
94
- published_at: draftNullableString(draft, "published_at", post.published_at),
95
94
  feature_image_id: draftNullableString(draft, "feature_image_id", post.feature_image_id),
96
95
  translation_group_id: draftString(
97
96
  draft,
@@ -228,7 +228,7 @@ export default function CmsClientLayout({
228
228
  // Fallback for general /cms/settings if no more specific language path matches
229
229
  else if (pathname.startsWith("/cms/settings/logos")) pageTitle = "Branding";
230
230
  else if (pathname.startsWith("/cms/settings/copyright")) pageTitle = "Copyright Settings";
231
- else if (pathname.startsWith("/cms/settings/global-css")) pageTitle = "Global CSS Settings";
231
+ else if (pathname.startsWith("/cms/settings/global-css")) pageTitle = "Themes & CSS";
232
232
  else if (pathname.startsWith("/cms/settings/extra-translations")) pageTitle = "Extra Translations";
233
233
  else if (pathname.startsWith("/cms/settings/backup-restore")) pageTitle = "Backup And Restore";
234
234
  else if (pathname.startsWith("/cms/settings/currencies")) pageTitle = "Currency Settings";
@@ -422,7 +422,7 @@ export default function CmsClientLayout({
422
422
  Copyright
423
423
  </NavItem>
424
424
  <NavItem href="/cms/settings/global-css" icon={Paintbrush} isActive={pathname.startsWith("/cms/settings/global-css")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
425
- Global CSS
425
+ Themes &amp; CSS
426
426
  </NavItem>
427
427
  <NavItem href="/cms/settings/privacy" icon={Cookie} isActive={pathname.startsWith("/cms/settings/privacy")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
428
428
  Privacy &amp; Consent
@@ -6,6 +6,7 @@ import { Button } from '@nextblock-cms/ui';
6
6
  import { PlusCircle, Trash2, Edit2, GripVertical, Image as ImageIcon } from "lucide-react";
7
7
  import { SectionBlockContent } from '../../../../lib/blocks/blockRegistry';
8
8
  import { availableBlockTypes, blockHasEditableContent, getBlockDefinition, getInitialContent, BlockType } from '../../../../lib/blocks/blockRegistry';
9
+ import { resolveTextAlign, resolveTextColor } from '../../../../lib/blocks/blockColors';
9
10
  import { useDroppable } from "@dnd-kit/core";
10
11
  import { useSortable } from "@dnd-kit/sortable";
11
12
  import { CSS } from "@dnd-kit/utilities";
@@ -136,9 +137,8 @@ function SortableColumnBlock({ block, index, columnIndex, onEdit, onDelete, bloc
136
137
  case 'heading': {
137
138
  const content = block.content as any;
138
139
  const level = content.level || 1;
139
- const headingAlign = content.textAlign || 'left';
140
140
  const textColor = content.textColor || 'foreground';
141
-
141
+
142
142
  const sizeClasses: Record<number, string> = {
143
143
  1: "text-4xl font-extrabold",
144
144
  2: "text-3xl font-bold",
@@ -148,32 +148,27 @@ function SortableColumnBlock({ block, index, columnIndex, onEdit, onDelete, bloc
148
148
  6: "text-base font-semibold",
149
149
  };
150
150
 
151
- const colorClasses: Record<string, string> = {
152
- primary: "text-primary",
153
- secondary: "text-secondary",
154
- accent: "text-accent",
155
- destructive: "text-destructive",
156
- muted: "text-muted-foreground",
157
- background: "text-background",
158
- foreground: "text-foreground"
159
- };
160
-
161
- // Override for dark background if color is basic
162
- let appliedColorClass = colorClasses[textColor] || "text-foreground";
163
- if (isDarkBackground) {
151
+ // Shared with HeadingBlockRenderer so the preview cannot drift.
152
+ const { className: resolvedColorClass, style: colorStyle } = resolveTextColor(textColor);
153
+ let appliedColorClass = resolvedColorClass ?? (colorStyle ? undefined : "text-foreground");
154
+ // A custom colour is literal — only the theme tokens get the dark-surface
155
+ // legibility override, since only they are ambiguous against it.
156
+ if (isDarkBackground && !colorStyle) {
164
157
  if (textColor === 'foreground') appliedColorClass = 'text-white/90';
165
158
  if (textColor === 'muted') appliedColorClass = 'text-white/70';
166
159
  }
167
160
 
168
161
  return (
169
162
  <div className="flex gap-3">
170
- <div className={cn(
171
- "w-full leading-tight",
172
- sizeClasses[level] || sizeClasses[1],
173
- appliedColorClass,
174
- headingAlign === 'center' && 'text-center',
175
- headingAlign === 'right' && 'text-right'
176
- )}>
163
+ <div
164
+ style={colorStyle}
165
+ className={cn(
166
+ "w-full leading-tight",
167
+ sizeClasses[level] || sizeClasses[1],
168
+ appliedColorClass,
169
+ resolveTextAlign(content.textAlign)
170
+ )}
171
+ >
177
172
  {content.text_content || <span className={cn("text-muted-foreground font-normal text-sm italic", isDarkBackground && "text-white/50")}>Empty heading</span>}
178
173
  </div>
179
174
  </div>
@@ -9,6 +9,7 @@ type Block = Database['public']['Tables']['blocks']['Row'];
9
9
  import { Button, Card, CardContent, Avatar, AvatarImage, AvatarFallback } from "@nextblock-cms/ui";
10
10
  import { GripVertical, Edit2, Image as ImageIcon, MessageSquareQuote } from "lucide-react";
11
11
  import { blockHasEditableContent, getBlockDefinition, blockRegistry, BlockType } from '../../../../lib/blocks/blockRegistry';
12
+ import { resolveTextAlign, resolveTextColor } from '../../../../lib/blocks/blockColors';
12
13
  import { BlockEditorModal } from './BlockEditorModal';
13
14
  import { DeleteBlockButtonClient } from './DeleteBlockButtonClient';
14
15
  import { cn } from '@nextblock-cms/utils';
@@ -161,9 +162,7 @@ export default function EditableBlock({
161
162
  case 'heading': {
162
163
  const content = (block.content || {}) as any;
163
164
  const level = content.level || 1;
164
- const headingAlign = content.textAlign || 'left';
165
- const textColor = content.textColor || 'foreground';
166
-
165
+
167
166
  const sizeClasses: Record<number, string> = {
168
167
  1: "text-4xl font-extrabold",
169
168
  2: "text-3xl font-bold",
@@ -173,25 +172,20 @@ export default function EditableBlock({
173
172
  6: "text-base font-semibold",
174
173
  };
175
174
 
176
- const colorClasses: Record<string, string> = {
177
- primary: "text-primary",
178
- secondary: "text-secondary",
179
- accent: "text-accent",
180
- destructive: "text-destructive",
181
- muted: "text-muted-foreground",
182
- background: "text-background",
183
- foreground: "text-foreground"
184
- };
175
+ // Shared with HeadingBlockRenderer so the preview cannot drift.
176
+ const { className: colorClass, style: colorStyle } = resolveTextColor(content.textColor);
185
177
 
186
178
  return (
187
179
  <div className="py-2">
188
- <div className={cn(
189
- "w-full leading-tight",
190
- sizeClasses[level] || sizeClasses[1],
191
- colorClasses[textColor] || "text-foreground",
192
- headingAlign === 'center' && 'text-center',
193
- headingAlign === 'right' && 'text-right'
194
- )}>
180
+ <div
181
+ style={colorStyle}
182
+ className={cn(
183
+ "w-full leading-tight",
184
+ sizeClasses[level] || sizeClasses[1],
185
+ colorClass ?? (colorStyle ? undefined : "text-foreground"),
186
+ resolveTextAlign(content.textAlign)
187
+ )}
188
+ >
195
189
  {content.text_content || <span className="text-muted-foreground italic text-sm font-normal">Empty heading</span>}
196
190
  </div>
197
191
  </div>
@@ -4,11 +4,27 @@
4
4
  import React from "react";
5
5
  import { Label } from "@nextblock-cms/ui";
6
6
  import { Input } from "@nextblock-cms/ui";
7
+ import { ColorField } from "@nextblock-cms/ui";
7
8
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@nextblock-cms/ui";
8
9
  import { HeadingBlockContent } from '../../../../lib/blocks/blockRegistry';
10
+ import { TEXT_COLOR_TOKEN_OPTIONS } from '../../../../lib/blocks/blockColors';
9
11
  import { BlockEditorProps } from '../components/BlockEditorModal';
10
12
 
11
- export default function HeadingBlockEditor({ content, onChange }: BlockEditorProps<Partial<HeadingBlockContent>>) {
13
+ /** Handy starting points that are not in the theme — brand-neutral and legible. */
14
+ const HEADING_COLOR_PRESETS = [
15
+ '#0F172A',
16
+ '#334155',
17
+ '#DC2626',
18
+ '#EA580C',
19
+ '#CA8A04',
20
+ '#16A34A',
21
+ '#0891B2',
22
+ '#2563EB',
23
+ '#7C3AED',
24
+ '#DB2777',
25
+ ] as const;
26
+
27
+ export default function HeadingBlockEditor({ content, onChange, sectionBackground }: BlockEditorProps<Partial<HeadingBlockContent>>) {
12
28
  const idPrefix = React.useId();
13
29
 
14
30
  const handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -21,24 +37,26 @@ export default function HeadingBlockEditor({ content, onChange }: BlockEditorPro
21
37
 
22
38
  const textAlignOptions = ['left', 'center', 'right', 'justify'] as const;
23
39
 
24
- const textColorOptions = [
25
- { value: 'primary', label: 'Primary', swatchClass: 'bg-primary' },
26
- { value: 'secondary', label: 'Secondary', swatchClass: 'bg-secondary' },
27
- { value: 'accent', label: 'Accent', swatchClass: 'bg-accent' },
28
- { value: 'muted', label: 'Muted', swatchClass: 'bg-muted-foreground' }, // Using muted-foreground for swatch as text-muted is for text
29
- { value: 'destructive', label: 'Destructive', swatchClass: 'bg-destructive' },
30
- { value: 'background', label: 'Background', swatchClass: 'bg-background' },
31
- ] as const;
32
-
33
40
  const handleTextAlignChange = (value: string) => {
34
41
  onChange({ ...content, textAlign: value as HeadingBlockContent['textAlign'] });
35
42
  };
36
43
 
37
- const handleTextColorChange = (value: string) => {
38
- const newTextColor = value === "" ? undefined : value as HeadingBlockContent['textColor'];
39
- onChange({ ...content, textColor: newTextColor });
44
+ const handleTextColorChange = (value: string | undefined) => {
45
+ onChange({ ...content, textColor: value as HeadingBlockContent['textColor'] });
40
46
  };
41
47
 
48
+ // Measure contrast against the real section backdrop when there is one, so the
49
+ // warning reflects what the visitor will actually see.
50
+ const contrastAgainst = React.useMemo(() => {
51
+ if (sectionBackground?.type === 'solid') return sectionBackground.solid_color;
52
+ if (sectionBackground?.type === 'theme' && sectionBackground.theme) {
53
+ return `hsl(var(--${sectionBackground.theme}))`;
54
+ }
55
+ if (!sectionBackground || sectionBackground.type === 'none') return 'hsl(var(--background))';
56
+ // Gradients and images have no single backdrop colour to measure against.
57
+ return undefined;
58
+ }, [sectionBackground]);
59
+
42
60
  return (
43
61
  <div className="space-y-3 p-3 border-t mt-2">
44
62
  <div>
@@ -85,27 +103,20 @@ export default function HeadingBlockEditor({ content, onChange }: BlockEditorPro
85
103
  </SelectContent>
86
104
  </Select>
87
105
  </div>
88
- <div>
89
- <Label htmlFor={`heading-text-color-${idPrefix}`}>Text Color</Label>
90
- <Select
91
- value={content.textColor || ""} // Use empty string if no color is selected initially
92
- onValueChange={handleTextColorChange}
93
- >
94
- <SelectTrigger id={`heading-text-color-${idPrefix}`} className="mt-1">
95
- <SelectValue placeholder="Select color (optional)" />
96
- </SelectTrigger>
97
- <SelectContent>
98
- {textColorOptions.map(color => (
99
- <SelectItem key={color.value} value={color.value}>
100
- <div className="flex items-center">
101
- <div className={`w-4 h-4 rounded-sm mr-2 border ${color.swatchClass}`}></div>
102
- {color.label}
103
- </div>
104
- </SelectItem>
105
- ))}
106
- </SelectContent>
107
- </Select>
108
- </div>
106
+ <ColorField
107
+ id={`heading-text-color-${idPrefix}`}
108
+ label="Text Color"
109
+ description="Theme colours follow light/dark mode. Custom colours stay fixed."
110
+ value={content.textColor}
111
+ onChange={handleTextColorChange}
112
+ tokens={TEXT_COLOR_TOKEN_OPTIONS}
113
+ presets={HEADING_COLOR_PRESETS}
114
+ contrastAgainst={contrastAgainst}
115
+ largeText
116
+ enableAlpha
117
+ clearLabel="Inherit from theme"
118
+ placeholder="Inherit from theme"
119
+ />
109
120
  </div>
110
121
  );
111
122
  }
@@ -45,8 +45,18 @@ export default function DraftStatusActions({
45
45
  );
46
46
  }
47
47
 
48
+ const warning = res && "success" in res ? res.warning : undefined;
49
+
48
50
  if (res && "error" in res && res.error) {
49
51
  toast.error(`Publish failed: ${res.error}`, { id: toastId });
52
+ } else if (warning) {
53
+ // The content IS live — only the revision failed to record. Say so plainly
54
+ // rather than claiming an unqualified success.
55
+ toast.error(warning, { id: toastId, duration: 8000 });
56
+ router.refresh();
57
+ setTimeout(() => {
58
+ window.location.reload();
59
+ }, 800);
50
60
  } else {
51
61
  toast.success("Changes published live successfully!", { id: toastId });
52
62
  router.refresh();
@@ -0,0 +1,62 @@
1
+ import { Badge } from "@nextblock-cms/ui";
2
+ import {
3
+ LIVE_STATUS,
4
+ resolveVisibilityState,
5
+ type PublishableType,
6
+ type VisibilityState,
7
+ } from "@nextblock-cms/utils";
8
+
9
+ /**
10
+ * Status badge for the CMS list views.
11
+ *
12
+ * Reads the same (status, published_at) pair as the editor's top-bar control, so a
13
+ * scheduled row is labelled "Scheduled" here instead of claiming to be published
14
+ * while its URL still 404s.
15
+ */
16
+
17
+ const LABEL: Record<VisibilityState, string> = {
18
+ draft: "Draft",
19
+ scheduled: "Scheduled",
20
+ published: "Published",
21
+ archived: "Archived",
22
+ };
23
+
24
+ const CLASS_NAME: Record<VisibilityState, string> = {
25
+ published:
26
+ "bg-green-100 text-green-700 dark:bg-green-700/30 dark:text-green-300 dark:border-green-700/50",
27
+ scheduled:
28
+ "bg-amber-100 text-amber-700 dark:bg-amber-700/30 dark:text-amber-300 dark:border-amber-700/50",
29
+ draft:
30
+ "bg-yellow-100 text-yellow-700 dark:bg-yellow-700/30 dark:text-yellow-300 dark:border-yellow-700/50",
31
+ archived:
32
+ "bg-slate-100 text-slate-700 dark:bg-slate-700/30 dark:text-slate-300 dark:border-slate-600",
33
+ };
34
+
35
+ const VARIANT: Record<VisibilityState, "default" | "secondary" | "destructive"> = {
36
+ published: "default",
37
+ scheduled: "secondary",
38
+ draft: "secondary",
39
+ archived: "destructive",
40
+ };
41
+
42
+ export default function VisibilityBadge({
43
+ type,
44
+ status,
45
+ publishedAt,
46
+ }: {
47
+ type: PublishableType;
48
+ status: string;
49
+ publishedAt?: string | null;
50
+ }) {
51
+ const state = resolveVisibilityState({
52
+ status,
53
+ publishedAt,
54
+ liveStatus: LIVE_STATUS[type],
55
+ });
56
+
57
+ return (
58
+ <Badge variant={VARIANT[state]} className={CLASS_NAME[state]}>
59
+ {LABEL[state]}
60
+ </Badge>
61
+ );
62
+ }