@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,563 @@
1
+ /**
2
+ * prefs — the look-and-language preferences, kept on the ACCOUNT.
3
+ *
4
+ * ⚠⚠ Why this module exists: a palette picked in one browser was not there
5
+ * in the next one. Theme, palette, density and language were each stored in
6
+ * `localStorage` and nowhere else, so every device — and every private
7
+ * window, and every cleared cache — started the product over with somebody
8
+ * else's defaults. They are a PERSON's preferences, not a machine's.
9
+ *
10
+ * So the answer lives on the account: `GET /api/me/prefs?surface=web` on
11
+ * boot, `PUT /api/me/prefs?surface=web` on every change, debounced.
12
+ *
13
+ * ⚠ `localStorage` stays, and stays for exactly ONE job: the first paint.
14
+ * The fetch cannot beat the first frame, so the mirror is what stops the
15
+ * window flashing the stock palette on the way to the chosen one. It is a
16
+ * CACHE of the account's answer, never the answer — when the server's
17
+ * values land they overwrite it.
18
+ *
19
+ * ⚠⚠ ONE module, several surfaces. The web app asks for `surface=web` and
20
+ * the desktop app for `surface=desktop` — same code, same keys, same
21
+ * debounce; only the query differs, because a person may legitimately want
22
+ * a dense list on a laptop and a roomy one on a wall screen. There is no
23
+ * per-surface BRANCH anywhere in here, and there must not be one: a
24
+ * behaviour that exists on one surface only is the bug this product keeps
25
+ * having to un-write.
26
+ */
27
+
28
+ /** The preferences an account carries for one surface. */
29
+ export interface UiPrefs {
30
+ /** `light` | `dark` | `auto`. */
31
+ theme?: string;
32
+ /** A palette id from `lib/themes` (`default`, `night-blue`, …). */
33
+ palette?: string;
34
+ /** `comfortable` | `compact`. */
35
+ density?: string;
36
+ /** A language tag the interface offers (`lib/uiLocales`). */
37
+ locale?: string;
38
+ /**
39
+ * Set (to `done`) once the first-use tour has been OFFERED to this person
40
+ * (`lib/tour`). Not a look: it has no first-paint mirror of its own, and it
41
+ * only ever goes one way — nothing in the product clears it.
42
+ */
43
+ tour?: string;
44
+ }
45
+
46
+ export type PrefKey = keyof UiPrefs;
47
+
48
+ /** The keys that paint the window — each has a first-paint mirror (below). */
49
+ export type LookKey = Exclude<PrefKey, 'tour'>;
50
+
51
+ /**
52
+ * Every key the account document carries.
53
+ *
54
+ * ⚠⚠ A key the client does not list here is DROPPED on the next write:
55
+ * `sanitize` keeps only these, and `PUT` replaces the whole document. So a
56
+ * preference that is not a look (the tour) still has to be named here, or the
57
+ * first palette change after it was stored would erase it.
58
+ */
59
+ export const PREF_KEYS: readonly PrefKey[] = ['theme', 'palette', 'density', 'locale', 'tour'];
60
+
61
+ /** The keys with a first-paint mirror in `localStorage` — the look, nothing else. */
62
+ export const LOOK_KEYS: readonly LookKey[] = ['theme', 'palette', 'density', 'locale'];
63
+
64
+ /**
65
+ * The localStorage mirror's key per preference.
66
+ *
67
+ * ⚠⚠ These are the names the surfaces ALREADY read at first paint
68
+ * (`web/src/lib/theme`, `lib/themes.THEME_LS_KEY`, `web/src/lib/density`,
69
+ * `web/src/i18n`). Inventing new ones here would have left four first-paint
70
+ * readers looking at keys nobody writes — the preference would work, and
71
+ * the window would flash on every load, which is the failure this module is
72
+ * supposed to remove.
73
+ */
74
+ export const PREF_LS_KEYS: Record<LookKey, string> = {
75
+ theme: 'filex.theme',
76
+ palette: 'filex.palette',
77
+ density: 'filex.density',
78
+ locale: 'filex.locale',
79
+ };
80
+
81
+ /** How long a change waits for the next one before it is sent. */
82
+ export const PREFS_PUT_DEBOUNCE_MS = 400;
83
+
84
+ /* ── is anybody signed in? ────────────────────────────────────────────── */
85
+
86
+ /**
87
+ * ⚠⚠ WITH NO SESSION, NOTHING PERSONAL PAINTS. The owner's rule, verbatim:
88
+ * "logoutluyken zaten seçtiğim tema değil, şirketin default, yoksa filex'in
89
+ * default teması gelecek; tarayıcı gece modundaysa gece modunda olacak ya da
90
+ * light mode."
91
+ *
92
+ * So a signed-out window wears the INSTANCE's look (`ui.default_theme`, or
93
+ * filex's own stock palette when the operator has not set one) and follows the
94
+ * BROWSER's `prefers-color-scheme`. The palette and the light/dark mode became
95
+ * per-PERSON settings in v3 (migration 00047, `GET/PUT /api/me/prefs`), and a
96
+ * person is exactly what a signed-out window does not have.
97
+ *
98
+ * ⚠ Measured before it was changed (2026-09-21, vitest against the shipped
99
+ * `packages/core/dist`): with `filex.palette` = `night` and no session at all,
100
+ * `useThemeState().themeId` came back `"night"` and `filex.thememode` = `dark`
101
+ * came back `"dark"` — both read at MODULE LOAD, long before anything could
102
+ * know whether a session existed. Worse, `instanceThemes.hasOwnChoice()` saw
103
+ * the same key and suppressed the operator's own default entirely, so an
104
+ * instance with a brand theme showed the LAST PERSON WHO SIGNED IN HERE their
105
+ * colours on its sign-in page — and showed the next person at that shared
106
+ * machine the previous one's taste.
107
+ *
108
+ * ⚠⚠ WHY A STORED HINT AND NOT THE REAL ANSWER. The real answer is
109
+ * `/api/auth/me`, and it cannot be had at the first paint: the session cookie
110
+ * is HttpOnly (handlers/auth.go) so JavaScript cannot see it, and the fetch
111
+ * resolves long after the window has painted. A wrong answer is a visible bug
112
+ * in BOTH directions — read the personal palette with no session and the leak
113
+ * above is back; ignore it WITH a session and every single load flashes the
114
+ * instance default on the way to the person's own palette. So this is a guess
115
+ * that is right in the overwhelming case (this browser's last load answered
116
+ * the same question) and is corrected within one round trip by
117
+ * `rememberSession`, which every entry path reaches through `/api/auth/me`.
118
+ */
119
+ export const SESSION_LS_KEY = 'filex.session';
120
+
121
+ /**
122
+ * Set for THIS DOCUMENT only, never persisted: a public link (`/s/`, `/d/`)
123
+ * cannot carry a session whatever the browser remembers.
124
+ *
125
+ * ⚠ Persisting it would be the bug. The same browser's admin tab reads the
126
+ * same localStorage, so writing "no session" from a share link would make that
127
+ * tab flash the instance default on its next load — the very flash the mirror
128
+ * exists to prevent.
129
+ */
130
+ let sealed = false;
131
+
132
+ function readSessionHint(): boolean {
133
+ try {
134
+ return localStorage.getItem(SESSION_LS_KEY) === '1';
135
+ } catch {
136
+ // Private window / blocked site data. "No session" is the safe answer: it
137
+ // shows the instance's look, never somebody else's.
138
+ return false;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Is a person signed in here? The synchronous, first-paint answer.
144
+ *
145
+ * ⚠ `true` for a host that has not declared `sessionAware` — see that
146
+ * option. "Nobody told us about sessions" means "this explorer is inside
147
+ * something that already dealt with it", not "nobody is here".
148
+ */
149
+ export function hasSession(): boolean {
150
+ if (sealed) return false;
151
+ if (!cfg.sessionAware) return true;
152
+ return readSessionHint();
153
+ }
154
+
155
+ /**
156
+ * `/api/auth/me` has answered. Remember it for this browser's next first paint.
157
+ *
158
+ * ⚠ A hint and nothing more — it is not a credential, it grants nothing, and
159
+ * every authenticated request still carries the HttpOnly cookie. Forging it
160
+ * buys an attacker the wrong palette.
161
+ */
162
+ export function rememberSession(present: boolean): void {
163
+ try {
164
+ if (present) localStorage.setItem(SESSION_LS_KEY, '1');
165
+ else localStorage.removeItem(SESSION_LS_KEY);
166
+ } catch {
167
+ /* quota / private mode — `hasSession()` then answers false, which is the
168
+ safe direction */
169
+ }
170
+ if (present) sealed = false;
171
+ }
172
+
173
+ /** This document is a public link: the answer is no, and it is not remembered. */
174
+ export function sealSessionless(): void {
175
+ sealed = true;
176
+ }
177
+
178
+ /**
179
+ * Every localStorage key that mirrors a PERSON and must not outlive their
180
+ * session.
181
+ *
182
+ * ⚠⚠ An allow-list, never `localStorage.clear()`. Most of what this product
183
+ * keeps in a browser is a fact about the MACHINE or the INSTALL and survives a
184
+ * sign-out on purpose:
185
+ * • `filex.instancetheme` — the operator's default, a PUBLIC fact identical
186
+ * for everybody. Clearing it would put back the very flash it was added to
187
+ * remove, on the sign-in page, for nothing.
188
+ * • `filex.installPrompt.dismissed`, `filex.notify.browserAsked`,
189
+ * `filex.tourDone` — "this browser has already been asked". Clearing them
190
+ * re-nags whoever sits down next, which is worse than useless. (The tour
191
+ * is ALSO recorded on the account now — `lib/tour` — and the two are read
192
+ * as one answer: seen here OR seen by this person anywhere.)
193
+ * • `filex.sidenav`, `filex.inspector`, `filex.tabs`, `filex.showHidden`,
194
+ * `filex.startpage`, `filex.shortcuts`, `filex.presence.expanded` — window
195
+ * furniture and this machine's ergonomics, not a look somebody wears.
196
+ * • `filex.openTrigger`, `filex.desktopHandoff` — in-flight machinery for a
197
+ * hand-off that may be mid-sign-in right now.
198
+ * `filex.bearer` is already dropped by the auth store, and it lives in
199
+ * sessionStorage besides.
200
+ */
201
+ const personalMirrors = new Set<string>(Object.values(PREF_LS_KEYS));
202
+
203
+ /**
204
+ * A module with a per-person key of its own adds it here.
205
+ *
206
+ * ⚠ `lib/themes` registers `filex.thememode` this way rather than having its
207
+ * name repeated in the list above — a second copy of a storage key is exactly
208
+ * the drift `lib/density`' header warns about, where a rename on one side
209
+ * leaves the other writing to a key nobody reads.
210
+ */
211
+ export function registerPersonalMirror(key: string): void {
212
+ personalMirrors.add(key);
213
+ }
214
+
215
+ /**
216
+ * The session ended: this browser forgets everything it cached ABOUT THE
217
+ * PERSON.
218
+ *
219
+ * Owner, 2026-09-21: *"oturum kapanınca temizlersek localstorage'ı tamamız ya
220
+ * o kısımda"* — once somebody has left, their preferences are not something
221
+ * this machine should go on holding.
222
+ *
223
+ * ⚠⚠ It is an "and", not an "instead of". Clearing only covers the moments
224
+ * the app KNOWS about; it cannot cover a cookie that quietly expired, a tab
225
+ * closed without signing out, or a browser whose owner never signs out at all.
226
+ * `hasSession()` is what covers those, by making the keys unreadable rather
227
+ * than absent. Drop either one and the sign-in page goes back to wearing the
228
+ * last person who used this browser.
229
+ *
230
+ * ⚠ The in-memory document goes too, and the queued PUT with it. Leaving
231
+ * `remote` behind would keep `hasOwnChoice()` answering for somebody who has
232
+ * gone; leaving `pending` behind would fire a write with their values a moment
233
+ * after their cookie stopped existing.
234
+ */
235
+ export function forgetPersonalPrefs(): void {
236
+ rememberSession(false);
237
+ for (const key of personalMirrors) {
238
+ try {
239
+ localStorage.removeItem(key);
240
+ } catch {
241
+ /* blocked site data — `hasSession()` already makes the key unreadable */
242
+ }
243
+ }
244
+ remote = {};
245
+ hydrated = false;
246
+ pending = {};
247
+ if (timer !== null) clearTimeout(timer);
248
+ timer = null;
249
+ // The answer to "whose preferences?" is "nobody's": nothing more is coming
250
+ // for this document until somebody signs in (and `hydratePrefs` runs again).
251
+ settle();
252
+ }
253
+
254
+ export interface PrefsConfig {
255
+ /** `web`, `desktop`, or whatever a new surface calls itself. */
256
+ surface: string;
257
+ /** API origin; empty = same origin. */
258
+ base?: string;
259
+ /** Per-call auth, for a host that does not ride on cookies (the desktop app). */
260
+ headers?: () => Promise<Record<string, string>> | Record<string, string>;
261
+ fetchImpl?: typeof fetch;
262
+ /**
263
+ * ⚠⚠ Does this host have a SIGNED-OUT state at all?
264
+ *
265
+ * The filex SPA does: it draws a sign-in page, and the rule this flag turns
266
+ * on is the owner's (`SESSION_LS_KEY` above) — with nobody signed in, the
267
+ * palette and the light/dark mode in this browser belong to somebody else
268
+ * and are not read.
269
+ *
270
+ * Most hosts do NOT. An explorer embedded in work.example.com or in the fishapp
271
+ * is mounted inside a page the host already authenticated; the desktop app
272
+ * is past its pairing screen. They never call `rememberSession`, they have
273
+ * no `/api/auth/me` to call, and gating them would mean the palette a person
274
+ * picked inside the embed silently stopped applying on the next load — a
275
+ * regression, not a fix.
276
+ *
277
+ * ⚠ This is a fact about the HOST, declared once beside `surface` and
278
+ * `base`, not a branch on which surface is running: there is exactly one
279
+ * mechanism and every caller goes through it. Default OFF, so a host that
280
+ * says nothing keeps the behaviour it has always had.
281
+ */
282
+ sessionAware?: boolean;
283
+ }
284
+
285
+ let cfg: PrefsConfig = { surface: 'web' };
286
+ let remote: UiPrefs = {};
287
+ /** The server has answered at least once. */
288
+ let hydrated = false;
289
+ /** A host said where the account's document lives (`configurePrefs`). */
290
+ let configured = false;
291
+ /**
292
+ * The question "whose preferences are these?" has been answered for this
293
+ * document: the server's copy arrived, or the fetch gave up, or the session
294
+ * turned out to be nobody's (`forgetPersonalPrefs`). Something that must not
295
+ * decide before the account has spoken (the first-use tour) waits for this,
296
+ * not for `hydrated` — a fetch that failed never hydrates.
297
+ */
298
+ let settled = false;
299
+
300
+ const listeners = new Set<(p: UiPrefs) => void>();
301
+ const settleWaiters = new Set<() => void>();
302
+
303
+ export function configurePrefs(next: PrefsConfig): void {
304
+ cfg = { ...next };
305
+ configured = true;
306
+ }
307
+
308
+ /**
309
+ * Did the host wire the account's document at all? An explorer embedded in
310
+ * another product never calls `configurePrefs`, and for it there is no account
311
+ * copy to wait for — only this browser's.
312
+ */
313
+ export function prefsConfigured(): boolean {
314
+ return configured;
315
+ }
316
+
317
+ /** See `settled`. */
318
+ export function prefsSettled(): boolean {
319
+ return settled;
320
+ }
321
+
322
+ /** Be told once the account's answer is in (or known not to be coming). Returns the unsubscribe. */
323
+ export function onPrefsSettled(fn: () => void): () => void {
324
+ if (settled) {
325
+ fn();
326
+ return () => {};
327
+ }
328
+ settleWaiters.add(fn);
329
+ return () => settleWaiters.delete(fn);
330
+ }
331
+
332
+ function settle(): void {
333
+ settled = true;
334
+ const waiting = [...settleWaiters];
335
+ settleWaiters.clear();
336
+ for (const fn of waiting) {
337
+ try {
338
+ fn();
339
+ } catch {
340
+ /* one waiter's mistake is not another's */
341
+ }
342
+ }
343
+ }
344
+
345
+ /** The preferences as they stand — the server's answer once it has landed. */
346
+ export function currentPrefs(): UiPrefs {
347
+ return { ...remote };
348
+ }
349
+
350
+ export function prefsHydrated(): boolean {
351
+ return hydrated;
352
+ }
353
+
354
+ /** Be told when the account's answer arrives or changes. Returns the unsubscribe. */
355
+ export function onPrefs(fn: (p: UiPrefs) => void): () => void {
356
+ listeners.add(fn);
357
+ return () => listeners.delete(fn);
358
+ }
359
+
360
+ function announce(): void {
361
+ const snapshot = currentPrefs();
362
+ for (const fn of listeners) {
363
+ try {
364
+ fn(snapshot);
365
+ } catch {
366
+ /* one listener's mistake is not another's */
367
+ }
368
+ }
369
+ }
370
+
371
+ /* ── the first-paint mirror ───────────────────────────────────────────── */
372
+
373
+ /** What this DEVICE last saw — read before the network, never trusted after it. */
374
+ export function localPref(key: LookKey): string {
375
+ try {
376
+ return localStorage.getItem(PREF_LS_KEYS[key]) ?? '';
377
+ } catch {
378
+ // Private window, blocked site data: the caller's own default is the
379
+ // answer, and a preference is never worth an exception.
380
+ return '';
381
+ }
382
+ }
383
+
384
+ export function setLocalPref(key: LookKey, value: string): void {
385
+ try {
386
+ if (value) localStorage.setItem(PREF_LS_KEYS[key], value);
387
+ else localStorage.removeItem(PREF_LS_KEYS[key]);
388
+ } catch {
389
+ /* see above */
390
+ }
391
+ }
392
+
393
+ /** Everything the mirror holds, for the boot that has no server answer yet. */
394
+ export function localPrefs(): UiPrefs {
395
+ const out: UiPrefs = {};
396
+ for (const k of LOOK_KEYS) {
397
+ const v = localPref(k);
398
+ if (v) out[k] = v;
399
+ }
400
+ return out;
401
+ }
402
+
403
+ /* ── the account's copy ───────────────────────────────────────────────── */
404
+
405
+ function url(): string {
406
+ const base = (cfg.base ?? '').replace(/\/+$/, '');
407
+ return `${base}/api/me/prefs?surface=${encodeURIComponent(cfg.surface)}`;
408
+ }
409
+
410
+ async function headers(withBody: boolean): Promise<Record<string, string>> {
411
+ const h: Record<string, string> = { Accept: 'application/json' };
412
+ if (withBody) h['Content-Type'] = 'application/json';
413
+ // ⚠ awaited — the desktop app's token is a FUNCTION that resolves
414
+ // asynchronously, and spreading the promise sends no Authorization at all.
415
+ const extra = cfg.headers ? await cfg.headers() : {};
416
+ return { ...h, ...extra };
417
+ }
418
+
419
+ /** Only the keys this client knows, only as strings — a server that grows another is ignored, not obeyed. */
420
+ function sanitize(raw: unknown): UiPrefs {
421
+ const src = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
422
+ const body = (src.prefs && typeof src.prefs === 'object' ? src.prefs : src) as Record<string, unknown>;
423
+ const out: UiPrefs = {};
424
+ for (const k of PREF_KEYS) {
425
+ const v = body[k];
426
+ if (typeof v === 'string' && v) out[k] = v;
427
+ }
428
+ return out;
429
+ }
430
+
431
+ /**
432
+ * Read the account's preferences.
433
+ *
434
+ * Answers `null` when there is nothing to apply — no session, an older
435
+ * server with no such route, a network that is not there. ⚠ `null` is not an
436
+ * error: the mirror has already painted the window, and a preference is the
437
+ * last thing that should put an error in front of a person.
438
+ */
439
+ export async function hydratePrefs(): Promise<UiPrefs | null> {
440
+ const doFetch = cfg.fetchImpl ?? (typeof fetch === 'function' ? fetch : null);
441
+ if (!doFetch) {
442
+ settle();
443
+ return null;
444
+ }
445
+ // ⚠ A new question is being asked. A sign-out settled the last one with
446
+ // "nobody's" (`forgetPersonalPrefs`), and a person who then signs in on the
447
+ // sign-in page — no reload — must not be decided for on that stale answer
448
+ // while their own document is still on its way.
449
+ settled = false;
450
+ try {
451
+ const res = await doFetch(url(), {
452
+ method: 'GET',
453
+ headers: await headers(false),
454
+ credentials: 'same-origin',
455
+ cache: 'no-store',
456
+ });
457
+ if (!res.ok) return null;
458
+ remote = sanitize(await res.json());
459
+ hydrated = true;
460
+ // ⚠⚠ An account that has NEVER stored anything adopts what this browser
461
+ // already had, instead of wiping it. Every one of these preferences lived
462
+ // in localStorage alone until v3, so the first load after the upgrade
463
+ // would otherwise meet an empty document, clear the mirror and hand the
464
+ // person back the stock palette they had chosen their way out of — a
465
+ // silent reset that looks exactly like the bug this module was written to
466
+ // fix. One key already stored is enough to make the ACCOUNT the answer:
467
+ // somebody who cleared a palette on another device has a document, and
468
+ // nothing here resurrects what they cleared.
469
+ if (!Object.keys(remote).length) {
470
+ const mine = localPrefs();
471
+ if (Object.keys(mine).length) {
472
+ remote = mine;
473
+ for (const k of LOOK_KEYS) if (mine[k]) pending[k] = mine[k];
474
+ if (timer !== null) clearTimeout(timer);
475
+ timer = setTimeout(() => {
476
+ inFlight = flush();
477
+ }, PREFS_PUT_DEBOUNCE_MS);
478
+ }
479
+ }
480
+ // The mirror is a cache of THIS answer from here on.
481
+ for (const k of LOOK_KEYS) setLocalPref(k, remote[k] ?? '');
482
+ announce();
483
+ return currentPrefs();
484
+ } catch {
485
+ return null;
486
+ } finally {
487
+ settle();
488
+ }
489
+ }
490
+
491
+ let pending: UiPrefs = {};
492
+ let timer: ReturnType<typeof setTimeout> | null = null;
493
+ let inFlight: Promise<void> | null = null;
494
+
495
+ async function flush(): Promise<void> {
496
+ timer = null;
497
+ const changed = pending;
498
+ pending = {};
499
+ if (!Object.keys(changed).length) return;
500
+ // ⚠⚠ The WHOLE document, wrapped in `{prefs: …}`. `PUT /api/me/prefs`
501
+ // REPLACES the stored document (handlers/userprefs.go: "Put replaces the
502
+ // caller's document for one surface") — sending only what changed would
503
+ // drop every other preference the account holds, which is a data loss that
504
+ // looks like "my palette reset itself".
505
+ const body = { prefs: currentPrefs() };
506
+ const doFetch = cfg.fetchImpl ?? (typeof fetch === 'function' ? fetch : null);
507
+ if (!doFetch) return;
508
+ try {
509
+ await doFetch(url(), {
510
+ method: 'PUT',
511
+ headers: await headers(true),
512
+ credentials: 'same-origin',
513
+ body: JSON.stringify(body),
514
+ });
515
+ } catch {
516
+ /* the change is already on screen and in the mirror; a failed write
517
+ means the next device does not see it, not that this one breaks */
518
+ }
519
+ }
520
+
521
+ /**
522
+ * A preference changed: mirror it now, tell the account shortly.
523
+ *
524
+ * ⚠ The mirror is written FIRST and unconditionally. If the PUT fails, this
525
+ * device still opens the way the person left it — which is exactly the
526
+ * behaviour the product had before the account copy existed.
527
+ */
528
+ export function savePref(key: PrefKey, value: string): void {
529
+ remote = { ...remote, [key]: value };
530
+ if (key !== 'tour') setLocalPref(key, value);
531
+ pending = { ...pending, [key]: value };
532
+ if (timer !== null) clearTimeout(timer);
533
+ timer = setTimeout(() => {
534
+ inFlight = flush();
535
+ }, PREFS_PUT_DEBOUNCE_MS);
536
+ }
537
+
538
+ /** Send anything still waiting, now (a window closing, a test). */
539
+ export async function flushPrefs(): Promise<void> {
540
+ if (timer !== null) {
541
+ clearTimeout(timer);
542
+ inFlight = flush();
543
+ }
544
+ await inFlight;
545
+ }
546
+
547
+ /** Testing seam: forget the session's state. */
548
+ export function resetPrefs(): void {
549
+ remote = {};
550
+ hydrated = false;
551
+ // ⚠ The document seal too. It is per-DOCUMENT state and a test file is one
552
+ // document: a spec that opened a public link would otherwise leave every
553
+ // later spec in this file believing it was on one.
554
+ sealed = false;
555
+ pending = {};
556
+ if (timer !== null) clearTimeout(timer);
557
+ timer = null;
558
+ inFlight = null;
559
+ listeners.clear();
560
+ settled = false;
561
+ settleWaiters.clear();
562
+ configured = false;
563
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * productVersion — the one line that says which filex this is: `filex 0.43.0`.
3
+ *
4
+ * Burak, 2026-09-24: the version should be somewhere a PERSON can find it —
5
+ * the account menu, or user settings. It was only on the sign-in page (twice,
6
+ * each written out by hand) and on the administrators' About page, so anybody
7
+ * who is not an administrator and is already signed in had no way to say
8
+ * which version they were looking at.
9
+ *
10
+ * ⚠⚠ NO CATALOGUE KEY, on purpose. A product name and a version number need
11
+ * no translation, and a new key would drop every language pack below 100%
12
+ * and put "99% translated" into the README's picture of the Apps list
13
+ * (lesson #417). `filex` is the SOFTWARE's name, not the operator's brand:
14
+ * the line answers "which filex is this", which a renamed instance is too.
15
+ *
16
+ * ⚠ The SERVER's own string (`GET /api/files/capabilities` → `version`),
17
+ * kept as it is: a development build says `0.1.0-dev`, and that is the
18
+ * honest answer. The one value it will not print is the client's own
19
+ * placeholder, `0.0.0` — the capabilities store holds it until the real
20
+ * answer lands, and a menu opened in that moment would otherwise announce a
21
+ * version that has never existed. Unknown is said by saying nothing.
22
+ */
23
+
24
+ /** The software's name, as every version line spells it. */
25
+ export const PRODUCT_NAME = 'filex';
26
+
27
+ /** The client's "not known yet" — never a real server's answer. */
28
+ const PLACEHOLDER = '0.0.0';
29
+
30
+ /** `filex 0.43.0`, or '' while the version is not known. */
31
+ export function productVersionLine(version: string | null | undefined): string {
32
+ const v = String(version ?? '').trim();
33
+ if (!v || v === PLACEHOLDER) return '';
34
+ return `${PRODUCT_NAME} ${v}`;
35
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * How much room an outward-facing body needs.
3
+ *
4
+ * ⚠ The public page has ONE look and three widths — the three the Go-rendered
5
+ * pages had until v0.42.2 (`.card` 400px centred, the drop card 520px, and
6
+ * `.card--folder` 880px). Which one a link gets is decided HERE, from the
7
+ * kind the server answered with, so the page that renders a link and the
8
+ * preview an operator is shown on the Corporate identity page cannot come to
9
+ * different conclusions — and so that no body carries a layout of its own
10
+ * (the whole reason the three pages drifted apart the first time).
11
+ *
12
+ * ⚠ Every state that is not `ready` is drawn as a `gate` whatever this says:
13
+ * a PIN box, "this link is not available" and "thank you" are short, and the
14
+ * shell forces it (`PublicShell.layout`).
15
+ */
16
+ export type PublicLayout = 'gate' | 'form' | 'wide';
17
+
18
+ /**
19
+ * `app` gets the wide card (a wizard with a document in it) and so does a
20
+ * folder that has a LISTING to show; a file request gets the middle one,
21
+ * because it is a form with labels; a single document — and anything
22
+ * unrecognised — gets the narrow centred card, which is the one the
23
+ * reference screen is.
24
+ *
25
+ * ⚠ `list` is why a folder is not simply wide. The server does not send a
26
+ * listing for every folder share yet, and an 880px card holding a heading
27
+ * and two buttons is a worse page than a 520px one holding the same thing.
28
+ * It widens the moment entries arrive, which is what the old `.card--folder`
29
+ * was for.
30
+ */
31
+ export function publicLayoutFor(
32
+ kind: string | null | undefined,
33
+ opts: { list?: boolean } = {},
34
+ ): PublicLayout {
35
+ if (kind === 'app') return 'wide';
36
+ if (kind === 'folder') return opts.list ? 'wide' : 'form';
37
+ if (kind === 'drop') return 'form';
38
+ return 'gate';
39
+ }