create-nextblock 0.15.10 → 0.16.1

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/api/ai/seo/alt-text/route.ts +221 -0
  3. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -0
  4. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +193 -1
  5. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +7 -1
  6. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +27 -0
  7. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -229
  8. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +171 -6
  9. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -245
  10. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +177 -2
  11. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +28 -0
  12. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -406
  13. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +33 -0
  14. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -383
  15. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -0
  16. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -0
  17. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -0
  18. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -0
  19. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -0
  20. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -0
  21. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -0
  22. package/templates/nextblock-template/app/layout.tsx +1 -1
  23. package/templates/nextblock-template/app/robots.ts +123 -0
  24. package/templates/nextblock-template/app/sitemap.ts +1 -1
  25. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -0
  26. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -0
  27. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -0
  28. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -0
  29. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -0
  30. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -0
  31. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -0
  32. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -0
  33. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -0
  34. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -0
  35. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -0
  36. package/templates/nextblock-template/lib/seo/block-content.ts +123 -0
  37. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -0
  38. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -0
  39. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -0
  40. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -0
  41. package/templates/nextblock-template/lib/seo/page-document.ts +412 -0
  42. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -0
  43. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -0
  44. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -0
  45. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -0
  46. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -0
  47. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -0
  48. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +10 -0
  49. package/templates/nextblock-template/next-env.d.ts +2 -2
  50. package/templates/nextblock-template/package.json +1 -1
  51. package/templates/nextblock-template/proxy.ts +240 -20
  52. package/templates/nextblock-template/app/robots.txt/route.ts +0 -32
@@ -1,245 +1,254 @@
1
- "use client";
2
-
3
- import { type KeyboardEvent, useCallback, useEffect, useState } from "react";
4
- import Image from "next/image";
5
- import {
6
- Button,
7
- Dialog,
8
- DialogClose,
9
- DialogContent,
10
- DialogFooter,
11
- DialogHeader,
12
- DialogTitle,
13
- DialogTrigger,
14
- Input,
15
- Label,
16
- Separator,
17
- Spinner,
18
- } from "@nextblock-cms/ui";
19
- import type { Database } from "@nextblock-cms/db";
20
-
21
- import { resolveMediaUrl } from "../../../lib/media/resolveMediaUrl";
22
- import { getMediaItems } from "../media/actions";
23
- import MediaImage from "../media/components/MediaImage";
24
- import MediaUploadForm from "../media/components/MediaUploadForm";
25
-
26
- type Media = Database["public"]["Tables"]["media"]["Row"];
27
-
28
- interface FeatureImageFieldProps {
29
- initialImageId?: string | null;
30
- initialImageUrl?: string | null;
31
- onImageIdChange?: (imageId: string | null) => void;
32
- uploadFolder: string;
33
- }
34
-
35
- export default function FeatureImageField({
36
- initialImageId,
37
- initialImageUrl,
38
- onImageIdChange,
39
- uploadFolder,
40
- }: FeatureImageFieldProps) {
41
- const [selectedFeatureImage, setSelectedFeatureImage] = useState<{
42
- id: string | null;
43
- url: string | null;
44
- }>({
45
- id: initialImageId || null,
46
- url: initialImageUrl || null,
47
- });
48
- const [isModalOpen, setIsModalOpen] = useState(false);
49
- const [mediaItems, setMediaItems] = useState<Media[]>([]);
50
- const [mediaLoading, setMediaLoading] = useState(false);
51
- const [mediaError, setMediaError] = useState<string | null>(null);
52
- const [mediaPage, setMediaPage] = useState(1);
53
- const [hasMoreMedia, setHasMoreMedia] = useState(true);
54
-
55
- useEffect(() => {
56
- setSelectedFeatureImage({
57
- id: initialImageId || null,
58
- url: initialImageUrl || null,
59
- });
60
- }, [initialImageId, initialImageUrl]);
61
-
62
- const loadMedia = useCallback(
63
- async (pageToLoad = 1, append = false) => {
64
- setMediaLoading(true);
65
- setMediaError(null);
66
-
67
- try {
68
- const result = await getMediaItems(pageToLoad, 20);
69
- if (result.error) {
70
- setMediaError(result.error);
71
- if (!append) setMediaItems([]);
72
- } else if (result.data) {
73
- setMediaItems((prev) =>
74
- append ? [...prev, ...(result.data || [])] : result.data || []
75
- );
76
- setHasMoreMedia(result.hasMore !== undefined ? result.hasMore : false);
77
- setMediaPage(pageToLoad);
78
- }
79
- } catch {
80
- setMediaError("An unexpected error occurred while fetching media.");
81
- if (!append) setMediaItems([]);
82
- } finally {
83
- setMediaLoading(false);
84
- }
85
- },
86
- []
87
- );
88
-
89
- useEffect(() => {
90
- if (isModalOpen) {
91
- setMediaPage(1);
92
- setHasMoreMedia(true);
93
- loadMedia(1, false);
94
- }
95
- }, [isModalOpen, loadMedia]);
96
-
97
- const handleImageSelectInModal = (image: Media) => {
98
- const imageUrl = resolveMediaUrl(image.file_path || image.object_key);
99
-
100
- if (!imageUrl) {
101
- setMediaError("Selected image is missing a valid path.");
102
- return;
103
- }
104
-
105
- setSelectedFeatureImage({ id: image.id, url: imageUrl });
106
- onImageIdChange?.(image.id);
107
- setIsModalOpen(false);
108
- };
109
-
110
- const handleRemoveImage = () => {
111
- setSelectedFeatureImage({ id: null, url: null });
112
- onImageIdChange?.(null);
113
- };
114
-
115
- const handleImageSelectKeyDown = (event: KeyboardEvent<HTMLDivElement>, image: Media) => {
116
- if (event.key !== "Enter" && event.key !== " ") {
117
- return;
118
- }
119
-
120
- event.preventDefault();
121
- handleImageSelectInModal(image);
122
- };
123
-
124
- return (
125
- <div>
126
- <Label htmlFor="feature_image_id">Feature Image</Label>
127
- <Input type="hidden" id="feature_image_id" name="feature_image_id" value={selectedFeatureImage.id || ""} />
128
- <div className="mt-2">
129
- {selectedFeatureImage.url && (
130
- <div className="mb-4">
131
- <Image
132
- src={selectedFeatureImage.url}
133
- alt="Selected feature image"
134
- width={200}
135
- height={200}
136
- className="rounded-md object-cover"
137
- />
138
- <Button
139
- type="button"
140
- variant="link"
141
- className="mt-2 px-0 text-red-600"
142
- onClick={handleRemoveImage}
143
- >
144
- Remove Image
145
- </Button>
146
- </div>
147
- )}
148
- <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
149
- <DialogTrigger asChild>
150
- <Button type="button" variant="outline">
151
- {selectedFeatureImage.id ? "Change Feature Image" : "Select Feature Image"}
152
- </Button>
153
- </DialogTrigger>
154
- <DialogContent className="flex max-h-[90vh] flex-col sm:max-w-[90vw]">
155
- <DialogHeader>
156
- <DialogTitle>Select Feature Image</DialogTitle>
157
- </DialogHeader>
158
- <div className="p-1">
159
- <MediaUploadForm
160
- compact
161
- returnJustData={true}
162
- defaultFolder={uploadFolder}
163
- onUploadSuccess={(newlyUploadedMedia) => {
164
- setMediaItems((prevItems) => [
165
- newlyUploadedMedia,
166
- ...prevItems.filter((item) => item.id !== newlyUploadedMedia.id),
167
- ]);
168
- handleImageSelectInModal(newlyUploadedMedia);
169
- }}
170
- />
171
- </div>
172
- <Separator className="my-4" />
173
- <div className="flex-grow overflow-y-auto py-4" id="media-modal-scroll-area">
174
- {mediaLoading && mediaItems.length === 0 && (
175
- <p className="text-center text-muted-foreground">Loading media...</p>
176
- )}
177
- {mediaError && <p className="text-center text-red-600">{mediaError}</p>}
178
- {!mediaLoading && !mediaError && mediaItems.length === 0 && (
179
- <p className="text-center text-muted-foreground">No media items found. Try uploading some first.</p>
180
- )}
181
-
182
- {mediaItems.length > 0 && (
183
- <div className="grid grid-cols-[repeat(auto-fill,minmax(0,150px))] gap-3">
184
- {mediaItems.map((item) => {
185
- const imageUrl = resolveMediaUrl(item.file_path || item.object_key);
186
-
187
- if (!item.file_type?.startsWith("image/") || !imageUrl) {
188
- return null;
189
- }
190
-
191
- return (
192
- <div
193
- key={item.id}
194
- className="group relative aspect-square cursor-pointer overflow-hidden rounded-lg border bg-muted/20 shadow-sm transition-all hover:ring-2 hover:ring-primary"
195
- onClick={() => handleImageSelectInModal(item)}
196
- onKeyDown={(event) => handleImageSelectKeyDown(event, item)}
197
- tabIndex={0}
198
- role="button"
199
- aria-label={`Select ${item.file_name}`}
200
- >
201
- <MediaImage
202
- src={imageUrl}
203
- alt={item.description || item.file_name}
204
- width={item.width || 300}
205
- height={item.height || 300}
206
- blurDataURL={item.blur_data_url}
207
- className="h-full w-full object-cover transition-transform group-hover:scale-105"
208
- />
209
- <div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/60 via-black/10 to-transparent p-2 opacity-0 transition-opacity group-hover:opacity-100">
210
- <p className="truncate text-xs text-white" title={item.file_name}>
211
- {item.file_name}
212
- </p>
213
- </div>
214
- </div>
215
- );
216
- })}
217
- </div>
218
- )}
219
- {!mediaLoading && hasMoreMedia && mediaItems.length > 0 && (
220
- <div className="mt-6 text-center">
221
- <Button type="button" onClick={() => loadMedia(mediaPage + 1, true)} variant="outline" disabled={mediaLoading}>
222
- {mediaLoading ? (
223
- <>
224
- <Spinner className="mr-2 h-4 w-4" /> Loading...
225
- </>
226
- ) : (
227
- "Load More"
228
- )}
229
- </Button>
230
- </div>
231
- )}
232
- </div>
233
- <DialogFooter className="mt-auto border-t pt-4">
234
- <DialogClose asChild>
235
- <Button type="button" variant="outline" onClick={() => setMediaError(null)}>
236
- Cancel
237
- </Button>
238
- </DialogClose>
239
- </DialogFooter>
240
- </DialogContent>
241
- </Dialog>
242
- </div>
243
- </div>
244
- );
245
- }
1
+ "use client";
2
+
3
+ import { type KeyboardEvent, useCallback, useEffect, useState } from "react";
4
+ import Image from "next/image";
5
+ import {
6
+ Button,
7
+ Dialog,
8
+ DialogClose,
9
+ DialogContent,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ DialogTrigger,
14
+ Input,
15
+ Label,
16
+ Separator,
17
+ Spinner,
18
+ } from "@nextblock-cms/ui";
19
+ import type { Database } from "@nextblock-cms/db";
20
+
21
+ import { resolveMediaUrl } from "../../../lib/media/resolveMediaUrl";
22
+ import { getMediaItems } from "../media/actions";
23
+ import MediaImage from "../media/components/MediaImage";
24
+ import MediaUploadForm from "../media/components/MediaUploadForm";
25
+
26
+ type Media = Database["public"]["Tables"]["media"]["Row"];
27
+
28
+ interface FeatureImageFieldProps {
29
+ initialImageId?: string | null;
30
+ initialImageUrl?: string | null;
31
+ /**
32
+ * Reports a selection or a removal to the parent form.
33
+ *
34
+ * The resolved URL is handed over alongside the id because this field has already
35
+ * computed it for its own thumbnail, and the parent otherwise has no way to learn it
36
+ * until the server round-trips the new `feature_image_id` back into
37
+ * `initialImageUrl`. `PageForm` and `PostForm` use it to keep their share-card preview
38
+ * honest the instant an image is picked; the id remains the only persisted half.
39
+ */
40
+ onImageIdChange?: (imageId: string | null, imageUrl: string | null) => void;
41
+ uploadFolder: string;
42
+ }
43
+
44
+ export default function FeatureImageField({
45
+ initialImageId,
46
+ initialImageUrl,
47
+ onImageIdChange,
48
+ uploadFolder,
49
+ }: FeatureImageFieldProps) {
50
+ const [selectedFeatureImage, setSelectedFeatureImage] = useState<{
51
+ id: string | null;
52
+ url: string | null;
53
+ }>({
54
+ id: initialImageId || null,
55
+ url: initialImageUrl || null,
56
+ });
57
+ const [isModalOpen, setIsModalOpen] = useState(false);
58
+ const [mediaItems, setMediaItems] = useState<Media[]>([]);
59
+ const [mediaLoading, setMediaLoading] = useState(false);
60
+ const [mediaError, setMediaError] = useState<string | null>(null);
61
+ const [mediaPage, setMediaPage] = useState(1);
62
+ const [hasMoreMedia, setHasMoreMedia] = useState(true);
63
+
64
+ useEffect(() => {
65
+ setSelectedFeatureImage({
66
+ id: initialImageId || null,
67
+ url: initialImageUrl || null,
68
+ });
69
+ }, [initialImageId, initialImageUrl]);
70
+
71
+ const loadMedia = useCallback(
72
+ async (pageToLoad = 1, append = false) => {
73
+ setMediaLoading(true);
74
+ setMediaError(null);
75
+
76
+ try {
77
+ const result = await getMediaItems(pageToLoad, 20);
78
+ if (result.error) {
79
+ setMediaError(result.error);
80
+ if (!append) setMediaItems([]);
81
+ } else if (result.data) {
82
+ setMediaItems((prev) =>
83
+ append ? [...prev, ...(result.data || [])] : result.data || []
84
+ );
85
+ setHasMoreMedia(result.hasMore !== undefined ? result.hasMore : false);
86
+ setMediaPage(pageToLoad);
87
+ }
88
+ } catch {
89
+ setMediaError("An unexpected error occurred while fetching media.");
90
+ if (!append) setMediaItems([]);
91
+ } finally {
92
+ setMediaLoading(false);
93
+ }
94
+ },
95
+ []
96
+ );
97
+
98
+ useEffect(() => {
99
+ if (isModalOpen) {
100
+ setMediaPage(1);
101
+ setHasMoreMedia(true);
102
+ loadMedia(1, false);
103
+ }
104
+ }, [isModalOpen, loadMedia]);
105
+
106
+ const handleImageSelectInModal = (image: Media) => {
107
+ const imageUrl = resolveMediaUrl(image.file_path || image.object_key);
108
+
109
+ if (!imageUrl) {
110
+ setMediaError("Selected image is missing a valid path.");
111
+ return;
112
+ }
113
+
114
+ setSelectedFeatureImage({ id: image.id, url: imageUrl });
115
+ onImageIdChange?.(image.id, imageUrl);
116
+ setIsModalOpen(false);
117
+ };
118
+
119
+ const handleRemoveImage = () => {
120
+ setSelectedFeatureImage({ id: null, url: null });
121
+ onImageIdChange?.(null, null);
122
+ };
123
+
124
+ const handleImageSelectKeyDown = (event: KeyboardEvent<HTMLDivElement>, image: Media) => {
125
+ if (event.key !== "Enter" && event.key !== " ") {
126
+ return;
127
+ }
128
+
129
+ event.preventDefault();
130
+ handleImageSelectInModal(image);
131
+ };
132
+
133
+ return (
134
+ <div>
135
+ <Label htmlFor="feature_image_id">Feature Image</Label>
136
+ <Input type="hidden" id="feature_image_id" name="feature_image_id" value={selectedFeatureImage.id || ""} />
137
+ <div className="mt-2">
138
+ {selectedFeatureImage.url && (
139
+ <div className="mb-4">
140
+ <Image
141
+ src={selectedFeatureImage.url}
142
+ alt="Selected feature image"
143
+ width={200}
144
+ height={200}
145
+ className="rounded-md object-cover"
146
+ />
147
+ <Button
148
+ type="button"
149
+ variant="link"
150
+ className="mt-2 px-0 text-red-600"
151
+ onClick={handleRemoveImage}
152
+ >
153
+ Remove Image
154
+ </Button>
155
+ </div>
156
+ )}
157
+ <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
158
+ <DialogTrigger asChild>
159
+ <Button type="button" variant="outline">
160
+ {selectedFeatureImage.id ? "Change Feature Image" : "Select Feature Image"}
161
+ </Button>
162
+ </DialogTrigger>
163
+ <DialogContent className="flex max-h-[90vh] flex-col sm:max-w-[90vw]">
164
+ <DialogHeader>
165
+ <DialogTitle>Select Feature Image</DialogTitle>
166
+ </DialogHeader>
167
+ <div className="p-1">
168
+ <MediaUploadForm
169
+ compact
170
+ returnJustData={true}
171
+ defaultFolder={uploadFolder}
172
+ onUploadSuccess={(newlyUploadedMedia) => {
173
+ setMediaItems((prevItems) => [
174
+ newlyUploadedMedia,
175
+ ...prevItems.filter((item) => item.id !== newlyUploadedMedia.id),
176
+ ]);
177
+ handleImageSelectInModal(newlyUploadedMedia);
178
+ }}
179
+ />
180
+ </div>
181
+ <Separator className="my-4" />
182
+ <div className="flex-grow overflow-y-auto py-4" id="media-modal-scroll-area">
183
+ {mediaLoading && mediaItems.length === 0 && (
184
+ <p className="text-center text-muted-foreground">Loading media...</p>
185
+ )}
186
+ {mediaError && <p className="text-center text-red-600">{mediaError}</p>}
187
+ {!mediaLoading && !mediaError && mediaItems.length === 0 && (
188
+ <p className="text-center text-muted-foreground">No media items found. Try uploading some first.</p>
189
+ )}
190
+
191
+ {mediaItems.length > 0 && (
192
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(0,150px))] gap-3">
193
+ {mediaItems.map((item) => {
194
+ const imageUrl = resolveMediaUrl(item.file_path || item.object_key);
195
+
196
+ if (!item.file_type?.startsWith("image/") || !imageUrl) {
197
+ return null;
198
+ }
199
+
200
+ return (
201
+ <div
202
+ key={item.id}
203
+ className="group relative aspect-square cursor-pointer overflow-hidden rounded-lg border bg-muted/20 shadow-sm transition-all hover:ring-2 hover:ring-primary"
204
+ onClick={() => handleImageSelectInModal(item)}
205
+ onKeyDown={(event) => handleImageSelectKeyDown(event, item)}
206
+ tabIndex={0}
207
+ role="button"
208
+ aria-label={`Select ${item.file_name}`}
209
+ >
210
+ <MediaImage
211
+ src={imageUrl}
212
+ alt={item.description || item.file_name}
213
+ width={item.width || 300}
214
+ height={item.height || 300}
215
+ blurDataURL={item.blur_data_url}
216
+ className="h-full w-full object-cover transition-transform group-hover:scale-105"
217
+ />
218
+ <div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/60 via-black/10 to-transparent p-2 opacity-0 transition-opacity group-hover:opacity-100">
219
+ <p className="truncate text-xs text-white" title={item.file_name}>
220
+ {item.file_name}
221
+ </p>
222
+ </div>
223
+ </div>
224
+ );
225
+ })}
226
+ </div>
227
+ )}
228
+ {!mediaLoading && hasMoreMedia && mediaItems.length > 0 && (
229
+ <div className="mt-6 text-center">
230
+ <Button type="button" onClick={() => loadMedia(mediaPage + 1, true)} variant="outline" disabled={mediaLoading}>
231
+ {mediaLoading ? (
232
+ <>
233
+ <Spinner className="mr-2 h-4 w-4" /> Loading...
234
+ </>
235
+ ) : (
236
+ "Load More"
237
+ )}
238
+ </Button>
239
+ </div>
240
+ )}
241
+ </div>
242
+ <DialogFooter className="mt-auto border-t pt-4">
243
+ <DialogClose asChild>
244
+ <Button type="button" variant="outline" onClick={() => setMediaError(null)}>
245
+ Cancel
246
+ </Button>
247
+ </DialogClose>
248
+ </DialogFooter>
249
+ </DialogContent>
250
+ </Dialog>
251
+ </div>
252
+ </div>
253
+ );
254
+ }