@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.
- package/dist/{ArchiveViewer-D_tJxJhJ.js → ArchiveViewer-QuNXAECN.js} +2 -2
- package/dist/{ArchiveViewer-D_tJxJhJ.js.map → ArchiveViewer-QuNXAECN.js.map} +1 -1
- package/dist/{CsvViewer-B95fArq0.js → CsvViewer-niuoz1Uk.js} +2 -2
- package/dist/{CsvViewer-B95fArq0.js.map → CsvViewer-niuoz1Uk.js.map} +1 -1
- package/dist/{DrawioViewer-BAaPMTge.js → DrawioViewer-BtqMiasr.js} +2 -2
- package/dist/{DrawioViewer-BAaPMTge.js.map → DrawioViewer-BtqMiasr.js.map} +1 -1
- package/dist/{EpubViewer-B-B7bRW7.js → EpubViewer-ERm7L1WN.js} +2 -2
- package/dist/{EpubViewer-B-B7bRW7.js.map → EpubViewer-ERm7L1WN.js.map} +1 -1
- package/dist/{IpynbViewer-COOTyOcu.js → IpynbViewer-Cmz5n0VR.js} +2 -2
- package/dist/{IpynbViewer-COOTyOcu.js.map → IpynbViewer-Cmz5n0VR.js.map} +1 -1
- package/dist/{MermaidViewer-CjVCmR3s.js → MermaidViewer-BJ5uOyhc.js} +2 -2
- package/dist/{MermaidViewer-CjVCmR3s.js.map → MermaidViewer-BJ5uOyhc.js.map} +1 -1
- package/dist/{PsdViewer-xsIYL9Gu.js → PsdViewer-DYCJF2BM.js} +2 -2
- package/dist/{PsdViewer-xsIYL9Gu.js.map → PsdViewer-DYCJF2BM.js.map} +1 -1
- package/dist/{TiffViewer-CtwnTxqL.js → TiffViewer-DbJv_0El.js} +2 -2
- package/dist/{TiffViewer-CtwnTxqL.js.map → TiffViewer-DbJv_0El.js.map} +1 -1
- package/dist/{Viewer3D-xYMwndEy.js → Viewer3D-DwZfYoO3.js} +2 -2
- package/dist/{Viewer3D-xYMwndEy.js.map → Viewer3D-DwZfYoO3.js.map} +1 -1
- package/dist/filex-core.js +298 -291
- package/dist/filex-core.umd.cjs +61 -61
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/{index-BBToAjwL.js → index-1RaeWGFO.js} +14218 -13831
- package/dist/index-1RaeWGFO.js.map +1 -0
- package/dist/index.d.ts +253 -31
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +265 -99
- package/src/components/AdvancedSearch.vue +3 -3
- package/src/components/CommandPalette.vue +166 -42
- package/src/components/ConnectionsPanel.vue +3 -11
- package/src/components/SideNav.vue +16 -11
- package/src/components/plugin/PluginViewModal.vue +7 -0
- package/src/components/plugin/nodes/SurfacePdfFields.vue +16 -1
- package/src/components/public/PublicLinkPage.vue +6 -5
- package/src/composables/useFileApi.ts +32 -1
- package/src/composables/usePluginSurface.ts +12 -0
- package/src/composables/useSystemDark.ts +32 -0
- package/src/index.ts +16 -0
- package/src/lib/connectionGuides.ts +11 -15
- package/src/lib/downloadSelection.ts +34 -0
- package/src/lib/dragOut.ts +0 -0
- package/src/lib/searchHit.ts +129 -0
- package/src/lib/storageLine.ts +124 -0
- package/src/lib/webdavMount.ts +120 -0
- package/src/locales/en.ts +13 -0
- package/src/locales/tr.ts +13 -0
- package/src/styles/base.css +84 -7
- package/src/types/ExplorerConfig.ts +65 -1
- package/src/types/Plugins.ts +12 -0
- 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
|
-
| {
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
<
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
<span class="fe-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
<
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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,
|
|
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
|
|
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
|
|
176
|
-
*
|
|
175
|
+
* The storage line, as `lib/storageLine` decided it. Null (the default)
|
|
176
|
+
* renders nothing at all.
|
|
177
177
|
*
|
|
178
|
-
* ⚠
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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="
|
|
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
|
-
|
|
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 {
|
|
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
|