@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,282 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Breadcrumb — adapter-aware path crumbs.
4
+ *
5
+ * Dirname arrives as `<adapter>://<rel>` (e.g. `s3-test://aa/bb`). The
6
+ * first crumb is ALWAYS the storage root — its label is the adapter
7
+ * name itself (`s3-test`) and clicking it lands you at `<adapter>://`,
8
+ * NOT at the first sub-folder you happened to be inside. The remaining
9
+ * crumbs walk down the path one segment at a time.
10
+ *
11
+ * Examples:
12
+ * `s3-test://` → [s3-test]
13
+ * `s3-test://aa` → [s3-test] › [aa]
14
+ * `s3-test://aa/bb` → [s3-test] › [aa] › [bb]
15
+ *
16
+ * `rootLabel` overrides the adapter name when the embedder wants a
17
+ * branded label ('Files', 'My Drive', etc). Defaults to the adapter.
18
+ *
19
+ * The ✏ button swaps the crumbs for a free-form input; Enter
20
+ * navigates, Escape cancels.
21
+ */
22
+ import { computed, nextTick, ref } from 'vue';
23
+ import type { LocaleCode } from '../types/ExplorerConfig';
24
+ import { useLocale } from '../composables/useLocale';
25
+
26
+ const props = defineProps<{
27
+ dirname: string;
28
+ adapter: string;
29
+ rootLabel: string;
30
+ locale: LocaleCode;
31
+ /**
32
+ * Multi-storage mode — when true the breadcrumb prepends a global
33
+ * "/" crumb so the user can pop all the way back to the storage
34
+ * picker. Storage name then occupies crumb #1 instead of #0.
35
+ */
36
+ multiStorageRoot?: boolean;
37
+ /**
38
+ * Root confinement (qualified `<adapter>://<rel>`). When set the breadcrumb
39
+ * treats this folder as `/`: crumbs above it are hidden, the root crumb is
40
+ * the confined folder's own name, and the ✏ path editor shows/accepts paths
41
+ * relative to it (`/`, `/sub`). Pairs with FileExplorer's rootPath clamp.
42
+ */
43
+ rootPath?: string;
44
+ }>();
45
+
46
+ // Confined root parsed into its adapter-relative form ("projeler/acme") and
47
+ // the trailing folder name used as the "/" crumb label.
48
+ const confinedRel = computed(() => {
49
+ if (!props.rootPath) return '';
50
+ const r = props.rootPath;
51
+ const i = r.indexOf('://');
52
+ return (i >= 0 ? r.slice(i + 3) : r).replace(/^\/+|\/+$/g, '');
53
+ });
54
+ const floorLabel = computed(() => {
55
+ const rel = confinedRel.value;
56
+ if (!rel) return props.rootLabel || props.adapter;
57
+ const parts = rel.split('/');
58
+ return parts[parts.length - 1] || (props.rootLabel || props.adapter);
59
+ });
60
+
61
+ const { t } = useLocale(() => props.locale);
62
+
63
+ const emit = defineEmits<{
64
+ (e: 'navigate', adapterPath: string): void;
65
+ (e: 'copy-path', adapterPath: string): void;
66
+ (e: 'crumb-context', payload: { x: number; y: number; adapterPath: string; label: string }): void;
67
+ (e: 'crumb-drop', adapterPath: string, ev: DragEvent): void;
68
+ }>();
69
+
70
+ interface Crumb {
71
+ label: string;
72
+ adapterPath: string;
73
+ }
74
+
75
+ const crumbs = computed<Crumb[]>(() => {
76
+ const adapterPrefix = `${props.adapter}://`;
77
+ const raw = props.dirname.startsWith(adapterPrefix)
78
+ ? props.dirname.slice(adapterPrefix.length)
79
+ : props.dirname;
80
+ const parts = raw.split('/').filter(Boolean);
81
+
82
+ const out: Crumb[] = [];
83
+
84
+ // Confined: the root folder is the top crumb (its own name), and only the
85
+ // path BELOW it is walked — segments above the confinement are never shown.
86
+ if (props.rootPath) {
87
+ const floor = confinedRel.value;
88
+ const cur = raw.replace(/^\/+|\/+$/g, '');
89
+ const below = floor && (cur === floor || cur.startsWith(floor + '/'))
90
+ ? cur.slice(floor.length).replace(/^\/+/, '')
91
+ : '';
92
+ out.push({ label: floorLabel.value, adapterPath: props.rootPath });
93
+ let acc = floor;
94
+ for (const part of below.split('/').filter(Boolean)) {
95
+ acc = acc ? `${acc}/${part}` : part;
96
+ out.push({ label: part === '.trash' ? t('node.trash') : part, adapterPath: `${adapterPrefix}${acc}` });
97
+ }
98
+ return out;
99
+ }
100
+
101
+ if (props.multiStorageRoot) {
102
+ // Global "/" crumb — clicking it pops back to the storage picker
103
+ // (FileExplorer treats the empty string as the virtual root).
104
+ out.push({ label: '/', adapterPath: '' });
105
+ if (props.adapter) {
106
+ // Storage segment — wire form `<adapter>://` lands at storage
107
+ // root regardless of where we are now.
108
+ out.push({ label: props.rootLabel || props.adapter, adapterPath: adapterPrefix });
109
+ }
110
+ } else {
111
+ // Single-storage mode: the storage name IS the root, no "/"
112
+ // above it.
113
+ out.push({
114
+ label: props.rootLabel || props.adapter,
115
+ adapterPath: adapterPrefix,
116
+ });
117
+ }
118
+
119
+ let acc = '';
120
+ for (const part of parts) {
121
+ acc = acc ? `${acc}/${part}` : part;
122
+ const label = part === '.trash' ? t('node.trash') : part;
123
+ out.push({ label, adapterPath: `${adapterPrefix}${acc}` });
124
+ }
125
+ return out;
126
+ });
127
+
128
+ function onClick(crumb: Crumb) {
129
+ emit('navigate', crumb.adapterPath);
130
+ }
131
+
132
+ function onContext(ev: MouseEvent, crumb: Crumb) {
133
+ ev.preventDefault();
134
+ ev.stopPropagation();
135
+ emit('crumb-context', {
136
+ x: ev.clientX,
137
+ y: ev.clientY,
138
+ adapterPath: crumb.adapterPath,
139
+ label: crumb.label,
140
+ });
141
+ }
142
+
143
+ function onCrumbDragOver(ev: DragEvent) {
144
+ if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
145
+ ev.preventDefault();
146
+ ev.stopPropagation();
147
+ if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
148
+ }
149
+
150
+ function onCrumbDrop(ev: DragEvent, crumb: Crumb) {
151
+ if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
152
+ ev.preventDefault();
153
+ ev.stopPropagation();
154
+ emit('crumb-drop', crumb.adapterPath, ev);
155
+ }
156
+
157
+ const editing = ref(false);
158
+ const pathDraft = ref('');
159
+ const pathInput = ref<HTMLInputElement | null>(null);
160
+
161
+ function startEdit() {
162
+ const adapterPrefix = `${props.adapter}://`;
163
+ const rel = props.dirname.startsWith(adapterPrefix)
164
+ ? props.dirname.slice(adapterPrefix.length)
165
+ : props.dirname;
166
+ // Confined: edit relative to the root folder — `/` is the root, `/sub` below.
167
+ if (props.rootPath) {
168
+ const floor = confinedRel.value;
169
+ const cur = rel.replace(/^\/+|\/+$/g, '');
170
+ const below = floor && (cur === floor || cur.startsWith(floor + '/'))
171
+ ? cur.slice(floor.length).replace(/^\/+/, '')
172
+ : '';
173
+ pathDraft.value = below ? `/${below}` : '/';
174
+ editing.value = true;
175
+ void nextTick(() => {
176
+ pathInput.value?.focus();
177
+ pathInput.value?.select();
178
+ });
179
+ return;
180
+ }
181
+ // Multi-storage: show `/<storage>/<rel>` so the user can edit the
182
+ // whole tree (`/main/foo`, `/s3-test/example`, just `/` for root).
183
+ if (props.multiStorageRoot) {
184
+ if (!props.adapter) {
185
+ pathDraft.value = '/';
186
+ } else {
187
+ const trimmed = rel.replace(/^\/+|\/+$/g, '');
188
+ pathDraft.value = trimmed
189
+ ? `/${props.adapter}/${trimmed}`
190
+ : `/${props.adapter}`;
191
+ }
192
+ } else {
193
+ pathDraft.value = rel;
194
+ }
195
+ editing.value = true;
196
+ void nextTick(() => {
197
+ pathInput.value?.focus();
198
+ pathInput.value?.select();
199
+ });
200
+ }
201
+
202
+ function submitPath() {
203
+ const raw = pathDraft.value.trim();
204
+ editing.value = false;
205
+
206
+ // Confined: the typed path is relative to the root folder; prepend the floor.
207
+ if (props.rootPath) {
208
+ const sub = raw.replace(/^\/+|\/+$/g, '');
209
+ const floor = confinedRel.value;
210
+ const rel = sub ? (floor ? `${floor}/${sub}` : sub) : floor;
211
+ emit('navigate', rel ? `${props.adapter}://${rel}` : `${props.adapter}://`);
212
+ return;
213
+ }
214
+
215
+ if (props.multiStorageRoot) {
216
+ // Strip leading/trailing slashes. First segment = storage.
217
+ const clean = raw.replace(/^\/+|\/+$/g, '');
218
+ if (!clean) {
219
+ // navigate to global root by emitting empty wire path; the
220
+ // FileExplorer treats it as virtual storage list.
221
+ emit('navigate', '');
222
+ return;
223
+ }
224
+ const slash = clean.indexOf('/');
225
+ const adapter = slash === -1 ? clean : clean.slice(0, slash);
226
+ const rel = slash === -1 ? '' : clean.slice(slash + 1);
227
+ emit('navigate', rel ? `${adapter}://${rel}` : `${adapter}://`);
228
+ return;
229
+ }
230
+
231
+ const v = raw.replace(/^\/+|\/+$/g, '');
232
+ if (!v) return;
233
+ emit('navigate', `${props.adapter}://${v}`);
234
+ }
235
+
236
+ function cancelEdit() {
237
+ editing.value = false;
238
+ }
239
+ </script>
240
+
241
+ <template>
242
+ <nav class="fe-breadcrumb" aria-label="Breadcrumb">
243
+ <template v-if="!editing">
244
+ <button
245
+ v-for="(c, i) in crumbs"
246
+ :key="c.adapterPath"
247
+ class="fe-breadcrumb__crumb"
248
+ :class="{ 'is-last': i === crumbs.length - 1 }"
249
+ type="button"
250
+ :aria-current="i === crumbs.length - 1 ? 'page' : undefined"
251
+ @click="onClick(c)"
252
+ @contextmenu="onContext($event, c)"
253
+ @dragover="onCrumbDragOver"
254
+ @drop="onCrumbDrop($event, c)"
255
+ >
256
+ <span>{{ c.label }}</span>
257
+ <span v-if="i < crumbs.length - 1" class="fe-breadcrumb__sep" aria-hidden="true">›</span>
258
+ </button>
259
+ <button
260
+ type="button"
261
+ class="fe-breadcrumb__edit"
262
+ :title="t('breadcrumb.go_to_path')"
263
+ :aria-label="t('breadcrumb.go_to_path')"
264
+ @click="startEdit"
265
+ >✏</button>
266
+ </template>
267
+ <template v-else>
268
+ <input
269
+ ref="pathInput"
270
+ v-model="pathDraft"
271
+ type="text"
272
+ class="fe-breadcrumb__input"
273
+ :placeholder="t('breadcrumb.path_placeholder')"
274
+ spellcheck="false"
275
+ autocomplete="off"
276
+ @keydown.enter.prevent="submitPath"
277
+ @keydown.escape.prevent="cancelEdit"
278
+ @blur="cancelEdit"
279
+ />
280
+ </template>
281
+ </nav>
282
+ </template>
@@ -0,0 +1,152 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ContextMenu — Teleport-based menu.
4
+ *
5
+ * Teleported under <body> so no parent (Ionic <ion-page>, Filament's
6
+ * transformed flex containers, panel sidebars, …) can shift the
7
+ * `position: fixed` backdrop off-window.
8
+ */
9
+ import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
10
+ import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
11
+ import type { FileNode } from '../types/FileNode';
12
+ import { useLocale } from '../composables/useLocale';
13
+
14
+ export interface ContextAction {
15
+ key: string;
16
+ label: string;
17
+ icon?: string;
18
+ danger?: boolean;
19
+ disabled?: boolean;
20
+ hidden?: boolean;
21
+ divider?: boolean;
22
+ }
23
+
24
+ const props = defineProps<{
25
+ locale: LocaleCode;
26
+ actions: ContextAction[];
27
+ /**
28
+ * Resolved theme. Teleported into <body> we lose the `.fe` parent
29
+ * that scopes CSS vars, so we tag the backdrop with the current
30
+ * theme so variables.css can re-apply dark overrides outside the
31
+ * explorer tree.
32
+ */
33
+ theme?: ThemeMode;
34
+ }>();
35
+
36
+ const emit = defineEmits<{
37
+ (e: 'select', action: ContextAction, target: FileNode[]): void;
38
+ }>();
39
+
40
+ void useLocale(() => props.locale); // eager-instantiate t/lookup so future templates can reuse
41
+
42
+ const open = ref(false);
43
+ const x = ref(0);
44
+ const y = ref(0);
45
+ const targetNodes = ref<FileNode[]>([]);
46
+ const menuEl = ref<HTMLElement | null>(null);
47
+
48
+ async function show(ev: { clientX: number; clientY: number }, nodes: FileNode[]) {
49
+ open.value = true;
50
+ x.value = ev.clientX;
51
+ y.value = ev.clientY;
52
+ targetNodes.value = nodes;
53
+ // After the menu renders, clamp it inside the viewport so a click near the
54
+ // bottom/right edge doesn't push the menu off-screen (it opens down-right
55
+ // from the cursor by default).
56
+ await nextTick();
57
+ clampToViewport();
58
+ }
59
+
60
+ function clampToViewport() {
61
+ const el = menuEl.value;
62
+ if (!el || typeof window === 'undefined') return;
63
+ const rect = el.getBoundingClientRect();
64
+ const margin = 8;
65
+ const vw = window.innerWidth;
66
+ const vh = window.innerHeight;
67
+ if (y.value + rect.height > vh - margin) {
68
+ y.value = Math.max(margin, vh - rect.height - margin);
69
+ }
70
+ if (x.value + rect.width > vw - margin) {
71
+ x.value = Math.max(margin, vw - rect.width - margin);
72
+ }
73
+ }
74
+
75
+ function hide() {
76
+ open.value = false;
77
+ }
78
+
79
+ function pick(a: ContextAction) {
80
+ if (a.disabled) return;
81
+ emit('select', a, targetNodes.value);
82
+ hide();
83
+ }
84
+
85
+ function onKey(e: KeyboardEvent) {
86
+ if (e.key === 'Escape') hide();
87
+ }
88
+
89
+ const visibleActions = computed(() => props.actions.filter((a) => !a.hidden));
90
+
91
+ const prefersDark = ref(false);
92
+ let mq: MediaQueryList | undefined;
93
+ function syncPrefersDark(e?: MediaQueryListEvent | MediaQueryList) {
94
+ prefersDark.value = !!(e && 'matches' in e && e.matches);
95
+ }
96
+ onMounted(() => {
97
+ if (typeof window === 'undefined') return;
98
+ mq = window.matchMedia('(prefers-color-scheme: dark)');
99
+ syncPrefersDark(mq);
100
+ mq.addEventListener?.('change', syncPrefersDark);
101
+ });
102
+ onBeforeUnmount(() => {
103
+ mq?.removeEventListener?.('change', syncPrefersDark);
104
+ });
105
+
106
+ const themeClass = computed(() => {
107
+ const t = props.theme || 'auto';
108
+ return `fe-ctx-backdrop--theme-${t}`;
109
+ });
110
+
111
+ defineExpose({ show, hide });
112
+ </script>
113
+
114
+ <template>
115
+ <Teleport to="body">
116
+ <transition name="fe-ctx">
117
+ <div
118
+ v-if="open"
119
+ class="fe-ctx-backdrop"
120
+ :class="themeClass"
121
+ :data-prefers-dark="prefersDark ? '1' : '0'"
122
+ @click="hide"
123
+ @contextmenu.prevent="hide"
124
+ @keydown="onKey"
125
+ >
126
+ <div
127
+ ref="menuEl"
128
+ class="fe-ctx"
129
+ role="menu"
130
+ :style="{ top: y + 'px', left: x + 'px' }"
131
+ @click.stop
132
+ >
133
+ <template v-for="(a, i) in visibleActions" :key="a.key || i">
134
+ <div v-if="a.divider" class="fe-ctx__sep" />
135
+ <button
136
+ v-else
137
+ type="button"
138
+ class="fe-ctx__item"
139
+ :class="{ 'is-danger': a.danger, 'is-disabled': a.disabled }"
140
+ :disabled="a.disabled"
141
+ role="menuitem"
142
+ @click="pick(a)"
143
+ >
144
+ <span v-if="a.icon" class="fe-ctx__icon" aria-hidden="true">{{ a.icon }}</span>
145
+ <span class="fe-ctx__label">{{ a.label }}</span>
146
+ </button>
147
+ </template>
148
+ </div>
149
+ </div>
150
+ </transition>
151
+ </Teleport>
152
+ </template>
@@ -0,0 +1,166 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * GridView — card grid. Thumbnails preferred, fall back to icon.
4
+ */
5
+ import type { FileNode } from '../types/FileNode';
6
+ import type { LocaleCode } from '../types/ExplorerConfig';
7
+ import { useLocale } from '../composables/useLocale';
8
+
9
+ const props = defineProps<{
10
+ files: FileNode[];
11
+ selected: Set<string>;
12
+ clipped?: Set<string>;
13
+ showParentPath?: boolean;
14
+ locale: LocaleCode;
15
+ loading?: boolean;
16
+ }>();
17
+
18
+ const emit = defineEmits<{
19
+ (e: 'click-card', node: FileNode, mod: { ctrl: boolean; shift: boolean }): void;
20
+ (e: 'dbl-card', node: FileNode): void;
21
+ (e: 'context-card', node: FileNode, ev: MouseEvent): void;
22
+ (e: 'item-drag-start', node: FileNode, ev: DragEvent): void;
23
+ (e: 'item-drop-into', target: FileNode, ev: DragEvent): void;
24
+ }>();
25
+
26
+ const { t, formatSize, nodeDisplayName } = useLocale(() => props.locale);
27
+
28
+ function isSelected(n: FileNode): boolean {
29
+ return props.selected.has(n.path);
30
+ }
31
+
32
+ function onClick(n: FileNode, ev: MouseEvent) {
33
+ emit('click-card', n, { ctrl: ev.ctrlKey || ev.metaKey, shift: ev.shiftKey });
34
+ }
35
+
36
+ function onDbl(n: FileNode) {
37
+ emit('dbl-card', n);
38
+ }
39
+
40
+ function onCtx(n: FileNode, ev: MouseEvent) {
41
+ ev.preventDefault();
42
+ ev.stopPropagation();
43
+ emit('context-card', n, ev);
44
+ }
45
+
46
+ function onItemDragStart(n: FileNode, ev: DragEvent) {
47
+ emit('item-drag-start', n, ev);
48
+ }
49
+
50
+ function onItemDragOver(n: FileNode, ev: DragEvent) {
51
+ if (n.type !== 'dir') return;
52
+ if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
53
+ ev.preventDefault();
54
+ ev.stopPropagation();
55
+ if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
56
+ }
57
+
58
+ function onItemDrop(n: FileNode, ev: DragEvent) {
59
+ if (n.type !== 'dir') return;
60
+ if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
61
+ ev.preventDefault();
62
+ ev.stopPropagation();
63
+ emit('item-drop-into', n, ev);
64
+ }
65
+
66
+ let pressTimer: ReturnType<typeof setTimeout> | undefined;
67
+ let pressTarget: FileNode | null = null;
68
+ function onTouchStart(n: FileNode, ev: TouchEvent) {
69
+ pressTarget = n;
70
+ if (pressTimer) clearTimeout(pressTimer);
71
+ pressTimer = setTimeout(() => {
72
+ if (pressTarget) {
73
+ const t0 = ev.touches[0];
74
+ emit('context-card', pressTarget, {
75
+ clientX: t0.clientX,
76
+ clientY: t0.clientY,
77
+ preventDefault: () => {},
78
+ } as unknown as MouseEvent);
79
+ }
80
+ }, 500);
81
+ }
82
+ function cancelPress() {
83
+ if (pressTimer) clearTimeout(pressTimer);
84
+ pressTarget = null;
85
+ }
86
+
87
+ function parentDir(path: string): string {
88
+ const stripped = path.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
89
+ const idx = stripped.lastIndexOf('/');
90
+ if (idx === -1) return '';
91
+ return stripped.slice(0, idx);
92
+ }
93
+
94
+ function iconFor(n: FileNode): string {
95
+ if (n.mime_type === 'inode/storage') return '💾';
96
+ if (n.type === 'dir') return '📁';
97
+ const e = (n.extension || '').toLowerCase();
98
+ if (['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'svg'].includes(e)) return '🖼';
99
+ if (['mp4', 'webm', 'mov', 'mkv'].includes(e)) return '🎞';
100
+ if (['mp3', 'wav', 'flac', 'ogg'].includes(e)) return '🎵';
101
+ if (e === 'pdf') return '📕';
102
+ if (['doc', 'docx', 'odt'].includes(e)) return '📄';
103
+ if (['xls', 'xlsx', 'csv'].includes(e)) return '📊';
104
+ if (['ppt', 'pptx'].includes(e)) return '📽';
105
+ if (['zip', 'tar', 'gz', '7z'].includes(e)) return '🗜';
106
+ return '📎';
107
+ }
108
+ </script>
109
+
110
+ <template>
111
+ <div class="fe-grid" :class="{ 'is-loading': loading }">
112
+ <div
113
+ v-for="n in files"
114
+ :key="n.path"
115
+ class="fe-grid__card"
116
+ :class="{
117
+ 'is-selected': isSelected(n),
118
+ 'is-dir': n.type === 'dir',
119
+ 'is-trash': n.trashed,
120
+ 'is-clipped': clipped?.has(n.path),
121
+ }"
122
+ tabindex="0"
123
+ draggable="true"
124
+ @click="onClick(n, $event)"
125
+ @dblclick="onDbl(n)"
126
+ @contextmenu="onCtx(n, $event)"
127
+ @dragstart="onItemDragStart(n, $event)"
128
+ @dragover="onItemDragOver(n, $event)"
129
+ @drop="onItemDrop(n, $event)"
130
+ @touchstart.passive="onTouchStart(n, $event)"
131
+ @touchend="cancelPress"
132
+ @touchmove="cancelPress"
133
+ >
134
+ <div class="fe-grid__thumb">
135
+ <!--
136
+ draggable="false" — HTML5 image drag dataTransfer adds 'Files'
137
+ MIME, which trips the parent's upload handler; without this
138
+ the user dragging an item with a thumbnail re-uploads it.
139
+ Parent card stays draggable=true so internal move works.
140
+ -->
141
+ <img
142
+ v-if="n.thumb_url"
143
+ :src="n.thumb_url"
144
+ :alt="n.basename"
145
+ loading="lazy"
146
+ draggable="false"
147
+ />
148
+ <span v-else class="fe-grid__icon">{{ iconFor(n) }}</span>
149
+ </div>
150
+ <div class="fe-grid__label" :title="n.basename">
151
+ {{ nodeDisplayName(n) }}
152
+ </div>
153
+ <div
154
+ v-if="showParentPath"
155
+ class="fe-grid__parent"
156
+ :title="parentDir(n.path)"
157
+ >{{ parentDir(n.path) || '—' }}</div>
158
+ <div class="fe-grid__meta">
159
+ {{ n.type === 'dir' ? '—' : formatSize(n.size) }}
160
+ </div>
161
+ </div>
162
+ <div v-if="!loading && files.length === 0" class="fe-grid__empty">
163
+ {{ t('empty.folder') }}
164
+ </div>
165
+ </div>
166
+ </template>