@weirdfingers/baseboards 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/api/config/generators.yaml +9 -0
- package/templates/api/src/boards/__init__.py +1 -1
- package/templates/api/src/boards/config.py +7 -7
- package/templates/api/src/boards/generators/implementations/fal/image/__init__.py +2 -0
- package/templates/api/src/boards/generators/implementations/fal/image/nano_banana_pro.py +179 -0
- package/templates/api/src/boards/generators/implementations/fal/video/__init__.py +4 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_image_to_video.py +183 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_reference_to_video.py +172 -0
- package/templates/api/src/boards/jobs/repository.py +3 -3
- package/templates/api/src/boards/workers/context.py +7 -3
- package/templates/web/package.json +1 -1
- package/templates/web/src/app/boards/[boardId]/page.tsx +44 -64
- package/templates/web/src/components/boards/ArtifactInputSlots.tsx +67 -3
- package/templates/web/src/components/boards/ArtifactPreview.tsx +292 -20
- package/templates/web/src/components/boards/GenerationGrid.tsx +51 -11
- package/templates/web/src/components/boards/GenerationInput.tsx +26 -23
- package/templates/web/src/components/boards/GeneratorSelector.tsx +10 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import React, { useState } from "react";
|
|
3
4
|
import { FileVideo, Volume2, X } from "lucide-react";
|
|
4
5
|
import Image from "next/image";
|
|
5
6
|
|
|
@@ -29,6 +30,8 @@ export function ArtifactInputSlots({
|
|
|
29
30
|
availableArtifacts,
|
|
30
31
|
onSelectArtifact,
|
|
31
32
|
}: ArtifactInputSlotsProps) {
|
|
33
|
+
const [dragOverSlot, setDragOverSlot] = useState<string | null>(null);
|
|
34
|
+
|
|
32
35
|
const getIcon = (type: string) => {
|
|
33
36
|
switch (type.toLowerCase()) {
|
|
34
37
|
case "video":
|
|
@@ -47,6 +50,56 @@ export function ArtifactInputSlots({
|
|
|
47
50
|
);
|
|
48
51
|
};
|
|
49
52
|
|
|
53
|
+
const handleDragOver = (
|
|
54
|
+
e: React.DragEvent,
|
|
55
|
+
slotType: string,
|
|
56
|
+
slotName: string
|
|
57
|
+
) => {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
e.stopPropagation();
|
|
60
|
+
|
|
61
|
+
// Check if the dragged artifact type matches the slot type
|
|
62
|
+
try {
|
|
63
|
+
const data = e.dataTransfer.types.includes("application/json");
|
|
64
|
+
if (data) {
|
|
65
|
+
e.dataTransfer.dropEffect = "copy";
|
|
66
|
+
setDragOverSlot(slotName);
|
|
67
|
+
}
|
|
68
|
+
} catch (err) {
|
|
69
|
+
// Ignore errors during drag over
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const handleDragLeave = (e: React.DragEvent) => {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
e.stopPropagation();
|
|
76
|
+
setDragOverSlot(null);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const handleDrop = (
|
|
80
|
+
e: React.DragEvent,
|
|
81
|
+
slotType: string,
|
|
82
|
+
slotName: string
|
|
83
|
+
) => {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
e.stopPropagation();
|
|
86
|
+
setDragOverSlot(null);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const jsonData = e.dataTransfer.getData("application/json");
|
|
90
|
+
if (jsonData) {
|
|
91
|
+
const artifact = JSON.parse(jsonData) as Generation;
|
|
92
|
+
|
|
93
|
+
// Check if artifact type matches slot type
|
|
94
|
+
if (artifact.artifactType.toLowerCase() === slotType.toLowerCase()) {
|
|
95
|
+
onSelectArtifact(slotName, artifact);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.error("Error handling drop:", err);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
50
103
|
return (
|
|
51
104
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
52
105
|
{slots.map((slot) => {
|
|
@@ -100,11 +153,22 @@ export function ArtifactInputSlots({
|
|
|
100
153
|
</div>
|
|
101
154
|
) : (
|
|
102
155
|
// Show slot placeholder
|
|
103
|
-
<div
|
|
156
|
+
<div
|
|
157
|
+
className={`border-2 border-dashed rounded-lg p-6 transition-all ${
|
|
158
|
+
dragOverSlot === slot.name
|
|
159
|
+
? "border-orange-500 bg-orange-50"
|
|
160
|
+
: "border-gray-300 hover:border-gray-400"
|
|
161
|
+
}`}
|
|
162
|
+
onDragOver={(e) => handleDragOver(e, slot.type, slot.name)}
|
|
163
|
+
onDragLeave={handleDragLeave}
|
|
164
|
+
onDrop={(e) => handleDrop(e, slot.type, slot.name)}
|
|
165
|
+
>
|
|
104
166
|
<div className="flex flex-col items-center justify-center text-center">
|
|
105
167
|
<div className="mb-2">{getIcon(slot.type)}</div>
|
|
106
168
|
<p className="text-sm font-medium text-gray-700 mb-1">
|
|
107
|
-
|
|
169
|
+
{dragOverSlot === slot.name
|
|
170
|
+
? `Drop ${slot.type} here`
|
|
171
|
+
: `Add a ${slot.type}`}
|
|
108
172
|
</p>
|
|
109
173
|
{matchingArtifacts.length > 0 ? (
|
|
110
174
|
<select
|
|
@@ -128,7 +192,7 @@ export function ArtifactInputSlots({
|
|
|
128
192
|
</select>
|
|
129
193
|
) : (
|
|
130
194
|
<p className="text-xs text-gray-500 mt-1">
|
|
131
|
-
No {slot.type} artifacts in this board yet
|
|
195
|
+
No {slot.type} artifacts in this board yet.
|
|
132
196
|
</p>
|
|
133
197
|
)}
|
|
134
198
|
</div>
|
|
@@ -1,5 +1,25 @@
|
|
|
1
|
-
import
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
FileVideo,
|
|
4
|
+
Volume2,
|
|
5
|
+
FileText,
|
|
6
|
+
Image as ImageIcon,
|
|
7
|
+
Plus,
|
|
8
|
+
MoreVertical,
|
|
9
|
+
GripVertical,
|
|
10
|
+
Download,
|
|
11
|
+
Eye,
|
|
12
|
+
Play,
|
|
13
|
+
Pause,
|
|
14
|
+
RotateCcw,
|
|
15
|
+
} from "lucide-react";
|
|
2
16
|
import Image from "next/image";
|
|
17
|
+
import {
|
|
18
|
+
DropdownMenu,
|
|
19
|
+
DropdownMenuContent,
|
|
20
|
+
DropdownMenuItem,
|
|
21
|
+
DropdownMenuTrigger,
|
|
22
|
+
} from "@/components/ui/dropdown-menu";
|
|
3
23
|
|
|
4
24
|
interface ArtifactPreviewProps {
|
|
5
25
|
artifactType: string;
|
|
@@ -8,6 +28,12 @@ interface ArtifactPreviewProps {
|
|
|
8
28
|
status: string;
|
|
9
29
|
errorMessage?: string | null;
|
|
10
30
|
onClick?: () => void;
|
|
31
|
+
onAddToSlot?: () => void;
|
|
32
|
+
canAddToSlot?: boolean;
|
|
33
|
+
onDownload?: () => void;
|
|
34
|
+
onPreview?: () => void;
|
|
35
|
+
artifactId?: string;
|
|
36
|
+
prompt?: string | null;
|
|
11
37
|
}
|
|
12
38
|
|
|
13
39
|
export function ArtifactPreview({
|
|
@@ -17,13 +43,67 @@ export function ArtifactPreview({
|
|
|
17
43
|
status,
|
|
18
44
|
errorMessage,
|
|
19
45
|
onClick,
|
|
46
|
+
onAddToSlot,
|
|
47
|
+
canAddToSlot = false,
|
|
48
|
+
onDownload,
|
|
49
|
+
onPreview,
|
|
50
|
+
artifactId,
|
|
51
|
+
prompt,
|
|
20
52
|
}: ArtifactPreviewProps) {
|
|
53
|
+
const [isPlaying, setIsPlaying] = React.useState(false);
|
|
54
|
+
const [currentTime, setCurrentTime] = React.useState(0);
|
|
55
|
+
const [duration, setDuration] = React.useState(0);
|
|
56
|
+
const audioRef = React.useRef<HTMLAudioElement>(null);
|
|
57
|
+
const videoRef = React.useRef<HTMLVideoElement>(null);
|
|
58
|
+
|
|
21
59
|
const isLoading = status === "PENDING" || status === "PROCESSING";
|
|
22
60
|
const isFailed = status === "FAILED" || status === "CANCELLED";
|
|
61
|
+
const isComplete = status === "COMPLETED";
|
|
23
62
|
|
|
24
63
|
// Determine which URL to use for preview
|
|
25
64
|
const previewUrl = thumbnailUrl || storageUrl;
|
|
26
65
|
|
|
66
|
+
// Media control functions
|
|
67
|
+
const handlePlayPause = (e: React.MouseEvent) => {
|
|
68
|
+
e.stopPropagation();
|
|
69
|
+
const mediaElement = artifactType === "AUDIO" ? audioRef.current : videoRef.current;
|
|
70
|
+
if (!mediaElement) return;
|
|
71
|
+
|
|
72
|
+
if (isPlaying) {
|
|
73
|
+
mediaElement.pause();
|
|
74
|
+
setIsPlaying(false);
|
|
75
|
+
} else {
|
|
76
|
+
mediaElement.play();
|
|
77
|
+
setIsPlaying(true);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const handleRestart = (e: React.MouseEvent) => {
|
|
82
|
+
e.stopPropagation();
|
|
83
|
+
const mediaElement = artifactType === "AUDIO" ? audioRef.current : videoRef.current;
|
|
84
|
+
if (!mediaElement) return;
|
|
85
|
+
|
|
86
|
+
mediaElement.currentTime = 0;
|
|
87
|
+
setCurrentTime(0);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handleTimeUpdate = () => {
|
|
91
|
+
const mediaElement = artifactType === "AUDIO" ? audioRef.current : videoRef.current;
|
|
92
|
+
if (!mediaElement) return;
|
|
93
|
+
setCurrentTime(mediaElement.currentTime);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleLoadedMetadata = () => {
|
|
97
|
+
const mediaElement = artifactType === "AUDIO" ? audioRef.current : videoRef.current;
|
|
98
|
+
if (!mediaElement) return;
|
|
99
|
+
setDuration(mediaElement.duration);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handleMediaEnded = () => {
|
|
103
|
+
setIsPlaying(false);
|
|
104
|
+
setCurrentTime(0);
|
|
105
|
+
};
|
|
106
|
+
|
|
27
107
|
const renderContent = () => {
|
|
28
108
|
if (isFailed) {
|
|
29
109
|
return (
|
|
@@ -66,28 +146,116 @@ export function ArtifactPreview({
|
|
|
66
146
|
);
|
|
67
147
|
|
|
68
148
|
case "VIDEO":
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
149
|
+
if (storageUrl) {
|
|
150
|
+
return (
|
|
151
|
+
<div className="relative w-full h-full">
|
|
152
|
+
<video
|
|
153
|
+
ref={videoRef}
|
|
154
|
+
src={storageUrl}
|
|
155
|
+
onTimeUpdate={handleTimeUpdate}
|
|
156
|
+
onLoadedMetadata={handleLoadedMetadata}
|
|
157
|
+
onEnded={handleMediaEnded}
|
|
158
|
+
preload="metadata"
|
|
75
159
|
className="w-full h-full object-cover"
|
|
76
|
-
|
|
77
|
-
height={512}
|
|
160
|
+
loop
|
|
78
161
|
/>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
162
|
+
<div className="absolute top-2 right-2 z-10 rounded-full bg-black/50 p-1.5">
|
|
163
|
+
<FileVideo className="w-4 h-4 text-white" />
|
|
164
|
+
</div>
|
|
165
|
+
{/* Video playback controls overlay */}
|
|
166
|
+
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
|
167
|
+
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center gap-2 pointer-events-auto">
|
|
168
|
+
<div className="flex items-center gap-2">
|
|
169
|
+
<button
|
|
170
|
+
onClick={handlePlayPause}
|
|
171
|
+
className="p-3 rounded-full bg-white/20 hover:bg-white/30 text-white transition-colors"
|
|
172
|
+
>
|
|
173
|
+
{isPlaying ? (
|
|
174
|
+
<Pause className="w-6 h-6" />
|
|
175
|
+
) : (
|
|
176
|
+
<Play className="w-6 h-6" />
|
|
177
|
+
)}
|
|
178
|
+
</button>
|
|
179
|
+
<button
|
|
180
|
+
onClick={handleRestart}
|
|
181
|
+
className="p-3 rounded-full bg-white/20 hover:bg-white/30 text-white transition-colors"
|
|
182
|
+
>
|
|
183
|
+
<RotateCcw className="w-6 h-6" />
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
{duration > 0 && (
|
|
187
|
+
<div className="text-sm text-white font-medium">
|
|
188
|
+
{Math.floor(currentTime)}s / {Math.floor(duration)}s
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
82
192
|
</div>
|
|
83
|
-
)}
|
|
84
|
-
<div className="absolute top-2 left-2 bg-black/50 rounded p-1">
|
|
85
|
-
<FileVideo className="w-5 h-5 text-white" />
|
|
86
193
|
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return (
|
|
197
|
+
<div className="flex items-center justify-center h-full bg-gray-100">
|
|
198
|
+
<FileVideo className="w-12 h-12 text-gray-400" />
|
|
87
199
|
</div>
|
|
88
200
|
);
|
|
89
201
|
|
|
90
202
|
case "AUDIO":
|
|
203
|
+
if (storageUrl) {
|
|
204
|
+
const truncatedPrompt = prompt
|
|
205
|
+
? prompt.length > 60
|
|
206
|
+
? prompt.substring(0, 60) + "..."
|
|
207
|
+
: prompt
|
|
208
|
+
: "Audio file";
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<div className="relative w-full h-full bg-gradient-to-br from-purple-500/10 to-blue-500/10">
|
|
212
|
+
<audio
|
|
213
|
+
ref={audioRef}
|
|
214
|
+
src={storageUrl}
|
|
215
|
+
onTimeUpdate={handleTimeUpdate}
|
|
216
|
+
onLoadedMetadata={handleLoadedMetadata}
|
|
217
|
+
onEnded={handleMediaEnded}
|
|
218
|
+
preload="metadata"
|
|
219
|
+
/>
|
|
220
|
+
|
|
221
|
+
<div className="flex flex-col items-center justify-center p-4 h-full">
|
|
222
|
+
<Volume2 className="w-8 h-8 text-primary mb-2" />
|
|
223
|
+
<p className="text-xs text-center text-foreground leading-relaxed">
|
|
224
|
+
{truncatedPrompt}
|
|
225
|
+
</p>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
{/* Audio playback controls overlay */}
|
|
229
|
+
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
|
230
|
+
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center gap-2 pointer-events-auto">
|
|
231
|
+
<div className="flex items-center gap-2">
|
|
232
|
+
<button
|
|
233
|
+
onClick={handlePlayPause}
|
|
234
|
+
className="p-3 rounded-full bg-white/20 hover:bg-white/30 text-white transition-colors"
|
|
235
|
+
>
|
|
236
|
+
{isPlaying ? (
|
|
237
|
+
<Pause className="w-6 h-6" />
|
|
238
|
+
) : (
|
|
239
|
+
<Play className="w-6 h-6" />
|
|
240
|
+
)}
|
|
241
|
+
</button>
|
|
242
|
+
<button
|
|
243
|
+
onClick={handleRestart}
|
|
244
|
+
className="p-3 rounded-full bg-white/20 hover:bg-white/30 text-white transition-colors"
|
|
245
|
+
>
|
|
246
|
+
<RotateCcw className="w-6 h-6" />
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
{duration > 0 && (
|
|
250
|
+
<div className="text-sm text-white font-medium">
|
|
251
|
+
{Math.floor(currentTime)}s / {Math.floor(duration)}s
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
91
259
|
return (
|
|
92
260
|
<div className="flex flex-col items-center justify-center h-full bg-gradient-to-br from-blue-900 to-blue-700">
|
|
93
261
|
<Volume2 className="w-12 h-12 text-white mb-2" />
|
|
@@ -114,12 +282,116 @@ export function ArtifactPreview({
|
|
|
114
282
|
|
|
115
283
|
return (
|
|
116
284
|
<div
|
|
117
|
-
className=
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
285
|
+
className="relative aspect-square rounded-lg overflow-hidden border border-gray-200 group"
|
|
286
|
+
draggable={isComplete && !!artifactId && canAddToSlot}
|
|
287
|
+
onDragStart={(e) => {
|
|
288
|
+
if (isComplete && artifactId) {
|
|
289
|
+
e.dataTransfer.setData(
|
|
290
|
+
"application/json",
|
|
291
|
+
JSON.stringify({
|
|
292
|
+
id: artifactId,
|
|
293
|
+
artifactType,
|
|
294
|
+
storageUrl,
|
|
295
|
+
thumbnailUrl,
|
|
296
|
+
})
|
|
297
|
+
);
|
|
298
|
+
e.dataTransfer.effectAllowed = "copy";
|
|
299
|
+
}
|
|
300
|
+
}}
|
|
121
301
|
>
|
|
122
|
-
|
|
302
|
+
<div
|
|
303
|
+
className={onClick ? "cursor-pointer aspect-square" : "aspect-square"}
|
|
304
|
+
onClick={onClick}
|
|
305
|
+
>
|
|
306
|
+
{renderContent()}
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
{/* Bottom overlay with controls - show for all artifacts when not loading/failed */}
|
|
310
|
+
{!isLoading && !isFailed && (
|
|
311
|
+
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
312
|
+
<div className="flex items-center justify-between gap-2">
|
|
313
|
+
{/* Drag handle - only for completed artifacts */}
|
|
314
|
+
{isComplete && (
|
|
315
|
+
<div
|
|
316
|
+
className="flex items-center gap-2 cursor-move text-white/80 hover:text-white"
|
|
317
|
+
title="Drag to input slot"
|
|
318
|
+
>
|
|
319
|
+
<GripVertical className="w-4 h-4" />
|
|
320
|
+
</div>
|
|
321
|
+
)}
|
|
322
|
+
|
|
323
|
+
<div
|
|
324
|
+
className={`flex items-center gap-2 ${
|
|
325
|
+
!isComplete ? "ml-auto" : ""
|
|
326
|
+
}`}
|
|
327
|
+
>
|
|
328
|
+
{/* Add button - only for completed artifacts */}
|
|
329
|
+
{isComplete && onAddToSlot && (
|
|
330
|
+
<button
|
|
331
|
+
onClick={(e) => {
|
|
332
|
+
e.stopPropagation();
|
|
333
|
+
onAddToSlot();
|
|
334
|
+
}}
|
|
335
|
+
disabled={!canAddToSlot}
|
|
336
|
+
className={`p-1.5 rounded transition-colors ${
|
|
337
|
+
canAddToSlot
|
|
338
|
+
? "bg-white/20 hover:bg-white/30 text-white cursor-pointer"
|
|
339
|
+
: "bg-white/10 text-white/40 cursor-not-allowed"
|
|
340
|
+
}`}
|
|
341
|
+
title={
|
|
342
|
+
canAddToSlot
|
|
343
|
+
? "Add to input slot"
|
|
344
|
+
: "No compatible input slots"
|
|
345
|
+
}
|
|
346
|
+
>
|
|
347
|
+
<Plus className="w-4 h-4" />
|
|
348
|
+
</button>
|
|
349
|
+
)}
|
|
350
|
+
|
|
351
|
+
{/* More options menu - show for all artifacts */}
|
|
352
|
+
{(onPreview || onDownload) && (
|
|
353
|
+
<DropdownMenu>
|
|
354
|
+
<DropdownMenuTrigger asChild>
|
|
355
|
+
<button
|
|
356
|
+
onClick={(e) => e.stopPropagation()}
|
|
357
|
+
className="p-1.5 rounded bg-white/20 hover:bg-white/30 text-white transition-colors"
|
|
358
|
+
title="More options"
|
|
359
|
+
>
|
|
360
|
+
<MoreVertical className="w-4 h-4" />
|
|
361
|
+
</button>
|
|
362
|
+
</DropdownMenuTrigger>
|
|
363
|
+
<DropdownMenuContent align="end" className="w-40">
|
|
364
|
+
{onPreview && (
|
|
365
|
+
<DropdownMenuItem
|
|
366
|
+
onClick={(e) => {
|
|
367
|
+
e.stopPropagation();
|
|
368
|
+
onPreview();
|
|
369
|
+
}}
|
|
370
|
+
className="cursor-pointer"
|
|
371
|
+
>
|
|
372
|
+
<Eye className="w-4 h-4 mr-2" />
|
|
373
|
+
Preview
|
|
374
|
+
</DropdownMenuItem>
|
|
375
|
+
)}
|
|
376
|
+
{onDownload && (
|
|
377
|
+
<DropdownMenuItem
|
|
378
|
+
onClick={(e) => {
|
|
379
|
+
e.stopPropagation();
|
|
380
|
+
onDownload();
|
|
381
|
+
}}
|
|
382
|
+
className="cursor-pointer"
|
|
383
|
+
>
|
|
384
|
+
<Download className="w-4 h-4 mr-2" />
|
|
385
|
+
Download
|
|
386
|
+
</DropdownMenuItem>
|
|
387
|
+
)}
|
|
388
|
+
</DropdownMenuContent>
|
|
389
|
+
</DropdownMenu>
|
|
390
|
+
)}
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
)}
|
|
123
395
|
</div>
|
|
124
396
|
);
|
|
125
397
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useGeneratorSelection } from "@weirdfingers/boards";
|
|
1
2
|
import { ArtifactPreview } from "./ArtifactPreview";
|
|
2
3
|
|
|
3
4
|
interface Generation {
|
|
@@ -19,6 +20,8 @@ export function GenerationGrid({
|
|
|
19
20
|
generations,
|
|
20
21
|
onGenerationClick,
|
|
21
22
|
}: GenerationGridProps) {
|
|
23
|
+
const { canArtifactBeAdded, addArtifactToSlot } = useGeneratorSelection();
|
|
24
|
+
|
|
22
25
|
if (generations.length === 0) {
|
|
23
26
|
return (
|
|
24
27
|
<div className="flex items-center justify-center py-12 text-gray-500">
|
|
@@ -27,19 +30,56 @@ export function GenerationGrid({
|
|
|
27
30
|
);
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
const handleDownload = (generation: Generation) => {
|
|
34
|
+
if (generation.storageUrl) {
|
|
35
|
+
window.open(generation.storageUrl, "_blank");
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const handlePreview = (generation: Generation) => {
|
|
40
|
+
// For now, use the same handler as onClick
|
|
41
|
+
onGenerationClick?.(generation);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const handleAddToSlot = (generation: Generation) => {
|
|
45
|
+
const success = addArtifactToSlot({
|
|
46
|
+
id: generation.id,
|
|
47
|
+
artifactType: generation.artifactType,
|
|
48
|
+
storageUrl: generation.storageUrl,
|
|
49
|
+
thumbnailUrl: generation.thumbnailUrl,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (success) {
|
|
53
|
+
// Scroll to the generation input to show the user where the artifact was added
|
|
54
|
+
const generationInput = document.getElementById('generation-input');
|
|
55
|
+
if (generationInput) {
|
|
56
|
+
generationInput.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
30
61
|
return (
|
|
31
62
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
32
|
-
{generations.map((generation) =>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
63
|
+
{generations.map((generation) => {
|
|
64
|
+
const canAdd = generation.status === "COMPLETED" && canArtifactBeAdded(generation.artifactType);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<ArtifactPreview
|
|
68
|
+
key={generation.id}
|
|
69
|
+
artifactId={generation.id}
|
|
70
|
+
artifactType={generation.artifactType}
|
|
71
|
+
storageUrl={generation.storageUrl}
|
|
72
|
+
thumbnailUrl={generation.thumbnailUrl}
|
|
73
|
+
status={generation.status}
|
|
74
|
+
errorMessage={generation.errorMessage}
|
|
75
|
+
onClick={() => onGenerationClick?.(generation)}
|
|
76
|
+
onAddToSlot={() => handleAddToSlot(generation)}
|
|
77
|
+
canAddToSlot={canAdd}
|
|
78
|
+
onDownload={() => handleDownload(generation)}
|
|
79
|
+
onPreview={() => handlePreview(generation)}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
})}
|
|
43
83
|
</div>
|
|
44
84
|
);
|
|
45
85
|
}
|
|
@@ -4,8 +4,7 @@ import { useState, useMemo, useEffect } from "react";
|
|
|
4
4
|
import { Settings, ArrowUp, X } from "lucide-react";
|
|
5
5
|
import Image from "next/image";
|
|
6
6
|
import {
|
|
7
|
-
|
|
8
|
-
parseGeneratorSchema,
|
|
7
|
+
useGeneratorSelection,
|
|
9
8
|
} from "@weirdfingers/boards";
|
|
10
9
|
import { GeneratorSelector, GeneratorInfo } from "./GeneratorSelector";
|
|
11
10
|
import { ArtifactInputSlots } from "./ArtifactInputSlots";
|
|
@@ -35,44 +34,49 @@ export function GenerationInput({
|
|
|
35
34
|
onSubmit,
|
|
36
35
|
isGenerating = false,
|
|
37
36
|
}: GenerationInputProps) {
|
|
38
|
-
const
|
|
39
|
-
|
|
37
|
+
const {
|
|
38
|
+
selectedGenerator,
|
|
39
|
+
setSelectedGenerator,
|
|
40
|
+
parsedSchema,
|
|
41
|
+
selectedArtifacts,
|
|
42
|
+
setSelectedArtifacts
|
|
43
|
+
} = useGeneratorSelection();
|
|
44
|
+
|
|
40
45
|
const [prompt, setPrompt] = useState("");
|
|
41
|
-
const [selectedArtifacts, setSelectedArtifacts] = useState<
|
|
42
|
-
Map<string, Generation>
|
|
43
|
-
>(new Map());
|
|
44
46
|
const [attachedImage, setAttachedImage] = useState<Generation | null>(null);
|
|
45
47
|
const [showSettings, setShowSettings] = useState(false);
|
|
46
48
|
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
|
47
49
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
if (!selectedGenerator) {
|
|
51
|
-
|
|
50
|
+
// Initialize selected generator if not set
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!selectedGenerator && generators.length > 0) {
|
|
53
|
+
setSelectedGenerator(generators[0]);
|
|
52
54
|
}
|
|
53
|
-
|
|
54
|
-
}, [selectedGenerator]);
|
|
55
|
+
}, [generators, selectedGenerator, setSelectedGenerator]);
|
|
55
56
|
|
|
56
57
|
const artifactSlots = useMemo(() => {
|
|
58
|
+
if (!parsedSchema) return [];
|
|
57
59
|
return parsedSchema.artifactSlots.map((slot) => ({
|
|
58
60
|
name: slot.fieldName,
|
|
59
61
|
type: slot.artifactType,
|
|
60
62
|
required: slot.required,
|
|
61
63
|
}));
|
|
62
|
-
}, [parsedSchema
|
|
64
|
+
}, [parsedSchema]);
|
|
63
65
|
|
|
64
66
|
const needsArtifactInputs = artifactSlots.length > 0;
|
|
65
67
|
|
|
66
68
|
// Initialize settings with defaults when generator changes
|
|
67
69
|
const defaultSettings = useMemo(() => {
|
|
68
70
|
const defaults: Record<string, unknown> = {};
|
|
69
|
-
parsedSchema
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
if (parsedSchema) {
|
|
72
|
+
parsedSchema.settingsFields.forEach((field) => {
|
|
73
|
+
if (field.default !== undefined) {
|
|
74
|
+
defaults[field.fieldName] = field.default;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
74
78
|
return defaults;
|
|
75
|
-
}, [parsedSchema
|
|
79
|
+
}, [parsedSchema]);
|
|
76
80
|
|
|
77
81
|
// Reset settings when generator changes or defaultSettings change
|
|
78
82
|
useEffect(() => {
|
|
@@ -89,9 +93,8 @@ export function GenerationInput({
|
|
|
89
93
|
settings,
|
|
90
94
|
});
|
|
91
95
|
|
|
92
|
-
// Reset form
|
|
96
|
+
// Reset form (but keep selected artifacts and generator)
|
|
93
97
|
setPrompt("");
|
|
94
|
-
setSelectedArtifacts(new Map());
|
|
95
98
|
setAttachedImage(null);
|
|
96
99
|
setSettings(defaultSettings);
|
|
97
100
|
};
|
|
@@ -229,7 +232,7 @@ export function GenerationInput({
|
|
|
229
232
|
{/* Settings panel (collapsed by default) */}
|
|
230
233
|
{showSettings && (
|
|
231
234
|
<div className="px-4 py-4 border-t border-gray-200 bg-gray-50">
|
|
232
|
-
{parsedSchema.settingsFields.length === 0 ? (
|
|
235
|
+
{!parsedSchema || parsedSchema.settingsFields.length === 0 ? (
|
|
233
236
|
<p className="text-sm text-gray-600">
|
|
234
237
|
No additional settings available for this generator
|
|
235
238
|
</p>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Zap, Check } from "lucide-react";
|
|
4
4
|
import type { JSONSchema7 } from "@weirdfingers/boards";
|
|
5
|
+
import { useGeneratorSelection } from "@weirdfingers/boards";
|
|
5
6
|
import {
|
|
6
7
|
DropdownMenu,
|
|
7
8
|
DropdownMenuContent,
|
|
@@ -27,11 +28,19 @@ export function GeneratorSelector({
|
|
|
27
28
|
selectedGenerator,
|
|
28
29
|
onSelect,
|
|
29
30
|
}: GeneratorSelectorProps) {
|
|
31
|
+
const { setSelectedGenerator } = useGeneratorSelection();
|
|
32
|
+
|
|
30
33
|
const getGeneratorIcon = (name: string) => {
|
|
31
34
|
// You can customize icons per generator here
|
|
32
35
|
return <Zap className="w-4 h-4" />;
|
|
33
36
|
};
|
|
34
37
|
|
|
38
|
+
const handleSelect = (generator: GeneratorInfo) => {
|
|
39
|
+
// Update both local state and context
|
|
40
|
+
setSelectedGenerator(generator);
|
|
41
|
+
onSelect(generator);
|
|
42
|
+
};
|
|
43
|
+
|
|
35
44
|
return (
|
|
36
45
|
<DropdownMenu>
|
|
37
46
|
<DropdownMenuTrigger asChild>
|
|
@@ -57,7 +66,7 @@ export function GeneratorSelector({
|
|
|
57
66
|
{generators.map((generator) => (
|
|
58
67
|
<DropdownMenuItem
|
|
59
68
|
key={generator.name}
|
|
60
|
-
onClick={() =>
|
|
69
|
+
onClick={() => handleSelect(generator)}
|
|
61
70
|
className="px-4 py-3 flex items-start gap-3 cursor-pointer"
|
|
62
71
|
>
|
|
63
72
|
<div className="flex-shrink-0 mt-0.5">
|