@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
|
@@ -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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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="
|
|
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">{{
|
|
88
|
+
<span v-if="!compact" class="filex-star-label">{{ label }}</span>
|
|
77
89
|
</button>
|
|
78
90
|
</template>
|
|
79
91
|
|