@brftech/filex-core 0.42.2 → 0.43.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 +14 -2
- package/dist/ArchiveViewer-BqU761Wk.js +149 -0
- package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
- package/dist/CsvViewer-ByHVK5ga.js +172 -0
- package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
- package/dist/{DrawioViewer-CC6rJXeP.js → DrawioViewer-Bgrj8-zG.js} +45 -44
- package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
- package/dist/{EpubViewer-CAoTSXTO.js → EpubViewer-DJwUKlHW.js} +50 -50
- package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
- package/dist/MermaidViewer-CEDTD_pp.js +136 -0
- package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
- package/dist/PsdViewer-CZcxUpZO.js +121 -0
- package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
- package/dist/TiffViewer-BwI62CbQ.js +141 -0
- package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
- package/dist/{Viewer3D-CdZeK4j4.js → Viewer3D-DoeZcuMm.js} +16 -16
- package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
- package/dist/filex-core.js +475 -219
- package/dist/filex-core.umd.cjs +165 -108
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/fonts/caveat-latin-ext.woff2 +0 -0
- package/dist/fonts/caveat-latin.woff2 +0 -0
- package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/dist/fonts/dancing-script-latin.woff2 +0 -0
- package/dist/fonts/homemade-apple-latin.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin.woff2 +0 -0
- package/dist/index-DuWnGuv5.js +35901 -0
- package/dist/index-DuWnGuv5.js.map +1 -0
- package/dist/index.d.ts +5227 -553
- package/dist/pdf-CaPYsOUo.js +21302 -0
- package/dist/pdf-CaPYsOUo.js.map +1 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +1055 -253
- package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
- package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
- package/src/assets/fonts/README.md +58 -0
- package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
- package/src/assets/fonts/caveat-latin.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
- package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
- package/src/components/AdvancedSearch.vue +49 -0
- package/src/components/Breadcrumb.vue +5 -5
- package/src/components/ChoiceButtons.vue +152 -0
- package/src/components/CommandPalette.vue +5 -5
- package/src/components/ConnectionGuideView.vue +1 -1
- package/src/components/ConnectionNotice.vue +59 -0
- package/src/components/ConnectionsPanel.vue +30 -568
- package/src/components/ContextMenu.vue +31 -5
- package/src/components/DataTable.vue +1331 -0
- package/src/components/FilePane.vue +3 -0
- package/src/components/FilterBar.vue +12 -5
- package/src/components/GalleryView.vue +50 -2
- package/src/components/GridView.vue +51 -3
- package/src/components/HomeView.vue +18 -6
- package/src/components/InspectorPanel.vue +134 -17
- package/src/components/ListView.vue +366 -900
- package/src/components/LogoMark.vue +57 -0
- package/src/components/NFSExportsPanel.vue +76 -28
- package/src/components/OnboardingTour.vue +19 -7
- package/src/components/OperationsCenter.vue +77 -1
- package/src/components/PendingOpsTray.vue +59 -5
- package/src/components/PresenceBar.vue +7 -7
- package/src/components/ProductVersion.vue +31 -0
- package/src/components/QuickLook.vue +10 -14
- package/src/components/RecentlyOpened.vue +31 -15
- package/src/components/RecoveryKeyModal.vue +1 -1
- package/src/components/RowActions.vue +134 -0
- package/src/components/S3KeysPanel.vue +98 -62
- package/src/components/SSHKeysPanel.vue +79 -30
- package/src/components/SideNav.vue +260 -96
- package/src/components/StarButton.vue +1 -0
- package/src/components/StorageFields.vue +74 -6
- package/src/components/StorageTags.vue +62 -0
- package/src/components/TabBar.vue +1 -1
- package/src/components/TagKindIcon.vue +56 -0
- package/src/components/TagPicker.vue +226 -40
- package/src/components/ThemePalette.vue +4 -4
- package/src/components/TimeZonePicker.vue +29 -10
- package/src/components/TokensPanel.vue +82 -31
- package/src/components/Toolbar.vue +28 -6
- package/src/components/plugin/PluginConfirmModal.vue +54 -0
- package/src/components/plugin/PluginInspectorSection.vue +149 -0
- package/src/components/plugin/PluginPageView.vue +339 -0
- package/src/components/plugin/PluginViewModal.vue +159 -0
- package/src/components/plugin/SurfaceConversation.vue +76 -0
- package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
- package/src/components/plugin/SurfaceRenderer.vue +299 -0
- package/src/components/plugin/SurfaceSections.vue +91 -0
- package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
- package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
- package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
- package/src/components/plugin/nodes/SurfaceList.vue +127 -0
- package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
- package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
- package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
- package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
- package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
- package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
- package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
- package/src/components/public/PublicLanguagePicker.vue +58 -0
- package/src/components/public/PublicLinkPage.vue +286 -0
- package/src/components/public/PublicLinkPreview.vue +85 -0
- package/src/components/public/PublicPinGate.vue +108 -0
- package/src/components/public/PublicRequestBody.vue +204 -0
- package/src/components/public/PublicShareBody.vue +188 -0
- package/src/components/public/PublicShell.vue +332 -0
- package/src/composables/useConnections.ts +8 -2
- package/src/composables/useFileApi.ts +183 -44
- package/src/composables/useLocale.ts +277 -41
- package/src/composables/useNFSExports.ts +8 -10
- package/src/composables/useOperations.ts +44 -3
- package/src/composables/usePendingOps.ts +59 -8
- package/src/composables/usePluginActions.ts +121 -0
- package/src/composables/usePluginSurface.ts +223 -0
- package/src/composables/usePublicBranding.ts +172 -0
- package/src/composables/usePublicLink.ts +573 -0
- package/src/composables/usePublicPage.ts +61 -0
- package/src/composables/useS3Keys.ts +17 -12
- package/src/composables/useSSHKeys.ts +8 -10
- package/src/composables/useTokens.ts +8 -12
- package/src/composables/useUploadChunked.ts +18 -8
- package/src/composables/useViewerFetch.ts +6 -3
- package/src/index.ts +404 -8
- package/src/lib/actionIcons.ts +46 -1
- package/src/lib/advSearch.ts +14 -7
- package/src/lib/appLock.ts +130 -0
- package/src/lib/connection.ts +110 -0
- package/src/lib/destinationTree.ts +19 -3
- package/src/lib/direction.ts +353 -0
- package/src/lib/dragGhost.ts +6 -0
- package/src/lib/dropLimits.ts +69 -0
- package/src/lib/errorWords.ts +394 -0
- package/src/lib/externalReach.ts +5 -1
- package/src/lib/fileFilters.ts +7 -2
- package/src/lib/fileIcons.ts +4 -0
- package/src/lib/internalPaths.ts +77 -0
- package/src/lib/listInput.ts +64 -0
- package/src/lib/listing.ts +58 -23
- package/src/lib/modalStack.ts +26 -0
- package/src/lib/nodeRow.ts +18 -3
- package/src/lib/pdfFieldRules.ts +126 -0
- package/src/lib/pdfFields.ts +589 -0
- package/src/lib/pdfFieldsGeom.ts +203 -0
- package/src/lib/pdfjsLoader.ts +51 -0
- package/src/lib/personName.ts +47 -0
- package/src/lib/pluginApplies.ts +123 -0
- package/src/lib/pluginLabel.ts +72 -0
- package/src/lib/pluginMenu.ts +253 -0
- package/src/lib/pluginPage.ts +58 -0
- package/src/lib/plural.ts +181 -0
- package/src/lib/popupLayer.ts +103 -0
- package/src/lib/prefs.ts +563 -0
- package/src/lib/productVersion.ts +35 -0
- package/src/lib/publicLayout.ts +39 -0
- package/src/lib/serviceGate.ts +104 -0
- package/src/lib/shareTtl.ts +25 -11
- package/src/lib/signFonts.ts +85 -0
- package/src/lib/signaturePad.ts +111 -0
- package/src/lib/sortOrder.ts +100 -38
- package/src/lib/star.ts +7 -1
- package/src/lib/surfaceCell.ts +41 -0
- package/src/lib/surfaceConditions.ts +156 -0
- package/src/lib/surfaceOpen.ts +106 -0
- package/src/lib/surfaceSteps.ts +68 -0
- package/src/lib/surfaceValues.ts +191 -0
- package/src/lib/symlink.ts +150 -0
- package/src/lib/tableColumns.ts +493 -0
- package/src/lib/tableEnv.ts +34 -0
- package/src/lib/tablePrefs.ts +84 -0
- package/src/lib/tags.ts +121 -12
- package/src/lib/themes.ts +286 -18
- package/src/lib/tour.ts +152 -0
- package/src/lib/trashEmpty.ts +111 -0
- package/src/lib/trashTimeLeft.ts +23 -0
- package/src/lib/uiLocales.ts +460 -0
- package/src/lib/viewPrefs.ts +696 -660
- package/src/lib/viewPrefsHttp.ts +78 -0
- package/src/locales/en.ts +515 -117
- package/src/locales/resolve.ts +17 -8
- package/src/locales/tr.ts +536 -152
- package/src/modals/ConvertModal.vue +62 -11
- package/src/modals/DestinationPickerModal.vue +59 -16
- package/src/modals/Modal.vue +113 -23
- package/src/modals/NewDocumentModal.vue +16 -6
- package/src/modals/NewFolderModal.vue +8 -0
- package/src/modals/PermissionsModal.vue +272 -163
- package/src/modals/PreviewModal.vue +119 -20
- package/src/modals/RenameModal.vue +37 -1
- package/src/styles/base.css +2890 -260
- package/src/styles/sign-fonts.css +118 -0
- package/src/styles/variables.css +60 -0
- package/src/types/Connections.ts +17 -0
- package/src/types/ExplorerConfig.ts +117 -1
- package/src/types/FileNode.ts +50 -0
- package/src/types/Plugins.ts +494 -0
- package/src/types/Public.ts +212 -0
- package/src/viewers/ArchiveViewer.vue +93 -48
- package/src/viewers/CsvViewer.vue +99 -53
- package/src/viewers/DrawioViewer.vue +35 -9
- package/src/viewers/EpubViewer.vue +7 -8
- package/src/viewers/IpynbViewer.vue +3 -1
- package/src/viewers/MermaidViewer.vue +4 -5
- package/src/viewers/PdfViewer.vue +8 -17
- package/src/viewers/PsdViewer.vue +2 -4
- package/src/viewers/TiffViewer.vue +4 -5
- package/src/viewers/Viewer3D.vue +1 -1
- package/LICENSE +0 -21
- package/dist/ArchiveViewer-BUQWLhwX.js +0 -124
- package/dist/ArchiveViewer-BUQWLhwX.js.map +0 -1
- package/dist/CsvViewer-Bg2_aqzc.js +0 -141
- package/dist/CsvViewer-Bg2_aqzc.js.map +0 -1
- package/dist/DrawioViewer-CC6rJXeP.js.map +0 -1
- package/dist/EpubViewer-CAoTSXTO.js.map +0 -1
- package/dist/IpynbViewer-D0GwGpnE.js +0 -184
- package/dist/IpynbViewer-D0GwGpnE.js.map +0 -1
- package/dist/MermaidViewer-DGwAvRZg.js +0 -137
- package/dist/MermaidViewer-DGwAvRZg.js.map +0 -1
- package/dist/PsdViewer-DhGweA0m.js +0 -122
- package/dist/PsdViewer-DhGweA0m.js.map +0 -1
- package/dist/TiffViewer-D0AurvAh.js +0 -142
- package/dist/TiffViewer-D0AurvAh.js.map +0 -1
- package/dist/Viewer3D-CdZeK4j4.js.map +0 -1
- package/dist/index-BArI-B1M.js +0 -26911
- package/dist/index-BArI-B1M.js.map +0 -1
- package/dist/useViewerFetch-czqbd2Lj.js +0 -25
- package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
- package/src/composables/useScrolledX.ts +0 -21
|
@@ -29,6 +29,8 @@ const props = defineProps<{
|
|
|
29
29
|
/** Upload the produced File into the current folder (api.uploadMultipart bound to dir). */
|
|
30
30
|
upload: (file: File) => Promise<void>;
|
|
31
31
|
locale: LocaleCode;
|
|
32
|
+
/** For an administrator: this converter is being retired, and by what. */
|
|
33
|
+
adminNote?: string;
|
|
32
34
|
}>();
|
|
33
35
|
|
|
34
36
|
const { t } = useLocale(() => props.locale);
|
|
@@ -96,10 +98,34 @@ function send(cmd: string, extra: Record<string, unknown> = {}, transfer: Transf
|
|
|
96
98
|
});
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
/**
|
|
102
|
+
* ⚠ The converter has to SAY it is there. The iframe posts `ready` when it
|
|
103
|
+
* has loaded; a converter that is down never does, and this modal used to sit
|
|
104
|
+
* on "Loading the converter…" for ever. 20 s is far longer than a healthy
|
|
105
|
+
* converter needs to boot and short enough that a person is not left
|
|
106
|
+
* wondering.
|
|
107
|
+
*/
|
|
108
|
+
const READY_TIMEOUT_MS = 20000;
|
|
109
|
+
let readyTimer: ReturnType<typeof setTimeout> | undefined;
|
|
110
|
+
|
|
111
|
+
/** Every failure a person sees here is a sentence (owner, 2026-09-21: never
|
|
112
|
+
* the raw status or body — which is what `fetchBytes`/`upload` errors carry,
|
|
113
|
+
* `${status} ${statusText} — <json>`). The detail goes to the console. */
|
|
114
|
+
function failWith(key: string, detail: unknown): void {
|
|
115
|
+
console.warn('[filex] convert:', key, detail);
|
|
116
|
+
error.value = t(key);
|
|
117
|
+
status.value = 'error';
|
|
118
|
+
}
|
|
119
|
+
|
|
99
120
|
function onMessage(ev: MessageEvent) {
|
|
100
121
|
const d = ev.data;
|
|
101
122
|
if (!d || d.source !== 'convert-embed') return;
|
|
102
|
-
if (d.event === 'ready') {
|
|
123
|
+
if (d.event === 'ready') {
|
|
124
|
+
if (readyTimer) clearTimeout(readyTimer);
|
|
125
|
+
readyTimer = undefined;
|
|
126
|
+
void loadFormats();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
103
129
|
const p = pending.get(d.id);
|
|
104
130
|
if (!p) return;
|
|
105
131
|
pending.delete(d.id);
|
|
@@ -115,8 +141,7 @@ async function loadFormats() {
|
|
|
115
141
|
error.value = t('convert.unsupported_input');
|
|
116
142
|
}
|
|
117
143
|
} catch (e) {
|
|
118
|
-
|
|
119
|
-
status.value = 'error';
|
|
144
|
+
failWith('convert.unreachable', e);
|
|
120
145
|
}
|
|
121
146
|
}
|
|
122
147
|
|
|
@@ -124,8 +149,10 @@ async function doConvert() {
|
|
|
124
149
|
if (!selectedTo.value || !fromFmt.value) return;
|
|
125
150
|
status.value = 'converting';
|
|
126
151
|
error.value = null;
|
|
152
|
+
let stage: 'read' | 'convert' | 'save' = 'read';
|
|
127
153
|
try {
|
|
128
154
|
const buf = await props.fetchBytes();
|
|
155
|
+
stage = 'convert';
|
|
129
156
|
const res = await send(
|
|
130
157
|
'convert',
|
|
131
158
|
{ name: props.fileName, bytes: buf, fromIndex: fromFmt.value.index, toIndex: selectedTo.value.index },
|
|
@@ -135,17 +162,28 @@ async function doConvert() {
|
|
|
135
162
|
const ext = res.ext || selectedTo.value.ext || selectedTo.value.format;
|
|
136
163
|
const outName = `${base}.${ext}`;
|
|
137
164
|
const file = new File([res.bytes], outName, { type: selectedTo.value.mime || 'application/octet-stream' });
|
|
165
|
+
stage = 'save';
|
|
138
166
|
await props.upload(file);
|
|
139
167
|
status.value = 'done';
|
|
140
168
|
emit('done', outName);
|
|
141
169
|
} catch (e) {
|
|
142
|
-
|
|
143
|
-
|
|
170
|
+
failWith(
|
|
171
|
+
stage === 'read' ? 'convert.read_failed' : stage === 'save' ? 'convert.save_failed' : 'convert.failed',
|
|
172
|
+
e,
|
|
173
|
+
);
|
|
144
174
|
}
|
|
145
175
|
}
|
|
146
176
|
|
|
147
|
-
onMounted(() =>
|
|
148
|
-
|
|
177
|
+
onMounted(() => {
|
|
178
|
+
window.addEventListener('message', onMessage);
|
|
179
|
+
readyTimer = setTimeout(() => {
|
|
180
|
+
if (status.value === 'loading') failWith('convert.unreachable', 'no ready message');
|
|
181
|
+
}, READY_TIMEOUT_MS);
|
|
182
|
+
});
|
|
183
|
+
onBeforeUnmount(() => {
|
|
184
|
+
window.removeEventListener('message', onMessage);
|
|
185
|
+
if (readyTimer) clearTimeout(readyTimer);
|
|
186
|
+
});
|
|
149
187
|
</script>
|
|
150
188
|
|
|
151
189
|
<template>
|
|
@@ -165,10 +203,18 @@ onBeforeUnmount(() => window.removeEventListener('message', onMessage));
|
|
|
165
203
|
</button>
|
|
166
204
|
</header>
|
|
167
205
|
|
|
206
|
+
<p v-if="adminNote" class="filex-cv__note" data-testid="convert-legacy-note">{{ adminNote }}</p>
|
|
207
|
+
|
|
168
208
|
<div v-if="status === 'loading'" class="filex-cv__msg">
|
|
169
209
|
{{ t('convert.loading') }}
|
|
170
210
|
</div>
|
|
171
211
|
|
|
212
|
+
<!-- The converter never answered: there is nothing to pick from, so the
|
|
213
|
+
sentence stands alone rather than above an empty format list. -->
|
|
214
|
+
<div v-else-if="status === 'error' && formats.length === 0" class="filex-cv__msg filex-cv__err">
|
|
215
|
+
{{ error }}
|
|
216
|
+
</div>
|
|
217
|
+
|
|
172
218
|
<div v-else-if="status === 'done'" class="filex-cv__msg filex-cv__ok">
|
|
173
219
|
<p>
|
|
174
220
|
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
|
|
@@ -195,7 +241,7 @@ onBeforeUnmount(() => window.removeEventListener('message', onMessage));
|
|
|
195
241
|
@click="selectedTo = f"
|
|
196
242
|
>
|
|
197
243
|
<b>{{ (f.ext || f.format).toUpperCase() }}</b>
|
|
198
|
-
<small>{{ f.name }}</small>
|
|
244
|
+
<small><bdi>{{ f.name }}</bdi></small>
|
|
199
245
|
</button>
|
|
200
246
|
<div v-if="toList.length === 0" class="filex-cv__empty">
|
|
201
247
|
{{ t('convert.no_format') }}
|
|
@@ -253,6 +299,11 @@ onBeforeUnmount(() => window.removeEventListener('message', onMessage));
|
|
|
253
299
|
.filex-cv__x .fe-aicon { color: currentColor; }
|
|
254
300
|
.filex-cv__ok .fe-icon { display: inline-flex; vertical-align: -0.2em; }
|
|
255
301
|
.filex-cv__msg { padding: 28px; text-align: center; color: var(--fe-text-muted, #5a6475); }
|
|
302
|
+
.filex-cv__note {
|
|
303
|
+
margin: 12px 16px 0; padding: 8px 10px; border-radius: 8px; font-size: 12.5px; line-height: 1.45;
|
|
304
|
+
background: var(--fe-bg-elev, #f7f8fa); color: var(--fe-text-muted, #5a6475);
|
|
305
|
+
border: 1px solid var(--fe-border, #e2e6ed);
|
|
306
|
+
}
|
|
256
307
|
.filex-cv__ok { color: #059669; }
|
|
257
308
|
.filex-cv__src { padding: 12px 16px 0; font-size: 13px; color: var(--fe-text-muted, #5a6475); }
|
|
258
309
|
.filex-cv__search {
|
|
@@ -266,7 +317,7 @@ onBeforeUnmount(() => window.removeEventListener('message', onMessage));
|
|
|
266
317
|
}
|
|
267
318
|
.filex-cv__fmt {
|
|
268
319
|
display: flex; flex-direction: column; gap: 2px; align-items: flex-start;
|
|
269
|
-
padding: 8px 10px; cursor: pointer; text-align:
|
|
320
|
+
padding: 8px 10px; cursor: pointer; text-align: start;
|
|
270
321
|
border: 1px solid var(--fe-border, #e2e6ed); border-radius: 8px;
|
|
271
322
|
background: var(--fe-bg-elev, #f7f8fa); color: inherit;
|
|
272
323
|
}
|
|
@@ -274,11 +325,11 @@ onBeforeUnmount(() => window.removeEventListener('message', onMessage));
|
|
|
274
325
|
.filex-cv__fmt.is-sel { border-color: #44c878; background: rgba(68, 200, 120, 0.12); }
|
|
275
326
|
.filex-cv__empty { grid-column: 1 / -1; text-align: center; color: var(--fe-text-muted, #5a6475); padding: 16px; }
|
|
276
327
|
.filex-cv__err { padding: 0 16px; color: var(--fe-danger, #dc2626); font-size: 13px; }
|
|
277
|
-
.filex-cv__foot { padding: 12px 16px; border-top: 1px solid var(--fe-border, #e2e6ed); text-align:
|
|
328
|
+
.filex-cv__foot { padding: 12px 16px; border-top: 1px solid var(--fe-border, #e2e6ed); text-align: end; }
|
|
278
329
|
.filex-cv__convert {
|
|
279
330
|
padding: 8px 18px; border: 0; border-radius: 8px; cursor: pointer;
|
|
280
331
|
background: #44c878; color: #03200f; font-weight: 600; font-size: 13px;
|
|
281
332
|
}
|
|
282
333
|
.filex-cv__convert:disabled { opacity: 0.5; cursor: default; }
|
|
283
|
-
.filex-cv__frame { position: absolute; width: 0; height: 0; border: 0;
|
|
334
|
+
.filex-cv__frame { position: absolute; width: 0; height: 0; border: 0; inset-inline-start: -9999px; }
|
|
284
335
|
</style>
|
|
@@ -42,7 +42,7 @@ import Modal from './Modal.vue';
|
|
|
42
42
|
import { useLocale } from '../composables/useLocale';
|
|
43
43
|
import type { FileApi, ManagerResponse } from '../composables/useFileApi';
|
|
44
44
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
45
|
-
import { iconTile } from '../lib/fileIcons';
|
|
45
|
+
import { fileIconTile, iconTile } from '../lib/fileIcons';
|
|
46
46
|
import {
|
|
47
47
|
DRIVES,
|
|
48
48
|
type DestinationRow,
|
|
@@ -72,6 +72,13 @@ const props = defineProps<{
|
|
|
72
72
|
moving?: string[];
|
|
73
73
|
/** Titles the dialog and names its confirm button. */
|
|
74
74
|
mode?: 'move' | 'copy' | 'choose';
|
|
75
|
+
/**
|
|
76
|
+
* What the answer is. `dir` (the default) is the folder you are standing in;
|
|
77
|
+
* `file` lists the files too and the answer is the one you tick — an app
|
|
78
|
+
* plugin's `file-chooser` asks for this. A file pick has no write check:
|
|
79
|
+
* the chooser reads, it never writes.
|
|
80
|
+
*/
|
|
81
|
+
pick?: 'dir' | 'file';
|
|
75
82
|
/** The parent is running the operation: the dialog stays up but inert. */
|
|
76
83
|
busy?: boolean;
|
|
77
84
|
}>();
|
|
@@ -91,6 +98,10 @@ const here = ref<ManagerResponse | undefined>(undefined);
|
|
|
91
98
|
const failed = ref(false);
|
|
92
99
|
|
|
93
100
|
const multiDrive = computed(() => (props.storages?.length ?? 0) > 1);
|
|
101
|
+
const drivesLabel = computed(() => t('destpicker.drives'));
|
|
102
|
+
const pickingFile = computed(() => props.pick === 'file');
|
|
103
|
+
/** The file ticked in this folder — file pick only. */
|
|
104
|
+
const picked = ref<string | null>(null);
|
|
94
105
|
|
|
95
106
|
/**
|
|
96
107
|
* One listing cache for the life of the dialog.
|
|
@@ -129,6 +140,7 @@ async function load(path: string): Promise<ManagerResponse | undefined> {
|
|
|
129
140
|
async function goTo(path: string): Promise<void> {
|
|
130
141
|
at.value = path;
|
|
131
142
|
failed.value = false;
|
|
143
|
+
picked.value = null;
|
|
132
144
|
if (path === DRIVES) {
|
|
133
145
|
here.value = undefined;
|
|
134
146
|
rows.value = driveRows(props.storages, props.moving);
|
|
@@ -142,7 +154,7 @@ async function goTo(path: string): Promise<void> {
|
|
|
142
154
|
loading.value = false;
|
|
143
155
|
here.value = resp;
|
|
144
156
|
failed.value = !resp;
|
|
145
|
-
rows.value = destinationRows(resp?.files, props.moving);
|
|
157
|
+
rows.value = destinationRows(resp?.files, props.moving, { files: pickingFile.value });
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
const parent = computed(() => parentOfWire(at.value, multiDrive.value));
|
|
@@ -158,9 +170,11 @@ const hereWritable = computed(() => {
|
|
|
158
170
|
return permAllowsWrite(here.value.perm as string | undefined);
|
|
159
171
|
});
|
|
160
172
|
|
|
161
|
-
const canChoose = computed(
|
|
162
|
-
(
|
|
163
|
-
);
|
|
173
|
+
const canChoose = computed(() => {
|
|
174
|
+
if (props.busy || at.value === DRIVES) return false;
|
|
175
|
+
if (pickingFile.value) return picked.value !== null;
|
|
176
|
+
return hereWritable.value && hereBlocked.value === null;
|
|
177
|
+
});
|
|
164
178
|
|
|
165
179
|
/**
|
|
166
180
|
* Is the line under the list a REFUSAL or a prompt?
|
|
@@ -170,11 +184,15 @@ const canChoose = computed(
|
|
|
170
184
|
* colour, which is what happened the first time this was drawn, makes the very
|
|
171
185
|
* first screen of the dialog look like something has gone wrong.
|
|
172
186
|
*/
|
|
173
|
-
const reasonIsRefusal = computed(() => at.value !== DRIVES);
|
|
187
|
+
const reasonIsRefusal = computed(() => at.value !== DRIVES && !(pickingFile.value && !failed.value));
|
|
174
188
|
|
|
175
189
|
/** The one line under the list that says why Choose is off, if it is. */
|
|
176
190
|
const reason = computed<string>(() => {
|
|
177
191
|
if (at.value === DRIVES) return t('destpicker.pick_a_drive');
|
|
192
|
+
if (pickingFile.value) {
|
|
193
|
+
if (failed.value) return t('destpicker.unreadable');
|
|
194
|
+
return picked.value === null ? t('destpicker.pick_a_file') : '';
|
|
195
|
+
}
|
|
178
196
|
if (hereBlocked.value === 'self') return t('destpicker.blocked.self');
|
|
179
197
|
if (hereBlocked.value === 'descendant') return t('destpicker.blocked.descendant');
|
|
180
198
|
if (failed.value) return t('destpicker.unreadable');
|
|
@@ -185,16 +203,28 @@ const reason = computed<string>(() => {
|
|
|
185
203
|
const title = computed(() => {
|
|
186
204
|
if (props.mode === 'move') return t('destpicker.title.move');
|
|
187
205
|
if (props.mode === 'copy') return t('destpicker.title.copy');
|
|
188
|
-
return t('destpicker.title.choose');
|
|
206
|
+
return pickingFile.value ? t('destpicker.title.choose_file') : t('destpicker.title.choose');
|
|
189
207
|
});
|
|
190
208
|
|
|
191
209
|
const confirmLabel = computed(() => {
|
|
192
210
|
if (props.mode === 'move') return t('destpicker.confirm.move');
|
|
193
211
|
if (props.mode === 'copy') return t('destpicker.confirm.copy');
|
|
194
|
-
return t('destpicker.confirm.choose');
|
|
212
|
+
return pickingFile.value ? t('destpicker.confirm.choose_file') : t('destpicker.confirm.choose');
|
|
195
213
|
});
|
|
196
214
|
|
|
215
|
+
/** What the line under the list names: the ticked file, else the folder. */
|
|
216
|
+
const targetName = computed(() =>
|
|
217
|
+
pickingFile.value && picked.value !== null
|
|
218
|
+
? labelOfWire(picked.value, drivesLabel.value)
|
|
219
|
+
: labelOfWire(at.value, drivesLabel.value),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
function rowIcon(row: DestinationRow): string {
|
|
223
|
+
return row.kind === 'file' ? fileIconTile({ type: 'file', basename: row.label }) : iconTile('folder');
|
|
224
|
+
}
|
|
225
|
+
|
|
197
226
|
function rowTitle(row: DestinationRow): string {
|
|
227
|
+
if (row.kind === 'file') return row.label;
|
|
198
228
|
if (row.blocked === 'self') return t('destpicker.blocked.self');
|
|
199
229
|
if (row.blocked === 'descendant') return t('destpicker.blocked.descendant');
|
|
200
230
|
if (!row.writable) return t('destpicker.readonly');
|
|
@@ -202,6 +232,11 @@ function rowTitle(row: DestinationRow): string {
|
|
|
202
232
|
}
|
|
203
233
|
|
|
204
234
|
function openRow(row: DestinationRow): void {
|
|
235
|
+
// A file is the answer, not a place to go.
|
|
236
|
+
if (row.kind === 'file') {
|
|
237
|
+
picked.value = row.path;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
205
240
|
// A blocked row is a dead end — everything under it is blocked too — so
|
|
206
241
|
// walking into it would be navigation that cannot end anywhere.
|
|
207
242
|
if (row.blocked) return;
|
|
@@ -210,10 +245,8 @@ function openRow(row: DestinationRow): void {
|
|
|
210
245
|
|
|
211
246
|
function choose(): void {
|
|
212
247
|
if (!canChoose.value) return;
|
|
213
|
-
emit('pick', at.value);
|
|
248
|
+
emit('pick', pickingFile.value && picked.value !== null ? picked.value : at.value);
|
|
214
249
|
}
|
|
215
|
-
|
|
216
|
-
const drivesLabel = computed(() => t('destpicker.drives'));
|
|
217
250
|
</script>
|
|
218
251
|
|
|
219
252
|
<template>
|
|
@@ -278,22 +311,32 @@ const drivesLabel = computed(() => t('destpicker.drives'));
|
|
|
278
311
|
<ul class="fe-destpick__rows" data-testid="destpicker-rows">
|
|
279
312
|
<li v-if="loading" class="fe-destpick__note">{{ t('destpicker.loading') }}</li>
|
|
280
313
|
<li v-else-if="failed" class="fe-destpick__note">{{ t('destpicker.unreadable') }}</li>
|
|
281
|
-
<li v-else-if="!rows.length" class="fe-destpick__note">
|
|
314
|
+
<li v-else-if="!rows.length" class="fe-destpick__note">
|
|
315
|
+
{{ pickingFile ? t('destpicker.empty_files') : t('destpicker.empty') }}
|
|
316
|
+
</li>
|
|
282
317
|
<li v-for="row in rows" :key="row.path">
|
|
283
318
|
<button
|
|
284
319
|
type="button"
|
|
285
320
|
class="fe-destpick__row"
|
|
286
|
-
:class="{
|
|
321
|
+
:class="{
|
|
322
|
+
'is-locked': !row.writable,
|
|
323
|
+
'is-blocked': !!row.blocked,
|
|
324
|
+
'is-file': row.kind === 'file',
|
|
325
|
+
'is-picked': row.kind === 'file' && picked === row.path,
|
|
326
|
+
}"
|
|
287
327
|
:disabled="!!row.blocked"
|
|
288
328
|
:title="rowTitle(row)"
|
|
329
|
+
:aria-pressed="row.kind === 'file' ? picked === row.path : undefined"
|
|
289
330
|
:data-testid="'destpicker-row-' + row.label"
|
|
290
331
|
@click="openRow(row)"
|
|
291
332
|
>
|
|
292
|
-
|
|
293
|
-
<span class="fe-
|
|
333
|
+
<!-- eslint-disable-next-line vue/no-v-html -- static markup from lib/fileIcons -->
|
|
334
|
+
<span class="fe-destpick__rowicon" aria-hidden="true" v-html="rowIcon(row)"></span>
|
|
335
|
+
<span class="fe-destpick__rowname"><bdi>{{ row.label }}</bdi></span>
|
|
294
336
|
<span v-if="row.blocked" class="fe-destpick__rowtag">{{ t('destpicker.tag.blocked') }}</span>
|
|
295
337
|
<span v-else-if="!row.writable" class="fe-destpick__rowtag">{{ t('destpicker.readonly') }}</span>
|
|
296
338
|
<svg
|
|
339
|
+
v-if="row.kind === 'dir'"
|
|
297
340
|
class="fe-destpick__rowinto"
|
|
298
341
|
viewBox="0 0 24 24"
|
|
299
342
|
fill="none"
|
|
@@ -319,7 +362,7 @@ const drivesLabel = computed(() => t('destpicker.drives'));
|
|
|
319
362
|
{{ reason }}
|
|
320
363
|
</p>
|
|
321
364
|
<p v-else class="fe-destpick__target" data-testid="destpicker-target">
|
|
322
|
-
{{ t('destpicker.target', { name:
|
|
365
|
+
{{ t('destpicker.target', { name: targetName }) }}
|
|
323
366
|
</p>
|
|
324
367
|
</div>
|
|
325
368
|
|
package/src/modals/Modal.vue
CHANGED
|
@@ -1,20 +1,47 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
3
|
* Modal — tiny headless wrapper. Backdrop + centered card + ESC + autofocus.
|
|
4
|
+
*
|
|
5
|
+
* ⚠⚠ Every dialog the explorer draws is this one — rename, new folder, the
|
|
6
|
+
* delete confirmation, and the frame EVERY app plugin's screen opens in
|
|
7
|
+
* (PluginViewModal). What it does on Escape, on a click outside the card
|
|
8
|
+
* and with the focus is therefore what all of them do, and until v0.43.0
|
|
9
|
+
* it did none of the three for a dialog MOUNTED open:
|
|
10
|
+
*
|
|
11
|
+
* • the keys and the focus were wired in a `watch` on `open` without
|
|
12
|
+
* `immediate`, so a dialog created with `open` already true (the plugin
|
|
13
|
+
* frame is `v-if` + `:open="true"`) never heard Escape and never took
|
|
14
|
+
* the focus — the converter's dialog stayed up on Escape while the share
|
|
15
|
+
* dialog next to it closed (release-candidate sweep, 2026-09-21);
|
|
16
|
+
* • `closeOnBackdrop` was read as `!== false`, but Vue casts an absent
|
|
17
|
+
* boolean prop to `false`, so NO dialog closed on an outside click.
|
|
4
18
|
*/
|
|
5
19
|
import { watch, onBeforeUnmount, ref } from 'vue';
|
|
20
|
+
import { isTopModal, popModal, pushModal } from '../lib/modalStack';
|
|
21
|
+
import { useLocale } from '../composables/useLocale';
|
|
6
22
|
|
|
7
|
-
const props = defineProps<{
|
|
23
|
+
const props = withDefaults(defineProps<{
|
|
24
|
+
/** The explorer's language, for the close button's name. Absent = English. */
|
|
25
|
+
locale?: string;
|
|
8
26
|
open: boolean;
|
|
9
27
|
title?: string;
|
|
10
28
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
|
29
|
+
/** A click outside the card closes the dialog (the default, as the share
|
|
30
|
+
* dialog and the admin panel's dialogs do). `false` for a dialog that
|
|
31
|
+
* must be answered — the recovery key a person has to write down. */
|
|
11
32
|
closeOnBackdrop?: boolean;
|
|
12
33
|
/** When true, drop the dialog chrome (backdrop tint, header, footer,
|
|
13
34
|
* centered card with border-radius) and render the slot full-bleed.
|
|
14
35
|
* Used by the standalone /files/edit route where the browser tab IS
|
|
15
36
|
* the container — a modal frame on top of it just steals real estate
|
|
16
|
-
* from the editor.
|
|
17
|
-
*
|
|
37
|
+
* from the editor.
|
|
38
|
+
*
|
|
39
|
+
* ⚠ Escape does NOT close a chromeless dialog. There it would close the
|
|
40
|
+
* TAB (the route answers `close` with window.close()), and Escape is a
|
|
41
|
+
* key editors use for themselves — dismissing a suggestion list in the
|
|
42
|
+
* text editor must not throw the document away. It never did in
|
|
43
|
+
* practice (the route mounts the dialog open, see above); now it is a
|
|
44
|
+
* rule rather than an accident. */
|
|
18
45
|
chromeless?: boolean;
|
|
19
46
|
/**
|
|
20
47
|
* gorunum:v1 — full-bleed overlay. Like `chromeless` it drops the dialog
|
|
@@ -35,7 +62,10 @@ const props = defineProps<{
|
|
|
35
62
|
* rather than a child of the parent component, so plain DOM
|
|
36
63
|
* inheritance isn't always enough). */
|
|
37
64
|
theme?: 'light' | 'dark' | 'auto';
|
|
38
|
-
}>()
|
|
65
|
+
}>(), {
|
|
66
|
+
closeOnBackdrop: true,
|
|
67
|
+
});
|
|
68
|
+
const { t } = useLocale(() => props.locale ?? 'en');
|
|
39
69
|
|
|
40
70
|
const emit = defineEmits<{
|
|
41
71
|
(e: 'close'): void;
|
|
@@ -54,29 +84,74 @@ const FOCUSABLE =
|
|
|
54
84
|
'input:not([disabled]),select:not([disabled]),textarea:not([disabled]),' +
|
|
55
85
|
'button:not([disabled]),a[href],[tabindex]:not([tabindex="-1"])';
|
|
56
86
|
|
|
87
|
+
/** This dialog's place in lib/modalStack: only the top one answers keys. */
|
|
88
|
+
const me = Symbol('fe-modal');
|
|
89
|
+
let wired = false;
|
|
90
|
+
let focusTimer: ReturnType<typeof setTimeout> | undefined;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The first thing to focus: something in the BODY when there is one, so a
|
|
94
|
+
* form's first box — or a plugin screen's first choice — has the cursor,
|
|
95
|
+
* not the header's ×; then the footer; then anything in the card (the ×).
|
|
96
|
+
*/
|
|
97
|
+
function focusFirst() {
|
|
98
|
+
const card = cardEl.value;
|
|
99
|
+
if (!card) return;
|
|
100
|
+
const within = (sel: string) => card.querySelector(sel)?.querySelector<HTMLElement>(FOCUSABLE) ?? null;
|
|
101
|
+
const target = within('.fe-modal__body') ?? within('.fe-modal__actions') ?? card.querySelector<HTMLElement>(FOCUSABLE);
|
|
102
|
+
target?.focus();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function wire() {
|
|
106
|
+
if (wired) return;
|
|
107
|
+
wired = true;
|
|
108
|
+
prevFocus = (document.activeElement as HTMLElement | null) ?? null; /* wiring:c4 */
|
|
109
|
+
pushModal(me);
|
|
110
|
+
document.addEventListener('keydown', onKey);
|
|
111
|
+
// ⚠ The handle is kept and cleared on unwire: a dialog closed (or torn
|
|
112
|
+
// down, as a test environment is) inside these 30 ms must not reach for a
|
|
113
|
+
// document that is gone — vitest reported exactly that as an unhandled
|
|
114
|
+
// "document is not defined" once dialogs created open were wired.
|
|
115
|
+
focusTimer = setTimeout(() => {
|
|
116
|
+
focusTimer = undefined;
|
|
117
|
+
if (typeof document === 'undefined') return;
|
|
118
|
+
// Only if the focus is not already inside (a component that focuses its
|
|
119
|
+
// own field on mount keeps it).
|
|
120
|
+
if (wired && !cardEl.value?.contains(document.activeElement)) focusFirst();
|
|
121
|
+
}, 30);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function unwire() {
|
|
125
|
+
if (!wired) return;
|
|
126
|
+
wired = false;
|
|
127
|
+
if (focusTimer !== undefined) {
|
|
128
|
+
clearTimeout(focusTimer);
|
|
129
|
+
focusTimer = undefined;
|
|
130
|
+
}
|
|
131
|
+
document.removeEventListener('keydown', onKey);
|
|
132
|
+
popModal(me);
|
|
133
|
+
/* wiring:c4 — return focus to the opener. */
|
|
134
|
+
prevFocus?.focus?.();
|
|
135
|
+
prevFocus = null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ⚠ `immediate`: a dialog created open (v-if + :open="true", which is how
|
|
139
|
+
// the explorer mounts every plugin screen) must be wired exactly like one
|
|
140
|
+
// that opens later. See the header for what happened without it.
|
|
57
141
|
watch(
|
|
58
142
|
() => props.open,
|
|
59
|
-
(v) =>
|
|
60
|
-
|
|
61
|
-
prevFocus = (document.activeElement as HTMLElement | null) ?? null; /* wiring:c4 */
|
|
62
|
-
document.addEventListener('keydown', onKey);
|
|
63
|
-
setTimeout(() => {
|
|
64
|
-
const focusable = cardEl.value?.querySelector<HTMLElement>(FOCUSABLE);
|
|
65
|
-
focusable?.focus();
|
|
66
|
-
}, 30);
|
|
67
|
-
} else {
|
|
68
|
-
document.removeEventListener('keydown', onKey);
|
|
69
|
-
/* wiring:c4 — return focus to the opener. */
|
|
70
|
-
prevFocus?.focus?.();
|
|
71
|
-
prevFocus = null;
|
|
72
|
-
}
|
|
73
|
-
},
|
|
143
|
+
(v) => (v ? wire() : unwire()),
|
|
144
|
+
{ immediate: true },
|
|
74
145
|
);
|
|
75
146
|
|
|
76
|
-
|
|
147
|
+
// Removed while still open (the parent's v-if went false first): the keys
|
|
148
|
+
// come off and the focus goes home, as if it had closed.
|
|
149
|
+
onBeforeUnmount(unwire);
|
|
77
150
|
|
|
78
151
|
function onKey(e: KeyboardEvent) {
|
|
152
|
+
if (!isTopModal(me)) return;
|
|
79
153
|
if (e.key === 'Escape') {
|
|
154
|
+
if (props.chromeless) return;
|
|
80
155
|
emit('close');
|
|
81
156
|
return;
|
|
82
157
|
}
|
|
@@ -100,8 +175,22 @@ function onKey(e: KeyboardEvent) {
|
|
|
100
175
|
}
|
|
101
176
|
}
|
|
102
177
|
|
|
103
|
-
|
|
104
|
-
|
|
178
|
+
/**
|
|
179
|
+
* A click outside the card closes the dialog — but only a click that also
|
|
180
|
+
* STARTED outside it. A drag that selects text in a field and is released
|
|
181
|
+
* over the backdrop fires `click` on the backdrop (the common ancestor), and
|
|
182
|
+
* closing then would throw away what was being typed.
|
|
183
|
+
*/
|
|
184
|
+
let downOnBackdrop = false;
|
|
185
|
+
function onBackdropDown(e: PointerEvent) {
|
|
186
|
+
downOnBackdrop = e.target === e.currentTarget;
|
|
187
|
+
}
|
|
188
|
+
function onBackdrop(e: MouseEvent) {
|
|
189
|
+
const started = downOnBackdrop;
|
|
190
|
+
downOnBackdrop = false;
|
|
191
|
+
if (!props.closeOnBackdrop || props.chromeless) return;
|
|
192
|
+
if (e.target !== e.currentTarget || !started) return;
|
|
193
|
+
emit('close');
|
|
105
194
|
}
|
|
106
195
|
</script>
|
|
107
196
|
|
|
@@ -117,6 +206,7 @@ function onBackdrop() {
|
|
|
117
206
|
theme === 'dark' ? 'fe--theme-dark' : '',
|
|
118
207
|
]"
|
|
119
208
|
role="presentation"
|
|
209
|
+
@pointerdown="onBackdropDown"
|
|
120
210
|
@click="onBackdrop"
|
|
121
211
|
>
|
|
122
212
|
<div
|
|
@@ -138,7 +228,7 @@ function onBackdrop() {
|
|
|
138
228
|
<button
|
|
139
229
|
type="button"
|
|
140
230
|
class="fe-modal__close"
|
|
141
|
-
aria-label="
|
|
231
|
+
:aria-label="t('modal.close')"
|
|
142
232
|
@click="emit('close')"
|
|
143
233
|
>×</button>
|
|
144
234
|
</header>
|
|
@@ -59,6 +59,7 @@ import { iconTile, iconFamilyFor, typeLabelFor } from '../lib/fileIcons';
|
|
|
59
59
|
import { crumbsOfWire, permAllowsWrite, splitWire } from '../lib/destinationTree';
|
|
60
60
|
import Modal from './Modal.vue';
|
|
61
61
|
import DestinationPickerModal from './DestinationPickerModal.vue';
|
|
62
|
+
import { inlineKeyStep } from '../lib/direction';
|
|
62
63
|
|
|
63
64
|
const props = defineProps<{
|
|
64
65
|
open: boolean;
|
|
@@ -79,6 +80,9 @@ const props = defineProps<{
|
|
|
79
80
|
/** Resolved by the host: is there a usable OnlyOffice / drawio? */
|
|
80
81
|
onlyOfficeReady?: boolean;
|
|
81
82
|
drawioReady?: boolean;
|
|
83
|
+
/** Could this person set a missing service up (`capabilities.caller_admin`)?
|
|
84
|
+
* Adds WHERE to the line that says which families are missing. */
|
|
85
|
+
canConfigure?: boolean;
|
|
82
86
|
}>();
|
|
83
87
|
|
|
84
88
|
const emit = defineEmits<{
|
|
@@ -87,7 +91,7 @@ const emit = defineEmits<{
|
|
|
87
91
|
(e: 'error', payload: { message: string }): void;
|
|
88
92
|
}>();
|
|
89
93
|
|
|
90
|
-
const { t } = useLocale(() => props.locale);
|
|
94
|
+
const { t, dir } = useLocale(() => props.locale);
|
|
91
95
|
|
|
92
96
|
/* ================================================================== types */
|
|
93
97
|
|
|
@@ -117,9 +121,13 @@ const withheld = computed(() => {
|
|
|
117
121
|
* ("...here.Diagrams need...") — measured in a browser, invisible in a unit
|
|
118
122
|
* test that asserts on textContent.
|
|
119
123
|
*/
|
|
120
|
-
const withheldText = computed(() =>
|
|
121
|
-
withheld.value.map((svc) => t('newdoc.withheld.' + svc))
|
|
122
|
-
|
|
124
|
+
const withheldText = computed(() => {
|
|
125
|
+
const parts = withheld.value.map((svc) => t('newdoc.withheld.' + svc));
|
|
126
|
+
/* Someone who can fix it is told where (the owner's rule for a missing
|
|
127
|
+
service, lib/serviceGate); everybody else reads only what is missing. */
|
|
128
|
+
if (parts.length && props.canConfigure) parts.push(t('newdoc.withheld_admin'));
|
|
129
|
+
return parts.join(' ');
|
|
130
|
+
});
|
|
123
131
|
|
|
124
132
|
/** Sections, in the order the server listed them. */
|
|
125
133
|
const groups = computed(() => {
|
|
@@ -313,8 +321,10 @@ function onTileKey(e: KeyboardEvent, ext: string) {
|
|
|
313
321
|
const list = offered.value.map((ty) => ty.ext);
|
|
314
322
|
const i = list.indexOf(ext);
|
|
315
323
|
let next = -1;
|
|
316
|
-
|
|
317
|
-
|
|
324
|
+
// ⚠ RTL: ← / → move the way they point — in a right-to-left grid the next
|
|
325
|
+
// tile is to the LEFT (lib/direction).
|
|
326
|
+
const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : inlineKeyStep(e.key, dir.value);
|
|
327
|
+
if (step) next = (i + step + list.length) % list.length;
|
|
318
328
|
else if (e.key === 'Home') next = 0;
|
|
319
329
|
else if (e.key === 'End') next = list.length - 1;
|
|
320
330
|
if (next < 0) return;
|
|
@@ -3,6 +3,7 @@ import { ref, watch } from 'vue';
|
|
|
3
3
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
4
4
|
import { useLocale } from '../composables/useLocale';
|
|
5
5
|
import { actionIconSvg } from '../lib/actionIcons'; /* ikon:emoji */
|
|
6
|
+
import { isInternalName } from '../lib/internalPaths';
|
|
6
7
|
import Modal from './Modal.vue';
|
|
7
8
|
|
|
8
9
|
const props = defineProps<{
|
|
@@ -42,6 +43,13 @@ function submit() {
|
|
|
42
43
|
err.value = t('modal.newfolder.invalid');
|
|
43
44
|
return;
|
|
44
45
|
}
|
|
46
|
+
// The server refuses these (403 RESERVED_NAME, handlers/reserved_guard.go)
|
|
47
|
+
// in English; saying it here, in the reader's language, is the courtesy —
|
|
48
|
+
// the server is still the check.
|
|
49
|
+
if (isInternalName(clean)) {
|
|
50
|
+
err.value = t('names.reserved', { name: clean });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
45
53
|
emit('submit', clean);
|
|
46
54
|
}
|
|
47
55
|
</script>
|