@brftech/filex-core 0.43.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ArchiveViewer-D_tJxJhJ.js +236 -0
- package/dist/ArchiveViewer-D_tJxJhJ.js.map +1 -0
- package/dist/{CsvViewer-ByHVK5ga.js → CsvViewer-B95fArq0.js} +2 -2
- package/dist/{CsvViewer-ByHVK5ga.js.map → CsvViewer-B95fArq0.js.map} +1 -1
- package/dist/{DrawioViewer-Bgrj8-zG.js → DrawioViewer-BAaPMTge.js} +2 -2
- package/dist/{DrawioViewer-Bgrj8-zG.js.map → DrawioViewer-BAaPMTge.js.map} +1 -1
- package/dist/{EpubViewer-DJwUKlHW.js → EpubViewer-B-B7bRW7.js} +2 -2
- package/dist/{EpubViewer-DJwUKlHW.js.map → EpubViewer-B-B7bRW7.js.map} +1 -1
- package/dist/{IpynbViewer-Bz0eJ4NS.js → IpynbViewer-COOTyOcu.js} +2 -2
- package/dist/{IpynbViewer-Bz0eJ4NS.js.map → IpynbViewer-COOTyOcu.js.map} +1 -1
- package/dist/{MermaidViewer-CEDTD_pp.js → MermaidViewer-CjVCmR3s.js} +2 -2
- package/dist/{MermaidViewer-CEDTD_pp.js.map → MermaidViewer-CjVCmR3s.js.map} +1 -1
- package/dist/{PsdViewer-CZcxUpZO.js → PsdViewer-xsIYL9Gu.js} +2 -2
- package/dist/{PsdViewer-CZcxUpZO.js.map → PsdViewer-xsIYL9Gu.js.map} +1 -1
- package/dist/{TiffViewer-BwI62CbQ.js → TiffViewer-CtwnTxqL.js} +2 -2
- package/dist/{TiffViewer-BwI62CbQ.js.map → TiffViewer-CtwnTxqL.js.map} +1 -1
- package/dist/{Viewer3D-DoeZcuMm.js → Viewer3D-xYMwndEy.js} +2 -2
- package/dist/{Viewer3D-DoeZcuMm.js.map → Viewer3D-xYMwndEy.js.map} +1 -1
- package/dist/filex-core.js +372 -366
- package/dist/filex-core.umd.cjs +62 -62
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/{index-DuWnGuv5.js → index-BBToAjwL.js} +16770 -15869
- package/dist/index-BBToAjwL.js.map +1 -0
- package/dist/index.d.ts +185 -10
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +363 -23
- package/src/components/ContextMenu.vue +17 -0
- package/src/components/GalleryView.vue +24 -30
- package/src/components/GridView.vue +33 -36
- package/src/components/HomeView.vue +14 -4
- package/src/components/InspectorPanel.vue +9 -3
- package/src/components/ListView.vue +21 -14
- package/src/components/OperationsCenter.vue +6 -0
- package/src/components/PendingOpsTray.vue +5 -3
- package/src/components/ThumbTile.vue +58 -0
- package/src/composables/useFileApi.ts +36 -6
- package/src/composables/useLocale.ts +26 -0
- package/src/composables/useOperations.ts +7 -1
- package/src/composables/usePendingOps.ts +11 -7
- package/src/composables/useThumbs.ts +53 -21
- package/src/index.ts +15 -0
- package/src/lib/actionIcons.ts +6 -0
- package/src/lib/archiveFormats.ts +12 -0
- package/src/lib/archivePreviewCache.ts +61 -0
- package/src/lib/catalogCoverage.ts +137 -0
- package/src/lib/nearViewport.ts +77 -0
- package/src/locales/en.ts +67 -0
- package/src/locales/tr.ts +65 -0
- package/src/modals/ArchiveCreateModal.vue +192 -0
- package/src/modals/ArchiveExtractModal.vue +57 -0
- package/src/modals/ArchivePasswordModal.vue +62 -0
- package/src/modals/PreviewModal.vue +15 -1
- package/src/styles/base.css +47 -2
- package/src/types/ExplorerConfig.ts +9 -0
- package/src/types/FileNode.ts +19 -0
- package/src/viewers/ArchiveViewer.vue +145 -40
- package/dist/ArchiveViewer-BqU761Wk.js +0 -149
- package/dist/ArchiveViewer-BqU761Wk.js.map +0 -1
- package/dist/index-DuWnGuv5.js.map +0 -1
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
} from '../lib/dateGroups'; /* gruplama */
|
|
39
39
|
import { useSortStore, type ListingOrder } from '../lib/sortOrder'; /* gruplama */
|
|
40
40
|
import StarButton from './StarButton.vue';
|
|
41
|
+
import ThumbTile from './ThumbTile.vue';
|
|
41
42
|
import { snippetSegments } from '../lib/snippet'; /* bul:s3 */
|
|
42
43
|
import { applyDragGhost } from '../lib/dragGhost'; /* wiring:c4 */
|
|
43
44
|
import { contentDir } from '../lib/direction';
|
|
@@ -229,7 +230,8 @@ function headingBefore(n: FileNode): string | null {
|
|
|
229
230
|
|
|
230
231
|
|
|
231
232
|
// Prefer the authenticated resolver when the host wired one; otherwise fall
|
|
232
|
-
// back to the raw URL (legacy same-origin behavior).
|
|
233
|
+
// back to the raw URL (legacy same-origin behavior). ⚠ Called by ThumbTile,
|
|
234
|
+
// not by this template — see the note on the tile in the markup.
|
|
233
235
|
function thumbOf(n: FileNode): string | null {
|
|
234
236
|
return props.thumbSrc ? props.thumbSrc(n) : (n.thumb_url ?? null);
|
|
235
237
|
}
|
|
@@ -512,48 +514,43 @@ function snippetTitle(snippet: string): string {
|
|
|
512
514
|
v-html="fileIconTile(n)"
|
|
513
515
|
></span>
|
|
514
516
|
</template>
|
|
515
|
-
<!--
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
517
|
+
<!-- ⚠⚠ The thumbnail is read inside ThumbTile, never here: read in
|
|
518
|
+
this template, each thumbnail that arrived re-rendered every card
|
|
519
|
+
of the folder (see ThumbTile). The tile draws the <img> once there
|
|
520
|
+
is a picture — draggable="false", because an image drag puts a
|
|
521
|
+
'Files' MIME on the dataTransfer and the parent's upload handler
|
|
522
|
+
re-uploads the file being moved — and its slot until then.
|
|
523
|
+
gorunum:v1-preview — the play badge goes over a real video frame
|
|
524
|
+
only: a video that fell back to its type tile already says what
|
|
525
|
+
it is. -->
|
|
526
|
+
<ThumbTile
|
|
527
|
+
v-else
|
|
528
|
+
:node="n"
|
|
529
|
+
:src-of="thumbOf"
|
|
530
|
+
:video-badge="drawsAsVideo(n)"
|
|
524
531
|
:alt="n.basename"
|
|
525
532
|
:class="{ 'fe-thumb--page': drawsAsPage(n) /* gorunum:v1-preview — crop a page from its TOP */ }"
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
v-html
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
<span v-else class="fe-grid__icon fe-grid__icon--svg" v-html="fileIconTile(n)"></span>
|
|
533
|
+
>
|
|
534
|
+
<!-- ikon:emoji — an encrypted folder is still a FOLDER, so it keeps the
|
|
535
|
+
folder's own shape and colour with the padlock cut out of it; the
|
|
536
|
+
🔒 it replaces said "locked" and nothing else. One definition,
|
|
537
|
+
in lib/fileIcons, for all three views. -->
|
|
538
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
|
|
539
|
+
<span
|
|
540
|
+
v-if="isEncryptedFolder(n)"
|
|
541
|
+
class="fe-grid__icon fe-grid__icon--svg"
|
|
542
|
+
role="img"
|
|
543
|
+
:aria-label="t('e2e.badge')"
|
|
544
|
+
v-html="encryptedFolderTile()"
|
|
545
|
+
></span>
|
|
546
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
|
|
547
|
+
<span v-else class="fe-grid__icon fe-grid__icon--svg" v-html="fileIconTile(n)"></span>
|
|
548
|
+
</ThumbTile>
|
|
543
549
|
<!-- Star, ON the tile. A hover-only affordance would be invisible to
|
|
544
550
|
the person looking for what they starred, so the chip is always
|
|
545
551
|
painted once the file IS starred and only appears on hover/focus
|
|
546
552
|
otherwise (see .fe-grid__star in styles/base.css). @click.stop so
|
|
547
553
|
starring never doubles as opening the card. -->
|
|
548
|
-
<!-- gorunum:v1-preview — a frame lifted out of a video is, on a card,
|
|
549
|
-
indistinguishable from a photograph. The badge is the difference,
|
|
550
|
-
and it is drawn only over a real frame: a video that fell back to
|
|
551
|
-
its type tile already says what it is. -->
|
|
552
|
-
<span
|
|
553
|
-
v-if="!previewKind(n) && thumbOf(n) && drawsAsVideo(n)"
|
|
554
|
-
class="fe-thumb__play"
|
|
555
|
-
aria-hidden="true"
|
|
556
|
-
></span>
|
|
557
554
|
<div v-if="canStar(n)" class="fe-grid__star" @click.stop @dblclick.stop>
|
|
558
555
|
<StarButton
|
|
559
556
|
:starred="!!starredIds?.has(n.id!)"
|
|
@@ -46,6 +46,9 @@ export interface HomeStorage {
|
|
|
46
46
|
readOnly?: boolean;
|
|
47
47
|
/** Bytes this storage holds, when the server reported one. */
|
|
48
48
|
usedBytes?: number;
|
|
49
|
+
/** The figure counts only part of the storage: its catalog does not cover
|
|
50
|
+
* all of it yet (lib/catalogCoverage). Drawn as a lower bound. */
|
|
51
|
+
usedPartial?: boolean;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
const props = defineProps<{
|
|
@@ -145,9 +148,12 @@ const storageTile = fileIconTile({ type: 'dir', mime_type: 'inode/storage' });
|
|
|
145
148
|
* with the wrong size line says something false.
|
|
146
149
|
*/
|
|
147
150
|
function storageCaption(s: HomeStorage): string {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
+
if (typeof s.usedBytes !== 'number' || s.usedBytes < 0) return t('conn.guide.storage');
|
|
152
|
+
// ⚠ A figure the catalog does not stand behind in full is a lower bound —
|
|
153
|
+
// "at least 1.2 GB used" — the same rule the listing's folder sizes follow.
|
|
154
|
+
return s.usedPartial
|
|
155
|
+
? t('drive.storage.used_partial', { used: formatSize(s.usedBytes) })
|
|
156
|
+
: t('drive.storage.used_unlimited', { used: formatSize(s.usedBytes) });
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
/**
|
|
@@ -199,7 +205,11 @@ function openNode(n: FileNode) {
|
|
|
199
205
|
<span class="fe-home__storage-tile" aria-hidden="true" v-html="storageTile"></span>
|
|
200
206
|
<span class="fe-home__storage-main">
|
|
201
207
|
<span class="fe-home__storage-label">{{ s.label || s.name }}</span>
|
|
202
|
-
<span
|
|
208
|
+
<span
|
|
209
|
+
class="fe-home__storage-meta"
|
|
210
|
+
data-testid="home-storage-used"
|
|
211
|
+
:data-partial="s.usedPartial ? 'true' : undefined"
|
|
212
|
+
>{{ storageCaption(s) }}</span>
|
|
203
213
|
<!-- A read-only mount says so on its card too (issue #30) — on a
|
|
204
214
|
line of its own, the panel's own mark (StorageTags, the one
|
|
205
215
|
read-only tag of every surface). ⚠ It used to be glued to
|
|
@@ -182,7 +182,7 @@ const emit = defineEmits<{
|
|
|
182
182
|
(e: 'plugin-open', payload: { plugin: string; req: { path: string; action?: string; view?: string } }): void;
|
|
183
183
|
}>();
|
|
184
184
|
|
|
185
|
-
const { t, formatSize, formatDate: formatDateOf, nodeDisplayName } = useLocale(
|
|
185
|
+
const { t, formatSize, formatNodeSize, nodeSizeHint, formatDate: formatDateOf, nodeDisplayName } = useLocale(
|
|
186
186
|
() => props.locale,
|
|
187
187
|
);
|
|
188
188
|
|
|
@@ -209,6 +209,12 @@ const nodeId = computed<number | null>(() =>
|
|
|
209
209
|
const multiTotal = computed(() =>
|
|
210
210
|
props.nodes.reduce((acc, n) => acc + (typeof n.size === 'number' ? n.size : 0), 0),
|
|
211
211
|
);
|
|
212
|
+
/** The selection's total, as a size: a lower bound when any selected folder's
|
|
213
|
+
* own size is one (`size_partial`, drawn by useLocale formatNodeSize). */
|
|
214
|
+
const multiSize = computed(() => ({
|
|
215
|
+
size: multiTotal.value,
|
|
216
|
+
size_partial: props.nodes.some((n) => n.size_partial === true),
|
|
217
|
+
}));
|
|
212
218
|
const etag = computed<string | null>(() => {
|
|
213
219
|
const v = single.value?.etag;
|
|
214
220
|
return typeof v === 'string' && v !== '' ? v : null;
|
|
@@ -249,7 +255,7 @@ const headName = computed<string>(() => {
|
|
|
249
255
|
|
|
250
256
|
/** The line under it — "Folder", "TypeScript · 4.8 KB", "12.4 MB in total". */
|
|
251
257
|
const headCaption = computed<string>(() => {
|
|
252
|
-
if (isMulti.value) return
|
|
258
|
+
if (isMulti.value) return formatNodeSize(multiSize.value);
|
|
253
259
|
const n = single.value;
|
|
254
260
|
if (n) {
|
|
255
261
|
const kind = typeLabelFor(n, t);
|
|
@@ -798,7 +804,7 @@ watch(
|
|
|
798
804
|
<dl v-if="isMulti" class="fe-inspector__meta">
|
|
799
805
|
<div class="fe-inspector__row">
|
|
800
806
|
<dt>{{ t('inspector.size') }}</dt>
|
|
801
|
-
<dd>{{
|
|
807
|
+
<dd :title="nodeSizeHint(multiSize)">{{ formatNodeSize(multiSize) }}</dd>
|
|
802
808
|
</div>
|
|
803
809
|
</dl>
|
|
804
810
|
|
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
} from '../lib/viewPrefs'; /* tablo:t1 — per-folder columns */
|
|
66
66
|
import DataTable, { type DataColumn } from './DataTable.vue';
|
|
67
67
|
import StarButton from './StarButton.vue';
|
|
68
|
+
import ThumbTile from './ThumbTile.vue';
|
|
68
69
|
|
|
69
70
|
const props = defineProps<{
|
|
70
71
|
files: FileNode[];
|
|
@@ -176,7 +177,8 @@ const emit = defineEmits<{
|
|
|
176
177
|
|
|
177
178
|
const {
|
|
178
179
|
t,
|
|
179
|
-
|
|
180
|
+
formatNodeSize,
|
|
181
|
+
nodeSizeHint,
|
|
180
182
|
formatDate,
|
|
181
183
|
formatDateFull,
|
|
182
184
|
formatMonthYear,
|
|
@@ -567,8 +569,10 @@ const columns = computed<DataColumn<FileNode>[]>(() => [
|
|
|
567
569
|
label: t('col.size'),
|
|
568
570
|
sortable: true,
|
|
569
571
|
class: 'fe-list__col--size',
|
|
570
|
-
|
|
571
|
-
|
|
572
|
+
// A folder the catalog does not cover in full says so: "≥ 1.2 GB", or
|
|
573
|
+
// "—" when nothing below it is known (useLocale formatNodeSize).
|
|
574
|
+
format: (n) => formatNodeSize(n),
|
|
575
|
+
title: (n) => nodeSizeHint(n),
|
|
572
576
|
},
|
|
573
577
|
{ id: 'star', label: t('col.star'), class: 'fe-list__col--star', headerLabel: false },
|
|
574
578
|
]);
|
|
@@ -786,21 +790,24 @@ const tableGroups = computed(() =>
|
|
|
786
790
|
v-html="encryptedFolderTile()"
|
|
787
791
|
></span>
|
|
788
792
|
<!-- tablo:t1 — the real thumbnail, at the tile's own size so the row
|
|
789
|
-
height does not move.
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
793
|
+
height does not move. ⚠⚠ Through ThumbTile, never read in this
|
|
794
|
+
template: here, each thumbnail that arrived re-rendered every row of
|
|
795
|
+
the table. The tile draws the <img> (`draggable="false"`: dragging
|
|
796
|
+
an <img> puts a 'Files' MIME on the dataTransfer and the parent's
|
|
797
|
+
upload handler re-uploads the thing you were only moving) and the
|
|
798
|
+
type tile until then. -->
|
|
799
|
+
<ThumbTile
|
|
800
|
+
v-else
|
|
801
|
+
:node="row"
|
|
802
|
+
:src-of="thumbOf"
|
|
794
803
|
class="fe-list__icon fe-list__icon--img"
|
|
795
804
|
:class="{ 'fe-thumb--page': drawsAsPage(row) }"
|
|
796
|
-
:src="thumbOf(row)!"
|
|
797
805
|
alt=""
|
|
798
806
|
aria-hidden="true"
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
<span v-else class="fe-list__icon fe-list__icon--svg" aria-hidden="true" v-html="fileIconTile(row)"></span>
|
|
807
|
+
>
|
|
808
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
|
|
809
|
+
<span class="fe-list__icon fe-list__icon--svg" aria-hidden="true" v-html="fileIconTile(row)"></span>
|
|
810
|
+
</ThumbTile>
|
|
804
811
|
<div class="fe-list__name-wrap">
|
|
805
812
|
<span class="fe-list__name" :title="nameTitle(row)">
|
|
806
813
|
<!-- ⚠ RTL: `<bdi>` — the name is the person's text, not the
|
|
@@ -70,6 +70,8 @@ const KIND_ICONS: Record<Operation['kind'], string> = {
|
|
|
70
70
|
/* An app-plugin job — the puzzle piece its menu row wears (lib/actionIcons). */
|
|
71
71
|
plugin:
|
|
72
72
|
'M9.5 4.5a2 2 0 1 1 4 0h3a1.5 1.5 0 0 1 1.5 1.5v3a2 2 0 1 1 0 4v3a1.5 1.5 0 0 1-1.5 1.5h-3a2 2 0 1 1-4 0h-3A1.5 1.5 0 0 1 5 16v-3a2 2 0 1 1 0-4V6a1.5 1.5 0 0 1 1.5-1.5z',
|
|
73
|
+
'archive-create': 'M4 8V5h16v3zM5 8h14v12H5zM10 12h4',
|
|
74
|
+
'archive-extract': 'M4 8V5h16v3zM5 8h14v12H5zM10 12h4',
|
|
73
75
|
};
|
|
74
76
|
|
|
75
77
|
function kindLabel(o: Operation): string {
|
|
@@ -100,6 +102,7 @@ function outputModeTitle(o: Operation): string {
|
|
|
100
102
|
|
|
101
103
|
function statusText(o: Operation): string {
|
|
102
104
|
if (o.status === 'running') {
|
|
105
|
+
if (o.cancelling) return t('opc.cancelling');
|
|
103
106
|
if (o.queued) return t('opc.queued');
|
|
104
107
|
// A plugin job says what it is doing in its own words (`job_progress`
|
|
105
108
|
// message); that beats a bare count.
|
|
@@ -107,6 +110,9 @@ function statusText(o: Operation): string {
|
|
|
107
110
|
if (o.totalBytes && o.totalBytes > 0) {
|
|
108
111
|
return `${formatSize(o.uploadedBytes ?? 0)} / ${formatSize(o.totalBytes)}`;
|
|
109
112
|
}
|
|
113
|
+
if ((o.kind === 'archive-create' || o.kind === 'archive-extract') && o.percent !== null) {
|
|
114
|
+
return t('opc.percent', { n: o.percent });
|
|
115
|
+
}
|
|
110
116
|
if (o.totalCount && o.totalCount > 0) {
|
|
111
117
|
return `${o.doneCount ?? 0}/${o.totalCount}`;
|
|
112
118
|
}
|
|
@@ -56,12 +56,12 @@ const emit = defineEmits<{
|
|
|
56
56
|
|
|
57
57
|
function mapStatus(op: PendingOp): OperationStatus {
|
|
58
58
|
if (op.status === 'done') return 'done';
|
|
59
|
-
if (op.status === 'error') return 'error';
|
|
60
59
|
if (op.status === 'cancelled') return 'aborted';
|
|
60
|
+
if (op.status === 'error') return 'error';
|
|
61
61
|
return 'running'; // pending | running
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
const DRAWN_KINDS: ReadonlySet<string> = new Set(['copy', 'move', 'delete', 'plugin']);
|
|
64
|
+
const DRAWN_KINDS: ReadonlySet<string> = new Set(['copy', 'move', 'delete', 'plugin', 'archive-create', 'archive-extract']);
|
|
65
65
|
|
|
66
66
|
/** A queue kind the center can draw. Anything it has no glyph for is shown as
|
|
67
67
|
* a generic job (`plugin`), never folded into delete. */
|
|
@@ -79,6 +79,7 @@ function isPluginJob(op: PendingOp): boolean {
|
|
|
79
79
|
* starts — for an administrator. */
|
|
80
80
|
function mayCancel(op: PendingOp): boolean {
|
|
81
81
|
if (op.status !== 'pending' && op.status !== 'running') return false;
|
|
82
|
+
if (op.op_type === 'archive-create' || op.op_type === 'archive-extract') return op.cancellable;
|
|
82
83
|
if (isPluginJob(op)) return true;
|
|
83
84
|
return op.op_type === 'trash-empty' && props.callerAdmin === true;
|
|
84
85
|
}
|
|
@@ -103,6 +104,7 @@ watch(
|
|
|
103
104
|
error: op.status === 'error' ? failureOf(op).text : null,
|
|
104
105
|
errorDetail: op.status === 'error' ? (failureOf(op).detail ?? null) : null,
|
|
105
106
|
queued: op.status === 'pending',
|
|
107
|
+
cancelling: op.status === 'cancelling',
|
|
106
108
|
doneCount: op.progress_done,
|
|
107
109
|
totalCount: op.progress_total,
|
|
108
110
|
cancellable: mayCancel(op),
|
|
@@ -112,8 +114,8 @@ watch(
|
|
|
112
114
|
outputMode: (isPluginJob(op) ? props.outputModeOf?.(op) : undefined) ?? null,
|
|
113
115
|
},
|
|
114
116
|
actions: {
|
|
115
|
-
dismiss: () => emit('dismiss', op.id),
|
|
116
117
|
cancel: () => emit('cancel', op.id),
|
|
118
|
+
dismiss: () => emit('dismiss', op.id),
|
|
117
119
|
open: (path: string) => emit('open', op.id, path),
|
|
118
120
|
},
|
|
119
121
|
})),
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ThumbTile — one file's thumbnail, or whatever the view draws instead.
|
|
4
|
+
*
|
|
5
|
+
* ⚠⚠ Why a component. A thumbnail's object URL arrives later, and whatever
|
|
6
|
+
* read it is woken when it does. Read in a folder view's own template, that was
|
|
7
|
+
* the whole view: every arrival re-rendered every row (see useThumbs). Read
|
|
8
|
+
* here, it is this tile.
|
|
9
|
+
*
|
|
10
|
+
* It also asks late. The resolver starts the fetch, and it is only called once
|
|
11
|
+
* the tile is near the viewport (lib/nearViewport), so opening a folder no
|
|
12
|
+
* longer downloads the thumbnails of tiles nobody has scrolled to.
|
|
13
|
+
*
|
|
14
|
+
* The <img> takes the attributes the view gives the tile (class, alt, aria-*);
|
|
15
|
+
* the default slot is the fallback — the view's type tile — drawn until the
|
|
16
|
+
* picture is there, and for good when there is none.
|
|
17
|
+
*
|
|
18
|
+
* ⚠ No `loading="lazy"`: the picture is already only handed over near the
|
|
19
|
+
* viewport, and it is an object URL in memory, so lazy would defer nothing but
|
|
20
|
+
* the decode — and an <img> that has not loaded yet when useThumbs revokes its
|
|
21
|
+
* URL (its oldest, past the cache's cap) would come up broken.
|
|
22
|
+
*/
|
|
23
|
+
import { computed, getCurrentInstance, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
24
|
+
import type { FileNode } from '../types/FileNode';
|
|
25
|
+
import { whenNearViewport } from '../lib/nearViewport';
|
|
26
|
+
|
|
27
|
+
defineOptions({ inheritAttrs: false });
|
|
28
|
+
|
|
29
|
+
const props = defineProps<{
|
|
30
|
+
node: FileNode;
|
|
31
|
+
/** The view's resolver: the object URL to draw, or null (not yet, or none). */
|
|
32
|
+
srcOf: (n: FileNode) => string | null;
|
|
33
|
+
/** Draw the play badge over the picture — a frame lifted out of a video is,
|
|
34
|
+
* on a card, indistinguishable from a photograph. */
|
|
35
|
+
videoBadge?: boolean;
|
|
36
|
+
}>();
|
|
37
|
+
|
|
38
|
+
const near = ref(false);
|
|
39
|
+
let stopWaiting: (() => void) | null = null;
|
|
40
|
+
|
|
41
|
+
onMounted(() => {
|
|
42
|
+
/* The tile draws no box of its own (it is the picture, or the fallback), so
|
|
43
|
+
it watches the box it sits in — the view's thumbnail cell. */
|
|
44
|
+
const anchor = getCurrentInstance()?.subTree.el as Node | null | undefined;
|
|
45
|
+
stopWaiting = whenNearViewport(anchor?.parentElement, () => {
|
|
46
|
+
near.value = true;
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
onBeforeUnmount(() => stopWaiting?.());
|
|
50
|
+
|
|
51
|
+
const src = computed(() => (near.value ? props.srcOf(props.node) : null));
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<img v-if="src" v-bind="$attrs" :src="src" draggable="false" />
|
|
56
|
+
<slot v-else />
|
|
57
|
+
<span v-if="src && videoBadge" class="fe-thumb__play" aria-hidden="true"></span>
|
|
58
|
+
</template>
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* thinks about CSRF vs Bearer vs Basic — it just calls `index(path)`.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import type { StorageInfo } from '../lib/catalogCoverage';
|
|
24
25
|
import type { ExplorerConfig, AuthConfig, EndpointMap } from '../types/ExplorerConfig';
|
|
25
26
|
import { resolveLocale } from '../locales/resolve';
|
|
26
27
|
import { listingAddress } from '../lib/internalPaths';
|
|
@@ -36,6 +37,7 @@ import type {
|
|
|
36
37
|
UploadLimits,
|
|
37
38
|
Capabilities,
|
|
38
39
|
ArchiveEntry,
|
|
40
|
+
ArchiveCreateFormat,
|
|
39
41
|
TrashEntry,
|
|
40
42
|
} from '../types/FileNode';
|
|
41
43
|
import type {
|
|
@@ -49,14 +51,15 @@ import type {
|
|
|
49
51
|
/** Server-side PendingOp DTO (mirror of Modules\FishApp\Models\PendingOp::toApiArray). */
|
|
50
52
|
export interface PendingOpDto {
|
|
51
53
|
id: number;
|
|
52
|
-
op_type: 'copy' | 'move' | 'delete';
|
|
53
|
-
status: 'pending' | 'running' | 'done' | 'error';
|
|
54
|
+
op_type: 'copy' | 'move' | 'delete' | 'archive-create' | 'archive-extract';
|
|
55
|
+
status: 'pending' | 'running' | 'cancelling' | 'done' | 'error' | 'cancelled';
|
|
54
56
|
progress_total: number;
|
|
55
57
|
progress_done: number;
|
|
56
58
|
target_path: string | null;
|
|
57
59
|
source_dir: string | null;
|
|
58
60
|
source_count: number;
|
|
59
61
|
error_message: string | null;
|
|
62
|
+
cancellable: boolean;
|
|
60
63
|
started_at: string | null;
|
|
61
64
|
finished_at: string | null;
|
|
62
65
|
created_at: string | null;
|
|
@@ -94,6 +97,10 @@ export interface ManagerResponse {
|
|
|
94
97
|
* list is not the whole answer. Absent on other actions and on servers
|
|
95
98
|
* older than the flag. */
|
|
96
99
|
truncated?: boolean;
|
|
100
|
+
/** Every storage the caller can open, with how much of it the catalog
|
|
101
|
+
* covers (`coverage`, absent when all of it — lib/catalogCoverage). Absent
|
|
102
|
+
* on servers older than the lazy catalog. */
|
|
103
|
+
storage_info?: StorageInfo[];
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
/** A single ACL grant row (RBAC permissions panel). */
|
|
@@ -256,6 +263,7 @@ export function resolveEndpoints(config: ExplorerConfig): EndpointMap {
|
|
|
256
263
|
capabilities: derive(config.capabilities, '/api/files/capabilities'),
|
|
257
264
|
archiveList: derive(config.archiveList, '/api/files/archive/list'),
|
|
258
265
|
archiveExtract: derive(config.archiveExtract, '/api/files/archive/extract'),
|
|
266
|
+
archiveCreate: derive(config.archiveCreate, '/api/files/archive/create'),
|
|
259
267
|
archiveAdd: derive(config.archiveAdd, '/api/files/archive/add'),
|
|
260
268
|
copy: derive(config.copy, '/api/files/copy'),
|
|
261
269
|
moveAsync: derive(config.moveAsync, '/api/files/move'),
|
|
@@ -979,14 +987,14 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
979
987
|
return jsonFetch(url, { method: 'DELETE' });
|
|
980
988
|
}
|
|
981
989
|
|
|
982
|
-
async function archiveList(path: string): Promise<{ entries: ArchiveEntry[] }> {
|
|
990
|
+
async function archiveList(path: string, password?: string): Promise<{ entries: ArchiveEntry[] }> {
|
|
983
991
|
if (!endpoints.archiveList) throw new Error('archiveList endpoint not configured');
|
|
984
992
|
const raw = await jsonFetch<{ entries: Array<{ name: string; size: number; is_dir: boolean; mtime?: number }> }>(
|
|
985
993
|
endpoints.archiveList,
|
|
986
994
|
{
|
|
987
995
|
method: 'POST',
|
|
988
996
|
headers: { 'Content-Type': 'application/json' },
|
|
989
|
-
body: JSON.stringify({ path }),
|
|
997
|
+
body: JSON.stringify({ path, password }),
|
|
990
998
|
},
|
|
991
999
|
);
|
|
992
1000
|
return {
|
|
@@ -999,12 +1007,33 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
999
1007
|
};
|
|
1000
1008
|
}
|
|
1001
1009
|
|
|
1002
|
-
async function archiveExtract(
|
|
1010
|
+
async function archiveExtract(
|
|
1011
|
+
path: string,
|
|
1012
|
+
options: { members?: string[]; password?: string; dest?: string } = {},
|
|
1013
|
+
): Promise<{ op?: PendingOpDto; keys?: string[]; count?: number }> {
|
|
1003
1014
|
if (!endpoints.archiveExtract) throw new Error('archiveExtract endpoint not configured');
|
|
1004
1015
|
return jsonFetch(endpoints.archiveExtract, {
|
|
1005
1016
|
method: 'POST',
|
|
1006
1017
|
headers: { 'Content-Type': 'application/json' },
|
|
1007
|
-
body: JSON.stringify({ path,
|
|
1018
|
+
body: JSON.stringify({ path, ...options }),
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
async function archiveCreate(payload: {
|
|
1023
|
+
dest: string;
|
|
1024
|
+
sources: string[];
|
|
1025
|
+
format: ArchiveCreateFormat;
|
|
1026
|
+
password?: string;
|
|
1027
|
+
encrypt_filenames?: boolean;
|
|
1028
|
+
compression?: number;
|
|
1029
|
+
solid?: boolean;
|
|
1030
|
+
dictionary_size_mb?: number;
|
|
1031
|
+
}): Promise<{ op: PendingOpDto }> {
|
|
1032
|
+
if (!endpoints.archiveCreate) throw new Error('archiveCreate endpoint not configured');
|
|
1033
|
+
return jsonFetch(endpoints.archiveCreate, {
|
|
1034
|
+
method: 'POST',
|
|
1035
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1036
|
+
body: JSON.stringify(payload),
|
|
1008
1037
|
});
|
|
1009
1038
|
}
|
|
1010
1039
|
|
|
@@ -1147,6 +1176,7 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
1147
1176
|
revokeShare,
|
|
1148
1177
|
archiveList,
|
|
1149
1178
|
archiveExtract,
|
|
1179
|
+
archiveCreate,
|
|
1150
1180
|
archiveAdd,
|
|
1151
1181
|
// Version history (koru:k1 inspector)
|
|
1152
1182
|
listVersions,
|
|
@@ -536,6 +536,30 @@ export function useLocale(
|
|
|
536
536
|
});
|
|
537
537
|
}
|
|
538
538
|
|
|
539
|
+
/**
|
|
540
|
+
* A listed item's size, the way every view draws it (list, inspector,
|
|
541
|
+
* multi-selection total).
|
|
542
|
+
*
|
|
543
|
+
* ⚠ `size_partial` is the server saying the catalog does not cover all of a
|
|
544
|
+
* folder yet (docs/LAZY-CATALOGUE.md): a lazily cataloged folder that is not
|
|
545
|
+
* cataloged below, or any folder while its storage's first sync runs. Its
|
|
546
|
+
* size is then a lower bound — "≥ 1.2 GB" — or, when nothing below it is
|
|
547
|
+
* known, not a number at all ("—"): printing "0 B" for a folder full of
|
|
548
|
+
* files nobody has cataloged yet would be a fact that is false. One helper,
|
|
549
|
+
* so the list and the inspector cannot disagree about it.
|
|
550
|
+
*/
|
|
551
|
+
function formatNodeSize(n: { size?: number | null; size_partial?: boolean } | null | undefined): string {
|
|
552
|
+
if (!n) return formatSize(null);
|
|
553
|
+
if (!n.size_partial) return formatSize(n.size);
|
|
554
|
+
return typeof n.size === 'number' && n.size > 0 ? t('size.at_least', { size: formatSize(n.size) }) : '—';
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** The hover text of a size `formatNodeSize` drew as partial, or undefined. */
|
|
558
|
+
function nodeSizeHint(n: { size?: number | null; size_partial?: boolean } | null | undefined): string | undefined {
|
|
559
|
+
if (!n?.size_partial) return undefined;
|
|
560
|
+
return typeof n.size === 'number' && n.size > 0 ? t('size.partial_hint') : t('size.unknown_hint');
|
|
561
|
+
}
|
|
562
|
+
|
|
539
563
|
/**
|
|
540
564
|
* Render a FileNode's basename with locale-aware overrides:
|
|
541
565
|
* - `.trash` directory → "Çöp Kutusu" / "Trash"
|
|
@@ -627,6 +651,8 @@ export function useLocale(
|
|
|
627
651
|
return {
|
|
628
652
|
t,
|
|
629
653
|
formatSize,
|
|
654
|
+
formatNodeSize,
|
|
655
|
+
nodeSizeHint,
|
|
630
656
|
formatDate,
|
|
631
657
|
formatDateFull,
|
|
632
658
|
formatMonthYear,
|
|
@@ -27,7 +27,9 @@ import { computed, getCurrentScope, onScopeDispose, ref } from 'vue';
|
|
|
27
27
|
|
|
28
28
|
/** `plugin` — an app-plugin job (docs/APP-PLUGINS-API.md); also what any
|
|
29
29
|
* queue kind this package has no drawing for is shown as. */
|
|
30
|
-
export type OperationKind =
|
|
30
|
+
export type OperationKind =
|
|
31
|
+
| 'upload' | 'copy' | 'move' | 'delete' | 'convert'
|
|
32
|
+
| 'archive' | 'archive-create' | 'archive-extract' | 'plugin' | 'trash';
|
|
31
33
|
export type OperationStatus = 'running' | 'done' | 'error' | 'aborted';
|
|
32
34
|
|
|
33
35
|
/** What a publisher hands to `sync()` for one row. */
|
|
@@ -47,6 +49,8 @@ export interface OperationInput {
|
|
|
47
49
|
errorDetail?: string | null;
|
|
48
50
|
/** Queue op accepted but not started yet ("Queued"). */
|
|
49
51
|
queued?: boolean;
|
|
52
|
+
/** Cancellation was accepted and the worker is stopping/cleaning up. */
|
|
53
|
+
cancelling?: boolean;
|
|
50
54
|
/** Progress counters for queue ops (3/5 items). */
|
|
51
55
|
doneCount?: number;
|
|
52
56
|
totalCount?: number;
|
|
@@ -90,6 +94,7 @@ export interface Operation {
|
|
|
90
94
|
error: string | null;
|
|
91
95
|
errorDetail: string | null;
|
|
92
96
|
queued: boolean;
|
|
97
|
+
cancelling: boolean;
|
|
93
98
|
doneCount: number | null;
|
|
94
99
|
totalCount: number | null;
|
|
95
100
|
uploadedBytes: number | null;
|
|
@@ -117,6 +122,7 @@ function toOperation(key: string, input: OperationInput, prev?: Operation): Oper
|
|
|
117
122
|
error: input.error ?? null,
|
|
118
123
|
errorDetail: input.errorDetail ?? null,
|
|
119
124
|
queued: input.queued ?? false,
|
|
125
|
+
cancelling: input.cancelling ?? false,
|
|
120
126
|
doneCount: input.doneCount ?? null,
|
|
121
127
|
totalCount: input.totalCount ?? null,
|
|
122
128
|
uploadedBytes: input.uploadedBytes ?? null,
|
|
@@ -33,7 +33,7 @@ export interface PendingOp {
|
|
|
33
33
|
id: number;
|
|
34
34
|
op_type: PendingOpType;
|
|
35
35
|
/** `cancelled` — somebody stopped it (POST /ops/{id}/cancel). */
|
|
36
|
-
status: 'pending' | 'running' | 'done' | 'error' | 'cancelled';
|
|
36
|
+
status: 'pending' | 'running' | 'cancelling' | 'done' | 'error' | 'cancelled';
|
|
37
37
|
progress_total: number;
|
|
38
38
|
progress_done: number;
|
|
39
39
|
/** Running cross-storage transfer's bytes (issue #27); absent otherwise. */
|
|
@@ -48,6 +48,7 @@ export interface PendingOp {
|
|
|
48
48
|
error_code?: string;
|
|
49
49
|
/** The engine a job needed and the server lacks (`error_code: engine_missing`). */
|
|
50
50
|
error_engine?: string;
|
|
51
|
+
cancellable: boolean;
|
|
51
52
|
started_at: string | null;
|
|
52
53
|
finished_at: string | null;
|
|
53
54
|
created_at: string | null;
|
|
@@ -94,9 +95,11 @@ export function normalizeOp(raw: Record<string, unknown>): PendingOp {
|
|
|
94
95
|
? 'error'
|
|
95
96
|
: rawStatus === 'cancelled'
|
|
96
97
|
? 'cancelled'
|
|
97
|
-
: rawStatus === '
|
|
98
|
-
? '
|
|
99
|
-
: '
|
|
98
|
+
: rawStatus === 'cancelling'
|
|
99
|
+
? 'cancelling'
|
|
100
|
+
: rawStatus === 'running'
|
|
101
|
+
? 'running'
|
|
102
|
+
: 'pending';
|
|
100
103
|
const sources = Array.isArray(raw.sources) ? raw.sources : [];
|
|
101
104
|
// ⚠ Unknown kinds pass through. This used to fold everything that was not
|
|
102
105
|
// copy/move into `delete`, which drew a bin beside a plugin job and read
|
|
@@ -124,6 +127,7 @@ export function normalizeOp(raw: Record<string, unknown>): PendingOp {
|
|
|
124
127
|
error_message: str(raw.error_message, raw.error),
|
|
125
128
|
error_code: str(raw.error_code) ?? undefined,
|
|
126
129
|
error_engine: str(raw.error_engine) ?? undefined,
|
|
130
|
+
cancellable: raw.cancellable === true,
|
|
127
131
|
started_at: str(raw.started_at),
|
|
128
132
|
finished_at: str(raw.finished_at),
|
|
129
133
|
created_at: str(raw.created_at),
|
|
@@ -155,7 +159,7 @@ export function usePendingOps(
|
|
|
155
159
|
let firstPollDone = false;
|
|
156
160
|
|
|
157
161
|
const hasActive = computed(() =>
|
|
158
|
-
ops.value.some((o) => o.status === 'pending' || o.status === 'running'),
|
|
162
|
+
ops.value.some((o) => o.status === 'pending' || o.status === 'running' || o.status === 'cancelling'),
|
|
159
163
|
);
|
|
160
164
|
|
|
161
165
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -197,7 +201,7 @@ export function usePendingOps(
|
|
|
197
201
|
// is currently seeing them in the local tray.
|
|
198
202
|
const localIds = new Set(ops.value.map((o) => o.id));
|
|
199
203
|
const visibleIncoming = incoming.filter((op) => {
|
|
200
|
-
if (op.status === 'pending' || op.status === 'running') return true;
|
|
204
|
+
if (op.status === 'pending' || op.status === 'running' || op.status === 'cancelling') return true;
|
|
201
205
|
if (!firstPollDone) return false;
|
|
202
206
|
return localIds.has(op.id);
|
|
203
207
|
});
|
|
@@ -211,7 +215,7 @@ export function usePendingOps(
|
|
|
211
215
|
// Sweep: drop terminal ops past RETAIN_MS.
|
|
212
216
|
const now = Date.now();
|
|
213
217
|
const after = merged.filter((o) => {
|
|
214
|
-
if (o.status === 'pending' || o.status === 'running') return true;
|
|
218
|
+
if (o.status === 'pending' || o.status === 'running' || o.status === 'cancelling') return true;
|
|
215
219
|
const at = settledAt.get(o.id);
|
|
216
220
|
return at !== undefined && now - at < RETAIN_MS;
|
|
217
221
|
});
|