@vryzel/file-next-headless 0.4.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 ADDED
@@ -0,0 +1,552 @@
1
+ import { useReducer, useCallback, useEffect, useRef, useState, useMemo } from 'react';
2
+ import { FileSystemError } from '@vryzel/file-next/errors';
3
+
4
+ // src/use-file-browser.ts
5
+ var initial = {
6
+ status: "idle",
7
+ files: [],
8
+ error: null
9
+ };
10
+ var reducer = (state, action) => {
11
+ switch (action.type) {
12
+ case "FETCH_START":
13
+ return { status: "loading", files: [], error: null };
14
+ case "FETCH_SUCCESS":
15
+ return { status: "success", files: action.files, error: null };
16
+ case "FETCH_ERROR":
17
+ return { status: "error", files: [], error: action.error };
18
+ }
19
+ };
20
+ function useFileBrowser(options) {
21
+ const { listFiles, tenantId, parentId, prefix, limit, autoFetch = false } = options;
22
+ const [state, dispatch] = useReducer(reducer, initial);
23
+ const run = useCallback(async () => {
24
+ dispatch({ type: "FETCH_START" });
25
+ const result = await listFiles({ tenantId, parentId, prefix, limit });
26
+ if (result.ok) {
27
+ dispatch({ type: "FETCH_SUCCESS", files: result.value.items });
28
+ } else {
29
+ dispatch({ type: "FETCH_ERROR", error: result.error });
30
+ }
31
+ }, [listFiles, tenantId, parentId, prefix, limit]);
32
+ useEffect(() => {
33
+ if (autoFetch) {
34
+ void run();
35
+ }
36
+ }, [autoFetch, run]);
37
+ return {
38
+ status: state.status,
39
+ files: state.files,
40
+ error: state.error,
41
+ refetch: run
42
+ };
43
+ }
44
+ var initial2 = {
45
+ status: "idle",
46
+ progress: 0,
47
+ error: null
48
+ };
49
+ var reducer2 = (state, action) => {
50
+ switch (action.type) {
51
+ case "UPLOAD_START":
52
+ return { status: "uploading", progress: 0, error: null };
53
+ case "PROGRESS": {
54
+ if (action.total <= 0) return state;
55
+ const pct = Math.min(100, Math.round(action.loaded / action.total * 100));
56
+ return { ...state, progress: pct };
57
+ }
58
+ case "UPLOAD_SUCCESS":
59
+ return { status: "success", progress: 100, error: null };
60
+ case "UPLOAD_ERROR":
61
+ return { status: "error", progress: state.progress, error: action.error };
62
+ case "UPLOAD_ABORTED":
63
+ return { status: "aborted", progress: state.progress, error: null };
64
+ }
65
+ };
66
+ function useUploader(options) {
67
+ const { uploadUrl, requestUpload, confirmUpload } = options;
68
+ const [state, dispatch] = useReducer(reducer2, initial2);
69
+ const xhrRef = useRef(null);
70
+ const canceledRef = useRef(false);
71
+ const startXhr = useCallback(
72
+ (file, target) => {
73
+ if (xhrRef.current !== null) return;
74
+ const xhr = new XMLHttpRequest();
75
+ xhrRef.current = xhr;
76
+ canceledRef.current = false;
77
+ xhr.upload.addEventListener("progress", (event) => {
78
+ if (event.lengthComputable) {
79
+ dispatch({ type: "PROGRESS", loaded: event.loaded, total: event.total });
80
+ }
81
+ });
82
+ xhr.addEventListener("load", () => {
83
+ if (xhrRef.current !== xhr || canceledRef.current) return;
84
+ xhrRef.current = null;
85
+ if (xhr.status < 200 || xhr.status >= 300) {
86
+ let message = `Upload failed (${xhr.status})`;
87
+ try {
88
+ const parsed = JSON.parse(xhr.responseText);
89
+ if (parsed.error?.message) message = parsed.error.message;
90
+ } catch {
91
+ }
92
+ dispatch({
93
+ type: "UPLOAD_ERROR",
94
+ error: new FileSystemError({
95
+ code: "NetworkError",
96
+ retryable: true,
97
+ message,
98
+ cause: { code: "HttpError", message: `status ${xhr.status}` }
99
+ })
100
+ });
101
+ return;
102
+ }
103
+ dispatch({ type: "UPLOAD_SUCCESS" });
104
+ void Promise.resolve(confirmUpload?.(xhr, file, { id: target.id })).catch(
105
+ (error) => {
106
+ dispatch({
107
+ type: "UPLOAD_ERROR",
108
+ error: new FileSystemError({
109
+ code: "InternalError",
110
+ retryable: false,
111
+ message: error instanceof Error ? error.message : "confirmUpload failed"
112
+ })
113
+ });
114
+ }
115
+ );
116
+ });
117
+ xhr.addEventListener("error", () => {
118
+ if (xhrRef.current !== xhr || canceledRef.current) return;
119
+ xhrRef.current = null;
120
+ dispatch({
121
+ type: "UPLOAD_ERROR",
122
+ error: new FileSystemError({
123
+ code: "NetworkError",
124
+ retryable: true,
125
+ message: "XHR upload failed (usually CORS). Same-origin /api/upload avoids this.",
126
+ cause: { code: "XhrError", message: "XHR error event" }
127
+ })
128
+ });
129
+ });
130
+ dispatch({ type: "UPLOAD_START" });
131
+ xhr.open(target.method ?? "PUT", target.url);
132
+ xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream");
133
+ for (const [header, value] of Object.entries(target.headers ?? {})) {
134
+ xhr.setRequestHeader(header, value);
135
+ }
136
+ xhr.send(file.content);
137
+ },
138
+ [confirmUpload]
139
+ );
140
+ const upload = useCallback(
141
+ (file) => {
142
+ if (xhrRef.current !== null) return;
143
+ if (requestUpload) {
144
+ dispatch({ type: "UPLOAD_START" });
145
+ void requestUpload(file).then((target) => {
146
+ if (canceledRef.current) return;
147
+ xhrRef.current = null;
148
+ startXhr(file, target);
149
+ }).catch((error) => {
150
+ dispatch({
151
+ type: "UPLOAD_ERROR",
152
+ error: new FileSystemError({
153
+ code: "NetworkError",
154
+ retryable: true,
155
+ message: error instanceof Error ? error.message : "requestUpload failed"
156
+ })
157
+ });
158
+ });
159
+ return;
160
+ }
161
+ if (!uploadUrl) return;
162
+ startXhr(file, { url: uploadUrl, method: "PUT" });
163
+ },
164
+ [requestUpload, startXhr, uploadUrl]
165
+ );
166
+ const cancel = useCallback(() => {
167
+ const xhr = xhrRef.current;
168
+ if (xhr === null) return;
169
+ xhr.abort();
170
+ canceledRef.current = true;
171
+ xhrRef.current = null;
172
+ dispatch({ type: "UPLOAD_ABORTED" });
173
+ }, []);
174
+ return {
175
+ status: state.status,
176
+ progress: state.progress,
177
+ error: state.error,
178
+ upload,
179
+ cancel
180
+ };
181
+ }
182
+ var initial3 = {
183
+ status: "idle",
184
+ error: null
185
+ };
186
+ var reducer3 = (state, action) => {
187
+ switch (action.type) {
188
+ case "START":
189
+ return { status: "pending", error: null };
190
+ case "SUCCESS":
191
+ return { status: "idle", error: null };
192
+ case "ERROR":
193
+ return { status: "idle", error: action.error };
194
+ }
195
+ };
196
+ function applyDelete(files, id) {
197
+ return files.filter((f) => f.id !== id);
198
+ }
199
+ function applyMove(files, id, newParentId) {
200
+ return files.map((f) => f.id === id ? { ...f, parentId: newParentId } : f);
201
+ }
202
+ function applyCopy(files, node) {
203
+ return [...files, node];
204
+ }
205
+ function applyRename(files, id, newName) {
206
+ return files.map((file) => file.id === id ? { ...file, name: newName } : file);
207
+ }
208
+ function useFileActions(options) {
209
+ const { files, setFiles, actions } = options;
210
+ const [state, dispatch] = useReducer(reducer3, initial3);
211
+ const run = useCallback(
212
+ async (args) => {
213
+ const snapshot = files;
214
+ const optimisticNext = args.optimistic();
215
+ dispatch({ type: "START" });
216
+ if (optimisticNext !== snapshot) {
217
+ setFiles(optimisticNext);
218
+ }
219
+ const result = await args.act(args.input);
220
+ if (result.ok) {
221
+ if (args.onSuccess) {
222
+ setFiles(args.onSuccess(result.value));
223
+ }
224
+ dispatch({ type: "SUCCESS" });
225
+ } else {
226
+ setFiles(snapshot);
227
+ dispatch({ type: "ERROR", error: result.error });
228
+ }
229
+ },
230
+ [files, setFiles]
231
+ );
232
+ const deleteFile = useCallback(
233
+ (id) => run({
234
+ input: { id },
235
+ optimistic: () => applyDelete(files, id),
236
+ act: actions.deleteFile
237
+ }),
238
+ [run, actions.deleteFile, files]
239
+ );
240
+ const moveFile = useCallback(
241
+ (id, newParentId) => run({
242
+ input: { id, newParentId },
243
+ optimistic: () => applyMove(files, id, newParentId),
244
+ act: actions.moveFile
245
+ }),
246
+ [run, actions.moveFile, files]
247
+ );
248
+ const copyFile = useCallback(
249
+ (id, newParentId) => run({
250
+ input: { id, newParentId },
251
+ optimistic: () => files,
252
+ // copy doesn't remove anything
253
+ act: actions.copyFile,
254
+ onSuccess: (output) => applyCopy(files, output.node)
255
+ }),
256
+ [run, actions.copyFile, files]
257
+ );
258
+ const renameFile = useCallback(
259
+ async (id, newName) => {
260
+ const snapshot = files;
261
+ dispatch({ type: "START" });
262
+ setFiles(applyRename(files, id, newName));
263
+ try {
264
+ await actions.renameFile(id, newName);
265
+ dispatch({ type: "SUCCESS" });
266
+ } catch (error) {
267
+ setFiles(snapshot);
268
+ dispatch({ type: "ERROR", error });
269
+ }
270
+ },
271
+ [actions.renameFile, files, setFiles]
272
+ );
273
+ return {
274
+ status: state.status,
275
+ isPending: state.status === "pending",
276
+ error: state.error,
277
+ deleteFile,
278
+ moveFile,
279
+ copyFile,
280
+ renameFile
281
+ };
282
+ }
283
+ function useFileUrl(options) {
284
+ const { getDownloadUrl, key, enabled = true } = options;
285
+ const [url, setUrl] = useState(null);
286
+ const [error, setError] = useState(null);
287
+ const [status, setStatus] = useState(enabled ? "loading" : "idle");
288
+ const latestKeyRef = useRef(null);
289
+ const latestEnabledRef = useRef(null);
290
+ useEffect(() => {
291
+ if (!enabled) {
292
+ latestEnabledRef.current = false;
293
+ setStatus("idle");
294
+ return;
295
+ }
296
+ latestEnabledRef.current = true;
297
+ latestKeyRef.current = key;
298
+ setStatus("loading");
299
+ setError(null);
300
+ let cancelled = false;
301
+ void (async () => {
302
+ const result = await getDownloadUrl({ key });
303
+ if (cancelled) return;
304
+ if (latestKeyRef.current !== key) return;
305
+ if (latestEnabledRef.current === false) return;
306
+ if (result.ok) {
307
+ setUrl(result.value.url);
308
+ setStatus("success");
309
+ } else {
310
+ setError(result.error);
311
+ setStatus("error");
312
+ }
313
+ })();
314
+ return () => {
315
+ cancelled = true;
316
+ };
317
+ }, [getDownloadUrl, key, enabled]);
318
+ return { url, status, error };
319
+ }
320
+ var initial4 = {
321
+ status: "idle",
322
+ progress: 0,
323
+ blob: null,
324
+ error: null
325
+ };
326
+ var reducer4 = (state, action) => {
327
+ switch (action.type) {
328
+ case "START":
329
+ return { status: "loading", progress: 0, blob: null, error: null };
330
+ case "PROGRESS": {
331
+ if (action.total <= 0) return state;
332
+ const pct = Math.min(100, Math.round(action.loaded / action.total * 100));
333
+ return { ...state, progress: pct };
334
+ }
335
+ case "SUCCESS":
336
+ return { status: "success", progress: 100, blob: action.blob, error: null };
337
+ case "ERROR":
338
+ return { ...state, status: "error", error: action.error };
339
+ case "ABORTED":
340
+ return { ...state, status: "aborted" };
341
+ }
342
+ };
343
+ function useDownloadProgress(options) {
344
+ const { url, externalSignal } = options;
345
+ const [state, dispatch] = useReducer(reducer4, initial4);
346
+ const acRef = useRef(null);
347
+ const fetchIdRef = useRef(0);
348
+ useEffect(() => {
349
+ const fetchId = ++fetchIdRef.current;
350
+ const ac = new AbortController();
351
+ acRef.current = ac;
352
+ let onExternalAbort = null;
353
+ if (externalSignal) {
354
+ onExternalAbort = () => ac.abort();
355
+ if (externalSignal.aborted) {
356
+ ac.abort();
357
+ } else {
358
+ externalSignal.addEventListener("abort", onExternalAbort);
359
+ }
360
+ }
361
+ dispatch({ type: "START" });
362
+ void (async () => {
363
+ try {
364
+ const res = await fetch(url, { signal: ac.signal });
365
+ if (!res.ok) {
366
+ throw new Error(`HTTP ${res.status}`);
367
+ }
368
+ const body = res.body;
369
+ if (!body) {
370
+ if (fetchId !== fetchIdRef.current) return;
371
+ dispatch({ type: "SUCCESS", blob: new Blob([]) });
372
+ return;
373
+ }
374
+ const reader = body.getReader();
375
+ const chunks = [];
376
+ let loaded = 0;
377
+ const totalRaw = res.headers?.get?.("content-length");
378
+ const total = totalRaw ? Number(totalRaw) : 0;
379
+ while (true) {
380
+ const { done, value } = await reader.read();
381
+ if (done) break;
382
+ if (!value) continue;
383
+ chunks.push(value);
384
+ loaded += value.byteLength;
385
+ if (fetchId !== fetchIdRef.current) return;
386
+ if (total > 0) {
387
+ dispatch({ type: "PROGRESS", loaded, total });
388
+ }
389
+ }
390
+ if (fetchId !== fetchIdRef.current) return;
391
+ const blob = new Blob(chunks);
392
+ dispatch({ type: "SUCCESS", blob });
393
+ } catch (err) {
394
+ if (fetchId !== fetchIdRef.current) return;
395
+ if (ac.signal.aborted || err instanceof Error && err.name === "AbortError") {
396
+ dispatch({ type: "ABORTED" });
397
+ return;
398
+ }
399
+ dispatch({
400
+ type: "ERROR",
401
+ error: new FileSystemError({
402
+ code: "NetworkError",
403
+ retryable: true,
404
+ message: err instanceof Error ? err.message : "fetch failed"
405
+ })
406
+ });
407
+ } finally {
408
+ if (onExternalAbort && externalSignal) {
409
+ externalSignal.removeEventListener("abort", onExternalAbort);
410
+ }
411
+ }
412
+ })();
413
+ return () => {
414
+ ac.abort();
415
+ };
416
+ }, [url, externalSignal]);
417
+ const cancel = useCallback(() => {
418
+ acRef.current?.abort();
419
+ dispatch({ type: "ABORTED" });
420
+ }, []);
421
+ return {
422
+ status: state.status,
423
+ progress: state.progress,
424
+ blob: state.blob,
425
+ error: state.error,
426
+ cancel
427
+ };
428
+ }
429
+ var reducer5 = (state, action) => {
430
+ switch (action.type) {
431
+ case "SET_VIEW":
432
+ return { ...state, view: action.view };
433
+ case "SELECT":
434
+ return { ...state, selectedId: action.id };
435
+ case "OPEN_CONTEXT":
436
+ return { ...state, contextTarget: action.file };
437
+ case "BEGIN_DRAG":
438
+ return { ...state, draggingId: action.id, dropTargetId: null };
439
+ case "SET_DROP_TARGET":
440
+ return { ...state, dropTargetId: action.id };
441
+ case "END_DRAG":
442
+ return { ...state, draggingId: null, dropTargetId: null };
443
+ }
444
+ };
445
+ var initialState = (initialView) => ({
446
+ view: initialView,
447
+ selectedId: null,
448
+ contextTarget: null,
449
+ draggingId: null,
450
+ dropTargetId: null
451
+ });
452
+ function useFileExplorer(options) {
453
+ const {
454
+ listFiles,
455
+ tenantId,
456
+ parentId,
457
+ initialView = "list",
458
+ autoFetch = true,
459
+ onActivate,
460
+ onMove
461
+ } = options;
462
+ const [state, dispatch] = useReducer(reducer5, initialView, initialState);
463
+ const lastParentId = useRef(parentId);
464
+ useEffect(() => {
465
+ if (lastParentId.current !== parentId) {
466
+ lastParentId.current = parentId;
467
+ dispatch({ type: "SELECT", id: null });
468
+ dispatch({ type: "OPEN_CONTEXT", file: null });
469
+ dispatch({ type: "END_DRAG" });
470
+ }
471
+ }, [parentId]);
472
+ const { status, files, error, refetch } = useFileBrowser({
473
+ listFiles,
474
+ tenantId,
475
+ parentId,
476
+ autoFetch
477
+ });
478
+ const setView = useCallback((view) => {
479
+ dispatch({ type: "SET_VIEW", view });
480
+ }, []);
481
+ const setSelected = useCallback((id) => {
482
+ dispatch({ type: "SELECT", id });
483
+ if (id && onActivate) {
484
+ const file = files.find((f) => f.id === id);
485
+ if (file) onActivate(file);
486
+ }
487
+ }, [files, onActivate]);
488
+ const openContextMenu = useCallback((file) => {
489
+ dispatch({ type: "OPEN_CONTEXT", file });
490
+ }, []);
491
+ const beginDrag = useCallback((id) => {
492
+ dispatch({ type: "BEGIN_DRAG", id });
493
+ }, []);
494
+ const setDropTarget = useCallback((id) => {
495
+ dispatch({ type: "SET_DROP_TARGET", id });
496
+ }, []);
497
+ const commitDrop = useCallback((destinationFolderId) => {
498
+ const destination = files.find((file) => file.id === destinationFolderId);
499
+ if (state.draggingId && destination?.kind === "folder" && state.draggingId !== destinationFolderId) {
500
+ void onMove?.({
501
+ itemIds: [state.draggingId],
502
+ destinationFolderId
503
+ });
504
+ }
505
+ dispatch({ type: "END_DRAG" });
506
+ }, [files, onMove, state.draggingId]);
507
+ const endDrag = useCallback(() => {
508
+ dispatch({ type: "END_DRAG" });
509
+ }, []);
510
+ return useMemo(
511
+ () => ({
512
+ status,
513
+ files,
514
+ error,
515
+ refetch,
516
+ view: state.view,
517
+ setView,
518
+ selectedId: state.selectedId,
519
+ setSelected,
520
+ contextTarget: state.contextTarget,
521
+ openContextMenu,
522
+ draggingId: state.draggingId,
523
+ dropTargetId: state.dropTargetId,
524
+ beginDrag,
525
+ setDropTarget,
526
+ commitDrop,
527
+ endDrag
528
+ }),
529
+ [
530
+ status,
531
+ files,
532
+ error,
533
+ refetch,
534
+ state.view,
535
+ state.selectedId,
536
+ state.contextTarget,
537
+ state.draggingId,
538
+ state.dropTargetId,
539
+ setView,
540
+ setSelected,
541
+ openContextMenu,
542
+ beginDrag,
543
+ setDropTarget,
544
+ commitDrop,
545
+ endDrag
546
+ ]
547
+ );
548
+ }
549
+
550
+ export { useDownloadProgress, useFileActions, useFileBrowser, useFileExplorer, useFileUrl, useUploader };
551
+ //# sourceMappingURL=index.js.map
552
+ //# sourceMappingURL=index.js.map