@brftech/filex-core 0.1.56

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/dist/ArchiveViewer-CJdaw5x3.js +99 -0
  4. package/dist/ArchiveViewer-CJdaw5x3.js.map +1 -0
  5. package/dist/CsvViewer-QYx_tePQ.js +132 -0
  6. package/dist/CsvViewer-QYx_tePQ.js.map +1 -0
  7. package/dist/DrawioViewer-GWt0TyB7.js +141 -0
  8. package/dist/DrawioViewer-GWt0TyB7.js.map +1 -0
  9. package/dist/EpubViewer-Je0ywklQ.js +146 -0
  10. package/dist/EpubViewer-Je0ywklQ.js.map +1 -0
  11. package/dist/IpynbViewer-TsB4gqP-.js +175 -0
  12. package/dist/IpynbViewer-TsB4gqP-.js.map +1 -0
  13. package/dist/MermaidViewer-CXfeDS-7.js +128 -0
  14. package/dist/MermaidViewer-CXfeDS-7.js.map +1 -0
  15. package/dist/PsdViewer-VtPpxKVe.js +113 -0
  16. package/dist/PsdViewer-VtPpxKVe.js.map +1 -0
  17. package/dist/TiffViewer-OINn9bbb.js +133 -0
  18. package/dist/TiffViewer-OINn9bbb.js.map +1 -0
  19. package/dist/UTIF-Cjsrlm0l.js +3104 -0
  20. package/dist/UTIF-Cjsrlm0l.js.map +1 -0
  21. package/dist/Viewer3D-xvvyiWHd.js +61 -0
  22. package/dist/Viewer3D-xvvyiWHd.js.map +1 -0
  23. package/dist/_commonjsHelpers-DaMA6jEr.js +9 -0
  24. package/dist/_commonjsHelpers-DaMA6jEr.js.map +1 -0
  25. package/dist/filex-core.js +26 -0
  26. package/dist/filex-core.js.map +1 -0
  27. package/dist/filex-core.umd.cjs +285 -0
  28. package/dist/filex-core.umd.cjs.map +1 -0
  29. package/dist/index-CmSYzt6L.js +5404 -0
  30. package/dist/index-CmSYzt6L.js.map +1 -0
  31. package/dist/index-UFULWo35.js +10736 -0
  32. package/dist/index-UFULWo35.js.map +1 -0
  33. package/dist/index.d.ts +1089 -0
  34. package/dist/katex-yuB6V-q6.js +11616 -0
  35. package/dist/katex-yuB6V-q6.js.map +1 -0
  36. package/dist/papaparse.min-VB1HBwYX.js +441 -0
  37. package/dist/papaparse.min-VB1HBwYX.js.map +1 -0
  38. package/dist/style.css +1 -0
  39. package/dist/useViewerFetch-czqbd2Lj.js +25 -0
  40. package/dist/useViewerFetch-czqbd2Lj.js.map +1 -0
  41. package/package.json +113 -0
  42. package/src/FileExplorer.vue +1872 -0
  43. package/src/components/Breadcrumb.vue +282 -0
  44. package/src/components/ContextMenu.vue +152 -0
  45. package/src/components/GridView.vue +166 -0
  46. package/src/components/ListView.vue +212 -0
  47. package/src/components/PendingOpsTray.vue +91 -0
  48. package/src/components/RecentlyOpened.vue +155 -0
  49. package/src/components/StarButton.vue +101 -0
  50. package/src/components/TagPicker.vue +184 -0
  51. package/src/components/Toolbar.vue +220 -0
  52. package/src/components/UploadProgress.vue +78 -0
  53. package/src/composables/useFileApi.ts +692 -0
  54. package/src/composables/useKeyboardShortcuts.ts +144 -0
  55. package/src/composables/useLocale.ts +60 -0
  56. package/src/composables/useMonacoLoader.ts +98 -0
  57. package/src/composables/usePendingOps.ts +200 -0
  58. package/src/composables/useSelection.ts +81 -0
  59. package/src/composables/useUploadChunked.ts +250 -0
  60. package/src/composables/useViewerFetch.ts +58 -0
  61. package/src/index.ts +80 -0
  62. package/src/locales/en.ts +122 -0
  63. package/src/locales/index.ts +12 -0
  64. package/src/locales/tr.ts +122 -0
  65. package/src/modals/ConvertModal.vue +254 -0
  66. package/src/modals/DeleteConfirmModal.vue +32 -0
  67. package/src/modals/Modal.vue +104 -0
  68. package/src/modals/NewFolderModal.vue +64 -0
  69. package/src/modals/PermissionsModal.vue +815 -0
  70. package/src/modals/PreviewModal.vue +1070 -0
  71. package/src/modals/RenameModal.vue +58 -0
  72. package/src/modals/ShareModal.vue +135 -0
  73. package/src/styles/base.css +1224 -0
  74. package/src/styles/variables.css +90 -0
  75. package/src/types/ExplorerConfig.ts +282 -0
  76. package/src/types/FileNode.ts +136 -0
  77. package/src/types/index.ts +27 -0
  78. package/src/types/peers.d.ts +45 -0
  79. package/src/viewers/ArchiveViewer.vue +186 -0
  80. package/src/viewers/CsvViewer.vue +319 -0
  81. package/src/viewers/DrawioViewer.vue +243 -0
  82. package/src/viewers/EpubViewer.vue +307 -0
  83. package/src/viewers/IpynbViewer.vue +365 -0
  84. package/src/viewers/MermaidViewer.vue +259 -0
  85. package/src/viewers/PdfViewer.vue +562 -0
  86. package/src/viewers/PsdViewer.vue +264 -0
  87. package/src/viewers/TiffViewer.vue +273 -0
  88. package/src/viewers/Viewer3D.vue +120 -0
@@ -0,0 +1,692 @@
1
+ /**
2
+ * useFileApi — backend wrapper for the manager + ancillary endpoints.
3
+ *
4
+ * Two URL strategies, picked at construction time:
5
+ *
6
+ * 1. NEW (RESTful): caller passes `apiBase: 'https://files.example.com'`.
7
+ * The manager endpoint becomes `${apiBase}/api/files/manager` and
8
+ * every action uses `?action=…&path=…` as a query parameter (still
9
+ * Vuefinder-compatible — the same `?q=…` convention is accepted by
10
+ * the new Go backend, but `action` is the canonical name in v0.1+).
11
+ *
12
+ * 2. LEGACY (Vuefinder-compat): caller passes `endpoint:
13
+ * '/api/files/manager'` (and the rest of the per-route fields).
14
+ * Used by `@brftech/file-explorer` 0.1.0 embedders that have a
15
+ * Laravel/Filament backend already.
16
+ *
17
+ * Per-route fields (`uploadInit`, `shareCreate`, …) ALWAYS win over
18
+ * the auto-derived `apiBase` URL — lets the caller mix and match.
19
+ *
20
+ * Auth normalisation is centralised here. The component code never
21
+ * thinks about CSRF vs Bearer vs Basic — it just calls `index(path)`.
22
+ */
23
+
24
+ import type { ExplorerConfig, AuthConfig, EndpointMap } from '../types/ExplorerConfig';
25
+ import type {
26
+ FileNode,
27
+ ShareInfo,
28
+ UploadLimits,
29
+ Capabilities,
30
+ ArchiveEntry,
31
+ TrashEntry,
32
+ } from '../types/FileNode';
33
+
34
+ /** Server-side PendingOp DTO (mirror of Modules\FishApp\Models\PendingOp::toApiArray). */
35
+ export interface PendingOpDto {
36
+ id: number;
37
+ op_type: 'copy' | 'move' | 'delete';
38
+ status: 'pending' | 'running' | 'done' | 'error';
39
+ progress_total: number;
40
+ progress_done: number;
41
+ target_path: string | null;
42
+ source_dir: string | null;
43
+ source_count: number;
44
+ error_message: string | null;
45
+ started_at: string | null;
46
+ finished_at: string | null;
47
+ created_at: string | null;
48
+ }
49
+
50
+ export interface ManagerResponse {
51
+ adapter: string;
52
+ storages: string[];
53
+ dirname: string;
54
+ read_only: boolean;
55
+ /** RBAC effective level for the current user on this directory ('' when ACL
56
+ * is not enforced on the storage). Gates the folder-level write actions. */
57
+ perm?: 'none' | 'viewer' | 'editor' | 'owner';
58
+ files: FileNode[];
59
+ }
60
+
61
+ /** A single ACL grant row (RBAC permissions panel). */
62
+ export interface Grant {
63
+ id: number;
64
+ storage_id: number;
65
+ path_prefix: string;
66
+ user_id: number;
67
+ level: 'viewer' | 'editor' | 'owner';
68
+ user_email?: string;
69
+ user_display_name?: string;
70
+ inherited?: boolean;
71
+ }
72
+
73
+ export interface PermissionsResponse {
74
+ path: string;
75
+ storage_rbac: boolean;
76
+ direct: Grant[];
77
+ inherited: Grant[];
78
+ effective: string;
79
+ }
80
+
81
+ export interface ResolveEmailResponse {
82
+ found: boolean;
83
+ user?: { id: number; email: string; display_name: string; role: string };
84
+ }
85
+
86
+ export interface UserSuggestion {
87
+ id: number;
88
+ email: string;
89
+ display_name: string;
90
+ role: string;
91
+ }
92
+ export interface UserSearchResponse {
93
+ users: UserSuggestion[];
94
+ }
95
+
96
+ export interface InviteResponse {
97
+ mode: 'granted' | 'user_created' | 'shared';
98
+ user_id?: number;
99
+ url?: string;
100
+ temp_password?: string;
101
+ emailed: boolean;
102
+ }
103
+
104
+ /**
105
+ * Resolve a Vuefinder-compatible endpoint map from the user's config.
106
+ * Either `apiBase` is set (auto-derive everything) or each route is
107
+ * supplied explicitly (legacy). Mixed mode works too — explicit fields
108
+ * trump the derived URL.
109
+ */
110
+ export function resolveEndpoints(config: ExplorerConfig): EndpointMap {
111
+ // `apiBase: ''` (empty string) is a *valid* relative-root prefix —
112
+ // it produces URLs like `/api/files/copy`. Treat only `undefined`
113
+ // as "no apiBase, legacy explicit-only mode". Falsy boolean checks
114
+ // would silently drop relative-root callers and leave every derived
115
+ // endpoint null → "endpoint not configured" UI dead-ends.
116
+ const base =
117
+ config.apiBase != null ? config.apiBase.replace(/\/+$/, '') : null;
118
+
119
+ function derive(path: string | undefined, autoSegment: string): string | null {
120
+ if (path) return path;
121
+ if (base === null) return null;
122
+ return `${base}${autoSegment}`;
123
+ }
124
+
125
+ // Manager URL is mandatory — pick the explicit `endpoint` first, then
126
+ // fall back to `${apiBase}/api/files/manager`.
127
+ const manager =
128
+ config.endpoint ??
129
+ (base !== null ? `${base}/api/files/manager` : null);
130
+
131
+ if (!manager) {
132
+ throw new Error(
133
+ "[@brftech/filex-core] config requires either `apiBase` or `endpoint`",
134
+ );
135
+ }
136
+
137
+ return {
138
+ manager,
139
+ uploadInit: derive(config.uploadInit, '/api/files/upload/init'),
140
+ uploadFinalize: derive(config.uploadFinalize, '/api/files/upload/finalize'),
141
+ uploadAbort: derive(config.uploadAbort, '/api/files/upload/abort'),
142
+ shareCreate: derive(config.shareCreate, '/api/files/share'),
143
+ shareList: derive(config.shareList, '/api/files/share'),
144
+ shareDelete: derive(config.shareDelete, '/api/files/share/{uuid}'),
145
+ limits: derive(config.limits, '/api/files/limits'),
146
+ capabilities: derive(config.capabilities, '/api/files/capabilities'),
147
+ archiveList: derive(config.archiveList, '/api/files/archive/list'),
148
+ archiveExtract: derive(config.archiveExtract, '/api/files/archive/extract'),
149
+ archiveAdd: derive(config.archiveAdd, '/api/files/archive/add'),
150
+ copy: derive(config.copy, '/api/files/copy'),
151
+ moveAsync: derive(config.moveAsync, '/api/files/move'),
152
+ deleteAsync: derive(config.deleteAsync, '/api/files/delete'),
153
+ opsList: derive(config.opsList, '/api/files/ops'),
154
+ opsShow: derive(config.opsShow, '/api/files/ops/{id}'),
155
+ onlyOfficeConfig: derive(config.onlyOfficeConfig, '/api/files/onlyoffice/config'),
156
+ saveText: derive(config.saveText, '/api/files/save-text'),
157
+ restore: derive(config.restore, '/api/files/restore'),
158
+ // filex trash: list soft-deleted nodes + restore one by node id.
159
+ trashList: derive(config.trashList, '/api/files/manager/trash'),
160
+ trashRestore: derive(config.trashRestore, '/api/files/manager/restore'),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Resolve a possibly-async bearer token to a string. Caller passes
166
+ * `auth.token` here so the auth header is fresh on every request.
167
+ */
168
+ async function resolveToken(t: string | (() => string | Promise<string>)): Promise<string> {
169
+ if (typeof t === 'function') {
170
+ const out = t();
171
+ return out instanceof Promise ? await out : out;
172
+ }
173
+ return t;
174
+ }
175
+
176
+ /**
177
+ * Normalise the legacy `{type: 'bearer'}` shape to the modern
178
+ * `{kind: 'bearer'}` discriminator. Lets us write a single auth-header
179
+ * builder downstream.
180
+ */
181
+ function normalizeAuth(auth: AuthConfig | undefined): { kind: 'bearer'; token: string | (() => string | Promise<string>) }
182
+ | { kind: 'csrf'; csrf: string }
183
+ | { kind: 'basic'; user: string; pass: string }
184
+ | { kind: 'none' } {
185
+ if (!auth) return { kind: 'none' };
186
+ if ('kind' in auth) return auth;
187
+ // Legacy shape — translate.
188
+ if (auth.type === 'bearer') return { kind: 'bearer', token: auth.token };
189
+ if (auth.type === 'csrf') return { kind: 'csrf', csrf: auth.csrf };
190
+ return { kind: 'none' };
191
+ }
192
+
193
+ export function useFileApi(config: ExplorerConfig) {
194
+ const endpoints = resolveEndpoints(config);
195
+ const authConf = normalizeAuth(config.auth);
196
+
197
+ async function authHeaders(extra: Record<string, string> = {}): Promise<Record<string, string>> {
198
+ const h: Record<string, string> = { Accept: 'application/json', ...extra };
199
+ if (authConf.kind === 'bearer') {
200
+ const token = await resolveToken(authConf.token);
201
+ if (token) h.Authorization = `Bearer ${token}`;
202
+ } else if (authConf.kind === 'csrf') {
203
+ h['X-CSRF-TOKEN'] = authConf.csrf;
204
+ h['X-Requested-With'] = 'XMLHttpRequest';
205
+ } else if (authConf.kind === 'basic') {
206
+ const creds = btoa(`${authConf.user}:${authConf.pass}`);
207
+ h.Authorization = `Basic ${creds}`;
208
+ }
209
+ return h;
210
+ }
211
+
212
+ /**
213
+ * Sync auth-header builder for callers that need headers in a
214
+ * non-async context (XMLHttpRequest, OnlyOffice config endpoint
215
+ * post-mounted PreviewModal). Function-token bearers are *resolved
216
+ * lazily* via the async path; here we just include the cached value
217
+ * if any. Component code should prefer the async `authHeaders()` —
218
+ * this is a defensive escape hatch.
219
+ */
220
+ function authHeadersSync(extra: Record<string, string> = {}): Record<string, string> {
221
+ const h: Record<string, string> = { Accept: 'application/json', ...extra };
222
+ if (authConf.kind === 'bearer' && typeof authConf.token === 'string') {
223
+ h.Authorization = `Bearer ${authConf.token}`;
224
+ } else if (authConf.kind === 'csrf') {
225
+ h['X-CSRF-TOKEN'] = authConf.csrf;
226
+ h['X-Requested-With'] = 'XMLHttpRequest';
227
+ } else if (authConf.kind === 'basic') {
228
+ const creds = btoa(`${authConf.user}:${authConf.pass}`);
229
+ h.Authorization = `Basic ${creds}`;
230
+ }
231
+ return h;
232
+ }
233
+
234
+ function credentialsMode(): RequestCredentials {
235
+ return authConf.kind === 'csrf' ? 'include' : 'same-origin';
236
+ }
237
+
238
+ // Map an HTTP status to a short, human-readable message in the explorer's
239
+ // locale. The raw JSON body is attached as `.detail` for debugging but never
240
+ // shown in the toast (Burak: "404/403 falan verince ham json görüyorum").
241
+ function statusMessage(status: number): string {
242
+ const tr = (config.locale ?? 'tr') !== 'en';
243
+ const m: Record<number, [string, string]> = {
244
+ 400: ['Geçersiz istek', 'Bad request'],
245
+ 401: ['Oturum gerekli, tekrar giriş yapın', 'Sign-in required'],
246
+ 403: ['Bu işlem için yetkiniz yok', 'You are not allowed to do this'],
247
+ 404: ['Bulunamadı', 'Not found'],
248
+ 409: ['Zaten var / çakışma', 'Already exists / conflict'],
249
+ 413: ['Dosya çok büyük', 'File too large'],
250
+ 415: ['Bu dosya türü desteklenmiyor', 'Unsupported file type'],
251
+ 422: ['Geçersiz veri', 'Invalid data'],
252
+ 429: ['Çok fazla istek, biraz bekleyin', 'Too many requests'],
253
+ 500: ['Sunucu hatası', 'Server error'],
254
+ 501: ['Bu işlem desteklenmiyor', 'Not supported'],
255
+ 503: ['Servis şu an kullanılamıyor', 'Service unavailable'],
256
+ };
257
+ const e = m[status];
258
+ if (e) return tr ? e[0] : e[1];
259
+ return tr ? `Hata (${status})` : `Error (${status})`;
260
+ }
261
+
262
+ async function jsonFetch<T>(url: string, init: RequestInit = {}): Promise<T> {
263
+ const headers = {
264
+ ...(await authHeaders()),
265
+ ...((init.headers as Record<string, string> | undefined) ?? {}),
266
+ };
267
+ const res = await fetch(url, {
268
+ ...init,
269
+ headers,
270
+ credentials: credentialsMode(),
271
+ });
272
+ if (!res.ok) {
273
+ const text = await res.text().catch(() => '');
274
+ const err = new Error(statusMessage(res.status)) as Error & { status?: number; detail?: string };
275
+ err.status = res.status;
276
+ err.detail = text.slice(0, 300);
277
+ throw err;
278
+ }
279
+ return res.json() as Promise<T>;
280
+ }
281
+
282
+ // --------------------------------------------------------------------
283
+ // Permissions (RBAC) — derived from the manager endpoint by swapping the
284
+ // trailing `/manager` for `/permissions`. Owner/admin only (backend gated).
285
+ // --------------------------------------------------------------------
286
+ function permissionsUrl(sub = ''): string {
287
+ const base = endpoints.manager.replace(/\/manager(\?.*)?$/, '/permissions');
288
+ return base + sub;
289
+ }
290
+ async function listPermissions(path: string): Promise<PermissionsResponse> {
291
+ return jsonFetch<PermissionsResponse>(permissionsUrl() + '?path=' + encodeURIComponent(path));
292
+ }
293
+ async function resolveEmail(email: string): Promise<ResolveEmailResponse> {
294
+ return jsonFetch<ResolveEmailResponse>(permissionsUrl('/resolve') + '?email=' + encodeURIComponent(email));
295
+ }
296
+ async function searchUsers(q: string): Promise<UserSearchResponse> {
297
+ return jsonFetch<UserSearchResponse>(permissionsUrl('/users') + '?q=' + encodeURIComponent(q));
298
+ }
299
+ async function addPermission(body: { path: string; user_id: number; level: string; is_dir?: boolean }): Promise<unknown> {
300
+ return jsonFetch(permissionsUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
301
+ }
302
+ async function updatePermission(id: number, level: string): Promise<unknown> {
303
+ return jsonFetch(permissionsUrl('/' + id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ level }) });
304
+ }
305
+ async function deletePermission(id: number): Promise<unknown> {
306
+ return jsonFetch(permissionsUrl('/' + id), { method: 'DELETE' });
307
+ }
308
+ async function invitePermission(body: { path: string; email: string; level: string; create_user?: boolean; role?: string; is_dir?: boolean; locale?: string }): Promise<InviteResponse> {
309
+ return jsonFetch<InviteResponse>(permissionsUrl('/invite'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
310
+ }
311
+ async function shareMail(body: { path: string; email?: string; emails?: string[]; url: string; pin?: string | null; expires_days?: number; locale?: string; is_dir?: boolean; size?: number; mode?: string }): Promise<{ emailed: boolean; sent?: string[]; failed?: string[] }> {
312
+ return jsonFetch<{ emailed: boolean; sent?: string[]; failed?: string[] }>(permissionsUrl('/share-mail'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
313
+ }
314
+
315
+ // --------------------------------------------------------------------
316
+ // Manager contract — supports both `?q=action` (legacy) and
317
+ // `?action=action` (new) by emitting BOTH. Backends recognising one
318
+ // ignore the other; new backends prefer `action`.
319
+ // --------------------------------------------------------------------
320
+
321
+ function qs(params: Record<string, string | number | boolean | undefined>): string {
322
+ const sp = new URLSearchParams();
323
+ for (const [k, v] of Object.entries(params)) {
324
+ if (v === undefined || v === null) continue;
325
+ sp.set(k, String(v));
326
+ }
327
+ return sp.toString();
328
+ }
329
+
330
+ function managerUrl(action: string, params: Record<string, string | number | boolean | undefined> = {}): string {
331
+ const sep = endpoints.manager.includes('?') ? '&' : '?';
332
+ return `${endpoints.manager}${sep}${qs({ q: action, action, ...params })}`;
333
+ }
334
+
335
+ async function index(path: string): Promise<ManagerResponse> {
336
+ return jsonFetch<ManagerResponse>(managerUrl('index', { path }));
337
+ }
338
+
339
+ async function search(path: string, filter: string): Promise<ManagerResponse> {
340
+ return jsonFetch<ManagerResponse>(managerUrl('search', { path, filter }));
341
+ }
342
+
343
+ async function subfolders(path: string): Promise<{ folders: FileNode[] }> {
344
+ return jsonFetch<{ folders: FileNode[] }>(managerUrl('subfolders', { path }));
345
+ }
346
+
347
+ async function newFolder(path: string, name: string): Promise<ManagerResponse> {
348
+ return jsonFetch<ManagerResponse>(managerUrl('newfolder'), {
349
+ method: 'POST',
350
+ headers: { 'Content-Type': 'application/json' },
351
+ body: JSON.stringify({ path, name }),
352
+ });
353
+ }
354
+
355
+ async function rename(path: string, item: string, name: string): Promise<ManagerResponse> {
356
+ return jsonFetch<ManagerResponse>(managerUrl('rename'), {
357
+ method: 'POST',
358
+ headers: { 'Content-Type': 'application/json' },
359
+ body: JSON.stringify({ path, item, name }),
360
+ });
361
+ }
362
+
363
+ async function move(path: string, items: string[], target: string): Promise<ManagerResponse> {
364
+ return jsonFetch<ManagerResponse>(managerUrl('move'), {
365
+ method: 'POST',
366
+ headers: { 'Content-Type': 'application/json' },
367
+ body: JSON.stringify({ path, item: target, items: items.map((p) => ({ path: p })) }),
368
+ });
369
+ }
370
+
371
+ async function deleteItems(path: string, items: string[]): Promise<ManagerResponse> {
372
+ return jsonFetch<ManagerResponse>(managerUrl('delete'), {
373
+ method: 'POST',
374
+ headers: { 'Content-Type': 'application/json' },
375
+ body: JSON.stringify({ path, items: items.map((p) => ({ path: p })) }),
376
+ });
377
+ }
378
+
379
+ /** Server-side recursive copy (async — returns a PendingOp). */
380
+ async function copy(source: string[], target: string): Promise<{ op: PendingOpDto }> {
381
+ if (!endpoints.copy) throw new Error('copy endpoint not configured');
382
+ return jsonFetch(endpoints.copy, {
383
+ method: 'POST',
384
+ headers: { 'Content-Type': 'application/json' },
385
+ body: JSON.stringify({ source, target }),
386
+ });
387
+ }
388
+
389
+ async function moveAsync(source: string[], target: string, sourceDir?: string): Promise<{ op: PendingOpDto }> {
390
+ if (!endpoints.moveAsync) throw new Error('moveAsync endpoint not configured');
391
+ return jsonFetch(endpoints.moveAsync, {
392
+ method: 'POST',
393
+ headers: { 'Content-Type': 'application/json' },
394
+ body: JSON.stringify({ source, target, sourceDir }),
395
+ });
396
+ }
397
+
398
+ async function deleteAsync(source: string[], sourceDir?: string): Promise<{ op: PendingOpDto }> {
399
+ if (!endpoints.deleteAsync) throw new Error('deleteAsync endpoint not configured');
400
+ return jsonFetch(endpoints.deleteAsync, {
401
+ method: 'POST',
402
+ headers: { 'Content-Type': 'application/json' },
403
+ body: JSON.stringify({ source, sourceDir }),
404
+ });
405
+ }
406
+
407
+ async function restore(source: string[]): Promise<{ ok: boolean; restored: number }> {
408
+ if (!endpoints.restore) throw new Error('restore endpoint not configured');
409
+ return jsonFetch(endpoints.restore, {
410
+ method: 'POST',
411
+ headers: { 'Content-Type': 'application/json' },
412
+ body: JSON.stringify({ source }),
413
+ });
414
+ }
415
+
416
+ /** filex trash listing — soft-deleted nodes across (or within) storages. */
417
+ async function listTrash(storageName?: string): Promise<{ entries: TrashEntry[]; total: number }> {
418
+ if (!endpoints.trashList) throw new Error('trashList endpoint not configured');
419
+ const base = endpoints.trashList;
420
+ const sep = base.includes('?') ? '&' : '?';
421
+ const url = storageName ? `${base}${sep}storage=${encodeURIComponent(storageName)}` : base;
422
+ return jsonFetch<{ entries: TrashEntry[]; total: number }>(url);
423
+ }
424
+
425
+ /**
426
+ * Restore soft-deleted nodes by their node id. The filex backend restores
427
+ * one node per call (`POST {node_id}`), so we fan out and tally successes.
428
+ */
429
+ async function restoreIds(ids: number[]): Promise<{ restored: number }> {
430
+ const url = endpoints.trashRestore;
431
+ if (!url) throw new Error('trashRestore endpoint not configured');
432
+ let restored = 0;
433
+ for (const id of ids) {
434
+ try {
435
+ await jsonFetch(url, {
436
+ method: 'POST',
437
+ headers: { 'Content-Type': 'application/json' },
438
+ body: JSON.stringify({ node_id: id }),
439
+ });
440
+ restored++;
441
+ } catch {
442
+ /* skip individual failures; report the count that succeeded */
443
+ }
444
+ }
445
+ return { restored };
446
+ }
447
+
448
+ /**
449
+ * Legacy in-band multipart upload (small files / chunked endpoint
450
+ * absent). XMLHttpRequest because fetch doesn't expose upload
451
+ * progress on most browsers.
452
+ */
453
+ async function uploadMultipart(
454
+ path: string,
455
+ files: File[],
456
+ onProgress?: (p: number) => void,
457
+ ): Promise<ManagerResponse> {
458
+ const fd = new FormData();
459
+ fd.append('path', path);
460
+ for (const f of files) {
461
+ fd.append('file[]', f, f.name);
462
+ }
463
+ const headers = await authHeaders();
464
+ return new Promise<ManagerResponse>((resolve, reject) => {
465
+ const xhr = new XMLHttpRequest();
466
+ xhr.open('POST', managerUrl('upload'));
467
+ for (const [k, v] of Object.entries(headers)) {
468
+ if (k === 'Content-Type') continue;
469
+ xhr.setRequestHeader(k, v);
470
+ }
471
+ xhr.withCredentials = credentialsMode() === 'include';
472
+ xhr.upload.onprogress = (ev) => {
473
+ if (onProgress && ev.lengthComputable) {
474
+ onProgress(Math.round((ev.loaded / ev.total) * 100));
475
+ }
476
+ };
477
+ xhr.onload = () => {
478
+ if (xhr.status >= 200 && xhr.status < 300) {
479
+ try {
480
+ resolve(JSON.parse(xhr.responseText));
481
+ } catch (e) {
482
+ reject(e);
483
+ }
484
+ } else {
485
+ reject(new Error(`${xhr.status} ${xhr.statusText}: ${xhr.responseText.slice(0, 200)}`));
486
+ }
487
+ };
488
+ xhr.onerror = () => reject(new Error('Network error'));
489
+ xhr.send(fd);
490
+ });
491
+ }
492
+
493
+ function downloadUrl(path: string): string {
494
+ return managerUrl('download', { path });
495
+ }
496
+
497
+ function previewUrl(path: string): string {
498
+ return managerUrl('preview', { path });
499
+ }
500
+
501
+ /**
502
+ * Fetch a file body with the configured auth headers + credentials,
503
+ * returning the raw blob plus a normalized object URL viewers can mount
504
+ * directly. Used by the rich viewers (3D, EPUB, PDF, PSD, TIFF, …) which
505
+ * need an `ArrayBuffer` or a `Blob`-backed `objectURL` rather than the
506
+ * relative preview URL.
507
+ *
508
+ * Caller is responsible for revoking `url` (`URL.revokeObjectURL(url)`)
509
+ * once the viewer unmounts to avoid leaking the blob.
510
+ */
511
+ async function fetchBlob(
512
+ path: string,
513
+ ): Promise<{ url: string; blob: Blob; mime: string }> {
514
+ const headers = await authHeaders();
515
+ const res = await fetch(previewUrl(path), {
516
+ headers,
517
+ credentials: credentialsMode(),
518
+ });
519
+ if (!res.ok) {
520
+ const text = await res.text().catch(() => '');
521
+ throw new Error(
522
+ `${res.status} ${res.statusText}${text ? ' — ' + text.slice(0, 200) : ''}`,
523
+ );
524
+ }
525
+ const blob = await res.blob();
526
+ const mime = blob.type || res.headers.get('content-type') || '';
527
+ const url = URL.createObjectURL(blob);
528
+ return { url, blob, mime };
529
+ }
530
+
531
+ /**
532
+ * Like `fetchBlob` but returns the raw bytes — viewers that need
533
+ * binary parsing (utif, ag-psd, pdfjs-dist) get the buffer directly
534
+ * without the extra `Blob → arrayBuffer` round trip.
535
+ */
536
+ async function fetchArrayBuffer(path: string): Promise<ArrayBuffer> {
537
+ const headers = await authHeaders();
538
+ const res = await fetch(previewUrl(path), {
539
+ headers,
540
+ credentials: credentialsMode(),
541
+ });
542
+ if (!res.ok) {
543
+ const text = await res.text().catch(() => '');
544
+ throw new Error(
545
+ `${res.status} ${res.statusText}${text ? ' — ' + text.slice(0, 200) : ''}`,
546
+ );
547
+ }
548
+ return res.arrayBuffer();
549
+ }
550
+
551
+ // --------------------------------------------------------------------
552
+ // Peripheral endpoints
553
+ // --------------------------------------------------------------------
554
+
555
+ async function limits(): Promise<UploadLimits> {
556
+ if (!endpoints.limits) return { max_upload_mb: 1024 };
557
+ return jsonFetch<UploadLimits>(endpoints.limits);
558
+ }
559
+
560
+ async function capabilities(): Promise<Capabilities> {
561
+ if (!endpoints.capabilities) {
562
+ return {
563
+ ffmpeg: false,
564
+ ghostscript: false,
565
+ libreoffice: false,
566
+ max_chunk_mb: 5,
567
+ upload_limit_mb: 1024,
568
+ onlyoffice_url: config.onlyOfficeBase ?? null,
569
+ drawio_url: config.drawioBase ?? null,
570
+ convert_url: config.convertBase ?? null,
571
+ };
572
+ }
573
+ return jsonFetch<Capabilities>(endpoints.capabilities);
574
+ }
575
+
576
+ async function createShare(payload: {
577
+ path: string;
578
+ password?: boolean;
579
+ expires_at?: string | null;
580
+ max_downloads?: number | null;
581
+ // File-drop (public upload link) — kind:'drop' mints an upload link into a
582
+ // folder instead of a download link; drop_settings carries the caps.
583
+ kind?: string;
584
+ max_uploads?: number | null;
585
+ drop_settings?: Record<string, unknown> | null;
586
+ }): Promise<{ share: ShareInfo & { url: string; path: string; filename: string; kind?: string } }> {
587
+ if (!endpoints.shareCreate) throw new Error('shareCreate endpoint not configured');
588
+ return jsonFetch(endpoints.shareCreate, {
589
+ method: 'POST',
590
+ headers: { 'Content-Type': 'application/json' },
591
+ body: JSON.stringify(payload),
592
+ });
593
+ }
594
+
595
+ async function listShares(path: string): Promise<{ shares: ShareInfo[] }> {
596
+ if (!endpoints.shareList) return { shares: [] };
597
+ const sep = endpoints.shareList.includes('?') ? '&' : '?';
598
+ return jsonFetch(`${endpoints.shareList}${sep}path=${encodeURIComponent(path)}`);
599
+ }
600
+
601
+ async function revokeShare(uuid: string): Promise<{ success: boolean }> {
602
+ if (!endpoints.shareDelete) throw new Error('shareDelete endpoint not configured');
603
+ const url = endpoints.shareDelete.replace('{uuid}', encodeURIComponent(uuid));
604
+ return jsonFetch(url, { method: 'DELETE' });
605
+ }
606
+
607
+ async function archiveList(path: string): Promise<{ entries: ArchiveEntry[] }> {
608
+ if (!endpoints.archiveList) throw new Error('archiveList endpoint not configured');
609
+ const raw = await jsonFetch<{ entries: Array<{ name: string; size: number; is_dir: boolean; mtime?: number }> }>(
610
+ endpoints.archiveList,
611
+ {
612
+ method: 'POST',
613
+ headers: { 'Content-Type': 'application/json' },
614
+ body: JSON.stringify({ path }),
615
+ },
616
+ );
617
+ return {
618
+ entries: raw.entries.map((e) => ({
619
+ name: e.name,
620
+ size: e.size,
621
+ isDir: e.is_dir,
622
+ lastModified: e.mtime,
623
+ })),
624
+ };
625
+ }
626
+
627
+ async function archiveExtract(path: string, members?: string[]): Promise<{ keys: string[]; count: number }> {
628
+ if (!endpoints.archiveExtract) throw new Error('archiveExtract endpoint not configured');
629
+ return jsonFetch(endpoints.archiveExtract, {
630
+ method: 'POST',
631
+ headers: { 'Content-Type': 'application/json' },
632
+ body: JSON.stringify({ path, members }),
633
+ });
634
+ }
635
+
636
+ async function archiveAdd(path: string, files: Array<{ name: string; source: string }>): Promise<{ path: string }> {
637
+ if (!endpoints.archiveAdd) throw new Error('archiveAdd endpoint not configured');
638
+ return jsonFetch(endpoints.archiveAdd, {
639
+ method: 'POST',
640
+ headers: { 'Content-Type': 'application/json' },
641
+ body: JSON.stringify({ path, files }),
642
+ });
643
+ }
644
+
645
+ return {
646
+ // Manager
647
+ index,
648
+ search,
649
+ subfolders,
650
+ newFolder,
651
+ rename,
652
+ move,
653
+ copy,
654
+ moveAsync,
655
+ deleteAsync,
656
+ deleteItems,
657
+ restore,
658
+ listTrash,
659
+ restoreIds,
660
+ uploadMultipart,
661
+ downloadUrl,
662
+ previewUrl,
663
+ fetchBlob,
664
+ fetchArrayBuffer,
665
+ // Peripheral
666
+ limits,
667
+ capabilities,
668
+ createShare,
669
+ listShares,
670
+ revokeShare,
671
+ archiveList,
672
+ archiveExtract,
673
+ archiveAdd,
674
+ // Permissions (RBAC panel)
675
+ listPermissions,
676
+ resolveEmail,
677
+ searchUsers,
678
+ addPermission,
679
+ updatePermission,
680
+ deletePermission,
681
+ invitePermission,
682
+ shareMail,
683
+ // Internals (exposed for useUploadChunked + PreviewModal)
684
+ endpoints,
685
+ authHeaders,
686
+ authHeadersSync,
687
+ credentialsMode,
688
+ jsonFetch,
689
+ };
690
+ }
691
+
692
+ export type FileApi = ReturnType<typeof useFileApi>;