@brftech/filex-core 0.29.0 → 0.30.1

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