@brftech/filex-core 0.30.0 → 0.31.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 +11 -0
- package/dist/filex-core.js +9661 -8550
- 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 +368 -64
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +602 -17
- package/src/components/Breadcrumb.vue +4 -10
- package/src/components/E2eRecoveryUnlockModal.vue +168 -0
- package/src/components/EncryptedFolderModal.vue +10 -0
- package/src/components/GalleryView.vue +37 -0
- package/src/components/GridView.vue +39 -0
- package/src/components/ListView.vue +1 -0
- package/src/components/RecoveryKeyModal.vue +133 -0
- package/src/components/SideNav.vue +168 -5
- package/src/components/StarButton.vue +27 -15
- package/src/composables/useFileApi.ts +38 -0
- package/src/composables/useKeyboardShortcuts.ts +7 -0
- package/src/index.ts +27 -1
- package/src/lib/e2ecrypto.ts +528 -70
- package/src/lib/listing.ts +79 -0
- package/src/lib/star.ts +42 -0
- package/src/lib/tags.ts +105 -0
- package/src/locales/en.ts +71 -2
- package/src/locales/tr.ts +71 -2
- package/src/styles/base.css +252 -0
- package/src/types/ExplorerConfig.ts +36 -0
- package/src/types/FileNode.ts +23 -0
package/src/FileExplorer.vue
CHANGED
|
@@ -86,16 +86,27 @@ import { useTabs, type TabState } from './composables/useTabs';
|
|
|
86
86
|
/* /wiring:d1 */
|
|
87
87
|
/* wiring:e2 — uçtan uca şifreli klasörler (docs/E2E-ENCRYPTION.md) */
|
|
88
88
|
import EncryptedFolderModal from './components/EncryptedFolderModal.vue';
|
|
89
|
+
import RecoveryKeyModal from './components/RecoveryKeyModal.vue';
|
|
90
|
+
import E2eRecoveryUnlockModal from './components/E2eRecoveryUnlockModal.vue';
|
|
89
91
|
import {
|
|
90
92
|
createKeyRing,
|
|
91
|
-
|
|
93
|
+
createEncryptedFolder,
|
|
94
|
+
upgradeMarkerV1,
|
|
92
95
|
parseMarker,
|
|
93
|
-
|
|
96
|
+
unlockWithPassword,
|
|
97
|
+
unlockWithRecoveryKey,
|
|
98
|
+
unlockWithEscrowKey,
|
|
99
|
+
importEscrowPrivateKey,
|
|
100
|
+
markerHasRecovery,
|
|
101
|
+
markerHasEscrow,
|
|
102
|
+
bytesToB64,
|
|
103
|
+
b64ToBytes,
|
|
94
104
|
encryptFile,
|
|
95
105
|
decryptFile,
|
|
96
106
|
hasMagic,
|
|
97
107
|
E2E_MARKER_NAME,
|
|
98
108
|
E2E_MAX_FILE_BYTES,
|
|
109
|
+
type E2eMarker,
|
|
99
110
|
} from './lib/e2ecrypto';
|
|
100
111
|
/* /wiring:e2 */
|
|
101
112
|
|
|
@@ -104,11 +115,17 @@ import {
|
|
|
104
115
|
panes or split view shows mismatched rows). */
|
|
105
116
|
import {
|
|
106
117
|
filterListing,
|
|
118
|
+
virtualSegmentLabel,
|
|
119
|
+
makeTagSegment,
|
|
120
|
+
tagOfPath,
|
|
121
|
+
VIRTUAL_SEGMENTS,
|
|
107
122
|
showHiddenFiles,
|
|
108
123
|
setShowHiddenFiles,
|
|
109
124
|
injectTrashRow,
|
|
110
125
|
hydrateTrashRow as hydrateTrashRowShared,
|
|
111
126
|
} from './lib/listing';
|
|
127
|
+
import { setNodeStarred } from './lib/star';
|
|
128
|
+
import { fetchAllTags, fetchTaggedRows, invalidateTagCache } from './lib/tags';
|
|
112
129
|
import { resolveTransfer, type TransferIntent } from './lib/transfer';
|
|
113
130
|
import {
|
|
114
131
|
activeNativeDrag,
|
|
@@ -309,17 +326,38 @@ const trashActive = computed(() => trashMode.value);
|
|
|
309
326
|
* pattern is trashMode's, generalised — including the part that matters most,
|
|
310
327
|
* that load() clears the mode, or the view sticks and every later navigation
|
|
311
328
|
* renders under the wrong heading. */
|
|
312
|
-
type NavView = '' | 'recent' | 'starred' | 'shared' | 'trash';
|
|
329
|
+
type NavView = '' | 'recent' | 'starred' | 'shared' | 'trash' | 'tag';
|
|
313
330
|
const navView = ref<NavView>('');
|
|
314
331
|
/** Where the view was entered from, so "up" goes back there. */
|
|
315
332
|
const navViewOrigin = ref<string>('');
|
|
316
|
-
/**
|
|
317
|
-
const
|
|
333
|
+
/** The tag being browsed while navView === 'tag' ('' otherwise). */
|
|
334
|
+
const navTag = ref<string>('');
|
|
335
|
+
/** Sentinel parked in `dirname` so the breadcrumb can label the view. The tag
|
|
336
|
+
* view's sentinel is built per tag (`makeTagSegment`) — see lib/listing. */
|
|
337
|
+
const NAV_VIEW_DIRNAME: Record<Exclude<NavView, '' | 'trash' | 'tag'>, string> = {
|
|
318
338
|
recent: '.recent',
|
|
319
339
|
starred: '.starred',
|
|
320
340
|
shared: '.shared',
|
|
321
341
|
};
|
|
322
342
|
|
|
343
|
+
/**
|
|
344
|
+
* A path that is a virtual view rather than a folder. Used by load() so a
|
|
345
|
+
* sentinel reaching it — a restored tab, a pasted `#.tag~invoices`, a reload,
|
|
346
|
+
* the breadcrumb's own crumb — opens the VIEW instead of asking the backend
|
|
347
|
+
* for a folder called `.starred` and landing on "not found". (That was already
|
|
348
|
+
* true of the four shipped views; the tag view would have inherited it.)
|
|
349
|
+
*/
|
|
350
|
+
function virtualViewOf(path: string): { kind: Exclude<NavView, ''>; tag: string } | null {
|
|
351
|
+
const clean = String(path ?? '').replace(/^\/+|\/+$/g, '');
|
|
352
|
+
if (!clean) return null;
|
|
353
|
+
const tag = tagOfPath(clean);
|
|
354
|
+
if (tag) return { kind: 'tag', tag };
|
|
355
|
+
const key = VIRTUAL_SEGMENTS[clean];
|
|
356
|
+
if (!key) return null;
|
|
357
|
+
const kind = clean.slice(1) as Exclude<NavView, '' | 'tag'>;
|
|
358
|
+
return { kind, tag: '' };
|
|
359
|
+
}
|
|
360
|
+
|
|
323
361
|
// When the caller can see exactly ONE storage, the multi-storage root is a
|
|
324
362
|
// one-row list that carries no information — the user clicks through it every
|
|
325
363
|
// single time. Treat that storage as the floor instead: open it directly and
|
|
@@ -447,6 +485,72 @@ function onStarChange(n: FileNode, value: boolean) {
|
|
|
447
485
|
starredIds.value = next;
|
|
448
486
|
}
|
|
449
487
|
|
|
488
|
+
/* === yildiz:s1 — starring as an ACTION ================================
|
|
489
|
+
* The star shipped as an indicator in ONE view: `StarButton` was rendered by
|
|
490
|
+
* ListView and nowhere else, so a user in grid view (the mode the navigation
|
|
491
|
+
* panel's own screenshots show) had a Starred view with no way to fill it.
|
|
492
|
+
* It is a verb, like tagging — so it is a menu entry beside Tags, a chip on
|
|
493
|
+
* every card, and a key.
|
|
494
|
+
*
|
|
495
|
+
* ⚠ ONE implementation of the request: `lib/star.ts`. StarButton calls it,
|
|
496
|
+
* this calls it. A menu cannot render a component, but it must not grow its
|
|
497
|
+
* own fetch either — that is the second path that drifts.
|
|
498
|
+
*/
|
|
499
|
+
/** Which of `targets` can carry a star: files the server knows by id. */
|
|
500
|
+
function starableNodes(targets: FileNode[]): FileNode[] {
|
|
501
|
+
return targets.filter((n) => typeof n.id === 'number' && n.type === 'file');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** True when EVERY starable target is starred — i.e. the action reads
|
|
505
|
+
* "Unstar". A mixed selection reads "Star" and stars the rest, which is the
|
|
506
|
+
* behaviour that needs no explanation. */
|
|
507
|
+
function selectionAllStarred(targets: FileNode[]): boolean {
|
|
508
|
+
const list = starableNodes(targets);
|
|
509
|
+
return list.length > 0 && list.every((n) => starredIds.value.has(n.id as number));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Toggle the star on a selection. Optimistic like the button, and rolled back
|
|
514
|
+
* per node on failure — a partial failure must not leave the set lying about
|
|
515
|
+
* what the server holds.
|
|
516
|
+
*/
|
|
517
|
+
async function toggleStar(targets: FileNode[]) {
|
|
518
|
+
const list = starableNodes(targets);
|
|
519
|
+
if (list.length === 0) return;
|
|
520
|
+
const next = !selectionAllStarred(list);
|
|
521
|
+
const opts = {
|
|
522
|
+
apiBase: props.config.apiBase ?? '',
|
|
523
|
+
authHeaders: () => buildAuthHeaders(),
|
|
524
|
+
authCredentials: api.credentialsMode(),
|
|
525
|
+
};
|
|
526
|
+
const set = new Set(starredIds.value);
|
|
527
|
+
for (const n of list) {
|
|
528
|
+
if (next) set.add(n.id as number);
|
|
529
|
+
else set.delete(n.id as number);
|
|
530
|
+
}
|
|
531
|
+
starredIds.value = set;
|
|
532
|
+
let failed = 0;
|
|
533
|
+
await Promise.all(
|
|
534
|
+
list.map(async (n) => {
|
|
535
|
+
try {
|
|
536
|
+
await setNodeStarred(n.id as number, next, opts);
|
|
537
|
+
} catch {
|
|
538
|
+
failed += 1;
|
|
539
|
+
const rollback = new Set(starredIds.value);
|
|
540
|
+
if (next) rollback.delete(n.id as number);
|
|
541
|
+
else rollback.add(n.id as number);
|
|
542
|
+
starredIds.value = rollback;
|
|
543
|
+
}
|
|
544
|
+
}),
|
|
545
|
+
);
|
|
546
|
+
if (failed > 0) flashToast(t('star.failed'));
|
|
547
|
+
// Starring is what fills the Starred view; if that IS the view on screen,
|
|
548
|
+
// an unstar has to remove the row instead of leaving a listing that
|
|
549
|
+
// disagrees with its own heading.
|
|
550
|
+
if (navView.value === 'starred') await loadNavView('starred');
|
|
551
|
+
}
|
|
552
|
+
/* === /yildiz:s1 === */
|
|
553
|
+
|
|
450
554
|
async function markRecent(n: FileNode) {
|
|
451
555
|
if (typeof n.id !== 'number') return;
|
|
452
556
|
try {
|
|
@@ -469,6 +573,17 @@ function openTagPickerFor(n: FileNode) {
|
|
|
469
573
|
showTagPicker.value = true;
|
|
470
574
|
}
|
|
471
575
|
|
|
576
|
+
/* etiket:t1 — the user just changed a node's tags, so the cached "every tag
|
|
577
|
+
* that exists" list is wrong RIGHT NOW, which is the only staleness anybody
|
|
578
|
+
* notices. Drop it and re-ask; if a tag view is on screen, refresh it too —
|
|
579
|
+
* removing a file's tag has to remove it from the listing that is named after
|
|
580
|
+
* that tag. */
|
|
581
|
+
function onNodeTagsChanged() {
|
|
582
|
+
invalidateTagCache();
|
|
583
|
+
void loadNavTags(true);
|
|
584
|
+
if (navView.value === 'tag' && navTag.value) void loadTagView(navTag.value);
|
|
585
|
+
}
|
|
586
|
+
|
|
472
587
|
function onRecentOpen(entry: { id: number; storage_id?: number; path: string; name: string }) {
|
|
473
588
|
// RecentlyOpened emits the bare row — synthesize a FileNode shaped
|
|
474
589
|
// enough for openNode to route into the editor / preview.
|
|
@@ -658,6 +773,32 @@ const sharedStorageNames = ref<string[]>([]);
|
|
|
658
773
|
* storage form), and /api/tokens caps every scope against the caller's own role.
|
|
659
774
|
*/
|
|
660
775
|
const connectionsEnabled = computed(() => props.config.connections ?? !simpleUi.value);
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Is this caller an integration rather than a person (backend migration 00030)?
|
|
779
|
+
*
|
|
780
|
+
* A filex API token authenticates AS its owner, so from here a shared embed
|
|
781
|
+
* token and somebody's own token are indistinguishable — only the server knows
|
|
782
|
+
* which kind it is, and it says so in `capabilities.caller_kind`. A host that
|
|
783
|
+
* already knows can say so with `config.callerKind`, which wins — purely to
|
|
784
|
+
* spare the flash of a Starred row that appears and then disappears when
|
|
785
|
+
* capabilities land.
|
|
786
|
+
*
|
|
787
|
+
* ⚠ Defaults to "person" in every unknown state — no config, capabilities not
|
|
788
|
+
* back yet, or a server too old to answer. The cost of guessing wrong that way
|
|
789
|
+
* is one row too many for a moment; guessing the other way would hide Recent
|
|
790
|
+
* and API keys from every ordinary user of every older server.
|
|
791
|
+
*/
|
|
792
|
+
const callerIsApp = computed(
|
|
793
|
+
() => (props.config.callerKind ?? capabilitiesData.value?.caller_kind) === 'app',
|
|
794
|
+
);
|
|
795
|
+
/**
|
|
796
|
+
* The identity-bearing surfaces: API keys, Recent, Starred, Shared with me.
|
|
797
|
+
* ⚠ Suppression is per token KIND, never per role — a viewer is still a person
|
|
798
|
+
* with their own recents. And it is not the whole panel: Upload, the storages,
|
|
799
|
+
* Trash and "How to connect" stay useful inside an embed.
|
|
800
|
+
*/
|
|
801
|
+
const identitySurfaces = computed(() => !callerIsApp.value);
|
|
661
802
|
const showConnections = ref(false);
|
|
662
803
|
const showTokens = ref(false);
|
|
663
804
|
function openConnections() {
|
|
@@ -665,6 +806,10 @@ function openConnections() {
|
|
|
665
806
|
showConnections.value = true;
|
|
666
807
|
}
|
|
667
808
|
function openTokens() {
|
|
809
|
+
// Belt and braces: the panel entry is gone for an app token, but a host may
|
|
810
|
+
// also open this overlay from its own chrome, and /api/tokens would answer
|
|
811
|
+
// that caller with a 403 it has nowhere to show.
|
|
812
|
+
if (callerIsApp.value) return;
|
|
668
813
|
showConnections.value = false;
|
|
669
814
|
showTokens.value = true;
|
|
670
815
|
}
|
|
@@ -775,17 +920,27 @@ async function fetchNavRows(kind: 'recent' | 'starred' | 'shared'): Promise<File
|
|
|
775
920
|
/** Open one of the panel views in the main pane. */
|
|
776
921
|
async function loadNavView(kind: Exclude<NavView, ''>) {
|
|
777
922
|
closeNavDrawer();
|
|
923
|
+
if (kind === 'tag') {
|
|
924
|
+
// The tag view needs a name; the panel calls loadTagView directly.
|
|
925
|
+
if (navTag.value) await loadTagView(navTag.value);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
778
928
|
if (kind === 'trash') {
|
|
779
929
|
await loadTrash();
|
|
780
930
|
// ⚠ After loadTrash, not before: loadTrash goes through load()-adjacent
|
|
781
931
|
// state and the mode has to be the last word, or the panel row for Trash
|
|
782
932
|
// never lights up.
|
|
783
933
|
navView.value = 'trash';
|
|
934
|
+
navTag.value = '';
|
|
784
935
|
return;
|
|
785
936
|
}
|
|
786
937
|
loading.value = true;
|
|
787
|
-
|
|
938
|
+
// ⚠ Only when coming from a real folder. Stepping Starred → Recent used to
|
|
939
|
+
// record `.starred` as the origin, so "up" out of Recent landed in Starred
|
|
940
|
+
// and the user had to press it twice to get back to their files.
|
|
941
|
+
if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
|
|
788
942
|
navView.value = kind;
|
|
943
|
+
navTag.value = '';
|
|
789
944
|
trashMode.value = false;
|
|
790
945
|
e2eRoot.value = '';
|
|
791
946
|
selection.clear();
|
|
@@ -807,6 +962,78 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
|
|
|
807
962
|
}
|
|
808
963
|
}
|
|
809
964
|
|
|
965
|
+
/* === etiket:t1 — the tag view ==========================================
|
|
966
|
+
* "Tagged files should show up inside the tag." A tag is not a folder: its
|
|
967
|
+
* files live all over the tree and in every storage, so this is the same
|
|
968
|
+
* shape as Starred — a per-user endpoint answering with node rows, each
|
|
969
|
+
* carrying its own qualified path, so opening one navigates normally.
|
|
970
|
+
*
|
|
971
|
+
* The sentinel is `.tag~<name>` (lib/listing). Every surface that renders a
|
|
972
|
+
* path segment — tab strip, breadcrumb, inspector heading, the address-bar
|
|
973
|
+
* hash — goes through `virtualSegmentLabel`, so none of them can print the
|
|
974
|
+
* sentinel the way the strip once printed `.shared`.
|
|
975
|
+
*/
|
|
976
|
+
async function loadTagView(tag: string) {
|
|
977
|
+
closeNavDrawer();
|
|
978
|
+
const name = String(tag ?? '').trim();
|
|
979
|
+
if (!name) return;
|
|
980
|
+
loading.value = true;
|
|
981
|
+
if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
|
|
982
|
+
navView.value = 'tag';
|
|
983
|
+
navTag.value = name;
|
|
984
|
+
trashMode.value = false;
|
|
985
|
+
e2eRoot.value = '';
|
|
986
|
+
selection.clear();
|
|
987
|
+
try {
|
|
988
|
+
const rows = await fetchTaggedRows(name, {
|
|
989
|
+
apiBase: props.config.apiBase ?? '',
|
|
990
|
+
authHeaders: () => buildAuthHeaders(),
|
|
991
|
+
authCredentials: api.credentialsMode(),
|
|
992
|
+
});
|
|
993
|
+
files.value = rows.map(nodeRowToFileNode).filter((n): n is FileNode => n !== null);
|
|
994
|
+
const seg = makeTagSegment(name);
|
|
995
|
+
dirname.value = seg;
|
|
996
|
+
currentPath.value = seg;
|
|
997
|
+
// Spans every storage, like Starred/Recent/Shared — so no storage crumb.
|
|
998
|
+
adapter.value = '';
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1001
|
+
files.value = [];
|
|
1002
|
+
emit('error', { message: msg, context: { op: `nav-view:tag:${name}` } });
|
|
1003
|
+
flashToast(msg);
|
|
1004
|
+
} finally {
|
|
1005
|
+
loading.value = false;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* The tags that exist, for the panel's Tags section.
|
|
1011
|
+
*
|
|
1012
|
+
* ⚠ WHEN this loads was a deliberate decision, not a default: `tags/all` is a
|
|
1013
|
+
* distinct-scan and the panel renders in every mounted explorer (a page can
|
|
1014
|
+
* hold several). It is therefore NOT fetched during mount — it is asked for
|
|
1015
|
+
* once the first listing is on screen, through a module-level cache that
|
|
1016
|
+
* dedupes concurrent callers and reuses the answer for a minute
|
|
1017
|
+
* (lib/tags.ts). N explorers on a page cost ONE query; a navigation costs
|
|
1018
|
+
* none. The cache is dropped the instant the user edits tags, which is the
|
|
1019
|
+
* only staleness anybody can notice.
|
|
1020
|
+
*/
|
|
1021
|
+
const navTags = ref<string[]>([]);
|
|
1022
|
+
const navTagsLoaded = ref(false);
|
|
1023
|
+
|
|
1024
|
+
async function loadNavTags(force = false) {
|
|
1025
|
+
if (!navVisible.value) return; // no panel → nobody can see the list
|
|
1026
|
+
navTags.value = await fetchAllTags({
|
|
1027
|
+
apiBase: props.config.apiBase ?? '',
|
|
1028
|
+
authHeaders: () => buildAuthHeaders(),
|
|
1029
|
+
authCredentials: api.credentialsMode(),
|
|
1030
|
+
force,
|
|
1031
|
+
});
|
|
1032
|
+
navTagsLoaded.value = true;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/* === /etiket:t1 === */
|
|
1036
|
+
|
|
810
1037
|
/** Panel to a storage root. */
|
|
811
1038
|
function openNavStorage(name: string) {
|
|
812
1039
|
closeNavDrawer();
|
|
@@ -839,7 +1066,11 @@ const inspectorDirLabel = computed(() => {
|
|
|
839
1066
|
if (trashMode.value) return t('node.trash');
|
|
840
1067
|
const p = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
|
|
841
1068
|
if (!p) return adapter.value || t('breadcrumb.root');
|
|
842
|
-
|
|
1069
|
+
const seg = p.split('/').pop() || p;
|
|
1070
|
+
/* etiket:t1 — a THIRD surface that renders a path segment, and it had the
|
|
1071
|
+
same hole the tab strip did: in a virtual view the details panel headed
|
|
1072
|
+
itself ".starred". Same shared resolver, so it cannot drift again. */
|
|
1073
|
+
return virtualSegmentLabel(seg, t) || seg;
|
|
843
1074
|
});
|
|
844
1075
|
function onInspectorManage(n: FileNode) {
|
|
845
1076
|
permTarget.value = n;
|
|
@@ -1024,6 +1255,48 @@ function toggleHiddenFiles() {
|
|
|
1024
1255
|
}
|
|
1025
1256
|
|
|
1026
1257
|
async function load(path?: string) {
|
|
1258
|
+
/* === etiket:t1 — a sentinel is a VIEW, not a folder ===================
|
|
1259
|
+
* A restored tab, a reload on `#.trash` / `#.starred` / `#.tag~invoices`,
|
|
1260
|
+
* or the breadcrumb crumb for the view you are standing in all arrive here
|
|
1261
|
+
* as a plain path. Without this they went to the backend as a FOLDER NAME
|
|
1262
|
+
* and came back 404, so a view that exists and is merely empty greeted the
|
|
1263
|
+
* user with "Folder not found — this folder does not exist, was moved, or
|
|
1264
|
+
* you do not have access to it" (measured on `#.trash` and `#.starred`,
|
|
1265
|
+
* v0.30.1). The trash is not missing; it is empty, and it has a state that
|
|
1266
|
+
* says so.
|
|
1267
|
+
*
|
|
1268
|
+
* ⚠ Through `virtualViewOf` → the ONE map in lib/listing.ts, never a second
|
|
1269
|
+
* list of names here: two copies of that mapping are what printed `.shared`
|
|
1270
|
+
* in the tab strip two days ago, and the tag view adds a dynamic third kind.
|
|
1271
|
+
*
|
|
1272
|
+
* ⚠ Only a sentinel this build KNOWS is intercepted. Anything else keeps
|
|
1273
|
+
* going — a user may genuinely own a folder called `.config`, and with
|
|
1274
|
+
* hidden files shown they can open it.
|
|
1275
|
+
*
|
|
1276
|
+
* ⚠ And only when the view is actually REACHABLE here. Under `rootPath` the
|
|
1277
|
+
* panel is off on purpose (the views span storages and would list files
|
|
1278
|
+
* outside the folder the embed was confined to), so a stale hash from
|
|
1279
|
+
* another deployment must not smuggle them in: it falls back to the root —
|
|
1280
|
+
* which the floor clamp below then turns into the confined folder.
|
|
1281
|
+
*
|
|
1282
|
+
* ⚠ No recursion: neither loader calls load(), and the fallback passes '',
|
|
1283
|
+
* which is not a sentinel.
|
|
1284
|
+
*/
|
|
1285
|
+
const asView = virtualViewOf(path ?? currentPath.value ?? '');
|
|
1286
|
+
if (asView) {
|
|
1287
|
+
const reachable =
|
|
1288
|
+
asView.kind === 'trash' ? props.config.trashVisible !== false : sideNavEnabled.value;
|
|
1289
|
+
if (!reachable) {
|
|
1290
|
+
// Clear it explicitly: if the fallback lands on the path we are already
|
|
1291
|
+
// on, watch(currentPath) never fires and the dead hash would survive to
|
|
1292
|
+
// the next reload (the same trap leaveNotFound documents).
|
|
1293
|
+
writePersistedPath('');
|
|
1294
|
+
return await load('');
|
|
1295
|
+
}
|
|
1296
|
+
if (asView.kind === 'tag') await loadTagView(asView.tag);
|
|
1297
|
+
else await loadNavView(asView.kind);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1027
1300
|
loading.value = true;
|
|
1028
1301
|
// Any normal navigation exits trash mode (the trash view is entered only
|
|
1029
1302
|
// by opening the virtual `.trash` row, which calls loadTrash()).
|
|
@@ -1032,6 +1305,7 @@ async function load(path?: string) {
|
|
|
1032
1305
|
this the mode sticks and the breadcrumb keeps saying "Starred" over a
|
|
1033
1306
|
folder listing. */
|
|
1034
1307
|
navView.value = '';
|
|
1308
|
+
navTag.value = '';
|
|
1035
1309
|
let requested = path ?? currentPath.value ?? '';
|
|
1036
1310
|
try {
|
|
1037
1311
|
notFoundPath.value = '';
|
|
@@ -1375,6 +1649,25 @@ onMounted(async () => {
|
|
|
1375
1649
|
/* gezinti:g1 — which storages are grant-only, so the panel can mark them
|
|
1376
1650
|
before anybody opens the shared view. */
|
|
1377
1651
|
void loadSharedStorages();
|
|
1652
|
+
/* etiket:t1 — the panel's tag list. AFTER the first listing has been
|
|
1653
|
+
awaited above, never racing it: `tags/all` is a distinct-scan and the
|
|
1654
|
+
folder the user asked for is the only thing on the critical path. The
|
|
1655
|
+
module-level cache in lib/tags.ts means several explorers on one page
|
|
1656
|
+
still cost a single query. */
|
|
1657
|
+
void loadNavTags();
|
|
1658
|
+
/* ⚠ The panel is not always on screen at mount. Below 560px it is a DRAWER
|
|
1659
|
+
that starts closed, so `navVisible` is false and the call above returns
|
|
1660
|
+
without asking for anything — measured at 390px: the drawer opened with
|
|
1661
|
+
no Tags section at all. Ask again the first time the panel appears.
|
|
1662
|
+
⚠ Registered HERE and not beside loadNavTags: `watch` evaluates its
|
|
1663
|
+
source immediately, `navVisible` reads `isNarrow`, and `isNarrow` is
|
|
1664
|
+
declared further down the file — so a watcher created at setup time threw
|
|
1665
|
+
"Cannot access 'isNarrow' before initialization" and took the whole
|
|
1666
|
+
explorer down with it (measured: blank pane, two TDZ errors in the
|
|
1667
|
+
console). In onMounted every ref exists. */
|
|
1668
|
+
watch(navVisible, (visible) => {
|
|
1669
|
+
if (visible && !navTagsLoaded.value) void loadNavTags();
|
|
1670
|
+
});
|
|
1378
1671
|
if (hashPersistEnabled()) {
|
|
1379
1672
|
window.addEventListener('hashchange', onHashChange);
|
|
1380
1673
|
}
|
|
@@ -1521,6 +1814,7 @@ useKeyboardShortcuts(rootEl, {
|
|
|
1521
1814
|
/* /cila:c wiring */
|
|
1522
1815
|
onQuickLook: () => quickLookToggle() /* wiring:c2 */,
|
|
1523
1816
|
onToggleHidden: () => toggleHiddenFiles(),
|
|
1817
|
+
onStar: () => void toggleStar(selection.nodes.value) /* yildiz:s1 */,
|
|
1524
1818
|
onToggleInspector: () => toggleInspector() /* koru:k1 */,
|
|
1525
1819
|
/* wiring:d1 — sekme aksiyonları (registry: tab-new/close/next/prev) */
|
|
1526
1820
|
onTabNew: () => newTabHere(),
|
|
@@ -2032,6 +2326,9 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
2032
2326
|
const isFile = single && sel[0]?.type === 'file';
|
|
2033
2327
|
const tagsLabel = locale.value === 'en' ? 'Tags…' : 'Etiketler…';
|
|
2034
2328
|
const singleHasId = single && typeof sel[0]?.id === 'number';
|
|
2329
|
+
/* yildiz:s1 */
|
|
2330
|
+
const canStar = starableNodes(sel).length > 0;
|
|
2331
|
+
const allStarred = selectionAllStarred(sel);
|
|
2035
2332
|
const copyIdLabel = locale.value === 'en' ? 'Copy node id' : "Node id'yi kopyala";
|
|
2036
2333
|
// RBAC: gate mutating actions when the caller lacks edit on the target. The
|
|
2037
2334
|
// "İzinler" (permissions) action shows only for owners on RBAC-on storages.
|
|
@@ -2057,7 +2354,17 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
2057
2354
|
{ key: 'cut', label: t('ctx.cut'), icon: '✂', hidden: !any || !w, disabled: !any },
|
|
2058
2355
|
{ key: 'copy', label: t('ctx.copy'), icon: '❐', hidden: !any, disabled: !any },
|
|
2059
2356
|
{ key: 'paste', label: t('ctx.paste'), icon: '📋', hidden: !w, disabled: !clipboard.value.mode },
|
|
2060
|
-
{ divider: true, key: 'sep-meta', label: '', hidden: !singleHasId },
|
|
2357
|
+
{ divider: true, key: 'sep-meta', label: '', hidden: !singleHasId && !canStar },
|
|
2358
|
+
/* yildiz:s1 — "star must be an action, like a tag" (owner, v0.30.0).
|
|
2359
|
+
Beside Tags on purpose: they are the same kind of verb, and this is the
|
|
2360
|
+
ONLY star a grid/gallery user reaches with the keyboard. Works on a
|
|
2361
|
+
multi-selection; the label follows the selection's state. */
|
|
2362
|
+
{
|
|
2363
|
+
key: 'star',
|
|
2364
|
+
label: allStarred ? t('ctx.unstar') : t('ctx.star'),
|
|
2365
|
+
icon: allStarred ? '★' : '☆',
|
|
2366
|
+
hidden: !canStar,
|
|
2367
|
+
},
|
|
2061
2368
|
{ key: 'tags', label: tagsLabel, icon: '🏷', hidden: !singleHasId, disabled: !singleHasId },
|
|
2062
2369
|
...keepActionsFor(sel),
|
|
2063
2370
|
{ divider: true, key: 'sep2', label: '', hidden: !w },
|
|
@@ -2176,6 +2483,9 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
2176
2483
|
);
|
|
2177
2484
|
}
|
|
2178
2485
|
break;
|
|
2486
|
+
case 'star':
|
|
2487
|
+
await toggleStar(targets);
|
|
2488
|
+
break;
|
|
2179
2489
|
case 'tags':
|
|
2180
2490
|
if (targets[0]) openTagPickerFor(targets[0]);
|
|
2181
2491
|
break;
|
|
@@ -3281,7 +3591,11 @@ const activeSplit = computed(() => tabsApi.activeTab.value?.split ?? null);
|
|
|
3281
3591
|
// Sekme adı OTOMATİK = güncel klasör adı (kök = depo adı / kök etiketi).
|
|
3282
3592
|
function tabLabel(path: string): string {
|
|
3283
3593
|
const p = (path || '').replace(/^\/+|\/+$/g, '');
|
|
3284
|
-
|
|
3594
|
+
// gezinti:g1 — the virtual views park a sentinel in the path. Translate via
|
|
3595
|
+
// the SHARED map: this special-cased only '.trash' when recent/starred/shared
|
|
3596
|
+
// arrived, so the strip read ".shared" at users (reported 2026-09-04).
|
|
3597
|
+
const virtualLabel = virtualSegmentLabel(p.split('/').pop() || p, t);
|
|
3598
|
+
if (virtualLabel) return virtualLabel;
|
|
3285
3599
|
if (!p) return multiStorageRoot.value ? t('breadcrumb.root') : adapter.value || t('breadcrumb.root');
|
|
3286
3600
|
return p.split('/').pop() || p;
|
|
3287
3601
|
}
|
|
@@ -3572,6 +3886,35 @@ const e2eUnlockErr = ref('');
|
|
|
3572
3886
|
const showEncFolder = ref(false);
|
|
3573
3887
|
const e2eCreateBusy = ref(false);
|
|
3574
3888
|
|
|
3889
|
+
/* wiring:e2 recovery — kurtarma anahtarı + escrow.
|
|
3890
|
+
*
|
|
3891
|
+
* The marker of the folder we are looking at is cached here while the lock
|
|
3892
|
+
* screen is up: the recovery dialog needs to know which doors this folder
|
|
3893
|
+
* actually has (a pre-0.31 folder has none) before offering them. */
|
|
3894
|
+
const e2eMarker = ref<E2eMarker | null>(null);
|
|
3895
|
+
const showRecoveryUnlock = ref(false);
|
|
3896
|
+
const e2eRecoverBusy = ref(false);
|
|
3897
|
+
const e2eRecoverErr = ref<string | null>(null);
|
|
3898
|
+
// The shown-once key. Held only while its dialog is open.
|
|
3899
|
+
const showRecoveryKey = ref(false);
|
|
3900
|
+
const recoveryKeyValue = ref('');
|
|
3901
|
+
const recoveryKeyVariant = ref<'created' | 'upgraded'>('created');
|
|
3902
|
+
const recoveryKeyFolder = ref('');
|
|
3903
|
+
// A v1 folder that just opened by password: offer to give it recovery now,
|
|
3904
|
+
// because this is the only moment filex holds the password.
|
|
3905
|
+
const e2eUpgradeOffer = ref(false);
|
|
3906
|
+
const e2eUpgradePw = ref('');
|
|
3907
|
+
const e2eUpgradeBusy = ref(false);
|
|
3908
|
+
|
|
3909
|
+
/** The installation's escrow public key, or null when escrow is off.
|
|
3910
|
+
* Published in /api/capabilities on purpose — see docs/E2E-ENCRYPTION.md. */
|
|
3911
|
+
const e2eEscrowPub = computed<string | null>(
|
|
3912
|
+
() => capabilitiesData.value?.e2e_escrow?.public_key || null,
|
|
3913
|
+
);
|
|
3914
|
+
const e2eEscrowKid = computed<string | null>(
|
|
3915
|
+
() => capabilitiesData.value?.e2e_escrow?.kid || null,
|
|
3916
|
+
);
|
|
3917
|
+
|
|
3575
3918
|
function e2eKek(): CryptoKey | null {
|
|
3576
3919
|
return e2eRing.get(e2eRoot.value) ?? null;
|
|
3577
3920
|
}
|
|
@@ -3602,13 +3945,24 @@ async function e2eUnlock() {
|
|
|
3602
3945
|
e2eUnlockErr.value = t('e2e.unlock.marker_missing');
|
|
3603
3946
|
return;
|
|
3604
3947
|
}
|
|
3605
|
-
|
|
3606
|
-
|
|
3948
|
+
e2eMarker.value = marker;
|
|
3949
|
+
const fmk = await unlockWithPassword(marker, e2ePw.value);
|
|
3950
|
+
if (!fmk) {
|
|
3607
3951
|
e2eUnlockErr.value = t('e2e.unlock.wrong');
|
|
3608
3952
|
return;
|
|
3609
3953
|
}
|
|
3610
|
-
e2eRing.set(e2eRoot.value,
|
|
3954
|
+
e2eRing.set(e2eRoot.value, fmk);
|
|
3611
3955
|
e2eRingVer.value++;
|
|
3956
|
+
/* wiring:e2 recovery — a folder from before recovery existed has no way
|
|
3957
|
+
* back in but its password. This is the ONE moment we hold that password,
|
|
3958
|
+
* so ask now. Asking is all we do: the folder keeps working untouched if
|
|
3959
|
+
* the user says no, and saying yes is the only path that also hands the
|
|
3960
|
+
* operator an escrow key (when the install has one), which is why the
|
|
3961
|
+
* prompt says so rather than doing it quietly. */
|
|
3962
|
+
if (marker.v === 1) {
|
|
3963
|
+
e2eUpgradePw.value = e2ePw.value;
|
|
3964
|
+
e2eUpgradeOffer.value = true;
|
|
3965
|
+
}
|
|
3612
3966
|
e2ePw.value = '';
|
|
3613
3967
|
} finally {
|
|
3614
3968
|
e2eUnlockBusy.value = false;
|
|
@@ -3735,17 +4089,28 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3735
4089
|
try {
|
|
3736
4090
|
const dirWire = qualify(currentPath.value);
|
|
3737
4091
|
await api.newFolder(dirWire, payload.name);
|
|
3738
|
-
|
|
4092
|
+
/* wiring:e2 recovery — the folder gets a recovery key at birth, and an
|
|
4093
|
+
* escrow slot when the installation has one. Both are decided HERE and
|
|
4094
|
+
* never again: the wrapped copies are written into the marker now, so a
|
|
4095
|
+
* folder created without escrow can never be opened by an escrow key. */
|
|
4096
|
+
const { marker, fmk, recoveryKey } = await createEncryptedFolder(payload.password, {
|
|
4097
|
+
escrowPublicKey: e2eEscrowPub.value,
|
|
4098
|
+
});
|
|
3739
4099
|
const markerFile = new File([JSON.stringify(marker)], E2E_MARKER_NAME, {
|
|
3740
4100
|
type: 'application/json',
|
|
3741
4101
|
});
|
|
3742
4102
|
const newDirWire = wireJoin(dirWire, payload.name);
|
|
3743
4103
|
await api.uploadMultipart(newDirWire, [markerFile]);
|
|
3744
4104
|
// Oluşturan oturumda kilit açık başlar (parolayı az önce kendisi girdi).
|
|
3745
|
-
e2eRing.set(newDirWire,
|
|
4105
|
+
e2eRing.set(newDirWire, fmk);
|
|
3746
4106
|
e2eRingVer.value++;
|
|
3747
4107
|
showEncFolder.value = false;
|
|
3748
|
-
|
|
4108
|
+
// ⚠ Show the key only after the marker is safely uploaded. Showing it
|
|
4109
|
+
// first would promise recovery for a folder that failed to be created.
|
|
4110
|
+
recoveryKeyValue.value = recoveryKey;
|
|
4111
|
+
recoveryKeyFolder.value = payload.name;
|
|
4112
|
+
recoveryKeyVariant.value = 'created';
|
|
4113
|
+
showRecoveryKey.value = true;
|
|
3749
4114
|
await load();
|
|
3750
4115
|
} catch (err) {
|
|
3751
4116
|
emit('error', { message: (err as Error).message, context: { op: 'e2e-create' } });
|
|
@@ -3754,6 +4119,143 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3754
4119
|
e2eCreateBusy.value = false;
|
|
3755
4120
|
}
|
|
3756
4121
|
}
|
|
4122
|
+
|
|
4123
|
+
/* --- wiring:e2 recovery ------------------------------------------------
|
|
4124
|
+
*
|
|
4125
|
+
* Two more ways into a locked folder, and one way to give an old folder
|
|
4126
|
+
* those ways. The password path above is untouched, and nothing here runs
|
|
4127
|
+
* without an explicit user action.
|
|
4128
|
+
*/
|
|
4129
|
+
|
|
4130
|
+
/** Unlock without the password: user recovery key, or the operator's escrow
|
|
4131
|
+
* key. The escrow branch announces itself to the server first. */
|
|
4132
|
+
async function e2eRecoverUnlock(payload: { mode: 'recovery' | 'escrow'; value: string }) {
|
|
4133
|
+
if (!e2eMarker.value || !e2eRoot.value) return;
|
|
4134
|
+
e2eRecoverBusy.value = true;
|
|
4135
|
+
e2eRecoverErr.value = null;
|
|
4136
|
+
try {
|
|
4137
|
+
let fmk: CryptoKey | null = null;
|
|
4138
|
+
if (payload.mode === 'recovery') {
|
|
4139
|
+
fmk = await unlockWithRecoveryKey(e2eMarker.value, payload.value);
|
|
4140
|
+
if (!fmk) {
|
|
4141
|
+
e2eRecoverErr.value = t('e2e.recover.wrong_recovery');
|
|
4142
|
+
return;
|
|
4143
|
+
}
|
|
4144
|
+
} else {
|
|
4145
|
+
let priv: CryptoKey;
|
|
4146
|
+
try {
|
|
4147
|
+
priv = await importEscrowPrivateKey(payload.value);
|
|
4148
|
+
} catch {
|
|
4149
|
+
e2eRecoverErr.value = t('e2e.recover.bad_escrow_key');
|
|
4150
|
+
return;
|
|
4151
|
+
}
|
|
4152
|
+
fmk = await unlockWithEscrowKey(e2eMarker.value, priv);
|
|
4153
|
+
if (!fmk) {
|
|
4154
|
+
e2eRecoverErr.value = t('e2e.recover.wrong_escrow');
|
|
4155
|
+
return;
|
|
4156
|
+
}
|
|
4157
|
+
/* ⚠ Announce BEFORE unlocking, and treat a failure to announce as a
|
|
4158
|
+
* failure to unlock. The server hands out a nonce sealed to the escrow
|
|
4159
|
+
* public key; returning it proves the key was really here, and that is
|
|
4160
|
+
* what earns the owner their notification.
|
|
4161
|
+
*
|
|
4162
|
+
* ⚠⚠ This is not enforcement and must never be described as such. An
|
|
4163
|
+
* operator holding the escrow private key can decrypt the same folder
|
|
4164
|
+
* offline, with a script, and this code will never run. Refusing to
|
|
4165
|
+
* unlock on a failed announcement only keeps the honest path honest. */
|
|
4166
|
+
try {
|
|
4167
|
+
const ch = await api.e2eEscrowChallenge(e2eRoot.value);
|
|
4168
|
+
const nonce = new Uint8Array(
|
|
4169
|
+
await crypto.subtle.decrypt(
|
|
4170
|
+
{ name: 'RSA-OAEP' },
|
|
4171
|
+
priv,
|
|
4172
|
+
b64ToBytes(ch.challenge).buffer as ArrayBuffer,
|
|
4173
|
+
),
|
|
4174
|
+
);
|
|
4175
|
+
await api.e2eEscrowUsed({
|
|
4176
|
+
path: e2eRoot.value,
|
|
4177
|
+
id: ch.id,
|
|
4178
|
+
nonce: bytesToB64(nonce),
|
|
4179
|
+
});
|
|
4180
|
+
} catch (err) {
|
|
4181
|
+
e2eRecoverErr.value = t('e2e.recover.notify_failed');
|
|
4182
|
+
emit('error', {
|
|
4183
|
+
message: (err as Error).message,
|
|
4184
|
+
context: { op: 'e2e-escrow-notify' },
|
|
4185
|
+
});
|
|
4186
|
+
return;
|
|
4187
|
+
}
|
|
4188
|
+
}
|
|
4189
|
+
e2eRing.set(e2eRoot.value, fmk);
|
|
4190
|
+
e2eRingVer.value++;
|
|
4191
|
+
showRecoveryUnlock.value = false;
|
|
4192
|
+
flashToast(
|
|
4193
|
+
payload.mode === 'escrow' ? t('e2e.recover.escrow_done') : t('e2e.recover.recovery_done'),
|
|
4194
|
+
);
|
|
4195
|
+
} catch (err) {
|
|
4196
|
+
e2eRecoverErr.value = (err as Error).message;
|
|
4197
|
+
} finally {
|
|
4198
|
+
e2eRecoverBusy.value = false;
|
|
4199
|
+
}
|
|
4200
|
+
}
|
|
4201
|
+
|
|
4202
|
+
/** Open the recovery dialog from the lock screen. The marker was cached by
|
|
4203
|
+
* the last unlock attempt; fetch it if the user came straight here. */
|
|
4204
|
+
async function openRecoveryUnlock() {
|
|
4205
|
+
if (!e2eMarker.value && e2eRoot.value) {
|
|
4206
|
+
try {
|
|
4207
|
+
const { blob, url } = await api.fetchBlob(wireJoin(e2eRoot.value, E2E_MARKER_NAME));
|
|
4208
|
+
URL.revokeObjectURL(url);
|
|
4209
|
+
e2eMarker.value = parseMarker(await blob.text());
|
|
4210
|
+
} catch {
|
|
4211
|
+
e2eMarker.value = null;
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
e2eRecoverErr.value = null;
|
|
4215
|
+
showRecoveryUnlock.value = true;
|
|
4216
|
+
}
|
|
4217
|
+
|
|
4218
|
+
/** Give a pre-0.31 folder a recovery key, in place, using the password the
|
|
4219
|
+
* user just typed. The files are NOT rewritten — only the marker is. */
|
|
4220
|
+
async function e2eDoUpgrade() {
|
|
4221
|
+
if (!e2eMarker.value || !e2eRoot.value || !e2eUpgradePw.value) return;
|
|
4222
|
+
e2eUpgradeBusy.value = true;
|
|
4223
|
+
try {
|
|
4224
|
+
const up = await upgradeMarkerV1(e2eMarker.value, e2eUpgradePw.value, {
|
|
4225
|
+
escrowPublicKey: e2eEscrowPub.value,
|
|
4226
|
+
});
|
|
4227
|
+
const markerFile = new File([JSON.stringify(up.marker)], E2E_MARKER_NAME, {
|
|
4228
|
+
type: 'application/json',
|
|
4229
|
+
});
|
|
4230
|
+
await api.uploadMultipart(e2eRoot.value, [markerFile]);
|
|
4231
|
+
e2eMarker.value = up.marker;
|
|
4232
|
+
e2eUpgradeOffer.value = false;
|
|
4233
|
+
e2eUpgradePw.value = '';
|
|
4234
|
+
recoveryKeyValue.value = up.recoveryKey;
|
|
4235
|
+
recoveryKeyFolder.value = wireBasename(e2eRoot.value);
|
|
4236
|
+
recoveryKeyVariant.value = 'upgraded';
|
|
4237
|
+
showRecoveryKey.value = true;
|
|
4238
|
+
} catch (err) {
|
|
4239
|
+
emit('error', { message: (err as Error).message, context: { op: 'e2e-upgrade' } });
|
|
4240
|
+
flashToast(t('e2e.upgrade.failed'));
|
|
4241
|
+
} finally {
|
|
4242
|
+
e2eUpgradeBusy.value = false;
|
|
4243
|
+
}
|
|
4244
|
+
}
|
|
4245
|
+
|
|
4246
|
+
/** Decline the offer. The folder keeps working exactly as it did, and the
|
|
4247
|
+
* prompt returns on the next unlock because the risk has not changed. */
|
|
4248
|
+
function e2eDeclineUpgrade() {
|
|
4249
|
+
e2eUpgradeOffer.value = false;
|
|
4250
|
+
e2eUpgradePw.value = '';
|
|
4251
|
+
}
|
|
4252
|
+
|
|
4253
|
+
/** Drop the shown-once key from memory the moment its dialog closes. */
|
|
4254
|
+
function closeRecoveryKey() {
|
|
4255
|
+
showRecoveryKey.value = false;
|
|
4256
|
+
recoveryKeyValue.value = '';
|
|
4257
|
+
recoveryKeyFolder.value = '';
|
|
4258
|
+
}
|
|
3757
4259
|
/* === /wiring:e2 === */
|
|
3758
4260
|
</script>
|
|
3759
4261
|
|
|
@@ -3839,16 +4341,21 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3839
4341
|
:expanded="sideNavExpanded"
|
|
3840
4342
|
:narrow="isNarrow"
|
|
3841
4343
|
:active-view="navView"
|
|
4344
|
+
:active-tag="navTag"
|
|
4345
|
+
:tags="navTags"
|
|
4346
|
+
:tags-loaded="navTagsLoaded"
|
|
3842
4347
|
:active-storage="adapter"
|
|
3843
4348
|
:storages="config.storages ?? []"
|
|
3844
4349
|
:shared-storages="sharedStorageNames"
|
|
3845
4350
|
:trash-visible="config.trashVisible !== false"
|
|
3846
4351
|
:show-connections="connectionsEnabled"
|
|
4352
|
+
:show-identity-surfaces="identitySurfaces"
|
|
3847
4353
|
:can-write="canWriteHere && !atVirtualRoot && !trashActive"
|
|
3848
4354
|
:locale="locale"
|
|
3849
4355
|
@toggle="toggleSideNav"
|
|
3850
4356
|
@close="closeNavDrawer"
|
|
3851
4357
|
@open-view="loadNavView"
|
|
4358
|
+
@open-tag="loadTagView"
|
|
3852
4359
|
@open-storage="openNavStorage"
|
|
3853
4360
|
@upload="triggerUpload"
|
|
3854
4361
|
@new-folder="showNewFolder = true"
|
|
@@ -3905,6 +4412,32 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
3905
4412
|
|
|
3906
4413
|
<!-- wiring:e2 — kilit açık şeridi: şifreli klasörde anahtar bellekteyken
|
|
3907
4414
|
görünür; "Kilitle" anahtarı ve çözülmüş blob'ları atar. -->
|
|
4415
|
+
<!-- wiring:e2 recovery — a v1 folder just opened by password. Offer it
|
|
4416
|
+
recovery HERE, visibly, rather than doing anything silently: this is
|
|
4417
|
+
the only moment filex holds the password, and (when the install has
|
|
4418
|
+
escrow) accepting also gives the operator a key. -->
|
|
4419
|
+
<div v-if="e2eUpgradeOffer" class="fe-e2e-upgrade" role="alert">
|
|
4420
|
+
<div class="fe-e2e-upgrade__text">
|
|
4421
|
+
<strong>{{ t('e2e.upgrade.title') }}</strong>
|
|
4422
|
+
<p>{{ t('e2e.upgrade.body') }}</p>
|
|
4423
|
+
<p v-if="e2eEscrowKid" class="fe-e2e-upgrade__escrow">
|
|
4424
|
+
{{ t('e2e.upgrade.escrow_note') }}
|
|
4425
|
+
</p>
|
|
4426
|
+
</div>
|
|
4427
|
+
<div class="fe-e2e-upgrade__actions">
|
|
4428
|
+
<button type="button" class="fe-btn" :disabled="e2eUpgradeBusy" @click="e2eDeclineUpgrade">
|
|
4429
|
+
{{ t('e2e.upgrade.decline') }}
|
|
4430
|
+
</button>
|
|
4431
|
+
<button
|
|
4432
|
+
type="button"
|
|
4433
|
+
class="fe-btn fe-btn--primary"
|
|
4434
|
+
:disabled="e2eUpgradeBusy"
|
|
4435
|
+
@click="e2eDoUpgrade"
|
|
4436
|
+
>
|
|
4437
|
+
{{ e2eUpgradeBusy ? t('e2e.upgrade.busy') : t('e2e.upgrade.accept') }}
|
|
4438
|
+
</button>
|
|
4439
|
+
</div>
|
|
4440
|
+
</div>
|
|
3908
4441
|
<div v-if="e2eUnlocked" class="fe-e2e-strip" role="status">
|
|
3909
4442
|
<span class="fe-e2e-strip__icon" aria-hidden="true">🔒</span>
|
|
3910
4443
|
<span class="fe-e2e-strip__label">{{ t('e2e.strip.label') }}</span>
|
|
@@ -4043,6 +4576,13 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4043
4576
|
</button>
|
|
4044
4577
|
</form>
|
|
4045
4578
|
<p v-if="e2eUnlockErr" class="fe-form__error" role="alert">{{ e2eUnlockErr }}</p>
|
|
4579
|
+
<!-- wiring:e2 recovery — the second door. Always offered: whether
|
|
4580
|
+
this folder actually has one is answered inside the dialog,
|
|
4581
|
+
which can say "this folder predates recovery keys" instead of
|
|
4582
|
+
leaving the user guessing why there is no link. -->
|
|
4583
|
+
<button type="button" class="fe-e2e-optlink" @click="openRecoveryUnlock">
|
|
4584
|
+
{{ t('e2e.locked.use_recovery') }}
|
|
4585
|
+
</button>
|
|
4046
4586
|
</div>
|
|
4047
4587
|
<!-- /wiring:e2 -->
|
|
4048
4588
|
<!-- Search with zero hits — its own message, not "folder is empty". -->
|
|
@@ -4093,6 +4633,10 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4093
4633
|
<template v-else-if="navView === 'starred'">
|
|
4094
4634
|
<path d="M60 26l9 18.6 20.4 3-14.8 14.4 3.5 20.4L60 72.8 41.9 82.4l3.5-20.4L30.6 47.6l20.4-3z" />
|
|
4095
4635
|
</template>
|
|
4636
|
+
<template v-else-if="navView === 'tag'">
|
|
4637
|
+
<path d="M30 30h24l32 32-24 24-32-32z" />
|
|
4638
|
+
<circle cx="43" cy="43" r="4.5" />
|
|
4639
|
+
</template>
|
|
4096
4640
|
<template v-else>
|
|
4097
4641
|
<circle cx="84" cy="34" r="9" />
|
|
4098
4642
|
<circle cx="36" cy="52" r="9" />
|
|
@@ -4100,8 +4644,15 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4100
4644
|
<path d="M44.5 47.5l31-9M44.5 56.5l31 9" />
|
|
4101
4645
|
</template>
|
|
4102
4646
|
</svg>
|
|
4103
|
-
|
|
4104
|
-
|
|
4647
|
+
<!-- etiket:t1 — the tag view's empty state names the TAG. "Nothing
|
|
4648
|
+
here" would be the fourth identical sentence and would not say
|
|
4649
|
+
which of the user's tags is the empty one. -->
|
|
4650
|
+
<p class="fe-state__title">
|
|
4651
|
+
{{ navView === 'tag' ? t('empty.tag.title', { tag: navTag }) : t(`empty.${navView}.title`) }}
|
|
4652
|
+
</p>
|
|
4653
|
+
<p class="fe-state__hint">
|
|
4654
|
+
{{ navView === 'tag' ? t('empty.tag.hint') : t(`empty.${navView}.hint`) }}
|
|
4655
|
+
</p>
|
|
4105
4656
|
</div>
|
|
4106
4657
|
<!-- Empty trash view. -->
|
|
4107
4658
|
<div v-else-if="!loading && files.length === 0 && trashMode" class="fe-state">
|
|
@@ -4184,11 +4735,16 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4184
4735
|
:loading="loading"
|
|
4185
4736
|
:keep-badge-for="desktopSync ? keepBadgeFor : undefined"
|
|
4186
4737
|
:thumb-src="thumbs.src"
|
|
4738
|
+
:starred-ids="starredIds"
|
|
4739
|
+
:api-base="props.config.apiBase ?? ''"
|
|
4740
|
+
:auth-headers="() => buildAuthHeaders()"
|
|
4741
|
+
:auth-credentials="api.credentialsMode()"
|
|
4187
4742
|
@click-card="(n, m) => selection.click(n.path, m)"
|
|
4188
4743
|
@dbl-card="openNode"
|
|
4189
4744
|
@context-card="onContextTarget"
|
|
4190
4745
|
@item-drag-start="onItemDragStart"
|
|
4191
4746
|
@item-drop-into="onItemDropInto"
|
|
4747
|
+
@star-change="onStarChange"
|
|
4192
4748
|
/>
|
|
4193
4749
|
<!-- wiring:d2 — galeri görünümü (GridView ile aynı event sözleşmesi) -->
|
|
4194
4750
|
<GalleryView
|
|
@@ -4200,11 +4756,16 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4200
4756
|
:locale="locale"
|
|
4201
4757
|
:loading="loading"
|
|
4202
4758
|
:thumb-src="thumbs.src"
|
|
4759
|
+
:starred-ids="starredIds"
|
|
4760
|
+
:api-base="props.config.apiBase ?? ''"
|
|
4761
|
+
:auth-headers="() => buildAuthHeaders()"
|
|
4762
|
+
:auth-credentials="api.credentialsMode()"
|
|
4203
4763
|
@click-card="(n, m) => selection.click(n.path, m)"
|
|
4204
4764
|
@dbl-card="openNode"
|
|
4205
4765
|
@context-card="onContextTarget"
|
|
4206
4766
|
@item-drag-start="onItemDragStart"
|
|
4207
4767
|
@item-drop-into="onItemDropInto"
|
|
4768
|
+
@star-change="onStarChange"
|
|
4208
4769
|
/>
|
|
4209
4770
|
<!-- /wiring:d2 -->
|
|
4210
4771
|
</div>
|
|
@@ -4394,9 +4955,32 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4394
4955
|
:open="showEncFolder"
|
|
4395
4956
|
:locale="locale"
|
|
4396
4957
|
:busy="e2eCreateBusy"
|
|
4958
|
+
:escrow-kid="e2eEscrowKid"
|
|
4397
4959
|
@close="showEncFolder = false"
|
|
4398
4960
|
@submit="submitEncryptedFolder"
|
|
4399
4961
|
/>
|
|
4962
|
+
<!-- wiring:e2 recovery — the key, shown exactly once. -->
|
|
4963
|
+
<RecoveryKeyModal
|
|
4964
|
+
:open="showRecoveryKey"
|
|
4965
|
+
:locale="locale"
|
|
4966
|
+
:recovery-key="recoveryKeyValue"
|
|
4967
|
+
:folder-name="recoveryKeyFolder"
|
|
4968
|
+
:escrow-kid="e2eEscrowKid"
|
|
4969
|
+
:variant="recoveryKeyVariant"
|
|
4970
|
+
@close="closeRecoveryKey"
|
|
4971
|
+
/>
|
|
4972
|
+
<!-- wiring:e2 recovery — the way back in without the password. -->
|
|
4973
|
+
<E2eRecoveryUnlockModal
|
|
4974
|
+
:open="showRecoveryUnlock"
|
|
4975
|
+
:locale="locale"
|
|
4976
|
+
:has-recovery="markerHasRecovery(e2eMarker)"
|
|
4977
|
+
:has-escrow="markerHasEscrow(e2eMarker) && !!e2eEscrowKid"
|
|
4978
|
+
:escrow-kid="e2eEscrowKid"
|
|
4979
|
+
:busy="e2eRecoverBusy"
|
|
4980
|
+
:error="e2eRecoverErr"
|
|
4981
|
+
@close="showRecoveryUnlock = false"
|
|
4982
|
+
@submit="e2eRecoverUnlock"
|
|
4983
|
+
/>
|
|
4400
4984
|
<!-- /wiring:e2 -->
|
|
4401
4985
|
<RenameModal
|
|
4402
4986
|
:open="showRename"
|
|
@@ -4514,6 +5098,7 @@ async function submitEncryptedFolder(payload: { name: string; password: string }
|
|
|
4514
5098
|
:api-base="props.config.apiBase ?? ''"
|
|
4515
5099
|
:auth-headers="() => buildAuthHeaders()"
|
|
4516
5100
|
:auth-credentials="api.credentialsMode()"
|
|
5101
|
+
@change="onNodeTagsChanged"
|
|
4517
5102
|
@error="(msg: string) => emit('error', { message: msg, context: { op: 'tags' } })"
|
|
4518
5103
|
/>
|
|
4519
5104
|
</div>
|