@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.
- package/README.md +32 -25
- package/dist/ArchiveViewer-BNqukFg8.js +124 -0
- package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
- package/dist/CsvViewer-ii_-MgmG.js +141 -0
- package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
- package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
- package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
- package/dist/EpubViewer-p4B8iiWb.js +163 -0
- package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
- package/dist/IpynbViewer-D_qYRJhu.js +184 -0
- package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
- package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
- package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
- package/dist/PsdViewer-CuV-OPSd.js +122 -0
- package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
- package/dist/TiffViewer-BLeoF4Be.js +142 -0
- package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
- package/dist/Viewer3D-DHhhU76E.js +75 -0
- package/dist/Viewer3D-DHhhU76E.js.map +1 -0
- package/dist/filex-core.js +217 -20077
- 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-BraG7Cz4.js +26664 -0
- package/dist/index-BraG7Cz4.js.map +1 -0
- package/dist/index.d.ts +4276 -851
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +2170 -492
- 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 +57 -2
- package/src/components/EncryptedFolderModal.vue +31 -25
- package/src/components/FilePane.vue +939 -0
- package/src/components/FilterBar.vue +568 -72
- package/src/components/GalleryView.vue +162 -30
- package/src/components/GridView.vue +322 -58
- package/src/components/HomeView.vue +331 -0
- package/src/components/InspectorPanel.vue +296 -53
- package/src/components/ListView.vue +1158 -167
- package/src/components/NFSExportsPanel.vue +7 -4
- package/src/components/OnboardingTour.vue +156 -33
- package/src/components/QuickLook.vue +34 -5
- 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 +43 -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 +127 -0
- package/src/components/TimeZonePicker.vue +696 -0
- package/src/components/TokensPanel.vue +16 -4
- package/src/components/Toolbar.vue +965 -469
- package/src/components/ViewSwitcher.vue +9 -4
- package/src/composables/useConnections.ts +35 -4
- package/src/composables/useExplorerTimeZone.ts +85 -0
- package/src/composables/useFileApi.ts +53 -4
- package/src/composables/useKeyboardShortcuts.ts +122 -0
- package/src/composables/useLocale.ts +365 -25
- package/src/index.ts +182 -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 +11 -1
- package/src/lib/sortOrder.ts +459 -0
- package/src/lib/themes.ts +152 -23
- package/src/lib/timezone.ts +456 -0
- package/src/lib/uiProfile.ts +82 -0
- package/src/lib/viewPrefs.ts +1036 -0
- package/src/locales/en.ts +432 -25
- package/src/locales/tr.ts +425 -25
- 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 +543 -473
- package/src/modals/PreviewModal.vue +516 -156
- package/src/styles/base.css +5435 -8
- package/src/styles/variables.css +140 -60
- 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,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
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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')}`;
|
|
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
|
-
|
|
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
|
|
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';
|