@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.
- package/README.md +35 -0
- package/dist/filex-core.js +13515 -12646
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +71 -71
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +78 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +440 -4
- package/src/components/Breadcrumb.vue +14 -1
- package/src/components/RecentlyOpened.vue +9 -1
- package/src/components/SideNav.vue +409 -0
- package/src/components/TokensPanel.vue +109 -1
- package/src/components/Toolbar.vue +98 -3
- package/src/lib/listing.ts +10 -2
- package/src/locales/en.ts +36 -2
- package/src/locales/tr.ts +36 -2
- package/src/styles/base.css +360 -0
- package/src/types/ExplorerConfig.ts +68 -0
package/src/FileExplorer.vue
CHANGED
|
@@ -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
|
-
|
|
2992
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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));
|