@weirdfingers/boards 0.6.2 → 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.
- package/dist/index.d.mts +23 -9
- package/dist/index.d.ts +23 -9
- package/dist/index.js +191 -65
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +190 -65
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -1
- package/src/graphql/operations.ts +6 -0
- package/src/hooks/__tests__/useMultiUpload.test.ts +238 -0
- package/src/hooks/useGeneration.ts +44 -0
- package/src/hooks/useMultiUpload.ts +309 -0
- package/src/index.ts +7 -5
- package/src/hooks/useUpload.ts +0 -175
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useMultiUpload hook.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
6
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
7
|
+
import { useMultiUpload } from "../useMultiUpload";
|
|
8
|
+
import { ArtifactType } from "../../graphql/operations";
|
|
9
|
+
|
|
10
|
+
// Mock dependencies
|
|
11
|
+
vi.mock("urql", async (importOriginal) => {
|
|
12
|
+
const actual = await importOriginal<typeof import("urql")>();
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
useMutation: vi.fn(() => [
|
|
16
|
+
{},
|
|
17
|
+
vi.fn().mockResolvedValue({
|
|
18
|
+
data: {
|
|
19
|
+
uploadArtifact: {
|
|
20
|
+
id: "test-artifact-id",
|
|
21
|
+
storageUrl: "https://example.com/artifact.jpg",
|
|
22
|
+
thumbnailUrl: "https://example.com/thumb.jpg",
|
|
23
|
+
artifactType: "IMAGE",
|
|
24
|
+
generatorName: "user-upload-image",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
}),
|
|
28
|
+
]),
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
vi.mock("../../auth/context", () => ({
|
|
33
|
+
useAuth: vi.fn(() => ({
|
|
34
|
+
getToken: vi.fn().mockResolvedValue("test-token"),
|
|
35
|
+
})),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("../../config/ApiConfigContext", () => ({
|
|
39
|
+
useApiConfig: vi.fn(() => ({
|
|
40
|
+
apiUrl: "http://localhost:8088",
|
|
41
|
+
})),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
describe("useMultiUpload", () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should initialize with empty uploads", () => {
|
|
50
|
+
const { result } = renderHook(() => useMultiUpload());
|
|
51
|
+
|
|
52
|
+
expect(result.current.uploads).toEqual([]);
|
|
53
|
+
expect(result.current.isUploading).toBe(false);
|
|
54
|
+
expect(result.current.overallProgress).toBe(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should handle URL upload via GraphQL", async () => {
|
|
58
|
+
const { result } = renderHook(() => useMultiUpload());
|
|
59
|
+
|
|
60
|
+
const uploadPromise = result.current.uploadMultiple([
|
|
61
|
+
{
|
|
62
|
+
boardId: "test-board-id",
|
|
63
|
+
artifactType: ArtifactType.IMAGE,
|
|
64
|
+
source: "https://example.com/image.jpg",
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
// Wait for the upload to start
|
|
69
|
+
await waitFor(() => {
|
|
70
|
+
expect(result.current.uploads.length).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result.current.uploads[0].fileName).toBe("image.jpg");
|
|
74
|
+
|
|
75
|
+
// Wait for upload to complete
|
|
76
|
+
const results = await uploadPromise;
|
|
77
|
+
|
|
78
|
+
await waitFor(() => {
|
|
79
|
+
expect(result.current.uploads[0].status).toBe("completed");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(results).toHaveLength(1);
|
|
83
|
+
expect(results[0].id).toBe("test-artifact-id");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should handle multiple URL uploads", async () => {
|
|
87
|
+
const { result } = renderHook(() => useMultiUpload());
|
|
88
|
+
|
|
89
|
+
const uploadPromise = result.current.uploadMultiple([
|
|
90
|
+
{
|
|
91
|
+
boardId: "test-board-id",
|
|
92
|
+
artifactType: ArtifactType.IMAGE,
|
|
93
|
+
source: "https://example.com/image1.jpg",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
boardId: "test-board-id",
|
|
97
|
+
artifactType: ArtifactType.IMAGE,
|
|
98
|
+
source: "https://example.com/image2.jpg",
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(result.current.uploads.length).toBe(2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const results = await uploadPromise;
|
|
107
|
+
|
|
108
|
+
await waitFor(() => {
|
|
109
|
+
expect(result.current.uploads.filter((u) => u.status === "completed")).toHaveLength(2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(results).toHaveLength(2);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should track upload progress", async () => {
|
|
116
|
+
const { result } = renderHook(() => useMultiUpload());
|
|
117
|
+
|
|
118
|
+
result.current.uploadMultiple([
|
|
119
|
+
{
|
|
120
|
+
boardId: "test-board-id",
|
|
121
|
+
artifactType: ArtifactType.IMAGE,
|
|
122
|
+
source: "https://example.com/image.jpg",
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
await waitFor(() => {
|
|
127
|
+
expect(result.current.uploads.length).toBeGreaterThan(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Initially progress should be 0
|
|
131
|
+
expect(result.current.overallProgress).toBeGreaterThanOrEqual(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should clear uploads", async () => {
|
|
135
|
+
const { result } = renderHook(() => useMultiUpload());
|
|
136
|
+
|
|
137
|
+
await result.current.uploadMultiple([
|
|
138
|
+
{
|
|
139
|
+
boardId: "test-board-id",
|
|
140
|
+
artifactType: ArtifactType.IMAGE,
|
|
141
|
+
source: "https://example.com/image.jpg",
|
|
142
|
+
},
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
await waitFor(() => {
|
|
146
|
+
expect(result.current.uploads.length).toBeGreaterThan(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
result.current.clearUploads();
|
|
150
|
+
|
|
151
|
+
await waitFor(() => {
|
|
152
|
+
expect(result.current.uploads).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should extract filename from URL", async () => {
|
|
157
|
+
const { result } = renderHook(() => useMultiUpload());
|
|
158
|
+
|
|
159
|
+
result.current.uploadMultiple([
|
|
160
|
+
{
|
|
161
|
+
boardId: "test-board-id",
|
|
162
|
+
artifactType: ArtifactType.IMAGE,
|
|
163
|
+
source: "https://example.com/path/to/my-image.jpg",
|
|
164
|
+
},
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
await waitFor(() => {
|
|
168
|
+
expect(result.current.uploads.length).toBe(1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(result.current.uploads[0].fileName).toBe("my-image.jpg");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("should handle upload failures gracefully", async () => {
|
|
175
|
+
// Mock a failed mutation
|
|
176
|
+
const { useMutation } = await import("urql");
|
|
177
|
+
vi.mocked(useMutation).mockReturnValueOnce([
|
|
178
|
+
{},
|
|
179
|
+
vi.fn().mockResolvedValue({
|
|
180
|
+
error: new Error("Upload failed"),
|
|
181
|
+
}),
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
const { result } = renderHook(() => useMultiUpload());
|
|
185
|
+
|
|
186
|
+
const uploadPromise = result.current.uploadMultiple([
|
|
187
|
+
{
|
|
188
|
+
boardId: "test-board-id",
|
|
189
|
+
artifactType: ArtifactType.IMAGE,
|
|
190
|
+
source: "https://example.com/image.jpg",
|
|
191
|
+
},
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
await waitFor(() => {
|
|
195
|
+
expect(result.current.uploads.length).toBe(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const results = await uploadPromise;
|
|
199
|
+
|
|
200
|
+
// Should return empty array when all uploads fail
|
|
201
|
+
expect(results).toEqual([]);
|
|
202
|
+
|
|
203
|
+
await waitFor(() => {
|
|
204
|
+
expect(result.current.uploads[0].status).toBe("failed");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(result.current.uploads[0].error).toBeDefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("should calculate overall progress correctly", async () => {
|
|
211
|
+
const { result } = renderHook(() => useMultiUpload());
|
|
212
|
+
|
|
213
|
+
result.current.uploadMultiple([
|
|
214
|
+
{
|
|
215
|
+
boardId: "test-board-id",
|
|
216
|
+
artifactType: ArtifactType.IMAGE,
|
|
217
|
+
source: "https://example.com/image1.jpg",
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
boardId: "test-board-id",
|
|
221
|
+
artifactType: ArtifactType.IMAGE,
|
|
222
|
+
source: "https://example.com/image2.jpg",
|
|
223
|
+
},
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
await waitFor(() => {
|
|
227
|
+
expect(result.current.uploads.length).toBe(2);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Wait for completion
|
|
231
|
+
await waitFor(() => {
|
|
232
|
+
expect(result.current.uploads.filter((u) => u.status === "completed")).toHaveLength(2);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Overall progress should be 100 when all complete
|
|
236
|
+
expect(result.current.overallProgress).toBe(100);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
CREATE_GENERATION,
|
|
10
10
|
CANCEL_GENERATION,
|
|
11
11
|
RETRY_GENERATION,
|
|
12
|
+
DELETE_GENERATION,
|
|
12
13
|
CreateGenerationInput,
|
|
13
14
|
ArtifactType,
|
|
14
15
|
} from "../graphql/operations";
|
|
@@ -98,6 +99,7 @@ export interface GenerationHook {
|
|
|
98
99
|
submit: (request: GenerationRequest) => Promise<string>;
|
|
99
100
|
cancel: (jobId: string) => Promise<void>;
|
|
100
101
|
retry: (jobId: string) => Promise<void>;
|
|
102
|
+
deleteGeneration: (jobId: string) => Promise<void>;
|
|
101
103
|
|
|
102
104
|
// History
|
|
103
105
|
history: GenerationResult[];
|
|
@@ -122,6 +124,7 @@ export function useGeneration(): GenerationHook {
|
|
|
122
124
|
const [, createGenerationMutation] = useMutation(CREATE_GENERATION);
|
|
123
125
|
const [, cancelGenerationMutation] = useMutation(CANCEL_GENERATION);
|
|
124
126
|
const [, retryGenerationMutation] = useMutation(RETRY_GENERATION);
|
|
127
|
+
const [, deleteGenerationMutation] = useMutation(DELETE_GENERATION);
|
|
125
128
|
|
|
126
129
|
// Clean up SSE connections on unmount
|
|
127
130
|
useEffect(() => {
|
|
@@ -411,6 +414,46 @@ export function useGeneration(): GenerationHook {
|
|
|
411
414
|
[retryGenerationMutation, connectToSSE]
|
|
412
415
|
);
|
|
413
416
|
|
|
417
|
+
const deleteGeneration = useCallback(
|
|
418
|
+
async (jobId: string): Promise<void> => {
|
|
419
|
+
try {
|
|
420
|
+
// Delete via GraphQL
|
|
421
|
+
const result = await deleteGenerationMutation({ id: jobId });
|
|
422
|
+
|
|
423
|
+
if (result.error) {
|
|
424
|
+
throw new Error(result.error.message);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (!result.data?.deleteGeneration) {
|
|
428
|
+
throw new Error("Failed to delete generation");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Close SSE connection if any
|
|
432
|
+
const controller = abortControllers.current.get(jobId);
|
|
433
|
+
if (controller) {
|
|
434
|
+
controller.abort();
|
|
435
|
+
abortControllers.current.delete(jobId);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Clear state if this was the current generation
|
|
439
|
+
if (progress?.jobId === jobId) {
|
|
440
|
+
setProgress(null);
|
|
441
|
+
setResult(null);
|
|
442
|
+
setIsGenerating(false);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Remove from history
|
|
446
|
+
setHistory((prev) => prev.filter((item) => item.jobId !== jobId));
|
|
447
|
+
} catch (err) {
|
|
448
|
+
setError(
|
|
449
|
+
err instanceof Error ? err : new Error("Failed to delete generation")
|
|
450
|
+
);
|
|
451
|
+
throw err;
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
[deleteGenerationMutation, progress]
|
|
455
|
+
);
|
|
456
|
+
|
|
414
457
|
const clearHistory = useCallback(() => {
|
|
415
458
|
setHistory([]);
|
|
416
459
|
}, []);
|
|
@@ -423,6 +466,7 @@ export function useGeneration(): GenerationHook {
|
|
|
423
466
|
submit,
|
|
424
467
|
cancel,
|
|
425
468
|
retry,
|
|
469
|
+
deleteGeneration,
|
|
426
470
|
history,
|
|
427
471
|
clearHistory,
|
|
428
472
|
};
|
|
@@ -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 {
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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";
|