@brftech/filex-core 0.1.83 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ArchiveViewer-BRASXNIS.js → ArchiveViewer-DCZ1FZYV.js} +2 -2
- package/dist/{ArchiveViewer-BRASXNIS.js.map → ArchiveViewer-DCZ1FZYV.js.map} +1 -1
- package/dist/{CsvViewer-CgiLiHWw.js → CsvViewer-DMvnp82x.js} +2 -2
- package/dist/{CsvViewer-CgiLiHWw.js.map → CsvViewer-DMvnp82x.js.map} +1 -1
- package/dist/{DrawioViewer-D2I_uEng.js → DrawioViewer-B5Io0yTO.js} +2 -2
- package/dist/{DrawioViewer-D2I_uEng.js.map → DrawioViewer-B5Io0yTO.js.map} +1 -1
- package/dist/{EpubViewer-6nVPRR6R.js → EpubViewer-z6RZd3K8.js} +2 -2
- package/dist/{EpubViewer-6nVPRR6R.js.map → EpubViewer-z6RZd3K8.js.map} +1 -1
- package/dist/{IpynbViewer-B7p9hxFx.js → IpynbViewer-DfL4EauH.js} +2 -2
- package/dist/{IpynbViewer-B7p9hxFx.js.map → IpynbViewer-DfL4EauH.js.map} +1 -1
- package/dist/{MermaidViewer-O9vGvp4y.js → MermaidViewer-QNl_o7V-.js} +2 -2
- package/dist/{MermaidViewer-O9vGvp4y.js.map → MermaidViewer-QNl_o7V-.js.map} +1 -1
- package/dist/{PsdViewer-C6EaEAxF.js → PsdViewer-Btmpr0Fz.js} +2 -2
- package/dist/{PsdViewer-C6EaEAxF.js.map → PsdViewer-Btmpr0Fz.js.map} +1 -1
- package/dist/{TiffViewer-Dod4uIXI.js → TiffViewer-CMLpwds5.js} +2 -2
- package/dist/{TiffViewer-Dod4uIXI.js.map → TiffViewer-CMLpwds5.js.map} +1 -1
- package/dist/{Viewer3D-B3kLZrwO.js → Viewer3D-gf3cb2gv.js} +2 -2
- package/dist/{Viewer3D-B3kLZrwO.js.map → Viewer3D-gf3cb2gv.js.map} +1 -1
- package/dist/filex-core.js +17 -15
- package/dist/filex-core.umd.cjs +38 -38
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-D49iw00E.js +7119 -0
- package/dist/index-D49iw00E.js.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +440 -32
- package/src/components/Breadcrumb.vue +100 -5
- package/src/components/CommandPalette.vue +533 -0
- package/src/components/GridView.vue +8 -13
- package/src/components/ListView.vue +199 -21
- package/src/components/ShortcutsHelp.vue +59 -0
- package/src/components/Toolbar.vue +65 -1
- package/src/composables/useFileApi.ts +45 -0
- package/src/composables/useKeyboardShortcuts.ts +38 -0
- package/src/composables/useRealtime.ts +9 -1
- package/src/index.ts +4 -0
- package/src/lib/fileIcons.ts +127 -0
- package/src/lib/snippet.ts +43 -0
- package/src/locales/en.ts +62 -0
- package/src/locales/tr.ts +62 -0
- package/src/styles/base.css +650 -0
- package/src/styles/variables.css +70 -0
- package/dist/index-DL6_eaM3.js +0 -5888
- package/dist/index-DL6_eaM3.js.map +0 -1
package/src/FileExplorer.vue
CHANGED
|
@@ -24,7 +24,7 @@ import type {
|
|
|
24
24
|
Capabilities,
|
|
25
25
|
} from './types/FileNode';
|
|
26
26
|
import { isExternalUsable } from './types/FileNode';
|
|
27
|
-
import { useFileApi } from './composables/useFileApi';
|
|
27
|
+
import { useFileApi, type GlobalSearchHit } from './composables/useFileApi';
|
|
28
28
|
import { useUploadChunked, type UploadJob } from './composables/useUploadChunked';
|
|
29
29
|
import { useSelection } from './composables/useSelection';
|
|
30
30
|
import { useKeyboardShortcuts } from './composables/useKeyboardShortcuts';
|
|
@@ -45,6 +45,10 @@ import GridView from './components/GridView.vue';
|
|
|
45
45
|
import ContextMenu, { type ContextAction } from './components/ContextMenu.vue';
|
|
46
46
|
import UploadProgress from './components/UploadProgress.vue';
|
|
47
47
|
import PendingOpsTray from './components/PendingOpsTray.vue';
|
|
48
|
+
/* cila:c wiring */
|
|
49
|
+
import CommandPalette from './components/CommandPalette.vue';
|
|
50
|
+
import ShortcutsHelp from './components/ShortcutsHelp.vue';
|
|
51
|
+
/* /cila:c wiring */
|
|
48
52
|
|
|
49
53
|
import NewFolderModal from './modals/NewFolderModal.vue';
|
|
50
54
|
import RenameModal from './modals/RenameModal.vue';
|
|
@@ -79,6 +83,12 @@ const emit = defineEmits<{
|
|
|
79
83
|
|
|
80
84
|
const api = useFileApi(props.config);
|
|
81
85
|
|
|
86
|
+
// Locale up-front: the pendingOps onSettled callback below (and the undo-toast
|
|
87
|
+
// helpers) need `t()` at runtime, so the catalogue must be constructed before
|
|
88
|
+
// they are wired. Depends only on props — safe this early.
|
|
89
|
+
const locale = computed(() => props.config.locale || 'tr');
|
|
90
|
+
const { t } = useLocale(locale);
|
|
91
|
+
|
|
82
92
|
// Live collaboration (WebSocket file-change events + presence), bundled into the
|
|
83
93
|
// core so every consumer — the native panel AND the embedded webcomponent —
|
|
84
94
|
// gets it. Auth is a short-lived ticket fetched through the same API (works
|
|
@@ -86,6 +96,10 @@ const api = useFileApi(props.config);
|
|
|
86
96
|
// socket is available.
|
|
87
97
|
const realtime = useRealtime(api, { reload: () => load() });
|
|
88
98
|
const presenceUsers = realtime.presenceUsers;
|
|
99
|
+
// True while the live socket is unavailable and the explorer runs on the
|
|
100
|
+
// polling fallback — drives the small "no live connection" badge. Healthy
|
|
101
|
+
// connections show nothing.
|
|
102
|
+
const realtimeDegraded = realtime.degraded;
|
|
89
103
|
function realtimeRoom(vp: string): string | null {
|
|
90
104
|
const p = (vp || '').replace(/^\/+|\/+$/g, '');
|
|
91
105
|
if (p === '.trash' || p.startsWith('.trash/')) return null;
|
|
@@ -112,10 +126,21 @@ onBeforeUnmount(() => realtime.stop());
|
|
|
112
126
|
const thumbs = useThumbs(props.config.apiBase, api);
|
|
113
127
|
|
|
114
128
|
const chunked = useUploadChunked(props.config, api);
|
|
129
|
+
|
|
130
|
+
// Undo registry for async pending ops: when a cleanly-invertible operation
|
|
131
|
+
// (move → reverse move, trash-delete → restore) is queued, its inverse is
|
|
132
|
+
// registered under the op id; once the op settles OK the toast grows a
|
|
133
|
+
// "Geri Al" action. Ops without an entry keep the plain settled toast.
|
|
134
|
+
const opUndo = new Map<number, { message: string; fn: () => Promise<void> }>();
|
|
135
|
+
|
|
115
136
|
const pendingOps = usePendingOps(props.config, api, {
|
|
116
137
|
onSettled: (op: PendingOp) => {
|
|
138
|
+
const undo = opUndo.get(op.id);
|
|
139
|
+
opUndo.delete(op.id);
|
|
117
140
|
if (op.status === 'error') {
|
|
118
141
|
flashToast(op.error_message || 'İşlem başarısız');
|
|
142
|
+
} else if (undo) {
|
|
143
|
+
undoToast(`${undo.message} (${op.progress_total})`, undo.fn);
|
|
119
144
|
} else {
|
|
120
145
|
const verb =
|
|
121
146
|
op.op_type === 'copy'
|
|
@@ -149,6 +174,16 @@ const dirPerm = ref<string>('');
|
|
|
149
174
|
// back 404 (folder doesn't exist) or 403 (RBAC-hidden — rendered identically
|
|
150
175
|
// on purpose so a denied folder doesn't reveal that it exists). '' = none.
|
|
151
176
|
const notFoundPath = ref<string>('');
|
|
177
|
+
// Listing failure that is NOT a dead link (network error, 5xx): remembered so
|
|
178
|
+
// the body can render a retryable error state instead of a misleading "this
|
|
179
|
+
// folder is empty". Only shown when no listing is visible — a failed
|
|
180
|
+
// navigation away from a healthy listing keeps the current list + toast,
|
|
181
|
+
// exactly as before.
|
|
182
|
+
const loadError = ref<string>('');
|
|
183
|
+
let loadErrorPath: string | undefined;
|
|
184
|
+
function retryLoad() {
|
|
185
|
+
void load(loadErrorPath);
|
|
186
|
+
}
|
|
152
187
|
|
|
153
188
|
const VIEW_MODE_KEY = 'brf-file-explorer:view-mode';
|
|
154
189
|
const viewMode = customRef<ViewMode>((track, trigger) => {
|
|
@@ -178,6 +213,9 @@ const viewMode = customRef<ViewMode>((track, trigger) => {
|
|
|
178
213
|
},
|
|
179
214
|
};
|
|
180
215
|
});
|
|
216
|
+
/* cila:a density — Toolbar owns the persisted preference (filex.density);
|
|
217
|
+
mirrored here only so the root `.fe` can carry fe--density-compact. */
|
|
218
|
+
const density = ref<'comfortable' | 'compact'>('comfortable');
|
|
181
219
|
const searchQuery = ref('');
|
|
182
220
|
// trashMode — true while viewing the filex trash (soft-deleted nodes from the
|
|
183
221
|
// backend trash endpoint), entered by opening the virtual `.trash` row and
|
|
@@ -189,7 +227,6 @@ const trashMode = ref(false);
|
|
|
189
227
|
// global root). Set in loadTrash().
|
|
190
228
|
const trashOrigin = ref<string>('');
|
|
191
229
|
const trashActive = computed(() => trashMode.value);
|
|
192
|
-
const locale = computed(() => props.config.locale || 'tr');
|
|
193
230
|
|
|
194
231
|
// canGoUp/goUp — toolbar's "↑ Up one level" button. In single-storage
|
|
195
232
|
// mode "" means the storage root; in multi-storage mode "" means
|
|
@@ -224,8 +261,6 @@ function goUp() {
|
|
|
224
261
|
void load(parent);
|
|
225
262
|
}
|
|
226
263
|
|
|
227
|
-
const { t } = useLocale(locale);
|
|
228
|
-
|
|
229
264
|
const selection = useSelection(() => files.value);
|
|
230
265
|
watch(
|
|
231
266
|
() => [...selection.selected.value],
|
|
@@ -396,19 +431,57 @@ function selPerm(sel: FileNode[]): string {
|
|
|
396
431
|
// Can the current user write into the directory being viewed? Gates the
|
|
397
432
|
// toolbar New Folder / Upload / Paste + drag-drop upload.
|
|
398
433
|
const canWriteHere = computed(() => permCanEdit(dirPerm.value));
|
|
434
|
+
// Empty-state affordances: the "drop files here" hint + upload button only
|
|
435
|
+
// make sense in a real writable folder (not the virtual drives root, not the
|
|
436
|
+
// trash view).
|
|
437
|
+
const emptyCanUpload = computed(
|
|
438
|
+
() => canWriteHere.value && !atVirtualRoot.value && !trashMode.value,
|
|
439
|
+
);
|
|
399
440
|
|
|
400
441
|
// Context menu
|
|
401
442
|
const ctxRef = ref<InstanceType<typeof ContextMenu> | null>(null);
|
|
402
443
|
const rootEl = ref<HTMLElement | null>(null);
|
|
403
444
|
const toolbarRef = ref<InstanceType<typeof Toolbar> | null>(null);
|
|
404
445
|
|
|
405
|
-
// Toast (tiny, no lib)
|
|
406
|
-
|
|
446
|
+
// Toast (tiny, no lib). Evolved into a snackbar: plain messages keep the old
|
|
447
|
+
// 2.5s auto-hide; messages carrying an action ("Geri Al") stay 8s and can be
|
|
448
|
+
// dismissed by click or Esc.
|
|
449
|
+
interface ToastState {
|
|
450
|
+
message: string;
|
|
451
|
+
actionLabel?: string;
|
|
452
|
+
action?: () => void | Promise<void>;
|
|
453
|
+
}
|
|
454
|
+
const toast = ref<ToastState | null>(null);
|
|
407
455
|
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
|
408
|
-
function
|
|
409
|
-
toast.value =
|
|
456
|
+
function showToast(state: ToastState, ms: number) {
|
|
457
|
+
toast.value = state;
|
|
410
458
|
if (toastTimer) clearTimeout(toastTimer);
|
|
411
|
-
toastTimer = setTimeout(() => (toast.value = null),
|
|
459
|
+
toastTimer = setTimeout(() => (toast.value = null), ms);
|
|
460
|
+
}
|
|
461
|
+
function flashToast(msg: string) {
|
|
462
|
+
showToast({ message: msg }, 2500);
|
|
463
|
+
}
|
|
464
|
+
function undoToast(message: string, undo: () => Promise<void>) {
|
|
465
|
+
showToast({ message, actionLabel: t('toast.undo'), action: undo }, 8000);
|
|
466
|
+
}
|
|
467
|
+
function dismissToast() {
|
|
468
|
+
if (toastTimer) {
|
|
469
|
+
clearTimeout(toastTimer);
|
|
470
|
+
toastTimer = undefined;
|
|
471
|
+
}
|
|
472
|
+
toast.value = null;
|
|
473
|
+
}
|
|
474
|
+
async function runToastAction() {
|
|
475
|
+
const act = toast.value?.action;
|
|
476
|
+
dismissToast();
|
|
477
|
+
if (!act) return;
|
|
478
|
+
try {
|
|
479
|
+
await act();
|
|
480
|
+
flashToast(t('toast.undone'));
|
|
481
|
+
await load();
|
|
482
|
+
} catch {
|
|
483
|
+
flashToast(t('toast.undo_failed'));
|
|
484
|
+
}
|
|
412
485
|
}
|
|
413
486
|
|
|
414
487
|
// --------------------------------------------------------------------
|
|
@@ -477,6 +550,7 @@ async function load(path?: string) {
|
|
|
477
550
|
let requested = path ?? currentPath.value ?? '';
|
|
478
551
|
try {
|
|
479
552
|
notFoundPath.value = '';
|
|
553
|
+
loadError.value = '';
|
|
480
554
|
// Clamp to the confined floor: an empty/above-floor request (incl. a stale
|
|
481
555
|
// persisted path or the drives root) snaps back to rootPath. This both
|
|
482
556
|
// suppresses the multi-storage drives list and blocks up-navigation.
|
|
@@ -549,8 +623,13 @@ async function load(path?: string) {
|
|
|
549
623
|
emit('error', { message: e, context: { path } });
|
|
550
624
|
return;
|
|
551
625
|
}
|
|
626
|
+
// Real failure (network, 5xx). Never swallowed: the error still emits, and
|
|
627
|
+
// it surfaces either as the retryable error state (nothing else on screen)
|
|
628
|
+
// or as the classic toast over the still-visible previous listing.
|
|
629
|
+
loadError.value = e;
|
|
630
|
+
loadErrorPath = typeof requested === 'string' ? requested : undefined;
|
|
552
631
|
emit('error', { message: e, context: { path } });
|
|
553
|
-
flashToast(e);
|
|
632
|
+
if (files.value.length > 0) flashToast(e);
|
|
554
633
|
} finally {
|
|
555
634
|
loading.value = false;
|
|
556
635
|
}
|
|
@@ -656,6 +735,53 @@ function qualify(p: string): string {
|
|
|
656
735
|
return `${adapter.value}://${p.replace(/^\/+/, '')}`;
|
|
657
736
|
}
|
|
658
737
|
|
|
738
|
+
// ----------------------------------------------------------------
|
|
739
|
+
// Undo helpers — compute the inverse of cleanly-invertible operations
|
|
740
|
+
// (move → reverse move, rename → rename back, trash → restore). All
|
|
741
|
+
// paths here are wire form (`<adapter>://<rel>`).
|
|
742
|
+
// ----------------------------------------------------------------
|
|
743
|
+
|
|
744
|
+
function wireBasename(p: string): string {
|
|
745
|
+
const idx = p.indexOf('://');
|
|
746
|
+
const rel = (idx === -1 ? p : p.slice(idx + 3)).replace(/\/+$/, '');
|
|
747
|
+
const slash = rel.lastIndexOf('/');
|
|
748
|
+
return slash === -1 ? rel : rel.slice(slash + 1);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function wireParent(p: string): string {
|
|
752
|
+
const idx = p.indexOf('://');
|
|
753
|
+
const prefix = idx === -1 ? '' : p.slice(0, idx + 3);
|
|
754
|
+
const rel = (idx === -1 ? p : p.slice(idx + 3)).replace(/\/+$/, '');
|
|
755
|
+
const slash = rel.lastIndexOf('/');
|
|
756
|
+
return slash === -1 ? prefix : prefix + rel.slice(0, slash);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function wireJoin(dir: string, name: string): string {
|
|
760
|
+
if (!dir) return name;
|
|
761
|
+
return dir.endsWith('://') || dir.endsWith('/') ? dir + name : `${dir}/${name}`;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Register the inverse of a queued async move under its op id: once the op
|
|
765
|
+
// settles OK, the toast offers "Geri Al" which queues the reverse move. The
|
|
766
|
+
// inverse op deliberately gets NO undo entry of its own (no redo ping-pong).
|
|
767
|
+
function registerMoveUndo(
|
|
768
|
+
opId: number,
|
|
769
|
+
sources: string[],
|
|
770
|
+
targetWire: string,
|
|
771
|
+
originWire: string | undefined,
|
|
772
|
+
) {
|
|
773
|
+
if (!originWire || !targetWire) return;
|
|
774
|
+
const movedPaths = sources.map((p) => wireJoin(targetWire, wireBasename(p)));
|
|
775
|
+
if (movedPaths.length === 0) return;
|
|
776
|
+
opUndo.set(opId, {
|
|
777
|
+
message: t('toast.moved'),
|
|
778
|
+
fn: async () => {
|
|
779
|
+
const { op } = await api.moveAsync(movedPaths, originWire, targetWire);
|
|
780
|
+
pendingOps.register(op);
|
|
781
|
+
},
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
659
785
|
watch(
|
|
660
786
|
() => searchQuery.value,
|
|
661
787
|
() => void load(),
|
|
@@ -793,6 +919,55 @@ onMounted(async () => {
|
|
|
793
919
|
// Keyboard
|
|
794
920
|
// --------------------------------------------------------------------
|
|
795
921
|
|
|
922
|
+
/* cila:c wiring — command palette (Ctrl/Cmd+K) + shortcuts help (?) state */
|
|
923
|
+
const showPalette = ref(false);
|
|
924
|
+
const showShortcutsHelp = ref(false);
|
|
925
|
+
/* /cila:c wiring */
|
|
926
|
+
|
|
927
|
+
/* bul:s3 — palette "everywhere" search + open-hit navigation */
|
|
928
|
+
|
|
929
|
+
// Debounce/min-chars live in the palette; this is just the API call.
|
|
930
|
+
function paletteGlobalSearch(q: string): Promise<GlobalSearchHit[]> {
|
|
931
|
+
return api.globalSearch(q, { limit: 8, scope: 'all' });
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Open a global-search hit: navigate to the file's folder, then select +
|
|
936
|
+
* preview it through the existing openNode mechanics. Hits come back as raw
|
|
937
|
+
* node rows (in-storage relative `path`, numeric `storage_id`), so the
|
|
938
|
+
* storage segment for multi-storage mode is resolved best-effort: an
|
|
939
|
+
* explicit name on the hit (future backends) > the only configured storage
|
|
940
|
+
* > the storage currently open. A wrong guess lands on the existing
|
|
941
|
+
* "folder not found" state, which is already a graceful dead-end.
|
|
942
|
+
*/
|
|
943
|
+
async function openSearchHit(hit: GlobalSearchHit) {
|
|
944
|
+
const rel = String(hit.path ?? '').replace(/^\/+|\/+$/g, '');
|
|
945
|
+
if (!rel) return;
|
|
946
|
+
const isDir = hit.type === 'dir';
|
|
947
|
+
const slash = rel.lastIndexOf('/');
|
|
948
|
+
const targetRel = isDir ? rel : slash === -1 ? '' : rel.slice(0, slash);
|
|
949
|
+
let target = targetRel;
|
|
950
|
+
if (multiStorageRoot.value) {
|
|
951
|
+
const configured = props.config.storages ?? [];
|
|
952
|
+
const storageName =
|
|
953
|
+
(typeof hit.storage === 'string' && hit.storage) ||
|
|
954
|
+
(typeof hit.storage_name === 'string' && hit.storage_name) ||
|
|
955
|
+
(configured.length === 1 ? configured[0].name : '') ||
|
|
956
|
+
adapter.value;
|
|
957
|
+
if (!storageName) return;
|
|
958
|
+
target = targetRel ? `${storageName}/${targetRel}` : storageName;
|
|
959
|
+
}
|
|
960
|
+
await load(target);
|
|
961
|
+
if (isDir) return;
|
|
962
|
+
const name = String(hit.name ?? rel.slice(slash + 1));
|
|
963
|
+
const node = files.value.find((f) => f.type === 'file' && f.basename === name);
|
|
964
|
+
if (node) {
|
|
965
|
+
selection.click(node.path);
|
|
966
|
+
openNode(node);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
/* /bul:s3 */
|
|
970
|
+
|
|
796
971
|
useKeyboardShortcuts(rootEl, {
|
|
797
972
|
onDelete: () => {
|
|
798
973
|
if (!selection.isEmpty.value) showDelete.value = true;
|
|
@@ -815,12 +990,21 @@ useKeyboardShortcuts(rootEl, {
|
|
|
815
990
|
showShare.value = false;
|
|
816
991
|
showPreview.value = false;
|
|
817
992
|
ctxRef.value?.hide();
|
|
993
|
+
dismissToast();
|
|
818
994
|
},
|
|
819
995
|
onFocusSearch: () => toolbarRef.value?.focusSearch(),
|
|
820
996
|
onCut: () => cut(),
|
|
821
997
|
onCopy: () => copyToClipboard(),
|
|
822
998
|
onPaste: () => paste(),
|
|
823
999
|
onGoUp: () => goUp(),
|
|
1000
|
+
/* cila:c wiring */
|
|
1001
|
+
onPathJump: () => {
|
|
1002
|
+
showPalette.value = true;
|
|
1003
|
+
},
|
|
1004
|
+
onShowHelp: () => {
|
|
1005
|
+
showShortcutsHelp.value = true;
|
|
1006
|
+
},
|
|
1007
|
+
/* /cila:c wiring */
|
|
824
1008
|
hasSelection: () => !selection.isEmpty.value,
|
|
825
1009
|
});
|
|
826
1010
|
|
|
@@ -1264,7 +1448,10 @@ async function paste() {
|
|
|
1264
1448
|
}
|
|
1265
1449
|
|
|
1266
1450
|
if (cb.mode === 'cut') {
|
|
1267
|
-
const
|
|
1451
|
+
const targetWire = qualify(currentPath.value);
|
|
1452
|
+
const originWire = qualify(sourceDir) || undefined;
|
|
1453
|
+
const { op } = await api.moveAsync(items, targetWire, originWire);
|
|
1454
|
+
registerMoveUndo(op.id, items, targetWire, originWire);
|
|
1268
1455
|
pendingOps.register(op);
|
|
1269
1456
|
flashToast('Taşıma kuyruğa alındı');
|
|
1270
1457
|
} else {
|
|
@@ -1311,10 +1498,20 @@ async function submitRename(name: string) {
|
|
|
1311
1498
|
const target = renameTarget.value;
|
|
1312
1499
|
if (!target) return;
|
|
1313
1500
|
try {
|
|
1314
|
-
|
|
1501
|
+
const dirWire = qualify(currentPath.value);
|
|
1502
|
+
const oldPath = target.path; // qualified
|
|
1503
|
+
const oldName = target.basename;
|
|
1504
|
+
await api.rename(dirWire, oldPath, name);
|
|
1315
1505
|
showRename.value = false;
|
|
1316
1506
|
renameTarget.value = null;
|
|
1317
1507
|
await load();
|
|
1508
|
+
// Clean inverse: rename the new path back to the old basename.
|
|
1509
|
+
if (name && name !== oldName) {
|
|
1510
|
+
const newPath = wireJoin(wireParent(oldPath), name);
|
|
1511
|
+
undoToast(t('toast.renamed'), async () => {
|
|
1512
|
+
await api.rename(dirWire, newPath, oldName);
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1318
1515
|
} catch (err) {
|
|
1319
1516
|
emit('error', { message: (err as Error).message, context: { op: 'rename' } });
|
|
1320
1517
|
}
|
|
@@ -1329,19 +1526,37 @@ async function confirmDelete() {
|
|
|
1329
1526
|
flashToast('Çöpteki öğeler saklama süresi sonunda otomatik silinir. Kalıcı silme yönetici panelinden yapılır.');
|
|
1330
1527
|
return;
|
|
1331
1528
|
}
|
|
1332
|
-
const
|
|
1529
|
+
const targets = selection.nodes.value;
|
|
1530
|
+
const items = targets.map((n) => n.path);
|
|
1333
1531
|
if (items.length === 0) {
|
|
1334
1532
|
showDelete.value = false;
|
|
1335
1533
|
return;
|
|
1336
1534
|
}
|
|
1535
|
+
// Trash-delete is invertible via node-id restore — but only when EVERY
|
|
1536
|
+
// selected node carries a backend id and the restore endpoint exists.
|
|
1537
|
+
// A partial-undo offer would be a lie, so all-or-nothing.
|
|
1538
|
+
const nodeIds = targets
|
|
1539
|
+
.map((n) => (n as { id?: number }).id)
|
|
1540
|
+
.filter((x): x is number => typeof x === 'number');
|
|
1541
|
+
const restoreUndo =
|
|
1542
|
+
api.endpoints.trashRestore && nodeIds.length === targets.length
|
|
1543
|
+
? async () => {
|
|
1544
|
+
const { restored } = await api.restoreIds(nodeIds);
|
|
1545
|
+
if (restored === 0) throw new Error('restore failed');
|
|
1546
|
+
}
|
|
1547
|
+
: null;
|
|
1337
1548
|
try {
|
|
1338
1549
|
if (api.endpoints.deleteAsync) {
|
|
1339
1550
|
const { op } = await api.deleteAsync(items, qualify(currentPath.value));
|
|
1551
|
+
if (restoreUndo) {
|
|
1552
|
+
opUndo.set(op.id, { message: t('toast.trashed'), fn: restoreUndo });
|
|
1553
|
+
}
|
|
1340
1554
|
pendingOps.register(op);
|
|
1341
1555
|
flashToast('Silme kuyruğa alındı');
|
|
1342
1556
|
} else {
|
|
1343
1557
|
await api.deleteItems(qualify(currentPath.value), items);
|
|
1344
1558
|
await load();
|
|
1559
|
+
if (restoreUndo) undoToast(t('toast.trashed'), restoreUndo);
|
|
1345
1560
|
}
|
|
1346
1561
|
showDelete.value = false;
|
|
1347
1562
|
selection.clear();
|
|
@@ -1635,13 +1850,20 @@ function onItemDragStart(node: FileNode, ev: DragEvent) {
|
|
|
1635
1850
|
|
|
1636
1851
|
async function moveSourcesAsync(sources: string[], targetDir: string, opLabel: string): Promise<void> {
|
|
1637
1852
|
try {
|
|
1853
|
+
const originWire = qualify(currentPath.value);
|
|
1638
1854
|
if (api.endpoints.moveAsync) {
|
|
1639
|
-
const { op } = await api.moveAsync(sources, targetDir,
|
|
1855
|
+
const { op } = await api.moveAsync(sources, targetDir, originWire);
|
|
1856
|
+
registerMoveUndo(op.id, sources, targetDir, originWire);
|
|
1640
1857
|
pendingOps.register(op);
|
|
1641
1858
|
flashToast('Taşıma kuyruğa alındı');
|
|
1642
1859
|
} else {
|
|
1643
|
-
await api.move(
|
|
1860
|
+
await api.move(originWire, sources, targetDir);
|
|
1644
1861
|
await load();
|
|
1862
|
+
// Sync move (no async endpoint): offer the reverse move right away.
|
|
1863
|
+
const movedPaths = sources.map((p) => wireJoin(targetDir, wireBasename(p)));
|
|
1864
|
+
undoToast(t('toast.moved'), async () => {
|
|
1865
|
+
await api.move(targetDir, movedPaths, originWire);
|
|
1866
|
+
});
|
|
1645
1867
|
}
|
|
1646
1868
|
selection.clear();
|
|
1647
1869
|
} catch (err) {
|
|
@@ -1741,6 +1963,7 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
|
|
|
1741
1963
|
'fe--theme-light': config.theme === 'light',
|
|
1742
1964
|
'fe--theme-dark': config.theme === 'dark',
|
|
1743
1965
|
'fe--is-dragover': dragOver,
|
|
1966
|
+
'fe--density-compact': density === 'compact' /* cila:a density */,
|
|
1744
1967
|
}"
|
|
1745
1968
|
tabindex="-1"
|
|
1746
1969
|
@dragenter="onDragEnter"
|
|
@@ -1764,6 +1987,7 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
|
|
|
1764
1987
|
:locale="locale"
|
|
1765
1988
|
@update:view-mode="viewMode = $event"
|
|
1766
1989
|
@update:search-query="searchQuery = $event"
|
|
1990
|
+
@update:density="density = $event"
|
|
1767
1991
|
@new-folder="showNewFolder = true"
|
|
1768
1992
|
@upload="triggerUpload"
|
|
1769
1993
|
@refresh="() => load()"
|
|
@@ -1785,28 +2009,171 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
|
|
|
1785
2009
|
@crumb-drop="onCrumbDropInto"
|
|
1786
2010
|
/>
|
|
1787
2011
|
|
|
1788
|
-
<!-- Live presence: who else is viewing this folder (empty → nothing shown).
|
|
1789
|
-
|
|
1790
|
-
|
|
2012
|
+
<!-- Live presence: who else is viewing this folder (empty → nothing shown).
|
|
2013
|
+
When the live socket is unavailable the same strip carries a small
|
|
2014
|
+
degraded-connection badge instead (presence is empty in fallback);
|
|
2015
|
+
a healthy connection shows nothing extra. -->
|
|
2016
|
+
<div v-if="presenceUsers.length || realtimeDegraded" class="fe__presence">
|
|
2017
|
+
<PresenceBar v-if="presenceUsers.length" :users="presenceUsers" :locale="locale" />
|
|
2018
|
+
<span
|
|
2019
|
+
v-if="realtimeDegraded"
|
|
2020
|
+
class="fe-connbadge"
|
|
2021
|
+
role="status"
|
|
2022
|
+
:title="t('conn.tooltip')"
|
|
2023
|
+
>
|
|
2024
|
+
<span class="fe-connbadge__dot" aria-hidden="true"></span>
|
|
2025
|
+
{{ t('conn.offline') }}
|
|
2026
|
+
</span>
|
|
1791
2027
|
</div>
|
|
1792
2028
|
|
|
1793
2029
|
<div class="fe__body" @click.self="selection.clear()">
|
|
1794
|
-
<!-- Initial load:
|
|
1795
|
-
Only when there's nothing yet — navigation
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
<
|
|
2030
|
+
<!-- Initial load: skeleton ghosts (view-mode aware) instead of an
|
|
2031
|
+
empty/"no files" flash. Only when there's nothing yet — navigation
|
|
2032
|
+
keeps the current list, exactly as before. -->
|
|
2033
|
+
<div v-if="loading && files.length === 0" class="fe__skeleton" role="status">
|
|
2034
|
+
<span class="fe-sr-only">{{ t('loading') }}</span>
|
|
2035
|
+
<div v-if="viewMode === 'grid'" class="fe-skel-grid" aria-hidden="true">
|
|
2036
|
+
<div v-for="i in 8" :key="i" class="fe-skel-card">
|
|
2037
|
+
<div class="fe-skel fe-skel--thumb"></div>
|
|
2038
|
+
<div class="fe-skel fe-skel--label"></div>
|
|
2039
|
+
</div>
|
|
2040
|
+
</div>
|
|
2041
|
+
<div v-else class="fe-skel-list" aria-hidden="true">
|
|
2042
|
+
<div v-for="i in 8" :key="i" class="fe-skel-row">
|
|
2043
|
+
<div class="fe-skel fe-skel--icon"></div>
|
|
2044
|
+
<div class="fe-skel fe-skel--name"></div>
|
|
2045
|
+
<div class="fe-skel fe-skel--size"></div>
|
|
2046
|
+
<div class="fe-skel fe-skel--date"></div>
|
|
2047
|
+
</div>
|
|
2048
|
+
</div>
|
|
1799
2049
|
</div>
|
|
1800
2050
|
<!-- Dead deep link (404) or RBAC-hidden dir (403, shown identically):
|
|
1801
2051
|
a dedicated state instead of a misleading "this folder is empty". -->
|
|
1802
|
-
<div v-else-if="notFoundPath" class="
|
|
1803
|
-
<
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
2052
|
+
<div v-else-if="notFoundPath" class="fe-state">
|
|
2053
|
+
<svg
|
|
2054
|
+
class="fe-state__art"
|
|
2055
|
+
viewBox="0 0 120 100"
|
|
2056
|
+
width="110"
|
|
2057
|
+
height="92"
|
|
2058
|
+
fill="none"
|
|
2059
|
+
stroke="currentColor"
|
|
2060
|
+
stroke-width="2"
|
|
2061
|
+
stroke-linecap="round"
|
|
2062
|
+
stroke-linejoin="round"
|
|
2063
|
+
aria-hidden="true"
|
|
2064
|
+
>
|
|
2065
|
+
<path d="M18 36v42a6 6 0 0 0 6 6h72a6 6 0 0 0 6-6V44a6 6 0 0 0-6-6H62l-9-10H24a6 6 0 0 0-6 6z" />
|
|
2066
|
+
<path d="M52 55c0-4.6 3.6-8 8-8s8 3.4 8 8c0 5.5-8 4.8-8 11" />
|
|
2067
|
+
<circle cx="60" cy="73" r="1.6" fill="currentColor" stroke="none" />
|
|
2068
|
+
</svg>
|
|
2069
|
+
<p class="fe-state__title">{{ t('notFound.title') }}</p>
|
|
2070
|
+
<p class="fe-state__path">{{ notFoundPath }}</p>
|
|
2071
|
+
<p class="fe-state__hint">{{ t('notFound.desc') }}</p>
|
|
2072
|
+
<div class="fe-state__actions">
|
|
2073
|
+
<button type="button" class="fe-btn" @click="leaveNotFound">
|
|
2074
|
+
{{ t('notFound.goRoot') }}
|
|
2075
|
+
</button>
|
|
2076
|
+
</div>
|
|
2077
|
+
</div>
|
|
2078
|
+
<!-- Listing failed (network / 5xx) with nothing else to show: retryable
|
|
2079
|
+
error state in the same visual language. -->
|
|
2080
|
+
<div v-else-if="loadError && files.length === 0" class="fe-state">
|
|
2081
|
+
<svg
|
|
2082
|
+
class="fe-state__art"
|
|
2083
|
+
viewBox="0 0 120 100"
|
|
2084
|
+
width="110"
|
|
2085
|
+
height="92"
|
|
2086
|
+
fill="none"
|
|
2087
|
+
stroke="currentColor"
|
|
2088
|
+
stroke-width="2"
|
|
2089
|
+
stroke-linecap="round"
|
|
2090
|
+
stroke-linejoin="round"
|
|
2091
|
+
aria-hidden="true"
|
|
2092
|
+
>
|
|
2093
|
+
<circle cx="60" cy="50" r="28" />
|
|
2094
|
+
<path d="M60 36v18" />
|
|
2095
|
+
<circle cx="60" cy="63" r="1.8" fill="currentColor" stroke="none" />
|
|
2096
|
+
<path d="M24 88h72" stroke-dasharray="3 5" />
|
|
2097
|
+
</svg>
|
|
2098
|
+
<p class="fe-state__title">{{ t('error.title') }}</p>
|
|
2099
|
+
<p class="fe-state__hint">{{ loadError }}</p>
|
|
2100
|
+
<div class="fe-state__actions">
|
|
2101
|
+
<button type="button" class="fe-btn fe-btn--primary" @click="retryLoad">
|
|
2102
|
+
{{ t('error.retry') }}
|
|
2103
|
+
</button>
|
|
2104
|
+
</div>
|
|
2105
|
+
</div>
|
|
2106
|
+
<!-- Search with zero hits — its own message, not "folder is empty". -->
|
|
2107
|
+
<div v-else-if="!loading && files.length === 0 && searchQuery" class="fe-state">
|
|
2108
|
+
<svg
|
|
2109
|
+
class="fe-state__art"
|
|
2110
|
+
viewBox="0 0 120 100"
|
|
2111
|
+
width="110"
|
|
2112
|
+
height="92"
|
|
2113
|
+
fill="none"
|
|
2114
|
+
stroke="currentColor"
|
|
2115
|
+
stroke-width="2"
|
|
2116
|
+
stroke-linecap="round"
|
|
2117
|
+
stroke-linejoin="round"
|
|
2118
|
+
aria-hidden="true"
|
|
2119
|
+
>
|
|
2120
|
+
<circle cx="52" cy="44" r="22" />
|
|
2121
|
+
<path d="M68 61l20 20" />
|
|
2122
|
+
<path d="M46 38l12 12M58 38l-12 12" />
|
|
2123
|
+
</svg>
|
|
2124
|
+
<p class="fe-state__title">{{ t('empty.search.title') }}</p>
|
|
2125
|
+
<p class="fe-state__hint">{{ t('empty.search.hint') }}</p>
|
|
2126
|
+
</div>
|
|
2127
|
+
<!-- Empty trash view. -->
|
|
2128
|
+
<div v-else-if="!loading && files.length === 0 && trashMode" class="fe-state">
|
|
2129
|
+
<svg
|
|
2130
|
+
class="fe-state__art"
|
|
2131
|
+
viewBox="0 0 120 100"
|
|
2132
|
+
width="110"
|
|
2133
|
+
height="92"
|
|
2134
|
+
fill="none"
|
|
2135
|
+
stroke="currentColor"
|
|
2136
|
+
stroke-width="2"
|
|
2137
|
+
stroke-linecap="round"
|
|
2138
|
+
stroke-linejoin="round"
|
|
2139
|
+
aria-hidden="true"
|
|
2140
|
+
>
|
|
2141
|
+
<path d="M38 34l4 48a6 6 0 0 0 6 5.6h24a6 6 0 0 0 6-5.6l4-48" />
|
|
2142
|
+
<path d="M32 34h56" />
|
|
2143
|
+
<path d="M50 34v-6a6 6 0 0 1 6-6h8a6 6 0 0 1 6 6v6" />
|
|
2144
|
+
<path d="M52 44v32M60 44v32M68 44v32" opacity="0.5" />
|
|
2145
|
+
</svg>
|
|
2146
|
+
<p class="fe-state__title">{{ t('empty.trash.title') }}</p>
|
|
2147
|
+
</div>
|
|
2148
|
+
<!-- Loaded, zero files, no search: the real empty-folder state. The
|
|
2149
|
+
upload affordances follow write permission (RBAC viewers only get
|
|
2150
|
+
the title). -->
|
|
2151
|
+
<div v-else-if="!loading && files.length === 0" class="fe-state">
|
|
2152
|
+
<svg
|
|
2153
|
+
class="fe-state__art"
|
|
2154
|
+
viewBox="0 0 120 100"
|
|
2155
|
+
width="110"
|
|
2156
|
+
height="92"
|
|
2157
|
+
fill="none"
|
|
2158
|
+
stroke="currentColor"
|
|
2159
|
+
stroke-width="2"
|
|
2160
|
+
stroke-linecap="round"
|
|
2161
|
+
stroke-linejoin="round"
|
|
2162
|
+
aria-hidden="true"
|
|
2163
|
+
>
|
|
2164
|
+
<path d="M18 36v42a6 6 0 0 0 6 6h72a6 6 0 0 0 6-6V44a6 6 0 0 0-6-6H62l-9-10H24a6 6 0 0 0-6 6z" />
|
|
2165
|
+
<g v-if="emptyCanUpload">
|
|
2166
|
+
<path d="M60 50v14" stroke-dasharray="3 4" />
|
|
2167
|
+
<path d="M53 59l7 8 7-8" />
|
|
2168
|
+
</g>
|
|
2169
|
+
</svg>
|
|
2170
|
+
<p class="fe-state__title">{{ t('empty.folder') }}</p>
|
|
2171
|
+
<p v-if="emptyCanUpload" class="fe-state__hint">{{ t('empty.hint') }}</p>
|
|
2172
|
+
<div v-if="emptyCanUpload" class="fe-state__actions">
|
|
2173
|
+
<button type="button" class="fe-btn fe-btn--primary" @click="triggerUpload">
|
|
2174
|
+
{{ t('empty.upload') }}
|
|
2175
|
+
</button>
|
|
2176
|
+
</div>
|
|
1810
2177
|
</div>
|
|
1811
2178
|
<ListView
|
|
1812
2179
|
v-else-if="viewMode === 'list'"
|
|
@@ -1995,6 +2362,33 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
|
|
|
1995
2362
|
</div>
|
|
1996
2363
|
</transition>
|
|
1997
2364
|
|
|
2365
|
+
<!-- cila:c wiring — command palette (Ctrl/Cmd+K) + shortcuts help (?) -->
|
|
2366
|
+
<CommandPalette
|
|
2367
|
+
:open="showPalette"
|
|
2368
|
+
:locale="locale"
|
|
2369
|
+
:files="files"
|
|
2370
|
+
:view-mode="viewMode"
|
|
2371
|
+
:can-write="canWriteHere && !atVirtualRoot && !trashActive"
|
|
2372
|
+
:can-go-up="canGoUp"
|
|
2373
|
+
:global-search="paletteGlobalSearch"
|
|
2374
|
+
@close="showPalette = false"
|
|
2375
|
+
@open-hit="openSearchHit"
|
|
2376
|
+
@open-node="openNode"
|
|
2377
|
+
@navigate="(p: string) => load(p)"
|
|
2378
|
+
@new-folder="showNewFolder = true"
|
|
2379
|
+
@upload="triggerUpload"
|
|
2380
|
+
@toggle-view="viewMode = viewMode === 'list' ? 'grid' : 'list'"
|
|
2381
|
+
@open-trash="loadTrash"
|
|
2382
|
+
@refresh="() => load()"
|
|
2383
|
+
@go-up="goUp"
|
|
2384
|
+
/>
|
|
2385
|
+
<ShortcutsHelp
|
|
2386
|
+
:open="showShortcutsHelp"
|
|
2387
|
+
:locale="locale"
|
|
2388
|
+
@close="showShortcutsHelp = false"
|
|
2389
|
+
/>
|
|
2390
|
+
<!-- /cila:c wiring -->
|
|
2391
|
+
|
|
1998
2392
|
<input
|
|
1999
2393
|
ref="fileInputEl"
|
|
2000
2394
|
type="file"
|
|
@@ -2004,7 +2398,21 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
|
|
|
2004
2398
|
/>
|
|
2005
2399
|
|
|
2006
2400
|
<transition name="fe-toast">
|
|
2007
|
-
<div
|
|
2401
|
+
<div
|
|
2402
|
+
v-if="toast"
|
|
2403
|
+
class="fe-toast"
|
|
2404
|
+
:class="{ 'fe-toast--action': !!toast.actionLabel }"
|
|
2405
|
+
role="status"
|
|
2406
|
+
@click="dismissToast"
|
|
2407
|
+
>
|
|
2408
|
+
<span class="fe-toast__msg">{{ toast.message }}</span>
|
|
2409
|
+
<button
|
|
2410
|
+
v-if="toast.actionLabel && toast.action"
|
|
2411
|
+
type="button"
|
|
2412
|
+
class="fe-toast__action"
|
|
2413
|
+
@click.stop="runToastAction"
|
|
2414
|
+
>{{ toast.actionLabel }}</button>
|
|
2415
|
+
</div>
|
|
2008
2416
|
</transition>
|
|
2009
2417
|
</div>
|
|
2010
2418
|
</template>
|