@weirdfingers/baseboards 0.4.1 → 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.
- 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
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
|
15
15
|
"@radix-ui/react-slot": "^1.2.3",
|
|
16
16
|
"@tailwindcss/postcss": "^4.1.13",
|
|
17
|
-
"@weirdfingers/boards": "^0.
|
|
17
|
+
"@weirdfingers/boards": "^0.5.0",
|
|
18
18
|
"class-variance-authority": "^0.7.1",
|
|
19
19
|
"clsx": "^2.0.0",
|
|
20
20
|
"graphql": "^16.11.0",
|
|
@@ -2,32 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { useParams } from "next/navigation";
|
|
5
|
-
import { useBoard, useGenerators, useGeneration } from "@weirdfingers/boards";
|
|
5
|
+
import { useBoard, useGenerators, useGeneration, GeneratorSelectionProvider } from "@weirdfingers/boards";
|
|
6
6
|
import { GenerationGrid } from "@/components/boards/GenerationGrid";
|
|
7
7
|
import { GenerationInput } from "@/components/boards/GenerationInput";
|
|
8
8
|
|
|
9
9
|
export default function BoardPage() {
|
|
10
10
|
const params = useParams();
|
|
11
11
|
const boardId = params.boardId as string;
|
|
12
|
-
console.log("[BoardPage] Rendering with boardId:", boardId);
|
|
13
12
|
|
|
14
|
-
const boardHookResult = useBoard(boardId);
|
|
15
|
-
console.log("[BoardPage] useBoard result:", boardHookResult);
|
|
16
13
|
const {
|
|
17
14
|
board,
|
|
18
15
|
loading: boardLoading,
|
|
19
16
|
error: boardError,
|
|
20
17
|
refresh: refreshBoard,
|
|
21
|
-
} =
|
|
18
|
+
} = useBoard(boardId);
|
|
22
19
|
|
|
23
20
|
// Fetch available generators
|
|
24
|
-
const generatorsHookResult = useGenerators();
|
|
25
|
-
console.log("[BoardPage] useGenerators result:", generatorsHookResult);
|
|
26
21
|
const {
|
|
27
22
|
generators,
|
|
28
23
|
loading: generatorsLoading,
|
|
29
24
|
error: generatorsError,
|
|
30
|
-
} =
|
|
25
|
+
} = useGenerators();
|
|
31
26
|
|
|
32
27
|
// Use generation hook for submitting generations and real-time progress
|
|
33
28
|
const {
|
|
@@ -38,10 +33,6 @@ export default function BoardPage() {
|
|
|
38
33
|
result,
|
|
39
34
|
} = useGeneration();
|
|
40
35
|
|
|
41
|
-
console.log("[BoardPage] board:", board);
|
|
42
|
-
console.log("[BoardPage] boardError:", boardError);
|
|
43
|
-
console.log("[BoardPage] board is null/undefined?", !board);
|
|
44
|
-
|
|
45
36
|
// Refresh board when a generation completes or fails
|
|
46
37
|
// MUST be before conditional returns to satisfy Rules of Hooks
|
|
47
38
|
React.useEffect(() => {
|
|
@@ -49,10 +40,6 @@ export default function BoardPage() {
|
|
|
49
40
|
progress &&
|
|
50
41
|
(progress.status === "completed" || progress.status === "failed")
|
|
51
42
|
) {
|
|
52
|
-
console.log(
|
|
53
|
-
"[BoardPage] Generation finished, refreshing board:",
|
|
54
|
-
progress.status
|
|
55
|
-
);
|
|
56
43
|
refreshBoard();
|
|
57
44
|
}
|
|
58
45
|
}, [progress, refreshBoard]);
|
|
@@ -121,12 +108,6 @@ export default function BoardPage() {
|
|
|
121
108
|
|
|
122
109
|
// Handle loading state
|
|
123
110
|
if (boardLoading || !board) {
|
|
124
|
-
console.log(
|
|
125
|
-
"[BoardPage] Showing loading spinner - boardLoading:",
|
|
126
|
-
boardLoading,
|
|
127
|
-
"board:",
|
|
128
|
-
board
|
|
129
|
-
);
|
|
130
111
|
return (
|
|
131
112
|
<div className="flex items-center justify-center min-h-screen">
|
|
132
113
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
|
|
@@ -134,8 +115,6 @@ export default function BoardPage() {
|
|
|
134
115
|
);
|
|
135
116
|
}
|
|
136
117
|
|
|
137
|
-
console.log("[BoardPage] Board loaded successfully:", board);
|
|
138
|
-
|
|
139
118
|
// Filter completed generations that can be used as inputs
|
|
140
119
|
const availableArtifacts = generations.filter(
|
|
141
120
|
(gen) => gen.status === "COMPLETED" && gen.storageUrl
|
|
@@ -186,47 +165,48 @@ export default function BoardPage() {
|
|
|
186
165
|
};
|
|
187
166
|
|
|
188
167
|
return (
|
|
189
|
-
<
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
<
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
/>
|
|
208
|
-
</div>
|
|
209
|
-
|
|
210
|
-
{/* Generation Input */}
|
|
211
|
-
<div className="sticky bottom-6 z-10">
|
|
212
|
-
{generatorsLoading ? (
|
|
213
|
-
<div className="bg-white rounded-lg shadow-lg p-6 text-center">
|
|
214
|
-
<p className="text-gray-500">Loading generators...</p>
|
|
215
|
-
</div>
|
|
216
|
-
) : generators.length === 0 ? (
|
|
217
|
-
<div className="bg-white rounded-lg shadow-lg p-6 text-center">
|
|
218
|
-
<p className="text-gray-500">No generators available</p>
|
|
219
|
-
</div>
|
|
220
|
-
) : (
|
|
221
|
-
<GenerationInput
|
|
222
|
-
generators={generators}
|
|
223
|
-
availableArtifacts={availableArtifacts}
|
|
224
|
-
onSubmit={handleGenerationSubmit}
|
|
225
|
-
isGenerating={isGenerating}
|
|
168
|
+
<GeneratorSelectionProvider>
|
|
169
|
+
<main className="min-h-screen bg-gray-50">
|
|
170
|
+
<div className="container mx-auto px-4 py-6 max-w-7xl">
|
|
171
|
+
{/* Header */}
|
|
172
|
+
<div className="mb-6">
|
|
173
|
+
<h1 className="text-3xl font-bold text-gray-900">{board.title}</h1>
|
|
174
|
+
{board.description && (
|
|
175
|
+
<p className="text-gray-600 mt-2">{board.description}</p>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{/* Generation Grid */}
|
|
180
|
+
<div className="mb-8">
|
|
181
|
+
<GenerationGrid
|
|
182
|
+
generations={generations}
|
|
183
|
+
onGenerationClick={() => {
|
|
184
|
+
// TODO: Open generation detail modal
|
|
185
|
+
}}
|
|
226
186
|
/>
|
|
227
|
-
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{/* Generation Input */}
|
|
190
|
+
<div id="generation-input" className="sticky bottom-6 z-10">
|
|
191
|
+
{generatorsLoading ? (
|
|
192
|
+
<div className="bg-white rounded-lg shadow-lg p-6 text-center">
|
|
193
|
+
<p className="text-gray-500">Loading generators...</p>
|
|
194
|
+
</div>
|
|
195
|
+
) : generators.length === 0 ? (
|
|
196
|
+
<div className="bg-white rounded-lg shadow-lg p-6 text-center">
|
|
197
|
+
<p className="text-gray-500">No generators available</p>
|
|
198
|
+
</div>
|
|
199
|
+
) : (
|
|
200
|
+
<GenerationInput
|
|
201
|
+
generators={generators}
|
|
202
|
+
availableArtifacts={availableArtifacts}
|
|
203
|
+
onSubmit={handleGenerationSubmit}
|
|
204
|
+
isGenerating={isGenerating}
|
|
205
|
+
/>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
228
208
|
</div>
|
|
229
|
-
</
|
|
230
|
-
</
|
|
209
|
+
</main>
|
|
210
|
+
</GeneratorSelectionProvider>
|
|
231
211
|
);
|
|
232
212
|
}
|
|
@@ -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
|
}
|