@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.
Files changed (234) hide show
  1. package/README.md +14 -2
  2. package/dist/ArchiveViewer-BqU761Wk.js +149 -0
  3. package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
  4. package/dist/CsvViewer-ByHVK5ga.js +172 -0
  5. package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
  6. package/dist/{DrawioViewer-CC6rJXeP.js → DrawioViewer-Bgrj8-zG.js} +45 -44
  7. package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
  8. package/dist/{EpubViewer-CAoTSXTO.js → EpubViewer-DJwUKlHW.js} +50 -50
  9. package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
  10. package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
  11. package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
  12. package/dist/MermaidViewer-CEDTD_pp.js +136 -0
  13. package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
  14. package/dist/PsdViewer-CZcxUpZO.js +121 -0
  15. package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
  16. package/dist/TiffViewer-BwI62CbQ.js +141 -0
  17. package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
  18. package/dist/{Viewer3D-CdZeK4j4.js → Viewer3D-DoeZcuMm.js} +16 -16
  19. package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
  20. package/dist/filex-core.js +475 -219
  21. package/dist/filex-core.umd.cjs +165 -108
  22. package/dist/filex-core.umd.cjs.map +1 -1
  23. package/dist/fonts/caveat-latin-ext.woff2 +0 -0
  24. package/dist/fonts/caveat-latin.woff2 +0 -0
  25. package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
  26. package/dist/fonts/dancing-script-latin.woff2 +0 -0
  27. package/dist/fonts/homemade-apple-latin.woff2 +0 -0
  28. package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
  29. package/dist/fonts/source-serif-4-latin.woff2 +0 -0
  30. package/dist/index-DuWnGuv5.js +35901 -0
  31. package/dist/index-DuWnGuv5.js.map +1 -0
  32. package/dist/index.d.ts +5227 -553
  33. package/dist/pdf-CaPYsOUo.js +21302 -0
  34. package/dist/pdf-CaPYsOUo.js.map +1 -0
  35. package/dist/style.css +1 -1
  36. package/package.json +1 -1
  37. package/src/FileExplorer.vue +1055 -253
  38. package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
  39. package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
  40. package/src/assets/fonts/README.md +58 -0
  41. package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
  42. package/src/assets/fonts/caveat-latin.woff2 +0 -0
  43. package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
  44. package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
  45. package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
  46. package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
  47. package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
  48. package/src/components/AdvancedSearch.vue +49 -0
  49. package/src/components/Breadcrumb.vue +5 -5
  50. package/src/components/ChoiceButtons.vue +152 -0
  51. package/src/components/CommandPalette.vue +5 -5
  52. package/src/components/ConnectionGuideView.vue +1 -1
  53. package/src/components/ConnectionNotice.vue +59 -0
  54. package/src/components/ConnectionsPanel.vue +30 -568
  55. package/src/components/ContextMenu.vue +31 -5
  56. package/src/components/DataTable.vue +1331 -0
  57. package/src/components/FilePane.vue +3 -0
  58. package/src/components/FilterBar.vue +12 -5
  59. package/src/components/GalleryView.vue +50 -2
  60. package/src/components/GridView.vue +51 -3
  61. package/src/components/HomeView.vue +18 -6
  62. package/src/components/InspectorPanel.vue +134 -17
  63. package/src/components/ListView.vue +366 -900
  64. package/src/components/LogoMark.vue +57 -0
  65. package/src/components/NFSExportsPanel.vue +76 -28
  66. package/src/components/OnboardingTour.vue +19 -7
  67. package/src/components/OperationsCenter.vue +77 -1
  68. package/src/components/PendingOpsTray.vue +59 -5
  69. package/src/components/PresenceBar.vue +7 -7
  70. package/src/components/ProductVersion.vue +31 -0
  71. package/src/components/QuickLook.vue +10 -14
  72. package/src/components/RecentlyOpened.vue +31 -15
  73. package/src/components/RecoveryKeyModal.vue +1 -1
  74. package/src/components/RowActions.vue +134 -0
  75. package/src/components/S3KeysPanel.vue +98 -62
  76. package/src/components/SSHKeysPanel.vue +79 -30
  77. package/src/components/SideNav.vue +260 -96
  78. package/src/components/StarButton.vue +1 -0
  79. package/src/components/StorageFields.vue +74 -6
  80. package/src/components/StorageTags.vue +62 -0
  81. package/src/components/TabBar.vue +1 -1
  82. package/src/components/TagKindIcon.vue +56 -0
  83. package/src/components/TagPicker.vue +226 -40
  84. package/src/components/ThemePalette.vue +4 -4
  85. package/src/components/TimeZonePicker.vue +29 -10
  86. package/src/components/TokensPanel.vue +82 -31
  87. package/src/components/Toolbar.vue +28 -6
  88. package/src/components/plugin/PluginConfirmModal.vue +54 -0
  89. package/src/components/plugin/PluginInspectorSection.vue +149 -0
  90. package/src/components/plugin/PluginPageView.vue +339 -0
  91. package/src/components/plugin/PluginViewModal.vue +159 -0
  92. package/src/components/plugin/SurfaceConversation.vue +76 -0
  93. package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
  94. package/src/components/plugin/SurfaceRenderer.vue +299 -0
  95. package/src/components/plugin/SurfaceSections.vue +91 -0
  96. package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
  97. package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
  98. package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
  99. package/src/components/plugin/nodes/SurfaceList.vue +127 -0
  100. package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
  101. package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
  102. package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
  103. package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
  104. package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
  105. package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
  106. package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
  107. package/src/components/public/PublicLanguagePicker.vue +58 -0
  108. package/src/components/public/PublicLinkPage.vue +286 -0
  109. package/src/components/public/PublicLinkPreview.vue +85 -0
  110. package/src/components/public/PublicPinGate.vue +108 -0
  111. package/src/components/public/PublicRequestBody.vue +204 -0
  112. package/src/components/public/PublicShareBody.vue +188 -0
  113. package/src/components/public/PublicShell.vue +332 -0
  114. package/src/composables/useConnections.ts +8 -2
  115. package/src/composables/useFileApi.ts +183 -44
  116. package/src/composables/useLocale.ts +277 -41
  117. package/src/composables/useNFSExports.ts +8 -10
  118. package/src/composables/useOperations.ts +44 -3
  119. package/src/composables/usePendingOps.ts +59 -8
  120. package/src/composables/usePluginActions.ts +121 -0
  121. package/src/composables/usePluginSurface.ts +223 -0
  122. package/src/composables/usePublicBranding.ts +172 -0
  123. package/src/composables/usePublicLink.ts +573 -0
  124. package/src/composables/usePublicPage.ts +61 -0
  125. package/src/composables/useS3Keys.ts +17 -12
  126. package/src/composables/useSSHKeys.ts +8 -10
  127. package/src/composables/useTokens.ts +8 -12
  128. package/src/composables/useUploadChunked.ts +18 -8
  129. package/src/composables/useViewerFetch.ts +6 -3
  130. package/src/index.ts +404 -8
  131. package/src/lib/actionIcons.ts +46 -1
  132. package/src/lib/advSearch.ts +14 -7
  133. package/src/lib/appLock.ts +130 -0
  134. package/src/lib/connection.ts +110 -0
  135. package/src/lib/destinationTree.ts +19 -3
  136. package/src/lib/direction.ts +353 -0
  137. package/src/lib/dragGhost.ts +6 -0
  138. package/src/lib/dropLimits.ts +69 -0
  139. package/src/lib/errorWords.ts +394 -0
  140. package/src/lib/externalReach.ts +5 -1
  141. package/src/lib/fileFilters.ts +7 -2
  142. package/src/lib/fileIcons.ts +4 -0
  143. package/src/lib/internalPaths.ts +77 -0
  144. package/src/lib/listInput.ts +64 -0
  145. package/src/lib/listing.ts +58 -23
  146. package/src/lib/modalStack.ts +26 -0
  147. package/src/lib/nodeRow.ts +18 -3
  148. package/src/lib/pdfFieldRules.ts +126 -0
  149. package/src/lib/pdfFields.ts +589 -0
  150. package/src/lib/pdfFieldsGeom.ts +203 -0
  151. package/src/lib/pdfjsLoader.ts +51 -0
  152. package/src/lib/personName.ts +47 -0
  153. package/src/lib/pluginApplies.ts +123 -0
  154. package/src/lib/pluginLabel.ts +72 -0
  155. package/src/lib/pluginMenu.ts +253 -0
  156. package/src/lib/pluginPage.ts +58 -0
  157. package/src/lib/plural.ts +181 -0
  158. package/src/lib/popupLayer.ts +103 -0
  159. package/src/lib/prefs.ts +563 -0
  160. package/src/lib/productVersion.ts +35 -0
  161. package/src/lib/publicLayout.ts +39 -0
  162. package/src/lib/serviceGate.ts +104 -0
  163. package/src/lib/shareTtl.ts +25 -11
  164. package/src/lib/signFonts.ts +85 -0
  165. package/src/lib/signaturePad.ts +111 -0
  166. package/src/lib/sortOrder.ts +100 -38
  167. package/src/lib/star.ts +7 -1
  168. package/src/lib/surfaceCell.ts +41 -0
  169. package/src/lib/surfaceConditions.ts +156 -0
  170. package/src/lib/surfaceOpen.ts +106 -0
  171. package/src/lib/surfaceSteps.ts +68 -0
  172. package/src/lib/surfaceValues.ts +191 -0
  173. package/src/lib/symlink.ts +150 -0
  174. package/src/lib/tableColumns.ts +493 -0
  175. package/src/lib/tableEnv.ts +34 -0
  176. package/src/lib/tablePrefs.ts +84 -0
  177. package/src/lib/tags.ts +121 -12
  178. package/src/lib/themes.ts +286 -18
  179. package/src/lib/tour.ts +152 -0
  180. package/src/lib/trashEmpty.ts +111 -0
  181. package/src/lib/trashTimeLeft.ts +23 -0
  182. package/src/lib/uiLocales.ts +460 -0
  183. package/src/lib/viewPrefs.ts +696 -660
  184. package/src/lib/viewPrefsHttp.ts +78 -0
  185. package/src/locales/en.ts +515 -117
  186. package/src/locales/resolve.ts +17 -8
  187. package/src/locales/tr.ts +536 -152
  188. package/src/modals/ConvertModal.vue +62 -11
  189. package/src/modals/DestinationPickerModal.vue +59 -16
  190. package/src/modals/Modal.vue +113 -23
  191. package/src/modals/NewDocumentModal.vue +16 -6
  192. package/src/modals/NewFolderModal.vue +8 -0
  193. package/src/modals/PermissionsModal.vue +272 -163
  194. package/src/modals/PreviewModal.vue +119 -20
  195. package/src/modals/RenameModal.vue +37 -1
  196. package/src/styles/base.css +2890 -260
  197. package/src/styles/sign-fonts.css +118 -0
  198. package/src/styles/variables.css +60 -0
  199. package/src/types/Connections.ts +17 -0
  200. package/src/types/ExplorerConfig.ts +117 -1
  201. package/src/types/FileNode.ts +50 -0
  202. package/src/types/Plugins.ts +494 -0
  203. package/src/types/Public.ts +212 -0
  204. package/src/viewers/ArchiveViewer.vue +93 -48
  205. package/src/viewers/CsvViewer.vue +99 -53
  206. package/src/viewers/DrawioViewer.vue +35 -9
  207. package/src/viewers/EpubViewer.vue +7 -8
  208. package/src/viewers/IpynbViewer.vue +3 -1
  209. package/src/viewers/MermaidViewer.vue +4 -5
  210. package/src/viewers/PdfViewer.vue +8 -17
  211. package/src/viewers/PsdViewer.vue +2 -4
  212. package/src/viewers/TiffViewer.vue +4 -5
  213. package/src/viewers/Viewer3D.vue +1 -1
  214. package/LICENSE +0 -21
  215. package/dist/ArchiveViewer-BUQWLhwX.js +0 -124
  216. package/dist/ArchiveViewer-BUQWLhwX.js.map +0 -1
  217. package/dist/CsvViewer-Bg2_aqzc.js +0 -141
  218. package/dist/CsvViewer-Bg2_aqzc.js.map +0 -1
  219. package/dist/DrawioViewer-CC6rJXeP.js.map +0 -1
  220. package/dist/EpubViewer-CAoTSXTO.js.map +0 -1
  221. package/dist/IpynbViewer-D0GwGpnE.js +0 -184
  222. package/dist/IpynbViewer-D0GwGpnE.js.map +0 -1
  223. package/dist/MermaidViewer-DGwAvRZg.js +0 -137
  224. package/dist/MermaidViewer-DGwAvRZg.js.map +0 -1
  225. package/dist/PsdViewer-DhGweA0m.js +0 -122
  226. package/dist/PsdViewer-DhGweA0m.js.map +0 -1
  227. package/dist/TiffViewer-D0AurvAh.js +0 -142
  228. package/dist/TiffViewer-D0AurvAh.js.map +0 -1
  229. package/dist/Viewer3D-CdZeK4j4.js.map +0 -1
  230. package/dist/index-BArI-B1M.js +0 -26911
  231. package/dist/index-BArI-B1M.js.map +0 -1
  232. package/dist/useViewerFetch-czqbd2Lj.js +0 -25
  233. package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
  234. package/src/composables/useScrolledX.ts +0 -21
@@ -0,0 +1,286 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * PublicLinkPage — the whole public page, for whichever link was followed.
4
+ *
5
+ * ⚠⚠ This is the component `web/` mounts for `/s/:token` and `/d/:token`,
6
+ * and it is the ONLY thing the routes do (v3 §1: one public shell, in
7
+ * `packages/core`, so the desktop app and an embed draw the same page). The
8
+ * web app binds addresses; it does not own a public surface.
9
+ *
10
+ * What happens here and nowhere else:
11
+ * · the visitor's language, which has no account to be read from — the
12
+ * browser's, or a choice made on this device, over a list that now
13
+ * includes whatever an app plugin added (`lib/uiLocales`);
14
+ * · the light/dark answer: the instance's default (branding) unless this
15
+ * device has chosen otherwise;
16
+ * · the instance's identity, applied to the shell rather than to `:root`.
17
+ *
18
+ * ⚠ Nothing about the visitor is stored beyond the two display choices
19
+ * above: no session, no token copy, no PIN (the unlock cookie is the
20
+ * server's and HttpOnly).
21
+ */
22
+ import { computed, onMounted, ref, watch } from 'vue';
23
+ import type { LocaleCode, ThemeMode } from '../../types/ExplorerConfig';
24
+ import type { PublicRequestInfo, PublicShareInfo } from '../../types/Public';
25
+ import { useLocale } from '../../composables/useLocale';
26
+ import { usePublicBranding } from '../../composables/usePublicBranding';
27
+ import { usePublicRequest, usePublicShare } from '../../composables/usePublicLink';
28
+ import { hasLocale, normalizeLocaleCode } from '../../lib/uiLocales';
29
+ import { localPref, setLocalPref } from '../../lib/prefs';
30
+ import { detectLocale } from '../../locales/resolve';
31
+ import { labelOf } from '../../lib/pluginLabel';
32
+ import { publicLayoutFor } from '../../lib/publicLayout';
33
+ import PublicShell from './PublicShell.vue';
34
+ import PublicShareBody from './PublicShareBody.vue';
35
+ import PublicRequestBody from './PublicRequestBody.vue';
36
+ import type { DropRefusal } from '../../lib/dropLimits';
37
+
38
+ const props = defineProps<{
39
+ /**
40
+ * `share` → `/s/<token>`; `request` → `/d/<token>`.
41
+ *
42
+ * ⚠ `/p/<token>` is RETIRED (v3 §1: an app's public page is a share now)
43
+ * and the server 301s it to the share, so there is no third kind here and
44
+ * no second code path for the transition.
45
+ */
46
+ kind: 'share' | 'request';
47
+ /** From the route. Empty (a URL that is not a token) draws "not available". */
48
+ token?: string;
49
+ /** API origin; empty = same origin. */
50
+ base?: string;
51
+ /** Forced by the host; otherwise the device's choice, then the browser's. */
52
+ locale?: LocaleCode | string;
53
+ /** Forced by the host; otherwise the instance's default, then the device's. */
54
+ theme?: ThemeMode;
55
+ }>();
56
+
57
+ /* ── the visitor's language ───────────────────────────────────────────── */
58
+
59
+ const chosen = ref<string>('');
60
+ const locale = computed<string>(() => {
61
+ if (props.locale) return String(props.locale);
62
+ if (chosen.value) return chosen.value;
63
+ const stored = normalizeLocaleCode(localPref('locale'));
64
+ if (stored && hasLocale(stored)) return stored;
65
+ return detectLocale();
66
+ });
67
+
68
+ function chooseLocale(v: string): void {
69
+ const code = normalizeLocaleCode(v);
70
+ if (!code || !hasLocale(code)) return;
71
+ chosen.value = code;
72
+ // ⚠ localStorage and nothing else. There is no account behind a share
73
+ // link, so this is the only place a stranger's choice can live — and it is
74
+ // a display preference, not a fact about them.
75
+ setLocalPref('locale', code);
76
+ }
77
+
78
+ const { t, formatSize } = useLocale(() => locale.value);
79
+
80
+ /* ── the instance's identity ──────────────────────────────────────────── */
81
+
82
+ const brand = usePublicBranding({ base: props.base });
83
+
84
+ /**
85
+ * Light or dark.
86
+ *
87
+ * Order: what the host forced, then what this device chose, then the
88
+ * instance's own default, then the system. ⚠ The instance's default is
89
+ * BELOW the device's choice deliberately — an operator picks the default
90
+ * their visitors start from, not the one they are held to.
91
+ */
92
+ const resolvedTheme = computed<'light' | 'dark'>(() => {
93
+ const want = props.theme && props.theme !== 'auto' ? props.theme : localPref('theme') || brand.themeDefault.value;
94
+ if (want === 'light' || want === 'dark') return want;
95
+ try {
96
+ return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
97
+ } catch {
98
+ return 'light';
99
+ }
100
+ });
101
+
102
+ /* ── the link itself ──────────────────────────────────────────────────── */
103
+
104
+ const opts = {
105
+ base: props.base,
106
+ locale: () => locale.value,
107
+ errorText: () => t('plugin.view.error'),
108
+ };
109
+
110
+ const share = props.kind === 'share' ? usePublicShare(props.token ?? '', opts) : null;
111
+ /* ⚠ A file request has no app behind it: its failure is "could not be sent",
112
+ never the app runtime's "The app returned an error" (QA, 2026-09-21). */
113
+ const request =
114
+ props.kind === 'request'
115
+ ? usePublicRequest(props.token ?? '', { ...opts, errorText: () => t('public.upload_failed') })
116
+ : null;
117
+
118
+ /** The words for a file the drop page will not send (lib/dropLimits). */
119
+ function refusedText(r: DropRefusal): string {
120
+ if (r.code === 'ext') return t('public.refused_ext', { name: r.name });
121
+ // The same number the limits line states, in the same units.
122
+ if (r.code === 'too_large') return t('public.refused_too_large', { size: formatSize(r.mb * 1_000_000) });
123
+ if (r.count <= 0) return t('public.request_full');
124
+ return t('public.refused_too_many', { count: r.count });
125
+ }
126
+ const link = (share ?? request)!;
127
+
128
+ const shareInfo = computed(() => (share?.info.value ?? null) as PublicShareInfo | null);
129
+ const requestInfo = computed(() => (request?.info.value ?? null) as PublicRequestInfo | null);
130
+
131
+ /**
132
+ * The heading.
133
+ *
134
+ * An app link is titled by the app (its manifest page, then the surface it
135
+ * answered with); a file or folder link by the thing behind it; a file
136
+ * request by the folder it drops into. ⚠ The app's title is read from the
137
+ * SURFACE as well, because a wizard renames the screen as it walks ("Who
138
+ * signs" → "Place the boxes") and a heading frozen at the manifest's page
139
+ * label would contradict the step under it.
140
+ */
141
+ const title = computed(() => {
142
+ const app = shareInfo.value?.app;
143
+ const fromSurface = labelOf(share?.conv.current.value?.title, locale.value);
144
+ if (app) return fromSurface || labelOf(app.title, locale.value) || '';
145
+ return shareInfo.value?.node?.name || requestInfo.value?.folder || '';
146
+ });
147
+
148
+ const subject = computed(() => {
149
+ const s = link.info.value?.subject ?? '';
150
+ // Not twice: a file share's subject defaults to the file's own name on the
151
+ // server, and printing it again under the heading is noise.
152
+ return s && s !== title.value ? s : '';
153
+ });
154
+
155
+ /**
156
+ * How wide the card is, and which badge it opens with — both from the kind
157
+ * the server answered with, through the one helper the admin preview uses
158
+ * too (`lib/publicLayout`). ⚠ A file request is titled by its folder, so its
159
+ * badge is a folder rather than a document.
160
+ */
161
+ const layout = computed(() =>
162
+ publicLayoutFor(shareInfo.value?.kind ?? (request ? 'drop' : ''), {
163
+ list: (shareInfo.value?.entries?.length ?? 0) > 0,
164
+ }),
165
+ );
166
+ const badge = computed<'file' | 'folder' | ''>(() => {
167
+ if (request) return '';
168
+ const kind = shareInfo.value?.kind;
169
+ if (kind === 'folder') return 'folder';
170
+ if (kind === 'app') return '';
171
+ return 'file';
172
+ });
173
+
174
+ /** Walking into a folder is a reload of the same link at another path. */
175
+ function navigate(path: string): void {
176
+ void link.load(path ? `?path=${encodeURIComponent(path)}` : '');
177
+ }
178
+
179
+ watch(title, (v) => {
180
+ if (v && typeof document !== 'undefined') document.title = v;
181
+ });
182
+
183
+ /**
184
+ * THE DOCUMENT ELEMENT follows the language — both halves of it.
185
+ *
186
+ * ⚠⚠ The shell puts `dir` on its own wrapper, which turns the page around
187
+ * visually, and that is what every eye test sees. `<html>` kept the values
188
+ * the SERVER rendered: a stranger who pressed العربية on a share page was
189
+ * reading Arabic inside `<html lang="en" dir="ltr">` (v0.43.0). Nothing on
190
+ * screen says so, and everything that does not look at pixels is misled — a
191
+ * screen reader announces the language it is told, hyphenation and quotation
192
+ * marks follow `lang`, and `dir` on the root is what a printed page and any
193
+ * unwrapped portal inherit.
194
+ *
195
+ * One watcher, `immediate`, so the first paint and every later change agree;
196
+ * `chooseLocale` does not touch the document at all any more, because a
197
+ * language can also arrive from the host (`props.locale`) or from what this
198
+ * device chose last time.
199
+ *
200
+ * ⚠ `lang` only. `dir` is DERIVED from it by the one rule that owns
201
+ * direction (`lib/direction` → `syncDocumentDir`, installed by the host that
202
+ * owns the page), which also re-applies when the language LIST arrives — a
203
+ * pack's language can be on `<html lang>` before the answer saying it is
204
+ * written right to left has landed. Setting `dir` here as well produced
205
+ * exactly that race: the page wrote `ltr` from a list it did not have yet.
206
+ */
207
+ watch(
208
+ locale,
209
+ (code) => {
210
+ if (typeof document !== 'undefined') document.documentElement.lang = code;
211
+ },
212
+ { immediate: true },
213
+ );
214
+
215
+ // A language chosen mid-visit has to reach the PLUGIN too: its strings come
216
+ // from the server, which reads `Accept-Language` on every call, so the screen
217
+ // has to be asked for again in the new language.
218
+ //
219
+ // ⚠⚠ Asked AGAIN, not started again. Reloading the link re-opens the surface
220
+ // at its first screen: somebody three steps into signing, with their title
221
+ // typed and their signature drawn, lost all of it by pressing "English" —
222
+ // and the language picker sits right next to the wizard. A `change` carries
223
+ // the state and the values that are already on screen, so the same step
224
+ // comes back in the other language with the answers still in it.
225
+ watch(locale, () => {
226
+ if (link.status.value === 'ready' && shareInfo.value?.kind === 'app') {
227
+ if (link.conv.current.value) void link.conv.post('change');
228
+ else void link.load();
229
+ }
230
+ });
231
+
232
+ onMounted(() => {
233
+ // ⚠ One fetch, not two: the branding answer CARRIES the language list
234
+ // (`locales`), and `usePublicBranding` folds it into the registry. A
235
+ // separate call would be a second unauthenticated round trip on every
236
+ // public page for half of what this one already says.
237
+ void brand.load();
238
+ if (!props.token) {
239
+ link.status.value = 'not_found';
240
+ return;
241
+ }
242
+ void link.load();
243
+ });
244
+ </script>
245
+
246
+ <template>
247
+ <PublicShell
248
+ :status="link.status.value"
249
+ :locale="locale"
250
+ :theme="resolvedTheme"
251
+ :brand-name="brand.name.value"
252
+ :logo-url="brand.logo.value"
253
+ :accent-style="brand.accentStyle.value"
254
+ :footer-text="brand.footerText.value"
255
+ :hide-powered-by="brand.hidePoweredBy.value"
256
+ :layout="layout"
257
+ :badge="badge"
258
+ :title="title"
259
+ :subject="subject"
260
+ :expires-at="link.info.value?.expires_at ?? null"
261
+ :visits-left="shareInfo?.visits_left ?? requestInfo?.uploads_left ?? null"
262
+ :pin-busy="link.pinBusy.value"
263
+ :pin-failure="link.pinFailure.value"
264
+ :lock-message="link.lockMessage.value"
265
+ :failure="link.failure.value"
266
+ @pin="(p: string) => link.submitPin(p)"
267
+ @retry="() => link.load()"
268
+ @update:locale="chooseLocale"
269
+ >
270
+ <PublicShareBody
271
+ v-if="share"
272
+ :link="share"
273
+ :locale="locale"
274
+ :theme="resolvedTheme"
275
+ @navigate="navigate"
276
+ />
277
+ <PublicRequestBody
278
+ v-else-if="request"
279
+ :info="requestInfo"
280
+ :uploads="request.uploads.value"
281
+ :locale="locale"
282
+ hide-folder
283
+ @files="(f: File[], name: string) => request!.upload(f, { name, refusedText })"
284
+ />
285
+ </PublicShell>
286
+ </template>
@@ -0,0 +1,85 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * PublicLinkPreview — the public share page, drawn with values that are not
4
+ * saved yet, for the admin's Corporate identity page.
5
+ *
6
+ * ⚠⚠ It is the REAL page's components — PublicShell and PublicShareBody, the
7
+ * ones `/s/<token>` mounts — not a picture of them. The admin page had a
8
+ * hand-drawn mock (a centred card with an icon and a full-width button) while
9
+ * the page it previewed is left-aligned and plain (release-candidate sweep,
10
+ * 2026-09-21, QA #25): the preview taught the operator a page that does not
11
+ * exist. Anything the public page changes, this changes with it.
12
+ *
13
+ * Nothing is fetched: the "link" handed to the body is a fixed one-file share
14
+ * (only the fields the one-document branch reads), and the accent goes
15
+ * through the same derivation the page uses (accentStyleOf).
16
+ *
17
+ * ⚠ Inert: a preview is looked at, not used. The page's controls (the
18
+ * download button, the language picker) must not act from inside the admin
19
+ * panel.
20
+ */
21
+ import { computed, ref } from 'vue';
22
+ import type { LocaleCode } from '../../types/ExplorerConfig';
23
+ import type { PublicShareInfo } from '../../types/Public';
24
+ import type { PublicLinkStore } from '../../composables/usePublicLink';
25
+ import { accentStyleOf, DEFAULT_BRAND_NAME } from '../../composables/usePublicBranding';
26
+ import { publicLayoutFor } from '../../lib/publicLayout';
27
+ import PublicShell from './PublicShell.vue';
28
+ import PublicShareBody from './PublicShareBody.vue';
29
+
30
+ const props = defineProps<{
31
+ brandName?: string;
32
+ logoUrl?: string;
33
+ /** A hex colour, as the operator typed it; anything else means "none". */
34
+ accent?: string;
35
+ footerText?: string;
36
+ hidePoweredBy?: boolean;
37
+ locale: LocaleCode | string;
38
+ theme?: 'light' | 'dark';
39
+ /** The file the sample link shares. */
40
+ sample?: { name: string; size: number; mime?: string };
41
+ }>();
42
+
43
+ const file = computed(() => props.sample ?? { name: 'report-2026.pdf', size: 1_240_000, mime: 'application/pdf' });
44
+
45
+ const info = computed<PublicShareInfo>(() => ({
46
+ kind: 'file',
47
+ node: { name: file.value.name, size: file.value.size, mime: file.value.mime ?? 'application/pdf' },
48
+ }) as PublicShareInfo);
49
+
50
+ /* The body reads `info` and, for a single document, `downloadUrl()`; it
51
+ touches nothing else of a link on that branch. */
52
+ const link = computed(
53
+ () =>
54
+ ({
55
+ info: ref(info.value),
56
+ downloadUrl: () => '#',
57
+ noJsUrl: () => '#',
58
+ }) as unknown as PublicLinkStore<PublicShareInfo> & {
59
+ downloadUrl: (o?: { zip?: boolean; path?: string }) => string;
60
+ noJsUrl: () => string;
61
+ },
62
+ );
63
+
64
+ const name = computed(() => props.brandName?.trim() || DEFAULT_BRAND_NAME);
65
+ </script>
66
+
67
+ <template>
68
+ <div class="fe-ppreview" inert data-testid="public-link-preview">
69
+ <PublicShell
70
+ status="ready"
71
+ :locale="locale"
72
+ :theme="theme ?? 'light'"
73
+ :brand-name="name"
74
+ :logo-url="logoUrl?.trim() || ''"
75
+ :accent-style="accentStyleOf(accent)"
76
+ :footer-text="footerText?.trim() || ''"
77
+ :hide-powered-by="hidePoweredBy === true"
78
+ :layout="publicLayoutFor('file')"
79
+ badge="file"
80
+ :title="file.name"
81
+ >
82
+ <PublicShareBody :link="link" :locale="locale" :theme="theme ?? 'light'" />
83
+ </PublicShell>
84
+ </div>
85
+ </template>
@@ -0,0 +1,108 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * PublicPinGate — the code a protected link asks for.
4
+ *
5
+ * ⚠⚠ ONE gate for all three public links (v3 §1). There used to be two: the
6
+ * Go-rendered form on `/s/` and `/d/`, and this one on an app's page. They
7
+ * disagreed about everything a person notices — the wording, the keyboard
8
+ * the phone opened, what a wrong code said, whether the box was cleared and
9
+ * refocused, and whether being locked out said so or just kept refusing.
10
+ *
11
+ * ⚠ The PIN is never stored, never logged and never put in a URL. What the
12
+ * server returns is an HttpOnly cookie this code cannot read; the box is
13
+ * cleared on a wrong answer so the next attempt starts from empty.
14
+ */
15
+ import { nextTick, onMounted, ref, watch } from 'vue';
16
+ import type { LocaleCode } from '../../types/ExplorerConfig';
17
+ import { useLocale } from '../../composables/useLocale';
18
+
19
+ const props = defineProps<{
20
+ locale: LocaleCode | string;
21
+ busy?: boolean;
22
+ /** `wrong` | `locked` | '' — from the composable, not from a status code here. */
23
+ failure?: '' | 'wrong' | 'locked';
24
+ /** The server's own words for a lock, when it sends any. */
25
+ lockMessage?: string;
26
+ }>();
27
+
28
+ const emit = defineEmits<{
29
+ (e: 'submit', pin: string): void;
30
+ }>();
31
+
32
+ const { t } = useLocale(() => props.locale);
33
+
34
+ const pin = ref('');
35
+ const input = ref<HTMLInputElement | null>(null);
36
+
37
+ const message = () => {
38
+ if (props.failure === 'wrong') return t('plugin.page.pin_wrong');
39
+ if (props.failure === 'locked') return t('plugin.page.pin_locked');
40
+ return '';
41
+ };
42
+
43
+ // A wrong code empties the box and puts the caret back in it: a form that
44
+ // keeps the rejected digits makes the person select-all before retyping.
45
+ watch(
46
+ () => props.failure,
47
+ async (f) => {
48
+ if (f === 'wrong') {
49
+ pin.value = '';
50
+ await nextTick();
51
+ input.value?.focus();
52
+ }
53
+ },
54
+ );
55
+
56
+ onMounted(() => input.value?.focus());
57
+
58
+ function submit(): void {
59
+ if (props.busy || props.failure === 'locked') return;
60
+ // ⚠ An empty box is not an error to announce, it is a caret to move. The
61
+ // button stays live — a greyed-out primary is the whole page's one act
62
+ // looking broken before the person has done anything wrong.
63
+ if (!pin.value.trim()) {
64
+ input.value?.focus();
65
+ return;
66
+ }
67
+ emit('submit', pin.value);
68
+ }
69
+ </script>
70
+
71
+ <template>
72
+ <form class="fe-ppage__pin" data-testid="public-page-pin" @submit.prevent="submit">
73
+ <div class="fe-ppage__state">
74
+ <h2 class="fe-ppage__state-title">{{ t('plugin.page.pin_title') }}</h2>
75
+ <p class="fe-surface__text fe-surface__text--muted">{{ t('plugin.page.pin_hint') }}</p>
76
+ </div>
77
+ <!-- ⚠ The label is for a screen reader only. The heading right above it
78
+ already says the box is for a code, and a visible "PIN" caption over
79
+ a PIN box is furniture — the old gate had none. -->
80
+ <label class="fe-ppage__srlabel" for="fe-ppage-pin">{{ t('plugin.pin.label') }}</label>
81
+ <input
82
+ id="fe-ppage-pin"
83
+ ref="input"
84
+ v-model="pin"
85
+ class="fe-cfield__input fe-ppage__pinput"
86
+ :class="{ 'is-invalid': !!message() }"
87
+ type="text"
88
+ inputmode="numeric"
89
+ autocomplete="one-time-code"
90
+ spellcheck="false"
91
+ maxlength="12"
92
+ :disabled="busy || failure === 'locked'"
93
+ :aria-invalid="message() ? 'true' : undefined"
94
+ data-testid="public-page-pin-input"
95
+ />
96
+ <p v-if="message()" class="fe-ppage__error" role="alert" data-testid="public-page-pin-error">
97
+ {{ message() }}<template v-if="lockMessage"> ({{ lockMessage }})</template>
98
+ </p>
99
+ <button
100
+ type="submit"
101
+ class="fe-btn fe-btn--primary"
102
+ :disabled="busy || failure === 'locked'"
103
+ data-testid="public-page-pin-submit"
104
+ >
105
+ {{ t('plugin.page.pin_submit') }}
106
+ </button>
107
+ </form>
108
+ </template>
@@ -0,0 +1,204 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * PublicRequestBody — the drop box a file request opens (`/d/<token>`).
4
+ *
5
+ * The person on this page is usually not a filex user and will never be one:
6
+ * they were asked for a document and sent a link. So the body is one thing —
7
+ * a place to put files — with the limits stated BEFORE they pick (what may
8
+ * be sent, how big, how many are left), because a rejection after a two
9
+ * minute upload is the version of this screen that gets abandoned.
10
+ *
11
+ * ⚠ Progress is real progress, from the upload itself (`usePublicRequest`
12
+ * uses XHR for exactly this). A spinner that does not move is what makes
13
+ * somebody reload the page halfway through a transfer.
14
+ *
15
+ * ⚠ "Blind drop": the server never tells this page what is already in the
16
+ * folder, and this page never asks. A file request is permission to ADD.
17
+ */
18
+ import { computed, ref } from 'vue';
19
+ import type { LocaleCode } from '../../types/ExplorerConfig';
20
+ import type { PublicRequestInfo } from '../../types/Public';
21
+ import { useLocale } from '../../composables/useLocale';
22
+
23
+ const props = defineProps<{
24
+ info: PublicRequestInfo | null;
25
+ uploads: Array<{ name: string; size: number; percent: number; state: string; error?: string }>;
26
+ locale: LocaleCode | string;
27
+ disabled?: boolean;
28
+ /**
29
+ * Leave the folder's name out of the body. The public page's heading is
30
+ * already that name (`PublicLinkPage` titles a request by its folder), and
31
+ * printing it again under itself read as a stutter.
32
+ */
33
+ hideFolder?: boolean;
34
+ }>();
35
+
36
+ const emit = defineEmits<{
37
+ /** The files, and the name the person gave (`''` when the link does not ask). */
38
+ (e: 'files', files: File[], name: string): void;
39
+ }>();
40
+
41
+ /**
42
+ * The uploader's name, when the link asks for it (`limits.ask_name`).
43
+ *
44
+ * ⚠⚠ The owner's "Ask uploader name" is ON by default, and this page never
45
+ * asked (QA, 2026-09-21): the setting did nothing, and every submission folder
46
+ * arrived as `<date>_anon`. Optional, as on the no-JavaScript page — a name
47
+ * nobody wants to give is not a reason to refuse their document — and ABOVE
48
+ * the drop area, because dropping a file sends it.
49
+ */
50
+ const uploaderName = ref('');
51
+ const askName = computed(() => props.info?.limits?.ask_name === true);
52
+ function sendFiles(files: File[]): void {
53
+ emit('files', files, askName.value ? uploaderName.value.trim() : '');
54
+ }
55
+
56
+ const { t, formatSize } = useLocale(() => props.locale);
57
+
58
+ const input = ref<HTMLInputElement | null>(null);
59
+ const over = ref(false);
60
+
61
+ const limitsOf = computed(() => props.info?.limits ?? {});
62
+
63
+ const accept = computed(() => {
64
+ const list = limitsOf.value.allowed_ext ?? [];
65
+ return list.length ? list.map((e) => `.${e.replace(/^\./, '')}`).join(',') : undefined;
66
+ });
67
+
68
+ const limits = computed(() => {
69
+ const out: string[] = [];
70
+ const mb = limitsOf.value.max_file_size_mb;
71
+ // ⚠ The server states MEGABYTES; the screen says it in the same units the
72
+ // rest of the product uses, so "5 MB" here and "5 MB" in the explorer are
73
+ // the same number rather than two roundings of it.
74
+ if (mb && mb > 0) out.push(t('public.max_size', { size: formatSize(mb * 1_000_000) }));
75
+ const ext = limitsOf.value.allowed_ext ?? [];
76
+ if (ext.length) out.push(t('public.accepts', { list: ext.map((e) => e.toUpperCase()).join(', ') }));
77
+ const left = props.info?.uploads_left;
78
+ if (left !== null && left !== undefined && Number.isFinite(left)) out.push(t('public.files_left', { n: left }));
79
+ return out;
80
+ });
81
+
82
+ /** No room left: the box says so instead of taking a file the server will refuse. */
83
+ const full = computed(() => {
84
+ const left = props.info?.uploads_left;
85
+ return left !== null && left !== undefined && Number.isFinite(left) && left <= 0;
86
+ });
87
+
88
+ function pick(): void {
89
+ if (props.disabled || full.value) return;
90
+ input.value?.click();
91
+ }
92
+
93
+ function onPicked(e: Event): void {
94
+ const el = e.target as HTMLInputElement;
95
+ const files = Array.from(el.files ?? []);
96
+ if (files.length) sendFiles(files);
97
+ // Cleared so choosing the SAME file twice still fires a change event.
98
+ el.value = '';
99
+ }
100
+
101
+ function onDrop(e: DragEvent): void {
102
+ over.value = false;
103
+ if (props.disabled || full.value) return;
104
+ const files = Array.from(e.dataTransfer?.files ?? []);
105
+ if (files.length) sendFiles(files);
106
+ }
107
+ </script>
108
+
109
+ <template>
110
+ <div class="fe-pdrop" data-testid="public-request">
111
+ <p v-if="info?.folder && !hideFolder" class="fe-surface__text" data-testid="public-request-folder">
112
+ <strong>{{ info?.folder }}</strong>
113
+ </p>
114
+
115
+ <!-- ⚠ What this page IS, said before anything is asked of the person.
116
+ The Go-rendered drop page opened on this sentence and the unified
117
+ shell dropped it, so a stranger arrived at a dashed rectangle with no
118
+ statement of what happens to what they put in it — and none of the
119
+ reassurance that they are not being shown somebody's folder. -->
120
+ <p class="fe-surface__text fe-surface__text--muted" data-testid="public-request-lead">
121
+ {{ t('public.drop_sub') }}
122
+ </p>
123
+
124
+ <label v-if="askName && !full" class="fe-pdrop__who" data-testid="public-request-name">
125
+ <span class="fe-pdrop__wholabel">{{ t('public.your_name') }}</span>
126
+ <input
127
+ v-model="uploaderName"
128
+ class="fe-pdrop__whoinput"
129
+ type="text"
130
+ maxlength="60"
131
+ autocomplete="name"
132
+ :placeholder="t('public.your_name_ph')"
133
+ :disabled="disabled"
134
+ data-testid="public-request-name-input"
135
+ />
136
+ </label>
137
+
138
+ <div
139
+ class="fe-pdrop__zone"
140
+ :class="{ 'is-over': over, 'is-disabled': disabled || full }"
141
+ role="button"
142
+ tabindex="0"
143
+ data-testid="public-request-drop"
144
+ @click="pick"
145
+ @keydown.enter.prevent="pick"
146
+ @keydown.space.prevent="pick"
147
+ @dragover.prevent="over = true"
148
+ @dragleave="over = false"
149
+ @drop.prevent="onDrop"
150
+ >
151
+ <svg
152
+ class="fe-pdrop__icon"
153
+ viewBox="0 0 24 24"
154
+ fill="none"
155
+ stroke="currentColor"
156
+ stroke-width="1.7"
157
+ stroke-linecap="round"
158
+ stroke-linejoin="round"
159
+ aria-hidden="true"
160
+ >
161
+ <path d="M4 14.9A7 7 0 1 1 15.7 8h1.8a4.5 4.5 0 0 1 2.5 8.2" />
162
+ <path d="M12 12v9" />
163
+ <path d="m16 16-4-4-4 4" />
164
+ </svg>
165
+ <p class="fe-pdrop__lead">{{ full ? t('public.request_full') : t('public.drop_here') }}</p>
166
+ <p v-if="!full" class="fe-surface__text fe-surface__text--muted">{{ t('public.or_choose') }}</p>
167
+ <input
168
+ ref="input"
169
+ class="fe-pdrop__input"
170
+ type="file"
171
+ multiple
172
+ :accept="accept"
173
+ :disabled="disabled || full"
174
+ data-testid="public-request-input"
175
+ @change="onPicked"
176
+ />
177
+ </div>
178
+
179
+ <!-- ⚠ The limits are separated by a dot, as every other list of facts on
180
+ this page is. Butted against each other they read as one sentence:
181
+ "At most 20 MB per file Accepted: PDF, PNG". -->
182
+ <p v-if="limits.length" class="fe-ppage__meta" data-testid="public-request-limits">
183
+ <template v-for="(l, i) in limits" :key="i">
184
+ <span v-if="i" aria-hidden="true">·</span>
185
+ <span>{{ l }}</span>
186
+ </template>
187
+ </p>
188
+
189
+ <ul v-if="uploads.length" class="fe-pdrop__list" data-testid="public-request-uploads">
190
+ <li v-for="(u, i) in uploads" :key="`${u.name}-${i}`" class="fe-pdrop__item" :data-state="u.state">
191
+ <span class="fe-pdrop__name"><bdi>{{ u.name }}</bdi></span>
192
+ <span class="fe-ppage__filesize">{{ formatSize(u.size) }}</span>
193
+ <progress
194
+ v-if="u.state === 'sending'"
195
+ class="fe-pdrop__bar"
196
+ :max="100"
197
+ :value="u.percent >= 0 ? u.percent : undefined"
198
+ ></progress>
199
+ <span v-else-if="u.state === 'done'" class="fe-pdrop__ok">{{ t('public.upload_done') }}</span>
200
+ <span v-else class="fe-surface__error">{{ u.error || t('public.upload_failed') }}</span>
201
+ </li>
202
+ </ul>
203
+ </div>
204
+ </template>