@brftech/filex-core 0.1.84 → 0.3.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.
Files changed (39) hide show
  1. package/dist/{ArchiveViewer-wg4uE-3C.js → ArchiveViewer-CYrKPEbE.js} +2 -2
  2. package/dist/{ArchiveViewer-wg4uE-3C.js.map → ArchiveViewer-CYrKPEbE.js.map} +1 -1
  3. package/dist/{CsvViewer-57uz4UKk.js → CsvViewer-C3ZU4Uua.js} +2 -2
  4. package/dist/{CsvViewer-57uz4UKk.js.map → CsvViewer-C3ZU4Uua.js.map} +1 -1
  5. package/dist/{DrawioViewer-BfL9Hbyp.js → DrawioViewer-15ZL4ken.js} +2 -2
  6. package/dist/{DrawioViewer-BfL9Hbyp.js.map → DrawioViewer-15ZL4ken.js.map} +1 -1
  7. package/dist/{EpubViewer-CYG2CV_3.js → EpubViewer-CrXcDgmo.js} +2 -2
  8. package/dist/{EpubViewer-CYG2CV_3.js.map → EpubViewer-CrXcDgmo.js.map} +1 -1
  9. package/dist/{IpynbViewer-DJw34-wV.js → IpynbViewer-BAb9rV2Q.js} +2 -2
  10. package/dist/{IpynbViewer-DJw34-wV.js.map → IpynbViewer-BAb9rV2Q.js.map} +1 -1
  11. package/dist/{MermaidViewer-C86jjm27.js → MermaidViewer-BN8GN4FY.js} +2 -2
  12. package/dist/{MermaidViewer-C86jjm27.js.map → MermaidViewer-BN8GN4FY.js.map} +1 -1
  13. package/dist/{PsdViewer-DGHIqv-x.js → PsdViewer-o-kWQExw.js} +2 -2
  14. package/dist/{PsdViewer-DGHIqv-x.js.map → PsdViewer-o-kWQExw.js.map} +1 -1
  15. package/dist/{TiffViewer-gct9Ub3o.js → TiffViewer-AjMVBzxp.js} +2 -2
  16. package/dist/{TiffViewer-gct9Ub3o.js.map → TiffViewer-AjMVBzxp.js.map} +1 -1
  17. package/dist/{Viewer3D-BVt4l2a9.js → Viewer3D-BR3WDbxQ.js} +2 -2
  18. package/dist/{Viewer3D-BVt4l2a9.js.map → Viewer3D-BR3WDbxQ.js.map} +1 -1
  19. package/dist/filex-core.js +17 -15
  20. package/dist/filex-core.umd.cjs +41 -41
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/index-BG5upYlH.js +7392 -0
  23. package/dist/index-BG5upYlH.js.map +1 -0
  24. package/dist/index.d.ts +106 -2
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +109 -1
  28. package/src/components/CommandPalette.vue +239 -5
  29. package/src/components/ContextMenu.vue +101 -3
  30. package/src/components/ListView.vue +26 -1
  31. package/src/components/Toolbar.vue +216 -5
  32. package/src/composables/useFileApi.ts +45 -0
  33. package/src/index.ts +4 -0
  34. package/src/lib/snippet.ts +43 -0
  35. package/src/locales/en.ts +14 -0
  36. package/src/locales/tr.ts +14 -0
  37. package/src/styles/base.css +238 -0
  38. package/dist/index-BeUoODQq.js +0 -6871
  39. package/dist/index-BeUoODQq.js.map +0 -1
@@ -11,10 +11,10 @@
11
11
  * Presentational only — all logic (rename, share, …) lives in
12
12
  * FileExplorer.vue, which listens for `action` emits.
13
13
  */
14
- import { computed, onMounted, ref, watch } from 'vue';
14
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
15
15
  import type { ViewMode } from '../types/FileNode';
16
- import type { LocaleCode } from '../types/ExplorerConfig';
17
- import type { ContextAction } from './ContextMenu.vue';
16
+ import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
17
+ import ContextMenu, { type ContextAction } from './ContextMenu.vue';
18
18
  import { useLocale } from '../composables/useLocale';
19
19
 
20
20
  export type SelectionMode = 'none' | 'single-file' | 'single-dir' | 'multi';
@@ -58,6 +58,19 @@ const props = defineProps<{
58
58
  */
59
59
  canWrite?: boolean;
60
60
  locale: LocaleCode;
61
+ /**
62
+ * bag:b4 — narrow/embed mini mode. When true the toolbar collapses to
63
+ * [↑?] [🔍] [⬆] [⋯]: secondary actions (New Folder, Refresh, density,
64
+ * view switcher, selection actions, Paste) move into the "⋯" overflow
65
+ * menu and search expands from an icon to a full-width input. When
66
+ * absent/false the classic wide layout renders unchanged.
67
+ */
68
+ narrow?: boolean;
69
+ /**
70
+ * bag:b4 — resolved theme, forwarded to the teleported overflow menu
71
+ * (which loses the `.fe` variable scope under <body>).
72
+ */
73
+ theme?: ThemeMode;
61
74
  }>();
62
75
 
63
76
  const emit = defineEmits<{
@@ -114,7 +127,12 @@ function onSearchInput(ev: Event) {
114
127
  debounce = setTimeout(() => emit('update:searchQuery', v), 200);
115
128
  }
116
129
 
117
- function focusSearch() {
130
+ async function focusSearch() {
131
+ /* bag:b4 — in narrow mode the input is collapsed behind the icon. */
132
+ if (props.narrow && !searchOpen.value) {
133
+ searchOpen.value = true;
134
+ await nextTick();
135
+ }
118
136
  searchEl.value?.focus();
119
137
  searchEl.value?.select();
120
138
  }
@@ -132,10 +150,120 @@ const toolbarItems = computed(() => props.actions.filter((a) => !a.divider && !a
132
150
  function fire(key: string) {
133
151
  emit('action', key);
134
152
  }
153
+
154
+ /* === bag:b4 — narrow-mode state: expandable search + "⋯" overflow menu === */
155
+
156
+ const searchOpen = ref(false);
157
+ // Leaving narrow mode discards the transient expanded-search state so the
158
+ // wide layout always comes back exactly as it was.
159
+ watch(
160
+ () => props.narrow,
161
+ (n) => {
162
+ if (!n) searchOpen.value = false;
163
+ },
164
+ );
165
+
166
+ async function openSearch() {
167
+ searchOpen.value = true;
168
+ await nextTick();
169
+ searchEl.value?.focus();
170
+ }
171
+ function closeSearch() {
172
+ // Keep the query (it still filters the listing); the icon shows an
173
+ // active state while a query is set.
174
+ searchOpen.value = false;
175
+ }
176
+
177
+ // Coarse-pointer detection — the overflow menu renders as a bottom sheet on
178
+ // touch devices, matching the file context menu.
179
+ const coarse = ref(false);
180
+ let coarseMq: MediaQueryList | undefined;
181
+ function syncCoarse(e?: MediaQueryListEvent | MediaQueryList) {
182
+ coarse.value = !!(e && 'matches' in e && e.matches);
183
+ }
184
+ onMounted(() => {
185
+ if (typeof window === 'undefined' || !window.matchMedia) return;
186
+ coarseMq = window.matchMedia('(pointer: coarse)');
187
+ syncCoarse(coarseMq);
188
+ coarseMq.addEventListener?.('change', syncCoarse);
189
+ });
190
+ onBeforeUnmount(() => {
191
+ coarseMq?.removeEventListener?.('change', syncCoarse);
192
+ });
193
+
194
+ const moreBtnEl = ref<HTMLElement | null>(null);
195
+ const moreRef = ref<InstanceType<typeof ContextMenu> | null>(null);
196
+
197
+ // Everything the wide toolbar renders as standalone buttons, folded into one
198
+ // action list: folder-level writes (New Folder / Paste), the shared
199
+ // selection actions, then the view utilities (Refresh / density / view mode).
200
+ const moreActions = computed<ContextAction[]>(() => {
201
+ const list: ContextAction[] = [];
202
+ const writable =
203
+ !props.trashActive && !props.atVirtualRoot && props.canWrite !== false;
204
+ if (mode.value === 'none' && writable) {
205
+ list.push({ key: 'new-folder', label: t('toolbar.new_folder'), icon: '📁' });
206
+ if (props.pasteEnabled) list.push({ key: 'paste', label: t('ctx.paste'), icon: '📋' });
207
+ }
208
+ list.push(...toolbarItems.value);
209
+ if (list.length) list.push({ divider: true, key: 'bag-sep', label: '' });
210
+ list.push({ key: 'refresh', label: t('toolbar.refresh'), icon: '↻' });
211
+ list.push({
212
+ key: 'density',
213
+ label:
214
+ density.value === 'compact'
215
+ ? t('toolbar.density.comfortable')
216
+ : t('toolbar.density.compact'),
217
+ icon: '⇅',
218
+ });
219
+ list.push(
220
+ props.viewMode === 'list'
221
+ ? { key: 'view-grid', label: t('toolbar.view.grid'), icon: '▦' }
222
+ : { key: 'view-list', label: t('toolbar.view.list'), icon: '☰' },
223
+ );
224
+ return list;
225
+ });
226
+
227
+ function openMore() {
228
+ const r = moreBtnEl.value?.getBoundingClientRect();
229
+ moreRef.value?.show({ clientX: r ? r.right : 0, clientY: r ? r.bottom + 4 : 0 }, []);
230
+ }
231
+
232
+ function onMoreSelect(a: ContextAction) {
233
+ switch (a.key) {
234
+ case 'new-folder':
235
+ emit('new-folder');
236
+ break;
237
+ case 'refresh':
238
+ emit('refresh');
239
+ break;
240
+ case 'density':
241
+ toggleDensity();
242
+ break;
243
+ case 'view-list':
244
+ emit('update:viewMode', 'list');
245
+ break;
246
+ case 'view-grid':
247
+ emit('update:viewMode', 'grid');
248
+ break;
249
+ default:
250
+ fire(a.key);
251
+ }
252
+ }
253
+
254
+ /* === /bag:b4 === */
135
255
  </script>
136
256
 
137
257
  <template>
138
- <div class="fe-toolbar">
258
+ <div
259
+ class="fe-toolbar"
260
+ :class="{
261
+ 'fe-toolbar--narrow': narrow /* bag:b4 */,
262
+ 'fe-toolbar--searching': narrow && searchOpen /* bag:b4 */,
263
+ }"
264
+ >
265
+ <!-- bag:b4 — wide layout, untouched; renders exactly as before when not narrow -->
266
+ <template v-if="!narrow">
139
267
  <div class="fe-toolbar__primary">
140
268
  <button
141
269
  v-if="canGoUp"
@@ -280,5 +408,88 @@ function fire(key: string) {
280
408
  <span class="fe-icon">▦</span>
281
409
  </button>
282
410
  </div>
411
+ </template>
412
+
413
+ <!-- bag:b4 — narrow layout: [↑?] ... [🔍] [⬆] [⋯], search expands full-width -->
414
+ <template v-else>
415
+ <template v-if="searchOpen">
416
+ <div class="fe-search fe-search--full">
417
+ <input
418
+ ref="searchEl"
419
+ type="search"
420
+ class="fe-search__input"
421
+ :placeholder="t('toolbar.search.placeholder')"
422
+ :value="localSearch"
423
+ :aria-label="t('toolbar.search')"
424
+ @input="onSearchInput"
425
+ @keydown.esc.stop.prevent="closeSearch"
426
+ />
427
+ </div>
428
+ <button
429
+ type="button"
430
+ class="fe-btn fe-btn--icon-only"
431
+ :title="t('toolbar.search.close')"
432
+ :aria-label="t('toolbar.search.close')"
433
+ @click="closeSearch"
434
+ >
435
+ <span class="fe-icon">✕</span>
436
+ </button>
437
+ </template>
438
+ <template v-else>
439
+ <button
440
+ v-if="canGoUp"
441
+ type="button"
442
+ class="fe-btn fe-btn--icon-only"
443
+ :title="t('toolbar.go_up')"
444
+ :aria-label="t('toolbar.go_up')"
445
+ @click="emit('go-up')"
446
+ >
447
+ <span class="fe-icon">↑</span>
448
+ </button>
449
+
450
+ <div class="fe-toolbar__spacer" />
451
+
452
+ <button
453
+ type="button"
454
+ class="fe-btn fe-btn--icon-only fe-toolbar__search-toggle"
455
+ :class="{ 'is-active': !!localSearch }"
456
+ :title="t('toolbar.search')"
457
+ :aria-label="t('toolbar.search')"
458
+ @click="openSearch"
459
+ >
460
+ <span class="fe-icon">🔍</span>
461
+ </button>
462
+ <button
463
+ v-if="!atVirtualRoot && canWrite !== false"
464
+ type="button"
465
+ class="fe-btn fe-btn--icon-only"
466
+ :title="t('toolbar.upload')"
467
+ :aria-label="t('toolbar.upload')"
468
+ @click="emit('upload')"
469
+ >
470
+ <span class="fe-icon">⬆</span>
471
+ </button>
472
+ <button
473
+ ref="moreBtnEl"
474
+ type="button"
475
+ class="fe-btn fe-btn--icon-only fe-toolbar__more"
476
+ :title="t('toolbar.more')"
477
+ :aria-label="t('toolbar.more')"
478
+ aria-haspopup="menu"
479
+ @click="openMore"
480
+ >
481
+ <span class="fe-icon">⋯</span>
482
+ </button>
483
+ </template>
484
+
485
+ <ContextMenu
486
+ ref="moreRef"
487
+ :locale="locale"
488
+ :theme="theme || 'auto'"
489
+ :sheet="coarse"
490
+ :actions="moreActions"
491
+ @select="onMoreSelect"
492
+ />
493
+ </template>
283
494
  </div>
284
495
  </template>
@@ -101,6 +101,34 @@ export interface InviteResponse {
101
101
  emailed: boolean;
102
102
  }
103
103
 
104
+ /* === bul:s3 — global search (GET /api/files/search) === */
105
+
106
+ export type GlobalSearchScope = 'name' | 'content' | 'all';
107
+
108
+ /**
109
+ * One hit from the dedicated files-search endpoint. The backend returns raw
110
+ * node rows (`{results: [...]}`), so `path` is the IN-STORAGE relative path
111
+ * (no `adapter://` prefix) and the storage comes back as a numeric
112
+ * `storage_id`. `snippet`/`matched` are the v0.2 "Bul" contract additions —
113
+ * older backends simply omit them, so every consumer must stay
114
+ * undefined-safe.
115
+ */
116
+ export interface GlobalSearchHit {
117
+ id?: number;
118
+ storage_id?: number;
119
+ name?: string;
120
+ path?: string;
121
+ /** `file` | `dir` (backend NodeType). */
122
+ type?: string;
123
+ size?: number;
124
+ mime?: string;
125
+ /** Plain-text content snippet; matches wrapped in «» (never HTML). */
126
+ snippet?: string;
127
+ /** Where the hit matched: name | content | both. */
128
+ matched?: 'name' | 'content' | 'both';
129
+ [k: string]: unknown;
130
+ }
131
+
104
132
  /**
105
133
  * Resolve a Vuefinder-compatible endpoint map from the user's config.
106
134
  * Either `apiBase` is set (auto-derive everything) or each route is
@@ -340,6 +368,22 @@ export function useFileApi(config: ExplorerConfig) {
340
368
  return jsonFetch<ManagerResponse>(managerUrl('search', { path, filter }));
341
369
  }
342
370
 
371
+ /* === bul:s3 — global "search everywhere" ===
372
+ * Derived from the manager endpoint by swapping `/manager` for `/search`
373
+ * (same trick permissionsUrl uses), so embedded proxies that forward the
374
+ * whole /api/files/* subtree keep working. Errors and legacy backends
375
+ * degrade to an empty result list — the palette just shows nothing. */
376
+ async function globalSearch(
377
+ query: string,
378
+ opts: { limit?: number; scope?: GlobalSearchScope } = {},
379
+ ): Promise<GlobalSearchHit[]> {
380
+ const base = endpoints.manager.replace(/\/manager(\?.*)?$/, '/search');
381
+ const sep = base.includes('?') ? '&' : '?';
382
+ const url = `${base}${sep}${qs({ q: query, limit: opts.limit, scope: opts.scope })}`;
383
+ const data = await jsonFetch<{ results?: GlobalSearchHit[] | null }>(url);
384
+ return Array.isArray(data?.results) ? data.results : [];
385
+ }
386
+
343
387
  async function subfolders(path: string): Promise<{ folders: FileNode[] }> {
344
388
  return jsonFetch<{ folders: FileNode[] }>(managerUrl('subfolders', { path }));
345
389
  }
@@ -661,6 +705,7 @@ export function useFileApi(config: ExplorerConfig) {
661
705
  // Manager
662
706
  index,
663
707
  search,
708
+ globalSearch /* bul:s3 */,
664
709
  subfolders,
665
710
  newFolder,
666
711
  rename,
package/src/index.ts CHANGED
@@ -57,6 +57,10 @@ export { isExternalUsable } from './types/FileNode';
57
57
  // ——— Composables (consumers can roll their own UI on top) ———
58
58
  export { useFileApi, resolveEndpoints } from './composables/useFileApi';
59
59
  export type { FileApi, ManagerResponse, PendingOpDto } from './composables/useFileApi';
60
+ /* bul:s3 — global-search contract types + snippet helpers */
61
+ export type { GlobalSearchHit, GlobalSearchScope } from './composables/useFileApi';
62
+ export { snippetSegments, matchedInContent } from './lib/snippet';
63
+ export type { SnippetSegment, SearchMatched } from './lib/snippet';
60
64
 
61
65
  export { useUploadChunked } from './composables/useUploadChunked';
62
66
  export type { UploadJob, UploadOptions } from './composables/useUploadChunked';
@@ -0,0 +1,43 @@
1
+ /* bul:s3 */
2
+ /**
3
+ * Snippet segment parser — the search backend returns content snippets as
4
+ * PLAIN TEXT with matched words wrapped in «guillemets» (contract: no HTML
5
+ * ever crosses the wire). The UI renders highlights by splitting the string
6
+ * into segments and emitting each one as a TEXT node (a <mark> wrapper for
7
+ * matches) — never via innerHTML, so a file whose content contains markup
8
+ * can't inject anything into the page.
9
+ */
10
+
11
+ export interface SnippetSegment {
12
+ /** Literal text of this segment. Always rendered as a text node. */
13
+ text: string;
14
+ /** True when the segment was «wrapped» — render inside <mark>. */
15
+ match: boolean;
16
+ }
17
+
18
+ /**
19
+ * Split a `«»`-annotated snippet into render segments. An unpaired `«`
20
+ * degrades gracefully: the rest of the string is treated as plain text.
21
+ */
22
+ export function snippetSegments(snippet: string): SnippetSegment[] {
23
+ const out: SnippetSegment[] = [];
24
+ if (!snippet) return out;
25
+ const re = /«([^«»]*)»/g;
26
+ let last = 0;
27
+ let m: RegExpExecArray | null;
28
+ while ((m = re.exec(snippet)) !== null) {
29
+ if (m.index > last) out.push({ text: snippet.slice(last, m.index), match: false });
30
+ if (m[1]) out.push({ text: m[1], match: true });
31
+ last = m.index + m[0].length;
32
+ }
33
+ if (last < snippet.length) out.push({ text: snippet.slice(last), match: false });
34
+ return out;
35
+ }
36
+
37
+ /** Matched-in values the search contract allows on a hit. */
38
+ export type SearchMatched = 'name' | 'content' | 'both';
39
+
40
+ /** True when the hit matched (at least partly) inside file CONTENT. */
41
+ export function matchedInContent(matched: unknown): boolean {
42
+ return matched === 'content' || matched === 'both';
43
+ }
package/src/locales/en.ts CHANGED
@@ -187,4 +187,18 @@ export const en: Record<string, string> = {
187
187
  'toast.trashed': 'Moved to trash',
188
188
  'conn.offline': 'No live connection — changes may be delayed',
189
189
  'conn.tooltip': 'Live connection to the server is unavailable; the list refreshes periodically. This notice disappears once the connection is back.',
190
+
191
+ /* === bul:s3 === */
192
+ 'palette.everywhere': 'Everywhere',
193
+ 'palette.searching': 'Searching…',
194
+ 'palette.saved': 'Saved searches',
195
+ 'palette.save': 'Save search',
196
+ 'palette.saved.delete': 'Delete saved search',
197
+ 'search.in_content': 'In content',
198
+
199
+ /* === bag:b4 === */
200
+ 'toolbar.more': 'More actions',
201
+ 'toolbar.search.close': 'Close search',
202
+ 'sheet.menu': 'Actions',
203
+ 'sheet.close': 'Close',
190
204
  };
package/src/locales/tr.ts CHANGED
@@ -187,4 +187,18 @@ export const tr: Record<string, string> = {
187
187
  'toast.trashed': 'Çöpe taşındı',
188
188
  'conn.offline': 'Canlı bağlantı yok — değişiklikler gecikebilir',
189
189
  'conn.tooltip': 'Sunucuyla canlı bağlantı kurulamadı; liste belirli aralıklarla otomatik yenilenir. Bağlantı geri gelince bu uyarı kaybolur.',
190
+
191
+ /* === bul:s3 === */
192
+ 'palette.everywhere': 'Her yerde',
193
+ 'palette.searching': 'Aranıyor…',
194
+ 'palette.saved': 'Kayıtlı aramalar',
195
+ 'palette.save': 'Aramayı kaydet',
196
+ 'palette.saved.delete': 'Kayıtlı aramayı sil',
197
+ 'search.in_content': 'İçerikte',
198
+
199
+ /* === bag:b4 === */
200
+ 'toolbar.more': 'Diğer işlemler',
201
+ 'toolbar.search.close': 'Aramayı kapat',
202
+ 'sheet.menu': 'İşlemler',
203
+ 'sheet.close': 'Kapat',
190
204
  };
@@ -1830,3 +1830,241 @@ filex-explorer {
1830
1830
  border-radius: 50%;
1831
1831
  background: var(--fe-warning, #f59e0b);
1832
1832
  }
1833
+
1834
+ /* === bul:s3 === */
1835
+
1836
+ /* Palette — "Everywhere" hit rows (name + crumb + snippet stack). */
1837
+ .fe-cmdp__item--hit {
1838
+ align-items: flex-start;
1839
+ }
1840
+ .fe-cmdp__item--hit .fe-cmdp__icon {
1841
+ margin-top: 1px;
1842
+ }
1843
+ .fe-cmdp__hitbody {
1844
+ flex: 1 1 auto;
1845
+ min-width: 0;
1846
+ display: flex;
1847
+ flex-direction: column;
1848
+ gap: 2px;
1849
+ }
1850
+ .fe-cmdp__hitline {
1851
+ display: flex;
1852
+ align-items: center;
1853
+ gap: 8px;
1854
+ min-width: 0;
1855
+ }
1856
+ .fe-cmdp__badge,
1857
+ .fe-list__badge {
1858
+ flex: 0 0 auto;
1859
+ display: inline-block;
1860
+ padding: 1px 6px;
1861
+ border-radius: 999px;
1862
+ border: 1px solid color-mix(in srgb, var(--fe-primary) 35%, transparent);
1863
+ background: color-mix(in srgb, var(--fe-primary) 12%, transparent);
1864
+ color: var(--fe-primary);
1865
+ font-size: 10px;
1866
+ font-weight: 600;
1867
+ line-height: 1.5;
1868
+ white-space: nowrap;
1869
+ vertical-align: middle;
1870
+ }
1871
+ .fe-cmdp__crumb {
1872
+ overflow: hidden;
1873
+ text-overflow: ellipsis;
1874
+ white-space: nowrap;
1875
+ font-size: 11.5px;
1876
+ color: var(--fe-text-muted);
1877
+ }
1878
+ .fe-cmdp__snippet,
1879
+ .fe-list__snippet {
1880
+ display: block;
1881
+ overflow: hidden;
1882
+ text-overflow: ellipsis;
1883
+ white-space: nowrap;
1884
+ font-size: 12px;
1885
+ color: var(--fe-text-muted);
1886
+ }
1887
+ .fe-cmdp__mark,
1888
+ .fe-list__mark {
1889
+ background: color-mix(in srgb, var(--fe-primary) 22%, transparent);
1890
+ color: var(--fe-text);
1891
+ border-radius: 2px;
1892
+ padding: 0 1px;
1893
+ font-weight: 600;
1894
+ }
1895
+ .fe-cmdp__loading {
1896
+ padding: 8px 12px;
1897
+ font-size: 13px;
1898
+ color: var(--fe-text-muted);
1899
+ }
1900
+
1901
+ /* Palette — saved-search rows (div-based so the delete button nests legally). */
1902
+ .fe-cmdp__item--saved {
1903
+ user-select: none;
1904
+ }
1905
+ .fe-cmdp__del {
1906
+ flex: 0 0 auto;
1907
+ background: transparent;
1908
+ border: 0;
1909
+ border-radius: var(--fe-radius-sm);
1910
+ padding: 2px 6px;
1911
+ font: inherit;
1912
+ font-size: 13px;
1913
+ color: var(--fe-text-muted);
1914
+ cursor: pointer;
1915
+ opacity: 0.55;
1916
+ }
1917
+ .fe-cmdp__item--saved:hover .fe-cmdp__del,
1918
+ .fe-cmdp__item--saved.is-active .fe-cmdp__del {
1919
+ opacity: 1;
1920
+ }
1921
+ .fe-cmdp__del:hover {
1922
+ background: var(--fe-bg-elev);
1923
+ color: var(--fe-danger, #dc2626);
1924
+ }
1925
+ .fe-cmdp__del:focus-visible {
1926
+ outline: 2px solid var(--fe-primary);
1927
+ outline-offset: -2px;
1928
+ }
1929
+
1930
+ /* List view — search-result snippet line under the filename. */
1931
+ .fe-list__snippet {
1932
+ max-width: 100%;
1933
+ }
1934
+
1935
+ /* === bag:b4 — narrow/embed mini mode === */
1936
+ /* Everything below is scoped to `.fe--narrow`, `.fe-toolbar--narrow`,
1937
+ * `.fe-fab` or `.fe-sheet` — none of which exist in the wide desktop
1938
+ * layout, so the classic look is untouched when the mode is off. */
1939
+
1940
+ /* Narrow toolbar: a single non-wrapping row of icon buttons. */
1941
+ .fe-toolbar--narrow {
1942
+ flex-wrap: nowrap;
1943
+ gap: 6px;
1944
+ }
1945
+ .fe-toolbar--narrow .fe-btn.is-active {
1946
+ background: var(--fe-bg-selected);
1947
+ }
1948
+ /* Expanded search: the input takes the whole row. */
1949
+ .fe-toolbar--narrow .fe-search--full {
1950
+ flex: 1 1 auto;
1951
+ min-width: 0;
1952
+ }
1953
+ .fe-toolbar--narrow .fe-search--full .fe-search__input {
1954
+ width: 100%;
1955
+ min-width: 0;
1956
+ }
1957
+
1958
+ /* Upload FAB — bottom-right corner of the explorer (narrow mode only). */
1959
+ .fe-fab {
1960
+ position: absolute;
1961
+ right: 16px;
1962
+ bottom: 16px;
1963
+ width: 56px;
1964
+ height: 56px;
1965
+ border: 0;
1966
+ border-radius: 50%;
1967
+ background: var(--fe-primary);
1968
+ color: var(--fe-text-on-primary);
1969
+ font-size: 24px;
1970
+ display: flex;
1971
+ align-items: center;
1972
+ justify-content: center;
1973
+ box-shadow: var(--fe-shadow);
1974
+ cursor: pointer;
1975
+ z-index: 45;
1976
+ transition: background 0.15s, transform 0.15s;
1977
+ }
1978
+ .fe-fab:hover {
1979
+ background: var(--fe-primary-hover);
1980
+ }
1981
+ .fe-fab:active {
1982
+ transform: scale(0.94);
1983
+ }
1984
+ .fe-fab:focus-visible {
1985
+ outline: 2px solid var(--fe-primary);
1986
+ outline-offset: 3px;
1987
+ }
1988
+ /* Keep the corner trays clear of the FAB while narrow. */
1989
+ .fe--narrow .fe-upload,
1990
+ .fe--narrow .fe-ops {
1991
+ bottom: 84px;
1992
+ }
1993
+
1994
+ /* Bottom sheet (ContextMenu sheet variant, coarse-pointer devices). */
1995
+ .fe-ctx-backdrop--sheet {
1996
+ background: rgba(13, 19, 27, 0.42);
1997
+ }
1998
+ .fe-sheet {
1999
+ position: fixed;
2000
+ left: 0;
2001
+ right: 0;
2002
+ bottom: 0;
2003
+ max-height: 70vh;
2004
+ display: flex;
2005
+ flex-direction: column;
2006
+ background: var(--fe-bg);
2007
+ color: var(--fe-text);
2008
+ border: 1px solid var(--fe-border);
2009
+ border-bottom: 0;
2010
+ border-top-left-radius: var(--fe-radius-lg);
2011
+ border-top-right-radius: var(--fe-radius-lg);
2012
+ box-shadow: 0 -10px 32px rgba(15, 23, 42, 0.28);
2013
+ padding: 0 8px calc(8px + env(safe-area-inset-bottom, 0px));
2014
+ outline: none;
2015
+ z-index: 90;
2016
+ }
2017
+ .fe-sheet__handle {
2018
+ flex: 0 0 auto;
2019
+ display: flex;
2020
+ align-items: center;
2021
+ justify-content: center;
2022
+ padding: 12px 0 8px;
2023
+ cursor: grab;
2024
+ touch-action: none;
2025
+ }
2026
+ .fe-sheet__handle::before {
2027
+ content: '';
2028
+ width: 40px;
2029
+ height: 4px;
2030
+ border-radius: 99px;
2031
+ background: var(--fe-border-strong);
2032
+ }
2033
+ .fe-sheet__items {
2034
+ overflow-y: auto;
2035
+ overscroll-behavior: contain;
2036
+ -webkit-overflow-scrolling: touch;
2037
+ }
2038
+ /* Big touch targets (≥44px) on sheet entries. */
2039
+ .fe-sheet__item {
2040
+ min-height: 44px;
2041
+ padding: 10px 14px;
2042
+ font-size: 15px;
2043
+ gap: 12px;
2044
+ }
2045
+ /* Sheet transitions: backdrop fades, panel slides up from the bottom. */
2046
+ .fe-sheet-enter-active,
2047
+ .fe-sheet-leave-active {
2048
+ transition: opacity 0.18s ease;
2049
+ }
2050
+ .fe-sheet-enter-active .fe-sheet,
2051
+ .fe-sheet-leave-active .fe-sheet {
2052
+ transition: transform 0.22s ease;
2053
+ }
2054
+ .fe-sheet-enter-from,
2055
+ .fe-sheet-leave-to {
2056
+ opacity: 0;
2057
+ }
2058
+ .fe-sheet-enter-from .fe-sheet,
2059
+ .fe-sheet-leave-to .fe-sheet {
2060
+ transform: translateY(100%);
2061
+ }
2062
+ @media (prefers-reduced-motion: reduce) {
2063
+ .fe-sheet-enter-active,
2064
+ .fe-sheet-leave-active,
2065
+ .fe-sheet-enter-active .fe-sheet,
2066
+ .fe-sheet-leave-active .fe-sheet,
2067
+ .fe-fab {
2068
+ transition: none;
2069
+ }
2070
+ }