@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.
Files changed (38) hide show
  1. package/README.md +37 -2
  2. package/dist/filex-core.js +10571 -8278
  3. package/dist/filex-core.js.map +1 -1
  4. package/dist/filex-core.umd.cjs +95 -87
  5. package/dist/filex-core.umd.cjs.map +1 -1
  6. package/dist/index.d.ts +548 -66
  7. package/dist/style.css +1 -1
  8. package/package.json +1 -1
  9. package/src/FileExplorer.vue +1104 -26
  10. package/src/components/Breadcrumb.vue +4 -2
  11. package/src/components/CommandPalette.vue +18 -2
  12. package/src/components/E2eRecoveryUnlockModal.vue +193 -0
  13. package/src/components/EncryptedFolderModal.vue +10 -0
  14. package/src/components/FilterBar.vue +244 -0
  15. package/src/components/GalleryView.vue +48 -0
  16. package/src/components/GridView.vue +85 -3
  17. package/src/components/InspectorPanel.vue +231 -8
  18. package/src/components/ListView.vue +11 -1
  19. package/src/components/RecoveryKeyModal.vue +133 -0
  20. package/src/components/SecondaryPane.vue +15 -1
  21. package/src/components/SideNav.vue +329 -6
  22. package/src/components/StarButton.vue +27 -15
  23. package/src/components/Toolbar.vue +235 -49
  24. package/src/components/ViewSwitcher.vue +81 -0
  25. package/src/composables/useFileApi.ts +83 -0
  26. package/src/composables/useKeyboardShortcuts.ts +7 -0
  27. package/src/index.ts +33 -1
  28. package/src/lib/e2ecrypto.ts +716 -70
  29. package/src/lib/fileFilters.ts +143 -0
  30. package/src/lib/listing.ts +103 -1
  31. package/src/lib/star.ts +42 -0
  32. package/src/lib/tags.ts +105 -0
  33. package/src/locales/en.ts +154 -2
  34. package/src/locales/tr.ts +154 -2
  35. package/src/modals/PermissionsModal.vue +18 -2
  36. package/src/styles/base.css +743 -0
  37. package/src/types/ExplorerConfig.ts +53 -1
  38. 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 { VIRTUAL_SEGMENTS } from '../lib/listing';
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
- const label = VIRTUAL_SEGMENTS[part] ? t(VIRTUAL_SEGMENTS[part]) : part;
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
- query.value = '';
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(() => inputEl.value?.focus());
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