@brftech/filex-core 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-BNqukFg8.js +124 -0
  3. package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
  4. package/dist/CsvViewer-ii_-MgmG.js +141 -0
  5. package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
  7. package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
  8. package/dist/EpubViewer-p4B8iiWb.js +163 -0
  9. package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
  10. package/dist/IpynbViewer-D_qYRJhu.js +184 -0
  11. package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
  12. package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
  13. package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
  14. package/dist/PsdViewer-CuV-OPSd.js +122 -0
  15. package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
  16. package/dist/TiffViewer-BLeoF4Be.js +142 -0
  17. package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
  18. package/dist/Viewer3D-DHhhU76E.js +75 -0
  19. package/dist/Viewer3D-DHhhU76E.js.map +1 -0
  20. package/dist/filex-core.js +217 -20077
  21. package/dist/filex-core.js.map +1 -1
  22. package/dist/filex-core.umd.cjs +61 -62
  23. package/dist/filex-core.umd.cjs.map +1 -1
  24. package/dist/index-BraG7Cz4.js +26664 -0
  25. package/dist/index-BraG7Cz4.js.map +1 -0
  26. package/dist/index.d.ts +4276 -851
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2170 -492
  30. package/src/components/AdvancedSearch.vue +870 -0
  31. package/src/components/Breadcrumb.vue +191 -19
  32. package/src/components/CommandPalette.vue +66 -22
  33. package/src/components/ConnectionsPanel.vue +6 -2
  34. package/src/components/ContextMenu.vue +57 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +939 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +162 -30
  39. package/src/components/GridView.vue +322 -58
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1158 -167
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +34 -5
  46. package/src/components/RecentlyOpened.vue +17 -2
  47. package/src/components/S3KeysPanel.vue +7 -4
  48. package/src/components/SSHKeysPanel.vue +7 -4
  49. package/src/components/ShortcutSettings.vue +43 -2
  50. package/src/components/ShortcutsHelp.vue +4 -1
  51. package/src/components/SideNav.vue +206 -140
  52. package/src/components/StorageFields.vue +19 -1
  53. package/src/components/TabBar.vue +87 -1
  54. package/src/components/TagPicker.vue +77 -6
  55. package/src/components/ThemeGallery.vue +13 -198
  56. package/src/components/ThemePalette.vue +244 -0
  57. package/src/components/TimeZoneDialog.vue +127 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +16 -4
  60. package/src/components/Toolbar.vue +965 -469
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +85 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useKeyboardShortcuts.ts +122 -0
  66. package/src/composables/useLocale.ts +365 -25
  67. package/src/index.ts +182 -3
  68. package/src/lib/actionIcons.ts +330 -0
  69. package/src/lib/advSearch.ts +162 -0
  70. package/src/lib/dateGroups.ts +197 -0
  71. package/src/lib/destinationTree.ts +235 -0
  72. package/src/lib/downloadSelection.ts +154 -0
  73. package/src/lib/fileFilters.ts +319 -12
  74. package/src/lib/fileIcons.ts +299 -5
  75. package/src/lib/filePreview.ts +605 -0
  76. package/src/lib/listing.ts +72 -0
  77. package/src/lib/shareTtl.ts +11 -1
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +152 -23
  80. package/src/lib/timezone.ts +456 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +432 -25
  84. package/src/locales/tr.ts +425 -25
  85. package/src/modals/ConvertModal.vue +23 -3
  86. package/src/modals/DestinationPickerModal.vue +341 -0
  87. package/src/modals/Modal.vue +18 -4
  88. package/src/modals/NewDocumentModal.vue +525 -0
  89. package/src/modals/NewFolderModal.vue +15 -9
  90. package/src/modals/PermissionsModal.vue +543 -473
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5435 -8
  93. package/src/styles/variables.css +140 -60
  94. package/src/types/Connections.ts +9 -0
  95. package/src/types/ExplorerConfig.ts +144 -39
  96. package/src/types/FileNode.ts +28 -0
  97. package/src/viewers/ArchiveViewer.vue +46 -8
  98. package/src/viewers/CsvViewer.vue +19 -2
  99. package/src/viewers/DrawioViewer.vue +12 -1
  100. package/src/viewers/EpubViewer.vue +50 -6
  101. package/src/viewers/IpynbViewer.vue +19 -2
  102. package/src/viewers/MermaidViewer.vue +20 -3
  103. package/src/viewers/PdfViewer.vue +24 -4
  104. package/src/viewers/PsdViewer.vue +28 -4
  105. package/src/viewers/TiffViewer.vue +19 -2
  106. package/src/viewers/Viewer3D.vue +36 -2
  107. package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
  108. package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
  109. package/dist/CsvViewer-CqWeV8VO.js +0 -131
  110. package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
  111. package/dist/DrawioViewer-BNALOB04.js.map +0 -1
  112. package/dist/EpubViewer-BPmlImG7.js +0 -145
  113. package/dist/EpubViewer-BPmlImG7.js.map +0 -1
  114. package/dist/IpynbViewer-CDlQmuCV.js +0 -175
  115. package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
  116. package/dist/MermaidViewer-sfZWdR-d.js +0 -127
  117. package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
  118. package/dist/PsdViewer-DwuySZsb.js +0 -112
  119. package/dist/PsdViewer-DwuySZsb.js.map +0 -1
  120. package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
  121. package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
  122. package/dist/Viewer3D-CJJJgkuv.js +0 -60
  123. package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
  124. package/src/components/SecondaryPane.vue +0 -447
  125. package/src/modals/ShareModal.vue +0 -139
@@ -1,47 +1,282 @@
1
1
  /**
2
- * useLocale — tr/en string table with a tiny `t()` helper.
2
+ * useLocale — tr/en string table with a tiny `t()` helper, and the three
3
+ * things every surface in this product has to agree about: which BCP-47 tag
4
+ * the viewer's language means, what a byte count reads as, and what an
5
+ * instant reads as.
3
6
  *
4
7
  * No i18n library — the catalogue is small enough to ship inline (see
5
8
  * src/locales).
9
+ *
10
+ * ⚠ The four exports ABOVE the composable are module-level on purpose. A
11
+ * plain module (`lib/shareTtl`), the admin app's own helpers
12
+ * (`web/src/lib/format`) and a share message that has to read like the one
13
+ * the server sends all need these same rules, and none of them can call a
14
+ * composable. Before this they each wrote their own, and the copies did not
15
+ * agree:
16
+ *
17
+ * - three locale → tag mappings. This file said `en-US`, `InspectorPanel`
18
+ * said `en-GB`, `lib/shareTtl` said `en-GB`. `en-GB` renders a medium
19
+ * date as "12 Sep 2026, 15:25" and `en-US` as "Sep 12, 2026, 3:25 PM", so
20
+ * one file's timestamp read two ways depending on which panel you were
21
+ * looking at.
22
+ * - four byte formatters, two of them disagreeing about the base.
23
+ * - and an `InspectorPanel.formatDate()` that passed NO time zone at all,
24
+ * so the details panel printed the BROWSER's clock beside a listing row
25
+ * printing the viewer's chosen one.
6
26
  */
7
27
 
8
28
  import { computed, type Ref } from 'vue';
9
29
  import type { LocaleCode } from '../types/ExplorerConfig';
10
30
  import { messages } from '../locales';
31
+ import { activeTimeZone, deviceTimeZone } from '../lib/timezone';
32
+
33
+ /* ── the tag ──────────────────────────────────────────────────────────── */
34
+
35
+ /** The BCP-47 tag a catalogue code means. ONE mapping, for every surface. */
36
+ export function localeTag(code: LocaleCode | string | undefined): string {
37
+ return code === 'tr' ? 'tr-TR' : 'en-US';
38
+ }
39
+
40
+ /* ── bytes ────────────────────────────────────────────────────────────── */
41
+
42
+ export type ByteUnitKey =
43
+ | 'unit.bytes'
44
+ | 'unit.kb'
45
+ | 'unit.mb'
46
+ | 'unit.gb'
47
+ | 'unit.tb'
48
+ | 'unit.pb';
49
+
50
+ const BYTE_UNIT_KEYS: ByteUnitKey[] = [
51
+ 'unit.bytes',
52
+ 'unit.kb',
53
+ 'unit.mb',
54
+ 'unit.gb',
55
+ 'unit.tb',
56
+ 'unit.pb',
57
+ ];
58
+
59
+ /** The catalogue's own English values, for callers with no `t()` to hand. */
60
+ const BYTE_UNIT_EN: Record<ByteUnitKey, string> = {
61
+ 'unit.bytes': 'B',
62
+ 'unit.kb': 'KB',
63
+ 'unit.mb': 'MB',
64
+ 'unit.gb': 'GB',
65
+ 'unit.tb': 'TB',
66
+ 'unit.pb': 'PB',
67
+ };
68
+
69
+ export interface ByteSizeOptions {
70
+ /** Resolve a unit key to its label. Default: the English short forms. */
71
+ unit?: (key: ByteUnitKey) => string;
72
+ /**
73
+ * Tag for the NUMBER itself — decimal separator and grouping. `null` (the
74
+ * default) is the C locale: a dot, no grouping, deterministic. Pass the
75
+ * viewer's tag on any surface a person reads, so Turkish gets "1,43 MB".
76
+ */
77
+ numberLocale?: string | null;
78
+ /**
79
+ * 1000 (the default) or 1024. See the note on `formatByteSize` — 1024 is
80
+ * for mirroring some OTHER system's arithmetic, never a style choice.
81
+ */
82
+ base?: 1000 | 1024;
83
+ /** `fixed1` always shows one decimal above B, like the server's humanSize(). */
84
+ digits?: 'auto' | 'fixed1';
85
+ /** What "no size at all" renders as. */
86
+ empty?: string;
87
+ }
88
+
89
+ /**
90
+ * A byte count as a string — the ONE implementation.
91
+ *
92
+ * There were four: this file (1024, i18n units), `web/src/lib/format`
93
+ * (1000, English units), `SideNav` (1024, hardcoded English units, next to a
94
+ * quota line that `HomeView` rendered through the shared one), and
95
+ * `Trash.vue` (1024, in a file already importing the admin helpers). A quota
96
+ * of 10 GB read "10 GB" in the admin panel, "9.3 GB" in the side nav and
97
+ * "9.31 GB" on the home screen — three spellings of one number.
98
+ *
99
+ * ⚠⚠ THE BASE IS 1000, and that is a decision, not an accident:
100
+ *
101
+ * - "KB" / "MB" / "GB" are SI prefixes and mean powers of 1000. Rendering
102
+ * 1024-arithmetic under those letters is simply mislabelled; being right
103
+ * at 1024 would mean printing KiB/MiB/GiB everywhere instead.
104
+ * - Both places in this product that CONVERT between a unit and bytes are
105
+ * already decimal, and both say so in a comment: the per-user quota input
106
+ * (`web/src/views/UserEdit.vue`, `GB = 1_000_000_000`) and the usage/cost
107
+ * path (`backend/internal/usage/b2.go`: "B2 bills in decimal GB, not GiB
108
+ * — using 1024³ here would quietly overstate every upload by 7%").
109
+ * - So decimal is the only base under which a quota typed as "10 GB" reads
110
+ * back as "10 GB" and a stored volume matches the invoice beside it.
111
+ *
112
+ * `base: 1024` exists for exactly one reason: mirroring a number some other
113
+ * program already printed. Today that is the share message, which has to read
114
+ * like `humanSize()` in backend/internal/api/handlers/mail_templates.go.
115
+ * That is an argument to this function, not a fifth copy of it.
116
+ */
117
+ export function formatByteSize(
118
+ bytes: number | null | undefined,
119
+ opts: ByteSizeOptions = {},
120
+ ): string {
121
+ const empty = opts.empty ?? '—';
122
+ if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return empty;
123
+
124
+ const base = opts.base ?? 1000;
125
+ const label = opts.unit ?? ((k: ByteUnitKey) => BYTE_UNIT_EN[k]);
126
+
127
+ let idx = 0;
128
+ let value = bytes;
129
+ while (value >= base && idx < BYTE_UNIT_KEYS.length - 1) {
130
+ value /= base;
131
+ idx += 1;
132
+ }
133
+
134
+ // Whole bytes are never fractional; above that, two digits while the
135
+ // number is small enough for them to mean something, one after that.
136
+ const digits = idx === 0 ? 0 : opts.digits === 'fixed1' ? 1 : value < 10 ? 2 : 1;
137
+
138
+ let num: string;
139
+ if (opts.numberLocale == null) {
140
+ num = value.toFixed(digits);
141
+ } else {
142
+ const fmt = numberFormatter(opts.numberLocale, digits, opts.digits === 'fixed1' ? digits : 0);
143
+ num = fmt ? fmt.format(value) : value.toFixed(digits);
144
+ }
145
+ return `${num} ${label(BYTE_UNIT_KEYS[idx])}`;
146
+ }
147
+
148
+ /**
149
+ * ⚠ Cached. This runs once per row per repaint in a listing, and constructing
150
+ * an `Intl.NumberFormat` is the expensive part of it — the old copy in this
151
+ * file used `toFixed` and paid nothing, so switching to a locale-aware number
152
+ * without a cache would be a visible cost on a folder with a few thousand
153
+ * files. There are three or four distinct keys in a session.
154
+ */
155
+ const nfCache = new Map<string, Intl.NumberFormat | null>();
156
+ function numberFormatter(tag: string, max: number, min: number): Intl.NumberFormat | null {
157
+ const key = `${tag}|${max}|${min}`;
158
+ if (!nfCache.has(key)) {
159
+ try {
160
+ nfCache.set(key, new Intl.NumberFormat(tag, {
161
+ maximumFractionDigits: max,
162
+ minimumFractionDigits: min,
163
+ }));
164
+ } catch {
165
+ nfCache.set(key, null);
166
+ }
167
+ }
168
+ return nfCache.get(key) ?? null;
169
+ }
170
+
171
+ /* ── instants ─────────────────────────────────────────────────────────── */
172
+
173
+ const dtfCache = new Map<string, Intl.DateTimeFormat>();
174
+
175
+ /**
176
+ * A formatter for `tag` in the VIEWER's zone.
177
+ *
178
+ * ⚠ The cache key names the device zone when the viewer follows the device:
179
+ * `Intl` resolves `timeZone: undefined` at CONSTRUCTION and freezes it, so a
180
+ * cache keyed only on "device" would keep answering in whatever zone the tab
181
+ * was opened in. Reading `activeTimeZone()` on every call is also what keeps
182
+ * a template that formats a date reactive to the preference changing.
183
+ */
184
+ function zonedFormatter(tag: string, opts: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
185
+ const zone = activeTimeZone();
186
+ const key = `${tag}|${zone ?? `device:${deviceTimeZone()}`}|${JSON.stringify(opts)}`;
187
+ let fmt = dtfCache.get(key);
188
+ if (!fmt) {
189
+ fmt = new Intl.DateTimeFormat(tag, { ...opts, timeZone: zone });
190
+ dtfCache.set(key, fmt);
191
+ }
192
+ return fmt;
193
+ }
194
+
195
+ /**
196
+ * Render an instant for a human: the viewer's language AND the viewer's
197
+ * chosen clock, together, in one call. Every date on every surface goes
198
+ * through here — that is the whole point of it being here.
199
+ */
200
+ export function formatInstant(
201
+ d: Date,
202
+ code: LocaleCode | string | undefined,
203
+ opts: Intl.DateTimeFormatOptions,
204
+ ): string {
205
+ try {
206
+ return zonedFormatter(localeTag(code), opts).format(d);
207
+ } catch {
208
+ return d.toISOString();
209
+ }
210
+ }
211
+
212
+ /** Seconds or milliseconds — the backend has sent both. Normalised once. */
213
+ function toDate(ms: number | undefined | null): Date | null {
214
+ if (!ms) return null;
215
+ const d = new Date(ms * (ms < 1e12 ? 1000 : 1));
216
+ return Number.isNaN(d.getTime()) ? null : d;
217
+ }
218
+
219
+ /* ── counted messages ─────────────────────────────────────────────────── */
220
+
221
+ /**
222
+ * The variables that COUNT something, in the order they are asked. The first
223
+ * one a call passes decides the form of the sentence.
224
+ */
225
+ export const COUNT_VARS = ['count', 'n', 'days'] as const;
226
+
227
+ /**
228
+ * Which catalogue key a counted message reads: `key_one` when the count is
229
+ * exactly one and the catalogue has that form, otherwise `key`.
230
+ *
231
+ * ⚠⚠ ONE rule, here, for every counted string in the package. The catalogue
232
+ * is a flat table with no plural machinery, so a `{n} items` message printed
233
+ * "1 items" — measured in the details panel's header on a folder with one
234
+ * file (v0.41.0 screenshot pass, 2026-09-14). Two call sites had already
235
+ * worked around it by choosing a `_one` key by hand (the advanced-search count
236
+ * and the empty-trash confirmation); every other counted message had not, and
237
+ * a few hedged with "item(s)". A rule each caller has to remember is a rule
238
+ * the next caller forgets, so `t()` applies it and the catalogue only has to
239
+ * SAY the singular.
240
+ *
241
+ * ⚠ Turkish does not inflect a noun after a number ("1 öğe", "5 öğe"), so its
242
+ * `_one` entries read exactly like the plain ones. They exist for key parity,
243
+ * not because the language needs them. `web/tests/i18n/corePlurals.test.ts`
244
+ * fails an English counted message that has no singular.
245
+ */
246
+ export function countedKey(
247
+ key: string,
248
+ vars: Record<string, string | number>,
249
+ has: (key: string) => boolean,
250
+ ): string {
251
+ const counter = COUNT_VARS.find((v) => v in vars);
252
+ if (counter === undefined || Number(vars[counter]) !== 1) return key;
253
+ const one = `${key}_one`;
254
+ return has(one) ? one : key;
255
+ }
11
256
 
12
257
  export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
13
- const lookup = computed(() => {
14
- const code = typeof localeRef === 'function' ? localeRef() : localeRef.value;
15
- return messages[code] ?? messages.en;
16
- });
258
+ const code = (): LocaleCode =>
259
+ typeof localeRef === 'function' ? localeRef() : localeRef.value;
260
+
261
+ const lookup = computed(() => messages[code()] ?? messages.en);
17
262
 
18
263
  function t(key: string, vars: Record<string, string | number> = {}): string {
19
- const raw = lookup.value[key] ?? key;
264
+ const table = lookup.value;
265
+ const raw = table[countedKey(key, vars, (k) => k in table)] ?? key;
20
266
  return Object.entries(vars).reduce(
21
267
  (acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v)),
22
268
  raw,
23
269
  );
24
270
  }
25
271
 
272
+ /** A byte count in the viewer's language and number format. */
26
273
  function formatSize(bytes: number | undefined | null): string {
27
- if (bytes == null || bytes < 0) return '';
28
- // A real zero (empty file / empty folder) is information, not absence.
29
- if (bytes === 0) return `0 ${t('unit.bytes')}`;
30
- const units: Array<[number, string]> = [
31
- [1024 ** 4, 'unit.tb'],
32
- [1024 ** 3, 'unit.gb'],
33
- [1024 ** 2, 'unit.mb'],
34
- [1024, 'unit.kb'],
35
- [1, 'unit.bytes'],
36
- ];
37
- for (const [div, key] of units) {
38
- if (bytes >= div) {
39
- const val = bytes / div;
40
- const rounded = val >= 100 ? Math.round(val) : val.toFixed(val >= 10 ? 1 : 2);
41
- return `${rounded} ${t(key)}`;
42
- }
43
- }
44
- return `${bytes} ${t('unit.bytes')}`;
274
+ // A real zero (empty file / empty folder) is information, not absence
275
+ // `formatByteSize` renders it "0 B" rather than the em dash.
276
+ return formatByteSize(bytes, {
277
+ unit: (key) => t(key),
278
+ numberLocale: localeTag(code()),
279
+ });
45
280
  }
46
281
 
47
282
  /**
@@ -58,5 +293,110 @@ export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
58
293
  return node.basename;
59
294
  }
60
295
 
61
- return { t, formatSize, nodeDisplayName };
296
+ /**
297
+ * gorunum:v1 — one date format for the whole explorer.
298
+ *
299
+ * There were two: the grid drew "Sep 12, 2026" from the explorer's own
300
+ * locale tag and the list drew "12.09.2026 15:25:04" from
301
+ * `toLocaleString()`, i.e. from the BROWSER's locale. Two views of the same
302
+ * folder, side by side in the same product, disagreeing about what day a
303
+ * file was touched and in whose language. The epoch normalisation is the
304
+ * same in both (the backend has sent seconds and milliseconds) and is kept
305
+ * here so a third caller cannot get it wrong.
306
+ *
307
+ * `time: true` is the listing's variant — a column wide enough to carry the
308
+ * clock, which is the one place the hour actually helps. It is also what
309
+ * the details panel shows, so the panel and the row it describes print the
310
+ * same characters.
311
+ *
312
+ * zaman:z1 — and the clock is the VIEWER's, from `lib/timezone`. The instant
313
+ * on the wire is not touched; only which zone it is read against. Before
314
+ * this, every date here was drawn in the browser's own zone, so a file
315
+ * uploaded at 03:00 Istanbul read "03:00" to a colleague on GMT as well —
316
+ * same digits, different instant. `undefined` (the default) still means the
317
+ * device's zone, resolved live by Intl rather than frozen at first run.
318
+ */
319
+ function formatDate(ms: number | undefined | null, opts: { time?: boolean } = {}): string {
320
+ const d = toDate(ms);
321
+ if (!d) return '';
322
+ const date = formatInstant(d, code(), {
323
+ month: 'short',
324
+ day: 'numeric',
325
+ year: 'numeric',
326
+ });
327
+ if (!opts.time) return date;
328
+ return `${date}, ${formatInstant(d, code(), { hour: 'numeric', minute: '2-digit' })}`;
329
+ }
330
+
331
+ /**
332
+ * The whole truth, for a tooltip: full date, seconds, the zone's name AND
333
+ * its IANA id.
334
+ *
335
+ * The listing's cell is short by necessity ("Sep 12, 2026, 3:14 AM") and a
336
+ * short date is exactly where the zone question bites — it looks like a
337
+ * fact and it is a fact *about a clock nobody named*. Hovering says which.
338
+ * The id is appended to the zone's display name because the display name
339
+ * alone is ambiguous in the direction that matters here: half the world's
340
+ * zones render as "GMT+3".
341
+ */
342
+ function formatDateFull(ms: number | undefined | null): string {
343
+ const d = toDate(ms);
344
+ if (!d) return '';
345
+ const zone = activeTimeZone() ?? deviceTimeZone();
346
+ const stamp = formatInstant(d, code(), { dateStyle: 'full', timeStyle: 'long' });
347
+ return `${stamp} (${zone})`;
348
+ }
349
+
350
+ /**
351
+ * "September 2026" — the header over a listing's date group.
352
+ *
353
+ * Here rather than in the view that draws it: the header answers "what
354
+ * month is this row from", and it has to answer it on the same clock the
355
+ * cell four pixels away prints. `ListView` built its own `Intl` formatter
356
+ * for this, which is how the two came to be able to disagree.
357
+ */
358
+ function formatMonthYear(value: number | Date | undefined | null): string {
359
+ const d = value instanceof Date ? value : toDate(value);
360
+ if (!d) return '';
361
+ return formatInstant(d, code(), { month: 'long', year: 'numeric' });
362
+ }
363
+
364
+ /**
365
+ * `YYYY-MM` in the viewer's zone — a bucket id, not a label.
366
+ *
367
+ * `en-CA` is not a locale choice, it is the shortest route to ISO order out
368
+ * of `Intl`; same trick as `lib/timezone.zonedDayNumber`, and for the same
369
+ * reason: the month a row is grouped under must be the month its own cell
370
+ * prints, which the DEVICE's calendar cannot tell you.
371
+ */
372
+ function zonedYearMonth(value: number | Date | undefined | null): string {
373
+ const d = value instanceof Date ? value : toDate(value);
374
+ if (!d) return '';
375
+ try {
376
+ return zonedFormatter('en-CA', { year: 'numeric', month: '2-digit' }).format(d);
377
+ } catch {
378
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
379
+ }
380
+ }
381
+
382
+ return {
383
+ t,
384
+ formatSize,
385
+ formatDate,
386
+ formatDateFull,
387
+ formatMonthYear,
388
+ zonedYearMonth,
389
+ /**
390
+ * zaman:z1 — the wire's `last_modified` as a `Date`, or null.
391
+ *
392
+ * Handed out because "seconds or milliseconds" is a fact about the WIRE,
393
+ * not about formatting, and every caller that needs the instant itself
394
+ * (rather than a string) was otherwise re-typing `v * (v < 1e12 ? 1000 :
395
+ * 1)`. ListView had the third copy and used it to group rows by day — so
396
+ * the grouping and the cell four pixels away were reading the same number
397
+ * through two different converters.
398
+ */
399
+ toDate,
400
+ nodeDisplayName,
401
+ };
62
402
  }
package/src/index.ts CHANGED
@@ -28,6 +28,47 @@ export { default as PreviewModal } from './modals/PreviewModal.vue';
28
28
  export { default as StarButton } from './components/StarButton.vue';
29
29
  export { default as TagPicker } from './components/TagPicker.vue';
30
30
  export { default as RecentlyOpened } from './components/RecentlyOpened.vue';
31
+ // belge:n1 — the "New document" picker. Exported because the entry belongs
32
+ // on every surface, not just the admin app: a host that draws its own
33
+ // "+ New" menu mounts this and gets the same dialog.
34
+ export { default as NewDocumentModal } from './modals/NewDocumentModal.vue';
35
+ /* tasi:m1 — the destination picker. Exported for the same reason and one
36
+ * more: it is the folder chooser the product did not have, and it is meant to
37
+ * be the ONLY one. "Move to", "Copy to" and (next) the new-document flow all
38
+ * mount this rather than growing a private browser each. Its rules are pure
39
+ * functions in lib/destinationTree so a host can reuse the decisions without
40
+ * the dialog. */
41
+ export { default as DestinationPickerModal } from './modals/DestinationPickerModal.vue';
42
+ export {
43
+ DRIVES,
44
+ blockedReason,
45
+ crumbsOfWire,
46
+ destinationRows,
47
+ driveRows,
48
+ initialLocation,
49
+ isAtOrInside,
50
+ joinWire,
51
+ labelOfWire,
52
+ parentOfWire,
53
+ permAllowsWrite,
54
+ splitWire,
55
+ } from './lib/destinationTree';
56
+ export type { DestinationRow } from './lib/destinationTree';
57
+
58
+ /* tasi:m1 — "download the selection as one archive". Exported so a host that
59
+ * draws its own selection bar gets the real two-step flow (authorized mint,
60
+ * then a navigation that streams) instead of reaching for window.open per
61
+ * file, which is what the explorer could not do and why Download used to
62
+ * disappear the moment a second row was selected. */
63
+ export {
64
+ DOWNLOAD_FRAME_TTL_MS,
65
+ absoluteTicketUrl,
66
+ archiveTicketUrl,
67
+ downloadArchive,
68
+ requestArchive,
69
+ triggerFileNavigation,
70
+ } from './lib/downloadSelection';
71
+ export type { ArchiveTicket } from './lib/downloadSelection';
31
72
 
32
73
  // ——— Types ———
33
74
  export type {
@@ -44,6 +85,7 @@ export type {
44
85
  ShareInfo,
45
86
  UploadLimits,
46
87
  Capabilities,
88
+ NewDocType,
47
89
  ExternalServiceState,
48
90
  ExternalServiceStatus,
49
91
  UploadInitResponse,
@@ -74,6 +116,18 @@ export type {
74
116
  BrowserProbeDeps,
75
117
  } from './lib/externalReach';
76
118
 
119
+ /* gorunum:v4-hostmenu — the ACTION glyph vocabulary.
120
+ *
121
+ * Exported because a host draws rows that belong to this explorer: the web
122
+ * app's account menu now carries the explorer's own settings rows (see
123
+ * Toolbar.vue's `fe:header-menu` claim), and an "Restart the tour" row drawn
124
+ * with somebody else's icon set is the emoji problem lib/actionIcons.ts was
125
+ * written to end — a dozen sets, whatever weight and colour the OS shipped,
126
+ * four pixels from a column of stroked grey ones. One vocabulary, one voice.
127
+ * `actionIconSvg` returns '' for an unknown key, so a host can call it for
128
+ * every row and draw nothing where there is nothing. */
129
+ export { actionIconSvg, actionIconKeys } from './lib/actionIcons';
130
+
77
131
  export { snippetSegments, matchedInContent } from './lib/snippet';
78
132
  export type { SnippetSegment, SearchMatched } from './lib/snippet';
79
133
 
@@ -100,7 +154,8 @@ export type { ResumeRecord, ResumeStorage } from './lib/uploadResume';
100
154
  export { useSelection } from './composables/useSelection';
101
155
  export { useKeyboardShortcuts } from './composables/useKeyboardShortcuts';
102
156
  export type { ShortcutHandlers } from './composables/useKeyboardShortcuts';
103
- export { useLocale } from './composables/useLocale';
157
+ export { useLocale, localeTag, formatByteSize, formatInstant } from './composables/useLocale';
158
+ export type { ByteSizeOptions, ByteUnitKey } from './composables/useLocale';
104
159
  export { usePendingOps } from './composables/usePendingOps';
105
160
  export type { PendingOp, UsePendingOpsOptions } from './composables/usePendingOps';
106
161
  export {
@@ -126,9 +181,51 @@ export {
126
181
  applyThemeToEl,
127
182
  syncThemeStyle,
128
183
  generateThemeCss,
184
+ /* The light/dark MODE half — a different question from which palette paints
185
+ * (see themes.ts). Exported because the appearance controls now live in the
186
+ * host's own settings surface, and a host that can pick a palette but cannot
187
+ * un-pin the mode the explorer's old strip wrote would leave the user with a
188
+ * switch that visibly does nothing. */
189
+ THEME_MODE_LS_KEY,
190
+ useThemeModeState,
191
+ setThemeMode,
129
192
  } from './lib/themes';
130
- export type { ThemeDef, ThemeTokenMap } from './lib/themes';
193
+ export type { ThemeDef, ThemeTokenMap, ThemeModePref } from './lib/themes';
131
194
  export { default as ThemeGallery } from './components/ThemeGallery.vue';
195
+ /* The palette grid without the modal around it — for hosts that show
196
+ * appearance settings in a pane of their own (the filex admin app does). */
197
+ export { default as ThemePalette } from './components/ThemePalette.vue';
198
+
199
+ /* zaman:z1 / z3 — whose clock an instant is read on. ONE resolver ranks the
200
+ * tiers (`TIME_ZONE_TIERS`: the viewer's own pick in this browser, the host's
201
+ * `config.timeZone`, the account behind a PERSON's credential, the device) and
202
+ * every surface formats through it — the explorer, and a host's own dates
203
+ * outside it (the filex admin app's `lib/format` reads `activeTimeZone()`), so
204
+ * an admin page and an embed cannot disagree about one file's time again. */
205
+ export {
206
+ TIME_ZONE_TIERS,
207
+ resolveTimeZone,
208
+ activeTimeZone,
209
+ resolvedTimeZone,
210
+ timeZoneSources,
211
+ TIMEZONE_VIEWER_LS_KEY,
212
+ viewerTimeZone,
213
+ setViewerTimeZone,
214
+ setHostTimeZone,
215
+ TIMEZONE_ACCOUNT_LS_KEY,
216
+ setAccountTimeZone,
217
+ accountTimeZoneOf,
218
+ rememberedAccountTimeZone,
219
+ releaseTimeZoneOwner,
220
+ deviceTimeZone,
221
+ isValidTimeZone,
222
+ supportedTimeZones,
223
+ zonedDayNumber,
224
+ } from './lib/timezone';
225
+ export type { TimeZoneTier, TimeZoneSources, ResolvedTimeZone } from './lib/timezone';
226
+ /* The picker itself — a host with a settings surface of its own (the filex
227
+ * admin app) mounts this rather than writing a second one. */
228
+ export { default as TimeZonePicker } from './components/TimeZonePicker.vue';
132
229
  /* wiring:c2 — customizable shortcut registry + settings/quick-look UI */
133
230
  export {
134
231
  SHORTCUT_ACTIONS,
@@ -146,6 +243,11 @@ export {
146
243
  comboLabel,
147
244
  eventMatchesShortcut,
148
245
  isMacLike,
246
+ /* tus:t1 — menu/toolbar rows name their key from the same registry, and the
247
+ * combos a browser tab never receives are declared in one place. */
248
+ menuShortcutHint,
249
+ MENU_ACTION_SHORTCUTS,
250
+ isReservedCombo,
149
251
  } from './composables/useKeyboardShortcuts';
150
252
  export type {
151
253
  ShortcutActionDef,
@@ -170,7 +272,7 @@ export { default as OperationsCenter } from './components/OperationsCenter.vue';
170
272
  export { useTabs } from './composables/useTabs';
171
273
  export type { TabState, TabSplit, TabsApi } from './composables/useTabs';
172
274
  export { default as TabBar } from './components/TabBar.vue';
173
- export { default as SecondaryPane } from './components/SecondaryPane.vue';
275
+ export { default as FilePane } from './components/FilePane.vue';
174
276
  /* /wiring:d1 */
175
277
  /* wiring:e2 — end-to-end encrypted folders (WebCrypto; docs/E2E-ENCRYPTION.md) */
176
278
  export {
@@ -283,3 +385,80 @@ export type {
283
385
  StorageWrite,
284
386
  } from './types/Connections';
285
387
  /* /connections */
388
+
389
+ /* tablo:t1 — per-folder view memory + the table configuration.
390
+ *
391
+ * Exported because the HOST owns two things this module cannot reach: the
392
+ * settings control that turns the memory on (`folderMemoryEnabled` /
393
+ * `setFolderMemoryEnabled`), and the transport that reads and writes the
394
+ * document (`attachViewPrefsStore` — the explorer wires its own, but a host
395
+ * embedding the views directly has to). The rest is exported so the gates in
396
+ * `web/tests/lib/viewPrefs.test.ts` drive the real module rather than a copy. */
397
+ export {
398
+ COLUMNS,
399
+ FOLDER_CAP,
400
+ NAME_AUTO,
401
+ NAME_MIN,
402
+ __flushViewPrefs,
403
+ __resetViewPrefs,
404
+ attachViewPrefsStore,
405
+ canMoveColumn,
406
+ columnHidden,
407
+ columnOrder,
408
+ columnWidth,
409
+ columnsCustomised,
410
+ folderIsRemembered,
411
+ folderKey,
412
+ folderMemoryEnabled,
413
+ folderPrefs,
414
+ forgetAllFolders,
415
+ forgetFolder,
416
+ freezeWidths,
417
+ moveColumn,
418
+ moveColumnBy,
419
+ rememberFolder,
420
+ rememberedCount,
421
+ resetColumns,
422
+ setColumnHidden,
423
+ setColumnWidth,
424
+ setFolderMemoryEnabled,
425
+ tableLayout,
426
+ touchFolder,
427
+ viewPrefsSlot,
428
+ widthsAreAuto,
429
+ } from './lib/viewPrefs';
430
+ export type {
431
+ ColumnId,
432
+ ColumnSpec,
433
+ FolderPrefs,
434
+ TableLayout,
435
+ ViewPrefsSlot,
436
+ ViewPrefsTransport,
437
+ } from './lib/viewPrefs';
438
+
439
+ /* gruplama — the date ladder every listing view draws its headings from.
440
+ *
441
+ * Exported so the gate in `web/tests/lib/dateGroups.test.ts` measures the real
442
+ * rungs rather than a copy of them, and so a host that mounts ListView /
443
+ * GridView / GalleryView itself can label a listing of its own with the same
444
+ * words the explorer uses. */
445
+ export { dateBucketFor, groupByDate, groupingActive } from './lib/dateGroups';
446
+ export type {
447
+ DateBucket,
448
+ DateGroupLabels,
449
+ DateGrouping,
450
+ DateRun,
451
+ } from './lib/dateGroups';
452
+
453
+ /* uiProfile — the two profiles, and the rule for everything that is not one of
454
+ * them. Exported because the web component resolves the `ui-profile` ATTRIBUTE
455
+ * with it: a string off the DOM needs the same answer the `config` object gets,
456
+ * and two copies of that answer is how the element and the SFC come to disagree
457
+ * about what an unknown value means. */
458
+ export {
459
+ DEFAULT_UI_PROFILE,
460
+ UI_PROFILES,
461
+ __resetUiProfileWarnings,
462
+ resolveUiProfile,
463
+ } from './lib/uiProfile';
464
+ export type { UiProfile } from './lib/uiProfile';