@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
@@ -28,12 +28,13 @@
28
28
  * hash does not match in the web-component build, so the rules silently stop
29
29
  * applying in every embed (measured on the share dialog: raw unstyled HTML).
30
30
  */
31
- import { computed } from 'vue';
31
+ import { computed, ref } from 'vue';
32
32
  import { useLocale } from '../composables/useLocale';
33
- import type { LocaleCode } from '../types/ExplorerConfig';
33
+ import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
34
+ import ContextMenu, { type ContextAction } from './ContextMenu.vue';
34
35
 
35
36
  /** The virtual listings the panel can open. '' = an ordinary folder. */
36
- export type NavView = '' | 'recent' | 'starred' | 'shared' | 'trash';
37
+ export type NavView = '' | 'recent' | 'starred' | 'shared' | 'trash' | 'tag';
37
38
 
38
39
  export interface NavStorage {
39
40
  name: string;
@@ -61,6 +62,20 @@ const props = defineProps<{
61
62
  sharedStorages?: string[];
62
63
  /** Show the Trash entry (mirrors ExplorerConfig.trashVisible). */
63
64
  trashVisible?: boolean;
65
+ /* === etiket:t1 — the Tags section ==================================
66
+ * "Tagged files should show up inside the tag." Tags are the one
67
+ * navigation family whose entries are USER data: unbounded in number and
68
+ * dynamic in name. Still presentational here — the host fetches
69
+ * `tags/all` (once, cached) and hands the list over, exactly as it does
70
+ * for storages. */
71
+ /** Every tag that exists, alphabetical. Empty → the section shows its own
72
+ * "no tags yet" line rather than disappearing. */
73
+ tags?: string[];
74
+ /** False until the first answer arrives, so "no tags yet" is never shown
75
+ * to somebody who is simply still waiting. */
76
+ tagsLoaded?: boolean;
77
+ /** The tag currently on screen (activeView === 'tag'). */
78
+ activeTag?: string;
64
79
  /**
65
80
  * Show the Connections entries — "How to connect" and "API keys".
66
81
  * ⚠ Never derived from a role here or anywhere: the backend decides what a
@@ -68,20 +83,67 @@ const props = defineProps<{
68
83
  * accounts that need it (see ExplorerConfig.connections).
69
84
  */
70
85
  showConnections?: boolean;
86
+ /**
87
+ * Draw the surfaces that only mean something for ONE person: API keys,
88
+ * Recent, Starred, Shared with me. False when the caller is an app token —
89
+ * a host proxy's shared credential, where "your keys" would be the proxy's
90
+ * own and "your Recent" would be the token owner's history shown to a
91
+ * stranger (see ExplorerConfig.callerKind).
92
+ *
93
+ * ⚠ This is a KIND check, not the role check the comment above forbids, and
94
+ * it is not the whole panel: Upload, the storages, Trash and "How to
95
+ * connect" stay — an embed's users still upload and still mount. An embedder
96
+ * who wants no panel at all already has `sideNav: false`.
97
+ */
98
+ showIdentitySurfaces?: boolean;
71
99
  /** RBAC/root state — false hides the write affordances. */
72
100
  canWrite?: boolean;
73
101
  locale: LocaleCode;
102
+ /* === surucu:d1 — the Drive shell ==================================== */
103
+ /**
104
+ * Fold the primary actions into ONE "+ New" menu (`uiProfile: 'drive'`).
105
+ *
106
+ * Absent/false keeps the two-button block every other profile has had since
107
+ * v0.30.1 — Upload as the primary, New folder one step quieter — so this is
108
+ * additive and nothing that mounts the package today moves.
109
+ *
110
+ * ⚠ The menu holds what the explorer can ALREADY do: upload files, make a
111
+ * folder (the modal offers the encrypted variant from inside itself), and
112
+ * ask somebody else for files. There is deliberately no "Upload folder": the
113
+ * upload path takes a flat `File[]` and would have to create the intermediate
114
+ * directories itself, and an entry that quietly flattens someone's folder into
115
+ * one heap is worse than an entry that is not there.
116
+ */
117
+ newMenu?: boolean;
118
+ /** Offer "Request files" in that menu — a folder we may write to and share. */
119
+ canRequestFiles?: boolean;
120
+ /**
121
+ * The signed-in person's storage line, from `GET /api/files/quota/me`. Null
122
+ * (the default) renders nothing at all.
123
+ *
124
+ * ⚠ It is a PER-USER figure, not this storage's — `quota.Snapshot` sums
125
+ * `nodes.size WHERE owner_id = me`, and there is no per-provider quota
126
+ * (internal/quota/service.go). The mockup labels it with the drive's name;
127
+ * that would be a lie about which number this is, so the label says
128
+ * "Storage" and the drive name stays out of it.
129
+ */
130
+ quota?: { used: number; total: number; unlimited: boolean } | null;
131
+ /** Resolved theme — the teleported New menu leaves the `.fe` variable scope. */
132
+ theme?: ThemeMode;
74
133
  }>();
75
134
 
76
135
  const emit = defineEmits<{
77
136
  (e: 'toggle'): void;
78
- (e: 'open-view', view: Exclude<NavView, ''>): void;
137
+ (e: 'open-view', view: Exclude<NavView, '' | 'tag'>): void;
138
+ (e: 'open-tag', tag: string): void;
79
139
  (e: 'open-storage', name: string): void;
80
140
  (e: 'open-root'): void;
81
141
  (e: 'upload'): void;
82
142
  (e: 'new-folder'): void;
83
143
  (e: 'open-connections'): void;
84
144
  (e: 'open-tokens'): void;
145
+ /* surucu:d1 — "Request files": the access modal on THIS folder, drop tab. */
146
+ (e: 'request-files'): void;
85
147
  /** Drawer scrim / Esc — narrow mode only. */
86
148
  (e: 'close'): void;
87
149
  }>();
@@ -95,18 +157,119 @@ const showLabels = computed(() => props.expanded || !!props.narrow);
95
157
 
96
158
  const sharedSet = computed(() => new Set(props.sharedStorages ?? []));
97
159
 
160
+ /** The views that answer "what did *I* do" — dropped for an app token. */
161
+ const IDENTITY_VIEWS = new Set<string>(['recent', 'starred', 'shared']);
162
+
98
163
  const views = computed(() => {
99
- const list: Array<{ key: Exclude<NavView, ''>; label: string }> = [
164
+ const list: Array<{ key: Exclude<NavView, '' | 'tag'>; label: string }> = [
100
165
  { key: 'recent', label: t('sidenav.recent') },
101
166
  { key: 'starred', label: t('sidenav.starred') },
102
167
  { key: 'shared', label: t('sidenav.shared') },
103
168
  ];
104
169
  if (props.trashVisible !== false) list.push({ key: 'trash', label: t('sidenav.trash') });
170
+ // Filtered at the end rather than built conditionally: Trash is shared by
171
+ // everyone and the list keeps growing, so one rule at the bottom beats a
172
+ // condition wrapped around each entry.
173
+ if (props.showIdentitySurfaces === false) return list.filter((v) => !IDENTITY_VIEWS.has(v.key));
105
174
  return list;
106
175
  });
107
176
 
108
177
  const writable = computed(() => props.canWrite !== false);
109
178
 
179
+ /* === etiket:t1 — an unbounded list in a fixed panel ====================
180
+ * A user with sixty tags must not push Storages and Connections off the
181
+ * bottom of the panel, and must not be handed sixty identical glyphs on a
182
+ * 56px rail either.
183
+ *
184
+ * expanded / drawer → the first TAG_PEEK, then "Show all (N)". Both states
185
+ * scroll (.fe-sidenav__scroll), so this is about the
186
+ * sections BELOW staying reachable, not about overflow.
187
+ * rail → ONE "Tags" button that opens the panel. Sixty rail
188
+ * icons would be sixty copies of the same glyph with no
189
+ * label — the rail's contract is "every destination one
190
+ * click away", and this keeps it with one click more.
191
+ */
192
+ const TAG_PEEK = 8;
193
+ const tagsExpanded = ref(false);
194
+ const allTags = computed(() => props.tags ?? []);
195
+ const visibleTags = computed(() =>
196
+ tagsExpanded.value ? allTags.value : allTags.value.slice(0, TAG_PEEK),
197
+ );
198
+ const hiddenTagCount = computed(() => Math.max(0, allTags.value.length - visibleTags.value.length));
199
+ /* The section is rendered as soon as the panel knows there ARE tags, and also
200
+ * once the answer came back empty — an empty section that says why is how a
201
+ * user learns the feature exists at all. It stays hidden only while the first
202
+ * answer is still in flight. */
203
+ const showTags = computed(() => !!props.tagsLoaded || allTags.value.length > 0);
204
+
205
+ /* === surucu:d1 — the "+ New" menu ====================================
206
+ * One primary action instead of two buttons, the shape the reporter drew and
207
+ * the shape Drive/FileRun/Nextcloud share. It is the SAME ContextMenu the
208
+ * right-click menu uses — teleported to <body>, so the panel's own scroll
209
+ * container cannot clip it, and one keyboard/focus behaviour for both. */
210
+ const newBtnEl = ref<HTMLElement | null>(null);
211
+ const newMenuRef = ref<InstanceType<typeof ContextMenu> | null>(null);
212
+
213
+ const newActions = computed<ContextAction[]>(() => {
214
+ const list: ContextAction[] = [
215
+ { key: 'upload', label: t('drive.new.upload'), icon: '⬆', disabled: !writable.value },
216
+ { key: 'new-folder', label: t('drive.new.folder'), icon: '📁', disabled: !writable.value },
217
+ ];
218
+ // Only when there is a real folder to hang a drop link on. Rendered as a
219
+ // disabled row rather than dropped, so the menu does not change height
220
+ // between folders — a menu whose items move is a menu people misclick.
221
+ list.push({ divider: true, key: 'new-sep', label: '' });
222
+ list.push({
223
+ key: 'request-files',
224
+ label: t('drive.new.request'),
225
+ icon: '🔗',
226
+ disabled: !props.canRequestFiles,
227
+ });
228
+ return list;
229
+ });
230
+
231
+ function openNewMenu() {
232
+ const r = newBtnEl.value?.getBoundingClientRect();
233
+ newMenuRef.value?.show(
234
+ { clientX: r ? r.left : 0, clientY: r ? r.bottom + 6 : 0 } as MouseEvent,
235
+ [],
236
+ );
237
+ }
238
+
239
+ function onNewSelect(a: ContextAction) {
240
+ if (a.key === 'upload') emit('upload');
241
+ else if (a.key === 'new-folder') emit('new-folder');
242
+ else if (a.key === 'request-files') emit('request-files');
243
+ }
244
+
245
+ /* === surucu:d1 — the storage line =================================== */
246
+ function formatBytes(n: number): string {
247
+ if (!Number.isFinite(n) || n < 0) return '—';
248
+ const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
249
+ let v = n;
250
+ let i = 0;
251
+ while (v >= 1024 && i < units.length - 1) {
252
+ v /= 1024;
253
+ i += 1;
254
+ }
255
+ const digits = v < 10 && i > 0 ? 1 : 0;
256
+ return `${v.toFixed(digits)} ${units[i]}`;
257
+ }
258
+
259
+ const quotaPercent = computed(() => {
260
+ const q = props.quota;
261
+ if (!q || q.unlimited || q.total <= 0) return 0;
262
+ return Math.max(0, Math.min(100, Math.round((q.used / q.total) * 100)));
263
+ });
264
+
265
+ const quotaText = computed(() => {
266
+ const q = props.quota;
267
+ if (!q) return '';
268
+ return q.unlimited || q.total <= 0
269
+ ? t('drive.storage.used_unlimited', { used: formatBytes(q.used) })
270
+ : t('drive.storage.used', { used: formatBytes(q.used), total: formatBytes(q.total) });
271
+ });
272
+
110
273
  const toggleLabel = computed(() =>
111
274
  props.narrow
112
275
  ? t('sidenav.close')
@@ -177,7 +340,35 @@ const toggleLabel = computed(() =>
177
340
  that appears and disappears makes every row below it jump by 90px each
178
341
  time the user opens a view, which reads as the panel reloading. -->
179
342
  <div class="fe-sidenav__primary">
343
+ <!-- surucu:d1 — one primary action, the shape #14's mockups draw. The
344
+ two-button block below is what every other profile still renders. -->
180
345
  <button
346
+ v-if="newMenu"
347
+ ref="newBtnEl"
348
+ type="button"
349
+ class="fe-sidenav__new"
350
+ aria-haspopup="menu"
351
+ :title="t('drive.new')"
352
+ :aria-label="t('drive.new')"
353
+ data-testid="sidenav-new"
354
+ @click="openNewMenu"
355
+ >
356
+ <svg
357
+ class="fe-ficon"
358
+ viewBox="0 0 24 24"
359
+ fill="none"
360
+ stroke="currentColor"
361
+ stroke-width="2"
362
+ stroke-linecap="round"
363
+ aria-hidden="true"
364
+ focusable="false"
365
+ >
366
+ <path d="M12 5v14M5 12h14" />
367
+ </svg>
368
+ <span v-if="showLabels" class="fe-sidenav__text">{{ t('drive.new') }}</span>
369
+ </button>
370
+ <button
371
+ v-if="!newMenu"
181
372
  type="button"
182
373
  class="fe-sidenav__upload"
183
374
  :disabled="!writable"
@@ -204,6 +395,7 @@ const toggleLabel = computed(() =>
204
395
  <span v-if="showLabels" class="fe-sidenav__text">{{ t('toolbar.upload') }}</span>
205
396
  </button>
206
397
  <button
398
+ v-if="!newMenu"
207
399
  type="button"
208
400
  class="fe-sidenav__secondary"
209
401
  :disabled="!writable"
@@ -278,6 +470,105 @@ const toggleLabel = computed(() =>
278
470
  </li>
279
471
  </ul>
280
472
 
473
+ <!-- etiket:t1 — Tags. Between the views and the storages because a tag
474
+ IS a view (a listing with no folder behind it), not a place files
475
+ live. On the rail it collapses to one button that opens the panel:
476
+ sixty tags would otherwise be sixty copies of one glyph with no
477
+ label, and the rail's promise is that every destination stays one
478
+ click away. -->
479
+ <div v-if="showTags" class="fe-sidenav__section">
480
+ <template v-if="showLabels">
481
+ <p class="fe-sidenav__heading">{{ t('sidenav.tags') }}</p>
482
+ <ul class="fe-sidenav__group" :aria-label="t('sidenav.tags')">
483
+ <li v-for="tag in visibleTags" :key="tag">
484
+ <button
485
+ type="button"
486
+ class="fe-sidenav__item fe-sidenav__item--tag"
487
+ :class="{ 'is-active': activeView === 'tag' && activeTag === tag }"
488
+ :aria-current="activeView === 'tag' && activeTag === tag ? 'page' : undefined"
489
+ :title="tag"
490
+ :aria-label="tag"
491
+ :data-testid="`sidenav-tag-${tag}`"
492
+ @click="emit('open-tag', tag)"
493
+ >
494
+ <svg
495
+ class="fe-ficon"
496
+ viewBox="0 0 24 24"
497
+ fill="none"
498
+ stroke="currentColor"
499
+ stroke-width="1.8"
500
+ stroke-linecap="round"
501
+ stroke-linejoin="round"
502
+ aria-hidden="true"
503
+ focusable="false"
504
+ >
505
+ <path d="M4 4.5h7l9 9-6.5 6.5-9-9z" />
506
+ <circle cx="8" cy="8.5" r="1.4" />
507
+ </svg>
508
+ <span class="fe-sidenav__text">{{ tag }}</span>
509
+ </button>
510
+ </li>
511
+ <!-- Nothing tagged yet: the section stays, and says how tags get
512
+ made. A section that only exists once you already know the
513
+ feature teaches nobody. -->
514
+ <li v-if="allTags.length === 0">
515
+ <p class="fe-sidenav__hint">{{ t('sidenav.tags.empty') }}</p>
516
+ </li>
517
+ <li v-if="hiddenTagCount > 0">
518
+ <button
519
+ type="button"
520
+ class="fe-sidenav__more"
521
+ data-testid="sidenav-tags-more"
522
+ @click="tagsExpanded = true"
523
+ >
524
+ {{ t('sidenav.tags.more', { count: hiddenTagCount }) }}
525
+ </button>
526
+ </li>
527
+ <li v-else-if="tagsExpanded && allTags.length > 8">
528
+ <button
529
+ type="button"
530
+ class="fe-sidenav__more"
531
+ data-testid="sidenav-tags-less"
532
+ @click="tagsExpanded = false"
533
+ >
534
+ {{ t('sidenav.tags.less') }}
535
+ </button>
536
+ </li>
537
+ </ul>
538
+ </template>
539
+ <template v-else>
540
+ <hr class="fe-sidenav__rule" aria-hidden="true" />
541
+ <ul class="fe-sidenav__group" :aria-label="t('sidenav.tags')">
542
+ <li>
543
+ <button
544
+ type="button"
545
+ class="fe-sidenav__item"
546
+ :class="{ 'is-active': activeView === 'tag' }"
547
+ :title="t('sidenav.tags')"
548
+ :aria-label="t('sidenav.tags')"
549
+ data-testid="sidenav-tags-rail"
550
+ @click="emit('toggle')"
551
+ >
552
+ <svg
553
+ class="fe-ficon"
554
+ viewBox="0 0 24 24"
555
+ fill="none"
556
+ stroke="currentColor"
557
+ stroke-width="1.8"
558
+ stroke-linecap="round"
559
+ stroke-linejoin="round"
560
+ aria-hidden="true"
561
+ focusable="false"
562
+ >
563
+ <path d="M4 4.5h7l9 9-6.5 6.5-9-9z" />
564
+ <circle cx="8" cy="8.5" r="1.4" />
565
+ </svg>
566
+ </button>
567
+ </li>
568
+ </ul>
569
+ </template>
570
+ </div>
571
+
281
572
  <div v-if="storages.length" class="fe-sidenav__section">
282
573
  <p v-if="showLabels" class="fe-sidenav__heading">{{ t('sidenav.storages') }}</p>
283
574
  <hr v-else class="fe-sidenav__rule" aria-hidden="true" />
@@ -375,7 +666,10 @@ const toggleLabel = computed(() =>
375
666
  <span v-if="showLabels" class="fe-sidenav__text">{{ t('sidenav.connect') }}</span>
376
667
  </button>
377
668
  </li>
378
- <li>
669
+ <!-- API keys is the person half of this section: "How to connect"
670
+ stays for an app token (mount instructions are not identity),
671
+ the keys go. -->
672
+ <li v-if="showIdentitySurfaces !== false">
379
673
  <button
380
674
  type="button"
381
675
  class="fe-sidenav__item"
@@ -405,5 +699,34 @@ const toggleLabel = computed(() =>
405
699
  </ul>
406
700
  </div>
407
701
  </div>
702
+
703
+ <!-- surucu:d1 — the storage line. Under the navigation, above nothing:
704
+ it is the last thing in the panel because it is a status, not a
705
+ destination. Hidden entirely when the server did not answer (an app
706
+ token has no person to have a quota) rather than drawn empty. -->
707
+ <div v-if="quota" class="fe-sidenav__quota" data-testid="sidenav-quota">
708
+ <p v-if="showLabels" class="fe-sidenav__quota-label">{{ t('drive.storage.label') }}</p>
709
+ <div
710
+ class="fe-sidenav__quota-bar"
711
+ role="progressbar"
712
+ :aria-valuenow="quotaPercent"
713
+ aria-valuemin="0"
714
+ aria-valuemax="100"
715
+ :aria-label="quotaText"
716
+ :title="quotaText"
717
+ >
718
+ <span class="fe-sidenav__quota-fill" :style="{ width: quotaPercent + '%' }"></span>
719
+ </div>
720
+ <p v-if="showLabels" class="fe-sidenav__quota-text">{{ quotaText }}</p>
721
+ </div>
722
+
723
+ <ContextMenu
724
+ v-if="newMenu"
725
+ ref="newMenuRef"
726
+ :locale="locale"
727
+ :theme="theme"
728
+ :actions="newActions"
729
+ @select="onNewSelect"
730
+ />
408
731
  </nav>
409
732
  </template>
@@ -6,7 +6,10 @@
6
6
  * `POST /api/files/manager/star`). Optimistic update — flips the local
7
7
  * state immediately and rolls back on API error.
8
8
  */
9
- import { ref, watch } from 'vue';
9
+ import { ref, watch, computed } from 'vue';
10
+ import { setNodeStarred } from '../lib/star';
11
+ import { useLocale } from '../composables/useLocale';
12
+ import type { LocaleCode } from '../types/ExplorerConfig';
10
13
 
11
14
  const props = defineProps<{
12
15
  starred: boolean;
@@ -21,6 +24,17 @@ const props = defineProps<{
21
24
  authCredentials?: RequestCredentials;
22
25
  /** Compact mode for grid view (no label, just the icon). */
23
26
  compact?: boolean;
27
+ /**
28
+ * Card mode — the same button sitting ON a grid/gallery tile instead of in
29
+ * a list cell: a round translucent chip in the tile's corner. It is the SAME
30
+ * component, deliberately: a card star written separately is a second
31
+ * starring path, and the two drift the first time one of them is fixed.
32
+ */
33
+ card?: boolean;
34
+ /** Locale for the title/label. ⚠ The strings used to be hardcoded English
35
+ * ("Star"/"Unstar"), which is a Turkish user's only untranslated control in
36
+ * the row. */
37
+ locale?: LocaleCode;
24
38
  }>();
25
39
 
26
40
  const emit = defineEmits<{
@@ -31,22 +45,18 @@ const emit = defineEmits<{
31
45
  const local = ref(props.starred);
32
46
  watch(() => props.starred, (v) => { local.value = v; });
33
47
 
48
+ const { t } = useLocale(() => props.locale ?? 'tr');
49
+ const label = computed(() => t(local.value ? 'ctx.unstar' : 'ctx.star'));
50
+
34
51
  async function toggle() {
35
52
  const next = !local.value;
36
53
  local.value = next; // optimistic
37
54
  try {
38
- const headers = {
39
- 'Content-Type': 'application/json',
40
- ...(await (props.authHeaders ?? (() => ({})))()),
41
- };
42
- const base = props.apiBase ?? '';
43
- const res = await fetch(`${base}/api/files/manager/star`, {
44
- method: 'POST',
45
- headers,
46
- credentials: props.authCredentials ?? 'same-origin',
47
- body: JSON.stringify({ node_id: props.nodeId, starred: next }),
55
+ await setNodeStarred(props.nodeId, next, {
56
+ apiBase: props.apiBase,
57
+ authHeaders: props.authHeaders,
58
+ authCredentials: props.authCredentials,
48
59
  });
49
- if (!res.ok) throw new Error(`star toggle failed: ${res.status}`);
50
60
  emit('change', next);
51
61
  } catch (err) {
52
62
  local.value = !next; // rollback
@@ -59,9 +69,11 @@ async function toggle() {
59
69
  <button
60
70
  type="button"
61
71
  class="filex-star-btn"
62
- :class="{ 'is-starred': local, 'is-compact': compact }"
72
+ :class="{ 'is-starred': local, 'is-compact': compact, 'is-card': card }"
63
73
  :aria-pressed="local"
64
- :title="local ? 'Unstar' : 'Star'"
74
+ :title="label"
75
+ :aria-label="label"
76
+ data-testid="star-toggle"
65
77
  @click.stop="toggle"
66
78
  >
67
79
  <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
@@ -73,7 +85,7 @@ async function toggle() {
73
85
  d="M12 2.5l3.09 6.26 6.91 1-5 4.87 1.18 6.87L12 18.27l-6.18 3.23L7 14.63 2 9.76l6.91-1z"
74
86
  />
75
87
  </svg>
76
- <span v-if="!compact" class="filex-star-label">{{ local ? 'Starred' : 'Star' }}</span>
88
+ <span v-if="!compact" class="filex-star-label">{{ label }}</span>
77
89
  </button>
78
90
  </template>
79
91