@brftech/filex-core 0.1.83 → 0.2.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-BRASXNIS.js → ArchiveViewer-DCZ1FZYV.js} +2 -2
- package/dist/{ArchiveViewer-BRASXNIS.js.map → ArchiveViewer-DCZ1FZYV.js.map} +1 -1
- package/dist/{CsvViewer-CgiLiHWw.js → CsvViewer-DMvnp82x.js} +2 -2
- package/dist/{CsvViewer-CgiLiHWw.js.map → CsvViewer-DMvnp82x.js.map} +1 -1
- package/dist/{DrawioViewer-D2I_uEng.js → DrawioViewer-B5Io0yTO.js} +2 -2
- package/dist/{DrawioViewer-D2I_uEng.js.map → DrawioViewer-B5Io0yTO.js.map} +1 -1
- package/dist/{EpubViewer-6nVPRR6R.js → EpubViewer-z6RZd3K8.js} +2 -2
- package/dist/{EpubViewer-6nVPRR6R.js.map → EpubViewer-z6RZd3K8.js.map} +1 -1
- package/dist/{IpynbViewer-B7p9hxFx.js → IpynbViewer-DfL4EauH.js} +2 -2
- package/dist/{IpynbViewer-B7p9hxFx.js.map → IpynbViewer-DfL4EauH.js.map} +1 -1
- package/dist/{MermaidViewer-O9vGvp4y.js → MermaidViewer-QNl_o7V-.js} +2 -2
- package/dist/{MermaidViewer-O9vGvp4y.js.map → MermaidViewer-QNl_o7V-.js.map} +1 -1
- package/dist/{PsdViewer-C6EaEAxF.js → PsdViewer-Btmpr0Fz.js} +2 -2
- package/dist/{PsdViewer-C6EaEAxF.js.map → PsdViewer-Btmpr0Fz.js.map} +1 -1
- package/dist/{TiffViewer-Dod4uIXI.js → TiffViewer-CMLpwds5.js} +2 -2
- package/dist/{TiffViewer-Dod4uIXI.js.map → TiffViewer-CMLpwds5.js.map} +1 -1
- package/dist/{Viewer3D-B3kLZrwO.js → Viewer3D-gf3cb2gv.js} +2 -2
- package/dist/{Viewer3D-B3kLZrwO.js.map → Viewer3D-gf3cb2gv.js.map} +1 -1
- package/dist/filex-core.js +17 -15
- package/dist/filex-core.umd.cjs +38 -38
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-D49iw00E.js +7119 -0
- package/dist/index-D49iw00E.js.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +440 -32
- package/src/components/Breadcrumb.vue +100 -5
- package/src/components/CommandPalette.vue +533 -0
- package/src/components/GridView.vue +8 -13
- package/src/components/ListView.vue +199 -21
- package/src/components/ShortcutsHelp.vue +59 -0
- package/src/components/Toolbar.vue +65 -1
- package/src/composables/useFileApi.ts +45 -0
- package/src/composables/useKeyboardShortcuts.ts +38 -0
- package/src/composables/useRealtime.ts +9 -1
- package/src/index.ts +4 -0
- package/src/lib/fileIcons.ts +127 -0
- package/src/lib/snippet.ts +43 -0
- package/src/locales/en.ts +62 -0
- package/src/locales/tr.ts +62 -0
- package/src/styles/base.css +650 -0
- package/src/styles/variables.css +70 -0
- package/dist/index-DL6_eaM3.js +0 -5888
- package/dist/index-DL6_eaM3.js.map +0 -1
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { FileNode } from '../types/FileNode';
|
|
6
6
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
7
7
|
import { useLocale } from '../composables/useLocale';
|
|
8
|
+
import { fileIconSvg } from '../lib/fileIcons';
|
|
8
9
|
|
|
9
10
|
const props = defineProps<{
|
|
10
11
|
files: FileNode[];
|
|
@@ -101,20 +102,12 @@ function parentDir(path: string): string {
|
|
|
101
102
|
return stripped.slice(0, idx);
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
// Special rows keep their emoji (trash/storage are not file-TYPE icons);
|
|
106
|
+
// everything else renders the SVG icon set from lib/fileIcons.
|
|
107
|
+
function specialEmojiFor(n: FileNode): string | null {
|
|
105
108
|
if (n.basename === '.trash') return '🗑';
|
|
106
109
|
if (n.mime_type === 'inode/storage') return '💾';
|
|
107
|
-
|
|
108
|
-
const e = (n.extension || '').toLowerCase();
|
|
109
|
-
if (['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'svg'].includes(e)) return '🖼';
|
|
110
|
-
if (['mp4', 'webm', 'mov', 'mkv'].includes(e)) return '🎞';
|
|
111
|
-
if (['mp3', 'wav', 'flac', 'ogg'].includes(e)) return '🎵';
|
|
112
|
-
if (e === 'pdf') return '📕';
|
|
113
|
-
if (['doc', 'docx', 'odt'].includes(e)) return '📄';
|
|
114
|
-
if (['xls', 'xlsx', 'csv'].includes(e)) return '📊';
|
|
115
|
-
if (['ppt', 'pptx'].includes(e)) return '📽';
|
|
116
|
-
if (['zip', 'tar', 'gz', '7z'].includes(e)) return '🗜';
|
|
117
|
-
return '📎';
|
|
110
|
+
return null;
|
|
118
111
|
}
|
|
119
112
|
</script>
|
|
120
113
|
|
|
@@ -156,7 +149,9 @@ function iconFor(n: FileNode): string {
|
|
|
156
149
|
loading="lazy"
|
|
157
150
|
draggable="false"
|
|
158
151
|
/>
|
|
159
|
-
<span v-else class="fe-grid__icon">{{
|
|
152
|
+
<span v-else-if="specialEmojiFor(n)" class="fe-grid__icon">{{ specialEmojiFor(n) }}</span>
|
|
153
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
|
|
154
|
+
<span v-else class="fe-grid__icon fe-grid__icon--svg" v-html="fileIconSvg(n)"></span>
|
|
160
155
|
</div>
|
|
161
156
|
<div class="fe-grid__label" :title="n.basename">
|
|
162
157
|
{{ nodeDisplayName(n) }}
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* just emit the events and let FileExplorer.vue handle them uniformly
|
|
7
7
|
* across List and Grid.
|
|
8
8
|
*/
|
|
9
|
-
import { computed } from 'vue';
|
|
9
|
+
import { computed, ref } from 'vue';
|
|
10
10
|
import type { FileNode } from '../types/FileNode';
|
|
11
11
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
12
12
|
import { useLocale } from '../composables/useLocale';
|
|
13
|
+
import { fileIconSvg } from '../lib/fileIcons';
|
|
14
|
+
import { matchedInContent, snippetSegments } from '../lib/snippet'; /* bul:s3 */
|
|
13
15
|
import StarButton from './StarButton.vue';
|
|
14
16
|
|
|
15
17
|
const props = defineProps<{
|
|
@@ -111,21 +113,16 @@ function cancelPress() {
|
|
|
111
113
|
pressTarget = null;
|
|
112
114
|
}
|
|
113
115
|
|
|
114
|
-
|
|
116
|
+
// Special rows keep their emoji (trash/storage are not file-TYPE icons);
|
|
117
|
+
// everything else renders the SVG icon set from lib/fileIcons.
|
|
118
|
+
function specialEmojiFor(n: FileNode): string | null {
|
|
115
119
|
if (n.basename === '.trash') return '🗑';
|
|
116
120
|
if (n.mime_type === 'inode/storage') return '💾';
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (e === 'pdf') return '📕';
|
|
123
|
-
if (['doc', 'docx', 'odt', 'rtf'].includes(e)) return '📄';
|
|
124
|
-
if (['xls', 'xlsx', 'ods', 'csv'].includes(e)) return '📊';
|
|
125
|
-
if (['ppt', 'pptx', 'odp'].includes(e)) return '📽';
|
|
126
|
-
if (['zip', 'tar', 'gz', 'bz2', '7z', 'rar'].includes(e)) return '🗜';
|
|
127
|
-
if (['txt', 'md', 'log', 'json', 'yml', 'yaml', 'xml'].includes(e)) return '📝';
|
|
128
|
-
return '📎';
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isPinnedSpecial(n: FileNode): boolean {
|
|
125
|
+
return n.basename === '.trash' || n.mime_type === 'inode/storage';
|
|
129
126
|
}
|
|
130
127
|
|
|
131
128
|
function displayDate(ms: number | undefined): string {
|
|
@@ -141,20 +138,187 @@ function parentDir(path: string): string {
|
|
|
141
138
|
return stripped.slice(0, idx);
|
|
142
139
|
}
|
|
143
140
|
|
|
144
|
-
|
|
141
|
+
/* bul:s3 — search-result enrichment. The v0.2 backend inlines `snippet`
|
|
142
|
+
* (plain text, «» highlights) + `matched` on search hits; regular listings
|
|
143
|
+
* never carry them, so presence-gating keeps normal rows untouched and an
|
|
144
|
+
* older backend simply renders nothing extra. */
|
|
145
|
+
function rowSnippet(n: FileNode): string {
|
|
146
|
+
const s = (n as Record<string, unknown>).snippet;
|
|
147
|
+
return typeof s === 'string' ? s : '';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function rowInContent(n: FileNode): boolean {
|
|
151
|
+
return matchedInContent((n as Record<string, unknown>).matched);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ------------------------------------------------------------------
|
|
155
|
+
// Column sorting — local to the list view. Default (null) keeps the
|
|
156
|
+
// backend order, i.e. exactly the pre-existing behavior.
|
|
157
|
+
// ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
type SortKey = 'name' | 'size' | 'modified';
|
|
160
|
+
const SORT_LS_KEY = 'filex.list-sort';
|
|
161
|
+
|
|
162
|
+
const sortKey = ref<SortKey | null>(null);
|
|
163
|
+
const sortDir = ref<'asc' | 'desc'>('asc');
|
|
164
|
+
try {
|
|
165
|
+
const raw = localStorage.getItem(SORT_LS_KEY);
|
|
166
|
+
if (raw) {
|
|
167
|
+
const p = JSON.parse(raw) as { key?: unknown; dir?: unknown };
|
|
168
|
+
if (p.key === 'name' || p.key === 'size' || p.key === 'modified') sortKey.value = p.key;
|
|
169
|
+
if (p.dir === 'asc' || p.dir === 'desc') sortDir.value = p.dir;
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
/* private mode / bad JSON */
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Dates default to newest-first, the others to ascending.
|
|
176
|
+
function defaultDir(key: SortKey): 'asc' | 'desc' {
|
|
177
|
+
return key === 'modified' ? 'desc' : 'asc';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Click cycle per column: default direction → flipped → back to backend order.
|
|
181
|
+
function toggleSort(key: SortKey) {
|
|
182
|
+
if (sortKey.value !== key) {
|
|
183
|
+
sortKey.value = key;
|
|
184
|
+
sortDir.value = defaultDir(key);
|
|
185
|
+
} else if (sortDir.value === defaultDir(key)) {
|
|
186
|
+
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
|
|
187
|
+
} else {
|
|
188
|
+
sortKey.value = null;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
localStorage.setItem(
|
|
192
|
+
SORT_LS_KEY,
|
|
193
|
+
JSON.stringify({ key: sortKey.value, dir: sortDir.value }),
|
|
194
|
+
);
|
|
195
|
+
} catch {
|
|
196
|
+
/* quota */
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function ariaSort(key: SortKey): 'ascending' | 'descending' | 'none' {
|
|
201
|
+
if (sortKey.value !== key) return 'none';
|
|
202
|
+
return sortDir.value === 'asc' ? 'ascending' : 'descending';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function modifiedMs(n: FileNode): number | null {
|
|
206
|
+
const v = n.last_modified;
|
|
207
|
+
if (!v) return null;
|
|
208
|
+
return v * (v < 1e12 ? 1000 : 1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const rows = computed(() => {
|
|
212
|
+
const key = sortKey.value;
|
|
213
|
+
if (!key) return props.files;
|
|
214
|
+
// Virtual rows (.trash / storage drives) stay pinned on top in their
|
|
215
|
+
// original order; only real entries take part in the sort.
|
|
216
|
+
const pinned: FileNode[] = [];
|
|
217
|
+
const rest: FileNode[] = [];
|
|
218
|
+
for (const n of props.files) (isPinnedSpecial(n) ? pinned : rest).push(n);
|
|
219
|
+
const dir = sortDir.value === 'asc' ? 1 : -1;
|
|
220
|
+
rest.sort((a, b) => {
|
|
221
|
+
if (key === 'name') {
|
|
222
|
+
return (
|
|
223
|
+
dir *
|
|
224
|
+
a.basename.localeCompare(b.basename, undefined, { numeric: true, sensitivity: 'base' })
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (key === 'size') return dir * ((a.size ?? -1) - (b.size ?? -1));
|
|
228
|
+
const am = modifiedMs(a);
|
|
229
|
+
const bm = modifiedMs(b);
|
|
230
|
+
// Undated rows go last in both directions so date groups stay clean.
|
|
231
|
+
if (am == null && bm == null) return 0;
|
|
232
|
+
if (am == null) return 1;
|
|
233
|
+
if (bm == null) return -1;
|
|
234
|
+
return dir * (am - bm);
|
|
235
|
+
});
|
|
236
|
+
return [...pinned, ...rest];
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ------------------------------------------------------------------
|
|
240
|
+
// Date grouping — only while sorted by modified date. Rows are split
|
|
241
|
+
// into contiguous segments so the row markup below stays untouched.
|
|
242
|
+
// ------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
interface Segment {
|
|
245
|
+
id: string;
|
|
246
|
+
/** null = no header (ungrouped mode, or the pinned virtual rows). */
|
|
247
|
+
label: string | null;
|
|
248
|
+
nodes: FileNode[];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const monthFmt = computed(
|
|
252
|
+
() =>
|
|
253
|
+
new Intl.DateTimeFormat(props.locale === 'tr' ? 'tr-TR' : 'en-US', {
|
|
254
|
+
month: 'long',
|
|
255
|
+
year: 'numeric',
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
function bucketFor(ms: number | null): { id: string; label: string } {
|
|
260
|
+
if (ms == null) return { id: 'none', label: t('group.no_date') };
|
|
261
|
+
const d = new Date(ms);
|
|
262
|
+
const now = new Date();
|
|
263
|
+
const dayStart = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
264
|
+
const today = dayStart(now);
|
|
265
|
+
const day = dayStart(d);
|
|
266
|
+
const DAY = 86_400_000;
|
|
267
|
+
// Future stamps (clock skew) read best as "today".
|
|
268
|
+
if (day >= today) return { id: 'today', label: t('group.today') };
|
|
269
|
+
if (day === today - DAY) return { id: 'yesterday', label: t('group.yesterday') };
|
|
270
|
+
if (day > today - 7 * DAY) return { id: 'week', label: t('group.this_week') };
|
|
271
|
+
if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) {
|
|
272
|
+
return { id: 'month', label: t('group.this_month') };
|
|
273
|
+
}
|
|
274
|
+
return { id: `m-${d.getFullYear()}-${d.getMonth()}`, label: monthFmt.value.format(d) };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const segments = computed<Segment[]>(() => {
|
|
278
|
+
const list = rows.value;
|
|
279
|
+
if (sortKey.value !== 'modified') return [{ id: 'all', label: null, nodes: list }];
|
|
280
|
+
const out: Segment[] = [];
|
|
281
|
+
let cur: Segment | null = null;
|
|
282
|
+
for (const n of list) {
|
|
283
|
+
const b = isPinnedSpecial(n) ? { id: 'pinned', label: null } : bucketFor(modifiedMs(n));
|
|
284
|
+
if (!cur || cur.id !== b.id) {
|
|
285
|
+
cur = { id: b.id, label: b.label, nodes: [] };
|
|
286
|
+
out.push(cur);
|
|
287
|
+
}
|
|
288
|
+
cur.nodes.push(n);
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
});
|
|
145
292
|
</script>
|
|
146
293
|
|
|
147
294
|
<template>
|
|
148
295
|
<div class="fe-list" :class="{ 'is-loading': loading, 'has-star-col': !!apiBase || apiBase === '' }">
|
|
149
296
|
<div class="fe-list__head" role="row">
|
|
150
297
|
<div class="fe-list__col fe-list__col--star" role="columnheader" aria-label="Star"></div>
|
|
151
|
-
<div class="fe-list__col fe-list__col--name" role="columnheader"
|
|
152
|
-
|
|
153
|
-
|
|
298
|
+
<div class="fe-list__col fe-list__col--name" role="columnheader" :aria-sort="ariaSort('name')">
|
|
299
|
+
<button type="button" class="fe-list__sort" :title="t('col.sort')" @click="toggleSort('name')">
|
|
300
|
+
{{ t('col.name') }}
|
|
301
|
+
<span v-if="sortKey === 'name'" class="fe-list__sort-arrow" aria-hidden="true">{{ sortDir === 'asc' ? '↑' : '↓' }}</span>
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
<div class="fe-list__col fe-list__col--size" role="columnheader" :aria-sort="ariaSort('size')">
|
|
305
|
+
<button type="button" class="fe-list__sort" :title="t('col.sort')" @click="toggleSort('size')">
|
|
306
|
+
{{ t('col.size') }}
|
|
307
|
+
<span v-if="sortKey === 'size'" class="fe-list__sort-arrow" aria-hidden="true">{{ sortDir === 'asc' ? '↑' : '↓' }}</span>
|
|
308
|
+
</button>
|
|
309
|
+
</div>
|
|
310
|
+
<div class="fe-list__col fe-list__col--mod" role="columnheader" :aria-sort="ariaSort('modified')">
|
|
311
|
+
<button type="button" class="fe-list__sort" :title="t('col.sort')" @click="toggleSort('modified')">
|
|
312
|
+
{{ t('col.modified') }}
|
|
313
|
+
<span v-if="sortKey === 'modified'" class="fe-list__sort-arrow" aria-hidden="true">{{ sortDir === 'asc' ? '↑' : '↓' }}</span>
|
|
314
|
+
</button>
|
|
315
|
+
</div>
|
|
154
316
|
</div>
|
|
155
317
|
<div class="fe-list__body" role="rowgroup">
|
|
318
|
+
<template v-for="seg in segments" :key="seg.id">
|
|
319
|
+
<div v-if="seg.label" class="fe-list__group" role="presentation">{{ seg.label }}</div>
|
|
156
320
|
<div
|
|
157
|
-
v-for="n in
|
|
321
|
+
v-for="n in seg.nodes"
|
|
158
322
|
:key="n.path"
|
|
159
323
|
class="fe-list__row"
|
|
160
324
|
:class="{
|
|
@@ -188,14 +352,27 @@ const rows = computed(() => props.files);
|
|
|
188
352
|
/>
|
|
189
353
|
</div>
|
|
190
354
|
<div class="fe-list__col fe-list__col--name">
|
|
191
|
-
<span class="fe-list__icon" aria-hidden="true">{{
|
|
355
|
+
<span v-if="specialEmojiFor(n)" class="fe-list__icon" aria-hidden="true">{{ specialEmojiFor(n) }}</span>
|
|
356
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
|
|
357
|
+
<span v-else class="fe-list__icon fe-list__icon--svg" aria-hidden="true" v-html="fileIconSvg(n)"></span>
|
|
192
358
|
<div class="fe-list__name-wrap">
|
|
193
|
-
<span class="fe-list__name" :title="n.basename">
|
|
359
|
+
<span class="fe-list__name" :title="n.basename">
|
|
360
|
+
{{ nodeDisplayName(n) }}
|
|
361
|
+
<!-- bul:s3 — content-match badge -->
|
|
362
|
+
<span v-if="rowInContent(n)" class="fe-list__badge">{{ t('search.in_content') }}</span>
|
|
363
|
+
</span>
|
|
194
364
|
<span
|
|
195
365
|
v-if="showParentPath"
|
|
196
366
|
class="fe-list__parent"
|
|
197
367
|
:title="parentDir(n.path)"
|
|
198
368
|
>{{ parentDir(n.path) || '—' }}</span>
|
|
369
|
+
<!-- bul:s3 — content snippet («» → <mark> via TEXT segments, no innerHTML) -->
|
|
370
|
+
<span v-if="rowSnippet(n)" class="fe-list__snippet">
|
|
371
|
+
<template v-for="(seg, si) in snippetSegments(rowSnippet(n))" :key="si">
|
|
372
|
+
<mark v-if="seg.match" class="fe-list__mark">{{ seg.text }}</mark>
|
|
373
|
+
<template v-else>{{ seg.text }}</template>
|
|
374
|
+
</template>
|
|
375
|
+
</span>
|
|
199
376
|
</div>
|
|
200
377
|
</div>
|
|
201
378
|
<div class="fe-list__col fe-list__col--size">
|
|
@@ -205,6 +382,7 @@ const rows = computed(() => props.files);
|
|
|
205
382
|
{{ displayDate(n.last_modified) }}
|
|
206
383
|
</div>
|
|
207
384
|
</div>
|
|
385
|
+
</template>
|
|
208
386
|
<div v-if="!loading && rows.length === 0" class="fe-list__empty">
|
|
209
387
|
{{ t('empty.folder') }}
|
|
210
388
|
</div>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ShortcutsHelp — "?" cheat-sheet modal.
|
|
4
|
+
*
|
|
5
|
+
* Renders the SHORTCUTS table exported by useKeyboardShortcuts (single
|
|
6
|
+
* source of truth) grouped by section. Modal.vue supplies Esc handling,
|
|
7
|
+
* backdrop, focus and the close button.
|
|
8
|
+
*/
|
|
9
|
+
import { computed } from 'vue';
|
|
10
|
+
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
11
|
+
import { useLocale } from '../composables/useLocale';
|
|
12
|
+
import { SHORTCUTS, type ShortcutDef } from '../composables/useKeyboardShortcuts';
|
|
13
|
+
import Modal from '../modals/Modal.vue';
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
open: boolean;
|
|
17
|
+
locale: LocaleCode;
|
|
18
|
+
}>();
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits<{
|
|
21
|
+
(e: 'close'): void;
|
|
22
|
+
}>();
|
|
23
|
+
|
|
24
|
+
const { t } = useLocale(() => props.locale);
|
|
25
|
+
|
|
26
|
+
const groups = computed(() => {
|
|
27
|
+
const order: string[] = [];
|
|
28
|
+
const map = new Map<string, ShortcutDef[]>();
|
|
29
|
+
for (const s of SHORTCUTS) {
|
|
30
|
+
if (!map.has(s.groupKey)) {
|
|
31
|
+
map.set(s.groupKey, []);
|
|
32
|
+
order.push(s.groupKey);
|
|
33
|
+
}
|
|
34
|
+
map.get(s.groupKey)!.push(s);
|
|
35
|
+
}
|
|
36
|
+
return order.map((key) => ({ key, items: map.get(key)! }));
|
|
37
|
+
});
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<Modal :open="open" :title="t('shortcuts.title')" size="md" @close="emit('close')">
|
|
42
|
+
<div class="fe-shortcuts">
|
|
43
|
+
<section v-for="g in groups" :key="g.key" class="fe-shortcuts__group">
|
|
44
|
+
<h3 class="fe-shortcuts__heading">{{ t(g.key) }}</h3>
|
|
45
|
+
<div class="fe-shortcuts__table">
|
|
46
|
+
<div v-for="s in g.items" :key="s.labelKey" class="fe-shortcuts__row">
|
|
47
|
+
<span class="fe-shortcuts__keys">
|
|
48
|
+
<template v-for="(combo, i) in s.keys" :key="combo">
|
|
49
|
+
<kbd class="fe-kbd">{{ combo }}</kbd>
|
|
50
|
+
<span v-if="i < s.keys.length - 1" class="fe-shortcuts__or" aria-hidden="true">/</span>
|
|
51
|
+
</template>
|
|
52
|
+
</span>
|
|
53
|
+
<span class="fe-shortcuts__desc">{{ t(s.labelKey) }}</span>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</section>
|
|
57
|
+
</div>
|
|
58
|
+
</Modal>
|
|
59
|
+
</template>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Presentational only — all logic (rename, share, …) lives in
|
|
12
12
|
* FileExplorer.vue, which listens for `action` emits.
|
|
13
13
|
*/
|
|
14
|
-
import { computed, ref, watch } from 'vue';
|
|
14
|
+
import { computed, onMounted, ref, watch } from 'vue';
|
|
15
15
|
import type { ViewMode } from '../types/FileNode';
|
|
16
16
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
17
17
|
import type { ContextAction } from './ContextMenu.vue';
|
|
@@ -69,8 +69,34 @@ const emit = defineEmits<{
|
|
|
69
69
|
(e: 'go-up'): void;
|
|
70
70
|
(e: 'action', key: string): void;
|
|
71
71
|
(e: 'open-recents'): void;
|
|
72
|
+
(e: 'update:density', v: Density): void;
|
|
72
73
|
}>();
|
|
73
74
|
|
|
75
|
+
// Density toggle — the toolbar owns the persisted preference; the parent
|
|
76
|
+
// only mirrors the value into a root class so both views pick it up.
|
|
77
|
+
export type Density = 'comfortable' | 'compact';
|
|
78
|
+
const DENSITY_LS_KEY = 'filex.density';
|
|
79
|
+
const density = ref<Density>(
|
|
80
|
+
(() => {
|
|
81
|
+
try {
|
|
82
|
+
return localStorage.getItem(DENSITY_LS_KEY) === 'compact' ? 'compact' : 'comfortable';
|
|
83
|
+
} catch {
|
|
84
|
+
return 'comfortable';
|
|
85
|
+
}
|
|
86
|
+
})(),
|
|
87
|
+
);
|
|
88
|
+
onMounted(() => emit('update:density', density.value));
|
|
89
|
+
|
|
90
|
+
function toggleDensity() {
|
|
91
|
+
density.value = density.value === 'compact' ? 'comfortable' : 'compact';
|
|
92
|
+
try {
|
|
93
|
+
localStorage.setItem(DENSITY_LS_KEY, density.value);
|
|
94
|
+
} catch {
|
|
95
|
+
/* quota */
|
|
96
|
+
}
|
|
97
|
+
emit('update:density', density.value);
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
const { t } = useLocale(() => props.locale);
|
|
75
101
|
|
|
76
102
|
const searchEl = ref<HTMLInputElement | null>(null);
|
|
@@ -192,6 +218,44 @@ function fire(key: string) {
|
|
|
192
218
|
</button>
|
|
193
219
|
</div>
|
|
194
220
|
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
class="fe-btn fe-btn--icon-only fe-toolbar__density"
|
|
224
|
+
:class="{ 'is-active': density === 'compact' }"
|
|
225
|
+
:aria-pressed="density === 'compact'"
|
|
226
|
+
:aria-label="density === 'compact' ? t('toolbar.density.comfortable') : t('toolbar.density.compact')"
|
|
227
|
+
:title="density === 'compact' ? t('toolbar.density.comfortable') : t('toolbar.density.compact')"
|
|
228
|
+
@click="toggleDensity"
|
|
229
|
+
>
|
|
230
|
+
<!-- Row-spacing glyph: tight rows when compact is ON, airy rows otherwise. -->
|
|
231
|
+
<svg
|
|
232
|
+
v-if="density === 'compact'"
|
|
233
|
+
class="fe-ficon"
|
|
234
|
+
viewBox="0 0 24 24"
|
|
235
|
+
fill="none"
|
|
236
|
+
stroke="currentColor"
|
|
237
|
+
stroke-width="1.8"
|
|
238
|
+
stroke-linecap="round"
|
|
239
|
+
aria-hidden="true"
|
|
240
|
+
focusable="false"
|
|
241
|
+
>
|
|
242
|
+
<path d="M4 6.5h16M4 10.5h16M4 14.5h16M4 18.5h16" />
|
|
243
|
+
</svg>
|
|
244
|
+
<svg
|
|
245
|
+
v-else
|
|
246
|
+
class="fe-ficon"
|
|
247
|
+
viewBox="0 0 24 24"
|
|
248
|
+
fill="none"
|
|
249
|
+
stroke="currentColor"
|
|
250
|
+
stroke-width="1.8"
|
|
251
|
+
stroke-linecap="round"
|
|
252
|
+
aria-hidden="true"
|
|
253
|
+
focusable="false"
|
|
254
|
+
>
|
|
255
|
+
<path d="M4 5.5h16M4 12h16M4 18.5h16" />
|
|
256
|
+
</svg>
|
|
257
|
+
</button>
|
|
258
|
+
|
|
195
259
|
<div class="fe-toolbar__view" role="tablist" aria-label="View">
|
|
196
260
|
<button
|
|
197
261
|
type="button"
|
|
@@ -101,6 +101,34 @@ export interface InviteResponse {
|
|
|
101
101
|
emailed: boolean;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/* === bul:s3 — global search (GET /api/files/search) === */
|
|
105
|
+
|
|
106
|
+
export type GlobalSearchScope = 'name' | 'content' | 'all';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One hit from the dedicated files-search endpoint. The backend returns raw
|
|
110
|
+
* node rows (`{results: [...]}`), so `path` is the IN-STORAGE relative path
|
|
111
|
+
* (no `adapter://` prefix) and the storage comes back as a numeric
|
|
112
|
+
* `storage_id`. `snippet`/`matched` are the v0.2 "Bul" contract additions —
|
|
113
|
+
* older backends simply omit them, so every consumer must stay
|
|
114
|
+
* undefined-safe.
|
|
115
|
+
*/
|
|
116
|
+
export interface GlobalSearchHit {
|
|
117
|
+
id?: number;
|
|
118
|
+
storage_id?: number;
|
|
119
|
+
name?: string;
|
|
120
|
+
path?: string;
|
|
121
|
+
/** `file` | `dir` (backend NodeType). */
|
|
122
|
+
type?: string;
|
|
123
|
+
size?: number;
|
|
124
|
+
mime?: string;
|
|
125
|
+
/** Plain-text content snippet; matches wrapped in «» (never HTML). */
|
|
126
|
+
snippet?: string;
|
|
127
|
+
/** Where the hit matched: name | content | both. */
|
|
128
|
+
matched?: 'name' | 'content' | 'both';
|
|
129
|
+
[k: string]: unknown;
|
|
130
|
+
}
|
|
131
|
+
|
|
104
132
|
/**
|
|
105
133
|
* Resolve a Vuefinder-compatible endpoint map from the user's config.
|
|
106
134
|
* Either `apiBase` is set (auto-derive everything) or each route is
|
|
@@ -340,6 +368,22 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
340
368
|
return jsonFetch<ManagerResponse>(managerUrl('search', { path, filter }));
|
|
341
369
|
}
|
|
342
370
|
|
|
371
|
+
/* === bul:s3 — global "search everywhere" ===
|
|
372
|
+
* Derived from the manager endpoint by swapping `/manager` for `/search`
|
|
373
|
+
* (same trick permissionsUrl uses), so embedded proxies that forward the
|
|
374
|
+
* whole /api/files/* subtree keep working. Errors and legacy backends
|
|
375
|
+
* degrade to an empty result list — the palette just shows nothing. */
|
|
376
|
+
async function globalSearch(
|
|
377
|
+
query: string,
|
|
378
|
+
opts: { limit?: number; scope?: GlobalSearchScope } = {},
|
|
379
|
+
): Promise<GlobalSearchHit[]> {
|
|
380
|
+
const base = endpoints.manager.replace(/\/manager(\?.*)?$/, '/search');
|
|
381
|
+
const sep = base.includes('?') ? '&' : '?';
|
|
382
|
+
const url = `${base}${sep}${qs({ q: query, limit: opts.limit, scope: opts.scope })}`;
|
|
383
|
+
const data = await jsonFetch<{ results?: GlobalSearchHit[] | null }>(url);
|
|
384
|
+
return Array.isArray(data?.results) ? data.results : [];
|
|
385
|
+
}
|
|
386
|
+
|
|
343
387
|
async function subfolders(path: string): Promise<{ folders: FileNode[] }> {
|
|
344
388
|
return jsonFetch<{ folders: FileNode[] }>(managerUrl('subfolders', { path }));
|
|
345
389
|
}
|
|
@@ -661,6 +705,7 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
661
705
|
// Manager
|
|
662
706
|
index,
|
|
663
707
|
search,
|
|
708
|
+
globalSearch /* bul:s3 */,
|
|
664
709
|
subfolders,
|
|
665
710
|
newFolder,
|
|
666
711
|
rename,
|
|
@@ -21,9 +21,41 @@ export interface ShortcutHandlers {
|
|
|
21
21
|
onDuplicate?: () => void; // Ctrl+D
|
|
22
22
|
onPathJump?: () => void; // Cmd+K / Ctrl+K
|
|
23
23
|
onGoUp?: () => void; // Alt+Up / Backspace (when nothing selected)
|
|
24
|
+
onShowHelp?: () => void; // ? (Shift+/ on most layouts)
|
|
24
25
|
hasSelection?: () => boolean; // disambiguates Backspace
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* SHORTCUTS — the single source of truth for the shortcut cheat-sheet
|
|
30
|
+
* (ShortcutsHelp.vue). Only shortcuts actually wired by FileExplorer are
|
|
31
|
+
* listed; labels/groups resolve through the locale catalogue so the help
|
|
32
|
+
* dialog stays tr/en aware. `keys` entries are display combos — each one
|
|
33
|
+
* renders as its own <kbd>, alternatives joined visually with "/".
|
|
34
|
+
*/
|
|
35
|
+
export interface ShortcutDef {
|
|
36
|
+
keys: string[];
|
|
37
|
+
labelKey: string;
|
|
38
|
+
groupKey: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const SHORTCUTS: ShortcutDef[] = [
|
|
42
|
+
// Navigation
|
|
43
|
+
{ keys: ['Ctrl+K'], labelKey: 'shortcuts.palette', groupKey: 'shortcuts.group.nav' },
|
|
44
|
+
{ keys: ['/'], labelKey: 'shortcuts.search', groupKey: 'shortcuts.group.nav' },
|
|
45
|
+
{ keys: ['Alt+↑', 'Backspace'], labelKey: 'shortcuts.go_up', groupKey: 'shortcuts.group.nav' },
|
|
46
|
+
{ keys: ['Enter'], labelKey: 'shortcuts.open', groupKey: 'shortcuts.group.nav' },
|
|
47
|
+
{ keys: ['Esc'], labelKey: 'shortcuts.close', groupKey: 'shortcuts.group.nav' },
|
|
48
|
+
{ keys: ['?'], labelKey: 'shortcuts.help', groupKey: 'shortcuts.group.nav' },
|
|
49
|
+
// Selection
|
|
50
|
+
{ keys: ['Ctrl+A'], labelKey: 'shortcuts.select_all', groupKey: 'shortcuts.group.selection' },
|
|
51
|
+
// File operations
|
|
52
|
+
{ keys: ['F2'], labelKey: 'shortcuts.rename', groupKey: 'shortcuts.group.file' },
|
|
53
|
+
{ keys: ['Del'], labelKey: 'shortcuts.delete', groupKey: 'shortcuts.group.file' },
|
|
54
|
+
{ keys: ['Ctrl+X'], labelKey: 'shortcuts.cut', groupKey: 'shortcuts.group.file' },
|
|
55
|
+
{ keys: ['Ctrl+C'], labelKey: 'shortcuts.copy', groupKey: 'shortcuts.group.file' },
|
|
56
|
+
{ keys: ['Ctrl+V'], labelKey: 'shortcuts.paste', groupKey: 'shortcuts.group.file' },
|
|
57
|
+
];
|
|
58
|
+
|
|
27
59
|
export function useKeyboardShortcuts(rootEl: Ref<HTMLElement | null>, handlers: ShortcutHandlers) {
|
|
28
60
|
function onKey(e: KeyboardEvent) {
|
|
29
61
|
const root = rootEl.value;
|
|
@@ -87,6 +119,12 @@ export function useKeyboardShortcuts(rootEl: Ref<HTMLElement | null>, handlers:
|
|
|
87
119
|
handlers.onFocusSearch();
|
|
88
120
|
}
|
|
89
121
|
break;
|
|
122
|
+
case '?':
|
|
123
|
+
if (handlers.onShowHelp && !ctrl && !e.altKey) {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
handlers.onShowHelp();
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
90
128
|
case 'a':
|
|
91
129
|
case 'A':
|
|
92
130
|
if (ctrl && handlers.onSelectAll) {
|
|
@@ -19,6 +19,12 @@ export interface RealtimeApi {
|
|
|
19
19
|
export function useRealtime(api: RealtimeApi, opts: { reload: () => void }) {
|
|
20
20
|
const presenceUsers: Ref<PresenceUser[]> = ref([]);
|
|
21
21
|
const connected = ref(false);
|
|
22
|
+
// True while the live socket is unavailable and the polling fallback is
|
|
23
|
+
// active — the explorer surfaces a small "no live connection" badge from it.
|
|
24
|
+
// Stays false during ordinary reconnect attempts, so brief blips don't flash
|
|
25
|
+
// the badge; only a genuinely given-up socket (RealtimeClient onFallback)
|
|
26
|
+
// flips it.
|
|
27
|
+
const degraded = ref(false);
|
|
22
28
|
|
|
23
29
|
let client: RealtimeClient | null = null;
|
|
24
30
|
let pendingSubscribe: string | null = null;
|
|
@@ -42,6 +48,7 @@ export function useRealtime(api: RealtimeApi, opts: { reload: () => void }) {
|
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
function onFallback(active: boolean): void {
|
|
51
|
+
degraded.value = active;
|
|
45
52
|
if (active) {
|
|
46
53
|
// No live socket → poll the listing; presence isn't available here.
|
|
47
54
|
presenceUsers.value = [];
|
|
@@ -88,7 +95,8 @@ export function useRealtime(api: RealtimeApi, opts: { reload: () => void }) {
|
|
|
88
95
|
}
|
|
89
96
|
client?.close();
|
|
90
97
|
client = null;
|
|
98
|
+
degraded.value = false;
|
|
91
99
|
}
|
|
92
100
|
|
|
93
|
-
return { presenceUsers, connected, start, subscribe, setFocus, stop };
|
|
101
|
+
return { presenceUsers, connected, degraded, start, subscribe, setFocus, stop };
|
|
94
102
|
}
|
package/src/index.ts
CHANGED
|
@@ -57,6 +57,10 @@ export { isExternalUsable } from './types/FileNode';
|
|
|
57
57
|
// ——— Composables (consumers can roll their own UI on top) ———
|
|
58
58
|
export { useFileApi, resolveEndpoints } from './composables/useFileApi';
|
|
59
59
|
export type { FileApi, ManagerResponse, PendingOpDto } from './composables/useFileApi';
|
|
60
|
+
/* bul:s3 — global-search contract types + snippet helpers */
|
|
61
|
+
export type { GlobalSearchHit, GlobalSearchScope } from './composables/useFileApi';
|
|
62
|
+
export { snippetSegments, matchedInContent } from './lib/snippet';
|
|
63
|
+
export type { SnippetSegment, SearchMatched } from './lib/snippet';
|
|
60
64
|
|
|
61
65
|
export { useUploadChunked } from './composables/useUploadChunked';
|
|
62
66
|
export type { UploadJob, UploadOptions } from './composables/useUploadChunked';
|