@brftech/filex-core 0.28.0 → 0.30.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.
@@ -14,7 +14,7 @@
14
14
  * (PWA / OIDC) / CSRF (panel) / basic / none — `useFileApi` swallows
15
15
  * the difference.
16
16
  */
17
- import { computed, customRef, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
17
+ import { computed, customRef, nextTick, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue';
18
18
  import type { ExplorerConfig, ThemeMode } from './types/ExplorerConfig';
19
19
  import type {
20
20
  FileNode,
@@ -51,6 +51,9 @@ import ContextMenu, { type ContextAction } from './components/ContextMenu.vue';
51
51
  import UploadProgress from './components/UploadProgress.vue';
52
52
  import PendingOpsTray from './components/PendingOpsTray.vue';
53
53
  import InspectorPanel from './components/InspectorPanel.vue'; /* koru:k1 */
54
+ import SideNav from './components/SideNav.vue'; /* gezinti:g1 */
55
+ import ConnectionsPanel from './components/ConnectionsPanel.vue'; /* gezinti:g1 */
56
+ import TokensPanel from './components/TokensPanel.vue'; /* gezinti:g1 */
54
57
  /* cila:c wiring */
55
58
  import CommandPalette from './components/CommandPalette.vue';
56
59
  import ShortcutsHelp from './components/ShortcutsHelp.vue';
@@ -299,6 +302,24 @@ const trashMode = ref(false);
299
302
  const trashOrigin = ref<string>('');
300
303
  const trashActive = computed(() => trashMode.value);
301
304
 
305
+ /* === gezinti:g1 — the navigation panel's virtual views ===================
306
+ * Recent / Starred / Shared with me / Trash are listings with no folder behind
307
+ * them: the rows come from a per-user endpoint and each carries its own
308
+ * adapter-qualified path, so opening one navigates the ordinary way. The
309
+ * pattern is trashMode's, generalised — including the part that matters most,
310
+ * that load() clears the mode, or the view sticks and every later navigation
311
+ * renders under the wrong heading. */
312
+ type NavView = '' | 'recent' | 'starred' | 'shared' | 'trash';
313
+ const navView = ref<NavView>('');
314
+ /** Where the view was entered from, so "up" goes back there. */
315
+ const navViewOrigin = ref<string>('');
316
+ /** Sentinel parked in `dirname` so the breadcrumb can label the view. */
317
+ const NAV_VIEW_DIRNAME: Record<Exclude<NavView, '' | 'trash'>, string> = {
318
+ recent: '.recent',
319
+ starred: '.starred',
320
+ shared: '.shared',
321
+ };
322
+
302
323
  // When the caller can see exactly ONE storage, the multi-storage root is a
303
324
  // one-row list that carries no information — the user clicks through it every
304
325
  // single time. Treat that storage as the floor instead: open it directly and
@@ -326,6 +347,10 @@ const canGoUp = computed(() => {
326
347
  // there's no real backend folder to mutate. New Folder / Upload /
327
348
  // Paste are hidden in this state.
328
349
  const atVirtualRoot = computed(() => {
350
+ // gezinti:g1 — a virtual view (Recent / Starred / Shared with me) has no
351
+ // backend folder behind it either. "New folder" there would have to invent a
352
+ // destination, and "upload" would have to guess one.
353
+ if (navView.value && navView.value !== 'trash') return true;
329
354
  if (!multiStorageRoot.value) return false;
330
355
  return !((currentPath.value ?? '').replace(/^\/+|\/+$/g, ''));
331
356
  });
@@ -337,6 +362,11 @@ function goUp() {
337
362
  void load(trashOrigin.value);
338
363
  return;
339
364
  }
365
+ /* gezinti:g1 — the other virtual views behave the same way. */
366
+ if (navView.value) {
367
+ void load(navViewOrigin.value);
368
+ return;
369
+ }
340
370
  const cur = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
341
371
  if (!cur || cur === rootFloor) return;
342
372
  // The button is hidden here, but Alt+↑ / Backspace still route through.
@@ -545,6 +575,265 @@ function closeInspector() {
545
575
  persistInspector(false);
546
576
  }
547
577
  }
578
+
579
+ /* === gezinti:g1 — navigation panel (SideNav) =============================
580
+ * One explorer with the navigation everybody already knows, collapsible so the
581
+ * existing UI keeps its width when somebody does not want it (GitHub #14).
582
+ *
583
+ * The panel is NOT gated on role or profile: administrators get it too, and
584
+ * `uiProfile` only changes the rest of the chrome. Gating it would be exactly
585
+ * the "one behaviour on one surface" split this shared package exists to
586
+ * prevent. */
587
+ const uiProfile = computed(() => props.config.uiProfile ?? 'standard');
588
+ const simpleUi = computed(() => uiProfile.value === 'simple');
589
+
590
+ const SIDENAV_LS_KEY = 'filex.sidenav';
591
+ const sideNavExpanded = ref<boolean>(
592
+ (() => {
593
+ try {
594
+ const v = localStorage.getItem(SIDENAV_LS_KEY);
595
+ if (v === '1') return true;
596
+ if (v === '0') return false;
597
+ } catch {
598
+ /* private mode / embed with site data blocked */
599
+ }
600
+ // No stored choice: expanded. A collapsed default would ship a navigation
601
+ // panel most people never discover, which is the problem it was built for.
602
+ return true;
603
+ })(),
604
+ );
605
+ function persistSideNav(v: boolean) {
606
+ try {
607
+ localStorage.setItem(SIDENAV_LS_KEY, v ? '1' : '0');
608
+ } catch {
609
+ /* quota / private mode — the choice just will not survive the session */
610
+ }
611
+ }
612
+ /**
613
+ * Narrow mode: the panel is a drawer over the listing, and this is its open
614
+ * state. Deliberately NOT persisted and NOT the same ref as the desktop
615
+ * collapse: at 390px a remembered "expanded" would reopen the drawer on top of
616
+ * the files every single time the explorer mounts.
617
+ */
618
+ const navDrawerOpen = ref(false);
619
+ /**
620
+ * Is the panel part of this deployment at all? On by default everywhere —
621
+ * except under `rootPath`, where there is no storage list to show and the views
622
+ * would list files from outside the folder the embed was confined to.
623
+ */
624
+ const sideNavEnabled = computed(() => props.config.sideNav ?? !rootPathProp);
625
+ const navVisible = computed(
626
+ () => sideNavEnabled.value && (isNarrow.value ? navDrawerOpen.value : true),
627
+ );
628
+ /** What the toolbar toggle reports as pressed. */
629
+ const navToggleOn = computed(() =>
630
+ isNarrow.value ? navDrawerOpen.value : sideNavExpanded.value,
631
+ );
632
+ function toggleSideNav() {
633
+ if (!sideNavEnabled.value) return;
634
+ if (isNarrow.value) {
635
+ navDrawerOpen.value = !navDrawerOpen.value;
636
+ return;
637
+ }
638
+ sideNavExpanded.value = !sideNavExpanded.value;
639
+ persistSideNav(sideNavExpanded.value);
640
+ }
641
+ function closeNavDrawer() {
642
+ navDrawerOpen.value = false;
643
+ }
644
+
645
+ /** Storages the caller reaches only through a grant — marked in the panel. */
646
+ const sharedStorageNames = ref<string[]>([]);
647
+
648
+ /**
649
+ * The Connections entries — "How to connect" and "API keys" — and the two
650
+ * overlays they open. Both surfaces already lived in this package
651
+ * (ConnectionsPanel, TokensPanel) and neither was reachable from inside the
652
+ * explorer: our own web app wired the buttons in its page shell, so an
653
+ * embedder's users had no path to a protocol guide or to the API token those
654
+ * guides tell them to use.
655
+ *
656
+ * ⚠ Not gated on role. The backend decides — ConnectionsPanel renders what the
657
+ * API returns (a non-admin gets the guides and a "why not" card instead of the
658
+ * storage form), and /api/tokens caps every scope against the caller's own role.
659
+ */
660
+ const connectionsEnabled = computed(() => props.config.connections ?? !simpleUi.value);
661
+ const showConnections = ref(false);
662
+ const showTokens = ref(false);
663
+ function openConnections() {
664
+ showTokens.value = false;
665
+ showConnections.value = true;
666
+ }
667
+ function openTokens() {
668
+ showConnections.value = false;
669
+ showTokens.value = true;
670
+ }
671
+ function closeOverlays() {
672
+ showConnections.value = false;
673
+ showTokens.value = false;
674
+ }
675
+ const anyOverlayOpen = computed(() => showConnections.value || showTokens.value);
676
+ /** ConnectionsPanel reports its own failures; surface them the way the
677
+ * explorer surfaces everything else rather than swallowing them. */
678
+ function onConnectionsError(err: unknown) {
679
+ const msg = err instanceof Error ? err.message : String(err);
680
+ emit('error', { message: msg, context: { op: 'connections' } });
681
+ flashToast(msg);
682
+ }
683
+
684
+ /**
685
+ * View modes the toolbar offers. `simple` drops gallery: a four-way switcher
686
+ * is one of the things #14 named as power-user chrome, and gallery is the one
687
+ * nobody outside a photo folder reaches for.
688
+ */
689
+ const allowedViewModes = computed<ViewMode[] | undefined>(() =>
690
+ simpleUi.value ? ['list', 'grid'] : undefined,
691
+ );
692
+ // A stored 'gallery' outlives a switch to the simple profile, and the button
693
+ // that would take the user back out of it is the one the profile hides.
694
+ watchEffect(() => {
695
+ if (simpleUi.value && viewMode.value === 'gallery') viewMode.value = 'grid';
696
+ });
697
+
698
+ /**
699
+ * nodeRowToFileNode — the starred / recently-opened endpoints answer with raw
700
+ * node rows (relative `path`, numeric `storage_id`), not the listing shape.
701
+ *
702
+ * The storage NAME is what a qualified path needs, and a node row does not
703
+ * carry the id-to-name mapping. The backend fills `storage` for exactly this
704
+ * (handlers/shared.go, attachStorageNames); against an older server the only
705
+ * safe fallback is the single-storage case — guessing in a multi-storage
706
+ * install sends the user to a path in somebody else's drive.
707
+ */
708
+ function nodeRowToFileNode(row: Record<string, unknown>): FileNode | null {
709
+ const rel = String(row?.path ?? '').replace(/^\/+/, '');
710
+ if (!rel) return null;
711
+ const configured = props.config.storages ?? [];
712
+ const storageName =
713
+ typeof row.storage === 'string' && row.storage
714
+ ? row.storage
715
+ : configured.length === 1
716
+ ? configured[0].name
717
+ : '';
718
+ if (multiStorageRoot.value && !storageName) return null;
719
+ const name = String(row.name ?? rel.split('/').pop() ?? '');
720
+ const isDir = row.type === 'dir';
721
+ const size = typeof row.size === 'number' ? row.size : 0;
722
+ const id = typeof row.id === 'number' ? row.id : undefined;
723
+ return {
724
+ type: isDir ? 'dir' : 'file',
725
+ id,
726
+ path: storageName ? `${storageName}://${rel}` : rel,
727
+ basename: name,
728
+ extension: isDir
729
+ ? ''
730
+ : name.includes('.')
731
+ ? (name.split('.').pop() || '').toLowerCase()
732
+ : '',
733
+ storage: storageName,
734
+ visibility: 'private',
735
+ size,
736
+ file_size: size,
737
+ mime_type: typeof row.mime === 'string' ? row.mime : '',
738
+ // Keyed by node id. A file with no rendered thumbnail 404s here and the
739
+ // view falls back to its icon — the contract the ordinary listing has too.
740
+ thumb_url: !isDir && id !== undefined ? `/api/files/thumb/${id}` : undefined,
741
+ extra_metadata: {},
742
+ } as unknown as FileNode;
743
+ }
744
+
745
+ /** GET one of the view endpoints. Returns rows already in listing shape. */
746
+ async function fetchNavRows(kind: 'recent' | 'starred' | 'shared'): Promise<FileNode[]> {
747
+ const base = props.config.apiBase ?? '';
748
+ const url =
749
+ kind === 'shared'
750
+ ? `${base}/api/files/manager/shared-with-me?limit=200`
751
+ : kind === 'starred'
752
+ ? `${base}/api/files/manager/star/list?limit=200`
753
+ : `${base}/api/files/manager/recent?limit=50`;
754
+ // ⚠ await. `buildAuthHeaders` is async because a token may be a function the
755
+ // desktop shell resolves per call; spreading the un-awaited promise sends the
756
+ // request with no Authorization header and it fails silently with a 401.
757
+ const res = await fetch(url, {
758
+ headers: await buildAuthHeaders(),
759
+ // ⚠ NOT 'include' — same reason as loadStarred: a credentialed
760
+ // cross-origin request cannot be answered with `ACAO: *`.
761
+ credentials: api.credentialsMode(),
762
+ });
763
+ if (!res.ok) throw new Error(String(res.status));
764
+ const body = await res.json();
765
+ if (kind === 'shared') {
766
+ // The shared endpoint already answers in the listing shape, and reports
767
+ // which storages are grant-only in the same call.
768
+ sharedStorageNames.value = Array.isArray(body?.storages) ? body.storages : [];
769
+ return (Array.isArray(body?.files) ? body.files : []) as FileNode[];
770
+ }
771
+ const rows: Record<string, unknown>[] = Array.isArray(body?.nodes) ? body.nodes : [];
772
+ return rows.map(nodeRowToFileNode).filter((n): n is FileNode => n !== null);
773
+ }
774
+
775
+ /** Open one of the panel views in the main pane. */
776
+ async function loadNavView(kind: Exclude<NavView, ''>) {
777
+ closeNavDrawer();
778
+ if (kind === 'trash') {
779
+ await loadTrash();
780
+ // ⚠ After loadTrash, not before: loadTrash goes through load()-adjacent
781
+ // state and the mode has to be the last word, or the panel row for Trash
782
+ // never lights up.
783
+ navView.value = 'trash';
784
+ return;
785
+ }
786
+ loading.value = true;
787
+ navViewOrigin.value = currentPath.value ?? '';
788
+ navView.value = kind;
789
+ trashMode.value = false;
790
+ e2eRoot.value = '';
791
+ selection.clear();
792
+ try {
793
+ files.value = await fetchNavRows(kind);
794
+ dirname.value = NAV_VIEW_DIRNAME[kind];
795
+ currentPath.value = NAV_VIEW_DIRNAME[kind];
796
+ // These three span every storage, so the crumb reads "/ > Starred", not
797
+ // "/ > My files > Starred", which would name a storage half the rows are
798
+ // not in. Trash keeps its storage crumb: trash IS per-storage.
799
+ adapter.value = '';
800
+ } catch (err) {
801
+ const msg = err instanceof Error ? err.message : String(err);
802
+ files.value = [];
803
+ emit('error', { message: msg, context: { op: `nav-view:${kind}` } });
804
+ flashToast(msg);
805
+ } finally {
806
+ loading.value = false;
807
+ }
808
+ }
809
+
810
+ /** Panel to a storage root. */
811
+ function openNavStorage(name: string) {
812
+ closeNavDrawer();
813
+ void load(multiStorageRoot.value ? name : '');
814
+ }
815
+
816
+ /**
817
+ * Which storages are grant-only, asked once at mount so the panel can mark them
818
+ * before anybody opens the shared view. `limit=1` on purpose: the storage list
819
+ * is built from every grant, the page size only bounds the item rows.
820
+ */
821
+ async function loadSharedStorages() {
822
+ try {
823
+ const base = props.config.apiBase ?? '';
824
+ const res = await fetch(`${base}/api/files/manager/shared-with-me?limit=1`, {
825
+ headers: await buildAuthHeaders(),
826
+ credentials: api.credentialsMode(),
827
+ });
828
+ if (!res.ok) return;
829
+ const body = await res.json();
830
+ sharedStorageNames.value = Array.isArray(body?.storages) ? body.storages : [];
831
+ } catch {
832
+ // Silent — an older backend has no such endpoint, and the panel is still
833
+ // useful without the shared markers.
834
+ }
835
+ }
836
+ /* === /gezinti:g1 === */
548
837
  // Folder summary label for the no-selection state.
549
838
  const inspectorDirLabel = computed(() => {
550
839
  if (trashMode.value) return t('node.trash');
@@ -739,6 +1028,10 @@ async function load(path?: string) {
739
1028
  // Any normal navigation exits trash mode (the trash view is entered only
740
1029
  // by opening the virtual `.trash` row, which calls loadTrash()).
741
1030
  trashMode.value = false;
1031
+ /* gezinti:g1 — and every other virtual view, for the same reason: without
1032
+ this the mode sticks and the breadcrumb keeps saying "Starred" over a
1033
+ folder listing. */
1034
+ navView.value = '';
742
1035
  let requested = path ?? currentPath.value ?? '';
743
1036
  try {
744
1037
  notFoundPath.value = '';
@@ -1079,6 +1372,9 @@ onMounted(async () => {
1079
1372
  // expose /api/files/manager/starred. Without this stars never light
1080
1373
  // up on first render even when the row IS starred server-side.
1081
1374
  void loadStarred();
1375
+ /* gezinti:g1 — which storages are grant-only, so the panel can mark them
1376
+ before anybody opens the shared view. */
1377
+ void loadSharedStorages();
1082
1378
  if (hashPersistEnabled()) {
1083
1379
  window.addEventListener('hashchange', onHashChange);
1084
1380
  }
@@ -1193,6 +1489,19 @@ useKeyboardShortcuts(rootEl, {
1193
1489
  showPreview.value = false;
1194
1490
  ctxRef.value?.hide();
1195
1491
  dismissToast();
1492
+ /* gezinti:g1 — an open Connections / API-keys overlay is the topmost thing
1493
+ on screen, so Esc dismisses that before anything under it. */
1494
+ if (anyOverlayOpen.value) {
1495
+ closeOverlays();
1496
+ return;
1497
+ }
1498
+ /* gezinti:g1 — Esc closes the navigation drawer first. It is the topmost
1499
+ thing on a narrow screen, so dismissing something underneath it while it
1500
+ covers the listing reads as Esc doing nothing. */
1501
+ if (navDrawerOpen.value) {
1502
+ closeNavDrawer();
1503
+ return;
1504
+ }
1196
1505
  /* koru:k1 — Esc closes the narrow-mode inspector overlay only; the wide
1197
1506
  side panel is a persistent surface toggled by `i` / the toolbar. */
1198
1507
  if (isNarrow.value) closeInspector();
@@ -2988,8 +3297,13 @@ const tabItems = computed(() =>
2988
3297
  // `+` floating above the toolbar.
2989
3298
  const tabsVisible = computed(
2990
3299
  () =>
2991
- tabsApi.hasMultiple.value ||
2992
- (props.config.tabStrip !== 'auto' && tabItems.value.length > 0),
3300
+ /* gezinti:g1 — the simple profile has no tab strip at all, not even once a
3301
+ second tab exists: tabs were the first thing #14 named as power-user
3302
+ chrome. The tab STATE is untouched, so switching the profile back brings
3303
+ the strip and its tabs straight back. */
3304
+ !simpleUi.value &&
3305
+ (tabsApi.hasMultiple.value ||
3306
+ (props.config.tabStrip !== 'auto' && tabItems.value.length > 0)),
2993
3307
  );
2994
3308
 
2995
3309
  // Aktif tab kullanıcıyı izler: gezinme + görünüm değişimi snapshot'a yazılır.
@@ -3075,7 +3389,9 @@ onBeforeUnmount(() => {
3075
3389
 
3076
3390
  const splitPaneRef = ref<InstanceType<typeof SecondaryPane> | null>(null);
3077
3391
  // Dar modda split devre dışı (state korunur, genişleyince geri gelir).
3078
- const splitVisible = computed(() => !!activeSplit.value && !isNarrow.value);
3392
+ const splitVisible = computed(
3393
+ () => !!activeSplit.value && !isNarrow.value && !simpleUi.value /* gezinti:g1 */,
3394
+ );
3079
3395
 
3080
3396
  function toggleSplit() {
3081
3397
  if (activeSplit.value) {
@@ -3490,7 +3806,11 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3490
3806
  :narrow="isNarrow /* bag:b4 */"
3491
3807
  :theme="themeMode /* bag:b4 */"
3492
3808
  :inspector-open="showInspector /* koru:k1 */"
3809
+ :nav-open="navToggleOn /* gezinti:g1 */"
3810
+ :nav-enabled="sideNavEnabled /* gezinti:g1 */"
3811
+ :view-modes="allowedViewModes /* gezinti:g1 */"
3493
3812
  @toggle-inspector="toggleInspector /* koru:k1 */"
3813
+ @toggle-nav="toggleSideNav /* gezinti:g1 */"
3494
3814
  @open-theme="showThemeGallery = true /* wiring:c1 */"
3495
3815
  @update:view-mode="setDisplayedViewMode($event) /* ui-fix — aktif panele */"
3496
3816
  @update:search-query="searchQuery = $event"
@@ -3508,6 +3828,43 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3508
3828
  as flex siblings (row). Without the inspector open it is visually
3509
3829
  identical to the previous direct-child fe__body. -->
3510
3830
  <div class="fe__main" :class="{ 'fe__main--split': splitVisible } /* wiring:d1 */">
3831
+ <!-- gezinti:g1 — navigation panel. First child of fe__main, the mirror of
3832
+ InspectorPanel on the right; .fe__primary already carries
3833
+ `flex: 1 1 auto; min-width: 0` so it absorbs the width with no rule of
3834
+ its own. Wide: a docked column (or a 56px icon rail when collapsed).
3835
+ Narrow: a drawer over the listing, because a column at 390px leaves
3836
+ the files 158px. -->
3837
+ <SideNav
3838
+ v-if="navVisible"
3839
+ :expanded="sideNavExpanded"
3840
+ :narrow="isNarrow"
3841
+ :active-view="navView"
3842
+ :active-storage="adapter"
3843
+ :storages="config.storages ?? []"
3844
+ :shared-storages="sharedStorageNames"
3845
+ :trash-visible="config.trashVisible !== false"
3846
+ :show-connections="connectionsEnabled"
3847
+ :can-write="canWriteHere && !atVirtualRoot && !trashActive"
3848
+ :locale="locale"
3849
+ @toggle="toggleSideNav"
3850
+ @close="closeNavDrawer"
3851
+ @open-view="loadNavView"
3852
+ @open-storage="openNavStorage"
3853
+ @upload="triggerUpload"
3854
+ @new-folder="showNewFolder = true"
3855
+ @open-connections="openConnections"
3856
+ @open-tokens="openTokens"
3857
+ />
3858
+ <!-- The drawer's scrim. A button, not a div: dismissing an overlay by
3859
+ clicking beside it has to be reachable from the keyboard too. -->
3860
+ <button
3861
+ v-if="isNarrow && navDrawerOpen"
3862
+ type="button"
3863
+ class="fe-sidenav__scrim"
3864
+ :title="t('sidenav.close')"
3865
+ :aria-label="t('sidenav.close')"
3866
+ @click="closeNavDrawer"
3867
+ ></button>
3511
3868
  <!-- ui-fix — sol panelin başlığı (breadcrumb + durum şeritleri + body)
3512
3869
  tek bir sarmalda: split modunda bu sarmal sol yarıya sığar, böylece
3513
3870
  breadcrumb tüm sayfayı değil kendi panelini kaplar (SecondaryPane'in
@@ -3709,6 +4066,43 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3709
4066
  <p class="fe-state__title">{{ t('empty.search.title') }}</p>
3710
4067
  <p class="fe-state__hint">{{ t('empty.search.hint') }}</p>
3711
4068
  </div>
4069
+ <!-- gezinti:g1 — empty panel views. Each says which list is empty and
4070
+ how it fills up; "This folder is empty" would be wrong twice over,
4071
+ because there is no folder and nothing to drop into it. -->
4072
+ <div
4073
+ v-else-if="!loading && files.length === 0 && navView && navView !== 'trash'"
4074
+ class="fe-state"
4075
+ :data-testid="`empty-${navView}`"
4076
+ >
4077
+ <svg
4078
+ class="fe-state__art"
4079
+ viewBox="0 0 120 100"
4080
+ width="110"
4081
+ height="92"
4082
+ fill="none"
4083
+ stroke="currentColor"
4084
+ stroke-width="2"
4085
+ stroke-linecap="round"
4086
+ stroke-linejoin="round"
4087
+ aria-hidden="true"
4088
+ >
4089
+ <template v-if="navView === 'recent'">
4090
+ <circle cx="60" cy="50" r="28" />
4091
+ <path d="M60 32v18l12 8" />
4092
+ </template>
4093
+ <template v-else-if="navView === 'starred'">
4094
+ <path d="M60 26l9 18.6 20.4 3-14.8 14.4 3.5 20.4L60 72.8 41.9 82.4l3.5-20.4L30.6 47.6l20.4-3z" />
4095
+ </template>
4096
+ <template v-else>
4097
+ <circle cx="84" cy="34" r="9" />
4098
+ <circle cx="36" cy="52" r="9" />
4099
+ <circle cx="84" cy="70" r="9" />
4100
+ <path d="M44.5 47.5l31-9M44.5 56.5l31 9" />
4101
+ </template>
4102
+ </svg>
4103
+ <p class="fe-state__title">{{ t(`empty.${navView}.title`) }}</p>
4104
+ <p class="fe-state__hint">{{ t(`empty.${navView}.hint`) }}</p>
4105
+ </div>
3712
4106
  <!-- Empty trash view. -->
3713
4107
  <div v-else-if="!loading && files.length === 0 && trashMode" class="fe-state">
3714
4108
  <svg
@@ -3868,6 +4262,48 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
3868
4262
  </div>
3869
4263
  <!-- /koru:k1 fe__main -->
3870
4264
 
4265
+ <!-- gezinti:g1 — the Connections / API-keys overlays, opened from the
4266
+ navigation panel. ⚠ z-index 130, measured, not guessed: the explorer's
4267
+ onboarding tour and its context menus are appended to <body> at 96 and
4268
+ 90 and are `fixed`, so anything in the normal stacking order is painted
4269
+ over by them — the tour card landed on top of the same panel in the web
4270
+ app and swallowed its clicks, which is why that page uses z-[120]. This
4271
+ one has to clear the host's wrapper too, so it goes above it. -->
4272
+ <div
4273
+ v-if="showConnections || showTokens"
4274
+ class="fe-overlay"
4275
+ data-testid="explorer-overlay"
4276
+ @click.self="closeOverlays"
4277
+ >
4278
+ <div class="fe-overlay__card" @click.stop>
4279
+ <ConnectionsPanel
4280
+ v-if="showConnections"
4281
+ :config="config"
4282
+ initial-tab="connect"
4283
+ closable
4284
+ @close="closeOverlays"
4285
+ @changed="() => load()"
4286
+ @error="onConnectionsError"
4287
+ />
4288
+ <template v-else>
4289
+ <header class="fe-overlay__head">
4290
+ <h2 class="fe-overlay__title">{{ t('sidenav.apikeys') }}</h2>
4291
+ <button
4292
+ type="button"
4293
+ class="fe-overlay__close"
4294
+ :title="t('overlay.close')"
4295
+ :aria-label="t('overlay.close')"
4296
+ @click="closeOverlays"
4297
+ >
4298
+ ×
4299
+ </button>
4300
+ </header>
4301
+ <TokensPanel :config="config" full />
4302
+ </template>
4303
+ </div>
4304
+ </div>
4305
+
4306
+
3871
4307
  <div v-if="dragOver" class="fe__dragover">
3872
4308
  <div class="fe__dragover-card">
3873
4309
  <span class="fe-icon">⬆</span>
@@ -61,6 +61,16 @@ const floorLabel = computed(() => {
61
61
 
62
62
  const { t } = useLocale(() => props.locale);
63
63
 
64
+ /* gezinti:g1 — sentinel dirname segment → locale key. The explorer parks these
65
+ in `dirname` while a virtual view (trash, recent, starred, shared) is on
66
+ screen; each has no real folder behind it. */
67
+ const VIRTUAL_SEGMENTS: Record<string, string> = {
68
+ '.trash': 'node.trash',
69
+ '.recent': 'node.recent',
70
+ '.starred': 'node.starred',
71
+ '.shared': 'node.shared',
72
+ };
73
+
64
74
  const emit = defineEmits<{
65
75
  (e: 'navigate', adapterPath: string): void;
66
76
  (e: 'copy-path', adapterPath: string): void;
@@ -120,7 +130,10 @@ const crumbs = computed<Crumb[]>(() => {
120
130
  let acc = '';
121
131
  for (const part of parts) {
122
132
  acc = acc ? `${acc}/${part}` : part;
123
- const label = part === '.trash' ? t('node.trash') : part;
133
+ // gezinti:g1 the sentinel segments the virtual views park in `dirname`
134
+ // (`.trash` predates them). Without a mapping the crumb reads ".starred",
135
+ // which is a filename the user never typed and cannot navigate to.
136
+ const label = VIRTUAL_SEGMENTS[part] ? t(VIRTUAL_SEGMENTS[part]) : part;
124
137
  out.push({ label, adapterPath: `${adapterPrefix}${acc}` });
125
138
  }
126
139
  return out;
@@ -47,7 +47,15 @@ async function load() {
47
47
  });
48
48
  if (res.ok) {
49
49
  const body = await res.json();
50
- items.value = Array.isArray(body.entries) ? body.entries : (Array.isArray(body) ? body : []);
50
+ // `nodes` is what the endpoint actually answers with; this read `entries`
51
+ // only, so the tray was empty on every server that ever served it.
52
+ items.value = Array.isArray(body.nodes)
53
+ ? body.nodes
54
+ : Array.isArray(body.entries)
55
+ ? body.entries
56
+ : Array.isArray(body)
57
+ ? body
58
+ : [];
51
59
  }
52
60
  } catch (err) {
53
61
  emit('error', err instanceof Error ? err.message : String(err));