@agent-native/core 0.78.0 → 0.78.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +9 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/types.ts +22 -0
  5. package/corpus/core/src/client/AgentPanel.tsx +8 -0
  6. package/corpus/core/src/client/AssistantChat.tsx +40 -0
  7. package/corpus/core/src/client/agent-chat.ts +173 -0
  8. package/corpus/core/src/client/composer/TiptapComposer.tsx +316 -60
  9. package/corpus/core/src/client/composer/extensions/MentionReference.tsx +3 -0
  10. package/corpus/core/src/client/composer/types.ts +22 -0
  11. package/corpus/core/src/client/index.ts +5 -0
  12. package/corpus/core/src/server/agent-chat-plugin.ts +10 -0
  13. package/corpus/templates/assets/.agents/skills/asset-generation/SKILL.md +10 -2
  14. package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +14 -7
  15. package/corpus/templates/assets/.agents/skills/library-management/SKILL.md +12 -0
  16. package/corpus/templates/assets/AGENTS.md +18 -3
  17. package/corpus/templates/assets/actions/_image-model-default.ts +24 -0
  18. package/corpus/templates/assets/actions/dismiss-variant-slots.ts +15 -22
  19. package/corpus/templates/assets/actions/generate-asset.ts +30 -23
  20. package/corpus/templates/assets/actions/generate-image-batch.ts +76 -29
  21. package/corpus/templates/assets/actions/generate-image.ts +46 -30
  22. package/corpus/templates/assets/actions/generate-video.ts +18 -3
  23. package/corpus/templates/assets/actions/list-assets.ts +39 -8
  24. package/corpus/templates/assets/actions/list-libraries.ts +24 -2
  25. package/corpus/templates/assets/actions/navigate.ts +1 -1
  26. package/corpus/templates/assets/actions/open-asset-picker.ts +38 -1
  27. package/corpus/templates/assets/actions/refresh-generation-run.ts +10 -0
  28. package/corpus/templates/assets/actions/save-generated-image.ts +21 -19
  29. package/corpus/templates/assets/actions/variant-slots.ts +131 -28
  30. package/corpus/templates/assets/actions/view-screen.ts +9 -0
  31. package/corpus/templates/assets/app/components/generation/GenerationResults.tsx +425 -0
  32. package/corpus/templates/assets/app/components/layout/Header.tsx +3 -1
  33. package/corpus/templates/assets/app/components/layout/Layout.tsx +5 -0
  34. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +4 -6
  35. package/corpus/templates/assets/app/hooks/use-navigation-state.ts +47 -21
  36. package/corpus/templates/assets/app/i18n-data.ts +1815 -0
  37. package/corpus/templates/assets/app/lib/libraries.ts +6 -0
  38. package/corpus/templates/assets/app/lib/picker-chat-handoff.ts +71 -0
  39. package/corpus/templates/assets/app/routes/_index.tsx +4 -0
  40. package/corpus/templates/assets/app/routes/asset.$id.tsx +2 -2
  41. package/corpus/templates/assets/app/routes/audit.tsx +1 -1
  42. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +1557 -2164
  43. package/corpus/templates/assets/app/routes/brand-kits._index.tsx +7 -191
  44. package/corpus/templates/assets/app/routes/brand-kits.tsx +1 -5
  45. package/corpus/templates/assets/app/routes/libraries.tsx +4 -4
  46. package/corpus/templates/assets/app/routes/library.$id.tsx +9 -7
  47. package/corpus/templates/assets/app/routes/library.tsx +1454 -175
  48. package/corpus/templates/assets/server/db/index.ts +1 -1
  49. package/corpus/templates/assets/server/lib/generation.ts +86 -15
  50. package/corpus/templates/assets/server/plugins/agent-chat.ts +196 -0
  51. package/corpus/templates/assets/server/plugins/db.ts +5 -0
  52. package/corpus/templates/assets/shared/api.ts +6 -3
  53. package/dist/agent/types.d.ts +21 -0
  54. package/dist/agent/types.d.ts.map +1 -1
  55. package/dist/agent/types.js.map +1 -1
  56. package/dist/client/AgentPanel.d.ts +5 -1
  57. package/dist/client/AgentPanel.d.ts.map +1 -1
  58. package/dist/client/AgentPanel.js +2 -2
  59. package/dist/client/AgentPanel.js.map +1 -1
  60. package/dist/client/AssistantChat.d.ts +6 -0
  61. package/dist/client/AssistantChat.d.ts.map +1 -1
  62. package/dist/client/AssistantChat.js +15 -3
  63. package/dist/client/AssistantChat.js.map +1 -1
  64. package/dist/client/agent-chat.d.ts +32 -0
  65. package/dist/client/agent-chat.d.ts.map +1 -1
  66. package/dist/client/agent-chat.js +113 -0
  67. package/dist/client/agent-chat.js.map +1 -1
  68. package/dist/client/composer/TiptapComposer.d.ts +2 -1
  69. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  70. package/dist/client/composer/TiptapComposer.js +228 -56
  71. package/dist/client/composer/TiptapComposer.js.map +1 -1
  72. package/dist/client/composer/extensions/MentionReference.d.ts.map +1 -1
  73. package/dist/client/composer/extensions/MentionReference.js +3 -0
  74. package/dist/client/composer/extensions/MentionReference.js.map +1 -1
  75. package/dist/client/composer/types.d.ts +21 -0
  76. package/dist/client/composer/types.d.ts.map +1 -1
  77. package/dist/client/composer/types.js.map +1 -1
  78. package/dist/client/index.d.ts +1 -1
  79. package/dist/client/index.d.ts.map +1 -1
  80. package/dist/client/index.js +1 -1
  81. package/dist/client/index.js.map +1 -1
  82. package/dist/collab/routes.d.ts +2 -2
  83. package/dist/notifications/routes.d.ts +3 -3
  84. package/dist/observability/routes.d.ts +6 -6
  85. package/dist/progress/routes.d.ts +1 -1
  86. package/dist/resources/handlers.d.ts +3 -3
  87. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  88. package/dist/server/agent-chat-plugin.js +5 -0
  89. package/dist/server/agent-chat-plugin.js.map +1 -1
  90. package/dist/server/agent-engine-api-key-route.d.ts +2 -2
  91. package/dist/server/transcribe-voice.d.ts +1 -1
  92. package/package.json +1 -1
@@ -3,14 +3,18 @@ import {
3
3
  appBasePath,
4
4
  agentNativePath,
5
5
  getBrowserTabId,
6
+ readClientAppState,
6
7
  sendToAgentChat,
7
8
  useT,
8
9
  useActionMutation,
9
10
  useActionQuery,
10
11
  } from "@agent-native/core/client";
11
12
  import {
13
+ IconCheck,
14
+ IconClipboard,
12
15
  IconCopy,
13
16
  IconDotsVertical,
17
+ IconArrowUpRight,
14
18
  IconArchive,
15
19
  IconFolder,
16
20
  IconFolderPlus,
@@ -38,10 +42,19 @@ import {
38
42
  type ReactNode,
39
43
  type SetStateAction,
40
44
  useEffect,
45
+ useMemo,
41
46
  useRef,
42
47
  useState,
43
48
  } from "react";
44
- import { Link, useNavigate, useParams, useSearchParams } from "react-router";
49
+ import { createPortal } from "react-dom";
50
+ import {
51
+ Link,
52
+ LoaderFunctionArgs,
53
+ redirect,
54
+ useNavigate,
55
+ useParams,
56
+ useSearchParams,
57
+ } from "react-router";
45
58
  import { toast } from "sonner";
46
59
 
47
60
  import { EditLibraryDialog } from "@/components/library/EditLibraryDialog";
@@ -78,11 +91,6 @@ import {
78
91
  } from "@/components/ui/dropdown-menu";
79
92
  import { Input } from "@/components/ui/input";
80
93
  import { Label } from "@/components/ui/label";
81
- import {
82
- Popover,
83
- PopoverContent,
84
- PopoverTrigger,
85
- } from "@/components/ui/popover";
86
94
  import {
87
95
  Select,
88
96
  SelectContent,
@@ -114,26 +122,13 @@ import {
114
122
  import {
115
123
  IMAGE_CATEGORIES,
116
124
  ASPECT_RATIOS,
117
- IMAGE_MODELS,
118
- IMAGE_SIZES,
119
- VIDEO_ASPECT_RATIOS,
120
- VIDEO_DURATIONS,
121
- VIDEO_MODELS,
122
- VIDEO_RESOLUTIONS,
125
+ type AssetVariantState,
123
126
  type AspectRatio,
124
127
  type ImageCategory,
125
128
  type ImageRole,
126
129
  } from "../../shared/api";
127
130
 
128
- function candidateSaveKey(slot: any): string {
129
- if (typeof slot?.assetId === "string" && slot.assetId) {
130
- return `asset:${slot.assetId}`;
131
- }
132
- if (typeof slot?.slotId === "string" && slot.slotId) {
133
- return `slot:${slot.slotId}`;
134
- }
135
- return "";
136
- }
131
+ export type VariantSlot = AssetVariantState["slots"][number];
137
132
 
138
133
  function referencePromotionKey(asset: any, slot?: any): string {
139
134
  if (typeof slot?.slotId === "string" && slot.slotId) {
@@ -293,27 +288,6 @@ function removeVariantSlotsByScopeFromCache(
293
288
  );
294
289
  }
295
290
 
296
- function variantSlotTime(slot: any): number {
297
- const raw = slot?.createdAt ?? slot?.updatedAt ?? "";
298
- const time = Date.parse(String(raw));
299
- return Number.isNaN(time) ? 0 : time;
300
- }
301
-
302
- function compareVariantSlotsNewestFirst(left: any, right: any): number {
303
- return (
304
- variantSlotTime(right) - variantSlotTime(left) ||
305
- String(right?.slotId ?? "").localeCompare(String(left?.slotId ?? ""))
306
- );
307
- }
308
-
309
- function stalePendingVariantRunId(slot: any): string | null {
310
- if (slot?.status !== "pending") return null;
311
- if (typeof slot?.runId !== "string" || !slot.runId) return null;
312
- const timestamp = variantSlotTime(slot);
313
- if (!timestamp) return null;
314
- return Date.now() - timestamp >= 2 * 60 * 1000 ? slot.runId : null;
315
- }
316
-
317
291
  function paletteDraftFromColors(colors: unknown): string {
318
292
  return Array.isArray(colors)
319
293
  ? colors.filter((color) => typeof color === "string").join(", ")
@@ -331,6 +305,18 @@ function referenceRoleForAsset(asset: any): ImageRole {
331
305
  return "style_reference";
332
306
  }
333
307
 
308
+ function variantSlotTime(slot: VariantSlot): number {
309
+ const raw = slot.createdAt ?? slot.updatedAt ?? "";
310
+ const time = Date.parse(raw);
311
+ return Number.isNaN(time) ? 0 : time;
312
+ }
313
+
314
+ function assetUpdatedTime(asset: any): number {
315
+ const raw = asset?.updatedAt ?? asset?.createdAt ?? "";
316
+ const time = Date.parse(String(raw));
317
+ return Number.isNaN(time) ? 0 : time;
318
+ }
319
+
334
320
  function parsePaletteDraft(value: string): string[] {
335
321
  const seen = new Set<string>();
336
322
  const colors: string[] = [];
@@ -347,6 +333,15 @@ function parsePaletteDraft(value: string): string[] {
347
333
  return colors;
348
334
  }
349
335
 
336
+ export function loader({ params, request }: LoaderFunctionArgs) {
337
+ const url = new URL(request.url);
338
+ return redirect(`/library/${params.id}${url.search}`);
339
+ }
340
+
341
+ export default function BrandKitDetailRedirect() {
342
+ return null;
343
+ }
344
+
350
345
  function libraryTabFromValue(value: unknown): LibraryTab | null {
351
346
  return value === "references" ||
352
347
  value === "generated" ||
@@ -356,26 +351,31 @@ function libraryTabFromValue(value: unknown): LibraryTab | null {
356
351
  : null;
357
352
  }
358
353
 
359
- export default function LibraryPage() {
354
+ export function BrandKitDetailRoute({
355
+ libraryId: explicitLibraryId = null,
356
+ headerMode = "full",
357
+ }: {
358
+ libraryId?: string | null;
359
+ headerMode?: "full" | "actions";
360
+ } = {}) {
360
361
  const t = useT();
361
362
  const { id } = useParams();
362
363
  const navigate = useNavigate();
363
364
  const [searchParams] = useSearchParams();
364
365
  const urlTab = libraryTabFromValue(searchParams.get("tab"));
365
- const libraryId = id!;
366
+ const libraryId = explicitLibraryId ?? id!;
366
367
  const { data } = useActionQuery("get-library", { id: libraryId }) as any;
367
368
  const updateLibrary = useActionMutation("update-library");
368
369
  const archiveLibrary = useActionMutation("archive-library");
369
370
  const duplicateLibrary = useActionMutation("duplicate-library");
370
- const saveGenerated = useActionMutation("save-generated-image");
371
371
  const updateAsset = useActionMutation("update-asset");
372
+ const saveGenerated = useActionMutation("save-generated-image");
372
373
  const rerunGeneration = useActionMutation("rerun-generation-run");
373
374
  const refreshGeneration = useActionMutation("refresh-generation-run");
374
375
  const createSession = useActionMutation("create-generation-session");
375
376
  const prepareSessionContinuation = useActionMutation(
376
377
  "prepare-generation-session-continuation",
377
378
  );
378
- const { data: variants } = useVariantState();
379
379
  const { data: presetData } = useActionQuery("list-generation-presets", {
380
380
  libraryId,
381
381
  }) as any;
@@ -383,17 +383,20 @@ export default function LibraryPage() {
383
383
  libraryId,
384
384
  }) as any;
385
385
  const queryClient = useQueryClient();
386
- const [generateOpen, setGenerateOpen] = useState(false);
387
386
  const [folderOpen, setFolderOpen] = useState(false);
388
387
  const [uploading, setUploading] = useState(false);
389
388
  const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([]);
390
389
  const [editOpen, setEditOpen] = useState(false);
391
390
  const [archiveOpen, setArchiveOpen] = useState(false);
391
+ const [headerPrimaryActionsTarget, setHeaderPrimaryActionsTarget] =
392
+ useState<HTMLElement | null>(null);
393
+ const [headerMoreActionsTarget, setHeaderMoreActionsTarget] =
394
+ useState<HTMLElement | null>(null);
392
395
  const [activeFolderId, setActiveFolderId] = useState<string | null>("all");
393
396
  const [activeTab, setActiveTab] = useState<LibraryTab>(
394
397
  () => urlTab ?? "references",
395
398
  );
396
- const [assetViewMode, setAssetViewMode] = useState<AssetViewMode>("lanes");
399
+ const [assetViewMode, setAssetViewMode] = useState<AssetViewMode>("cards");
397
400
  const [assetScope, setAssetScope] = useState<AssetLibraryScope>("all");
398
401
  const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(
399
402
  () => new Set(),
@@ -402,12 +405,12 @@ export default function LibraryPage() {
402
405
  useState<Set<string>>(() => new Set());
403
406
  const [optimisticallySavedAssetIds, setOptimisticallySavedAssetIds] =
404
407
  useState<Set<string>>(() => new Set());
405
- const [savingCandidateKeys, setSavingCandidateKeys] = useState<Set<string>>(
406
- () => new Set(),
407
- );
408
408
  const [promotingReferenceKeys, setPromotingReferenceKeys] = useState<
409
409
  Set<string>
410
410
  >(() => new Set());
411
+ const [savingCandidateSlotId, setSavingCandidateSlotId] = useState<
412
+ string | null
413
+ >(null);
411
414
  const [mediaFilter, setMediaFilter] = useState<"all" | "image" | "video">(
412
415
  "all",
413
416
  );
@@ -416,30 +419,43 @@ export default function LibraryPage() {
416
419
  const [customInstructionsDraft, setCustomInstructionsDraft] = useState("");
417
420
  const [paletteDraft, setPaletteDraft] = useState("");
418
421
  const fileInputRef = useRef<HTMLInputElement>(null);
419
- const refreshingVariantRunIdsRef = useRef<Set<string>>(new Set());
420
422
  const dragCounterRef = useRef(0);
421
423
  const [isDragOver, setIsDragOver] = useState(false);
422
424
  const createFolder = useActionMutation("create-folder");
425
+ const { data: liveVariants } = useQuery({
426
+ queryKey: ["app-state", "asset-variants"],
427
+ queryFn: ({ signal }) => {
428
+ return readClientAppState<AssetVariantState>("asset-variants", {
429
+ signal,
430
+ });
431
+ },
432
+ refetchInterval: 1000,
433
+ });
423
434
 
424
435
  useEffect(() => {
425
436
  if (!urlTab) return;
426
437
  setActiveTab((current) => (current === urlTab ? current : urlTab));
427
438
  }, [urlTab]);
428
439
 
440
+ useEffect(() => {
441
+ if (headerMode !== "actions" || typeof document === "undefined") {
442
+ setHeaderPrimaryActionsTarget(null);
443
+ setHeaderMoreActionsTarget(null);
444
+ return;
445
+ }
446
+ setHeaderPrimaryActionsTarget(
447
+ document.getElementById("assets-library-detail-primary-actions"),
448
+ );
449
+ setHeaderMoreActionsTarget(
450
+ document.getElementById("assets-library-detail-more-actions"),
451
+ );
452
+ }, [headerMode, libraryId]);
453
+
429
454
  const library = data?.library;
430
455
  const folders = (data?.folders ?? []) as any[];
431
456
  const generationPresets = ((presetData as any)?.presets ?? []) as any[];
432
457
  const generationSessions = ((sessionData as any)?.sessions ?? []) as any[];
433
458
  const serverAssets = (data?.assets ?? []) as any[];
434
- const finishedVariantAssetIds = new Set(
435
- serverAssets
436
- .filter(
437
- (asset) =>
438
- asset.status !== "candidate" ||
439
- optimisticallySavedAssetIds.has(asset.id),
440
- )
441
- .map((asset) => asset.id),
442
- );
443
459
  const assets = serverAssets
444
460
  .map((asset) =>
445
461
  optimisticallySavedAssetIds.has(asset.id)
@@ -447,9 +463,6 @@ export default function LibraryPage() {
447
463
  : asset,
448
464
  )
449
465
  .filter((asset) => !optimisticallyDeletedAssetIds.has(asset.id));
450
- const candidateAssets = assets.filter(
451
- (asset) => asset.role === "generated" && asset.status === "candidate",
452
- );
453
466
  const libraryAssets = assets.filter((asset) => asset.status !== "candidate");
454
467
  const visibleAssets = libraryAssets.filter((asset) => {
455
468
  if (activeFolderId !== "all") {
@@ -495,6 +508,45 @@ export default function LibraryPage() {
495
508
  const libraryPaletteDraft = paletteDraftFromColors(
496
509
  library?.styleBrief?.palette,
497
510
  );
511
+ const liveVariantsForLibrary =
512
+ liveVariants?.libraryId === libraryId ? liveVariants : null;
513
+ const liveCandidateSlots = useMemo(
514
+ () =>
515
+ (liveVariantsForLibrary?.slots ?? [])
516
+ .filter(
517
+ (slot) =>
518
+ slot.status === "pending" ||
519
+ slot.status === "ready" ||
520
+ slot.status === "failed",
521
+ )
522
+ .slice()
523
+ .sort(
524
+ (left, right) =>
525
+ variantSlotTime(right) - variantSlotTime(left) ||
526
+ right.slotId.localeCompare(left.slotId),
527
+ ),
528
+ [liveVariantsForLibrary?.slots],
529
+ );
530
+ const draftCandidateAssets = useMemo(() => {
531
+ const liveAssetIds = new Set(
532
+ liveCandidateSlots
533
+ .map((slot) => slot.assetId)
534
+ .filter((assetId): assetId is string => typeof assetId === "string"),
535
+ );
536
+ return assets
537
+ .filter(
538
+ (asset) =>
539
+ asset.status === "candidate" &&
540
+ asset.role === "generated" &&
541
+ !liveAssetIds.has(asset.id),
542
+ )
543
+ .slice()
544
+ .sort(
545
+ (left, right) =>
546
+ assetUpdatedTime(right) - assetUpdatedTime(left) ||
547
+ String(right.id).localeCompare(String(left.id)),
548
+ );
549
+ }, [assets, liveCandidateSlots]);
498
550
 
499
551
  useEffect(() => {
500
552
  setStyleDescriptionDraft(libraryStyleDescription);
@@ -514,41 +566,6 @@ export default function LibraryPage() {
514
566
  return upload.folderId === activeFolderId;
515
567
  });
516
568
 
517
- const pendingVariants =
518
- variants?.libraryId === libraryId
519
- ? (variants.slots ?? [])
520
- .filter(
521
- (slot: any) =>
522
- !slot.assetId || !finishedVariantAssetIds.has(slot.assetId),
523
- )
524
- .sort(compareVariantSlotsNewestFirst)
525
- : [];
526
-
527
- useEffect(() => {
528
- if (refreshGeneration.isPending) return;
529
- const runId = pendingVariants
530
- .map(stalePendingVariantRunId)
531
- .find((id: string | null): id is string =>
532
- Boolean(id && !refreshingVariantRunIdsRef.current.has(id)),
533
- );
534
- if (!runId) return;
535
- refreshingVariantRunIdsRef.current.add(runId);
536
- refreshGeneration.mutate(
537
- { runId },
538
- {
539
- onSettled: () => {
540
- window.setTimeout(() => {
541
- refreshingVariantRunIdsRef.current.delete(runId);
542
- }, 30_000);
543
- void queryClient.invalidateQueries({
544
- queryKey: ["app-state", "asset-variants"],
545
- refetchType: "active",
546
- });
547
- },
548
- },
549
- );
550
- }, [pendingVariants, queryClient, refreshGeneration]);
551
-
552
569
  function markAssetsOptimisticallyDeleted(ids: string[]) {
553
570
  setOptimisticallyDeletedAssetIds((current) => {
554
571
  const next = new Set(current);
@@ -591,18 +608,6 @@ export default function LibraryPage() {
591
608
  });
592
609
  }, [serverAssets]);
593
610
 
594
- function setCandidateSaving(key: string, saving: boolean) {
595
- setSavingCandidateKeys((current) => {
596
- const next = new Set(current);
597
- if (saving) {
598
- next.add(key);
599
- } else {
600
- next.delete(key);
601
- }
602
- return next.size === current.size ? current : next;
603
- });
604
- }
605
-
606
611
  function setReferencePromoting(key: string, promoting: boolean) {
607
612
  setPromotingReferenceKeys((current) => {
608
613
  const next = new Set(current);
@@ -615,52 +620,103 @@ export default function LibraryPage() {
615
620
  });
616
621
  }
617
622
 
618
- async function handleSaveCandidate(slot: any) {
619
- const key = candidateSaveKey(slot);
620
- if (!key || savingCandidateKeys.has(key)) return;
621
-
622
- setCandidateSaving(key, true);
623
+ async function handleSaveLiveCandidate(
624
+ slot: VariantSlot,
625
+ folderId?: string | null,
626
+ ) {
627
+ if (savingCandidateSlotId || (!slot.assetId && !slot.slotId)) return;
628
+ setSavingCandidateSlotId(slot.slotId);
623
629
  try {
624
630
  const savedAsset = await saveGenerated.mutateAsync({
625
- ...(slot.assetId ? { assetId: slot.assetId } : {}),
626
- ...(slot.slotId ? { slotId: slot.slotId } : {}),
627
- ...(slot.folderId !== undefined ? { folderId: slot.folderId } : {}),
631
+ assetId: slot.assetId,
632
+ slotId: slot.slotId,
633
+ folderId,
628
634
  });
629
- const savedAssetId =
630
- typeof (savedAsset as any)?.id === "string"
631
- ? (savedAsset as any).id
632
- : slot.assetId;
633
-
634
- if (savedAssetId) {
635
+ if (slot.assetId) {
635
636
  setOptimisticallySavedAssetIds((current) => {
636
637
  const next = new Set(current);
637
- next.add(savedAssetId);
638
+ next.add(slot.assetId!);
638
639
  return next;
639
640
  });
640
641
  markLibraryAssetSavedInCache(
641
642
  queryClient,
642
643
  libraryId,
643
- savedAssetId,
644
+ slot.assetId,
644
645
  savedAsset,
645
646
  );
646
647
  }
647
648
  removeVariantSlotFromCache(queryClient, slot);
648
649
  void queryClient.invalidateQueries({
649
- queryKey: ["app-state", "asset-variants"],
650
+ queryKey: ["action", "get-library", { id: libraryId }],
651
+ refetchType: "active",
652
+ });
653
+ void queryClient.invalidateQueries({
654
+ queryKey: ["app-state"],
650
655
  refetchType: "active",
651
656
  });
652
- toast.success(t("brandKitDetail.savedToGenerated"));
657
+ toast.success(t("library.savedToLibrary"));
653
658
  } catch (error) {
654
659
  toast.error(
655
660
  error instanceof Error
656
661
  ? error.message
657
- : t("brandKitDetail.couldNotSaveCandidate"),
662
+ : t("library.couldNotSaveCandidate"),
663
+ );
664
+ } finally {
665
+ setSavingCandidateSlotId(null);
666
+ }
667
+ }
668
+
669
+ async function handleSaveDraftCandidate(
670
+ asset: any,
671
+ folderId?: string | null,
672
+ ) {
673
+ if (!asset?.id || savingCandidateSlotId) return;
674
+ const key = `draft:${asset.id}`;
675
+ setSavingCandidateSlotId(key);
676
+ try {
677
+ const savedAsset = await saveGenerated.mutateAsync({
678
+ assetId: asset.id,
679
+ folderId,
680
+ });
681
+ setOptimisticallySavedAssetIds((current) => {
682
+ const next = new Set(current);
683
+ next.add(asset.id);
684
+ return next;
685
+ });
686
+ markLibraryAssetSavedInCache(
687
+ queryClient,
688
+ libraryId,
689
+ asset.id,
690
+ savedAsset,
691
+ );
692
+ void queryClient.invalidateQueries({
693
+ queryKey: ["action", "get-library", { id: libraryId }],
694
+ refetchType: "active",
695
+ });
696
+ void queryClient.invalidateQueries({
697
+ queryKey: ["app-state"],
698
+ refetchType: "active",
699
+ });
700
+ toast.success(t("library.savedToLibrary"));
701
+ } catch (error) {
702
+ toast.error(
703
+ error instanceof Error ? error.message : t("library.couldNotSaveDraft"),
658
704
  );
659
705
  } finally {
660
- setCandidateSaving(key, false);
706
+ setSavingCandidateSlotId(null);
661
707
  }
662
708
  }
663
709
 
710
+ function handleMoveLiveCandidateToReferences(slot: VariantSlot) {
711
+ if (!slot.assetId) return;
712
+ const asset = assetById.get(slot.assetId) ?? {
713
+ id: slot.assetId,
714
+ mediaType: "image",
715
+ status: "candidate",
716
+ };
717
+ void handleMoveToReferences(asset, slot);
718
+ }
719
+
664
720
  async function handleMoveToReferences(asset: any, slot?: any) {
665
721
  const key = referencePromotionKey(asset, slot);
666
722
  if (!asset?.id || !key || promotingReferenceKeys.has(key)) return;
@@ -684,7 +740,7 @@ export default function LibraryPage() {
684
740
  refetchType: "active",
685
741
  });
686
742
  void queryClient.invalidateQueries({
687
- queryKey: ["app-state", "asset-variants"],
743
+ queryKey: ["app-state"],
688
744
  refetchType: "active",
689
745
  });
690
746
  toast.success(t("brandKitDetail.addedToReferences"));
@@ -889,50 +945,41 @@ export default function LibraryPage() {
889
945
  }
890
946
  if (failedCount > 0) {
891
947
  toast.warning(
892
- t("brandKitDetail.uploadedSomeFailed", {
893
- uploaded: uploadedCount,
894
- uploadedPlural: uploadedCount === 1 ? "" : "s",
895
- failed: failedCount,
948
+ t("library.uploadedWithFailures", {
949
+ uploadedCount,
950
+ failedCount,
896
951
  }),
897
952
  {
898
953
  id: toastId,
899
954
  description:
900
955
  skippedCount > 0
901
- ? t("brandKitDetail.skippedDuplicates", {
902
- count: skippedCount,
903
- plural: skippedCount === 1 ? "" : "s",
904
- })
956
+ ? t("library.skippedDuplicates", { count: skippedCount })
905
957
  : null,
906
958
  },
907
959
  );
908
960
  } else if (uploadedCount > 0 && skippedCount > 0) {
909
961
  toast.success(
910
- t("brandKitDetail.uploadedSkipped", {
911
- uploaded: uploadedCount,
912
- uploadedPlural: uploadedCount === 1 ? "" : "s",
913
- skipped: skippedCount,
914
- skippedPlural: skippedCount === 1 ? "" : "s",
962
+ t("library.uploadedAndSkippedDuplicates", {
963
+ uploadedCount,
964
+ skippedCount,
915
965
  }),
916
966
  { id: toastId, description: null },
917
967
  );
918
968
  } else if (uploadedCount > 0) {
919
- toast.success(
920
- t("brandKitDetail.uploadedCount", { count: uploadedCount }),
921
- {
922
- id: toastId,
923
- description: null,
924
- },
925
- );
969
+ toast.success(t("library.uploadedAssets", { count: uploadedCount }), {
970
+ id: toastId,
971
+ description: null,
972
+ });
926
973
  } else if (skippedCount > 0) {
927
974
  toast.warning(
928
- t("brandKitDetail.skippedDuplicateAssets", { count: skippedCount }),
975
+ t("library.skippedDuplicateAssets", { count: skippedCount }),
929
976
  {
930
977
  id: toastId,
931
- description: t("brandKitDetail.alreadyInBrandKit"),
978
+ description: t("library.alreadyInThisBrandKit"),
932
979
  },
933
980
  );
934
981
  } else {
935
- toast.warning(t("brandKitDetail.noNewAssetsUploaded"), {
982
+ toast.warning(t("library.noNewAssetsUploaded"), {
936
983
  id: toastId,
937
984
  description: null,
938
985
  });
@@ -940,7 +987,7 @@ export default function LibraryPage() {
940
987
  await refreshLibrary();
941
988
  } catch (e) {
942
989
  const message =
943
- e instanceof Error ? e.message : t("brandKitDetail.uploadFailed");
990
+ e instanceof Error ? e.message : t("library.uploadFailed");
944
991
  const indeterminate =
945
992
  /(?:\b408\b|\b504\b|timeout|timed out|network|failed to fetch|load failed)/i.test(
946
993
  message,
@@ -950,9 +997,9 @@ export default function LibraryPage() {
950
997
  setPendingUploads(
951
998
  pending.map((upload) => ({ ...upload, status: "checking" })),
952
999
  );
953
- toast.warning(t("brandKitDetail.uploadTakingLonger"), {
1000
+ toast.warning(t("library.uploadTakingLonger"), {
954
1001
  id: toastId,
955
- description: t("brandKitDetail.uploadMayFinish"),
1002
+ description: t("library.uploadTakingLongerDescription"),
956
1003
  });
957
1004
  void refreshLibrary();
958
1005
  window.setTimeout(() => void refreshLibrary(), 4_000);
@@ -974,8 +1021,8 @@ export default function LibraryPage() {
974
1021
  if (!library || archiveLibrary.isPending) return;
975
1022
  try {
976
1023
  await archiveLibrary.mutateAsync({ id: library.id });
977
- toast.success(t("brandKitDetail.brandKitArchived"));
978
- navigate("/brand-kits");
1024
+ toast.success(t("library.brandKitArchived"));
1025
+ navigate("/library");
979
1026
  } catch (error) {
980
1027
  toast.error(
981
1028
  error instanceof Error
@@ -991,8 +1038,8 @@ export default function LibraryPage() {
991
1038
  const copy = (await duplicateLibrary.mutateAsync({
992
1039
  id: library.id,
993
1040
  })) as any;
994
- toast.success(t("brandKitDetail.privateCopyCreated"));
995
- navigate(`/brand-kits/${copy.id}`);
1041
+ toast.success(t("library.privateBrandKitCopyCreated"));
1042
+ navigate(`/library/${copy.id}`);
996
1043
  } catch (error) {
997
1044
  toast.error(
998
1045
  error instanceof Error
@@ -1002,61 +1049,6 @@ export default function LibraryPage() {
1002
1049
  }
1003
1050
  }
1004
1051
 
1005
- function generate(prompt: string, options: GenerateOptions) {
1006
- const selectedPreset = options.presetId
1007
- ? generationPresets.find((preset) => preset.id === options.presetId)
1008
- : null;
1009
- const trimmedPrompt = prompt.trim();
1010
- const chatMessage =
1011
- trimmedPrompt ||
1012
- (options.mediaType === "video"
1013
- ? "Generate a video for this library."
1014
- : "Generate images for this library.");
1015
- const context = [
1016
- "## Assets library context",
1017
- options.mediaType === "video"
1018
- ? "Requested output: 1 video candidate for this library"
1019
- : `Requested output: ${options.count} image candidate${options.count === 1 ? "" : "s"} for this library`,
1020
- `User prompt: ${trimmedPrompt || "(no text prompt provided)"}`,
1021
- options.presetId ? `Preset ID: ${options.presetId}` : "Preset ID: none",
1022
- `Aspect ratio: ${options.aspectRatio}`,
1023
- options.mediaType === "video"
1024
- ? `Duration: ${options.durationSeconds}s\nResolution: ${options.resolution}`
1025
- : `Image size: ${options.imageSize}`,
1026
- `Model: ${options.model}`,
1027
- activeFolderId && activeFolderId !== "all"
1028
- ? `Folder ID: ${activeFolderId}`
1029
- : "Folder ID: none",
1030
- `Reference categories: ${options.category}`,
1031
- `Include canonical logo: ${options.includeLogo ? "yes" : "no"}`,
1032
- "",
1033
- "## Selected library",
1034
- `Library: ${library.title} (${library.id})`,
1035
- `Description: ${library.description || ""}`,
1036
- `Folder: ${activeFolderId && activeFolderId !== "all" ? folders.find((folder) => folder.id === activeFolderId)?.title : "All assets"}`, // i18n-ignore: agent prompt context, not UI
1037
- `References: ${references.length}`,
1038
- `Saved assets: ${saved.length}`,
1039
- `Style brief: ${JSON.stringify(library.styleBrief ?? {})}`,
1040
- customInstructions
1041
- ? `Custom instructions: ${customInstructions}`
1042
- : "Custom instructions: none",
1043
- selectedPreset
1044
- ? `Generation preset: ${selectedPreset.title} (${selectedPreset.id}); ${selectedPreset.aspectRatio}; text policy: ${selectedPreset.textPolicy || "none"}`
1045
- : "Generation preset: none",
1046
- "",
1047
- options.mediaType === "video"
1048
- ? "Use generate-video, then call refresh-generation-run until the run completes and returns a video asset. Use save-generated-asset when the user approves it."
1049
- : "Use the asset generation actions. If a generation preset ID is present, pass presetId to generate-image or generate-image-batch. Generate candidates, show previews, ask for feedback, and refine by assetId until the user is happy.",
1050
- ].join("\n");
1051
- sendToAgentChat({
1052
- message: chatMessage,
1053
- context,
1054
- submit: true,
1055
- newTab: true,
1056
- });
1057
- setGenerateOpen(false);
1058
- }
1059
-
1060
1052
  function continueSession(sessionId: string) {
1061
1053
  prepareSessionContinuation.mutate(
1062
1054
  { id: sessionId },
@@ -1070,9 +1062,7 @@ export default function LibraryPage() {
1070
1062
  });
1071
1063
  },
1072
1064
  onError: (error: Error) => {
1073
- toast.error(
1074
- error.message || t("brandKitDetail.couldNotPrepareHandoff"),
1075
- );
1065
+ toast.error(error.message || t("library.couldNotPrepareHandoff"));
1076
1066
  },
1077
1067
  },
1078
1068
  );
@@ -1081,11 +1071,11 @@ export default function LibraryPage() {
1081
1071
  function createHandoffFromRun(run: any) {
1082
1072
  const outputIds = outputAssetIds(run);
1083
1073
  if (!outputIds.length) {
1084
- toast.error(t("brandKitDetail.runNoGeneratedAssets"));
1074
+ toast.error(t("library.runHasNoGeneratedAssets"));
1085
1075
  return;
1086
1076
  }
1087
1077
  const prompt =
1088
- run.originalPrompt || run.prompt || t("brandKitDetail.generatedAsset");
1078
+ run.originalPrompt || run.prompt || t("library.generatedAsset");
1089
1079
  createSession.mutate(
1090
1080
  {
1091
1081
  libraryId,
@@ -1113,7 +1103,7 @@ export default function LibraryPage() {
1113
1103
  if (!library) {
1114
1104
  return (
1115
1105
  <div className="p-6 text-sm text-muted-foreground">
1116
- {t("brandKitDetail.loadingBrandKit")}
1106
+ {t("library.loadingBrandKit")}
1117
1107
  </div>
1118
1108
  );
1119
1109
  }
@@ -1122,121 +1112,123 @@ export default function LibraryPage() {
1122
1112
  activeTab === "runs" || activeTab === "settings" ? activeTab : "assets";
1123
1113
  const hideEmptyLanes =
1124
1114
  activeFolderId !== "all" || mediaFilter !== "all" || search.trim() !== "";
1115
+ const uploadAction = (
1116
+ <Button
1117
+ variant="outline"
1118
+ className="gap-2"
1119
+ onClick={() => fileInputRef.current?.click()}
1120
+ disabled={uploading}
1121
+ >
1122
+ {uploading ? (
1123
+ <Spinner className="h-4 w-4" />
1124
+ ) : (
1125
+ <IconUpload className="h-4 w-4" />
1126
+ )}
1127
+ {uploading
1128
+ ? t("library.uploadingCount", { count: pendingUploads.length })
1129
+ : t("library.upload")}
1130
+ </Button>
1131
+ );
1132
+ const moreActions = (
1133
+ <DropdownMenu>
1134
+ <DropdownMenuTrigger asChild>
1135
+ <Button
1136
+ variant="outline"
1137
+ size="icon"
1138
+ aria-label={t("library.kitActions")}
1139
+ disabled={archiveLibrary.isPending || duplicateLibrary.isPending}
1140
+ >
1141
+ <IconDotsVertical className="h-4 w-4" />
1142
+ </Button>
1143
+ </DropdownMenuTrigger>
1144
+ <DropdownMenuContent align="end">
1145
+ <DropdownMenuItem
1146
+ onSelect={(event) => {
1147
+ event.preventDefault();
1148
+ setFolderOpen(true);
1149
+ }}
1150
+ >
1151
+ <IconFolderPlus className="mr-2 h-4 w-4 shrink-0" />
1152
+ {t("library.newFolder")}
1153
+ </DropdownMenuItem>
1154
+ <ShareButton
1155
+ resourceType="asset-library"
1156
+ resourceId={library.id}
1157
+ resourceTitle={library.title}
1158
+ triggerClassName="w-full justify-start border-0 bg-transparent px-2 py-1.5 text-sm font-normal shadow-none hover:bg-accent hover:text-accent-foreground"
1159
+ />
1160
+ <DropdownMenuSeparator />
1161
+ <DropdownMenuItem
1162
+ disabled={duplicateLibrary.isPending}
1163
+ onSelect={(event) => {
1164
+ event.preventDefault();
1165
+ void duplicateCurrentLibrary();
1166
+ }}
1167
+ >
1168
+ <IconCopy className="mr-2 h-4 w-4 shrink-0" />
1169
+ {duplicateLibrary.isPending
1170
+ ? t("library.duplicating")
1171
+ : t("library.duplicate")}
1172
+ </DropdownMenuItem>
1173
+ <DropdownMenuSeparator />
1174
+ <DropdownMenuItem
1175
+ onSelect={(event) => {
1176
+ event.preventDefault();
1177
+ setArchiveOpen(true);
1178
+ }}
1179
+ >
1180
+ <IconArchive className="mr-2 h-4 w-4 shrink-0" />
1181
+ {t("library.archiveBrandKit")}
1182
+ </DropdownMenuItem>
1183
+ </DropdownMenuContent>
1184
+ </DropdownMenu>
1185
+ );
1186
+ const headerActions = (
1187
+ <div className="flex flex-wrap items-center gap-2 sm:flex-nowrap lg:shrink-0">
1188
+ {uploadAction}
1189
+ {moreActions}
1190
+ </div>
1191
+ );
1192
+ const headerPrimaryActionsPortal =
1193
+ headerMode === "actions" && headerPrimaryActionsTarget
1194
+ ? createPortal(uploadAction, headerPrimaryActionsTarget)
1195
+ : null;
1196
+ const headerMoreActionsPortal =
1197
+ headerMode === "actions" && headerMoreActionsTarget
1198
+ ? createPortal(moreActions, headerMoreActionsTarget)
1199
+ : null;
1125
1200
 
1126
1201
  return (
1127
- <div className="flex h-full min-h-0 flex-col">
1128
- <div className="border-b border-border px-6 py-4">
1129
- <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
1130
- <div className="min-w-0">
1131
- <div className="flex items-center gap-2">
1132
- <h2 className="truncate text-2xl font-semibold tracking-tight">
1133
- {library.title}
1134
- </h2>
1135
- <Badge variant="outline">{library.visibility}</Badge>
1136
- <Button
1137
- variant="ghost"
1138
- size="icon"
1139
- className="h-8 w-8 text-muted-foreground hover:text-foreground"
1140
- onClick={() => setEditOpen(true)}
1141
- aria-label={t("brandKitDetail.editBrandKit")}
1142
- >
1143
- <IconPencil className="h-4 w-4" />
1144
- </Button>
1145
- </div>
1146
- <p className="mt-1 max-w-3xl text-sm text-muted-foreground">
1147
- {library.description || t("brandKitDetail.defaultDescription")}
1148
- </p>
1149
- </div>
1150
- <div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:flex-nowrap lg:shrink-0">
1151
- <ShareButton
1152
- resourceType="asset-library"
1153
- resourceId={library.id}
1154
- resourceTitle={library.title}
1155
- />
1156
- <Button
1157
- variant="outline"
1158
- className="gap-2"
1159
- onClick={() => fileInputRef.current?.click()}
1160
- disabled={uploading}
1161
- >
1162
- {uploading ? (
1163
- <Spinner className="h-4 w-4" />
1164
- ) : (
1165
- <IconUpload className="h-4 w-4" />
1166
- )}
1167
- {uploading
1168
- ? t("brandKitDetail.uploadingCount", {
1169
- count: pendingUploads.length,
1170
- })
1171
- : t("brandKitDetail.upload")}
1172
- </Button>
1173
- <Button
1174
- variant="outline"
1175
- className="hidden gap-2 xl:inline-flex"
1176
- onClick={() => setFolderOpen(true)}
1177
- >
1178
- <IconFolderPlus className="h-4 w-4" />
1179
- {t("brandKitDetail.folder")}
1180
- </Button>
1181
- <GeneratePopover
1182
- open={generateOpen}
1183
- onOpenChange={setGenerateOpen}
1184
- onSubmit={generate}
1185
- hasLogo={!!library.canonicalLogoAssetId}
1186
- presets={generationPresets}
1187
- />
1188
- <DropdownMenu>
1189
- <DropdownMenuTrigger asChild>
1202
+ <div className="flex min-w-0 flex-col">
1203
+ {headerPrimaryActionsPortal}
1204
+ {headerMoreActionsPortal}
1205
+ {headerMode === "full" ? (
1206
+ <div className="border-b border-border px-6 py-4">
1207
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
1208
+ <div className="min-w-0">
1209
+ <div className="flex items-center gap-2">
1210
+ <h2 className="truncate text-2xl font-semibold tracking-tight">
1211
+ {library.title}
1212
+ </h2>
1213
+ <Badge variant="outline">{library.visibility}</Badge>
1190
1214
  <Button
1191
- variant="outline"
1215
+ variant="ghost"
1192
1216
  size="icon"
1193
- aria-label={t("brandKitDetail.brandKitActions")}
1194
- disabled={
1195
- archiveLibrary.isPending || duplicateLibrary.isPending
1196
- }
1217
+ className="h-8 w-8 text-muted-foreground hover:text-foreground"
1218
+ onClick={() => setEditOpen(true)}
1219
+ aria-label={t("library.editBrandKit")}
1197
1220
  >
1198
- <IconDotsVertical className="h-4 w-4" />
1221
+ <IconPencil className="h-4 w-4" />
1199
1222
  </Button>
1200
- </DropdownMenuTrigger>
1201
- <DropdownMenuContent align="end">
1202
- <DropdownMenuItem
1203
- className="xl:hidden"
1204
- onSelect={(event) => {
1205
- event.preventDefault();
1206
- setFolderOpen(true);
1207
- }}
1208
- >
1209
- <IconFolderPlus className="mr-2 h-4 w-4 shrink-0" />
1210
- {t("brandKitDetail.newFolder")}
1211
- </DropdownMenuItem>
1212
- <DropdownMenuSeparator className="xl:hidden" />
1213
- <DropdownMenuItem
1214
- disabled={duplicateLibrary.isPending}
1215
- onSelect={(event) => {
1216
- event.preventDefault();
1217
- void duplicateCurrentLibrary();
1218
- }}
1219
- >
1220
- <IconCopy className="mr-2 h-4 w-4 shrink-0" />
1221
- {duplicateLibrary.isPending
1222
- ? t("brandKitDetail.duplicating")
1223
- : t("brandKitDetail.duplicate")}
1224
- </DropdownMenuItem>
1225
- <DropdownMenuSeparator />
1226
- <DropdownMenuItem
1227
- onSelect={(event) => {
1228
- event.preventDefault();
1229
- setArchiveOpen(true);
1230
- }}
1231
- >
1232
- <IconArchive className="mr-2 h-4 w-4 shrink-0" />
1233
- {t("brandKitDetail.archiveBrandKit")}
1234
- </DropdownMenuItem>
1235
- </DropdownMenuContent>
1236
- </DropdownMenu>
1223
+ </div>
1224
+ <p className="mt-1 max-w-3xl text-sm text-muted-foreground">
1225
+ {library.description || t("library.defaultKitDescription")}
1226
+ </p>
1227
+ </div>
1228
+ {headerActions}
1237
1229
  </div>
1238
1230
  </div>
1239
- </div>
1231
+ ) : null}
1240
1232
 
1241
1233
  <input
1242
1234
  ref={fileInputRef}
@@ -1255,15 +1247,13 @@ export default function LibraryPage() {
1255
1247
  <AlertDialog open={archiveOpen} onOpenChange={setArchiveOpen}>
1256
1248
  <AlertDialogContent>
1257
1249
  <AlertDialogHeader>
1258
- <AlertDialogTitle>
1259
- {t("brandKitDetail.archiveThisBrandKit")}
1260
- </AlertDialogTitle>
1250
+ <AlertDialogTitle>{t("library.archiveKitTitle")}</AlertDialogTitle>
1261
1251
  <AlertDialogDescription>
1262
- {t("brandKitDetail.archiveDescription")}
1252
+ {t("library.archiveKitDescription")}
1263
1253
  </AlertDialogDescription>
1264
1254
  </AlertDialogHeader>
1265
1255
  <AlertDialogFooter>
1266
- <AlertDialogCancel>{t("brandKitDetail.cancel")}</AlertDialogCancel>
1256
+ <AlertDialogCancel>{t("library.cancel")}</AlertDialogCancel>
1267
1257
  <AlertDialogAction
1268
1258
  disabled={archiveLibrary.isPending}
1269
1259
  onClick={() => {
@@ -1271,8 +1261,8 @@ export default function LibraryPage() {
1271
1261
  }}
1272
1262
  >
1273
1263
  {archiveLibrary.isPending
1274
- ? t("brandKitDetail.archiving")
1275
- : t("brandKitDetail.archive")}
1264
+ ? t("library.archiving")
1265
+ : t("library.archive")}
1276
1266
  </AlertDialogAction>
1277
1267
  </AlertDialogFooter>
1278
1268
  </AlertDialogContent>
@@ -1298,7 +1288,7 @@ export default function LibraryPage() {
1298
1288
  ) : null}
1299
1289
 
1300
1290
  <div
1301
- className="relative flex-1 overflow-y-auto px-6 py-5"
1291
+ className="relative px-6 py-5"
1302
1292
  onDragEnter={(e: DragEvent<HTMLDivElement>) => {
1303
1293
  if (!e.dataTransfer.types.includes("Files")) return;
1304
1294
  e.preventDefault();
@@ -1338,38 +1328,24 @@ export default function LibraryPage() {
1338
1328
  className="space-y-4"
1339
1329
  >
1340
1330
  <TabsList>
1341
- <TabsTrigger value="assets">
1342
- {t("brandKitDetail.assets")}
1343
- </TabsTrigger>
1344
- <TabsTrigger value="runs">{t("brandKitDetail.runs")}</TabsTrigger>
1345
- <TabsTrigger value="settings">
1346
- {t("brandKitDetail.settings")}
1347
- </TabsTrigger>
1331
+ <TabsTrigger value="assets">{t("library.assetsTab")}</TabsTrigger>
1332
+ <TabsTrigger value="runs">{t("library.runs")}</TabsTrigger>
1333
+ <TabsTrigger value="settings">{t("library.settings")}</TabsTrigger>
1348
1334
  </TabsList>
1349
1335
 
1350
1336
  <TabsContent value="assets" className="space-y-5">
1351
- <CandidateStage
1352
- candidates={candidateAssets}
1353
- pendingVariants={pendingVariants}
1354
- libraryId={libraryId}
1355
- folders={folders}
1356
- savingCandidateKeys={savingCandidateKeys}
1357
- onSaveCandidate={(slot) => {
1358
- void handleSaveCandidate(slot);
1359
- }}
1360
- />
1361
1337
  <section className="space-y-3">
1362
1338
  <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
1363
1339
  <div className="flex min-w-0 flex-wrap items-center gap-2">
1364
1340
  <FolderChip
1365
1341
  active={activeFolderId === "all"}
1366
- label={t("brandKitDetail.allAssets")}
1342
+ label={t("library.allAssets")}
1367
1343
  count={libraryAssets.length}
1368
1344
  onClick={() => setActiveFolderId("all")}
1369
1345
  />
1370
1346
  <FolderChip
1371
1347
  active={activeFolderId === null}
1372
- label={t("brandKitDetail.unfiled")}
1348
+ label={t("library.unfiled")}
1373
1349
  count={unfiledCount}
1374
1350
  onClick={() => setActiveFolderId(null)}
1375
1351
  />
@@ -1393,13 +1369,13 @@ export default function LibraryPage() {
1393
1369
  <Input
1394
1370
  value={search}
1395
1371
  onChange={(event) => setSearch(event.target.value)}
1396
- placeholder={t("brandKitDetail.searchAssets")}
1372
+ placeholder={t("library.searchAssets")}
1397
1373
  className="h-9 w-full pl-8 pr-8 sm:w-64"
1398
1374
  />
1399
1375
  {search && (
1400
1376
  <button
1401
1377
  type="button"
1402
- aria-label={t("brandKitDetail.clearSearch")}
1378
+ aria-label={t("library.clearSearch")}
1403
1379
  className="absolute right-2 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
1404
1380
  onClick={() => setSearch("")}
1405
1381
  >
@@ -1624,19 +1600,6 @@ export default function LibraryPage() {
1624
1600
  );
1625
1601
  }
1626
1602
 
1627
- type GenerateOptions = {
1628
- mediaType: "image" | "video";
1629
- presetId?: string;
1630
- count: number;
1631
- aspectRatio: string;
1632
- imageSize: string;
1633
- durationSeconds: number;
1634
- resolution: string;
1635
- model: string;
1636
- category: string;
1637
- includeLogo: boolean;
1638
- };
1639
-
1640
1603
  type PendingUpload = {
1641
1604
  id: string;
1642
1605
  name: string;
@@ -1655,10 +1618,12 @@ type LaneGalleryItem = {
1655
1618
  subtitle?: string | null;
1656
1619
  metadata?: string | null;
1657
1620
  status?: string | null;
1621
+ asset?: any;
1658
1622
  mediaType?: "image" | "video";
1659
1623
  href?: string;
1660
1624
  selected?: boolean;
1661
1625
  busy?: boolean;
1626
+ showBusyOverlay?: boolean;
1662
1627
  deleting?: boolean;
1663
1628
  preview: ReactNode;
1664
1629
  thumbnail: ReactNode; // i18n-ignore structural preview slot name
@@ -1912,6 +1877,63 @@ function assetLineageSourceText(asset: any): string | null {
1912
1877
  : null;
1913
1878
  }
1914
1879
 
1880
+ function detailAssetPayload(asset: any) {
1881
+ const mediaType =
1882
+ asset?.mediaType === "video" || asset?.mimeType?.startsWith("video/")
1883
+ ? "video"
1884
+ : "image";
1885
+ const title = assetDisplayTitle(asset);
1886
+ const url = assetMediaUrl(
1887
+ asset?.previewUrl ?? asset?.downloadUrl ?? asset?.url,
1888
+ );
1889
+ const width = Number(asset?.width);
1890
+ const height = Number(asset?.height);
1891
+ return {
1892
+ assetId: asset?.id,
1893
+ title,
1894
+ mediaType,
1895
+ url,
1896
+ previewUrl: assetMediaUrl(asset?.previewUrl),
1897
+ downloadUrl: assetMediaUrl(asset?.downloadUrl),
1898
+ ...(Number.isFinite(width) && Number.isFinite(height) && width && height
1899
+ ? { width, height }
1900
+ : {}),
1901
+ };
1902
+ }
1903
+
1904
+ function detailAssetClipboardText(asset: any) {
1905
+ const payload = detailAssetPayload(asset);
1906
+ const url = payload.url ?? payload.downloadUrl ?? payload.previewUrl;
1907
+ const previewTip =
1908
+ payload.mediaType === "image" && url
1909
+ ? [
1910
+ `Markdown preview: ![Selected asset](${url})`,
1911
+ "If this remote preview does not render in Codex or Claude Code, download the image locally and embed the absolute local file path.",
1912
+ ]
1913
+ : [];
1914
+ return [
1915
+ `Use this selected ${payload.mediaType} in the current work: ${payload.title}`,
1916
+ url ? `URL: ${url}` : null,
1917
+ ...previewTip,
1918
+ "",
1919
+ JSON.stringify(
1920
+ {
1921
+ assetId: payload.assetId,
1922
+ title: payload.title,
1923
+ mediaType: payload.mediaType,
1924
+ url,
1925
+ ...(payload.width && payload.height
1926
+ ? { width: payload.width, height: payload.height }
1927
+ : {}),
1928
+ },
1929
+ null,
1930
+ 2,
1931
+ ),
1932
+ ]
1933
+ .filter((line): line is string => line !== null)
1934
+ .join("\n");
1935
+ }
1936
+
1915
1937
  function shortId(id: string) {
1916
1938
  return id.length > 12 ? `${id.slice(0, 6)}...${id.slice(-4)}` : id;
1917
1939
  }
@@ -1997,287 +2019,30 @@ function SessionCard({
1997
2019
  );
1998
2020
  }
1999
2021
 
2000
- function GeneratePopover({
2001
- open,
2002
- onOpenChange,
2003
- onSubmit,
2004
- hasLogo,
2022
+ function GenerationPresetsPanel({
2023
+ libraryId,
2005
2024
  presets,
2006
2025
  }: {
2007
- open: boolean;
2008
- onOpenChange: (open: boolean) => void;
2009
- onSubmit: (prompt: string, options: GenerateOptions) => void;
2010
- hasLogo: boolean;
2026
+ libraryId: string;
2011
2027
  presets: any[];
2012
2028
  }) {
2013
2029
  const t = useT();
2014
- const [prompt, setPrompt] = useState("");
2015
- const [mediaType, setMediaType] = useState<"image" | "video">("image");
2016
- const [presetId, setPresetId] = useState("none");
2017
- const [count, setCount] = useState(3);
2018
- const [aspectRatio, setAspectRatio] = useState("16:9");
2019
- const [imageSize, setImageSize] = useState("2K");
2020
- const [durationSeconds, setDurationSeconds] = useState(8);
2021
- const [resolution, setResolution] = useState("720p");
2022
- const [model, setModel] = useState("gemini-3.1-flash-image");
2023
- const [category, setCategory] = useState("hero");
2024
- const [includeLogo, setIncludeLogo] = useState(false);
2025
- const selectedPreset = presets.find((preset) => preset.id === presetId);
2026
-
2027
- return (
2028
- <Popover open={open} onOpenChange={onOpenChange}>
2029
- <PopoverTrigger asChild>
2030
- <Button className="gap-2">
2031
- <IconMessageCircle className="h-4 w-4" />
2032
- {t("brandKitDetail.generate")}
2033
- </Button>
2034
- </PopoverTrigger>
2035
- <PopoverContent
2036
- align="end"
2037
- className="w-[420px] max-w-[calc(100vw-2rem)]"
2038
- >
2039
- <div className="space-y-4">
2040
- <div>
2041
- <div className="text-sm font-semibold">
2042
- {t("brandKitDetail.generateWithChat")}
2043
- </div>
2044
- </div>
2045
- {presets.length ? (
2046
- <Select
2047
- value={presetId}
2048
- onValueChange={(value) => {
2049
- setPresetId(value);
2050
- const preset = presets.find((item) => item.id === value);
2051
- if (!preset) return;
2052
- setMediaType(preset.mediaType === "video" ? "video" : "image");
2053
- setAspectRatio(preset.aspectRatio || "16:9");
2054
- setImageSize(preset.imageSize || "2K");
2055
- setModel(preset.model || "gemini-3.1-flash-image");
2056
- setCategory(preset.category || "hero");
2057
- }}
2058
- >
2059
- <SelectTrigger>
2060
- <SelectValue placeholder={t("brandKitDetail.preset")} />
2061
- </SelectTrigger>
2062
- <SelectContent>
2063
- <SelectItem value="none">
2064
- {t("brandKitDetail.noPreset")}
2065
- </SelectItem>
2066
- {presets.map((preset) => (
2067
- <SelectItem key={preset.id} value={preset.id}>
2068
- {preset.title}
2069
- </SelectItem>
2070
- ))}
2071
- </SelectContent>
2072
- </Select>
2073
- ) : null}
2074
- {selectedPreset?.textPolicy ? (
2075
- <p className="rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
2076
- {selectedPreset.textPolicy}
2077
- </p>
2078
- ) : null}
2079
- <Select
2080
- value={mediaType}
2081
- onValueChange={(value) => {
2082
- const next = value as "image" | "video";
2083
- setMediaType(next);
2084
- setModel(
2085
- next === "video"
2086
- ? "veo-3.1-generate-preview"
2087
- : "gemini-3.1-flash-image",
2088
- );
2089
- setCategory(next === "video" ? "video" : "hero");
2090
- setAspectRatio(next === "video" ? "16:9" : aspectRatio);
2091
- }}
2092
- >
2093
- <SelectTrigger>
2094
- <SelectValue />
2095
- </SelectTrigger>
2096
- <SelectContent>
2097
- <SelectItem value="image">
2098
- {t("brandKitDetail.imageCandidates")}
2099
- </SelectItem>
2100
- <SelectItem value="video">
2101
- {t("brandKitDetail.videoCandidate")}
2102
- </SelectItem>
2103
- </SelectContent>
2104
- </Select>
2105
- <Textarea
2106
- autoGrow
2107
- value={prompt}
2108
- onChange={(event) => setPrompt(event.target.value)}
2109
- placeholder={
2110
- mediaType === "video"
2111
- ? t("brandKitDetail.videoPromptPlaceholder")
2112
- : t("brandKitDetail.imagePromptPlaceholder")
2113
- }
2114
- className="min-h-28 max-h-48 resize-none overflow-y-auto"
2115
- />
2116
- <div className="grid grid-cols-2 gap-3">
2117
- {mediaType === "image" ? (
2118
- <Select
2119
- value={String(count)}
2120
- onValueChange={(v) => setCount(Number(v))}
2121
- >
2122
- <SelectTrigger>
2123
- <SelectValue />
2124
- </SelectTrigger>
2125
- <SelectContent>
2126
- {[1, 2, 3, 4].map((n) => (
2127
- <SelectItem key={n} value={String(n)}>
2128
- {t("brandKitDetail.variants", { count: n })}
2129
- </SelectItem>
2130
- ))}
2131
- </SelectContent>
2132
- </Select>
2133
- ) : (
2134
- <Select
2135
- value={String(durationSeconds)}
2136
- onValueChange={(v) => setDurationSeconds(Number(v))}
2137
- >
2138
- <SelectTrigger>
2139
- <SelectValue />
2140
- </SelectTrigger>
2141
- <SelectContent>
2142
- {VIDEO_DURATIONS.map((n) => (
2143
- <SelectItem key={n} value={String(n)}>
2144
- {n}s
2145
- </SelectItem>
2146
- ))}
2147
- </SelectContent>
2148
- </Select>
2149
- )}
2150
- <Select value={aspectRatio} onValueChange={setAspectRatio}>
2151
- <SelectTrigger>
2152
- <SelectValue />
2153
- </SelectTrigger>
2154
- <SelectContent>
2155
- {(mediaType === "video"
2156
- ? VIDEO_ASPECT_RATIOS
2157
- : ASPECT_RATIOS
2158
- ).map((ratio) => (
2159
- <SelectItem key={ratio} value={ratio}>
2160
- {ratio}
2161
- </SelectItem>
2162
- ))}
2163
- </SelectContent>
2164
- </Select>
2165
- {mediaType === "image" ? (
2166
- <Select value={imageSize} onValueChange={setImageSize}>
2167
- <SelectTrigger>
2168
- <SelectValue />
2169
- </SelectTrigger>
2170
- <SelectContent>
2171
- {IMAGE_SIZES.map((size) => (
2172
- <SelectItem key={size} value={size}>
2173
- {size}
2174
- </SelectItem>
2175
- ))}
2176
- </SelectContent>
2177
- </Select>
2178
- ) : (
2179
- <Select value={resolution} onValueChange={setResolution}>
2180
- <SelectTrigger>
2181
- <SelectValue />
2182
- </SelectTrigger>
2183
- <SelectContent>
2184
- {VIDEO_RESOLUTIONS.map((item) => (
2185
- <SelectItem key={item} value={item}>
2186
- {item}
2187
- </SelectItem>
2188
- ))}
2189
- </SelectContent>
2190
- </Select>
2191
- )}
2192
- <Select value={category} onValueChange={setCategory}>
2193
- <SelectTrigger>
2194
- <SelectValue />
2195
- </SelectTrigger>
2196
- <SelectContent>
2197
- {IMAGE_CATEGORIES.map((item) => (
2198
- <SelectItem key={item} value={item}>
2199
- {item}
2200
- </SelectItem>
2201
- ))}
2202
- </SelectContent>
2203
- </Select>
2204
- </div>
2205
- <Select value={model} onValueChange={setModel}>
2206
- <SelectTrigger>
2207
- <SelectValue />
2208
- </SelectTrigger>
2209
- <SelectContent>
2210
- {(mediaType === "video" ? VIDEO_MODELS : IMAGE_MODELS).map(
2211
- (item) => (
2212
- <SelectItem key={item} value={item}>
2213
- {item}
2214
- </SelectItem>
2215
- ),
2216
- )}
2217
- </SelectContent>
2218
- </Select>
2219
- {mediaType === "image" && (
2220
- <label className="flex items-center gap-2 text-sm">
2221
- <Checkbox
2222
- checked={includeLogo}
2223
- disabled={!hasLogo}
2224
- onCheckedChange={(checked) => setIncludeLogo(checked === true)}
2225
- />
2226
- {t("brandKitDetail.compositeCanonicalLogo")}
2227
- </label>
2228
- )}
2229
- <Button
2230
- className="w-full"
2231
- disabled={!prompt.trim()}
2232
- onClick={() =>
2233
- onSubmit(prompt, {
2234
- mediaType,
2235
- presetId: presetId === "none" ? undefined : presetId,
2236
- count,
2237
- aspectRatio,
2238
- imageSize,
2239
- durationSeconds,
2240
- resolution,
2241
- model,
2242
- category,
2243
- includeLogo,
2244
- })
2245
- }
2246
- >
2247
- {t("brandKitDetail.openChat")}
2248
- </Button>
2249
- </div>
2250
- </PopoverContent>
2251
- </Popover>
2252
- );
2253
- }
2254
-
2255
- function GenerationPresetsPanel({
2256
- libraryId,
2257
- presets,
2258
- }: {
2259
- libraryId: string;
2260
- presets: any[];
2261
- }) {
2262
- const t = useT();
2263
- const createPreset = useActionMutation("create-generation-preset");
2264
- const deletePreset = useActionMutation("delete-generation-preset");
2265
- const [open, setOpen] = useState(false);
2266
- const [confirmPresetId, setConfirmPresetId] = useState<string | null>(null);
2267
- const [title, setTitle] = useState("");
2268
- const [category, setCategory] = useState<ImageCategory>("social");
2269
- const [aspectRatio, setAspectRatio] = useState<AspectRatio>("1:1");
2270
- const [promptTemplate, setPromptTemplate] = useState("");
2271
- const [textPolicy, setTextPolicy] = useState(
2272
- t("brandKitDetail.defaultTextPolicy"),
2273
- );
2030
+ const createPreset = useActionMutation("create-generation-preset");
2031
+ const deletePreset = useActionMutation("delete-generation-preset");
2032
+ const [open, setOpen] = useState(false);
2033
+ const [confirmPresetId, setConfirmPresetId] = useState<string | null>(null);
2034
+ const [title, setTitle] = useState("");
2035
+ const [category, setCategory] = useState<ImageCategory>("social");
2036
+ const [aspectRatio, setAspectRatio] = useState<AspectRatio>("1:1");
2037
+ const [promptTemplate, setPromptTemplate] = useState("");
2038
+ const [textPolicy, setTextPolicy] = useState(t("library.defaultTextPolicy"));
2274
2039
 
2275
2040
  function reset() {
2276
2041
  setTitle("");
2277
2042
  setCategory("social");
2278
2043
  setAspectRatio("1:1");
2279
2044
  setPromptTemplate("");
2280
- setTextPolicy(t("brandKitDetail.defaultTextPolicy"));
2045
+ setTextPolicy(t("library.defaultTextPolicy"));
2281
2046
  }
2282
2047
 
2283
2048
  function submit() {
@@ -2401,7 +2166,7 @@ function GenerationPresetsPanel({
2401
2166
  );
2402
2167
  }}
2403
2168
  >
2404
- {t("brandKitDetail.delete")}
2169
+ {t("assetDetail.delete")}
2405
2170
  </AlertDialogAction>
2406
2171
  </AlertDialogFooter>
2407
2172
  </AlertDialogContent>
@@ -2473,9 +2238,7 @@ function GenerationPresetsPanel({
2473
2238
  id="preset-template"
2474
2239
  value={promptTemplate}
2475
2240
  onChange={(event) => setPromptTemplate(event.target.value)}
2476
- placeholder={t("brandKitDetail.promptTemplatePlaceholder", {
2477
- prompt: "{{prompt}}",
2478
- })}
2241
+ placeholder={t("library.promptTemplatePlaceholder")}
2479
2242
  />
2480
2243
  </div>
2481
2244
  <div className="grid gap-2">
@@ -2603,767 +2366,118 @@ function AssetPreview({
2603
2366
  );
2604
2367
  }
2605
2368
 
2606
- function CandidateStage({
2607
- candidates,
2608
- pendingVariants,
2369
+ function AssetSwimlaneBoard({
2609
2370
  libraryId,
2371
+ viewMode,
2372
+ onViewModeChange,
2373
+ scope,
2374
+ onScopeChange,
2375
+ hideEmptyLanes,
2376
+ assets,
2377
+ pendingUploads,
2610
2378
  folders,
2611
- savingCandidateKeys,
2612
- onSaveCandidate,
2379
+ promotingReferenceKeys,
2380
+ onUploadClick,
2381
+ onDrop,
2382
+ onMoveToReferences,
2383
+ onRemoveFromReferences,
2384
+ selectedIds,
2385
+ onSelectedIdsChange,
2386
+ onOptimisticDelete,
2387
+ onRestoreOptimisticDelete,
2613
2388
  }: {
2614
- candidates: any[];
2615
- pendingVariants: any[];
2616
2389
  libraryId: string;
2390
+ viewMode: AssetViewMode;
2391
+ onViewModeChange: (mode: AssetViewMode) => void;
2392
+ scope: AssetLibraryScope;
2393
+ onScopeChange: (scope: AssetLibraryScope) => void;
2394
+ hideEmptyLanes: boolean;
2395
+ assets: any[];
2396
+ pendingUploads: PendingUpload[];
2617
2397
  folders: any[];
2618
- savingCandidateKeys: Set<string>;
2619
- onSaveCandidate: (slot: any) => void;
2398
+ promotingReferenceKeys: Set<string>;
2399
+ onUploadClick: () => void;
2400
+ onDrop: (files: FileList) => void;
2401
+ onMoveToReferences: (asset: any, slot?: any) => void;
2402
+ onRemoveFromReferences: (asset: any) => void;
2403
+ selectedIds: Set<string>;
2404
+ onSelectedIdsChange: Dispatch<SetStateAction<Set<string>>>;
2405
+ onOptimisticDelete?: (ids: string[]) => void;
2406
+ onRestoreOptimisticDelete?: (ids: string[]) => void;
2620
2407
  }) {
2621
2408
  const t = useT();
2622
- const dismissSlot = useActionMutation("dismiss-variant-slots");
2623
2409
  const deleteAsset = useActionMutation("delete-asset");
2410
+ const deleteAssets = useActionMutation("delete-assets");
2411
+ const updateAsset = useActionMutation("update-asset");
2624
2412
  const queryClient = useQueryClient();
2625
- const [dismissTarget, setDismissTarget] = useState<{
2626
- kind: "slot" | "asset";
2627
- title: string;
2628
- slot?: any;
2629
- asset?: any;
2630
- } | null>(null);
2631
- const dismissing = dismissSlot.isPending || deleteAsset.isPending;
2413
+ const [confirmDeleteIds, setConfirmDeleteIds] = useState<string[]>([]);
2414
+ const [deletingIds, setDeletingIds] = useState<Set<string>>(() => new Set());
2415
+ const [bulkReferenceAction, setBulkReferenceAction] = useState<
2416
+ "add" | "remove" | null
2417
+ >(null);
2418
+ const visiblePendingUploads = scope === "all" ? pendingUploads : [];
2419
+ const referenceAssets = assets.filter(
2420
+ (asset) => asset.status === "reference",
2421
+ );
2422
+ const savedAssets = assets.filter((asset) => asset.status === "saved");
2423
+ const visibleLibraryAssets =
2424
+ scope === "references" ? referenceAssets : assets;
2425
+ const boardAssets = visibleLibraryAssets;
2426
+ const selectedAssets = boardAssets.filter((asset) =>
2427
+ selectedIds.has(asset.id),
2428
+ );
2429
+ const selectedReferenceAssets = selectedAssets.filter(
2430
+ (asset) => asset.status === "reference",
2431
+ );
2432
+ const selectedSavedAssets = selectedAssets.filter(
2433
+ (asset) => asset.status === "saved",
2434
+ );
2435
+ const selectedCount = selectedAssets.length;
2436
+ const allSelected =
2437
+ boardAssets.length > 0 && selectedCount === boardAssets.length;
2438
+ const pendingDeleteCount = deletingIds.size;
2439
+ const deleting =
2440
+ deleteAsset.isPending || deleteAssets.isPending || pendingDeleteCount > 0;
2441
+ const changingReference = bulkReferenceAction !== null;
2442
+ const hasAnyBoardItem =
2443
+ assets.length > 0 || pendingUploads.length > 0 || pendingDeleteCount > 0;
2632
2444
 
2633
- async function handleDismissCandidate() {
2634
- if (!dismissTarget || dismissing) return;
2635
- try {
2636
- if (dismissTarget.kind === "slot" && dismissTarget.slot) {
2637
- await dismissSlot.mutateAsync({ slotId: dismissTarget.slot.slotId });
2638
- removeVariantSlotFromCache(queryClient, dismissTarget.slot);
2639
- removeAssetsFromLibraryCache(queryClient, libraryId, [
2640
- dismissTarget.slot.assetId,
2641
- ]);
2642
- void queryClient.invalidateQueries({
2643
- queryKey: ["app-state", "asset-variants"],
2644
- refetchType: "active",
2645
- });
2646
- } else if (dismissTarget.kind === "asset" && dismissTarget.asset?.id) {
2647
- await deleteAsset.mutateAsync({ id: dismissTarget.asset.id });
2648
- removeAssetsFromLibraryCache(queryClient, libraryId, [
2649
- dismissTarget.asset.id,
2650
- ]);
2445
+ function toggleAsset(assetId: string, checked: boolean) {
2446
+ onSelectedIdsChange((current) => {
2447
+ const next = new Set(current);
2448
+ if (checked) {
2449
+ next.add(assetId);
2450
+ } else {
2451
+ next.delete(assetId);
2651
2452
  }
2652
- setDismissTarget(null);
2653
- toast.success(t("brandKitDetail.dismissedCandidate"));
2654
- } catch (error) {
2655
- toast.error(
2656
- error instanceof Error
2657
- ? error.message
2658
- : t("brandKitDetail.couldNotDismissCandidate"),
2659
- );
2660
- }
2453
+ return next;
2454
+ });
2661
2455
  }
2662
2456
 
2663
- function slotItem(slot: any): LaneGalleryItem {
2664
- const isFailed = slot.status === "failed";
2665
- const saving = savingCandidateKeys.has(candidateSaveKey(slot));
2666
- const busy = saving;
2667
- return {
2668
- id: `slot:${slot.slotId}`,
2669
- title: isFailed
2670
- ? t("brandKitDetail.failedCandidate")
2671
- : slot.status === "ready"
2672
- ? t("brandKitDetail.readyCandidate")
2673
- : t("brandKitDetail.generatingCandidate"),
2674
- subtitle: slot.slotId
2675
- ? shortId(String(slot.slotId))
2676
- : t("brandKitDetail.liveSlot"),
2677
- metadata: t("brandKitDetail.candidate"),
2678
- status: slot.status,
2679
- mediaType: "image",
2680
- href: slot.assetId ? `/asset/${slot.assetId}` : undefined,
2681
- busy,
2682
- preview: <VariantPreview slot={slot} fit="contain" />, // i18n-ignore structural preview slot name
2683
- thumbnail: <VariantPreview slot={slot} />, // i18n-ignore structural preview slot name
2684
- menu: (
2685
- <VariantActionsMenu slot={slot} libraryId={libraryId} busy={busy} />
2686
- ),
2687
- primaryActions:
2688
- slot.status === "ready" ? (
2689
- <div className="grid grid-cols-2 gap-2">
2690
- <CandidateSaveMenu
2691
- libraryId={libraryId}
2692
- folders={folders}
2693
- saving={saving}
2694
- disabled={busy}
2695
- onSave={(folderId) => onSaveCandidate({ ...slot, folderId })}
2696
- />
2697
- <Button
2698
- variant="ghost"
2699
- size="sm"
2700
- className="h-8 px-2 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive"
2701
- onClick={() =>
2702
- setDismissTarget({
2703
- kind: "slot",
2704
- title: isFailed
2705
- ? t("brandKitDetail.failedCandidate")
2706
- : t("brandKitDetail.readyCandidate"),
2707
- slot,
2708
- })
2709
- }
2710
- disabled={busy || dismissing}
2711
- >
2712
- {t("brandKitDetail.dismiss")}
2713
- </Button>
2714
- </div>
2715
- ) : (
2716
- <Button
2717
- variant="ghost"
2718
- size="sm"
2719
- className="h-8 w-full px-2 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive"
2720
- onClick={() =>
2721
- setDismissTarget({
2722
- kind: "slot",
2723
- title: isFailed
2724
- ? t("brandKitDetail.failedCandidate")
2725
- : t("brandKitDetail.generatingCandidate"),
2726
- slot,
2727
- })
2728
- }
2729
- disabled={busy || dismissing}
2730
- >
2731
- {t("brandKitDetail.dismiss")}
2732
- </Button>
2733
- ),
2734
- };
2457
+ function toggleAll(checked: boolean) {
2458
+ onSelectedIdsChange(
2459
+ checked ? new Set(boardAssets.map((asset) => asset.id)) : new Set(),
2460
+ );
2735
2461
  }
2736
2462
 
2737
- function candidateItem(asset: any): LaneGalleryItem {
2738
- const saving = savingCandidateKeys.has(`asset:${asset.id}`);
2739
- const busy = saving;
2740
- return {
2741
- id: `candidate:${asset.id}`,
2742
- title: assetDisplayTitle(asset),
2743
- subtitle: assetLineageSourceText(asset) || assetCategoryLabel(asset),
2744
- metadata:
2745
- asset.mediaType === "video"
2746
- ? t("brandKitDetail.video")
2747
- : asset.mimeType?.startsWith("image/")
2748
- ? t("brandKitDetail.image")
2749
- : t("brandKitDetail.candidate"),
2750
- status: "candidate",
2751
- mediaType: asset.mediaType === "video" ? "video" : "image",
2752
- href: `/asset/${asset.id}`,
2753
- busy,
2754
- preview: <AssetPreview asset={asset} fit="contain" />, // i18n-ignore structural preview slot name
2755
- thumbnail: <AssetPreview asset={asset} />, // i18n-ignore structural preview slot name
2756
- primaryActions: (
2757
- <div className="grid grid-cols-2 gap-2">
2758
- <CandidateSaveMenu
2759
- libraryId={libraryId}
2760
- folders={folders}
2761
- saving={saving}
2762
- disabled={busy}
2763
- onSave={(folderId) =>
2764
- onSaveCandidate({ assetId: asset.id, folderId })
2765
- }
2766
- />
2767
- <Button
2768
- variant="ghost"
2769
- size="sm"
2770
- className="h-8 px-2 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive"
2771
- onClick={() =>
2772
- setDismissTarget({
2773
- kind: "asset",
2774
- title: assetDisplayTitle(asset),
2775
- asset,
2776
- })
2777
- }
2778
- disabled={busy || dismissing}
2779
- >
2780
- {t("brandKitDetail.dismiss")}
2781
- </Button>
2782
- </div>
2783
- ),
2784
- };
2463
+ function confirmDelete(ids: string[]) {
2464
+ const uniqueIds = [...new Set(ids)];
2465
+ if (uniqueIds.length) setConfirmDeleteIds(uniqueIds);
2785
2466
  }
2786
2467
 
2787
- const pendingVariantAssetIds = new Set(
2788
- pendingVariants
2789
- .map((slot: any) => slot.assetId)
2790
- .filter(
2791
- (assetId: unknown): assetId is string => typeof assetId === "string",
2792
- ),
2793
- );
2794
- const detachedCandidates = candidates.filter(
2795
- (asset) => !pendingVariantAssetIds.has(asset.id),
2796
- );
2797
- const items = [
2798
- ...pendingVariants.map(slotItem),
2799
- ...detachedCandidates.map(candidateItem),
2800
- ];
2801
- const [activeItemId, setActiveItemId] = useState<string | null>(null);
2802
- const itemIds = items.map((item) => item.id).join("\n");
2803
- const activeItem =
2804
- items.find((item) => item.id === activeItemId) ?? items[0] ?? null;
2805
-
2806
- useEffect(() => {
2807
- if (!items.length) {
2808
- setActiveItemId(null);
2809
- return;
2810
- }
2811
- setActiveItemId((current) =>
2812
- current && items.some((item) => item.id === current)
2813
- ? current
2814
- : items[0].id,
2815
- );
2816
- }, [itemIds, items]);
2817
-
2818
- if (!items.length) return null;
2819
-
2820
- return (
2821
- <>
2822
- <AlertDialog
2823
- open={dismissTarget !== null}
2824
- onOpenChange={(open) => {
2825
- if (!open && !dismissing) setDismissTarget(null);
2826
- }}
2827
- >
2828
- <AlertDialogContent>
2829
- <AlertDialogHeader>
2830
- <AlertDialogTitle>
2831
- {t("brandKitDetail.dismissThisCandidate")}
2832
- </AlertDialogTitle>
2833
- <AlertDialogDescription>
2834
- {t("brandKitDetail.dismissCandidateDescription", {
2835
- title:
2836
- dismissTarget?.title ?? t("brandKitDetail.thisCandidate"),
2837
- })}
2838
- </AlertDialogDescription>
2839
- </AlertDialogHeader>
2840
- <AlertDialogFooter>
2841
- <AlertDialogCancel disabled={dismissing}>
2842
- {t("brandKitDetail.cancel")}
2843
- </AlertDialogCancel>
2844
- <AlertDialogAction
2845
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
2846
- disabled={dismissing}
2847
- onClick={(event) => {
2848
- event.preventDefault();
2849
- void handleDismissCandidate();
2850
- }}
2851
- >
2852
- {dismissing ? (
2853
- <>
2854
- <Spinner className="h-4 w-4" />
2855
- {t("brandKitDetail.dismissing")}
2856
- </>
2857
- ) : (
2858
- t("brandKitDetail.dismiss")
2859
- )}
2860
- </AlertDialogAction>
2861
- </AlertDialogFooter>
2862
- </AlertDialogContent>
2863
- </AlertDialog>
2864
-
2865
- <section className="overflow-hidden rounded-lg border border-border bg-background">
2866
- <div className="flex flex-col gap-3 border-b border-border px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
2867
- <div className="min-w-0">
2868
- <div className="flex items-center gap-2">
2869
- <h3 className="truncate text-sm font-semibold">
2870
- {t("brandKitDetail.candidates")}
2871
- </h3>
2872
- <Badge variant="outline">{items.length}</Badge>
2873
- </div>
2874
- <p className="mt-1 text-xs text-muted-foreground">
2875
- {t("brandKitDetail.generationQueue")}
2876
- </p>
2877
- </div>
2878
- <div className="flex shrink-0 items-center gap-2">
2879
- <CandidateStageActions
2880
- slots={pendingVariants}
2881
- detachedCandidates={detachedCandidates}
2882
- libraryId={libraryId}
2883
- />
2884
- </div>
2885
- </div>
2886
- <div className="grid lg:grid-cols-[minmax(0,1fr)_300px]">
2887
- <div className="min-w-0 bg-muted/10 p-3">
2888
- <div
2889
- className={[
2890
- "group relative overflow-hidden rounded-lg border border-border bg-background",
2891
- activeItem?.busy ? "opacity-80" : "",
2892
- ].join(" ")}
2893
- aria-busy={activeItem?.busy}
2894
- >
2895
- <div className="aspect-[16/9] bg-muted/30">
2896
- {activeItem?.href ? (
2897
- <Link to={activeItem.href} className="block h-full w-full">
2898
- {activeItem.preview}
2899
- </Link>
2900
- ) : (
2901
- activeItem?.preview
2902
- )}
2903
- </div>
2904
- {activeItem?.menu ? (
2905
- <div className="absolute right-3 top-3 z-10">
2906
- {activeItem.menu}
2907
- </div>
2908
- ) : null}
2909
- {activeItem?.busy ? (
2910
- <div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background/20">
2911
- <Spinner className="h-5 w-5" />
2912
- </div>
2913
- ) : null}
2914
- </div>
2915
- <div className="mt-3 flex gap-2 overflow-x-auto pb-1">
2916
- {items.map((item) => {
2917
- const active = item.id === activeItem?.id;
2918
- return (
2919
- <button
2920
- key={item.id}
2921
- type="button"
2922
- onClick={() => setActiveItemId(item.id)}
2923
- className={[
2924
- "group relative h-16 w-24 shrink-0 overflow-hidden rounded-md border bg-background transition",
2925
- active
2926
- ? "border-primary ring-2 ring-primary/25"
2927
- : "border-border/80 hover:border-foreground/30",
2928
- ].join(" ")}
2929
- aria-label={t("brandKitDetail.showItem", {
2930
- title: item.title,
2931
- })}
2932
- aria-pressed={active}
2933
- >
2934
- {item.thumbnail}
2935
- <span className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background/90 to-transparent" />
2936
- {item.busy ? (
2937
- <span className="absolute right-1.5 top-1.5 rounded-full bg-background/90 p-1 shadow-sm">
2938
- <Spinner className="h-3 w-3" />
2939
- </span>
2940
- ) : null}
2941
- </button>
2942
- );
2943
- })}
2944
- </div>
2945
- </div>
2946
- <aside className="flex min-h-56 flex-col justify-between gap-4 border-t border-border bg-background p-4 lg:border-l lg:border-t-0">
2947
- <div className="min-w-0 space-y-4">
2948
- <div className="min-w-0">
2949
- <div className="truncate text-sm font-medium">
2950
- {activeItem?.title}
2951
- </div>
2952
- {activeItem?.subtitle ? (
2953
- <div className="mt-1 truncate text-xs text-muted-foreground">
2954
- {activeItem.subtitle}
2955
- </div>
2956
- ) : null}
2957
- </div>
2958
- <div className="grid grid-cols-2 gap-2 text-xs">
2959
- {activeItem?.status ? (
2960
- <div className="rounded-md border border-border bg-muted/20 px-2 py-1.5">
2961
- <div className="text-[10px] font-medium uppercase text-muted-foreground">
2962
- {t("brandKitDetail.status")}
2963
- </div>
2964
- <div className="mt-0.5 truncate">{activeItem.status}</div>
2965
- </div>
2966
- ) : null}
2967
- {activeItem?.metadata ? (
2968
- <div className="rounded-md border border-border bg-muted/20 px-2 py-1.5">
2969
- <div className="text-[10px] font-medium uppercase text-muted-foreground">
2970
- {t("brandKitDetail.type")}
2971
- </div>
2972
- <div className="mt-0.5 truncate">{activeItem.metadata}</div>
2973
- </div>
2974
- ) : null}
2975
- </div>
2976
- {activeItem?.primaryActions ? (
2977
- <div>{activeItem.primaryActions}</div>
2978
- ) : null}
2979
- </div>
2980
- {activeItem?.href ? (
2981
- <Button asChild variant="outline" size="sm">
2982
- <Link to={activeItem.href}>{t("brandKitDetail.open")}</Link>
2983
- </Button>
2984
- ) : null}
2985
- </aside>
2986
- </div>
2987
- </section>
2988
- </>
2989
- );
2990
- }
2991
-
2992
- function CandidateSaveMenu({
2993
- libraryId,
2994
- folders,
2995
- saving,
2996
- disabled,
2997
- onSave,
2998
- }: {
2999
- libraryId: string;
3000
- folders: any[];
3001
- saving?: boolean;
3002
- disabled?: boolean;
3003
- onSave: (folderId: string | null) => void;
3004
- }) {
3005
- const t = useT();
3006
- const createFolder = useActionMutation("create-folder");
3007
- const queryClient = useQueryClient();
3008
- const [createOpen, setCreateOpen] = useState(false);
3009
- const pending = saving || createFolder.isPending;
3010
-
3011
- return (
3012
- <>
3013
- <CreateFolderDialog
3014
- open={createOpen}
3015
- onOpenChange={setCreateOpen}
3016
- onSubmit={async (title) => {
3017
- const folder = (await createFolder.mutateAsync({
3018
- libraryId,
3019
- title,
3020
- parentId: null,
3021
- })) as any;
3022
- void queryClient.invalidateQueries({
3023
- queryKey: ["action", "get-library", { id: libraryId }],
3024
- refetchType: "active",
3025
- });
3026
- setCreateOpen(false);
3027
- if (folder?.id) onSave(folder.id);
3028
- }}
3029
- pending={createFolder.isPending}
3030
- />
3031
- <DropdownMenu>
3032
- <DropdownMenuTrigger asChild>
3033
- <Button size="sm" className="h-8 px-2 text-xs" disabled={disabled}>
3034
- {pending ? (
3035
- <Spinner className="h-3.5 w-3.5" />
3036
- ) : (
3037
- t("brandKitDetail.saveTo")
3038
- )}
3039
- </Button>
3040
- </DropdownMenuTrigger>
3041
- <DropdownMenuContent align="start">
3042
- <DropdownMenuItem onSelect={() => onSave(null)}>
3043
- <IconFolder className="mr-2 h-4 w-4 shrink-0" />
3044
- {t("brandKitDetail.unfiled")}
3045
- </DropdownMenuItem>
3046
- {folders.map((folder) => (
3047
- <DropdownMenuItem
3048
- key={folder.id}
3049
- onSelect={() => onSave(folder.id)}
3050
- >
3051
- <IconFolder className="mr-2 h-4 w-4 shrink-0" />
3052
- {t("brandKitDetail.folderLabel", { title: folder.title })}
3053
- </DropdownMenuItem>
3054
- ))}
3055
- <DropdownMenuSeparator />
3056
- <DropdownMenuItem
3057
- onSelect={(event) => {
3058
- event.preventDefault();
3059
- setCreateOpen(true);
3060
- }}
3061
- >
3062
- <IconFolderPlus className="mr-2 h-4 w-4 shrink-0" />
3063
- {t("brandKitDetail.newFolderEllipsis")}
3064
- </DropdownMenuItem>
3065
- </DropdownMenuContent>
3066
- </DropdownMenu>
3067
- </>
3068
- );
3069
- }
3070
-
3071
- function CandidateStageActions({
3072
- slots,
3073
- detachedCandidates,
3074
- libraryId,
3075
- }: {
3076
- slots: any[];
3077
- detachedCandidates: any[];
3078
- libraryId: string;
3079
- }) {
3080
- const t = useT();
3081
- const dismissSlots = useActionMutation("dismiss-variant-slots");
3082
- const deleteAssets = useActionMutation("delete-assets");
3083
- const queryClient = useQueryClient();
3084
- const [pending, setPending] = useState<"failed" | "all" | null>(null);
3085
- const failedSlots = slots.filter((slot) => slot.status === "failed");
3086
- const failedCount = failedSlots.length;
3087
- const detachedCount = detachedCandidates.length;
3088
- const totalCount = slots.length + detachedCount;
3089
- const isClearing = dismissSlots.isPending || deleteAssets.isPending;
3090
- const actionLabel =
3091
- pending === "failed"
3092
- ? t("brandKitDetail.dismissFailed")
3093
- : t("brandKitDetail.clearAll");
3094
- const busyLabel =
3095
- pending === "failed"
3096
- ? t("brandKitDetail.dismissing")
3097
- : t("brandKitDetail.clearing");
3098
-
3099
- async function handleClear(scope: "failed" | "all") {
3100
- const slotAssetIds = slots
3101
- .filter((slot) => scope === "all" || slot.status === "failed")
3102
- .map((slot) => slot.assetId)
3103
- .filter(
3104
- (assetId: unknown): assetId is string => typeof assetId === "string",
3105
- );
3106
- const detachedAssetIds =
3107
- scope === "all" ? detachedCandidates.map((asset) => asset.id) : [];
3108
- const removedAssetIds = [
3109
- ...new Set([...slotAssetIds, ...detachedAssetIds]),
3110
- ];
3111
-
3112
- if (
3113
- scope === "all" &&
3114
- slots.length === 0 &&
3115
- detachedAssetIds.length === 0
3116
- ) {
3117
- setPending(null);
3118
- return;
3119
- }
3120
- if (scope === "failed" && failedCount === 0) {
3121
- setPending(null);
3122
- return;
3123
- }
3124
-
3125
- try {
3126
- if (slots.length > 0 && (scope === "all" || failedCount > 0)) {
3127
- await dismissSlots.mutateAsync({ scope });
3128
- removeVariantSlotsByScopeFromCache(queryClient, scope);
3129
- }
3130
- if (detachedAssetIds.length > 0) {
3131
- await deleteAssets.mutateAsync({ ids: detachedAssetIds });
3132
- }
3133
- if (removedAssetIds.length > 0) {
3134
- removeAssetsFromLibraryCache(queryClient, libraryId, removedAssetIds);
3135
- }
3136
- setPending(null);
3137
- void queryClient.invalidateQueries({
3138
- queryKey: ["app-state", "asset-variants"],
3139
- refetchType: "active",
3140
- });
3141
- if (scope === "failed") {
3142
- toast.success(
3143
- t("brandKitDetail.dismissedFailedCandidates", {
3144
- count: failedCount,
3145
- }),
3146
- );
3147
- } else {
3148
- toast.success(
3149
- t("brandKitDetail.clearedCandidates", { count: totalCount }),
3150
- );
3151
- }
3152
- } catch (error) {
3153
- toast.error(
3154
- error instanceof Error
3155
- ? error.message
3156
- : t("brandKitDetail.couldNotClearCandidates"),
3157
- );
3158
- }
3159
- }
3160
-
3161
- if (totalCount === 0) return null;
3162
-
3163
- return (
3164
- <>
3165
- <AlertDialog
3166
- open={pending !== null}
3167
- onOpenChange={(open) => {
3168
- if (!open && !isClearing) setPending(null);
3169
- }}
3170
- >
3171
- <AlertDialogContent>
3172
- <AlertDialogHeader>
3173
- <AlertDialogTitle>
3174
- {pending === "failed"
3175
- ? t("brandKitDetail.dismissFailedCandidatesTitle", {
3176
- count: failedCount,
3177
- })
3178
- : t("brandKitDetail.clearCandidatesTitle", {
3179
- count: totalCount,
3180
- })}
3181
- </AlertDialogTitle>
3182
- <AlertDialogDescription>
3183
- {pending === "failed"
3184
- ? t("brandKitDetail.dismissFailedDescription")
3185
- : t("brandKitDetail.clearCandidatesDescription")}
3186
- </AlertDialogDescription>
3187
- </AlertDialogHeader>
3188
- <AlertDialogFooter>
3189
- <AlertDialogCancel disabled={isClearing}>
3190
- {t("brandKitDetail.cancel")}
3191
- </AlertDialogCancel>
3192
- <AlertDialogAction
3193
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
3194
- disabled={isClearing || pending === null}
3195
- onClick={(event) => {
3196
- event.preventDefault();
3197
- if (pending) void handleClear(pending);
3198
- }}
3199
- >
3200
- {isClearing ? (
3201
- <>
3202
- <Spinner className="h-4 w-4" />
3203
- {busyLabel}
3204
- </>
3205
- ) : (
3206
- actionLabel
3207
- )}
3208
- </AlertDialogAction>
3209
- </AlertDialogFooter>
3210
- </AlertDialogContent>
3211
- </AlertDialog>
3212
-
3213
- <DropdownMenu>
3214
- <DropdownMenuTrigger asChild>
3215
- <Button
3216
- type="button"
3217
- variant="outline"
3218
- size="sm"
3219
- className="gap-2"
3220
- aria-label={t("brandKitDetail.candidateActions")}
3221
- disabled={isClearing}
3222
- >
3223
- <IconDotsVertical className="h-4 w-4" />
3224
- {t("brandKitDetail.clear")}
3225
- </Button>
3226
- </DropdownMenuTrigger>
3227
- <DropdownMenuContent align="end">
3228
- <DropdownMenuItem
3229
- disabled={failedCount === 0 || isClearing}
3230
- onSelect={(event) => {
3231
- event.preventDefault();
3232
- setPending("failed");
3233
- }}
3234
- >
3235
- <IconTrash className="mr-2 h-4 w-4 shrink-0" />
3236
- {t("brandKitDetail.dismissFailed")} ({failedCount})
3237
- </DropdownMenuItem>
3238
- <DropdownMenuItem
3239
- className="text-destructive focus:bg-destructive/10 focus:text-destructive"
3240
- disabled={isClearing}
3241
- onSelect={(event) => {
3242
- event.preventDefault();
3243
- setPending("all");
3244
- }}
3245
- >
3246
- <IconTrash className="mr-2 h-4 w-4 shrink-0" />
3247
- {t("brandKitDetail.clearAll")}
3248
- </DropdownMenuItem>
3249
- </DropdownMenuContent>
3250
- </DropdownMenu>
3251
- </>
3252
- );
3253
- }
3254
-
3255
- function AssetSwimlaneBoard({
3256
- libraryId,
3257
- viewMode,
3258
- onViewModeChange,
3259
- scope,
3260
- onScopeChange,
3261
- hideEmptyLanes,
3262
- assets,
3263
- pendingUploads,
3264
- folders,
3265
- promotingReferenceKeys,
3266
- onUploadClick,
3267
- onDrop,
3268
- onMoveToReferences,
3269
- onRemoveFromReferences,
3270
- selectedIds,
3271
- onSelectedIdsChange,
3272
- onOptimisticDelete,
3273
- onRestoreOptimisticDelete,
3274
- }: {
3275
- libraryId: string;
3276
- viewMode: AssetViewMode;
3277
- onViewModeChange: (mode: AssetViewMode) => void;
3278
- scope: AssetLibraryScope;
3279
- onScopeChange: (scope: AssetLibraryScope) => void;
3280
- hideEmptyLanes: boolean;
3281
- assets: any[];
3282
- pendingUploads: PendingUpload[];
3283
- folders: any[];
3284
- promotingReferenceKeys: Set<string>;
3285
- onUploadClick: () => void;
3286
- onDrop: (files: FileList) => void;
3287
- onMoveToReferences: (asset: any, slot?: any) => void;
3288
- onRemoveFromReferences: (asset: any) => void;
3289
- selectedIds: Set<string>;
3290
- onSelectedIdsChange: Dispatch<SetStateAction<Set<string>>>;
3291
- onOptimisticDelete?: (ids: string[]) => void;
3292
- onRestoreOptimisticDelete?: (ids: string[]) => void;
3293
- }) {
3294
- const t = useT();
3295
- const deleteAsset = useActionMutation("delete-asset");
3296
- const deleteAssets = useActionMutation("delete-assets");
3297
- const updateAsset = useActionMutation("update-asset");
3298
- const queryClient = useQueryClient();
3299
- const [confirmDeleteIds, setConfirmDeleteIds] = useState<string[]>([]);
3300
- const [deletingIds, setDeletingIds] = useState<Set<string>>(() => new Set());
3301
- const [bulkReferenceAction, setBulkReferenceAction] = useState<
3302
- "add" | "remove" | null
3303
- >(null);
3304
- const visiblePendingUploads = scope === "all" ? pendingUploads : [];
3305
- const referenceAssets = assets.filter(
3306
- (asset) => asset.status === "reference",
3307
- );
3308
- const savedAssets = assets.filter((asset) => asset.status === "saved");
3309
- const visibleLibraryAssets =
3310
- scope === "references" ? referenceAssets : assets;
3311
- const boardAssets = visibleLibraryAssets;
3312
- const selectedAssets = boardAssets.filter((asset) =>
3313
- selectedIds.has(asset.id),
3314
- );
3315
- const selectedReferenceAssets = selectedAssets.filter(
3316
- (asset) => asset.status === "reference",
3317
- );
3318
- const selectedSavedAssets = selectedAssets.filter(
3319
- (asset) => asset.status === "saved",
3320
- );
3321
- const selectedCount = selectedAssets.length;
3322
- const allSelected =
3323
- boardAssets.length > 0 && selectedCount === boardAssets.length;
3324
- const pendingDeleteCount = deletingIds.size;
3325
- const deleting =
3326
- deleteAsset.isPending || deleteAssets.isPending || pendingDeleteCount > 0;
3327
- const changingReference = bulkReferenceAction !== null;
3328
- const hasAnyBoardItem =
3329
- assets.length > 0 || pendingUploads.length > 0 || pendingDeleteCount > 0;
3330
-
3331
- function toggleAsset(assetId: string, checked: boolean) {
3332
- onSelectedIdsChange((current) => {
3333
- const next = new Set(current);
3334
- if (checked) {
3335
- next.add(assetId);
3336
- } else {
3337
- next.delete(assetId);
3338
- }
3339
- return next;
3340
- });
3341
- }
3342
-
3343
- function toggleAll(checked: boolean) {
3344
- onSelectedIdsChange(
3345
- checked ? new Set(boardAssets.map((asset) => asset.id)) : new Set(),
3346
- );
3347
- }
3348
-
3349
- function confirmDelete(ids: string[]) {
3350
- const uniqueIds = [...new Set(ids)];
3351
- if (uniqueIds.length) setConfirmDeleteIds(uniqueIds);
3352
- }
3353
-
3354
- function markDeleting(ids: string[]) {
3355
- setDeletingIds((current) => {
3356
- const next = new Set(current);
3357
- for (const id of ids) next.add(id);
3358
- return next;
3359
- });
3360
- onSelectedIdsChange((current) => {
3361
- const next = new Set(current);
3362
- for (const id of ids) next.delete(id);
3363
- return next;
3364
- });
3365
- onOptimisticDelete?.(ids);
3366
- }
2468
+ function markDeleting(ids: string[]) {
2469
+ setDeletingIds((current) => {
2470
+ const next = new Set(current);
2471
+ for (const id of ids) next.add(id);
2472
+ return next;
2473
+ });
2474
+ onSelectedIdsChange((current) => {
2475
+ const next = new Set(current);
2476
+ for (const id of ids) next.delete(id);
2477
+ return next;
2478
+ });
2479
+ onOptimisticDelete?.(ids);
2480
+ }
3367
2481
 
3368
2482
  function finishDeleting(ids: string[]) {
3369
2483
  setDeletingIds((current) => {
@@ -3395,13 +2509,11 @@ function AssetSwimlaneBoard({
3395
2509
  {
3396
2510
  onSuccess: () => {
3397
2511
  finishDeleting(ids);
3398
- toast.success(t("brandKitDetail.deletedAsset"));
2512
+ toast.success(t("library.deletedAsset"));
3399
2513
  },
3400
2514
  onError: (error) => {
3401
2515
  restoreAfterDeleteError(ids);
3402
- toast.error(
3403
- error.message || t("brandKitDetail.couldNotDeleteAsset"),
3404
- );
2516
+ toast.error(error.message || t("library.couldNotDeleteAsset"));
3405
2517
  },
3406
2518
  },
3407
2519
  );
@@ -3416,12 +2528,12 @@ function AssetSwimlaneBoard({
3416
2528
  onSuccess: (result: any) => {
3417
2529
  finishDeleting(ids);
3418
2530
  const count = Number(result?.deletedCount ?? ids.length);
3419
- toast.success(t("brandKitDetail.deletedAssets", { count }));
2531
+ toast.success(t("library.deletedAssets", { count }));
3420
2532
  },
3421
2533
  onError: (error) => {
3422
2534
  restoreAfterDeleteError(ids);
3423
2535
  toast.error(
3424
- error.message || t("brandKitDetail.couldNotDeleteSelectedAssets"),
2536
+ error.message || t("library.couldNotDeleteSelectedAssets"),
3425
2537
  );
3426
2538
  },
3427
2539
  },
@@ -3477,10 +2589,10 @@ function AssetSwimlaneBoard({
3477
2589
  }
3478
2590
  toast.success(
3479
2591
  enabled
3480
- ? t("brandKitDetail.addedAssetsToReferences", {
2592
+ ? t("library.addedAssetsToReferences", {
3481
2593
  count: assetList.length,
3482
2594
  })
3483
- : t("brandKitDetail.removedAssetsFromReferences", {
2595
+ : t("library.removedAssetsFromReferences", {
3484
2596
  count: assetList.length,
3485
2597
  }),
3486
2598
  );
@@ -3489,8 +2601,8 @@ function AssetSwimlaneBoard({
3489
2601
  error instanceof Error
3490
2602
  ? error.message
3491
2603
  : enabled
3492
- ? t("brandKitDetail.couldNotAddSelectedToReferences")
3493
- : t("brandKitDetail.couldNotRemoveSelectedFromReferences"),
2604
+ ? t("library.couldNotAddSelectedToReferences")
2605
+ : t("library.couldNotRemoveSelectedFromReferences"),
3494
2606
  );
3495
2607
  return;
3496
2608
  } finally {
@@ -3504,15 +2616,14 @@ function AssetSwimlaneBoard({
3504
2616
  id: `upload:${upload.id}`,
3505
2617
  title: upload.name,
3506
2618
  subtitle: isChecking
3507
- ? t("brandKitDetail.checkingUpload")
3508
- : t("brandKitDetail.uploading"),
3509
- status: isChecking
3510
- ? t("brandKitDetail.checking")
3511
- : t("brandKitDetail.uploading"),
2619
+ ? t("library.checkingUpload")
2620
+ : t("library.uploading"),
2621
+ status: isChecking ? t("library.checking") : t("library.uploading"),
3512
2622
  mediaType: upload.mediaType,
3513
2623
  busy: true,
2624
+ showBusyOverlay: false,
3514
2625
  preview: <PendingUploadPreview upload={upload} fit="contain" />, // i18n-ignore structural preview slot name
3515
- thumbnail: <PendingUploadPreview upload={upload} />, // i18n-ignore structural preview slot name
2626
+ thumbnail: <PendingUploadPreview upload={upload} />,
3516
2627
  };
3517
2628
  }
3518
2629
 
@@ -3547,15 +2658,14 @@ function AssetSwimlaneBoard({
3547
2658
  id: `asset:${asset.id}`,
3548
2659
  title: displayTitle,
3549
2660
  subtitle: sourceText || categoryLabel || asset.status,
2661
+ asset,
3550
2662
  metadata:
3551
2663
  asset.mediaType === "video"
3552
- ? t("brandKitDetail.video")
2664
+ ? t("library.video")
3553
2665
  : asset.mimeType?.startsWith("image/")
3554
- ? t("brandKitDetail.image")
3555
- : asset.mimeType || t("brandKitDetail.asset"),
3556
- status: isReference
3557
- ? t("brandKitDetail.reference")
3558
- : t("brandKitDetail.saved"),
2666
+ ? t("library.image")
2667
+ : asset.mimeType || t("library.asset"),
2668
+ status: isReference ? t("library.reference") : t("library.saved"),
3559
2669
  mediaType: asset.mediaType === "video" ? "video" : "image",
3560
2670
  href: `/asset/${asset.id}`,
3561
2671
  selected: selectedIds.has(asset.id),
@@ -3594,7 +2704,7 @@ function AssetSwimlaneBoard({
3594
2704
  {saving ? (
3595
2705
  <Spinner className="h-3.5 w-3.5" />
3596
2706
  ) : (
3597
- t("brandKitDetail.save")
2707
+ t("library.save")
3598
2708
  )}
3599
2709
  </Button>
3600
2710
  ) : null}
@@ -3607,12 +2717,12 @@ function AssetSwimlaneBoard({
3607
2717
  }
3608
2718
  onClick={onMoveToReferences}
3609
2719
  disabled={busy}
3610
- title={t("brandKitDetail.addToReferences")}
2720
+ title={t("library.addToReferences")}
3611
2721
  >
3612
2722
  {promoting ? (
3613
2723
  <Spinner className="h-3.5 w-3.5" />
3614
2724
  ) : (
3615
- t("brandKitDetail.addToReferences")
2725
+ t("library.addToReferences")
3616
2726
  )}
3617
2727
  </Button>
3618
2728
  ) : null}
@@ -3625,12 +2735,12 @@ function AssetSwimlaneBoard({
3625
2735
  }
3626
2736
  onClick={onRemoveFromReferences}
3627
2737
  disabled={busy}
3628
- title={t("brandKitDetail.removeFromReferences")}
2738
+ title={t("library.removeFromReferences")}
3629
2739
  >
3630
2740
  {promoting ? (
3631
2741
  <Spinner className="h-3.5 w-3.5" />
3632
2742
  ) : (
3633
- t("brandKitDetail.removeFromReferences")
2743
+ t("library.removeFromReferences")
3634
2744
  )}
3635
2745
  </Button>
3636
2746
  ) : null}
@@ -3665,10 +2775,10 @@ function AssetSwimlaneBoard({
3665
2775
  <div className="flex min-h-[280px] w-full flex-col items-center justify-center rounded-lg border border-dashed border-border bg-muted/15 p-8 text-center">
3666
2776
  <IconSearch className="h-9 w-9 text-muted-foreground" />
3667
2777
  <span className="mt-4 text-base font-semibold">
3668
- {t("brandKitDetail.noAssetsMatch")}
2778
+ {t("library.noAssetsMatchView")}
3669
2779
  </span>
3670
2780
  <span className="mt-2 max-w-md text-sm text-muted-foreground">
3671
- {t("brandKitDetail.noAssetsMatchDescription")}
2781
+ {t("library.noAssetsMatchViewBody")}
3672
2782
  </span>
3673
2783
  </div>
3674
2784
  );
@@ -3688,10 +2798,10 @@ function AssetSwimlaneBoard({
3688
2798
  >
3689
2799
  <IconPhotoPlus className="h-10 w-10 text-muted-foreground" />
3690
2800
  <span className="mt-4 text-base font-semibold">
3691
- {t("brandKitDetail.addAssets")}
2801
+ {t("library.addAssets")}
3692
2802
  </span>
3693
2803
  <span className="mt-2 max-w-md text-sm text-muted-foreground">
3694
- {t("brandKitDetail.addAssetsDescription")}
2804
+ {t("library.addAssetsDescription")}
3695
2805
  </span>
3696
2806
  </button>
3697
2807
  );
@@ -3709,19 +2819,19 @@ function AssetSwimlaneBoard({
3709
2819
  <AlertDialogHeader>
3710
2820
  <AlertDialogTitle>
3711
2821
  {confirmDeleteIds.length > 1
3712
- ? t("brandKitDetail.deleteAssetsTitle", {
2822
+ ? t("library.deleteAssetsTitle", {
3713
2823
  count: confirmDeleteIds.length,
3714
2824
  })
3715
- : t("brandKitDetail.deleteAssetsTitle", { count: 1 })}
2825
+ : t("library.deleteAssetTitle")}
3716
2826
  </AlertDialogTitle>
3717
2827
  <AlertDialogDescription>
3718
2828
  {confirmDeleteIds.length > 1
3719
- ? t("brandKitDetail.deleteManyDescription")
3720
- : t("brandKitDetail.deleteOneDescription")}
2829
+ ? t("library.deleteAssetsDescription")
2830
+ : t("library.deleteAssetDescription")}
3721
2831
  </AlertDialogDescription>
3722
2832
  </AlertDialogHeader>
3723
2833
  <AlertDialogFooter>
3724
- <AlertDialogCancel>{t("brandKitDetail.cancel")}</AlertDialogCancel>
2834
+ <AlertDialogCancel>{t("library.cancel")}</AlertDialogCancel>
3725
2835
  <AlertDialogAction
3726
2836
  className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
3727
2837
  disabled={!confirmDeleteIds.length || deleting}
@@ -3743,18 +2853,14 @@ function AssetSwimlaneBoard({
3743
2853
  checked={allSelected}
3744
2854
  disabled={!boardAssets.length || deleting}
3745
2855
  onCheckedChange={(checked) => toggleAll(checked === true)}
3746
- aria-label={t("brandKitDetail.selectAllVisibleAssets")}
2856
+ aria-label={t("library.selectAllVisibleAssets")}
3747
2857
  />
3748
- {allSelected
3749
- ? t("brandKitDetail.deselectAll")
3750
- : t("brandKitDetail.selectAll")}
2858
+ {allSelected ? t("library.deselectAll") : t("library.selectAll")}
3751
2859
  </label>
3752
2860
  <span className="text-xs text-muted-foreground">
3753
- {t("brandKitDetail.visibleAssets", {
3754
- count: boardAssets.length,
3755
- })}
2861
+ {t("library.visibleAssetsCount", { count: boardAssets.length })}
3756
2862
  {referenceAssets.length > 0
3757
- ? ` · ${t("brandKitDetail.referenceCount", {
2863
+ ? ` · ${t("library.referencesCount", {
3758
2864
  count: referenceAssets.length,
3759
2865
  })}`
3760
2866
  : ""}
@@ -3766,7 +2872,7 @@ function AssetSwimlaneBoard({
3766
2872
  size="sm"
3767
2873
  onClick={() => onSelectedIdsChange(new Set())}
3768
2874
  >
3769
- {t("brandKitDetail.clear")}
2875
+ {t("library.clear")}
3770
2876
  </Button>
3771
2877
  ) : null}
3772
2878
  </div>
@@ -3787,9 +2893,7 @@ function AssetSwimlaneBoard({
3787
2893
  <div className="flex min-w-0 items-center gap-2 text-sm font-medium">
3788
2894
  <Spinner className="h-4 w-4" />
3789
2895
  <span className="truncate">
3790
- {t("brandKitDetail.deletingAssets", {
3791
- count: pendingDeleteCount,
3792
- })}
2896
+ {t("library.deletingAssets", { count: pendingDeleteCount })}
3793
2897
  </span>
3794
2898
  </div>
3795
2899
  ) : (
@@ -3797,19 +2901,17 @@ function AssetSwimlaneBoard({
3797
2901
  <Checkbox
3798
2902
  checked={allSelected}
3799
2903
  onCheckedChange={(checked) => toggleAll(checked === true)}
3800
- aria-label={t("brandKitDetail.selectAllAssetsBoard")}
2904
+ aria-label={t("library.selectAllAssetsInBoard")}
3801
2905
  />
3802
2906
  <span className="truncate">
3803
2907
  {selectedCount > 0
3804
- ? t("brandKitDetail.selectedCount", { count: selectedCount })
3805
- : t("brandKitDetail.boardAssetCount", {
3806
- count: boardAssets.length,
3807
- })}
2908
+ ? t("library.selectedCount", { count: selectedCount })
2909
+ : t("library.assetCount", { count: boardAssets.length })}
3808
2910
  </span>
3809
2911
  <span className="hidden text-xs text-muted-foreground sm:inline">
3810
- {t("brandKitDetail.referencesSavedCounts", {
3811
- references: referenceAssets.length,
3812
- saved: savedAssets.length,
2912
+ {t("library.referencesAndSavedCount", {
2913
+ referenceCount: referenceAssets.length,
2914
+ savedCount: savedAssets.length,
3813
2915
  })}
3814
2916
  </span>
3815
2917
  </div>
@@ -3874,7 +2976,7 @@ function AssetSwimlaneBoard({
3874
2976
  ) : (
3875
2977
  <IconTrash className="h-4 w-4" />
3876
2978
  )}
3877
- {t("brandKitDetail.delete")}
2979
+ {t("assetDetail.delete")}
3878
2980
  </Button>
3879
2981
  </div>
3880
2982
  ) : null}
@@ -3887,39 +2989,39 @@ function AssetSwimlaneBoard({
3887
2989
  <SwimLane
3888
2990
  title={
3889
2991
  scope === "references"
3890
- ? t("brandKitDetail.references")
3891
- : t("brandKitDetail.library")
2992
+ ? t("library.references")
2993
+ : t("library.library")
3892
2994
  }
3893
2995
  eyebrow={
3894
2996
  scope === "references"
3895
- ? t("brandKitDetail.referencesEyebrow")
3896
- : t("brandKitDetail.libraryEyebrow")
2997
+ ? t("library.referencesEyebrow")
2998
+ : t("library.libraryEyebrow")
3897
2999
  }
3898
3000
  items={visibleGalleryItems}
3899
3001
  action={
3900
3002
  <Button variant="outline" size="sm" onClick={onUploadClick}>
3901
- {t("brandKitDetail.add")}
3003
+ {t("library.add")}
3902
3004
  </Button>
3903
3005
  }
3904
3006
  empty={
3905
3007
  scope === "references" && assets.length > 0 ? (
3906
3008
  <LaneActionEmpty
3907
- title={t("brandKitDetail.noReferencesTitle")}
3908
- body={t("brandKitDetail.noReferencesBody")}
3009
+ title={t("library.noReferencesInView")}
3010
+ body={t("library.noReferencesInViewBody")}
3909
3011
  onClick={() => onScopeChange("all")}
3910
- action={t("brandKitDetail.showAll")}
3012
+ action={t("library.showAll")}
3911
3013
  />
3912
3014
  ) : hideEmptyLanes ? (
3913
3015
  <LaneActionEmpty
3914
- title={t("brandKitDetail.noAssetsMatch")}
3915
- body={t("brandKitDetail.noAssetsMatchDescription")}
3016
+ title={t("library.noAssetsMatchView")}
3017
+ body={t("library.noAssetsMatchViewBody")}
3916
3018
  onClick={() => onScopeChange("all")}
3917
- action={t("brandKitDetail.showAll")}
3019
+ action={t("library.showAll")}
3918
3020
  />
3919
3021
  ) : (
3920
3022
  <LaneDropTarget
3921
- title={t("brandKitDetail.dropAssetsHere")}
3922
- body={t("brandKitDetail.dropAssetsBody")}
3023
+ title={t("library.dropAssetsHere")}
3024
+ body={t("library.dropAssetsHereBody")}
3923
3025
  onClick={onUploadClick}
3924
3026
  onDrop={onDrop}
3925
3027
  />
@@ -3946,12 +3048,12 @@ function AssetViewModeToggle({
3946
3048
  }> = [
3947
3049
  {
3948
3050
  value: "lanes",
3949
- label: t("brandKitDetail.lanes"),
3051
+ label: t("library.lanes"),
3950
3052
  icon: <IconLayoutBottombar className="h-4 w-4" />,
3951
3053
  },
3952
3054
  {
3953
3055
  value: "cards",
3954
- label: t("brandKitDetail.cards"),
3056
+ label: t("library.cards"),
3955
3057
  icon: <IconLayoutGrid className="h-4 w-4" />,
3956
3058
  },
3957
3059
  ];
@@ -3960,7 +3062,7 @@ function AssetViewModeToggle({
3960
3062
  <TooltipProvider delayDuration={150}>
3961
3063
  <div
3962
3064
  role="group"
3963
- aria-label={t("brandKitDetail.assetView")}
3065
+ aria-label={t("library.assetView")}
3964
3066
  className="inline-flex shrink-0 gap-1 rounded-md border border-border bg-muted/20 p-1"
3965
3067
  >
3966
3068
  {options.map((option) => {
@@ -3977,19 +3079,17 @@ function AssetViewModeToggle({
3977
3079
  ? "bg-background text-foreground shadow-sm"
3978
3080
  : "text-muted-foreground hover:bg-background/60 hover:text-foreground",
3979
3081
  ].join(" ")}
3980
- aria-label={t("brandKitDetail.viewModeLabel", {
3981
- label: option.label,
3082
+ aria-label={t("library.assetViewMode", {
3083
+ mode: option.label,
3982
3084
  })}
3983
3085
  aria-pressed={active}
3984
- title={t("brandKitDetail.viewModeLabel", {
3985
- label: option.label,
3986
- })}
3086
+ title={t("library.assetViewMode", { mode: option.label })}
3987
3087
  >
3988
3088
  {option.icon}
3989
3089
  </button>
3990
3090
  </TooltipTrigger>
3991
3091
  <TooltipContent side="bottom">
3992
- {t("brandKitDetail.viewModeLabel", { label: option.label })}
3092
+ {t("library.assetViewMode", { mode: option.label })}
3993
3093
  </TooltipContent>
3994
3094
  </Tooltip>
3995
3095
  );
@@ -4016,10 +3116,10 @@ function AssetScopeToggle({
4016
3116
  label: string;
4017
3117
  count: number;
4018
3118
  }> = [
4019
- { value: "all", label: t("brandKitDetail.all"), count: allCount },
3119
+ { value: "all", label: t("library.tabsAll"), count: allCount },
4020
3120
  {
4021
3121
  value: "references",
4022
- label: t("brandKitDetail.references"),
3122
+ label: t("library.references"),
4023
3123
  count: referenceCount,
4024
3124
  },
4025
3125
  ];
@@ -4027,7 +3127,7 @@ function AssetScopeToggle({
4027
3127
  return (
4028
3128
  <div
4029
3129
  role="group"
4030
- aria-label={t("brandKitDetail.assetScope")}
3130
+ aria-label={t("library.assetScope")}
4031
3131
  className="inline-flex shrink-0 gap-1 rounded-md border border-border bg-muted/20 p-1"
4032
3132
  >
4033
3133
  {options.map((option) => {
@@ -4065,78 +3165,141 @@ function AssetScopeToggle({
4065
3165
 
4066
3166
  function AssetCardsView({ items }: { items: LaneGalleryItem[] }) {
4067
3167
  const t = useT();
3168
+ const [copiedItemId, setCopiedItemId] = useState<string | null>(null);
3169
+
3170
+ async function copyItem(item: LaneGalleryItem) {
3171
+ if (!item.asset) return;
3172
+ try {
3173
+ await navigator.clipboard.writeText(detailAssetClipboardText(item.asset));
3174
+ setCopiedItemId(item.id);
3175
+ toast.success(t("library.selectionCopied"));
3176
+ window.setTimeout(() => {
3177
+ setCopiedItemId((current) => (current === item.id ? null : current));
3178
+ }, 1400);
3179
+ } catch {
3180
+ toast.info(t("library.selectionReady"));
3181
+ }
3182
+ }
3183
+
4068
3184
  if (!items.length) {
4069
3185
  return (
4070
3186
  <div className="flex min-h-[220px] items-center justify-center rounded-lg border border-dashed border-border bg-muted/15 p-8 text-center text-sm text-muted-foreground">
4071
- {t("brandKitDetail.noAssetsToShow")}
3187
+ {t("library.noAssetsToShow")}
4072
3188
  </div>
4073
3189
  );
4074
3190
  }
4075
3191
 
4076
3192
  return (
4077
3193
  <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
4078
- {items.map((item) => (
4079
- <article
4080
- key={item.id}
4081
- className={[
4082
- "group overflow-hidden rounded-lg border border-border/80 bg-background transition hover:border-foreground/25",
4083
- item.selected ? "border-primary ring-2 ring-primary/25" : "",
4084
- item.deleting ? "opacity-60" : "",
4085
- ].join(" ")}
4086
- aria-busy={item.busy}
4087
- >
4088
- <div className="relative aspect-[4/3] bg-muted/30">
4089
- {item.href ? (
4090
- <Link to={item.href} className="block h-full w-full">
4091
- {item.thumbnail}
4092
- </Link>
4093
- ) : (
4094
- item.thumbnail
4095
- )}
4096
- <div className="absolute left-2 top-2 z-10">
4097
- {item.onToggle ? (
4098
- <Checkbox
4099
- checked={item.selected}
4100
- onCheckedChange={(checked) =>
4101
- item.onToggle?.(checked === true)
4102
- }
4103
- aria-label={t("brandKitDetail.selectItem", {
4104
- title: item.title,
4105
- })}
4106
- className="border-background bg-background/90 shadow-sm"
4107
- />
4108
- ) : null}
4109
- </div>
4110
- {item.menu ? (
4111
- <div className="absolute right-2 top-2 z-10">{item.menu}</div>
4112
- ) : null}
4113
- {item.busy ? (
4114
- <div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background/20">
4115
- <Spinner className="h-5 w-5" />
3194
+ {items.map((item) => {
3195
+ const copied = copiedItemId === item.id;
3196
+ const secondary =
3197
+ item.subtitle &&
3198
+ item.subtitle.toLowerCase() !== item.status?.toLowerCase()
3199
+ ? item.subtitle
3200
+ : null;
3201
+ return (
3202
+ <article
3203
+ key={item.id}
3204
+ className={[
3205
+ "group overflow-hidden rounded-lg border border-border/80 bg-background transition hover:border-foreground/25",
3206
+ item.selected ? "border-primary ring-2 ring-primary/25" : "",
3207
+ item.deleting ? "opacity-60" : "",
3208
+ ].join(" ")}
3209
+ aria-busy={item.busy}
3210
+ >
3211
+ <div className="relative aspect-[4/3] bg-muted/30">
3212
+ {item.href ? (
3213
+ <Link to={item.href} className="block h-full w-full">
3214
+ {item.thumbnail}
3215
+ </Link>
3216
+ ) : (
3217
+ item.thumbnail
3218
+ )}
3219
+ <div className="absolute left-2 top-2 z-10">
3220
+ {item.onToggle ? (
3221
+ <Checkbox
3222
+ checked={item.selected}
3223
+ onCheckedChange={(checked) =>
3224
+ item.onToggle?.(checked === true)
3225
+ }
3226
+ aria-label={t("library.selectAsset", {
3227
+ title: item.title,
3228
+ })}
3229
+ className="border-background bg-background/90 shadow-sm"
3230
+ />
3231
+ ) : null}
4116
3232
  </div>
4117
- ) : null}
4118
- </div>
4119
- <div className="space-y-3 p-3">
4120
- <div className="min-w-0">
4121
- <div className="truncate text-sm font-medium">{item.title}</div>
4122
- {item.subtitle ? (
4123
- <div className="mt-1 truncate text-xs text-muted-foreground">
4124
- {item.subtitle}
3233
+ <div className="absolute right-2 top-2 z-10 flex items-center gap-1.5 opacity-100 transition md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
3234
+ {item.asset ? (
3235
+ <TooltipProvider delayDuration={200}>
3236
+ <Tooltip>
3237
+ <TooltipTrigger asChild>
3238
+ <Button
3239
+ type="button"
3240
+ size="icon"
3241
+ variant="secondary"
3242
+ className="size-8 border border-border/80 bg-background/90 shadow-sm backdrop-blur hover:bg-background"
3243
+ onClick={(event) => {
3244
+ event.preventDefault();
3245
+ event.stopPropagation();
3246
+ void copyItem(item);
3247
+ }}
3248
+ aria-label={t("library.copyAsset", {
3249
+ title: item.title,
3250
+ })}
3251
+ >
3252
+ {copied ? (
3253
+ <IconCheck className="h-4 w-4" />
3254
+ ) : (
3255
+ <IconClipboard className="h-4 w-4" />
3256
+ )}
3257
+ </Button>
3258
+ </TooltipTrigger>
3259
+ <TooltipContent>
3260
+ {copied
3261
+ ? t("library.copied")
3262
+ : t("library.copyToClipboard")}
3263
+ </TooltipContent>
3264
+ </Tooltip>
3265
+ </TooltipProvider>
3266
+ ) : null}
3267
+ {item.menu}
3268
+ </div>
3269
+ {item.busy && item.showBusyOverlay !== false ? (
3270
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background/20">
3271
+ <Spinner className="h-5 w-5" />
4125
3272
  </div>
4126
3273
  ) : null}
4127
3274
  </div>
4128
- <div className="flex min-h-5 flex-wrap items-center gap-1.5">
4129
- {item.status ? (
4130
- <Badge variant="secondary">{item.status}</Badge>
4131
- ) : null}
4132
- {item.metadata ? (
4133
- <Badge variant="outline">{item.metadata}</Badge>
4134
- ) : null}
3275
+ <div className="p-3">
3276
+ <div className="min-w-0">
3277
+ <div className="truncate text-sm font-medium">{item.title}</div>
3278
+ <div className="mt-2 flex min-h-5 min-w-0 flex-wrap items-center gap-1.5">
3279
+ {item.status ? (
3280
+ <Badge
3281
+ variant="secondary"
3282
+ className="h-5 rounded-full px-2"
3283
+ >
3284
+ {item.status}
3285
+ </Badge>
3286
+ ) : null}
3287
+ {item.metadata ? (
3288
+ <Badge variant="outline" className="h-5 rounded-full px-2">
3289
+ {item.metadata}
3290
+ </Badge>
3291
+ ) : null}
3292
+ {secondary ? (
3293
+ <span className="min-w-0 truncate text-xs text-muted-foreground">
3294
+ {secondary}
3295
+ </span>
3296
+ ) : null}
3297
+ </div>
3298
+ </div>
4135
3299
  </div>
4136
- {item.primaryActions ? <div>{item.primaryActions}</div> : null}
4137
- </div>
4138
- </article>
4139
- ))}
3300
+ </article>
3301
+ );
3302
+ })}
4140
3303
  </div>
4141
3304
  );
4142
3305
  }
@@ -4233,7 +3396,7 @@ function SwimLane({
4233
3396
  >
4234
3397
  {item.thumbnail}
4235
3398
  <span className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background/90 to-transparent" />
4236
- {item.busy ? (
3399
+ {item.busy && item.showBusyOverlay !== false ? (
4237
3400
  <span className="absolute right-1.5 top-1.5 rounded-full bg-background/90 p-1 shadow-sm">
4238
3401
  <Spinner className="h-3 w-3" />
4239
3402
  </span>
@@ -4401,9 +3564,7 @@ function PendingUploadPreview({
4401
3564
  <div className="flex flex-col items-center gap-2 text-muted-foreground">
4402
3565
  <Spinner className={fit === "contain" ? "h-6 w-6" : "h-4 w-4"} />
4403
3566
  <span className="text-xs font-medium">
4404
- {isChecking
4405
- ? t("brandKitDetail.checking")
4406
- : t("brandKitDetail.uploading")}
3567
+ {isChecking ? t("library.checking") : t("library.uploading")}
4407
3568
  </span>
4408
3569
  </div>
4409
3570
  </div>
@@ -4436,7 +3597,7 @@ function AssetActionsMenu({
4436
3597
  variant="secondary"
4437
3598
  size="icon"
4438
3599
  className="h-8 w-8 shadow-sm"
4439
- aria-label={t("brandKitDetail.assetActions")}
3600
+ aria-label={t("library.assetActions")}
4440
3601
  disabled={busy}
4441
3602
  >
4442
3603
  <IconDotsVertical className="h-4 w-4" />
@@ -4445,7 +3606,8 @@ function AssetActionsMenu({
4445
3606
  <DropdownMenuContent align="end">
4446
3607
  <DropdownMenuItem asChild>
4447
3608
  <Link to={`/asset/${asset.id}`}>
4448
- {t("brandKitDetail.viewDetails")}
3609
+ <IconArrowUpRight className="mr-2 h-4 w-4 shrink-0" />
3610
+ {t("library.viewDetails")}
4449
3611
  </Link>
4450
3612
  </DropdownMenuItem>
4451
3613
  {onMoveToReferences ? (
@@ -4456,7 +3618,7 @@ function AssetActionsMenu({
4456
3618
  }}
4457
3619
  >
4458
3620
  <IconPhotoPlus className="mr-2 h-4 w-4 shrink-0" />
4459
- {t("brandKitDetail.addToReferences")}
3621
+ {t("library.addToReferences")}
4460
3622
  </DropdownMenuItem>
4461
3623
  ) : null}
4462
3624
  {onRemoveFromReferences ? (
@@ -4467,13 +3629,13 @@ function AssetActionsMenu({
4467
3629
  }}
4468
3630
  >
4469
3631
  <IconX className="mr-2 h-4 w-4 shrink-0" />
4470
- {t("brandKitDetail.removeFromReferences")}
3632
+ {t("library.removeFromReferences")}
4471
3633
  </DropdownMenuItem>
4472
3634
  ) : null}
4473
3635
  <DropdownMenuSub>
4474
3636
  <DropdownMenuSubTrigger>
4475
3637
  <IconFolder className="mr-2 h-4 w-4 shrink-0" />
4476
- {t("brandKitDetail.moveTo")}
3638
+ {t("library.moveTo")}
4477
3639
  </DropdownMenuSubTrigger>
4478
3640
  <DropdownMenuSubContent>
4479
3641
  <DropdownMenuItem
@@ -4484,7 +3646,7 @@ function AssetActionsMenu({
4484
3646
  })
4485
3647
  }
4486
3648
  >
4487
- {t("brandKitDetail.unfiled")}
3649
+ {t("library.unfiled")}
4488
3650
  </DropdownMenuItem>
4489
3651
  {folders.map((folder) => (
4490
3652
  <DropdownMenuItem
@@ -4507,661 +3669,887 @@ function AssetActionsMenu({
4507
3669
  onSelect={onDelete}
4508
3670
  >
4509
3671
  <IconTrash className="mr-2 h-4 w-4 shrink-0" />
4510
- {t("brandKitDetail.delete")}
3672
+ {t("assetDetail.delete")}
4511
3673
  </DropdownMenuItem>
4512
3674
  </DropdownMenuContent>
4513
3675
  </DropdownMenu>
4514
3676
  );
4515
3677
  }
4516
3678
 
4517
- function VariantPreview({
4518
- slot,
4519
- fit = "cover",
3679
+ function PendingUploadLaneTile({ upload }: { upload: PendingUpload }) {
3680
+ const t = useT();
3681
+ const isChecking = upload.status === "checking";
3682
+ return (
3683
+ <div className="w-[144px] shrink-0 overflow-hidden rounded-md border border-dashed border-border bg-background sm:w-[156px]">
3684
+ <div className="flex aspect-[4/3] items-center justify-center bg-muted/30">
3685
+ <div className="flex flex-col items-center gap-2 text-muted-foreground">
3686
+ <Spinner className="h-5 w-5" />
3687
+ <span className="text-xs font-medium">
3688
+ {isChecking ? t("library.checking") : t("library.uploading")}
3689
+ </span>
3690
+ </div>
3691
+ </div>
3692
+ <div className="p-2.5">
3693
+ <div className="flex items-center gap-2 truncate text-xs font-medium">
3694
+ {upload.mediaType === "video" ? (
3695
+ <IconVideo className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
3696
+ ) : (
3697
+ <IconPhoto className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
3698
+ )}
3699
+ <span className="truncate">{upload.name}</span>
3700
+ </div>
3701
+ </div>
3702
+ </div>
3703
+ );
3704
+ }
3705
+
3706
+ function AssetLaneTile({
3707
+ asset,
3708
+ folders,
3709
+ selected,
3710
+ deleting,
3711
+ saving,
3712
+ promoting,
3713
+ onToggle,
3714
+ onDelete,
3715
+ updateAsset,
3716
+ onSave,
3717
+ onMoveToReferences,
4520
3718
  }: {
4521
- slot: any;
4522
- fit?: "cover" | "contain";
3719
+ asset: any;
3720
+ folders: any[];
3721
+ selected: boolean;
3722
+ deleting?: boolean;
3723
+ saving?: boolean;
3724
+ promoting?: boolean;
3725
+ onToggle: (checked: boolean) => void;
3726
+ onDelete: () => void;
3727
+ updateAsset: any;
3728
+ onSave?: () => void;
3729
+ onMoveToReferences?: () => void;
4523
3730
  }) {
4524
3731
  const t = useT();
4525
- const [sourceIndex, setSourceIndex] = useState(0);
4526
- const [previewUnavailable, setPreviewUnavailable] = useState(false);
4527
- const previewSources = assetPreviewSources(slot, "thumbnail");
4528
- const previewSourcesKey = previewSources.join("\n");
4529
- const isFailed = slot.status === "failed";
4530
- const previewSrc = previewSources[sourceIndex];
4531
-
4532
- useEffect(() => {
4533
- setSourceIndex(0);
4534
- setPreviewUnavailable(false);
4535
- }, [previewSourcesKey]);
3732
+ const displayTitle = assetDisplayTitle(asset);
3733
+ const sourceText = assetLineageSourceText(asset);
3734
+ const canMoveToReferences = Boolean(onMoveToReferences);
3735
+ const hasPrimaryActions = Boolean(onSave || canMoveToReferences);
3736
+ const categoryLabel = assetCategoryLabel(asset);
3737
+ const busy = deleting || saving || promoting;
4536
3738
 
4537
3739
  return (
4538
- <div className="flex h-full w-full items-center justify-center bg-muted">
4539
- {previewSrc && !previewUnavailable ? (
4540
- <img
4541
- src={previewSrc}
4542
- alt=""
3740
+ <div
3741
+ className={[
3742
+ "group relative w-[144px] shrink-0 overflow-hidden rounded-md border bg-background transition sm:w-[156px]",
3743
+ selected
3744
+ ? "border-primary ring-2 ring-primary/25"
3745
+ : "border-border/80 hover:border-foreground/20",
3746
+ deleting ? "opacity-60" : "",
3747
+ ].join(" ")}
3748
+ aria-busy={busy}
3749
+ >
3750
+ <div className="absolute left-2 top-2 z-10">
3751
+ <Checkbox
3752
+ checked={selected}
3753
+ onCheckedChange={(checked) => onToggle(checked === true)}
3754
+ aria-label={t("library.selectAsset", { title: displayTitle })}
4543
3755
  className={[
4544
- "h-full w-full",
4545
- fit === "contain" ? "object-contain" : "object-cover",
3756
+ "border-background bg-background/90 shadow-sm opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100",
3757
+ selected ? "sm:opacity-100" : "",
4546
3758
  ].join(" ")}
4547
- onError={() => {
4548
- const nextIndex = sourceIndex + 1;
4549
- if (nextIndex < previewSources.length) {
4550
- setSourceIndex(nextIndex);
4551
- } else {
4552
- setPreviewUnavailable(true);
4553
- }
4554
- }}
4555
3759
  />
4556
- ) : isFailed ? (
4557
- <div className="p-4 text-center text-xs text-destructive">
4558
- {slot.error}
3760
+ </div>
3761
+ <div className="absolute right-2 top-2 z-10">
3762
+ <DropdownMenu>
3763
+ <DropdownMenuTrigger asChild>
3764
+ <Button
3765
+ type="button"
3766
+ variant="secondary"
3767
+ size="icon"
3768
+ className="h-8 w-8 shadow-sm opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100 data-[state=open]:opacity-100"
3769
+ aria-label={t("library.assetActions")}
3770
+ disabled={busy}
3771
+ >
3772
+ <IconDotsVertical className="h-4 w-4" />
3773
+ </Button>
3774
+ </DropdownMenuTrigger>
3775
+ <DropdownMenuContent align="end">
3776
+ <DropdownMenuItem asChild>
3777
+ <Link to={`/asset/${asset.id}`}>
3778
+ <IconArrowUpRight className="mr-2 h-4 w-4 shrink-0" />
3779
+ {t("library.viewDetails")}
3780
+ </Link>
3781
+ </DropdownMenuItem>
3782
+ {canMoveToReferences ? (
3783
+ <DropdownMenuItem
3784
+ onSelect={(event) => {
3785
+ event.preventDefault();
3786
+ onMoveToReferences?.();
3787
+ }}
3788
+ >
3789
+ <IconPhotoPlus className="mr-2 h-4 w-4 shrink-0" />
3790
+ {t("library.addToReferences")}
3791
+ </DropdownMenuItem>
3792
+ ) : null}
3793
+ <DropdownMenuSub>
3794
+ <DropdownMenuSubTrigger>
3795
+ <IconFolder className="mr-2 h-4 w-4 shrink-0" />
3796
+ {t("library.moveTo")}
3797
+ </DropdownMenuSubTrigger>
3798
+ <DropdownMenuSubContent>
3799
+ <DropdownMenuItem
3800
+ onSelect={() =>
3801
+ updateAsset.mutate({
3802
+ id: asset.id,
3803
+ folderId: null,
3804
+ })
3805
+ }
3806
+ >
3807
+ {t("library.unfiled")}
3808
+ </DropdownMenuItem>
3809
+ {folders.map((folder) => (
3810
+ <DropdownMenuItem
3811
+ key={folder.id}
3812
+ onSelect={() =>
3813
+ updateAsset.mutate({
3814
+ id: asset.id,
3815
+ folderId: folder.id,
3816
+ })
3817
+ }
3818
+ >
3819
+ {folder.title}
3820
+ </DropdownMenuItem>
3821
+ ))}
3822
+ </DropdownMenuSubContent>
3823
+ </DropdownMenuSub>
3824
+ <DropdownMenuSeparator />
3825
+ <DropdownMenuItem
3826
+ className="text-destructive focus:bg-destructive/10 focus:text-destructive"
3827
+ onSelect={onDelete}
3828
+ >
3829
+ <IconTrash className="mr-2 h-4 w-4 shrink-0" />
3830
+ {t("assetDetail.delete")}
3831
+ </DropdownMenuItem>
3832
+ </DropdownMenuContent>
3833
+ </DropdownMenu>
3834
+ </div>
3835
+ <Link to={`/asset/${asset.id}`} className="block outline-none">
3836
+ <div className="relative aspect-[4/3] bg-muted">
3837
+ <AssetPreview asset={asset} />
3838
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-background via-background/90 to-transparent px-2 pb-2 pt-8">
3839
+ <div className="flex items-center gap-1.5 truncate text-xs font-medium">
3840
+ {asset.mediaType === "video" ? (
3841
+ <IconVideo className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
3842
+ ) : (
3843
+ <IconPhoto className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
3844
+ )}
3845
+ <span className="truncate">{displayTitle}</span>
3846
+ </div>
3847
+ <div className="mt-1 flex min-w-0 items-center gap-1 overflow-hidden text-[10px] font-medium text-muted-foreground">
3848
+ {sourceText ? (
3849
+ <span className="truncate">{sourceText}</span>
3850
+ ) : (
3851
+ <>
3852
+ <span className="truncate">{asset.status}</span>
3853
+ {categoryLabel ? (
3854
+ <>
3855
+ <span className="shrink-0 text-muted-foreground/60">
3856
+ /
3857
+ </span>
3858
+ <span className="truncate">{categoryLabel}</span>
3859
+ </>
3860
+ ) : null}
3861
+ </>
3862
+ )}
3863
+ </div>
3864
+ </div>
4559
3865
  </div>
4560
- ) : previewUnavailable ? (
4561
- <div className="p-4 text-center text-xs text-muted-foreground">
4562
- {t("brandKitDetail.previewUnavailable")}
3866
+ </Link>
3867
+ {hasPrimaryActions ? (
3868
+ <div className="space-y-2 border-t border-border/70 p-2">
3869
+ <div
3870
+ className={
3871
+ onSave && canMoveToReferences
3872
+ ? "grid grid-cols-1 gap-2"
3873
+ : "grid grid-cols-2 gap-2"
3874
+ }
3875
+ >
3876
+ {onSave ? (
3877
+ <Button
3878
+ size="sm"
3879
+ className="h-8 px-2 text-xs"
3880
+ onClick={onSave}
3881
+ disabled={busy}
3882
+ >
3883
+ {saving ? (
3884
+ <Spinner className="h-3.5 w-3.5" />
3885
+ ) : (
3886
+ t("library.save")
3887
+ )}
3888
+ </Button>
3889
+ ) : null}
3890
+ {canMoveToReferences ? (
3891
+ <Button
3892
+ variant="outline"
3893
+ size="sm"
3894
+ className={
3895
+ onSave ? "h-8 px-2 text-xs" : "col-span-2 h-8 px-2 text-xs"
3896
+ }
3897
+ onClick={onMoveToReferences}
3898
+ disabled={busy}
3899
+ title={t("library.addToReferences")}
3900
+ >
3901
+ {promoting ? (
3902
+ <Spinner className="h-3.5 w-3.5" />
3903
+ ) : (
3904
+ t("library.addToReferences")
3905
+ )}
3906
+ </Button>
3907
+ ) : null}
3908
+ </div>
4563
3909
  </div>
4564
- ) : (
4565
- <IconPhoto className="h-8 w-8 animate-pulse text-muted-foreground" />
4566
- )}
3910
+ ) : null}
4567
3911
  </div>
4568
3912
  );
4569
3913
  }
4570
3914
 
4571
- function VariantActionsMenu({
4572
- slot,
3915
+ export function LiveCandidatesStage({
3916
+ slots,
3917
+ draftAssets,
4573
3918
  libraryId,
4574
- busy,
3919
+ folders,
3920
+ foldersByLibraryId = {},
3921
+ allowCreateFolder = true,
3922
+ savingSlotId,
3923
+ promotingReferenceKeys,
3924
+ onSave,
3925
+ onSaveDraft,
4575
3926
  onMoveToReferences,
3927
+ onMoveDraftToReferences,
3928
+ onUse,
3929
+ onUseDraft,
4576
3930
  }: {
4577
- slot: any;
3931
+ slots: VariantSlot[];
3932
+ draftAssets: any[];
4578
3933
  libraryId: string;
4579
- busy?: boolean;
4580
- onMoveToReferences?: () => void;
3934
+ folders: any[];
3935
+ foldersByLibraryId?: Record<string, any[]>;
3936
+ allowCreateFolder?: boolean;
3937
+ savingSlotId: string | null;
3938
+ promotingReferenceKeys: Set<string>;
3939
+ onSave: (slot: VariantSlot, folderId: string | null) => void;
3940
+ onSaveDraft: (asset: any, folderId: string | null) => void;
3941
+ onMoveToReferences: (slot: VariantSlot) => void;
3942
+ onMoveDraftToReferences: (asset: any) => void;
3943
+ onUse?: (slot: VariantSlot) => void;
3944
+ onUseDraft?: (asset: any) => void;
4581
3945
  }) {
4582
3946
  const t = useT();
4583
3947
  const dismissSlot = useActionMutation("dismiss-variant-slots");
3948
+ const deleteAsset = useActionMutation("delete-asset");
4584
3949
  const queryClient = useQueryClient();
4585
- const [confirmOpen, setConfirmOpen] = useState(false);
4586
- const isFailed = slot.status === "failed";
4587
- const label = isFailed
4588
- ? t("brandKitDetail.dismiss")
4589
- : t("brandKitDetail.delete");
3950
+ const [dismissTarget, setDismissTarget] = useState<{
3951
+ kind: "slot" | "asset";
3952
+ title: string;
3953
+ slot?: VariantSlot;
3954
+ asset?: any;
3955
+ } | null>(null);
3956
+ const dismissing = dismissSlot.isPending || deleteAsset.isPending;
3957
+ const totalCount = slots.length + draftAssets.length;
3958
+ const [activeItemId, setActiveItemId] = useState<string | null>(null);
4590
3959
 
4591
- return (
4592
- <>
4593
- <DropdownMenu>
4594
- <DropdownMenuTrigger asChild>
3960
+ async function handleDismissCandidate() {
3961
+ if (!dismissTarget || dismissing) return;
3962
+ try {
3963
+ if (dismissTarget.kind === "slot" && dismissTarget.slot) {
3964
+ await dismissSlot.mutateAsync({ slotId: dismissTarget.slot.slotId });
3965
+ removeVariantSlotFromCache(queryClient, dismissTarget.slot);
3966
+ removeAssetsFromLibraryCache(queryClient, libraryId, [
3967
+ dismissTarget.slot.assetId,
3968
+ ]);
3969
+ void queryClient.invalidateQueries({
3970
+ queryKey: ["app-state"],
3971
+ refetchType: "active",
3972
+ });
3973
+ } else if (dismissTarget.kind === "asset" && dismissTarget.asset?.id) {
3974
+ await deleteAsset.mutateAsync({ id: dismissTarget.asset.id });
3975
+ removeAssetsFromLibraryCache(queryClient, libraryId, [
3976
+ dismissTarget.asset.id,
3977
+ ]);
3978
+ }
3979
+ setDismissTarget(null);
3980
+ void queryClient.invalidateQueries({
3981
+ queryKey: ["action", "get-library", { id: libraryId }],
3982
+ refetchType: "active",
3983
+ });
3984
+ void queryClient.invalidateQueries({
3985
+ queryKey: ["action", "get-library"],
3986
+ refetchType: "active",
3987
+ });
3988
+ void queryClient.invalidateQueries({
3989
+ queryKey: ["action", "list-assets"],
3990
+ refetchType: "active",
3991
+ });
3992
+ toast.success(t("library.dismissedCandidate"));
3993
+ } catch (error) {
3994
+ toast.error(
3995
+ error instanceof Error
3996
+ ? error.message
3997
+ : t("library.couldNotDismissCandidate"),
3998
+ );
3999
+ }
4000
+ }
4001
+
4002
+ function candidateActions({
4003
+ canUseCandidate,
4004
+ saving,
4005
+ promoting,
4006
+ candidateLibraryId,
4007
+ onSaveCandidate,
4008
+ onAddToReferences,
4009
+ onUseCandidate,
4010
+ onDismiss,
4011
+ }: {
4012
+ canUseCandidate: boolean;
4013
+ saving?: boolean;
4014
+ promoting?: boolean;
4015
+ candidateLibraryId?: string | null;
4016
+ onSaveCandidate?: (folderId: string | null) => void;
4017
+ onAddToReferences?: () => void;
4018
+ onUseCandidate?: () => void;
4019
+ onDismiss: () => void;
4020
+ }) {
4021
+ const busy = saving || promoting || dismissing;
4022
+ const actionLibraryId = candidateLibraryId || libraryId;
4023
+ const candidateFolders =
4024
+ foldersByLibraryId[actionLibraryId] ??
4025
+ (actionLibraryId === libraryId ? folders : []);
4026
+ if (!canUseCandidate) {
4027
+ return (
4028
+ <Button
4029
+ variant="outline"
4030
+ size="sm"
4031
+ className="h-8 w-full justify-center px-2 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive"
4032
+ onClick={onDismiss}
4033
+ disabled={busy}
4034
+ >
4035
+ {t("library.dismiss")}
4036
+ </Button>
4037
+ );
4038
+ }
4039
+ return (
4040
+ <div className="grid min-w-0 gap-2">
4041
+ {onUseCandidate ? (
4595
4042
  <Button
4596
- type="button"
4597
- variant="secondary"
4598
- size="icon"
4599
- className="h-8 w-8 shadow-sm"
4600
- aria-label={t("brandKitDetail.candidateActions")}
4601
- disabled={busy || dismissSlot.isPending}
4043
+ size="sm"
4044
+ className="h-8 min-w-0 justify-center px-2 text-xs"
4045
+ onClick={onUseCandidate}
4046
+ disabled={busy}
4602
4047
  >
4603
- <IconDotsVertical className="h-4 w-4" />
4048
+ {t("library.useCandidate")}
4604
4049
  </Button>
4605
- </DropdownMenuTrigger>
4606
- <DropdownMenuContent align="end">
4607
- {onMoveToReferences && !isFailed ? (
4608
- <DropdownMenuItem
4609
- onSelect={(event) => {
4610
- event.preventDefault();
4611
- onMoveToReferences();
4612
- }}
4613
- >
4614
- <IconPhotoPlus className="mr-2 h-4 w-4 shrink-0" />
4615
- {t("brandKitDetail.addToReferences")}
4616
- </DropdownMenuItem>
4617
- ) : null}
4618
- <DropdownMenuItem
4619
- className="text-destructive focus:bg-destructive/10 focus:text-destructive"
4620
- onSelect={(event) => {
4621
- event.preventDefault();
4622
- setConfirmOpen(true);
4623
- }}
4050
+ ) : null}
4051
+ <div className="grid min-w-0 grid-cols-1 gap-2 min-[420px]:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
4052
+ <CandidateSaveMenu
4053
+ libraryId={actionLibraryId}
4054
+ folders={candidateFolders}
4055
+ allowCreateFolder={allowCreateFolder}
4056
+ saving={saving}
4057
+ disabled={busy}
4058
+ onSave={(folderId) => onSaveCandidate?.(folderId)}
4059
+ />
4060
+ <Button
4061
+ variant="outline"
4062
+ size="sm"
4063
+ className="h-8 min-w-0 px-2 text-xs"
4064
+ onClick={onAddToReferences}
4065
+ disabled={busy}
4624
4066
  >
4625
- <IconTrash className="mr-2 h-4 w-4 shrink-0" />
4626
- {label}
4627
- </DropdownMenuItem>
4628
- </DropdownMenuContent>
4629
- </DropdownMenu>
4067
+ {promoting ? (
4068
+ <Spinner className="h-3.5 w-3.5" />
4069
+ ) : (
4070
+ t("library.addToReferences")
4071
+ )}
4072
+ </Button>
4073
+ </div>
4074
+ <Button
4075
+ variant="ghost"
4076
+ size="sm"
4077
+ className="h-8 min-w-0 justify-center px-2 text-xs text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
4078
+ onClick={onDismiss}
4079
+ disabled={busy}
4080
+ >
4081
+ {t("library.dismiss")}
4082
+ </Button>
4083
+ </div>
4084
+ );
4085
+ }
4086
+
4087
+ function slotItem(slot: VariantSlot): LaneGalleryItem {
4088
+ const isFailed = slot.status === "failed";
4089
+ const canUseCandidate = slot.status === "ready" && Boolean(slot.assetId);
4090
+ const promotingKey = referencePromotionKey(
4091
+ slot.assetId ? { id: slot.assetId } : null,
4092
+ slot,
4093
+ );
4094
+ const saving = savingSlotId === slot.slotId;
4095
+ const promoting =
4096
+ Boolean(promotingKey) && promotingReferenceKeys.has(promotingKey);
4097
+ const busy = saving || promoting || dismissing;
4098
+ const title = isFailed
4099
+ ? t("library.failedCandidate")
4100
+ : slot.status === "ready"
4101
+ ? t("library.readyCandidate")
4102
+ : t("library.generatingCandidate");
4103
+ return {
4104
+ id: `slot:${slot.slotId}`,
4105
+ title,
4106
+ subtitle: slot.slotId
4107
+ ? shortId(String(slot.slotId))
4108
+ : t("library.liveSlot"),
4109
+ metadata: t("library.live"),
4110
+ status: slot.status,
4111
+ mediaType: "image",
4112
+ href: slot.assetId ? `/asset/${slot.assetId}` : undefined,
4113
+ busy,
4114
+ preview: <VariantPreview slot={slot} fit="contain" />, // i18n-ignore structural preview slot name
4115
+ thumbnail: <VariantPreview slot={slot} />,
4116
+ primaryActions: candidateActions({
4117
+ canUseCandidate,
4118
+ saving,
4119
+ promoting,
4120
+ candidateLibraryId: libraryId,
4121
+ onSaveCandidate: (folderId) => onSave(slot, folderId),
4122
+ onAddToReferences: () => onMoveToReferences(slot),
4123
+ onUseCandidate: onUse ? () => onUse(slot) : undefined,
4124
+ onDismiss: () =>
4125
+ setDismissTarget({
4126
+ kind: "slot",
4127
+ title,
4128
+ slot,
4129
+ }),
4130
+ }),
4131
+ };
4132
+ }
4133
+
4134
+ function draftItem(asset: any): LaneGalleryItem {
4135
+ const promotingKey = referencePromotionKey(asset);
4136
+ const saving = savingSlotId === `draft:${asset.id}`;
4137
+ const promoting =
4138
+ Boolean(promotingKey) && promotingReferenceKeys.has(promotingKey);
4139
+ const busy = saving || promoting || dismissing;
4140
+ return {
4141
+ id: `draft:${asset.id}`,
4142
+ title: assetDisplayTitle(asset),
4143
+ subtitle:
4144
+ [asset.libraryTitle, assetLineageSourceText(asset)]
4145
+ .filter(Boolean)
4146
+ .join(" / ") || assetCategoryLabel(asset),
4147
+ metadata:
4148
+ asset.mediaType === "video"
4149
+ ? t("library.video")
4150
+ : asset.mimeType?.startsWith("image/")
4151
+ ? t("library.image")
4152
+ : t("library.draft"),
4153
+ status: "draft",
4154
+ mediaType: asset.mediaType === "video" ? "video" : "image",
4155
+ href: `/asset/${asset.id}`,
4156
+ busy,
4157
+ preview: <AssetPreview asset={asset} fit="contain" />, // i18n-ignore structural preview slot name
4158
+ thumbnail: <AssetPreview asset={asset} />,
4159
+ primaryActions: candidateActions({
4160
+ canUseCandidate: true,
4161
+ saving,
4162
+ promoting,
4163
+ candidateLibraryId: asset.libraryId,
4164
+ onSaveCandidate: (folderId) => onSaveDraft(asset, folderId),
4165
+ onAddToReferences: () => onMoveDraftToReferences(asset),
4166
+ onUseCandidate: onUseDraft ? () => onUseDraft(asset) : undefined,
4167
+ onDismiss: () =>
4168
+ setDismissTarget({
4169
+ kind: "asset",
4170
+ title: assetDisplayTitle(asset),
4171
+ asset,
4172
+ }),
4173
+ }),
4174
+ };
4175
+ }
4176
+
4177
+ const items = [...slots.map(slotItem), ...draftAssets.map(draftItem)];
4178
+ const itemIds = items.map((item) => item.id).join("\n");
4179
+ const activeItem =
4180
+ items.find((item) => item.id === activeItemId) ?? items[0] ?? null;
4181
+
4182
+ useEffect(() => {
4183
+ if (!items.length) {
4184
+ setActiveItemId(null);
4185
+ return;
4186
+ }
4187
+ setActiveItemId((current) =>
4188
+ current && items.some((item) => item.id === current)
4189
+ ? current
4190
+ : items[0].id,
4191
+ );
4192
+ }, [itemIds, items]);
4630
4193
 
4194
+ return (
4195
+ <>
4631
4196
  <AlertDialog
4632
- open={confirmOpen}
4197
+ open={dismissTarget !== null}
4633
4198
  onOpenChange={(open) => {
4634
- if (!dismissSlot.isPending) setConfirmOpen(open);
4199
+ if (!open && !dismissing) setDismissTarget(null);
4635
4200
  }}
4636
4201
  >
4637
4202
  <AlertDialogContent>
4638
4203
  <AlertDialogHeader>
4639
4204
  <AlertDialogTitle>
4640
- {isFailed
4641
- ? t("brandKitDetail.dismissThisSlot")
4642
- : t("brandKitDetail.deleteCandidate")}
4205
+ {t("library.dismissCandidateTitle")}
4643
4206
  </AlertDialogTitle>
4644
4207
  <AlertDialogDescription>
4645
- {isFailed
4646
- ? t("brandKitDetail.dismissSlotDescription")
4647
- : t("brandKitDetail.deleteCandidateDescription")}
4208
+ {t("library.dismissCandidateDescription", {
4209
+ title: dismissTarget?.title ?? t("library.thisCandidate"),
4210
+ })}
4648
4211
  </AlertDialogDescription>
4649
4212
  </AlertDialogHeader>
4650
4213
  <AlertDialogFooter>
4651
- <AlertDialogCancel disabled={dismissSlot.isPending}>
4652
- {t("brandKitDetail.cancel")}
4214
+ <AlertDialogCancel disabled={dismissing}>
4215
+ {t("library.cancel")}
4653
4216
  </AlertDialogCancel>
4654
4217
  <AlertDialogAction
4655
4218
  className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
4656
- disabled={dismissSlot.isPending}
4219
+ disabled={dismissing}
4657
4220
  onClick={(event) => {
4658
4221
  event.preventDefault();
4659
- dismissSlot.mutate(
4660
- { slotId: slot.slotId },
4661
- {
4662
- onSuccess: () => {
4663
- removeVariantSlotFromCache(queryClient, slot);
4664
- removeAssetsFromLibraryCache(queryClient, libraryId, [
4665
- slot.assetId,
4666
- ]);
4667
- setConfirmOpen(false);
4668
- void queryClient.invalidateQueries({
4669
- queryKey: ["app-state", "asset-variants"],
4670
- refetchType: "active",
4671
- });
4672
- },
4673
- onError: (error) =>
4674
- toast.error(
4675
- error.message ||
4676
- t("brandKitDetail.couldNotClearCandidate"),
4677
- ),
4678
- },
4679
- );
4222
+ void handleDismissCandidate();
4680
4223
  }}
4681
4224
  >
4682
- {dismissSlot.isPending ? (
4225
+ {dismissing ? (
4683
4226
  <>
4684
4227
  <Spinner className="h-4 w-4" />
4685
- {isFailed
4686
- ? t("brandKitDetail.dismissing")
4687
- : t("brandKitDetail.deleting")}
4228
+ {t("library.dismissing")}
4688
4229
  </>
4689
4230
  ) : (
4690
- label
4231
+ t("library.dismiss")
4691
4232
  )}
4692
4233
  </AlertDialogAction>
4693
4234
  </AlertDialogFooter>
4694
4235
  </AlertDialogContent>
4695
4236
  </AlertDialog>
4237
+
4238
+ <section className="min-w-0 overflow-hidden rounded-lg border border-border bg-background">
4239
+ <div className="flex min-w-0 items-center justify-between gap-3 border-b border-border px-3 py-2.5 sm:px-4">
4240
+ <div className="flex min-w-0 flex-1 items-center">
4241
+ <h3 className="shrink-0 text-sm font-semibold">
4242
+ {t("library.candidates")}
4243
+ </h3>
4244
+ </div>
4245
+ <div className="flex shrink-0 items-center gap-1.5">
4246
+ <LiveCandidatesActions
4247
+ slots={slots}
4248
+ draftAssets={draftAssets}
4249
+ libraryId={libraryId}
4250
+ />
4251
+ </div>
4252
+ </div>
4253
+ <div className="grid min-w-0 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,22rem)]">
4254
+ <div className="min-w-0 bg-muted/10 p-2.5 sm:p-3">
4255
+ <div
4256
+ className={[
4257
+ "group relative overflow-hidden rounded-lg border border-border bg-background shadow-sm",
4258
+ activeItem?.busy ? "opacity-80" : "",
4259
+ ].join(" ")}
4260
+ aria-busy={activeItem?.busy}
4261
+ >
4262
+ <div className="h-36 bg-muted/30 sm:h-44 lg:h-56 2xl:h-64">
4263
+ {activeItem?.href ? (
4264
+ <Link to={activeItem.href} className="block h-full w-full">
4265
+ {activeItem.preview}
4266
+ </Link>
4267
+ ) : (
4268
+ activeItem?.preview
4269
+ )}
4270
+ </div>
4271
+ {activeItem?.busy ? (
4272
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background/20">
4273
+ <Spinner className="h-5 w-5" />
4274
+ </div>
4275
+ ) : null}
4276
+ {activeItem?.href ? (
4277
+ <Button
4278
+ asChild
4279
+ variant="secondary"
4280
+ size="sm"
4281
+ className="absolute right-2 top-2 h-8 gap-1.5 bg-background/85 px-2.5 text-xs opacity-0 shadow-sm backdrop-blur transition group-hover:opacity-100 focus-within:opacity-100"
4282
+ >
4283
+ <Link to={activeItem.href}>
4284
+ <IconArrowUpRight className="h-3.5 w-3.5" />
4285
+ {t("library.details")}
4286
+ </Link>
4287
+ </Button>
4288
+ ) : null}
4289
+ </div>
4290
+ <div className="mt-2.5 flex gap-2 overflow-x-auto pb-1">
4291
+ {items.map((item) => {
4292
+ const active = item.id === activeItem?.id;
4293
+ return (
4294
+ <button
4295
+ key={item.id}
4296
+ type="button"
4297
+ onClick={() => setActiveItemId(item.id)}
4298
+ className={[
4299
+ "group relative h-16 w-24 shrink-0 overflow-hidden rounded-md border bg-background transition",
4300
+ active
4301
+ ? "border-primary ring-2 ring-primary/25"
4302
+ : "border-border/80 hover:border-foreground/30",
4303
+ ].join(" ")}
4304
+ aria-label={t("library.showCandidate", {
4305
+ title: item.title,
4306
+ })}
4307
+ aria-pressed={active}
4308
+ >
4309
+ {item.thumbnail}
4310
+ <span className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background/90 to-transparent" />
4311
+ {item.busy && item.showBusyOverlay !== false ? (
4312
+ <span className="absolute right-1.5 top-1.5 rounded-full bg-background/90 p-1 shadow-sm">
4313
+ <Spinner className="h-3 w-3" />
4314
+ </span>
4315
+ ) : null}
4316
+ </button>
4317
+ );
4318
+ })}
4319
+ </div>
4320
+ </div>
4321
+ <aside className="flex min-w-0 flex-col justify-between gap-3 border-t border-border bg-background p-3 lg:border-l lg:border-t-0 lg:p-4">
4322
+ <div className="min-w-0 space-y-3">
4323
+ <div className="min-w-0">
4324
+ <div className="flex min-w-0 flex-wrap items-center gap-1.5">
4325
+ {activeItem?.status ? (
4326
+ <CandidateStatusPill status={activeItem.status} />
4327
+ ) : null}
4328
+ {activeItem?.metadata ? (
4329
+ <Badge
4330
+ variant="outline"
4331
+ className="h-6 max-w-full rounded-full px-2 text-[11px]"
4332
+ >
4333
+ {activeItem.metadata}
4334
+ </Badge>
4335
+ ) : null}
4336
+ </div>
4337
+ <div className="mt-2 truncate text-sm font-semibold">
4338
+ {activeItem?.title}
4339
+ </div>
4340
+ {activeItem?.subtitle ? (
4341
+ <div className="mt-1 truncate text-xs text-muted-foreground">
4342
+ {activeItem.subtitle}
4343
+ </div>
4344
+ ) : null}
4345
+ </div>
4346
+ {activeItem?.primaryActions ? (
4347
+ <div>{activeItem.primaryActions}</div>
4348
+ ) : null}
4349
+ </div>
4350
+ {activeItem?.href ? (
4351
+ <Button asChild variant="ghost" size="sm" className="gap-1.5">
4352
+ <Link to={activeItem.href}>
4353
+ <IconArrowUpRight className="h-3.5 w-3.5" />
4354
+ {t("library.openDetails")}
4355
+ </Link>
4356
+ </Button>
4357
+ ) : null}
4358
+ </aside>
4359
+ </div>
4360
+ </section>
4696
4361
  </>
4697
4362
  );
4698
4363
  }
4699
4364
 
4700
- function PendingUploadLaneTile({ upload }: { upload: PendingUpload }) {
4365
+ function CandidateStatusPill({ status }: { status: string }) {
4701
4366
  const t = useT();
4702
- const isChecking = upload.status === "checking";
4367
+ const normalized = status.toLowerCase();
4368
+ const label =
4369
+ normalized === "pending"
4370
+ ? t("library.generating")
4371
+ : normalized === "ready"
4372
+ ? t("library.ready")
4373
+ : normalized === "failed"
4374
+ ? t("library.failed")
4375
+ : normalized === "draft"
4376
+ ? t("library.draft")
4377
+ : status;
4378
+ const className =
4379
+ normalized === "ready"
4380
+ ? "border-primary/30 bg-primary/10 text-primary"
4381
+ : normalized === "failed"
4382
+ ? "border-destructive/30 bg-destructive/10 text-destructive"
4383
+ : normalized === "pending"
4384
+ ? "border-border bg-muted/70 text-muted-foreground"
4385
+ : "border-border bg-background text-muted-foreground";
4386
+
4703
4387
  return (
4704
- <div className="w-[144px] shrink-0 overflow-hidden rounded-md border border-dashed border-border bg-background sm:w-[156px]">
4705
- <div className="flex aspect-[4/3] items-center justify-center bg-muted/30">
4706
- <div className="flex flex-col items-center gap-2 text-muted-foreground">
4707
- <Spinner className="h-5 w-5" />
4708
- <span className="text-xs font-medium">
4709
- {isChecking
4710
- ? t("brandKitDetail.checking")
4711
- : t("brandKitDetail.uploading")}
4712
- </span>
4713
- </div>
4714
- </div>
4715
- <div className="p-2.5">
4716
- <div className="flex items-center gap-2 truncate text-xs font-medium">
4717
- {upload.mediaType === "video" ? (
4718
- <IconVideo className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
4719
- ) : (
4720
- <IconPhoto className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
4721
- )}
4722
- <span className="truncate">{upload.name}</span>
4723
- </div>
4724
- </div>
4725
- </div>
4388
+ <span
4389
+ className={[
4390
+ "inline-flex h-6 max-w-full items-center gap-1.5 rounded-full border px-2 text-[11px] font-medium",
4391
+ className,
4392
+ ].join(" ")}
4393
+ >
4394
+ <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-current" />
4395
+ <span className="truncate">{label}</span>
4396
+ </span>
4726
4397
  );
4727
4398
  }
4728
4399
 
4729
- function AssetLaneTile({
4730
- asset,
4731
- folders,
4732
- selected,
4733
- deleting,
4734
- saving,
4735
- promoting,
4736
- onToggle,
4737
- onDelete,
4738
- updateAsset,
4739
- onSave,
4740
- onMoveToReferences,
4400
+ function VariantPreview({
4401
+ slot,
4402
+ fit = "cover",
4741
4403
  }: {
4742
- asset: any;
4743
- folders: any[];
4744
- selected: boolean;
4745
- deleting?: boolean;
4746
- saving?: boolean;
4747
- promoting?: boolean;
4748
- onToggle: (checked: boolean) => void;
4749
- onDelete: () => void;
4750
- updateAsset: any;
4751
- onSave?: () => void;
4752
- onMoveToReferences?: () => void;
4404
+ slot: VariantSlot;
4405
+ fit?: "cover" | "contain";
4753
4406
  }) {
4754
4407
  const t = useT();
4755
- const displayTitle = assetDisplayTitle(asset);
4756
- const sourceText = assetLineageSourceText(asset);
4757
- const canMoveToReferences = Boolean(onMoveToReferences);
4758
- const hasPrimaryActions = Boolean(onSave || canMoveToReferences);
4759
- const categoryLabel = assetCategoryLabel(asset);
4760
- const busy = deleting || saving || promoting;
4408
+ const [sourceIndex, setSourceIndex] = useState(0);
4409
+ const [previewUnavailable, setPreviewUnavailable] = useState(false);
4410
+ const previewSources = assetPreviewSources(slot, "thumbnail");
4411
+ const previewSourcesKey = previewSources.join("\n");
4412
+ const isFailed = slot.status === "failed";
4413
+ const previewSrc = previewSources[sourceIndex];
4414
+
4415
+ useEffect(() => {
4416
+ setSourceIndex(0);
4417
+ setPreviewUnavailable(false);
4418
+ }, [previewSourcesKey]);
4761
4419
 
4762
4420
  return (
4763
- <div
4764
- className={[
4765
- "group relative w-[144px] shrink-0 overflow-hidden rounded-md border bg-background transition sm:w-[156px]",
4766
- selected
4767
- ? "border-primary ring-2 ring-primary/25"
4768
- : "border-border/80 hover:border-foreground/20",
4769
- deleting ? "opacity-60" : "",
4770
- ].join(" ")}
4771
- aria-busy={busy}
4772
- >
4773
- <div className="absolute left-2 top-2 z-10">
4774
- <Checkbox
4775
- checked={selected}
4776
- onCheckedChange={(checked) => onToggle(checked === true)}
4777
- aria-label={t("brandKitDetail.selectItem", { title: displayTitle })}
4421
+ <div className="flex h-full w-full items-center justify-center bg-muted">
4422
+ {previewSrc && !previewUnavailable ? (
4423
+ <img
4424
+ src={previewSrc}
4425
+ alt=""
4778
4426
  className={[
4779
- "border-background bg-background/90 shadow-sm opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100",
4780
- selected ? "sm:opacity-100" : "",
4781
- ].join(" ")}
4782
- />
4783
- </div>
4784
- <div className="absolute right-2 top-2 z-10">
4785
- <DropdownMenu>
4786
- <DropdownMenuTrigger asChild>
4787
- <Button
4788
- type="button"
4789
- variant="secondary"
4790
- size="icon"
4791
- className="h-8 w-8 shadow-sm opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100 data-[state=open]:opacity-100"
4792
- aria-label={t("brandKitDetail.assetActions")}
4793
- disabled={busy}
4794
- >
4795
- <IconDotsVertical className="h-4 w-4" />
4796
- </Button>
4797
- </DropdownMenuTrigger>
4798
- <DropdownMenuContent align="end">
4799
- <DropdownMenuItem asChild>
4800
- <Link to={`/asset/${asset.id}`}>
4801
- {t("brandKitDetail.viewDetails")}
4802
- </Link>
4803
- </DropdownMenuItem>
4804
- {canMoveToReferences ? (
4805
- <DropdownMenuItem
4806
- onSelect={(event) => {
4807
- event.preventDefault();
4808
- onMoveToReferences?.();
4809
- }}
4810
- >
4811
- <IconPhotoPlus className="mr-2 h-4 w-4 shrink-0" />
4812
- {t("brandKitDetail.addToReferences")}
4813
- </DropdownMenuItem>
4814
- ) : null}
4815
- <DropdownMenuSub>
4816
- <DropdownMenuSubTrigger>
4817
- <IconFolder className="mr-2 h-4 w-4 shrink-0" />
4818
- {t("brandKitDetail.moveTo")}
4819
- </DropdownMenuSubTrigger>
4820
- <DropdownMenuSubContent>
4821
- <DropdownMenuItem
4822
- onSelect={() =>
4823
- updateAsset.mutate({
4824
- id: asset.id,
4825
- folderId: null,
4826
- })
4827
- }
4828
- >
4829
- {t("brandKitDetail.unfiled")}
4830
- </DropdownMenuItem>
4831
- {folders.map((folder) => (
4832
- <DropdownMenuItem
4833
- key={folder.id}
4834
- onSelect={() =>
4835
- updateAsset.mutate({
4836
- id: asset.id,
4837
- folderId: folder.id,
4838
- })
4839
- }
4840
- >
4841
- {folder.title}
4842
- </DropdownMenuItem>
4843
- ))}
4844
- </DropdownMenuSubContent>
4845
- </DropdownMenuSub>
4846
- <DropdownMenuSeparator />
4847
- <DropdownMenuItem
4848
- className="text-destructive focus:bg-destructive/10 focus:text-destructive"
4849
- onSelect={onDelete}
4850
- >
4851
- <IconTrash className="mr-2 h-4 w-4 shrink-0" />
4852
- {t("brandKitDetail.delete")}
4853
- </DropdownMenuItem>
4854
- </DropdownMenuContent>
4855
- </DropdownMenu>
4856
- </div>
4857
- <Link to={`/asset/${asset.id}`} className="block outline-none">
4858
- <div className="relative aspect-[4/3] bg-muted">
4859
- <AssetPreview asset={asset} />
4860
- <div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-background via-background/90 to-transparent px-2 pb-2 pt-8">
4861
- <div className="flex items-center gap-1.5 truncate text-xs font-medium">
4862
- {asset.mediaType === "video" ? (
4863
- <IconVideo className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
4864
- ) : (
4865
- <IconPhoto className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
4866
- )}
4867
- <span className="truncate">{displayTitle}</span>
4868
- </div>
4869
- <div className="mt-1 flex min-w-0 items-center gap-1 overflow-hidden text-[10px] font-medium text-muted-foreground">
4870
- {sourceText ? (
4871
- <span className="truncate">{sourceText}</span>
4872
- ) : (
4873
- <>
4874
- <span className="truncate">{asset.status}</span>
4875
- {categoryLabel ? (
4876
- <>
4877
- <span className="shrink-0 text-muted-foreground/60">
4878
- /
4879
- </span>
4880
- <span className="truncate">{categoryLabel}</span>
4881
- </>
4882
- ) : null}
4883
- </>
4884
- )}
4885
- </div>
4886
- </div>
4887
- </div>
4888
- </Link>
4889
- {hasPrimaryActions ? (
4890
- <div className="space-y-2 border-t border-border/70 p-2">
4891
- <div
4892
- className={
4893
- onSave && canMoveToReferences
4894
- ? "grid grid-cols-1 gap-2"
4895
- : "grid grid-cols-2 gap-2"
4896
- }
4897
- >
4898
- {onSave ? (
4899
- <Button
4900
- size="sm"
4901
- className="h-8 px-2 text-xs"
4902
- onClick={onSave}
4903
- disabled={busy}
4904
- >
4905
- {saving ? (
4906
- <Spinner className="h-3.5 w-3.5" />
4907
- ) : (
4908
- t("brandKitDetail.save")
4909
- )}
4910
- </Button>
4911
- ) : null}
4912
- {canMoveToReferences ? (
4913
- <Button
4914
- variant="outline"
4915
- size="sm"
4916
- className={
4917
- onSave ? "h-8 px-2 text-xs" : "col-span-2 h-8 px-2 text-xs"
4918
- }
4919
- onClick={onMoveToReferences}
4920
- disabled={busy}
4921
- title={t("brandKitDetail.addToReferences")}
4922
- >
4923
- {promoting ? (
4924
- <Spinner className="h-3.5 w-3.5" />
4925
- ) : (
4926
- t("brandKitDetail.addToReferences")
4927
- )}
4928
- </Button>
4929
- ) : null}
4930
- </div>
4427
+ "h-full w-full",
4428
+ fit === "contain" ? "object-contain" : "object-cover",
4429
+ ].join(" ")}
4430
+ onError={() => {
4431
+ const nextIndex = sourceIndex + 1;
4432
+ if (nextIndex < previewSources.length) {
4433
+ setSourceIndex(nextIndex);
4434
+ } else {
4435
+ setPreviewUnavailable(true);
4436
+ }
4437
+ }}
4438
+ />
4439
+ ) : isFailed ? (
4440
+ <div className="p-4 text-center text-xs text-destructive">
4441
+ {slot.error}
4931
4442
  </div>
4932
- ) : null}
4443
+ ) : previewUnavailable ? (
4444
+ <div className="p-4 text-center text-xs text-muted-foreground">
4445
+ {t("assetDetail.previewUnavailable")}
4446
+ </div>
4447
+ ) : (
4448
+ <div className="flex flex-col items-center gap-2 text-muted-foreground">
4449
+ <IconPhoto className="h-8 w-8 animate-pulse" />
4450
+ {fit === "contain" ? (
4451
+ <span className="text-xs font-medium">
4452
+ {t("library.rendering")}
4453
+ </span>
4454
+ ) : null}
4455
+ </div>
4456
+ )}
4933
4457
  </div>
4934
4458
  );
4935
4459
  }
4936
4460
 
4937
- function VariantLaneTile({
4938
- slot,
4461
+ function CandidateSaveMenu({
4939
4462
  libraryId,
4463
+ folders,
4464
+ allowCreateFolder = true,
4465
+ saving,
4466
+ disabled,
4940
4467
  onSave,
4941
- onMoveToReferences,
4942
- saving = false,
4943
- promoting = false,
4944
4468
  }: {
4945
- slot: any;
4946
4469
  libraryId: string;
4947
- onSave: () => void;
4948
- onMoveToReferences?: () => void;
4470
+ folders: any[];
4471
+ allowCreateFolder?: boolean;
4949
4472
  saving?: boolean;
4950
- promoting?: boolean;
4473
+ disabled?: boolean;
4474
+ onSave: (folderId: string | null) => void;
4951
4475
  }) {
4952
4476
  const t = useT();
4953
- const dismissSlot = useActionMutation("dismiss-variant-slots");
4477
+ const createFolder = useActionMutation("create-folder");
4954
4478
  const queryClient = useQueryClient();
4955
- const [confirmOpen, setConfirmOpen] = useState(false);
4956
- const [sourceIndex, setSourceIndex] = useState(0);
4957
- const [previewUnavailable, setPreviewUnavailable] = useState(false);
4958
- const previewSources = assetPreviewSources(slot, "thumbnail");
4959
- const previewSourcesKey = previewSources.join("\n");
4960
- const isFailed = slot.status === "failed";
4961
- const label = isFailed
4962
- ? t("brandKitDetail.dismiss")
4963
- : t("brandKitDetail.delete");
4964
- const busy = saving || promoting || dismissSlot.isPending;
4965
- const previewSrc = previewSources[sourceIndex];
4966
-
4967
- useEffect(() => {
4968
- setSourceIndex(0);
4969
- setPreviewUnavailable(false);
4970
- }, [previewSourcesKey]);
4479
+ const [createOpen, setCreateOpen] = useState(false);
4480
+ const pending = saving || createFolder.isPending;
4971
4481
 
4972
4482
  return (
4973
- <div
4974
- className="group relative w-[144px] shrink-0 overflow-hidden rounded-md border border-border/80 bg-background transition hover:border-foreground/20 sm:w-[156px]"
4975
- aria-busy={busy}
4976
- >
4977
- <div className="absolute right-2 top-2 z-10">
4978
- <DropdownMenu>
4979
- <DropdownMenuTrigger asChild>
4980
- <Button
4981
- type="button"
4982
- variant="secondary"
4983
- size="icon"
4984
- className="h-8 w-8 shadow-sm opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100 data-[state=open]:opacity-100"
4985
- aria-label={t("brandKitDetail.candidateActions")}
4986
- disabled={busy}
4483
+ <>
4484
+ {allowCreateFolder ? (
4485
+ <CreateFolderDialog
4486
+ open={createOpen}
4487
+ onOpenChange={setCreateOpen}
4488
+ onSubmit={async (title) => {
4489
+ const folder = (await createFolder.mutateAsync({
4490
+ libraryId,
4491
+ title,
4492
+ parentId: null,
4493
+ })) as any;
4494
+ void queryClient.invalidateQueries({
4495
+ queryKey: ["action", "get-library", { id: libraryId }],
4496
+ refetchType: "active",
4497
+ });
4498
+ void queryClient.invalidateQueries({
4499
+ queryKey: ["action", "list-libraries"],
4500
+ refetchType: "active",
4501
+ });
4502
+ setCreateOpen(false);
4503
+ if (folder?.id) onSave(folder.id);
4504
+ }}
4505
+ pending={createFolder.isPending}
4506
+ />
4507
+ ) : null}
4508
+ <DropdownMenu>
4509
+ <DropdownMenuTrigger asChild>
4510
+ <Button
4511
+ size="sm"
4512
+ className="h-8 min-w-0 px-2 text-xs"
4513
+ disabled={disabled}
4514
+ >
4515
+ {pending ? (
4516
+ <Spinner className="h-3.5 w-3.5" />
4517
+ ) : (
4518
+ t("library.saveTo")
4519
+ )}
4520
+ </Button>
4521
+ </DropdownMenuTrigger>
4522
+ <DropdownMenuContent align="start">
4523
+ <DropdownMenuItem onSelect={() => onSave(null)}>
4524
+ <IconFolder className="mr-2 h-4 w-4 shrink-0" />
4525
+ {t("library.unfiled")}
4526
+ </DropdownMenuItem>
4527
+ {folders.map((folder) => (
4528
+ <DropdownMenuItem
4529
+ key={folder.id}
4530
+ onSelect={() => onSave(folder.id)}
4987
4531
  >
4988
- <IconDotsVertical className="h-4 w-4" />
4989
- </Button>
4990
- </DropdownMenuTrigger>
4991
- <DropdownMenuContent align="end">
4992
- {onMoveToReferences && !isFailed ? (
4532
+ <IconFolder className="mr-2 h-4 w-4 shrink-0" />
4533
+ {t("library.folderLabel", { title: folder.title })}
4534
+ </DropdownMenuItem>
4535
+ ))}
4536
+ {allowCreateFolder ? (
4537
+ <>
4538
+ <DropdownMenuSeparator />
4993
4539
  <DropdownMenuItem
4994
4540
  onSelect={(event) => {
4995
4541
  event.preventDefault();
4996
- onMoveToReferences?.();
4542
+ setCreateOpen(true);
4997
4543
  }}
4998
4544
  >
4999
- <IconPhotoPlus className="mr-2 h-4 w-4 shrink-0" />
5000
- {t("brandKitDetail.addToReferences")}
4545
+ <IconFolderPlus className="mr-2 h-4 w-4 shrink-0" />
4546
+ {t("library.newFolderEllipsis")}
5001
4547
  </DropdownMenuItem>
5002
- ) : null}
5003
- <DropdownMenuItem
5004
- className="text-destructive focus:bg-destructive/10 focus:text-destructive"
5005
- onSelect={(event) => {
5006
- event.preventDefault();
5007
- setConfirmOpen(true);
5008
- }}
5009
- >
5010
- <IconTrash className="mr-2 h-4 w-4 shrink-0" />
5011
- {label}
5012
- </DropdownMenuItem>
5013
- </DropdownMenuContent>
5014
- </DropdownMenu>
5015
- </div>
5016
-
5017
- <AlertDialog
5018
- open={confirmOpen}
5019
- onOpenChange={(open) => {
5020
- if (!dismissSlot.isPending) setConfirmOpen(open);
5021
- }}
5022
- >
5023
- <AlertDialogContent>
5024
- <AlertDialogHeader>
5025
- <AlertDialogTitle>
5026
- {isFailed
5027
- ? t("brandKitDetail.dismissThisSlot")
5028
- : t("brandKitDetail.deleteCandidate")}
5029
- </AlertDialogTitle>
5030
- <AlertDialogDescription>
5031
- {isFailed
5032
- ? t("brandKitDetail.dismissSlotDescription")
5033
- : t("brandKitDetail.deleteCandidateDescription")}
5034
- </AlertDialogDescription>
5035
- </AlertDialogHeader>
5036
- <AlertDialogFooter>
5037
- <AlertDialogCancel disabled={dismissSlot.isPending}>
5038
- {t("brandKitDetail.cancel")}
5039
- </AlertDialogCancel>
5040
- <AlertDialogAction
5041
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
5042
- disabled={dismissSlot.isPending}
5043
- onClick={(event) => {
5044
- event.preventDefault();
5045
- dismissSlot.mutate(
5046
- { slotId: slot.slotId },
5047
- {
5048
- onSuccess: () => {
5049
- removeVariantSlotFromCache(queryClient, slot);
5050
- removeAssetsFromLibraryCache(queryClient, libraryId, [
5051
- slot.assetId,
5052
- ]);
5053
- setConfirmOpen(false);
5054
- void queryClient.invalidateQueries({
5055
- queryKey: ["app-state", "asset-variants"],
5056
- refetchType: "active",
5057
- });
5058
- },
5059
- onError: (error) =>
5060
- toast.error(
5061
- error.message ||
5062
- t("brandKitDetail.couldNotClearCandidate"),
5063
- ),
5064
- },
5065
- );
5066
- }}
5067
- >
5068
- {dismissSlot.isPending ? (
5069
- <>
5070
- <Spinner className="h-4 w-4" />
5071
- {isFailed
5072
- ? t("brandKitDetail.dismissing")
5073
- : t("brandKitDetail.deleting")}
5074
- </>
5075
- ) : (
5076
- label
5077
- )}
5078
- </AlertDialogAction>
5079
- </AlertDialogFooter>
5080
- </AlertDialogContent>
5081
- </AlertDialog>
5082
-
5083
- <div className="relative flex aspect-[4/3] items-center justify-center bg-muted">
5084
- {previewSrc && !previewUnavailable ? (
5085
- <img
5086
- src={previewSrc}
5087
- alt=""
5088
- className="h-full w-full object-cover"
5089
- onError={() => {
5090
- const nextIndex = sourceIndex + 1;
5091
- if (nextIndex < previewSources.length) {
5092
- setSourceIndex(nextIndex);
5093
- } else {
5094
- setPreviewUnavailable(true);
5095
- }
5096
- }}
5097
- />
5098
- ) : isFailed ? (
5099
- <div className="p-4 text-center text-xs text-destructive">
5100
- {slot.error}
5101
- </div>
5102
- ) : previewUnavailable ? (
5103
- <div className="p-4 text-center text-xs text-muted-foreground">
5104
- {t("brandKitDetail.previewUnavailable")}
5105
- </div>
5106
- ) : (
5107
- <IconPhoto className="h-8 w-8 animate-pulse text-muted-foreground" />
5108
- )}
5109
- <div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-background via-background/90 to-transparent px-2 pb-2 pt-8">
5110
- <div className="flex items-center justify-between gap-2">
5111
- <span className="truncate text-xs font-medium">
5112
- {slot.status === "ready"
5113
- ? t("brandKitDetail.readyCandidate")
5114
- : t("brandKitDetail.generating")}
5115
- </span>
5116
- <span className="shrink-0 text-[10px] text-muted-foreground">
5117
- {slot.slotId
5118
- ? shortId(String(slot.slotId))
5119
- : t("brandKitDetail.slot")}
5120
- </span>
5121
- </div>
5122
- </div>
5123
- </div>
5124
- {slot.status === "ready" ? (
5125
- <div className="border-t border-border/70 p-2">
5126
- <div
5127
- className={
5128
- onMoveToReferences
5129
- ? "grid grid-cols-1 gap-2"
5130
- : "grid grid-cols-2 gap-2"
5131
- }
5132
- >
5133
- <Button
5134
- size="sm"
5135
- className="h-8 px-2 text-xs"
5136
- onClick={onSave}
5137
- disabled={busy}
5138
- >
5139
- {saving ? (
5140
- <Spinner className="h-3.5 w-3.5" />
5141
- ) : (
5142
- t("brandKitDetail.save")
5143
- )}
5144
- </Button>
5145
- {onMoveToReferences ? (
5146
- <Button
5147
- variant="outline"
5148
- size="sm"
5149
- className="h-8 px-2 text-xs"
5150
- onClick={onMoveToReferences}
5151
- disabled={busy}
5152
- title={t("brandKitDetail.addToReferences")}
5153
- >
5154
- {promoting ? (
5155
- <Spinner className="h-3.5 w-3.5" />
5156
- ) : (
5157
- t("brandKitDetail.addToReferences")
5158
- )}
5159
- </Button>
5160
- ) : null}
5161
- </div>
5162
- </div>
5163
- ) : null}
5164
- </div>
4548
+ </>
4549
+ ) : null}
4550
+ </DropdownMenuContent>
4551
+ </DropdownMenu>
4552
+ </>
5165
4553
  );
5166
4554
  }
5167
4555
 
@@ -5188,7 +4576,7 @@ function CreateFolderDialog({
5188
4576
  toast.error(
5189
4577
  error instanceof Error
5190
4578
  ? error.message
5191
- : t("brandKitDetail.couldNotCreateFolder"),
4579
+ : t("library.couldNotCreateFolder"),
5192
4580
  );
5193
4581
  }
5194
4582
  }
@@ -5196,13 +4584,13 @@ function CreateFolderDialog({
5196
4584
  <Dialog open={open} onOpenChange={onOpenChange}>
5197
4585
  <DialogContent className="sm:max-w-md">
5198
4586
  <DialogHeader>
5199
- <DialogTitle>{t("brandKitDetail.newFolder")}</DialogTitle>
4587
+ <DialogTitle>{t("library.newFolder")}</DialogTitle>
5200
4588
  <DialogDescription>
5201
- {t("brandKitDetail.newFolderDescription")}
4589
+ {t("library.newFolderDescription")}
5202
4590
  </DialogDescription>
5203
4591
  </DialogHeader>
5204
4592
  <div className="space-y-2">
5205
- <Label htmlFor="folder-title">{t("brandKitDetail.name")}</Label>
4593
+ <Label htmlFor="folder-title">{t("library.name")}</Label>
5206
4594
  <Input
5207
4595
  id="folder-title"
5208
4596
  value={title}
@@ -5213,13 +4601,13 @@ function CreateFolderDialog({
5213
4601
  void submit();
5214
4602
  }
5215
4603
  }}
5216
- placeholder={t("brandKitDetail.campaignLaunch")}
4604
+ placeholder={t("library.folderNamePlaceholder")}
5217
4605
  autoFocus
5218
4606
  />
5219
4607
  </div>
5220
4608
  <DialogFooter>
5221
4609
  <Button variant="outline" onClick={() => onOpenChange(false)}>
5222
- {t("brandKitDetail.cancel")}
4610
+ {t("library.cancel")}
5223
4611
  </Button>
5224
4612
  <Button
5225
4613
  disabled={!title.trim() || pending}
@@ -5227,7 +4615,7 @@ function CreateFolderDialog({
5227
4615
  void submit();
5228
4616
  }}
5229
4617
  >
5230
- {t("brandKitDetail.create")}
4618
+ {t("library.create")}
5231
4619
  </Button>
5232
4620
  </DialogFooter>
5233
4621
  </DialogContent>
@@ -5237,26 +4625,72 @@ function CreateFolderDialog({
5237
4625
 
5238
4626
  function LiveCandidatesActions({
5239
4627
  slots,
4628
+ draftAssets,
5240
4629
  libraryId,
5241
4630
  }: {
5242
4631
  slots: any[];
4632
+ draftAssets: any[];
5243
4633
  libraryId: string;
5244
4634
  }) {
5245
4635
  const t = useT();
5246
4636
  const dismissSlots = useActionMutation("dismiss-variant-slots");
4637
+ const deleteAssets = useActionMutation("delete-assets");
5247
4638
  const queryClient = useQueryClient();
5248
4639
  const [pending, setPending] = useState<"failed" | "all" | null>(null);
5249
4640
  const failedCount = slots.filter((s) => s.status === "failed").length;
4641
+ const draftCount = draftAssets.length;
4642
+ const totalCount = slots.length + draftCount;
5250
4643
  const hasFailed = failedCount > 0;
5251
- const isClearing = dismissSlots.isPending;
4644
+ const isClearing = dismissSlots.isPending || deleteAssets.isPending;
5252
4645
  const actionLabel =
5253
- pending === "failed"
5254
- ? t("brandKitDetail.dismissFailed")
5255
- : t("brandKitDetail.clearAll");
4646
+ pending === "failed" ? t("library.dismissFailed") : t("library.clearAll");
5256
4647
  const busyLabel =
5257
- pending === "failed"
5258
- ? t("brandKitDetail.dismissing")
5259
- : t("brandKitDetail.clearing");
4648
+ pending === "failed" ? t("library.dismissing") : t("library.clearing");
4649
+
4650
+ async function handleClear(scope: "failed" | "all") {
4651
+ const slotAssetIds = slots
4652
+ .filter((slot) => scope === "all" || slot.status === "failed")
4653
+ .map((slot) => slot.assetId)
4654
+ .filter((assetId): assetId is string => typeof assetId === "string");
4655
+ const draftAssetIds =
4656
+ scope === "all" ? draftAssets.map((asset) => asset.id) : [];
4657
+ const removedAssetIds = [...new Set([...slotAssetIds, ...draftAssetIds])];
4658
+ try {
4659
+ if (slots.length > 0 && (scope === "all" || failedCount > 0)) {
4660
+ await dismissSlots.mutateAsync({ scope });
4661
+ removeVariantSlotsByScopeFromCache(queryClient, scope);
4662
+ }
4663
+ if (draftAssetIds.length > 0) {
4664
+ await deleteAssets.mutateAsync({ ids: draftAssetIds });
4665
+ }
4666
+ if (removedAssetIds.length > 0) {
4667
+ removeAssetsFromLibraryCache(queryClient, libraryId, removedAssetIds);
4668
+ }
4669
+ setPending(null);
4670
+ void queryClient.invalidateQueries({
4671
+ queryKey: ["app-state"],
4672
+ refetchType: "active",
4673
+ });
4674
+ void queryClient.invalidateQueries({
4675
+ queryKey: ["action", "get-library", { id: libraryId }],
4676
+ refetchType: "active",
4677
+ });
4678
+ void queryClient.invalidateQueries({
4679
+ queryKey: ["action", "get-library"],
4680
+ refetchType: "active",
4681
+ });
4682
+ void queryClient.invalidateQueries({
4683
+ queryKey: ["action", "list-assets"],
4684
+ refetchType: "active",
4685
+ });
4686
+ } catch (error) {
4687
+ toast.error(
4688
+ error instanceof Error
4689
+ ? error.message
4690
+ : t("library.couldNotClearCandidates"),
4691
+ );
4692
+ }
4693
+ }
5260
4694
 
5261
4695
  return (
5262
4696
  <>
@@ -5270,20 +4704,18 @@ function LiveCandidatesActions({
5270
4704
  <AlertDialogHeader>
5271
4705
  <AlertDialogTitle>
5272
4706
  {pending === "failed"
5273
- ? t("brandKitDetail.dismissFailedSlotsTitle", {
5274
- count: failedCount,
5275
- })
5276
- : t("brandKitDetail.clearLiveCandidates")}
4707
+ ? t("library.dismissFailedSlotsTitle", { count: failedCount })
4708
+ : t("library.clearCandidatesTitle", { count: totalCount })}
5277
4709
  </AlertDialogTitle>
5278
4710
  <AlertDialogDescription>
5279
4711
  {pending === "failed"
5280
- ? t("brandKitDetail.dismissFailedSlotsDescription")
5281
- : t("brandKitDetail.clearLiveCandidatesDescription")}
4712
+ ? t("library.dismissFailedSlotsDescription")
4713
+ : t("library.clearCandidatesDescription")}
5282
4714
  </AlertDialogDescription>
5283
4715
  </AlertDialogHeader>
5284
4716
  <AlertDialogFooter>
5285
4717
  <AlertDialogCancel disabled={isClearing}>
5286
- {t("brandKitDetail.cancel")}
4718
+ {t("library.cancel")}
5287
4719
  </AlertDialogCancel>
5288
4720
  <AlertDialogAction
5289
4721
  className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
@@ -5292,32 +4724,7 @@ function LiveCandidatesActions({
5292
4724
  event.preventDefault();
5293
4725
  const scope = pending;
5294
4726
  if (!scope) return;
5295
- const removedAssetIds = slots
5296
- .filter((slot) => scope === "all" || slot.status === "failed")
5297
- .map((slot) => slot.assetId);
5298
- dismissSlots.mutate(
5299
- { scope },
5300
- {
5301
- onSuccess: () => {
5302
- removeVariantSlotsByScopeFromCache(queryClient, scope);
5303
- removeAssetsFromLibraryCache(
5304
- queryClient,
5305
- libraryId,
5306
- removedAssetIds,
5307
- );
5308
- setPending(null);
5309
- void queryClient.invalidateQueries({
5310
- queryKey: ["app-state", "asset-variants"],
5311
- refetchType: "active",
5312
- });
5313
- },
5314
- onError: (error) =>
5315
- toast.error(
5316
- error.message ||
5317
- t("brandKitDetail.couldNotClearLiveCandidates"),
5318
- ),
5319
- },
5320
- );
4727
+ void handleClear(scope);
5321
4728
  }}
5322
4729
  >
5323
4730
  {isClearing ? (
@@ -5337,14 +4744,14 @@ function LiveCandidatesActions({
5337
4744
  <DropdownMenuTrigger asChild>
5338
4745
  <Button
5339
4746
  type="button"
5340
- variant="outline"
5341
- size="sm"
5342
- className="gap-2"
5343
- aria-label={t("brandKitDetail.liveCandidatesActions")}
4747
+ variant="ghost"
4748
+ size="icon"
4749
+ className="h-8 w-8"
4750
+ aria-label={t("library.candidateActions")}
4751
+ title={t("library.candidateActions")}
5344
4752
  disabled={isClearing}
5345
4753
  >
5346
4754
  <IconDotsVertical className="h-4 w-4" />
5347
- {t("brandKitDetail.clear")}
5348
4755
  </Button>
5349
4756
  </DropdownMenuTrigger>
5350
4757
  <DropdownMenuContent align="end">
@@ -5356,7 +4763,7 @@ function LiveCandidatesActions({
5356
4763
  }}
5357
4764
  >
5358
4765
  <IconTrash className="mr-2 h-4 w-4 shrink-0" />
5359
- {t("brandKitDetail.dismissFailed")} ({failedCount})
4766
+ {t("library.dismissFailedWithCount", { count: failedCount })}
5360
4767
  </DropdownMenuItem>
5361
4768
  <DropdownMenuItem
5362
4769
  className="text-destructive focus:bg-destructive/10 focus:text-destructive"
@@ -5367,24 +4774,10 @@ function LiveCandidatesActions({
5367
4774
  }}
5368
4775
  >
5369
4776
  <IconTrash className="mr-2 h-4 w-4 shrink-0" />
5370
- {t("brandKitDetail.clearAll")}
4777
+ {t("library.clearAll")}
5371
4778
  </DropdownMenuItem>
5372
4779
  </DropdownMenuContent>
5373
4780
  </DropdownMenu>
5374
4781
  </>
5375
4782
  );
5376
4783
  }
5377
-
5378
- function useVariantState() {
5379
- return useQuery({
5380
- queryKey: ["app-state", "asset-variants"],
5381
- queryFn: async () => {
5382
- const res = await fetch(
5383
- agentNativePath("/_agent-native/application-state/asset-variants"),
5384
- );
5385
- if (!res.ok) return null;
5386
- return res.json();
5387
- },
5388
- refetchInterval: 1000,
5389
- }) as any;
5390
- }