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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/actions/interactions.ts +27 -4
- package/templates/nextblock-template/app/api/ai/global-agent/route.ts +287 -48
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +238 -209
- package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +103 -8
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +31 -2
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +37 -15
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +26 -15
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +123 -46
- package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +8 -1
- package/templates/nextblock-template/app/cms/components/CortexGlobalAgentChat.tsx +62 -22
- package/templates/nextblock-template/app/cms/custom-blocks/components/BlockComposer.tsx +40 -2
- package/templates/nextblock-template/app/cms/interactions/EmailRecipientsInput.tsx +189 -0
- package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +138 -71
- package/templates/nextblock-template/app/cms/media/import-external-image.ts +289 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +13 -10
- package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +14 -3
- package/templates/nextblock-template/app/cms/pages/actions.ts +59 -6
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +21 -11
- package/templates/nextblock-template/app/cms/posts/actions.ts +45 -0
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +11 -9
- package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +463 -227
- package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +220 -1
- package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +11 -0
- package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +20 -1
- package/templates/nextblock-template/app/cms/users/actions.ts +69 -0
- package/templates/nextblock-template/app/cms/users/components/CreateUserForm.tsx +217 -0
- package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +4 -1
- package/templates/nextblock-template/app/cms/users/new/page.tsx +44 -0
- package/templates/nextblock-template/app/cms/users/page.tsx +12 -3
- package/templates/nextblock-template/app/lib/homepage.ts +36 -0
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +13 -6
- package/templates/nextblock-template/app/page.tsx +55 -12
- package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +56 -0
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +60 -30
- package/templates/nextblock-template/components/blocks/renderers/StockPhotoCredit.tsx +167 -0
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +94 -6
- package/templates/nextblock-template/docs/09-LIVE-DRAFT-MODE.md +7 -1
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +29 -3
- package/templates/nextblock-template/lib/search/server.ts +11 -1
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +82 -72
- package/templates/nextblock-template/next-env.d.ts +1 -1
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +5 -0
- 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
|
-
|
|
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(
|
|
929
|
+
.some(
|
|
930
|
+
(nextActivity) =>
|
|
931
|
+
nextActivity.name === activity.name &&
|
|
932
|
+
nextActivity.status === "success" &&
|
|
933
|
+
!toolOutputIsNotice(nextActivity.output)
|
|
934
|
+
);
|
|
914
935
|
}),
|
|
915
|
-
[cancelledConfirmationKeys,
|
|
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
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|