create-nextblock 0.12.16 → 0.13.2

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 (45) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/actions/interactions.ts +27 -4
  3. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +287 -48
  4. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +238 -209
  5. package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +103 -8
  6. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +31 -2
  7. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +37 -15
  8. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +26 -15
  9. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +123 -46
  10. package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +8 -1
  11. package/templates/nextblock-template/app/cms/components/CortexGlobalAgentChat.tsx +62 -22
  12. package/templates/nextblock-template/app/cms/custom-blocks/components/BlockComposer.tsx +40 -2
  13. package/templates/nextblock-template/app/cms/interactions/EmailRecipientsInput.tsx +189 -0
  14. package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +138 -71
  15. package/templates/nextblock-template/app/cms/media/import-external-image.ts +289 -0
  16. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +13 -10
  17. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +14 -3
  18. package/templates/nextblock-template/app/cms/pages/actions.ts +59 -6
  19. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +21 -11
  20. package/templates/nextblock-template/app/cms/posts/actions.ts +45 -0
  21. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +11 -9
  22. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +463 -227
  23. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +220 -1
  24. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +11 -0
  25. package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +20 -1
  26. package/templates/nextblock-template/app/cms/users/actions.ts +69 -0
  27. package/templates/nextblock-template/app/cms/users/components/CreateUserForm.tsx +217 -0
  28. package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +4 -1
  29. package/templates/nextblock-template/app/cms/users/new/page.tsx +44 -0
  30. package/templates/nextblock-template/app/cms/users/page.tsx +12 -3
  31. package/templates/nextblock-template/app/lib/homepage.ts +36 -0
  32. package/templates/nextblock-template/app/lib/sitemap-utils.ts +13 -6
  33. package/templates/nextblock-template/app/page.tsx +55 -12
  34. package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +56 -0
  35. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +60 -30
  36. package/templates/nextblock-template/components/blocks/renderers/StockPhotoCredit.tsx +167 -0
  37. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +94 -6
  38. package/templates/nextblock-template/docs/09-LIVE-DRAFT-MODE.md +7 -1
  39. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +29 -3
  40. package/templates/nextblock-template/lib/search/server.ts +11 -1
  41. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +82 -72
  42. package/templates/nextblock-template/next-env.d.ts +1 -1
  43. package/templates/nextblock-template/package.json +1 -1
  44. package/templates/nextblock-template/proxy.ts +5 -0
  45. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -84,6 +84,9 @@ type CortexAgentStreamEvent =
84
84
  message: string;
85
85
  type: "error";
86
86
  }
87
+ | {
88
+ type: "status";
89
+ }
87
90
  | {
88
91
  type: "finish";
89
92
  };
@@ -92,7 +95,10 @@ const LEGACY_STORAGE_KEY = "nextblock-cortex-global-agent-chat";
92
95
  const THREADS_STORAGE_KEY = "nextblock-cortex-global-agent-chat-threads";
93
96
  const MAX_STORED_MESSAGES = 40;
94
97
  const MAX_STORED_THREADS = 20;
95
- const REQUEST_TIMEOUT_MS = 90000;
98
+ // Idle timeout: the request is aborted only after this many ms with NO stream
99
+ // activity, so a long multi-section build that keeps streaming tool/text events
100
+ // is not killed at a fixed wall-clock deadline.
101
+ const IDLE_TIMEOUT_MS = 90000;
96
102
  const CORTEX_AI_SETTINGS_CHANGED_EVENT = "nextblock:cortex-ai-settings-changed";
97
103
  const MUTATING_TOOL_NAMES = new Set([
98
104
  "create_cms_page",
@@ -105,6 +111,9 @@ const MUTATING_TOOL_NAMES = new Set([
105
111
  "execute_database_mutation",
106
112
  "execute_cms_action_plan",
107
113
  "insert_content_block",
114
+ "rewrite_page_draft",
115
+ "set_content_images",
116
+ "translate_page",
108
117
  "update_cms_item_field",
109
118
  "update_current_cms_fields",
110
119
  "update_content_block",
@@ -123,6 +132,22 @@ const TOOL_COPY: Record<string, { done: string; running: string }> = {
123
132
  done: "Documentation searched",
124
133
  running: "Searching documentation...",
125
134
  },
135
+ fetch_url_content: {
136
+ done: "Website content read",
137
+ running: "Reading the website...",
138
+ },
139
+ rewrite_page_draft: {
140
+ done: "Draft rewrite staged",
141
+ running: "Staging a live draft...",
142
+ },
143
+ translate_page: {
144
+ done: "Translation created",
145
+ running: "Creating the translation...",
146
+ },
147
+ set_content_images: {
148
+ done: "Images updated",
149
+ running: "Setting images...",
150
+ },
126
151
  create_cms_page: {
127
152
  done: "Page created",
128
153
  running: "Preparing page...",
@@ -875,23 +900,9 @@ export function CortexGlobalAgentChat() {
875
900
  const canSubmit = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]);
876
901
  const fallbackPageContext = useMemo(() => buildFallbackPageContext(pathname), [pathname]);
877
902
  const pageContext = cortexAiPageContext?.pageContext ?? fallbackPageContext;
878
- const hasSuccessfulMutationActivity = useMemo(
879
- () =>
880
- toolActivities.some(
881
- (activity) =>
882
- activity.status === "success" &&
883
- isMutatingToolName(activity.name) &&
884
- toolOutputExecutedMutation(activity.output)
885
- ),
886
- [toolActivities]
887
- );
888
903
  const visibleToolActivities = useMemo(
889
904
  () =>
890
905
  toolActivities.filter((activity, index) => {
891
- if (hasSuccessfulMutationActivity && activity.status === "error") {
892
- return false;
893
- }
894
-
895
906
  if (activity.status !== "error") {
896
907
  const confirmationKey = getConfirmationKey(activity);
897
908
 
@@ -908,11 +919,21 @@ export function CortexGlobalAgentChat() {
908
919
  return true;
909
920
  }
910
921
 
922
+ // Keep genuine failures visible. Only hide an error when a later retry
923
+ // of the SAME tool succeeded (a transient error that self-recovered);
924
+ // do not hide it just because some other, unrelated tool later
925
+ // succeeded, or a multi-section build would look fully successful even
926
+ // when individual sections failed.
911
927
  return !toolActivities
912
928
  .slice(index + 1)
913
- .some((nextActivity) => nextActivity.status === "success" && !toolOutputIsNotice(nextActivity.output));
929
+ .some(
930
+ (nextActivity) =>
931
+ nextActivity.name === activity.name &&
932
+ nextActivity.status === "success" &&
933
+ !toolOutputIsNotice(nextActivity.output)
934
+ );
914
935
  }),
915
- [cancelledConfirmationKeys, hasSuccessfulMutationActivity, toolActivities]
936
+ [cancelledConfirmationKeys, toolActivities]
916
937
  );
917
938
 
918
939
  const updateThreadMessages = (
@@ -1133,10 +1154,17 @@ export function CortexGlobalAgentChat() {
1133
1154
  }));
1134
1155
  const abortController = new AbortController();
1135
1156
  let timedOut = false;
1136
- const timeoutId = window.setTimeout(() => {
1137
- timedOut = true;
1138
- abortController.abort();
1139
- }, REQUEST_TIMEOUT_MS);
1157
+ let idleTimeoutId: number | undefined;
1158
+ const armIdleTimeout = () => {
1159
+ if (idleTimeoutId !== undefined) {
1160
+ window.clearTimeout(idleTimeoutId);
1161
+ }
1162
+ idleTimeoutId = window.setTimeout(() => {
1163
+ timedOut = true;
1164
+ abortController.abort();
1165
+ }, IDLE_TIMEOUT_MS);
1166
+ };
1167
+ armIdleTimeout();
1140
1168
 
1141
1169
  if (!threadId) {
1142
1170
  const thread = createChatThread([userMessage, assistantMessage]);
@@ -1206,6 +1234,9 @@ export function CortexGlobalAgentChat() {
1206
1234
  break;
1207
1235
  }
1208
1236
 
1237
+ // Reset the idle timer on every chunk so an actively-streaming build
1238
+ // (many tool + text events) is never aborted mid-flight.
1239
+ armIdleTimeout();
1209
1240
  buffer += decoder.decode(value, { stream: true });
1210
1241
  const frames = buffer.split("\n\n");
1211
1242
  buffer = frames.pop() || "";
@@ -1282,7 +1313,9 @@ export function CortexGlobalAgentChat() {
1282
1313
  )
1283
1314
  );
1284
1315
  } finally {
1285
- window.clearTimeout(timeoutId);
1316
+ if (idleTimeoutId !== undefined) {
1317
+ window.clearTimeout(idleTimeoutId);
1318
+ }
1286
1319
  if (shouldRefreshAfterMutation && typeof window !== "undefined") {
1287
1320
  // Let client-rendered lists (e.g. the custom blocks library) refetch even
1288
1321
  // though router.refresh() does not re-run their mount-time data fetch.
@@ -1465,6 +1498,13 @@ export function CortexGlobalAgentChat() {
1465
1498
  ))}
1466
1499
  </div>
1467
1500
  )}
1501
+
1502
+ {isStreaming && (
1503
+ <div className="flex items-center gap-2 px-1 text-xs text-slate-500 dark:text-slate-400">
1504
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
1505
+ <span>Cortex is working… building a full page can take a little while.</span>
1506
+ </div>
1507
+ )}
1468
1508
  </div>
1469
1509
 
1470
1510
  {streamError && (
@@ -55,7 +55,7 @@ import {
55
55
  createCustomBlockDefinition,
56
56
  updateCustomBlockDefinition,
57
57
  } from "../actions";
58
- import { orderCustomBlockFieldsByLayout } from "@nextblock-cms/utils";
58
+ import { orderCustomBlockFieldsByLayout, cn } from "@nextblock-cms/utils";
59
59
  import type { CustomBlockDefinition, CustomBlockField } from "@nextblock-cms/utils";
60
60
 
61
61
  // Allowed container and field tags
@@ -544,6 +544,14 @@ export function BlockComposer({ initialData, mode }: BlockComposerProps) {
544
544
  toast.error("Block must contain at least one field.");
545
545
  return;
546
546
  }
547
+ if (fields.some((field) => !field.key.trim())) {
548
+ toast.error("Every property needs a key.");
549
+ return;
550
+ }
551
+ if (duplicateFieldKeys.size > 0) {
552
+ toast.error("Property keys must be unique — fix the highlighted duplicate keys.");
553
+ return;
554
+ }
547
555
 
548
556
  const payload = {
549
557
  name,
@@ -823,10 +831,30 @@ export function BlockComposer({ initialData, mode }: BlockComposerProps) {
823
831
  return used;
824
832
  }, [layoutFieldRefs, selectedNodePath]);
825
833
 
834
+ // Property keys that appear on more than one field. Duplicate keys are invalid (a key
835
+ // must be unique), so they're flagged in the editor rather than silently dropping a field.
836
+ const duplicateFieldKeys = useMemo(() => {
837
+ const counts = new Map<string, number>();
838
+ for (const field of fields) {
839
+ counts.set(field.key, (counts.get(field.key) ?? 0) + 1);
840
+ }
841
+ const dups = new Set<string>();
842
+ for (const [key, count] of counts) {
843
+ if (count > 1) dups.add(key);
844
+ }
845
+ return dups;
846
+ }, [fields]);
847
+
826
848
  // Keep the Properties Schema list itself ordered to match the layout blueprint,
827
849
  // so the fields editor mirrors the visual tree order everywhere.
828
850
  useEffect(() => {
829
851
  setFields((prev) => {
852
+ // While any keys collide, skip layout-driven reordering: orderCustomBlockFieldsByLayout
853
+ // dedupes by key and would silently drop the colliding field. Keep the list intact so
854
+ // the duplicate can be flagged (red border) and fixed by the user instead of vanishing.
855
+ const keys = prev.map((field) => field.key);
856
+ if (new Set(keys).size !== keys.length) return prev;
857
+
830
858
  const ordered = orderCustomBlockFieldsByLayout(prev, layoutSchema);
831
859
  const unchanged =
832
860
  ordered.length === prev.length && ordered.every((field, index) => field === prev[index]);
@@ -1063,8 +1091,18 @@ export function BlockComposer({ initialData, mode }: BlockComposerProps) {
1063
1091
  value={field.key}
1064
1092
  placeholder="e.g. quote"
1065
1093
  onChange={(e) => updateField(idx, { key: e.target.value })}
1066
- className="h-8 font-mono text-xs"
1094
+ aria-invalid={duplicateFieldKeys.has(field.key)}
1095
+ className={cn(
1096
+ "h-8 font-mono text-xs",
1097
+ duplicateFieldKeys.has(field.key) &&
1098
+ "border-destructive focus-visible:ring-destructive"
1099
+ )}
1067
1100
  />
1101
+ {duplicateFieldKeys.has(field.key) && (
1102
+ <p className="text-[10px] font-medium text-destructive">
1103
+ Duplicate key — property keys must be unique.
1104
+ </p>
1105
+ )}
1068
1106
  </div>
1069
1107
  <div className="md:col-span-4 space-y-1">
1070
1108
  <Label className="text-[10px] uppercase tracking-wide font-semibold text-muted-foreground">Label</Label>
@@ -0,0 +1,189 @@
1
+ "use client";
2
+
3
+ import React, { forwardRef, useImperativeHandle, useRef, useState } from "react";
4
+ import { X } from "lucide-react";
5
+ import { cn } from "@nextblock-cms/utils";
6
+
7
+ // Same address shape used elsewhere in the app (createUser). Intentionally pragmatic
8
+ // rather than RFC-exhaustive: something@something.tld with no whitespace.
9
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
10
+
11
+ export function isValidEmail(value: string): boolean {
12
+ return EMAIL_RE.test(value.trim().toLowerCase());
13
+ }
14
+
15
+ export interface EmailRecipientsInputHandle {
16
+ /**
17
+ * Commit any text still sitting in the input, then return the full recipient list.
18
+ * Returns `null` (and shows an inline error) when that pending text is not a valid
19
+ * email — the caller should abort the save in that case. Reading the returned array
20
+ * directly (rather than parent state) avoids a stale-closure race on save.
21
+ */
22
+ flush: () => string[] | null;
23
+ }
24
+
25
+ interface EmailRecipientsInputProps {
26
+ value: string[];
27
+ onChange: (emails: string[]) => void;
28
+ disabled?: boolean;
29
+ inputId?: string;
30
+ }
31
+
32
+ const EmailRecipientsInput = forwardRef<EmailRecipientsInputHandle, EmailRecipientsInputProps>(
33
+ function EmailRecipientsInput({ value, onChange, disabled, inputId }, ref) {
34
+ const [inputValue, setInputValue] = useState("");
35
+ const [error, setError] = useState<string | null>(null);
36
+ const inputRef = useRef<HTMLInputElement>(null);
37
+
38
+ const hasEmail = (list: string[], email: string) =>
39
+ list.some((e) => e.toLowerCase() === email);
40
+
41
+ // Commit a single typed token. Returns the resulting list, or null when invalid.
42
+ const commit = (raw: string): string[] | null => {
43
+ const email = raw.trim().toLowerCase();
44
+ if (!email) return value;
45
+ if (!EMAIL_RE.test(email)) {
46
+ setError(`"${raw.trim()}" is not a valid email address.`);
47
+ return null;
48
+ }
49
+ if (hasEmail(value, email)) {
50
+ // Already a recipient — harmless. Clear the input without raising an error so
51
+ // it can't collide with the save-success banner when committed via Save/flush.
52
+ setInputValue("");
53
+ setError(null);
54
+ return value;
55
+ }
56
+ const next = [...value, email];
57
+ onChange(next);
58
+ setInputValue("");
59
+ setError(null);
60
+ return next;
61
+ };
62
+
63
+ // Add several tokens at once (paste of a comma/space/newline separated list).
64
+ const addMany = (raw: string) => {
65
+ const tokens = raw
66
+ .split(/[,\s;]+/)
67
+ .map((t) => t.trim())
68
+ .filter(Boolean);
69
+ if (tokens.length === 0) return;
70
+
71
+ const next = [...value];
72
+ const invalid: string[] = [];
73
+ for (const token of tokens) {
74
+ const email = token.toLowerCase();
75
+ if (!EMAIL_RE.test(email)) {
76
+ invalid.push(token);
77
+ continue;
78
+ }
79
+ if (!hasEmail(next, email)) next.push(email);
80
+ }
81
+ onChange(next);
82
+ setInputValue("");
83
+ setError(
84
+ invalid.length
85
+ ? `Skipped ${invalid.length} invalid address${invalid.length > 1 ? "es" : ""}: ${invalid.join(", ")}`
86
+ : null,
87
+ );
88
+ };
89
+
90
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
91
+ if (e.key === "Enter" || e.key === ",") {
92
+ e.preventDefault();
93
+ commit(inputValue);
94
+ } else if (e.key === "Backspace" && inputValue === "" && value.length > 0) {
95
+ onChange(value.slice(0, -1));
96
+ setError(null);
97
+ } else if (error) {
98
+ setError(null);
99
+ }
100
+ };
101
+
102
+ const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
103
+ const text = e.clipboardData.getData("text");
104
+ if (/[,\s;]/.test(text)) {
105
+ e.preventDefault();
106
+ // Prepend whatever is already typed so a pasted "@corp.com, ..." completes the
107
+ // in-progress "support" rather than discarding it.
108
+ addMany(inputValue + text);
109
+ }
110
+ };
111
+
112
+ const removeAt = (idx: number) => {
113
+ onChange(value.filter((_, i) => i !== idx));
114
+ setError(null);
115
+ inputRef.current?.focus();
116
+ };
117
+
118
+ // `commit` closes over the latest inputValue/value, so re-create the handle when
119
+ // either changes to keep flush() reading current state.
120
+ useImperativeHandle(ref, () => ({ flush: () => commit(inputValue) }), [inputValue, value]);
121
+
122
+ return (
123
+ <div className="space-y-1.5">
124
+ <div
125
+ onClick={() => inputRef.current?.focus()}
126
+ className={cn(
127
+ "flex flex-wrap gap-1.5 min-h-[84px] w-full items-start content-start bg-background border rounded-xl px-2.5 py-2 text-sm cursor-text transition-colors",
128
+ "focus-within:ring-1 focus-within:ring-primary focus-within:border-primary",
129
+ error ? "border-destructive" : "border-border",
130
+ disabled && "opacity-60 pointer-events-none",
131
+ )}
132
+ >
133
+ {value.map((email, idx) => (
134
+ <span
135
+ key={email}
136
+ className="inline-flex items-center gap-1 h-7 pl-2.5 pr-1 rounded-full bg-secondary text-secondary-foreground text-xs font-medium"
137
+ >
138
+ {email}
139
+ <button
140
+ type="button"
141
+ onClick={(e) => {
142
+ e.stopPropagation();
143
+ removeAt(idx);
144
+ }}
145
+ className="inline-flex items-center justify-center h-4 w-4 rounded-full text-muted-foreground hover:text-foreground hover:bg-black/10 dark:hover:bg-white/15 focus:outline-none focus-visible:ring-1 focus-visible:ring-primary transition-colors"
146
+ aria-label={`Remove ${email}`}
147
+ >
148
+ <X className="h-3 w-3" />
149
+ </button>
150
+ </span>
151
+ ))}
152
+ <input
153
+ id={inputId}
154
+ ref={inputRef}
155
+ type="email"
156
+ value={inputValue}
157
+ onChange={(e) => setInputValue(e.target.value)}
158
+ onKeyDown={handleKeyDown}
159
+ onPaste={handlePaste}
160
+ onBlur={() => commit(inputValue)}
161
+ disabled={disabled}
162
+ aria-invalid={!!error}
163
+ placeholder={value.length === 0 ? "admin@example.com" : "Add another…"}
164
+ className="flex-1 min-w-[160px] h-7 bg-transparent outline-none placeholder:text-muted-foreground"
165
+ />
166
+ </div>
167
+
168
+ <div className="flex items-center justify-between gap-3">
169
+ <span className="text-[10px] text-muted-foreground">
170
+ Type an address and press Enter or comma to add it.
171
+ </span>
172
+ {value.length > 0 && (
173
+ <span className="text-[10px] font-medium text-muted-foreground shrink-0">
174
+ {value.length} recipient{value.length > 1 ? "s" : ""}
175
+ </span>
176
+ )}
177
+ </div>
178
+
179
+ {error && (
180
+ <p role="alert" className="text-xs font-medium text-destructive">
181
+ {error}
182
+ </p>
183
+ )}
184
+ </div>
185
+ );
186
+ },
187
+ );
188
+
189
+ export default EmailRecipientsInput;