@brftech/filex-core 0.1.83 → 0.1.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/{ArchiveViewer-BRASXNIS.js → ArchiveViewer-wg4uE-3C.js} +2 -2
  2. package/dist/{ArchiveViewer-BRASXNIS.js.map → ArchiveViewer-wg4uE-3C.js.map} +1 -1
  3. package/dist/{CsvViewer-CgiLiHWw.js → CsvViewer-57uz4UKk.js} +2 -2
  4. package/dist/{CsvViewer-CgiLiHWw.js.map → CsvViewer-57uz4UKk.js.map} +1 -1
  5. package/dist/{DrawioViewer-D2I_uEng.js → DrawioViewer-BfL9Hbyp.js} +2 -2
  6. package/dist/{DrawioViewer-D2I_uEng.js.map → DrawioViewer-BfL9Hbyp.js.map} +1 -1
  7. package/dist/{EpubViewer-6nVPRR6R.js → EpubViewer-CYG2CV_3.js} +2 -2
  8. package/dist/{EpubViewer-6nVPRR6R.js.map → EpubViewer-CYG2CV_3.js.map} +1 -1
  9. package/dist/{IpynbViewer-B7p9hxFx.js → IpynbViewer-DJw34-wV.js} +2 -2
  10. package/dist/{IpynbViewer-B7p9hxFx.js.map → IpynbViewer-DJw34-wV.js.map} +1 -1
  11. package/dist/{MermaidViewer-O9vGvp4y.js → MermaidViewer-C86jjm27.js} +2 -2
  12. package/dist/{MermaidViewer-O9vGvp4y.js.map → MermaidViewer-C86jjm27.js.map} +1 -1
  13. package/dist/{PsdViewer-C6EaEAxF.js → PsdViewer-DGHIqv-x.js} +2 -2
  14. package/dist/{PsdViewer-C6EaEAxF.js.map → PsdViewer-DGHIqv-x.js.map} +1 -1
  15. package/dist/{TiffViewer-Dod4uIXI.js → TiffViewer-gct9Ub3o.js} +2 -2
  16. package/dist/{TiffViewer-Dod4uIXI.js.map → TiffViewer-gct9Ub3o.js.map} +1 -1
  17. package/dist/{Viewer3D-B3kLZrwO.js → Viewer3D-BVt4l2a9.js} +2 -2
  18. package/dist/{Viewer3D-B3kLZrwO.js.map → Viewer3D-BVt4l2a9.js.map} +1 -1
  19. package/dist/filex-core.js +1 -1
  20. package/dist/filex-core.umd.cjs +38 -38
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/index-BeUoODQq.js +6871 -0
  23. package/dist/index-BeUoODQq.js.map +1 -0
  24. package/dist/index.d.ts +6 -0
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +393 -31
  28. package/src/components/Breadcrumb.vue +100 -5
  29. package/src/components/CommandPalette.vue +299 -0
  30. package/src/components/GridView.vue +8 -13
  31. package/src/components/ListView.vue +173 -20
  32. package/src/components/ShortcutsHelp.vue +59 -0
  33. package/src/components/Toolbar.vue +65 -1
  34. package/src/composables/useKeyboardShortcuts.ts +38 -0
  35. package/src/composables/useRealtime.ts +9 -1
  36. package/src/lib/fileIcons.ts +127 -0
  37. package/src/locales/en.ts +54 -0
  38. package/src/locales/tr.ts +54 -0
  39. package/src/styles/base.css +549 -0
  40. package/src/styles/variables.css +70 -0
  41. package/dist/index-DL6_eaM3.js +0 -5888
  42. package/dist/index-DL6_eaM3.js.map +0 -1
@@ -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
- const toast = ref<string | null>(null);
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 flashToast(msg: string) {
409
- toast.value = msg;
456
+ function showToast(state: ToastState, ms: number) {
457
+ toast.value = state;
410
458
  if (toastTimer) clearTimeout(toastTimer);
411
- toastTimer = setTimeout(() => (toast.value = null), 2500);
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,11 @@ 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
+
796
927
  useKeyboardShortcuts(rootEl, {
797
928
  onDelete: () => {
798
929
  if (!selection.isEmpty.value) showDelete.value = true;
@@ -815,12 +946,21 @@ useKeyboardShortcuts(rootEl, {
815
946
  showShare.value = false;
816
947
  showPreview.value = false;
817
948
  ctxRef.value?.hide();
949
+ dismissToast();
818
950
  },
819
951
  onFocusSearch: () => toolbarRef.value?.focusSearch(),
820
952
  onCut: () => cut(),
821
953
  onCopy: () => copyToClipboard(),
822
954
  onPaste: () => paste(),
823
955
  onGoUp: () => goUp(),
956
+ /* cila:c wiring */
957
+ onPathJump: () => {
958
+ showPalette.value = true;
959
+ },
960
+ onShowHelp: () => {
961
+ showShortcutsHelp.value = true;
962
+ },
963
+ /* /cila:c wiring */
824
964
  hasSelection: () => !selection.isEmpty.value,
825
965
  });
826
966
 
@@ -1264,7 +1404,10 @@ async function paste() {
1264
1404
  }
1265
1405
 
1266
1406
  if (cb.mode === 'cut') {
1267
- const { op } = await api.moveAsync(items, qualify(currentPath.value), qualify(sourceDir) || undefined);
1407
+ const targetWire = qualify(currentPath.value);
1408
+ const originWire = qualify(sourceDir) || undefined;
1409
+ const { op } = await api.moveAsync(items, targetWire, originWire);
1410
+ registerMoveUndo(op.id, items, targetWire, originWire);
1268
1411
  pendingOps.register(op);
1269
1412
  flashToast('Taşıma kuyruğa alındı');
1270
1413
  } else {
@@ -1311,10 +1454,20 @@ async function submitRename(name: string) {
1311
1454
  const target = renameTarget.value;
1312
1455
  if (!target) return;
1313
1456
  try {
1314
- await api.rename(qualify(currentPath.value), target.path, name);
1457
+ const dirWire = qualify(currentPath.value);
1458
+ const oldPath = target.path; // qualified
1459
+ const oldName = target.basename;
1460
+ await api.rename(dirWire, oldPath, name);
1315
1461
  showRename.value = false;
1316
1462
  renameTarget.value = null;
1317
1463
  await load();
1464
+ // Clean inverse: rename the new path back to the old basename.
1465
+ if (name && name !== oldName) {
1466
+ const newPath = wireJoin(wireParent(oldPath), name);
1467
+ undoToast(t('toast.renamed'), async () => {
1468
+ await api.rename(dirWire, newPath, oldName);
1469
+ });
1470
+ }
1318
1471
  } catch (err) {
1319
1472
  emit('error', { message: (err as Error).message, context: { op: 'rename' } });
1320
1473
  }
@@ -1329,19 +1482,37 @@ async function confirmDelete() {
1329
1482
  flashToast('Çöpteki öğeler saklama süresi sonunda otomatik silinir. Kalıcı silme yönetici panelinden yapılır.');
1330
1483
  return;
1331
1484
  }
1332
- const items = selection.nodes.value.map((n) => n.path);
1485
+ const targets = selection.nodes.value;
1486
+ const items = targets.map((n) => n.path);
1333
1487
  if (items.length === 0) {
1334
1488
  showDelete.value = false;
1335
1489
  return;
1336
1490
  }
1491
+ // Trash-delete is invertible via node-id restore — but only when EVERY
1492
+ // selected node carries a backend id and the restore endpoint exists.
1493
+ // A partial-undo offer would be a lie, so all-or-nothing.
1494
+ const nodeIds = targets
1495
+ .map((n) => (n as { id?: number }).id)
1496
+ .filter((x): x is number => typeof x === 'number');
1497
+ const restoreUndo =
1498
+ api.endpoints.trashRestore && nodeIds.length === targets.length
1499
+ ? async () => {
1500
+ const { restored } = await api.restoreIds(nodeIds);
1501
+ if (restored === 0) throw new Error('restore failed');
1502
+ }
1503
+ : null;
1337
1504
  try {
1338
1505
  if (api.endpoints.deleteAsync) {
1339
1506
  const { op } = await api.deleteAsync(items, qualify(currentPath.value));
1507
+ if (restoreUndo) {
1508
+ opUndo.set(op.id, { message: t('toast.trashed'), fn: restoreUndo });
1509
+ }
1340
1510
  pendingOps.register(op);
1341
1511
  flashToast('Silme kuyruğa alındı');
1342
1512
  } else {
1343
1513
  await api.deleteItems(qualify(currentPath.value), items);
1344
1514
  await load();
1515
+ if (restoreUndo) undoToast(t('toast.trashed'), restoreUndo);
1345
1516
  }
1346
1517
  showDelete.value = false;
1347
1518
  selection.clear();
@@ -1635,13 +1806,20 @@ function onItemDragStart(node: FileNode, ev: DragEvent) {
1635
1806
 
1636
1807
  async function moveSourcesAsync(sources: string[], targetDir: string, opLabel: string): Promise<void> {
1637
1808
  try {
1809
+ const originWire = qualify(currentPath.value);
1638
1810
  if (api.endpoints.moveAsync) {
1639
- const { op } = await api.moveAsync(sources, targetDir, qualify(currentPath.value));
1811
+ const { op } = await api.moveAsync(sources, targetDir, originWire);
1812
+ registerMoveUndo(op.id, sources, targetDir, originWire);
1640
1813
  pendingOps.register(op);
1641
1814
  flashToast('Taşıma kuyruğa alındı');
1642
1815
  } else {
1643
- await api.move(qualify(currentPath.value), sources, targetDir);
1816
+ await api.move(originWire, sources, targetDir);
1644
1817
  await load();
1818
+ // Sync move (no async endpoint): offer the reverse move right away.
1819
+ const movedPaths = sources.map((p) => wireJoin(targetDir, wireBasename(p)));
1820
+ undoToast(t('toast.moved'), async () => {
1821
+ await api.move(targetDir, movedPaths, originWire);
1822
+ });
1645
1823
  }
1646
1824
  selection.clear();
1647
1825
  } catch (err) {
@@ -1741,6 +1919,7 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
1741
1919
  'fe--theme-light': config.theme === 'light',
1742
1920
  'fe--theme-dark': config.theme === 'dark',
1743
1921
  'fe--is-dragover': dragOver,
1922
+ 'fe--density-compact': density === 'compact' /* cila:a density */,
1744
1923
  }"
1745
1924
  tabindex="-1"
1746
1925
  @dragenter="onDragEnter"
@@ -1764,6 +1943,7 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
1764
1943
  :locale="locale"
1765
1944
  @update:view-mode="viewMode = $event"
1766
1945
  @update:search-query="searchQuery = $event"
1946
+ @update:density="density = $event"
1767
1947
  @new-folder="showNewFolder = true"
1768
1948
  @upload="triggerUpload"
1769
1949
  @refresh="() => load()"
@@ -1785,28 +1965,171 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
1785
1965
  @crumb-drop="onCrumbDropInto"
1786
1966
  />
1787
1967
 
1788
- <!-- Live presence: who else is viewing this folder (empty → nothing shown). -->
1789
- <div v-if="presenceUsers.length" class="fe__presence">
1790
- <PresenceBar :users="presenceUsers" :locale="locale" />
1968
+ <!-- Live presence: who else is viewing this folder (empty → nothing shown).
1969
+ When the live socket is unavailable the same strip carries a small
1970
+ degraded-connection badge instead (presence is empty in fallback);
1971
+ a healthy connection shows nothing extra. -->
1972
+ <div v-if="presenceUsers.length || realtimeDegraded" class="fe__presence">
1973
+ <PresenceBar v-if="presenceUsers.length" :users="presenceUsers" :locale="locale" />
1974
+ <span
1975
+ v-if="realtimeDegraded"
1976
+ class="fe-connbadge"
1977
+ role="status"
1978
+ :title="t('conn.tooltip')"
1979
+ >
1980
+ <span class="fe-connbadge__dot" aria-hidden="true"></span>
1981
+ {{ t('conn.offline') }}
1982
+ </span>
1791
1983
  </div>
1792
1984
 
1793
1985
  <div class="fe__body" @click.self="selection.clear()">
1794
- <!-- Initial load: show a spinner rather than an empty/"no files" flash.
1795
- Only when there's nothing yet — navigation keeps the current list. -->
1796
- <div v-if="loading && files.length === 0" class="fe__loading">
1797
- <span class="fe__spinner" aria-hidden="true"></span>
1798
- <p class="fe__loading-text">{{ t('loading') }}</p>
1986
+ <!-- Initial load: skeleton ghosts (view-mode aware) instead of an
1987
+ empty/"no files" flash. Only when there's nothing yet — navigation
1988
+ keeps the current list, exactly as before. -->
1989
+ <div v-if="loading && files.length === 0" class="fe__skeleton" role="status">
1990
+ <span class="fe-sr-only">{{ t('loading') }}</span>
1991
+ <div v-if="viewMode === 'grid'" class="fe-skel-grid" aria-hidden="true">
1992
+ <div v-for="i in 8" :key="i" class="fe-skel-card">
1993
+ <div class="fe-skel fe-skel--thumb"></div>
1994
+ <div class="fe-skel fe-skel--label"></div>
1995
+ </div>
1996
+ </div>
1997
+ <div v-else class="fe-skel-list" aria-hidden="true">
1998
+ <div v-for="i in 8" :key="i" class="fe-skel-row">
1999
+ <div class="fe-skel fe-skel--icon"></div>
2000
+ <div class="fe-skel fe-skel--name"></div>
2001
+ <div class="fe-skel fe-skel--size"></div>
2002
+ <div class="fe-skel fe-skel--date"></div>
2003
+ </div>
2004
+ </div>
1799
2005
  </div>
1800
2006
  <!-- Dead deep link (404) or RBAC-hidden dir (403, shown identically):
1801
2007
  a dedicated state instead of a misleading "this folder is empty". -->
1802
- <div v-else-if="notFoundPath" class="fe__notfound">
1803
- <span class="fe__notfound-icon" aria-hidden="true">📁</span>
1804
- <p class="fe__notfound-title">{{ t('notFound.title') }}</p>
1805
- <p class="fe__notfound-path">{{ notFoundPath }}</p>
1806
- <p class="fe__notfound-desc">{{ t('notFound.desc') }}</p>
1807
- <button type="button" class="fe-btn" @click="leaveNotFound">
1808
- {{ t('notFound.goRoot') }}
1809
- </button>
2008
+ <div v-else-if="notFoundPath" class="fe-state">
2009
+ <svg
2010
+ class="fe-state__art"
2011
+ viewBox="0 0 120 100"
2012
+ width="110"
2013
+ height="92"
2014
+ fill="none"
2015
+ stroke="currentColor"
2016
+ stroke-width="2"
2017
+ stroke-linecap="round"
2018
+ stroke-linejoin="round"
2019
+ aria-hidden="true"
2020
+ >
2021
+ <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" />
2022
+ <path d="M52 55c0-4.6 3.6-8 8-8s8 3.4 8 8c0 5.5-8 4.8-8 11" />
2023
+ <circle cx="60" cy="73" r="1.6" fill="currentColor" stroke="none" />
2024
+ </svg>
2025
+ <p class="fe-state__title">{{ t('notFound.title') }}</p>
2026
+ <p class="fe-state__path">{{ notFoundPath }}</p>
2027
+ <p class="fe-state__hint">{{ t('notFound.desc') }}</p>
2028
+ <div class="fe-state__actions">
2029
+ <button type="button" class="fe-btn" @click="leaveNotFound">
2030
+ {{ t('notFound.goRoot') }}
2031
+ </button>
2032
+ </div>
2033
+ </div>
2034
+ <!-- Listing failed (network / 5xx) with nothing else to show: retryable
2035
+ error state in the same visual language. -->
2036
+ <div v-else-if="loadError && files.length === 0" class="fe-state">
2037
+ <svg
2038
+ class="fe-state__art"
2039
+ viewBox="0 0 120 100"
2040
+ width="110"
2041
+ height="92"
2042
+ fill="none"
2043
+ stroke="currentColor"
2044
+ stroke-width="2"
2045
+ stroke-linecap="round"
2046
+ stroke-linejoin="round"
2047
+ aria-hidden="true"
2048
+ >
2049
+ <circle cx="60" cy="50" r="28" />
2050
+ <path d="M60 36v18" />
2051
+ <circle cx="60" cy="63" r="1.8" fill="currentColor" stroke="none" />
2052
+ <path d="M24 88h72" stroke-dasharray="3 5" />
2053
+ </svg>
2054
+ <p class="fe-state__title">{{ t('error.title') }}</p>
2055
+ <p class="fe-state__hint">{{ loadError }}</p>
2056
+ <div class="fe-state__actions">
2057
+ <button type="button" class="fe-btn fe-btn--primary" @click="retryLoad">
2058
+ {{ t('error.retry') }}
2059
+ </button>
2060
+ </div>
2061
+ </div>
2062
+ <!-- Search with zero hits — its own message, not "folder is empty". -->
2063
+ <div v-else-if="!loading && files.length === 0 && searchQuery" class="fe-state">
2064
+ <svg
2065
+ class="fe-state__art"
2066
+ viewBox="0 0 120 100"
2067
+ width="110"
2068
+ height="92"
2069
+ fill="none"
2070
+ stroke="currentColor"
2071
+ stroke-width="2"
2072
+ stroke-linecap="round"
2073
+ stroke-linejoin="round"
2074
+ aria-hidden="true"
2075
+ >
2076
+ <circle cx="52" cy="44" r="22" />
2077
+ <path d="M68 61l20 20" />
2078
+ <path d="M46 38l12 12M58 38l-12 12" />
2079
+ </svg>
2080
+ <p class="fe-state__title">{{ t('empty.search.title') }}</p>
2081
+ <p class="fe-state__hint">{{ t('empty.search.hint') }}</p>
2082
+ </div>
2083
+ <!-- Empty trash view. -->
2084
+ <div v-else-if="!loading && files.length === 0 && trashMode" class="fe-state">
2085
+ <svg
2086
+ class="fe-state__art"
2087
+ viewBox="0 0 120 100"
2088
+ width="110"
2089
+ height="92"
2090
+ fill="none"
2091
+ stroke="currentColor"
2092
+ stroke-width="2"
2093
+ stroke-linecap="round"
2094
+ stroke-linejoin="round"
2095
+ aria-hidden="true"
2096
+ >
2097
+ <path d="M38 34l4 48a6 6 0 0 0 6 5.6h24a6 6 0 0 0 6-5.6l4-48" />
2098
+ <path d="M32 34h56" />
2099
+ <path d="M50 34v-6a6 6 0 0 1 6-6h8a6 6 0 0 1 6 6v6" />
2100
+ <path d="M52 44v32M60 44v32M68 44v32" opacity="0.5" />
2101
+ </svg>
2102
+ <p class="fe-state__title">{{ t('empty.trash.title') }}</p>
2103
+ </div>
2104
+ <!-- Loaded, zero files, no search: the real empty-folder state. The
2105
+ upload affordances follow write permission (RBAC viewers only get
2106
+ the title). -->
2107
+ <div v-else-if="!loading && files.length === 0" 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
+ <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" />
2121
+ <g v-if="emptyCanUpload">
2122
+ <path d="M60 50v14" stroke-dasharray="3 4" />
2123
+ <path d="M53 59l7 8 7-8" />
2124
+ </g>
2125
+ </svg>
2126
+ <p class="fe-state__title">{{ t('empty.folder') }}</p>
2127
+ <p v-if="emptyCanUpload" class="fe-state__hint">{{ t('empty.hint') }}</p>
2128
+ <div v-if="emptyCanUpload" class="fe-state__actions">
2129
+ <button type="button" class="fe-btn fe-btn--primary" @click="triggerUpload">
2130
+ {{ t('empty.upload') }}
2131
+ </button>
2132
+ </div>
1810
2133
  </div>
1811
2134
  <ListView
1812
2135
  v-else-if="viewMode === 'list'"
@@ -1995,6 +2318,31 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
1995
2318
  </div>
1996
2319
  </transition>
1997
2320
 
2321
+ <!-- cila:c wiring — command palette (Ctrl/Cmd+K) + shortcuts help (?) -->
2322
+ <CommandPalette
2323
+ :open="showPalette"
2324
+ :locale="locale"
2325
+ :files="files"
2326
+ :view-mode="viewMode"
2327
+ :can-write="canWriteHere && !atVirtualRoot && !trashActive"
2328
+ :can-go-up="canGoUp"
2329
+ @close="showPalette = false"
2330
+ @open-node="openNode"
2331
+ @navigate="(p: string) => load(p)"
2332
+ @new-folder="showNewFolder = true"
2333
+ @upload="triggerUpload"
2334
+ @toggle-view="viewMode = viewMode === 'list' ? 'grid' : 'list'"
2335
+ @open-trash="loadTrash"
2336
+ @refresh="() => load()"
2337
+ @go-up="goUp"
2338
+ />
2339
+ <ShortcutsHelp
2340
+ :open="showShortcutsHelp"
2341
+ :locale="locale"
2342
+ @close="showShortcutsHelp = false"
2343
+ />
2344
+ <!-- /cila:c wiring -->
2345
+
1998
2346
  <input
1999
2347
  ref="fileInputEl"
2000
2348
  type="file"
@@ -2004,7 +2352,21 @@ function buildAuthHeaders(extra: Record<string, string> = {}) {
2004
2352
  />
2005
2353
 
2006
2354
  <transition name="fe-toast">
2007
- <div v-if="toast" class="fe-toast">{{ toast }}</div>
2355
+ <div
2356
+ v-if="toast"
2357
+ class="fe-toast"
2358
+ :class="{ 'fe-toast--action': !!toast.actionLabel }"
2359
+ role="status"
2360
+ @click="dismissToast"
2361
+ >
2362
+ <span class="fe-toast__msg">{{ toast.message }}</span>
2363
+ <button
2364
+ v-if="toast.actionLabel && toast.action"
2365
+ type="button"
2366
+ class="fe-toast__action"
2367
+ @click.stop="runToastAction"
2368
+ >{{ toast.actionLabel }}</button>
2369
+ </div>
2008
2370
  </transition>
2009
2371
  </div>
2010
2372
  </template>