@weirdfingers/baseboards 0.6.2 → 0.7.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 (57) hide show
  1. package/dist/index.js +54 -28
  2. package/dist/index.js.map +1 -1
  3. package/package.json +1 -1
  4. package/templates/README.md +2 -0
  5. package/templates/api/.env.example +3 -0
  6. package/templates/api/config/generators.yaml +58 -0
  7. package/templates/api/pyproject.toml +1 -1
  8. package/templates/api/src/boards/__init__.py +1 -1
  9. package/templates/api/src/boards/api/endpoints/storage.py +85 -4
  10. package/templates/api/src/boards/api/endpoints/uploads.py +1 -2
  11. package/templates/api/src/boards/database/connection.py +98 -58
  12. package/templates/api/src/boards/generators/implementations/fal/audio/__init__.py +4 -0
  13. package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_text_to_speech.py +176 -0
  14. package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_tts_turbo.py +195 -0
  15. package/templates/api/src/boards/generators/implementations/fal/image/__init__.py +14 -0
  16. package/templates/api/src/boards/generators/implementations/fal/image/bytedance_seedream_v45_edit.py +219 -0
  17. package/templates/api/src/boards/generators/implementations/fal/image/gemini_25_flash_image_edit.py +208 -0
  18. package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_15_edit.py +216 -0
  19. package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_5.py +177 -0
  20. package/templates/api/src/boards/generators/implementations/fal/image/reve_edit.py +178 -0
  21. package/templates/api/src/boards/generators/implementations/fal/image/reve_text_to_image.py +155 -0
  22. package/templates/api/src/boards/generators/implementations/fal/image/seedream_v45_text_to_image.py +180 -0
  23. package/templates/api/src/boards/generators/implementations/fal/video/__init__.py +18 -0
  24. package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_pro.py +168 -0
  25. package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_standard.py +159 -0
  26. package/templates/api/src/boards/generators/implementations/fal/video/veed_fabric_1_0.py +180 -0
  27. package/templates/api/src/boards/generators/implementations/fal/video/veo31.py +190 -0
  28. package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast.py +190 -0
  29. package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast_image_to_video.py +191 -0
  30. package/templates/api/src/boards/generators/implementations/fal/video/veo31_first_last_frame_to_video.py +13 -6
  31. package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_image_to_video.py +212 -0
  32. package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_text_to_video.py +208 -0
  33. package/templates/api/src/boards/generators/implementations/kie/__init__.py +11 -0
  34. package/templates/api/src/boards/generators/implementations/kie/base.py +316 -0
  35. package/templates/api/src/boards/generators/implementations/kie/image/__init__.py +3 -0
  36. package/templates/api/src/boards/generators/implementations/kie/image/nano_banana_edit.py +190 -0
  37. package/templates/api/src/boards/generators/implementations/kie/utils.py +98 -0
  38. package/templates/api/src/boards/generators/implementations/kie/video/__init__.py +8 -0
  39. package/templates/api/src/boards/generators/implementations/kie/video/veo3.py +161 -0
  40. package/templates/api/src/boards/graphql/resolvers/upload.py +1 -1
  41. package/templates/web/package.json +4 -1
  42. package/templates/web/src/app/boards/[boardId]/page.tsx +156 -24
  43. package/templates/web/src/app/globals.css +3 -0
  44. package/templates/web/src/app/layout.tsx +15 -5
  45. package/templates/web/src/components/boards/ArtifactInputSlots.tsx +9 -9
  46. package/templates/web/src/components/boards/ArtifactPreview.tsx +34 -18
  47. package/templates/web/src/components/boards/GenerationGrid.tsx +101 -7
  48. package/templates/web/src/components/boards/GenerationInput.tsx +21 -21
  49. package/templates/web/src/components/boards/GeneratorSelector.tsx +232 -30
  50. package/templates/web/src/components/boards/UploadArtifact.tsx +385 -75
  51. package/templates/web/src/components/header.tsx +3 -1
  52. package/templates/web/src/components/theme-provider.tsx +10 -0
  53. package/templates/web/src/components/theme-toggle.tsx +75 -0
  54. package/templates/web/src/components/ui/alert-dialog.tsx +157 -0
  55. package/templates/web/src/components/ui/toast.tsx +128 -0
  56. package/templates/web/src/components/ui/toaster.tsx +35 -0
  57. package/templates/web/src/components/ui/use-toast.ts +186 -0
@@ -1,5 +1,17 @@
1
- import { useGeneratorSelection } from "@weirdfingers/boards";
1
+ import { useState } from "react";
2
+ import { useGeneratorSelection, useGeneration } from "@weirdfingers/boards";
2
3
  import { ArtifactPreview } from "./ArtifactPreview";
4
+ import { useToast } from "@/components/ui/use-toast";
5
+ import {
6
+ AlertDialog,
7
+ AlertDialogAction,
8
+ AlertDialogCancel,
9
+ AlertDialogContent,
10
+ AlertDialogDescription,
11
+ AlertDialogFooter,
12
+ AlertDialogHeader,
13
+ AlertDialogTitle,
14
+ } from "@/components/ui/alert-dialog";
3
15
 
4
16
  interface Generation {
5
17
  id: string;
@@ -14,24 +26,49 @@ interface Generation {
14
26
  interface GenerationGridProps {
15
27
  generations: Generation[];
16
28
  onGenerationClick?: (generation: Generation) => void;
29
+ onRemoveSuccess?: () => void;
17
30
  }
18
31
 
19
32
  export function GenerationGrid({
20
33
  generations,
21
34
  onGenerationClick,
35
+ onRemoveSuccess: onRemoveSuccess,
22
36
  }: GenerationGridProps) {
23
37
  const { canArtifactBeAdded, addArtifactToSlot } = useGeneratorSelection();
38
+ const { deleteGeneration } = useGeneration();
39
+ const { toast } = useToast();
40
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
41
+ const [generationToDelete, setGenerationToDelete] =
42
+ useState<Generation | null>(null);
24
43
 
25
44
  if (generations.length === 0) {
26
45
  return (
27
- <div className="flex items-center justify-center py-12 text-gray-500">
46
+ <div className="flex items-center justify-center py-12 text-muted-foreground">
28
47
  <p>No generations yet. Create your first one below!</p>
29
48
  </div>
30
49
  );
31
50
  }
32
51
 
33
- const handleDownload = (generation: Generation) => {
34
- if (generation.storageUrl) {
52
+ const handleDownload = async (generation: Generation) => {
53
+ if (!generation.storageUrl) return;
54
+
55
+ try {
56
+ // Add download query parameter to force download instead of inline preview
57
+ // Also add custom filename based on generation ID
58
+ const url = new URL(generation.storageUrl);
59
+ url.searchParams.set("download", "true");
60
+ url.searchParams.set("filename", `gen-${generation.id}`);
61
+
62
+ // Create temporary anchor and trigger download
63
+ const link = document.createElement("a");
64
+ link.href = url.toString();
65
+ link.target = "_blank";
66
+ document.body.appendChild(link);
67
+ link.click();
68
+ document.body.removeChild(link);
69
+ } catch (error) {
70
+ console.error("Failed to download file:", error);
71
+ // Fallback to opening in new tab if download fails
35
72
  window.open(generation.storageUrl, "_blank");
36
73
  }
37
74
  };
@@ -51,17 +88,52 @@ export function GenerationGrid({
51
88
 
52
89
  if (success) {
53
90
  // Scroll to the generation input to show the user where the artifact was added
54
- const generationInput = document.getElementById('generation-input');
91
+ const generationInput = document.getElementById("generation-input");
55
92
  if (generationInput) {
56
- generationInput.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
93
+ generationInput.scrollIntoView({
94
+ behavior: "smooth",
95
+ block: "nearest",
96
+ });
57
97
  }
58
98
  }
59
99
  };
60
100
 
101
+ const handleDeleteClick = (generation: Generation) => {
102
+ setGenerationToDelete(generation);
103
+ setDeleteDialogOpen(true);
104
+ };
105
+
106
+ const handleDeleteConfirm = async () => {
107
+ if (!generationToDelete) return;
108
+
109
+ try {
110
+ await deleteGeneration(generationToDelete.id);
111
+ toast({
112
+ title: "Generation deleted",
113
+ description: "The generation has been permanently removed.",
114
+ });
115
+ setDeleteDialogOpen(false);
116
+ setGenerationToDelete(null);
117
+ // Refresh the board data to update the generations list
118
+ onRemoveSuccess?.();
119
+ } catch (error) {
120
+ console.error("Failed to delete generation:", error);
121
+ toast({
122
+ title: "Failed to delete generation",
123
+ description:
124
+ error instanceof Error ? error.message : "An unknown error occurred",
125
+ variant: "destructive",
126
+ });
127
+ setDeleteDialogOpen(false);
128
+ }
129
+ };
130
+
61
131
  return (
62
132
  <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
63
133
  {generations.map((generation) => {
64
- const canAdd = generation.status === "COMPLETED" && canArtifactBeAdded(generation.artifactType);
134
+ const canAdd =
135
+ generation.status === "COMPLETED" &&
136
+ canArtifactBeAdded(generation.artifactType);
65
137
 
66
138
  return (
67
139
  <ArtifactPreview
@@ -77,9 +149,31 @@ export function GenerationGrid({
77
149
  canAddToSlot={canAdd}
78
150
  onDownload={() => handleDownload(generation)}
79
151
  onPreview={() => handlePreview(generation)}
152
+ onDelete={() => handleDeleteClick(generation)}
80
153
  />
81
154
  );
82
155
  })}
156
+
157
+ <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
158
+ <AlertDialogContent>
159
+ <AlertDialogHeader>
160
+ <AlertDialogTitle>Delete generation?</AlertDialogTitle>
161
+ <AlertDialogDescription>
162
+ This action cannot be undone. The generation will be permanently
163
+ deleted.
164
+ </AlertDialogDescription>
165
+ </AlertDialogHeader>
166
+ <AlertDialogFooter>
167
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
168
+ <AlertDialogAction
169
+ onClick={handleDeleteConfirm}
170
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
171
+ >
172
+ Delete
173
+ </AlertDialogAction>
174
+ </AlertDialogFooter>
175
+ </AlertDialogContent>
176
+ </AlertDialog>
83
177
  </div>
84
178
  );
85
179
  }
@@ -122,9 +122,9 @@ export function GenerationInput({
122
122
  .every((s) => selectedArtifacts.has(s.name)));
123
123
 
124
124
  return (
125
- <div className="border border-gray-300 rounded-lg bg-white shadow-sm">
125
+ <div className="border border-border rounded-lg bg-background shadow-sm">
126
126
  {/* Generator selector header */}
127
- <div className="px-4 py-3 border-b border-gray-200 flex items-center justify-center">
127
+ <div className="px-4 py-3 border-b border-border flex items-center justify-center">
128
128
  <GeneratorSelector
129
129
  generators={generators}
130
130
  selectedGenerator={selectedGenerator}
@@ -134,7 +134,7 @@ export function GenerationInput({
134
134
 
135
135
  {/* Artifact input slots (for generators like lipsync) */}
136
136
  {needsArtifactInputs && (
137
- <div className="px-4 py-4 border-b border-gray-200">
137
+ <div className="px-4 py-4 border-b border-border">
138
138
  <ArtifactInputSlots
139
139
  slots={artifactSlots}
140
140
  selectedArtifacts={selectedArtifacts}
@@ -142,7 +142,7 @@ export function GenerationInput({
142
142
  onSelectArtifact={handleSelectArtifact}
143
143
  />
144
144
  {artifactSlots.every((s) => selectedArtifacts.has(s.name)) && (
145
- <div className="mt-3 flex items-center gap-2 text-sm text-green-600">
145
+ <div className="mt-3 flex items-center gap-2 text-sm text-success">
146
146
  <svg
147
147
  className="w-4 h-4"
148
148
  fill="none"
@@ -164,7 +164,7 @@ export function GenerationInput({
164
164
 
165
165
  {/* Attached image preview (if any) */}
166
166
  {attachedImage && (
167
- <div className="px-4 py-3 border-b border-gray-200">
167
+ <div className="px-4 py-3 border-b border-border">
168
168
  <div className="flex items-center gap-3">
169
169
  <Image
170
170
  src={attachedImage.thumbnailUrl || attachedImage.storageUrl || ""}
@@ -175,13 +175,13 @@ export function GenerationInput({
175
175
  />
176
176
  <div className="flex-1">
177
177
  <p className="text-sm font-medium">Image attached</p>
178
- <p className="text-xs text-gray-500">
178
+ <p className="text-xs text-muted-foreground">
179
179
  ID: {attachedImage.id.substring(0, 8)}
180
180
  </p>
181
181
  </div>
182
182
  <button
183
183
  onClick={() => setAttachedImage(null)}
184
- className="p-1 hover:bg-gray-100 rounded"
184
+ className="p-1 hover:bg-muted rounded"
185
185
  >
186
186
  <X className="w-4 h-4" />
187
187
  </button>
@@ -209,18 +209,18 @@ export function GenerationInput({
209
209
  <div className="flex items-center gap-2">
210
210
  <button
211
211
  onClick={() => setShowSettings(!showSettings)}
212
- className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
212
+ className="p-2 hover:bg-muted rounded-lg transition-colors"
213
213
  title="Settings"
214
214
  >
215
- <Settings className="w-5 h-5 text-gray-600" />
215
+ <Settings className="w-5 h-5 text-muted-foreground" />
216
216
  </button>
217
217
  <button
218
218
  onClick={handleSubmit}
219
219
  disabled={!canSubmit}
220
220
  className={`p-3 rounded-full transition-all ${
221
221
  canSubmit
222
- ? "bg-orange-500 hover:bg-orange-600 text-white shadow-lg"
223
- : "bg-gray-200 text-gray-400 cursor-not-allowed"
222
+ ? "bg-primary hover:bg-primary/90 text-primary-foreground shadow-lg"
223
+ : "bg-muted text-muted-foreground cursor-not-allowed"
224
224
  }`}
225
225
  title="Generate (⌘+Enter)"
226
226
  >
@@ -231,14 +231,14 @@ export function GenerationInput({
231
231
 
232
232
  {/* Settings panel (collapsed by default) */}
233
233
  {showSettings && (
234
- <div className="px-4 py-4 border-t border-gray-200 bg-gray-50">
234
+ <div className="px-4 py-4 border-t border-border bg-muted/50">
235
235
  {!parsedSchema || parsedSchema.settingsFields.length === 0 ? (
236
- <p className="text-sm text-gray-600">
236
+ <p className="text-sm text-muted-foreground">
237
237
  No additional settings available for this generator
238
238
  </p>
239
239
  ) : (
240
240
  <div>
241
- <h3 className="text-sm font-medium text-gray-900 mb-4">
241
+ <h3 className="text-sm font-medium text-foreground mb-4">
242
242
  Generator Settings
243
243
  </h3>
244
244
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -246,12 +246,12 @@ export function GenerationInput({
246
246
  <div key={field.fieldName} className="space-y-1.5">
247
247
  <label
248
248
  htmlFor={field.fieldName}
249
- className="block text-sm font-medium text-gray-700"
249
+ className="block text-sm font-medium text-foreground"
250
250
  >
251
251
  {field.title}
252
252
  </label>
253
253
  {field.description && (
254
- <p className="text-xs text-gray-500">{field.description}</p>
254
+ <p className="text-xs text-muted-foreground">{field.description}</p>
255
255
  )}
256
256
 
257
257
  {/* Slider control */}
@@ -274,9 +274,9 @@ export function GenerationInput({
274
274
  : parseFloat(e.target.value),
275
275
  })
276
276
  }
277
- className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-orange-500"
277
+ className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
278
278
  />
279
- <div className="flex justify-between text-xs text-gray-600">
279
+ <div className="flex justify-between text-xs text-muted-foreground">
280
280
  <span>{field.min}</span>
281
281
  <span className="font-medium">
282
282
  {String(settings[field.fieldName] ?? field.default ?? field.min)}
@@ -297,7 +297,7 @@ export function GenerationInput({
297
297
  [field.fieldName]: e.target.value,
298
298
  })
299
299
  }
300
- className="block w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
300
+ className="block w-full px-3 py-2 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary"
301
301
  >
302
302
  {field.options.map((option) => (
303
303
  <option key={option} value={option}>
@@ -324,7 +324,7 @@ export function GenerationInput({
324
324
  : parseFloat(e.target.value),
325
325
  })
326
326
  }
327
- className="block w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
327
+ className="block w-full px-3 py-2 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary"
328
328
  />
329
329
  )}
330
330
 
@@ -341,7 +341,7 @@ export function GenerationInput({
341
341
  })
342
342
  }
343
343
  pattern={field.pattern}
344
- className="block w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
344
+ className="block w-full px-3 py-2 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary"
345
345
  />
346
346
  )}
347
347
  </div>
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Zap, Check } from "lucide-react";
3
+ import { Zap, Check, Search } from "lucide-react";
4
4
  import type { JSONSchema7 } from "@weirdfingers/boards";
5
5
  import { useGeneratorSelection } from "@weirdfingers/boards";
6
6
  import {
@@ -9,6 +9,7 @@ import {
9
9
  DropdownMenuItem,
10
10
  DropdownMenuTrigger,
11
11
  } from "@/components/ui/dropdown-menu";
12
+ import { useState, useMemo, useEffect, useDeferredValue, useRef } from "react";
12
13
 
13
14
  export interface GeneratorInfo {
14
15
  name: string;
@@ -23,12 +24,102 @@ interface GeneratorSelectorProps {
23
24
  onSelect: (generator: GeneratorInfo) => void;
24
25
  }
25
26
 
27
+ const MRU_STORAGE_KEY = "boards-generator-mru";
28
+ const MRU_MAX_SIZE = 3;
29
+
26
30
  export function GeneratorSelector({
27
31
  generators,
28
32
  selectedGenerator,
29
33
  onSelect,
30
34
  }: GeneratorSelectorProps) {
31
35
  const { setSelectedGenerator } = useGeneratorSelection();
36
+ const [searchInput, setSearchInput] = useState("");
37
+ const deferredSearch = useDeferredValue(searchInput);
38
+ const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
39
+ const [mruGenerators, setMruGenerators] = useState<string[]>([]);
40
+ const [isOpen, setIsOpen] = useState(false);
41
+ const searchInputRef = useRef<HTMLInputElement>(null);
42
+
43
+ // Load MRU from localStorage on mount
44
+ useEffect(() => {
45
+ try {
46
+ const stored = localStorage.getItem(MRU_STORAGE_KEY);
47
+ if (stored) {
48
+ const parsed = JSON.parse(stored);
49
+ if (Array.isArray(parsed)) {
50
+ setMruGenerators(parsed);
51
+ }
52
+ }
53
+ } catch (error) {
54
+ console.error("Failed to load MRU generators:", error);
55
+ }
56
+ }, []);
57
+
58
+ // Focus search input when dropdown opens
59
+ useEffect(() => {
60
+ if (isOpen) {
61
+ // Use requestAnimationFrame to wait for the DOM to update
62
+ requestAnimationFrame(() => {
63
+ searchInputRef.current?.focus();
64
+ });
65
+ }
66
+ }, [isOpen]);
67
+
68
+ // Get unique artifact types from generators
69
+ const artifactTypes = useMemo(() => {
70
+ const types = new Set<string>();
71
+ generators.forEach((gen) => types.add(gen.artifactType));
72
+ return Array.from(types).sort();
73
+ }, [generators]);
74
+
75
+ // Filter generators based on search and type
76
+ const filteredGenerators = useMemo(() => {
77
+ let filtered = generators;
78
+
79
+ // Filter by type if any selected
80
+ if (selectedTypes.size > 0) {
81
+ filtered = filtered.filter((gen) => selectedTypes.has(gen.artifactType));
82
+ }
83
+
84
+ // Filter by search text - split by spaces and require all terms to match
85
+ if (deferredSearch.trim()) {
86
+ const searchTerms = deferredSearch
87
+ .toLowerCase()
88
+ .split(/\s+/)
89
+ .filter((term) => term.length > 0);
90
+
91
+ filtered = filtered.filter((gen) => {
92
+ const searchableText = `${gen.name} ${gen.description}`.toLowerCase();
93
+ // All search terms must be present in the combined name + description
94
+ return searchTerms.every((term) => searchableText.includes(term));
95
+ });
96
+ }
97
+
98
+ return filtered;
99
+ }, [generators, selectedTypes, deferredSearch]);
100
+
101
+ // Get MRU generators (only when no search input)
102
+ const mruList = useMemo(() => {
103
+ if (searchInput.trim()) {
104
+ return [];
105
+ }
106
+
107
+ return mruGenerators
108
+ .map((name) => generators.find((gen) => gen.name === name))
109
+ .filter((gen): gen is GeneratorInfo => gen !== undefined)
110
+ .filter((gen) =>
111
+ selectedTypes.size > 0 ? selectedTypes.has(gen.artifactType) : true
112
+ );
113
+ }, [mruGenerators, generators, searchInput, selectedTypes]);
114
+
115
+ // Get remaining generators (excluding MRU)
116
+ const remainingGenerators = useMemo(() => {
117
+ if (!searchInput.trim() && mruList.length > 0) {
118
+ const mruNames = new Set(mruList.map((gen) => gen.name));
119
+ return filteredGenerators.filter((gen) => !mruNames.has(gen.name));
120
+ }
121
+ return filteredGenerators;
122
+ }, [filteredGenerators, mruList, searchInput]);
32
123
 
33
124
  const getGeneratorIcon = (name: string) => {
34
125
  // You can customize icons per generator here
@@ -39,57 +130,168 @@ export function GeneratorSelector({
39
130
  // Update both local state and context
40
131
  setSelectedGenerator(generator);
41
132
  onSelect(generator);
133
+
134
+ // Update MRU
135
+ const newMru = [
136
+ generator.name,
137
+ ...mruGenerators.filter((name) => name !== generator.name),
138
+ ].slice(0, MRU_MAX_SIZE);
139
+
140
+ setMruGenerators(newMru);
141
+ try {
142
+ localStorage.setItem(MRU_STORAGE_KEY, JSON.stringify(newMru));
143
+ } catch (error) {
144
+ console.error("Failed to save MRU generators:", error);
145
+ }
146
+ };
147
+
148
+ const toggleType = (type: string) => {
149
+ setSelectedTypes((prev) => {
150
+ const next = new Set(prev);
151
+ if (next.has(type)) {
152
+ next.delete(type);
153
+ } else {
154
+ next.add(type);
155
+ }
156
+ return next;
157
+ });
42
158
  };
43
159
 
44
160
  return (
45
- <DropdownMenu>
161
+ <DropdownMenu onOpenChange={setIsOpen}>
46
162
  <DropdownMenuTrigger asChild>
47
- <button className="px-4 py-2 bg-white border border-gray-300 rounded-lg shadow-sm hover:bg-gray-50 flex items-center gap-2">
163
+ <button className="px-4 py-2 bg-background border border-border rounded-lg shadow-sm hover:bg-muted/50 flex items-center gap-2">
48
164
  {selectedGenerator ? (
49
165
  <>
50
166
  {getGeneratorIcon(selectedGenerator.name)}
51
167
  <span className="font-medium">{selectedGenerator.name}</span>
52
168
  </>
53
169
  ) : (
54
- <span className="text-gray-500">Select Generator</span>
170
+ <span className="text-muted-foreground">Select Generator</span>
55
171
  )}
56
172
  </button>
57
173
  </DropdownMenuTrigger>
58
174
 
59
175
  <DropdownMenuContent
60
- className="min-w-[250px] max-w-[400px] max-h-[400px] overflow-y-auto"
176
+ className="min-w-[250px] max-w-[400px] max-h-[500px] overflow-y-auto"
61
177
  align="start"
62
178
  side="bottom"
63
179
  sideOffset={8}
64
180
  collisionPadding={8}
65
181
  >
66
- {generators.map((generator) => (
67
- <DropdownMenuItem
68
- key={generator.name}
69
- onClick={() => handleSelect(generator)}
70
- className="px-4 py-3 flex items-start gap-3 cursor-pointer"
71
- >
72
- <div className="flex-shrink-0 mt-0.5">
73
- {getGeneratorIcon(generator.name)}
182
+ {/* Search Input */}
183
+ <div className="px-3 py-2 sticky top-0 bg-background border-b border-border z-10">
184
+ <div className="relative">
185
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
186
+ <input
187
+ ref={searchInputRef}
188
+ type="text"
189
+ placeholder="Search generators..."
190
+ value={searchInput}
191
+ onChange={(e) => setSearchInput(e.target.value)}
192
+ className="w-full pl-8 pr-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
193
+ onClick={(e) => e.stopPropagation()}
194
+ onKeyDown={(e) => e.stopPropagation()}
195
+ />
196
+ </div>
197
+
198
+ {/* Type Filter Pills */}
199
+ {artifactTypes.length > 1 && (
200
+ <div className="flex flex-wrap gap-1.5 mt-2">
201
+ {artifactTypes.map((type) => (
202
+ <button
203
+ key={type}
204
+ onClick={(e) => {
205
+ e.stopPropagation();
206
+ toggleType(type);
207
+ }}
208
+ className={`px-2 py-1 text-xs rounded-full border transition-colors ${
209
+ selectedTypes.has(type)
210
+ ? "bg-primary text-primary-foreground border-primary"
211
+ : "bg-background border-border text-muted-foreground hover:bg-muted"
212
+ }`}
213
+ >
214
+ {type}
215
+ </button>
216
+ ))}
74
217
  </div>
75
- <div className="flex-1 min-w-0">
76
- <div className="flex items-center gap-2">
77
- <span className="font-medium text-sm">
78
- {generator.name}
79
- </span>
80
- <span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
81
- {generator.artifactType}
82
- </span>
83
- </div>
84
- <p className="text-xs text-gray-600 mt-1">
85
- {generator.description}
86
- </p>
218
+ )}
219
+ </div>
220
+
221
+ {/* MRU Section */}
222
+ {mruList.length > 0 && (
223
+ <>
224
+ <div className="px-3 py-2 text-xs font-semibold text-muted-foreground">
225
+ Recently Used
87
226
  </div>
88
- {selectedGenerator?.name === generator.name && (
89
- <Check className="w-4 h-4 text-green-600 flex-shrink-0" />
90
- )}
91
- </DropdownMenuItem>
92
- ))}
227
+ {mruList.map((generator) => (
228
+ <DropdownMenuItem
229
+ key={`mru-${generator.name}`}
230
+ onClick={() => handleSelect(generator)}
231
+ className="px-4 py-3 flex items-start gap-3 cursor-pointer"
232
+ >
233
+ <div className="flex-shrink-0 mt-0.5">
234
+ {getGeneratorIcon(generator.name)}
235
+ </div>
236
+ <div className="flex-1 min-w-0">
237
+ <div className="flex items-center gap-2">
238
+ <span className="font-medium text-sm">
239
+ {generator.name}
240
+ </span>
241
+ <span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">
242
+ {generator.artifactType}
243
+ </span>
244
+ </div>
245
+ <p className="text-xs text-muted-foreground mt-1">
246
+ {generator.description}
247
+ </p>
248
+ </div>
249
+ {selectedGenerator?.name === generator.name && (
250
+ <Check className="w-4 h-4 text-success flex-shrink-0" />
251
+ )}
252
+ </DropdownMenuItem>
253
+ ))}
254
+ <div className="border-t border-border my-1" />
255
+ </>
256
+ )}
257
+
258
+ {/* All Generators Section */}
259
+ {mruList.length > 0 && remainingGenerators.length > 0 && (
260
+ <div className="px-3 py-2 text-xs font-semibold text-muted-foreground">
261
+ All Generators
262
+ </div>
263
+ )}
264
+ {remainingGenerators.length > 0 ? (
265
+ remainingGenerators.map((generator) => (
266
+ <DropdownMenuItem
267
+ key={generator.name}
268
+ onClick={() => handleSelect(generator)}
269
+ className="px-4 py-3 flex items-start gap-3 cursor-pointer"
270
+ >
271
+ <div className="flex-shrink-0 mt-0.5">
272
+ {getGeneratorIcon(generator.name)}
273
+ </div>
274
+ <div className="flex-1 min-w-0">
275
+ <div className="flex items-center gap-2">
276
+ <span className="font-medium text-sm">{generator.name}</span>
277
+ <span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">
278
+ {generator.artifactType}
279
+ </span>
280
+ </div>
281
+ <p className="text-xs text-muted-foreground mt-1">
282
+ {generator.description}
283
+ </p>
284
+ </div>
285
+ {selectedGenerator?.name === generator.name && (
286
+ <Check className="w-4 h-4 text-success flex-shrink-0" />
287
+ )}
288
+ </DropdownMenuItem>
289
+ ))
290
+ ) : (
291
+ <div className="px-4 py-3 text-sm text-muted-foreground text-center">
292
+ No generators found
293
+ </div>
294
+ )}
93
295
  </DropdownMenuContent>
94
296
  </DropdownMenu>
95
297
  );