@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.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/ArchiveViewer-CJdaw5x3.js +99 -0
- package/dist/ArchiveViewer-CJdaw5x3.js.map +1 -0
- package/dist/CsvViewer-QYx_tePQ.js +132 -0
- package/dist/CsvViewer-QYx_tePQ.js.map +1 -0
- package/dist/DrawioViewer-GWt0TyB7.js +141 -0
- package/dist/DrawioViewer-GWt0TyB7.js.map +1 -0
- package/dist/EpubViewer-Je0ywklQ.js +146 -0
- package/dist/EpubViewer-Je0ywklQ.js.map +1 -0
- package/dist/IpynbViewer-TsB4gqP-.js +175 -0
- package/dist/IpynbViewer-TsB4gqP-.js.map +1 -0
- package/dist/MermaidViewer-CXfeDS-7.js +128 -0
- package/dist/MermaidViewer-CXfeDS-7.js.map +1 -0
- package/dist/PsdViewer-VtPpxKVe.js +113 -0
- package/dist/PsdViewer-VtPpxKVe.js.map +1 -0
- package/dist/TiffViewer-OINn9bbb.js +133 -0
- package/dist/TiffViewer-OINn9bbb.js.map +1 -0
- package/dist/UTIF-Cjsrlm0l.js +3104 -0
- package/dist/UTIF-Cjsrlm0l.js.map +1 -0
- package/dist/Viewer3D-xvvyiWHd.js +61 -0
- package/dist/Viewer3D-xvvyiWHd.js.map +1 -0
- package/dist/_commonjsHelpers-DaMA6jEr.js +9 -0
- package/dist/_commonjsHelpers-DaMA6jEr.js.map +1 -0
- package/dist/filex-core.js +26 -0
- package/dist/filex-core.js.map +1 -0
- package/dist/filex-core.umd.cjs +285 -0
- package/dist/filex-core.umd.cjs.map +1 -0
- package/dist/index-CmSYzt6L.js +5404 -0
- package/dist/index-CmSYzt6L.js.map +1 -0
- package/dist/index-UFULWo35.js +10736 -0
- package/dist/index-UFULWo35.js.map +1 -0
- package/dist/index.d.ts +1089 -0
- package/dist/katex-yuB6V-q6.js +11616 -0
- package/dist/katex-yuB6V-q6.js.map +1 -0
- package/dist/papaparse.min-VB1HBwYX.js +441 -0
- package/dist/papaparse.min-VB1HBwYX.js.map +1 -0
- package/dist/style.css +1 -0
- package/dist/useViewerFetch-czqbd2Lj.js +25 -0
- package/dist/useViewerFetch-czqbd2Lj.js.map +1 -0
- package/package.json +113 -0
- package/src/FileExplorer.vue +1872 -0
- package/src/components/Breadcrumb.vue +282 -0
- package/src/components/ContextMenu.vue +152 -0
- package/src/components/GridView.vue +166 -0
- package/src/components/ListView.vue +212 -0
- package/src/components/PendingOpsTray.vue +91 -0
- package/src/components/RecentlyOpened.vue +155 -0
- package/src/components/StarButton.vue +101 -0
- package/src/components/TagPicker.vue +184 -0
- package/src/components/Toolbar.vue +220 -0
- package/src/components/UploadProgress.vue +78 -0
- package/src/composables/useFileApi.ts +692 -0
- package/src/composables/useKeyboardShortcuts.ts +144 -0
- package/src/composables/useLocale.ts +60 -0
- package/src/composables/useMonacoLoader.ts +98 -0
- package/src/composables/usePendingOps.ts +200 -0
- package/src/composables/useSelection.ts +81 -0
- package/src/composables/useUploadChunked.ts +250 -0
- package/src/composables/useViewerFetch.ts +58 -0
- package/src/index.ts +80 -0
- package/src/locales/en.ts +122 -0
- package/src/locales/index.ts +12 -0
- package/src/locales/tr.ts +122 -0
- package/src/modals/ConvertModal.vue +254 -0
- package/src/modals/DeleteConfirmModal.vue +32 -0
- package/src/modals/Modal.vue +104 -0
- package/src/modals/NewFolderModal.vue +64 -0
- package/src/modals/PermissionsModal.vue +815 -0
- package/src/modals/PreviewModal.vue +1070 -0
- package/src/modals/RenameModal.vue +58 -0
- package/src/modals/ShareModal.vue +135 -0
- package/src/styles/base.css +1224 -0
- package/src/styles/variables.css +90 -0
- package/src/types/ExplorerConfig.ts +282 -0
- package/src/types/FileNode.ts +136 -0
- package/src/types/index.ts +27 -0
- package/src/types/peers.d.ts +45 -0
- package/src/viewers/ArchiveViewer.vue +186 -0
- package/src/viewers/CsvViewer.vue +319 -0
- package/src/viewers/DrawioViewer.vue +243 -0
- package/src/viewers/EpubViewer.vue +307 -0
- package/src/viewers/IpynbViewer.vue +365 -0
- package/src/viewers/MermaidViewer.vue +259 -0
- package/src/viewers/PdfViewer.vue +562 -0
- package/src/viewers/PsdViewer.vue +264 -0
- package/src/viewers/TiffViewer.vue +273 -0
- package/src/viewers/Viewer3D.vue +120 -0
|
@@ -0,0 +1,1872 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* FileExplorer — the public Vue component, panel + PWA + standalone use.
|
|
4
|
+
*
|
|
5
|
+
* Orchestrates:
|
|
6
|
+
* - Directory listing (useFileApi)
|
|
7
|
+
* - Chunked multipart upload (useUploadChunked) + drag & drop
|
|
8
|
+
* - Selection + keyboard shortcuts
|
|
9
|
+
* - Context menu (Teleport-based) with per-target actions
|
|
10
|
+
* - Modal flows: newFolder / rename / delete / share / preview
|
|
11
|
+
* - Eager Monaco preload so the code-edit path is snappy
|
|
12
|
+
*
|
|
13
|
+
* All backend endpoints arrive via the `config` prop. Auth is bearer
|
|
14
|
+
* (PWA / OIDC) / CSRF (panel) / basic / none — `useFileApi` swallows
|
|
15
|
+
* the difference.
|
|
16
|
+
*/
|
|
17
|
+
import { computed, customRef, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
18
|
+
import type { ExplorerConfig } from './types/ExplorerConfig';
|
|
19
|
+
import type {
|
|
20
|
+
FileNode,
|
|
21
|
+
ShareInfo,
|
|
22
|
+
ViewMode,
|
|
23
|
+
ClipboardState,
|
|
24
|
+
Capabilities,
|
|
25
|
+
} from './types/FileNode';
|
|
26
|
+
import { isExternalUsable } from './types/FileNode';
|
|
27
|
+
import { useFileApi } from './composables/useFileApi';
|
|
28
|
+
import { useUploadChunked, type UploadJob } from './composables/useUploadChunked';
|
|
29
|
+
import { useSelection } from './composables/useSelection';
|
|
30
|
+
import { useKeyboardShortcuts } from './composables/useKeyboardShortcuts';
|
|
31
|
+
import { useLocale } from './composables/useLocale';
|
|
32
|
+
import { usePendingOps, type PendingOp } from './composables/usePendingOps';
|
|
33
|
+
import { preloadEditor } from './composables/useMonacoLoader';
|
|
34
|
+
|
|
35
|
+
import Toolbar, { type SelectionMode } from './components/Toolbar.vue';
|
|
36
|
+
import StarButton from './components/StarButton.vue';
|
|
37
|
+
import TagPicker from './components/TagPicker.vue';
|
|
38
|
+
import RecentlyOpened from './components/RecentlyOpened.vue';
|
|
39
|
+
import Breadcrumb from './components/Breadcrumb.vue';
|
|
40
|
+
import ListView from './components/ListView.vue';
|
|
41
|
+
import GridView from './components/GridView.vue';
|
|
42
|
+
import ContextMenu, { type ContextAction } from './components/ContextMenu.vue';
|
|
43
|
+
import UploadProgress from './components/UploadProgress.vue';
|
|
44
|
+
import PendingOpsTray from './components/PendingOpsTray.vue';
|
|
45
|
+
|
|
46
|
+
import NewFolderModal from './modals/NewFolderModal.vue';
|
|
47
|
+
import RenameModal from './modals/RenameModal.vue';
|
|
48
|
+
import DeleteConfirmModal from './modals/DeleteConfirmModal.vue';
|
|
49
|
+
import ShareModal from './modals/ShareModal.vue';
|
|
50
|
+
import PreviewModal from './modals/PreviewModal.vue';
|
|
51
|
+
import ConvertModal from './modals/ConvertModal.vue';
|
|
52
|
+
import PermissionsModal from './modals/PermissionsModal.vue';
|
|
53
|
+
|
|
54
|
+
const props = defineProps<{
|
|
55
|
+
config: ExplorerConfig;
|
|
56
|
+
}>();
|
|
57
|
+
|
|
58
|
+
const emit = defineEmits<{
|
|
59
|
+
(e: 'share-created', payload: { path: string; url: string; pin: string | null }): void;
|
|
60
|
+
(e: 'file-opened', file: { path: string; basename: string }): void;
|
|
61
|
+
(e: 'error', err: { message: string; context?: unknown }): void;
|
|
62
|
+
(e: 'upload-progress', p: { uploadId: string; percent: number; done: boolean }): void;
|
|
63
|
+
(
|
|
64
|
+
e: 'selection-change',
|
|
65
|
+
items: Array<{ path: string; basename: string; type: 'file' | 'dir' }>,
|
|
66
|
+
): void;
|
|
67
|
+
}>();
|
|
68
|
+
|
|
69
|
+
// --------------------------------------------------------------------
|
|
70
|
+
// State
|
|
71
|
+
// --------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
const api = useFileApi(props.config);
|
|
74
|
+
const chunked = useUploadChunked(props.config, api);
|
|
75
|
+
const pendingOps = usePendingOps(props.config, api, {
|
|
76
|
+
onSettled: (op: PendingOp) => {
|
|
77
|
+
if (op.status === 'error') {
|
|
78
|
+
flashToast(op.error_message || 'İşlem başarısız');
|
|
79
|
+
} else {
|
|
80
|
+
const verb =
|
|
81
|
+
op.op_type === 'copy'
|
|
82
|
+
? 'Kopyalandı'
|
|
83
|
+
: op.op_type === 'move'
|
|
84
|
+
? 'Taşındı'
|
|
85
|
+
: 'Silindi';
|
|
86
|
+
flashToast(`${verb} (${op.progress_total})`);
|
|
87
|
+
}
|
|
88
|
+
void load();
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const loading = ref(false);
|
|
93
|
+
// rootPath confinement (UX): when set, the explorer treats this folder as its
|
|
94
|
+
// floor — it opens there, never lists the drives root, and can't navigate
|
|
95
|
+
// above it. Security is enforced server-side (X-Filex-Root / token root scope);
|
|
96
|
+
// this is purely the clean-embed presentation. `rootFloor` is the virtual form
|
|
97
|
+
// (`<storage>/<rel>`) used for path comparisons in multi-storage mode.
|
|
98
|
+
const rootPathProp = (props.config.rootPath || '').trim(); // qualified `<adapter>://<rel>`
|
|
99
|
+
const rootFloor = rootPathProp.replace('://', '/').replace(/^\/+|\/+$/g, '');
|
|
100
|
+
const initialFloorPath = rootFloor || props.config.initialPath || '';
|
|
101
|
+
const currentPath = ref<string>(initialFloorPath);
|
|
102
|
+
const adapter = ref<string>(props.config.defaultAdapter || 'brf');
|
|
103
|
+
const dirname = ref<string>(initialFloorPath);
|
|
104
|
+
const files = ref<FileNode[]>([]);
|
|
105
|
+
// RBAC effective level for the current directory ('' = ACL not enforced on
|
|
106
|
+
// this storage → no gating). Drives which write/manage actions are offered.
|
|
107
|
+
const dirPerm = ref<string>('');
|
|
108
|
+
|
|
109
|
+
const VIEW_MODE_KEY = 'brf-file-explorer:view-mode';
|
|
110
|
+
const viewMode = customRef<ViewMode>((track, trigger) => {
|
|
111
|
+
let value: ViewMode = (() => {
|
|
112
|
+
try {
|
|
113
|
+
const stored = localStorage.getItem(VIEW_MODE_KEY);
|
|
114
|
+
if (stored === 'list' || stored === 'grid') return stored;
|
|
115
|
+
} catch {
|
|
116
|
+
/* private mode */
|
|
117
|
+
}
|
|
118
|
+
return props.config.viewMode ?? 'list';
|
|
119
|
+
})();
|
|
120
|
+
return {
|
|
121
|
+
get() {
|
|
122
|
+
track();
|
|
123
|
+
return value;
|
|
124
|
+
},
|
|
125
|
+
set(next) {
|
|
126
|
+
if (next === value) return;
|
|
127
|
+
value = next;
|
|
128
|
+
try {
|
|
129
|
+
localStorage.setItem(VIEW_MODE_KEY, next);
|
|
130
|
+
} catch {
|
|
131
|
+
/* quota */
|
|
132
|
+
}
|
|
133
|
+
trigger();
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
const searchQuery = ref('');
|
|
138
|
+
// trashMode — true while viewing the filex trash (soft-deleted nodes from the
|
|
139
|
+
// backend trash endpoint), entered by opening the virtual `.trash` row and
|
|
140
|
+
// exited by any normal navigation (load() resets it). Replaces a brittle
|
|
141
|
+
// `currentPath.startsWith('fileman/.trash')` check that never matched the
|
|
142
|
+
// filex backend's storage layout, so trash always looked empty.
|
|
143
|
+
const trashMode = ref(false);
|
|
144
|
+
// The storage the trash view was entered from, so "up" returns there (not the
|
|
145
|
+
// global root). Set in loadTrash().
|
|
146
|
+
const trashOrigin = ref<string>('');
|
|
147
|
+
const trashActive = computed(() => trashMode.value);
|
|
148
|
+
const locale = computed(() => props.config.locale || 'tr');
|
|
149
|
+
|
|
150
|
+
// canGoUp/goUp — toolbar's "↑ Up one level" button. In single-storage
|
|
151
|
+
// mode "" means the storage root; in multi-storage mode "" means
|
|
152
|
+
// the global root (storage list). Both → no parent → button hidden.
|
|
153
|
+
const canGoUp = computed(() => {
|
|
154
|
+
const p = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
|
|
155
|
+
if (rootFloor && p === rootFloor) return false; // at the confined floor — nowhere above
|
|
156
|
+
return p.length > 0;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// True when the explorer is showing the synthetic storage list and
|
|
160
|
+
// there's no real backend folder to mutate. New Folder / Upload /
|
|
161
|
+
// Paste are hidden in this state.
|
|
162
|
+
const atVirtualRoot = computed(() => {
|
|
163
|
+
if (!multiStorageRoot.value) return false;
|
|
164
|
+
return !((currentPath.value ?? '').replace(/^\/+|\/+$/g, ''));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
function goUp() {
|
|
168
|
+
// Leaving the trash view returns to the storage it was opened from, not the
|
|
169
|
+
// global storage-list root.
|
|
170
|
+
if (trashMode.value) {
|
|
171
|
+
void load(trashOrigin.value);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const cur = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
|
|
175
|
+
if (!cur || cur === rootFloor) return;
|
|
176
|
+
const idx = cur.lastIndexOf('/');
|
|
177
|
+
let parent = idx === -1 ? '' : cur.slice(0, idx);
|
|
178
|
+
// Never step above the confined floor.
|
|
179
|
+
if (rootFloor && !(parent === rootFloor || parent.startsWith(rootFloor + '/'))) parent = rootFloor;
|
|
180
|
+
void load(parent);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { t } = useLocale(locale);
|
|
184
|
+
|
|
185
|
+
const selection = useSelection(() => files.value);
|
|
186
|
+
watch(
|
|
187
|
+
() => [...selection.selected.value],
|
|
188
|
+
() => {
|
|
189
|
+
emit(
|
|
190
|
+
'selection-change',
|
|
191
|
+
selection.nodes.value.map((n) => ({ path: n.path, basename: n.basename, type: n.type })),
|
|
192
|
+
);
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const clipboard = ref<ClipboardState>({ mode: null, items: [], sourcePath: null });
|
|
197
|
+
|
|
198
|
+
const capabilitiesData = ref<Capabilities | null>(null);
|
|
199
|
+
|
|
200
|
+
// Creative UI state: starred / tags / recently-opened. The component
|
|
201
|
+
// helpers (StarButton, TagPicker, RecentlyOpened) handle their own
|
|
202
|
+
// API calls — the explorer just tracks the cross-row state needed to
|
|
203
|
+
// render inline stars and keep the recents tray in sync.
|
|
204
|
+
const starredIds = ref(new Set<number>());
|
|
205
|
+
const showRecents = ref(false);
|
|
206
|
+
const showTagPicker = ref(false);
|
|
207
|
+
const tagPickerNode = ref<FileNode | null>(null);
|
|
208
|
+
const recentRefreshKey = ref(0);
|
|
209
|
+
|
|
210
|
+
async function loadStarred() {
|
|
211
|
+
try {
|
|
212
|
+
const headers = await buildAuthHeaders();
|
|
213
|
+
const base = props.config.apiBase ?? '';
|
|
214
|
+
const res = await fetch(`${base}/api/files/manager/star/list?limit=500`, {
|
|
215
|
+
headers,
|
|
216
|
+
credentials: 'include',
|
|
217
|
+
});
|
|
218
|
+
if (!res.ok) return;
|
|
219
|
+
const body = await res.json();
|
|
220
|
+
const rows: { id?: number }[] = Array.isArray(body)
|
|
221
|
+
? body
|
|
222
|
+
: Array.isArray(body?.entries)
|
|
223
|
+
? body.entries
|
|
224
|
+
: Array.isArray(body?.nodes)
|
|
225
|
+
? body.nodes
|
|
226
|
+
: [];
|
|
227
|
+
starredIds.value = new Set(rows.map((n) => n.id).filter((id): id is number => typeof id === 'number'));
|
|
228
|
+
} catch {
|
|
229
|
+
// Silent — backend may be older without the meta routes.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function onStarChange(n: FileNode, value: boolean) {
|
|
234
|
+
if (typeof n.id !== 'number') return;
|
|
235
|
+
const next = new Set(starredIds.value);
|
|
236
|
+
if (value) next.add(n.id);
|
|
237
|
+
else next.delete(n.id);
|
|
238
|
+
starredIds.value = next;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function markRecent(n: FileNode) {
|
|
242
|
+
if (typeof n.id !== 'number') return;
|
|
243
|
+
try {
|
|
244
|
+
const base = props.config.apiBase ?? '';
|
|
245
|
+
await fetch(`${base}/api/files/manager/recent`, {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
headers: await buildAuthHeaders({ 'Content-Type': 'application/json' }),
|
|
248
|
+
credentials: 'include',
|
|
249
|
+
body: JSON.stringify({ node_id: n.id }),
|
|
250
|
+
});
|
|
251
|
+
recentRefreshKey.value += 1;
|
|
252
|
+
} catch {
|
|
253
|
+
// Silent — the open succeeds, recent tracking is best-effort.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function openTagPickerFor(n: FileNode) {
|
|
258
|
+
if (typeof n.id !== 'number') return;
|
|
259
|
+
tagPickerNode.value = n;
|
|
260
|
+
showTagPicker.value = true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function onRecentOpen(entry: { id: number; storage_id?: number; path: string; name: string }) {
|
|
264
|
+
// RecentlyOpened emits the bare row — synthesize a FileNode shaped
|
|
265
|
+
// enough for openNode to route into the editor / preview.
|
|
266
|
+
const node = {
|
|
267
|
+
type: 'file',
|
|
268
|
+
path: entry.path,
|
|
269
|
+
basename: entry.name,
|
|
270
|
+
extension: (entry.name.split('.').pop() || '').toLowerCase(),
|
|
271
|
+
id: entry.id,
|
|
272
|
+
} as unknown as FileNode;
|
|
273
|
+
showRecents.value = false;
|
|
274
|
+
openNode(node);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Resolution order for each external viewer: explicit config override → live
|
|
278
|
+
// backend probe. The probe is the source of truth: an operator can flip the
|
|
279
|
+
// service "on" but if last_check failed (state='error') we still hide the
|
|
280
|
+
// entry so users don't get 503s on click. Explicit config wins because
|
|
281
|
+
// embedders sometimes terminate TLS in front of filex and the backend can't
|
|
282
|
+
// see the public URL.
|
|
283
|
+
const effectiveOnlyOfficeBase = computed<string | null>(() => {
|
|
284
|
+
if (props.config.onlyOfficeBase) return props.config.onlyOfficeBase;
|
|
285
|
+
const ext = capabilitiesData.value?.external?.onlyoffice;
|
|
286
|
+
if (ext && !isExternalUsable(ext)) return null;
|
|
287
|
+
return capabilitiesData.value?.onlyoffice_url || null;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const effectiveOnlyOfficeConfigEndpoint = computed<string | null>(() => {
|
|
291
|
+
if (!effectiveOnlyOfficeBase.value) return null;
|
|
292
|
+
return api.endpoints.onlyOfficeConfig || null;
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const effectiveDrawioUrl = computed<string | null>(() => {
|
|
296
|
+
const override = props.config.drawioUrl || props.config.drawioBase;
|
|
297
|
+
if (override) return override;
|
|
298
|
+
const ext = capabilitiesData.value?.external?.drawio;
|
|
299
|
+
if (ext && !isExternalUsable(ext)) return null;
|
|
300
|
+
return capabilitiesData.value?.drawio_url || null;
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Universal converter (p2r3/convert fork). convert_url is only populated by
|
|
304
|
+
// the backend when the "convert" external service is enabled, so a simple
|
|
305
|
+
// presence check is enough gating.
|
|
306
|
+
const effectiveConvertUrl = computed<string | null>(
|
|
307
|
+
() => props.config.convertBase || capabilitiesData.value?.convert_url || null,
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// Upload
|
|
311
|
+
const uploadJobs = ref<UploadJob[]>([]);
|
|
312
|
+
const fileInputEl = ref<HTMLInputElement | null>(null);
|
|
313
|
+
|
|
314
|
+
// Modals
|
|
315
|
+
const showNewFolder = ref(false);
|
|
316
|
+
const showRename = ref(false);
|
|
317
|
+
const showDelete = ref(false);
|
|
318
|
+
const showShare = ref(false);
|
|
319
|
+
const showPreview = ref(false);
|
|
320
|
+
const renameTarget = ref<FileNode | null>(null);
|
|
321
|
+
const shareTarget = ref<FileNode | null>(null);
|
|
322
|
+
const activeShare = ref<(ShareInfo & { url: string; filename?: string }) | null>(null);
|
|
323
|
+
const previewTarget = ref<FileNode | null>(null);
|
|
324
|
+
const previewMode = ref<'edit' | 'view'>('edit');
|
|
325
|
+
const showConvert = ref(false);
|
|
326
|
+
const convertTarget = ref<FileNode | null>(null);
|
|
327
|
+
const showPerm = ref(false);
|
|
328
|
+
const permTarget = ref<FileNode | null>(null);
|
|
329
|
+
|
|
330
|
+
// RBAC helpers. '' means ACL is not enforced on this storage → full access
|
|
331
|
+
// (the pre-RBAC default). Otherwise 'editor'/'owner' may write; only 'owner'
|
|
332
|
+
// manages permissions. Enforcement is server-side; this just shapes the menu.
|
|
333
|
+
function permCanEdit(p: string | undefined): boolean {
|
|
334
|
+
// undefined = ACL not enforced (dev / unwired) → full access. In production
|
|
335
|
+
// the backend always sends a level; 'none'/'viewer' cannot write, only
|
|
336
|
+
// 'editor'/'owner' can.
|
|
337
|
+
return p === undefined || p === 'editor' || p === 'owner';
|
|
338
|
+
}
|
|
339
|
+
function permIsOwner(p: string | undefined): boolean {
|
|
340
|
+
return p === 'owner';
|
|
341
|
+
}
|
|
342
|
+
// Effective perm for a selection: a single entry's own perm (falls back to the
|
|
343
|
+
// directory perm), else the directory perm for multi-select / background.
|
|
344
|
+
function selPerm(sel: FileNode[]): string {
|
|
345
|
+
if (sel.length === 1 && typeof sel[0]?.perm === 'string') return sel[0].perm as string;
|
|
346
|
+
return dirPerm.value;
|
|
347
|
+
}
|
|
348
|
+
// Can the current user write into the directory being viewed? Gates the
|
|
349
|
+
// toolbar New Folder / Upload / Paste + drag-drop upload.
|
|
350
|
+
const canWriteHere = computed(() => permCanEdit(dirPerm.value));
|
|
351
|
+
|
|
352
|
+
// Context menu
|
|
353
|
+
const ctxRef = ref<InstanceType<typeof ContextMenu> | null>(null);
|
|
354
|
+
const rootEl = ref<HTMLElement | null>(null);
|
|
355
|
+
const toolbarRef = ref<InstanceType<typeof Toolbar> | null>(null);
|
|
356
|
+
|
|
357
|
+
// Toast (tiny, no lib)
|
|
358
|
+
const toast = ref<string | null>(null);
|
|
359
|
+
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
|
360
|
+
function flashToast(msg: string) {
|
|
361
|
+
toast.value = msg;
|
|
362
|
+
if (toastTimer) clearTimeout(toastTimer);
|
|
363
|
+
toastTimer = setTimeout(() => (toast.value = null), 2500);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// --------------------------------------------------------------------
|
|
367
|
+
// Data loading
|
|
368
|
+
// --------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
// multiStorageRoot — when on, "/" is a virtual folder listing every
|
|
371
|
+
// configured storage as a clickable dir. Path semantics shift:
|
|
372
|
+
//
|
|
373
|
+
// "" → global root, list storages
|
|
374
|
+
// "<storage>" → that storage's root (api: `<storage>://`)
|
|
375
|
+
// "<storage>/<rel>" → deeper folder (api: `<storage>://<rel>`)
|
|
376
|
+
//
|
|
377
|
+
// `qualify()` is overridden inside this mode to translate the
|
|
378
|
+
// slash-separated user path into the wire `<adapter>://<rel>` form.
|
|
379
|
+
const multiStorageRoot = computed(() => props.config.multiStorageRoot === true);
|
|
380
|
+
|
|
381
|
+
function splitVirtualPath(p: string): { adapter: string; rel: string } {
|
|
382
|
+
const clean = p.replace(/^\/+|\/+$/g, '');
|
|
383
|
+
if (!clean) return { adapter: '', rel: '' };
|
|
384
|
+
const slash = clean.indexOf('/');
|
|
385
|
+
if (slash === -1) return { adapter: clean, rel: '' };
|
|
386
|
+
return { adapter: clean.slice(0, slash), rel: clean.slice(slash + 1) };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function virtualToWire(p: string): string {
|
|
390
|
+
// Convert `s3-test/example` → `s3-test://example`. Pass-through
|
|
391
|
+
// when the input already carries `://` (legacy callers).
|
|
392
|
+
if (p.includes('://')) return p;
|
|
393
|
+
const { adapter, rel } = splitVirtualPath(p);
|
|
394
|
+
if (!adapter) return ''; // global root — no wire form
|
|
395
|
+
return rel ? `${adapter}://${rel}` : `${adapter}://`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function wireToVirtual(p: string): string {
|
|
399
|
+
// Convert `s3-test://example` → `s3-test/example`.
|
|
400
|
+
const idx = p.indexOf('://');
|
|
401
|
+
if (idx === -1) return p.replace(/^\/+|\/+$/g, '');
|
|
402
|
+
const adapter = p.slice(0, idx);
|
|
403
|
+
const rel = p.slice(idx + 3).replace(/^\/+|\/+$/g, '');
|
|
404
|
+
return rel ? `${adapter}/${rel}` : adapter;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function virtualStorageRows(): FileNode[] {
|
|
408
|
+
// Synthesize a FileNode for every configured storage. Used as the
|
|
409
|
+
// "/" listing in multi-storage mode.
|
|
410
|
+
const list = props.config.storages ?? [];
|
|
411
|
+
return list.map((s) => ({
|
|
412
|
+
type: 'dir',
|
|
413
|
+
path: s.name, // virtual path (no adapter prefix)
|
|
414
|
+
basename: s.label || s.name,
|
|
415
|
+
extension: '',
|
|
416
|
+
storage: s.name,
|
|
417
|
+
visibility: 'private',
|
|
418
|
+
file_size: 0,
|
|
419
|
+
mime_type: 'inode/storage',
|
|
420
|
+
extra_metadata: { driver: s.driver, readOnly: s.readOnly },
|
|
421
|
+
} as unknown as FileNode));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function load(path?: string) {
|
|
425
|
+
loading.value = true;
|
|
426
|
+
// Any normal navigation exits trash mode (the trash view is entered only
|
|
427
|
+
// by opening the virtual `.trash` row, which calls loadTrash()).
|
|
428
|
+
trashMode.value = false;
|
|
429
|
+
try {
|
|
430
|
+
let requested = path ?? currentPath.value ?? '';
|
|
431
|
+
// Clamp to the confined floor: an empty/above-floor request (incl. a stale
|
|
432
|
+
// persisted path or the drives root) snaps back to rootPath. This both
|
|
433
|
+
// suppresses the multi-storage drives list and blocks up-navigation.
|
|
434
|
+
if (rootFloor) {
|
|
435
|
+
const p = String(requested).replace(/^\/+|\/+$/g, '');
|
|
436
|
+
if (!p || !(p === rootFloor || p.startsWith(rootFloor + '/'))) requested = rootFloor;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Multi-storage virtual root — synthesize a list of storages
|
|
440
|
+
// instead of calling the backend.
|
|
441
|
+
if (multiStorageRoot.value && !virtualToWire(requested)) {
|
|
442
|
+
currentPath.value = '';
|
|
443
|
+
adapter.value = '';
|
|
444
|
+
dirname.value = '';
|
|
445
|
+
files.value = virtualStorageRows();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const target = multiStorageRoot.value
|
|
450
|
+
? virtualToWire(requested)
|
|
451
|
+
: qualify(requested);
|
|
452
|
+
|
|
453
|
+
const resp = searchQuery.value
|
|
454
|
+
? await api.search(target, searchQuery.value)
|
|
455
|
+
: await api.index(target);
|
|
456
|
+
adapter.value = resp.adapter;
|
|
457
|
+
dirname.value = resp.dirname;
|
|
458
|
+
dirPerm.value = (resp.perm as string) || '';
|
|
459
|
+
files.value = (resp.files || []).filter((f) => {
|
|
460
|
+
if (f.path.includes('.thumbs')) return false;
|
|
461
|
+
if (f.path.includes('.versions') || f.basename === '.versions') return false;
|
|
462
|
+
if (f.basename === '.trash') return false;
|
|
463
|
+
if (f.basename === '.keepdir') return false;
|
|
464
|
+
return true;
|
|
465
|
+
});
|
|
466
|
+
// Inject virtual `.trash` entry at root only.
|
|
467
|
+
const dirRel = stripAdapter(resp.dirname);
|
|
468
|
+
const inRoot = dirRel === 'fileman' || dirRel === '';
|
|
469
|
+
const isTrashListing = dirRel.startsWith('fileman/.trash');
|
|
470
|
+
const trashEntryEnabled = props.config.trashVisible !== false;
|
|
471
|
+
if (!isTrashListing && inRoot && trashEntryEnabled) {
|
|
472
|
+
files.value.unshift({
|
|
473
|
+
type: 'dir',
|
|
474
|
+
path: `${resp.adapter}://fileman/.trash`,
|
|
475
|
+
basename: '.trash',
|
|
476
|
+
extension: '',
|
|
477
|
+
storage: resp.adapter,
|
|
478
|
+
visibility: 'private',
|
|
479
|
+
file_size: 0,
|
|
480
|
+
mime_type: 'inode/directory',
|
|
481
|
+
extra_metadata: {},
|
|
482
|
+
} as unknown as FileNode);
|
|
483
|
+
}
|
|
484
|
+
// currentPath is the user-facing form: `s3-test/example` in
|
|
485
|
+
// multi-storage mode, the bare relative path otherwise.
|
|
486
|
+
currentPath.value = multiStorageRoot.value
|
|
487
|
+
? wireToVirtual(resp.dirname)
|
|
488
|
+
: stripAdapter(resp.dirname);
|
|
489
|
+
} catch (err) {
|
|
490
|
+
const e = err instanceof Error ? err.message : String(err);
|
|
491
|
+
emit('error', { message: e, context: { path } });
|
|
492
|
+
flashToast(e);
|
|
493
|
+
} finally {
|
|
494
|
+
loading.value = false;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function stripAdapter(p: string): string {
|
|
499
|
+
const idx = p.indexOf('://');
|
|
500
|
+
return idx === -1 ? p : p.slice(idx + 3);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// loadTrash — show the backend trash (soft-deleted nodes) as a flat listing.
|
|
504
|
+
// Entered by opening the virtual `.trash` row. Each row keeps its node `id`
|
|
505
|
+
// so restore can target it. Permanent delete is admin-only / auto-purge, so
|
|
506
|
+
// the only mutation offered here is Restore.
|
|
507
|
+
async function loadTrash() {
|
|
508
|
+
loading.value = true;
|
|
509
|
+
trashOrigin.value = adapter.value || '';
|
|
510
|
+
trashMode.value = true;
|
|
511
|
+
selection.clear();
|
|
512
|
+
try {
|
|
513
|
+
const { entries } = await api.listTrash();
|
|
514
|
+
files.value = entries.map(
|
|
515
|
+
(e) =>
|
|
516
|
+
({
|
|
517
|
+
type: 'file',
|
|
518
|
+
id: e.id,
|
|
519
|
+
path: e.storage_name ? `${e.storage_name}://${e.path}` : e.path,
|
|
520
|
+
basename: e.name,
|
|
521
|
+
extension: e.name.includes('.') ? e.name.split('.').pop() || '' : '',
|
|
522
|
+
storage: e.storage_name || '',
|
|
523
|
+
visibility: 'private',
|
|
524
|
+
file_size: e.size,
|
|
525
|
+
mime_type: e.mime || '',
|
|
526
|
+
extra_metadata: { deleted_at: e.deleted_at, ttl_days: e.ttl_days ?? null },
|
|
527
|
+
}) as unknown as FileNode,
|
|
528
|
+
);
|
|
529
|
+
dirname.value = '.trash';
|
|
530
|
+
currentPath.value = '.trash';
|
|
531
|
+
} catch (err) {
|
|
532
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
533
|
+
emit('error', { message: msg, context: { op: 'trash-list' } });
|
|
534
|
+
flashToast(msg);
|
|
535
|
+
} finally {
|
|
536
|
+
loading.value = false;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* qualify — return `<adapter>://<rel>` for backend calls.
|
|
542
|
+
*
|
|
543
|
+
* The backend's manager handler picks a storage by parsing the
|
|
544
|
+
* adapter prefix. Without one it falls back to `storages[0]`,
|
|
545
|
+
* which 404s on every non-default storage (S3/SFTP/WebDAV in a
|
|
546
|
+
* multi-storage install). All API callers (rename/move/delete/
|
|
547
|
+
* upload/preview/download/share/copy) must use a qualified path.
|
|
548
|
+
*
|
|
549
|
+
* In multi-storage mode `currentPath` is `<storage>/<rel>` (no
|
|
550
|
+
* `://`), so qualify forwards through `virtualToWire` which
|
|
551
|
+
* splits the first segment off as the adapter. In single-storage
|
|
552
|
+
* mode the legacy bare-relative path is glued onto `adapter.value`.
|
|
553
|
+
*
|
|
554
|
+
* `stripAdapter()` stays for cosmetic display logic only
|
|
555
|
+
* (breadcrumb root check, inRoot computation, openPageBase).
|
|
556
|
+
*/
|
|
557
|
+
function qualify(p: string): string {
|
|
558
|
+
if (p && p.includes('://')) return p;
|
|
559
|
+
if (multiStorageRoot.value) {
|
|
560
|
+
const wire = virtualToWire(p ?? '');
|
|
561
|
+
if (wire) return wire;
|
|
562
|
+
return adapter.value ? `${adapter.value}://` : '';
|
|
563
|
+
}
|
|
564
|
+
if (!p) return `${adapter.value}://`;
|
|
565
|
+
return `${adapter.value}://${p.replace(/^\/+/, '')}`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
watch(
|
|
569
|
+
() => searchQuery.value,
|
|
570
|
+
() => void load(),
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
// ----------------------------------------------------------------
|
|
574
|
+
// Path persistence
|
|
575
|
+
// ----------------------------------------------------------------
|
|
576
|
+
const PATH_LS_KEY = 'brf-file-explorer:path';
|
|
577
|
+
|
|
578
|
+
function persistMode(): 'hash' | 'localStorage' | 'none' {
|
|
579
|
+
return props.config.pathPersist ?? 'hash';
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function readPersistedPath(): string {
|
|
583
|
+
if (typeof window === 'undefined') return '';
|
|
584
|
+
const mode = persistMode();
|
|
585
|
+
if (mode === 'none') return '';
|
|
586
|
+
if (mode === 'localStorage') {
|
|
587
|
+
try {
|
|
588
|
+
return localStorage.getItem(PATH_LS_KEY) || '';
|
|
589
|
+
} catch {
|
|
590
|
+
return '';
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const h = window.location.hash || '';
|
|
594
|
+
if (!h.startsWith('#')) return '';
|
|
595
|
+
return decodeURIComponent(h.slice(1)).replace(/^\/+|\/+$/g, '');
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
let hashSyncSuppressed = false;
|
|
599
|
+
|
|
600
|
+
function writePersistedPath(path: string) {
|
|
601
|
+
if (typeof window === 'undefined') return;
|
|
602
|
+
const mode = persistMode();
|
|
603
|
+
if (mode === 'none') return;
|
|
604
|
+
if (mode === 'localStorage') {
|
|
605
|
+
try {
|
|
606
|
+
if (path) localStorage.setItem(PATH_LS_KEY, path);
|
|
607
|
+
else localStorage.removeItem(PATH_LS_KEY);
|
|
608
|
+
} catch {
|
|
609
|
+
/* private mode / quota */
|
|
610
|
+
}
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const target = path ? `#${path}` : '';
|
|
614
|
+
if (window.location.hash === target) return;
|
|
615
|
+
hashSyncSuppressed = true;
|
|
616
|
+
history.replaceState(
|
|
617
|
+
null,
|
|
618
|
+
'',
|
|
619
|
+
target || window.location.pathname + window.location.search,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function onHashChange() {
|
|
624
|
+
if (persistMode() !== 'hash') return;
|
|
625
|
+
if (hashSyncSuppressed) {
|
|
626
|
+
hashSyncSuppressed = false;
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const p = readPersistedPath();
|
|
630
|
+
if (p && p !== currentPath.value) {
|
|
631
|
+
void load(p);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
watch(currentPath, (p) => writePersistedPath(p));
|
|
636
|
+
|
|
637
|
+
onMounted(async () => {
|
|
638
|
+
// Eagerly start fetching Monaco — the user doesn't pay for it
|
|
639
|
+
// perceptually; click-to-edit hits an in-memory cache.
|
|
640
|
+
preloadEditor();
|
|
641
|
+
|
|
642
|
+
const fromPersist = readPersistedPath();
|
|
643
|
+
await load(fromPersist || undefined);
|
|
644
|
+
await nextTick();
|
|
645
|
+
rootEl.value?.focus();
|
|
646
|
+
// Best-effort initial fetch — silent if the older backend doesn't
|
|
647
|
+
// expose /api/files/manager/starred. Without this stars never light
|
|
648
|
+
// up on first render even when the row IS starred server-side.
|
|
649
|
+
void loadStarred();
|
|
650
|
+
if (persistMode() === 'hash') {
|
|
651
|
+
window.addEventListener('hashchange', onHashChange);
|
|
652
|
+
}
|
|
653
|
+
if (api.endpoints.opsList) {
|
|
654
|
+
pendingOps.startPolling();
|
|
655
|
+
}
|
|
656
|
+
if (api.endpoints.capabilities) {
|
|
657
|
+
api
|
|
658
|
+
.capabilities()
|
|
659
|
+
.then((cap) => {
|
|
660
|
+
capabilitiesData.value = cap;
|
|
661
|
+
})
|
|
662
|
+
.catch(() => {
|
|
663
|
+
/* swallow — `onlyoffice_url` falls back to null */
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
// --------------------------------------------------------------------
|
|
669
|
+
// Keyboard
|
|
670
|
+
// --------------------------------------------------------------------
|
|
671
|
+
|
|
672
|
+
useKeyboardShortcuts(rootEl, {
|
|
673
|
+
onDelete: () => {
|
|
674
|
+
if (!selection.isEmpty.value) showDelete.value = true;
|
|
675
|
+
},
|
|
676
|
+
onRename: () => {
|
|
677
|
+
if (selection.nodes.value.length === 1) {
|
|
678
|
+
renameTarget.value = selection.nodes.value[0];
|
|
679
|
+
showRename.value = true;
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
onSelectAll: () => selection.selectAll(),
|
|
683
|
+
onOpen: () => {
|
|
684
|
+
const n = selection.nodes.value[0];
|
|
685
|
+
if (n) openNode(n);
|
|
686
|
+
},
|
|
687
|
+
onClose: () => {
|
|
688
|
+
showNewFolder.value = false;
|
|
689
|
+
showRename.value = false;
|
|
690
|
+
showDelete.value = false;
|
|
691
|
+
showShare.value = false;
|
|
692
|
+
showPreview.value = false;
|
|
693
|
+
ctxRef.value?.hide();
|
|
694
|
+
},
|
|
695
|
+
onFocusSearch: () => toolbarRef.value?.focusSearch(),
|
|
696
|
+
onCut: () => cut(),
|
|
697
|
+
onCopy: () => copyToClipboard(),
|
|
698
|
+
onPaste: () => paste(),
|
|
699
|
+
onGoUp: () => goUp(),
|
|
700
|
+
hasSelection: () => !selection.isEmpty.value,
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
// --------------------------------------------------------------------
|
|
704
|
+
// Actions
|
|
705
|
+
// --------------------------------------------------------------------
|
|
706
|
+
|
|
707
|
+
const OFFICE_EXTS = new Set([
|
|
708
|
+
'docx', 'xlsx', 'pptx',
|
|
709
|
+
'doc', 'xls', 'ppt',
|
|
710
|
+
'odt', 'ods', 'odp',
|
|
711
|
+
]);
|
|
712
|
+
const TEXT_CODE_EXTS = new Set([
|
|
713
|
+
'txt', 'md', 'markdown', 'log', 'csv', 'tsv', 'conf', 'ini',
|
|
714
|
+
'env', 'toml', 'cfg',
|
|
715
|
+
'json', 'jsonc', 'yaml', 'yml', 'xml', 'svg',
|
|
716
|
+
'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx',
|
|
717
|
+
'css', 'scss', 'sass', 'less',
|
|
718
|
+
'html', 'htm', 'vue', 'svelte',
|
|
719
|
+
'php', 'py', 'rb', 'rs', 'go', 'java', 'kt', 'swift',
|
|
720
|
+
'cpp', 'c', 'h', 'hpp', 'cs', 'dart',
|
|
721
|
+
'sh', 'bash', 'zsh', 'sql', 'lua', 'pl', 'r',
|
|
722
|
+
'dockerfile', 'gradle', 'gitignore',
|
|
723
|
+
]);
|
|
724
|
+
|
|
725
|
+
function openNode(n: FileNode) {
|
|
726
|
+
// The virtual `.trash` row opens the backend trash listing, not a real dir.
|
|
727
|
+
if (n.basename === '.trash') {
|
|
728
|
+
void loadTrash();
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (n.type === 'dir') {
|
|
732
|
+
// Multi-storage virtual rows have a bare path (`s3-test`); pass
|
|
733
|
+
// them straight to load() which will treat them as the wire form
|
|
734
|
+
// for that storage's root. Real backend rows still come back as
|
|
735
|
+
// `<adapter>://<rel>` and stripAdapter turns them into the user
|
|
736
|
+
// path semantics load() expects.
|
|
737
|
+
const target = multiStorageRoot.value
|
|
738
|
+
? wireToVirtual(n.path)
|
|
739
|
+
: stripAdapter(n.path);
|
|
740
|
+
void load(target);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
// "Aç" / double-click contract: open in a new tab against the
|
|
744
|
+
// standalone editor route, regardless of file type. The editor page
|
|
745
|
+
// picks the right viewer (OnlyOffice for office, Monaco for code/
|
|
746
|
+
// text, drawio iframe for .drawio, image/PDF/3D viewers otherwise)
|
|
747
|
+
// and wires save-on-change. This is the shape the origin app ships and
|
|
748
|
+
// what users expect from a Files-style file manager.
|
|
749
|
+
//
|
|
750
|
+
// Capability gate: if we already know the required backend is offline
|
|
751
|
+
// (OnlyOffice for office docs, drawio for diagrams), don't launch a
|
|
752
|
+
// new tab that we'd just render a "service not configured" fallback
|
|
753
|
+
// inside — drop into the in-page preview instead, which is the same
|
|
754
|
+
// dead-end UI but without the tab-switching whiplash.
|
|
755
|
+
// Double-click contract: in-page modal preview. Office docs and
|
|
756
|
+
// other read-only kinds open in view mode so a quick peek doesn't
|
|
757
|
+
// mount an editing surface on top of the content. Code/markdown
|
|
758
|
+
// open in edit so the user gets the fast "open, tweak, Ctrl+S"
|
|
759
|
+
// loop. Modal's "Yeni sekmede aç" button still launches the
|
|
760
|
+
// standalone fullscreen editor route when richer editing is wanted.
|
|
761
|
+
const ext = (n.extension || '').toLowerCase();
|
|
762
|
+
// RBAC: viewers (no edit on this item) always get the read-only preview
|
|
763
|
+
// modal — never the editable surface. This is the "view vs edit" split.
|
|
764
|
+
previewMode.value = permCanEdit((n.perm as string) ?? dirPerm.value)
|
|
765
|
+
? previewModeForExt(ext)
|
|
766
|
+
: 'view';
|
|
767
|
+
previewTarget.value = n;
|
|
768
|
+
showPreview.value = true;
|
|
769
|
+
emit('file-opened', { path: n.path, basename: n.basename });
|
|
770
|
+
void markRecent(n);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const VIEW_DEFAULT_EXTS = new Set<string>([
|
|
774
|
+
...OFFICE_EXTS,
|
|
775
|
+
'drawio', 'dio',
|
|
776
|
+
'pdf', 'epub', 'ipynb', 'tiff', 'tif', 'psd',
|
|
777
|
+
'mmd', 'mermaid',
|
|
778
|
+
'glb', 'gltf', 'obj', 'stl', 'fbx', '3ds',
|
|
779
|
+
'zip', 'rar', '7z', 'tar', 'gz', 'tgz',
|
|
780
|
+
'jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'avif', 'svg', 'heic',
|
|
781
|
+
'mp4', 'webm', 'mov', 'mkv', 'm4v', 'ogv',
|
|
782
|
+
'mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus',
|
|
783
|
+
]);
|
|
784
|
+
|
|
785
|
+
function previewModeForExt(ext: string): 'view' | 'edit' {
|
|
786
|
+
if (VIEW_DEFAULT_EXTS.has(ext)) return 'view';
|
|
787
|
+
return 'edit';
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function restoreSelection(targets?: FileNode[]) {
|
|
791
|
+
const nodes = targets ?? selection.nodes.value;
|
|
792
|
+
if (nodes.length === 0) return;
|
|
793
|
+
try {
|
|
794
|
+
// filex trash: restore by node id, then refresh the trash listing.
|
|
795
|
+
if (trashMode.value) {
|
|
796
|
+
const ids = nodes
|
|
797
|
+
.map((n) => (n as { id?: number }).id)
|
|
798
|
+
.filter((x): x is number => typeof x === 'number');
|
|
799
|
+
const { restored } = await api.restoreIds(ids);
|
|
800
|
+
flashToast(`${restored} öğe geri getirildi`);
|
|
801
|
+
selection.clear();
|
|
802
|
+
await loadTrash();
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
// Legacy path-based restore (the origin app `.trash/` convention).
|
|
806
|
+
if (!api.endpoints.restore) return;
|
|
807
|
+
const items = nodes.map((n) => n.path); // qualified
|
|
808
|
+
const { restored } = await api.restore(items);
|
|
809
|
+
flashToast(`${restored} öğe geri getirildi`);
|
|
810
|
+
selection.clear();
|
|
811
|
+
await load();
|
|
812
|
+
} catch (err) {
|
|
813
|
+
emit('error', { message: (err as Error).message, context: { op: 'restore' } });
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function previewNode(n: FileNode) {
|
|
818
|
+
previewMode.value = 'view';
|
|
819
|
+
previewTarget.value = n;
|
|
820
|
+
showPreview.value = true;
|
|
821
|
+
void markRecent(n);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* openNodeInNewTab — launches the standalone /files/edit route in a
|
|
826
|
+
* fresh tab. Used by the context-menu "Aç" action; double-click stays
|
|
827
|
+
* on the in-page modal path. Dirs still navigate inline (no editor for
|
|
828
|
+
* directories). Falls back to the modal if no `openPageBase` is wired
|
|
829
|
+
* by the embedder.
|
|
830
|
+
*/
|
|
831
|
+
function openNodeInNewTab(n: FileNode) {
|
|
832
|
+
if (n.type === 'dir') {
|
|
833
|
+
const target = multiStorageRoot.value
|
|
834
|
+
? wireToVirtual(n.path)
|
|
835
|
+
: stripAdapter(n.path);
|
|
836
|
+
void load(target);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
// RBAC: a viewer (no edit on this item) can't use the editable "Aç"
|
|
840
|
+
// surface — drop to the read-only in-page preview instead.
|
|
841
|
+
if (!permCanEdit((n.perm as string) ?? dirPerm.value)) {
|
|
842
|
+
previewNode(n);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const ext = (n.extension || '').toLowerCase();
|
|
846
|
+
const base = props.config.openPageBase;
|
|
847
|
+
if (!base) {
|
|
848
|
+
// Embedder didn't supply a standalone editor route — keep the
|
|
849
|
+
// in-page modal as the only available affordance.
|
|
850
|
+
openNode(n);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
// Context-menu "Aç" is the intent-to-edit action — request edit
|
|
854
|
+
// mode so OnlyOffice / Monaco mount with write permissions.
|
|
855
|
+
// Read-only inspection lives on the "Önizle" entry + the dbl-click
|
|
856
|
+
// in-page modal.
|
|
857
|
+
const sep = base.includes('?') ? '&' : '?';
|
|
858
|
+
const url =
|
|
859
|
+
`${base}${sep}path=${encodeURIComponent(n.path)}` +
|
|
860
|
+
`&type=${encodeURIComponent(ext)}` +
|
|
861
|
+
`&mode=edit`;
|
|
862
|
+
window.open(url, '_blank', 'noopener');
|
|
863
|
+
emit('file-opened', { path: n.path, basename: n.basename });
|
|
864
|
+
void markRecent(n);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
type ContextMode = 'selection' | 'breadcrumb';
|
|
868
|
+
const ctxMode = ref<ContextMode>('selection');
|
|
869
|
+
const breadcrumbCtxPath = ref<string>('');
|
|
870
|
+
const breadcrumbCtxLabel = ref<string>('');
|
|
871
|
+
|
|
872
|
+
const selectionMode = computed<SelectionMode>(() => {
|
|
873
|
+
const sel = selection.nodes.value;
|
|
874
|
+
if (sel.length === 0) return 'none';
|
|
875
|
+
if (sel.length === 1) return sel[0].type === 'dir' ? 'single-dir' : 'single-file';
|
|
876
|
+
return 'multi';
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
async function onToolbarAction(key: string) {
|
|
880
|
+
const sel = selection.nodes.value;
|
|
881
|
+
// The toolbar's "Aç" opens the in-page preview/editor modal (quick peek);
|
|
882
|
+
// everything else shares dispatchItemAction with the context menu so the two
|
|
883
|
+
// identical menus also behave identically.
|
|
884
|
+
if (key === 'open') {
|
|
885
|
+
if (sel[0]) openNode(sel[0]);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
await dispatchItemAction(key, sel);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async function onContextTarget(node: FileNode, ev: MouseEvent) {
|
|
892
|
+
ctxMode.value = 'selection';
|
|
893
|
+
if (!selection.has(node.path)) {
|
|
894
|
+
selection.click(node.path);
|
|
895
|
+
await nextTick();
|
|
896
|
+
}
|
|
897
|
+
ctxRef.value?.show({ clientX: ev.clientX, clientY: ev.clientY }, selection.nodes.value);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function onContextCanvas(ev: MouseEvent) {
|
|
901
|
+
ev.preventDefault();
|
|
902
|
+
ctxMode.value = 'selection';
|
|
903
|
+
selection.clear();
|
|
904
|
+
ctxRef.value?.show({ clientX: ev.clientX, clientY: ev.clientY }, []);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function onCrumbContext(payload: { x: number; y: number; adapterPath: string; label: string }) {
|
|
908
|
+
ctxMode.value = 'breadcrumb';
|
|
909
|
+
breadcrumbCtxPath.value = payload.adapterPath;
|
|
910
|
+
breadcrumbCtxLabel.value = payload.label;
|
|
911
|
+
ctxRef.value?.show({ clientX: payload.x, clientY: payload.y }, []);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const contextActions = computed<ContextAction[]>(() => {
|
|
915
|
+
if (ctxMode.value === 'breadcrumb') {
|
|
916
|
+
return [
|
|
917
|
+
{ key: 'open', label: t('ctx.open'), icon: '↗' },
|
|
918
|
+
{ key: 'copy-path', label: t('breadcrumb.copy_path'), icon: '📋' },
|
|
919
|
+
];
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const sel = selection.nodes.value;
|
|
923
|
+
const any = sel.length > 0;
|
|
924
|
+
const single = sel.length === 1;
|
|
925
|
+
|
|
926
|
+
if (trashActive.value) {
|
|
927
|
+
if (!any) return [];
|
|
928
|
+
return [
|
|
929
|
+
{ key: 'restore', label: t('ctx.restore'), icon: '↩' },
|
|
930
|
+
{ divider: true, key: 'sep1', label: '' },
|
|
931
|
+
{ key: 'delete', label: t('ctx.delete_perm'), icon: '🗑', danger: true },
|
|
932
|
+
];
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Storage roots (the virtual rows shown at the multi-storage "/"
|
|
936
|
+
// overview) aren't real filesystem entries — they're mount points.
|
|
937
|
+
// Hide every mutation entry (rename/delete/share/cut/copy/new-folder/
|
|
938
|
+
// paste) and only offer "Aç" so the menu doesn't surface actions
|
|
939
|
+
// that would 4xx on the backend.
|
|
940
|
+
//
|
|
941
|
+
// PRIOR BUG: this used `currentPath === '/'` but the load() branch
|
|
942
|
+
// for the virtual root sets currentPath to EMPTY string, not '/'.
|
|
943
|
+
// So the guard never fired and every mutation action leaked into
|
|
944
|
+
// the menu at the depo listing — including new-folder + paste,
|
|
945
|
+
// which Burak called out in the most direct possible terms. Use
|
|
946
|
+
// the same empty-after-trim test as `atVirtualRoot` above.
|
|
947
|
+
const trimmedPath = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
|
|
948
|
+
const inStorageRoot = multiStorageRoot.value && trimmedPath === '';
|
|
949
|
+
if (inStorageRoot) {
|
|
950
|
+
if (!any) return [];
|
|
951
|
+
if (!single) return [];
|
|
952
|
+
return [
|
|
953
|
+
{ key: 'open', label: t('ctx.open'), icon: '↗' },
|
|
954
|
+
];
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Empty background right-click: folder-level actions only. Viewers (no edit
|
|
958
|
+
// on this dir) get nothing here.
|
|
959
|
+
if (!any) {
|
|
960
|
+
if (!permCanEdit(dirPerm.value)) return [];
|
|
961
|
+
return [
|
|
962
|
+
{ key: 'new-folder', label: t('toolbar.new_folder'), icon: '📁' },
|
|
963
|
+
{ key: 'paste', label: t('ctx.paste'), icon: '📋', disabled: !clipboard.value.mode },
|
|
964
|
+
];
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
return selectionActionList(sel);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
// selectionActionList — the SINGLE source of truth for the actions offered on a
|
|
971
|
+
// selection. BOTH the right-click context menu AND the top toolbar render this
|
|
972
|
+
// exact list so they can never drift apart (Burak: "sağ klik menüyle üst menü
|
|
973
|
+
// tutmuyor"). The toolbar filters out dividers/hidden; the context menu shows
|
|
974
|
+
// them. Action handling is unified in dispatchItemAction().
|
|
975
|
+
function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
976
|
+
const any = sel.length > 0;
|
|
977
|
+
const single = sel.length === 1;
|
|
978
|
+
const isFile = single && sel[0]?.type === 'file';
|
|
979
|
+
const tagsLabel = locale.value === 'en' ? 'Tags…' : 'Etiketler…';
|
|
980
|
+
const singleHasId = single && typeof sel[0]?.id === 'number';
|
|
981
|
+
const copyIdLabel = locale.value === 'en' ? 'Copy node id' : "Node id'yi kopyala";
|
|
982
|
+
// RBAC: gate mutating actions when the caller lacks edit on the target. The
|
|
983
|
+
// "İzinler" (permissions) action shows only for owners on RBAC-on storages.
|
|
984
|
+
const p = selPerm(sel);
|
|
985
|
+
const w = permCanEdit(p); // may write here
|
|
986
|
+
// Unified "Paylaş / İzinler" popup: public share link (editor+) + per-user
|
|
987
|
+
// permissions (owner-only, decided inside the modal).
|
|
988
|
+
// Unified "Paylaş / İzinler" popup carries the public share link, per-user
|
|
989
|
+
// permissions AND the folder-only "Dosya İste" (file-drop) tab — the user
|
|
990
|
+
// picks the action from inside the modal, so there's no separate button.
|
|
991
|
+
const accessLabel = locale.value === 'en' ? 'Share / Permissions' : 'Paylaş / İzinler';
|
|
992
|
+
return [
|
|
993
|
+
{ key: 'open', label: t('ctx.open'), icon: '↗', hidden: !single },
|
|
994
|
+
{ key: 'preview', label: t('ctx.preview'), icon: '👁', hidden: !single, disabled: !isFile },
|
|
995
|
+
{ key: 'download', label: t('ctx.download'), icon: '⬇', hidden: !single, disabled: !isFile },
|
|
996
|
+
{ key: 'convert', label: t('ctx.convert'), icon: '🔄', hidden: !single || !effectiveConvertUrl.value || !w, disabled: !isFile },
|
|
997
|
+
{ key: 'access', label: accessLabel, icon: '🔗', hidden: !single || !w },
|
|
998
|
+
{ key: 'copy-id', label: copyIdLabel, icon: '🆔', hidden: !singleHasId, disabled: !singleHasId },
|
|
999
|
+
{ divider: true, key: 'sep1', label: '', hidden: !w },
|
|
1000
|
+
{ key: 'rename', label: t('ctx.rename'), icon: '✎', hidden: !single || !w, disabled: !single },
|
|
1001
|
+
{ key: 'cut', label: t('ctx.cut'), icon: '✂', hidden: !any || !w, disabled: !any },
|
|
1002
|
+
{ key: 'copy', label: t('ctx.copy'), icon: '❐', hidden: !any, disabled: !any },
|
|
1003
|
+
{ key: 'paste', label: t('ctx.paste'), icon: '📋', hidden: !w, disabled: !clipboard.value.mode },
|
|
1004
|
+
{ divider: true, key: 'sep-meta', label: '', hidden: !singleHasId },
|
|
1005
|
+
{ key: 'tags', label: tagsLabel, icon: '🏷', hidden: !singleHasId, disabled: !singleHasId },
|
|
1006
|
+
{ divider: true, key: 'sep2', label: '', hidden: !w },
|
|
1007
|
+
{ key: 'delete', label: t('ctx.delete'), icon: '🗑', danger: true, hidden: !any || !w, disabled: !any },
|
|
1008
|
+
];
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// toolbarActions — what the top toolbar shows. Mirrors the context menu so the
|
|
1012
|
+
// two stay identical for a selection; the empty/trash/virtual-root cases match
|
|
1013
|
+
// the context menu's special branches.
|
|
1014
|
+
const toolbarActions = computed<ContextAction[]>(() => {
|
|
1015
|
+
const sel = selection.nodes.value;
|
|
1016
|
+
if (trashActive.value) {
|
|
1017
|
+
if (sel.length === 0) return [];
|
|
1018
|
+
return [
|
|
1019
|
+
{ key: 'restore', label: t('ctx.restore'), icon: '↩' },
|
|
1020
|
+
{ key: 'delete', label: t('ctx.delete_perm'), icon: '🗑', danger: true },
|
|
1021
|
+
];
|
|
1022
|
+
}
|
|
1023
|
+
const trimmedPath = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
|
|
1024
|
+
if (multiStorageRoot.value && trimmedPath === '') {
|
|
1025
|
+
return sel.length === 1 ? [{ key: 'open', label: t('ctx.open'), icon: '↗' }] : [];
|
|
1026
|
+
}
|
|
1027
|
+
if (sel.length === 0) return [];
|
|
1028
|
+
return selectionActionList(sel);
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
async function onContextAction(action: ContextAction, targets: FileNode[]) {
|
|
1032
|
+
if (ctxMode.value === 'breadcrumb') {
|
|
1033
|
+
if (action.key === 'open') {
|
|
1034
|
+
void load(stripAdapter(breadcrumbCtxPath.value));
|
|
1035
|
+
} else if (action.key === 'copy-path') {
|
|
1036
|
+
await onCopyPath(breadcrumbCtxPath.value);
|
|
1037
|
+
}
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
await dispatchItemAction(action.key, targets);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// dispatchItemAction — unified handler for an action key on a target set. Both
|
|
1044
|
+
// the right-click menu (onContextAction) and the toolbar (onToolbarAction)
|
|
1045
|
+
// route here, so the two menus that now render the SAME list also behave the
|
|
1046
|
+
// same. (Toolbar "Aç" is the one deliberate exception — see onToolbarAction.)
|
|
1047
|
+
async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
1048
|
+
switch (key) {
|
|
1049
|
+
case 'open':
|
|
1050
|
+
// Context-menu "Aç" launches the standalone fullscreen route
|
|
1051
|
+
// in a new tab. Double-click (openNode) opens the in-page
|
|
1052
|
+
// modal — two distinct affordances on purpose: quick peek vs
|
|
1053
|
+
// dedicated editing surface.
|
|
1054
|
+
if (targets[0]) openNodeInNewTab(targets[0]);
|
|
1055
|
+
break;
|
|
1056
|
+
case 'preview':
|
|
1057
|
+
if (targets[0]) previewNode(targets[0]);
|
|
1058
|
+
break;
|
|
1059
|
+
case 'download':
|
|
1060
|
+
if (targets[0]) downloadFile(targets[0]);
|
|
1061
|
+
break;
|
|
1062
|
+
case 'convert':
|
|
1063
|
+
if (targets[0]) openConvert(targets[0]);
|
|
1064
|
+
break;
|
|
1065
|
+
case 'share':
|
|
1066
|
+
if (targets[0]) openShare(targets[0]);
|
|
1067
|
+
break;
|
|
1068
|
+
case 'access':
|
|
1069
|
+
if (targets[0]) {
|
|
1070
|
+
permTarget.value = targets[0];
|
|
1071
|
+
showPerm.value = true;
|
|
1072
|
+
}
|
|
1073
|
+
break;
|
|
1074
|
+
case 'copy-id':
|
|
1075
|
+
if (targets[0] && typeof targets[0].id === 'number') {
|
|
1076
|
+
const id = targets[0].id;
|
|
1077
|
+
navigator.clipboard?.writeText(String(id)).then(
|
|
1078
|
+
() => flashToast(locale.value === 'en' ? `Node id ${id} copied` : `Node id ${id} kopyalandı`),
|
|
1079
|
+
() => flashToast(`#${id}`),
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
break;
|
|
1083
|
+
case 'tags':
|
|
1084
|
+
if (targets[0]) openTagPickerFor(targets[0]);
|
|
1085
|
+
break;
|
|
1086
|
+
case 'rename':
|
|
1087
|
+
if (targets[0]) {
|
|
1088
|
+
renameTarget.value = targets[0];
|
|
1089
|
+
showRename.value = true;
|
|
1090
|
+
}
|
|
1091
|
+
break;
|
|
1092
|
+
case 'cut':
|
|
1093
|
+
clipboard.value = { mode: 'cut', items: targets, sourcePath: currentPath.value };
|
|
1094
|
+
flashToast('Kes → Yapıştır hazır');
|
|
1095
|
+
break;
|
|
1096
|
+
case 'copy':
|
|
1097
|
+
clipboard.value = { mode: 'copy', items: targets, sourcePath: currentPath.value };
|
|
1098
|
+
flashToast('Kopyala → Yapıştır hazır');
|
|
1099
|
+
break;
|
|
1100
|
+
case 'paste':
|
|
1101
|
+
await paste();
|
|
1102
|
+
break;
|
|
1103
|
+
case 'delete':
|
|
1104
|
+
showDelete.value = true;
|
|
1105
|
+
break;
|
|
1106
|
+
case 'restore':
|
|
1107
|
+
if (targets.length > 0) await restoreSelection(targets);
|
|
1108
|
+
break;
|
|
1109
|
+
case 'new-folder':
|
|
1110
|
+
showNewFolder.value = true;
|
|
1111
|
+
break;
|
|
1112
|
+
case 'duplicate':
|
|
1113
|
+
if (targets[0]) await duplicate(targets[0]);
|
|
1114
|
+
break;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function cut() {
|
|
1119
|
+
if (selection.isEmpty.value) return;
|
|
1120
|
+
clipboard.value = { mode: 'cut', items: selection.nodes.value, sourcePath: currentPath.value };
|
|
1121
|
+
flashToast('Kesildi');
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function copyToClipboard() {
|
|
1125
|
+
if (selection.isEmpty.value) return;
|
|
1126
|
+
clipboard.value = { mode: 'copy', items: selection.nodes.value, sourcePath: currentPath.value };
|
|
1127
|
+
flashToast('Kopyalandı');
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async function paste() {
|
|
1131
|
+
const cb = clipboard.value;
|
|
1132
|
+
if (!cb.mode || cb.items.length === 0) return;
|
|
1133
|
+
try {
|
|
1134
|
+
const items = cb.items.map((n) => n.path); // already qualified (adapter://rel)
|
|
1135
|
+
const sourceDir = cb.sourcePath || '';
|
|
1136
|
+
const sameDir = cb.mode === 'cut' && sourceDir === currentPath.value;
|
|
1137
|
+
if (sameDir) {
|
|
1138
|
+
flashToast('Aynı klasöre kesilemez');
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (cb.mode === 'cut') {
|
|
1143
|
+
const { op } = await api.moveAsync(items, qualify(currentPath.value), qualify(sourceDir) || undefined);
|
|
1144
|
+
pendingOps.register(op);
|
|
1145
|
+
flashToast('Taşıma kuyruğa alındı');
|
|
1146
|
+
} else {
|
|
1147
|
+
const { op } = await api.copy(items, qualify(currentPath.value));
|
|
1148
|
+
pendingOps.register(op);
|
|
1149
|
+
flashToast('Kopyalama kuyruğa alındı');
|
|
1150
|
+
}
|
|
1151
|
+
clipboard.value = { mode: null, items: [], sourcePath: null };
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
emit('error', { message: (err as Error).message, context: { op: 'paste' } });
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
async function duplicate(n: FileNode) {
|
|
1158
|
+
try {
|
|
1159
|
+
const { op } = await api.copy([n.path], qualify(currentPath.value));
|
|
1160
|
+
pendingOps.register(op);
|
|
1161
|
+
} catch (err) {
|
|
1162
|
+
emit('error', { message: (err as Error).message, context: { op: 'duplicate' } });
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function downloadFile(n: FileNode) {
|
|
1167
|
+
// Keep `<adapter>://<rel>` so backend resolves the right storage
|
|
1168
|
+
// (stripping it would default to the first storage, which 404s for
|
|
1169
|
+
// any non-default storage like S3/SFTP/WebDAV).
|
|
1170
|
+
const url = api.downloadUrl(n.path);
|
|
1171
|
+
window.open(url, '_blank');
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// ------- Modals -------
|
|
1175
|
+
|
|
1176
|
+
async function submitNewFolder(name: string) {
|
|
1177
|
+
try {
|
|
1178
|
+
await api.newFolder(qualify(currentPath.value), name);
|
|
1179
|
+
showNewFolder.value = false;
|
|
1180
|
+
await load();
|
|
1181
|
+
} catch (err) {
|
|
1182
|
+
emit('error', { message: (err as Error).message, context: { op: 'newfolder' } });
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async function submitRename(name: string) {
|
|
1187
|
+
const target = renameTarget.value;
|
|
1188
|
+
if (!target) return;
|
|
1189
|
+
try {
|
|
1190
|
+
await api.rename(qualify(currentPath.value), target.path, name);
|
|
1191
|
+
showRename.value = false;
|
|
1192
|
+
renameTarget.value = null;
|
|
1193
|
+
await load();
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
emit('error', { message: (err as Error).message, context: { op: 'rename' } });
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
async function confirmDelete() {
|
|
1200
|
+
// In the trash view, items are already soft-deleted. Permanent removal is
|
|
1201
|
+
// admin-only (and the backend auto-purges after the retention window), so
|
|
1202
|
+
// offer Restore here rather than a delete that would just re-trash a path.
|
|
1203
|
+
if (trashMode.value) {
|
|
1204
|
+
showDelete.value = false;
|
|
1205
|
+
flashToast('Çöpteki öğeler saklama süresi sonunda otomatik silinir. Kalıcı silme yönetici panelinden yapılır.');
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
const items = selection.nodes.value.map((n) => n.path);
|
|
1209
|
+
if (items.length === 0) {
|
|
1210
|
+
showDelete.value = false;
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
try {
|
|
1214
|
+
if (api.endpoints.deleteAsync) {
|
|
1215
|
+
const { op } = await api.deleteAsync(items, qualify(currentPath.value));
|
|
1216
|
+
pendingOps.register(op);
|
|
1217
|
+
flashToast('Silme kuyruğa alındı');
|
|
1218
|
+
} else {
|
|
1219
|
+
await api.deleteItems(qualify(currentPath.value), items);
|
|
1220
|
+
await load();
|
|
1221
|
+
}
|
|
1222
|
+
showDelete.value = false;
|
|
1223
|
+
selection.clear();
|
|
1224
|
+
} catch (err) {
|
|
1225
|
+
emit('error', { message: (err as Error).message, context: { op: 'delete' } });
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function openShare(n: FileNode) {
|
|
1230
|
+
shareTarget.value = n;
|
|
1231
|
+
activeShare.value = null;
|
|
1232
|
+
showShare.value = true;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function openConvert(n: FileNode) {
|
|
1236
|
+
convertTarget.value = n;
|
|
1237
|
+
showConvert.value = true;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function onConvertDone(name: string) {
|
|
1241
|
+
flashToast(locale.value === 'en' ? `Converted → ${name}` : `Dönüştürüldü → ${name}`);
|
|
1242
|
+
void load();
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
async function submitShare(payload: {
|
|
1246
|
+
password: boolean;
|
|
1247
|
+
expires_at: string | null;
|
|
1248
|
+
max_downloads: number | null;
|
|
1249
|
+
}) {
|
|
1250
|
+
const target = shareTarget.value;
|
|
1251
|
+
if (!target) return;
|
|
1252
|
+
try {
|
|
1253
|
+
const { share } = await api.createShare({
|
|
1254
|
+
path: target.path, // qualified `<adapter>://<rel>`
|
|
1255
|
+
password: payload.password,
|
|
1256
|
+
expires_at: payload.expires_at,
|
|
1257
|
+
max_downloads: payload.max_downloads,
|
|
1258
|
+
});
|
|
1259
|
+
activeShare.value = share;
|
|
1260
|
+
emit('share-created', { path: target.path, url: share.url, pin: share.password_pin ?? null });
|
|
1261
|
+
} catch (err) {
|
|
1262
|
+
emit('error', { message: (err as Error).message, context: { op: 'share' } });
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function closeShare() {
|
|
1267
|
+
showShare.value = false;
|
|
1268
|
+
shareTarget.value = null;
|
|
1269
|
+
activeShare.value = null;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// ------- Upload -------
|
|
1273
|
+
|
|
1274
|
+
function triggerUpload() {
|
|
1275
|
+
if (!canWriteHere.value) {
|
|
1276
|
+
flashToast(locale.value === 'en' ? 'Read-only here' : 'Burada yazma yetkiniz yok');
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
fileInputEl.value?.click();
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function onFilePicked(ev: Event) {
|
|
1283
|
+
const input = ev.target as HTMLInputElement;
|
|
1284
|
+
const list = input.files ? Array.from(input.files) : [];
|
|
1285
|
+
input.value = '';
|
|
1286
|
+
void uploadFiles(list);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
async function uploadFiles(list: File[]) {
|
|
1290
|
+
if (list.length === 0) return;
|
|
1291
|
+
const canChunk = !!(api.endpoints.uploadInit && api.endpoints.uploadFinalize);
|
|
1292
|
+
for (const f of list) {
|
|
1293
|
+
// Chunked (S3 multipart) only when the endpoints exist AND the file is
|
|
1294
|
+
// large. If chunked isn't viable (storage has no multipart support —
|
|
1295
|
+
// e.g. the local driver — or init errors out) fall back to the legacy
|
|
1296
|
+
// single-POST upload, which works for any storage / file size.
|
|
1297
|
+
if (canChunk && f.size >= 10 * 1024 * 1024) {
|
|
1298
|
+
if (await chunkedUpload(f)) continue;
|
|
1299
|
+
}
|
|
1300
|
+
await legacyUpload(f);
|
|
1301
|
+
}
|
|
1302
|
+
await load();
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
async function legacyUpload(file: File) {
|
|
1306
|
+
// Register a progress row so the corner badge tracks the upload — large files
|
|
1307
|
+
// fall back here from the chunked path, and previously showed no progress at
|
|
1308
|
+
// all (the chunked placeholder was removed on init failure and the legacy
|
|
1309
|
+
// POST tracked nothing, so the badge vanished mid-upload).
|
|
1310
|
+
const id = crypto.randomUUID();
|
|
1311
|
+
const target = qualify(currentPath.value);
|
|
1312
|
+
uploadJobs.value = [
|
|
1313
|
+
...uploadJobs.value,
|
|
1314
|
+
{ id, file, path: target, totalBytes: file.size, uploadedBytes: 0, percent: 0, status: 'uploading', cancel() {} },
|
|
1315
|
+
];
|
|
1316
|
+
const patch = (p: Partial<UploadJob>) => {
|
|
1317
|
+
const idx = uploadJobs.value.findIndex((j) => j.id === id);
|
|
1318
|
+
if (idx === -1) return;
|
|
1319
|
+
const next = [...uploadJobs.value];
|
|
1320
|
+
next[idx] = { ...next[idx], ...p };
|
|
1321
|
+
uploadJobs.value = next;
|
|
1322
|
+
};
|
|
1323
|
+
try {
|
|
1324
|
+
await api.uploadMultipart(target, [file], (percent) => {
|
|
1325
|
+
patch({ percent, uploadedBytes: Math.round((percent / 100) * file.size) });
|
|
1326
|
+
emit('upload-progress', { uploadId: id, percent, done: percent >= 100 });
|
|
1327
|
+
});
|
|
1328
|
+
patch({ percent: 100, uploadedBytes: file.size, status: 'done' });
|
|
1329
|
+
emit('upload-progress', { uploadId: id, percent: 100, done: true });
|
|
1330
|
+
} catch (err) {
|
|
1331
|
+
patch({ status: 'error' });
|
|
1332
|
+
emit('error', {
|
|
1333
|
+
message: (err as Error).message,
|
|
1334
|
+
context: { op: 'upload', file: file.name },
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* Attempt an S3 multipart (chunked) upload. Returns `true` on success,
|
|
1341
|
+
* `false` when the storage can't do multipart (local driver, init 4xx/5xx)
|
|
1342
|
+
* so the caller can transparently fall back to the legacy single-POST
|
|
1343
|
+
* upload. On failure the progress placeholder is removed — no stuck error
|
|
1344
|
+
* row, no error toast, because the fallback path will report any real error.
|
|
1345
|
+
*/
|
|
1346
|
+
async function chunkedUpload(file: File): Promise<boolean> {
|
|
1347
|
+
// Register the progress row LAZILY — only once init succeeded and bytes are
|
|
1348
|
+
// actually moving. A doomed init (local driver / 4xx) then shows no badge at
|
|
1349
|
+
// all, so the legacy fallback's own badge is the only one the user sees (no
|
|
1350
|
+
// appear-then-vanish flicker).
|
|
1351
|
+
const id = crypto.randomUUID();
|
|
1352
|
+
let registered = false;
|
|
1353
|
+
try {
|
|
1354
|
+
await chunked.uploadFile({
|
|
1355
|
+
path: qualify(currentPath.value),
|
|
1356
|
+
file,
|
|
1357
|
+
onProgress: (job) => {
|
|
1358
|
+
if (!registered) {
|
|
1359
|
+
if (job.status !== 'uploading' && job.uploadedBytes <= 0) return;
|
|
1360
|
+
uploadJobs.value = [...uploadJobs.value, { ...job, id } as UploadJob];
|
|
1361
|
+
registered = true;
|
|
1362
|
+
} else {
|
|
1363
|
+
const idx = uploadJobs.value.findIndex((j) => j.id === id);
|
|
1364
|
+
if (idx !== -1) {
|
|
1365
|
+
const next = [...uploadJobs.value];
|
|
1366
|
+
next[idx] = { ...job, id } as UploadJob;
|
|
1367
|
+
uploadJobs.value = next;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
emit('upload-progress', {
|
|
1371
|
+
uploadId: job.uploadId ?? id,
|
|
1372
|
+
percent: job.percent,
|
|
1373
|
+
done: job.status === 'done',
|
|
1374
|
+
});
|
|
1375
|
+
},
|
|
1376
|
+
});
|
|
1377
|
+
return true;
|
|
1378
|
+
} catch {
|
|
1379
|
+
if (registered) uploadJobs.value = uploadJobs.value.filter((j) => j.id !== id);
|
|
1380
|
+
return false;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const dragCounter = ref(0);
|
|
1385
|
+
const dragOver = ref(false);
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* isExternalFileDrag — `true` only when the user is dragging files
|
|
1389
|
+
* INTO the page from the OS (file picker, finder, etc.). Filters out:
|
|
1390
|
+
* - internal row drags (FE_DND_MIME present)
|
|
1391
|
+
* - browser image drags (`<img draggable=true>` on this page or
|
|
1392
|
+
* across pages). HTML5 `Files` type is leaky — it appears when
|
|
1393
|
+
* dragging any image element even though no real file is moving;
|
|
1394
|
+
* `dataTransfer.items[*].kind === 'file'` is the canonical signal
|
|
1395
|
+
* for an actual OS file.
|
|
1396
|
+
*/
|
|
1397
|
+
function isExternalFileDrag(ev: DragEvent): boolean {
|
|
1398
|
+
const dt = ev.dataTransfer;
|
|
1399
|
+
if (!dt) return false;
|
|
1400
|
+
if (dt.types && dt.types.includes(FE_DND_MIME)) return false;
|
|
1401
|
+
// Some browsers expose `items` early in the drag, others only on
|
|
1402
|
+
// drop. When `items` is available we use it as the authoritative
|
|
1403
|
+
// signal — `kind === 'file'` means a real OS file. When unavailable
|
|
1404
|
+
// (Firefox during dragover sometimes returns 0 items), fall back to
|
|
1405
|
+
// the legacy `Files` type check.
|
|
1406
|
+
if (dt.items && dt.items.length > 0) {
|
|
1407
|
+
let hasFile = false;
|
|
1408
|
+
for (const it of Array.from(dt.items)) {
|
|
1409
|
+
if (it.kind === 'file') {
|
|
1410
|
+
hasFile = true;
|
|
1411
|
+
break;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
return hasFile;
|
|
1415
|
+
}
|
|
1416
|
+
return dt.types ? dt.types.includes('Files') : false;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function onDragEnter(ev: DragEvent) {
|
|
1420
|
+
if (!isExternalFileDrag(ev)) return;
|
|
1421
|
+
ev.preventDefault();
|
|
1422
|
+
dragCounter.value++;
|
|
1423
|
+
dragOver.value = true;
|
|
1424
|
+
}
|
|
1425
|
+
function onDragLeave() {
|
|
1426
|
+
dragCounter.value = Math.max(0, dragCounter.value - 1);
|
|
1427
|
+
if (dragCounter.value === 0) dragOver.value = false;
|
|
1428
|
+
}
|
|
1429
|
+
function onDragOver(ev: DragEvent) {
|
|
1430
|
+
if (isExternalFileDrag(ev)) {
|
|
1431
|
+
ev.preventDefault();
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
function onDropUpload(ev: DragEvent) {
|
|
1435
|
+
// Internal row drag — nothing to do here, the row drop handler
|
|
1436
|
+
// in GridView/ListView already resolved the move.
|
|
1437
|
+
if (ev.dataTransfer?.types.includes(FE_DND_MIME)) {
|
|
1438
|
+
dragCounter.value = 0;
|
|
1439
|
+
dragOver.value = false;
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
// Browser-internal image drag without real files — bail before
|
|
1443
|
+
// we accidentally synthesise an upload from a 0-length file list
|
|
1444
|
+
// (some browsers populate `files` with zero-byte placeholders).
|
|
1445
|
+
if (!isExternalFileDrag(ev)) {
|
|
1446
|
+
dragCounter.value = 0;
|
|
1447
|
+
dragOver.value = false;
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
ev.preventDefault();
|
|
1451
|
+
dragCounter.value = 0;
|
|
1452
|
+
dragOver.value = false;
|
|
1453
|
+
// RBAC: block drag-drop upload where the user can't write.
|
|
1454
|
+
if (!canWriteHere.value) {
|
|
1455
|
+
flashToast(locale.value === 'en' ? 'Read-only here' : 'Burada yazma yetkiniz yok');
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
const list = ev.dataTransfer?.files ? Array.from(ev.dataTransfer.files) : [];
|
|
1459
|
+
if (list.length === 0) return;
|
|
1460
|
+
void uploadFiles(list);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function onWindowDragOver(ev: DragEvent) {
|
|
1464
|
+
if (ev.dataTransfer?.types.includes('Files')) ev.preventDefault();
|
|
1465
|
+
}
|
|
1466
|
+
function onWindowDrop(ev: DragEvent) {
|
|
1467
|
+
const root = rootEl.value;
|
|
1468
|
+
const target = ev.target as Node | null;
|
|
1469
|
+
if (root && target && !root.contains(target)) {
|
|
1470
|
+
ev.preventDefault();
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
onMounted(() => {
|
|
1474
|
+
window.addEventListener('dragover', onWindowDragOver);
|
|
1475
|
+
window.addEventListener('drop', onWindowDrop);
|
|
1476
|
+
});
|
|
1477
|
+
onBeforeUnmount(() => {
|
|
1478
|
+
window.removeEventListener('dragover', onWindowDragOver);
|
|
1479
|
+
window.removeEventListener('drop', onWindowDrop);
|
|
1480
|
+
window.removeEventListener('hashchange', onHashChange);
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
const clippedPaths = computed<Set<string>>(() => {
|
|
1484
|
+
if (clipboard.value.mode !== 'cut') return new Set();
|
|
1485
|
+
return new Set(clipboard.value.items.map((n) => n.path));
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
// --------------------------------------------------------------------
|
|
1489
|
+
// Item drag&drop move
|
|
1490
|
+
// --------------------------------------------------------------------
|
|
1491
|
+
|
|
1492
|
+
const FE_DND_MIME = 'application/x-brf-files';
|
|
1493
|
+
|
|
1494
|
+
function onItemDragStart(node: FileNode, ev: DragEvent) {
|
|
1495
|
+
if (!ev.dataTransfer) return;
|
|
1496
|
+
if (node.basename === '.trash') {
|
|
1497
|
+
ev.preventDefault();
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
if (!selection.has(node.path)) {
|
|
1501
|
+
selection.click(node.path);
|
|
1502
|
+
}
|
|
1503
|
+
const items = selection.nodes.value
|
|
1504
|
+
.filter((n) => !clippedPaths.value.has(n.path))
|
|
1505
|
+
.filter((n) => n.basename !== '.trash')
|
|
1506
|
+
.map((n) => ({ path: n.path, basename: n.basename, type: n.type })); // qualified
|
|
1507
|
+
ev.dataTransfer.setData(FE_DND_MIME, JSON.stringify(items));
|
|
1508
|
+
ev.dataTransfer.setData('text/plain', items.map((i) => i.path).join('\n'));
|
|
1509
|
+
ev.dataTransfer.effectAllowed = 'move';
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
async function moveSourcesAsync(sources: string[], targetDir: string, opLabel: string): Promise<void> {
|
|
1513
|
+
try {
|
|
1514
|
+
if (api.endpoints.moveAsync) {
|
|
1515
|
+
const { op } = await api.moveAsync(sources, targetDir, qualify(currentPath.value));
|
|
1516
|
+
pendingOps.register(op);
|
|
1517
|
+
flashToast('Taşıma kuyruğa alındı');
|
|
1518
|
+
} else {
|
|
1519
|
+
await api.move(qualify(currentPath.value), sources, targetDir);
|
|
1520
|
+
await load();
|
|
1521
|
+
}
|
|
1522
|
+
selection.clear();
|
|
1523
|
+
} catch (err) {
|
|
1524
|
+
emit('error', { message: (err as Error).message, context: { op: opLabel, targetDir } });
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
async function onItemDropInto(target: FileNode, ev: DragEvent) {
|
|
1529
|
+
if (target.type !== 'dir') return;
|
|
1530
|
+
const raw = ev.dataTransfer?.getData(FE_DND_MIME);
|
|
1531
|
+
if (!raw) return;
|
|
1532
|
+
let items: Array<{ path: string }> = [];
|
|
1533
|
+
try {
|
|
1534
|
+
items = JSON.parse(raw);
|
|
1535
|
+
} catch {
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
if (items.length === 0) return;
|
|
1539
|
+
|
|
1540
|
+
const targetDir = target.path; // qualified
|
|
1541
|
+
const sources = items
|
|
1542
|
+
.map((i) => i.path)
|
|
1543
|
+
.filter((p) => p && p !== targetDir && !targetDir.startsWith(p + '/'));
|
|
1544
|
+
if (sources.length === 0) {
|
|
1545
|
+
flashToast('Aynı klasöre taşınamaz');
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
await moveSourcesAsync(sources, targetDir, 'move-dnd');
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
async function onCrumbDropInto(adapterPath: string, ev: DragEvent) {
|
|
1552
|
+
const raw = ev.dataTransfer?.getData(FE_DND_MIME);
|
|
1553
|
+
if (!raw) return;
|
|
1554
|
+
let items: Array<{ path: string }> = [];
|
|
1555
|
+
try {
|
|
1556
|
+
items = JSON.parse(raw);
|
|
1557
|
+
} catch {
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
if (items.length === 0) return;
|
|
1561
|
+
|
|
1562
|
+
const targetDir = adapterPath; // already qualified by breadcrumb
|
|
1563
|
+
const sources = items
|
|
1564
|
+
.map((i) => i.path)
|
|
1565
|
+
.filter((p) => p && p !== targetDir && !targetDir.startsWith(p + '/'));
|
|
1566
|
+
if (sources.length === 0) return;
|
|
1567
|
+
await moveSourcesAsync(sources, targetDir, 'move-dnd-crumb');
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function onCancelUpload(job: UploadJob) {
|
|
1571
|
+
job.cancel();
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function onDismissUpload(job: UploadJob) {
|
|
1575
|
+
uploadJobs.value = uploadJobs.value.filter((j) => j.id !== job.id);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// ------- Breadcrumb -------
|
|
1579
|
+
|
|
1580
|
+
function onNavigate(adapterPath: string) {
|
|
1581
|
+
// Multi-storage emits empty string for the global "/" crumb. The
|
|
1582
|
+
// load() function recognises that as the storage-list virtual root.
|
|
1583
|
+
if (multiStorageRoot.value && !adapterPath) {
|
|
1584
|
+
void load('');
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
if (multiStorageRoot.value) {
|
|
1588
|
+
void load(wireToVirtual(adapterPath));
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
void load(stripAdapter(adapterPath));
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
async function onCopyPath(adapterPath: string) {
|
|
1595
|
+
try {
|
|
1596
|
+
await navigator.clipboard.writeText(adapterPath);
|
|
1597
|
+
flashToast(t('breadcrumb.copy_path'));
|
|
1598
|
+
} catch {
|
|
1599
|
+
/* no-op */
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// Sync auth-headers builder for PreviewModal — fetches against the
|
|
1604
|
+
// OnlyOffice config endpoint and the saveText endpoint need real
|
|
1605
|
+
// headers, not promises. Function-token bearers will use the cached
|
|
1606
|
+
// token; first-call resolution happens via the async path elsewhere.
|
|
1607
|
+
function buildAuthHeaders(extra: Record<string, string> = {}) {
|
|
1608
|
+
return api.authHeadersSync({ ...extra });
|
|
1609
|
+
}
|
|
1610
|
+
</script>
|
|
1611
|
+
|
|
1612
|
+
<template>
|
|
1613
|
+
<div
|
|
1614
|
+
ref="rootEl"
|
|
1615
|
+
class="fe"
|
|
1616
|
+
:class="{
|
|
1617
|
+
'fe--theme-light': config.theme === 'light',
|
|
1618
|
+
'fe--theme-dark': config.theme === 'dark',
|
|
1619
|
+
'fe--is-dragover': dragOver,
|
|
1620
|
+
}"
|
|
1621
|
+
tabindex="-1"
|
|
1622
|
+
@dragenter="onDragEnter"
|
|
1623
|
+
@dragover="onDragOver"
|
|
1624
|
+
@dragleave="onDragLeave"
|
|
1625
|
+
@drop="onDropUpload"
|
|
1626
|
+
@contextmenu="onContextCanvas"
|
|
1627
|
+
>
|
|
1628
|
+
<Toolbar
|
|
1629
|
+
ref="toolbarRef"
|
|
1630
|
+
:view-mode="viewMode"
|
|
1631
|
+
:search-query="searchQuery"
|
|
1632
|
+
:trash-active="trashActive"
|
|
1633
|
+
:actions="toolbarActions"
|
|
1634
|
+
:selection-mode="selectionMode"
|
|
1635
|
+
:paste-enabled="!!clipboard.mode"
|
|
1636
|
+
:convert-enabled="!!effectiveConvertUrl"
|
|
1637
|
+
:can-go-up="canGoUp"
|
|
1638
|
+
:at-virtual-root="atVirtualRoot"
|
|
1639
|
+
:can-write="canWriteHere"
|
|
1640
|
+
:locale="locale"
|
|
1641
|
+
@update:view-mode="viewMode = $event"
|
|
1642
|
+
@update:search-query="searchQuery = $event"
|
|
1643
|
+
@new-folder="showNewFolder = true"
|
|
1644
|
+
@upload="triggerUpload"
|
|
1645
|
+
@refresh="() => load()"
|
|
1646
|
+
@go-up="goUp"
|
|
1647
|
+
@action="onToolbarAction"
|
|
1648
|
+
@open-recents="showRecents = true"
|
|
1649
|
+
/>
|
|
1650
|
+
|
|
1651
|
+
<Breadcrumb
|
|
1652
|
+
:dirname="dirname"
|
|
1653
|
+
:adapter="adapter"
|
|
1654
|
+
:root-label="adapter"
|
|
1655
|
+
:locale="locale"
|
|
1656
|
+
:multi-storage-root="multiStorageRoot"
|
|
1657
|
+
:root-path="rootPathProp"
|
|
1658
|
+
@navigate="onNavigate"
|
|
1659
|
+
@copy-path="onCopyPath"
|
|
1660
|
+
@crumb-context="onCrumbContext"
|
|
1661
|
+
@crumb-drop="onCrumbDropInto"
|
|
1662
|
+
/>
|
|
1663
|
+
|
|
1664
|
+
<div class="fe__body" @click.self="selection.clear()">
|
|
1665
|
+
<!-- Initial load: show a spinner rather than an empty/"no files" flash.
|
|
1666
|
+
Only when there's nothing yet — navigation keeps the current list. -->
|
|
1667
|
+
<div v-if="loading && files.length === 0" class="fe__loading">
|
|
1668
|
+
<span class="fe__spinner" aria-hidden="true"></span>
|
|
1669
|
+
<p class="fe__loading-text">{{ t('loading') }}</p>
|
|
1670
|
+
</div>
|
|
1671
|
+
<ListView
|
|
1672
|
+
v-else-if="viewMode === 'list'"
|
|
1673
|
+
:files="files"
|
|
1674
|
+
:selected="selection.selected.value"
|
|
1675
|
+
:clipped="clippedPaths"
|
|
1676
|
+
:show-parent-path="!!searchQuery"
|
|
1677
|
+
:locale="locale"
|
|
1678
|
+
:loading="loading"
|
|
1679
|
+
:starred-ids="starredIds"
|
|
1680
|
+
:api-base="props.config.apiBase ?? ''"
|
|
1681
|
+
:auth-headers="() => buildAuthHeaders()"
|
|
1682
|
+
@click-row="(n, m) => selection.click(n.path, m)"
|
|
1683
|
+
@dbl-row="openNode"
|
|
1684
|
+
@context-row="onContextTarget"
|
|
1685
|
+
@item-drag-start="onItemDragStart"
|
|
1686
|
+
@item-drop-into="onItemDropInto"
|
|
1687
|
+
@star-change="onStarChange"
|
|
1688
|
+
/>
|
|
1689
|
+
<GridView
|
|
1690
|
+
v-else
|
|
1691
|
+
:files="files"
|
|
1692
|
+
:selected="selection.selected.value"
|
|
1693
|
+
:clipped="clippedPaths"
|
|
1694
|
+
:show-parent-path="!!searchQuery"
|
|
1695
|
+
:locale="locale"
|
|
1696
|
+
:loading="loading"
|
|
1697
|
+
@click-card="(n, m) => selection.click(n.path, m)"
|
|
1698
|
+
@dbl-card="openNode"
|
|
1699
|
+
@context-card="onContextTarget"
|
|
1700
|
+
@item-drag-start="onItemDragStart"
|
|
1701
|
+
@item-drop-into="onItemDropInto"
|
|
1702
|
+
/>
|
|
1703
|
+
</div>
|
|
1704
|
+
|
|
1705
|
+
<div v-if="dragOver" class="fe__dragover">
|
|
1706
|
+
<div class="fe__dragover-card">
|
|
1707
|
+
<span class="fe-icon">⬆</span>
|
|
1708
|
+
<p>Dosyaları buraya bırak</p>
|
|
1709
|
+
</div>
|
|
1710
|
+
</div>
|
|
1711
|
+
|
|
1712
|
+
<UploadProgress
|
|
1713
|
+
:jobs="uploadJobs"
|
|
1714
|
+
:locale="locale"
|
|
1715
|
+
@cancel="onCancelUpload"
|
|
1716
|
+
@dismiss="onDismissUpload"
|
|
1717
|
+
/>
|
|
1718
|
+
|
|
1719
|
+
<PendingOpsTray
|
|
1720
|
+
:ops="pendingOps.ops.value"
|
|
1721
|
+
:locale="locale"
|
|
1722
|
+
@dismiss="(id) => pendingOps.dismiss(id)"
|
|
1723
|
+
/>
|
|
1724
|
+
|
|
1725
|
+
<ContextMenu
|
|
1726
|
+
ref="ctxRef"
|
|
1727
|
+
:locale="locale"
|
|
1728
|
+
:theme="config.theme || 'auto'"
|
|
1729
|
+
:actions="contextActions"
|
|
1730
|
+
@select="onContextAction"
|
|
1731
|
+
/>
|
|
1732
|
+
|
|
1733
|
+
<NewFolderModal
|
|
1734
|
+
:open="showNewFolder"
|
|
1735
|
+
:locale="locale"
|
|
1736
|
+
@close="showNewFolder = false"
|
|
1737
|
+
@submit="submitNewFolder"
|
|
1738
|
+
/>
|
|
1739
|
+
<RenameModal
|
|
1740
|
+
:open="showRename"
|
|
1741
|
+
:locale="locale"
|
|
1742
|
+
:current-name="renameTarget?.basename || ''"
|
|
1743
|
+
@close="showRename = false"
|
|
1744
|
+
@submit="submitRename"
|
|
1745
|
+
/>
|
|
1746
|
+
<DeleteConfirmModal
|
|
1747
|
+
:open="showDelete"
|
|
1748
|
+
:locale="locale"
|
|
1749
|
+
:count="selection.size.value"
|
|
1750
|
+
@close="showDelete = false"
|
|
1751
|
+
@confirm="confirmDelete"
|
|
1752
|
+
/>
|
|
1753
|
+
<ShareModal
|
|
1754
|
+
:open="showShare"
|
|
1755
|
+
:locale="locale"
|
|
1756
|
+
:share="activeShare"
|
|
1757
|
+
@close="closeShare"
|
|
1758
|
+
@submit="submitShare"
|
|
1759
|
+
@toast="flashToast"
|
|
1760
|
+
/>
|
|
1761
|
+
<PreviewModal
|
|
1762
|
+
:open="showPreview"
|
|
1763
|
+
:locale="locale"
|
|
1764
|
+
:file="previewTarget"
|
|
1765
|
+
:theme="config.theme || 'auto'"
|
|
1766
|
+
:preview-url="(p) => api.previewUrl(p)"
|
|
1767
|
+
:download-url="(p) => api.downloadUrl(p)"
|
|
1768
|
+
:only-office-base="effectiveOnlyOfficeBase"
|
|
1769
|
+
:only-office-config-endpoint="effectiveOnlyOfficeConfigEndpoint"
|
|
1770
|
+
:save-text-endpoint="api.endpoints.saveText || null"
|
|
1771
|
+
:open-mode="previewMode"
|
|
1772
|
+
:auth-headers="() => buildAuthHeaders({ 'Content-Type': 'application/json' })"
|
|
1773
|
+
:auth-credentials="api.credentialsMode()"
|
|
1774
|
+
:drawio-url="effectiveDrawioUrl"
|
|
1775
|
+
:pdf-worker-url="props.config.pdfWorkerUrl || null"
|
|
1776
|
+
:pdf-save-url="props.config.pdfSaveUrl || null"
|
|
1777
|
+
:viewer-base-url="props.config.viewerBaseUrl || null"
|
|
1778
|
+
@close="showPreview = false"
|
|
1779
|
+
/>
|
|
1780
|
+
<ConvertModal
|
|
1781
|
+
v-if="showConvert && convertTarget && effectiveConvertUrl"
|
|
1782
|
+
:convert-url="effectiveConvertUrl"
|
|
1783
|
+
:file-name="convertTarget?.basename || convertTarget?.path || ''"
|
|
1784
|
+
:fetch-bytes="() => api.fetchArrayBuffer(convertTarget?.path ?? '')"
|
|
1785
|
+
:upload="(f) => api.uploadMultipart(qualify(currentPath), [f]).then(() => {})"
|
|
1786
|
+
@close="showConvert = false"
|
|
1787
|
+
@done="onConvertDone"
|
|
1788
|
+
/>
|
|
1789
|
+
<PermissionsModal
|
|
1790
|
+
v-if="showPerm && permTarget"
|
|
1791
|
+
:api="api"
|
|
1792
|
+
:path="permTarget.path"
|
|
1793
|
+
:is-dir="permTarget.type === 'dir'"
|
|
1794
|
+
:size="typeof permTarget.size === 'number' ? permTarget.size : undefined"
|
|
1795
|
+
:locale="locale === 'en' ? 'en' : 'tr'"
|
|
1796
|
+
@close="showPerm = false"
|
|
1797
|
+
/>
|
|
1798
|
+
|
|
1799
|
+
<!-- Recently-opened tray. Anchored to the toolbar trigger via fixed
|
|
1800
|
+
position; click the backdrop or any entry to dismiss.
|
|
1801
|
+
`.fe` + theme class keeps the dark/light cascade matching the
|
|
1802
|
+
host shell — without them the popup floats outside the
|
|
1803
|
+
FileExplorer root and falls back to :root light defaults. -->
|
|
1804
|
+
<transition name="fe-modal">
|
|
1805
|
+
<div
|
|
1806
|
+
v-if="showRecents"
|
|
1807
|
+
class="fe fe-modal__backdrop fe-recents__backdrop"
|
|
1808
|
+
:class="{
|
|
1809
|
+
'fe--theme-light': config.theme === 'light',
|
|
1810
|
+
'fe--theme-dark': config.theme === 'dark',
|
|
1811
|
+
}"
|
|
1812
|
+
@click="showRecents = false"
|
|
1813
|
+
>
|
|
1814
|
+
<div class="fe-recents__panel" @click.stop>
|
|
1815
|
+
<div class="fe-recents__header">
|
|
1816
|
+
<strong>{{ locale === 'en' ? 'Recently opened' : 'Son açılanlar' }}</strong>
|
|
1817
|
+
<button class="fe-recents__close" aria-label="Close" @click="showRecents = false">×</button>
|
|
1818
|
+
</div>
|
|
1819
|
+
<RecentlyOpened
|
|
1820
|
+
:api-base="props.config.apiBase ?? ''"
|
|
1821
|
+
:auth-headers="() => buildAuthHeaders()"
|
|
1822
|
+
:limit="20"
|
|
1823
|
+
:refresh-key="recentRefreshKey"
|
|
1824
|
+
@open="onRecentOpen"
|
|
1825
|
+
@error="(msg: string) => emit('error', { message: msg, context: { op: 'recents' } })"
|
|
1826
|
+
/>
|
|
1827
|
+
</div>
|
|
1828
|
+
</div>
|
|
1829
|
+
</transition>
|
|
1830
|
+
|
|
1831
|
+
<!-- Tag editor — opened from the context menu via Etiketler. -->
|
|
1832
|
+
<transition name="fe-modal">
|
|
1833
|
+
<div
|
|
1834
|
+
v-if="showTagPicker && tagPickerNode && typeof tagPickerNode.id === 'number'"
|
|
1835
|
+
class="fe-modal__backdrop"
|
|
1836
|
+
@click="showTagPicker = false"
|
|
1837
|
+
>
|
|
1838
|
+
<div class="fe-modal__card fe-modal__card--md" @click.stop>
|
|
1839
|
+
<header class="fe-modal__head">
|
|
1840
|
+
<h2 class="fe-modal__title">
|
|
1841
|
+
{{ locale === 'en' ? 'Tags' : 'Etiketler' }} — {{ tagPickerNode.basename }}
|
|
1842
|
+
</h2>
|
|
1843
|
+
<button class="fe-modal__close" aria-label="Close" @click="showTagPicker = false">×</button>
|
|
1844
|
+
</header>
|
|
1845
|
+
<div class="fe-modal__body">
|
|
1846
|
+
<TagPicker
|
|
1847
|
+
:node-id="tagPickerNode.id"
|
|
1848
|
+
:api-base="props.config.apiBase ?? ''"
|
|
1849
|
+
:auth-headers="() => buildAuthHeaders()"
|
|
1850
|
+
@error="(msg: string) => emit('error', { message: msg, context: { op: 'tags' } })"
|
|
1851
|
+
/>
|
|
1852
|
+
</div>
|
|
1853
|
+
</div>
|
|
1854
|
+
</div>
|
|
1855
|
+
</transition>
|
|
1856
|
+
|
|
1857
|
+
<input
|
|
1858
|
+
ref="fileInputEl"
|
|
1859
|
+
type="file"
|
|
1860
|
+
multiple
|
|
1861
|
+
class="fe__file-input"
|
|
1862
|
+
@change="onFilePicked"
|
|
1863
|
+
/>
|
|
1864
|
+
|
|
1865
|
+
<transition name="fe-toast">
|
|
1866
|
+
<div v-if="toast" class="fe-toast">{{ toast }}</div>
|
|
1867
|
+
</transition>
|
|
1868
|
+
</div>
|
|
1869
|
+
</template>
|
|
1870
|
+
|
|
1871
|
+
<style src="./styles/variables.css"></style>
|
|
1872
|
+
<style src="./styles/base.css"></style>
|