@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
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* GridView — card grid. Thumbnails preferred, fall back to icon.
|
|
4
4
|
*/
|
|
5
|
-
import { ref } from 'vue'; /* wiring:c4 */
|
|
5
|
+
import { computed, ref } from 'vue'; /* wiring:c4 */
|
|
6
6
|
import type { FileNode } from '../types/FileNode';
|
|
7
7
|
import { hasInternalDrag } from '../lib/dragOut';
|
|
8
8
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
9
9
|
import { useLocale } from '../composables/useLocale';
|
|
10
10
|
import { fileIconSvg } from '../lib/fileIcons';
|
|
11
|
+
import StarButton from './StarButton.vue';
|
|
11
12
|
import { snippetSegments } from '../lib/snippet'; /* bul:s3 */
|
|
12
13
|
import { applyDragGhost } from '../lib/dragGhost'; /* wiring:c4 */
|
|
13
14
|
|
|
@@ -25,6 +26,40 @@ const props = defineProps<{
|
|
|
25
26
|
/** Desktop selective sync: availability badge per tile. Absent on the
|
|
26
27
|
* web — no badge renders at all. */
|
|
27
28
|
keepBadgeFor?: (n: FileNode) => 'kept' | 'syncing' | 'cloud' | 'partial' | null;
|
|
29
|
+
/**
|
|
30
|
+
* Starring on a card. Same contract as ListView: the id set the explorer
|
|
31
|
+
* keeps, plus the API wiring StarButton needs. Absent apiBase → no star is
|
|
32
|
+
* rendered at all, exactly as in the list.
|
|
33
|
+
*
|
|
34
|
+
* ⚠ Not list-only. The Starred VIEW shipped before starring was reachable
|
|
35
|
+
* from anywhere but the list, so a user in grid view — the mode the panel's
|
|
36
|
+
* own screenshots show — had a view they could not fill.
|
|
37
|
+
*/
|
|
38
|
+
starredIds?: Set<number>;
|
|
39
|
+
/**
|
|
40
|
+
* Offer the star affordance at all. Follows the Starred view: when the panel
|
|
41
|
+
* leaves that view out — a shared app token has no single person behind it,
|
|
42
|
+
* so "your starred files" is one list shown to strangers — offering to star
|
|
43
|
+
* something is offering to write into that same shared list. Owner's call,
|
|
44
|
+
* 2026-09-05: "yıldızlı yeri gözükmüyorsa o zaman yıldızla/yıldızı kaldır da
|
|
45
|
+
* gözükmemeli".
|
|
46
|
+
*/
|
|
47
|
+
starEnabled?: boolean;
|
|
48
|
+
apiBase?: string;
|
|
49
|
+
authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
50
|
+
authCredentials?: RequestCredentials;
|
|
51
|
+
/**
|
|
52
|
+
* surucu:d1 — draw "Folders" and "Files" as labelled sections
|
|
53
|
+
* (`uiProfile: 'drive'`). Absent/false renders the flat grid unchanged, down
|
|
54
|
+
* to the DOM: the two headings are the only extra nodes, and without this
|
|
55
|
+
* prop none are emitted.
|
|
56
|
+
*
|
|
57
|
+
* The rows are re-ordered HERE (directories first, stable within each group)
|
|
58
|
+
* rather than trusted to arrive that way — the listing endpoint promises no
|
|
59
|
+
* grouping, and a "Folders" heading with a spreadsheet under it is worse
|
|
60
|
+
* than no heading at all.
|
|
61
|
+
*/
|
|
62
|
+
sections?: boolean;
|
|
28
63
|
}>();
|
|
29
64
|
|
|
30
65
|
const emit = defineEmits<{
|
|
@@ -33,8 +68,26 @@ const emit = defineEmits<{
|
|
|
33
68
|
(e: 'context-card', node: FileNode, ev: MouseEvent): void;
|
|
34
69
|
(e: 'item-drag-start', node: FileNode, ev: DragEvent): void;
|
|
35
70
|
(e: 'item-drop-into', target: FileNode, ev: DragEvent): void;
|
|
71
|
+
(e: 'star-change', node: FileNode, value: boolean): void;
|
|
36
72
|
}>();
|
|
37
73
|
|
|
74
|
+
/* surucu:d1 — grouped order + the two headings. `sort` is stable, so within
|
|
75
|
+
* folders and within files the listing's own order survives untouched. */
|
|
76
|
+
const ordered = computed<FileNode[]>(() =>
|
|
77
|
+
props.sections
|
|
78
|
+
? [...props.files].sort((a, b) => (a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1))
|
|
79
|
+
: props.files,
|
|
80
|
+
);
|
|
81
|
+
const firstDirPath = computed(() => ordered.value.find((f) => f.type === 'dir')?.path ?? null);
|
|
82
|
+
const firstFilePath = computed(() => ordered.value.find((f) => f.type !== 'dir')?.path ?? null);
|
|
83
|
+
|
|
84
|
+
function headingBefore(n: FileNode): string | null {
|
|
85
|
+
if (!props.sections) return null;
|
|
86
|
+
if (n.path === firstDirPath.value) return t('drive.section.folders');
|
|
87
|
+
if (n.path === firstFilePath.value) return t('drive.section.files');
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
38
91
|
const { t, formatSize, nodeDisplayName } = useLocale(() => props.locale);
|
|
39
92
|
|
|
40
93
|
// Prefer the authenticated resolver when the host wired one; otherwise fall
|
|
@@ -43,6 +96,13 @@ function thumbOf(n: FileNode): string | null {
|
|
|
43
96
|
return props.thumbSrc ? props.thumbSrc(n) : (n.thumb_url ?? null);
|
|
44
97
|
}
|
|
45
98
|
|
|
99
|
+
/** A card carries a star when the host wired the API and the node is a file
|
|
100
|
+
* with a server id — the same rule the list row uses. */
|
|
101
|
+
function canStar(n: FileNode): boolean {
|
|
102
|
+
if (props.starEnabled === false) return false;
|
|
103
|
+
return props.apiBase !== undefined && typeof n.id === 'number' && n.type === 'file';
|
|
104
|
+
}
|
|
105
|
+
|
|
46
106
|
function isSelected(n: FileNode): boolean {
|
|
47
107
|
return props.selected.has(n.path);
|
|
48
108
|
}
|
|
@@ -168,9 +228,12 @@ function snippetTitle(snippet: string): string {
|
|
|
168
228
|
:aria-label="t('grid.aria')"
|
|
169
229
|
:aria-busy="loading ? 'true' : undefined"
|
|
170
230
|
>
|
|
231
|
+
<template v-for="n in ordered" :key="n.path">
|
|
232
|
+
<!-- surucu:d1 — the section label: a grid item spanning every column. The
|
|
233
|
+
cards keep `role="option"`; the heading is aria-hidden, and the group
|
|
234
|
+
it names is already in each card's own label. -->
|
|
235
|
+
<p v-if="headingBefore(n)" class="fe-grid__heading" aria-hidden="true">{{ headingBefore(n) }}</p>
|
|
171
236
|
<div
|
|
172
|
-
v-for="n in files"
|
|
173
|
-
:key="n.path"
|
|
174
237
|
class="fe-grid__card"
|
|
175
238
|
:class="{
|
|
176
239
|
'is-selected': isSelected(n),
|
|
@@ -213,6 +276,24 @@ function snippetTitle(snippet: string): string {
|
|
|
213
276
|
<span v-else-if="specialEmojiFor(n)" class="fe-grid__icon">{{ specialEmojiFor(n) }}</span>
|
|
214
277
|
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
|
|
215
278
|
<span v-else class="fe-grid__icon fe-grid__icon--svg" v-html="fileIconSvg(n)"></span>
|
|
279
|
+
<!-- Star, ON the tile. A hover-only affordance would be invisible to
|
|
280
|
+
the person looking for what they starred, so the chip is always
|
|
281
|
+
painted once the file IS starred and only appears on hover/focus
|
|
282
|
+
otherwise (see .fe-grid__star in styles/base.css). @click.stop so
|
|
283
|
+
starring never doubles as a card selection. -->
|
|
284
|
+
<div v-if="canStar(n)" class="fe-grid__star" @click.stop @dblclick.stop>
|
|
285
|
+
<StarButton
|
|
286
|
+
:starred="!!starredIds?.has(n.id!)"
|
|
287
|
+
:node-id="n.id!"
|
|
288
|
+
:api-base="apiBase"
|
|
289
|
+
:auth-headers="authHeaders"
|
|
290
|
+
:auth-credentials="authCredentials"
|
|
291
|
+
:locale="locale"
|
|
292
|
+
compact
|
|
293
|
+
card
|
|
294
|
+
@change="(val: boolean) => emit('star-change', n, val)"
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
216
297
|
</div>
|
|
217
298
|
<div class="fe-grid__label" :title="n.basename">
|
|
218
299
|
{{ nodeDisplayName(n) }}
|
|
@@ -240,6 +321,7 @@ function snippetTitle(snippet: string): string {
|
|
|
240
321
|
</template>
|
|
241
322
|
</div>
|
|
242
323
|
</div>
|
|
324
|
+
</template>
|
|
243
325
|
<div v-if="!loading && files.length === 0" class="fe-grid__empty">
|
|
244
326
|
{{ t('empty.folder') }}
|
|
245
327
|
</div>
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* The panel is mounted with v-if by the host — closed state leaves zero DOM.
|
|
25
25
|
*/
|
|
26
26
|
import { computed, ref, watch } from 'vue';
|
|
27
|
-
import type { FileApi, NodeVersion } from '../composables/useFileApi';
|
|
27
|
+
import type { FileApi, Grant, NodeVersion } from '../composables/useFileApi';
|
|
28
28
|
import type { FileNode, ShareInfo } from '../types/FileNode';
|
|
29
29
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
30
30
|
import { useLocale } from '../composables/useLocale';
|
|
@@ -45,10 +45,28 @@ const props = defineProps<{
|
|
|
45
45
|
narrow?: boolean;
|
|
46
46
|
/** Authenticated thumbnail resolver (useThumbs.src). Optional. */
|
|
47
47
|
thumbSrc?: (n: FileNode) => string | null;
|
|
48
|
+
/* === surucu:d1 — the Drive shell's details panel ===================== */
|
|
49
|
+
/**
|
|
50
|
+
* Split the panel into **Details** and **Activity** tabs, and draw the two
|
|
51
|
+
* sections the mockups add: who has access, and the share link with a
|
|
52
|
+
* "Create link" button. `uiProfile: 'drive'` turns it on; absent/false is
|
|
53
|
+
* the flat scroll of sections this panel has always been, with no extra
|
|
54
|
+
* request made.
|
|
55
|
+
*
|
|
56
|
+
* ⚠ "Activity" is version history + comments, and nothing else, because
|
|
57
|
+
* nothing else exists: there is no per-file activity endpoint. The audit log
|
|
58
|
+
* is admin-only, has no path filter, and stores an EMPTY `target_id` for
|
|
59
|
+
* every file action (auth/audit_middleware.go) — so "Ayşe renamed this on
|
|
60
|
+
* Tuesday" cannot be answered by this server, and a timeline that made it up
|
|
61
|
+
* would be worse than the two real feeds.
|
|
62
|
+
*/
|
|
63
|
+
tabs?: boolean;
|
|
48
64
|
}>();
|
|
49
65
|
|
|
50
66
|
const emit = defineEmits<{
|
|
51
67
|
(e: 'close'): void;
|
|
68
|
+
/** surucu:d1 — a share link was minted from the panel. */
|
|
69
|
+
(e: 'share-created', payload: { path: string; url: string }): void;
|
|
52
70
|
(e: 'manage-permissions', node: FileNode): void;
|
|
53
71
|
(e: 'toast', message: string): void;
|
|
54
72
|
/** Fired after a successful restore/snapshot so the host can reload. */
|
|
@@ -239,6 +257,107 @@ async function takeSnapshot(): Promise<void> {
|
|
|
239
257
|
}
|
|
240
258
|
}
|
|
241
259
|
|
|
260
|
+
/* === surucu:d1 — tabs, people with access, the share-link row ==========
|
|
261
|
+
*
|
|
262
|
+
* The tab strip is presentation only: every section below is the same section,
|
|
263
|
+
* rendered under Details or under Activity. Nothing is fetched twice, and
|
|
264
|
+
* nothing new is fetched at all unless `tabs` is on.
|
|
265
|
+
*/
|
|
266
|
+
type InspectorTab = 'details' | 'activity';
|
|
267
|
+
const tab = ref<InspectorTab>('details');
|
|
268
|
+
|
|
269
|
+
/** People with access — `GET /api/files/permissions?path=…`.
|
|
270
|
+
*
|
|
271
|
+
* ⚠ Owner-gated on the server (handlers/grants.go `requireOwner`), so an
|
|
272
|
+
* editor or a viewer gets a 403 for a file they can perfectly well see. That
|
|
273
|
+
* is the server's call, not something to route around: the section hides,
|
|
274
|
+
* exactly the way the shares list already hides for the same class of caller.
|
|
275
|
+
* What it must NOT do is render an empty "People with access" and let the
|
|
276
|
+
* reader conclude that nobody has any.
|
|
277
|
+
*/
|
|
278
|
+
const peopleState = ref<SectionState>('hidden');
|
|
279
|
+
const people = ref<Grant[]>([]);
|
|
280
|
+
let peopleSeq = 0;
|
|
281
|
+
|
|
282
|
+
async function loadPeople(): Promise<void> {
|
|
283
|
+
const seq = ++peopleSeq;
|
|
284
|
+
const node = single.value;
|
|
285
|
+
if (!props.tabs || !node) {
|
|
286
|
+
peopleState.value = 'hidden';
|
|
287
|
+
people.value = [];
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
peopleState.value = 'loading';
|
|
291
|
+
try {
|
|
292
|
+
const r = await props.api.listPermissions(node.path);
|
|
293
|
+
if (seq !== peopleSeq) return;
|
|
294
|
+
// RBAC off on this storage → there are no grants, and a section reading
|
|
295
|
+
// "nobody" would misdescribe a drive where everyone can already read it.
|
|
296
|
+
if (!r.storage_rbac) {
|
|
297
|
+
peopleState.value = 'hidden';
|
|
298
|
+
people.value = [];
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
people.value = [...(r.direct ?? []), ...(r.inherited ?? [])];
|
|
302
|
+
peopleState.value = 'ok';
|
|
303
|
+
} catch {
|
|
304
|
+
if (seq !== peopleSeq) return;
|
|
305
|
+
people.value = [];
|
|
306
|
+
peopleState.value = 'hidden';
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function personName(g: Grant): string {
|
|
311
|
+
return g.user_display_name || g.user_email || `#${g.user_id}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function personInitial(g: Grant): string {
|
|
315
|
+
const n = personName(g).trim();
|
|
316
|
+
return n ? n[0].toUpperCase() : '?';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The share-link row: the first live link, or the Create button. */
|
|
320
|
+
const primaryShare = computed(() => (shares.value.length ? shares.value[0] : null));
|
|
321
|
+
const shareBusy = ref(false);
|
|
322
|
+
|
|
323
|
+
async function createLink(): Promise<void> {
|
|
324
|
+
const node = single.value;
|
|
325
|
+
if (!node || shareBusy.value) return;
|
|
326
|
+
shareBusy.value = true;
|
|
327
|
+
try {
|
|
328
|
+
const r = await props.api.createShare({ path: node.path });
|
|
329
|
+
const url = r.share?.url ?? '';
|
|
330
|
+
// Re-read the list rather than push the response into it: the LIST
|
|
331
|
+
// endpoint is what a later copy or revoke acts on, and its `uuid` is the
|
|
332
|
+
// numeric id the DELETE route wants — not the token the create response
|
|
333
|
+
// carries under that name.
|
|
334
|
+
const { shares: list } = await props.api.listShares(node.path);
|
|
335
|
+
shares.value = Array.isArray(list) ? list : [];
|
|
336
|
+
sharesState.value = 'ok';
|
|
337
|
+
if (url) {
|
|
338
|
+
emit('share-created', { path: node.path, url });
|
|
339
|
+
await copyText(url);
|
|
340
|
+
}
|
|
341
|
+
} catch (err) {
|
|
342
|
+
emit('toast', (err as Error).message);
|
|
343
|
+
} finally {
|
|
344
|
+
shareBusy.value = false;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
watch(
|
|
349
|
+
() => [props.nodes.map((n) => n.path).join('|'), props.tabs] as const,
|
|
350
|
+
() => void loadPeople(),
|
|
351
|
+
{ immediate: true },
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
/* Versions and comments are the two real feeds; when a selection has neither,
|
|
355
|
+
* the Activity tab says so instead of showing two empty headings. */
|
|
356
|
+
const activityUsable = computed(
|
|
357
|
+
() => versionsState.value !== 'hidden' || commentsState.value !== 'hidden',
|
|
358
|
+
);
|
|
359
|
+
/* === /surucu:d1 === */
|
|
360
|
+
|
|
242
361
|
watch(
|
|
243
362
|
() => props.nodes.map((n) => n.path).join(''),
|
|
244
363
|
() => void refresh(),
|
|
@@ -372,9 +491,32 @@ watch(
|
|
|
372
491
|
>×</button>
|
|
373
492
|
</header>
|
|
374
493
|
|
|
494
|
+
<!-- surucu:d1 — Details / Activity. Rendered only in the drive shell; the
|
|
495
|
+
classic panel keeps its single flat scroll of sections. -->
|
|
496
|
+
<div v-if="tabs" class="fe-inspector__tabs" role="tablist" :aria-label="t('inspector.title')">
|
|
497
|
+
<button
|
|
498
|
+
type="button"
|
|
499
|
+
class="fe-inspector__tab"
|
|
500
|
+
:class="{ 'is-active': tab === 'details' }"
|
|
501
|
+
role="tab"
|
|
502
|
+
:aria-selected="tab === 'details'"
|
|
503
|
+
data-testid="inspector-tab-details"
|
|
504
|
+
@click="tab = 'details'"
|
|
505
|
+
>{{ t('inspector.tab.details') }}</button>
|
|
506
|
+
<button
|
|
507
|
+
type="button"
|
|
508
|
+
class="fe-inspector__tab"
|
|
509
|
+
:class="{ 'is-active': tab === 'activity' }"
|
|
510
|
+
role="tab"
|
|
511
|
+
:aria-selected="tab === 'activity'"
|
|
512
|
+
data-testid="inspector-tab-activity"
|
|
513
|
+
@click="tab = 'activity'"
|
|
514
|
+
>{{ t('inspector.tab.activity') }}</button>
|
|
515
|
+
</div>
|
|
516
|
+
|
|
375
517
|
<div class="fe-inspector__scroll">
|
|
376
518
|
<!-- ══ Genel ══ -->
|
|
377
|
-
<section class="fe-inspector__section">
|
|
519
|
+
<section v-if="!tabs || tab === 'details'" class="fe-inspector__section">
|
|
378
520
|
<h3 class="fe-inspector__heading">{{ t('inspector.section.general') }}</h3>
|
|
379
521
|
|
|
380
522
|
<!-- Multi selection → summary -->
|
|
@@ -457,7 +599,7 @@ watch(
|
|
|
457
599
|
|
|
458
600
|
<!-- ══ Sürümler ══ -->
|
|
459
601
|
<section
|
|
460
|
-
v-if="versionsState === 'ok' || versionsState === 'error'"
|
|
602
|
+
v-if="(versionsState === 'ok' || versionsState === 'error') && (!tabs || tab === 'activity')"
|
|
461
603
|
class="fe-inspector__section"
|
|
462
604
|
>
|
|
463
605
|
<h3 class="fe-inspector__heading">{{ t('inspector.section.versions') }}</h3>
|
|
@@ -519,7 +661,21 @@ watch(
|
|
|
519
661
|
</section>
|
|
520
662
|
|
|
521
663
|
<!-- ══ İzinler ══ -->
|
|
522
|
-
|
|
664
|
+
<!-- ⚠ surucu:d1 — stands down when "People with access" is on screen. The
|
|
665
|
+
two say the same thing there (your own row is in the roster, with the
|
|
666
|
+
same level) and each carries its own button into the SAME modal —
|
|
667
|
+
two doors into one room, six lines apart. It stays for every other
|
|
668
|
+
case, which is exactly the case where People is hidden: RBAC off on
|
|
669
|
+
the storage, or the server refusing the grant list to a non-owner. -->
|
|
670
|
+
<section
|
|
671
|
+
v-if="
|
|
672
|
+
single &&
|
|
673
|
+
effectivePerm &&
|
|
674
|
+
(!tabs || tab === 'details') &&
|
|
675
|
+
!(tabs && peopleState === 'ok')
|
|
676
|
+
"
|
|
677
|
+
class="fe-inspector__section"
|
|
678
|
+
>
|
|
523
679
|
<h3 class="fe-inspector__heading">{{ t('inspector.section.permissions') }}</h3>
|
|
524
680
|
<div class="fe-inspector__permrow">
|
|
525
681
|
<span
|
|
@@ -535,10 +691,62 @@ watch(
|
|
|
535
691
|
</div>
|
|
536
692
|
</section>
|
|
537
693
|
|
|
694
|
+
<!-- ══ surucu:d1 — People with access ══
|
|
695
|
+
Real grants from `GET /api/files/permissions`; hidden entirely when
|
|
696
|
+
the caller is not an owner (403) or the storage has RBAC off, rather
|
|
697
|
+
than drawn empty. -->
|
|
698
|
+
<section
|
|
699
|
+
v-if="tabs && tab === 'details' && peopleState === 'ok'"
|
|
700
|
+
class="fe-inspector__section"
|
|
701
|
+
data-testid="inspector-people"
|
|
702
|
+
>
|
|
703
|
+
<h3 class="fe-inspector__heading">{{ t('inspector.people') }}</h3>
|
|
704
|
+
<p v-if="people.length === 0" class="fe-inspector__empty">
|
|
705
|
+
{{ t('inspector.people.empty') }}
|
|
706
|
+
</p>
|
|
707
|
+
<ul v-else class="fe-inspector__people">
|
|
708
|
+
<li v-for="g in people" :key="`${g.id}-${g.user_id}`" class="fe-inspector__person">
|
|
709
|
+
<span class="fe-inspector__avatar" aria-hidden="true">{{ personInitial(g) }}</span>
|
|
710
|
+
<span class="fe-inspector__person-main">
|
|
711
|
+
<span class="fe-inspector__person-name" :title="g.user_email">{{ personName(g) }}</span>
|
|
712
|
+
<span class="fe-inspector__person-sub">
|
|
713
|
+
{{ permLabel(g.level) }}<template v-if="g.inherited"> · {{ t('inspector.people.inherited') }}</template>
|
|
714
|
+
</span>
|
|
715
|
+
</span>
|
|
716
|
+
</li>
|
|
717
|
+
</ul>
|
|
718
|
+
<button
|
|
719
|
+
v-if="canManagePerms && single"
|
|
720
|
+
type="button"
|
|
721
|
+
class="fe-btn fe-btn--sm"
|
|
722
|
+
@click="emit('manage-permissions', single)"
|
|
723
|
+
>{{ t('inspector.people.manage') }}</button>
|
|
724
|
+
</section>
|
|
725
|
+
|
|
538
726
|
<!-- ══ Paylaşımlar ══ -->
|
|
539
|
-
<section
|
|
540
|
-
|
|
541
|
-
|
|
727
|
+
<section
|
|
728
|
+
v-if="sharesState === 'ok' && (!tabs || tab === 'details')"
|
|
729
|
+
class="fe-inspector__section"
|
|
730
|
+
data-testid="inspector-shares"
|
|
731
|
+
>
|
|
732
|
+
<h3 class="fe-inspector__heading">
|
|
733
|
+
{{ tabs ? t('inspector.link') : t('inspector.section.shares') }}
|
|
734
|
+
</h3>
|
|
735
|
+
<!-- surucu:d1 — the mockup's link row: what the state IS, and the one
|
|
736
|
+
button that changes it. Only when there is no link yet; the list
|
|
737
|
+
below is what an item that HAS links has always shown. -->
|
|
738
|
+
<div v-if="tabs && shares.length === 0" class="fe-inspector__linkrow">
|
|
739
|
+
<span class="fe-inspector__linkicon" aria-hidden="true">🔗</span>
|
|
740
|
+
<span class="fe-inspector__linknone">{{ t('inspector.link.none') }}</span>
|
|
741
|
+
<button
|
|
742
|
+
type="button"
|
|
743
|
+
class="fe-btn fe-btn--sm"
|
|
744
|
+
:disabled="shareBusy || !single"
|
|
745
|
+
data-testid="inspector-create-link"
|
|
746
|
+
@click="createLink"
|
|
747
|
+
>{{ t('inspector.link.create') }}</button>
|
|
748
|
+
</div>
|
|
749
|
+
<p v-else-if="shares.length === 0" class="fe-inspector__empty">
|
|
542
750
|
{{ t('inspector.shares.empty') }}
|
|
543
751
|
</p>
|
|
544
752
|
<ul v-else class="fe-inspector__shares">
|
|
@@ -561,7 +769,7 @@ watch(
|
|
|
561
769
|
|
|
562
770
|
<!-- ══ calisma:d3 — Yorumlar ══ -->
|
|
563
771
|
<section
|
|
564
|
-
v-if="commentsState === 'ok' || commentsState === 'error'"
|
|
772
|
+
v-if="(commentsState === 'ok' || commentsState === 'error') && (!tabs || tab === 'activity')"
|
|
565
773
|
class="fe-inspector__section"
|
|
566
774
|
>
|
|
567
775
|
<h3 class="fe-inspector__heading">
|
|
@@ -620,6 +828,21 @@ watch(
|
|
|
620
828
|
</template>
|
|
621
829
|
</section>
|
|
622
830
|
<!-- ══ /calisma:d3 ══ -->
|
|
831
|
+
|
|
832
|
+
<!-- surucu:d1 — the Activity tab with nothing behind it. It says which
|
|
833
|
+
two feeds fill it, because there is no third one to wait for: this
|
|
834
|
+
server keeps no per-file audit trail (target_id is empty for every
|
|
835
|
+
file action, and the log is admin-only). -->
|
|
836
|
+
<section
|
|
837
|
+
v-if="tabs && tab === 'activity' && !activityUsable"
|
|
838
|
+
class="fe-inspector__section"
|
|
839
|
+
data-testid="inspector-activity-empty"
|
|
840
|
+
>
|
|
841
|
+
<p class="fe-inspector__empty">
|
|
842
|
+
{{ single ? t('inspector.activity.empty') : t('inspector.activity.select') }}
|
|
843
|
+
</p>
|
|
844
|
+
<p v-if="single" class="fe-inspector__hint">{{ t('inspector.activity.hint') }}</p>
|
|
845
|
+
</section>
|
|
623
846
|
</div>
|
|
624
847
|
</aside>
|
|
625
848
|
</template>
|
|
@@ -33,6 +33,15 @@ const props = defineProps<{
|
|
|
33
33
|
/** Set of node IDs flagged starred by the user — render an inline
|
|
34
34
|
* filled star indicator and let the user toggle it from the row. */
|
|
35
35
|
starredIds?: Set<number>;
|
|
36
|
+
/**
|
|
37
|
+
* Offer the star affordance at all. Follows the Starred view: when the panel
|
|
38
|
+
* leaves that view out — a shared app token has no single person behind it,
|
|
39
|
+
* so "your starred files" is one list shown to strangers — offering to star
|
|
40
|
+
* something is offering to write into that same shared list. Owner's call,
|
|
41
|
+
* 2026-09-05: "yıldızlı yeri gözükmüyorsa o zaman yıldızla/yıldızı kaldır da
|
|
42
|
+
* gözükmemeli".
|
|
43
|
+
*/
|
|
44
|
+
starEnabled?: boolean;
|
|
36
45
|
/** Backend base URL + auth header builder forwarded to StarButton
|
|
37
46
|
* so it can POST /api/files/manager/star on click. Optional —
|
|
38
47
|
* embedders without auth wire-up pass nothing and the star column
|
|
@@ -390,12 +399,13 @@ const segments = computed<Segment[]>(() => {
|
|
|
390
399
|
>
|
|
391
400
|
<div class="fe-list__col fe-list__col--star" role="gridcell" @click.stop>
|
|
392
401
|
<StarButton
|
|
393
|
-
v-if="typeof n.id === 'number' && n.type === 'file'"
|
|
402
|
+
v-if="starEnabled !== false && typeof n.id === 'number' && n.type === 'file'"
|
|
394
403
|
:starred="!!starredIds?.has(n.id)"
|
|
395
404
|
:node-id="n.id"
|
|
396
405
|
:api-base="apiBase"
|
|
397
406
|
:auth-headers="authHeaders"
|
|
398
407
|
:auth-credentials="authCredentials"
|
|
408
|
+
:locale="locale"
|
|
399
409
|
compact
|
|
400
410
|
@change="(val: boolean) => emit('star-change', n, val)"
|
|
401
411
|
/>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* RecoveryKeyModal — shows a folder recovery key, exactly once (wiring:e2).
|
|
4
|
+
*
|
|
5
|
+
* This is the only moment the key exists anywhere outside the user's own
|
|
6
|
+
* records. filex does not store it, cannot recompute it, and will never be
|
|
7
|
+
* able to show it again — so the dialog is deliberately hard to dismiss by
|
|
8
|
+
* accident: no backdrop close, no cancel button, and an acknowledgement the
|
|
9
|
+
* user has to tick.
|
|
10
|
+
*
|
|
11
|
+
* ⚠ The key must not leave this component. No logging, no analytics, no
|
|
12
|
+
* emit carrying its value. Copy and download are both local.
|
|
13
|
+
*
|
|
14
|
+
* Crypto scheme: docs/E2E-ENCRYPTION.md and lib/e2ecrypto.ts.
|
|
15
|
+
*/
|
|
16
|
+
import { computed, ref, watch } from 'vue';
|
|
17
|
+
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
18
|
+
import { useLocale } from '../composables/useLocale';
|
|
19
|
+
import Modal from '../modals/Modal.vue';
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
open: boolean;
|
|
23
|
+
locale: LocaleCode;
|
|
24
|
+
/** The key itself. Shown, copied, downloaded — never emitted or stored. */
|
|
25
|
+
recoveryKey: string;
|
|
26
|
+
/** Folder name, for the downloaded file and the dialog copy. */
|
|
27
|
+
folderName?: string;
|
|
28
|
+
/** Set when this installation holds an escrow key for the folder too. */
|
|
29
|
+
escrowKid?: string | null;
|
|
30
|
+
/** 'created' = a new folder; 'upgraded' = an existing folder gained recovery. */
|
|
31
|
+
variant?: 'created' | 'upgraded';
|
|
32
|
+
}>();
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits<{ (e: 'close'): void }>();
|
|
35
|
+
|
|
36
|
+
const { t } = useLocale(() => props.locale);
|
|
37
|
+
const ack = ref(false);
|
|
38
|
+
const copied = ref(false);
|
|
39
|
+
|
|
40
|
+
watch(
|
|
41
|
+
() => props.open,
|
|
42
|
+
(v) => {
|
|
43
|
+
if (v) {
|
|
44
|
+
ack.value = false;
|
|
45
|
+
copied.value = false;
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const title = computed(() =>
|
|
51
|
+
props.variant === 'upgraded' ? t('e2e.recovery.title_upgraded') : t('e2e.recovery.title'),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
async function copy() {
|
|
55
|
+
try {
|
|
56
|
+
await navigator.clipboard.writeText(props.recoveryKey);
|
|
57
|
+
copied.value = true;
|
|
58
|
+
window.setTimeout(() => (copied.value = false), 2000);
|
|
59
|
+
} catch {
|
|
60
|
+
// Clipboard permission denied (or a non-secure origin). The key is on
|
|
61
|
+
// screen and selectable, so this is a convenience, not the only route.
|
|
62
|
+
copied.value = false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function download() {
|
|
67
|
+
const name = props.folderName || 'folder';
|
|
68
|
+
const body =
|
|
69
|
+
`filex recovery key\n` +
|
|
70
|
+
`folder: ${name}\n` +
|
|
71
|
+
`\n${props.recoveryKey}\n\n` +
|
|
72
|
+
`This key opens the encrypted folder without its password.\n` +
|
|
73
|
+
`Anyone holding it can read the folder. Store it like a password.\n` +
|
|
74
|
+
`filex does not keep a copy and cannot show it again.\n`;
|
|
75
|
+
const url = URL.createObjectURL(new Blob([body], { type: 'text/plain' }));
|
|
76
|
+
const a = document.createElement('a');
|
|
77
|
+
a.href = url;
|
|
78
|
+
a.download = `filex-recovery-${name}.txt`;
|
|
79
|
+
document.body.appendChild(a);
|
|
80
|
+
a.click();
|
|
81
|
+
a.remove();
|
|
82
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function done() {
|
|
86
|
+
if (!ack.value) return;
|
|
87
|
+
emit('close');
|
|
88
|
+
}
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<template>
|
|
92
|
+
<!-- No backdrop close, and ESC only lands once the box is ticked: the key
|
|
93
|
+
cannot be shown again, so an accidental dismissal is data loss. -->
|
|
94
|
+
<Modal :open="open" :title="title" size="sm" :close-on-backdrop="false" @close="done">
|
|
95
|
+
<div class="fe-e2e-rk">
|
|
96
|
+
<p class="fe-e2e-rk__lead">
|
|
97
|
+
{{ variant === 'upgraded' ? t('e2e.recovery.lead_upgraded') : t('e2e.recovery.lead') }}
|
|
98
|
+
</p>
|
|
99
|
+
|
|
100
|
+
<output class="fe-e2e-rk__key" aria-label="recovery key">{{ recoveryKey }}</output>
|
|
101
|
+
|
|
102
|
+
<div class="fe-e2e-rk__actions">
|
|
103
|
+
<button type="button" class="fe-btn" @click="copy">
|
|
104
|
+
{{ copied ? t('e2e.recovery.copied') : t('e2e.recovery.copy') }}
|
|
105
|
+
</button>
|
|
106
|
+
<button type="button" class="fe-btn" @click="download">
|
|
107
|
+
{{ t('e2e.recovery.download') }}
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div class="fe-e2e-warn" role="alert">
|
|
112
|
+
<strong>{{ t('e2e.recovery.warn_title') }}</strong>
|
|
113
|
+
<p>{{ t('e2e.recovery.warn_body') }}</p>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div v-if="escrowKid" class="fe-e2e-rk__escrow" role="note">
|
|
117
|
+
<strong>{{ t('e2e.recovery.escrow_title') }}</strong>
|
|
118
|
+
<p>{{ t('e2e.recovery.escrow_body') }}</p>
|
|
119
|
+
<p class="fe-e2e-rk__kid">{{ t('e2e.recovery.escrow_kid') }}: <code>{{ escrowKid }}</code></p>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<label class="fe-e2e-ack">
|
|
123
|
+
<input v-model="ack" type="checkbox" />
|
|
124
|
+
<span>{{ t('e2e.recovery.ack') }}</span>
|
|
125
|
+
</label>
|
|
126
|
+
</div>
|
|
127
|
+
<template #actions>
|
|
128
|
+
<button type="button" class="fe-btn fe-btn--primary" :disabled="!ack" @click="done">
|
|
129
|
+
{{ t('e2e.recovery.done') }}
|
|
130
|
+
</button>
|
|
131
|
+
</template>
|
|
132
|
+
</Modal>
|
|
133
|
+
</template>
|
|
@@ -64,6 +64,13 @@ const props = defineProps<{
|
|
|
64
64
|
/** ui-fix — mirror the main panel's virtual `.trash` row at storage root
|
|
65
65
|
* so both split panes list identical rows (no row-offset). Defaults on. */
|
|
66
66
|
trashVisible?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* The navigation panel is already offering Trash, so neither pane draws the
|
|
69
|
+
* virtual row. Passed down rather than worked out here: the panel belongs to
|
|
70
|
+
* the host explorer, and a pane that guessed at it is how the two halves of a
|
|
71
|
+
* split end up listing different rows.
|
|
72
|
+
*/
|
|
73
|
+
navOffersTrash?: boolean;
|
|
67
74
|
}>();
|
|
68
75
|
|
|
69
76
|
const emit = defineEmits<{
|
|
@@ -114,7 +121,14 @@ async function loadPane(target?: string): Promise<void> {
|
|
|
114
121
|
// Same internal-entry filter + virtual `.trash` row as the main panel
|
|
115
122
|
// (shared helpers → both split panes list identical rows, no offset).
|
|
116
123
|
files.value = filterListing(resp.files);
|
|
117
|
-
if (
|
|
124
|
+
if (
|
|
125
|
+
injectTrashRow(files.value, resp.adapter, resp.dirname, props.trashVisible !== false, {
|
|
126
|
+
// ⚠ The panel is a sibling of BOTH panes. If it is offering Trash, this
|
|
127
|
+
// pane must drop the row too — otherwise the duplication is not removed,
|
|
128
|
+
// it just moves to the right-hand half of a split.
|
|
129
|
+
navOffersTrash: props.navOffersTrash,
|
|
130
|
+
})
|
|
131
|
+
) {
|
|
118
132
|
void hydrateTrashRow(files.value, resp.adapter, props.api);
|
|
119
133
|
}
|
|
120
134
|
path.value = props.toUser(resp.dirname);
|