@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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* PublicShareBody — what a share actually contains.
|
|
4
|
+
*
|
|
5
|
+
* Three bodies, one shell (`PublicShell`): a document to download, a folder,
|
|
6
|
+
* or an app plugin's screen when the link carries one (v3 §1.1 — "an app's
|
|
7
|
+
* public page IS a share"). The chrome around all three — branding, the PIN
|
|
8
|
+
* gate, expiry, the language picker — is the shell's and is not repeated
|
|
9
|
+
* here.
|
|
10
|
+
*
|
|
11
|
+
* ⚠ The surface is drawn by the SAME `SurfaceConversation` the explorer's
|
|
12
|
+
* dialog and an app's page use. A visitor and a signed-in user must see one
|
|
13
|
+
* surface rendered one way, or every plugin has to be tested twice.
|
|
14
|
+
*
|
|
15
|
+
* ⚠⚠ The bytes are NOT fetched through `/api/`. `/s/<token>` has always been
|
|
16
|
+
* the address that serves the file and still is: the SPA is what a
|
|
17
|
+
* JavaScript browser gets when it NAVIGATES there, and `?download=1` /
|
|
18
|
+
* `?zip=1` are the escapes that mean "the thing, not the page"
|
|
19
|
+
* (`handlers/public_shell.go`). A second download route under `/api/` would
|
|
20
|
+
* be a second thing to keep working.
|
|
21
|
+
*/
|
|
22
|
+
import { computed } from 'vue';
|
|
23
|
+
import type { LocaleCode, ThemeMode } from '../../types/ExplorerConfig';
|
|
24
|
+
import type { PublicEntry, PublicShareInfo } from '../../types/Public';
|
|
25
|
+
import type { PublicLinkStore } from '../../composables/usePublicLink';
|
|
26
|
+
import { useLocale } from '../../composables/useLocale';
|
|
27
|
+
import SurfaceConversation from '../plugin/SurfaceConversation.vue';
|
|
28
|
+
import SurfaceFooterButtons from '../plugin/SurfaceFooterButtons.vue';
|
|
29
|
+
|
|
30
|
+
const props = defineProps<{
|
|
31
|
+
link: PublicLinkStore<PublicShareInfo> & {
|
|
32
|
+
downloadUrl: (o?: { zip?: boolean; path?: string }) => string;
|
|
33
|
+
noJsUrl: () => string;
|
|
34
|
+
};
|
|
35
|
+
locale: LocaleCode | string;
|
|
36
|
+
theme?: ThemeMode;
|
|
37
|
+
}>();
|
|
38
|
+
|
|
39
|
+
const emit = defineEmits<{
|
|
40
|
+
(e: 'navigate', path: string): void;
|
|
41
|
+
}>();
|
|
42
|
+
|
|
43
|
+
const { t, formatSize, formatDate } = useLocale(() => props.locale);
|
|
44
|
+
|
|
45
|
+
const info = computed(() => props.link.info.value);
|
|
46
|
+
const kind = computed(() => info.value?.kind ?? 'file');
|
|
47
|
+
const node = computed(() => info.value?.node ?? null);
|
|
48
|
+
const app = computed(() => info.value?.app ?? null);
|
|
49
|
+
const entries = computed<PublicEntry[]>(() => info.value?.entries ?? []);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A picture is shown, everything else is offered.
|
|
53
|
+
*
|
|
54
|
+
* ⚠ Deliberately narrow. A public page renders bytes a stranger was sent by
|
|
55
|
+
* a stranger; an <img> is a decode the browser does every day, while an
|
|
56
|
+
* inline frame of arbitrary content on our origin is not something a share
|
|
57
|
+
* link should be able to ask for.
|
|
58
|
+
*/
|
|
59
|
+
const isImage = computed(() => (node.value?.mime ?? '').startsWith('image/'));
|
|
60
|
+
|
|
61
|
+
/** The path back up, or null at the share's root. */
|
|
62
|
+
const parent = computed(() => {
|
|
63
|
+
const p = (info.value?.path ?? '').replace(/\/+$/, '');
|
|
64
|
+
if (!p) return null;
|
|
65
|
+
const i = p.lastIndexOf('/');
|
|
66
|
+
return i <= 0 ? '' : p.slice(0, i);
|
|
67
|
+
});
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<template>
|
|
71
|
+
<!-- ── an app plugin's screen ─────────────────────────────────────── -->
|
|
72
|
+
<template v-if="kind === 'app'">
|
|
73
|
+
<!-- ⚠⚠ A document in an app's screen (a signing link's "see and
|
|
74
|
+
approve" step) is fitted to the WINDOW, as the in-app page fits it —
|
|
75
|
+
never drawn at the card's full width inside a fixed-height pane.
|
|
76
|
+
Measured on a signing link, 2026-09-21: the page was 676px wide in a
|
|
77
|
+
615px-tall pane, the signer saw the top 60% of it and none of their
|
|
78
|
+
own boxes, which sat at the bottom — the owner's "arayüz berbat". -->
|
|
79
|
+
<SurfaceConversation
|
|
80
|
+
:conv="link.conv"
|
|
81
|
+
:locale="locale as LocaleCode"
|
|
82
|
+
:theme="theme"
|
|
83
|
+
:file-url="link.resolveFile"
|
|
84
|
+
:toast="link.toast.value"
|
|
85
|
+
layout="page"
|
|
86
|
+
page-height="min(80vh, 1000px)"
|
|
87
|
+
/>
|
|
88
|
+
<div v-if="link.conv.footer.value.length" class="fe-ppage__actions">
|
|
89
|
+
<SurfaceFooterButtons :conv="link.conv" :locale="locale as LocaleCode" testid-prefix="public-page" />
|
|
90
|
+
</div>
|
|
91
|
+
<!-- ⚠ The files an app exposes are drawn the way a SHARED FILE is —
|
|
92
|
+
its name and size, and a Download button — because to the person
|
|
93
|
+
holding the link that is what they are: a signer's copy of the
|
|
94
|
+
document is a file somebody shared with them. It used to be a
|
|
95
|
+
"Documents" heading over a bare link, a second way of saying the
|
|
96
|
+
same thing on what should be the same page. -->
|
|
97
|
+
<section v-if="app?.files?.length" class="fe-ppage__appfiles" data-testid="public-page-files">
|
|
98
|
+
<div v-for="f in app?.files ?? []" :key="f.ref" class="fe-ppage__file-one fe-ppage__appfile">
|
|
99
|
+
<p class="fe-surface__text">
|
|
100
|
+
<strong>{{ f.name }}</strong>
|
|
101
|
+
<span v-if="f.size" class="fe-ppage__filesize"> · {{ formatSize(f.size) }}</span>
|
|
102
|
+
</p>
|
|
103
|
+
<a
|
|
104
|
+
class="fe-btn"
|
|
105
|
+
:href="link.fileDownloadUrl(f.ref)"
|
|
106
|
+
download
|
|
107
|
+
:title="t('plugin.page.open_file', { name: f.name })"
|
|
108
|
+
:data-testid="`public-page-file-${f.ref}`"
|
|
109
|
+
>
|
|
110
|
+
{{ t('ctx.download') }}
|
|
111
|
+
</a>
|
|
112
|
+
</div>
|
|
113
|
+
</section>
|
|
114
|
+
</template>
|
|
115
|
+
|
|
116
|
+
<!-- ── a folder ───────────────────────────────────────────────────── -->
|
|
117
|
+
<template v-else-if="kind === 'folder'">
|
|
118
|
+
<div class="fe-ppage__file-one" data-testid="public-share-folder">
|
|
119
|
+
<!-- Same rule as a document: the heading is the folder's name already. -->
|
|
120
|
+
<p v-if="!node?.name" class="fe-surface__text">
|
|
121
|
+
<strong>{{ info?.subject }}</strong>
|
|
122
|
+
</p>
|
|
123
|
+
<nav v-if="info?.path" class="fe-ppage__crumbs" data-testid="public-share-crumbs">
|
|
124
|
+
<button type="button" class="fe-btn fe-btn--sm" @click="emit('navigate', parent ?? '')">
|
|
125
|
+
{{ t('public.up') }}
|
|
126
|
+
</button>
|
|
127
|
+
<span class="fe-ppage__crumb">{{ info?.path }}</span>
|
|
128
|
+
</nav>
|
|
129
|
+
|
|
130
|
+
<!-- ⚠ The server does not send a listing for a folder share yet: it
|
|
131
|
+
answers the folder's NAME and leaves the walking to the no-JS page.
|
|
132
|
+
The listing is drawn the moment one arrives; until then the two
|
|
133
|
+
things a visitor actually came for are here, and the third is one
|
|
134
|
+
link away. Writing a SECOND browse implementation against a
|
|
135
|
+
different endpoint is what v3 exists to stop. -->
|
|
136
|
+
<ul v-if="entries.length" class="fe-ppage__files" data-testid="public-share-entries">
|
|
137
|
+
<li v-for="e in entries" :key="e.path" class="fe-ppage__file" :data-dir="e.is_dir ? '1' : undefined">
|
|
138
|
+
<button
|
|
139
|
+
v-if="e.is_dir"
|
|
140
|
+
type="button"
|
|
141
|
+
class="fe-ppage__entry"
|
|
142
|
+
:data-testid="`public-entry-${e.name}`"
|
|
143
|
+
@click="emit('navigate', e.path)"
|
|
144
|
+
>
|
|
145
|
+
<bdi>{{ e.name }}</bdi>
|
|
146
|
+
</button>
|
|
147
|
+
<a
|
|
148
|
+
v-else
|
|
149
|
+
:href="e.url || link.downloadUrl({ path: e.path })"
|
|
150
|
+
class="fe-ppage__entry"
|
|
151
|
+
:data-testid="`public-entry-${e.name}`"
|
|
152
|
+
download
|
|
153
|
+
><bdi>{{ e.name }}</bdi></a>
|
|
154
|
+
<span v-if="!e.is_dir && e.size !== undefined" class="fe-ppage__filesize">{{ formatSize(e.size) }}</span>
|
|
155
|
+
<span v-if="e.modified" class="fe-ppage__filesize">{{ formatDate(e.modified) }}</span>
|
|
156
|
+
</li>
|
|
157
|
+
</ul>
|
|
158
|
+
|
|
159
|
+
<div class="fe-ppage__actions">
|
|
160
|
+
<a class="fe-btn fe-btn--primary" :href="link.downloadUrl({ zip: true })" data-testid="public-share-zip">
|
|
161
|
+
{{ t('public.download_all') }}
|
|
162
|
+
</a>
|
|
163
|
+
<a v-if="!entries.length" class="fe-btn" :href="link.noJsUrl()" data-testid="public-share-browse">
|
|
164
|
+
{{ t('public.browse') }}
|
|
165
|
+
</a>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</template>
|
|
169
|
+
|
|
170
|
+
<!-- ── one document ───────────────────────────────────────────────── -->
|
|
171
|
+
<template v-else>
|
|
172
|
+
<div class="fe-ppage__file-one" data-testid="public-share-file">
|
|
173
|
+
<img v-if="isImage" class="fe-ppage__shot" :src="link.downloadUrl()" :alt="node?.name ?? ''" />
|
|
174
|
+
<!-- ⚠ The name is NOT repeated here. The shell's heading is already the
|
|
175
|
+
file's own name (`PublicLinkPage.title`), and printing it again
|
|
176
|
+
under itself — "brand-guidelines.pdf" over "brand-guidelines.pdf ·
|
|
177
|
+
791 B" — is the stutter the levelled-down page shipped with. Only
|
|
178
|
+
the size, and the name as a fallback for a link that has none. -->
|
|
179
|
+
<p class="fe-surface__text fe-surface__text--muted" data-testid="public-share-fileline">
|
|
180
|
+
<strong v-if="!node?.name">{{ info?.subject }}</strong>
|
|
181
|
+
<span v-if="node?.size !== undefined" class="fe-ppage__filesize">{{ formatSize(node?.size) }}</span>
|
|
182
|
+
</p>
|
|
183
|
+
<a class="fe-btn fe-btn--primary" :href="link.downloadUrl()" data-testid="public-share-download">
|
|
184
|
+
{{ t('ctx.download') }}
|
|
185
|
+
</a>
|
|
186
|
+
</div>
|
|
187
|
+
</template>
|
|
188
|
+
</template>
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* PublicShell — the frame every public link is drawn in.
|
|
4
|
+
*
|
|
5
|
+
* ⚠⚠ v3 §1, and the whole point of the round: a share, a file request and an
|
|
6
|
+
* app plugin's page are ONE page with three bodies, not three pages. What
|
|
7
|
+
* the shell owns is exactly what they used to each get wrong on their own —
|
|
8
|
+
*
|
|
9
|
+
* · the instance's name, logo and colour (§1.2: whitelabel means
|
|
10
|
+
* whitelabel — a signature request from a renamed instance does not say
|
|
11
|
+
* "filex");
|
|
12
|
+
* · the PIN gate, once, with one wording and one lockout behaviour;
|
|
13
|
+
* · expiry and the visit counter, said out loud rather than discovered;
|
|
14
|
+
* · "this link is not available", identical for expired, used up and
|
|
15
|
+
* withdrawn, because a visitor must not be told which;
|
|
16
|
+
* · the language picker, including a language an app plugin added.
|
|
17
|
+
*
|
|
18
|
+
* The BODY is a slot. Nothing in here knows whether it is showing a document,
|
|
19
|
+
* a folder, a drop box or a plugin's screen, and that is what keeps the
|
|
20
|
+
* three from drifting apart again.
|
|
21
|
+
*
|
|
22
|
+
* ⚠⚠ Unifying them had to level them UP and did not (owner, 2026-09-23:
|
|
23
|
+
* "böyle güzel bir ekranımız vardı share katmanında … iki sayfa aynı olsun
|
|
24
|
+
* dediğim için ikisini de kötü hale çevirmişsin"). The look is the one the
|
|
25
|
+
* Go-rendered gate had until v0.42.2 and it is the shell's, not a page's:
|
|
26
|
+
* the mark centred ABOVE the card, a round tinted badge, a heading, a quiet
|
|
27
|
+
* line, and — on a gate — a full-width field and a full-width accent button
|
|
28
|
+
* on a card that is 400px and centred in the viewport. A body that wants
|
|
29
|
+
* room asks for it through `layout`, which is the only thing that varies
|
|
30
|
+
* (`gate` 400 · `form` 520 · `wide` 880 — the old stylesheet's three cards).
|
|
31
|
+
*
|
|
32
|
+
* ⚠ It lives in `packages/core`, not in the web app, so the desktop app and
|
|
33
|
+
* an embed draw the same public page. `web/` only binds the routes.
|
|
34
|
+
*
|
|
35
|
+
* ⚠ The accent is applied to THIS element, never to `:root` — the shell can
|
|
36
|
+
* be mounted inside another page, and a link must not repaint its host.
|
|
37
|
+
*/
|
|
38
|
+
import { computed } from 'vue';
|
|
39
|
+
import type { LocaleCode, ThemeMode } from '../../types/ExplorerConfig';
|
|
40
|
+
import type { PublicStatus } from '../../composables/usePublicLink';
|
|
41
|
+
import type { PublicLayout } from '../../lib/publicLayout';
|
|
42
|
+
import { useLocale } from '../../composables/useLocale';
|
|
43
|
+
import { DEFAULT_BRAND_NAME } from '../../composables/usePublicBranding';
|
|
44
|
+
import LogoMark from '../LogoMark.vue';
|
|
45
|
+
import PublicPinGate from './PublicPinGate.vue';
|
|
46
|
+
import PublicLanguagePicker from './PublicLanguagePicker.vue';
|
|
47
|
+
|
|
48
|
+
const props = defineProps<{
|
|
49
|
+
status: PublicStatus;
|
|
50
|
+
locale: LocaleCode | string;
|
|
51
|
+
/** `light` | `dark` — already resolved; `auto` is the caller's to answer. */
|
|
52
|
+
theme?: 'light' | 'dark' | ThemeMode;
|
|
53
|
+
/** The instance's name (branding, or the product's own). */
|
|
54
|
+
brandName: string;
|
|
55
|
+
logoUrl?: string;
|
|
56
|
+
/** `--fe-*` overrides from the instance's accent colour. */
|
|
57
|
+
accentStyle?: Record<string, string>;
|
|
58
|
+
footerText?: string;
|
|
59
|
+
hidePoweredBy?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* How much room the body needs (`lib/publicLayout`). Every state that is
|
|
62
|
+
* NOT `ready` overrides it to `gate`: a PIN box, "this link is not
|
|
63
|
+
* available" and "thank you" are the same short, centred card whatever
|
|
64
|
+
* was going to be behind them.
|
|
65
|
+
*/
|
|
66
|
+
layout?: PublicLayout;
|
|
67
|
+
/**
|
|
68
|
+
* The round badge a READY body opens with — `file` or `folder`. Every
|
|
69
|
+
* other badge is a state of the shell's own (a padlock, a tick, a warning
|
|
70
|
+
* sign, a spinner) and is not the caller's to pick.
|
|
71
|
+
*/
|
|
72
|
+
badge?: 'file' | 'folder' | '';
|
|
73
|
+
/** The heading: a file name, a folder, a plugin page's title. */
|
|
74
|
+
title?: string;
|
|
75
|
+
/** The line under it — a share message, a signature request's subject. */
|
|
76
|
+
subject?: string;
|
|
77
|
+
/** RFC 3339, or null for "does not expire". */
|
|
78
|
+
expiresAt?: string | null;
|
|
79
|
+
/** Visits left before the link stops answering; null = uncounted. */
|
|
80
|
+
visitsLeft?: number | null;
|
|
81
|
+
pinBusy?: boolean;
|
|
82
|
+
pinFailure?: '' | 'wrong' | 'locked';
|
|
83
|
+
lockMessage?: string;
|
|
84
|
+
/** A failure outside the conversation, in the server's words. */
|
|
85
|
+
failure?: string;
|
|
86
|
+
}>();
|
|
87
|
+
|
|
88
|
+
const emit = defineEmits<{
|
|
89
|
+
(e: 'pin', pin: string): void;
|
|
90
|
+
(e: 'retry'): void;
|
|
91
|
+
(e: 'update:locale', v: string): void;
|
|
92
|
+
}>();
|
|
93
|
+
|
|
94
|
+
// ⚠ RTL: `dir` on the page's own root too — the shell is the whole page when
|
|
95
|
+
// the admin app mounts it, but an embedder may mount it in a page of its own.
|
|
96
|
+
const { t, formatDate, dir } = useLocale(() => props.locale);
|
|
97
|
+
|
|
98
|
+
/** The body is drawn for these; everything else is a state of the shell's own. */
|
|
99
|
+
const showBody = computed(() => props.status === 'ready');
|
|
100
|
+
|
|
101
|
+
/** A state of the shell's own is always the narrow, centred card. */
|
|
102
|
+
const layout = computed<PublicLayout>(() => (showBody.value ? (props.layout ?? 'form') : 'gate'));
|
|
103
|
+
|
|
104
|
+
const themeClass = computed(() =>
|
|
105
|
+
props.theme === 'dark' ? 'fe--theme-dark' : props.theme === 'light' ? 'fe--theme-light' : '',
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const expiry = computed(() => {
|
|
109
|
+
if (!props.expiresAt) return '';
|
|
110
|
+
const ms = Date.parse(props.expiresAt);
|
|
111
|
+
if (!Number.isFinite(ms)) return '';
|
|
112
|
+
return t('public.expires', { when: formatDate(ms, { time: true }) });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const visits = computed(() => {
|
|
116
|
+
const n = props.visitsLeft;
|
|
117
|
+
if (n === null || n === undefined || !Number.isFinite(n)) return '';
|
|
118
|
+
return t('public.visits_left', { n });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Which badge the card opens with. ONE decision, so a state and a body can
|
|
123
|
+
* never both draw one, and a body cannot invent a shape of its own.
|
|
124
|
+
*/
|
|
125
|
+
const badgeKind = computed(() => {
|
|
126
|
+
if (props.status === 'pin') return 'lock';
|
|
127
|
+
if (props.status === 'accepted' || props.status === 'done') return 'check';
|
|
128
|
+
if (props.status === 'loading') return 'loading';
|
|
129
|
+
if (!showBody.value) return 'alert';
|
|
130
|
+
return props.badge ?? '';
|
|
131
|
+
});
|
|
132
|
+
const badgeTone = computed(() =>
|
|
133
|
+
badgeKind.value === 'check'
|
|
134
|
+
? 'fe-ppage__badge--ok'
|
|
135
|
+
: badgeKind.value === 'alert'
|
|
136
|
+
? 'fe-ppage__badge--err'
|
|
137
|
+
: '',
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Whether the PRODUCT's own mark may be drawn beside the attribution line.
|
|
142
|
+
*
|
|
143
|
+
* ⚠ Only when the instance has neither a logo nor a name of its own — that
|
|
144
|
+
* is, when the line under it reads "Served by filex" and the folder-and-tick
|
|
145
|
+
* is what it is talking about. An operator who has renamed the instance gets
|
|
146
|
+
* their own logo, or their name and nothing else: putting our mark next to
|
|
147
|
+
* "Served by Acme Files" is the whitelabel leak v3 §1.2 exists to stop, and
|
|
148
|
+
* the shell's own test asserts a renamed page never says "filex".
|
|
149
|
+
*
|
|
150
|
+
* ⚠ The mark is `@brftech/filex-core`'s LogoMark — the ONE copy in the
|
|
151
|
+
* product (dup-scan: `brand-mark`). It was hand-typed here for about an hour
|
|
152
|
+
* on 2026-09-23 and the duplicate gate caught it, which is exactly the
|
|
153
|
+
* failure that once shipped the pre-rebrand indigo on the page strangers see.
|
|
154
|
+
*/
|
|
155
|
+
const ownMark = computed(() => !props.logoUrl && props.brandName === DEFAULT_BRAND_NAME);
|
|
156
|
+
|
|
157
|
+
function pluginNote(plugin: string): string {
|
|
158
|
+
return t('public.language_from', { app: plugin });
|
|
159
|
+
}
|
|
160
|
+
</script>
|
|
161
|
+
|
|
162
|
+
<template>
|
|
163
|
+
<div
|
|
164
|
+
class="fe fe-ppage"
|
|
165
|
+
:dir="dir"
|
|
166
|
+
:class="[themeClass, `fe-ppage--${layout}`]"
|
|
167
|
+
:style="accentStyle"
|
|
168
|
+
data-testid="public-page"
|
|
169
|
+
:data-state="status"
|
|
170
|
+
:data-layout="layout"
|
|
171
|
+
>
|
|
172
|
+
<!-- ⚠ ABOVE the card and centred on it. Inside the card it read as a
|
|
173
|
+
caption on somebody else's document; above it, it says whose page
|
|
174
|
+
this is — which is the first thing a stranger needs.
|
|
175
|
+
⚠ The mark is the INSTANCE's logo, or — only while the instance is
|
|
176
|
+
still called by the product's name — the product's own (`ownMark`).
|
|
177
|
+
It is DRAWN, never re-typed: LogoMark is the one home of that path
|
|
178
|
+
data (dup-scan `brand-mark`), and a second hand copy is how the page
|
|
179
|
+
strangers see shipped the pre-rebrand indigo for a whole wave. -->
|
|
180
|
+
<header class="fe-ppage__brand" data-testid="public-brand">
|
|
181
|
+
<img v-if="logoUrl" class="fe-ppage__logo" :src="logoUrl" :alt="brandName" />
|
|
182
|
+
<LogoMark v-else-if="ownMark" class="fe-ppage__logo" />
|
|
183
|
+
<span class="fe-ppage__brandname">{{ brandName }}</span>
|
|
184
|
+
</header>
|
|
185
|
+
|
|
186
|
+
<main class="fe-ppage__card">
|
|
187
|
+
<!-- The badge the card opens with. The shell draws the one its state
|
|
188
|
+
calls for; a READY body names `file` or `folder` and gets the same
|
|
189
|
+
circle in the same place, so a document, a folder and a locked
|
|
190
|
+
link are one family rather than three drawings. -->
|
|
191
|
+
<div
|
|
192
|
+
v-if="badgeKind"
|
|
193
|
+
class="fe-ppage__badge"
|
|
194
|
+
:class="badgeTone"
|
|
195
|
+
data-testid="public-page-badge"
|
|
196
|
+
:data-badge="badgeKind"
|
|
197
|
+
aria-hidden="true"
|
|
198
|
+
>
|
|
199
|
+
<span v-if="badgeKind === 'loading'" class="fe-ppage__spinner"></span>
|
|
200
|
+
<svg
|
|
201
|
+
v-else
|
|
202
|
+
viewBox="0 0 24 24"
|
|
203
|
+
fill="none"
|
|
204
|
+
stroke="currentColor"
|
|
205
|
+
:stroke-width="badgeKind === 'check' ? 2 : 1.8"
|
|
206
|
+
stroke-linecap="round"
|
|
207
|
+
stroke-linejoin="round"
|
|
208
|
+
>
|
|
209
|
+
<template v-if="badgeKind === 'lock'">
|
|
210
|
+
<rect x="4.5" y="10.5" width="15" height="9.5" rx="2" />
|
|
211
|
+
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
|
|
212
|
+
<path d="M12 14.5v2" />
|
|
213
|
+
</template>
|
|
214
|
+
<template v-else-if="badgeKind === 'check'">
|
|
215
|
+
<path d="M4.5 12.5l5 5 10-11" />
|
|
216
|
+
</template>
|
|
217
|
+
<template v-else-if="badgeKind === 'alert'">
|
|
218
|
+
<path d="M10.3 4.2 2.6 17.9a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 4.2a2 2 0 0 0-3.4 0z" />
|
|
219
|
+
<path d="M12 9.5v4.5" />
|
|
220
|
+
<path d="M12 17.4h.01" />
|
|
221
|
+
</template>
|
|
222
|
+
<template v-else-if="badgeKind === 'folder'">
|
|
223
|
+
<path d="M3.5 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2V7z" />
|
|
224
|
+
<path d="M12 10.5V16" />
|
|
225
|
+
<path d="M9.5 13.5 12 16l2.5-2.5" />
|
|
226
|
+
</template>
|
|
227
|
+
<template v-else>
|
|
228
|
+
<path d="M7 3.5h7l4 4V20a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 6 20V5A1.5 1.5 0 0 1 7.5 3.5z" />
|
|
229
|
+
<path d="M13.5 3.5V8H18" />
|
|
230
|
+
<path d="M12 11.5V17" />
|
|
231
|
+
<path d="M9.5 14.5 12 17l2.5-2.5" />
|
|
232
|
+
</template>
|
|
233
|
+
</svg>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<!-- ⚠⚠ `&& showBody`, on purpose and confirmed 2026-09-23: the heading
|
|
237
|
+
is drawn only once the link has OPENED. A locked gate used to print
|
|
238
|
+
the file's name above the PIN box, which tells somebody who cannot
|
|
239
|
+
get in what is behind it — and a stranger typing at a token they
|
|
240
|
+
were not given learns the name of a real document. It is the same
|
|
241
|
+
reasoning as the one sentence for expired / used up / withdrawn
|
|
242
|
+
below: the page says nothing about what it is guarding until it is
|
|
243
|
+
satisfied the person may see it. Do not "restore" the heading here;
|
|
244
|
+
if a gate ever needs to name its subject, that is a decision about
|
|
245
|
+
what the SERVER sends before an unlock, not a template change. -->
|
|
246
|
+
<header v-if="(title || subject) && showBody" class="fe-ppage__head">
|
|
247
|
+
<h1 v-if="title" class="fe-ppage__title" data-testid="public-page-title"><bdi>{{ title }}</bdi></h1>
|
|
248
|
+
<p v-if="subject" class="fe-ppage__subject" data-testid="public-page-subject">{{ subject }}</p>
|
|
249
|
+
</header>
|
|
250
|
+
|
|
251
|
+
<!-- ⚠ Said out loud, not discovered. A link that stops working
|
|
252
|
+
tomorrow, or after one more visit, is a fact the person holding it
|
|
253
|
+
needs BEFORE they put it aside for the weekend. -->
|
|
254
|
+
<p v-if="(expiry || visits) && showBody" class="fe-ppage__meta" data-testid="public-page-meta">
|
|
255
|
+
<span v-if="expiry">{{ expiry }}</span>
|
|
256
|
+
<span v-if="expiry && visits" aria-hidden="true">·</span>
|
|
257
|
+
<span v-if="visits">{{ visits }}</span>
|
|
258
|
+
</p>
|
|
259
|
+
|
|
260
|
+
<p
|
|
261
|
+
v-if="status === 'loading'"
|
|
262
|
+
class="fe-surface__text fe-surface__text--muted"
|
|
263
|
+
data-testid="public-page-loading"
|
|
264
|
+
>
|
|
265
|
+
{{ t('plugin.page.loading') }}
|
|
266
|
+
</p>
|
|
267
|
+
|
|
268
|
+
<PublicPinGate
|
|
269
|
+
v-else-if="status === 'pin'"
|
|
270
|
+
:locale="locale"
|
|
271
|
+
:busy="pinBusy"
|
|
272
|
+
:failure="pinFailure"
|
|
273
|
+
:lock-message="lockMessage"
|
|
274
|
+
@submit="(p: string) => emit('pin', p)"
|
|
275
|
+
/>
|
|
276
|
+
|
|
277
|
+
<slot v-else-if="showBody" />
|
|
278
|
+
|
|
279
|
+
<div
|
|
280
|
+
v-else-if="status === 'accepted'"
|
|
281
|
+
class="fe-ppage__state fe-ppage__state--ok"
|
|
282
|
+
data-testid="public-page-accepted"
|
|
283
|
+
>
|
|
284
|
+
<slot name="accepted">
|
|
285
|
+
<h2 class="fe-ppage__state-title">{{ t('plugin.page.accepted_title') }}</h2>
|
|
286
|
+
<p class="fe-surface__text">{{ t('plugin.page.accepted_text') }}</p>
|
|
287
|
+
</slot>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
<div v-else-if="status === 'done'" class="fe-ppage__state fe-ppage__state--ok" data-testid="public-page-done">
|
|
291
|
+
<slot name="done">
|
|
292
|
+
<h2 class="fe-ppage__state-title">{{ t('plugin.page.done_title') }}</h2>
|
|
293
|
+
<p class="fe-surface__text">{{ t('plugin.page.done_text') }}</p>
|
|
294
|
+
</slot>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<div v-else-if="status === 'error'" class="fe-ppage__state" data-testid="public-page-error">
|
|
298
|
+
<h2 class="fe-ppage__state-title">{{ t('plugin.page.error_title') }}</h2>
|
|
299
|
+
<p v-if="failure" class="fe-ppage__error">{{ failure }}</p>
|
|
300
|
+
<button type="button" class="fe-btn fe-btn--primary" @click="emit('retry')">
|
|
301
|
+
{{ t('plugin.page.retry') }}
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<!-- ⚠ ONE sentence for expired, used up and withdrawn. Telling a
|
|
306
|
+
visitor WHICH confirms that the link existed, which is more than
|
|
307
|
+
somebody guessing tokens should learn. -->
|
|
308
|
+
<div v-else class="fe-ppage__state" data-testid="public-page-unavailable">
|
|
309
|
+
<h2 class="fe-ppage__state-title">{{ t('plugin.page.unavailable_title') }}</h2>
|
|
310
|
+
<p class="fe-surface__text fe-surface__text--muted">{{ t('plugin.page.unavailable_text') }}</p>
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
<slot name="extra" />
|
|
314
|
+
</main>
|
|
315
|
+
|
|
316
|
+
<footer class="fe-ppage__foot">
|
|
317
|
+
<PublicLanguagePicker
|
|
318
|
+
:model-value="String(locale)"
|
|
319
|
+
:plugin-note="pluginNote"
|
|
320
|
+
@update:model-value="(v: string) => emit('update:locale', v)"
|
|
321
|
+
/>
|
|
322
|
+
<p v-if="footerText" class="fe-ppage__footline" data-testid="public-footer-text">{{ footerText }}</p>
|
|
323
|
+
<!-- The modest brand line the old page ended on: a small mark and one
|
|
324
|
+
sentence, centred under the card. -->
|
|
325
|
+
<span v-if="!hidePoweredBy" class="fe-ppage__footmark" data-testid="public-footer-powered">
|
|
326
|
+
<img v-if="logoUrl" :src="logoUrl" alt="" />
|
|
327
|
+
<LogoMark v-else-if="ownMark" />
|
|
328
|
+
{{ t('public.served_by', { name: brandName }) }}
|
|
329
|
+
</span>
|
|
330
|
+
</footer>
|
|
331
|
+
</div>
|
|
332
|
+
</template>
|
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
StorageWrite,
|
|
33
33
|
} from '../types/Connections';
|
|
34
34
|
import { useFileApi } from './useFileApi';
|
|
35
|
+
import { serverWords } from '../lib/errorWords';
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* The URL prefix the `/api/...` routes hang off.
|
|
@@ -108,9 +109,14 @@ export function useConnections(config: ExplorerConfig) {
|
|
|
108
109
|
return (e as { status?: number } | null)?.status;
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/** A refusal, said — lib/errorWords `serverWords`, the one rule every
|
|
113
|
+
* connection panel shares (it used to print the server's `error` field as
|
|
114
|
+
* it came: an environment variable, to a regular user). ⚠ The locale is
|
|
115
|
+
* passed because what comes back may be the SERVER's own sentence: it
|
|
116
|
+
* never met a translator, and an Arabic panel needs its machine runs
|
|
117
|
+
* isolated (lib/direction `foreignText`). */
|
|
111
118
|
function messageOf(e: unknown): string {
|
|
112
|
-
|
|
113
|
-
return err?.message || String(e);
|
|
119
|
+
return serverWords(e, config.locale);
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
/** The full picture, in as few round-trips as the permission allows. */
|