@weirdfingers/baseboards 0.4.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.
@@ -1,5 +1,25 @@
1
- import { FileVideo, Volume2, FileText, Image as ImageIcon } from "lucide-react";
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
- return (
70
- <div className="relative w-full h-full">
71
- {previewUrl ? (
72
- <Image
73
- src={previewUrl}
74
- alt="Video thumbnail"
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
- width={512}
77
- height={512}
160
+ loop
78
161
  />
79
- ) : (
80
- <div className="flex items-center justify-center h-full bg-gray-100">
81
- <FileVideo className="w-12 h-12 text-gray-400" />
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={`relative aspect-square rounded-lg overflow-hidden border border-gray-200 ${
118
- onClick ? "cursor-pointer hover:opacity-80 transition-opacity" : ""
119
- }`}
120
- onClick={onClick}
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
- {renderContent()}
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
- <ArtifactPreview
34
- key={generation.id}
35
- artifactType={generation.artifactType}
36
- storageUrl={generation.storageUrl}
37
- thumbnailUrl={generation.thumbnailUrl}
38
- status={generation.status}
39
- errorMessage={generation.errorMessage}
40
- onClick={() => onGenerationClick?.(generation)}
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
- ParsedGeneratorSchema,
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 [selectedGenerator, setSelectedGenerator] =
39
- useState<GeneratorInfo | null>(generators[0] || null);
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
- // Parse input schema using the toolkit's schema parser
49
- const parsedSchema = useMemo((): ParsedGeneratorSchema => {
50
- if (!selectedGenerator) {
51
- return { artifactSlots: [], promptField: null, settingsFields: [] };
50
+ // Initialize selected generator if not set
51
+ useEffect(() => {
52
+ if (!selectedGenerator && generators.length > 0) {
53
+ setSelectedGenerator(generators[0]);
52
54
  }
53
- return parseGeneratorSchema(selectedGenerator.inputSchema);
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.artifactSlots]);
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.settingsFields.forEach((field) => {
70
- if (field.default !== undefined) {
71
- defaults[field.fieldName] = field.default;
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.settingsFields]);
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={() => onSelect(generator)}
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">