@agent-native/core 0.92.4 → 0.92.6

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 (160) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +19 -0
  3. package/corpus/core/docs/content/template-assets.mdx +139 -0
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/core/src/agent/production-agent.ts +96 -6
  6. package/corpus/core/src/cli/design-connect.ts +43 -16
  7. package/corpus/core/src/cli/skills.ts +52 -16
  8. package/corpus/core/src/client/AssistantChat.tsx +127 -15
  9. package/corpus/core/src/client/chat/tool-call-display.tsx +9 -3
  10. package/corpus/core/src/client/org/TeamPage.tsx +274 -177
  11. package/corpus/core/src/client/settings/DemoModeSection.tsx +6 -6
  12. package/corpus/core/src/client/settings/SettingsPanel.tsx +1 -1
  13. package/corpus/core/src/client/settings/SettingsTabsPage.tsx +49 -1
  14. package/corpus/core/src/demo/actions/toggle-demo-mode.ts +2 -2
  15. package/corpus/core/src/demo/config.ts +3 -3
  16. package/corpus/core/src/demo/redact.ts +720 -0
  17. package/corpus/core/src/extensions/url-safety.ts +13 -1
  18. package/corpus/core/src/localization/default-messages.ts +7 -0
  19. package/corpus/core/src/templates/default/app/i18n/ar-SA.ts +7 -0
  20. package/corpus/core/src/templates/default/app/i18n/de-DE.ts +7 -0
  21. package/corpus/core/src/templates/default/app/i18n/en-US.ts +7 -0
  22. package/corpus/core/src/templates/default/app/i18n/es-ES.ts +7 -0
  23. package/corpus/core/src/templates/default/app/i18n/fr-FR.ts +7 -0
  24. package/corpus/core/src/templates/default/app/i18n/hi-IN.ts +7 -0
  25. package/corpus/core/src/templates/default/app/i18n/ja-JP.ts +7 -0
  26. package/corpus/core/src/templates/default/app/i18n/ko-KR.ts +7 -0
  27. package/corpus/core/src/templates/default/app/i18n/pt-BR.ts +7 -0
  28. package/corpus/core/src/templates/default/app/i18n/zh-CN.ts +7 -0
  29. package/corpus/core/src/templates/default/app/i18n/zh-TW.ts +7 -0
  30. package/corpus/templates/analytics/AGENTS.md +6 -3
  31. package/corpus/templates/analytics/changelog/2026-07-09-uptime-monitors-use-scheduled-workers-in-production-serverless.md +6 -0
  32. package/corpus/templates/analytics/docs/uptime-monitoring.md +5 -0
  33. package/corpus/templates/analytics/server/plugins/uptime-monitor-jobs.ts +14 -2
  34. package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +35 -0
  35. package/corpus/templates/assets/.agents/skills/library-management/SKILL.md +19 -0
  36. package/corpus/templates/assets/AGENTS.md +13 -0
  37. package/corpus/templates/assets/actions/_generation-preset-settings.ts +73 -0
  38. package/corpus/templates/assets/actions/_preset-skeleton-validation.ts +63 -0
  39. package/corpus/templates/assets/actions/create-generation-preset.ts +22 -8
  40. package/corpus/templates/assets/actions/duplicate-library.ts +49 -1
  41. package/corpus/templates/assets/actions/generate-image-batch.ts +14 -1
  42. package/corpus/templates/assets/actions/generate-image.ts +195 -26
  43. package/corpus/templates/assets/actions/import-asset-from-url.ts +323 -0
  44. package/corpus/templates/assets/actions/list-generation-presets.ts +1 -1
  45. package/corpus/templates/assets/actions/rerun-generation-run.ts +37 -0
  46. package/corpus/templates/assets/actions/update-generation-preset.ts +19 -1
  47. package/corpus/templates/assets/app/i18n/ar-SA.ts +1 -0
  48. package/corpus/templates/assets/app/i18n/de-DE.ts +1 -0
  49. package/corpus/templates/assets/app/i18n/es-ES.ts +1 -0
  50. package/corpus/templates/assets/app/i18n/fr-FR.ts +1 -0
  51. package/corpus/templates/assets/app/i18n/hi-IN.ts +1 -0
  52. package/corpus/templates/assets/app/i18n/ja-JP.ts +1 -0
  53. package/corpus/templates/assets/app/i18n/ko-KR.ts +1 -0
  54. package/corpus/templates/assets/app/i18n/pt-BR.ts +1 -0
  55. package/corpus/templates/assets/app/i18n/zh-CN.ts +1 -0
  56. package/corpus/templates/assets/app/i18n/zh-TW.ts +41 -0
  57. package/corpus/templates/assets/app/i18n-data.ts +93 -0
  58. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +66 -11
  59. package/corpus/templates/assets/app/routes/brand-kits.$id_.presets.$presetId.tsx +959 -24
  60. package/corpus/templates/assets/changelog/2026-07-07-preset-skeleton-controls-fit-correctly-on-narrower-edit-page.md +6 -0
  61. package/corpus/templates/assets/changelog/2026-07-09-import-reference-images-from-url.md +6 -0
  62. package/corpus/templates/assets/changelog/2026-07-09-preset-reference-board.md +6 -0
  63. package/corpus/templates/assets/server/handlers/assets.ts +7 -27
  64. package/corpus/templates/assets/server/lib/generation.ts +83 -2
  65. package/corpus/templates/assets/server/lib/preset-chat-context.ts +17 -4
  66. package/corpus/templates/assets/server/lib/preset-references.ts +182 -0
  67. package/corpus/templates/assets/server/lib/upload-validation.ts +38 -0
  68. package/corpus/templates/assets/shared/api.ts +20 -0
  69. package/corpus/templates/calendar/.agents/skills/event-management/SKILL.md +6 -0
  70. package/corpus/templates/calendar/actions/create-event.ts +8 -2
  71. package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +22 -7
  72. package/corpus/templates/calendar/changelog/2026-07-09-calendar-events-with-guests-now-automatically-get-a-google-m.md +6 -0
  73. package/corpus/templates/calendar/changelog/2026-07-09-new-events-now-mark-the-creator-rsvp-as-yes.md +6 -0
  74. package/corpus/templates/calendar/server/handlers/events.ts +9 -2
  75. package/corpus/templates/calendar/server/lib/event-video-conferencing.ts +58 -0
  76. package/corpus/templates/calendar/server/lib/google-calendar.ts +1 -0
  77. package/corpus/templates/clips/changelog/2026-07-09-meeting-reminder-popovers-can-now-be-dismissed-with-a-top-le.md +6 -0
  78. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +3 -1
  79. package/corpus/templates/clips/desktop/src/styles.css +13 -9
  80. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +5 -2
  81. package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +27 -15
  82. package/corpus/templates/design/actions/connect-localhost.ts +40 -11
  83. package/corpus/templates/design/actions/open-visual-edit.ts +7 -1
  84. package/dist/agent/production-agent.d.ts +34 -1
  85. package/dist/agent/production-agent.d.ts.map +1 -1
  86. package/dist/agent/production-agent.js +58 -5
  87. package/dist/agent/production-agent.js.map +1 -1
  88. package/dist/cli/design-connect.d.ts +5 -1
  89. package/dist/cli/design-connect.d.ts.map +1 -1
  90. package/dist/cli/design-connect.js +37 -16
  91. package/dist/cli/design-connect.js.map +1 -1
  92. package/dist/cli/skills.d.ts.map +1 -1
  93. package/dist/cli/skills.js +52 -16
  94. package/dist/cli/skills.js.map +1 -1
  95. package/dist/client/AssistantChat.d.ts +3 -1
  96. package/dist/client/AssistantChat.d.ts.map +1 -1
  97. package/dist/client/AssistantChat.js +96 -15
  98. package/dist/client/AssistantChat.js.map +1 -1
  99. package/dist/client/chat/tool-call-display.d.ts.map +1 -1
  100. package/dist/client/chat/tool-call-display.js +5 -1
  101. package/dist/client/chat/tool-call-display.js.map +1 -1
  102. package/dist/client/org/TeamPage.d.ts.map +1 -1
  103. package/dist/client/org/TeamPage.js +32 -13
  104. package/dist/client/org/TeamPage.js.map +1 -1
  105. package/dist/client/settings/DemoModeSection.d.ts +3 -3
  106. package/dist/client/settings/DemoModeSection.js +4 -4
  107. package/dist/client/settings/DemoModeSection.js.map +1 -1
  108. package/dist/client/settings/SettingsPanel.js +1 -1
  109. package/dist/client/settings/SettingsPanel.js.map +1 -1
  110. package/dist/client/settings/SettingsTabsPage.d.ts.map +1 -1
  111. package/dist/client/settings/SettingsTabsPage.js +38 -2
  112. package/dist/client/settings/SettingsTabsPage.js.map +1 -1
  113. package/dist/collab/routes.d.ts +1 -1
  114. package/dist/collab/struct-routes.d.ts +1 -1
  115. package/dist/demo/actions/toggle-demo-mode.js +2 -2
  116. package/dist/demo/actions/toggle-demo-mode.js.map +1 -1
  117. package/dist/demo/config.js +3 -3
  118. package/dist/demo/config.js.map +1 -1
  119. package/dist/demo/redact.d.ts +1 -1
  120. package/dist/demo/redact.d.ts.map +1 -1
  121. package/dist/demo/redact.js +0 -0
  122. package/dist/demo/redact.js.map +1 -1
  123. package/dist/extensions/url-safety.d.ts +5 -0
  124. package/dist/extensions/url-safety.d.ts.map +1 -1
  125. package/dist/extensions/url-safety.js +10 -0
  126. package/dist/extensions/url-safety.js.map +1 -1
  127. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  128. package/dist/localization/default-messages.d.ts +7 -0
  129. package/dist/localization/default-messages.d.ts.map +1 -1
  130. package/dist/localization/default-messages.js +7 -0
  131. package/dist/localization/default-messages.js.map +1 -1
  132. package/dist/notifications/routes.d.ts +2 -2
  133. package/dist/observability/routes.d.ts +3 -3
  134. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  135. package/dist/resources/handlers.d.ts +1 -1
  136. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  137. package/dist/templates/default/app/i18n/ar-SA.ts +7 -0
  138. package/dist/templates/default/app/i18n/de-DE.ts +7 -0
  139. package/dist/templates/default/app/i18n/en-US.ts +7 -0
  140. package/dist/templates/default/app/i18n/es-ES.ts +7 -0
  141. package/dist/templates/default/app/i18n/fr-FR.ts +7 -0
  142. package/dist/templates/default/app/i18n/hi-IN.ts +7 -0
  143. package/dist/templates/default/app/i18n/ja-JP.ts +7 -0
  144. package/dist/templates/default/app/i18n/ko-KR.ts +7 -0
  145. package/dist/templates/default/app/i18n/pt-BR.ts +7 -0
  146. package/dist/templates/default/app/i18n/zh-CN.ts +7 -0
  147. package/dist/templates/default/app/i18n/zh-TW.ts +7 -0
  148. package/docs/content/template-assets.mdx +139 -0
  149. package/package.json +1 -1
  150. package/src/templates/default/app/i18n/ar-SA.ts +7 -0
  151. package/src/templates/default/app/i18n/de-DE.ts +7 -0
  152. package/src/templates/default/app/i18n/en-US.ts +7 -0
  153. package/src/templates/default/app/i18n/es-ES.ts +7 -0
  154. package/src/templates/default/app/i18n/fr-FR.ts +7 -0
  155. package/src/templates/default/app/i18n/hi-IN.ts +7 -0
  156. package/src/templates/default/app/i18n/ja-JP.ts +7 -0
  157. package/src/templates/default/app/i18n/ko-KR.ts +7 -0
  158. package/src/templates/default/app/i18n/pt-BR.ts +7 -0
  159. package/src/templates/default/app/i18n/zh-CN.ts +7 -0
  160. package/src/templates/default/app/i18n/zh-TW.ts +7 -0
@@ -37,35 +37,63 @@ import { Skeleton } from "@agent-native/toolkit/ui/skeleton";
37
37
  import { Spinner } from "@agent-native/toolkit/ui/spinner";
38
38
  import {
39
39
  IconArrowLeft,
40
+ IconCheck,
40
41
  IconChevronDown,
41
42
  IconDeviceFloppy,
42
43
  IconLock,
43
44
  IconPhotoPlus,
45
+ IconPlus,
46
+ IconSearch,
44
47
  IconTrash,
45
48
  IconUpload,
49
+ IconUserPlus,
50
+ IconX,
46
51
  } from "@tabler/icons-react";
47
52
  import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
48
53
  import { Link, useNavigate, useParams } from "react-router";
49
54
  import { toast } from "sonner";
50
55
 
56
+ import {
57
+ Dialog,
58
+ DialogContent,
59
+ DialogDescription,
60
+ DialogHeader,
61
+ DialogTitle,
62
+ } from "@/components/ui/dialog";
63
+ import {
64
+ Popover,
65
+ PopoverContent,
66
+ PopoverTrigger,
67
+ } from "@/components/ui/popover";
68
+ import {
69
+ Sheet,
70
+ SheetContent,
71
+ SheetDescription,
72
+ SheetHeader,
73
+ SheetTitle,
74
+ } from "@/components/ui/sheet";
51
75
  import { Textarea } from "@/components/ui/textarea";
52
76
  import { messagesByLocale } from "@/i18n-data";
53
77
  import { assetMediaUrl } from "@/lib/asset-urls";
54
78
  import type { AssetUploadResult } from "@/lib/upload-results";
55
79
  import { cn } from "@/lib/utils";
56
80
 
81
+ import { normalizePresetReferences } from "../../server/lib/preset-references";
57
82
  import {
58
83
  ASPECT_RATIOS,
59
84
  GENERATION_PRESET_REFERENCE_POLICIES,
60
85
  IMAGE_CATEGORIES,
61
86
  IMAGE_MODELS,
62
87
  IMAGE_SIZES,
88
+ PRESET_REFERENCE_ROLES,
63
89
  supportedAspectRatiosForModel,
64
90
  type AspectRatio,
65
91
  type GenerationPresetReferencePolicy,
66
92
  type ImageCategory,
67
93
  type ImageModel,
68
94
  type ImageSize,
95
+ type PresetReference,
96
+ type PresetReferenceRole,
69
97
  type PresetSkeletonSpec,
70
98
  } from "../../shared/api";
71
99
 
@@ -101,11 +129,13 @@ type PresetFormState = {
101
129
  skeletonDropShadow: boolean;
102
130
  skeletonLogo: boolean;
103
131
  skeletonLogoPlacement: SkeletonLogoPlacement;
132
+ presetReferences: PresetReference[];
104
133
  };
105
134
 
106
135
  const NO_COLLECTION = "__none__";
107
136
  const NO_SKELETON_BACKGROUND = "__none__";
108
137
  const NO_SKELETON_MASK = "__none__";
138
+ type ReferenceMode = "fixed" | "swappable" | "required";
109
139
 
110
140
  function usesSkeletonInpaintMode(
111
141
  form: Pick<PresetFormState, "model" | "skeletonContentMode">,
@@ -122,6 +152,10 @@ function skeletonFromPreset(preset: any): PresetSkeletonSpec | null {
122
152
  return spec && typeof spec === "object" ? (spec as PresetSkeletonSpec) : null;
123
153
  }
124
154
 
155
+ function presetReferencesFromPreset(preset: any): PresetReference[] {
156
+ return normalizePresetReferences(preset?.settings?.presetReferences);
157
+ }
158
+
125
159
  function logoPlacementFromLayer(
126
160
  layer: SkeletonForegroundLayer,
127
161
  ): SkeletonLogoPlacement {
@@ -177,6 +211,40 @@ function buildSkeletonSpec(
177
211
  };
178
212
  }
179
213
 
214
+ function normalizeReferenceEntries(entries: PresetReference[]) {
215
+ return normalizePresetReferences(
216
+ entries.map((entry) => ({
217
+ ...entry,
218
+ id: entry.id.trim(),
219
+ // A cleared label must not delete the entry (normalize drops
220
+ // label-less entries); fall back to the durable id as the label.
221
+ label: entry.label.trim() || entry.id.trim(),
222
+ description: entry.description?.trim() || undefined,
223
+ assetIds: entry.assetIds.filter(Boolean).slice(0, 4),
224
+ variable: Boolean(entry.variable),
225
+ required: Boolean(entry.variable && entry.required),
226
+ })),
227
+ );
228
+ }
229
+
230
+ function kebabIdFromLabel(label: string, existingIds: string[] = []): string {
231
+ const base =
232
+ label
233
+ .toLowerCase()
234
+ .replace(/[^a-z0-9]+/g, "-")
235
+ .replace(/^-+|-+$/g, "")
236
+ .slice(0, 40)
237
+ .replace(/-+$/g, "") || "reference";
238
+ const existing = new Set(existingIds);
239
+ if (!existing.has(base)) return base;
240
+ for (let index = 2; index < 100; index += 1) {
241
+ const suffix = `-${index}`;
242
+ const candidate = `${base.slice(0, 40 - suffix.length).replace(/-+$/g, "")}${suffix}`;
243
+ if (!existing.has(candidate)) return candidate;
244
+ }
245
+ return base;
246
+ }
247
+
180
248
  function formFromPreset(
181
249
  preset: any,
182
250
  assets: Array<Record<string, any>> = [],
@@ -224,6 +292,7 @@ function formFromPreset(
224
292
  skeletonLogoPlacement: skeletonLogoLayer
225
293
  ? logoPlacementFromLayer(skeletonLogoLayer)
226
294
  : "upper-right",
295
+ presetReferences: presetReferencesFromPreset(preset),
227
296
  };
228
297
  }
229
298
 
@@ -239,6 +308,7 @@ function normalizedForm(form: PresetFormState) {
239
308
  : "0",
240
309
  skeletonBackgroundPreviewUrl: "",
241
310
  skeletonMaskPreviewUrl: "",
311
+ presetReferences: normalizeReferenceEntries(form.presetReferences),
242
312
  };
243
313
  }
244
314
 
@@ -250,6 +320,30 @@ function previewUrlForAsset(asset: any): string {
250
320
  );
251
321
  }
252
322
 
323
+ function referenceMode(entry: PresetReference): ReferenceMode {
324
+ if (!entry.variable) return "fixed";
325
+ return entry.required ? "required" : "swappable";
326
+ }
327
+
328
+ function referenceModePatch(mode: ReferenceMode): Partial<PresetReference> {
329
+ switch (mode) {
330
+ case "required":
331
+ return { variable: true, required: true };
332
+ case "swappable":
333
+ return { variable: true, required: false };
334
+ default:
335
+ return { variable: false, required: false };
336
+ }
337
+ }
338
+
339
+ function assetDisplayTitle(asset: any): string {
340
+ return asset?.title || asset?.metadata?.originalName || asset?.id || "Asset";
341
+ }
342
+
343
+ function truncateReferenceLabel(label: string): string {
344
+ return label.trim().slice(0, 60);
345
+ }
346
+
253
347
  function isImageAsset(asset: any): boolean {
254
348
  return (
255
349
  asset?.mediaType === "image" ||
@@ -288,6 +382,23 @@ function FieldLabel({
288
382
  );
289
383
  }
290
384
 
385
+ function useMediaQuery(query: string): boolean {
386
+ const [matches, setMatches] = useState(false);
387
+
388
+ useEffect(() => {
389
+ if (typeof window === "undefined") return;
390
+ const mediaQuery = window.matchMedia(query);
391
+ setMatches(mediaQuery.matches);
392
+ const listener = (event: MediaQueryListEvent) => {
393
+ setMatches(event.matches);
394
+ };
395
+ mediaQuery.addEventListener("change", listener);
396
+ return () => mediaQuery.removeEventListener("change", listener);
397
+ }, [query]);
398
+
399
+ return matches;
400
+ }
401
+
291
402
  function SkeletonPreview({ form }: { form: PresetFormState }) {
292
403
  const logoLayer = logoLayerFromPlacement(form.skeletonLogoPlacement);
293
404
  return (
@@ -346,8 +457,26 @@ export default function GenerationPresetEditorRoute() {
346
457
  const [skeletonUploadPending, setSkeletonUploadPending] = useState(false);
347
458
  const [skeletonMaskUploadPending, setSkeletonMaskUploadPending] =
348
459
  useState(false);
460
+ const [referenceUploadPending, setReferenceUploadPending] = useState<
461
+ string | null
462
+ >(null);
463
+ const [referencePreviewUrls, setReferencePreviewUrls] = useState<
464
+ Record<string, string>
465
+ >({});
466
+ const [activeReferenceIndex, setActiveReferenceIndex] = useState<
467
+ number | null
468
+ >(null);
469
+ const [pickerReferenceIndex, setPickerReferenceIndex] = useState<
470
+ number | null
471
+ >(null);
472
+ const [referenceAssetSearch, setReferenceAssetSearch] = useState("");
473
+ const [autofocusReferenceIndex, setAutofocusReferenceIndex] = useState<
474
+ number | null
475
+ >(null);
349
476
  const skeletonFileInputRef = useRef<HTMLInputElement | null>(null);
350
477
  const skeletonMaskFileInputRef = useRef<HTMLInputElement | null>(null);
478
+ const referenceUploadInputRef = useRef<HTMLInputElement | null>(null);
479
+ const isSmallEditorViewport = useMediaQuery("(max-width: 640px)");
351
480
 
352
481
  const library = libraryData?.library;
353
482
  const assets = useMemo(
@@ -363,6 +492,12 @@ export default function GenerationPresetEditorRoute() {
363
492
  );
364
493
  const presets = Array.isArray(presetData?.presets) ? presetData.presets : [];
365
494
  const preset = presets.find((item: any) => item.id === presetId);
495
+ // Saved entry ids are durable keys referenced by presetReferenceFills and
496
+ // past runs' boardAssignments; renaming a label must never change them.
497
+ const persistedReferenceIds = useMemo(
498
+ () => new Set(presetReferencesFromPreset(preset).map((entry) => entry.id)),
499
+ [preset],
500
+ );
366
501
  const loading = libraryLoading || presetsLoading;
367
502
  const accessRole = library?.accessRole;
368
503
  const readOnly = Boolean(accessRole && !isEditableRole(accessRole));
@@ -478,6 +613,34 @@ export default function GenerationPresetEditorRoute() {
478
613
  JSON.stringify(normalizedForm(form)) !==
479
614
  JSON.stringify(normalizedForm(initialForm)),
480
615
  );
616
+ const referenceImageCounts = useMemo(() => {
617
+ const entries = form?.presetReferences ?? [];
618
+ return {
619
+ total: entries.reduce((sum, entry) => sum + entry.assetIds.length, 0),
620
+ subject: entries
621
+ .filter((entry) => entry.role === "subject")
622
+ .reduce((sum, entry) => sum + entry.assetIds.length, 0),
623
+ };
624
+ }, [form?.presetReferences]);
625
+ const activeReferenceEntry =
626
+ activeReferenceIndex == null
627
+ ? null
628
+ : (form?.presetReferences[activeReferenceIndex] ?? null);
629
+ const pickerReferenceEntry =
630
+ pickerReferenceIndex == null
631
+ ? null
632
+ : (form?.presetReferences[pickerReferenceIndex] ?? null);
633
+ const filteredReferenceAssets = useMemo(() => {
634
+ const query = referenceAssetSearch.trim().toLowerCase();
635
+ if (!query) return skeletonBackgroundAssets;
636
+ return skeletonBackgroundAssets.filter((asset: any) =>
637
+ [assetDisplayTitle(asset), asset.id, asset.description]
638
+ .filter((value): value is string => typeof value === "string")
639
+ .join("\n")
640
+ .toLowerCase()
641
+ .includes(query),
642
+ );
643
+ }, [referenceAssetSearch, skeletonBackgroundAssets]);
481
644
  const settingsHref = libraryId
482
645
  ? `/library/${encodeURIComponent(libraryId)}?tab=settings`
483
646
  : "/library";
@@ -579,6 +742,217 @@ export default function GenerationPresetEditorRoute() {
579
742
  });
580
743
  }
581
744
 
745
+ function addPresetReference() {
746
+ if (!form || form.presetReferences.length >= 6 || readOnly) return;
747
+ const label = t("brandKitDetail.referenceDefaultLabel");
748
+ const id = kebabIdFromLabel(
749
+ label,
750
+ form.presetReferences.map((entry) => entry.id),
751
+ );
752
+ const nextIndex = form.presetReferences.length;
753
+ setForm({
754
+ ...form,
755
+ presetReferences: [
756
+ ...form.presetReferences,
757
+ {
758
+ id,
759
+ label,
760
+ role: "subject",
761
+ assetIds: [],
762
+ variable: true,
763
+ required: false,
764
+ },
765
+ ],
766
+ });
767
+ setActiveReferenceIndex(nextIndex);
768
+ setPickerReferenceIndex(nextIndex);
769
+ setReferenceAssetSearch("");
770
+ setAutofocusReferenceIndex(nextIndex);
771
+ }
772
+
773
+ function updatePresetReference(
774
+ index: number,
775
+ patch: Partial<PresetReference>,
776
+ ) {
777
+ setForm((current) => {
778
+ if (!current) return current;
779
+ const entries = current.presetReferences.map((entry, entryIndex) => {
780
+ if (entryIndex !== index) return entry;
781
+ const next = { ...entry, ...patch };
782
+ if (patch.label !== undefined && !persistedReferenceIds.has(entry.id)) {
783
+ next.id = kebabIdFromLabel(
784
+ patch.label,
785
+ current.presetReferences
786
+ .filter((_, otherIndex) => otherIndex !== index)
787
+ .map((item) => item.id),
788
+ );
789
+ }
790
+ if (!next.variable) {
791
+ next.required = false;
792
+ }
793
+ return next;
794
+ });
795
+ return { ...current, presetReferences: entries };
796
+ });
797
+ }
798
+
799
+ function removePresetReference(index: number) {
800
+ setActiveReferenceIndex(null);
801
+ setPickerReferenceIndex(null);
802
+ setForm((current) =>
803
+ current
804
+ ? {
805
+ ...current,
806
+ presetReferences: current.presetReferences.filter(
807
+ (_, entryIndex) => entryIndex !== index,
808
+ ),
809
+ }
810
+ : current,
811
+ );
812
+ }
813
+
814
+ function addReferenceAsset(index: number, assetId: string, title?: string) {
815
+ const entry = form?.presetReferences[index];
816
+ const asset = skeletonBackgroundAssets.find(
817
+ (item: any) => item.id === assetId,
818
+ );
819
+ if (!entry || entry.assetIds.includes(assetId)) return;
820
+ if (entry.assetIds.length >= 4) return;
821
+ if (referenceImageCounts.total >= 8) {
822
+ toast.error(t("brandKitDetail.referenceBoardTotalLimitError"));
823
+ return;
824
+ }
825
+ if (entry.role === "subject" && referenceImageCounts.subject >= 4) {
826
+ toast.error(t("brandKitDetail.referenceBoardSubjectLimitError"));
827
+ return;
828
+ }
829
+ setForm((current) => {
830
+ if (!current) return current;
831
+ const entries = current.presetReferences.map((entry, entryIndex) => {
832
+ if (
833
+ entryIndex !== index ||
834
+ entry.assetIds.includes(assetId) ||
835
+ entry.assetIds.length >= 4
836
+ ) {
837
+ return entry;
838
+ }
839
+ const defaultLabel = t("brandKitDetail.referenceDefaultLabel");
840
+ const shouldPrefillLabel =
841
+ entry.assetIds.length === 0 && entry.label.trim() === defaultLabel;
842
+ const nextLabel = shouldPrefillLabel
843
+ ? truncateReferenceLabel(title || assetDisplayTitle(asset)) ||
844
+ entry.label
845
+ : entry.label;
846
+ return {
847
+ ...entry,
848
+ id: shouldPrefillLabel
849
+ ? kebabIdFromLabel(
850
+ nextLabel,
851
+ current.presetReferences
852
+ .filter((_, otherIndex) => otherIndex !== index)
853
+ .map((item) => item.id),
854
+ )
855
+ : entry.id,
856
+ label: nextLabel,
857
+ assetIds: [...entry.assetIds, assetId],
858
+ };
859
+ });
860
+ return { ...current, presetReferences: entries };
861
+ });
862
+ setPickerReferenceIndex(null);
863
+ }
864
+
865
+ function removeReferenceAsset(index: number, assetId: string) {
866
+ setForm((current) =>
867
+ current
868
+ ? {
869
+ ...current,
870
+ presetReferences: current.presetReferences.map(
871
+ (entry, entryIndex) =>
872
+ entryIndex === index
873
+ ? {
874
+ ...entry,
875
+ assetIds: entry.assetIds.filter((id) => id !== assetId),
876
+ }
877
+ : entry,
878
+ ),
879
+ }
880
+ : current,
881
+ );
882
+ }
883
+
884
+ async function uploadReferenceImage(files: FileList | null, index: number) {
885
+ if (!files?.length || !libraryId || readOnly || referenceUploadPending) {
886
+ return;
887
+ }
888
+ const entry = form?.presetReferences[index];
889
+ if (!entry || entry.assetIds.length >= 4) return;
890
+ if (referenceImageCounts.total >= 8) {
891
+ toast.error(t("brandKitDetail.referenceBoardTotalLimitError"));
892
+ return;
893
+ }
894
+ if (entry.role === "subject" && referenceImageCounts.subject >= 4) {
895
+ toast.error(t("brandKitDetail.referenceBoardSubjectLimitError"));
896
+ return;
897
+ }
898
+ const file = files[0];
899
+ const localPreviewUrl = URL.createObjectURL(file);
900
+ const body = new FormData();
901
+ body.append("libraryId", libraryId);
902
+ body.append(
903
+ "category",
904
+ entry.role === "product"
905
+ ? "product"
906
+ : entry.role === "subject"
907
+ ? "other"
908
+ : "style-only",
909
+ );
910
+ if (entry.role === "subject") {
911
+ body.append("intent", "subject");
912
+ }
913
+ body.append("files", file);
914
+ setReferenceUploadPending(entry.id);
915
+ try {
916
+ const response = await fetch(`${appBasePath()}/api/assets/upload`, {
917
+ method: "POST",
918
+ body,
919
+ });
920
+ if (!response.ok) {
921
+ const errorBody = (await response.json().catch(() => null)) as {
922
+ error?: string;
923
+ } | null;
924
+ throw new Error(
925
+ errorBody?.error || `Upload failed (${response.status})`,
926
+ );
927
+ }
928
+ const result = (await response
929
+ .json()
930
+ .catch(() => null)) as AssetUploadResult | null;
931
+ const assetId = uploadedSkeletonAssetId(result);
932
+ if (!assetId) {
933
+ throw new Error(
934
+ result?.errors?.[0]?.message ??
935
+ t("brandKitDetail.couldNotUploadReferenceImage"),
936
+ );
937
+ }
938
+ addReferenceAsset(index, assetId, file.name);
939
+ setReferencePreviewUrls((current) => ({
940
+ ...current,
941
+ [assetId]: localPreviewUrl,
942
+ }));
943
+ toast.success(t("brandKitDetail.referenceImageUploaded"));
944
+ } catch (error) {
945
+ URL.revokeObjectURL(localPreviewUrl);
946
+ toast.error(
947
+ error instanceof Error
948
+ ? error.message
949
+ : t("brandKitDetail.couldNotUploadReferenceImage"),
950
+ );
951
+ } finally {
952
+ setReferenceUploadPending(null);
953
+ }
954
+ }
955
+
582
956
  async function uploadSkeletonImage(
583
957
  files: FileList | null,
584
958
  target: "background" | "mask" = "background",
@@ -670,6 +1044,14 @@ export default function GenerationPresetEditorRoute() {
670
1044
  toast.error(t("brandKitDetail.skeletonImageRequired"));
671
1045
  return;
672
1046
  }
1047
+ if (referenceImageCounts.total > 8) {
1048
+ toast.error(t("brandKitDetail.referenceBoardTotalLimitError"));
1049
+ return;
1050
+ }
1051
+ if (referenceImageCounts.subject > 4) {
1052
+ toast.error(t("brandKitDetail.referenceBoardSubjectLimitError"));
1053
+ return;
1054
+ }
673
1055
  try {
674
1056
  const saved = await updatePreset.mutateAsync({
675
1057
  id: preset.id,
@@ -688,6 +1070,9 @@ export default function GenerationPresetEditorRoute() {
688
1070
  normalized,
689
1071
  skeletonFromPreset(preset),
690
1072
  ),
1073
+ presetReferences: normalized.presetReferences.length
1074
+ ? normalized.presetReferences
1075
+ : null,
691
1076
  },
692
1077
  collectionId: normalized.collectionId,
693
1078
  sortOrder: Number(normalized.sortOrder),
@@ -729,6 +1114,332 @@ export default function GenerationPresetEditorRoute() {
729
1114
  }
730
1115
  }
731
1116
 
1117
+ const referenceUploadTargetIndex =
1118
+ pickerReferenceIndex ?? activeReferenceIndex;
1119
+ const referenceUploadTarget =
1120
+ referenceUploadTargetIndex == null
1121
+ ? null
1122
+ : (form?.presetReferences[referenceUploadTargetIndex] ?? null);
1123
+ const referenceUploadDisabled =
1124
+ readOnly ||
1125
+ referenceUploadTargetIndex == null ||
1126
+ !referenceUploadTarget ||
1127
+ referenceUploadTarget.assetIds.length >= 4 ||
1128
+ Boolean(referenceUploadPending);
1129
+
1130
+ function openReferencePicker(index: number) {
1131
+ setPickerReferenceIndex(index);
1132
+ setReferenceAssetSearch("");
1133
+ }
1134
+
1135
+ function renderReferenceEditor(entry: PresetReference, index: number) {
1136
+ const uploadPending = referenceUploadPending === entry.id;
1137
+ const mode = referenceMode(entry);
1138
+ return (
1139
+ <div className="grid gap-4">
1140
+ <div className="grid gap-2">
1141
+ <div className="flex min-w-0 items-center justify-between gap-2">
1142
+ <div className="text-xs font-medium text-muted-foreground">
1143
+ {t("brandKitDetail.referenceImageCount", {
1144
+ count: entry.assetIds.length,
1145
+ max: 4,
1146
+ })}
1147
+ </div>
1148
+ <div className="flex shrink-0 gap-2">
1149
+ <Button
1150
+ type="button"
1151
+ variant="outline"
1152
+ size="sm"
1153
+ className="h-8 gap-2"
1154
+ disabled={readOnly || entry.assetIds.length >= 4}
1155
+ onClick={() => openReferencePicker(index)}
1156
+ >
1157
+ <IconPhotoPlus className="h-4 w-4" />
1158
+ {t("brandKitDetail.chooseReferenceImage")}
1159
+ </Button>
1160
+ <Button
1161
+ type="button"
1162
+ variant="outline"
1163
+ size="sm"
1164
+ className="h-8 gap-2"
1165
+ disabled={
1166
+ readOnly || uploadPending || entry.assetIds.length >= 4
1167
+ }
1168
+ onClick={() => referenceUploadInputRef.current?.click()}
1169
+ >
1170
+ {uploadPending ? (
1171
+ <Spinner className="h-4 w-4" />
1172
+ ) : (
1173
+ <IconUpload className="h-4 w-4" />
1174
+ )}
1175
+ {uploadPending
1176
+ ? t("brandKitDetail.uploadingReferenceImage")
1177
+ : t("brandKitDetail.uploadReferenceImage")}
1178
+ </Button>
1179
+ </div>
1180
+ </div>
1181
+ <div className="grid grid-cols-4 gap-2">
1182
+ {entry.assetIds.map((assetId) => {
1183
+ const asset = assets.find((item: any) => item.id === assetId);
1184
+ const src =
1185
+ referencePreviewUrls[assetId] || previewUrlForAsset(asset);
1186
+ return (
1187
+ <div
1188
+ key={assetId}
1189
+ className="group relative aspect-square overflow-hidden rounded-md border border-border bg-muted"
1190
+ >
1191
+ {src ? (
1192
+ <img
1193
+ src={src}
1194
+ alt={assetDisplayTitle(asset)}
1195
+ className="h-full w-full object-cover"
1196
+ />
1197
+ ) : (
1198
+ <div className="flex h-full items-center justify-center px-2 text-center text-[11px] text-muted-foreground">
1199
+ {t("brandKitDetail.previewUnavailable")}
1200
+ </div>
1201
+ )}
1202
+ <Button
1203
+ type="button"
1204
+ variant="secondary"
1205
+ size="icon"
1206
+ className="absolute right-1 top-1 h-6 w-6 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
1207
+ disabled={readOnly}
1208
+ onClick={() => removeReferenceAsset(index, assetId)}
1209
+ aria-label={t("brandKitDetail.removeReferenceImage")}
1210
+ >
1211
+ <IconX className="h-3.5 w-3.5" />
1212
+ </Button>
1213
+ </div>
1214
+ );
1215
+ })}
1216
+ {entry.assetIds.length === 0 ? (
1217
+ <button
1218
+ type="button"
1219
+ className="col-span-4 flex aspect-[4/1] items-center justify-center rounded-md border border-dashed border-border bg-muted/40 px-3 text-center text-xs text-muted-foreground transition hover:border-foreground/30 hover:text-foreground"
1220
+ disabled={readOnly}
1221
+ onClick={() => openReferencePicker(index)}
1222
+ >
1223
+ {entry.variable
1224
+ ? t("brandKitDetail.filledPerGeneration")
1225
+ : t("brandKitDetail.noReferenceImages")}
1226
+ </button>
1227
+ ) : null}
1228
+ </div>
1229
+ </div>
1230
+
1231
+ <div className="grid gap-2">
1232
+ <FieldLabel htmlFor={`reference-label-${entry.id}`}>
1233
+ {t("brandKitDetail.referenceLabel")}
1234
+ </FieldLabel>
1235
+ <Input
1236
+ id={`reference-label-${entry.id}`}
1237
+ value={entry.label}
1238
+ maxLength={60}
1239
+ disabled={readOnly}
1240
+ autoFocus={autofocusReferenceIndex === index}
1241
+ onFocus={() => setAutofocusReferenceIndex(null)}
1242
+ onChange={(event) =>
1243
+ updatePresetReference(index, {
1244
+ label: event.target.value,
1245
+ })
1246
+ }
1247
+ />
1248
+ </div>
1249
+
1250
+ <div className="grid gap-2">
1251
+ <FieldLabel>{t("brandKitDetail.referenceRoleLabel")}</FieldLabel>
1252
+ <Select
1253
+ value={entry.role}
1254
+ disabled={readOnly}
1255
+ onValueChange={(value) =>
1256
+ updatePresetReference(index, {
1257
+ role: value as PresetReferenceRole,
1258
+ })
1259
+ }
1260
+ >
1261
+ <SelectTrigger>
1262
+ <SelectValue />
1263
+ </SelectTrigger>
1264
+ <SelectContent>
1265
+ {PRESET_REFERENCE_ROLES.map((role) => (
1266
+ <SelectItem key={role} value={role}>
1267
+ {t(`brandKitDetail.referenceRole_${role}` as any)}
1268
+ </SelectItem>
1269
+ ))}
1270
+ </SelectContent>
1271
+ </Select>
1272
+ </div>
1273
+
1274
+ <div className="grid gap-2">
1275
+ <FieldLabel htmlFor={`reference-description-${entry.id}`}>
1276
+ {t("brandKitDetail.referenceDescription")}
1277
+ </FieldLabel>
1278
+ <Textarea
1279
+ id={`reference-description-${entry.id}`}
1280
+ value={entry.description ?? ""}
1281
+ maxLength={400}
1282
+ disabled={readOnly}
1283
+ placeholder={t("brandKitDetail.referenceDescriptionPlaceholder")}
1284
+ onChange={(event) =>
1285
+ updatePresetReference(index, {
1286
+ description: event.target.value,
1287
+ })
1288
+ }
1289
+ />
1290
+ </div>
1291
+
1292
+ <div className="grid gap-2">
1293
+ <FieldLabel>{t("brandKitDetail.referenceMode")}</FieldLabel>
1294
+ <div
1295
+ className="grid grid-cols-3 rounded-md border border-border bg-muted/30 p-1"
1296
+ role="radiogroup"
1297
+ >
1298
+ {(["fixed", "swappable", "required"] as const).map((item) => {
1299
+ const selected = mode === item;
1300
+ const label =
1301
+ item === "fixed"
1302
+ ? t("brandKitDetail.fixedReference")
1303
+ : item === "swappable"
1304
+ ? t("brandKitDetail.swappableReference")
1305
+ : t("brandKitDetail.requiredPerRun");
1306
+ return (
1307
+ <button
1308
+ key={item}
1309
+ type="button"
1310
+ role="radio"
1311
+ aria-checked={selected}
1312
+ disabled={readOnly}
1313
+ className={cn(
1314
+ "h-8 rounded-sm px-2 text-xs font-medium text-muted-foreground transition",
1315
+ selected
1316
+ ? "bg-background text-foreground shadow-sm"
1317
+ : "hover:text-foreground",
1318
+ )}
1319
+ onClick={() =>
1320
+ updatePresetReference(index, referenceModePatch(item))
1321
+ }
1322
+ >
1323
+ <span className="block truncate">{label}</span>
1324
+ </button>
1325
+ );
1326
+ })}
1327
+ </div>
1328
+ </div>
1329
+
1330
+ <div className="flex items-center justify-between gap-3 border-t pt-3">
1331
+ <Button
1332
+ type="button"
1333
+ variant="ghost"
1334
+ className="h-8 px-0 text-destructive hover:text-destructive"
1335
+ disabled={readOnly}
1336
+ onClick={() => removePresetReference(index)}
1337
+ >
1338
+ {t("brandKitDetail.removeReference")}
1339
+ </Button>
1340
+ </div>
1341
+ </div>
1342
+ );
1343
+ }
1344
+
1345
+ function renderReferenceTile(entry: PresetReference, index: number) {
1346
+ const firstAssetId = entry.assetIds[0];
1347
+ const firstAsset = firstAssetId
1348
+ ? assets.find((item: any) => item.id === firstAssetId)
1349
+ : null;
1350
+ const src =
1351
+ (firstAssetId && referencePreviewUrls[firstAssetId]) ||
1352
+ previewUrlForAsset(firstAsset);
1353
+ const variableEmpty = entry.variable && entry.assetIds.length === 0;
1354
+ const tileButton = (
1355
+ <button
1356
+ type="button"
1357
+ className={cn(
1358
+ "group block min-w-0 text-left outline-none",
1359
+ readOnly && "cursor-default",
1360
+ )}
1361
+ disabled={readOnly}
1362
+ onClick={() => setActiveReferenceIndex(index)}
1363
+ onKeyDown={(event) => {
1364
+ if (event.key === "Enter" || event.key === " ") {
1365
+ event.preventDefault();
1366
+ setActiveReferenceIndex(index);
1367
+ }
1368
+ }}
1369
+ >
1370
+ <div
1371
+ className={cn(
1372
+ "relative aspect-square overflow-hidden rounded-md border border-border bg-muted transition group-hover:border-foreground/30 group-focus-visible:ring-2 group-focus-visible:ring-ring",
1373
+ entry.variable && "border-dashed",
1374
+ variableEmpty && "bg-muted/30",
1375
+ )}
1376
+ >
1377
+ {src && !variableEmpty ? (
1378
+ <img
1379
+ src={src}
1380
+ alt={entry.label}
1381
+ className="h-full w-full object-cover transition group-hover:scale-[1.02]"
1382
+ />
1383
+ ) : (
1384
+ <div className="flex h-full flex-col items-center justify-center gap-2 px-3 text-center text-muted-foreground">
1385
+ <IconUserPlus className="h-8 w-8" />
1386
+ <span className="text-xs font-medium">
1387
+ {variableEmpty
1388
+ ? t("brandKitDetail.filledPerGeneration")
1389
+ : t("brandKitDetail.noReferenceImages")}
1390
+ </span>
1391
+ </div>
1392
+ )}
1393
+ {entry.assetIds.length > 1 ? (
1394
+ <Badge className="absolute right-2 top-2 h-6 px-2 shadow-sm">
1395
+ {t("brandKitDetail.moreImagesCount", {
1396
+ count: entry.assetIds.length - 1,
1397
+ })}
1398
+ </Badge>
1399
+ ) : null}
1400
+ {entry.required ? (
1401
+ <Badge
1402
+ variant="secondary"
1403
+ className="absolute left-2 top-2 h-6 px-2 shadow-sm"
1404
+ >
1405
+ {t("brandKitDetail.required")}
1406
+ </Badge>
1407
+ ) : null}
1408
+ <Badge className="absolute bottom-2 left-2 max-w-[calc(100%-1rem)] truncate px-2 shadow-sm">
1409
+ {t(`brandKitDetail.referenceRole_${entry.role}` as any)}
1410
+ </Badge>
1411
+ </div>
1412
+ <div className="mt-2 min-w-0">
1413
+ <div className="truncate text-sm font-medium">
1414
+ {entry.label || t("brandKitDetail.referenceDefaultLabel")}
1415
+ </div>
1416
+ {variableEmpty ? (
1417
+ <div className="truncate text-xs text-muted-foreground">
1418
+ {t("brandKitDetail.filledPerGeneration")}
1419
+ </div>
1420
+ ) : null}
1421
+ </div>
1422
+ </button>
1423
+ );
1424
+
1425
+ if (isSmallEditorViewport) return tileButton;
1426
+
1427
+ return (
1428
+ <Popover
1429
+ open={activeReferenceIndex === index}
1430
+ onOpenChange={(open) => {
1431
+ setActiveReferenceIndex(open ? index : null);
1432
+ if (!open) setAutofocusReferenceIndex(null);
1433
+ }}
1434
+ >
1435
+ <PopoverTrigger asChild>{tileButton}</PopoverTrigger>
1436
+ <PopoverContent align="start" className="w-[420px] max-w-[90vw] p-4">
1437
+ {renderReferenceEditor(entry, index)}
1438
+ </PopoverContent>
1439
+ </Popover>
1440
+ );
1441
+ }
1442
+
732
1443
  if (loading) {
733
1444
  return (
734
1445
  <div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 py-6">
@@ -909,6 +1620,94 @@ export default function GenerationPresetEditorRoute() {
909
1620
  </Select>
910
1621
  </div>
911
1622
  </div>
1623
+ <div className="grid min-w-0 gap-4 rounded-md border border-border p-4">
1624
+ <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
1625
+ <div className="min-w-0 flex-1">
1626
+ <div className="text-sm font-medium">
1627
+ {t("brandKitDetail.referenceBoard")}
1628
+ </div>
1629
+ {form.presetReferences.length ? (
1630
+ <div className="mt-2 flex flex-wrap gap-2">
1631
+ <Badge variant="outline">
1632
+ {t("brandKitDetail.referenceImageCount", {
1633
+ count: referenceImageCounts.total,
1634
+ max: 8,
1635
+ })}
1636
+ </Badge>
1637
+ <Badge variant="outline">
1638
+ {t("brandKitDetail.referenceSubjectImageCount", {
1639
+ count: referenceImageCounts.subject,
1640
+ max: 4,
1641
+ })}
1642
+ </Badge>
1643
+ </div>
1644
+ ) : (
1645
+ <div className="mt-2 max-w-xl text-sm text-muted-foreground">
1646
+ {t("brandKitDetail.referenceBoardEmptyDescription")}
1647
+ </div>
1648
+ )}
1649
+ </div>
1650
+ <div className="grid w-full min-w-0 gap-2 md:w-60">
1651
+ <FieldLabel>{t("brandKitDetail.referencePolicy")}</FieldLabel>
1652
+ <Select
1653
+ value={form.referencePolicy}
1654
+ disabled={readOnly}
1655
+ onValueChange={(value) =>
1656
+ updateForm({
1657
+ referencePolicy: value as GenerationPresetReferencePolicy,
1658
+ })
1659
+ }
1660
+ >
1661
+ <SelectTrigger>
1662
+ <SelectValue />
1663
+ </SelectTrigger>
1664
+ <SelectContent>
1665
+ {GENERATION_PRESET_REFERENCE_POLICIES.map((item) => (
1666
+ <SelectItem key={item} value={item}>
1667
+ {item}
1668
+ </SelectItem>
1669
+ ))}
1670
+ </SelectContent>
1671
+ </Select>
1672
+ </div>
1673
+ </div>
1674
+
1675
+ {form.presetReferences.length === 0 ? (
1676
+ <Button
1677
+ type="button"
1678
+ variant="outline"
1679
+ className="w-fit gap-2"
1680
+ disabled={readOnly}
1681
+ onClick={addPresetReference}
1682
+ >
1683
+ <IconPlus className="h-4 w-4" />
1684
+ {t("brandKitDetail.addReference")}
1685
+ </Button>
1686
+ ) : (
1687
+ <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
1688
+ {form.presetReferences.map((entry, index) => (
1689
+ <div key={entry.id} className="min-w-0">
1690
+ {renderReferenceTile(entry, index)}
1691
+ </div>
1692
+ ))}
1693
+ {form.presetReferences.length < 6 ? (
1694
+ <button
1695
+ type="button"
1696
+ className="group block min-w-0 text-left outline-none"
1697
+ disabled={readOnly}
1698
+ onClick={addPresetReference}
1699
+ >
1700
+ <div className="flex aspect-square items-center justify-center rounded-md border border-dashed border-border bg-muted/30 text-muted-foreground transition group-hover:border-foreground/30 group-hover:text-foreground group-focus-visible:ring-2 group-focus-visible:ring-ring">
1701
+ <IconPlus className="h-8 w-8" />
1702
+ </div>
1703
+ <div className="mt-2 truncate text-sm font-medium">
1704
+ {t("brandKitDetail.addReference")}
1705
+ </div>
1706
+ </button>
1707
+ ) : null}
1708
+ </div>
1709
+ )}
1710
+ </div>
912
1711
  <div className="grid gap-2">
913
1712
  <FieldLabel htmlFor="preset-template">
914
1713
  {t("brandKitDetail.promptTemplate")}
@@ -1322,6 +2121,13 @@ export default function GenerationPresetEditorRoute() {
1322
2121
  ))}
1323
2122
  </SelectContent>
1324
2123
  </Select>
2124
+ {form.presetReferences.some(
2125
+ (entry) => entry.role === "subject",
2126
+ ) ? (
2127
+ <p className="text-xs text-muted-foreground">
2128
+ {t("brandKitDetail.subjectReferenceModelTip")}
2129
+ </p>
2130
+ ) : null}
1325
2131
  </div>
1326
2132
  <div className="grid gap-2">
1327
2133
  <FieldLabel>{t("brandKitDetail.imageSize")}</FieldLabel>
@@ -1344,30 +2150,6 @@ export default function GenerationPresetEditorRoute() {
1344
2150
  </SelectContent>
1345
2151
  </Select>
1346
2152
  </div>
1347
- <div className="grid gap-2">
1348
- <FieldLabel>{t("brandKitDetail.referencePolicy")}</FieldLabel>
1349
- <Select
1350
- value={form.referencePolicy}
1351
- disabled={readOnly}
1352
- onValueChange={(value) =>
1353
- updateForm({
1354
- referencePolicy:
1355
- value as GenerationPresetReferencePolicy,
1356
- })
1357
- }
1358
- >
1359
- <SelectTrigger>
1360
- <SelectValue />
1361
- </SelectTrigger>
1362
- <SelectContent>
1363
- {GENERATION_PRESET_REFERENCE_POLICIES.map((item) => (
1364
- <SelectItem key={item} value={item}>
1365
- {item}
1366
- </SelectItem>
1367
- ))}
1368
- </SelectContent>
1369
- </Select>
1370
- </div>
1371
2153
  <div className="grid gap-2">
1372
2154
  <FieldLabel>{t("brandKitDetail.collection")}</FieldLabel>
1373
2155
  <Select
@@ -1486,6 +2268,159 @@ export default function GenerationPresetEditorRoute() {
1486
2268
  </AlertDialogFooter>
1487
2269
  </AlertDialogContent>
1488
2270
  </AlertDialog>
2271
+
2272
+ <input
2273
+ ref={referenceUploadInputRef}
2274
+ type="file"
2275
+ accept="image/*"
2276
+ className="hidden"
2277
+ disabled={referenceUploadDisabled}
2278
+ onChange={(event) => {
2279
+ if (referenceUploadTargetIndex != null) {
2280
+ void uploadReferenceImage(
2281
+ event.currentTarget.files,
2282
+ referenceUploadTargetIndex,
2283
+ );
2284
+ }
2285
+ event.currentTarget.value = "";
2286
+ }}
2287
+ />
2288
+
2289
+ <Sheet
2290
+ open={isSmallEditorViewport && activeReferenceEntry != null}
2291
+ onOpenChange={(open) => {
2292
+ if (!open) {
2293
+ setActiveReferenceIndex(null);
2294
+ setAutofocusReferenceIndex(null);
2295
+ }
2296
+ }}
2297
+ >
2298
+ <SheetContent side="bottom" className="max-h-[90vh] overflow-auto">
2299
+ <SheetHeader>
2300
+ <SheetTitle>
2301
+ {activeReferenceEntry?.label ||
2302
+ t("brandKitDetail.referenceDefaultLabel")}
2303
+ </SheetTitle>
2304
+ <SheetDescription>
2305
+ {t("brandKitDetail.editReference")}
2306
+ </SheetDescription>
2307
+ </SheetHeader>
2308
+ <div className="mt-4">
2309
+ {activeReferenceEntry && activeReferenceIndex != null
2310
+ ? renderReferenceEditor(
2311
+ activeReferenceEntry,
2312
+ activeReferenceIndex,
2313
+ )
2314
+ : null}
2315
+ </div>
2316
+ </SheetContent>
2317
+ </Sheet>
2318
+
2319
+ <Dialog
2320
+ open={pickerReferenceEntry != null}
2321
+ onOpenChange={(open) => {
2322
+ if (!open) {
2323
+ setPickerReferenceIndex(null);
2324
+ setReferenceAssetSearch("");
2325
+ }
2326
+ }}
2327
+ >
2328
+ <DialogContent className="max-w-3xl">
2329
+ <DialogHeader>
2330
+ <DialogTitle>
2331
+ {t("brandKitDetail.chooseReferenceImage")}
2332
+ </DialogTitle>
2333
+ <DialogDescription>
2334
+ {t("brandKitDetail.searchLibraryImages")}
2335
+ </DialogDescription>
2336
+ </DialogHeader>
2337
+ <div className="grid gap-3">
2338
+ <div className="relative">
2339
+ <IconSearch className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
2340
+ <Input
2341
+ value={referenceAssetSearch}
2342
+ onChange={(event) =>
2343
+ setReferenceAssetSearch(event.target.value)
2344
+ }
2345
+ placeholder={t("brandKitDetail.searchLibraryImages")}
2346
+ className="pl-9"
2347
+ />
2348
+ </div>
2349
+ <div className="max-h-[54vh] overflow-auto rounded-md border border-border p-3">
2350
+ {filteredReferenceAssets.length ? (
2351
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
2352
+ {filteredReferenceAssets.map((asset: any) => {
2353
+ const alreadyAdded = Boolean(
2354
+ pickerReferenceEntry?.assetIds.includes(asset.id),
2355
+ );
2356
+ const src = previewUrlForAsset(asset);
2357
+ return (
2358
+ <button
2359
+ key={asset.id}
2360
+ type="button"
2361
+ disabled={alreadyAdded || readOnly}
2362
+ title={`${assetDisplayTitle(asset)} · ${asset.id}`}
2363
+ className={cn(
2364
+ "group min-w-0 rounded-md border border-border bg-background p-1 text-left outline-none transition hover:border-foreground/30 focus-visible:ring-2 focus-visible:ring-ring",
2365
+ alreadyAdded && "cursor-default opacity-65",
2366
+ )}
2367
+ onClick={() => {
2368
+ if (pickerReferenceIndex == null) return;
2369
+ addReferenceAsset(pickerReferenceIndex, asset.id);
2370
+ }}
2371
+ >
2372
+ <div className="relative aspect-square overflow-hidden rounded-sm bg-muted">
2373
+ {src ? (
2374
+ <img
2375
+ src={src}
2376
+ alt={assetDisplayTitle(asset)}
2377
+ className="h-full w-full object-cover transition group-hover:scale-[1.02]"
2378
+ />
2379
+ ) : (
2380
+ <div className="flex h-full items-center justify-center px-2 text-center text-xs text-muted-foreground">
2381
+ {t("brandKitDetail.previewUnavailable")}
2382
+ </div>
2383
+ )}
2384
+ {alreadyAdded ? (
2385
+ <div className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-background/95 shadow-sm">
2386
+ <IconCheck className="h-4 w-4" />
2387
+ </div>
2388
+ ) : null}
2389
+ </div>
2390
+ <div className="mt-1 truncate px-0.5 text-xs font-medium">
2391
+ {assetDisplayTitle(asset)}
2392
+ </div>
2393
+ </button>
2394
+ );
2395
+ })}
2396
+ </div>
2397
+ ) : (
2398
+ <div className="flex min-h-32 items-center justify-center text-center text-sm text-muted-foreground">
2399
+ {t("brandKitDetail.noReferenceImages")}
2400
+ </div>
2401
+ )}
2402
+ </div>
2403
+ <div className="flex justify-end">
2404
+ <Button
2405
+ type="button"
2406
+ variant="outline"
2407
+ className="gap-2"
2408
+ disabled={referenceUploadDisabled}
2409
+ onClick={() => referenceUploadInputRef.current?.click()}
2410
+ >
2411
+ {referenceUploadPending ? (
2412
+ <Spinner className="h-4 w-4" />
2413
+ ) : (
2414
+ <IconUpload className="h-4 w-4" />
2415
+ )}
2416
+ {referenceUploadPending
2417
+ ? t("brandKitDetail.uploadingReferenceImage")
2418
+ : t("brandKitDetail.uploadReferenceImage")}
2419
+ </Button>
2420
+ </div>
2421
+ </div>
2422
+ </DialogContent>
2423
+ </Dialog>
1489
2424
  </div>
1490
2425
  );
1491
2426
  }