@weirdfingers/baseboards 0.5.2 → 0.6.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/README.md +4 -1
- package/dist/index.js +131 -11
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/api/alembic/env.py +9 -1
- package/templates/api/alembic/versions/20250101_000000_initial_schema.py +107 -49
- package/templates/api/alembic/versions/20251022_174729_remove_provider_name_from_generations.py +7 -3
- package/templates/api/alembic/versions/20251023_165852_switch_to_declarative_base_and_mapping.py +57 -1
- package/templates/api/alembic/versions/20251202_000000_add_artifact_lineage.py +134 -0
- package/templates/api/alembic/versions/2025925_62735_add_seed_data_for_default_tenant.py +8 -5
- package/templates/api/config/generators.yaml +111 -0
- package/templates/api/src/boards/__init__.py +1 -1
- package/templates/api/src/boards/api/app.py +2 -1
- package/templates/api/src/boards/api/endpoints/tenant_registration.py +1 -1
- package/templates/api/src/boards/api/endpoints/uploads.py +150 -0
- package/templates/api/src/boards/auth/factory.py +1 -1
- package/templates/api/src/boards/dbmodels/__init__.py +8 -22
- package/templates/api/src/boards/generators/artifact_resolution.py +45 -12
- package/templates/api/src/boards/generators/implementations/fal/audio/__init__.py +16 -1
- package/templates/api/src/boards/generators/implementations/fal/audio/beatoven_music_generation.py +171 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/beatoven_sound_effect_generation.py +167 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/elevenlabs_sound_effects_v2.py +194 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/elevenlabs_tts_eleven_v3.py +209 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/fal_elevenlabs_tts_turbo_v2_5.py +206 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/fal_minimax_speech_26_hd.py +237 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/minimax_speech_2_6_turbo.py +1 -1
- package/templates/api/src/boards/generators/implementations/fal/image/__init__.py +30 -0
- package/templates/api/src/boards/generators/implementations/fal/image/clarity_upscaler.py +220 -0
- package/templates/api/src/boards/generators/implementations/fal/image/crystal_upscaler.py +173 -0
- package/templates/api/src/boards/generators/implementations/fal/image/fal_ideogram_character.py +227 -0
- package/templates/api/src/boards/generators/implementations/fal/image/flux_2.py +203 -0
- package/templates/api/src/boards/generators/implementations/fal/image/flux_2_edit.py +230 -0
- package/templates/api/src/boards/generators/implementations/fal/image/flux_2_pro.py +204 -0
- package/templates/api/src/boards/generators/implementations/fal/image/flux_2_pro_edit.py +221 -0
- package/templates/api/src/boards/generators/implementations/fal/image/gemini_25_flash_image.py +177 -0
- package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_edit_image.py +182 -0
- package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_mini.py +167 -0
- package/templates/api/src/boards/generators/implementations/fal/image/ideogram_character_edit.py +299 -0
- package/templates/api/src/boards/generators/implementations/fal/image/ideogram_v2.py +190 -0
- package/templates/api/src/boards/generators/implementations/fal/image/nano_banana_pro_edit.py +226 -0
- package/templates/api/src/boards/generators/implementations/fal/image/qwen_image.py +249 -0
- package/templates/api/src/boards/generators/implementations/fal/image/qwen_image_edit.py +244 -0
- package/templates/api/src/boards/generators/implementations/fal/video/__init__.py +42 -0
- package/templates/api/src/boards/generators/implementations/fal/video/bytedance_seedance_v1_pro_text_to_video.py +209 -0
- package/templates/api/src/boards/generators/implementations/fal/video/creatify_lipsync.py +161 -0
- package/templates/api/src/boards/generators/implementations/fal/video/fal_bytedance_seedance_v1_pro_image_to_video.py +222 -0
- package/templates/api/src/boards/generators/implementations/fal/video/fal_minimax_hailuo_02_standard_text_to_video.py +152 -0
- package/templates/api/src/boards/generators/implementations/fal/video/fal_pixverse_lipsync.py +197 -0
- package/templates/api/src/boards/generators/implementations/fal/video/fal_sora_2_text_to_video.py +173 -0
- package/templates/api/src/boards/generators/implementations/fal/video/infinitalk.py +221 -0
- package/templates/api/src/boards/generators/implementations/fal/video/kling_video_v2_5_turbo_pro_image_to_video.py +175 -0
- package/templates/api/src/boards/generators/implementations/fal/video/minimax_hailuo_2_3_pro_image_to_video.py +153 -0
- package/templates/api/src/boards/generators/implementations/fal/video/sora2_image_to_video.py +172 -0
- package/templates/api/src/boards/generators/implementations/fal/video/sora_2_image_to_video_pro.py +175 -0
- package/templates/api/src/boards/generators/implementations/fal/video/sora_2_text_to_video_pro.py +163 -0
- package/templates/api/src/boards/generators/implementations/fal/video/sync_lipsync_v2_pro.py +155 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veed_lipsync.py +174 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo3.py +194 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_first_last_frame_to_video.py +1 -1
- package/templates/api/src/boards/generators/implementations/fal/video/wan_pro_image_to_video.py +158 -0
- package/templates/api/src/boards/graphql/access_control.py +1 -1
- package/templates/api/src/boards/graphql/mutations/root.py +16 -4
- package/templates/api/src/boards/graphql/resolvers/board.py +0 -2
- package/templates/api/src/boards/graphql/resolvers/generation.py +10 -233
- package/templates/api/src/boards/graphql/resolvers/lineage.py +381 -0
- package/templates/api/src/boards/graphql/resolvers/upload.py +463 -0
- package/templates/api/src/boards/graphql/types/generation.py +62 -26
- package/templates/api/src/boards/middleware.py +1 -1
- package/templates/api/src/boards/storage/factory.py +2 -2
- package/templates/api/src/boards/tenant_isolation.py +9 -9
- package/templates/api/src/boards/workers/actors.py +10 -1
- package/templates/web/package.json +1 -1
- package/templates/web/src/app/boards/[boardId]/page.tsx +14 -5
- package/templates/web/src/app/lineage/[generationId]/page.tsx +233 -0
- package/templates/web/src/components/boards/ArtifactPreview.tsx +20 -1
- package/templates/web/src/components/boards/UploadArtifact.tsx +253 -0
|
@@ -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.6.0",
|
|
18
18
|
"class-variance-authority": "^0.7.1",
|
|
19
19
|
"clsx": "^2.0.0",
|
|
20
20
|
"graphql": "^16.11.0",
|
|
@@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
|
|
|
5
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
|
+
import { UploadArtifact } from "@/components/boards/UploadArtifact";
|
|
8
9
|
|
|
9
10
|
export default function BoardPage() {
|
|
10
11
|
const params = useParams();
|
|
@@ -169,11 +170,19 @@ export default function BoardPage() {
|
|
|
169
170
|
<main className="min-h-screen bg-gray-50">
|
|
170
171
|
<div className="container mx-auto px-4 py-6 max-w-7xl">
|
|
171
172
|
{/* Header */}
|
|
172
|
-
<div className="mb-6">
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
<div className="mb-6 flex items-start justify-between">
|
|
174
|
+
<div>
|
|
175
|
+
<h1 className="text-3xl font-bold text-gray-900">{board.title}</h1>
|
|
176
|
+
{board.description && (
|
|
177
|
+
<p className="text-gray-600 mt-2">{board.description}</p>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
<UploadArtifact
|
|
181
|
+
boardId={boardId}
|
|
182
|
+
onUploadComplete={() => {
|
|
183
|
+
refreshBoard();
|
|
184
|
+
}}
|
|
185
|
+
/>
|
|
177
186
|
</div>
|
|
178
187
|
|
|
179
188
|
{/* Generation Grid */}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { useParams, useRouter } from "next/navigation";
|
|
5
|
+
import { useLineage, type AncestryNode, type DescendantNode } from "@weirdfingers/boards";
|
|
6
|
+
import { Card } from "@/components/ui/card";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { ArrowLeft, Loader2 } from "lucide-react";
|
|
9
|
+
|
|
10
|
+
export default function LineageExplorerPage() {
|
|
11
|
+
const params = useParams();
|
|
12
|
+
const router = useRouter();
|
|
13
|
+
const generationId = params.generationId as string;
|
|
14
|
+
|
|
15
|
+
const { ancestry, descendants, loading, error } = useLineage(generationId, {
|
|
16
|
+
maxDepth: 10,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
if (loading) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="container mx-auto p-6 flex items-center justify-center min-h-screen">
|
|
22
|
+
<div className="flex items-center gap-2">
|
|
23
|
+
<Loader2 className="h-6 w-6 animate-spin" />
|
|
24
|
+
<span>Loading lineage...</span>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (error) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="container mx-auto p-6">
|
|
33
|
+
<Card className="p-6 border-red-500">
|
|
34
|
+
<h2 className="text-xl font-bold text-red-600 mb-2">Error</h2>
|
|
35
|
+
<p className="text-red-600">{error.message}</p>
|
|
36
|
+
<Button
|
|
37
|
+
onClick={() => router.back()}
|
|
38
|
+
variant="outline"
|
|
39
|
+
className="mt-4"
|
|
40
|
+
>
|
|
41
|
+
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
42
|
+
Go Back
|
|
43
|
+
</Button>
|
|
44
|
+
</Card>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="container mx-auto p-6">
|
|
51
|
+
<div className="mb-6">
|
|
52
|
+
<Button
|
|
53
|
+
onClick={() => router.back()}
|
|
54
|
+
variant="outline"
|
|
55
|
+
className="mb-4"
|
|
56
|
+
>
|
|
57
|
+
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
58
|
+
Back
|
|
59
|
+
</Button>
|
|
60
|
+
<h1 className="text-3xl font-bold">Artifact Lineage Explorer</h1>
|
|
61
|
+
<p className="text-muted-foreground mt-2">
|
|
62
|
+
Explore the ancestry and descendants of generation {generationId}
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
67
|
+
{/* Ancestry Section */}
|
|
68
|
+
<Card className="p-6">
|
|
69
|
+
<h2 className="text-2xl font-bold mb-4">Ancestry</h2>
|
|
70
|
+
<p className="text-muted-foreground mb-4">
|
|
71
|
+
Shows all parent generations that contributed to this artifact
|
|
72
|
+
</p>
|
|
73
|
+
{ancestry ? (
|
|
74
|
+
<AncestryTree node={ancestry} currentGenerationId={generationId} />
|
|
75
|
+
) : (
|
|
76
|
+
<p className="text-muted-foreground">No ancestry data available</p>
|
|
77
|
+
)}
|
|
78
|
+
</Card>
|
|
79
|
+
|
|
80
|
+
{/* Descendants Section */}
|
|
81
|
+
<Card className="p-6">
|
|
82
|
+
<h2 className="text-2xl font-bold mb-4">Descendants</h2>
|
|
83
|
+
<p className="text-muted-foreground mb-4">
|
|
84
|
+
Shows all child generations that used this artifact as input
|
|
85
|
+
</p>
|
|
86
|
+
{descendants ? (
|
|
87
|
+
<DescendantTree node={descendants} currentGenerationId={generationId} />
|
|
88
|
+
) : (
|
|
89
|
+
<p className="text-muted-foreground">
|
|
90
|
+
No descendants data available
|
|
91
|
+
</p>
|
|
92
|
+
)}
|
|
93
|
+
</Card>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface AncestryTreeProps {
|
|
100
|
+
node: AncestryNode;
|
|
101
|
+
currentGenerationId: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function AncestryTree({ node, currentGenerationId }: AncestryTreeProps) {
|
|
105
|
+
const router = useRouter();
|
|
106
|
+
const isCurrentGeneration = node.generation.id === currentGenerationId;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="space-y-2">
|
|
110
|
+
<div
|
|
111
|
+
className={`p-3 rounded-lg border ${
|
|
112
|
+
isCurrentGeneration
|
|
113
|
+
? "bg-primary/10 border-primary"
|
|
114
|
+
: "bg-card hover:bg-accent cursor-pointer"
|
|
115
|
+
}`}
|
|
116
|
+
onClick={() => {
|
|
117
|
+
if (!isCurrentGeneration) {
|
|
118
|
+
router.push(`/lineage/${node.generation.id}`);
|
|
119
|
+
}
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<div className="flex items-start justify-between">
|
|
123
|
+
<div className="flex-1">
|
|
124
|
+
<div className="flex items-center gap-2">
|
|
125
|
+
<span className="font-mono text-sm text-muted-foreground">
|
|
126
|
+
Depth: {node.depth}
|
|
127
|
+
</span>
|
|
128
|
+
{node.role && (
|
|
129
|
+
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
|
130
|
+
{node.role}
|
|
131
|
+
</span>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
<p className="font-semibold mt-1">{node.generation.generatorName}</p>
|
|
135
|
+
<p className="text-sm text-muted-foreground">
|
|
136
|
+
{node.generation.artifactType} • {node.generation.status}
|
|
137
|
+
</p>
|
|
138
|
+
<p className="text-xs text-muted-foreground font-mono mt-1">
|
|
139
|
+
{node.generation.id.slice(0, 8)}...
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
{node.generation.thumbnailUrl && (
|
|
143
|
+
<img
|
|
144
|
+
src={node.generation.thumbnailUrl}
|
|
145
|
+
alt="Thumbnail"
|
|
146
|
+
className="w-16 h-16 object-cover rounded"
|
|
147
|
+
/>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
{node.parents && node.parents.length > 0 && (
|
|
153
|
+
<div className="ml-6 pl-4 border-l-2 border-muted space-y-2">
|
|
154
|
+
{node.parents.map((parent, idx) => (
|
|
155
|
+
<AncestryTree
|
|
156
|
+
key={`${parent.generation.id}-${idx}`}
|
|
157
|
+
node={parent}
|
|
158
|
+
currentGenerationId={currentGenerationId}
|
|
159
|
+
/>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface DescendantTreeProps {
|
|
168
|
+
node: DescendantNode;
|
|
169
|
+
currentGenerationId: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function DescendantTree({ node, currentGenerationId }: DescendantTreeProps) {
|
|
173
|
+
const router = useRouter();
|
|
174
|
+
const isCurrentGeneration = node.generation.id === currentGenerationId;
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div className="space-y-2">
|
|
178
|
+
<div
|
|
179
|
+
className={`p-3 rounded-lg border ${
|
|
180
|
+
isCurrentGeneration
|
|
181
|
+
? "bg-primary/10 border-primary"
|
|
182
|
+
: "bg-card hover:bg-accent cursor-pointer"
|
|
183
|
+
}`}
|
|
184
|
+
onClick={() => {
|
|
185
|
+
if (!isCurrentGeneration) {
|
|
186
|
+
router.push(`/lineage/${node.generation.id}`);
|
|
187
|
+
}
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
<div className="flex items-start justify-between">
|
|
191
|
+
<div className="flex-1">
|
|
192
|
+
<div className="flex items-center gap-2">
|
|
193
|
+
<span className="font-mono text-sm text-muted-foreground">
|
|
194
|
+
Depth: {node.depth}
|
|
195
|
+
</span>
|
|
196
|
+
{node.role && (
|
|
197
|
+
<span className="px-2 py-0.5 text-xs rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">
|
|
198
|
+
{node.role}
|
|
199
|
+
</span>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
<p className="font-semibold mt-1">{node.generation.generatorName}</p>
|
|
203
|
+
<p className="text-sm text-muted-foreground">
|
|
204
|
+
{node.generation.artifactType} • {node.generation.status}
|
|
205
|
+
</p>
|
|
206
|
+
<p className="text-xs text-muted-foreground font-mono mt-1">
|
|
207
|
+
{node.generation.id.slice(0, 8)}...
|
|
208
|
+
</p>
|
|
209
|
+
</div>
|
|
210
|
+
{node.generation.thumbnailUrl && (
|
|
211
|
+
<img
|
|
212
|
+
src={node.generation.thumbnailUrl}
|
|
213
|
+
alt="Thumbnail"
|
|
214
|
+
className="w-16 h-16 object-cover rounded"
|
|
215
|
+
/>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
{node.children && node.children.length > 0 && (
|
|
221
|
+
<div className="ml-6 pl-4 border-l-2 border-muted space-y-2">
|
|
222
|
+
{node.children.map((child, idx) => (
|
|
223
|
+
<DescendantTree
|
|
224
|
+
key={`${child.generation.id}-${idx}`}
|
|
225
|
+
node={child}
|
|
226
|
+
currentGenerationId={currentGenerationId}
|
|
227
|
+
/>
|
|
228
|
+
))}
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
@@ -12,13 +12,16 @@ import {
|
|
|
12
12
|
Play,
|
|
13
13
|
Pause,
|
|
14
14
|
RotateCcw,
|
|
15
|
+
GitBranch,
|
|
15
16
|
} from "lucide-react";
|
|
16
17
|
import Image from "next/image";
|
|
18
|
+
import { useRouter } from "next/navigation";
|
|
17
19
|
import {
|
|
18
20
|
DropdownMenu,
|
|
19
21
|
DropdownMenuContent,
|
|
20
22
|
DropdownMenuItem,
|
|
21
23
|
DropdownMenuTrigger,
|
|
24
|
+
DropdownMenuSeparator,
|
|
22
25
|
} from "@/components/ui/dropdown-menu";
|
|
23
26
|
|
|
24
27
|
interface ArtifactPreviewProps {
|
|
@@ -50,6 +53,7 @@ export function ArtifactPreview({
|
|
|
50
53
|
artifactId,
|
|
51
54
|
prompt,
|
|
52
55
|
}: ArtifactPreviewProps) {
|
|
56
|
+
const router = useRouter();
|
|
53
57
|
const [isPlaying, setIsPlaying] = React.useState(false);
|
|
54
58
|
const [currentTime, setCurrentTime] = React.useState(0);
|
|
55
59
|
const [duration, setDuration] = React.useState(0);
|
|
@@ -349,7 +353,7 @@ export function ArtifactPreview({
|
|
|
349
353
|
)}
|
|
350
354
|
|
|
351
355
|
{/* More options menu - show for all artifacts */}
|
|
352
|
-
{(onPreview || onDownload) && (
|
|
356
|
+
{(onPreview || onDownload || artifactId) && (
|
|
353
357
|
<DropdownMenu>
|
|
354
358
|
<DropdownMenuTrigger asChild>
|
|
355
359
|
<button
|
|
@@ -361,6 +365,21 @@ export function ArtifactPreview({
|
|
|
361
365
|
</button>
|
|
362
366
|
</DropdownMenuTrigger>
|
|
363
367
|
<DropdownMenuContent align="end" className="w-40">
|
|
368
|
+
{artifactId && (
|
|
369
|
+
<DropdownMenuItem
|
|
370
|
+
onClick={(e) => {
|
|
371
|
+
e.stopPropagation();
|
|
372
|
+
router.push(`/lineage/${artifactId}`);
|
|
373
|
+
}}
|
|
374
|
+
className="cursor-pointer"
|
|
375
|
+
>
|
|
376
|
+
<GitBranch className="w-4 h-4 mr-2" />
|
|
377
|
+
View Lineage
|
|
378
|
+
</DropdownMenuItem>
|
|
379
|
+
)}
|
|
380
|
+
{artifactId && (onPreview || onDownload) && (
|
|
381
|
+
<DropdownMenuSeparator />
|
|
382
|
+
)}
|
|
364
383
|
{onPreview && (
|
|
365
384
|
<DropdownMenuItem
|
|
366
385
|
onClick={(e) => {
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useState, useRef } from "react";
|
|
4
|
+
import { useUpload, ArtifactType } from "@weirdfingers/boards";
|
|
5
|
+
|
|
6
|
+
interface UploadArtifactProps {
|
|
7
|
+
boardId: string;
|
|
8
|
+
onUploadComplete?: (generationId: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function UploadArtifact({ boardId, onUploadComplete }: UploadArtifactProps) {
|
|
12
|
+
const { upload, isUploading, progress, error } = useUpload();
|
|
13
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
14
|
+
const [urlInput, setUrlInput] = useState("");
|
|
15
|
+
const [dragActive, setDragActive] = useState(false);
|
|
16
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
17
|
+
|
|
18
|
+
const handleFileUpload = useCallback(
|
|
19
|
+
async (file: File) => {
|
|
20
|
+
// Detect artifact type from file
|
|
21
|
+
const type = file.type;
|
|
22
|
+
let artifactType: ArtifactType = ArtifactType.IMAGE;
|
|
23
|
+
|
|
24
|
+
if (type.startsWith("image/")) {
|
|
25
|
+
artifactType = ArtifactType.IMAGE;
|
|
26
|
+
} else if (type.startsWith("video/")) {
|
|
27
|
+
artifactType = ArtifactType.VIDEO;
|
|
28
|
+
} else if (type.startsWith("audio/")) {
|
|
29
|
+
artifactType = ArtifactType.AUDIO;
|
|
30
|
+
} else if (type.startsWith("text/")) {
|
|
31
|
+
artifactType = ArtifactType.TEXT;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const result = await upload({
|
|
36
|
+
boardId,
|
|
37
|
+
artifactType,
|
|
38
|
+
source: file,
|
|
39
|
+
});
|
|
40
|
+
onUploadComplete?.(result.id);
|
|
41
|
+
setIsOpen(false);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error("Upload failed:", err);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
[upload, boardId, onUploadComplete]
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const handleUrlUpload = useCallback(async () => {
|
|
50
|
+
if (!urlInput.trim()) return;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
// Default to image for URL uploads
|
|
54
|
+
const result = await upload({
|
|
55
|
+
boardId,
|
|
56
|
+
artifactType: ArtifactType.IMAGE,
|
|
57
|
+
source: urlInput.trim(),
|
|
58
|
+
});
|
|
59
|
+
onUploadComplete?.(result.id);
|
|
60
|
+
setUrlInput("");
|
|
61
|
+
setIsOpen(false);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error("URL upload failed:", err);
|
|
64
|
+
}
|
|
65
|
+
}, [upload, boardId, urlInput, onUploadComplete]);
|
|
66
|
+
|
|
67
|
+
const handleDrop = useCallback(
|
|
68
|
+
(e: React.DragEvent) => {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
setDragActive(false);
|
|
71
|
+
|
|
72
|
+
const files = e.dataTransfer.files;
|
|
73
|
+
if (files.length > 0) {
|
|
74
|
+
handleFileUpload(files[0]);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
[handleFileUpload]
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const handleDragOver = (e: React.DragEvent) => {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
setDragActive(true);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleDragLeave = () => {
|
|
86
|
+
setDragActive(false);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handlePaste = useCallback(
|
|
90
|
+
async (e: React.ClipboardEvent) => {
|
|
91
|
+
const items = Array.from(e.clipboardData.items);
|
|
92
|
+
|
|
93
|
+
// Check for image in clipboard
|
|
94
|
+
for (const item of items) {
|
|
95
|
+
if (item.type.startsWith("image/")) {
|
|
96
|
+
const file = item.getAsFile();
|
|
97
|
+
if (file) {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
await handleFileUpload(file);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
[handleFileUpload]
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="relative">
|
|
110
|
+
<button
|
|
111
|
+
onClick={() => setIsOpen(true)}
|
|
112
|
+
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors"
|
|
113
|
+
>
|
|
114
|
+
<svg
|
|
115
|
+
className="w-5 h-5"
|
|
116
|
+
fill="none"
|
|
117
|
+
stroke="currentColor"
|
|
118
|
+
viewBox="0 0 24 24"
|
|
119
|
+
>
|
|
120
|
+
<path
|
|
121
|
+
strokeLinecap="round"
|
|
122
|
+
strokeLinejoin="round"
|
|
123
|
+
strokeWidth={2}
|
|
124
|
+
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
|
125
|
+
/>
|
|
126
|
+
</svg>
|
|
127
|
+
Upload
|
|
128
|
+
</button>
|
|
129
|
+
|
|
130
|
+
{isOpen && (
|
|
131
|
+
<div className="absolute right-0 top-full mt-2 w-96 bg-white rounded-lg shadow-xl p-6 z-50 border border-gray-200">
|
|
132
|
+
<div className="flex items-center justify-between mb-4">
|
|
133
|
+
<h3 className="text-lg font-semibold text-gray-900">Upload Artifact</h3>
|
|
134
|
+
<button
|
|
135
|
+
onClick={() => setIsOpen(false)}
|
|
136
|
+
className="text-gray-400 hover:text-gray-600"
|
|
137
|
+
>
|
|
138
|
+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
139
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
140
|
+
</svg>
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{/* Drag and drop zone */}
|
|
145
|
+
<div
|
|
146
|
+
onDrop={handleDrop}
|
|
147
|
+
onDragOver={handleDragOver}
|
|
148
|
+
onDragLeave={handleDragLeave}
|
|
149
|
+
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
|
150
|
+
dragActive
|
|
151
|
+
? "border-orange-500 bg-orange-50"
|
|
152
|
+
: "border-gray-300 hover:border-gray-400"
|
|
153
|
+
}`}
|
|
154
|
+
>
|
|
155
|
+
<input
|
|
156
|
+
ref={fileInputRef}
|
|
157
|
+
type="file"
|
|
158
|
+
onChange={(e) => {
|
|
159
|
+
const file = e.target.files?.[0];
|
|
160
|
+
if (file) handleFileUpload(file);
|
|
161
|
+
}}
|
|
162
|
+
className="hidden"
|
|
163
|
+
accept="image/*,video/*,audio/*,text/*"
|
|
164
|
+
/>
|
|
165
|
+
|
|
166
|
+
<div className="flex flex-col items-center gap-2">
|
|
167
|
+
<svg
|
|
168
|
+
className="w-12 h-12 text-gray-400"
|
|
169
|
+
fill="none"
|
|
170
|
+
stroke="currentColor"
|
|
171
|
+
viewBox="0 0 24 24"
|
|
172
|
+
>
|
|
173
|
+
<path
|
|
174
|
+
strokeLinecap="round"
|
|
175
|
+
strokeLinejoin="round"
|
|
176
|
+
strokeWidth={2}
|
|
177
|
+
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
178
|
+
/>
|
|
179
|
+
</svg>
|
|
180
|
+
|
|
181
|
+
<div>
|
|
182
|
+
<button
|
|
183
|
+
onClick={() => fileInputRef.current?.click()}
|
|
184
|
+
className="text-orange-600 hover:text-orange-700 font-medium"
|
|
185
|
+
>
|
|
186
|
+
Choose a file
|
|
187
|
+
</button>
|
|
188
|
+
<span className="text-gray-500"> or drag and drop here</span>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<p className="text-sm text-gray-500">
|
|
192
|
+
Images, videos, audio, and text files (max 100MB)
|
|
193
|
+
</p>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
{/* URL input */}
|
|
198
|
+
<div className="mt-4">
|
|
199
|
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
200
|
+
Or paste a URL or image
|
|
201
|
+
</label>
|
|
202
|
+
<div className="flex gap-2">
|
|
203
|
+
<input
|
|
204
|
+
type="text"
|
|
205
|
+
value={urlInput}
|
|
206
|
+
onChange={(e) => setUrlInput(e.target.value)}
|
|
207
|
+
onPaste={handlePaste}
|
|
208
|
+
onKeyDown={(e) => {
|
|
209
|
+
if (e.key === "Enter") {
|
|
210
|
+
handleUrlUpload();
|
|
211
|
+
}
|
|
212
|
+
}}
|
|
213
|
+
placeholder="https://example.com/image.jpg or paste an image"
|
|
214
|
+
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
|
215
|
+
disabled={isUploading}
|
|
216
|
+
/>
|
|
217
|
+
<button
|
|
218
|
+
onClick={handleUrlUpload}
|
|
219
|
+
disabled={!urlInput.trim() || isUploading}
|
|
220
|
+
className="px-6 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
|
221
|
+
>
|
|
222
|
+
Upload
|
|
223
|
+
</button>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{/* Progress bar */}
|
|
228
|
+
{isUploading && (
|
|
229
|
+
<div className="mt-4">
|
|
230
|
+
<div className="flex items-center justify-between text-sm text-gray-600 mb-2">
|
|
231
|
+
<span>Uploading...</span>
|
|
232
|
+
<span>{Math.round(progress)}%</span>
|
|
233
|
+
</div>
|
|
234
|
+
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
235
|
+
<div
|
|
236
|
+
className="bg-orange-500 h-2 rounded-full transition-all duration-300"
|
|
237
|
+
style={{ width: `${progress}%` }}
|
|
238
|
+
/>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{/* Error message */}
|
|
244
|
+
{error && (
|
|
245
|
+
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
|
246
|
+
<p className="text-red-800 text-sm">{error.message}</p>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|