@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.
- package/README.md +32 -25
- package/dist/ArchiveViewer-DxxjhYZD.js +124 -0
- package/dist/ArchiveViewer-DxxjhYZD.js.map +1 -0
- package/dist/CsvViewer-CyuwENI_.js +141 -0
- package/dist/CsvViewer-CyuwENI_.js.map +1 -0
- package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-BIYsTX97.js} +48 -42
- package/dist/DrawioViewer-BIYsTX97.js.map +1 -0
- package/dist/EpubViewer-K9PjCMmS.js +163 -0
- package/dist/EpubViewer-K9PjCMmS.js.map +1 -0
- package/dist/IpynbViewer-DYU6Zgz2.js +184 -0
- package/dist/IpynbViewer-DYU6Zgz2.js.map +1 -0
- package/dist/MermaidViewer-Bp73aId8.js +137 -0
- package/dist/MermaidViewer-Bp73aId8.js.map +1 -0
- package/dist/PsdViewer-C2eLivw1.js +122 -0
- package/dist/PsdViewer-C2eLivw1.js.map +1 -0
- package/dist/TiffViewer-DsHg5gcz.js +142 -0
- package/dist/TiffViewer-DsHg5gcz.js.map +1 -0
- package/dist/Viewer3D-C_dc2HN9.js +75 -0
- package/dist/Viewer3D-C_dc2HN9.js.map +1 -0
- package/dist/filex-core.js +218 -20361
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +61 -62
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-vxMZpYb-.js +26700 -0
- package/dist/index-vxMZpYb-.js.map +1 -0
- package/dist/index.d.ts +4212 -862
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +2154 -506
- package/src/components/AdvancedSearch.vue +870 -0
- package/src/components/Breadcrumb.vue +191 -19
- package/src/components/CommandPalette.vue +66 -22
- package/src/components/ConnectionsPanel.vue +6 -2
- package/src/components/ContextMenu.vue +39 -2
- package/src/components/EncryptedFolderModal.vue +31 -25
- package/src/components/FilePane.vue +951 -0
- package/src/components/FilterBar.vue +568 -72
- package/src/components/GalleryView.vue +172 -56
- package/src/components/GridView.vue +331 -82
- package/src/components/HomeView.vue +331 -0
- package/src/components/InspectorPanel.vue +296 -53
- package/src/components/ListView.vue +1173 -194
- package/src/components/NFSExportsPanel.vue +7 -4
- package/src/components/OnboardingTour.vue +156 -33
- package/src/components/QuickLook.vue +26 -1
- package/src/components/RecentlyOpened.vue +17 -2
- package/src/components/S3KeysPanel.vue +7 -4
- package/src/components/SSHKeysPanel.vue +7 -4
- package/src/components/ShortcutSettings.vue +6 -2
- package/src/components/ShortcutsHelp.vue +4 -1
- package/src/components/SideNav.vue +206 -140
- package/src/components/StorageFields.vue +19 -1
- package/src/components/TabBar.vue +87 -1
- package/src/components/TagPicker.vue +77 -6
- package/src/components/ThemeGallery.vue +13 -198
- package/src/components/ThemePalette.vue +244 -0
- package/src/components/TimeZoneDialog.vue +135 -0
- package/src/components/TimeZonePicker.vue +696 -0
- package/src/components/TokensPanel.vue +35 -14
- package/src/components/Toolbar.vue +953 -465
- package/src/components/ViewSwitcher.vue +9 -4
- package/src/composables/useConnections.ts +35 -4
- package/src/composables/useExplorerTimeZone.ts +89 -0
- package/src/composables/useFileApi.ts +53 -4
- package/src/composables/useLocale.ts +382 -26
- package/src/composables/useRowTouch.ts +95 -0
- package/src/index.ts +177 -3
- package/src/lib/actionIcons.ts +330 -0
- package/src/lib/advSearch.ts +162 -0
- package/src/lib/dateGroups.ts +197 -0
- package/src/lib/destinationTree.ts +235 -0
- package/src/lib/downloadSelection.ts +154 -0
- package/src/lib/fileFilters.ts +319 -12
- package/src/lib/fileIcons.ts +299 -5
- package/src/lib/filePreview.ts +605 -0
- package/src/lib/listing.ts +72 -0
- package/src/lib/shareTtl.ts +31 -2
- package/src/lib/sortOrder.ts +459 -0
- package/src/lib/themes.ts +151 -116
- package/src/lib/timezone.ts +501 -0
- package/src/lib/uiProfile.ts +82 -0
- package/src/lib/viewPrefs.ts +1036 -0
- package/src/locales/en.ts +416 -26
- package/src/locales/tr.ts +409 -26
- package/src/modals/ConvertModal.vue +23 -3
- package/src/modals/DestinationPickerModal.vue +341 -0
- package/src/modals/Modal.vue +18 -4
- package/src/modals/NewDocumentModal.vue +525 -0
- package/src/modals/NewFolderModal.vue +15 -9
- package/src/modals/PermissionsModal.vue +557 -476
- package/src/modals/PreviewModal.vue +516 -156
- package/src/styles/base.css +5409 -8
- package/src/styles/variables.css +139 -65
- package/src/types/Connections.ts +9 -0
- package/src/types/ExplorerConfig.ts +144 -39
- package/src/types/FileNode.ts +28 -0
- package/src/viewers/ArchiveViewer.vue +46 -8
- package/src/viewers/CsvViewer.vue +19 -2
- package/src/viewers/DrawioViewer.vue +12 -1
- package/src/viewers/EpubViewer.vue +50 -6
- package/src/viewers/IpynbViewer.vue +19 -2
- package/src/viewers/MermaidViewer.vue +20 -3
- package/src/viewers/PdfViewer.vue +24 -4
- package/src/viewers/PsdViewer.vue +28 -4
- package/src/viewers/TiffViewer.vue +19 -2
- package/src/viewers/Viewer3D.vue +36 -2
- package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
- package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
- package/dist/CsvViewer-CqWeV8VO.js +0 -131
- package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
- package/dist/DrawioViewer-BNALOB04.js.map +0 -1
- package/dist/EpubViewer-BPmlImG7.js +0 -145
- package/dist/EpubViewer-BPmlImG7.js.map +0 -1
- package/dist/IpynbViewer-CDlQmuCV.js +0 -175
- package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
- package/dist/MermaidViewer-sfZWdR-d.js +0 -127
- package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
- package/dist/PsdViewer-DwuySZsb.js +0 -112
- package/dist/PsdViewer-DwuySZsb.js.map +0 -1
- package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
- package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
- package/dist/Viewer3D-CJJJgkuv.js +0 -60
- package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
- package/src/components/SecondaryPane.vue +0 -447
- 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
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
+
}
|