@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.
Files changed (57) hide show
  1. package/dist/index.js +54 -28
  2. package/dist/index.js.map +1 -1
  3. package/package.json +1 -1
  4. package/templates/README.md +2 -0
  5. package/templates/api/.env.example +3 -0
  6. package/templates/api/config/generators.yaml +58 -0
  7. package/templates/api/pyproject.toml +1 -1
  8. package/templates/api/src/boards/__init__.py +1 -1
  9. package/templates/api/src/boards/api/endpoints/storage.py +85 -4
  10. package/templates/api/src/boards/api/endpoints/uploads.py +1 -2
  11. package/templates/api/src/boards/database/connection.py +98 -58
  12. package/templates/api/src/boards/generators/implementations/fal/audio/__init__.py +4 -0
  13. package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_text_to_speech.py +176 -0
  14. package/templates/api/src/boards/generators/implementations/fal/audio/chatterbox_tts_turbo.py +195 -0
  15. package/templates/api/src/boards/generators/implementations/fal/image/__init__.py +14 -0
  16. package/templates/api/src/boards/generators/implementations/fal/image/bytedance_seedream_v45_edit.py +219 -0
  17. package/templates/api/src/boards/generators/implementations/fal/image/gemini_25_flash_image_edit.py +208 -0
  18. package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_15_edit.py +216 -0
  19. package/templates/api/src/boards/generators/implementations/fal/image/gpt_image_1_5.py +177 -0
  20. package/templates/api/src/boards/generators/implementations/fal/image/reve_edit.py +178 -0
  21. package/templates/api/src/boards/generators/implementations/fal/image/reve_text_to_image.py +155 -0
  22. package/templates/api/src/boards/generators/implementations/fal/image/seedream_v45_text_to_image.py +180 -0
  23. package/templates/api/src/boards/generators/implementations/fal/video/__init__.py +18 -0
  24. package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_pro.py +168 -0
  25. package/templates/api/src/boards/generators/implementations/fal/video/kling_video_ai_avatar_v2_standard.py +159 -0
  26. package/templates/api/src/boards/generators/implementations/fal/video/veed_fabric_1_0.py +180 -0
  27. package/templates/api/src/boards/generators/implementations/fal/video/veo31.py +190 -0
  28. package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast.py +190 -0
  29. package/templates/api/src/boards/generators/implementations/fal/video/veo31_fast_image_to_video.py +191 -0
  30. package/templates/api/src/boards/generators/implementations/fal/video/veo31_first_last_frame_to_video.py +13 -6
  31. package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_image_to_video.py +212 -0
  32. package/templates/api/src/boards/generators/implementations/fal/video/wan_25_preview_text_to_video.py +208 -0
  33. package/templates/api/src/boards/generators/implementations/kie/__init__.py +11 -0
  34. package/templates/api/src/boards/generators/implementations/kie/base.py +316 -0
  35. package/templates/api/src/boards/generators/implementations/kie/image/__init__.py +3 -0
  36. package/templates/api/src/boards/generators/implementations/kie/image/nano_banana_edit.py +190 -0
  37. package/templates/api/src/boards/generators/implementations/kie/utils.py +98 -0
  38. package/templates/api/src/boards/generators/implementations/kie/video/__init__.py +8 -0
  39. package/templates/api/src/boards/generators/implementations/kie/video/veo3.py +161 -0
  40. package/templates/api/src/boards/graphql/resolvers/upload.py +1 -1
  41. package/templates/web/package.json +4 -1
  42. package/templates/web/src/app/boards/[boardId]/page.tsx +156 -24
  43. package/templates/web/src/app/globals.css +3 -0
  44. package/templates/web/src/app/layout.tsx +15 -5
  45. package/templates/web/src/components/boards/ArtifactInputSlots.tsx +9 -9
  46. package/templates/web/src/components/boards/ArtifactPreview.tsx +34 -18
  47. package/templates/web/src/components/boards/GenerationGrid.tsx +101 -7
  48. package/templates/web/src/components/boards/GenerationInput.tsx +21 -21
  49. package/templates/web/src/components/boards/GeneratorSelector.tsx +232 -30
  50. package/templates/web/src/components/boards/UploadArtifact.tsx +385 -75
  51. package/templates/web/src/components/header.tsx +3 -1
  52. package/templates/web/src/components/theme-provider.tsx +10 -0
  53. package/templates/web/src/components/theme-toggle.tsx +75 -0
  54. package/templates/web/src/components/ui/alert-dialog.tsx +157 -0
  55. package/templates/web/src/components/ui/toast.tsx +128 -0
  56. package/templates/web/src/components/ui/toaster.tsx +35 -0
  57. 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 { useUpload, ArtifactType } from "@weirdfingers/boards";
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
- export function UploadArtifact({ boardId, onUploadComplete }: UploadArtifactProps) {
12
- const { upload, isUploading, progress, error } = useUpload();
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 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;
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 result = await upload({
36
- boardId,
37
- artifactType,
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
- console.error("Upload failed:", err);
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
- [upload, boardId, onUploadComplete]
248
+ [uploadMultiple, boardId, onUploadComplete]
47
249
  );
48
250
 
49
251
  const handleUrlUpload = useCallback(async () => {
50
252
  if (!urlInput.trim()) return;
51
253
 
52
- try {
53
- // Default to image for URL uploads
54
- const result = await upload({
55
- boardId,
56
- artifactType: ArtifactType.IMAGE,
57
- source: urlInput.trim(),
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
- onUploadComplete?.(result.id);
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
- console.error("URL upload failed:", err);
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
- }, [upload, boardId, urlInput, onUploadComplete]);
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
- handleFileUpload(files[0]);
293
+ handleFilesUpload(files);
75
294
  }
76
295
  },
77
- [handleFileUpload]
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
- e.preventDefault();
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
- [handleFileUpload]
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-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors"
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-white rounded-lg shadow-xl p-6 z-50 border border-gray-200">
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-gray-900">Upload Artifact</h3>
370
+ <h3 className="text-lg font-semibold text-foreground">
371
+ Upload Artifacts
372
+ </h3>
134
373
  <button
135
- onClick={() => setIsOpen(false)}
136
- className="text-gray-400 hover:text-gray-600"
374
+ onClick={handleClose}
375
+ className="text-muted-foreground hover:text-foreground"
376
+ aria-label="Close upload dialog"
137
377
  >
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" />
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-orange-500 bg-orange-50"
152
- : "border-gray-300 hover:border-gray-400"
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 file = e.target.files?.[0];
160
- if (file) handleFileUpload(file);
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-gray-400"
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-orange-600 hover:text-orange-700 font-medium"
454
+ className="text-primary hover:text-primary/90 font-medium"
455
+ aria-label="Choose files to upload"
185
456
  >
186
- Choose a file
457
+ Choose files
187
458
  </button>
188
- <span className="text-gray-500"> or drag and drop here</span>
459
+ <span className="text-muted-foreground"> or drag and drop here</span>
189
460
  </div>
190
461
 
191
- <p className="text-sm text-gray-500">
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-gray-700 mb-2">
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-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
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-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
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
- {/* Progress bar */}
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-gray-600 mb-2">
231
- <span>Uploading...</span>
232
- <span>{Math.round(progress)}%</span>
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-gray-200 rounded-full h-2">
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-orange-500 h-2 rounded-full transition-all duration-300"
237
- style={{ width: `${progress}%` }}
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
- {/* 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>
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
+ }