@brftech/filex-core 0.30.1 → 0.32.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 +37 -2
- package/dist/filex-core.js +10571 -8278
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +95 -87
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +548 -66
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +1104 -26
- package/src/components/Breadcrumb.vue +4 -2
- package/src/components/CommandPalette.vue +18 -2
- package/src/components/E2eRecoveryUnlockModal.vue +193 -0
- package/src/components/EncryptedFolderModal.vue +10 -0
- package/src/components/FilterBar.vue +244 -0
- package/src/components/GalleryView.vue +48 -0
- package/src/components/GridView.vue +85 -3
- package/src/components/InspectorPanel.vue +231 -8
- package/src/components/ListView.vue +11 -1
- package/src/components/RecoveryKeyModal.vue +133 -0
- package/src/components/SecondaryPane.vue +15 -1
- package/src/components/SideNav.vue +329 -6
- package/src/components/StarButton.vue +27 -15
- package/src/components/Toolbar.vue +235 -49
- package/src/components/ViewSwitcher.vue +81 -0
- package/src/composables/useFileApi.ts +83 -0
- package/src/composables/useKeyboardShortcuts.ts +7 -0
- package/src/index.ts +33 -1
- package/src/lib/e2ecrypto.ts +716 -70
- package/src/lib/fileFilters.ts +143 -0
- package/src/lib/listing.ts +103 -1
- package/src/lib/star.ts +42 -0
- package/src/lib/tags.ts +105 -0
- package/src/locales/en.ts +154 -2
- package/src/locales/tr.ts +154 -2
- package/src/modals/PermissionsModal.vue +18 -2
- package/src/styles/base.css +743 -0
- package/src/types/ExplorerConfig.ts +53 -1
- package/src/types/FileNode.ts +23 -0
|
@@ -15,6 +15,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
15
15
|
import type { ViewMode } from '../types/FileNode';
|
|
16
16
|
import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
|
|
17
17
|
import ContextMenu, { type ContextAction } from './ContextMenu.vue';
|
|
18
|
+
import ViewSwitcher from './ViewSwitcher.vue';
|
|
18
19
|
import { useLocale } from '../composables/useLocale';
|
|
19
20
|
|
|
20
21
|
export type SelectionMode = 'none' | 'single-file' | 'single-dir' | 'multi';
|
|
@@ -90,6 +91,26 @@ const props = defineProps<{
|
|
|
90
91
|
* "power user tool", and gallery is the one nobody outside photos asks for.
|
|
91
92
|
*/
|
|
92
93
|
viewModes?: ViewMode[];
|
|
94
|
+
/* === surucu:d1 — the Drive shell ==================================== */
|
|
95
|
+
/**
|
|
96
|
+
* Which header this is.
|
|
97
|
+
*
|
|
98
|
+
* 'classic' (default) — the row this toolbar has always been.
|
|
99
|
+
* 'drive' — `uiProfile: 'drive'`: one search field across the
|
|
100
|
+
* width with its ⌘K/Ctrl+K hint, and the controls
|
|
101
|
+
* that belong to a *listing* rather than to the app
|
|
102
|
+
* (view switcher, details toggle) moved down onto the
|
|
103
|
+
* breadcrumb row, where the mockups put them.
|
|
104
|
+
*
|
|
105
|
+
* ⚠ Nothing is DELETED by 'drive'. Density, theme, the shortcut editor, the
|
|
106
|
+
* tour and both other view modes are all in the "⋯" menu this variant
|
|
107
|
+
* renders — the same list the narrow layout has used since bag:b4. A control
|
|
108
|
+
* that is on screen in one profile and unreachable in another is a capability
|
|
109
|
+
* split, not a preset.
|
|
110
|
+
*/
|
|
111
|
+
shell?: 'classic' | 'drive';
|
|
112
|
+
/** Folder name for the drive field's placeholder ("Search in Photos"). */
|
|
113
|
+
scopeLabel?: string;
|
|
93
114
|
}>();
|
|
94
115
|
|
|
95
116
|
const emit = defineEmits<{
|
|
@@ -106,6 +127,8 @@ const emit = defineEmits<{
|
|
|
106
127
|
(e: 'toggle-nav'): void /* gezinti:g1 */;
|
|
107
128
|
(e: 'open-theme'): void /* wiring:c1 — tema galerisi */;
|
|
108
129
|
(e: 'open-shortcut-settings'): void /* wiring:c2 */;
|
|
130
|
+
/* surucu:d1 — escalate this query into the command palette (⌘K/Ctrl+K). */
|
|
131
|
+
(e: 'open-palette', query: string): void;
|
|
109
132
|
}>();
|
|
110
133
|
|
|
111
134
|
// Density toggle — the toolbar owns the persisted preference; the parent
|
|
@@ -294,16 +317,14 @@ function offersView(v: ViewMode): boolean {
|
|
|
294
317
|
return !props.viewModes || props.viewModes.includes(v);
|
|
295
318
|
}
|
|
296
319
|
|
|
297
|
-
|
|
320
|
+
/* surucu:d1 — the tail of the "⋯" menu on its own: everything that is a
|
|
321
|
+
* SETTING of the explorer rather than an action on a file. The narrow layout
|
|
322
|
+
* shows it after the write/selection actions (unchanged); the drive header
|
|
323
|
+
* shows only this half, because in that layout the selection actions are
|
|
324
|
+
* already on screen as buttons and a menu that repeats them is a second door
|
|
325
|
+
* to the same room. */
|
|
326
|
+
const utilityActions = computed<ContextAction[]>(() => {
|
|
298
327
|
const list: ContextAction[] = [];
|
|
299
|
-
const writable =
|
|
300
|
-
!props.trashActive && !props.atVirtualRoot && props.canWrite !== false;
|
|
301
|
-
if (mode.value === 'none' && writable) {
|
|
302
|
-
list.push({ key: 'new-folder', label: t('toolbar.new_folder'), icon: '📁' });
|
|
303
|
-
if (props.pasteEnabled) list.push({ key: 'paste', label: t('ctx.paste'), icon: '📋' });
|
|
304
|
-
}
|
|
305
|
-
list.push(...toolbarItems.value);
|
|
306
|
-
if (list.length) list.push({ divider: true, key: 'bag-sep', label: '' });
|
|
307
328
|
list.push({ key: 'refresh', label: t('toolbar.refresh'), icon: '↻' });
|
|
308
329
|
list.push({
|
|
309
330
|
key: 'density',
|
|
@@ -333,6 +354,62 @@ const moreActions = computed<ContextAction[]>(() => {
|
|
|
333
354
|
return list;
|
|
334
355
|
});
|
|
335
356
|
|
|
357
|
+
const moreActions = computed<ContextAction[]>(() => {
|
|
358
|
+
const list: ContextAction[] = [];
|
|
359
|
+
const writable =
|
|
360
|
+
!props.trashActive && !props.atVirtualRoot && props.canWrite !== false;
|
|
361
|
+
if (mode.value === 'none' && writable) {
|
|
362
|
+
list.push({ key: 'new-folder', label: t('toolbar.new_folder'), icon: '📁' });
|
|
363
|
+
if (props.pasteEnabled) list.push({ key: 'paste', label: t('ctx.paste'), icon: '📋' });
|
|
364
|
+
}
|
|
365
|
+
list.push(...toolbarItems.value);
|
|
366
|
+
if (list.length) list.push({ divider: true, key: 'bag-sep', label: '' });
|
|
367
|
+
list.push(...utilityActions.value);
|
|
368
|
+
return list;
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
/* === surucu:d1 — the drive header's search field ======================
|
|
372
|
+
* ONE field, and it is the one the toolbar already had: typing here sets
|
|
373
|
+
* `searchQuery`, which the explorer answers with
|
|
374
|
+
* `?action=search&filter=…` — a real search of the folder you are standing in,
|
|
375
|
+
* exactly as before.
|
|
376
|
+
*
|
|
377
|
+
* The ⌘K chip beside it is the ESCALATION, not a second box: it opens the
|
|
378
|
+
* command palette carrying whatever has been typed, and the palette is where
|
|
379
|
+
* "everywhere" lives (the global endpoint, saved searches, and the commands).
|
|
380
|
+
* That is why the hint can sit on this field honestly — Ctrl+K from here does
|
|
381
|
+
* what the hint says, and Ctrl+K from anywhere else still opens the palette
|
|
382
|
+
* as it has since cila:c.
|
|
383
|
+
*/
|
|
384
|
+
const macLike =
|
|
385
|
+
typeof navigator !== 'undefined' &&
|
|
386
|
+
/mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent || '');
|
|
387
|
+
const paletteCombo = computed(() => (macLike ? '⌘ K' : 'Ctrl K'));
|
|
388
|
+
|
|
389
|
+
const drivePlaceholder = computed(() =>
|
|
390
|
+
props.scopeLabel
|
|
391
|
+
? t('drive.search.placeholder', { scope: props.scopeLabel })
|
|
392
|
+
: t('drive.search.placeholder_all'),
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
/** ⌘/Ctrl+K typed INSIDE the field. The global shortcut handler ignores
|
|
396
|
+
* keystrokes aimed at an input — which is correct, and it would otherwise
|
|
397
|
+
* make the hint on this very field a lie. */
|
|
398
|
+
function onSearchKeydown(ev: KeyboardEvent) {
|
|
399
|
+
if ((ev.ctrlKey || ev.metaKey) && (ev.key === 'k' || ev.key === 'K')) {
|
|
400
|
+
ev.preventDefault();
|
|
401
|
+
ev.stopPropagation();
|
|
402
|
+
emit('open-palette', localSearch.value);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/* surucu:d1 — the drive header renders the selection actions as buttons, so
|
|
407
|
+
* its "⋯" carries only the settings half; the narrow layout renders no action
|
|
408
|
+
* buttons at all and still needs the whole list. */
|
|
409
|
+
const menuActions = computed<ContextAction[]>(() =>
|
|
410
|
+
props.shell === 'drive' && !props.narrow ? utilityActions.value : moreActions.value,
|
|
411
|
+
);
|
|
412
|
+
|
|
336
413
|
function openMore() {
|
|
337
414
|
const r = moreBtnEl.value?.getBoundingClientRect();
|
|
338
415
|
moreRef.value?.show({ clientX: r ? r.right : 0, clientY: r ? r.bottom + 4 : 0 }, []);
|
|
@@ -387,6 +464,7 @@ function onMoreSelect(a: ContextAction) {
|
|
|
387
464
|
:class="{
|
|
388
465
|
'fe-toolbar--narrow': narrow /* bag:b4 */,
|
|
389
466
|
'fe-toolbar--searching': narrow && searchOpen /* bag:b4 */,
|
|
467
|
+
'fe-toolbar--drive': shell === 'drive' /* surucu:d1 */,
|
|
390
468
|
}"
|
|
391
469
|
>
|
|
392
470
|
<!-- bag:b4 — wide layout, untouched; renders exactly as before when not narrow -->
|
|
@@ -433,8 +511,11 @@ function onMoreSelect(a: ContextAction) {
|
|
|
433
511
|
<span class="fe-icon">↑</span>
|
|
434
512
|
</button>
|
|
435
513
|
|
|
514
|
+
<!-- surucu:d1 — in the drive shell the panel's "+ New" menu is the one
|
|
515
|
+
primary action; a second New Folder button here would be a second
|
|
516
|
+
front door to the same modal. -->
|
|
436
517
|
<button
|
|
437
|
-
v-if="mode === 'none' && !trashActive && !atVirtualRoot && canWrite !== false"
|
|
518
|
+
v-if="shell !== 'drive' && mode === 'none' && !trashActive && !atVirtualRoot && canWrite !== false"
|
|
438
519
|
type="button"
|
|
439
520
|
class="fe-btn fe-btn--primary"
|
|
440
521
|
:title="t('toolbar.new_folder')"
|
|
@@ -502,8 +583,45 @@ function onMoreSelect(a: ContextAction) {
|
|
|
502
583
|
|
|
503
584
|
<div class="fe-toolbar__spacer" />
|
|
504
585
|
|
|
505
|
-
<div class="fe-toolbar__search-group">
|
|
506
|
-
|
|
586
|
+
<div class="fe-toolbar__search-group" :class="{ 'fe-toolbar__search-group--drive': shell === 'drive' }">
|
|
587
|
+
<!-- surucu:d1 — the drive shell's one search field: wide, in the header,
|
|
588
|
+
with the ⌘K chip that opens the palette carrying this query. -->
|
|
589
|
+
<div v-if="shell === 'drive'" class="fe-drivesearch" data-testid="drive-search">
|
|
590
|
+
<svg
|
|
591
|
+
class="fe-ficon fe-drivesearch__glass"
|
|
592
|
+
viewBox="0 0 24 24"
|
|
593
|
+
fill="none"
|
|
594
|
+
stroke="currentColor"
|
|
595
|
+
stroke-width="1.9"
|
|
596
|
+
stroke-linecap="round"
|
|
597
|
+
aria-hidden="true"
|
|
598
|
+
focusable="false"
|
|
599
|
+
>
|
|
600
|
+
<circle cx="11" cy="11" r="6.5" />
|
|
601
|
+
<path d="M16 16l4.5 4.5" />
|
|
602
|
+
</svg>
|
|
603
|
+
<input
|
|
604
|
+
ref="searchEl"
|
|
605
|
+
type="search"
|
|
606
|
+
class="fe-drivesearch__input"
|
|
607
|
+
:placeholder="drivePlaceholder"
|
|
608
|
+
:value="localSearch"
|
|
609
|
+
:aria-label="t('toolbar.search')"
|
|
610
|
+
@input="onSearchInput"
|
|
611
|
+
@keydown="onSearchKeydown"
|
|
612
|
+
/>
|
|
613
|
+
<button
|
|
614
|
+
type="button"
|
|
615
|
+
class="fe-drivesearch__kbd"
|
|
616
|
+
:title="t('drive.search.hint_title', { combo: paletteCombo })"
|
|
617
|
+
:aria-label="t('drive.search.hint_title', { combo: paletteCombo })"
|
|
618
|
+
data-testid="drive-search-palette"
|
|
619
|
+
@click="emit('open-palette', localSearch)"
|
|
620
|
+
>
|
|
621
|
+
{{ paletteCombo }}
|
|
622
|
+
</button>
|
|
623
|
+
</div>
|
|
624
|
+
<div v-else class="fe-search">
|
|
507
625
|
<input
|
|
508
626
|
ref="searchEl"
|
|
509
627
|
type="search"
|
|
@@ -515,7 +633,7 @@ function onMoreSelect(a: ContextAction) {
|
|
|
515
633
|
/>
|
|
516
634
|
</div>
|
|
517
635
|
<button
|
|
518
|
-
v-if="!atVirtualRoot && canWrite !== false"
|
|
636
|
+
v-if="shell !== 'drive' && !atVirtualRoot && canWrite !== false"
|
|
519
637
|
type="button"
|
|
520
638
|
class="fe-btn fe-btn--icon-only"
|
|
521
639
|
:title="t('toolbar.upload')"
|
|
@@ -533,9 +651,26 @@ function onMoreSelect(a: ContextAction) {
|
|
|
533
651
|
>
|
|
534
652
|
<span class="fe-icon" aria-hidden="true">↻</span>
|
|
535
653
|
</button>
|
|
654
|
+
<!-- surucu:d1 — density, theme, the shortcut editor, the tour and the
|
|
655
|
+
other view modes. Everything the drive header does not draw is one
|
|
656
|
+
click away here; nothing is removed from the build. -->
|
|
657
|
+
<button
|
|
658
|
+
v-if="shell === 'drive'"
|
|
659
|
+
ref="moreBtnEl"
|
|
660
|
+
type="button"
|
|
661
|
+
class="fe-btn fe-btn--icon-only"
|
|
662
|
+
:title="t('toolbar.more')"
|
|
663
|
+
:aria-label="t('toolbar.more')"
|
|
664
|
+
aria-haspopup="menu"
|
|
665
|
+
data-testid="drive-more"
|
|
666
|
+
@click="openMore"
|
|
667
|
+
>
|
|
668
|
+
<span class="fe-icon" aria-hidden="true">⋯</span>
|
|
669
|
+
</button>
|
|
536
670
|
</div>
|
|
537
671
|
|
|
538
672
|
<button
|
|
673
|
+
v-if="shell !== 'drive'"
|
|
539
674
|
type="button"
|
|
540
675
|
class="fe-btn fe-btn--icon-only fe-toolbar__density"
|
|
541
676
|
:class="{ 'is-active': density === 'compact' }"
|
|
@@ -573,8 +708,11 @@ function onMoreSelect(a: ContextAction) {
|
|
|
573
708
|
</svg>
|
|
574
709
|
</button>
|
|
575
710
|
|
|
576
|
-
<!-- koru:k1 — inspector (details panel) toggle
|
|
711
|
+
<!-- koru:k1 — inspector (details panel) toggle.
|
|
712
|
+
surucu:d1 — the drive shell renders it on the breadcrumb row instead,
|
|
713
|
+
beside the view switcher, where both belong to the listing. -->
|
|
577
714
|
<button
|
|
715
|
+
v-if="shell !== 'drive'"
|
|
578
716
|
type="button"
|
|
579
717
|
class="fe-btn fe-btn--icon-only fe-toolbar__inspector"
|
|
580
718
|
:class="{ 'is-active': inspectorOpen }"
|
|
@@ -601,6 +739,7 @@ function onMoreSelect(a: ContextAction) {
|
|
|
601
739
|
|
|
602
740
|
<!-- wiring:c1 — tema galerisi (palet ikonu) -->
|
|
603
741
|
<button
|
|
742
|
+
v-if="shell !== 'drive'"
|
|
604
743
|
type="button"
|
|
605
744
|
class="fe-btn fe-btn--icon-only fe-toolbar__theme"
|
|
606
745
|
:title="t('theme.menu')"
|
|
@@ -626,49 +765,96 @@ function onMoreSelect(a: ContextAction) {
|
|
|
626
765
|
</button>
|
|
627
766
|
<!-- /wiring:c1 -->
|
|
628
767
|
|
|
629
|
-
<
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
768
|
+
<ViewSwitcher
|
|
769
|
+
v-if="shell !== 'drive'"
|
|
770
|
+
:view-mode="viewMode"
|
|
771
|
+
:locale="locale"
|
|
772
|
+
:modes="viewModes"
|
|
773
|
+
@update:view-mode="emit('update:viewMode', $event)"
|
|
774
|
+
/>
|
|
775
|
+
</template>
|
|
776
|
+
|
|
777
|
+
<!-- surucu:d1 — narrow DRIVE layout: [☰] [ search ] [⋯].
|
|
778
|
+
The classic narrow row hides search behind a magnifier, which is the
|
|
779
|
+
right trade when the row also carries Up / Upload / actions. This shell
|
|
780
|
+
does not: "+ New" lives in the drawer, upload has the floating button
|
|
781
|
+
at this width already, and the whole point of the profile is that the
|
|
782
|
+
search field is the thing you see. So the field keeps the row, and only
|
|
783
|
+
the ⌘K chip goes (CSS) — a phone cannot press it, and the palette stays
|
|
784
|
+
in the "⋯" menu. -->
|
|
785
|
+
<template v-else-if="shell === 'drive'">
|
|
643
786
|
<button
|
|
787
|
+
v-if="navEnabled !== false"
|
|
644
788
|
type="button"
|
|
645
|
-
|
|
646
|
-
class="
|
|
647
|
-
:
|
|
648
|
-
|
|
649
|
-
:aria-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
@click="emit('update:viewMode', 'grid')"
|
|
789
|
+
class="fe-btn fe-btn--icon-only fe-toolbar__nav"
|
|
790
|
+
:class="{ 'is-active': navOpen }"
|
|
791
|
+
:aria-pressed="!!navOpen"
|
|
792
|
+
:title="t('toolbar.nav')"
|
|
793
|
+
:aria-label="t('toolbar.nav')"
|
|
794
|
+
data-testid="toolbar-nav"
|
|
795
|
+
@click="emit('toggle-nav')"
|
|
653
796
|
>
|
|
654
|
-
<
|
|
797
|
+
<svg
|
|
798
|
+
class="fe-ficon"
|
|
799
|
+
viewBox="0 0 24 24"
|
|
800
|
+
fill="none"
|
|
801
|
+
stroke="currentColor"
|
|
802
|
+
stroke-width="1.8"
|
|
803
|
+
stroke-linecap="round"
|
|
804
|
+
aria-hidden="true"
|
|
805
|
+
focusable="false"
|
|
806
|
+
>
|
|
807
|
+
<rect x="3.5" y="4.5" width="17" height="15" rx="2" />
|
|
808
|
+
<path d="M9.5 4.5v15" />
|
|
809
|
+
</svg>
|
|
655
810
|
</button>
|
|
656
|
-
|
|
811
|
+
<div class="fe-drivesearch" data-testid="drive-search">
|
|
812
|
+
<svg
|
|
813
|
+
class="fe-ficon fe-drivesearch__glass"
|
|
814
|
+
viewBox="0 0 24 24"
|
|
815
|
+
fill="none"
|
|
816
|
+
stroke="currentColor"
|
|
817
|
+
stroke-width="1.9"
|
|
818
|
+
stroke-linecap="round"
|
|
819
|
+
aria-hidden="true"
|
|
820
|
+
focusable="false"
|
|
821
|
+
>
|
|
822
|
+
<circle cx="11" cy="11" r="6.5" />
|
|
823
|
+
<path d="M16 16l4.5 4.5" />
|
|
824
|
+
</svg>
|
|
825
|
+
<input
|
|
826
|
+
ref="searchEl"
|
|
827
|
+
type="search"
|
|
828
|
+
class="fe-drivesearch__input"
|
|
829
|
+
:placeholder="drivePlaceholder"
|
|
830
|
+
:value="localSearch"
|
|
831
|
+
:aria-label="t('toolbar.search')"
|
|
832
|
+
@input="onSearchInput"
|
|
833
|
+
@keydown="onSearchKeydown"
|
|
834
|
+
/>
|
|
835
|
+
<button
|
|
836
|
+
type="button"
|
|
837
|
+
class="fe-drivesearch__kbd"
|
|
838
|
+
:title="t('drive.search.hint_title', { combo: paletteCombo })"
|
|
839
|
+
:aria-label="t('drive.search.hint_title', { combo: paletteCombo })"
|
|
840
|
+
data-testid="drive-search-palette"
|
|
841
|
+
@click="emit('open-palette', localSearch)"
|
|
842
|
+
>
|
|
843
|
+
{{ paletteCombo }}
|
|
844
|
+
</button>
|
|
845
|
+
</div>
|
|
657
846
|
<button
|
|
847
|
+
ref="moreBtnEl"
|
|
658
848
|
type="button"
|
|
659
|
-
v-if="offersView('gallery')"
|
|
660
849
|
class="fe-btn fe-btn--icon-only"
|
|
661
|
-
:
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
@click="emit('update:viewMode', 'gallery')"
|
|
850
|
+
:title="t('toolbar.more')"
|
|
851
|
+
:aria-label="t('toolbar.more')"
|
|
852
|
+
aria-haspopup="menu"
|
|
853
|
+
data-testid="drive-more"
|
|
854
|
+
@click="openMore"
|
|
667
855
|
>
|
|
668
|
-
<span class="fe-icon"
|
|
856
|
+
<span class="fe-icon">⋯</span>
|
|
669
857
|
</button>
|
|
670
|
-
<!-- /wiring:d2 -->
|
|
671
|
-
</div>
|
|
672
858
|
</template>
|
|
673
859
|
|
|
674
860
|
<!-- bag:b4 — narrow layout: [↑?] ... [🔍] [⬆] [⋯], search expands full-width -->
|
|
@@ -778,7 +964,7 @@ function onMoreSelect(a: ContextAction) {
|
|
|
778
964
|
:locale="locale"
|
|
779
965
|
:theme="theme || 'auto'"
|
|
780
966
|
:sheet="coarse"
|
|
781
|
-
:actions="
|
|
967
|
+
:actions="menuActions"
|
|
782
968
|
@select="onMoreSelect"
|
|
783
969
|
/>
|
|
784
970
|
</template>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ViewSwitcher — the list / grid / gallery toggle.
|
|
4
|
+
*
|
|
5
|
+
* Lifted out of Toolbar.vue unchanged (same classes, same glyphs, same
|
|
6
|
+
* `role="tablist"` semantics) because the `drive` profile puts it on the
|
|
7
|
+
* breadcrumb row instead of in the header, and two copies of a switcher is
|
|
8
|
+
* how the two rows end up offering different modes. One component, mounted
|
|
9
|
+
* in whichever row the profile asks for.
|
|
10
|
+
*
|
|
11
|
+
* `modes` absent = all three, so every embedder that predates `uiProfile`
|
|
12
|
+
* keeps the switcher it had.
|
|
13
|
+
*
|
|
14
|
+
* ⚠ No `<style>` block — package CSS lives in `styles/base.css` (a scoped
|
|
15
|
+
* block's `data-v` hash does not match in the web-component build).
|
|
16
|
+
*/
|
|
17
|
+
import { useLocale } from '../composables/useLocale';
|
|
18
|
+
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
19
|
+
import type { ViewMode } from '../types/FileNode';
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
viewMode: ViewMode;
|
|
23
|
+
locale: LocaleCode;
|
|
24
|
+
modes?: ViewMode[];
|
|
25
|
+
}>();
|
|
26
|
+
|
|
27
|
+
const emit = defineEmits<{ (e: 'update:viewMode', v: ViewMode): void }>();
|
|
28
|
+
|
|
29
|
+
const { t } = useLocale(() => props.locale);
|
|
30
|
+
|
|
31
|
+
function offers(v: ViewMode): boolean {
|
|
32
|
+
return !props.modes || props.modes.includes(v);
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<div class="fe-toolbar__view" role="tablist" :aria-label="t('toolbar.view_label')">
|
|
38
|
+
<button
|
|
39
|
+
v-if="offers('list')"
|
|
40
|
+
type="button"
|
|
41
|
+
class="fe-btn fe-btn--icon-only"
|
|
42
|
+
:class="{ 'is-active': viewMode === 'list' }"
|
|
43
|
+
role="tab"
|
|
44
|
+
:aria-selected="viewMode === 'list'"
|
|
45
|
+
:title="t('toolbar.view.list')"
|
|
46
|
+
:aria-label="t('toolbar.view.list')"
|
|
47
|
+
data-testid="view-list"
|
|
48
|
+
@click="emit('update:viewMode', 'list')"
|
|
49
|
+
>
|
|
50
|
+
<span class="fe-icon" aria-hidden="true">☰</span>
|
|
51
|
+
</button>
|
|
52
|
+
<button
|
|
53
|
+
v-if="offers('grid')"
|
|
54
|
+
type="button"
|
|
55
|
+
class="fe-btn fe-btn--icon-only"
|
|
56
|
+
:class="{ 'is-active': viewMode === 'grid' }"
|
|
57
|
+
role="tab"
|
|
58
|
+
:aria-selected="viewMode === 'grid'"
|
|
59
|
+
:title="t('toolbar.view.grid')"
|
|
60
|
+
:aria-label="t('toolbar.view.grid')"
|
|
61
|
+
data-testid="view-grid"
|
|
62
|
+
@click="emit('update:viewMode', 'grid')"
|
|
63
|
+
>
|
|
64
|
+
<span class="fe-icon" aria-hidden="true">▦</span>
|
|
65
|
+
</button>
|
|
66
|
+
<button
|
|
67
|
+
v-if="offers('gallery')"
|
|
68
|
+
type="button"
|
|
69
|
+
class="fe-btn fe-btn--icon-only"
|
|
70
|
+
:class="{ 'is-active': viewMode === 'gallery' }"
|
|
71
|
+
role="tab"
|
|
72
|
+
:aria-selected="viewMode === 'gallery'"
|
|
73
|
+
:title="t('toolbar.view.gallery')"
|
|
74
|
+
:aria-label="t('toolbar.view.gallery')"
|
|
75
|
+
data-testid="view-gallery"
|
|
76
|
+
@click="emit('update:viewMode', 'gallery')"
|
|
77
|
+
>
|
|
78
|
+
<span class="fe-icon" aria-hidden="true">▣</span>
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
</template>
|
|
@@ -77,6 +77,14 @@ export interface Grant {
|
|
|
77
77
|
inherited?: boolean;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/** surucu:d1 — `GET /api/files/quota/me` (quota.Snapshot). */
|
|
81
|
+
export interface QuotaSnapshot {
|
|
82
|
+
used_bytes: number;
|
|
83
|
+
quota_bytes: number;
|
|
84
|
+
percent_used: number;
|
|
85
|
+
unlimited: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
80
88
|
export interface PermissionsResponse {
|
|
81
89
|
path: string;
|
|
82
90
|
storage_rbac: boolean;
|
|
@@ -226,6 +234,9 @@ export function resolveEndpoints(config: ExplorerConfig): EndpointMap {
|
|
|
226
234
|
// filex trash: list soft-deleted nodes + restore one by node id.
|
|
227
235
|
trashList: derive(config.trashList, '/api/files/manager/trash'),
|
|
228
236
|
trashRestore: derive(config.trashRestore, '/api/files/manager/restore'),
|
|
237
|
+
/* wiring:e2 — escrow proof-of-possession, then the owner is told. */
|
|
238
|
+
e2eEscrowChallenge: derive(config.e2eEscrowChallenge, '/api/files/e2e/escrow/challenge'),
|
|
239
|
+
e2eEscrowUsed: derive(config.e2eEscrowUsed, '/api/files/e2e/escrow/used'),
|
|
229
240
|
};
|
|
230
241
|
}
|
|
231
242
|
|
|
@@ -454,6 +465,31 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
454
465
|
return Array.isArray(data?.results) ? data.results : [];
|
|
455
466
|
}
|
|
456
467
|
|
|
468
|
+
/* === surucu:d1 — the signed-in person's storage line ==================
|
|
469
|
+
* `GET /api/files/quota/me` → `{used_bytes, quota_bytes, percent_used,
|
|
470
|
+
* unlimited}` (internal/quota/service.go `Snapshot`). Derived from the
|
|
471
|
+
* manager endpoint the same way permissions and search are, so a proxy that
|
|
472
|
+
* forwards /api/files/* keeps working.
|
|
473
|
+
*
|
|
474
|
+
* ⚠ PER-USER, never per-storage: usage is `SUM(nodes.size) WHERE owner_id=me`
|
|
475
|
+
* and there is no per-provider quota. Anything labelling this figure with a
|
|
476
|
+
* drive's name is describing a number the server did not send.
|
|
477
|
+
*
|
|
478
|
+
* ⚠ Returns null rather than throwing on ANY failure — a server without the
|
|
479
|
+
* route (404), an app token with no person behind it (403) or an older build
|
|
480
|
+
* must leave the panel exactly as it was, not put an error where a status
|
|
481
|
+
* line goes.
|
|
482
|
+
*/
|
|
483
|
+
async function quotaMe(): Promise<QuotaSnapshot | null> {
|
|
484
|
+
const base = endpoints.manager.replace(/\/manager(\?.*)?$/, '/quota/me');
|
|
485
|
+
try {
|
|
486
|
+
const q = await jsonFetch<QuotaSnapshot>(base);
|
|
487
|
+
return q && typeof q.used_bytes === 'number' ? q : null;
|
|
488
|
+
} catch {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
457
493
|
async function subfolders(path: string): Promise<{ folders: FileNode[] }> {
|
|
458
494
|
return jsonFetch<{ folders: FileNode[] }>(managerUrl('subfolders', { path }));
|
|
459
495
|
}
|
|
@@ -624,11 +660,22 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
624
660
|
*/
|
|
625
661
|
async function fetchBlob(
|
|
626
662
|
path: string,
|
|
663
|
+
opts: { fresh?: boolean } = {},
|
|
627
664
|
): Promise<{ url: string; blob: Blob; mime: string }> {
|
|
628
665
|
const headers = await authHeaders();
|
|
629
666
|
const res = await fetch(previewUrl(path), {
|
|
630
667
|
headers,
|
|
631
668
|
credentials: credentialsMode(),
|
|
669
|
+
// ⚠ The preview endpoint answers `Cache-Control: private, max-age=60`,
|
|
670
|
+
// which is right for the thing it was built for — a viewer re-opening
|
|
671
|
+
// an image — and wrong for anything the client treats as CONTROL data.
|
|
672
|
+
// `.filex-e2e.json` is control data: after the client rewrites it
|
|
673
|
+
// (recovery upgrade, escrow slot, escrow refusal) the next read inside
|
|
674
|
+
// that minute came back PRE-write, so the folder looked un-upgraded
|
|
675
|
+
// and the question filex had just been answered was asked again.
|
|
676
|
+
// Measured 2026-09-05 in a real browser: decline the escrow offer,
|
|
677
|
+
// reopen the folder, and the offer was back.
|
|
678
|
+
cache: opts.fresh ? 'no-store' : 'default',
|
|
632
679
|
});
|
|
633
680
|
if (!res.ok) {
|
|
634
681
|
const text = await res.text().catch(() => '');
|
|
@@ -687,6 +734,38 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
687
734
|
return jsonFetch<Capabilities>(endpoints.capabilities);
|
|
688
735
|
}
|
|
689
736
|
|
|
737
|
+
/* wiring:e2 — escrow use is announced, not merely performed.
|
|
738
|
+
*
|
|
739
|
+
* Ask the server for a nonce sealed to the escrow public key; only the
|
|
740
|
+
* holder of the private half can read it back. Returning it is what earns
|
|
741
|
+
* the notification to the folder's owner — a bare "I used escrow" POST
|
|
742
|
+
* would be a string anyone could send.
|
|
743
|
+
*
|
|
744
|
+
* ⚠ This is an announcement, not a gate. An operator holding the private
|
|
745
|
+
* key can decrypt the folder offline with a script and never come here.
|
|
746
|
+
* docs/E2E-ENCRYPTION.md says so plainly and must keep saying so. */
|
|
747
|
+
async function e2eEscrowChallenge(
|
|
748
|
+
path: string,
|
|
749
|
+
): Promise<{ id: string; challenge: string; kid: string }> {
|
|
750
|
+
if (!endpoints.e2eEscrowChallenge) throw new Error('e2e escrow endpoint not configured');
|
|
751
|
+
return jsonFetch(endpoints.e2eEscrowChallenge, {
|
|
752
|
+
method: 'POST',
|
|
753
|
+
body: JSON.stringify({ path }),
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function e2eEscrowUsed(payload: {
|
|
758
|
+
path: string;
|
|
759
|
+
id: string;
|
|
760
|
+
nonce: string;
|
|
761
|
+
}): Promise<{ ok: boolean; notified: boolean }> {
|
|
762
|
+
if (!endpoints.e2eEscrowUsed) throw new Error('e2e escrow endpoint not configured');
|
|
763
|
+
return jsonFetch(endpoints.e2eEscrowUsed, {
|
|
764
|
+
method: 'POST',
|
|
765
|
+
body: JSON.stringify(payload),
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
690
769
|
async function createShare(payload: {
|
|
691
770
|
path: string;
|
|
692
771
|
password?: boolean;
|
|
@@ -850,6 +929,7 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
850
929
|
index,
|
|
851
930
|
search,
|
|
852
931
|
globalSearch /* bul:s3 */,
|
|
932
|
+
quotaMe /* surucu:d1 */,
|
|
853
933
|
subfolders,
|
|
854
934
|
newFolder,
|
|
855
935
|
rename,
|
|
@@ -869,6 +949,9 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
869
949
|
// Peripheral
|
|
870
950
|
limits,
|
|
871
951
|
capabilities,
|
|
952
|
+
/* wiring:e2 */
|
|
953
|
+
e2eEscrowChallenge,
|
|
954
|
+
e2eEscrowUsed,
|
|
872
955
|
createShare,
|
|
873
956
|
listShares,
|
|
874
957
|
revokeShare,
|
|
@@ -38,6 +38,8 @@ export interface ShortcutHandlers {
|
|
|
38
38
|
onShowHelp?: () => void; // ? (Shift+/ on most layouts)
|
|
39
39
|
onToggleInspector?: () => void; // i (koru:k1 details panel)
|
|
40
40
|
onToggleHidden?: () => void; // Ctrl+Shift+. — dot-file visibility
|
|
41
|
+
/* yildiz:s1 */
|
|
42
|
+
onStar?: () => void; // S — star / unstar the selection
|
|
41
43
|
onQuickLook?: () => void; // Space (wiring:c2 quick-look overlay)
|
|
42
44
|
/* wiring:d1 — tab strip actions */
|
|
43
45
|
onTabNew?: () => void; // Ctrl+T
|
|
@@ -87,6 +89,10 @@ export const SHORTCUT_ACTIONS: ShortcutActionDef[] = [
|
|
|
87
89
|
{ id: 'inspector', defaultCombo: 'I', labelKey: 'shortcuts.inspector', groupKey: 'shortcuts.group.nav' } /* koru:k1 */,
|
|
88
90
|
{ id: 'help', defaultCombo: '?', labelKey: 'shortcuts.help', groupKey: 'shortcuts.group.nav' },
|
|
89
91
|
{ id: 'toggle-hidden', defaultCombo: 'Ctrl+Shift+.', labelKey: 'shortcuts.toggle_hidden', groupKey: 'shortcuts.group.nav' },
|
|
92
|
+
/* yildiz:s1 — starring is an action, so it belongs where the other verbs
|
|
93
|
+
* are. A bare letter like the inspector's `I`; the handler ignores events
|
|
94
|
+
* from form controls, so it never eats a keystroke meant for a filename. */
|
|
95
|
+
{ id: 'star', defaultCombo: 'S', labelKey: 'shortcuts.star', groupKey: 'shortcuts.group.file' },
|
|
90
96
|
// Selection
|
|
91
97
|
{ id: 'select-all', defaultCombo: 'Ctrl+A', labelKey: 'shortcuts.select_all', groupKey: 'shortcuts.group.selection' },
|
|
92
98
|
// File operations
|
|
@@ -115,6 +121,7 @@ const HANDLER_KEY: Record<string, keyof ShortcutHandlers> = {
|
|
|
115
121
|
close: 'onClose',
|
|
116
122
|
inspector: 'onToggleInspector',
|
|
117
123
|
'toggle-hidden': 'onToggleHidden',
|
|
124
|
+
star: 'onStar' /* yildiz:s1 */,
|
|
118
125
|
help: 'onShowHelp',
|
|
119
126
|
'select-all': 'onSelectAll',
|
|
120
127
|
rename: 'onRename',
|