@brftech/filex-core 0.30.1 → 0.32.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 +37 -2
- package/dist/filex-core.js +10571 -8278
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +95 -87
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +548 -66
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +1104 -26
- package/src/components/Breadcrumb.vue +4 -2
- package/src/components/CommandPalette.vue +18 -2
- package/src/components/E2eRecoveryUnlockModal.vue +193 -0
- package/src/components/EncryptedFolderModal.vue +10 -0
- package/src/components/FilterBar.vue +244 -0
- package/src/components/GalleryView.vue +48 -0
- package/src/components/GridView.vue +85 -3
- package/src/components/InspectorPanel.vue +231 -8
- package/src/components/ListView.vue +11 -1
- package/src/components/RecoveryKeyModal.vue +133 -0
- package/src/components/SecondaryPane.vue +15 -1
- package/src/components/SideNav.vue +329 -6
- package/src/components/StarButton.vue +27 -15
- package/src/components/Toolbar.vue +235 -49
- package/src/components/ViewSwitcher.vue +81 -0
- package/src/composables/useFileApi.ts +83 -0
- package/src/composables/useKeyboardShortcuts.ts +7 -0
- package/src/index.ts +33 -1
- package/src/lib/e2ecrypto.ts +716 -70
- package/src/lib/fileFilters.ts +143 -0
- package/src/lib/listing.ts +103 -1
- package/src/lib/star.ts +42 -0
- package/src/lib/tags.ts +105 -0
- package/src/locales/en.ts +154 -2
- package/src/locales/tr.ts +154 -2
- package/src/modals/PermissionsModal.vue +18 -2
- package/src/styles/base.css +743 -0
- package/src/types/ExplorerConfig.ts +53 -1
- package/src/types/FileNode.ts +23 -0
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
|
23
23
|
import { hasInternalDrag } from '../lib/dragOut';
|
|
24
|
-
import {
|
|
24
|
+
import { virtualSegmentLabel } from '../lib/listing';
|
|
25
25
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
26
26
|
import { useLocale } from '../composables/useLocale';
|
|
27
27
|
|
|
@@ -125,7 +125,9 @@ const crumbs = computed<Crumb[]>(() => {
|
|
|
125
125
|
// gezinti:g1 — the sentinel segments the virtual views park in `dirname`
|
|
126
126
|
// (`.trash` predates them). Without a mapping the crumb reads ".starred",
|
|
127
127
|
// which is a filename the user never typed and cannot navigate to.
|
|
128
|
-
|
|
128
|
+
// etiket:t1 — via the shared resolver, which also knows the tag view's
|
|
129
|
+
// `.tag~<name>` segment; the raw map only covers the fixed-label views.
|
|
130
|
+
const label = virtualSegmentLabel(part, t) || part;
|
|
129
131
|
out.push({ label, adapterPath: `${adapterPrefix}${acc}` });
|
|
130
132
|
}
|
|
131
133
|
return out;
|
|
@@ -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<{
|
|
@@ -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);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* E2eRecoveryUnlockModal — open an encrypted folder without its password.
|
|
4
|
+
*
|
|
5
|
+
* Two doors, and the dialog is explicit about which one you are using
|
|
6
|
+
* because they are not equivalent:
|
|
7
|
+
*
|
|
8
|
+
* - the USER RECOVERY KEY, shown once when the folder was created. Opens
|
|
9
|
+
* the folder and nobody is told, because it is your key.
|
|
10
|
+
* - the ESCROW KEY, held by the operator of this installation. Opening a
|
|
11
|
+
* folder with it NOTIFIES the folder's owner. The dialog says so before
|
|
12
|
+
* the key is typed, not after.
|
|
13
|
+
*
|
|
14
|
+
* The component collects input and validates its shape. All crypto and all
|
|
15
|
+
* network calls happen in the parent (FileExplorer), which owns the marker
|
|
16
|
+
* and the key ring — same division as the password lock screen.
|
|
17
|
+
*/
|
|
18
|
+
import { computed, ref, watch } from 'vue';
|
|
19
|
+
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
20
|
+
import { useLocale } from '../composables/useLocale';
|
|
21
|
+
import { parseRecoveryKey } from '../lib/e2ecrypto';
|
|
22
|
+
import type { EscrowAvailability } from '../lib/e2ecrypto';
|
|
23
|
+
import Modal from '../modals/Modal.vue';
|
|
24
|
+
|
|
25
|
+
const props = defineProps<{
|
|
26
|
+
open: boolean;
|
|
27
|
+
locale: LocaleCode;
|
|
28
|
+
/** The folder has a user recovery key slot (v2 markers created since 0.31). */
|
|
29
|
+
hasRecovery: boolean;
|
|
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. */
|
|
39
|
+
escrowKid?: string | null;
|
|
40
|
+
busy?: boolean;
|
|
41
|
+
/** Set by the parent after a failed attempt. */
|
|
42
|
+
error?: string | null;
|
|
43
|
+
}>();
|
|
44
|
+
|
|
45
|
+
const emit = defineEmits<{
|
|
46
|
+
(e: 'close'): void;
|
|
47
|
+
(e: 'submit', payload: { mode: 'recovery' | 'escrow'; value: string }): void;
|
|
48
|
+
}>();
|
|
49
|
+
|
|
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
|
+
});
|
|
61
|
+
const mode = ref<'recovery' | 'escrow'>('recovery');
|
|
62
|
+
const recoveryValue = ref('');
|
|
63
|
+
const escrowValue = ref('');
|
|
64
|
+
const localErr = ref<string | null>(null);
|
|
65
|
+
|
|
66
|
+
watch(
|
|
67
|
+
() => props.open,
|
|
68
|
+
(v) => {
|
|
69
|
+
if (v) {
|
|
70
|
+
mode.value = props.hasRecovery ? 'recovery' : 'escrow';
|
|
71
|
+
recoveryValue.value = '';
|
|
72
|
+
escrowValue.value = '';
|
|
73
|
+
localErr.value = null;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/** A folder with neither slot is a pre-0.31 folder: the password is the
|
|
79
|
+
* only way in, and saying so is more useful than an empty dialog. */
|
|
80
|
+
const nothingAvailable = computed(() => !props.hasRecovery && !hasEscrow.value);
|
|
81
|
+
|
|
82
|
+
const shownError = computed(() => localErr.value || props.error || null);
|
|
83
|
+
|
|
84
|
+
function submit() {
|
|
85
|
+
if (props.busy || nothingAvailable.value) return;
|
|
86
|
+
localErr.value = null;
|
|
87
|
+
if (mode.value === 'recovery') {
|
|
88
|
+
// Check the shape locally so a typo reads as a typo rather than as a
|
|
89
|
+
// wrong key — the two failures call for different next steps.
|
|
90
|
+
if (!parseRecoveryKey(recoveryValue.value)) {
|
|
91
|
+
localErr.value = t('e2e.recover.bad_format');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
emit('submit', { mode: 'recovery', value: recoveryValue.value });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!escrowValue.value.trim()) {
|
|
98
|
+
localErr.value = t('e2e.recover.escrow_required');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
emit('submit', { mode: 'escrow', value: escrowValue.value });
|
|
102
|
+
}
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<template>
|
|
106
|
+
<Modal :open="open" :title="t('e2e.recover.title')" size="sm" @close="emit('close')">
|
|
107
|
+
<div class="fe-e2e-recover">
|
|
108
|
+
<p v-if="nothingAvailable" class="fe-e2e-recover__none">
|
|
109
|
+
{{ t('e2e.recover.none') }}
|
|
110
|
+
</p>
|
|
111
|
+
|
|
112
|
+
<template v-else>
|
|
113
|
+
<div v-if="hasRecovery && hasEscrow" class="fe-e2e-recover__tabs" role="tablist">
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
role="tab"
|
|
117
|
+
class="fe-e2e-recover__tab"
|
|
118
|
+
:class="{ 'fe-e2e-recover__tab--on': mode === 'recovery' }"
|
|
119
|
+
:aria-selected="mode === 'recovery'"
|
|
120
|
+
@click="mode = 'recovery'"
|
|
121
|
+
>
|
|
122
|
+
{{ t('e2e.recover.tab_recovery') }}
|
|
123
|
+
</button>
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
role="tab"
|
|
127
|
+
class="fe-e2e-recover__tab"
|
|
128
|
+
:class="{ 'fe-e2e-recover__tab--on': mode === 'escrow' }"
|
|
129
|
+
:aria-selected="mode === 'escrow'"
|
|
130
|
+
@click="mode = 'escrow'"
|
|
131
|
+
>
|
|
132
|
+
{{ t('e2e.recover.tab_escrow') }}
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<form v-if="mode === 'recovery'" class="fe-e2e-form" @submit.prevent="submit">
|
|
137
|
+
<p class="fe-e2e-recover__hint">{{ t('e2e.recover.recovery_hint') }}</p>
|
|
138
|
+
<input
|
|
139
|
+
v-model="recoveryValue"
|
|
140
|
+
type="text"
|
|
141
|
+
class="fe-input fe-e2e-recover__key"
|
|
142
|
+
:placeholder="t('e2e.recover.recovery_placeholder')"
|
|
143
|
+
autocomplete="off"
|
|
144
|
+
spellcheck="false"
|
|
145
|
+
:disabled="busy"
|
|
146
|
+
/>
|
|
147
|
+
</form>
|
|
148
|
+
|
|
149
|
+
<form v-else class="fe-e2e-form" @submit.prevent="submit">
|
|
150
|
+
<div class="fe-e2e-warn" role="alert">
|
|
151
|
+
<strong>{{ t('e2e.recover.escrow_warn_title') }}</strong>
|
|
152
|
+
<p>{{ t('e2e.recover.escrow_warn_body') }}</p>
|
|
153
|
+
</div>
|
|
154
|
+
<p v-if="escrowKid" class="fe-e2e-recover__hint">
|
|
155
|
+
{{ t('e2e.recover.escrow_kid') }}: <code>{{ escrowKid }}</code>
|
|
156
|
+
</p>
|
|
157
|
+
<textarea
|
|
158
|
+
v-model="escrowValue"
|
|
159
|
+
class="fe-input fe-e2e-recover__escrow"
|
|
160
|
+
rows="5"
|
|
161
|
+
:placeholder="t('e2e.recover.escrow_placeholder')"
|
|
162
|
+
autocomplete="off"
|
|
163
|
+
spellcheck="false"
|
|
164
|
+
:disabled="busy"
|
|
165
|
+
></textarea>
|
|
166
|
+
</form>
|
|
167
|
+
</template>
|
|
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
|
+
|
|
177
|
+
<p v-if="shownError" class="fe-form__error">{{ shownError }}</p>
|
|
178
|
+
</div>
|
|
179
|
+
<template #actions>
|
|
180
|
+
<button type="button" class="fe-btn" :disabled="busy" @click="emit('close')">
|
|
181
|
+
{{ t('modal.newfolder.cancel') }}
|
|
182
|
+
</button>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
class="fe-btn fe-btn--primary"
|
|
186
|
+
:disabled="busy || nothingAvailable"
|
|
187
|
+
@click="submit"
|
|
188
|
+
>
|
|
189
|
+
{{ busy ? t('e2e.recover.busy') : t('e2e.recover.unlock') }}
|
|
190
|
+
</button>
|
|
191
|
+
</template>
|
|
192
|
+
</Modal>
|
|
193
|
+
</template>
|
|
@@ -20,6 +20,10 @@ const props = defineProps<{
|
|
|
20
20
|
locale: LocaleCode;
|
|
21
21
|
/** True while the parent is creating the folder + uploading the marker. */
|
|
22
22
|
busy?: boolean;
|
|
23
|
+
/** Short id of this installation's E2E escrow key, when one is configured.
|
|
24
|
+
* Shown BEFORE the folder is created: escrow means the operator can open
|
|
25
|
+
* it without the password, and that is not a detail to discover later. */
|
|
26
|
+
escrowKid?: string | null;
|
|
23
27
|
}>();
|
|
24
28
|
|
|
25
29
|
const emit = defineEmits<{
|
|
@@ -107,6 +111,12 @@ function submit() {
|
|
|
107
111
|
<strong>{{ t('e2e.create.warn_title') }}</strong>
|
|
108
112
|
<p>{{ t('e2e.create.warn_body') }}</p>
|
|
109
113
|
</div>
|
|
114
|
+
<!-- wiring:e2 recovery — disclosure, not a setting. The user cannot
|
|
115
|
+
turn escrow off for their folder; they can only know about it. -->
|
|
116
|
+
<div v-if="escrowKid" class="fe-e2e-rk__escrow" role="note">
|
|
117
|
+
<strong>{{ t('e2e.create.escrow_title') }}</strong>
|
|
118
|
+
<p>{{ t('e2e.create.escrow_body') }}</p>
|
|
119
|
+
</div>
|
|
110
120
|
<label class="fe-e2e-ack">
|
|
111
121
|
<input v-model="ack" type="checkbox" :disabled="busy" />
|
|
112
122
|
<span>{{ t('e2e.create.ack') }}</span>
|
|
@@ -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>
|
|
@@ -14,6 +14,7 @@ import type { FileNode } from '../types/FileNode';
|
|
|
14
14
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
15
15
|
import { useLocale } from '../composables/useLocale';
|
|
16
16
|
import { fileIconSvg } from '../lib/fileIcons';
|
|
17
|
+
import StarButton from './StarButton.vue';
|
|
17
18
|
import { applyDragGhost } from '../lib/dragGhost';
|
|
18
19
|
|
|
19
20
|
const props = defineProps<{
|
|
@@ -27,6 +28,28 @@ const props = defineProps<{
|
|
|
27
28
|
* GridView: raw `thumb_url` is root-relative and unauthenticated, so
|
|
28
29
|
* embedded hosts NEED this. null = icon fallback. */
|
|
29
30
|
thumbSrc?: (n: FileNode) => string | null;
|
|
31
|
+
/**
|
|
32
|
+
* Starring on a card. Same contract as ListView: the id set the explorer
|
|
33
|
+
* keeps, plus the API wiring StarButton needs. Absent apiBase → no star is
|
|
34
|
+
* rendered at all, exactly as in the list.
|
|
35
|
+
*
|
|
36
|
+
* ⚠ Not list-only. The Starred VIEW shipped before starring was reachable
|
|
37
|
+
* from anywhere but the list, so a user in grid view — the mode the panel's
|
|
38
|
+
* own screenshots show — had a view they could not fill.
|
|
39
|
+
*/
|
|
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: "yıldızlı yeri gözükmüyorsa o zaman yıldızla/yıldızı kaldır da
|
|
47
|
+
* gözükmemeli".
|
|
48
|
+
*/
|
|
49
|
+
starEnabled?: boolean;
|
|
50
|
+
apiBase?: string;
|
|
51
|
+
authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
52
|
+
authCredentials?: RequestCredentials;
|
|
30
53
|
}>();
|
|
31
54
|
|
|
32
55
|
const emit = defineEmits<{
|
|
@@ -35,6 +58,7 @@ const emit = defineEmits<{
|
|
|
35
58
|
(e: 'context-card', node: FileNode, ev: MouseEvent): void;
|
|
36
59
|
(e: 'item-drag-start', node: FileNode, ev: DragEvent): void;
|
|
37
60
|
(e: 'item-drop-into', target: FileNode, ev: DragEvent): void;
|
|
61
|
+
(e: 'star-change', node: FileNode, value: boolean): void;
|
|
38
62
|
}>();
|
|
39
63
|
|
|
40
64
|
const { t, formatSize, nodeDisplayName } = useLocale(() => props.locale);
|
|
@@ -43,6 +67,13 @@ function thumbOf(n: FileNode): string | null {
|
|
|
43
67
|
return props.thumbSrc ? props.thumbSrc(n) : (n.thumb_url ?? null);
|
|
44
68
|
}
|
|
45
69
|
|
|
70
|
+
/** A card carries a star when the host wired the API and the node is a file
|
|
71
|
+
* with a server id — the same rule the list row uses. */
|
|
72
|
+
function canStar(n: FileNode): boolean {
|
|
73
|
+
if (props.starEnabled === false) return false;
|
|
74
|
+
return props.apiBase !== undefined && typeof n.id === 'number' && n.type === 'file';
|
|
75
|
+
}
|
|
76
|
+
|
|
46
77
|
function isSelected(n: FileNode): boolean {
|
|
47
78
|
return props.selected.has(n.path);
|
|
48
79
|
}
|
|
@@ -170,6 +201,7 @@ function metaFor(n: FileNode): string {
|
|
|
170
201
|
role="option"
|
|
171
202
|
:aria-selected="isSelected(n) ? 'true' : 'false'"
|
|
172
203
|
:aria-label="nodeDisplayName(n)"
|
|
204
|
+
:data-fe-path="n.path /* wiring:d1 - middle-click new-tab delegation */"
|
|
173
205
|
draggable="true"
|
|
174
206
|
@click="onClick(n, $event)"
|
|
175
207
|
@dblclick="onDbl(n)"
|
|
@@ -196,6 +228,22 @@ function metaFor(n: FileNode): string {
|
|
|
196
228
|
<span v-else-if="specialEmojiFor(n)" class="fe-gal__icon">{{ specialEmojiFor(n) }}</span>
|
|
197
229
|
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
|
|
198
230
|
<span v-else class="fe-gal__icon fe-gal__icon--svg" v-html="fileIconSvg(n)"></span>
|
|
231
|
+
<!-- Same star chip as the grid, same component, same rule: painted
|
|
232
|
+
when starred, on hover/focus otherwise. It sits above .fe-gal__meta
|
|
233
|
+
(which is aria-hidden and covers the tile's foot on hover). -->
|
|
234
|
+
<div v-if="canStar(n)" class="fe-gal__star" @click.stop @dblclick.stop>
|
|
235
|
+
<StarButton
|
|
236
|
+
:starred="!!starredIds?.has(n.id!)"
|
|
237
|
+
:node-id="n.id!"
|
|
238
|
+
:api-base="apiBase"
|
|
239
|
+
:auth-headers="authHeaders"
|
|
240
|
+
:auth-credentials="authCredentials"
|
|
241
|
+
:locale="locale"
|
|
242
|
+
compact
|
|
243
|
+
card
|
|
244
|
+
@change="(val: boolean) => emit('star-change', n, val)"
|
|
245
|
+
/>
|
|
246
|
+
</div>
|
|
199
247
|
<div class="fe-gal__meta" aria-hidden="true">
|
|
200
248
|
<span v-if="metaFor(n)" class="fe-gal__meta-line">{{ metaFor(n) }}</span>
|
|
201
249
|
<span
|