@brftech/filex-core 0.39.1 → 0.41.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 +32 -25
- package/dist/ArchiveViewer-BNqukFg8.js +124 -0
- package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
- package/dist/CsvViewer-ii_-MgmG.js +141 -0
- package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
- package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
- package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
- package/dist/EpubViewer-p4B8iiWb.js +163 -0
- package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
- package/dist/IpynbViewer-D_qYRJhu.js +184 -0
- package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
- package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
- package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
- package/dist/PsdViewer-CuV-OPSd.js +122 -0
- package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
- package/dist/TiffViewer-BLeoF4Be.js +142 -0
- package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
- package/dist/Viewer3D-DHhhU76E.js +75 -0
- package/dist/Viewer3D-DHhhU76E.js.map +1 -0
- package/dist/filex-core.js +217 -20077
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +61 -62
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-BraG7Cz4.js +26664 -0
- package/dist/index-BraG7Cz4.js.map +1 -0
- package/dist/index.d.ts +4276 -851
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +2170 -492
- package/src/components/AdvancedSearch.vue +870 -0
- package/src/components/Breadcrumb.vue +191 -19
- package/src/components/CommandPalette.vue +66 -22
- package/src/components/ConnectionsPanel.vue +6 -2
- package/src/components/ContextMenu.vue +57 -2
- package/src/components/EncryptedFolderModal.vue +31 -25
- package/src/components/FilePane.vue +939 -0
- package/src/components/FilterBar.vue +568 -72
- package/src/components/GalleryView.vue +162 -30
- package/src/components/GridView.vue +322 -58
- package/src/components/HomeView.vue +331 -0
- package/src/components/InspectorPanel.vue +296 -53
- package/src/components/ListView.vue +1158 -167
- package/src/components/NFSExportsPanel.vue +7 -4
- package/src/components/OnboardingTour.vue +156 -33
- package/src/components/QuickLook.vue +34 -5
- package/src/components/RecentlyOpened.vue +17 -2
- package/src/components/S3KeysPanel.vue +7 -4
- package/src/components/SSHKeysPanel.vue +7 -4
- package/src/components/ShortcutSettings.vue +43 -2
- package/src/components/ShortcutsHelp.vue +4 -1
- package/src/components/SideNav.vue +206 -140
- package/src/components/StorageFields.vue +19 -1
- package/src/components/TabBar.vue +87 -1
- package/src/components/TagPicker.vue +77 -6
- package/src/components/ThemeGallery.vue +13 -198
- package/src/components/ThemePalette.vue +244 -0
- package/src/components/TimeZoneDialog.vue +127 -0
- package/src/components/TimeZonePicker.vue +696 -0
- package/src/components/TokensPanel.vue +16 -4
- package/src/components/Toolbar.vue +965 -469
- package/src/components/ViewSwitcher.vue +9 -4
- package/src/composables/useConnections.ts +35 -4
- package/src/composables/useExplorerTimeZone.ts +85 -0
- package/src/composables/useFileApi.ts +53 -4
- package/src/composables/useKeyboardShortcuts.ts +122 -0
- package/src/composables/useLocale.ts +365 -25
- package/src/index.ts +182 -3
- package/src/lib/actionIcons.ts +330 -0
- package/src/lib/advSearch.ts +162 -0
- package/src/lib/dateGroups.ts +197 -0
- package/src/lib/destinationTree.ts +235 -0
- package/src/lib/downloadSelection.ts +154 -0
- package/src/lib/fileFilters.ts +319 -12
- package/src/lib/fileIcons.ts +299 -5
- package/src/lib/filePreview.ts +605 -0
- package/src/lib/listing.ts +72 -0
- package/src/lib/shareTtl.ts +11 -1
- package/src/lib/sortOrder.ts +459 -0
- package/src/lib/themes.ts +152 -23
- package/src/lib/timezone.ts +456 -0
- package/src/lib/uiProfile.ts +82 -0
- package/src/lib/viewPrefs.ts +1036 -0
- package/src/locales/en.ts +432 -25
- package/src/locales/tr.ts +425 -25
- package/src/modals/ConvertModal.vue +23 -3
- package/src/modals/DestinationPickerModal.vue +341 -0
- package/src/modals/Modal.vue +18 -4
- package/src/modals/NewDocumentModal.vue +525 -0
- package/src/modals/NewFolderModal.vue +15 -9
- package/src/modals/PermissionsModal.vue +543 -473
- package/src/modals/PreviewModal.vue +516 -156
- package/src/styles/base.css +5435 -8
- package/src/styles/variables.css +140 -60
- package/src/types/Connections.ts +9 -0
- package/src/types/ExplorerConfig.ts +144 -39
- package/src/types/FileNode.ts +28 -0
- package/src/viewers/ArchiveViewer.vue +46 -8
- package/src/viewers/CsvViewer.vue +19 -2
- package/src/viewers/DrawioViewer.vue +12 -1
- package/src/viewers/EpubViewer.vue +50 -6
- package/src/viewers/IpynbViewer.vue +19 -2
- package/src/viewers/MermaidViewer.vue +20 -3
- package/src/viewers/PdfViewer.vue +24 -4
- package/src/viewers/PsdViewer.vue +28 -4
- package/src/viewers/TiffViewer.vue +19 -2
- package/src/viewers/Viewer3D.vue +36 -2
- package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
- package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
- package/dist/CsvViewer-CqWeV8VO.js +0 -131
- package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
- package/dist/DrawioViewer-BNALOB04.js.map +0 -1
- package/dist/EpubViewer-BPmlImG7.js +0 -145
- package/dist/EpubViewer-BPmlImG7.js.map +0 -1
- package/dist/IpynbViewer-CDlQmuCV.js +0 -175
- package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
- package/dist/MermaidViewer-sfZWdR-d.js +0 -127
- package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
- package/dist/PsdViewer-DwuySZsb.js +0 -112
- package/dist/PsdViewer-DwuySZsb.js.map +0 -1
- package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
- package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
- package/dist/Viewer3D-CJJJgkuv.js +0 -60
- package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
- package/src/components/SecondaryPane.vue +0 -447
- package/src/modals/ShareModal.vue +0 -139
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* ViewSwitcher — the list / grid / gallery toggle.
|
|
4
4
|
*
|
|
5
5
|
* Lifted out of Toolbar.vue unchanged (same classes, same glyphs, same
|
|
6
|
-
* `role="tablist"` semantics
|
|
6
|
+
* `role="tablist"` semantics — the glyphs moved to lib/actionIcons with the
|
|
7
|
+
* rest of the emoji, see `gorunum:v1-icons`) because the `drive` profile puts it on the
|
|
7
8
|
* breadcrumb row instead of in the header, and two copies of a switcher is
|
|
8
9
|
* how the two rows end up offering different modes. One component, mounted
|
|
9
10
|
* in whichever row the profile asks for.
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
* block's `data-v` hash does not match in the web-component build).
|
|
16
17
|
*/
|
|
17
18
|
import { useLocale } from '../composables/useLocale';
|
|
19
|
+
import { actionIconSvg } from '../lib/actionIcons';
|
|
18
20
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
19
21
|
import type { ViewMode } from '../types/FileNode';
|
|
20
22
|
|
|
@@ -47,7 +49,8 @@ function offers(v: ViewMode): boolean {
|
|
|
47
49
|
data-testid="view-list"
|
|
48
50
|
@click="emit('update:viewMode', 'list')"
|
|
49
51
|
>
|
|
50
|
-
|
|
52
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
|
|
53
|
+
<span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('view-list')"></span>
|
|
51
54
|
</button>
|
|
52
55
|
<button
|
|
53
56
|
v-if="offers('grid')"
|
|
@@ -61,7 +64,8 @@ function offers(v: ViewMode): boolean {
|
|
|
61
64
|
data-testid="view-grid"
|
|
62
65
|
@click="emit('update:viewMode', 'grid')"
|
|
63
66
|
>
|
|
64
|
-
|
|
67
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
|
|
68
|
+
<span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('view-grid')"></span>
|
|
65
69
|
</button>
|
|
66
70
|
<button
|
|
67
71
|
v-if="offers('gallery')"
|
|
@@ -75,7 +79,8 @@ function offers(v: ViewMode): boolean {
|
|
|
75
79
|
data-testid="view-gallery"
|
|
76
80
|
@click="emit('update:viewMode', 'gallery')"
|
|
77
81
|
>
|
|
78
|
-
|
|
82
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
|
|
83
|
+
<span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('view-gallery')"></span>
|
|
79
84
|
</button>
|
|
80
85
|
</div>
|
|
81
86
|
</template>
|
|
@@ -53,11 +53,25 @@ export function connectionsBase(config: ExplorerConfig): string {
|
|
|
53
53
|
* The origin a client program should be pointed at.
|
|
54
54
|
*
|
|
55
55
|
* The instruction pages are only worth anything if they name the real
|
|
56
|
-
* deployment, so this resolves to an absolute
|
|
57
|
-
*
|
|
58
|
-
*
|
|
56
|
+
* deployment, so this resolves to an absolute address, best evidence first:
|
|
57
|
+
*
|
|
58
|
+
* 1. `publicUrl` — what the SERVER says it is reached at
|
|
59
|
+
* (`/api/files/capabilities` → `public_url`, published only when the
|
|
60
|
+
* operator configured it or the request came in on a tenant's own host);
|
|
61
|
+
* 2. the configured apiBase when it is absolute (the desktop app, embeds
|
|
62
|
+
* that talk to filex directly);
|
|
63
|
+
* 3. the page's own origin (the admin SPA, which the same binary serves).
|
|
64
|
+
*
|
|
65
|
+
* ⚠⚠ The first rung is new and it is the fix. Without it an explorer embedded
|
|
66
|
+
* in ANOTHER app — which proxies `/api` to filex under its own origin, the way
|
|
67
|
+
* work.example.com and fishapp do — printed `https://<that app>/dav/`: an address
|
|
68
|
+
* that reaches the host application, never filex. It also left one guide page
|
|
69
|
+
* speaking with two voices, because its S3 endpoint and SFTP host were always
|
|
70
|
+
* the server's and only the WebDAV / mount lines were the page's.
|
|
59
71
|
*/
|
|
60
|
-
export function connectionsOrigin(config: ExplorerConfig): string {
|
|
72
|
+
export function connectionsOrigin(config: ExplorerConfig, publicUrl?: string | null): string {
|
|
73
|
+
const told = String(publicUrl ?? '').trim();
|
|
74
|
+
if (/^https?:\/\/[^/]/i.test(told)) return told.replace(/\/+$/, '');
|
|
61
75
|
const base = connectionsBase(config);
|
|
62
76
|
if (/^https?:\/\//i.test(base)) {
|
|
63
77
|
try {
|
|
@@ -80,6 +94,8 @@ export function useConnections(config: ExplorerConfig) {
|
|
|
80
94
|
/** Storage names a non-admin may see (manager root). */
|
|
81
95
|
const visible = shallowRef<string[]>([]);
|
|
82
96
|
const me = ref<ConnectionsUser | null>(null);
|
|
97
|
+
/** The server's own public address, when it has one to give (see connectionsOrigin). */
|
|
98
|
+
const publicUrl = ref<string | null>(null);
|
|
83
99
|
|
|
84
100
|
const loading = ref(false);
|
|
85
101
|
const loaded = ref(false);
|
|
@@ -102,6 +118,19 @@ export function useConnections(config: ExplorerConfig) {
|
|
|
102
118
|
loading.value = true;
|
|
103
119
|
error.value = null;
|
|
104
120
|
try {
|
|
121
|
+
// Where the server says it lives. Best-effort and in parallel: a server
|
|
122
|
+
// older than `public_url`, or one that has none to give, leaves the
|
|
123
|
+
// guides on the address the page loaded from — exactly as before.
|
|
124
|
+
const told = api
|
|
125
|
+
.capabilities()
|
|
126
|
+
.then((c) => {
|
|
127
|
+
const v = (c as { public_url?: unknown } | null)?.public_url;
|
|
128
|
+
publicUrl.value = typeof v === 'string' && v ? v : null;
|
|
129
|
+
})
|
|
130
|
+
.catch(() => {
|
|
131
|
+
publicUrl.value = null;
|
|
132
|
+
});
|
|
133
|
+
|
|
105
134
|
// Identity first: the guides need the caller's own e-mail (it IS the
|
|
106
135
|
// WebDAV username), and it is the one call every role may make.
|
|
107
136
|
try {
|
|
@@ -152,6 +181,7 @@ export function useConnections(config: ExplorerConfig) {
|
|
|
152
181
|
visible.value = [];
|
|
153
182
|
}
|
|
154
183
|
}
|
|
184
|
+
await told;
|
|
155
185
|
loaded.value = true;
|
|
156
186
|
} finally {
|
|
157
187
|
loading.value = false;
|
|
@@ -249,6 +279,7 @@ export function useConnections(config: ExplorerConfig) {
|
|
|
249
279
|
storages,
|
|
250
280
|
visible,
|
|
251
281
|
me,
|
|
282
|
+
publicUrl,
|
|
252
283
|
loading,
|
|
253
284
|
loaded,
|
|
254
285
|
error,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useExplorerTimeZone — what ONE explorer instance knows about the clock, fed
|
|
3
|
+
* into `lib/timezone`'s resolver.
|
|
4
|
+
*
|
|
5
|
+
* An explorer knows two of the four tiers and nobody else can know them for
|
|
6
|
+
* it: the zone its host configured (`config.timeZone`, tier `host`) and the
|
|
7
|
+
* account behind the credential it holds (tier `account`). The viewer tier is
|
|
8
|
+
* the browser's and the device tier is `Intl`'s; neither needs an instance.
|
|
9
|
+
*
|
|
10
|
+
* How the account is learned — nothing new on the server:
|
|
11
|
+
*
|
|
12
|
+
* 1. the credential's KIND, from `GET /api/files/capabilities`
|
|
13
|
+
* (`caller_kind`, migration 00030), which the explorer already fetches —
|
|
14
|
+
* or `config.callerKind`, which a host may set to answer sooner and which
|
|
15
|
+
* wins over the server's, exactly as it does for the panel's surfaces;
|
|
16
|
+
* 2. for a person, `GET /api/auth/me` — the route every role may call, open
|
|
17
|
+
* to a bearer token (MiddlewareWithToken) and to cross-origin callers
|
|
18
|
+
* (CORS allows `Authorization`), and already used by the Connections
|
|
19
|
+
* panel for the caller's own e-mail. `user.timezone` is the zone.
|
|
20
|
+
*
|
|
21
|
+
* ⚠⚠ An `app` token skips the tier. It authenticates AS its owner, so
|
|
22
|
+
* `/api/auth/me` would happily answer with that owner's zone — and the embeds
|
|
23
|
+
* we run authenticate every visitor with ONE such token. Imposing its owner's
|
|
24
|
+
* clock on all of them is the exact thing the owner ruled out.
|
|
25
|
+
*
|
|
26
|
+
* ⚠ Unknown kind also skips. When capabilities never lands (an old server, a
|
|
27
|
+
* host that routes nothing but the manager), nobody can say whether the key is
|
|
28
|
+
* a person's, and guessing "person" would be guessing the dangerous way.
|
|
29
|
+
*
|
|
30
|
+
* ⚠ Every failure is silent and falls through: a 401, a 404 from a host that
|
|
31
|
+
* does not proxy `/api/auth/me`, a network error. The tier is withdrawn and
|
|
32
|
+
* the browser's zone answers. A clock must never throw.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { onBeforeUnmount, watch, type Ref } from 'vue';
|
|
36
|
+
import type { ExplorerConfig } from '../types/ExplorerConfig';
|
|
37
|
+
import type { Capabilities } from '../types/FileNode';
|
|
38
|
+
import { releaseTimeZoneOwner, setAccountTimeZone, setHostTimeZone } from '../lib/timezone';
|
|
39
|
+
|
|
40
|
+
export interface ExplorerTimeZoneDeps {
|
|
41
|
+
config: () => ExplorerConfig;
|
|
42
|
+
/** The explorer's capabilities answer; `null` until (and unless) it lands. */
|
|
43
|
+
capabilities: Ref<Capabilities | null>;
|
|
44
|
+
/** `GET /api/auth/me` with this explorer's credential. */
|
|
45
|
+
fetchMe: () => Promise<{ user?: { timezone?: unknown } | null } | undefined>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useExplorerTimeZone(deps: ExplorerTimeZoneDeps): void {
|
|
49
|
+
const owner = Symbol('filex-explorer');
|
|
50
|
+
|
|
51
|
+
watch(
|
|
52
|
+
() => deps.config().timeZone,
|
|
53
|
+
(tz) => setHostTimeZone(owner, tz),
|
|
54
|
+
{ immediate: true },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const kind = (): 'user' | 'app' | undefined =>
|
|
58
|
+
deps.config().callerKind ?? deps.capabilities.value?.caller_kind;
|
|
59
|
+
|
|
60
|
+
let asked = false;
|
|
61
|
+
watch(
|
|
62
|
+
kind,
|
|
63
|
+
(k) => {
|
|
64
|
+
if (k !== 'user') {
|
|
65
|
+
// `app`: nobody's account. Unknown: cannot tell. Either way, withdraw.
|
|
66
|
+
setAccountTimeZone(owner, null);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (asked) return;
|
|
70
|
+
asked = true;
|
|
71
|
+
deps
|
|
72
|
+
.fetchMe()
|
|
73
|
+
.then((body) => {
|
|
74
|
+
// The kind may have changed while the request was out.
|
|
75
|
+
if (kind() !== 'user') return;
|
|
76
|
+
const tz = body?.user?.timezone;
|
|
77
|
+
setAccountTimeZone(owner, typeof tz === 'string' ? tz : null);
|
|
78
|
+
})
|
|
79
|
+
.catch(() => setAccountTimeZone(owner, null));
|
|
80
|
+
},
|
|
81
|
+
{ immediate: true },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
onBeforeUnmount(() => releaseTimeZoneOwner(owner));
|
|
85
|
+
}
|
|
@@ -48,6 +48,17 @@ export interface PendingOpDto {
|
|
|
48
48
|
created_at: string | null;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Answer to `?action=newfile` — where the new document actually landed. */
|
|
52
|
+
export interface NewFileResponse {
|
|
53
|
+
/** Adapter-qualified path, ready to hand to the viewer. */
|
|
54
|
+
path: string;
|
|
55
|
+
/** Final basename, which may have gained the extension server-side. */
|
|
56
|
+
name: string;
|
|
57
|
+
ext: string;
|
|
58
|
+
size: number;
|
|
59
|
+
mime: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
export interface ManagerResponse {
|
|
52
63
|
adapter: string;
|
|
53
64
|
storages: string[];
|
|
@@ -504,6 +515,27 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
504
515
|
});
|
|
505
516
|
}
|
|
506
517
|
|
|
518
|
+
/**
|
|
519
|
+
* Create an empty document of a known type in `path`.
|
|
520
|
+
*
|
|
521
|
+
* `type` is an extension from `capabilities.newdoc_types` — the registry the
|
|
522
|
+
* SERVER compiled in, not a list the client keeps. That matters for the
|
|
523
|
+
* office formats: a .docx is a ZIP of XML parts, so "create an empty file"
|
|
524
|
+
* has to be answered by whoever holds the template bytes, and the client
|
|
525
|
+
* cannot manufacture one.
|
|
526
|
+
*
|
|
527
|
+
* `name` may or may not already carry the extension; the server appends it
|
|
528
|
+
* when it is missing. Throws on a name collision (409 NAME_TAKEN) — the
|
|
529
|
+
* dialog warns first, but this is the check.
|
|
530
|
+
*/
|
|
531
|
+
async function newFile(path: string, name: string, type: string): Promise<NewFileResponse> {
|
|
532
|
+
return jsonFetch<NewFileResponse>(managerUrl('newfile'), {
|
|
533
|
+
method: 'POST',
|
|
534
|
+
headers: { 'Content-Type': 'application/json' },
|
|
535
|
+
body: JSON.stringify({ path, name, type }),
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
507
539
|
async function rename(path: string, item: string, name: string): Promise<ManagerResponse> {
|
|
508
540
|
return jsonFetch<ManagerResponse>(managerUrl('rename'), {
|
|
509
541
|
method: 'POST',
|
|
@@ -577,11 +609,18 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
577
609
|
/**
|
|
578
610
|
* Restore soft-deleted nodes by their node id. The filex backend restores
|
|
579
611
|
* one node per call (`POST {node_id}`), so we fan out and tally successes.
|
|
612
|
+
*
|
|
613
|
+
* `taken` names the entries the server refused because something already
|
|
614
|
+
* holds their original path (409 `EXISTS`). That refusal is the server
|
|
615
|
+
* protecting the file that holds the name — a restore used to overwrite it —
|
|
616
|
+
* so it is reported by name rather than folded into "0 items restored",
|
|
617
|
+
* which would read as if nothing had been tried.
|
|
580
618
|
*/
|
|
581
|
-
async function restoreIds(ids: number[]): Promise<{ restored: number }> {
|
|
619
|
+
async function restoreIds(ids: number[]): Promise<{ restored: number; taken: string[] }> {
|
|
582
620
|
const url = endpoints.trashRestore;
|
|
583
621
|
if (!url) throw new Error('trashRestore endpoint not configured');
|
|
584
622
|
let restored = 0;
|
|
623
|
+
const taken: string[] = [];
|
|
585
624
|
for (const id of ids) {
|
|
586
625
|
try {
|
|
587
626
|
await jsonFetch(url, {
|
|
@@ -590,11 +629,20 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
590
629
|
body: JSON.stringify({ node_id: id }),
|
|
591
630
|
});
|
|
592
631
|
restored++;
|
|
593
|
-
} catch {
|
|
594
|
-
|
|
632
|
+
} catch (err) {
|
|
633
|
+
const e = err as { status?: number; detail?: string };
|
|
634
|
+
if (e.status === 409) {
|
|
635
|
+
try {
|
|
636
|
+
const body = JSON.parse(e.detail ?? '') as { code?: string; name?: string };
|
|
637
|
+
if (body.code === 'EXISTS') taken.push(body.name || String(id));
|
|
638
|
+
} catch {
|
|
639
|
+
/* a 409 without the envelope is counted as a plain failure */
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/* any other failure: skip it, report the count that succeeded */
|
|
595
643
|
}
|
|
596
644
|
}
|
|
597
|
-
return { restored };
|
|
645
|
+
return { restored, taken };
|
|
598
646
|
}
|
|
599
647
|
|
|
600
648
|
/**
|
|
@@ -934,6 +982,7 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
934
982
|
quotaMe /* surucu:d1 */,
|
|
935
983
|
subfolders,
|
|
936
984
|
newFolder,
|
|
985
|
+
newFile,
|
|
937
986
|
rename,
|
|
938
987
|
move,
|
|
939
988
|
copy,
|
|
@@ -47,6 +47,22 @@ export interface ShortcutHandlers {
|
|
|
47
47
|
onTabNext?: () => void; // Ctrl+Tab
|
|
48
48
|
onTabPrev?: () => void; // Ctrl+Shift+Tab
|
|
49
49
|
/* /wiring:d1 */
|
|
50
|
+
/* tus:t1 — the verbs that were only ever reachable from the menus. Every
|
|
51
|
+
* one of them is remappable like the rest; the ones that ship unbound are
|
|
52
|
+
* there so the user can give them a key, not because they do less. */
|
|
53
|
+
onNewFolder?: () => void; // Shift+N
|
|
54
|
+
onUpload?: () => void; // U
|
|
55
|
+
onRefresh?: () => void; // R
|
|
56
|
+
onDownload?: () => void; // D
|
|
57
|
+
onPreview?: () => void; // P
|
|
58
|
+
onShare?: () => void; // Shift+S
|
|
59
|
+
onTags?: () => void; // T
|
|
60
|
+
onConvert?: () => void; // (unbound)
|
|
61
|
+
onOpenTab?: () => void; // (unbound)
|
|
62
|
+
onCopyPath?: () => void; // (unbound)
|
|
63
|
+
onCopyId?: () => void; // (unbound)
|
|
64
|
+
onRestore?: () => void; // (unbound)
|
|
65
|
+
/* /tus:t1 */
|
|
50
66
|
hasSelection?: () => boolean; // disambiguates Backspace
|
|
51
67
|
}
|
|
52
68
|
|
|
@@ -77,6 +93,33 @@ export interface ShortcutActionDef {
|
|
|
77
93
|
inForms?: boolean;
|
|
78
94
|
}
|
|
79
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Combos the BROWSER takes before the page sees them. `preventDefault()`
|
|
98
|
+
* cannot stop these in an ordinary tab — Chrome and Firefox handle them at
|
|
99
|
+
* the window level — so an action bound to one of them only ever fires in
|
|
100
|
+
* the desktop app, an installed PWA or a kiosk window.
|
|
101
|
+
*
|
|
102
|
+
* They are not forbidden as DEFAULTS (the tab actions have shipped on
|
|
103
|
+
* Ctrl+T/W/Tab since wiring:d1 and work in the desktop app), but the
|
|
104
|
+
* settings modal refuses to let a user assign one: choosing a key that
|
|
105
|
+
* silently does nothing where you are standing is not a choice, it is a
|
|
106
|
+
* trap. `isReservedCombo` is the one place that list lives.
|
|
107
|
+
*/
|
|
108
|
+
const RESERVED_COMBOS = new Set([
|
|
109
|
+
'Ctrl+N', 'Ctrl+Shift+N',
|
|
110
|
+
'Ctrl+T', 'Ctrl+Shift+T',
|
|
111
|
+
'Ctrl+W', 'Ctrl+Shift+W',
|
|
112
|
+
'Ctrl+Tab', 'Ctrl+Shift+Tab',
|
|
113
|
+
'Ctrl+Q',
|
|
114
|
+
'Ctrl+Shift+I', 'Ctrl+Shift+J', 'Ctrl+Shift+C',
|
|
115
|
+
'F11', 'F12',
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
/** Does the browser eat this combo before the page can see it? */
|
|
119
|
+
export function isReservedCombo(combo: string): boolean {
|
|
120
|
+
return RESERVED_COMBOS.has(combo);
|
|
121
|
+
}
|
|
122
|
+
|
|
80
123
|
/** Registry order = help/settings display order. */
|
|
81
124
|
export const SHORTCUT_ACTIONS: ShortcutActionDef[] = [
|
|
82
125
|
// Navigation
|
|
@@ -109,6 +152,25 @@ export const SHORTCUT_ACTIONS: ShortcutActionDef[] = [
|
|
|
109
152
|
{ id: 'tab-next', defaultCombo: 'Ctrl+Tab', labelKey: 'shortcuts.tab_next', groupKey: 'shortcuts.group.tabs' },
|
|
110
153
|
{ id: 'tab-prev', defaultCombo: 'Ctrl+Shift+Tab', labelKey: 'shortcuts.tab_prev', groupKey: 'shortcuts.group.tabs' },
|
|
111
154
|
/* /wiring:d1 */
|
|
155
|
+
/* tus:t1 — the rest of the menu verbs. Until now these were reachable only
|
|
156
|
+
* by right-clicking, and the right-click menu named no keys at all, so 16 of
|
|
157
|
+
* the registry's actions were invisible unless somebody opened the `?` sheet
|
|
158
|
+
* on their own. The ones with no default are deliberate: they are rare
|
|
159
|
+
* enough that taking a key from the user by default is the wrong trade, and
|
|
160
|
+
* they are in the registry so the user can take one. */
|
|
161
|
+
{ id: 'new-folder', defaultCombo: 'Shift+N', labelKey: 'shortcuts.new_folder', groupKey: 'shortcuts.group.file' },
|
|
162
|
+
{ id: 'upload', defaultCombo: 'U', labelKey: 'shortcuts.upload', groupKey: 'shortcuts.group.file' },
|
|
163
|
+
{ id: 'refresh', defaultCombo: 'R', labelKey: 'shortcuts.refresh', groupKey: 'shortcuts.group.nav' },
|
|
164
|
+
{ id: 'download', defaultCombo: 'D', labelKey: 'shortcuts.download', groupKey: 'shortcuts.group.file' },
|
|
165
|
+
{ id: 'preview', defaultCombo: 'P', labelKey: 'shortcuts.preview', groupKey: 'shortcuts.group.nav' },
|
|
166
|
+
{ id: 'share', defaultCombo: 'Shift+S', labelKey: 'shortcuts.share', groupKey: 'shortcuts.group.file' },
|
|
167
|
+
{ id: 'tags', defaultCombo: 'T', labelKey: 'shortcuts.tags', groupKey: 'shortcuts.group.file' },
|
|
168
|
+
{ id: 'convert', defaultCombo: '', labelKey: 'shortcuts.convert', groupKey: 'shortcuts.group.file' },
|
|
169
|
+
{ id: 'open-tab', defaultCombo: '', labelKey: 'shortcuts.open_tab', groupKey: 'shortcuts.group.tabs' },
|
|
170
|
+
{ id: 'copy-path', defaultCombo: '', labelKey: 'shortcuts.copy_path', groupKey: 'shortcuts.group.file' },
|
|
171
|
+
{ id: 'copy-id', defaultCombo: '', labelKey: 'shortcuts.copy_id', groupKey: 'shortcuts.group.file' },
|
|
172
|
+
{ id: 'restore', defaultCombo: '', labelKey: 'shortcuts.restore', groupKey: 'shortcuts.group.file' },
|
|
173
|
+
/* /tus:t1 */
|
|
112
174
|
];
|
|
113
175
|
|
|
114
176
|
/** action id → ShortcutHandlers callback name. */
|
|
@@ -134,6 +196,19 @@ const HANDLER_KEY: Record<string, keyof ShortcutHandlers> = {
|
|
|
134
196
|
'tab-close': 'onTabClose',
|
|
135
197
|
'tab-next': 'onTabNext',
|
|
136
198
|
'tab-prev': 'onTabPrev',
|
|
199
|
+
/* tus:t1 */
|
|
200
|
+
'new-folder': 'onNewFolder',
|
|
201
|
+
upload: 'onUpload',
|
|
202
|
+
refresh: 'onRefresh',
|
|
203
|
+
download: 'onDownload',
|
|
204
|
+
preview: 'onPreview',
|
|
205
|
+
share: 'onShare',
|
|
206
|
+
tags: 'onTags',
|
|
207
|
+
convert: 'onConvert',
|
|
208
|
+
'open-tab': 'onOpenTab',
|
|
209
|
+
'copy-path': 'onCopyPath',
|
|
210
|
+
'copy-id': 'onCopyId',
|
|
211
|
+
restore: 'onRestore',
|
|
137
212
|
};
|
|
138
213
|
|
|
139
214
|
// --------------------------------------------------------------------
|
|
@@ -336,6 +411,53 @@ export function comboLabel(combo: string): string {
|
|
|
336
411
|
return isMacLike() ? combo.replace(/\bCtrl\b/g, '⌘') : combo;
|
|
337
412
|
}
|
|
338
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Menu action key → registry action id.
|
|
416
|
+
*
|
|
417
|
+
* The right-click menu, the toolbar and the keyboard all name the same verbs,
|
|
418
|
+
* but the menu's `key` and the registry's `id` are separate vocabularies and
|
|
419
|
+
* two of them genuinely differ (`access` is the share dialog, `details` is the
|
|
420
|
+
* inspector). One map, in one place: a second copy is a second chance to
|
|
421
|
+
* forget, and the symptom would be a menu row that silently stops naming its
|
|
422
|
+
* key while every other row keeps working.
|
|
423
|
+
*/
|
|
424
|
+
export const MENU_ACTION_SHORTCUTS: Record<string, string> = {
|
|
425
|
+
open: 'open',
|
|
426
|
+
'open-tab': 'open-tab',
|
|
427
|
+
preview: 'preview',
|
|
428
|
+
download: 'download',
|
|
429
|
+
convert: 'convert',
|
|
430
|
+
access: 'share',
|
|
431
|
+
details: 'inspector',
|
|
432
|
+
'copy-id': 'copy-id',
|
|
433
|
+
'copy-path': 'copy-path',
|
|
434
|
+
rename: 'rename',
|
|
435
|
+
cut: 'cut',
|
|
436
|
+
copy: 'copy',
|
|
437
|
+
paste: 'paste',
|
|
438
|
+
star: 'star',
|
|
439
|
+
tags: 'tags',
|
|
440
|
+
delete: 'delete',
|
|
441
|
+
restore: 'restore',
|
|
442
|
+
'new-folder': 'new-folder',
|
|
443
|
+
'toggle-hidden': 'toggle-hidden',
|
|
444
|
+
upload: 'upload',
|
|
445
|
+
refresh: 'refresh',
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* What a menu row or a toolbar button should print beside its label: the
|
|
450
|
+
* current combo for the verb it triggers, or '' when it has none (unbound, or
|
|
451
|
+
* not a registry action at all — `keep-local`, the E2E entries and so on).
|
|
452
|
+
*/
|
|
453
|
+
export function menuShortcutHint(menuKey: string): string {
|
|
454
|
+
/* Fall back to the key itself: the toolbar's own buttons (go-up, upload,
|
|
455
|
+
* refresh…) pass a registry id directly, and an id nothing knows returns ''
|
|
456
|
+
* rather than throwing. */
|
|
457
|
+
const id = MENU_ACTION_SHORTCUTS[menuKey] ?? menuKey;
|
|
458
|
+
return SHORTCUT_ACTIONS.some((a) => a.id === id) ? shortcutHint(id) : '';
|
|
459
|
+
}
|
|
460
|
+
|
|
339
461
|
/**
|
|
340
462
|
* The one way a hint may name a key.
|
|
341
463
|
*
|