@brftech/filex-core 0.40.0 → 0.41.1

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-DxxjhYZD.js +124 -0
  3. package/dist/ArchiveViewer-DxxjhYZD.js.map +1 -0
  4. package/dist/CsvViewer-CyuwENI_.js +141 -0
  5. package/dist/CsvViewer-CyuwENI_.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-BIYsTX97.js} +48 -42
  7. package/dist/DrawioViewer-BIYsTX97.js.map +1 -0
  8. package/dist/EpubViewer-K9PjCMmS.js +163 -0
  9. package/dist/EpubViewer-K9PjCMmS.js.map +1 -0
  10. package/dist/IpynbViewer-DYU6Zgz2.js +184 -0
  11. package/dist/IpynbViewer-DYU6Zgz2.js.map +1 -0
  12. package/dist/MermaidViewer-Bp73aId8.js +137 -0
  13. package/dist/MermaidViewer-Bp73aId8.js.map +1 -0
  14. package/dist/PsdViewer-C2eLivw1.js +122 -0
  15. package/dist/PsdViewer-C2eLivw1.js.map +1 -0
  16. package/dist/TiffViewer-DsHg5gcz.js +142 -0
  17. package/dist/TiffViewer-DsHg5gcz.js.map +1 -0
  18. package/dist/Viewer3D-C_dc2HN9.js +75 -0
  19. package/dist/Viewer3D-C_dc2HN9.js.map +1 -0
  20. package/dist/filex-core.js +218 -20361
  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-vxMZpYb-.js +26700 -0
  25. package/dist/index-vxMZpYb-.js.map +1 -0
  26. package/dist/index.d.ts +4212 -862
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2154 -506
  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 +39 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +951 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +172 -56
  39. package/src/components/GridView.vue +331 -82
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1173 -194
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +26 -1
  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 +6 -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 +135 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +35 -14
  60. package/src/components/Toolbar.vue +953 -465
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +89 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useLocale.ts +382 -26
  66. package/src/composables/useRowTouch.ts +95 -0
  67. package/src/index.ts +177 -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 +31 -2
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +151 -116
  80. package/src/lib/timezone.ts +501 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +416 -26
  84. package/src/locales/tr.ts +409 -26
  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 +557 -476
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5409 -8
  93. package/src/styles/variables.css +139 -65
  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,293 @@
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
- import { computed, type Ref } from 'vue';
28
+ import { computed, getCurrentInstance, inject, type Ref } from 'vue';
9
29
  import type { LocaleCode } from '../types/ExplorerConfig';
10
30
  import { messages } from '../locales';
31
+ import { EXPLORER_CLOCK, activeTimeZoneFor, 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(
185
+ tag: string,
186
+ opts: Intl.DateTimeFormatOptions,
187
+ owner?: symbol,
188
+ ): Intl.DateTimeFormat {
189
+ const zone = activeTimeZoneFor(owner);
190
+ const key = `${tag}|${zone ?? `device:${deviceTimeZone()}`}|${JSON.stringify(opts)}`;
191
+ let fmt = dtfCache.get(key);
192
+ if (!fmt) {
193
+ fmt = new Intl.DateTimeFormat(tag, { ...opts, timeZone: zone });
194
+ dtfCache.set(key, fmt);
195
+ }
196
+ return fmt;
197
+ }
198
+
199
+ /**
200
+ * Render an instant for a human: the viewer's language AND the viewer's
201
+ * chosen clock, together, in one call. Every date on every surface goes
202
+ * through here — that is the whole point of it being here.
203
+ */
204
+ export function formatInstant(
205
+ d: Date,
206
+ code: LocaleCode | string | undefined,
207
+ opts: Intl.DateTimeFormatOptions,
208
+ /** The explorer whose clock applies (EXPLORER_CLOCK); omitted = page-wide. */
209
+ owner?: symbol,
210
+ ): string {
211
+ try {
212
+ return zonedFormatter(localeTag(code), opts, owner).format(d);
213
+ } catch {
214
+ return d.toISOString();
215
+ }
216
+ }
217
+
218
+ /** Seconds or milliseconds — the backend has sent both. Normalised once. */
219
+ function toDate(ms: number | undefined | null): Date | null {
220
+ if (!ms) return null;
221
+ const d = new Date(ms * (ms < 1e12 ? 1000 : 1));
222
+ return Number.isNaN(d.getTime()) ? null : d;
223
+ }
224
+
225
+ /* ── counted messages ─────────────────────────────────────────────────── */
226
+
227
+ /**
228
+ * The variables that COUNT something, in the order they are asked. The first
229
+ * one a call passes decides the form of the sentence.
230
+ */
231
+ export const COUNT_VARS = ['count', 'n', 'days'] as const;
232
+
233
+ /**
234
+ * Which catalogue key a counted message reads: `key_one` when the count is
235
+ * exactly one and the catalogue has that form, otherwise `key`.
236
+ *
237
+ * ⚠⚠ ONE rule, here, for every counted string in the package. The catalogue
238
+ * is a flat table with no plural machinery, so a `{n} items` message printed
239
+ * "1 items" — measured in the details panel's header on a folder with one
240
+ * file (v0.41.0 screenshot pass, 2026-09-14). Two call sites had already
241
+ * worked around it by choosing a `_one` key by hand (the advanced-search count
242
+ * and the empty-trash confirmation); every other counted message had not, and
243
+ * a few hedged with "item(s)". A rule each caller has to remember is a rule
244
+ * the next caller forgets, so `t()` applies it and the catalogue only has to
245
+ * SAY the singular.
246
+ *
247
+ * ⚠ Turkish does not inflect a noun after a number ("1 öğe", "5 öğe"), so its
248
+ * `_one` entries read exactly like the plain ones. They exist for key parity,
249
+ * not because the language needs them. `web/tests/i18n/corePlurals.test.ts`
250
+ * fails an English counted message that has no singular.
251
+ */
252
+ export function countedKey(
253
+ key: string,
254
+ vars: Record<string, string | number>,
255
+ has: (key: string) => boolean,
256
+ ): string {
257
+ const counter = COUNT_VARS.find((v) => v in vars);
258
+ if (counter === undefined || Number(vars[counter]) !== 1) return key;
259
+ const one = `${key}_one`;
260
+ return has(one) ? one : key;
261
+ }
11
262
 
12
263
  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
- });
264
+ const code = (): LocaleCode =>
265
+ typeof localeRef === 'function' ? localeRef() : localeRef.value;
266
+
267
+ const lookup = computed(() => messages[code()] ?? messages.en);
268
+
269
+ // The explorer this component sits in, whose clock its dates are read on
270
+ // (lib/timezone, timeZoneSourcesFor). Outside a component, or outside any
271
+ // explorer, the page-wide answer.
272
+ const clock = getCurrentInstance() ? inject(EXPLORER_CLOCK, undefined) : undefined;
17
273
 
18
274
  function t(key: string, vars: Record<string, string | number> = {}): string {
19
- const raw = lookup.value[key] ?? key;
275
+ const table = lookup.value;
276
+ const raw = table[countedKey(key, vars, (k) => k in table)] ?? key;
20
277
  return Object.entries(vars).reduce(
21
278
  (acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v)),
22
279
  raw,
23
280
  );
24
281
  }
25
282
 
283
+ /** A byte count in the viewer's language and number format. */
26
284
  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')}`;
285
+ // A real zero (empty file / empty folder) is information, not absence
286
+ // `formatByteSize` renders it "0 B" rather than the em dash.
287
+ return formatByteSize(bytes, {
288
+ unit: (key) => t(key),
289
+ numberLocale: localeTag(code()),
290
+ });
45
291
  }
46
292
 
47
293
  /**
@@ -58,5 +304,115 @@ export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
58
304
  return node.basename;
59
305
  }
60
306
 
61
- return { t, formatSize, nodeDisplayName };
307
+ /**
308
+ * gorunum:v1 — one date format for the whole explorer.
309
+ *
310
+ * There were two: the grid drew "Sep 12, 2026" from the explorer's own
311
+ * locale tag and the list drew "12.09.2026 15:25:04" from
312
+ * `toLocaleString()`, i.e. from the BROWSER's locale. Two views of the same
313
+ * folder, side by side in the same product, disagreeing about what day a
314
+ * file was touched and in whose language. The epoch normalisation is the
315
+ * same in both (the backend has sent seconds and milliseconds) and is kept
316
+ * here so a third caller cannot get it wrong.
317
+ *
318
+ * `time: true` is the listing's variant — a column wide enough to carry the
319
+ * clock, which is the one place the hour actually helps. It is also what
320
+ * the details panel shows, so the panel and the row it describes print the
321
+ * same characters.
322
+ *
323
+ * zaman:z1 — and the clock is the VIEWER's, from `lib/timezone`. The instant
324
+ * on the wire is not touched; only which zone it is read against. Before
325
+ * this, every date here was drawn in the browser's own zone, so a file
326
+ * uploaded at 03:00 Istanbul read "03:00" to a colleague on GMT as well —
327
+ * same digits, different instant. `undefined` (the default) still means the
328
+ * device's zone, resolved live by Intl rather than frozen at first run.
329
+ */
330
+ function formatDate(ms: number | undefined | null, opts: { time?: boolean } = {}): string {
331
+ const d = toDate(ms);
332
+ if (!d) return '';
333
+ const date = formatInstant(
334
+ d,
335
+ code(),
336
+ {
337
+ month: 'short',
338
+ day: 'numeric',
339
+ year: 'numeric',
340
+ },
341
+ clock,
342
+ );
343
+ if (!opts.time) return date;
344
+ return `${date}, ${formatInstant(d, code(), { hour: 'numeric', minute: '2-digit' }, clock)}`;
345
+ }
346
+
347
+ /**
348
+ * The whole truth, for a tooltip: full date, seconds, the zone's name AND
349
+ * its IANA id.
350
+ *
351
+ * The listing's cell is short by necessity ("Sep 12, 2026, 3:14 AM") and a
352
+ * short date is exactly where the zone question bites — it looks like a
353
+ * fact and it is a fact *about a clock nobody named*. Hovering says which.
354
+ * The id is appended to the zone's display name because the display name
355
+ * alone is ambiguous in the direction that matters here: half the world's
356
+ * zones render as "GMT+3".
357
+ */
358
+ function formatDateFull(ms: number | undefined | null): string {
359
+ const d = toDate(ms);
360
+ if (!d) return '';
361
+ const zone = activeTimeZoneFor(clock) ?? deviceTimeZone();
362
+ const stamp = formatInstant(d, code(), { dateStyle: 'full', timeStyle: 'long' }, clock);
363
+ return `${stamp} (${zone})`;
364
+ }
365
+
366
+ /**
367
+ * "September 2026" — the header over a listing's date group.
368
+ *
369
+ * Here rather than in the view that draws it: the header answers "what
370
+ * month is this row from", and it has to answer it on the same clock the
371
+ * cell four pixels away prints. `ListView` built its own `Intl` formatter
372
+ * for this, which is how the two came to be able to disagree.
373
+ */
374
+ function formatMonthYear(value: number | Date | undefined | null): string {
375
+ const d = value instanceof Date ? value : toDate(value);
376
+ if (!d) return '';
377
+ return formatInstant(d, code(), { month: 'long', year: 'numeric' }, clock);
378
+ }
379
+
380
+ /**
381
+ * `YYYY-MM` in the viewer's zone — a bucket id, not a label.
382
+ *
383
+ * `en-CA` is not a locale choice, it is the shortest route to ISO order out
384
+ * of `Intl`; same trick as `lib/timezone.zonedDayNumber`, and for the same
385
+ * reason: the month a row is grouped under must be the month its own cell
386
+ * prints, which the DEVICE's calendar cannot tell you.
387
+ */
388
+ function zonedYearMonth(value: number | Date | undefined | null): string {
389
+ const d = value instanceof Date ? value : toDate(value);
390
+ if (!d) return '';
391
+ try {
392
+ return zonedFormatter('en-CA', { year: 'numeric', month: '2-digit' }, clock).format(d);
393
+ } catch {
394
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
395
+ }
396
+ }
397
+
398
+ return {
399
+ t,
400
+ formatSize,
401
+ formatDate,
402
+ formatDateFull,
403
+ formatMonthYear,
404
+ zonedYearMonth,
405
+ /**
406
+ * zaman:z1 — the wire's `last_modified` as a `Date`, or null.
407
+ *
408
+ * Handed out because "seconds or milliseconds" is a fact about the WIRE,
409
+ * not about formatting, and every caller that needs the instant itself
410
+ * (rather than a string) was otherwise re-typing `v * (v < 1e12 ? 1000 :
411
+ * 1)`. ListView had the third copy and used it to group rows by day — so
412
+ * the grouping and the cell four pixels away were reading the same number
413
+ * through two different converters.
414
+ */
415
+ toDate,
416
+ nodeDisplayName,
417
+ };
62
418
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * useRowTouch — the one touch grammar every file view speaks (list, grid,
3
+ * gallery).
4
+ *
5
+ * A mouse selects with a click and opens with a double-click. A finger has no
6
+ * double-click: the browser swallows the second tap into a zoom or never sends
7
+ * `dblclick` at all, so on a phone the desktop grammar left nothing openable
8
+ * (issue #26). What a finger does instead is what every mobile file manager
9
+ * does:
10
+ *
11
+ * - a long press opens the item's menu (and, through it, selects the item);
12
+ * - a tap is reported AS a tap, so the host can open instead of select.
13
+ *
14
+ * The views only report. What a tap means is decided once, in FilePane.
15
+ *
16
+ * ⚠ A tap is judged from the gesture that produced the click, never from the
17
+ * screen: `pointerType` where the browser sets it on click (Chromium, Firefox),
18
+ * and the touchend that just preceded the click where it does not (older
19
+ * WebKit). A touch laptop's trackpad therefore keeps the desktop grammar while
20
+ * its screen gets the phone one. A `(pointer: coarse)` media query cannot tell
21
+ * those two apart.
22
+ *
23
+ * ⚠ It used to live as three identical copies of the long-press timer, one per
24
+ * view; the tap rule is exactly the kind of addition a copy misses.
25
+ */
26
+ import { onBeforeUnmount } from 'vue';
27
+
28
+ /** How long a finger rests before the press becomes a long press. */
29
+ export const LONG_PRESS_MS = 500;
30
+ /** A click this soon after a touchend is the tap that touchend ended. */
31
+ const TAP_WINDOW_MS = 800;
32
+ /** A finger that travels further than this is scrolling, not pressing. */
33
+ const MOVE_TOLERANCE_PX = 10;
34
+
35
+ export interface TouchPoint {
36
+ clientX: number;
37
+ clientY: number;
38
+ }
39
+
40
+ export function useRowTouch<T>(onLongPress: (item: T, at: TouchPoint) => void) {
41
+ let timer: ReturnType<typeof setTimeout> | undefined;
42
+ let target: T | null = null;
43
+ let origin: TouchPoint = { clientX: 0, clientY: 0 };
44
+ let longPressed = false;
45
+ let tapEndedAt = 0;
46
+
47
+ function stopTimer() {
48
+ if (timer) clearTimeout(timer);
49
+ timer = undefined;
50
+ }
51
+
52
+ function onTouchStart(item: T, ev: TouchEvent) {
53
+ const t0 = ev.touches[0];
54
+ if (!t0) return;
55
+ stopTimer();
56
+ target = item;
57
+ longPressed = false;
58
+ origin = { clientX: t0.clientX, clientY: t0.clientY };
59
+ timer = setTimeout(() => {
60
+ timer = undefined;
61
+ if (target === null) return;
62
+ longPressed = true;
63
+ onLongPress(target, origin);
64
+ }, LONG_PRESS_MS);
65
+ }
66
+
67
+ function onTouchMove(ev: TouchEvent) {
68
+ const t0 = ev.touches[0];
69
+ if (
70
+ !t0 ||
71
+ Math.abs(t0.clientX - origin.clientX) > MOVE_TOLERANCE_PX ||
72
+ Math.abs(t0.clientY - origin.clientY) > MOVE_TOLERANCE_PX
73
+ ) {
74
+ stopTimer();
75
+ target = null;
76
+ }
77
+ }
78
+
79
+ function onTouchEnd() {
80
+ stopTimer();
81
+ if (target !== null && !longPressed) tapEndedAt = Date.now();
82
+ target = null;
83
+ }
84
+
85
+ /** Whether this click is a finger's tap. */
86
+ function isTap(ev: MouseEvent): boolean {
87
+ const kind = (ev as PointerEvent).pointerType;
88
+ if (kind) return kind === 'touch';
89
+ return Date.now() - tapEndedAt < TAP_WINDOW_MS;
90
+ }
91
+
92
+ onBeforeUnmount(stopTimer);
93
+
94
+ return { onTouchStart, onTouchMove, onTouchEnd, isTap };
95
+ }