@weirdfingers/baseboards 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +54 -28
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/README.md +2 -0
- package/templates/api/.env.example +3 -0
- package/templates/api/config/generators.yaml +58 -0
- package/templates/api/pyproject.toml +1 -1
- package/templates/api/src/boards/__init__.py +1 -1
- package/templates/api/src/boards/api/endpoints/storage.py +85 -4
- package/templates/api/src/boards/api/endpoints/uploads.py +1 -2
- package/templates/api/src/boards/database/connection.py +98 -58
- package/templates/api/src/boards/generators/implementations/fal/audio/__init__.py +4 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_text_to_speech.py +176 -0
- package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_tts_turbo.py +195 -0
- package/templates/api/src/boards/generators/implementations/fal/image/__init__.py +14 -0
- package/templates/api/src/boards/generators/implementations/fal/image/bytedance_seedream_v45_edit.py +219 -0
- package/templates/api/src/boards/generators/implementations/fal/image/gemini_25_flash_image_edit.py +208 -0
- package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_15_edit.py +216 -0
- package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_5.py +177 -0
- package/templates/api/src/boards/generators/implementations/fal/image/reve_edit.py +178 -0
- package/templates/api/src/boards/generators/implementations/fal/image/reve_text_to_image.py +155 -0
- package/templates/api/src/boards/generators/implementations/fal/image/seedream_v45_text_to_image.py +180 -0
- package/templates/api/src/boards/generators/implementations/fal/video/__init__.py +18 -0
- package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_pro.py +168 -0
- package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_standard.py +159 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veed_fabric_1_0.py +180 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31.py +190 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast.py +190 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast_image_to_video.py +191 -0
- package/templates/api/src/boards/generators/implementations/fal/video/veo31_first_last_frame_to_video.py +13 -6
- package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_image_to_video.py +212 -0
- package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_text_to_video.py +208 -0
- package/templates/api/src/boards/generators/implementations/kie/__init__.py +11 -0
- package/templates/api/src/boards/generators/implementations/kie/base.py +316 -0
- package/templates/api/src/boards/generators/implementations/kie/image/__init__.py +3 -0
- package/templates/api/src/boards/generators/implementations/kie/image/nano_banana_edit.py +190 -0
- package/templates/api/src/boards/generators/implementations/kie/utils.py +98 -0
- package/templates/api/src/boards/generators/implementations/kie/video/__init__.py +8 -0
- package/templates/api/src/boards/generators/implementations/kie/video/veo3.py +161 -0
- package/templates/api/src/boards/graphql/resolvers/upload.py +1 -1
- package/templates/web/package.json +4 -1
- package/templates/web/src/app/boards/[boardId]/page.tsx +156 -24
- package/templates/web/src/app/globals.css +3 -0
- package/templates/web/src/app/layout.tsx +15 -5
- package/templates/web/src/components/boards/ArtifactInputSlots.tsx +9 -9
- package/templates/web/src/components/boards/ArtifactPreview.tsx +34 -18
- package/templates/web/src/components/boards/GenerationGrid.tsx +101 -7
- package/templates/web/src/components/boards/GenerationInput.tsx +21 -21
- package/templates/web/src/components/boards/GeneratorSelector.tsx +232 -30
- package/templates/web/src/components/boards/UploadArtifact.tsx +385 -75
- package/templates/web/src/components/header.tsx +3 -1
- package/templates/web/src/components/theme-provider.tsx +10 -0
- package/templates/web/src/components/theme-toggle.tsx +75 -0
- package/templates/web/src/components/ui/alert-dialog.tsx +157 -0
- package/templates/web/src/components/ui/toast.tsx +128 -0
- package/templates/web/src/components/ui/toaster.tsx +35 -0
- package/templates/web/src/components/ui/use-toast.ts +186 -0
|
@@ -1,80 +1,299 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import React, { useCallback, useState, useRef } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
useMultiUpload,
|
|
6
|
+
ArtifactType,
|
|
7
|
+
UploadItem,
|
|
8
|
+
MultiUploadRequest,
|
|
9
|
+
} from "@weirdfingers/boards";
|
|
10
|
+
import { toast } from "@/components/ui/use-toast";
|
|
5
11
|
|
|
6
12
|
interface UploadArtifactProps {
|
|
7
13
|
boardId: string;
|
|
8
14
|
onUploadComplete?: (generationId: string) => void;
|
|
9
15
|
}
|
|
10
16
|
|
|
11
|
-
|
|
12
|
-
|
|
17
|
+
// Maximum file size: 100MB
|
|
18
|
+
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
// Allowed MIME types for each artifact type
|
|
21
|
+
const ALLOWED_MIME_TYPES = {
|
|
22
|
+
image: [
|
|
23
|
+
"image/jpeg",
|
|
24
|
+
"image/png",
|
|
25
|
+
"image/gif",
|
|
26
|
+
"image/webp",
|
|
27
|
+
"image/bmp",
|
|
28
|
+
"image/svg+xml",
|
|
29
|
+
],
|
|
30
|
+
video: [
|
|
31
|
+
"video/mp4",
|
|
32
|
+
"video/quicktime",
|
|
33
|
+
"video/x-msvideo",
|
|
34
|
+
"video/webm",
|
|
35
|
+
"video/mpeg",
|
|
36
|
+
"video/x-matroska",
|
|
37
|
+
],
|
|
38
|
+
audio: [
|
|
39
|
+
"audio/mpeg",
|
|
40
|
+
"audio/wav",
|
|
41
|
+
"audio/ogg",
|
|
42
|
+
"audio/webm",
|
|
43
|
+
"audio/mp4",
|
|
44
|
+
"audio/x-m4a",
|
|
45
|
+
],
|
|
46
|
+
text: [
|
|
47
|
+
"text/plain",
|
|
48
|
+
"text/markdown",
|
|
49
|
+
"application/json",
|
|
50
|
+
"text/html",
|
|
51
|
+
"text/csv",
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function detectArtifactType(mimeType: string): ArtifactType {
|
|
56
|
+
if (mimeType.startsWith("image/")) {
|
|
57
|
+
return ArtifactType.IMAGE;
|
|
58
|
+
} else if (mimeType.startsWith("video/")) {
|
|
59
|
+
return ArtifactType.VIDEO;
|
|
60
|
+
} else if (mimeType.startsWith("audio/")) {
|
|
61
|
+
return ArtifactType.AUDIO;
|
|
62
|
+
} else if (mimeType.startsWith("text/")) {
|
|
63
|
+
return ArtifactType.TEXT;
|
|
64
|
+
}
|
|
65
|
+
return ArtifactType.IMAGE;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateFile(file: File): { valid: boolean; error?: string } {
|
|
69
|
+
// Check file size
|
|
70
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
71
|
+
return {
|
|
72
|
+
valid: false,
|
|
73
|
+
error: `File size exceeds maximum of 100MB (${Math.round(file.size / 1024 / 1024)}MB)`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check MIME type
|
|
78
|
+
const mimeType = file.type.toLowerCase();
|
|
79
|
+
const isValidMimeType = Object.values(ALLOWED_MIME_TYPES)
|
|
80
|
+
.flat()
|
|
81
|
+
.some((allowed) => mimeType === allowed.toLowerCase());
|
|
82
|
+
|
|
83
|
+
if (!isValidMimeType) {
|
|
84
|
+
return {
|
|
85
|
+
valid: false,
|
|
86
|
+
error: `Unsupported file type: ${file.type || "unknown"}. Please upload an image, video, audio, or text file.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { valid: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function validateUrl(url: string): { valid: boolean; error?: string } {
|
|
94
|
+
try {
|
|
95
|
+
const parsedUrl = new URL(url);
|
|
96
|
+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
97
|
+
return {
|
|
98
|
+
valid: false,
|
|
99
|
+
error: "URL must use HTTP or HTTPS protocol",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return { valid: true };
|
|
103
|
+
} catch {
|
|
104
|
+
return {
|
|
105
|
+
valid: false,
|
|
106
|
+
error: "Invalid URL format",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function UploadItemRow({
|
|
112
|
+
item,
|
|
113
|
+
onCancel,
|
|
114
|
+
}: {
|
|
115
|
+
item: UploadItem;
|
|
116
|
+
onCancel: () => void;
|
|
117
|
+
}) {
|
|
118
|
+
return (
|
|
119
|
+
<div className="flex items-center gap-3 p-2 bg-muted/50 rounded-lg">
|
|
120
|
+
<div className="flex-1 min-w-0">
|
|
121
|
+
<p className="text-sm font-medium text-foreground truncate">
|
|
122
|
+
{item.fileName}
|
|
123
|
+
</p>
|
|
124
|
+
{item.status === "uploading" && (
|
|
125
|
+
<div className="mt-1 w-full bg-muted rounded-full h-1.5">
|
|
126
|
+
<div
|
|
127
|
+
className="bg-primary h-1.5 rounded-full transition-all duration-300"
|
|
128
|
+
style={{ width: `${item.progress}%` }}
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
{item.status === "failed" && item.error && (
|
|
133
|
+
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{item.error.message}</p>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
<div className="flex-shrink-0">
|
|
137
|
+
{item.status === "pending" && (
|
|
138
|
+
<span className="text-xs text-muted-foreground">Waiting...</span>
|
|
139
|
+
)}
|
|
140
|
+
{item.status === "uploading" && (
|
|
141
|
+
<button
|
|
142
|
+
onClick={onCancel}
|
|
143
|
+
className="text-xs text-muted-foreground hover:text-red-600 dark:hover:text-red-400"
|
|
144
|
+
>
|
|
145
|
+
Cancel
|
|
146
|
+
</button>
|
|
147
|
+
)}
|
|
148
|
+
{item.status === "completed" && (
|
|
149
|
+
<svg
|
|
150
|
+
className="w-5 h-5 text-success"
|
|
151
|
+
fill="none"
|
|
152
|
+
stroke="currentColor"
|
|
153
|
+
viewBox="0 0 24 24"
|
|
154
|
+
>
|
|
155
|
+
<path
|
|
156
|
+
strokeLinecap="round"
|
|
157
|
+
strokeLinejoin="round"
|
|
158
|
+
strokeWidth={2}
|
|
159
|
+
d="M5 13l4 4L19 7"
|
|
160
|
+
/>
|
|
161
|
+
</svg>
|
|
162
|
+
)}
|
|
163
|
+
{item.status === "failed" && (
|
|
164
|
+
<svg
|
|
165
|
+
className="w-5 h-5 text-red-600 dark:text-red-400"
|
|
166
|
+
fill="none"
|
|
167
|
+
stroke="currentColor"
|
|
168
|
+
viewBox="0 0 24 24"
|
|
169
|
+
>
|
|
170
|
+
<path
|
|
171
|
+
strokeLinecap="round"
|
|
172
|
+
strokeLinejoin="round"
|
|
173
|
+
strokeWidth={2}
|
|
174
|
+
d="M6 18L18 6M6 6l12 12"
|
|
175
|
+
/>
|
|
176
|
+
</svg>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function UploadArtifact({
|
|
184
|
+
boardId,
|
|
185
|
+
onUploadComplete,
|
|
186
|
+
}: UploadArtifactProps) {
|
|
187
|
+
const {
|
|
188
|
+
uploadMultiple,
|
|
189
|
+
uploads,
|
|
190
|
+
isUploading,
|
|
191
|
+
overallProgress,
|
|
192
|
+
clearUploads,
|
|
193
|
+
cancelUpload,
|
|
194
|
+
} = useMultiUpload();
|
|
13
195
|
const [isOpen, setIsOpen] = useState(false);
|
|
14
196
|
const [urlInput, setUrlInput] = useState("");
|
|
15
197
|
const [dragActive, setDragActive] = useState(false);
|
|
16
198
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
17
199
|
|
|
18
|
-
const
|
|
19
|
-
async (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
200
|
+
const handleFilesUpload = useCallback(
|
|
201
|
+
async (files: File[]) => {
|
|
202
|
+
if (files.length === 0) return;
|
|
203
|
+
|
|
204
|
+
// Validate all files first
|
|
205
|
+
const invalidFiles: string[] = [];
|
|
206
|
+
const validFiles: File[] = [];
|
|
207
|
+
|
|
208
|
+
files.forEach((file) => {
|
|
209
|
+
const validation = validateFile(file);
|
|
210
|
+
if (!validation.valid) {
|
|
211
|
+
invalidFiles.push(`${file.name}: ${validation.error}`);
|
|
212
|
+
} else {
|
|
213
|
+
validFiles.push(file);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Show errors for invalid files
|
|
218
|
+
if (invalidFiles.length > 0) {
|
|
219
|
+
toast({
|
|
220
|
+
variant: "destructive",
|
|
221
|
+
title: "Some files were rejected",
|
|
222
|
+
description: invalidFiles.join("; "),
|
|
223
|
+
});
|
|
32
224
|
}
|
|
33
225
|
|
|
226
|
+
// Upload valid files only
|
|
227
|
+
if (validFiles.length === 0) return;
|
|
228
|
+
|
|
229
|
+
const requests: MultiUploadRequest[] = validFiles.map((file) => ({
|
|
230
|
+
boardId,
|
|
231
|
+
artifactType: detectArtifactType(file.type),
|
|
232
|
+
source: file,
|
|
233
|
+
}));
|
|
234
|
+
|
|
34
235
|
try {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
source: file,
|
|
236
|
+
const results = await uploadMultiple(requests);
|
|
237
|
+
results.forEach((result) => {
|
|
238
|
+
onUploadComplete?.(result.id);
|
|
39
239
|
});
|
|
40
|
-
onUploadComplete?.(result.id);
|
|
41
|
-
setIsOpen(false);
|
|
42
240
|
} catch (err) {
|
|
43
|
-
|
|
241
|
+
toast({
|
|
242
|
+
variant: "destructive",
|
|
243
|
+
title: "Upload failed",
|
|
244
|
+
description: err instanceof Error ? err.message : "An unknown error occurred",
|
|
245
|
+
});
|
|
44
246
|
}
|
|
45
247
|
},
|
|
46
|
-
[
|
|
248
|
+
[uploadMultiple, boardId, onUploadComplete]
|
|
47
249
|
);
|
|
48
250
|
|
|
49
251
|
const handleUrlUpload = useCallback(async () => {
|
|
50
252
|
if (!urlInput.trim()) return;
|
|
51
253
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
254
|
+
// Validate URL
|
|
255
|
+
const validation = validateUrl(urlInput.trim());
|
|
256
|
+
if (!validation.valid) {
|
|
257
|
+
toast({
|
|
258
|
+
variant: "destructive",
|
|
259
|
+
title: "Invalid URL",
|
|
260
|
+
description: validation.error,
|
|
58
261
|
});
|
|
59
|
-
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const results = await uploadMultiple([
|
|
267
|
+
{
|
|
268
|
+
boardId,
|
|
269
|
+
artifactType: ArtifactType.IMAGE,
|
|
270
|
+
source: urlInput.trim(),
|
|
271
|
+
},
|
|
272
|
+
]);
|
|
273
|
+
if (results.length > 0) {
|
|
274
|
+
onUploadComplete?.(results[0].id);
|
|
275
|
+
}
|
|
60
276
|
setUrlInput("");
|
|
61
|
-
setIsOpen(false);
|
|
62
277
|
} catch (err) {
|
|
63
|
-
|
|
278
|
+
toast({
|
|
279
|
+
variant: "destructive",
|
|
280
|
+
title: "URL upload failed",
|
|
281
|
+
description: err instanceof Error ? err.message : "An unknown error occurred",
|
|
282
|
+
});
|
|
64
283
|
}
|
|
65
|
-
}, [
|
|
284
|
+
}, [uploadMultiple, boardId, urlInput, onUploadComplete]);
|
|
66
285
|
|
|
67
286
|
const handleDrop = useCallback(
|
|
68
287
|
(e: React.DragEvent) => {
|
|
69
288
|
e.preventDefault();
|
|
70
289
|
setDragActive(false);
|
|
71
290
|
|
|
72
|
-
const files = e.dataTransfer.files;
|
|
291
|
+
const files = Array.from(e.dataTransfer.files);
|
|
73
292
|
if (files.length > 0) {
|
|
74
|
-
|
|
293
|
+
handleFilesUpload(files);
|
|
75
294
|
}
|
|
76
295
|
},
|
|
77
|
-
[
|
|
296
|
+
[handleFilesUpload]
|
|
78
297
|
);
|
|
79
298
|
|
|
80
299
|
const handleDragOver = (e: React.DragEvent) => {
|
|
@@ -89,27 +308,45 @@ export function UploadArtifact({ boardId, onUploadComplete }: UploadArtifactProp
|
|
|
89
308
|
const handlePaste = useCallback(
|
|
90
309
|
async (e: React.ClipboardEvent) => {
|
|
91
310
|
const items = Array.from(e.clipboardData.items);
|
|
311
|
+
const imageFiles: File[] = [];
|
|
92
312
|
|
|
93
|
-
// Check for image in clipboard
|
|
94
313
|
for (const item of items) {
|
|
95
314
|
if (item.type.startsWith("image/")) {
|
|
96
315
|
const file = item.getAsFile();
|
|
97
316
|
if (file) {
|
|
98
|
-
|
|
99
|
-
await handleFileUpload(file);
|
|
100
|
-
return;
|
|
317
|
+
imageFiles.push(file);
|
|
101
318
|
}
|
|
102
319
|
}
|
|
103
320
|
}
|
|
321
|
+
|
|
322
|
+
if (imageFiles.length > 0) {
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
await handleFilesUpload(imageFiles);
|
|
325
|
+
}
|
|
104
326
|
},
|
|
105
|
-
[
|
|
327
|
+
[handleFilesUpload]
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const handleClose = useCallback(() => {
|
|
331
|
+
setIsOpen(false);
|
|
332
|
+
// Clear completed/failed uploads when closing
|
|
333
|
+
if (!isUploading) {
|
|
334
|
+
clearUploads();
|
|
335
|
+
}
|
|
336
|
+
}, [isUploading, clearUploads]);
|
|
337
|
+
|
|
338
|
+
// Filter to show only active uploads (not completed ones unless recent)
|
|
339
|
+
const activeUploads = uploads.filter(
|
|
340
|
+
(u) => u.status === "pending" || u.status === "uploading"
|
|
106
341
|
);
|
|
342
|
+
const completedCount = uploads.filter((u) => u.status === "completed").length;
|
|
343
|
+
const failedCount = uploads.filter((u) => u.status === "failed").length;
|
|
107
344
|
|
|
108
345
|
return (
|
|
109
346
|
<div className="relative">
|
|
110
347
|
<button
|
|
111
348
|
onClick={() => setIsOpen(true)}
|
|
112
|
-
className="inline-flex items-center gap-2 px-4 py-2 bg-
|
|
349
|
+
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
|
113
350
|
>
|
|
114
351
|
<svg
|
|
115
352
|
className="w-5 h-5"
|
|
@@ -128,15 +365,29 @@ export function UploadArtifact({ boardId, onUploadComplete }: UploadArtifactProp
|
|
|
128
365
|
</button>
|
|
129
366
|
|
|
130
367
|
{isOpen && (
|
|
131
|
-
<div className="absolute right-0 top-full mt-2 w-96 bg-
|
|
368
|
+
<div className="absolute right-0 top-full mt-2 w-96 bg-background rounded-lg shadow-xl p-6 z-50 border border-border">
|
|
132
369
|
<div className="flex items-center justify-between mb-4">
|
|
133
|
-
<h3 className="text-lg font-semibold text-
|
|
370
|
+
<h3 className="text-lg font-semibold text-foreground">
|
|
371
|
+
Upload Artifacts
|
|
372
|
+
</h3>
|
|
134
373
|
<button
|
|
135
|
-
onClick={
|
|
136
|
-
className="text-
|
|
374
|
+
onClick={handleClose}
|
|
375
|
+
className="text-muted-foreground hover:text-foreground"
|
|
376
|
+
aria-label="Close upload dialog"
|
|
137
377
|
>
|
|
138
|
-
<svg
|
|
139
|
-
|
|
378
|
+
<svg
|
|
379
|
+
className="w-6 h-6"
|
|
380
|
+
fill="none"
|
|
381
|
+
stroke="currentColor"
|
|
382
|
+
viewBox="0 0 24 24"
|
|
383
|
+
aria-hidden="true"
|
|
384
|
+
>
|
|
385
|
+
<path
|
|
386
|
+
strokeLinecap="round"
|
|
387
|
+
strokeLinejoin="round"
|
|
388
|
+
strokeWidth={2}
|
|
389
|
+
d="M6 18L18 6M6 6l12 12"
|
|
390
|
+
/>
|
|
140
391
|
</svg>
|
|
141
392
|
</button>
|
|
142
393
|
</div>
|
|
@@ -148,27 +399,45 @@ export function UploadArtifact({ boardId, onUploadComplete }: UploadArtifactProp
|
|
|
148
399
|
onDragLeave={handleDragLeave}
|
|
149
400
|
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
|
150
401
|
dragActive
|
|
151
|
-
? "border-
|
|
152
|
-
: "border-
|
|
402
|
+
? "border-primary bg-primary/5"
|
|
403
|
+
: "border-border hover:border-border/80"
|
|
153
404
|
}`}
|
|
405
|
+
role="button"
|
|
406
|
+
tabIndex={0}
|
|
407
|
+
aria-label="Upload files by drag and drop or click to select"
|
|
408
|
+
onKeyDown={(e) => {
|
|
409
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
410
|
+
e.preventDefault();
|
|
411
|
+
fileInputRef.current?.click();
|
|
412
|
+
}
|
|
413
|
+
}}
|
|
154
414
|
>
|
|
155
415
|
<input
|
|
416
|
+
id="file-upload"
|
|
156
417
|
ref={fileInputRef}
|
|
157
418
|
type="file"
|
|
419
|
+
multiple
|
|
158
420
|
onChange={(e) => {
|
|
159
|
-
const
|
|
160
|
-
if (
|
|
421
|
+
const files = Array.from(e.target.files || []);
|
|
422
|
+
if (files.length > 0) {
|
|
423
|
+
handleFilesUpload(files);
|
|
424
|
+
}
|
|
425
|
+
// Reset input so same files can be selected again
|
|
426
|
+
e.target.value = "";
|
|
161
427
|
}}
|
|
162
428
|
className="hidden"
|
|
163
429
|
accept="image/*,video/*,audio/*,text/*"
|
|
430
|
+
aria-label="Select files to upload"
|
|
431
|
+
aria-describedby="file-upload-description"
|
|
164
432
|
/>
|
|
165
433
|
|
|
166
434
|
<div className="flex flex-col items-center gap-2">
|
|
167
435
|
<svg
|
|
168
|
-
className="w-12 h-12 text-
|
|
436
|
+
className="w-12 h-12 text-muted-foreground"
|
|
169
437
|
fill="none"
|
|
170
438
|
stroke="currentColor"
|
|
171
439
|
viewBox="0 0 24 24"
|
|
440
|
+
aria-hidden="true"
|
|
172
441
|
>
|
|
173
442
|
<path
|
|
174
443
|
strokeLinecap="round"
|
|
@@ -180,23 +449,28 @@ export function UploadArtifact({ boardId, onUploadComplete }: UploadArtifactProp
|
|
|
180
449
|
|
|
181
450
|
<div>
|
|
182
451
|
<button
|
|
452
|
+
type="button"
|
|
183
453
|
onClick={() => fileInputRef.current?.click()}
|
|
184
|
-
className="text-
|
|
454
|
+
className="text-primary hover:text-primary/90 font-medium"
|
|
455
|
+
aria-label="Choose files to upload"
|
|
185
456
|
>
|
|
186
|
-
Choose
|
|
457
|
+
Choose files
|
|
187
458
|
</button>
|
|
188
|
-
<span className="text-
|
|
459
|
+
<span className="text-muted-foreground"> or drag and drop here</span>
|
|
189
460
|
</div>
|
|
190
461
|
|
|
191
|
-
<p className="text-sm text-
|
|
192
|
-
Images, videos, audio, and text files (max 100MB)
|
|
462
|
+
<p id="file-upload-description" className="text-sm text-muted-foreground">
|
|
463
|
+
Images, videos, audio, and text files (max 100MB each)
|
|
464
|
+
</p>
|
|
465
|
+
<p className="text-xs text-muted-foreground/80">
|
|
466
|
+
You can select multiple files at once
|
|
193
467
|
</p>
|
|
194
468
|
</div>
|
|
195
469
|
</div>
|
|
196
470
|
|
|
197
471
|
{/* URL input */}
|
|
198
472
|
<div className="mt-4">
|
|
199
|
-
<label className="block text-sm font-medium text-
|
|
473
|
+
<label className="block text-sm font-medium text-foreground mb-2">
|
|
200
474
|
Or paste a URL or image
|
|
201
475
|
</label>
|
|
202
476
|
<div className="flex gap-2">
|
|
@@ -211,39 +485,75 @@ export function UploadArtifact({ boardId, onUploadComplete }: UploadArtifactProp
|
|
|
211
485
|
}
|
|
212
486
|
}}
|
|
213
487
|
placeholder="https://example.com/image.jpg or paste an image"
|
|
214
|
-
className="flex-1 px-4 py-2 border border-
|
|
488
|
+
className="flex-1 px-4 py-2 border border-border rounded-lg bg-background focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
215
489
|
disabled={isUploading}
|
|
216
490
|
/>
|
|
217
491
|
<button
|
|
218
492
|
onClick={handleUrlUpload}
|
|
219
493
|
disabled={!urlInput.trim() || isUploading}
|
|
220
|
-
className="px-6 py-2 bg-
|
|
494
|
+
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed transition-colors"
|
|
221
495
|
>
|
|
222
496
|
Upload
|
|
223
497
|
</button>
|
|
224
498
|
</div>
|
|
225
499
|
</div>
|
|
226
500
|
|
|
227
|
-
{/*
|
|
501
|
+
{/* Upload progress list */}
|
|
502
|
+
{activeUploads.length > 0 && (
|
|
503
|
+
<div className="mt-4 space-y-2 max-h-48 overflow-y-auto" role="status" aria-live="polite">
|
|
504
|
+
{activeUploads.map((item) => (
|
|
505
|
+
<UploadItemRow
|
|
506
|
+
key={item.id}
|
|
507
|
+
item={item}
|
|
508
|
+
onCancel={() => cancelUpload(item.id)}
|
|
509
|
+
/>
|
|
510
|
+
))}
|
|
511
|
+
</div>
|
|
512
|
+
)}
|
|
513
|
+
|
|
514
|
+
{/* Overall progress bar */}
|
|
228
515
|
{isUploading && (
|
|
229
|
-
<div className="mt-4">
|
|
230
|
-
<div className="flex items-center justify-between text-sm text-
|
|
231
|
-
<span>
|
|
232
|
-
|
|
516
|
+
<div className="mt-4" role="status" aria-live="polite" aria-atomic="true">
|
|
517
|
+
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
|
|
518
|
+
<span>
|
|
519
|
+
Uploading {activeUploads.length} file
|
|
520
|
+
{activeUploads.length !== 1 ? "s" : ""}...
|
|
521
|
+
</span>
|
|
522
|
+
<span>{Math.round(overallProgress)}%</span>
|
|
233
523
|
</div>
|
|
234
|
-
<div className="w-full bg-
|
|
524
|
+
<div className="w-full bg-muted rounded-full h-2" role="progressbar" aria-valuenow={Math.round(overallProgress)} aria-valuemin={0} aria-valuemax={100}>
|
|
235
525
|
<div
|
|
236
|
-
className="bg-
|
|
237
|
-
style={{ width: `${
|
|
526
|
+
className="bg-primary h-2 rounded-full transition-all duration-300"
|
|
527
|
+
style={{ width: `${overallProgress}%` }}
|
|
238
528
|
/>
|
|
239
529
|
</div>
|
|
240
530
|
</div>
|
|
241
531
|
)}
|
|
242
532
|
|
|
243
|
-
{/*
|
|
244
|
-
{
|
|
245
|
-
<div className="mt-4 p-
|
|
246
|
-
<
|
|
533
|
+
{/* Summary when uploads finish */}
|
|
534
|
+
{!isUploading && uploads.length > 0 && (
|
|
535
|
+
<div className="mt-4 p-3 bg-muted/50 rounded-lg" role="status" aria-live="polite">
|
|
536
|
+
<div className="flex items-center justify-between text-sm">
|
|
537
|
+
<span className="text-muted-foreground">
|
|
538
|
+
{completedCount > 0 && (
|
|
539
|
+
<span className="text-success">
|
|
540
|
+
{completedCount} completed
|
|
541
|
+
</span>
|
|
542
|
+
)}
|
|
543
|
+
{completedCount > 0 && failedCount > 0 && ", "}
|
|
544
|
+
{failedCount > 0 && (
|
|
545
|
+
<span className="text-red-600 dark:text-red-400">{failedCount} failed</span>
|
|
546
|
+
)}
|
|
547
|
+
</span>
|
|
548
|
+
<button
|
|
549
|
+
type="button"
|
|
550
|
+
onClick={clearUploads}
|
|
551
|
+
className="text-muted-foreground hover:text-foreground text-xs"
|
|
552
|
+
aria-label="Clear upload history"
|
|
553
|
+
>
|
|
554
|
+
Clear
|
|
555
|
+
</button>
|
|
556
|
+
</div>
|
|
247
557
|
</div>
|
|
248
558
|
)}
|
|
249
559
|
</div>
|
|
@@ -8,11 +8,12 @@ import {
|
|
|
8
8
|
NavigationMenuList,
|
|
9
9
|
navigationMenuTriggerStyle,
|
|
10
10
|
} from "@/components/ui/navigation-menu";
|
|
11
|
+
import { ThemeToggle } from "@/components/theme-toggle";
|
|
11
12
|
|
|
12
13
|
export function Header() {
|
|
13
14
|
return (
|
|
14
15
|
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
15
|
-
<div className="container flex h-14 max-w-screen-2xl items-center">
|
|
16
|
+
<div className="container flex h-14 max-w-screen-2xl items-center justify-between">
|
|
16
17
|
<NavigationMenu>
|
|
17
18
|
<NavigationMenuList>
|
|
18
19
|
<NavigationMenuItem>
|
|
@@ -24,6 +25,7 @@ export function Header() {
|
|
|
24
25
|
</NavigationMenuItem>
|
|
25
26
|
</NavigationMenuList>
|
|
26
27
|
</NavigationMenu>
|
|
28
|
+
<ThemeToggle />
|
|
27
29
|
</div>
|
|
28
30
|
</header>
|
|
29
31
|
);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
|
5
|
+
|
|
6
|
+
type ThemeProviderProps = React.ComponentProps<typeof NextThemesProvider>;
|
|
7
|
+
|
|
8
|
+
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
|
9
|
+
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
|
10
|
+
}
|