@weirdfingers/boards 0.6.1 → 0.7.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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Hook for uploading multiple artifacts concurrently with individual progress tracking.
3
+ */
4
+
5
+ import { useCallback, useState, useRef } from "react";
6
+ import { useMutation } from "urql";
7
+ import { UPLOAD_ARTIFACT_FROM_URL, ArtifactType } from "../graphql/operations";
8
+ import { useAuth } from "../auth/context";
9
+ import { useApiConfig } from "../config/ApiConfigContext";
10
+
11
+ export interface MultiUploadRequest {
12
+ boardId: string;
13
+ artifactType: ArtifactType;
14
+ source: File | string; // File object or URL string
15
+ userDescription?: string;
16
+ parentGenerationId?: string;
17
+ }
18
+
19
+ export interface MultiUploadResult {
20
+ id: string;
21
+ storageUrl: string;
22
+ thumbnailUrl?: string;
23
+ status: "completed" | "failed";
24
+ artifactType: ArtifactType;
25
+ generatorName: string;
26
+ }
27
+
28
+ export type UploadStatus = "pending" | "uploading" | "completed" | "failed";
29
+
30
+ export interface UploadItem {
31
+ id: string;
32
+ file: File | string;
33
+ fileName: string;
34
+ status: UploadStatus;
35
+ progress: number;
36
+ result?: MultiUploadResult;
37
+ error?: Error;
38
+ }
39
+
40
+ export interface MultiUploadHook {
41
+ uploadMultiple: (requests: MultiUploadRequest[]) => Promise<MultiUploadResult[]>;
42
+ uploads: UploadItem[];
43
+ isUploading: boolean;
44
+ overallProgress: number;
45
+ clearUploads: () => void;
46
+ cancelUpload: (uploadId: string) => void;
47
+ }
48
+
49
+ let uploadIdCounter = 0;
50
+
51
+ function generateUploadId(): string {
52
+ return `upload-${Date.now()}-${++uploadIdCounter}`;
53
+ }
54
+
55
+ function getFileName(source: File | string): string {
56
+ if (typeof source === "string") {
57
+ try {
58
+ const url = new URL(source);
59
+ const pathParts = url.pathname.split("/");
60
+ return pathParts[pathParts.length - 1] || source;
61
+ } catch {
62
+ return source;
63
+ }
64
+ }
65
+ return source.name;
66
+ }
67
+
68
+ export function useMultiUpload(): MultiUploadHook {
69
+ const [uploads, setUploads] = useState<UploadItem[]>([]);
70
+ const abortControllersRef = useRef<Map<string, () => void>>(new Map());
71
+
72
+ const { apiUrl } = useApiConfig();
73
+ const auth = useAuth();
74
+
75
+ const [, uploadFromUrlMutation] = useMutation(UPLOAD_ARTIFACT_FROM_URL);
76
+
77
+ const updateUpload = useCallback(
78
+ (uploadId: string, updates: Partial<UploadItem>) => {
79
+ setUploads((prev) =>
80
+ prev.map((item) =>
81
+ item.id === uploadId ? { ...item, ...updates } : item
82
+ )
83
+ );
84
+ },
85
+ []
86
+ );
87
+
88
+ const uploadSingle = useCallback(
89
+ async (
90
+ uploadId: string,
91
+ request: MultiUploadRequest
92
+ ): Promise<MultiUploadResult> => {
93
+ updateUpload(uploadId, { status: "uploading", progress: 0 });
94
+
95
+ try {
96
+ // Handle URL upload via GraphQL
97
+ if (typeof request.source === "string") {
98
+ const result = await uploadFromUrlMutation({
99
+ input: {
100
+ boardId: request.boardId,
101
+ artifactType: request.artifactType,
102
+ fileUrl: request.source,
103
+ userDescription: request.userDescription,
104
+ parentGenerationId: request.parentGenerationId,
105
+ },
106
+ });
107
+
108
+ if (result.error) {
109
+ throw new Error(result.error.message);
110
+ }
111
+
112
+ if (!result.data?.uploadArtifact) {
113
+ throw new Error("Upload failed");
114
+ }
115
+
116
+ const uploadResult: MultiUploadResult = {
117
+ id: result.data.uploadArtifact.id,
118
+ storageUrl: result.data.uploadArtifact.storageUrl,
119
+ thumbnailUrl: result.data.uploadArtifact.thumbnailUrl,
120
+ status: "completed",
121
+ artifactType: result.data.uploadArtifact.artifactType,
122
+ generatorName: result.data.uploadArtifact.generatorName,
123
+ };
124
+
125
+ updateUpload(uploadId, {
126
+ status: "completed",
127
+ progress: 100,
128
+ result: uploadResult,
129
+ });
130
+
131
+ return uploadResult;
132
+ }
133
+
134
+ // Handle file upload via REST API
135
+ const formData = new FormData();
136
+ formData.append("board_id", request.boardId);
137
+ formData.append("artifact_type", request.artifactType.toLowerCase());
138
+ formData.append("file", request.source);
139
+ if (request.userDescription) {
140
+ formData.append("user_description", request.userDescription);
141
+ }
142
+ if (request.parentGenerationId) {
143
+ formData.append("parent_generation_id", request.parentGenerationId);
144
+ }
145
+
146
+ const token = await auth.getToken();
147
+ const headers: Record<string, string> = {};
148
+ if (token) {
149
+ headers.Authorization = `Bearer ${token}`;
150
+ }
151
+
152
+ // Use XMLHttpRequest for progress tracking
153
+ const result = await new Promise<MultiUploadResult>(
154
+ (resolve, reject) => {
155
+ const xhr = new XMLHttpRequest();
156
+
157
+ // Store abort function for cancellation
158
+ abortControllersRef.current.set(uploadId, () => xhr.abort());
159
+
160
+ // Cleanup function to ensure abort controller is always removed
161
+ const cleanup = () => {
162
+ abortControllersRef.current.delete(uploadId);
163
+ };
164
+
165
+ xhr.upload.addEventListener("progress", (e) => {
166
+ if (e.lengthComputable) {
167
+ const percentComplete = (e.loaded / e.total) * 100;
168
+ updateUpload(uploadId, { progress: percentComplete });
169
+ }
170
+ });
171
+
172
+ xhr.addEventListener("load", () => {
173
+ cleanup();
174
+ if (xhr.status === 200) {
175
+ try {
176
+ const data = JSON.parse(xhr.responseText);
177
+ resolve({
178
+ id: data.id,
179
+ storageUrl: data.storageUrl,
180
+ thumbnailUrl: data.thumbnailUrl,
181
+ status: "completed",
182
+ artifactType: data.artifactType as ArtifactType,
183
+ generatorName: data.generatorName,
184
+ });
185
+ } catch (err) {
186
+ reject(new Error("Failed to parse response"));
187
+ }
188
+ } else {
189
+ try {
190
+ const errorData = JSON.parse(xhr.responseText);
191
+ reject(
192
+ new Error(
193
+ errorData.detail || `Upload failed: ${xhr.statusText}`
194
+ )
195
+ );
196
+ } catch {
197
+ reject(new Error(`Upload failed: ${xhr.statusText}`));
198
+ }
199
+ }
200
+ });
201
+
202
+ xhr.addEventListener("error", () => {
203
+ cleanup();
204
+ reject(new Error("Upload failed: Network error"));
205
+ });
206
+
207
+ xhr.addEventListener("abort", () => {
208
+ cleanup();
209
+ reject(new Error("Upload cancelled"));
210
+ });
211
+
212
+ xhr.addEventListener("loadend", () => {
213
+ // Ensure cleanup happens in all cases (additional safety net)
214
+ cleanup();
215
+ });
216
+
217
+ xhr.open("POST", `${apiUrl}/api/uploads/artifact`);
218
+ Object.entries(headers).forEach(([key, value]) => {
219
+ xhr.setRequestHeader(key, value);
220
+ });
221
+ xhr.send(formData);
222
+ }
223
+ );
224
+
225
+ updateUpload(uploadId, {
226
+ status: "completed",
227
+ progress: 100,
228
+ result,
229
+ });
230
+
231
+ return result;
232
+ } catch (err) {
233
+ const uploadError =
234
+ err instanceof Error ? err : new Error("Upload failed");
235
+
236
+ updateUpload(uploadId, {
237
+ status: "failed",
238
+ error: uploadError,
239
+ });
240
+
241
+ throw uploadError;
242
+ }
243
+ },
244
+ [uploadFromUrlMutation, apiUrl, auth, updateUpload]
245
+ );
246
+
247
+ const uploadMultiple = useCallback(
248
+ async (requests: MultiUploadRequest[]): Promise<MultiUploadResult[]> => {
249
+ // Create upload items for all requests
250
+ const newUploads: UploadItem[] = requests.map((request) => ({
251
+ id: generateUploadId(),
252
+ file: request.source,
253
+ fileName: getFileName(request.source),
254
+ status: "pending" as UploadStatus,
255
+ progress: 0,
256
+ }));
257
+
258
+ setUploads((prev) => [...prev, ...newUploads]);
259
+
260
+ // Upload all files concurrently
261
+ const results = await Promise.allSettled(
262
+ requests.map((request, index) =>
263
+ uploadSingle(newUploads[index].id, request)
264
+ )
265
+ );
266
+
267
+ // Extract successful results
268
+ const successfulResults: MultiUploadResult[] = [];
269
+ results.forEach((result) => {
270
+ if (result.status === "fulfilled") {
271
+ successfulResults.push(result.value);
272
+ }
273
+ });
274
+
275
+ return successfulResults;
276
+ },
277
+ [uploadSingle]
278
+ );
279
+
280
+ const clearUploads = useCallback(() => {
281
+ // Abort any in-progress uploads
282
+ abortControllersRef.current.forEach((abort) => abort());
283
+ abortControllersRef.current.clear();
284
+ setUploads([]);
285
+ }, []);
286
+
287
+ const cancelUpload = useCallback((uploadId: string) => {
288
+ const abort = abortControllersRef.current.get(uploadId);
289
+ if (abort) {
290
+ abort();
291
+ }
292
+ }, []);
293
+
294
+ // Calculate derived state
295
+ const isUploading = uploads.some((u) => u.status === "uploading");
296
+ const overallProgress =
297
+ uploads.length > 0
298
+ ? uploads.reduce((sum, u) => sum + u.progress, 0) / uploads.length
299
+ : 0;
300
+
301
+ return {
302
+ uploadMultiple,
303
+ uploads,
304
+ isUploading,
305
+ overallProgress,
306
+ clearUploads,
307
+ cancelUpload,
308
+ };
309
+ }
package/src/index.ts CHANGED
@@ -32,7 +32,7 @@ export { useBoards } from "./hooks/useBoards";
32
32
  export { useBoard } from "./hooks/useBoard";
33
33
  export { useGeneration } from "./hooks/useGeneration";
34
34
  export { useGenerators } from "./hooks/useGenerators";
35
- export { useUpload } from "./hooks/useUpload";
35
+ export { useMultiUpload } from "./hooks/useMultiUpload";
36
36
  export type { Generator, JSONSchema7 } from "./hooks/useGenerators";
37
37
  export {
38
38
  useAncestry,
@@ -46,10 +46,12 @@ export type {
46
46
  DescendantNode,
47
47
  } from "./hooks/useLineage";
48
48
  export type {
49
- UploadRequest,
50
- UploadResult,
51
- UploadHook,
52
- } from "./hooks/useUpload";
49
+ MultiUploadRequest,
50
+ MultiUploadResult,
51
+ MultiUploadHook,
52
+ UploadItem,
53
+ UploadStatus,
54
+ } from "./hooks/useMultiUpload";
53
55
 
54
56
  // Generator schema utilities
55
57
  export * from "./types/generatorSchema";
@@ -1,175 +0,0 @@
1
- /**
2
- * Hook for uploading artifacts (images, videos, audio, text).
3
- */
4
-
5
- import { useCallback, useState } from "react";
6
- import { useMutation } from "urql";
7
- import { UPLOAD_ARTIFACT_FROM_URL, ArtifactType } from "../graphql/operations";
8
- import { useAuth } from "../auth/context";
9
- import { useApiConfig } from "../config/ApiConfigContext";
10
-
11
- export interface UploadRequest {
12
- boardId: string;
13
- artifactType: ArtifactType;
14
- source: File | string; // File object or URL string
15
- userDescription?: string;
16
- parentGenerationId?: string;
17
- }
18
-
19
- export interface UploadResult {
20
- id: string;
21
- storageUrl: string;
22
- thumbnailUrl?: string;
23
- status: "completed" | "failed";
24
- artifactType: ArtifactType;
25
- generatorName: string;
26
- }
27
-
28
- export interface UploadHook {
29
- upload: (request: UploadRequest) => Promise<UploadResult>;
30
- isUploading: boolean;
31
- progress: number; // 0-100
32
- error: Error | null;
33
- }
34
-
35
- export function useUpload(): UploadHook {
36
- const [isUploading, setIsUploading] = useState(false);
37
- const [progress, setProgress] = useState(0);
38
- const [error, setError] = useState<Error | null>(null);
39
-
40
- const { apiUrl } = useApiConfig();
41
- const auth = useAuth();
42
-
43
- const [, uploadFromUrlMutation] = useMutation(UPLOAD_ARTIFACT_FROM_URL);
44
-
45
- const upload = useCallback(
46
- async (request: UploadRequest): Promise<UploadResult> => {
47
- setError(null);
48
- setProgress(0);
49
- setIsUploading(true);
50
-
51
- try {
52
- // Handle URL upload via GraphQL
53
- if (typeof request.source === "string") {
54
- const result = await uploadFromUrlMutation({
55
- input: {
56
- boardId: request.boardId,
57
- artifactType: request.artifactType,
58
- fileUrl: request.source,
59
- userDescription: request.userDescription,
60
- parentGenerationId: request.parentGenerationId,
61
- },
62
- });
63
-
64
- if (result.error) {
65
- throw new Error(result.error.message);
66
- }
67
-
68
- if (!result.data?.uploadArtifact) {
69
- throw new Error("Upload failed");
70
- }
71
-
72
- setProgress(100);
73
- setIsUploading(false);
74
-
75
- return {
76
- id: result.data.uploadArtifact.id,
77
- storageUrl: result.data.uploadArtifact.storageUrl,
78
- thumbnailUrl: result.data.uploadArtifact.thumbnailUrl,
79
- status: "completed",
80
- artifactType: result.data.uploadArtifact.artifactType,
81
- generatorName: result.data.uploadArtifact.generatorName,
82
- };
83
- }
84
-
85
- // Handle file upload via REST API
86
- const formData = new FormData();
87
- formData.append("board_id", request.boardId);
88
- formData.append("artifact_type", request.artifactType.toLowerCase());
89
- formData.append("file", request.source);
90
- if (request.userDescription) {
91
- formData.append("user_description", request.userDescription);
92
- }
93
- if (request.parentGenerationId) {
94
- formData.append("parent_generation_id", request.parentGenerationId);
95
- }
96
-
97
- const token = await auth.getToken();
98
- const headers: Record<string, string> = {};
99
- if (token) {
100
- headers.Authorization = `Bearer ${token}`;
101
- }
102
-
103
- // Use XMLHttpRequest for progress tracking
104
- const result = await new Promise<UploadResult>((resolve, reject) => {
105
- const xhr = new XMLHttpRequest();
106
-
107
- xhr.upload.addEventListener("progress", (e) => {
108
- if (e.lengthComputable) {
109
- const percentComplete = (e.loaded / e.total) * 100;
110
- setProgress(percentComplete);
111
- }
112
- });
113
-
114
- xhr.addEventListener("load", () => {
115
- if (xhr.status === 200) {
116
- try {
117
- const data = JSON.parse(xhr.responseText);
118
- resolve({
119
- id: data.id,
120
- storageUrl: data.storageUrl,
121
- thumbnailUrl: data.thumbnailUrl,
122
- status: "completed",
123
- artifactType: data.artifactType as ArtifactType,
124
- generatorName: data.generatorName,
125
- });
126
- } catch (err) {
127
- reject(new Error("Failed to parse response"));
128
- }
129
- } else {
130
- try {
131
- const errorData = JSON.parse(xhr.responseText);
132
- reject(new Error(errorData.detail || `Upload failed: ${xhr.statusText}`));
133
- } catch {
134
- reject(new Error(`Upload failed: ${xhr.statusText}`));
135
- }
136
- }
137
- });
138
-
139
- xhr.addEventListener("error", () => {
140
- reject(new Error("Upload failed: Network error"));
141
- });
142
-
143
- xhr.addEventListener("abort", () => {
144
- reject(new Error("Upload cancelled"));
145
- });
146
-
147
- xhr.open("POST", `${apiUrl}/api/uploads/artifact`);
148
- Object.entries(headers).forEach(([key, value]) => {
149
- xhr.setRequestHeader(key, value);
150
- });
151
- xhr.send(formData);
152
- });
153
-
154
- setProgress(100);
155
- setIsUploading(false);
156
- return result;
157
- } catch (err) {
158
- const uploadError =
159
- err instanceof Error ? err : new Error("Upload failed");
160
- setError(uploadError);
161
- setIsUploading(false);
162
- setProgress(0);
163
- throw uploadError;
164
- }
165
- },
166
- [uploadFromUrlMutation, apiUrl, auth]
167
- );
168
-
169
- return {
170
- upload,
171
- isUploading,
172
- progress,
173
- error,
174
- };
175
- }