create-nextblock 0.11.2 → 0.12.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/package.json +1 -1
- package/templates/nextblock-template/app/(auth-pages)/sign-up/SignUpForm.tsx +141 -0
- package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +40 -122
- package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +5 -0
- package/templates/nextblock-template/app/[slug]/page.tsx +5 -2
- package/templates/nextblock-template/app/[slug]/page.utils.ts +10 -9
- package/templates/nextblock-template/app/actions/email.ts +8 -1
- package/templates/nextblock-template/app/actions/feedback.ts +1 -0
- package/templates/nextblock-template/app/actions/formActions.ts +20 -97
- package/templates/nextblock-template/app/actions/interactions.ts +3 -2
- package/templates/nextblock-template/app/actions/twoFactorEmail.ts +3 -2
- package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +1 -1
- package/templates/nextblock-template/app/actions/visualEditingActions.ts +2 -0
- package/templates/nextblock-template/app/actions.ts +14 -0
- package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +47 -1
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4970 -8566
- package/templates/nextblock-template/app/article/[slug]/page.tsx +5 -2
- package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -0
- package/templates/nextblock-template/app/cms/pages/actions.ts +22 -18
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +20 -2
- package/templates/nextblock-template/app/cms/posts/actions.ts +5 -0
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +137 -133
- package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +36 -0
- package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +26 -1
- package/templates/nextblock-template/app/cms/settings/copyright/page.tsx +6 -2
- package/templates/nextblock-template/app/cms/settings/email/actions.ts +7 -3
- package/templates/nextblock-template/app/cms/settings/logos/actions.ts +29 -14
- package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -0
- package/templates/nextblock-template/app/cms/settings/logos/page.tsx +18 -5
- package/templates/nextblock-template/app/layout.tsx +29 -17
- package/templates/nextblock-template/app/lib/seo.test.ts +36 -0
- package/templates/nextblock-template/app/lib/seo.ts +32 -0
- package/templates/nextblock-template/app/product/[slug]/page.tsx +5 -2
- package/templates/nextblock-template/components/AppShell.tsx +16 -1
- package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -0
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +36 -42
- package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +7 -0
- package/templates/nextblock-template/lib/botProtection/verify.ts +134 -0
- package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -0
- package/templates/nextblock-template/lib/email/branding-format.ts +123 -0
- package/templates/nextblock-template/lib/email/branding.ts +76 -0
- package/templates/nextblock-template/lib/logos/active-logo.ts +53 -0
- package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +1 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +8 -203
- package/templates/nextblock-template/lib/visual-editing/draft-content.ts +4 -2
- package/templates/nextblock-template/lib/visual-editing/mutations.ts +2 -2
- package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +1 -0
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +16 -0
- package/templates/nextblock-template/public/images/nextblock-logo-button-tiny.png +0 -0
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
resolvePostMetaDescription,
|
|
15
15
|
stringifyJsonLd,
|
|
16
16
|
buildSocialMetadata,
|
|
17
|
+
buildCanonicalUrl,
|
|
17
18
|
toOpenGraphLocale,
|
|
18
19
|
} from '../../lib/seo';
|
|
19
20
|
import { getSiteSettings } from '../../lib/site-settings';
|
|
@@ -110,6 +111,8 @@ export async function generateMetadata(
|
|
|
110
111
|
const title = resolveMetaTitle(postData.meta_title, postData.title);
|
|
111
112
|
const description = resolvePostMetaDescription(postData.meta_description, postData.subtitle);
|
|
112
113
|
const { siteTitle } = await getSiteSettings();
|
|
114
|
+
// Self-referencing `<siteUrl>/article/<slug>` unless the post sets a manual custom_canonical override.
|
|
115
|
+
const canonicalUrl = buildCanonicalUrl(postData.custom_canonical, siteUrl, `/article/${params.slug}`);
|
|
113
116
|
|
|
114
117
|
return {
|
|
115
118
|
title,
|
|
@@ -117,7 +120,7 @@ export async function generateMetadata(
|
|
|
117
120
|
...buildSocialMetadata({
|
|
118
121
|
title,
|
|
119
122
|
description,
|
|
120
|
-
url:
|
|
123
|
+
url: canonicalUrl,
|
|
121
124
|
siteTitle,
|
|
122
125
|
imageUrl: postData.feature_image_url,
|
|
123
126
|
type: 'article',
|
|
@@ -125,7 +128,7 @@ export async function generateMetadata(
|
|
|
125
128
|
locale: toOpenGraphLocale(postData.language_code),
|
|
126
129
|
}),
|
|
127
130
|
alternates: {
|
|
128
|
-
canonical:
|
|
131
|
+
canonical: canonicalUrl,
|
|
129
132
|
languages: Object.keys(alternates).length > 0 ? alternates : undefined,
|
|
130
133
|
},
|
|
131
134
|
};
|
|
@@ -87,6 +87,7 @@ function applyDraftToPost(post: any, draft: ContentDraftRow) {
|
|
|
87
87
|
status: draftString(draft, "status", post.status) as PostType["status"],
|
|
88
88
|
meta_title: draftNullableString(draft, "meta_title", post.meta_title),
|
|
89
89
|
meta_description: draftNullableString(draft, "meta_description", post.meta_description),
|
|
90
|
+
custom_canonical: draftNullableString(draft, "custom_canonical", post.custom_canonical),
|
|
90
91
|
label: draftNullableString(draft, "label", post.label),
|
|
91
92
|
excerpt: draftNullableString(draft, "excerpt", post.excerpt),
|
|
92
93
|
subtitle: draftNullableString(draft, "subtitle", post.subtitle),
|
|
@@ -40,6 +40,7 @@ export async function createPage(formData: FormData) {
|
|
|
40
40
|
status: formData.get("status") as PageStatus,
|
|
41
41
|
meta_title: formData.get("meta_title") as string || null,
|
|
42
42
|
meta_description: formData.get("meta_description") as string || null,
|
|
43
|
+
custom_canonical: formData.get("custom_canonical") as string || null,
|
|
43
44
|
feature_image_id: getOptionalFeatureImageId(formData),
|
|
44
45
|
};
|
|
45
46
|
|
|
@@ -123,6 +124,7 @@ export async function updatePage(pageId: number, formData: FormData) {
|
|
|
123
124
|
status: formData.get("status") as PageStatus,
|
|
124
125
|
meta_title: formData.get("meta_title") as string || null,
|
|
125
126
|
meta_description: formData.get("meta_description") as string || null,
|
|
127
|
+
custom_canonical: formData.get("custom_canonical") as string || null,
|
|
126
128
|
feature_image_id: getOptionalFeatureImageId(formData),
|
|
127
129
|
};
|
|
128
130
|
|
|
@@ -137,6 +139,7 @@ export async function updatePage(pageId: number, formData: FormData) {
|
|
|
137
139
|
status: rawFormData.status,
|
|
138
140
|
meta_title: rawFormData.meta_title,
|
|
139
141
|
meta_description: rawFormData.meta_description,
|
|
142
|
+
custom_canonical: rawFormData.custom_canonical,
|
|
140
143
|
feature_image_id: rawFormData.feature_image_id,
|
|
141
144
|
};
|
|
142
145
|
|
|
@@ -147,24 +150,24 @@ export async function updatePage(pageId: number, formData: FormData) {
|
|
|
147
150
|
...pageUpdateData,
|
|
148
151
|
};
|
|
149
152
|
|
|
150
|
-
const { error: updateError } = await supabase
|
|
151
|
-
.from("content_drafts")
|
|
152
|
-
.update({ meta: updatedMeta as any })
|
|
153
|
-
.eq("id", draft.id);
|
|
154
|
-
|
|
155
|
-
if (updateError) {
|
|
156
|
-
console.error("Error updating page draft:", updateError);
|
|
157
|
-
if (updateError.code === '23505' && updateError.message.includes('pages_language_id_slug_key')) {
|
|
158
|
-
return { error: `The slug "${pageUpdateData.slug}" already exists for the selected language. Please use a unique slug.` };
|
|
159
|
-
}
|
|
160
|
-
return { error: `Failed to update draft: ${updateError.message}` };
|
|
161
|
-
}
|
|
162
|
-
} catch (err: any) {
|
|
163
|
-
console.error("Error loading/creating draft for page metadata update:", err);
|
|
164
|
-
return { error: `Failed to load draft: ${err.message || err}` };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
revalidatePath("/cms/pages");
|
|
153
|
+
const { error: updateError } = await supabase
|
|
154
|
+
.from("content_drafts")
|
|
155
|
+
.update({ meta: updatedMeta as any })
|
|
156
|
+
.eq("id", draft.id);
|
|
157
|
+
|
|
158
|
+
if (updateError) {
|
|
159
|
+
console.error("Error updating page draft:", updateError);
|
|
160
|
+
if (updateError.code === '23505' && updateError.message.includes('pages_language_id_slug_key')) {
|
|
161
|
+
return { error: `The slug "${pageUpdateData.slug}" already exists for the selected language. Please use a unique slug.` };
|
|
162
|
+
}
|
|
163
|
+
return { error: `Failed to update draft: ${updateError.message}` };
|
|
164
|
+
}
|
|
165
|
+
} catch (err: any) {
|
|
166
|
+
console.error("Error loading/creating draft for page metadata update:", err);
|
|
167
|
+
return { error: `Failed to load draft: ${err.message || err}` };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
revalidatePath("/cms/pages");
|
|
168
171
|
revalidatePublicPageSlug(existingPage.slug);
|
|
169
172
|
if (rawFormData.slug && rawFormData.slug !== existingPage.slug) {
|
|
170
173
|
revalidatePublicPageSlug(rawFormData.slug);
|
|
@@ -252,6 +255,7 @@ type UpsertPagePayload = {
|
|
|
252
255
|
status: PageStatus;
|
|
253
256
|
meta_title?: string | null;
|
|
254
257
|
meta_description?: string | null;
|
|
258
|
+
custom_canonical?: string | null;
|
|
255
259
|
feature_image_id?: string | null;
|
|
256
260
|
translation_group_id: string; // UUID
|
|
257
261
|
};
|
|
@@ -81,6 +81,7 @@ export default function PageForm({
|
|
|
81
81
|
const [metaDescription, setMetaDescription] = useState(
|
|
82
82
|
page?.meta_description || ""
|
|
83
83
|
);
|
|
84
|
+
const [customCanonical, setCustomCanonical] = useState(page?.custom_canonical || "");
|
|
84
85
|
const [featureImageId, setFeatureImageId] = useState<string | null>(
|
|
85
86
|
initialFeatureImageId || page?.feature_image_id || null
|
|
86
87
|
);
|
|
@@ -120,10 +121,12 @@ export default function PageForm({
|
|
|
120
121
|
setStatus(page.status || "draft");
|
|
121
122
|
setMetaTitle(page.meta_title || "");
|
|
122
123
|
setMetaDescription(page.meta_description || "");
|
|
124
|
+
setCustomCanonical(page.custom_canonical || "");
|
|
123
125
|
setFeatureImageId(initialFeatureImageId || page.feature_image_id || null);
|
|
124
126
|
}, [
|
|
125
127
|
initialFeatureImageId,
|
|
126
128
|
page?.id,
|
|
129
|
+
page?.custom_canonical,
|
|
127
130
|
page?.language_id,
|
|
128
131
|
page?.meta_description,
|
|
129
132
|
page?.meta_title,
|
|
@@ -156,6 +159,7 @@ export default function PageForm({
|
|
|
156
159
|
formData.append("status", status);
|
|
157
160
|
formData.append("meta_title", metaTitle);
|
|
158
161
|
formData.append("meta_description", metaDescription);
|
|
162
|
+
formData.append("custom_canonical", customCanonical);
|
|
159
163
|
formData.append("feature_image_id", featureImageId || "");
|
|
160
164
|
if (translationGroupId) {
|
|
161
165
|
formData.append("translation_group_id", translationGroupId);
|
|
@@ -213,6 +217,7 @@ export default function PageForm({
|
|
|
213
217
|
status !== (page?.status || "draft") ||
|
|
214
218
|
metaTitle !== (page?.meta_title || "") ||
|
|
215
219
|
metaDescription !== (page?.meta_description || "") ||
|
|
220
|
+
customCanonical !== (page?.custom_canonical || "") ||
|
|
216
221
|
featureImageId !== (page?.feature_image_id || null);
|
|
217
222
|
|
|
218
223
|
if (!hasChanges) return;
|
|
@@ -347,7 +352,7 @@ export default function PageForm({
|
|
|
347
352
|
</div>
|
|
348
353
|
</div>
|
|
349
354
|
|
|
350
|
-
{/* Row 2: SEO Settings */}
|
|
355
|
+
{/* Row 2: SEO Settings. Canonical override (optional): blank = self-referencing canonical. */}
|
|
351
356
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
|
|
352
357
|
{/* Meta Title */}
|
|
353
358
|
<div className="md:col-span-4 flex flex-col gap-1">
|
|
@@ -362,7 +367,7 @@ export default function PageForm({
|
|
|
362
367
|
</div>
|
|
363
368
|
|
|
364
369
|
{/* Meta Description */}
|
|
365
|
-
<div className="md:col-span-
|
|
370
|
+
<div className="md:col-span-4 flex flex-col gap-1">
|
|
366
371
|
<Label htmlFor="meta_description" className="text-xs font-medium">Meta Description (SEO)</Label>
|
|
367
372
|
<Textarea
|
|
368
373
|
id="meta_description"
|
|
@@ -374,6 +379,19 @@ export default function PageForm({
|
|
|
374
379
|
placeholder="Meta description for search engines..."
|
|
375
380
|
/>
|
|
376
381
|
</div>
|
|
382
|
+
|
|
383
|
+
{/* Canonical URL */}
|
|
384
|
+
<div className="md:col-span-4 flex flex-col gap-1">
|
|
385
|
+
<Label htmlFor="custom_canonical" className="text-xs font-medium">Canonical URL (SEO, optional)</Label>
|
|
386
|
+
<Input
|
|
387
|
+
id="custom_canonical"
|
|
388
|
+
name="custom_canonical"
|
|
389
|
+
value={customCanonical}
|
|
390
|
+
onChange={(e) => setCustomCanonical(e.target.value)}
|
|
391
|
+
className="h-9"
|
|
392
|
+
placeholder="Blank = self-referencing. Absolute https://… URL or /relative path to override."
|
|
393
|
+
/>
|
|
394
|
+
</div>
|
|
377
395
|
</div>
|
|
378
396
|
|
|
379
397
|
<FeatureImageField
|
|
@@ -36,6 +36,7 @@ export async function createPost(formData: FormData) {
|
|
|
36
36
|
published_at: formData.get("published_at") as string || null,
|
|
37
37
|
meta_title: formData.get("meta_title") as string || null,
|
|
38
38
|
meta_description: formData.get("meta_description") as string || null,
|
|
39
|
+
custom_canonical: formData.get("custom_canonical") as string || null,
|
|
39
40
|
feature_image_id: featureImageId_create,
|
|
40
41
|
};
|
|
41
42
|
|
|
@@ -99,6 +100,7 @@ export async function createPost(formData: FormData) {
|
|
|
99
100
|
subtitle: `Placeholder for ${lang.code.toUpperCase()} translation. Original subtitle: ${newPost.subtitle || ''}`.substring(0, 500),
|
|
100
101
|
meta_title: null,
|
|
101
102
|
meta_description: null,
|
|
103
|
+
custom_canonical: null,
|
|
102
104
|
translation_group_id: newPost.translation_group_id,
|
|
103
105
|
author_id: user.id,
|
|
104
106
|
};
|
|
@@ -160,6 +162,7 @@ export async function updatePost(postId: number, formData: FormData) {
|
|
|
160
162
|
published_at: formData.get("published_at") as string || null,
|
|
161
163
|
meta_title: formData.get("meta_title") as string || null,
|
|
162
164
|
meta_description: formData.get("meta_description") as string || null,
|
|
165
|
+
custom_canonical: formData.get("custom_canonical") as string || null,
|
|
163
166
|
feature_image_id: featureImageId_update,
|
|
164
167
|
};
|
|
165
168
|
|
|
@@ -188,6 +191,7 @@ export async function updatePost(postId: number, formData: FormData) {
|
|
|
188
191
|
published_at: publishedAtISO,
|
|
189
192
|
meta_title: rawFormData.meta_title,
|
|
190
193
|
meta_description: rawFormData.meta_description,
|
|
194
|
+
custom_canonical: rawFormData.custom_canonical,
|
|
191
195
|
feature_image_id: rawFormData.feature_image_id,
|
|
192
196
|
};
|
|
193
197
|
|
|
@@ -306,6 +310,7 @@ type UpsertPostPayload = {
|
|
|
306
310
|
published_at?: string | null;
|
|
307
311
|
meta_title?: string | null;
|
|
308
312
|
meta_description?: string | null;
|
|
313
|
+
custom_canonical?: string | null;
|
|
309
314
|
translation_group_id: string;
|
|
310
315
|
feature_image_id?: string | null;
|
|
311
316
|
};
|
|
@@ -84,12 +84,13 @@ export default function PostForm({
|
|
|
84
84
|
formatDateTimeLocal(post?.published_at)
|
|
85
85
|
);
|
|
86
86
|
const [metaTitle, setMetaTitle] = useState(post?.meta_title || "");
|
|
87
|
-
const [metaDescription, setMetaDescription] = useState(
|
|
88
|
-
post?.meta_description || ""
|
|
89
|
-
);
|
|
90
|
-
const [
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
const [metaDescription, setMetaDescription] = useState(
|
|
88
|
+
post?.meta_description || ""
|
|
89
|
+
);
|
|
90
|
+
const [customCanonical, setCustomCanonical] = useState(post?.custom_canonical || "");
|
|
91
|
+
const [featureImageId, setFeatureImageId] = useState<string | null>(
|
|
92
|
+
initialFeatureImageId || post?.feature_image_id || null
|
|
93
|
+
);
|
|
93
94
|
|
|
94
95
|
// Use the passed-in languages directly
|
|
95
96
|
const [availableLanguages] = useState<Language[]>(availableLanguagesProp);
|
|
@@ -126,14 +127,16 @@ export default function PostForm({
|
|
|
126
127
|
setStatus(post.status || "draft");
|
|
127
128
|
setExcerpt(post.excerpt || "");
|
|
128
129
|
setSubtitle(post.subtitle || "");
|
|
129
|
-
setPublishedAt(formatDateTimeLocal(post.published_at));
|
|
130
|
-
setMetaTitle(post.meta_title || "");
|
|
131
|
-
setMetaDescription(post.meta_description || "");
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
post?.
|
|
130
|
+
setPublishedAt(formatDateTimeLocal(post.published_at));
|
|
131
|
+
setMetaTitle(post.meta_title || "");
|
|
132
|
+
setMetaDescription(post.meta_description || "");
|
|
133
|
+
setCustomCanonical(post.custom_canonical || "");
|
|
134
|
+
setFeatureImageId(initialFeatureImageId || post.feature_image_id || null);
|
|
135
|
+
}, [
|
|
136
|
+
initialFeatureImageId,
|
|
137
|
+
post?.custom_canonical,
|
|
138
|
+
post?.excerpt,
|
|
139
|
+
post?.id,
|
|
137
140
|
post?.label,
|
|
138
141
|
post?.language_id,
|
|
139
142
|
post?.meta_description,
|
|
@@ -142,9 +145,9 @@ export default function PostForm({
|
|
|
142
145
|
post?.slug,
|
|
143
146
|
post?.status,
|
|
144
147
|
post?.subtitle,
|
|
145
|
-
post?.title,
|
|
146
|
-
post?.updated_at,
|
|
147
|
-
]);
|
|
148
|
+
post?.title,
|
|
149
|
+
post?.updated_at,
|
|
150
|
+
]);
|
|
148
151
|
|
|
149
152
|
// Initialize languageId if creating new post and languages are available
|
|
150
153
|
useEffect(() => {
|
|
@@ -172,20 +175,21 @@ export default function PostForm({
|
|
|
172
175
|
setIsSaving(true);
|
|
173
176
|
setSaveError(null);
|
|
174
177
|
|
|
175
|
-
const formData = customFormData || (formRef.current ? new FormData(formRef.current) : new FormData());
|
|
176
|
-
if (!customFormData && !formRef.current) {
|
|
177
|
-
formData.append("title", title);
|
|
178
|
-
formData.append("slug", slug);
|
|
179
|
-
formData.append("language_id", languageId);
|
|
178
|
+
const formData = customFormData || (formRef.current ? new FormData(formRef.current) : new FormData());
|
|
179
|
+
if (!customFormData && !formRef.current) {
|
|
180
|
+
formData.append("title", title);
|
|
181
|
+
formData.append("slug", slug);
|
|
182
|
+
formData.append("language_id", languageId);
|
|
180
183
|
formData.append("label", label);
|
|
181
184
|
formData.append("status", status);
|
|
182
185
|
formData.append("excerpt", excerpt);
|
|
183
186
|
formData.append("subtitle", subtitle);
|
|
184
|
-
formData.append("published_at", publishedAt);
|
|
185
|
-
formData.append("meta_title", metaTitle);
|
|
186
|
-
formData.append("meta_description", metaDescription);
|
|
187
|
-
formData.append("
|
|
188
|
-
|
|
187
|
+
formData.append("published_at", publishedAt);
|
|
188
|
+
formData.append("meta_title", metaTitle);
|
|
189
|
+
formData.append("meta_description", metaDescription);
|
|
190
|
+
formData.append("custom_canonical", customCanonical);
|
|
191
|
+
formData.append("feature_image_id", featureImageId || "");
|
|
192
|
+
}
|
|
189
193
|
|
|
190
194
|
try {
|
|
191
195
|
const result = await formAction(formData);
|
|
@@ -241,10 +245,11 @@ export default function PostForm({
|
|
|
241
245
|
status !== (post?.status || "draft") ||
|
|
242
246
|
excerpt !== (post?.excerpt || "") ||
|
|
243
247
|
subtitle !== (post?.subtitle || "") ||
|
|
244
|
-
publishedAt !== dbPublishedAt ||
|
|
245
|
-
metaTitle !== (post?.meta_title || "") ||
|
|
246
|
-
metaDescription !== (post?.meta_description || "") ||
|
|
247
|
-
|
|
248
|
+
publishedAt !== dbPublishedAt ||
|
|
249
|
+
metaTitle !== (post?.meta_title || "") ||
|
|
250
|
+
metaDescription !== (post?.meta_description || "") ||
|
|
251
|
+
customCanonical !== (post?.custom_canonical || "") ||
|
|
252
|
+
featureImageId !== (post?.feature_image_id || null);
|
|
248
253
|
|
|
249
254
|
if (!hasChanges) return;
|
|
250
255
|
|
|
@@ -261,12 +266,13 @@ export default function PostForm({
|
|
|
261
266
|
status,
|
|
262
267
|
excerpt,
|
|
263
268
|
subtitle,
|
|
264
|
-
publishedAt,
|
|
265
|
-
metaTitle,
|
|
266
|
-
metaDescription,
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
269
|
+
publishedAt,
|
|
270
|
+
metaTitle,
|
|
271
|
+
metaDescription,
|
|
272
|
+
customCanonical,
|
|
273
|
+
featureImageId,
|
|
274
|
+
post,
|
|
275
|
+
isEditing,
|
|
270
276
|
]);
|
|
271
277
|
|
|
272
278
|
// Remove languagesLoading from this condition
|
|
@@ -278,7 +284,7 @@ export default function PostForm({
|
|
|
278
284
|
}
|
|
279
285
|
|
|
280
286
|
return (
|
|
281
|
-
<form ref={formRef} onSubmit={handleSubmit} className="space-y-
|
|
287
|
+
<form ref={formRef} onSubmit={handleSubmit} className="space-y-4 w-full mx-auto px-6">
|
|
282
288
|
{isEditing && (
|
|
283
289
|
<div className="flex items-center justify-between text-xs text-muted-foreground pb-2 border-b border-border/40 mb-2">
|
|
284
290
|
<span className="font-semibold text-[11px] uppercase tracking-wider text-muted-foreground/80">Post Settings</span>
|
|
@@ -308,114 +314,112 @@ export default function PostForm({
|
|
|
308
314
|
<AlertDescription>{formMessage.text}</AlertDescription>
|
|
309
315
|
</Alert>
|
|
310
316
|
)}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
317
|
+
{/* Row 1: Basic Post Information */}
|
|
318
|
+
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
|
|
319
|
+
{/* Title */}
|
|
320
|
+
<div className="md:col-span-4 flex flex-col gap-1">
|
|
321
|
+
<Label htmlFor="title" className="text-xs font-medium">Title</Label>
|
|
322
|
+
<Input id="title" name="title" value={title} onChange={handleTitleChange} required className="h-9" />
|
|
323
|
+
</div>
|
|
315
324
|
|
|
316
|
-
|
|
317
|
-
<
|
|
318
|
-
|
|
319
|
-
|
|
325
|
+
{/* Slug */}
|
|
326
|
+
<div className="md:col-span-4 flex flex-col gap-1">
|
|
327
|
+
<Label htmlFor="slug" className="text-xs font-medium">Slug</Label>
|
|
328
|
+
<Input id="slug" name="slug" value={slug} onChange={(e) => setSlug(e.target.value)} required className="h-9" />
|
|
329
|
+
</div>
|
|
320
330
|
|
|
321
|
-
|
|
322
|
-
<
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
331
|
+
{/* Language */}
|
|
332
|
+
<div className="md:col-span-2 flex flex-col gap-1">
|
|
333
|
+
<Label htmlFor="language_id" className="text-xs font-medium">Language</Label>
|
|
334
|
+
{availableLanguages.length > 0 ? (
|
|
335
|
+
<Select name="language_id" value={languageId} onValueChange={setLanguageId} required disabled={isEditing}>
|
|
336
|
+
<SelectTrigger className="h-9"><SelectValue placeholder="Select language" /></SelectTrigger>
|
|
337
|
+
<SelectContent>
|
|
338
|
+
{availableLanguages.map((lang) => (
|
|
339
|
+
<SelectItem key={lang.id} value={lang.id.toString()}>{lang.name} ({lang.code})</SelectItem>
|
|
340
|
+
))}
|
|
341
|
+
</SelectContent>
|
|
342
|
+
</Select>
|
|
343
|
+
) : (
|
|
344
|
+
<p className="text-[10px] text-muted-foreground leading-tight py-2">No languages available. Add languages in CMS settings.</p>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
336
347
|
|
|
337
|
-
|
|
338
|
-
<
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
+
{/* Status */}
|
|
349
|
+
<div className="md:col-span-2 flex flex-col gap-1">
|
|
350
|
+
<Label htmlFor="status" className="text-xs font-medium">Status</Label>
|
|
351
|
+
<Select name="status" value={status} onValueChange={(value) => setStatus(value as PageStatus)} required>
|
|
352
|
+
<SelectTrigger className="h-9"><SelectValue placeholder="Select status" /></SelectTrigger>
|
|
353
|
+
<SelectContent>
|
|
354
|
+
<SelectItem value="draft">Draft</SelectItem>
|
|
355
|
+
<SelectItem value="published">Published</SelectItem>
|
|
356
|
+
<SelectItem value="archived">Archived</SelectItem>
|
|
357
|
+
</SelectContent>
|
|
358
|
+
</Select>
|
|
359
|
+
</div>
|
|
348
360
|
</div>
|
|
349
361
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
value={
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
rows={3}
|
|
359
|
-
placeholder="Short editorial summary for the hero metadata row and article cards"
|
|
360
|
-
/>
|
|
361
|
-
<p className="text-xs text-muted-foreground mt-1">Used as the short summary above the hero and on public post cards.</p>
|
|
362
|
-
</div>
|
|
362
|
+
{/* Row 2: Label + Published At */}
|
|
363
|
+
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
|
|
364
|
+
{/* Label */}
|
|
365
|
+
<div className="md:col-span-6 flex flex-col gap-1">
|
|
366
|
+
<Label htmlFor="label" className="text-xs font-medium">Label</Label>
|
|
367
|
+
<Input id="label" name="label" value={label} onChange={(e) => setLabel(e.target.value)} className="h-9" placeholder="e.g. Architecture" />
|
|
368
|
+
<p className="text-[10px] text-muted-foreground leading-tight">Short pill text shown on the article hero and post cards.</p>
|
|
369
|
+
</div>
|
|
363
370
|
|
|
364
|
-
|
|
365
|
-
<
|
|
366
|
-
|
|
367
|
-
id="
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
onChange={(e) => setSubtitle(e.target.value)}
|
|
371
|
-
className="mt-1"
|
|
372
|
-
rows={4}
|
|
373
|
-
placeholder="Longer deck shown under the article title"
|
|
374
|
-
/>
|
|
375
|
-
<p className="text-xs text-muted-foreground mt-1">Displayed as the larger deck under the article title.</p>
|
|
371
|
+
{/* Published At */}
|
|
372
|
+
<div className="md:col-span-6 flex flex-col gap-1">
|
|
373
|
+
<Label htmlFor="published_at" className="text-xs font-medium">Published At (Optional)</Label>
|
|
374
|
+
<Input id="published_at" name="published_at" type="datetime-local" value={publishedAt} onChange={(e) => setPublishedAt(e.target.value)} className="h-9" />
|
|
375
|
+
<p className="text-[10px] text-muted-foreground leading-tight">Leave blank to publish immediately when status is 'Published'.</p>
|
|
376
|
+
</div>
|
|
376
377
|
</div>
|
|
377
378
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
<
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
</SelectContent>
|
|
387
|
-
</Select>
|
|
388
|
-
</div>
|
|
379
|
+
{/* Row 3: Excerpt + Subtitle */}
|
|
380
|
+
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
|
|
381
|
+
{/* Excerpt */}
|
|
382
|
+
<div className="md:col-span-6 flex flex-col gap-1">
|
|
383
|
+
<Label htmlFor="excerpt" className="text-xs font-medium">Excerpt</Label>
|
|
384
|
+
<Textarea id="excerpt" name="excerpt" value={excerpt} onChange={(e) => setExcerpt(e.target.value)} className="resize-y text-sm leading-normal" rows={3} placeholder="Short editorial summary for the hero metadata row and article cards" />
|
|
385
|
+
<p className="text-[10px] text-muted-foreground leading-tight">Used as the short summary above the hero and on public post cards.</p>
|
|
386
|
+
</div>
|
|
389
387
|
|
|
390
|
-
|
|
391
|
-
<
|
|
392
|
-
|
|
393
|
-
id="
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
value={publishedAt}
|
|
397
|
-
onChange={(e) => setPublishedAt(e.target.value)}
|
|
398
|
-
className="mt-1"
|
|
399
|
-
/>
|
|
400
|
-
<p className="text-xs text-muted-foreground mt-1">Leave blank to publish immediately when status is 'Published'.</p>
|
|
388
|
+
{/* Subtitle */}
|
|
389
|
+
<div className="md:col-span-6 flex flex-col gap-1">
|
|
390
|
+
<Label htmlFor="subtitle" className="text-xs font-medium">Subtitle</Label>
|
|
391
|
+
<Textarea id="subtitle" name="subtitle" value={subtitle} onChange={(e) => setSubtitle(e.target.value)} className="resize-y text-sm leading-normal" rows={3} placeholder="Longer deck shown under the article title" />
|
|
392
|
+
<p className="text-[10px] text-muted-foreground leading-tight">Displayed as the larger deck under the article title.</p>
|
|
393
|
+
</div>
|
|
401
394
|
</div>
|
|
402
395
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
396
|
+
{/* Row 4: SEO Settings. Canonical override (optional): blank = self-referencing canonical. */}
|
|
397
|
+
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
|
|
398
|
+
{/* Meta Title */}
|
|
399
|
+
<div className="md:col-span-4 flex flex-col gap-1">
|
|
400
|
+
<Label htmlFor="meta_title" className="text-xs font-medium">Meta Title (SEO)</Label>
|
|
401
|
+
<Input id="meta_title" name="meta_title" value={metaTitle} onChange={(e) => setMetaTitle(e.target.value)} className="h-9" />
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
{/* Meta Description */}
|
|
405
|
+
<div className="md:col-span-4 flex flex-col gap-1">
|
|
406
|
+
<Label htmlFor="meta_description" className="text-xs font-medium">Meta Description (SEO)</Label>
|
|
407
|
+
<Textarea id="meta_description" name="meta_description" value={metaDescription} onChange={(e) => setMetaDescription(e.target.value)} className="min-h-[36px] h-9 py-1.5 resize-y text-sm leading-normal" rows={1} placeholder="Meta description for search engines..." />
|
|
408
|
+
</div>
|
|
407
409
|
|
|
408
|
-
|
|
409
|
-
<
|
|
410
|
-
|
|
410
|
+
{/* Canonical URL */}
|
|
411
|
+
<div className="md:col-span-4 flex flex-col gap-1">
|
|
412
|
+
<Label htmlFor="custom_canonical" className="text-xs font-medium">Canonical URL (SEO, optional)</Label>
|
|
413
|
+
<Input id="custom_canonical" name="custom_canonical" value={customCanonical} onChange={(e) => setCustomCanonical(e.target.value)} className="h-9" placeholder="Blank = self-referencing. Absolute https://… URL or /relative path to override." />
|
|
414
|
+
</div>
|
|
411
415
|
</div>
|
|
412
|
-
|
|
413
|
-
<FeatureImageField
|
|
414
|
-
initialImageId={initialFeatureImageId || post?.feature_image_id || null}
|
|
415
|
-
initialImageUrl={initialFeatureImageUrl || null}
|
|
416
|
-
onImageIdChange={setFeatureImageId}
|
|
417
|
-
uploadFolder={`posts/${(slug || 'untitled').toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-_]/g, '')}/`}
|
|
418
|
-
/>
|
|
416
|
+
|
|
417
|
+
<FeatureImageField
|
|
418
|
+
initialImageId={initialFeatureImageId || post?.feature_image_id || null}
|
|
419
|
+
initialImageUrl={initialFeatureImageUrl || null}
|
|
420
|
+
onImageIdChange={setFeatureImageId}
|
|
421
|
+
uploadFolder={`posts/${(slug || 'untitled').toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-_]/g, '')}/`}
|
|
422
|
+
/>
|
|
419
423
|
|
|
420
424
|
{!isEditing && (
|
|
421
425
|
<div className="flex justify-end space-x-3 pt-6"> {/* Increased pt for spacing */}
|
|
@@ -8,6 +8,30 @@ export type CopyrightSettings = {
|
|
|
8
8
|
[key: string]: string;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
// site_settings key holding the "Published with NextBlock™ CMS" footer attribution
|
|
12
|
+
// toggle. Absent row = enabled (the default), so fresh installs show the backlink.
|
|
13
|
+
const FOOTER_ATTRIBUTION_KEY = 'footer_show_attribution';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether the "Published with NextBlock™ CMS" backlink shows in the public footer.
|
|
17
|
+
* Defaults to ON when the setting has never been saved.
|
|
18
|
+
*/
|
|
19
|
+
export async function getFooterAttributionEnabled(): Promise<boolean> {
|
|
20
|
+
const supabase = createClient();
|
|
21
|
+
const { data, error } = await supabase
|
|
22
|
+
.from('site_settings')
|
|
23
|
+
.select('value')
|
|
24
|
+
.eq('key', FOOTER_ATTRIBUTION_KEY)
|
|
25
|
+
.maybeSingle();
|
|
26
|
+
|
|
27
|
+
if (error || !data) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Stored as a JSON boolean; only an explicit `false` disables it.
|
|
32
|
+
return data.value !== false;
|
|
33
|
+
}
|
|
34
|
+
|
|
11
35
|
export async function getCopyrightSettings(): Promise<CopyrightSettings> {
|
|
12
36
|
const supabase = createClient();
|
|
13
37
|
const { data, error } = await supabase
|
|
@@ -61,6 +85,18 @@ export async function updateCopyrightSettings(formData: FormData) {
|
|
|
61
85
|
throw new Error('Failed to update copyright settings.');
|
|
62
86
|
}
|
|
63
87
|
|
|
88
|
+
// Persist the footer attribution toggle. The client always submits an explicit
|
|
89
|
+
// 'true'/'false' so an unchecked box is captured (not just omitted).
|
|
90
|
+
const attributionEnabled = formData.get('footer_show_attribution') !== 'false';
|
|
91
|
+
const { error: attributionError } = await supabase
|
|
92
|
+
.from('site_settings')
|
|
93
|
+
.upsert({ key: FOOTER_ATTRIBUTION_KEY, value: attributionEnabled });
|
|
94
|
+
|
|
95
|
+
if (attributionError) {
|
|
96
|
+
console.error('Error updating footer attribution setting:', attributionError);
|
|
97
|
+
throw new Error('Failed to update footer attribution setting.');
|
|
98
|
+
}
|
|
99
|
+
|
|
64
100
|
// Revalidate the root layout to reflect changes immediately across the site.
|
|
65
101
|
revalidatePath('/', 'layout');
|
|
66
102
|
|