@agent-native/creative-context 0.3.0 → 0.5.0

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 (185) hide show
  1. package/README.md +51 -9
  2. package/agent-native.package.json +4 -0
  3. package/dist/actions/index.d.ts.map +1 -1
  4. package/dist/actions/index.js +8 -0
  5. package/dist/actions/index.js.map +1 -1
  6. package/dist/actions/list-context-memberships.d.ts +11 -0
  7. package/dist/actions/list-context-memberships.d.ts.map +1 -0
  8. package/dist/actions/list-context-memberships.js +47 -0
  9. package/dist/actions/list-context-memberships.js.map +1 -0
  10. package/dist/actions/list-creative-contexts.d.ts +12 -0
  11. package/dist/actions/list-creative-contexts.d.ts.map +1 -0
  12. package/dist/actions/list-creative-contexts.js +28 -0
  13. package/dist/actions/list-creative-contexts.js.map +1 -0
  14. package/dist/actions/manage-context-membership.d.ts +64 -0
  15. package/dist/actions/manage-context-membership.d.ts.map +1 -0
  16. package/dist/actions/manage-context-membership.js +70 -0
  17. package/dist/actions/manage-context-membership.js.map +1 -0
  18. package/dist/actions/manage-context-pack.d.ts +3 -0
  19. package/dist/actions/manage-context-pack.js +6 -0
  20. package/dist/actions/manage-context-pack.js.map +1 -1
  21. package/dist/actions/manage-context-source.js +4 -11
  22. package/dist/actions/manage-context-source.js.map +1 -1
  23. package/dist/actions/manage-creative-context.d.ts +28 -0
  24. package/dist/actions/manage-creative-context.d.ts.map +1 -0
  25. package/dist/actions/manage-creative-context.js +65 -0
  26. package/dist/actions/manage-creative-context.js.map +1 -0
  27. package/dist/actions/process-context-purge.d.ts +16 -1
  28. package/dist/actions/process-context-purge.js +2 -1
  29. package/dist/actions/process-context-purge.js.map +1 -1
  30. package/dist/actions/search-creative-context.d.ts +1 -0
  31. package/dist/actions/search-creative-context.js +2 -1
  32. package/dist/actions/search-creative-context.js.map +1 -1
  33. package/dist/client/CreativeContextChip.d.ts +5 -1
  34. package/dist/client/CreativeContextChip.d.ts.map +1 -1
  35. package/dist/client/CreativeContextChip.js +36 -7
  36. package/dist/client/CreativeContextChip.js.map +1 -1
  37. package/dist/client/CreativeContextPanel.d.ts.map +1 -1
  38. package/dist/client/CreativeContextPanel.js +406 -89
  39. package/dist/client/CreativeContextPanel.js.map +1 -1
  40. package/dist/client/CreativeContextShareTab.d.ts +56 -0
  41. package/dist/client/CreativeContextShareTab.d.ts.map +1 -0
  42. package/dist/client/CreativeContextShareTab.js +213 -0
  43. package/dist/client/CreativeContextShareTab.js.map +1 -0
  44. package/dist/client/actions.d.ts +210 -0
  45. package/dist/client/actions.d.ts.map +1 -1
  46. package/dist/client/actions.js +318 -0
  47. package/dist/client/actions.js.map +1 -1
  48. package/dist/client/application-state.d.ts +2 -0
  49. package/dist/client/application-state.d.ts.map +1 -1
  50. package/dist/client/application-state.js +25 -2
  51. package/dist/client/application-state.js.map +1 -1
  52. package/dist/client/index.d.ts +4 -3
  53. package/dist/client/index.d.ts.map +1 -1
  54. package/dist/client/index.js +4 -3
  55. package/dist/client/index.js.map +1 -1
  56. package/dist/client/messages.d.ts +1 -1
  57. package/dist/client/messages.d.ts.map +1 -1
  58. package/dist/client/messages.js +77 -0
  59. package/dist/client/messages.js.map +1 -1
  60. package/dist/connectors/types.d.ts +2 -2
  61. package/dist/connectors/types.d.ts.map +1 -1
  62. package/dist/schema/index.d.ts +1592 -0
  63. package/dist/schema/index.d.ts.map +1 -1
  64. package/dist/schema/index.js +95 -0
  65. package/dist/schema/index.js.map +1 -1
  66. package/dist/schema/migrations.d.ts.map +1 -1
  67. package/dist/schema/migrations.js +80 -0
  68. package/dist/schema/migrations.js.map +1 -1
  69. package/dist/server/generation-artifact-access.js +2 -0
  70. package/dist/server/generation-artifact-access.js.map +1 -1
  71. package/dist/server/generation-context.d.ts +12 -4
  72. package/dist/server/generation-context.d.ts.map +1 -1
  73. package/dist/server/generation-context.js +129 -7
  74. package/dist/server/generation-context.js.map +1 -1
  75. package/dist/server/index.d.ts +4 -0
  76. package/dist/server/index.d.ts.map +1 -1
  77. package/dist/server/index.js +16 -0
  78. package/dist/server/index.js.map +1 -1
  79. package/dist/server/isolated-a2a.d.ts +3 -0
  80. package/dist/server/isolated-a2a.d.ts.map +1 -1
  81. package/dist/server/isolated-a2a.js +3 -1
  82. package/dist/server/isolated-a2a.js.map +1 -1
  83. package/dist/server/media.d.ts +1 -1
  84. package/dist/server/media.d.ts.map +1 -1
  85. package/dist/server/media.js +24 -9
  86. package/dist/server/media.js.map +1 -1
  87. package/dist/server/native-resource-capture.d.ts +78 -0
  88. package/dist/server/native-resource-capture.d.ts.map +1 -0
  89. package/dist/server/native-resource-capture.js +94 -0
  90. package/dist/server/native-resource-capture.js.map +1 -0
  91. package/dist/server/public-serialization.js +1 -1
  92. package/dist/server/public-serialization.js.map +1 -1
  93. package/dist/server/retrieval.d.ts +1 -0
  94. package/dist/server/retrieval.d.ts.map +1 -1
  95. package/dist/server/retrieval.js +2 -0
  96. package/dist/server/retrieval.js.map +1 -1
  97. package/dist/server/safe-native-preview.d.ts +15 -0
  98. package/dist/server/safe-native-preview.d.ts.map +1 -0
  99. package/dist/server/safe-native-preview.js +149 -0
  100. package/dist/server/safe-native-preview.js.map +1 -0
  101. package/dist/store/brand.d.ts.map +1 -1
  102. package/dist/store/brand.js +2 -16
  103. package/dist/store/brand.js.map +1 -1
  104. package/dist/store/content.d.ts +1 -0
  105. package/dist/store/content.d.ts.map +1 -1
  106. package/dist/store/content.js +84 -20
  107. package/dist/store/content.js.map +1 -1
  108. package/dist/store/contexts.d.ts +216 -0
  109. package/dist/store/contexts.d.ts.map +1 -0
  110. package/dist/store/contexts.js +1341 -0
  111. package/dist/store/contexts.js.map +1 -0
  112. package/dist/store/index.d.ts +1 -0
  113. package/dist/store/index.d.ts.map +1 -1
  114. package/dist/store/index.js +1 -0
  115. package/dist/store/index.js.map +1 -1
  116. package/dist/store/packs.d.ts +3 -0
  117. package/dist/store/packs.d.ts.map +1 -1
  118. package/dist/store/packs.js +126 -9
  119. package/dist/store/packs.js.map +1 -1
  120. package/dist/store/purge.d.ts.map +1 -1
  121. package/dist/store/purge.js +103 -6
  122. package/dist/store/purge.js.map +1 -1
  123. package/dist/store/sources.d.ts.map +1 -1
  124. package/dist/store/sources.js +2 -15
  125. package/dist/store/sources.js.map +1 -1
  126. package/dist/store/suggestions.d.ts.map +1 -1
  127. package/dist/store/suggestions.js +3 -1
  128. package/dist/store/suggestions.js.map +1 -1
  129. package/dist/types.d.ts +77 -0
  130. package/dist/types.d.ts.map +1 -1
  131. package/dist/types.js +9 -1
  132. package/dist/types.js.map +1 -1
  133. package/docs/skills/creative-context/SKILL.md +13 -5
  134. package/package.json +3 -3
  135. package/src/actions/index.ts +8 -0
  136. package/src/actions/job-public-serialization.spec.ts +21 -0
  137. package/src/actions/list-context-memberships.ts +56 -0
  138. package/src/actions/list-creative-contexts.spec.ts +38 -0
  139. package/src/actions/list-creative-contexts.ts +32 -0
  140. package/src/actions/manage-context-membership.ts +80 -0
  141. package/src/actions/manage-context-pack.ts +6 -0
  142. package/src/actions/manage-context-source.ts +8 -11
  143. package/src/actions/manage-creative-context.ts +73 -0
  144. package/src/actions/process-context-purge.ts +4 -1
  145. package/src/actions/search-creative-context.ts +2 -1
  146. package/src/client/CreativeContextChip.spec.ts +32 -0
  147. package/src/client/CreativeContextChip.tsx +83 -14
  148. package/src/client/CreativeContextPanel.tsx +1947 -885
  149. package/src/client/CreativeContextShareTab.spec.ts +70 -0
  150. package/src/client/CreativeContextShareTab.tsx +657 -0
  151. package/src/client/actions.spec.ts +177 -0
  152. package/src/client/actions.ts +566 -0
  153. package/src/client/application-state.spec.ts +5 -0
  154. package/src/client/application-state.ts +30 -2
  155. package/src/client/index.ts +33 -0
  156. package/src/client/messages.ts +95 -0
  157. package/src/connectors/types.ts +6 -8
  158. package/src/eval/library-flow.acceptance.spec.tsx +1 -0
  159. package/src/schema/index.ts +116 -0
  160. package/src/schema/migrations.ts +80 -0
  161. package/src/server/generation-artifact-access.ts +1 -0
  162. package/src/server/generation-context-a2a.spec.ts +133 -0
  163. package/src/server/generation-context.ts +153 -7
  164. package/src/server/index.spec.ts +15 -0
  165. package/src/server/index.ts +16 -0
  166. package/src/server/isolated-a2a.ts +4 -1
  167. package/src/server/media.spec.ts +30 -2
  168. package/src/server/media.ts +31 -12
  169. package/src/server/native-resource-capture.spec.ts +121 -0
  170. package/src/server/native-resource-capture.ts +198 -0
  171. package/src/server/public-serialization.spec.ts +1 -0
  172. package/src/server/public-serialization.ts +1 -1
  173. package/src/server/retrieval.ts +3 -0
  174. package/src/server/safe-native-preview.spec.ts +29 -0
  175. package/src/server/safe-native-preview.ts +200 -0
  176. package/src/store/access.integration.spec.ts +1116 -0
  177. package/src/store/brand.ts +2 -21
  178. package/src/store/content.ts +217 -17
  179. package/src/store/contexts.ts +1826 -0
  180. package/src/store/index.ts +1 -0
  181. package/src/store/packs.ts +279 -24
  182. package/src/store/purge.ts +139 -6
  183. package/src/store/sources.ts +2 -18
  184. package/src/store/suggestions.ts +3 -1
  185. package/src/types.ts +95 -0
@@ -28,13 +28,23 @@ import {
28
28
  SelectItem,
29
29
  SelectTrigger,
30
30
  SelectValue,
31
+ Sheet,
32
+ SheetContent,
33
+ SheetDescription,
34
+ SheetHeader,
35
+ SheetTitle,
31
36
  Skeleton,
37
+ Tabs,
38
+ TabsContent,
39
+ TabsList,
40
+ TabsTrigger,
32
41
  Textarea,
33
42
  } from "@agent-native/toolkit/ui";
34
43
  import {
35
44
  IconAlertTriangle,
36
45
  IconArrowUpRight,
37
46
  IconBooks,
47
+ IconChartBar,
38
48
  IconCheck,
39
49
  IconDots,
40
50
  IconFileImport,
@@ -42,7 +52,9 @@ import {
42
52
  IconLayout,
43
53
  IconPalette,
44
54
  IconPhoto,
55
+ IconPlayerPlay,
45
56
  IconPin,
57
+ IconPlus,
46
58
  IconRefresh,
47
59
  IconSearch,
48
60
  IconSlideshow,
@@ -70,6 +82,7 @@ import type {
70
82
  } from "../types.js";
71
83
  import {
72
84
  useCreativeContextBrandProfile,
85
+ useCreativeContexts,
73
86
  useCanonicalLogoCandidates,
74
87
  useCreativeContextConnections,
75
88
  useCreativeContextGooglePickerSession,
@@ -77,6 +90,9 @@ import {
77
90
  useCreativeContextImportStatus,
78
91
  useCreativeContextPack,
79
92
  useCreativeContextPacks,
93
+ useContextMemberships,
94
+ useManageCreativeContext,
95
+ useManageContextMembership,
80
96
  useCreativeContextSearch,
81
97
  useCreativeContextSuggestions,
82
98
  useCreativeContextSources,
@@ -89,6 +105,9 @@ import {
89
105
  useRefreshCreativeContextSource,
90
106
  useReviewCreativeContextItems,
91
107
  useStartCreativeContextImport,
108
+ parseCreativeContexts,
109
+ parseContextMemberships,
110
+ type CreativeContextSafePreview,
92
111
  type CreativeContextConnectionProvider,
93
112
  type CreativeContextRootRecommendation,
94
113
  type CreativeContextRecommendationProvider,
@@ -789,6 +808,394 @@ function ItemCuration({
789
808
  );
790
809
  }
791
810
 
811
+ type LibraryView = "items" | "sources" | "approvals" | "settings";
812
+
813
+ interface SafePreviewManifest {
814
+ title: string;
815
+ kind: string;
816
+ itemId: string;
817
+ itemVersionId: string;
818
+ preview: CreativeContextSafePreview | null;
819
+ media?: {
820
+ kind: string;
821
+ mimeType: string | null;
822
+ url: string;
823
+ } | null;
824
+ gallery?: Array<{
825
+ kind: string;
826
+ mimeType: string | null;
827
+ url: string;
828
+ }>;
829
+ posterUrl?: string;
830
+ }
831
+
832
+ function StructuredPreview({
833
+ preview,
834
+ compact = false,
835
+ }: {
836
+ preview: CreativeContextSafePreview | null;
837
+ compact?: boolean;
838
+ }) {
839
+ if (!preview) {
840
+ return (
841
+ <div className="flex h-full min-h-28 items-center justify-center bg-muted text-muted-foreground">
842
+ <IconFileText className="size-5" />
843
+ </div>
844
+ );
845
+ }
846
+ if (preview.type === "slides") {
847
+ const visibleSlides = compact ? preview.slides.slice(0, 3) : preview.slides;
848
+ return (
849
+ <div className="grid h-full grid-cols-3 gap-1.5 bg-muted/50 p-2">
850
+ {visibleSlides.map((slide) => (
851
+ <div
852
+ key={slide.index}
853
+ className="min-w-0 rounded border border-border/70 bg-background p-1.5"
854
+ >
855
+ <span className="text-[10px] font-medium text-muted-foreground">
856
+ {slide.index}
857
+ </span>
858
+ <p className="mt-1 line-clamp-2 text-[11px] font-medium leading-tight">
859
+ {slide.title}
860
+ </p>
861
+ {!compact && slide.excerpt ? (
862
+ <p className="mt-1 line-clamp-5 text-[10px] leading-snug text-muted-foreground">
863
+ {slide.excerpt}
864
+ </p>
865
+ ) : null}
866
+ </div>
867
+ ))}
868
+ {!visibleSlides.length ? (
869
+ <div className="col-span-3 flex items-center justify-center text-xs text-muted-foreground">
870
+ {preview.slideCount} slides
871
+ </div>
872
+ ) : null}
873
+ </div>
874
+ );
875
+ }
876
+ if (preview.type === "slide") {
877
+ return (
878
+ <div className="flex h-full flex-col justify-between bg-muted/50 p-4">
879
+ <span className="text-xs text-muted-foreground">
880
+ Slide {preview.index}
881
+ </span>
882
+ <p className="line-clamp-3 text-sm font-semibold">{preview.title}</p>
883
+ {preview.excerpt ? (
884
+ <p className="line-clamp-5 text-xs leading-relaxed text-muted-foreground">
885
+ {preview.excerpt}
886
+ </p>
887
+ ) : null}
888
+ </div>
889
+ );
890
+ }
891
+ if (preview.type === "design" || preview.type === "design-frame") {
892
+ const frames =
893
+ preview.type === "design"
894
+ ? preview.frames
895
+ : [
896
+ {
897
+ title: preview.title,
898
+ fileType: preview.fileType,
899
+ excerpt: preview.excerpt,
900
+ },
901
+ ];
902
+ const visibleFrames = compact ? frames.slice(0, 4) : frames;
903
+ return (
904
+ <div className="grid h-full grid-cols-2 gap-1.5 bg-muted/50 p-2">
905
+ {visibleFrames.map((frame, index) => (
906
+ <div
907
+ key={`${frame.title}-${index}`}
908
+ className="min-w-0 rounded border border-border/70 bg-background p-2"
909
+ >
910
+ <div className="flex items-center gap-1 text-muted-foreground">
911
+ <IconLayout className="size-3 shrink-0" />
912
+ <span className="truncate text-[10px] uppercase tracking-wide">
913
+ {frame.fileType}
914
+ </span>
915
+ </div>
916
+ <p className="mt-2 line-clamp-2 text-xs font-medium leading-tight">
917
+ {frame.title}
918
+ </p>
919
+ {!compact && frame.excerpt ? (
920
+ <p className="mt-1 line-clamp-4 text-[10px] leading-snug text-muted-foreground">
921
+ {frame.excerpt}
922
+ </p>
923
+ ) : null}
924
+ </div>
925
+ ))}
926
+ </div>
927
+ );
928
+ }
929
+ if (preview.type === "document") {
930
+ const visibleBlocks = preview.blocks.slice(0, compact ? 7 : 40);
931
+ return (
932
+ <article className="h-full overflow-auto bg-background p-4">
933
+ {visibleBlocks.length ? (
934
+ <div className="space-y-2">
935
+ {visibleBlocks.map((block, index) => {
936
+ if (block.kind === "heading") {
937
+ return (
938
+ <p
939
+ key={`${block.kind}-${index}`}
940
+ className={
941
+ (block.level ?? 2) <= 2
942
+ ? "text-sm font-semibold"
943
+ : "text-xs font-medium"
944
+ }
945
+ >
946
+ {block.text}
947
+ </p>
948
+ );
949
+ }
950
+ if (block.kind === "bullet") {
951
+ return (
952
+ <p
953
+ key={`${block.kind}-${index}`}
954
+ className="flex gap-2 text-xs leading-relaxed text-muted-foreground before:content-['•']"
955
+ >
956
+ {block.text}
957
+ </p>
958
+ );
959
+ }
960
+ if (block.kind === "quote") {
961
+ return (
962
+ <blockquote
963
+ key={`${block.kind}-${index}`}
964
+ className="border-s-2 border-border ps-3 text-xs italic leading-relaxed text-muted-foreground"
965
+ >
966
+ {block.text}
967
+ </blockquote>
968
+ );
969
+ }
970
+ if (block.kind === "code") {
971
+ return (
972
+ <pre
973
+ key={`${block.kind}-${index}`}
974
+ className="overflow-hidden rounded bg-muted p-2 font-mono text-[10px] leading-relaxed"
975
+ >
976
+ {block.text}
977
+ </pre>
978
+ );
979
+ }
980
+ return (
981
+ <p
982
+ key={`${block.kind}-${index}`}
983
+ className="text-xs leading-relaxed text-muted-foreground"
984
+ >
985
+ {block.text}
986
+ </p>
987
+ );
988
+ })}
989
+ </div>
990
+ ) : preview.excerpt ? (
991
+ <p className="mt-3 line-clamp-6 whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
992
+ {preview.excerpt}
993
+ </p>
994
+ ) : null}
995
+ </article>
996
+ );
997
+ }
998
+ if (preview.type === "asset") {
999
+ return (
1000
+ <div className="flex h-full flex-col items-center justify-center gap-2 bg-muted text-muted-foreground">
1001
+ {preview.mediaType === "video" ? (
1002
+ <IconPlayerPlay className="size-6" />
1003
+ ) : (
1004
+ <IconPhoto className="size-6" />
1005
+ )}
1006
+ {!compact && preview.width && preview.height ? (
1007
+ <span className="text-xs">
1008
+ {preview.width} × {preview.height}
1009
+ </span>
1010
+ ) : null}
1011
+ </div>
1012
+ );
1013
+ }
1014
+ return (
1015
+ <div className="grid h-full grid-cols-2 gap-2 bg-muted/50 p-3">
1016
+ {preview.panels.slice(0, compact ? 4 : 24).map((panel) => (
1017
+ <div
1018
+ key={panel.id}
1019
+ className="rounded border border-border/70 bg-background p-2"
1020
+ >
1021
+ <div className="flex items-center gap-1 text-muted-foreground">
1022
+ <IconChartBar className="size-3" />
1023
+ <span className="truncate text-[10px] capitalize">
1024
+ {panel.visualization}
1025
+ </span>
1026
+ </div>
1027
+ <p className="mt-2 line-clamp-2 text-xs font-medium">{panel.title}</p>
1028
+ </div>
1029
+ ))}
1030
+ {!preview.panels.length ? (
1031
+ <div className="col-span-2 flex items-center justify-center text-xs text-muted-foreground">
1032
+ Synthetic dashboard preview
1033
+ </div>
1034
+ ) : null}
1035
+ </div>
1036
+ );
1037
+ }
1038
+
1039
+ function ContextPreviewVisual({
1040
+ manifest,
1041
+ compact = false,
1042
+ }: {
1043
+ manifest: SafePreviewManifest;
1044
+ compact?: boolean;
1045
+ }) {
1046
+ if (manifest.media?.mimeType?.startsWith("video/")) {
1047
+ return (
1048
+ <video
1049
+ controls={!compact}
1050
+ muted={compact}
1051
+ playsInline
1052
+ preload="metadata"
1053
+ src={manifest.media.url}
1054
+ poster={manifest.posterUrl}
1055
+ className="h-full w-full bg-black object-contain"
1056
+ />
1057
+ );
1058
+ }
1059
+ if (manifest.media) {
1060
+ return (
1061
+ <img
1062
+ src={manifest.media.url}
1063
+ alt=""
1064
+ className="h-full w-full object-contain"
1065
+ />
1066
+ );
1067
+ }
1068
+ return <StructuredPreview preview={manifest.preview} compact={compact} />;
1069
+ }
1070
+
1071
+ function ContextPreviewSheet({
1072
+ manifest,
1073
+ onOpenChange,
1074
+ }: {
1075
+ manifest: SafePreviewManifest | null;
1076
+ onOpenChange: (open: boolean) => void;
1077
+ }) {
1078
+ const [selectedMediaUrl, setSelectedMediaUrl] = useState<string | null>(null);
1079
+ useEffect(() => setSelectedMediaUrl(null), [manifest?.itemVersionId]);
1080
+ const selectedMedia =
1081
+ manifest?.gallery?.find((medium) => medium.url === selectedMediaUrl) ??
1082
+ manifest?.media ??
1083
+ null;
1084
+ return (
1085
+ <Sheet open={Boolean(manifest)} onOpenChange={onOpenChange}>
1086
+ <SheetContent side="right" className="w-full overflow-y-auto sm:max-w-xl">
1087
+ <SheetHeader>
1088
+ <SheetTitle>{manifest?.title ?? "Context preview"}</SheetTitle>
1089
+ <SheetDescription>{manifest?.kind ?? ""}</SheetDescription>
1090
+ </SheetHeader>
1091
+ {manifest && (selectedMedia || manifest.preview) ? (
1092
+ <div className="mt-5 min-h-56 overflow-hidden rounded-md border border-border">
1093
+ <ContextPreviewVisual
1094
+ manifest={{ ...manifest, media: selectedMedia }}
1095
+ />
1096
+ </div>
1097
+ ) : (
1098
+ <div className="mt-5 flex min-h-44 items-center justify-center rounded-md border border-dashed border-border text-sm text-muted-foreground">
1099
+ No safe preview is available for this item.
1100
+ </div>
1101
+ )}
1102
+ {manifest?.gallery && manifest.gallery.length > 1 ? (
1103
+ <div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4">
1104
+ {manifest.gallery.map((medium, index) => (
1105
+ <button
1106
+ key={`${medium.url}-${index}`}
1107
+ type="button"
1108
+ className={`aspect-video overflow-hidden rounded border bg-muted transition-colors ${
1109
+ medium.url === (selectedMedia?.url ?? manifest.media?.url)
1110
+ ? "border-foreground"
1111
+ : "border-border hover:border-foreground/50"
1112
+ }`}
1113
+ onClick={() => setSelectedMediaUrl(medium.url)}
1114
+ >
1115
+ <img
1116
+ src={medium.url}
1117
+ alt={`Preview ${index + 1}`}
1118
+ loading="lazy"
1119
+ className="h-full w-full object-contain"
1120
+ />
1121
+ </button>
1122
+ ))}
1123
+ </div>
1124
+ ) : null}
1125
+ </SheetContent>
1126
+ </Sheet>
1127
+ );
1128
+ }
1129
+
1130
+ function ContextRail({
1131
+ contexts,
1132
+ selectedContextId,
1133
+ disabled,
1134
+ canCreate,
1135
+ onSelect,
1136
+ onCreate,
1137
+ }: {
1138
+ contexts: Array<{
1139
+ id: string;
1140
+ name: string;
1141
+ description?: string | null;
1142
+ memberCount: number;
1143
+ }>;
1144
+ selectedContextId: string | null | undefined;
1145
+ disabled: boolean;
1146
+ canCreate: boolean;
1147
+ onSelect: (contextId: string) => void;
1148
+ onCreate: () => void;
1149
+ }) {
1150
+ return (
1151
+ <aside className="border-b border-border/70 pb-5">
1152
+ <div className="flex items-center justify-between gap-3">
1153
+ <div>
1154
+ <h2 className="text-sm font-semibold">Contexts</h2>
1155
+ <p className="mt-0.5 text-xs text-muted-foreground">
1156
+ Choose the reusable context that should guide this work.
1157
+ </p>
1158
+ </div>
1159
+ <Badge variant="outline">{contexts.length}</Badge>
1160
+ </div>
1161
+ <div className="mt-3 flex gap-2 overflow-x-auto pb-1">
1162
+ {contexts.map((context) => (
1163
+ <button
1164
+ key={context.id}
1165
+ type="button"
1166
+ disabled={disabled}
1167
+ aria-pressed={selectedContextId === context.id}
1168
+ onClick={() => onSelect(context.id)}
1169
+ className={`min-w-40 rounded-md border px-3 py-2 text-start transition-colors disabled:opacity-60 ${selectedContextId === context.id ? "border-foreground/25 bg-accent text-foreground" : "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground"}`}
1170
+ >
1171
+ <span className="block truncate text-sm font-medium">
1172
+ {context.name}
1173
+ </span>
1174
+ <span className="mt-0.5 block truncate text-xs">
1175
+ {context.description || `${context.memberCount} resources`}
1176
+ </span>
1177
+ </button>
1178
+ ))}
1179
+ <Button
1180
+ type="button"
1181
+ variant="outline"
1182
+ className="min-w-40 justify-start"
1183
+ disabled={disabled || !canCreate}
1184
+ onClick={onCreate}
1185
+ >
1186
+ <IconPlus /> New context
1187
+ </Button>
1188
+ {!contexts.length ? (
1189
+ <p className="py-2 text-sm text-muted-foreground">
1190
+ Create a context from a resource’s Share tab to start organizing the
1191
+ Library.
1192
+ </p>
1193
+ ) : null}
1194
+ </div>
1195
+ </aside>
1196
+ );
1197
+ }
1198
+
792
1199
  export function CreativeContextPanel({
793
1200
  scope = "user",
794
1201
  canManageOrg = false,
@@ -800,6 +1207,7 @@ export function CreativeContextPanel({
800
1207
  const { data: org } = useOrg();
801
1208
  const [libraryScope, setLibraryScope] = useState<AgentPageScope>(scope);
802
1209
  const sourcesQuery = useCreativeContextSources({ limit: 100 });
1210
+ const contextsQuery = useCreativeContexts();
803
1211
  const packsQuery = useCreativeContextPacks();
804
1212
  const brandProfileQuery = useCreativeContextBrandProfile();
805
1213
  const suggestionsQuery = useCreativeContextSuggestions();
@@ -819,6 +1227,9 @@ export function CreativeContextPanel({
819
1227
  const manageLayoutTemplate = useManageLayoutTemplate();
820
1228
  const contextState = useCreativeContextState();
821
1229
  const [query, setQuery] = useState("");
1230
+ const [libraryView, setLibraryView] = useState<LibraryView>("items");
1231
+ const [previewManifest, setPreviewManifest] =
1232
+ useState<SafePreviewManifest | null>(null);
822
1233
  const [savingState, setSavingState] = useState(false);
823
1234
  const [stateError, setStateError] = useState<string | null>(null);
824
1235
  const [refreshMessage, setRefreshMessage] = useState<string | null>(null);
@@ -887,6 +1298,13 @@ export function CreativeContextPanel({
887
1298
  );
888
1299
  const [reviewedItems, setReviewedItems] = useState<ContextReviewItem[]>([]);
889
1300
  const [reviewError, setReviewError] = useState<string | null>(null);
1301
+ const [membershipUpdateCandidate, setMembershipUpdateCandidate] = useState<{
1302
+ id: string;
1303
+ title: string;
1304
+ } | null>(null);
1305
+ const [updatingMembershipId, setUpdatingMembershipId] = useState<
1306
+ string | null
1307
+ >(null);
890
1308
  const [selectedPackId, setSelectedPackId] = useState<string | null>(null);
891
1309
  const [suggestionError, setSuggestionError] = useState<string | null>(null);
892
1310
  const previewQuery = usePreviewCreativeContextImport(previewSourceId);
@@ -945,6 +1363,42 @@ export function CreativeContextPanel({
945
1363
  ),
946
1364
  [libraryScope, packsQuery.data?.packs],
947
1365
  );
1366
+ const contexts = useMemo(
1367
+ () => parseCreativeContexts(contextsQuery.data),
1368
+ [contextsQuery.data],
1369
+ );
1370
+ const selectedLibraryContextId =
1371
+ contextState.state.selectedContextId ?? contexts[0]?.id ?? null;
1372
+ const contextMembershipsQuery = useContextMemberships(
1373
+ selectedLibraryContextId ? { contextId: selectedLibraryContextId } : null,
1374
+ );
1375
+ const manageContext = useManageCreativeContext();
1376
+ const manageContextMembership = useManageContextMembership();
1377
+ const contextMemberships = parseContextMemberships(
1378
+ contextMembershipsQuery.data,
1379
+ );
1380
+ const publishedContextMemberships = contextMemberships.filter(
1381
+ (membership) => membership.publishedItem,
1382
+ );
1383
+ const pendingContextMemberships = contextMemberships.filter(
1384
+ (membership) => membership.pendingSubmission,
1385
+ );
1386
+ const selectedLibraryContext = contexts.find(
1387
+ (context) => context.id === selectedLibraryContextId,
1388
+ );
1389
+ const [contextSettingsName, setContextSettingsName] = useState("");
1390
+ const [contextSettingsDescription, setContextSettingsDescription] =
1391
+ useState("");
1392
+ const [contextSettingsPolicy, setContextSettingsPolicy] = useState<
1393
+ "open" | "review" | "admins-only"
1394
+ >("open");
1395
+ const [newContextName, setNewContextName] = useState("");
1396
+ const [newContextPolicy, setNewContextPolicy] = useState<
1397
+ "open" | "review" | "admins-only"
1398
+ >("open");
1399
+ const [contextSettingsError, setContextSettingsError] = useState<
1400
+ string | null
1401
+ >(null);
948
1402
  const activePack = packs.find(
949
1403
  (pack) => pack.id === contextState.state.currentPackId,
950
1404
  );
@@ -960,6 +1414,13 @@ export function CreativeContextPanel({
960
1414
  const brandVoicePreview =
961
1415
  brandProposal?.voiceDescriptors?.join(" · ") ?? brandProposal?.voiceLine;
962
1416
  const canManageScope = libraryScope === "user" || canManageOrg;
1417
+ const canCreateContext =
1418
+ canManageScope && contexts.some((context) => context.access.canAdmin);
1419
+ const activeAppId = contextsQuery.data?.appId;
1420
+ const appDefaultContextId = contextsQuery.data?.appDefaultContextId ?? null;
1421
+ const canSetAppDefault = Boolean(
1422
+ activeAppId && selectedLibraryContext?.access.canAdmin && canManageScope,
1423
+ );
963
1424
  const proposalCapabilities = suggestionsQuery.data?.capabilities;
964
1425
  const logoCandidates = proposalCapabilities?.canonicalLogo
965
1426
  ? (logoCandidatesQuery.data?.candidates ?? [])
@@ -970,6 +1431,13 @@ export function CreativeContextPanel({
970
1431
  (suggestion.status === "proposed" || suggestion.status === "promoted"),
971
1432
  );
972
1433
 
1434
+ useEffect(() => {
1435
+ setContextSettingsName(selectedLibraryContext?.name ?? "");
1436
+ setContextSettingsDescription(selectedLibraryContext?.description ?? "");
1437
+ setContextSettingsPolicy(selectedLibraryContext?.approvalPolicy ?? "open");
1438
+ setContextSettingsError(null);
1439
+ }, [selectedLibraryContext]);
1440
+
973
1441
  useEffect(() => {
974
1442
  if (!previewSourceId || !previewQuery.data) return;
975
1443
  const defaults = previewQuery.data.smartDefaultExternalIds ?? [];
@@ -1020,8 +1488,18 @@ export function CreativeContextPanel({
1020
1488
  try {
1021
1489
  await contextState.setState(
1022
1490
  mode === "off"
1023
- ? { contextMode: "off", currentPackId: null, pinnedPackId: null }
1024
- : { ...contextState.state, contextMode: "auto" },
1491
+ ? {
1492
+ contextMode: "off",
1493
+ selectedContextId: null,
1494
+ currentPackId: null,
1495
+ pinnedPackId: null,
1496
+ }
1497
+ : {
1498
+ ...contextState.state,
1499
+ contextMode: "auto",
1500
+ selectedContextId: null,
1501
+ pinnedPackId: null,
1502
+ },
1025
1503
  );
1026
1504
  } catch {
1027
1505
  setStateError(t("creativeContext.stateSaveFailed"));
@@ -1037,6 +1515,7 @@ export function CreativeContextPanel({
1037
1515
  await contextState.setState({
1038
1516
  ...contextState.state,
1039
1517
  contextMode: "auto",
1518
+ selectedContextId: null,
1040
1519
  pinnedPackId: packId,
1041
1520
  });
1042
1521
  } catch {
@@ -1046,6 +1525,60 @@ export function CreativeContextPanel({
1046
1525
  }
1047
1526
  }
1048
1527
 
1528
+ async function selectContext(contextId: string) {
1529
+ setSavingState(true);
1530
+ setStateError(null);
1531
+ try {
1532
+ await contextState.setState({
1533
+ ...contextState.state,
1534
+ contextMode: "auto",
1535
+ selectedContextId: contextId,
1536
+ pinnedPackId: null,
1537
+ });
1538
+ } catch {
1539
+ setStateError(t("creativeContext.stateSaveFailed"));
1540
+ } finally {
1541
+ setSavingState(false);
1542
+ }
1543
+ }
1544
+
1545
+ async function reviewContextMembership(
1546
+ membershipId: string,
1547
+ operation: "approve" | "request-changes",
1548
+ ) {
1549
+ if (!selectedLibraryContextId) return;
1550
+ try {
1551
+ await manageContextMembership.mutateAsync({
1552
+ operation,
1553
+ contextId: selectedLibraryContextId,
1554
+ membershipId,
1555
+ });
1556
+ await contextMembershipsQuery.refetch();
1557
+ } catch {
1558
+ setReviewError(t("creativeContext.saveFailed"));
1559
+ }
1560
+ }
1561
+
1562
+ async function submitLatestContextMembershipUpdate() {
1563
+ if (!selectedLibraryContextId || !membershipUpdateCandidate) return;
1564
+ setReviewError(null);
1565
+ setUpdatingMembershipId(membershipUpdateCandidate.id);
1566
+ try {
1567
+ await manageContextMembership.mutateAsync({
1568
+ operation: "submit-latest",
1569
+ contextId: selectedLibraryContextId,
1570
+ membershipId: membershipUpdateCandidate.id,
1571
+ confirmBroaderPublication: true,
1572
+ });
1573
+ setMembershipUpdateCandidate(null);
1574
+ await contextMembershipsQuery.refetch();
1575
+ } catch {
1576
+ setReviewError(t("creativeContext.submitUpdateFailed"));
1577
+ } finally {
1578
+ setUpdatingMembershipId(null);
1579
+ }
1580
+ }
1581
+
1049
1582
  function refresh(sourceId: string) {
1050
1583
  setRefreshMessage(null);
1051
1584
  refreshSource.mutate(
@@ -1412,16 +1945,84 @@ export function CreativeContextPanel({
1412
1945
  }
1413
1946
  }
1414
1947
 
1948
+ async function saveContextSettings() {
1949
+ if (!selectedLibraryContext?.access.canAdmin || !contextSettingsName.trim())
1950
+ return;
1951
+ setContextSettingsError(null);
1952
+ try {
1953
+ await manageContext.mutateAsync({
1954
+ operation: "update",
1955
+ contextId: selectedLibraryContext.id,
1956
+ patch: {
1957
+ name: contextSettingsName.trim(),
1958
+ description: contextSettingsDescription.trim() || null,
1959
+ approvalPolicy: contextSettingsPolicy,
1960
+ },
1961
+ });
1962
+ await contextsQuery.refetch();
1963
+ } catch {
1964
+ setContextSettingsError("Could not update this context.");
1965
+ }
1966
+ }
1967
+
1968
+ async function createSpecialtyContext() {
1969
+ if (!canCreateContext || !newContextName.trim()) return;
1970
+ setContextSettingsError(null);
1971
+ try {
1972
+ const result = await manageContext.mutateAsync({
1973
+ operation: "create",
1974
+ name: newContextName.trim(),
1975
+ kind: "specialty",
1976
+ approvalPolicy: newContextPolicy,
1977
+ });
1978
+ setNewContextName("");
1979
+ setNewContextPolicy("open");
1980
+ await contextsQuery.refetch();
1981
+ if (result.context?.id) await selectContext(result.context.id);
1982
+ } catch {
1983
+ setContextSettingsError("Could not create this context.");
1984
+ }
1985
+ }
1986
+
1987
+ async function setAppDefaultContext() {
1988
+ if (!activeAppId || !selectedLibraryContext || !canSetAppDefault) return;
1989
+ setContextSettingsError(null);
1990
+ try {
1991
+ await manageContext.mutateAsync({
1992
+ operation: "set-app-default",
1993
+ contextId: selectedLibraryContext.id,
1994
+ appId: activeAppId,
1995
+ });
1996
+ await contextsQuery.refetch();
1997
+ } catch {
1998
+ setContextSettingsError(
1999
+ "Could not update the automatic context for this app.",
2000
+ );
2001
+ }
2002
+ }
2003
+
1415
2004
  async function search(event: FormEvent<HTMLFormElement>) {
1416
2005
  event.preventDefault();
1417
2006
  const searchText = query.trim();
1418
- if (!searchText || !sources.length) return;
2007
+ if (
2008
+ !searchText ||
2009
+ (!sources.length &&
2010
+ !selectedLibraryContextId &&
2011
+ !contextState.state.pinnedPackId)
2012
+ )
2013
+ return;
1419
2014
  setSearchError(null);
1420
2015
  try {
1421
2016
  const result = await searchContext.mutateAsync({
1422
2017
  query: searchText,
1423
- sourceIds: sources.map((source) => source.id),
2018
+ sourceIds:
2019
+ selectedLibraryContextId || contextState.state.pinnedPackId
2020
+ ? undefined
2021
+ : sources.map((source) => source.id),
1424
2022
  packId: contextState.state.pinnedPackId ?? undefined,
2023
+ contextId: contextState.state.pinnedPackId
2024
+ ? undefined
2025
+ : (selectedLibraryContextId ?? undefined),
1425
2026
  limit: 20,
1426
2027
  snapshot: true,
1427
2028
  });
@@ -1437,8 +2038,12 @@ export function CreativeContextPanel({
1437
2038
  }
1438
2039
 
1439
2040
  const loading =
1440
- sourcesQuery.isLoading || packsQuery.isLoading || contextState.isLoading;
1441
- const unavailable = sourcesQuery.error || packsQuery.error;
2041
+ sourcesQuery.isLoading ||
2042
+ packsQuery.isLoading ||
2043
+ contextsQuery.isLoading ||
2044
+ contextState.isLoading;
2045
+ const unavailable =
2046
+ sourcesQuery.error || packsQuery.error || contextsQuery.error;
1442
2047
 
1443
2048
  return (
1444
2049
  <div className="mx-auto flex w-full max-w-5xl flex-col gap-7 p-6 lg:p-10">
@@ -1456,7 +2061,11 @@ export function CreativeContextPanel({
1456
2061
  (org?.orgId ? (
1457
2062
  <ScopeControl scope={libraryScope} onChange={setLibraryScope} />
1458
2063
  ) : null)}
1459
- <CreativeContextChip state={contextState.state} packs={packs} />
2064
+ <CreativeContextChip
2065
+ state={contextState.state}
2066
+ packs={packs}
2067
+ contexts={contexts}
2068
+ />
1460
2069
  </div>
1461
2070
  </header>
1462
2071
 
@@ -1473,92 +2082,469 @@ export function CreativeContextPanel({
1473
2082
  </div>
1474
2083
  ) : (
1475
2084
  <>
1476
- <section className="space-y-3">
1477
- <div>
1478
- <h2 className="text-sm font-semibold">
1479
- {t("creativeContext.modeLabel")}
1480
- </h2>
1481
- <p className="mt-1 text-xs text-muted-foreground">
1482
- {activePack
1483
- ? `${t("creativeContext.activePack")}: ${activePack.name}`
1484
- : t("creativeContext.noActivePack")}
1485
- </p>
1486
- </div>
1487
- <div className="flex flex-col gap-2 sm:flex-row" role="radiogroup">
1488
- <ContextModeButton
1489
- mode="auto"
1490
- activeMode={contextState.state.contextMode}
1491
- label={t("creativeContext.automatic")}
1492
- description={t("creativeContext.automaticDescription")}
1493
- disabled={savingState}
1494
- onSelect={(mode) => void changeMode(mode)}
1495
- />
1496
- <ContextModeButton
1497
- mode="off"
1498
- activeMode={contextState.state.contextMode}
1499
- label={t("creativeContext.off")}
1500
- description={t("creativeContext.offDescription")}
1501
- disabled={savingState}
1502
- onSelect={(mode) => void changeMode(mode)}
1503
- />
1504
- </div>
1505
- {stateError ? (
1506
- <p className="text-xs text-destructive">{stateError}</p>
1507
- ) : null}
1508
- </section>
1509
-
1510
- {brandProfileQuery.data?.profile && brandProfileQuery.data.dna ? (
1511
- <section className="border-t border-border/70 pt-6">
1512
- <div className="rounded-md border border-border p-4">
1513
- <div className="flex flex-wrap items-center justify-between gap-2">
1514
- <div>
1515
- <h2 className="text-sm font-semibold">
1516
- {t("creativeContext.publishedBrandContext")}
1517
- </h2>
1518
- <p className="mt-0.5 text-xs text-muted-foreground">
1519
- {brandProfileQuery.data.profile.name}
1520
- </p>
1521
- </div>
1522
- <Badge variant="secondary">
1523
- v{brandProfileQuery.data.dna.versionNumber}
1524
- </Badge>
1525
- </div>
1526
- <p className="mt-3 text-sm text-muted-foreground">
1527
- {brandProfileQuery.data.dna.payload.summary}
1528
- </p>
1529
- </div>
1530
- </section>
1531
- ) : null}
1532
-
1533
- {logoCandidates.length || proposedLayouts.length ? (
1534
- <section className="border-t border-border/70 pt-6">
1535
- <div className="flex items-center gap-2">
1536
- <IconSparkles className="size-5 text-muted-foreground" />
1537
- <h2 className="text-lg font-semibold">
1538
- {t("creativeContext.suggestions")}
1539
- </h2>
1540
- </div>
1541
- {logoCandidates.length ? (
1542
- <div className="mt-4">
1543
- <h3 className="flex items-center gap-2 text-sm font-semibold">
1544
- <IconPhoto className="size-4 text-muted-foreground" />
1545
- {t("creativeContext.logo")}
1546
- </h3>
1547
- <div className="mt-2 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
1548
- {logoCandidates.map((candidate) => (
1549
- <article
1550
- key={candidate.mediaId}
1551
- className="overflow-hidden rounded-md border border-border"
2085
+ <ContextRail
2086
+ contexts={contexts}
2087
+ selectedContextId={contextState.state.selectedContextId}
2088
+ disabled={savingState}
2089
+ canCreate={canCreateContext}
2090
+ onSelect={(contextId) => void selectContext(contextId)}
2091
+ onCreate={() => setLibraryView("settings")}
2092
+ />
2093
+ <Tabs
2094
+ value={libraryView}
2095
+ onValueChange={(value) => setLibraryView(value as LibraryView)}
2096
+ >
2097
+ <TabsList className="w-full justify-start overflow-x-auto">
2098
+ <TabsTrigger value="items">Items</TabsTrigger>
2099
+ <TabsTrigger value="sources">Sources</TabsTrigger>
2100
+ <TabsTrigger value="approvals">Approvals</TabsTrigger>
2101
+ <TabsTrigger value="settings">Settings</TabsTrigger>
2102
+ </TabsList>
2103
+ <TabsContent value="items">
2104
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
2105
+ {publishedContextMemberships.map((membership) => {
2106
+ const item = membership.publishedItem!;
2107
+ const imageMedium = item.media.find((medium) =>
2108
+ medium.mimeType?.startsWith("image/"),
2109
+ );
2110
+ const playbackMedium = item.media.find((medium) =>
2111
+ medium.mimeType?.startsWith("video/"),
2112
+ );
2113
+ const medium = imageMedium ?? playbackMedium ?? item.media[0];
2114
+ const sheetMedium = playbackMedium ?? medium;
2115
+ const updateAvailable =
2116
+ membership.nativeUpdateStatus?.state === "update-available";
2117
+ return (
2118
+ <article
2119
+ key={membership.id}
2120
+ className="overflow-hidden rounded-md border border-border"
2121
+ >
2122
+ <button
2123
+ type="button"
2124
+ onClick={() =>
2125
+ setPreviewManifest({
2126
+ title: item.title,
2127
+ kind: item.kind,
2128
+ itemId: item.id,
2129
+ itemVersionId: item.itemVersionId,
2130
+ preview: item.preview,
2131
+ media: sheetMedium ?? null,
2132
+ gallery: item.media.filter((candidate) =>
2133
+ candidate.mimeType?.startsWith("image/"),
2134
+ ),
2135
+ posterUrl:
2136
+ playbackMedium && imageMedium
2137
+ ? imageMedium.url
2138
+ : undefined,
2139
+ })
2140
+ }
2141
+ className="block w-full text-start transition-colors hover:bg-accent/40"
1552
2142
  >
1553
- <div className="flex h-28 items-center justify-center bg-muted/40 p-3">
1554
- <img
1555
- src={candidate.thumbnailUrl}
1556
- alt={candidate.title}
1557
- className="max-h-full max-w-full object-contain"
2143
+ <span className="block aspect-video overflow-hidden">
2144
+ <ContextPreviewVisual
2145
+ compact
2146
+ manifest={{
2147
+ title: item.title,
2148
+ kind: item.kind,
2149
+ itemId: item.id,
2150
+ itemVersionId: item.itemVersionId,
2151
+ preview: item.preview,
2152
+ media: medium ?? null,
2153
+ gallery: item.media,
2154
+ }}
1558
2155
  />
1559
- </div>
1560
- <div className="flex items-center justify-between gap-2 p-3">
1561
- <p className="min-w-0 truncate text-sm font-medium">
2156
+ </span>
2157
+ <span className="block p-3">
2158
+ <span className="block truncate text-sm font-medium">
2159
+ {item.title}
2160
+ </span>
2161
+ <span className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
2162
+ <span>{item.kind}</span>
2163
+ <span>·</span>
2164
+ <span className="capitalize">
2165
+ {membership.rank}
2166
+ </span>
2167
+ <Badge variant="secondary">Published</Badge>
2168
+ {updateAvailable ? (
2169
+ <Badge variant="outline">
2170
+ {t("creativeContext.updateAvailable")}
2171
+ </Badge>
2172
+ ) : null}
2173
+ </span>
2174
+ <span className="mt-1 block truncate font-mono text-[10px] text-muted-foreground">
2175
+ Version {item.itemVersionId.slice(0, 12)}
2176
+ </span>
2177
+ </span>
2178
+ </button>
2179
+ {updateAvailable &&
2180
+ selectedLibraryContext?.access.canSubmit ? (
2181
+ <div className="border-t border-border/70 p-2">
2182
+ <Button
2183
+ type="button"
2184
+ variant="outline"
2185
+ size="sm"
2186
+ className="w-full"
2187
+ disabled={updatingMembershipId === membership.id}
2188
+ onClick={() =>
2189
+ setMembershipUpdateCandidate({
2190
+ id: membership.id,
2191
+ title: item.title,
2192
+ })
2193
+ }
2194
+ >
2195
+ <IconRefresh />
2196
+ {updatingMembershipId === membership.id
2197
+ ? t("creativeContext.submittingUpdate")
2198
+ : t("creativeContext.submitUpdate")}
2199
+ </Button>
2200
+ </div>
2201
+ ) : null}
2202
+ </article>
2203
+ );
2204
+ })}
2205
+ </div>
2206
+ {!publishedContextMemberships.length ? (
2207
+ <p className="py-4 text-sm text-muted-foreground">
2208
+ Approved context items appear here after publication.
2209
+ </p>
2210
+ ) : null}
2211
+ {reviewError ? (
2212
+ <p className="mt-3 text-sm text-destructive">{reviewError}</p>
2213
+ ) : null}
2214
+ </TabsContent>
2215
+ <TabsContent value="approvals">
2216
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
2217
+ {pendingContextMemberships.map((membership) => {
2218
+ const submission = membership.pendingSubmission!;
2219
+ const item = submission.proposedItem;
2220
+ const medium = item?.media[0];
2221
+ return (
2222
+ <article
2223
+ key={membership.id}
2224
+ className="overflow-hidden rounded-md border border-border"
2225
+ >
2226
+ {item ? (
2227
+ <button
2228
+ type="button"
2229
+ className="block aspect-video w-full overflow-hidden text-start"
2230
+ onClick={() =>
2231
+ setPreviewManifest({
2232
+ title: item.title,
2233
+ kind: item.kind,
2234
+ itemId: item.id,
2235
+ itemVersionId: item.itemVersionId,
2236
+ preview: item.preview,
2237
+ media: medium ?? null,
2238
+ gallery: item.media.filter((candidate) =>
2239
+ candidate.mimeType?.startsWith("image/"),
2240
+ ),
2241
+ })
2242
+ }
2243
+ >
2244
+ <ContextPreviewVisual
2245
+ compact
2246
+ manifest={{
2247
+ title: item.title,
2248
+ kind: item.kind,
2249
+ itemId: item.id,
2250
+ itemVersionId: item.itemVersionId,
2251
+ preview: item.preview,
2252
+ media: medium ?? null,
2253
+ }}
2254
+ />
2255
+ </button>
2256
+ ) : null}
2257
+ <div className="p-3">
2258
+ <p className="truncate text-sm font-medium">
2259
+ {item?.title ?? "Pending context submission"}
2260
+ </p>
2261
+ <p className="mt-0.5 text-xs text-muted-foreground">
2262
+ Submitted by {submission.submittedBy}
2263
+ </p>
2264
+ {submission.note ? (
2265
+ <p className="mt-2 line-clamp-3 text-xs text-muted-foreground">
2266
+ {submission.note}
2267
+ </p>
2268
+ ) : null}
2269
+ {selectedLibraryContext?.access.canReview ? (
2270
+ <div className="mt-3 flex gap-2">
2271
+ <Button
2272
+ size="sm"
2273
+ onClick={() =>
2274
+ void reviewContextMembership(
2275
+ membership.id,
2276
+ "approve",
2277
+ )
2278
+ }
2279
+ >
2280
+ Approve
2281
+ </Button>
2282
+ <Button
2283
+ size="sm"
2284
+ variant="outline"
2285
+ onClick={() =>
2286
+ void reviewContextMembership(
2287
+ membership.id,
2288
+ "request-changes",
2289
+ )
2290
+ }
2291
+ >
2292
+ Request changes
2293
+ </Button>
2294
+ </div>
2295
+ ) : (
2296
+ <Badge variant="outline" className="mt-3">
2297
+ Awaiting review
2298
+ </Badge>
2299
+ )}
2300
+ </div>
2301
+ </article>
2302
+ );
2303
+ })}
2304
+ </div>
2305
+ {!pendingContextMemberships.length ? (
2306
+ <p className="py-4 text-sm text-muted-foreground">
2307
+ No context submissions need review.
2308
+ </p>
2309
+ ) : null}
2310
+ </TabsContent>
2311
+ <TabsContent value="sources">
2312
+ <p className="text-sm text-muted-foreground">
2313
+ Sources and their review queues are managed below.
2314
+ </p>
2315
+ </TabsContent>
2316
+ <TabsContent value="settings">
2317
+ <div className="grid gap-5 lg:grid-cols-2">
2318
+ <section className="space-y-3 rounded-md border border-border p-4">
2319
+ <div>
2320
+ <h3 className="text-sm font-semibold">Context settings</h3>
2321
+ <p className="mt-1 text-xs text-muted-foreground">
2322
+ Automatic selection uses Default plus at most one matching
2323
+ specialty. Exact packs remain available under advanced
2324
+ provenance.
2325
+ </p>
2326
+ </div>
2327
+ <Input
2328
+ value={contextSettingsName}
2329
+ disabled={!selectedLibraryContext?.access.canAdmin}
2330
+ onChange={(event) =>
2331
+ setContextSettingsName(event.target.value)
2332
+ }
2333
+ placeholder="Context name"
2334
+ />
2335
+ <Textarea
2336
+ value={contextSettingsDescription}
2337
+ disabled={!selectedLibraryContext?.access.canAdmin}
2338
+ onChange={(event) =>
2339
+ setContextSettingsDescription(event.target.value)
2340
+ }
2341
+ placeholder="When should agents use this context?"
2342
+ rows={3}
2343
+ />
2344
+ <Select
2345
+ value={contextSettingsPolicy}
2346
+ disabled={!selectedLibraryContext?.access.canAdmin}
2347
+ onValueChange={(value) =>
2348
+ setContextSettingsPolicy(
2349
+ value as "open" | "review" | "admins-only",
2350
+ )
2351
+ }
2352
+ >
2353
+ <SelectTrigger>
2354
+ <SelectValue />
2355
+ </SelectTrigger>
2356
+ <SelectContent>
2357
+ <SelectItem value="open">Open publishing</SelectItem>
2358
+ <SelectItem value="review">Require review</SelectItem>
2359
+ <SelectItem value="admins-only">Admins only</SelectItem>
2360
+ </SelectContent>
2361
+ </Select>
2362
+ <Button
2363
+ type="button"
2364
+ size="sm"
2365
+ disabled={
2366
+ !selectedLibraryContext?.access.canAdmin ||
2367
+ !contextSettingsName.trim() ||
2368
+ manageContext.isPending
2369
+ }
2370
+ onClick={() => void saveContextSettings()}
2371
+ >
2372
+ Save settings
2373
+ </Button>
2374
+ {activeAppId ? (
2375
+ <div className="border-t border-border/70 pt-3">
2376
+ <p className="text-xs text-muted-foreground">
2377
+ Automatic generations use Default plus this context for
2378
+ {` ${activeAppId}`} when no context is chosen
2379
+ explicitly.
2380
+ </p>
2381
+ <Button
2382
+ type="button"
2383
+ size="sm"
2384
+ variant="outline"
2385
+ className="mt-3"
2386
+ disabled={
2387
+ !canSetAppDefault ||
2388
+ appDefaultContextId === selectedLibraryContext?.id ||
2389
+ manageContext.isPending
2390
+ }
2391
+ onClick={() => void setAppDefaultContext()}
2392
+ >
2393
+ {appDefaultContextId === selectedLibraryContext?.id
2394
+ ? `Automatic for ${activeAppId}`
2395
+ : `Use automatically for ${activeAppId}`}
2396
+ </Button>
2397
+ </div>
2398
+ ) : null}
2399
+ </section>
2400
+ <section className="space-y-3 rounded-md border border-dashed border-border p-4">
2401
+ <div>
2402
+ <h3 className="text-sm font-semibold">New specialty</h3>
2403
+ <p className="mt-1 text-xs text-muted-foreground">
2404
+ Create a focused style such as Marketing, Product, or
2405
+ Sales. Contexts start open unless you choose review.
2406
+ </p>
2407
+ </div>
2408
+ <Input
2409
+ value={newContextName}
2410
+ disabled={!canCreateContext}
2411
+ onChange={(event) => setNewContextName(event.target.value)}
2412
+ placeholder="Marketing"
2413
+ />
2414
+ <Select
2415
+ value={newContextPolicy}
2416
+ disabled={!canCreateContext}
2417
+ onValueChange={(value) =>
2418
+ setNewContextPolicy(
2419
+ value as "open" | "review" | "admins-only",
2420
+ )
2421
+ }
2422
+ >
2423
+ <SelectTrigger>
2424
+ <SelectValue />
2425
+ </SelectTrigger>
2426
+ <SelectContent>
2427
+ <SelectItem value="open">Open publishing</SelectItem>
2428
+ <SelectItem value="review">Require review</SelectItem>
2429
+ <SelectItem value="admins-only">Admins only</SelectItem>
2430
+ </SelectContent>
2431
+ </Select>
2432
+ <Button
2433
+ type="button"
2434
+ size="sm"
2435
+ variant="outline"
2436
+ disabled={
2437
+ !canCreateContext ||
2438
+ !newContextName.trim() ||
2439
+ manageContext.isPending
2440
+ }
2441
+ onClick={() => void createSpecialtyContext()}
2442
+ >
2443
+ <IconPlus /> Create context
2444
+ </Button>
2445
+ </section>
2446
+ </div>
2447
+ {contextSettingsError ? (
2448
+ <p className="mt-3 text-xs text-destructive">
2449
+ {contextSettingsError}
2450
+ </p>
2451
+ ) : null}
2452
+ </TabsContent>
2453
+ </Tabs>
2454
+ {libraryView === "settings" ? (
2455
+ <section className="space-y-3">
2456
+ <div>
2457
+ <h2 className="text-sm font-semibold">
2458
+ {t("creativeContext.modeLabel")}
2459
+ </h2>
2460
+ <p className="mt-1 text-xs text-muted-foreground">
2461
+ {activePack
2462
+ ? `${t("creativeContext.activePack")}: ${activePack.name}`
2463
+ : t("creativeContext.noActivePack")}
2464
+ </p>
2465
+ </div>
2466
+ <div
2467
+ className="flex flex-col gap-2 sm:flex-row"
2468
+ role="radiogroup"
2469
+ >
2470
+ <ContextModeButton
2471
+ mode="auto"
2472
+ activeMode={contextState.state.contextMode}
2473
+ label={t("creativeContext.automatic")}
2474
+ description={t("creativeContext.automaticDescription")}
2475
+ disabled={savingState}
2476
+ onSelect={(mode) => void changeMode(mode)}
2477
+ />
2478
+ <ContextModeButton
2479
+ mode="off"
2480
+ activeMode={contextState.state.contextMode}
2481
+ label={t("creativeContext.off")}
2482
+ description={t("creativeContext.offDescription")}
2483
+ disabled={savingState}
2484
+ onSelect={(mode) => void changeMode(mode)}
2485
+ />
2486
+ </div>
2487
+ {stateError ? (
2488
+ <p className="text-xs text-destructive">{stateError}</p>
2489
+ ) : null}
2490
+ </section>
2491
+ ) : null}
2492
+
2493
+ {libraryView === "settings" &&
2494
+ brandProfileQuery.data?.profile &&
2495
+ brandProfileQuery.data.dna ? (
2496
+ <section className="border-t border-border/70 pt-6">
2497
+ <div className="rounded-md border border-border p-4">
2498
+ <div className="flex flex-wrap items-center justify-between gap-2">
2499
+ <div>
2500
+ <h2 className="text-sm font-semibold">
2501
+ {t("creativeContext.publishedBrandContext")}
2502
+ </h2>
2503
+ <p className="mt-0.5 text-xs text-muted-foreground">
2504
+ {brandProfileQuery.data.profile.name}
2505
+ </p>
2506
+ </div>
2507
+ <Badge variant="secondary">
2508
+ v{brandProfileQuery.data.dna.versionNumber}
2509
+ </Badge>
2510
+ </div>
2511
+ <p className="mt-3 text-sm text-muted-foreground">
2512
+ {brandProfileQuery.data.dna.payload.summary}
2513
+ </p>
2514
+ </div>
2515
+ </section>
2516
+ ) : null}
2517
+
2518
+ {libraryView === "approvals" &&
2519
+ (logoCandidates.length || proposedLayouts.length) ? (
2520
+ <section className="border-t border-border/70 pt-6">
2521
+ <div className="flex items-center gap-2">
2522
+ <IconSparkles className="size-5 text-muted-foreground" />
2523
+ <h2 className="text-lg font-semibold">
2524
+ {t("creativeContext.suggestions")}
2525
+ </h2>
2526
+ </div>
2527
+ {logoCandidates.length ? (
2528
+ <div className="mt-4">
2529
+ <h3 className="flex items-center gap-2 text-sm font-semibold">
2530
+ <IconPhoto className="size-4 text-muted-foreground" />
2531
+ {t("creativeContext.logo")}
2532
+ </h3>
2533
+ <div className="mt-2 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
2534
+ {logoCandidates.map((candidate) => (
2535
+ <article
2536
+ key={candidate.mediaId}
2537
+ className="overflow-hidden rounded-md border border-border"
2538
+ >
2539
+ <div className="flex h-28 items-center justify-center bg-muted/40 p-3">
2540
+ <img
2541
+ src={candidate.thumbnailUrl}
2542
+ alt={candidate.title}
2543
+ className="max-h-full max-w-full object-contain"
2544
+ />
2545
+ </div>
2546
+ <div className="flex items-center justify-between gap-2 p-3">
2547
+ <p className="min-w-0 truncate text-sm font-medium">
1562
2548
  {candidate.title}
1563
2549
  </p>
1564
2550
  <Button
@@ -1663,851 +2649,927 @@ export function CreativeContextPanel({
1663
2649
  </section>
1664
2650
  ) : null}
1665
2651
 
1666
- <section className="border-t border-border/70 pt-6">
1667
- <div>
1668
- <h2 className="text-lg font-semibold">
1669
- {t("creativeContext.addSource")}
1670
- </h2>
1671
- <p className="mt-1 text-sm text-muted-foreground">
1672
- {t("creativeContext.sourcesDescription")}
1673
- </p>
1674
- </div>
1675
- <div className="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
1676
- {CONNECTORS.map((connector) => {
1677
- const Icon = connector.icon;
1678
- return (
1679
- <button
1680
- key={connector.kind}
1681
- type="button"
1682
- disabled={!canManageScope}
1683
- onClick={() => {
1684
- setConnectorKind(connector.kind);
1685
- setSourceName(connector.label);
1686
- setSourceReference("");
1687
- setUploadedFiles([]);
1688
- setPickerRecommendations([]);
1689
- seenRecommendationIdsRef.current.clear();
1690
- setSelectedConnectionId("");
1691
- setSelectedRecommendationIds(new Set());
1692
- setSetupError(null);
1693
- }}
1694
- className="flex min-h-24 flex-col items-start justify-between rounded-md border border-border p-3 text-start transition-colors hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50"
2652
+ {libraryView === "sources" ? (
2653
+ <>
2654
+ <section className="border-t border-border/70 pt-6">
2655
+ <div>
2656
+ <h2 className="text-lg font-semibold">
2657
+ {t("creativeContext.addSource")}
2658
+ </h2>
2659
+ <p className="mt-1 text-sm text-muted-foreground">
2660
+ {t("creativeContext.sourcesDescription")}
2661
+ </p>
2662
+ </div>
2663
+ <div className="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
2664
+ {CONNECTORS.map((connector) => {
2665
+ const Icon = connector.icon;
2666
+ return (
2667
+ <button
2668
+ key={connector.kind}
2669
+ type="button"
2670
+ disabled={!canManageScope}
2671
+ onClick={() => {
2672
+ setConnectorKind(connector.kind);
2673
+ setSourceName(connector.label);
2674
+ setSourceReference("");
2675
+ setUploadedFiles([]);
2676
+ setPickerRecommendations([]);
2677
+ seenRecommendationIdsRef.current.clear();
2678
+ setSelectedConnectionId("");
2679
+ setSelectedRecommendationIds(new Set());
2680
+ setSetupError(null);
2681
+ }}
2682
+ className="flex min-h-24 flex-col items-start justify-between rounded-md border border-border p-3 text-start transition-colors hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50"
2683
+ >
2684
+ <Icon className="size-5 text-muted-foreground" />
2685
+ <span className="text-sm font-medium">
2686
+ {connector.label}
2687
+ </span>
2688
+ </button>
2689
+ );
2690
+ })}
2691
+ </div>
2692
+ {selectedConnector ? (
2693
+ <form
2694
+ className="mt-4 rounded-md border border-border p-4"
2695
+ onSubmit={(event) => void previewImport(event)}
1695
2696
  >
1696
- <Icon className="size-5 text-muted-foreground" />
1697
- <span className="text-sm font-medium">
1698
- {connector.label}
1699
- </span>
1700
- </button>
1701
- );
1702
- })}
1703
- </div>
1704
- {selectedConnector ? (
1705
- <form
1706
- className="mt-4 rounded-md border border-border p-4"
1707
- onSubmit={(event) => void previewImport(event)}
1708
- >
1709
- {connectionProvider ? (
1710
- <div className="mb-3">
1711
- {connectionsQuery.isLoading ? (
1712
- <Skeleton className="h-9 w-full" />
1713
- ) : connectionsQuery.data?.needsSetup ? (
1714
- <div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-dashed border-border p-3 text-sm text-muted-foreground">
1715
- <span>{t("creativeContext.setupConnection")}</span>
1716
- <Button
1717
- asChild
1718
- type="button"
1719
- variant="outline"
1720
- size="sm"
1721
- >
1722
- <a
1723
- href={
1724
- connectionsQuery.data.connectPath ||
1725
- connectionsQuery.data.connectionsPath ||
1726
- connectionsHref
1727
- }
1728
- >
1729
- {t("creativeContext.connectProvider")}
1730
- <IconArrowUpRight />
1731
- </a>
1732
- </Button>
2697
+ {connectionProvider ? (
2698
+ <div className="mb-3">
2699
+ {connectionsQuery.isLoading ? (
2700
+ <Skeleton className="h-9 w-full" />
2701
+ ) : connectionsQuery.data?.needsSetup ? (
2702
+ <div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-dashed border-border p-3 text-sm text-muted-foreground">
2703
+ <span>{t("creativeContext.setupConnection")}</span>
2704
+ <Button
2705
+ asChild
2706
+ type="button"
2707
+ variant="outline"
2708
+ size="sm"
2709
+ >
2710
+ <a
2711
+ href={
2712
+ connectionsQuery.data.connectPath ||
2713
+ connectionsQuery.data.connectionsPath ||
2714
+ connectionsHref
2715
+ }
2716
+ >
2717
+ {t("creativeContext.connectProvider")}
2718
+ <IconArrowUpRight />
2719
+ </a>
2720
+ </Button>
2721
+ </div>
2722
+ ) : connectionsQuery.data?.needsPicker ? (
2723
+ <label className="block space-y-1.5 text-xs font-medium">
2724
+ <span>{t("creativeContext.chooseConnection")}</span>
2725
+ <Select
2726
+ value={selectedConnectionId}
2727
+ onValueChange={setSelectedConnectionId}
2728
+ >
2729
+ <SelectTrigger>
2730
+ <SelectValue
2731
+ placeholder={t(
2732
+ "creativeContext.chooseConnection",
2733
+ )}
2734
+ />
2735
+ </SelectTrigger>
2736
+ <SelectContent>
2737
+ {connectionsQuery.data.connections.map(
2738
+ (connection) => (
2739
+ <SelectItem
2740
+ key={connection.connectionId}
2741
+ value={connection.connectionId}
2742
+ >
2743
+ {connection.label}
2744
+ </SelectItem>
2745
+ ),
2746
+ )}
2747
+ </SelectContent>
2748
+ </Select>
2749
+ </label>
2750
+ ) : null}
1733
2751
  </div>
1734
- ) : connectionsQuery.data?.needsPicker ? (
1735
- <label className="block space-y-1.5 text-xs font-medium">
1736
- <span>{t("creativeContext.chooseConnection")}</span>
1737
- <Select
1738
- value={selectedConnectionId}
1739
- onValueChange={setSelectedConnectionId}
1740
- >
1741
- <SelectTrigger>
1742
- <SelectValue
1743
- placeholder={t(
1744
- "creativeContext.chooseConnection",
1745
- )}
1746
- />
1747
- </SelectTrigger>
1748
- <SelectContent>
1749
- {connectionsQuery.data.connections.map(
1750
- (connection) => (
1751
- <SelectItem
1752
- key={connection.connectionId}
1753
- value={connection.connectionId}
1754
- >
1755
- {connection.label}
1756
- </SelectItem>
1757
- ),
1758
- )}
1759
- </SelectContent>
1760
- </Select>
1761
- </label>
1762
2752
  ) : null}
1763
- </div>
1764
- ) : null}
1765
- {recommendationProvider && selectedConnectionId ? (
1766
- <div className="mb-3 rounded-md border border-border p-3">
1767
- {connectorKind === "google-slides" ? (
1768
- <div className="mb-3 flex flex-wrap items-center justify-between gap-3 border-b border-border/60 pb-3">
1769
- <p className="max-w-lg text-xs text-muted-foreground">
1770
- {t("creativeContext.googlePickerDescription")}
1771
- </p>
1772
- <Button
1773
- type="button"
1774
- variant="outline"
1775
- size="sm"
1776
- disabled={openingGooglePicker}
1777
- onClick={() => void chooseGoogleSlides()}
1778
- >
1779
- <IconSlideshow />
1780
- {openingGooglePicker
1781
- ? t("creativeContext.loading")
1782
- : t("creativeContext.choosePresentations")}
1783
- </Button>
2753
+ {recommendationProvider && selectedConnectionId ? (
2754
+ <div className="mb-3 rounded-md border border-border p-3">
2755
+ {connectorKind === "google-slides" ? (
2756
+ <div className="mb-3 flex flex-wrap items-center justify-between gap-3 border-b border-border/60 pb-3">
2757
+ <p className="max-w-lg text-xs text-muted-foreground">
2758
+ {t("creativeContext.googlePickerDescription")}
2759
+ </p>
2760
+ <Button
2761
+ type="button"
2762
+ variant="outline"
2763
+ size="sm"
2764
+ disabled={openingGooglePicker}
2765
+ onClick={() => void chooseGoogleSlides()}
2766
+ >
2767
+ <IconSlideshow />
2768
+ {openingGooglePicker
2769
+ ? t("creativeContext.loading")
2770
+ : t("creativeContext.choosePresentations")}
2771
+ </Button>
2772
+ </div>
2773
+ ) : null}
2774
+ {recommendationsQuery.isLoading &&
2775
+ !availableRecommendations.length ? (
2776
+ <Skeleton className="h-16 w-full" />
2777
+ ) : availableRecommendations.length ? (
2778
+ <div className="space-y-2">
2779
+ <div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
2780
+ <span>
2781
+ {t("creativeContext.discoveredItems", {
2782
+ count: formatNumber(
2783
+ availableRecommendations.length,
2784
+ ),
2785
+ })}
2786
+ </span>
2787
+ <span>
2788
+ {t("creativeContext.selectedItems", {
2789
+ count: formatNumber(
2790
+ selectedRecommendationIds.size,
2791
+ ),
2792
+ })}
2793
+ </span>
2794
+ </div>
2795
+ <div className="max-h-52 divide-y divide-border/60 overflow-y-auto">
2796
+ {availableRecommendations.map(
2797
+ (recommendation) => (
2798
+ <label
2799
+ key={recommendation.externalId}
2800
+ className="flex cursor-pointer items-start gap-3 py-2"
2801
+ >
2802
+ <Checkbox
2803
+ className="mt-0.5"
2804
+ checked={selectedRecommendationIds.has(
2805
+ recommendation.externalId,
2806
+ )}
2807
+ onCheckedChange={(checked) =>
2808
+ setSelectedRecommendationIds(
2809
+ (current) => {
2810
+ const next = new Set(current);
2811
+ if (checked) {
2812
+ next.add(
2813
+ recommendation.externalId,
2814
+ );
2815
+ } else {
2816
+ next.delete(
2817
+ recommendation.externalId,
2818
+ );
2819
+ }
2820
+ return next;
2821
+ },
2822
+ )
2823
+ }
2824
+ />
2825
+ <span className="min-w-0">
2826
+ <span className="block truncate text-sm">
2827
+ {recommendation.title}
2828
+ </span>
2829
+ <span className="block text-xs text-muted-foreground">
2830
+ {recommendation.containerRef ??
2831
+ recommendation.kind}
2832
+ </span>
2833
+ </span>
2834
+ </label>
2835
+ ),
2836
+ )}
2837
+ </div>
2838
+ </div>
2839
+ ) : (
2840
+ <p className="text-xs text-muted-foreground">
2841
+ {recommendationsQuery.data?.unavailableReason ??
2842
+ t("creativeContext.unavailable")}
2843
+ </p>
2844
+ )}
1784
2845
  </div>
1785
2846
  ) : null}
1786
- {recommendationsQuery.isLoading &&
1787
- !availableRecommendations.length ? (
1788
- <Skeleton className="h-16 w-full" />
1789
- ) : availableRecommendations.length ? (
1790
- <div className="space-y-2">
1791
- <div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
1792
- <span>
1793
- {t("creativeContext.discoveredItems", {
1794
- count: formatNumber(
1795
- availableRecommendations.length,
1796
- ),
1797
- })}
1798
- </span>
1799
- <span>
1800
- {t("creativeContext.selectedItems", {
1801
- count: formatNumber(
1802
- selectedRecommendationIds.size,
1803
- ),
2847
+ <div className="grid gap-3 sm:grid-cols-2">
2848
+ <label className="space-y-1.5 text-xs font-medium">
2849
+ <span>{t("creativeContext.sourceName")}</span>
2850
+ <Input
2851
+ value={sourceName}
2852
+ onChange={(event) =>
2853
+ setSourceName(event.target.value)
2854
+ }
2855
+ required
2856
+ />
2857
+ </label>
2858
+ {selectedConnector.kind === "upload" ? (
2859
+ <div className="space-y-1.5 text-xs font-medium">
2860
+ <span>{t("creativeContext.sourceReference")}</span>
2861
+ <input
2862
+ ref={fileInputRef}
2863
+ type="file"
2864
+ multiple
2865
+ accept=".pptx,.docx,.pdf,.png,.jpg,.jpeg,.webp,.gif,.svg,application/pdf,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.openxmlformats-officedocument.wordprocessingml.document,image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
2866
+ className="hidden"
2867
+ onChange={chooseFiles}
2868
+ />
2869
+ <input
2870
+ ref={folderInputRef}
2871
+ type="file"
2872
+ multiple
2873
+ className="hidden"
2874
+ onChange={chooseFiles}
2875
+ {...({ webkitdirectory: "", directory: "" } as {
2876
+ webkitdirectory: string;
2877
+ directory: string;
1804
2878
  })}
1805
- </span>
2879
+ />
2880
+ <div
2881
+ onDragOver={(event) => event.preventDefault()}
2882
+ onDrop={dropFiles}
2883
+ className="rounded-md border border-dashed border-border p-4"
2884
+ >
2885
+ <p className="text-xs text-muted-foreground">
2886
+ {t("creativeContext.dropFiles")}
2887
+ </p>
2888
+ <div className="mt-3 flex flex-wrap gap-2">
2889
+ <Button
2890
+ type="button"
2891
+ variant="outline"
2892
+ size="sm"
2893
+ onClick={() => fileInputRef.current?.click()}
2894
+ >
2895
+ {t("creativeContext.chooseFiles")}
2896
+ </Button>
2897
+ <Button
2898
+ type="button"
2899
+ variant="outline"
2900
+ size="sm"
2901
+ onClick={() => folderInputRef.current?.click()}
2902
+ >
2903
+ {t("creativeContext.chooseFolder")}
2904
+ </Button>
2905
+ </div>
2906
+ {uploadedFiles.length ? (
2907
+ <ul className="mt-3 space-y-1 text-xs text-muted-foreground">
2908
+ {uploadedFiles.map((file) => (
2909
+ <li key={file.id} className="truncate">
2910
+ {file.fileName}
2911
+ </li>
2912
+ ))}
2913
+ </ul>
2914
+ ) : null}
2915
+ </div>
1806
2916
  </div>
1807
- <div className="max-h-52 divide-y divide-border/60 overflow-y-auto">
1808
- {availableRecommendations.map((recommendation) => (
1809
- <label
1810
- key={recommendation.externalId}
1811
- className="flex cursor-pointer items-start gap-3 py-2"
1812
- >
1813
- <Checkbox
1814
- className="mt-0.5"
1815
- checked={selectedRecommendationIds.has(
1816
- recommendation.externalId,
1817
- )}
1818
- onCheckedChange={(checked) =>
1819
- setSelectedRecommendationIds((current) => {
1820
- const next = new Set(current);
1821
- if (checked) {
1822
- next.add(recommendation.externalId);
1823
- } else {
1824
- next.delete(recommendation.externalId);
1825
- }
1826
- return next;
1827
- })
2917
+ ) : (
2918
+ <label className="space-y-1.5 text-xs font-medium">
2919
+ <span>{t("creativeContext.sourceReference")}</span>
2920
+ {selectedConnector.kind === "website" ||
2921
+ selectedConnector.kind === "figma" ||
2922
+ selectedConnector.kind === "notion" ? (
2923
+ <Textarea
2924
+ value={sourceReference}
2925
+ onChange={(event) =>
2926
+ setSourceReference(event.target.value)
2927
+ }
2928
+ placeholder={
2929
+ selectedConnector.referencePlaceholder
2930
+ }
2931
+ required={selectedConnector.referenceRequired}
2932
+ rows={3}
2933
+ />
2934
+ ) : (
2935
+ <Input
2936
+ value={sourceReference}
2937
+ onChange={(event) =>
2938
+ setSourceReference(event.target.value)
2939
+ }
2940
+ placeholder={
2941
+ selectedConnector.referencePlaceholder
2942
+ }
2943
+ required={selectedConnector.referenceRequired}
2944
+ />
2945
+ )}
2946
+ </label>
2947
+ )}
2948
+ </div>
2949
+ <div className="mt-3 flex justify-end gap-2">
2950
+ <Button
2951
+ type="button"
2952
+ variant="ghost"
2953
+ onClick={() => setConnectorKind(null)}
2954
+ >
2955
+ {t("creativeContext.cancel")}
2956
+ </Button>
2957
+ <Button
2958
+ type="submit"
2959
+ disabled={
2960
+ manageSource.isPending ||
2961
+ uploadResource.isPending ||
2962
+ (selectedConnector.kind === "upload" &&
2963
+ !uploadedFiles.length) ||
2964
+ (Boolean(connectionProvider) &&
2965
+ !selectedConnectionId) ||
2966
+ (selectedConnector.referenceRequired &&
2967
+ !sourceReference.trim()) ||
2968
+ (Boolean(recommendationProvider) &&
2969
+ !sourceReference.trim() &&
2970
+ !selectedRecommendationIds.size)
2971
+ }
2972
+ >
2973
+ <IconFileImport />
2974
+ {t("creativeContext.preview")}
2975
+ </Button>
2976
+ </div>
2977
+ </form>
2978
+ ) : null}
2979
+
2980
+ {previewSourceId ? (
2981
+ <div className="mt-4 rounded-md border border-border p-4">
2982
+ <div className="flex flex-wrap items-start justify-between gap-3">
2983
+ <div>
2984
+ <h3 className="text-sm font-semibold">
2985
+ {previewSourceName}
2986
+ </h3>
2987
+ {previewQuery.isLoading ? (
2988
+ <p className="mt-0.5 text-xs text-muted-foreground">
2989
+ {t("creativeContext.loading")}
2990
+ </p>
2991
+ ) : (
2992
+ <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
2993
+ <span>
2994
+ {t("creativeContext.discoveredItems", {
2995
+ count: formatNumber(
2996
+ previewQuery.data?.total ??
2997
+ previewQuery.data?.items.length ??
2998
+ 0,
2999
+ ),
3000
+ })}
3001
+ </span>
3002
+ <span>
3003
+ {t("creativeContext.selectedItems", {
3004
+ count: formatNumber(
3005
+ selectedPreviewItemIds.size,
3006
+ ),
3007
+ })}
3008
+ </span>
3009
+ </div>
3010
+ )}
3011
+ </div>
3012
+ <Button
3013
+ type="button"
3014
+ disabled={
3015
+ previewQuery.isLoading ||
3016
+ !previewQuery.data?.items.length ||
3017
+ !selectedPreviewItemIds.size ||
3018
+ startImport.isPending ||
3019
+ importJob?.status === "queued" ||
3020
+ importJob?.status === "running"
3021
+ }
3022
+ onClick={() => void beginImport()}
3023
+ >
3024
+ <IconFileImport />
3025
+ {startImport.isPending
3026
+ ? t("creativeContext.importing")
3027
+ : t("creativeContext.startImport")}
3028
+ </Button>
3029
+ </div>
3030
+ {previewQuery.data?.items.length ? (
3031
+ <div className="mt-3">
3032
+ <div className="flex items-center gap-2 border-y border-border/60 py-2">
3033
+ <Button
3034
+ type="button"
3035
+ variant="ghost"
3036
+ size="sm"
3037
+ onClick={() =>
3038
+ setSelectedPreviewItemIds(
3039
+ new Set(
3040
+ previewQuery.data?.items.map(
3041
+ (item) => item.externalId,
3042
+ ) ?? [],
3043
+ ),
3044
+ )
3045
+ }
3046
+ >
3047
+ {t("creativeContext.selectAll")}
3048
+ </Button>
3049
+ <Button
3050
+ type="button"
3051
+ variant="ghost"
3052
+ size="sm"
3053
+ onClick={() => setSelectedPreviewItemIds(new Set())}
3054
+ >
3055
+ {t("creativeContext.clearSelection")}
3056
+ </Button>
3057
+ </div>
3058
+ <div className="max-h-80 divide-y divide-border/60 overflow-y-auto">
3059
+ {previewQuery.data.items.map((item) => (
3060
+ <label
3061
+ key={item.externalId}
3062
+ className="flex cursor-pointer items-start gap-3 py-2"
3063
+ >
3064
+ <Checkbox
3065
+ className="mt-0.5"
3066
+ checked={selectedPreviewItemIds.has(
3067
+ item.externalId,
3068
+ )}
3069
+ onCheckedChange={(checked) =>
3070
+ setSelectedPreviewItemIds((current) => {
3071
+ const next = new Set(current);
3072
+ if (checked) next.add(item.externalId);
3073
+ else next.delete(item.externalId);
3074
+ return next;
3075
+ })
1828
3076
  }
1829
3077
  />
1830
3078
  <span className="min-w-0">
1831
3079
  <span className="block truncate text-sm">
1832
- {recommendation.title}
3080
+ {item.title}
1833
3081
  </span>
1834
3082
  <span className="block text-xs text-muted-foreground">
1835
- {recommendation.containerRef ??
1836
- recommendation.kind}
3083
+ {item.kind}
1837
3084
  </span>
1838
3085
  </span>
1839
3086
  </label>
1840
3087
  ))}
1841
3088
  </div>
1842
3089
  </div>
3090
+ ) : null}
3091
+ </div>
3092
+ ) : null}
3093
+
3094
+ {importJob ? (
3095
+ <div className="mt-4 rounded-md border border-border p-4">
3096
+ <div className="flex items-center gap-2">
3097
+ {importJob.status === "completed" ? (
3098
+ <IconCheck className="size-5 text-emerald-600" />
3099
+ ) : importJob.status === "failed" ? (
3100
+ <IconAlertTriangle className="size-5 text-destructive" />
3101
+ ) : (
3102
+ <IconRefresh className="size-5 animate-spin text-muted-foreground" />
3103
+ )}
3104
+ <h3 className="text-sm font-semibold">
3105
+ {importJob.status === "completed"
3106
+ ? t("creativeContext.importComplete")
3107
+ : importJob.status === "failed"
3108
+ ? t("creativeContext.importFailed")
3109
+ : t("creativeContext.importing")}
3110
+ </h3>
3111
+ </div>
3112
+ {importJob.status === "completed" && brandProposal ? (
3113
+ <div className="mt-4 rounded-md bg-muted/50 p-4">
3114
+ <div className="flex items-start gap-3">
3115
+ <IconSparkles className="mt-0.5 size-5 text-muted-foreground" />
3116
+ <div className="min-w-0 flex-1">
3117
+ <h4 className="text-sm font-semibold">
3118
+ {t("creativeContext.brandDnaTitle")}
3119
+ </h4>
3120
+ <p className="mt-1 text-sm text-muted-foreground">
3121
+ {brandProposal.summary}
3122
+ </p>
3123
+ {brandProposal.colors.length ? (
3124
+ <div className="mt-4">
3125
+ <p className="text-xs font-medium">
3126
+ {t("creativeContext.colors")}
3127
+ </p>
3128
+ <div className="mt-2 flex flex-wrap gap-2">
3129
+ {brandProposal.colors.map((color) => (
3130
+ <div
3131
+ key={color}
3132
+ className="flex items-center gap-1.5 rounded border border-border bg-background px-2 py-1 text-xs"
3133
+ >
3134
+ <span
3135
+ className="size-4 rounded-sm border border-black/10"
3136
+ style={{ backgroundColor: color }}
3137
+ />
3138
+ {color}
3139
+ </div>
3140
+ ))}
3141
+ </div>
3142
+ </div>
3143
+ ) : null}
3144
+ {brandProposal.fonts.length ? (
3145
+ <div className="mt-4">
3146
+ <p className="text-xs font-medium">
3147
+ {t("creativeContext.fonts")}
3148
+ </p>
3149
+ <div className="mt-2 flex flex-wrap gap-2">
3150
+ {brandProposal.fonts.map((font) => (
3151
+ <Badge key={font} variant="outline">
3152
+ {font}
3153
+ </Badge>
3154
+ ))}
3155
+ </div>
3156
+ </div>
3157
+ ) : null}
3158
+ {brandLayoutThumbnails.length ? (
3159
+ <div className="mt-4">
3160
+ <p className="text-xs font-medium">
3161
+ {t("creativeContext.layouts")}
3162
+ </p>
3163
+ <div className="mt-2 grid grid-cols-3 gap-2">
3164
+ {brandLayoutThumbnails.map((thumbnail) => (
3165
+ <AccessScopedThumbnail
3166
+ key={thumbnail.itemVersionId}
3167
+ itemId={thumbnail.itemId}
3168
+ itemVersionId={thumbnail.itemVersionId}
3169
+ className="aspect-video w-full rounded border border-border object-cover"
3170
+ />
3171
+ ))}
3172
+ </div>
3173
+ </div>
3174
+ ) : null}
3175
+ {brandVoicePreview ? (
3176
+ <div className="mt-4">
3177
+ <p className="text-xs font-medium">
3178
+ {t("creativeContext.voice")}
3179
+ </p>
3180
+ <blockquote className="mt-1 border-s-2 border-border ps-3 text-sm text-muted-foreground">
3181
+ {brandVoicePreview}
3182
+ </blockquote>
3183
+ </div>
3184
+ ) : null}
3185
+ <div className="mt-4 flex flex-wrap gap-2">
3186
+ <Button
3187
+ type="button"
3188
+ disabled={publishBrandDna.isPending}
3189
+ onClick={() => void publishProposal()}
3190
+ >
3191
+ <IconSparkles />
3192
+ {publishBrandDna.isPending
3193
+ ? t("creativeContext.applyingBrandContext")
3194
+ : t("creativeContext.applyBrandContext")}
3195
+ </Button>
3196
+ <Button asChild type="button" variant="outline">
3197
+ <a href="/agent">
3198
+ {t("creativeContext.generateWithContext")}
3199
+ <IconArrowUpRight />
3200
+ </a>
3201
+ </Button>
3202
+ </div>
3203
+ {publishedMessage ? (
3204
+ <p className="mt-2 text-xs text-muted-foreground">
3205
+ {publishedMessage}
3206
+ </p>
3207
+ ) : null}
3208
+ </div>
3209
+ </div>
3210
+ </div>
3211
+ ) : importJob.error ? (
3212
+ <p className="mt-2 text-sm text-destructive">
3213
+ {importJob.error}
3214
+ </p>
1843
3215
  ) : (
1844
- <p className="text-xs text-muted-foreground">
1845
- {recommendationsQuery.data?.unavailableReason ??
1846
- t("creativeContext.unavailable")}
3216
+ <p className="mt-2 text-xs text-muted-foreground">
3217
+ {formatNumber(importJob.progressCurrent)} /{" "}
3218
+ {formatNumber(importJob.progressTotal ?? 0)}
1847
3219
  </p>
1848
3220
  )}
1849
3221
  </div>
1850
3222
  ) : null}
1851
- <div className="grid gap-3 sm:grid-cols-2">
1852
- <label className="space-y-1.5 text-xs font-medium">
1853
- <span>{t("creativeContext.sourceName")}</span>
1854
- <Input
1855
- value={sourceName}
1856
- onChange={(event) => setSourceName(event.target.value)}
1857
- required
1858
- />
1859
- </label>
1860
- {selectedConnector.kind === "upload" ? (
1861
- <div className="space-y-1.5 text-xs font-medium">
1862
- <span>{t("creativeContext.sourceReference")}</span>
1863
- <input
1864
- ref={fileInputRef}
1865
- type="file"
1866
- multiple
1867
- accept=".pptx,.docx,.pdf,.png,.jpg,.jpeg,.webp,.gif,.svg,application/pdf,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.openxmlformats-officedocument.wordprocessingml.document,image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
1868
- className="hidden"
1869
- onChange={chooseFiles}
1870
- />
1871
- <input
1872
- ref={folderInputRef}
1873
- type="file"
1874
- multiple
1875
- className="hidden"
1876
- onChange={chooseFiles}
1877
- {...({ webkitdirectory: "", directory: "" } as {
1878
- webkitdirectory: string;
1879
- directory: string;
1880
- })}
1881
- />
1882
- <div
1883
- onDragOver={(event) => event.preventDefault()}
1884
- onDrop={dropFiles}
1885
- className="rounded-md border border-dashed border-border p-4"
1886
- >
1887
- <p className="text-xs text-muted-foreground">
1888
- {t("creativeContext.dropFiles")}
1889
- </p>
1890
- <div className="mt-3 flex flex-wrap gap-2">
1891
- <Button
1892
- type="button"
1893
- variant="outline"
1894
- size="sm"
1895
- onClick={() => fileInputRef.current?.click()}
1896
- >
1897
- {t("creativeContext.chooseFiles")}
1898
- </Button>
1899
- <Button
1900
- type="button"
1901
- variant="outline"
1902
- size="sm"
1903
- onClick={() => folderInputRef.current?.click()}
1904
- >
1905
- {t("creativeContext.chooseFolder")}
1906
- </Button>
1907
- </div>
1908
- {uploadedFiles.length ? (
1909
- <ul className="mt-3 space-y-1 text-xs text-muted-foreground">
1910
- {uploadedFiles.map((file) => (
1911
- <li key={file.id} className="truncate">
1912
- {file.fileName}
1913
- </li>
1914
- ))}
1915
- </ul>
1916
- ) : null}
1917
- </div>
1918
- </div>
1919
- ) : (
1920
- <label className="space-y-1.5 text-xs font-medium">
1921
- <span>{t("creativeContext.sourceReference")}</span>
1922
- {selectedConnector.kind === "website" ||
1923
- selectedConnector.kind === "figma" ||
1924
- selectedConnector.kind === "notion" ? (
1925
- <Textarea
1926
- value={sourceReference}
1927
- onChange={(event) =>
1928
- setSourceReference(event.target.value)
1929
- }
1930
- placeholder={selectedConnector.referencePlaceholder}
1931
- required={selectedConnector.referenceRequired}
1932
- rows={3}
1933
- />
1934
- ) : (
1935
- <Input
1936
- value={sourceReference}
1937
- onChange={(event) =>
1938
- setSourceReference(event.target.value)
1939
- }
1940
- placeholder={selectedConnector.referencePlaceholder}
1941
- required={selectedConnector.referenceRequired}
1942
- />
1943
- )}
1944
- </label>
1945
- )}
1946
- </div>
1947
- <div className="mt-3 flex justify-end gap-2">
1948
- <Button
1949
- type="button"
1950
- variant="ghost"
1951
- onClick={() => setConnectorKind(null)}
1952
- >
1953
- {t("creativeContext.cancel")}
1954
- </Button>
1955
- <Button
1956
- type="submit"
1957
- disabled={
1958
- manageSource.isPending ||
1959
- uploadResource.isPending ||
1960
- (selectedConnector.kind === "upload" &&
1961
- !uploadedFiles.length) ||
1962
- (Boolean(connectionProvider) && !selectedConnectionId) ||
1963
- (selectedConnector.referenceRequired &&
1964
- !sourceReference.trim()) ||
1965
- (Boolean(recommendationProvider) &&
1966
- !sourceReference.trim() &&
1967
- !selectedRecommendationIds.size)
1968
- }
1969
- >
1970
- <IconFileImport />
1971
- {t("creativeContext.preview")}
1972
- </Button>
1973
- </div>
1974
- </form>
1975
- ) : null}
1976
-
1977
- {previewSourceId ? (
1978
- <div className="mt-4 rounded-md border border-border p-4">
1979
- <div className="flex flex-wrap items-start justify-between gap-3">
1980
- <div>
3223
+ {promotionPreview ? (
3224
+ <div className="mt-4 rounded-md border border-border p-4">
1981
3225
  <h3 className="text-sm font-semibold">
1982
- {previewSourceName}
3226
+ {t("creativeContext.promoteToOrganization")}
1983
3227
  </h3>
1984
- {previewQuery.isLoading ? (
1985
- <p className="mt-0.5 text-xs text-muted-foreground">
1986
- {t("creativeContext.loading")}
1987
- </p>
1988
- ) : (
1989
- <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
1990
- <span>
1991
- {t("creativeContext.discoveredItems", {
1992
- count: formatNumber(
1993
- previewQuery.data?.total ??
1994
- previewQuery.data?.items.length ??
1995
- 0,
1996
- ),
1997
- })}
1998
- </span>
1999
- <span>
2000
- {t("creativeContext.selectedItems", {
2001
- count: formatNumber(selectedPreviewItemIds.size),
2002
- })}
2003
- </span>
3228
+ <p className="mt-1 text-sm text-muted-foreground">
3229
+ {t("creativeContext.promotionDescription")}
3230
+ </p>
3231
+ <dl className="mt-3 grid gap-2 text-xs sm:grid-cols-3">
3232
+ <div>
3233
+ <dt className="text-muted-foreground">
3234
+ {t("creativeContext.sourceReference")}
3235
+ </dt>
3236
+ <dd className="mt-0.5 truncate font-medium">
3237
+ {promotionPreview.containerRef}
3238
+ </dd>
2004
3239
  </div>
2005
- )}
3240
+ <div>
3241
+ <dt className="text-muted-foreground">
3242
+ {t("creativeContext.itemsLabel", { count: "" })}
3243
+ </dt>
3244
+ <dd className="mt-0.5 font-medium">
3245
+ {formatNumber(promotionPreview.itemCount)}
3246
+ </dd>
3247
+ </div>
3248
+ <div>
3249
+ <dt className="text-muted-foreground">
3250
+ {t("creativeContext.restrictedItems", { count: "" })}
3251
+ </dt>
3252
+ <dd className="mt-0.5 font-medium">
3253
+ {formatNumber(promotionPreview.restrictedItemCount)}
3254
+ </dd>
3255
+ </div>
3256
+ </dl>
3257
+ <Button
3258
+ type="button"
3259
+ className="mt-4"
3260
+ disabled={manageSource.isPending}
3261
+ onClick={() => void confirmPromotion()}
3262
+ >
3263
+ {t("creativeContext.promoteToOrganization")}
3264
+ </Button>
2006
3265
  </div>
2007
- <Button
2008
- type="button"
2009
- disabled={
2010
- previewQuery.isLoading ||
2011
- !previewQuery.data?.items.length ||
2012
- !selectedPreviewItemIds.size ||
2013
- startImport.isPending ||
2014
- importJob?.status === "queued" ||
2015
- importJob?.status === "running"
2016
- }
2017
- onClick={() => void beginImport()}
2018
- >
2019
- <IconFileImport />
2020
- {startImport.isPending
2021
- ? t("creativeContext.importing")
2022
- : t("creativeContext.startImport")}
2023
- </Button>
2024
- </div>
2025
- {previewQuery.data?.items.length ? (
3266
+ ) : null}
3267
+ {promotionMessage ? (
3268
+ <p className="mt-2 text-xs text-muted-foreground">
3269
+ {promotionMessage}
3270
+ </p>
3271
+ ) : null}
3272
+ {setupError ? (
3273
+ <p className="mt-2 text-sm text-destructive">{setupError}</p>
3274
+ ) : null}
3275
+ </section>
3276
+
3277
+ <section className="border-t border-border/70 pt-6">
3278
+ <h2 className="text-lg font-semibold">
3279
+ {t("creativeContext.sourcesTitle")}
3280
+ </h2>
3281
+ {sources.length ? (
2026
3282
  <div className="mt-3">
2027
- <div className="flex items-center gap-2 border-y border-border/60 py-2">
2028
- <Button
2029
- type="button"
2030
- variant="ghost"
2031
- size="sm"
2032
- onClick={() =>
2033
- setSelectedPreviewItemIds(
2034
- new Set(
2035
- previewQuery.data?.items.map(
2036
- (item) => item.externalId,
2037
- ) ?? [],
2038
- ),
2039
- )
3283
+ {sources.map((source) => (
3284
+ <SourceRow
3285
+ key={source.id}
3286
+ source={source}
3287
+ refreshing={
3288
+ refreshSource.isPending &&
3289
+ refreshSource.variables?.sourceId === source.id
2040
3290
  }
2041
- >
2042
- {t("creativeContext.selectAll")}
2043
- </Button>
2044
- <Button
2045
- type="button"
2046
- variant="ghost"
2047
- size="sm"
2048
- onClick={() => setSelectedPreviewItemIds(new Set())}
2049
- >
2050
- {t("creativeContext.clearSelection")}
2051
- </Button>
2052
- </div>
2053
- <div className="max-h-80 divide-y divide-border/60 overflow-y-auto">
2054
- {previewQuery.data.items.map((item) => (
2055
- <label
2056
- key={item.externalId}
2057
- className="flex cursor-pointer items-start gap-3 py-2"
2058
- >
2059
- <Checkbox
2060
- className="mt-0.5"
2061
- checked={selectedPreviewItemIds.has(
2062
- item.externalId,
2063
- )}
2064
- onCheckedChange={(checked) =>
2065
- setSelectedPreviewItemIds((current) => {
2066
- const next = new Set(current);
2067
- if (checked) next.add(item.externalId);
2068
- else next.delete(item.externalId);
2069
- return next;
2070
- })
2071
- }
2072
- />
2073
- <span className="min-w-0">
2074
- <span className="block truncate text-sm">
2075
- {item.title}
2076
- </span>
2077
- <span className="block text-xs text-muted-foreground">
2078
- {item.kind}
2079
- </span>
2080
- </span>
2081
- </label>
2082
- ))}
2083
- </div>
3291
+ canReview={canManageScope}
3292
+ canPromote={
3293
+ libraryScope === "user" &&
3294
+ Boolean(org?.orgId) &&
3295
+ canManageOrg &&
3296
+ source.visibility === "private"
3297
+ }
3298
+ onRefresh={refresh}
3299
+ onReview={(selected) =>
3300
+ void openItemCuration(selected, "restricted")
3301
+ }
3302
+ onCurate={(selected) => void openItemCuration(selected)}
3303
+ onPromote={(selected) =>
3304
+ void previewSourcePromotion(selected)
3305
+ }
3306
+ onPause={(selected) => void pauseSource(selected)}
3307
+ onRestore={(selected) => void restoreSource(selected)}
3308
+ onDelete={setDeleteSource}
3309
+ />
3310
+ ))}
3311
+ </div>
3312
+ ) : (
3313
+ <div className="mt-4 rounded-md border border-dashed border-border p-6 text-center">
3314
+ <IconBooks className="mx-auto size-7 text-muted-foreground" />
3315
+ <h3 className="mt-3 text-sm font-semibold">
3316
+ {t("creativeContext.noSourcesTitle")}
3317
+ </h3>
3318
+ <p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
3319
+ {t("creativeContext.noSourcesDescription")}
3320
+ </p>
3321
+ <Button
3322
+ asChild
3323
+ variant="outline"
3324
+ size="sm"
3325
+ className="mt-4"
3326
+ >
3327
+ <a href={connectionsHref}>
3328
+ {t("creativeContext.connectSources")}
3329
+ <IconArrowUpRight />
3330
+ </a>
3331
+ </Button>
2084
3332
  </div>
3333
+ )}
3334
+ {reviewSource ? (
3335
+ <ItemCuration
3336
+ source={reviewSource}
3337
+ items={reviewedItems}
3338
+ busy={reviewItems.isPending}
3339
+ onReview={(operation, itemId) =>
3340
+ void reviewContextItem(operation, itemId)
3341
+ }
3342
+ onClose={() => {
3343
+ setReviewSource(null);
3344
+ setReviewedItems([]);
3345
+ }}
3346
+ />
2085
3347
  ) : null}
2086
- </div>
2087
- ) : null}
3348
+ {reviewError ? (
3349
+ <p className="mt-2 text-sm text-destructive">{reviewError}</p>
3350
+ ) : null}
3351
+ {refreshMessage ? (
3352
+ <p className="mt-2 text-xs text-muted-foreground">
3353
+ {refreshMessage}
3354
+ </p>
3355
+ ) : null}
3356
+ {lifecycleMessage ? (
3357
+ <p className="mt-2 text-xs text-muted-foreground">
3358
+ {lifecycleMessage}
3359
+ </p>
3360
+ ) : null}
3361
+ </section>
3362
+ </>
3363
+ ) : null}
2088
3364
 
2089
- {importJob ? (
2090
- <div className="mt-4 rounded-md border border-border p-4">
2091
- <div className="flex items-center gap-2">
2092
- {importJob.status === "completed" ? (
2093
- <IconCheck className="size-5 text-emerald-600" />
2094
- ) : importJob.status === "failed" ? (
2095
- <IconAlertTriangle className="size-5 text-destructive" />
2096
- ) : (
2097
- <IconRefresh className="size-5 animate-spin text-muted-foreground" />
2098
- )}
2099
- <h3 className="text-sm font-semibold">
2100
- {importJob.status === "completed"
2101
- ? t("creativeContext.importComplete")
2102
- : importJob.status === "failed"
2103
- ? t("creativeContext.importFailed")
2104
- : t("creativeContext.importing")}
2105
- </h3>
3365
+ {libraryView === "settings" ? (
3366
+ <section className="border-t border-border/70 pt-6">
3367
+ <h2 className="text-lg font-semibold">
3368
+ {t("creativeContext.packsTitle")}
3369
+ </h2>
3370
+ {packs.length ? (
3371
+ <div className="mt-3">
3372
+ {packs.map((pack) => (
3373
+ <PackRow
3374
+ key={pack.id}
3375
+ pack={pack}
3376
+ pinned={contextState.state.pinnedPackId === pack.id}
3377
+ disabled={savingState}
3378
+ onPin={(packId) => void changePinnedPack(packId)}
3379
+ onDetails={setSelectedPackId}
3380
+ />
3381
+ ))}
2106
3382
  </div>
2107
- {importJob.status === "completed" && brandProposal ? (
2108
- <div className="mt-4 rounded-md bg-muted/50 p-4">
2109
- <div className="flex items-start gap-3">
2110
- <IconSparkles className="mt-0.5 size-5 text-muted-foreground" />
2111
- <div className="min-w-0 flex-1">
2112
- <h4 className="text-sm font-semibold">
2113
- {t("creativeContext.brandDnaTitle")}
2114
- </h4>
2115
- <p className="mt-1 text-sm text-muted-foreground">
2116
- {brandProposal.summary}
3383
+ ) : (
3384
+ <p className="mt-3 rounded-md border border-dashed border-border p-5 text-sm text-muted-foreground">
3385
+ {t("creativeContext.noPacks")}
3386
+ </p>
3387
+ )}
3388
+ {selectedPackId ? (
3389
+ <div className="mt-4 rounded-md border border-border p-4">
3390
+ <div className="flex items-start justify-between gap-3">
3391
+ <div>
3392
+ <h3 className="text-sm font-semibold">
3393
+ {packQuery.data?.pack?.name ??
3394
+ t("creativeContext.packDetails")}
3395
+ </h3>
3396
+ {packQuery.data?.pack?.derivedFromPackId ? (
3397
+ <p className="mt-1 text-xs text-muted-foreground">
3398
+ {t("creativeContext.influence")}:{" "}
3399
+ {packQuery.data.pack.derivedFromPackId}
2117
3400
  </p>
2118
- {brandProposal.colors.length ? (
2119
- <div className="mt-4">
2120
- <p className="text-xs font-medium">
2121
- {t("creativeContext.colors")}
2122
- </p>
2123
- <div className="mt-2 flex flex-wrap gap-2">
2124
- {brandProposal.colors.map((color) => (
2125
- <div
2126
- key={color}
2127
- className="flex items-center gap-1.5 rounded border border-border bg-background px-2 py-1 text-xs"
2128
- >
2129
- <span
2130
- className="size-4 rounded-sm border border-black/10"
2131
- style={{ backgroundColor: color }}
2132
- />
2133
- {color}
2134
- </div>
2135
- ))}
2136
- </div>
2137
- </div>
2138
- ) : null}
2139
- {brandProposal.fonts.length ? (
2140
- <div className="mt-4">
2141
- <p className="text-xs font-medium">
2142
- {t("creativeContext.fonts")}
2143
- </p>
2144
- <div className="mt-2 flex flex-wrap gap-2">
2145
- {brandProposal.fonts.map((font) => (
2146
- <Badge key={font} variant="outline">
2147
- {font}
2148
- </Badge>
2149
- ))}
2150
- </div>
2151
- </div>
2152
- ) : null}
2153
- {brandLayoutThumbnails.length ? (
2154
- <div className="mt-4">
2155
- <p className="text-xs font-medium">
2156
- {t("creativeContext.layouts")}
2157
- </p>
2158
- <div className="mt-2 grid grid-cols-3 gap-2">
2159
- {brandLayoutThumbnails.map((thumbnail) => (
2160
- <AccessScopedThumbnail
2161
- key={thumbnail.itemVersionId}
2162
- itemId={thumbnail.itemId}
2163
- itemVersionId={thumbnail.itemVersionId}
2164
- className="aspect-video w-full rounded border border-border object-cover"
2165
- />
2166
- ))}
2167
- </div>
2168
- </div>
2169
- ) : null}
2170
- {brandVoicePreview ? (
2171
- <div className="mt-4">
2172
- <p className="text-xs font-medium">
2173
- {t("creativeContext.voice")}
3401
+ ) : null}
3402
+ </div>
3403
+ <Button
3404
+ type="button"
3405
+ variant="ghost"
3406
+ size="sm"
3407
+ onClick={() => setSelectedPackId(null)}
3408
+ >
3409
+ {t("creativeContext.cancel")}
3410
+ </Button>
3411
+ </div>
3412
+ {packQuery.data?.pack?.members.length ? (
3413
+ <div className="mt-3 divide-y divide-border/60">
3414
+ {packQuery.data.pack.members.map((member) => (
3415
+ <div key={member.id} className="py-2 text-xs">
3416
+ <p className="font-medium">{member.itemId}</p>
3417
+ {member.reason ? (
3418
+ <p className="mt-0.5 text-muted-foreground">
3419
+ {t("creativeContext.influence")}: {member.reason}
2174
3420
  </p>
2175
- <blockquote className="mt-1 border-s-2 border-border ps-3 text-sm text-muted-foreground">
2176
- {brandVoicePreview}
2177
- </blockquote>
2178
- </div>
2179
- ) : null}
2180
- <div className="mt-4 flex flex-wrap gap-2">
2181
- <Button
2182
- type="button"
2183
- disabled={publishBrandDna.isPending}
2184
- onClick={() => void publishProposal()}
2185
- >
2186
- <IconSparkles />
2187
- {publishBrandDna.isPending
2188
- ? t("creativeContext.applyingBrandContext")
2189
- : t("creativeContext.applyBrandContext")}
2190
- </Button>
2191
- <Button asChild type="button" variant="outline">
2192
- <a href="/agent">
2193
- {t("creativeContext.generateWithContext")}
2194
- <IconArrowUpRight />
2195
- </a>
2196
- </Button>
3421
+ ) : null}
2197
3422
  </div>
2198
- {publishedMessage ? (
2199
- <p className="mt-2 text-xs text-muted-foreground">
2200
- {publishedMessage}
2201
- </p>
2202
- ) : null}
2203
- </div>
3423
+ ))}
2204
3424
  </div>
2205
- </div>
2206
- ) : importJob.error ? (
2207
- <p className="mt-2 text-sm text-destructive">
2208
- {importJob.error}
2209
- </p>
2210
- ) : (
2211
- <p className="mt-2 text-xs text-muted-foreground">
2212
- {formatNumber(importJob.progressCurrent)} /{" "}
2213
- {formatNumber(importJob.progressTotal ?? 0)}
2214
- </p>
2215
- )}
2216
- </div>
2217
- ) : null}
2218
- {promotionPreview ? (
2219
- <div className="mt-4 rounded-md border border-border p-4">
2220
- <h3 className="text-sm font-semibold">
2221
- {t("creativeContext.promoteToOrganization")}
2222
- </h3>
3425
+ ) : null}
3426
+ </div>
3427
+ ) : null}
3428
+ </section>
3429
+ ) : null}
3430
+
3431
+ {libraryView === "items" ? (
3432
+ <section className="border-t border-border/70 pt-6">
3433
+ <div>
3434
+ <h2 className="text-lg font-semibold">
3435
+ {t("creativeContext.searchTitle")}
3436
+ </h2>
2223
3437
  <p className="mt-1 text-sm text-muted-foreground">
2224
- {t("creativeContext.promotionDescription")}
3438
+ {t("creativeContext.searchDescription")}
2225
3439
  </p>
2226
- <dl className="mt-3 grid gap-2 text-xs sm:grid-cols-3">
2227
- <div>
2228
- <dt className="text-muted-foreground">
2229
- {t("creativeContext.sourceReference")}
2230
- </dt>
2231
- <dd className="mt-0.5 truncate font-medium">
2232
- {promotionPreview.containerRef}
2233
- </dd>
2234
- </div>
2235
- <div>
2236
- <dt className="text-muted-foreground">
2237
- {t("creativeContext.itemsLabel", { count: "" })}
2238
- </dt>
2239
- <dd className="mt-0.5 font-medium">
2240
- {formatNumber(promotionPreview.itemCount)}
2241
- </dd>
2242
- </div>
2243
- <div>
2244
- <dt className="text-muted-foreground">
2245
- {t("creativeContext.restrictedItems", { count: "" })}
2246
- </dt>
2247
- <dd className="mt-0.5 font-medium">
2248
- {formatNumber(promotionPreview.restrictedItemCount)}
2249
- </dd>
2250
- </div>
2251
- </dl>
3440
+ </div>
3441
+ <form
3442
+ className="mt-4 flex gap-2"
3443
+ onSubmit={(event) => void search(event)}
3444
+ >
3445
+ <div className="relative min-w-0 flex-1">
3446
+ <IconSearch className="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
3447
+ <Input
3448
+ type="search"
3449
+ value={query}
3450
+ onChange={(event) => setQuery(event.target.value)}
3451
+ placeholder={t("creativeContext.searchPlaceholder")}
3452
+ aria-label={t("creativeContext.searchPlaceholder")}
3453
+ className="ps-9"
3454
+ />
3455
+ </div>
2252
3456
  <Button
2253
- type="button"
2254
- className="mt-4"
2255
- disabled={manageSource.isPending}
2256
- onClick={() => void confirmPromotion()}
3457
+ type="submit"
3458
+ variant="outline"
3459
+ disabled={
3460
+ !query.trim() ||
3461
+ (!sources.length &&
3462
+ !selectedLibraryContextId &&
3463
+ !contextState.state.pinnedPackId) ||
3464
+ searchContext.isPending
3465
+ }
2257
3466
  >
2258
- {t("creativeContext.promoteToOrganization")}
3467
+ <IconSearch />
3468
+ {t("creativeContext.searchTitle")}
2259
3469
  </Button>
2260
- </div>
2261
- ) : null}
2262
- {promotionMessage ? (
2263
- <p className="mt-2 text-xs text-muted-foreground">
2264
- {promotionMessage}
2265
- </p>
2266
- ) : null}
2267
- {setupError ? (
2268
- <p className="mt-2 text-sm text-destructive">{setupError}</p>
2269
- ) : null}
2270
- </section>
2271
-
2272
- <section className="border-t border-border/70 pt-6">
2273
- <h2 className="text-lg font-semibold">
2274
- {t("creativeContext.sourcesTitle")}
2275
- </h2>
2276
- {sources.length ? (
2277
- <div className="mt-3">
2278
- {sources.map((source) => (
2279
- <SourceRow
2280
- key={source.id}
2281
- source={source}
2282
- refreshing={
2283
- refreshSource.isPending &&
2284
- refreshSource.variables?.sourceId === source.id
2285
- }
2286
- canReview={canManageScope}
2287
- canPromote={
2288
- libraryScope === "user" &&
2289
- Boolean(org?.orgId) &&
2290
- canManageOrg &&
2291
- source.visibility === "private"
2292
- }
2293
- onRefresh={refresh}
2294
- onReview={(selected) =>
2295
- void openItemCuration(selected, "restricted")
2296
- }
2297
- onCurate={(selected) => void openItemCuration(selected)}
2298
- onPromote={(selected) =>
2299
- void previewSourcePromotion(selected)
2300
- }
2301
- onPause={(selected) => void pauseSource(selected)}
2302
- onRestore={(selected) => void restoreSource(selected)}
2303
- onDelete={setDeleteSource}
2304
- />
2305
- ))}
2306
- </div>
2307
- ) : (
2308
- <div className="mt-4 rounded-md border border-dashed border-border p-6 text-center">
2309
- <IconBooks className="mx-auto size-7 text-muted-foreground" />
2310
- <h3 className="mt-3 text-sm font-semibold">
2311
- {t("creativeContext.noSourcesTitle")}
2312
- </h3>
2313
- <p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
2314
- {t("creativeContext.noSourcesDescription")}
3470
+ </form>
3471
+ {searchError ? (
3472
+ <p className="mt-4 text-sm text-destructive">{searchError}</p>
3473
+ ) : !searchContext.data && !searchContext.isPending ? (
3474
+ <p className="mt-4 text-sm text-muted-foreground">
3475
+ {t("creativeContext.searchPrompt")}
2315
3476
  </p>
2316
- <Button asChild variant="outline" size="sm" className="mt-4">
2317
- <a href={connectionsHref}>
2318
- {t("creativeContext.connectSources")}
2319
- <IconArrowUpRight />
2320
- </a>
2321
- </Button>
2322
- </div>
2323
- )}
2324
- {reviewSource ? (
2325
- <ItemCuration
2326
- source={reviewSource}
2327
- items={reviewedItems}
2328
- busy={reviewItems.isPending}
2329
- onReview={(operation, itemId) =>
2330
- void reviewContextItem(operation, itemId)
2331
- }
2332
- onClose={() => {
2333
- setReviewSource(null);
2334
- setReviewedItems([]);
2335
- }}
2336
- />
2337
- ) : null}
2338
- {reviewError ? (
2339
- <p className="mt-2 text-sm text-destructive">{reviewError}</p>
2340
- ) : null}
2341
- {refreshMessage ? (
2342
- <p className="mt-2 text-xs text-muted-foreground">
2343
- {refreshMessage}
2344
- </p>
2345
- ) : null}
2346
- {lifecycleMessage ? (
2347
- <p className="mt-2 text-xs text-muted-foreground">
2348
- {lifecycleMessage}
2349
- </p>
2350
- ) : null}
2351
- </section>
2352
-
2353
- <section className="border-t border-border/70 pt-6">
2354
- <h2 className="text-lg font-semibold">
2355
- {t("creativeContext.packsTitle")}
2356
- </h2>
2357
- {packs.length ? (
2358
- <div className="mt-3">
2359
- {packs.map((pack) => (
2360
- <PackRow
2361
- key={pack.id}
2362
- pack={pack}
2363
- pinned={contextState.state.pinnedPackId === pack.id}
2364
- disabled={savingState}
2365
- onPin={(packId) => void changePinnedPack(packId)}
2366
- onDetails={setSelectedPackId}
2367
- />
2368
- ))}
2369
- </div>
2370
- ) : (
2371
- <p className="mt-3 rounded-md border border-dashed border-border p-5 text-sm text-muted-foreground">
2372
- {t("creativeContext.noPacks")}
2373
- </p>
2374
- )}
2375
- {selectedPackId ? (
2376
- <div className="mt-4 rounded-md border border-border p-4">
2377
- <div className="flex items-start justify-between gap-3">
2378
- <div>
2379
- <h3 className="text-sm font-semibold">
2380
- {packQuery.data?.pack?.name ??
2381
- t("creativeContext.packDetails")}
2382
- </h3>
2383
- {packQuery.data?.pack?.derivedFromPackId ? (
2384
- <p className="mt-1 text-xs text-muted-foreground">
2385
- {t("creativeContext.influence")}:{" "}
2386
- {packQuery.data.pack.derivedFromPackId}
2387
- </p>
2388
- ) : null}
2389
- </div>
2390
- <Button
2391
- type="button"
2392
- variant="ghost"
2393
- size="sm"
2394
- onClick={() => setSelectedPackId(null)}
2395
- >
2396
- {t("creativeContext.cancel")}
2397
- </Button>
3477
+ ) : searchContext.isPending ? (
3478
+ <div className="mt-4 space-y-2">
3479
+ <Skeleton className="h-20 w-full" />
3480
+ <Skeleton className="h-20 w-full" />
2398
3481
  </div>
2399
- {packQuery.data?.pack?.members.length ? (
2400
- <div className="mt-3 divide-y divide-border/60">
2401
- {packQuery.data.pack.members.map((member) => (
2402
- <div key={member.id} className="py-2 text-xs">
2403
- <p className="font-medium">{member.itemId}</p>
2404
- {member.reason ? (
2405
- <p className="mt-0.5 text-muted-foreground">
2406
- {t("creativeContext.influence")}: {member.reason}
3482
+ ) : searchContext.data?.results.length ? (
3483
+ <div className="mt-4 space-y-2">
3484
+ <p className="text-xs text-muted-foreground">
3485
+ {t("creativeContext.resultsLabel", {
3486
+ count: formatNumber(searchContext.data.results.length),
3487
+ })}
3488
+ </p>
3489
+ {searchContext.data.results.map((result) => (
3490
+ <article
3491
+ key={`${result.itemVersionId}:${result.chunkId ?? "item"}`}
3492
+ className="rounded-md border border-border/70 p-3"
3493
+ >
3494
+ <div className="flex items-start justify-between gap-3">
3495
+ <div className="min-w-0">
3496
+ <h3 className="truncate text-sm font-medium">
3497
+ {result.title}
3498
+ </h3>
3499
+ <p className="mt-0.5 text-xs text-muted-foreground">
3500
+ {result.sourceName} · {result.kind}
2407
3501
  </p>
3502
+ </div>
3503
+ {result.canonicalUrl ? (
3504
+ <Button asChild variant="ghost" size="icon">
3505
+ <a
3506
+ href={result.canonicalUrl}
3507
+ target="_blank"
3508
+ rel="noreferrer"
3509
+ aria-label={result.title}
3510
+ >
3511
+ <IconArrowUpRight />
3512
+ </a>
3513
+ </Button>
2408
3514
  ) : null}
2409
3515
  </div>
2410
- ))}
2411
- </div>
2412
- ) : null}
2413
- </div>
2414
- ) : null}
2415
- </section>
2416
-
2417
- <section className="border-t border-border/70 pt-6">
2418
- <div>
2419
- <h2 className="text-lg font-semibold">
2420
- {t("creativeContext.searchTitle")}
2421
- </h2>
2422
- <p className="mt-1 text-sm text-muted-foreground">
2423
- {t("creativeContext.searchDescription")}
2424
- </p>
2425
- </div>
2426
- <form
2427
- className="mt-4 flex gap-2"
2428
- onSubmit={(event) => void search(event)}
2429
- >
2430
- <div className="relative min-w-0 flex-1">
2431
- <IconSearch className="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
2432
- <Input
2433
- type="search"
2434
- value={query}
2435
- onChange={(event) => setQuery(event.target.value)}
2436
- placeholder={t("creativeContext.searchPlaceholder")}
2437
- aria-label={t("creativeContext.searchPlaceholder")}
2438
- className="ps-9"
2439
- />
2440
- </div>
2441
- <Button
2442
- type="submit"
2443
- variant="outline"
2444
- disabled={
2445
- !query.trim() || !sources.length || searchContext.isPending
2446
- }
2447
- >
2448
- <IconSearch />
2449
- {t("creativeContext.searchTitle")}
2450
- </Button>
2451
- </form>
2452
- {searchError ? (
2453
- <p className="mt-4 text-sm text-destructive">{searchError}</p>
2454
- ) : !searchContext.data && !searchContext.isPending ? (
2455
- <p className="mt-4 text-sm text-muted-foreground">
2456
- {t("creativeContext.searchPrompt")}
2457
- </p>
2458
- ) : searchContext.isPending ? (
2459
- <div className="mt-4 space-y-2">
2460
- <Skeleton className="h-20 w-full" />
2461
- <Skeleton className="h-20 w-full" />
2462
- </div>
2463
- ) : searchContext.data?.results.length ? (
2464
- <div className="mt-4 space-y-2">
2465
- <p className="text-xs text-muted-foreground">
2466
- {t("creativeContext.resultsLabel", {
2467
- count: formatNumber(searchContext.data.results.length),
2468
- })}
3516
+ <p className="mt-2 line-clamp-3 text-sm leading-relaxed text-muted-foreground">
3517
+ {result.excerpt}
3518
+ </p>
3519
+ </article>
3520
+ ))}
3521
+ </div>
3522
+ ) : (
3523
+ <p className="mt-4 text-sm text-muted-foreground">
3524
+ {t("creativeContext.noResults")}
2469
3525
  </p>
2470
- {searchContext.data.results.map((result) => (
2471
- <article
2472
- key={`${result.itemVersionId}:${result.chunkId ?? "item"}`}
2473
- className="rounded-md border border-border/70 p-3"
2474
- >
2475
- <div className="flex items-start justify-between gap-3">
2476
- <div className="min-w-0">
2477
- <h3 className="truncate text-sm font-medium">
2478
- {result.title}
2479
- </h3>
2480
- <p className="mt-0.5 text-xs text-muted-foreground">
2481
- {result.sourceName} · {result.kind}
2482
- </p>
2483
- </div>
2484
- {result.canonicalUrl ? (
2485
- <Button asChild variant="ghost" size="icon">
2486
- <a
2487
- href={result.canonicalUrl}
2488
- target="_blank"
2489
- rel="noreferrer"
2490
- aria-label={result.title}
2491
- >
2492
- <IconArrowUpRight />
2493
- </a>
2494
- </Button>
2495
- ) : null}
2496
- </div>
2497
- <p className="mt-2 line-clamp-3 text-sm leading-relaxed text-muted-foreground">
2498
- {result.excerpt}
2499
- </p>
2500
- </article>
2501
- ))}
2502
- </div>
2503
- ) : (
2504
- <p className="mt-4 text-sm text-muted-foreground">
2505
- {t("creativeContext.noResults")}
2506
- </p>
2507
- )}
2508
- </section>
3526
+ )}
3527
+ </section>
3528
+ ) : null}
2509
3529
  </>
2510
3530
  )}
3531
+ <ContextPreviewSheet
3532
+ manifest={previewManifest}
3533
+ onOpenChange={(open) => {
3534
+ if (!open) setPreviewManifest(null);
3535
+ }}
3536
+ />
3537
+ <AlertDialog
3538
+ open={Boolean(membershipUpdateCandidate)}
3539
+ onOpenChange={(open) => {
3540
+ if (!open && !updatingMembershipId)
3541
+ setMembershipUpdateCandidate(null);
3542
+ }}
3543
+ >
3544
+ <AlertDialogContent>
3545
+ <AlertDialogHeader>
3546
+ <AlertDialogTitle>
3547
+ {t("creativeContext.submitUpdateTitle")}
3548
+ </AlertDialogTitle>
3549
+ <AlertDialogDescription>
3550
+ {t("creativeContext.submitUpdateDescription", {
3551
+ name: membershipUpdateCandidate?.title ?? "",
3552
+ })}
3553
+ </AlertDialogDescription>
3554
+ </AlertDialogHeader>
3555
+ <AlertDialogFooter>
3556
+ <AlertDialogCancel disabled={Boolean(updatingMembershipId)}>
3557
+ {t("creativeContext.cancel")}
3558
+ </AlertDialogCancel>
3559
+ <AlertDialogAction
3560
+ disabled={Boolean(updatingMembershipId)}
3561
+ onClick={(event) => {
3562
+ event.preventDefault();
3563
+ void submitLatestContextMembershipUpdate();
3564
+ }}
3565
+ >
3566
+ {updatingMembershipId
3567
+ ? t("creativeContext.submittingUpdate")
3568
+ : t("creativeContext.submitUpdate")}
3569
+ </AlertDialogAction>
3570
+ </AlertDialogFooter>
3571
+ </AlertDialogContent>
3572
+ </AlertDialog>
2511
3573
  <AlertDialog
2512
3574
  open={Boolean(deleteSource)}
2513
3575
  onOpenChange={(open) => {