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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/(auth-pages)/sign-up/SignUpForm.tsx +141 -0
  3. package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +40 -122
  4. package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +5 -0
  5. package/templates/nextblock-template/app/[slug]/page.tsx +5 -2
  6. package/templates/nextblock-template/app/[slug]/page.utils.ts +10 -9
  7. package/templates/nextblock-template/app/actions/email.ts +8 -1
  8. package/templates/nextblock-template/app/actions/feedback.ts +1 -0
  9. package/templates/nextblock-template/app/actions/formActions.ts +20 -97
  10. package/templates/nextblock-template/app/actions/interactions.ts +3 -2
  11. package/templates/nextblock-template/app/actions/twoFactorEmail.ts +3 -2
  12. package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +1 -1
  13. package/templates/nextblock-template/app/actions/visualEditingActions.ts +2 -0
  14. package/templates/nextblock-template/app/actions.ts +14 -0
  15. package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -0
  16. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +47 -1
  17. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4970 -8566
  18. package/templates/nextblock-template/app/article/[slug]/page.tsx +5 -2
  19. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -0
  20. package/templates/nextblock-template/app/cms/pages/actions.ts +22 -18
  21. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +20 -2
  22. package/templates/nextblock-template/app/cms/posts/actions.ts +5 -0
  23. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +137 -133
  24. package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +36 -0
  25. package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +26 -1
  26. package/templates/nextblock-template/app/cms/settings/copyright/page.tsx +6 -2
  27. package/templates/nextblock-template/app/cms/settings/email/actions.ts +7 -3
  28. package/templates/nextblock-template/app/cms/settings/logos/actions.ts +29 -14
  29. package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -0
  30. package/templates/nextblock-template/app/cms/settings/logos/page.tsx +18 -5
  31. package/templates/nextblock-template/app/layout.tsx +29 -17
  32. package/templates/nextblock-template/app/lib/seo.test.ts +36 -0
  33. package/templates/nextblock-template/app/lib/seo.ts +32 -0
  34. package/templates/nextblock-template/app/product/[slug]/page.tsx +5 -2
  35. package/templates/nextblock-template/components/AppShell.tsx +16 -1
  36. package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -0
  37. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +36 -42
  38. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +7 -0
  39. package/templates/nextblock-template/lib/botProtection/verify.ts +134 -0
  40. package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -0
  41. package/templates/nextblock-template/lib/email/branding-format.ts +123 -0
  42. package/templates/nextblock-template/lib/email/branding.ts +76 -0
  43. package/templates/nextblock-template/lib/logos/active-logo.ts +53 -0
  44. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +1 -0
  45. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +8 -203
  46. package/templates/nextblock-template/lib/visual-editing/draft-content.ts +4 -2
  47. package/templates/nextblock-template/lib/visual-editing/mutations.ts +2 -2
  48. package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +1 -0
  49. package/templates/nextblock-template/package.json +1 -1
  50. package/templates/nextblock-template/proxy.ts +16 -0
  51. package/templates/nextblock-template/public/images/nextblock-logo-button-tiny.png +0 -0
  52. 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: `${siteUrl}/article/${params.slug}`,
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: `${siteUrl}/article/${params.slug}`,
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-8 flex flex-col gap-1">
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 [featureImageId, setFeatureImageId] = useState<string | null>(
91
- initialFeatureImageId || post?.feature_image_id || null
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
- setFeatureImageId(initialFeatureImageId || post.feature_image_id || null);
133
- }, [
134
- initialFeatureImageId,
135
- post?.excerpt,
136
- post?.id,
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("feature_image_id", featureImageId || "");
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
- featureImageId !== (post?.feature_image_id || null);
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
- featureImageId,
268
- post,
269
- isEditing,
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-6 w-full mx-auto px-6">
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
- <div>
312
- <Label htmlFor="title">Title</Label>
313
- <Input id="title" name="title" value={title} onChange={handleTitleChange} required className="mt-1" />
314
- </div>
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
- <div>
317
- <Label htmlFor="slug">Slug</Label>
318
- <Input id="slug" name="slug" value={slug} onChange={(e) => setSlug(e.target.value)} required className="mt-1" />
319
- </div>
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
- <div>
322
- <Label htmlFor="language_id">Language</Label>
323
- {availableLanguages.length > 0 ? (
324
- <Select name="language_id" value={languageId} onValueChange={setLanguageId} required disabled={isEditing}>
325
- <SelectTrigger className="mt-1"><SelectValue placeholder="Select language" /></SelectTrigger>
326
- <SelectContent>
327
- {availableLanguages.map((lang) => (
328
- <SelectItem key={lang.id} value={lang.id.toString()}>{lang.name} ({lang.code})</SelectItem>
329
- ))}
330
- </SelectContent>
331
- </Select>
332
- ) : (
333
- <p className="text-sm text-muted-foreground mt-1">No languages available. Please add languages in CMS settings.</p>
334
- )}
335
- </div>
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
- <div>
338
- <Label htmlFor="label">Label</Label>
339
- <Input
340
- id="label"
341
- name="label"
342
- value={label}
343
- onChange={(e) => setLabel(e.target.value)}
344
- className="mt-1"
345
- placeholder="e.g. Architecture"
346
- />
347
- <p className="text-xs text-muted-foreground mt-1">Short pill text shown on the article hero and post cards.</p>
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
- <div>
351
- <Label htmlFor="excerpt">Excerpt</Label>
352
- <Textarea
353
- id="excerpt"
354
- name="excerpt"
355
- value={excerpt}
356
- onChange={(e) => setExcerpt(e.target.value)}
357
- className="mt-1"
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
- <div>
365
- <Label htmlFor="subtitle">Subtitle</Label>
366
- <Textarea
367
- id="subtitle"
368
- name="subtitle"
369
- value={subtitle}
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 &apos;Published&apos;.</p>
376
+ </div>
376
377
  </div>
377
378
 
378
- <div>
379
- <Label htmlFor="status">Status</Label>
380
- <Select name="status" value={status} onValueChange={(value) => setStatus(value as PageStatus)} required>
381
- <SelectTrigger className="mt-1"><SelectValue placeholder="Select status" /></SelectTrigger>
382
- <SelectContent>
383
- <SelectItem value="draft">Draft</SelectItem>
384
- <SelectItem value="published">Published</SelectItem>
385
- <SelectItem value="archived">Archived</SelectItem>
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
- <div>
391
- <Label htmlFor="published_at">Published At (Optional)</Label>
392
- <Input
393
- id="published_at"
394
- name="published_at"
395
- type="datetime-local"
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 &apos;Published&apos;.</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
- <div>
404
- <Label htmlFor="meta_title">Meta Title (SEO)</Label>
405
- <Input id="meta_title" name="meta_title" value={metaTitle} onChange={(e) => setMetaTitle(e.target.value)} className="mt-1" />
406
- </div>
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
- <div>
409
- <Label htmlFor="meta_description">Meta Description (SEO)</Label>
410
- <Textarea id="meta_description" name="meta_description" value={metaDescription} onChange={(e) => setMetaDescription(e.target.value)} className="mt-1" rows={3} />
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