@weirdfingers/baseboards 0.6.2 → 0.8.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.
- package/dist/index.js +54 -28
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/README.md +2 -0
- package/templates/api/.env.example +3 -0
- package/templates/api/config/generators.yaml +58 -0
- package/templates/api/pyproject.toml +1 -1
- package/templates/api/src/boards/__init__.py +1 -1
- package/templates/api/src/boards/api/endpoints/storage.py +85 -4
- package/templates/api/src/boards/api/endpoints/uploads.py +1 -2
- package/templates/api/src/boards/database/connection.py +98 -58
- package/templates/api/src/boards/generators/implementations/fal/audio/__init__.py +4 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_text_to_speech.py +176 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_tts_turbo.py +195 -0
- package/templates/api/src/boards/generators/implementations/fal/image/__init__.py +14 -0
- package/templates/api/src/boards/generators/implementations/fal/image/bytedance_seedream_v45_edit.py +219 -0
- package/templates/api/src/boards/generators/implementations/fal/image/gemini_25_flash_image_edit.py +208 -0
- package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_15_edit.py +216 -0
- package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_5.py +177 -0
- package/templates/api/src/boards/generators/implementations/fal/image/reve_edit.py +178 -0
- package/templates/api/src/boards/generators/implementations/fal/image/reve_text_to_image.py +155 -0
- package/templates/api/src/boards/generators/implementations/fal/image/seedream_v45_text_to_image.py +180 -0
- package/templates/api/src/boards/generators/implementations/fal/video/__init__.py +18 -0
- package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_pro.py +168 -0
- package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_standard.py +159 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veed_fabric_1_0.py +180 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31.py +190 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast.py +190 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast_image_to_video.py +191 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_first_last_frame_to_video.py +13 -6
- package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_image_to_video.py +212 -0
- package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_text_to_video.py +208 -0
- package/templates/api/src/boards/generators/implementations/kie/__init__.py +11 -0
- package/templates/api/src/boards/generators/implementations/kie/base.py +316 -0
- package/templates/api/src/boards/generators/implementations/kie/image/__init__.py +3 -0
- package/templates/api/src/boards/generators/implementations/kie/image/nano_banana_edit.py +190 -0
- package/templates/api/src/boards/generators/implementations/kie/utils.py +98 -0
- package/templates/api/src/boards/generators/implementations/kie/video/__init__.py +8 -0
- package/templates/api/src/boards/generators/implementations/kie/video/veo3.py +161 -0
- package/templates/api/src/boards/graphql/resolvers/upload.py +1 -1
- package/templates/web/package.json +4 -1
- package/templates/web/src/app/boards/[boardId]/page.tsx +156 -24
- package/templates/web/src/app/globals.css +3 -0
- package/templates/web/src/app/layout.tsx +15 -5
- package/templates/web/src/components/boards/ArtifactInputSlots.tsx +9 -9
- package/templates/web/src/components/boards/ArtifactPreview.tsx +34 -18
- package/templates/web/src/components/boards/GenerationGrid.tsx +101 -7
- package/templates/web/src/components/boards/GenerationInput.tsx +21 -21
- package/templates/web/src/components/boards/GeneratorSelector.tsx +232 -30
- package/templates/web/src/components/boards/UploadArtifact.tsx +385 -75
- package/templates/web/src/components/header.tsx +3 -1
- package/templates/web/src/components/theme-provider.tsx +10 -0
- package/templates/web/src/components/theme-toggle.tsx +75 -0
- package/templates/web/src/components/ui/alert-dialog.tsx +157 -0
- package/templates/web/src/components/ui/toast.tsx +128 -0
- package/templates/web/src/components/ui/toaster.tsx +35 -0
- package/templates/web/src/components/ui/use-toast.ts +186 -0
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
import {
|
|
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-
|
|
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(
|
|
91
|
+
const generationInput = document.getElementById("generation-input");
|
|
55
92
|
if (generationInput) {
|
|
56
|
-
generationInput.scrollIntoView({
|
|
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 =
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
223
|
-
: "bg-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-[
|
|
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
|
-
{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
{
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
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
|
);
|