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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/[slug]/page.tsx +7 -2
- package/templates/nextblock-template/app/[slug]/page.utils.ts +8 -3
- package/templates/nextblock-template/app/actions/postActions.ts +3 -0
- package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -0
- package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +83 -3
- package/templates/nextblock-template/app/actions/visualEditingActions.ts +34 -14
- package/templates/nextblock-template/app/api/ai/global-agent/route.ts +45 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +736 -117
- package/templates/nextblock-template/app/api/view/route.ts +114 -0
- package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -2
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +2 -2
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +17 -22
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +13 -19
- package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +45 -34
- package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +10 -0
- package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
- package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
- package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
- package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
- package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
- package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
- package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
- package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
- package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
- package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
- package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -0
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -0
- package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -24
- package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -0
- package/templates/nextblock-template/app/layout.tsx +49 -0
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +6 -4
- package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
- package/templates/nextblock-template/app/page.tsx +6 -3
- package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
- package/templates/nextblock-template/app/providers.tsx +16 -4
- package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +7 -10
- package/templates/nextblock-template/components/theme-icon.tsx +78 -0
- package/templates/nextblock-template/components/theme-switcher.tsx +85 -90
- package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
- package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -0
- package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -0
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +7 -2
- package/templates/nextblock-template/lib/blocks/blockColors.test.ts +114 -0
- package/templates/nextblock-template/lib/blocks/blockColors.ts +132 -0
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +17 -1
- package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
- package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
- package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
- package/templates/nextblock-template/lib/search/server.ts +3 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +102 -87
- package/templates/nextblock-template/lib/themes/buildThemeCss.test.ts +163 -0
- package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -0
- package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -0
- package/templates/nextblock-template/lib/themes/tokens.ts +143 -0
- package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
- package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
- package/templates/nextblock-template/next-env.d.ts +2 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/scripts/verify-site-themes.ts +63 -0
- 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
|
-
|
|
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 = "
|
|
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
|
-
|
|
425
|
+
Themes & 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 & 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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
}
|