@brftech/filex-core 0.31.0 → 0.33.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/README.md +26 -2
- package/dist/{CsvViewer-DxujFEM3.js → CsvViewer-CqWeV8VO.js} +22 -22
- package/dist/CsvViewer-CqWeV8VO.js.map +1 -0
- package/dist/{DrawioViewer-DCvWnX2t.js → DrawioViewer-BNALOB04.js} +16 -16
- package/dist/DrawioViewer-BNALOB04.js.map +1 -0
- package/dist/filex-core.js +9346 -8069
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +49 -49
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +235 -16
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +776 -246
- package/src/components/CommandPalette.vue +21 -5
- package/src/components/ConnectionsPanel.vue +2 -1
- package/src/components/E2eRecoveryUnlockModal.vue +29 -4
- package/src/components/FilterBar.vue +244 -0
- package/src/components/GalleryView.vue +12 -1
- package/src/components/GridView.vue +48 -5
- package/src/components/InspectorPanel.vue +231 -8
- package/src/components/ListView.vue +12 -3
- package/src/components/NFSExportsPanel.vue +2 -1
- package/src/components/PendingOpsTray.vue +1 -1
- package/src/components/PresenceBar.vue +3 -2
- package/src/components/S3KeysPanel.vue +2 -1
- package/src/components/SSHKeysPanel.vue +2 -1
- package/src/components/SecondaryPane.vue +28 -13
- package/src/components/SideNav.vue +161 -1
- package/src/components/StarButton.vue +2 -1
- package/src/components/TabBar.vue +2 -2
- package/src/components/ThemeGallery.vue +1 -1
- package/src/components/TokensPanel.vue +2 -1
- package/src/components/Toolbar.vue +244 -58
- package/src/components/ViewSwitcher.vue +81 -0
- package/src/composables/useFileApi.ts +49 -2
- package/src/composables/useLocale.ts +1 -1
- package/src/composables/useOperations.ts +3 -3
- package/src/composables/usePendingOps.ts +1 -1
- package/src/composables/useTabs.ts +1 -1
- package/src/composables/useUploadChunked.ts +39 -7
- package/src/index.ts +8 -2
- package/src/lib/e2ecrypto.ts +191 -3
- package/src/lib/fileFilters.ts +143 -0
- package/src/lib/listing.ts +47 -0
- package/src/lib/themes.ts +7 -7
- package/src/lib/transfer.ts +6 -6
- package/src/locales/en.ts +115 -0
- package/src/locales/index.ts +1 -0
- package/src/locales/resolve.ts +66 -0
- package/src/locales/tr.ts +116 -0
- package/src/modals/ConvertModal.vue +14 -12
- package/src/modals/NewFolderModal.vue +1 -1
- package/src/modals/PermissionsModal.vue +30 -13
- package/src/modals/PreviewModal.vue +9 -9
- package/src/styles/base.css +520 -26
- package/src/styles/variables.css +1 -1
- package/src/types/ExplorerConfig.ts +18 -2
- package/src/types/FileNode.ts +1 -1
- package/src/viewers/CsvViewer.vue +4 -4
- package/src/viewers/DrawioViewer.vue +2 -2
- package/dist/CsvViewer-DxujFEM3.js.map +0 -1
- package/dist/DrawioViewer-DCvWnX2t.js.map +0 -1
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* bul:s3 additions:
|
|
17
17
|
* 4. "Everywhere" group — ≥3-char queries also hit the global files-search
|
|
18
18
|
* endpoint (debounced 250ms, max 8 rows) via the `globalSearch` prop.
|
|
19
|
-
* Content matches get an "
|
|
19
|
+
* Content matches get an "In content" badge + a «»-highlighted snippet
|
|
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.
|
|
@@ -41,6 +41,15 @@ const props = defineProps<{
|
|
|
41
41
|
globalSearch?: (q: string) => Promise<GlobalSearchHit[]>;
|
|
42
42
|
/* wiring:d1 — false hides the split command (narrow embeds). */
|
|
43
43
|
splitEnabled?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* surucu:d1 — the query this palette opens WITH.
|
|
46
|
+
*
|
|
47
|
+
* The drive shell's header field searches the folder you are in; ⌘K from
|
|
48
|
+
* inside it opens this palette carrying the same words, so "not in this
|
|
49
|
+
* folder — try everywhere" costs no retyping. Empty (the default) is the
|
|
50
|
+
* behaviour every other caller has had: a blank palette.
|
|
51
|
+
*/
|
|
52
|
+
initialQuery?: string;
|
|
44
53
|
}>();
|
|
45
54
|
|
|
46
55
|
const emit = defineEmits<{
|
|
@@ -131,7 +140,7 @@ const commandItems = computed<PaletteItem[]>(() => {
|
|
|
131
140
|
const defs: Array<{ command: string; label: string; icon: string; enabled: boolean }> = [
|
|
132
141
|
{ command: 'new-folder', label: t('toolbar.new_folder'), icon: '📁', enabled: props.canWrite !== false },
|
|
133
142
|
{ command: 'upload', label: t('toolbar.upload'), icon: '⬆', enabled: props.canWrite !== false },
|
|
134
|
-
{ command: 'toggle-view', label: t('cmd.view_toggle'), icon: props.viewMode === 'list' ? '▦' : props.viewMode === 'grid' ? '▣' : '☰', enabled: true /* wiring:d2 —
|
|
143
|
+
{ command: 'toggle-view', label: t('cmd.view_toggle'), icon: props.viewMode === 'list' ? '▦' : props.viewMode === 'grid' ? '▣' : '☰', enabled: true /* wiring:d2 — icon of the NEXT mode (list→grid→gallery→list) */ },
|
|
135
144
|
{ command: 'open-trash', label: t('cmd.trash'), icon: '🗑', enabled: true },
|
|
136
145
|
{ command: 'refresh', label: t('toolbar.refresh'), icon: '⟳', enabled: true },
|
|
137
146
|
{ command: 'go-up', label: t('toolbar.go_up'), icon: '↑', enabled: props.canGoUp !== false },
|
|
@@ -139,7 +148,7 @@ const commandItems = computed<PaletteItem[]>(() => {
|
|
|
139
148
|
{ command: 'open-theme', label: t('theme.menu'), icon: '🎨', enabled: true },
|
|
140
149
|
{ command: 'open-shortcut-settings', label: t('shortcuts.settings.menu'), icon: '⌨', enabled: true },
|
|
141
150
|
{ command: 'start-tour', label: t('tour.restart'), icon: '🎓', enabled: true },
|
|
142
|
-
/* wiring:d1 —
|
|
151
|
+
/* wiring:d1 — tabs + split */
|
|
143
152
|
{ command: 'tab-new', label: t('cmd.tab_new'), icon: '⧉', enabled: true },
|
|
144
153
|
{ command: 'split-toggle', label: t('cmd.split_toggle'), icon: '◫', enabled: props.splitEnabled !== false },
|
|
145
154
|
];
|
|
@@ -367,14 +376,21 @@ watch(
|
|
|
367
376
|
() => props.open,
|
|
368
377
|
(v) => {
|
|
369
378
|
if (v) {
|
|
370
|
-
|
|
379
|
+
// ⚠ Read at OPEN, not watched: a seed that kept overwriting the field
|
|
380
|
+
// would undo every keystroke of whoever is typing here now.
|
|
381
|
+
query.value = props.initialQuery ?? '';
|
|
371
382
|
active.value = 0;
|
|
372
383
|
/* bul:s3 — fresh session state for the new groups. */
|
|
373
384
|
everywhere.value = [];
|
|
374
385
|
everywhereLoading.value = false;
|
|
375
386
|
savedSearches.value = readSavedSearches();
|
|
376
387
|
document.addEventListener('keydown', onDocKeydown, true);
|
|
377
|
-
void nextTick(() =>
|
|
388
|
+
void nextTick(() => {
|
|
389
|
+
inputEl.value?.focus();
|
|
390
|
+
// Selected, not appended to: the seed is a starting point, and the
|
|
391
|
+
// next keystroke should be able to replace it outright.
|
|
392
|
+
inputEl.value?.select();
|
|
393
|
+
});
|
|
378
394
|
} else {
|
|
379
395
|
if (everywhereTimer) clearTimeout(everywhereTimer); /* bul:s3 */
|
|
380
396
|
document.removeEventListener('keydown', onDocKeydown, true);
|
|
@@ -40,6 +40,7 @@ import S3KeysPanel from './S3KeysPanel.vue';
|
|
|
40
40
|
import SSHKeysPanel from './SSHKeysPanel.vue';
|
|
41
41
|
import NFSExportsPanel from './NFSExportsPanel.vue';
|
|
42
42
|
import TokensPanel from './TokensPanel.vue';
|
|
43
|
+
import { resolveLocale } from '../locales/resolve';
|
|
43
44
|
|
|
44
45
|
const props = defineProps<{
|
|
45
46
|
config: ExplorerConfig;
|
|
@@ -56,7 +57,7 @@ const emit = defineEmits<{
|
|
|
56
57
|
(e: 'error', err: { message: string }): void;
|
|
57
58
|
}>();
|
|
58
59
|
|
|
59
|
-
const locale = computed<LocaleCode>(() => props.config.locale
|
|
60
|
+
const locale = computed<LocaleCode>(() => resolveLocale(props.config.locale));
|
|
60
61
|
const { t } = useLocale(locale);
|
|
61
62
|
|
|
62
63
|
// Destructured on purpose: Vue only auto-unwraps refs that are top-level in
|
|
@@ -19,6 +19,7 @@ import { computed, ref, watch } from 'vue';
|
|
|
19
19
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
20
20
|
import { useLocale } from '../composables/useLocale';
|
|
21
21
|
import { parseRecoveryKey } from '../lib/e2ecrypto';
|
|
22
|
+
import type { EscrowAvailability } from '../lib/e2ecrypto';
|
|
22
23
|
import Modal from '../modals/Modal.vue';
|
|
23
24
|
|
|
24
25
|
const props = defineProps<{
|
|
@@ -26,9 +27,15 @@ const props = defineProps<{
|
|
|
26
27
|
locale: LocaleCode;
|
|
27
28
|
/** The folder has a user recovery key slot (v2 markers created since 0.31). */
|
|
28
29
|
hasRecovery: boolean;
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Whether the escrow door applies to THIS folder — see escrowAvailability.
|
|
32
|
+
* 'predates' and 'other-key' are not "no escrow tab"; they are two
|
|
33
|
+
* different facts the dialog has to state, because an admin who knows the
|
|
34
|
+
* installation has escrow reads a missing tab as a bug and tries the key
|
|
35
|
+
* anyway.
|
|
36
|
+
*/
|
|
37
|
+
escrowState: EscrowAvailability;
|
|
38
|
+
/** Short id of the escrow key THIS FOLDER was sealed to, when it has one. */
|
|
32
39
|
escrowKid?: string | null;
|
|
33
40
|
busy?: boolean;
|
|
34
41
|
/** Set by the parent after a failed attempt. */
|
|
@@ -41,6 +48,16 @@ const emit = defineEmits<{
|
|
|
41
48
|
}>();
|
|
42
49
|
|
|
43
50
|
const { t } = useLocale(() => props.locale);
|
|
51
|
+
/** The escrow tab is offered only when the key can actually open this
|
|
52
|
+
* folder. Every other state is explained in words instead. */
|
|
53
|
+
const hasEscrow = computed(() => props.escrowState === 'available');
|
|
54
|
+
/** The installation has an escrow key that does NOT apply here. Saying so
|
|
55
|
+
* is the whole point: silence reads as "not enabled". */
|
|
56
|
+
const escrowUnavailableReason = computed(() => {
|
|
57
|
+
if (props.escrowState === 'predates') return t('e2e.recover.escrow_predates');
|
|
58
|
+
if (props.escrowState === 'other-key') return t('e2e.recover.escrow_other_key');
|
|
59
|
+
return null;
|
|
60
|
+
});
|
|
44
61
|
const mode = ref<'recovery' | 'escrow'>('recovery');
|
|
45
62
|
const recoveryValue = ref('');
|
|
46
63
|
const escrowValue = ref('');
|
|
@@ -60,7 +77,7 @@ watch(
|
|
|
60
77
|
|
|
61
78
|
/** A folder with neither slot is a pre-0.31 folder: the password is the
|
|
62
79
|
* only way in, and saying so is more useful than an empty dialog. */
|
|
63
|
-
const nothingAvailable = computed(() => !props.hasRecovery && !
|
|
80
|
+
const nothingAvailable = computed(() => !props.hasRecovery && !hasEscrow.value);
|
|
64
81
|
|
|
65
82
|
const shownError = computed(() => localErr.value || props.error || null);
|
|
66
83
|
|
|
@@ -149,6 +166,14 @@ function submit() {
|
|
|
149
166
|
</form>
|
|
150
167
|
</template>
|
|
151
168
|
|
|
169
|
+
<!-- ⚠ Shown whenever the installation has an escrow key that cannot
|
|
170
|
+
open THIS folder. A folder created before escrow was adopted must
|
|
171
|
+
not merely lack a tab; it must say why, or the absence reads as a
|
|
172
|
+
bug and the operator burns an afternoon on it. -->
|
|
173
|
+
<p v-if="escrowUnavailableReason" class="fe-e2e-recover__note">
|
|
174
|
+
{{ escrowUnavailableReason }}
|
|
175
|
+
</p>
|
|
176
|
+
|
|
152
177
|
<p v-if="shownError" class="fe-form__error">{{ shownError }}</p>
|
|
153
178
|
</div>
|
|
154
179
|
<template #actions>
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* FilterBar — surucu:d1, the chip row under the breadcrumb in the `drive`
|
|
4
|
+
* profile.
|
|
5
|
+
*
|
|
6
|
+
* Three chips: Type · Modified · Size. Each opens a small single-choice
|
|
7
|
+
* popover; a chip with a choice made carries that choice as its label, so the
|
|
8
|
+
* row says what is being filtered without opening anything.
|
|
9
|
+
*
|
|
10
|
+
* ⚠ THREE, not the four the mockup draws. There is no **People** chip because
|
|
11
|
+
* there is nothing behind one: a listing row carries no owner
|
|
12
|
+
* (`projectFileNodes` emits id/path/basename/type/extension/size/mime_type/
|
|
13
|
+
* storage/etag/perm/thumb_url/last_modified and nothing else), `nodes.owner_id`
|
|
14
|
+
* is quota bookkeeping that is nil for everything a sync discovered, and the
|
|
15
|
+
* listing endpoint reads no owner parameter. A People chip would be a control
|
|
16
|
+
* that opens, offers names, and changes nothing — see docs and the report on
|
|
17
|
+
* this branch; the advanced-search round owns that question.
|
|
18
|
+
*
|
|
19
|
+
* ⚠ Popovers are TELEPORTED to <body>, like ContextMenu, and positioned
|
|
20
|
+
* `fixed`. An absolutely-positioned panel inside the explorer is clipped by
|
|
21
|
+
* `.fe__body`'s own scroll container, which is how a menu ends up half visible
|
|
22
|
+
* with its own scrollbar.
|
|
23
|
+
*
|
|
24
|
+
* ⚠ No `<style>` block. Package CSS lives in `styles/base.css` — a scoped block
|
|
25
|
+
* compiles to `.cls[data-v-HASH]` and the hash does not match in the
|
|
26
|
+
* web-component build, so the rules silently stop applying in every embed.
|
|
27
|
+
*/
|
|
28
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
29
|
+
import { useLocale } from '../composables/useLocale';
|
|
30
|
+
import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
|
|
31
|
+
import type { DriveFilters, ModifiedFilter, SizeFilter, TypeFilter } from '../lib/fileFilters';
|
|
32
|
+
import { activeFilterCount } from '../lib/fileFilters';
|
|
33
|
+
|
|
34
|
+
const props = defineProps<{
|
|
35
|
+
value: DriveFilters;
|
|
36
|
+
locale: LocaleCode;
|
|
37
|
+
/** Resolved theme — the teleported popover leaves the `.fe` variable scope. */
|
|
38
|
+
theme?: ThemeMode;
|
|
39
|
+
/** Rows currently shown / rows the folder holds, for the count chip. */
|
|
40
|
+
shown?: number;
|
|
41
|
+
total?: number;
|
|
42
|
+
}>();
|
|
43
|
+
|
|
44
|
+
const emit = defineEmits<{ (e: 'update:value', v: DriveFilters): void }>();
|
|
45
|
+
|
|
46
|
+
const { t } = useLocale(() => props.locale);
|
|
47
|
+
|
|
48
|
+
type Group = 'type' | 'modified' | 'size';
|
|
49
|
+
|
|
50
|
+
const TYPE_OPTIONS: TypeFilter[] = [
|
|
51
|
+
'any', 'folder', 'document', 'spreadsheet', 'presentation', 'pdf',
|
|
52
|
+
'image', 'video', 'audio', 'archive', 'code',
|
|
53
|
+
];
|
|
54
|
+
const MODIFIED_OPTIONS: ModifiedFilter[] = ['any', 'today', '7d', '30d', 'year'];
|
|
55
|
+
const SIZE_OPTIONS: SizeFilter[] = ['any', 'lt1', '1to10', '10to100', 'gt100'];
|
|
56
|
+
|
|
57
|
+
function optionsFor(g: Group): string[] {
|
|
58
|
+
if (g === 'type') return TYPE_OPTIONS;
|
|
59
|
+
if (g === 'modified') return MODIFIED_OPTIONS;
|
|
60
|
+
return SIZE_OPTIONS;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function optionLabel(g: Group, v: string): string {
|
|
64
|
+
return t(`filter.${g}.${v}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The chip's own label: the group name until a choice is made, then the
|
|
68
|
+
* choice — a row of three identical words tells the reader nothing. */
|
|
69
|
+
function chipLabel(g: Group): string {
|
|
70
|
+
const v = props.value[g];
|
|
71
|
+
return v === 'any' ? t(`filter.${g}`) : optionLabel(g, v);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const activeCount = computed(() => activeFilterCount(props.value));
|
|
75
|
+
|
|
76
|
+
// ── the popover ──────────────────────────────────────────────────────
|
|
77
|
+
const openGroup = ref<Group | null>(null);
|
|
78
|
+
const pos = ref({ x: 0, y: 0 });
|
|
79
|
+
const panelEl = ref<HTMLElement | null>(null);
|
|
80
|
+
const chipEls = ref<Record<string, HTMLElement | null>>({});
|
|
81
|
+
|
|
82
|
+
function setChipEl(g: Group, el: unknown) {
|
|
83
|
+
chipEls.value[g] = (el as HTMLElement | null) ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The open popover's data, precomputed. Reading `value[openGroup]` straight
|
|
87
|
+
// from the template would lean on template narrowing across a Teleport, and
|
|
88
|
+
// `vue-tsc --noEmit` is part of this package's build.
|
|
89
|
+
const popOptions = computed<string[]>(() => (openGroup.value ? optionsFor(openGroup.value) : []));
|
|
90
|
+
const popTitle = computed(() => (openGroup.value ? t(`filter.${openGroup.value}`) : ''));
|
|
91
|
+
function popChecked(opt: string): boolean {
|
|
92
|
+
return !!openGroup.value && props.value[openGroup.value] === opt;
|
|
93
|
+
}
|
|
94
|
+
function popLabel(opt: string): string {
|
|
95
|
+
return openGroup.value ? optionLabel(openGroup.value, opt) : opt;
|
|
96
|
+
}
|
|
97
|
+
function popPick(opt: string): void {
|
|
98
|
+
if (openGroup.value) pick(openGroup.value, opt);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function toggle(g: Group) {
|
|
102
|
+
if (openGroup.value === g) {
|
|
103
|
+
close();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const r = chipEls.value[g]?.getBoundingClientRect();
|
|
107
|
+
pos.value = { x: r ? r.left : 8, y: r ? r.bottom + 6 : 8 };
|
|
108
|
+
openGroup.value = g;
|
|
109
|
+
await nextTick();
|
|
110
|
+
// Keep it on screen: a chip near the right edge would otherwise open a panel
|
|
111
|
+
// that runs off it, and the row is right beside the info panel.
|
|
112
|
+
const panel = panelEl.value;
|
|
113
|
+
if (panel) {
|
|
114
|
+
const box = panel.getBoundingClientRect();
|
|
115
|
+
if (box.right > window.innerWidth - 8) {
|
|
116
|
+
pos.value = { ...pos.value, x: Math.max(8, window.innerWidth - 8 - box.width) };
|
|
117
|
+
}
|
|
118
|
+
if (box.bottom > window.innerHeight - 8) {
|
|
119
|
+
const r2 = chipEls.value[g]?.getBoundingClientRect();
|
|
120
|
+
pos.value = { ...pos.value, y: Math.max(8, (r2 ? r2.top : 0) - box.height - 6) };
|
|
121
|
+
}
|
|
122
|
+
panel.querySelector<HTMLElement>('[data-checked="true"]')?.focus();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function close(restoreFocus = false) {
|
|
127
|
+
const g = openGroup.value;
|
|
128
|
+
openGroup.value = null;
|
|
129
|
+
if (restoreFocus && g) chipEls.value[g]?.focus();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function pick(g: Group, v: string) {
|
|
133
|
+
emit('update:value', { ...props.value, [g]: v } as DriveFilters);
|
|
134
|
+
close(true);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function clearAll() {
|
|
138
|
+
emit('update:value', { type: 'any', modified: 'any', size: 'any' });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function onDocPointer(ev: PointerEvent) {
|
|
142
|
+
if (!openGroup.value) return;
|
|
143
|
+
const el = ev.target as Node;
|
|
144
|
+
if (panelEl.value?.contains(el)) return;
|
|
145
|
+
if (Object.values(chipEls.value).some((c) => c?.contains(el))) return;
|
|
146
|
+
close();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function onKey(ev: KeyboardEvent) {
|
|
150
|
+
if (ev.key === 'Escape' && openGroup.value) {
|
|
151
|
+
ev.stopPropagation();
|
|
152
|
+
close(true);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
onMounted(() => {
|
|
157
|
+
document.addEventListener('pointerdown', onDocPointer, true);
|
|
158
|
+
document.addEventListener('keydown', onKey, true);
|
|
159
|
+
});
|
|
160
|
+
onBeforeUnmount(() => {
|
|
161
|
+
document.removeEventListener('pointerdown', onDocPointer, true);
|
|
162
|
+
document.removeEventListener('keydown', onKey, true);
|
|
163
|
+
});
|
|
164
|
+
</script>
|
|
165
|
+
|
|
166
|
+
<template>
|
|
167
|
+
<div class="fe-filterbar" role="group" :aria-label="t('filter.aria')" data-testid="filterbar">
|
|
168
|
+
<button
|
|
169
|
+
v-for="g in (['type', 'modified', 'size'] as Group[])"
|
|
170
|
+
:key="g"
|
|
171
|
+
:ref="(el) => setChipEl(g, el)"
|
|
172
|
+
type="button"
|
|
173
|
+
class="fe-filterbar__chip"
|
|
174
|
+
:class="{ 'is-set': value[g] !== 'any', 'is-open': openGroup === g }"
|
|
175
|
+
:aria-expanded="openGroup === g"
|
|
176
|
+
aria-haspopup="listbox"
|
|
177
|
+
:data-testid="`filter-${g}`"
|
|
178
|
+
@click="toggle(g)"
|
|
179
|
+
>
|
|
180
|
+
<span class="fe-filterbar__label">{{ chipLabel(g) }}</span>
|
|
181
|
+
<svg
|
|
182
|
+
class="fe-ficon fe-filterbar__caret"
|
|
183
|
+
viewBox="0 0 24 24"
|
|
184
|
+
fill="none"
|
|
185
|
+
stroke="currentColor"
|
|
186
|
+
stroke-width="2"
|
|
187
|
+
stroke-linecap="round"
|
|
188
|
+
stroke-linejoin="round"
|
|
189
|
+
aria-hidden="true"
|
|
190
|
+
focusable="false"
|
|
191
|
+
>
|
|
192
|
+
<path d="M7 10l5 5 5-5" />
|
|
193
|
+
</svg>
|
|
194
|
+
</button>
|
|
195
|
+
|
|
196
|
+
<button
|
|
197
|
+
v-if="activeCount > 0"
|
|
198
|
+
type="button"
|
|
199
|
+
class="fe-filterbar__clear"
|
|
200
|
+
data-testid="filter-clear"
|
|
201
|
+
@click="clearAll"
|
|
202
|
+
>
|
|
203
|
+
{{ t('filter.clear') }}
|
|
204
|
+
</button>
|
|
205
|
+
|
|
206
|
+
<span
|
|
207
|
+
v-if="activeCount > 0 && typeof shown === 'number' && typeof total === 'number'"
|
|
208
|
+
class="fe-filterbar__count"
|
|
209
|
+
role="status"
|
|
210
|
+
data-testid="filter-count"
|
|
211
|
+
>{{ t('filter.count', { shown: String(shown), total: String(total) }) }}</span
|
|
212
|
+
>
|
|
213
|
+
|
|
214
|
+
<Teleport to="body">
|
|
215
|
+
<div
|
|
216
|
+
v-if="openGroup"
|
|
217
|
+
ref="panelEl"
|
|
218
|
+
class="fe-filterpop"
|
|
219
|
+
:class="{
|
|
220
|
+
'fe--theme-light': theme === 'light',
|
|
221
|
+
'fe--theme-dark': theme === 'dark',
|
|
222
|
+
}"
|
|
223
|
+
role="listbox"
|
|
224
|
+
:aria-label="popTitle"
|
|
225
|
+
:style="{ left: pos.x + 'px', top: pos.y + 'px' }"
|
|
226
|
+
>
|
|
227
|
+
<button
|
|
228
|
+
v-for="opt in popOptions"
|
|
229
|
+
:key="opt"
|
|
230
|
+
type="button"
|
|
231
|
+
class="fe-filterpop__item"
|
|
232
|
+
role="option"
|
|
233
|
+
:aria-selected="popChecked(opt)"
|
|
234
|
+
:data-checked="popChecked(opt) ? 'true' : 'false'"
|
|
235
|
+
:data-testid="`filter-opt-${opt}`"
|
|
236
|
+
@click="popPick(opt)"
|
|
237
|
+
>
|
|
238
|
+
<span class="fe-filterpop__tick" aria-hidden="true">{{ popChecked(opt) ? '✓' : '' }}</span>
|
|
239
|
+
<span>{{ popLabel(opt) }}</span>
|
|
240
|
+
</button>
|
|
241
|
+
</div>
|
|
242
|
+
</Teleport>
|
|
243
|
+
</div>
|
|
244
|
+
</template>
|
|
@@ -38,6 +38,15 @@ const props = defineProps<{
|
|
|
38
38
|
* own screenshots show — had a view they could not fill.
|
|
39
39
|
*/
|
|
40
40
|
starredIds?: Set<number>;
|
|
41
|
+
/**
|
|
42
|
+
* Offer the star affordance at all. Follows the Starred view: when the panel
|
|
43
|
+
* leaves that view out — a shared app token has no single person behind it,
|
|
44
|
+
* so "your starred files" is one list shown to strangers — offering to star
|
|
45
|
+
* something is offering to write into that same shared list. Owner's call,
|
|
46
|
+
* 2026-09-05, verbatim (translated from Turkish): "if the starred place is
|
|
47
|
+
* not visible, then star/unstar should not be visible either".
|
|
48
|
+
*/
|
|
49
|
+
starEnabled?: boolean;
|
|
41
50
|
apiBase?: string;
|
|
42
51
|
authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
43
52
|
authCredentials?: RequestCredentials;
|
|
@@ -61,6 +70,7 @@ function thumbOf(n: FileNode): string | null {
|
|
|
61
70
|
/** A card carries a star when the host wired the API and the node is a file
|
|
62
71
|
* with a server id — the same rule the list row uses. */
|
|
63
72
|
function canStar(n: FileNode): boolean {
|
|
73
|
+
if (props.starEnabled === false) return false;
|
|
64
74
|
return props.apiBase !== undefined && typeof n.id === 'number' && n.type === 'file';
|
|
65
75
|
}
|
|
66
76
|
|
|
@@ -149,7 +159,7 @@ function parentDir(path: string): string {
|
|
|
149
159
|
function specialEmojiFor(n: FileNode): string | null {
|
|
150
160
|
if (n.basename === '.trash') return '🗑';
|
|
151
161
|
if (n.mime_type === 'inode/storage') return '💾';
|
|
152
|
-
if (n.type === 'dir' && n.e2e === true) return '🔒'; /* wiring:e2 —
|
|
162
|
+
if (n.type === 'dir' && n.e2e === true) return '🔒'; /* wiring:e2 — encrypted-folder badge */
|
|
153
163
|
return null;
|
|
154
164
|
}
|
|
155
165
|
|
|
@@ -191,6 +201,7 @@ function metaFor(n: FileNode): string {
|
|
|
191
201
|
role="option"
|
|
192
202
|
:aria-selected="isSelected(n) ? 'true' : 'false'"
|
|
193
203
|
:aria-label="nodeDisplayName(n)"
|
|
204
|
+
:data-fe-path="n.path /* wiring:d1 - middle-click new-tab delegation */"
|
|
194
205
|
draggable="true"
|
|
195
206
|
@click="onClick(n, $event)"
|
|
196
207
|
@dblclick="onDbl(n)"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* GridView — card grid. Thumbnails preferred, fall back to icon.
|
|
4
4
|
*/
|
|
5
|
-
import { ref } from 'vue'; /* wiring:c4 */
|
|
5
|
+
import { computed, ref } from 'vue'; /* wiring:c4 */
|
|
6
6
|
import type { FileNode } from '../types/FileNode';
|
|
7
7
|
import { hasInternalDrag } from '../lib/dragOut';
|
|
8
8
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
@@ -36,9 +36,30 @@ const props = defineProps<{
|
|
|
36
36
|
* own screenshots show — had a view they could not fill.
|
|
37
37
|
*/
|
|
38
38
|
starredIds?: Set<number>;
|
|
39
|
+
/**
|
|
40
|
+
* Offer the star affordance at all. Follows the Starred view: when the panel
|
|
41
|
+
* leaves that view out — a shared app token has no single person behind it,
|
|
42
|
+
* so "your starred files" is one list shown to strangers — offering to star
|
|
43
|
+
* something is offering to write into that same shared list. Owner's call,
|
|
44
|
+
* 2026-09-05, verbatim (translated from Turkish): "if the starred place is
|
|
45
|
+
* not visible, then star/unstar should not be visible either".
|
|
46
|
+
*/
|
|
47
|
+
starEnabled?: boolean;
|
|
39
48
|
apiBase?: string;
|
|
40
49
|
authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
41
50
|
authCredentials?: RequestCredentials;
|
|
51
|
+
/**
|
|
52
|
+
* surucu:d1 — draw "Folders" and "Files" as labelled sections
|
|
53
|
+
* (`uiProfile: 'drive'`). Absent/false renders the flat grid unchanged, down
|
|
54
|
+
* to the DOM: the two headings are the only extra nodes, and without this
|
|
55
|
+
* prop none are emitted.
|
|
56
|
+
*
|
|
57
|
+
* The rows are re-ordered HERE (directories first, stable within each group)
|
|
58
|
+
* rather than trusted to arrive that way — the listing endpoint promises no
|
|
59
|
+
* grouping, and a "Folders" heading with a spreadsheet under it is worse
|
|
60
|
+
* than no heading at all.
|
|
61
|
+
*/
|
|
62
|
+
sections?: boolean;
|
|
42
63
|
}>();
|
|
43
64
|
|
|
44
65
|
const emit = defineEmits<{
|
|
@@ -50,6 +71,23 @@ const emit = defineEmits<{
|
|
|
50
71
|
(e: 'star-change', node: FileNode, value: boolean): void;
|
|
51
72
|
}>();
|
|
52
73
|
|
|
74
|
+
/* surucu:d1 — grouped order + the two headings. `sort` is stable, so within
|
|
75
|
+
* folders and within files the listing's own order survives untouched. */
|
|
76
|
+
const ordered = computed<FileNode[]>(() =>
|
|
77
|
+
props.sections
|
|
78
|
+
? [...props.files].sort((a, b) => (a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1))
|
|
79
|
+
: props.files,
|
|
80
|
+
);
|
|
81
|
+
const firstDirPath = computed(() => ordered.value.find((f) => f.type === 'dir')?.path ?? null);
|
|
82
|
+
const firstFilePath = computed(() => ordered.value.find((f) => f.type !== 'dir')?.path ?? null);
|
|
83
|
+
|
|
84
|
+
function headingBefore(n: FileNode): string | null {
|
|
85
|
+
if (!props.sections) return null;
|
|
86
|
+
if (n.path === firstDirPath.value) return t('drive.section.folders');
|
|
87
|
+
if (n.path === firstFilePath.value) return t('drive.section.files');
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
53
91
|
const { t, formatSize, nodeDisplayName } = useLocale(() => props.locale);
|
|
54
92
|
|
|
55
93
|
// Prefer the authenticated resolver when the host wired one; otherwise fall
|
|
@@ -61,6 +99,7 @@ function thumbOf(n: FileNode): string | null {
|
|
|
61
99
|
/** A card carries a star when the host wired the API and the node is a file
|
|
62
100
|
* with a server id — the same rule the list row uses. */
|
|
63
101
|
function canStar(n: FileNode): boolean {
|
|
102
|
+
if (props.starEnabled === false) return false;
|
|
64
103
|
return props.apiBase !== undefined && typeof n.id === 'number' && n.type === 'file';
|
|
65
104
|
}
|
|
66
105
|
|
|
@@ -158,7 +197,7 @@ function keepGlyph(b: 'kept' | 'syncing' | 'cloud' | 'partial'): string {
|
|
|
158
197
|
function specialEmojiFor(n: FileNode): string | null {
|
|
159
198
|
if (n.basename === '.trash') return '🗑';
|
|
160
199
|
if (n.mime_type === 'inode/storage') return '💾';
|
|
161
|
-
if (n.type === 'dir' && n.e2e === true) return '🔒'; /* wiring:e2 —
|
|
200
|
+
if (n.type === 'dir' && n.e2e === true) return '🔒'; /* wiring:e2 — encrypted-folder badge */
|
|
162
201
|
return null;
|
|
163
202
|
}
|
|
164
203
|
|
|
@@ -189,9 +228,12 @@ function snippetTitle(snippet: string): string {
|
|
|
189
228
|
:aria-label="t('grid.aria')"
|
|
190
229
|
:aria-busy="loading ? 'true' : undefined"
|
|
191
230
|
>
|
|
231
|
+
<template v-for="n in ordered" :key="n.path">
|
|
232
|
+
<!-- surucu:d1 — the section label: a grid item spanning every column. The
|
|
233
|
+
cards keep `role="option"`; the heading is aria-hidden, and the group
|
|
234
|
+
it names is already in each card's own label. -->
|
|
235
|
+
<p v-if="headingBefore(n)" class="fe-grid__heading" aria-hidden="true">{{ headingBefore(n) }}</p>
|
|
192
236
|
<div
|
|
193
|
-
v-for="n in files"
|
|
194
|
-
:key="n.path"
|
|
195
237
|
class="fe-grid__card"
|
|
196
238
|
:class="{
|
|
197
239
|
'is-selected': isSelected(n),
|
|
@@ -204,7 +246,7 @@ function snippetTitle(snippet: string): string {
|
|
|
204
246
|
role="option"
|
|
205
247
|
:aria-selected="isSelected(n) ? 'true' : 'false'"
|
|
206
248
|
:aria-label="nodeDisplayName(n) /* wiring:c4 */"
|
|
207
|
-
:data-fe-path="n.path /* wiring:d1 —
|
|
249
|
+
:data-fe-path="n.path /* wiring:d1 — middle-click open-in-new-tab delegation */"
|
|
208
250
|
draggable="true"
|
|
209
251
|
@click="onClick(n, $event)"
|
|
210
252
|
@dblclick="onDbl(n)"
|
|
@@ -279,6 +321,7 @@ function snippetTitle(snippet: string): string {
|
|
|
279
321
|
</template>
|
|
280
322
|
</div>
|
|
281
323
|
</div>
|
|
324
|
+
</template>
|
|
282
325
|
<div v-if="!loading && files.length === 0" class="fe-grid__empty">
|
|
283
326
|
{{ t('empty.folder') }}
|
|
284
327
|
</div>
|