@brftech/filex-core 0.31.0 → 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.
@@ -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
- const moreActions = computed<ContextAction[]>(() => {
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
- <div class="fe-search">
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
- <div class="fe-toolbar__view" role="tablist" :aria-label="t('toolbar.view_label') /* wiring:c4 — was hardcoded EN */">
630
- <button
631
- type="button"
632
- v-if="offersView('list')"
633
- class="fe-btn fe-btn--icon-only"
634
- :class="{ 'is-active': viewMode === 'list' }"
635
- role="tab"
636
- :aria-selected="viewMode === 'list'"
637
- :title="t('toolbar.view.list')"
638
- :aria-label="t('toolbar.view.list') /* wiring:c4 */"
639
- @click="emit('update:viewMode', 'list')"
640
- >
641
- <span class="fe-icon" aria-hidden="true">☰</span>
642
- </button>
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
- v-if="offersView('grid')"
646
- class="fe-btn fe-btn--icon-only"
647
- :class="{ 'is-active': viewMode === 'grid' }"
648
- role="tab"
649
- :aria-selected="viewMode === 'grid'"
650
- :title="t('toolbar.view.grid')"
651
- :aria-label="t('toolbar.view.grid') /* wiring:c4 */"
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
- <span class="fe-icon" aria-hidden="true">▦</span>
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
- <!-- wiring:d2 — üçüncü görünüm: galeri -->
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
- :class="{ 'is-active': viewMode === 'gallery' }"
662
- role="tab"
663
- :aria-selected="viewMode === 'gallery'"
664
- :title="t('toolbar.view.gallery')"
665
- :aria-label="t('toolbar.view.gallery')"
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" aria-hidden="true">▣</span>
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="moreActions"
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;
@@ -457,6 +465,31 @@ export function useFileApi(config: ExplorerConfig) {
457
465
  return Array.isArray(data?.results) ? data.results : [];
458
466
  }
459
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
+
460
493
  async function subfolders(path: string): Promise<{ folders: FileNode[] }> {
461
494
  return jsonFetch<{ folders: FileNode[] }>(managerUrl('subfolders', { path }));
462
495
  }
@@ -627,11 +660,22 @@ export function useFileApi(config: ExplorerConfig) {
627
660
  */
628
661
  async function fetchBlob(
629
662
  path: string,
663
+ opts: { fresh?: boolean } = {},
630
664
  ): Promise<{ url: string; blob: Blob; mime: string }> {
631
665
  const headers = await authHeaders();
632
666
  const res = await fetch(previewUrl(path), {
633
667
  headers,
634
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',
635
679
  });
636
680
  if (!res.ok) {
637
681
  const text = await res.text().catch(() => '');
@@ -885,6 +929,7 @@ export function useFileApi(config: ExplorerConfig) {
885
929
  index,
886
930
  search,
887
931
  globalSearch /* bul:s3 */,
932
+ quotaMe /* surucu:d1 */,
888
933
  subfolders,
889
934
  newFolder,
890
935
  rename,
package/src/index.ts CHANGED
@@ -173,6 +173,10 @@ export {
173
173
  unlockWithEscrowKey,
174
174
  markerHasRecovery,
175
175
  markerHasEscrow,
176
+ escrowAvailability,
177
+ escrowOfferState,
178
+ addEscrowSlot,
179
+ declineEscrowSlot,
176
180
  generateRecoveryKey,
177
181
  formatRecoveryKey,
178
182
  parseRecoveryKey,
@@ -190,6 +194,8 @@ export type {
190
194
  E2eFmkMode,
191
195
  E2eRecoverySlot,
192
196
  E2eEscrowSlot,
197
+ EscrowAvailability,
198
+ EscrowOfferState,
193
199
  CreateFolderOptions,
194
200
  CreatedFolder,
195
201
  } from './lib/e2ecrypto';