@brftech/filex-core 0.44.2 → 0.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/{ArchiveViewer-D_tJxJhJ.js → ArchiveViewer-QuNXAECN.js} +2 -2
  2. package/dist/{ArchiveViewer-D_tJxJhJ.js.map → ArchiveViewer-QuNXAECN.js.map} +1 -1
  3. package/dist/{CsvViewer-B95fArq0.js → CsvViewer-niuoz1Uk.js} +2 -2
  4. package/dist/{CsvViewer-B95fArq0.js.map → CsvViewer-niuoz1Uk.js.map} +1 -1
  5. package/dist/{DrawioViewer-BAaPMTge.js → DrawioViewer-BtqMiasr.js} +2 -2
  6. package/dist/{DrawioViewer-BAaPMTge.js.map → DrawioViewer-BtqMiasr.js.map} +1 -1
  7. package/dist/{EpubViewer-B-B7bRW7.js → EpubViewer-ERm7L1WN.js} +2 -2
  8. package/dist/{EpubViewer-B-B7bRW7.js.map → EpubViewer-ERm7L1WN.js.map} +1 -1
  9. package/dist/{IpynbViewer-COOTyOcu.js → IpynbViewer-Cmz5n0VR.js} +2 -2
  10. package/dist/{IpynbViewer-COOTyOcu.js.map → IpynbViewer-Cmz5n0VR.js.map} +1 -1
  11. package/dist/{MermaidViewer-CjVCmR3s.js → MermaidViewer-BJ5uOyhc.js} +2 -2
  12. package/dist/{MermaidViewer-CjVCmR3s.js.map → MermaidViewer-BJ5uOyhc.js.map} +1 -1
  13. package/dist/{PsdViewer-xsIYL9Gu.js → PsdViewer-DYCJF2BM.js} +2 -2
  14. package/dist/{PsdViewer-xsIYL9Gu.js.map → PsdViewer-DYCJF2BM.js.map} +1 -1
  15. package/dist/{TiffViewer-CtwnTxqL.js → TiffViewer-DbJv_0El.js} +2 -2
  16. package/dist/{TiffViewer-CtwnTxqL.js.map → TiffViewer-DbJv_0El.js.map} +1 -1
  17. package/dist/{Viewer3D-xYMwndEy.js → Viewer3D-DwZfYoO3.js} +2 -2
  18. package/dist/{Viewer3D-xYMwndEy.js.map → Viewer3D-DwZfYoO3.js.map} +1 -1
  19. package/dist/filex-core.js +298 -291
  20. package/dist/filex-core.umd.cjs +61 -61
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/{index-BBToAjwL.js → index-1RaeWGFO.js} +14218 -13831
  23. package/dist/index-1RaeWGFO.js.map +1 -0
  24. package/dist/index.d.ts +253 -31
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +265 -99
  28. package/src/components/AdvancedSearch.vue +3 -3
  29. package/src/components/CommandPalette.vue +166 -42
  30. package/src/components/ConnectionsPanel.vue +3 -11
  31. package/src/components/SideNav.vue +16 -11
  32. package/src/components/plugin/PluginViewModal.vue +7 -0
  33. package/src/components/plugin/nodes/SurfacePdfFields.vue +16 -1
  34. package/src/components/public/PublicLinkPage.vue +6 -5
  35. package/src/composables/useFileApi.ts +32 -1
  36. package/src/composables/usePluginSurface.ts +12 -0
  37. package/src/composables/useSystemDark.ts +32 -0
  38. package/src/index.ts +16 -0
  39. package/src/lib/connectionGuides.ts +11 -15
  40. package/src/lib/downloadSelection.ts +34 -0
  41. package/src/lib/dragOut.ts +0 -0
  42. package/src/lib/searchHit.ts +129 -0
  43. package/src/lib/storageLine.ts +124 -0
  44. package/src/lib/webdavMount.ts +120 -0
  45. package/src/locales/en.ts +13 -0
  46. package/src/locales/tr.ts +13 -0
  47. package/src/styles/base.css +84 -7
  48. package/src/types/ExplorerConfig.ts +65 -1
  49. package/src/types/Plugins.ts +12 -0
  50. package/dist/index-BBToAjwL.js.map +0 -1
@@ -20,11 +20,28 @@
20
20
  * rendered as pure text nodes (no innerHTML).
21
21
  * 5. Saved searches — localStorage-backed (`filex.saved-searches`, max 10);
22
22
  * "save current query" command + per-row delete.
23
+ *
24
+ * #47 — a hit is something to act on, not only to open:
25
+ * 6. Each hit row carries a Download button (`download-hit`) and drags out
26
+ * (`drag-hit`, with the DragEvent). The palette only says WHICH hit; the
27
+ * explorer answers through the same hooks a listing row uses, and
28
+ * `hitCan` lets it withhold a verb it cannot serve for that hit.
29
+ * 7. Hits stamped with an `account` (a host with several accounts — the
30
+ * desktop rail) are drawn one group per account under a badge, each
31
+ * group capped on its own. Unstamped hits draw exactly as before.
32
+ * 8. A drag let go INSIDE the palette is swallowed (`drop-inside`): the
33
+ * explorer's root turns an OS file drop into an UPLOAD, and the palette
34
+ * sits inside that root.
35
+ *
36
+ * #71 — `warm-hit` when the pointer rests on / presses a draggable hit: the
37
+ * explorer may need a server-minted link for the drag, and `dragstart` is
38
+ * too late to ask (lib/dragOut createDragLinks).
23
39
  */
24
40
  import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
25
- import type { LocaleCode } from '../types/ExplorerConfig';
41
+ import type { LocaleCode, SearchAccount } from '../types/ExplorerConfig';
26
42
  import type { FileNode, ViewMode } from '../types/FileNode';
27
43
  import type { GlobalSearchHit } from '../composables/useFileApi';
44
+ import { groupHitsByAccount } from '../lib/searchHit';
28
45
  import { matchedInContent, snippetSegments } from '../lib/snippet';
29
46
  import { actionIconSvg } from '../lib/actionIcons';
30
47
  import { fileIconTile } from '../lib/fileIcons';
@@ -52,6 +69,13 @@ const props = defineProps<{
52
69
  * behaviour every other caller has had: a blank palette.
53
70
  */
54
71
  initialQuery?: string;
72
+ /**
73
+ * #47 — may this hit be downloaded / dragged out? Absent = yes to both.
74
+ * The explorer knows what a hit's account and this host can do (a folder
75
+ * cannot ride the browser's single-file drag; another account's hit needs
76
+ * the host's hook); the palette only draws the answer.
77
+ */
78
+ hitCan?: (hit: GlobalSearchHit, action: 'download' | 'drag') => boolean;
55
79
  }>();
56
80
 
57
81
  const emit = defineEmits<{
@@ -66,6 +90,13 @@ const emit = defineEmits<{
66
90
  (e: 'go-up'): void;
67
91
  /* bul:s3 — an "everywhere" hit was chosen. */
68
92
  (e: 'open-hit', hit: GlobalSearchHit): void;
93
+ /* #47 — the hit's Download button / a drag started on its row / a drag let
94
+ go inside the palette (swallowed here — see the file header, point 8). */
95
+ (e: 'download-hit', hit: GlobalSearchHit): void;
96
+ (e: 'drag-hit', hit: GlobalSearchHit, ev: DragEvent): void;
97
+ (e: 'drop-inside'): void;
98
+ /* #71 — the pointer rests on / presses a draggable hit. */
99
+ (e: 'warm-hit', hit: GlobalSearchHit): void;
69
100
  /* wiring:int — settings surfaces reachable from anywhere via the palette */
70
101
  (e: 'open-theme'): void;
71
102
  (e: 'open-shortcut-settings'): void;
@@ -87,7 +118,13 @@ type PaletteItem =
87
118
  | { kind: 'file'; id: string; label: string; icon: string; node: FileNode }
88
119
  | { kind: 'command'; id: string; label: string; icon: string; command: string }
89
120
  /* bul:s3 — everywhere hit / saved search / save-current-query. */
90
- | { kind: 'hit'; id: string; label: string; icon: string; hit: GlobalSearchHit; crumb: string; inContent: boolean; snippet: string }
121
+ | {
122
+ kind: 'hit'; id: string; label: string; icon: string; hit: GlobalSearchHit; crumb: string; inContent: boolean; snippet: string;
123
+ /* #47 — the account badge drawn ABOVE this row (first row of its group only). */
124
+ groupHead?: SearchAccount;
125
+ canDownload: boolean;
126
+ canDrag: boolean;
127
+ }
91
128
  | { kind: 'saved'; id: string; label: string; icon: string; query: string }
92
129
  | { kind: 'save'; id: string; label: string; icon: string; query: string };
93
130
 
@@ -221,7 +258,9 @@ function scheduleEverywhere(q: string) {
221
258
  try {
222
259
  const hits = await fn(q);
223
260
  if (seq !== everywhereSeq) return; // stale response — a newer query won
224
- everywhere.value = Array.isArray(hits) ? hits.slice(0, EVERYWHERE_LIMIT) : [];
261
+ // #47 — capped per ACCOUNT in hitItems (groupHitsByAccount), not here: a
262
+ // flat cut would let the first account's eight rows hide every other.
263
+ everywhere.value = Array.isArray(hits) ? hits : [];
225
264
  } catch {
226
265
  if (seq === everywhereSeq) everywhere.value = [];
227
266
  } finally {
@@ -244,18 +283,66 @@ function hitCrumb(h: GlobalSearchHit): string {
244
283
  return parent || '/';
245
284
  }
246
285
 
247
- const hitItems = computed<HitItem[]>(() =>
248
- everywhere.value.map((h) => ({
249
- kind: 'hit' as const,
250
- id: `hit:${h.storage_id ?? ''}:${h.path ?? ''}:${h.id ?? ''}`,
251
- label: String(h.name ?? h.path ?? ''),
252
- icon: '', // hit rows draw their real tile — see iconHtml()
253
- hit: h,
254
- crumb: hitCrumb(h),
255
- inContent: matchedInContent(h.matched),
256
- snippet: typeof h.snippet === 'string' ? h.snippet : '',
257
- })),
258
- );
286
+ /* #47 — flattened in DRAWN order: group by group, so the keyboard's ↑/↓ and
287
+ the rows on screen walk the same sequence. */
288
+ const hitItems = computed<HitItem[]>(() => {
289
+ const can = props.hitCan;
290
+ const out: HitItem[] = [];
291
+ for (const group of groupHitsByAccount(everywhere.value, EVERYWHERE_LIMIT)) {
292
+ group.hits.forEach((h, i) => {
293
+ out.push({
294
+ kind: 'hit' as const,
295
+ // The account is part of the key: two accounts can both hold node 7
296
+ // at `a.txt` on storage 1.
297
+ id: `hit:${h.account?.id ?? ''}:${h.storage_id ?? ''}:${h.path ?? ''}:${h.id ?? ''}`,
298
+ label: String(h.name ?? h.path ?? ''),
299
+ icon: '', // hit rows draw their real tile — see iconHtml()
300
+ hit: h,
301
+ crumb: hitCrumb(h),
302
+ inContent: matchedInContent(h.matched),
303
+ snippet: typeof h.snippet === 'string' ? h.snippet : '',
304
+ groupHead: i === 0 ? group.account : undefined,
305
+ canDownload: can ? can(h, 'download') : true,
306
+ canDrag: can ? can(h, 'drag') : true,
307
+ });
308
+ });
309
+ }
310
+ return out;
311
+ });
312
+
313
+ /* === #47 — hit verbs ==================================================== */
314
+
315
+ function downloadHit(it: HitItem) {
316
+ if (!it.canDownload) return;
317
+ emit('download-hit', it.hit);
318
+ }
319
+
320
+ function warmHit(it: HitItem) {
321
+ if (it.canDrag) emit('warm-hit', it.hit);
322
+ }
323
+
324
+ function onHitDragStart(it: HitItem, ev: DragEvent) {
325
+ if (!it.canDrag) {
326
+ ev.preventDefault();
327
+ return;
328
+ }
329
+ emit('drag-hit', it.hit, ev);
330
+ }
331
+
332
+ /**
333
+ * Everything dragged over the palette stops here. The explorer's root owns
334
+ * `dragenter`/`dragover`/`drop` for uploads, and the palette is inside it: a
335
+ * hit dragged out through the desktop shell is an OS FILE drag, so letting it
336
+ * bubble would light the upload overlay and, on release, upload the shell's
337
+ * stand-in copy into whatever folder is open behind the palette.
338
+ */
339
+ function onBackdropDragOver(ev: DragEvent) {
340
+ ev.preventDefault();
341
+ }
342
+ function onBackdropDrop(ev: DragEvent) {
343
+ ev.preventDefault();
344
+ emit('drop-inside');
345
+ }
259
346
 
260
347
  /* === bul:s3 — saved searches (localStorage) ============================= */
261
348
 
@@ -450,6 +537,10 @@ onBeforeUnmount(() => {
450
537
  class="fe-modal__backdrop fe-cmdp__backdrop"
451
538
  role="presentation"
452
539
  @click="emit('close')"
540
+ @dragenter.stop="onBackdropDragOver"
541
+ @dragover.stop="onBackdropDragOver"
542
+ @dragleave.stop
543
+ @drop.stop="onBackdropDrop"
453
544
  >
454
545
  <div
455
546
  class="fe-cmdp"
@@ -526,34 +617,67 @@ onBeforeUnmount(() => {
526
617
  <div v-if="everywhereLoading && !hitItems.length" class="fe-cmdp__loading">
527
618
  {{ t('palette.searching') }}
528
619
  </div>
529
- <button
530
- v-for="(it, i) in hitItems"
531
- :key="it.id"
532
- type="button"
533
- class="fe-cmdp__item fe-cmdp__item--hit"
534
- :class="{ 'is-active': hitOffset + i === active }"
535
- role="option"
536
- :aria-selected="hitOffset + i === active"
537
- @mouseenter="active = hitOffset + i"
538
- @click="choose(it)"
539
- >
540
- <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons + lib/fileIcons -->
541
- <span class="fe-cmdp__icon" aria-hidden="true" v-html="iconHtml(it)"></span>
542
- <span class="fe-cmdp__hitbody">
543
- <span class="fe-cmdp__hitline">
544
- <span class="fe-cmdp__label"><bdi>{{ it.label }}</bdi></span>
545
- <span v-if="it.inContent" class="fe-cmdp__badge">{{ t('search.in_content') }}</span>
620
+ <template v-for="(it, i) in hitItems" :key="it.id">
621
+ <!-- #47 — one badge per signed-in account, above its first row. -->
622
+ <div
623
+ v-if="it.groupHead"
624
+ class="fe-cmdp__acct"
625
+ data-testid="palette-account-group"
626
+ :data-account="it.groupHead.id"
627
+ >
628
+ <span
629
+ class="fe-cmdp__acct-dot"
630
+ aria-hidden="true"
631
+ :style="it.groupHead.color ? { background: it.groupHead.color } : undefined"
632
+ ></span>
633
+ <span class="fe-cmdp__acct-label"><bdi>{{ it.groupHead.label }}</bdi></span>
634
+ <span v-if="it.groupHead.detail" class="fe-cmdp__acct-detail"><bdi>{{ it.groupHead.detail }}</bdi></span>
635
+ </div>
636
+ <!-- A div, not a button: the row now holds its own Download button,
637
+ and a button inside a button is not HTML (same shape as the
638
+ saved-search rows below). -->
639
+ <div
640
+ class="fe-cmdp__item fe-cmdp__item--hit"
641
+ :class="{ 'is-active': hitOffset + i === active }"
642
+ role="option"
643
+ tabindex="-1"
644
+ :aria-selected="hitOffset + i === active"
645
+ :data-account="it.hit.account?.id"
646
+ :draggable="it.canDrag ? 'true' : 'false'"
647
+ @mouseenter="active = hitOffset + i; warmHit(it)"
648
+ @pointerdown="warmHit(it)"
649
+ @click="choose(it)"
650
+ @keydown.enter.prevent="choose(it)"
651
+ @dragstart="onHitDragStart(it, $event)"
652
+ >
653
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons + lib/fileIcons -->
654
+ <span class="fe-cmdp__icon" aria-hidden="true" v-html="iconHtml(it)"></span>
655
+ <span class="fe-cmdp__hitbody">
656
+ <span class="fe-cmdp__hitline">
657
+ <span class="fe-cmdp__label"><bdi>{{ it.label }}</bdi></span>
658
+ <span v-if="it.inContent" class="fe-cmdp__badge">{{ t('search.in_content') }}</span>
659
+ </span>
660
+ <span v-if="it.crumb" class="fe-cmdp__crumb" :title="it.crumb">{{ it.crumb }}</span>
661
+ <!-- Snippet: «»-highlights become <mark> via TEXT segments — never innerHTML. -->
662
+ <span v-if="it.snippet" class="fe-cmdp__snippet">
663
+ <template v-for="(seg, si) in snippetSegments(it.snippet)" :key="si">
664
+ <mark v-if="seg.match" class="fe-cmdp__mark">{{ seg.text }}</mark>
665
+ <template v-else>{{ seg.text }}</template>
666
+ </template>
667
+ </span>
546
668
  </span>
547
- <span v-if="it.crumb" class="fe-cmdp__crumb" :title="it.crumb">{{ it.crumb }}</span>
548
- <!-- Snippet: «»-highlights become <mark> via TEXT segments — never innerHTML. -->
549
- <span v-if="it.snippet" class="fe-cmdp__snippet">
550
- <template v-for="(seg, si) in snippetSegments(it.snippet)" :key="si">
551
- <mark v-if="seg.match" class="fe-cmdp__mark">{{ seg.text }}</mark>
552
- <template v-else>{{ seg.text }}</template>
553
- </template>
554
- </span>
555
- </span>
556
- </button>
669
+ <button
670
+ v-if="it.canDownload"
671
+ type="button"
672
+ class="fe-cmdp__hitact"
673
+ data-testid="palette-hit-download"
674
+ :title="t('ctx.download')"
675
+ :aria-label="t('ctx.download')"
676
+ draggable="false"
677
+ @click.stop="downloadHit(it)"
678
+ ><!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons --><span aria-hidden="true" v-html="actionIconSvg('download')"></span></button>
679
+ </div>
680
+ </template>
557
681
  </template>
558
682
 
559
683
  <!-- bul:s3 — saved searches -->
@@ -24,9 +24,10 @@
24
24
  * copy of the first in the screen that answers the OTHER question was the
25
25
  * whole complaint; do not bring it back, extend Admin → Storages instead.
26
26
  */
27
- import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
27
+ import { computed, onMounted, ref, watch } from 'vue';
28
28
  import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
29
29
  import { useLocale } from '../composables/useLocale';
30
+ import { useSystemDark } from '../composables/useSystemDark';
30
31
  import { actionIconSvg } from '../lib/actionIcons'; /* ikon:emoji */
31
32
  import { useConnections, connectionsOrigin } from '../composables/useConnections';
32
33
  import {
@@ -71,14 +72,7 @@ const { visible, me, publicUrl, error, load } = useConnections(props.config);
71
72
  // Resolved in JS rather than left to `prefers-color-scheme`, because the
72
73
  // stylesheet's auto rule keys off the explorer's own `.fe` root and this
73
74
  // panel is mounted on its own.
74
- const mq =
75
- typeof window !== 'undefined' && window.matchMedia
76
- ? window.matchMedia('(prefers-color-scheme: dark)')
77
- : undefined;
78
- const osDark = ref(!!mq?.matches);
79
- function onMq(e: MediaQueryListEvent) {
80
- osDark.value = e.matches;
81
- }
75
+ const osDark = useSystemDark();
82
76
  const themeResolved = computed(() => {
83
77
  const mode = props.config.theme ?? 'auto';
84
78
  if (mode === 'light' || mode === 'dark') return mode;
@@ -169,10 +163,8 @@ watch(
169
163
 
170
164
  // ── lifecycle ────────────────────────────────────────────────────────
171
165
  onMounted(() => {
172
- mq?.addEventListener?.('change', onMq);
173
166
  void load();
174
167
  });
175
- onBeforeUnmount(() => mq?.removeEventListener?.('change', onMq));
176
168
 
177
169
  // The panel is often mounted once and re-pointed (the desktop app switches
178
170
  // accounts without tearing the window down), so a changed server must
@@ -172,16 +172,17 @@ const props = defineProps<{
172
172
  */
173
173
  canNewDocument?: boolean;
174
174
  /**
175
- * The signed-in person's storage line, from `GET /api/files/quota/me`. Null
176
- * (the default) renders nothing at all.
175
+ * The storage line, as `lib/storageLine` decided it. Null (the default)
176
+ * renders nothing at all.
177
177
  *
178
- * ⚠ It is a PER-USER figure, not this storage's — `quota.Snapshot` sums
179
- * `nodes.size WHERE owner_id = me`, and there is no per-provider quota
180
- * (internal/quota/service.go). The mockup labels it with the drive's name;
181
- * that would be a lie about which number this is, so the label says
182
- * "Storage" and the drive name stays out of it.
178
+ * ⚠ Which number it is depends on the person, and the label covers both:
179
+ * with a quota, their own uploads against it ("X of Y"); without one, the
180
+ * size of the drives listed above — the figure Home's cards print, in the
181
+ * same sentence. `partial` makes that size a lower bound ("at least X").
182
+ * The mockup labels it with one drive's name; the line may sum several, so
183
+ * the label says "Storage" and the drive names stay out of it.
183
184
  */
184
- quota?: { used: number; total: number; unlimited: boolean } | null;
185
+ quota?: { used: number; total: number; unlimited: boolean; partial?: boolean } | null;
185
186
  /** Resolved theme — the teleported New menu leaves the `.fe` variable scope. */
186
187
  theme?: ThemeMode;
187
188
  /**
@@ -454,9 +455,13 @@ const quotaPercent = computed(() => {
454
455
  const quotaText = computed(() => {
455
456
  const q = props.quota;
456
457
  if (!q) return '';
457
- return q.unlimited || q.total <= 0
458
- ? t('drive.storage.used_unlimited', { used: formatSize(q.used) })
459
- : t('drive.storage.used', { used: formatSize(q.used), total: formatSize(q.total) });
458
+ if (q.unlimited || q.total <= 0) {
459
+ // The drives' size — said as Home's card says it (HomeView.storageCaption).
460
+ return q.partial
461
+ ? t('drive.storage.used_partial', { used: formatSize(q.used) })
462
+ : t('drive.storage.used_unlimited', { used: formatSize(q.used) });
463
+ }
464
+ return t('drive.storage.used', { used: formatSize(q.used), total: formatSize(q.total) });
460
465
  });
461
466
 
462
467
  /** Only the drawer draws this control now (see the template). */
@@ -40,6 +40,12 @@ const props = defineProps<{
40
40
  surface: PluginSurface | null;
41
41
  /** Adapter-qualified path of the row the view was opened on (echoed on every event). */
42
42
  path?: string;
43
+ /**
44
+ * Every row of the selection the view was opened on (echoed on every
45
+ * event as `paths`). ⚠ Without it a screen opened on three files talks
46
+ * about the first one from its second event on (#64).
47
+ */
48
+ paths?: string[];
43
49
  /** Storage names a file-chooser may span; where its picker opens. */
44
50
  storages?: string[];
45
51
  startAt?: string;
@@ -69,6 +75,7 @@ const conv = usePluginSurface(
69
75
  plugin: props.plugin,
70
76
  view: props.view,
71
77
  path: () => props.path,
78
+ paths: () => props.paths,
72
79
  locale: () => props.locale,
73
80
  errorText: () => t('plugin.view.error'),
74
81
  },
@@ -50,6 +50,7 @@ import type { FileRefResolver, PdfFieldsSource } from '../../../types/Plugins';
50
50
  import { useLocale } from '../../../composables/useLocale';
51
51
  import { fetchViewerArrayBuffer } from '../../../composables/useViewerFetch';
52
52
  import { labelOf } from '../../../lib/pluginLabel';
53
+ import { inkOn } from '../../../composables/usePublicBranding';
53
54
  import { loadPdfjs } from '../../../lib/pdfjsLoader';
54
55
  import { actionIconSvg } from '../../../lib/actionIcons';
55
56
  import {
@@ -206,6 +207,20 @@ function fieldColor(f: PdfField): string {
206
207
  return signerColor(signers.value, f.assignee);
207
208
  }
208
209
 
210
+ /**
211
+ * A box card's colours: its signer's, and the ink that reads on THAT colour.
212
+ *
213
+ * ⚠ Not `--fe-text-on-primary`. A signer's colour is an identity colour, the
214
+ * same in every theme, so the ink on it must be the colour's own — the theme
215
+ * token is dark in dark mode and measured 3.5:1 on the first signer's blue
216
+ * (#57). A colour that is not hex (a plugin may name its own) keeps the token.
217
+ */
218
+ function cardStyle(f: PdfField): Record<string, string> {
219
+ const color = fieldColor(f);
220
+ const ink = inkOn(color);
221
+ return ink ? { '--spdf-color': color, '--spdf-ink': ink } : { '--spdf-color': color };
222
+ }
223
+
209
224
  function signerLabel(assignee: string | undefined): string {
210
225
  const s = signers.value.find((x) => x.id === assignee);
211
226
  return s ? labelOf(s.label, props.locale) || s.id : assignee || '';
@@ -1042,7 +1057,7 @@ const checkIcon = actionIconSvg('check');
1042
1057
  v-for="(f, i) in local"
1043
1058
  :key="f.id"
1044
1059
  class="fe-spdf__card"
1045
- :style="{ '--spdf-color': fieldColor(f) }"
1060
+ :style="cardStyle(f)"
1046
1061
  :data-testid="`surface-pdf-card-${f.id}`"
1047
1062
  >
1048
1063
  <span class="fe-spdf__cardno" aria-hidden="true">{{ i + 1 }}</span>
@@ -24,6 +24,7 @@ import type { LocaleCode, ThemeMode } from '../../types/ExplorerConfig';
24
24
  import type { PublicRequestInfo, PublicShareInfo } from '../../types/Public';
25
25
  import { useLocale } from '../../composables/useLocale';
26
26
  import { usePublicBranding } from '../../composables/usePublicBranding';
27
+ import { useSystemDark } from '../../composables/useSystemDark';
27
28
  import { usePublicRequest, usePublicShare } from '../../composables/usePublicLink';
28
29
  import { hasLocale, normalizeLocaleCode } from '../../lib/uiLocales';
29
30
  import { localPref, setLocalPref } from '../../lib/prefs';
@@ -81,6 +82,10 @@ const { t, formatSize } = useLocale(() => locale.value);
81
82
 
82
83
  const brand = usePublicBranding({ base: props.base });
83
84
 
85
+ /* ⚠ The system's answer as a LIVE ref (#57): read inside the computed below
86
+ it was read once, and an open page kept the mode it was opened in. */
87
+ const systemDark = useSystemDark();
88
+
84
89
  /**
85
90
  * Light or dark.
86
91
  *
@@ -92,11 +97,7 @@ const brand = usePublicBranding({ base: props.base });
92
97
  const resolvedTheme = computed<'light' | 'dark'>(() => {
93
98
  const want = props.theme && props.theme !== 'auto' ? props.theme : localPref('theme') || brand.themeDefault.value;
94
99
  if (want === 'light' || want === 'dark') return want;
95
- try {
96
- return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
97
- } catch {
98
- return 'light';
99
- }
100
+ return systemDark.value ? 'dark' : 'light';
100
101
  });
101
102
 
102
103
  /* ── the link itself ──────────────────────────────────────────────────── */
@@ -22,7 +22,8 @@
22
22
  */
23
23
 
24
24
  import type { StorageInfo } from '../lib/catalogCoverage';
25
- import type { ExplorerConfig, AuthConfig, EndpointMap } from '../types/ExplorerConfig';
25
+ import type { MeasuredDrive } from '../lib/storageLine';
26
+ import type { ExplorerConfig, AuthConfig, EndpointMap, SearchAccount } from '../types/ExplorerConfig';
26
27
  import { resolveLocale } from '../locales/resolve';
27
28
  import { listingAddress } from '../lib/internalPaths';
28
29
  import { localeTag } from './useLocale';
@@ -212,6 +213,15 @@ export interface GlobalSearchHit {
212
213
  snippet?: string;
213
214
  /** Where the hit matched: name | content | both. */
214
215
  matched?: 'name' | 'content' | 'both';
216
+ /** Drive NAME the hit lives on (the server fills it; older ones did not). */
217
+ storage?: string;
218
+ /**
219
+ * #47 — which signed-in account the hit came from. Never on the wire: the
220
+ * explorer stamps it when the host searches several accounts
221
+ * (`ExplorerConfig.accountSearch`). Absent = this mount's own account, with
222
+ * no other account in play.
223
+ */
224
+ account?: SearchAccount;
215
225
  [k: string]: unknown;
216
226
  }
217
227
 
@@ -555,6 +565,26 @@ export function useFileApi(config: ExplorerConfig) {
555
565
  }
556
566
  }
557
567
 
568
+ /**
569
+ * `GET /api/files/quota/storages` → `{storages: [{name, used_bytes,
570
+ * file_count, coverage?}]}` — how full each drive the caller can open is
571
+ * (handlers/quota_storages.go, RBAC-filtered server-side). The storage line
572
+ * asks for it when the host handed over no size for some drive — the
573
+ * desktop app hands over names only (lib/storageLine).
574
+ *
575
+ * ⚠ Null on ANY failure, for the reason `quotaMe()` is: a server without the
576
+ * route must leave the panel as it was.
577
+ */
578
+ async function storageUsage(): Promise<MeasuredDrive[] | null> {
579
+ const base = endpoints.manager.replace(/\/manager(\?.*)?$/, '/quota/storages');
580
+ try {
581
+ const body = await jsonFetch<{ storages?: unknown }>(base);
582
+ return Array.isArray(body?.storages) ? (body.storages as MeasuredDrive[]) : null;
583
+ } catch {
584
+ return null;
585
+ }
586
+ }
587
+
558
588
  async function subfolders(path: string): Promise<{ folders: FileNode[] }> {
559
589
  return jsonFetch<{ folders: FileNode[] }>(managerUrl('subfolders', { path }));
560
590
  }
@@ -1141,6 +1171,7 @@ export function useFileApi(config: ExplorerConfig) {
1141
1171
  search,
1142
1172
  globalSearch /* bul:s3 */,
1143
1173
  quotaMe /* surucu:d1 */,
1174
+ storageUsage /* surucu:d1 */,
1144
1175
  subfolders,
1145
1176
  newFolder,
1146
1177
  newFile,
@@ -38,6 +38,13 @@ export interface PluginSurfaceHost {
38
38
  view: string;
39
39
  /** Adapter-qualified path the view was opened on — echoed on every event. */
40
40
  path?: () => string | undefined;
41
+ /**
42
+ * Every row the view was opened on (a selection) — echoed on every event
43
+ * as `paths`, beside `path`. Absent or empty for a frame opened on one
44
+ * row or on none (a deep link, a home page, the inspector, a public page),
45
+ * which then sends `path` alone, exactly as before.
46
+ */
47
+ paths?: () => readonly string[] | undefined;
41
48
  locale: () => string;
42
49
  /** The words for an answer without a surface. */
43
50
  errorText: () => string;
@@ -128,9 +135,14 @@ export function usePluginSurface(host: PluginSurfaceHost, events: PluginSurfaceE
128
135
  }
129
136
  const mine = ++seq;
130
137
  failure.value = '';
138
+ // ⚠⚠ The WHOLE selection, on every event (#64). The opening `run` carried
139
+ // it; echoing only `path` here made the server answer every later event
140
+ // about the first file, and the submit queue the job on that file alone.
141
+ const selection = host.paths?.();
131
142
  try {
132
143
  const res = await host.api.pluginViewEvent(host.plugin, host.view, {
133
144
  path: host.path?.(),
145
+ ...(selection?.length ? { paths: [...selection] } : {}),
134
146
  state: current.value?.state ?? {},
135
147
  event,
136
148
  action_id: actionId,
@@ -0,0 +1,32 @@
1
+ /**
2
+ * useSystemDark — is the operating system in dark mode, as a ref that turns
3
+ * with it while the component is mounted.
4
+ *
5
+ * ⚠⚠ `window.matchMedia('(prefers-color-scheme: dark)').matches` read inside
6
+ * a `computed` is read ONCE: Vue cannot track a media query, so a page that
7
+ * resolves "auto" that way keeps the mode it was opened in. The public page
8
+ * (a share, a file request, an app's signing page) did exactly that — opened
9
+ * dark, the OS turned light, and the page stayed dark with `fe--theme-dark`
10
+ * pinned on its root (#57, measured in a browser). Every screen that resolves
11
+ * `auto` in script takes it from here, so the listener and its cleanup exist
12
+ * once.
13
+ */
14
+ import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue';
15
+
16
+ export function useSystemDark(): Readonly<Ref<boolean>> {
17
+ let mq: MediaQueryList | undefined;
18
+ try {
19
+ mq = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
20
+ ? window.matchMedia('(prefers-color-scheme: dark)')
21
+ : undefined;
22
+ } catch {
23
+ mq = undefined;
24
+ }
25
+ const dark = ref(!!mq?.matches);
26
+ const onChange = (e: { matches: boolean }) => {
27
+ dark.value = e.matches;
28
+ };
29
+ onMounted(() => mq?.addEventListener?.('change', onChange));
30
+ onBeforeUnmount(() => mq?.removeEventListener?.('change', onChange));
31
+ return dark;
32
+ }
package/src/index.ts CHANGED
@@ -43,6 +43,17 @@ export {
43
43
  type CoverageReason,
44
44
  type StorageInfo,
45
45
  } from './lib/catalogCoverage';
46
+ /* surucu:d1 — which number a storage line prints: the explorer panel's, and
47
+ * the admin top bar's chip, by one rule. */
48
+ export {
49
+ hasCeiling,
50
+ needsMeasuredDrives,
51
+ storageLine,
52
+ type MeasuredDrive,
53
+ type PanelDrive,
54
+ type PersonUsage,
55
+ type StorageLine,
56
+ } from './lib/storageLine';
46
57
  // tablo:t3 — the row's ONE action control ("Actions" / "Aksiyon"). DataTable
47
58
  // draws it for every table (`rowActions`); it stays exported for a host that
48
59
  // needs the same control outside a table. A second menu built beside
@@ -394,6 +405,11 @@ export { useFileApi, resolveEndpoints } from './composables/useFileApi';
394
405
  export type { FileApi, ManagerResponse, PendingOpDto } from './composables/useFileApi';
395
406
  /* bul:s3 — global-search contract types + snippet helpers */
396
407
  export type { GlobalSearchHit, GlobalSearchScope } from './composables/useFileApi';
408
+ /* #47 — several accounts in one ⌘K (the desktop rail), and the one place a
409
+ search hit becomes an address. */
410
+ export type { AccountSearchHook, SearchAccount, SearchHitItem } from './types/ExplorerConfig';
411
+ export { groupHitsByAccount, hitItem, hitStorageName, hitToNode } from './lib/searchHit';
412
+ export type { HitDriveContext, HitGroup } from './lib/searchHit';
397
413
  /* Browser-side reachability probe for external services (OnlyOffice, drawio).
398
414
  Lives here, not in the admin app, because the admin page and every embedder
399
415
  need the same answer to "can THIS browser reach the document server?" — see