@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
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timezone.ts — whose clock an instant is read on.
|
|
3
|
+
*
|
|
4
|
+
* A timestamp is an instant. `last_modified` is the same number for everybody
|
|
5
|
+
* on the planet; what differs is the clock it is read against. The owner's
|
|
6
|
+
* rule, verbatim: *"ben 03'te video yükledim, GMT 0 eleman o videonun yüklenme
|
|
7
|
+
* saatini kendi zaman diliminde görecek."* So the instant stays untouched on
|
|
8
|
+
* the wire and the READER picks the clock.
|
|
9
|
+
*
|
|
10
|
+
* ⚠⚠ WHICH reader is a question with several answers, and this module is the
|
|
11
|
+
* ONE place they are ranked. Before it, the admin web app followed the account
|
|
12
|
+
* and an embedded `<filex-explorer>` on another origin followed the browser —
|
|
13
|
+
* a person who chose Tokyo saw Tokyo in one and their laptop's zone in the
|
|
14
|
+
* other, for the same file. That disagreement is the bug this exists to end,
|
|
15
|
+
* so every surface resolves through `resolveTimeZone()` and nothing else
|
|
16
|
+
* decides.
|
|
17
|
+
*
|
|
18
|
+
* The owner's ruling (2026-09-14): three tiers — the embedder, then the
|
|
19
|
+
* account behind the key when it IS a person's key, then the browser — plus a
|
|
20
|
+
* setting inside the embed, kept in the browser, because an embed has no
|
|
21
|
+
* user-settings dialog. Where that setting ranks is `TIME_ZONE_TIERS` below,
|
|
22
|
+
* and the reasoning is on it.
|
|
23
|
+
*
|
|
24
|
+
* Scope: page-level, deliberately. The clock belongs to the person looking at
|
|
25
|
+
* the page, and there is one of them. Host and account zones are REGISTERED by
|
|
26
|
+
* whoever knows them (an explorer instance, the web app's auth store) under an
|
|
27
|
+
* owner key, so an explorer that unmounts takes its zones with it instead of
|
|
28
|
+
* leaving them behind for the next one. Two explorers on one page that
|
|
29
|
+
* disagree about a host zone, or hold different people's tokens, resolve to
|
|
30
|
+
* the most recently written value for both — one viewer, one clock.
|
|
31
|
+
*
|
|
32
|
+
* The same-tab `filex:timezone` event survives from the previous design and for
|
|
33
|
+
* the same reason: the admin app and a `<filex-explorer>` web component on one
|
|
34
|
+
* page can be two different BUNDLES, i.e. two copies of this module, and a ref
|
|
35
|
+
* in one says nothing to the other. The event crosses that gap for the one tier
|
|
36
|
+
* that is shared state by nature — the browser's.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { computed, ref, type Ref } from 'vue';
|
|
40
|
+
|
|
41
|
+
/* ── the order ────────────────────────────────────────────────────────── */
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The places a zone can come from.
|
|
45
|
+
*
|
|
46
|
+
* viewer — the person looking at this page picked one in THIS browser
|
|
47
|
+
* (the explorer's "⋯" → Time zone). localStorage, per origin.
|
|
48
|
+
* host — the embedder set `config.timeZone` on the explorer.
|
|
49
|
+
* account — the account behind the credential, from `GET /api/auth/me`, and
|
|
50
|
+
* ONLY when that credential is a person's (a cookie session or
|
|
51
|
+
* their own API key). An `app` token is shared by every visitor of
|
|
52
|
+
* the embed that holds it, so its owner's zone is nobody's clock
|
|
53
|
+
* and the tier is skipped.
|
|
54
|
+
* device — the browser's own zone, resolved live by `Intl`.
|
|
55
|
+
*/
|
|
56
|
+
export type TimeZoneTier = 'viewer' | 'host' | 'account' | 'device';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ⚠⚠ THE order. First tier holding a valid zone wins; `device` always holds
|
|
60
|
+
* one. Flipping a ranking is a change to this array and to nothing else — the
|
|
61
|
+
* resolver, the explorer, the web app and the embed's own dialog all read it
|
|
62
|
+
* (pinned by web/tests/lib/timeZoneTiers.test.ts).
|
|
63
|
+
*
|
|
64
|
+
* `viewer` is on top, above the embedder the owner numbered "1", and that is a
|
|
65
|
+
* judgement rather than a transcription:
|
|
66
|
+
*
|
|
67
|
+
* - A host's `config.timeZone` is a DEFAULT it chooses for everybody who
|
|
68
|
+
* visits, written before any of them arrived. A viewer's pick is a
|
|
69
|
+
* decision one person made on purpose, afterwards, in a control that
|
|
70
|
+
* exists for nothing else. If the host outranked it, every host that sets
|
|
71
|
+
* a zone would ship an embed whose Time zone control visibly does nothing
|
|
72
|
+
* — and a control that saves, reads back and changes nothing is worse than
|
|
73
|
+
* no control.
|
|
74
|
+
* - The explorer already ranks the same two things this way for the theme
|
|
75
|
+
* mode: `config.theme` is what `'host'` (never chosen) resolves to, and
|
|
76
|
+
* the first click in the gallery pins the viewer's own choice above it
|
|
77
|
+
* (FileExplorer.vue, `themeMode`). One product, one answer to "does the
|
|
78
|
+
* host or the person decide how this looks".
|
|
79
|
+
* - The pick is cheap to undo: the picker's first row is "use the default",
|
|
80
|
+
* and that row names the zone and the tier it falls back to.
|
|
81
|
+
*
|
|
82
|
+
* Below the viewer, the owner's own numbering stands as given.
|
|
83
|
+
*/
|
|
84
|
+
export const TIME_ZONE_TIERS: readonly TimeZoneTier[] = Object.freeze([
|
|
85
|
+
'viewer',
|
|
86
|
+
'host',
|
|
87
|
+
'account',
|
|
88
|
+
'device',
|
|
89
|
+
] as TimeZoneTier[]);
|
|
90
|
+
|
|
91
|
+
/** A value per tier. Empty, missing or engine-rejected means "no opinion". */
|
|
92
|
+
export type TimeZoneSources = Partial<Record<Exclude<TimeZoneTier, 'device'>, string | null>>;
|
|
93
|
+
|
|
94
|
+
export interface ResolvedTimeZone {
|
|
95
|
+
/** The zone to hand `Intl`; `undefined` = the device, resolved live. */
|
|
96
|
+
zone: string | undefined;
|
|
97
|
+
/** Which tier it came from. */
|
|
98
|
+
tier: TimeZoneTier;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Walk `TIME_ZONE_TIERS` and return the first tier with a usable zone.
|
|
103
|
+
*
|
|
104
|
+
* Pure — the reactive state below feeds it, and so can a test, or a surface
|
|
105
|
+
* that needs to say what a default WOULD be (the embed's dialog asks it with
|
|
106
|
+
* the viewer tier blanked, to label its "use the default" row).
|
|
107
|
+
*/
|
|
108
|
+
export function resolveTimeZone(sources: TimeZoneSources): ResolvedTimeZone {
|
|
109
|
+
for (const tier of TIME_ZONE_TIERS) {
|
|
110
|
+
if (tier === 'device') break;
|
|
111
|
+
const v = sources[tier];
|
|
112
|
+
if (typeof v === 'string' && v && isValidTimeZone(v)) return { zone: v, tier };
|
|
113
|
+
}
|
|
114
|
+
return { zone: undefined, tier: 'device' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ── storage keys ─────────────────────────────────────────────────────── */
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* localStorage key of the VIEWER tier — the embed's own setting.
|
|
121
|
+
*
|
|
122
|
+
* ⚠ Not `filex.timezone`. That key already holds the web app's copy of the
|
|
123
|
+
* ACCOUNT's zone in every browser that has opened the admin app, and reading
|
|
124
|
+
* it as a viewer choice would rank a stale cache above the account forever: a
|
|
125
|
+
* zone changed in another browser would never arrive in this one.
|
|
126
|
+
*/
|
|
127
|
+
export const TIMEZONE_VIEWER_LS_KEY = 'filex.timezone.viewer';
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* localStorage key of the web app's copy of the ACCOUNT tier, so the first
|
|
131
|
+
* paint after a reload is already right instead of flashing the device's zone
|
|
132
|
+
* until `/api/auth/me` lands. Written only by a host that opts in
|
|
133
|
+
* (`setAccountTimeZone(…, { remember: true })`) — an embed on a shared origin
|
|
134
|
+
* must not inherit the zone of whoever last signed in to something else there.
|
|
135
|
+
*/
|
|
136
|
+
export const TIMEZONE_ACCOUNT_LS_KEY = 'filex.timezone';
|
|
137
|
+
|
|
138
|
+
/* ── the engine ───────────────────────────────────────────────────────── */
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A short, boring fallback list for engines without
|
|
142
|
+
* `Intl.supportedValuesOf` (Safari < 15.4, older embedded WebViews). Not a
|
|
143
|
+
* world atlas: enough to cover the zones this product's users are actually
|
|
144
|
+
* in, plus UTC as the neutral reference every ops conversation falls back to.
|
|
145
|
+
*/
|
|
146
|
+
const FALLBACK_ZONES = [
|
|
147
|
+
'UTC',
|
|
148
|
+
'Europe/Istanbul',
|
|
149
|
+
'Europe/London',
|
|
150
|
+
'Europe/Berlin',
|
|
151
|
+
'Europe/Paris',
|
|
152
|
+
'Europe/Madrid',
|
|
153
|
+
'Europe/Moscow',
|
|
154
|
+
'America/New_York',
|
|
155
|
+
'America/Chicago',
|
|
156
|
+
'America/Denver',
|
|
157
|
+
'America/Los_Angeles',
|
|
158
|
+
'America/Sao_Paulo',
|
|
159
|
+
'Africa/Cairo',
|
|
160
|
+
'Africa/Lagos',
|
|
161
|
+
'Asia/Dubai',
|
|
162
|
+
'Asia/Karachi',
|
|
163
|
+
'Asia/Kolkata',
|
|
164
|
+
'Asia/Shanghai',
|
|
165
|
+
'Asia/Tokyo',
|
|
166
|
+
'Asia/Seoul',
|
|
167
|
+
'Asia/Singapore',
|
|
168
|
+
'Australia/Sydney',
|
|
169
|
+
'Pacific/Auckland',
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
/** The zone this browser is in right now, asked fresh every time. */
|
|
173
|
+
export function deviceTimeZone(): string {
|
|
174
|
+
try {
|
|
175
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
176
|
+
} catch {
|
|
177
|
+
return 'UTC';
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* True when the engine accepts `tz` as an IANA zone.
|
|
183
|
+
*
|
|
184
|
+
* ⚠ The check is a real formatter construction, not a lookup in the list
|
|
185
|
+
* below: `supportedValuesOf` is the engine's *canonical* set and does not
|
|
186
|
+
* contain the aliases it nonetheless accepts (`Asia/Istanbul`,
|
|
187
|
+
* `US/Pacific`, …). The value may have come from another device, another
|
|
188
|
+
* browser, a host page or a hand-edited account row.
|
|
189
|
+
*/
|
|
190
|
+
export function isValidTimeZone(tz: string): boolean {
|
|
191
|
+
if (!tz) return false;
|
|
192
|
+
try {
|
|
193
|
+
new Intl.DateTimeFormat('en-US', { timeZone: tz });
|
|
194
|
+
return true;
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Every zone the engine knows, sorted; the fallback list when it knows none. */
|
|
201
|
+
export function supportedTimeZones(): string[] {
|
|
202
|
+
try {
|
|
203
|
+
const fn = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] })
|
|
204
|
+
.supportedValuesOf;
|
|
205
|
+
if (typeof fn === 'function') {
|
|
206
|
+
const list = fn('timeZone');
|
|
207
|
+
if (Array.isArray(list) && list.length) {
|
|
208
|
+
// UTC is missing from the canonical list on some engines (it is
|
|
209
|
+
// spelled Etc/UTC there) and it is the one zone an operator asks for
|
|
210
|
+
// by name, so it is guaranteed a row rather than left to chance.
|
|
211
|
+
return list.includes('UTC') ? [...list] : ['UTC', ...list];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
/* fall through */
|
|
216
|
+
}
|
|
217
|
+
return [...FALLBACK_ZONES];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Which calendar day an instant falls on, in `zone`, as a day count since the
|
|
222
|
+
* epoch. Comparable and subtractable — `a - b === 1` means "the day before".
|
|
223
|
+
*
|
|
224
|
+
* Why not `new Date(ms).getDate()`: that is the DEVICE's calendar, and the
|
|
225
|
+
* date a listing groups by has to be the same date it prints in the cell four
|
|
226
|
+
* pixels away. A file touched at 01:00 in Istanbul belongs under "Yesterday"
|
|
227
|
+
* for a viewer reading UTC and under "Today" for one reading Istanbul.
|
|
228
|
+
*
|
|
229
|
+
* `en-CA` is not a locale choice, it is the shortest route to ISO `Y-M-D` out
|
|
230
|
+
* of `Intl`; the parts are re-assembled through `Date.UTC` so the arithmetic
|
|
231
|
+
* happens on a calendar with no offsets in it at all.
|
|
232
|
+
*/
|
|
233
|
+
export function zonedDayNumber(d: Date, zone?: string): number {
|
|
234
|
+
const tz = zone ?? activeTimeZone();
|
|
235
|
+
try {
|
|
236
|
+
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
237
|
+
timeZone: tz,
|
|
238
|
+
year: 'numeric',
|
|
239
|
+
month: '2-digit',
|
|
240
|
+
day: '2-digit',
|
|
241
|
+
}).formatToParts(d);
|
|
242
|
+
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value);
|
|
243
|
+
const y = get('year');
|
|
244
|
+
const m = get('month');
|
|
245
|
+
const day = get('day');
|
|
246
|
+
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(day)) {
|
|
247
|
+
throw new Error('unparsable');
|
|
248
|
+
}
|
|
249
|
+
return Math.floor(Date.UTC(y, m - 1, day) / 86_400_000);
|
|
250
|
+
} catch {
|
|
251
|
+
// Engine refused the zone (or formatToParts) — the device's calendar is a
|
|
252
|
+
// worse answer than the right one but a much better answer than none.
|
|
253
|
+
return Math.floor(
|
|
254
|
+
Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86_400_000,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* ── the state ────────────────────────────────────────────────────────── */
|
|
260
|
+
|
|
261
|
+
function readValid(key: string): string {
|
|
262
|
+
try {
|
|
263
|
+
const v = localStorage.getItem(key);
|
|
264
|
+
return v && isValidTimeZone(v) ? v : '';
|
|
265
|
+
} catch {
|
|
266
|
+
return '';
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function writeOrClear(key: string, value: string): void {
|
|
271
|
+
try {
|
|
272
|
+
if (value) localStorage.setItem(key, value);
|
|
273
|
+
else localStorage.removeItem(key);
|
|
274
|
+
} catch {
|
|
275
|
+
/* private mode / blocked site data — the in-memory state still moved, so
|
|
276
|
+
this page is correct; only the next reload forgets. */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const hasWindow = typeof window !== 'undefined';
|
|
281
|
+
|
|
282
|
+
/** Tier `viewer`. `''` = no choice made in this browser. Read at module load
|
|
283
|
+
* so the value is right on the FIRST paint after a reload. */
|
|
284
|
+
const viewerZone: Ref<string> = ref(hasWindow ? readValid(TIMEZONE_VIEWER_LS_KEY) : '');
|
|
285
|
+
|
|
286
|
+
interface OwnedZone {
|
|
287
|
+
owner: symbol;
|
|
288
|
+
zone: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* A tier fed by several owners. The newest WRITE wins; re-stating an unchanged
|
|
293
|
+
* value keeps its place, so a watcher that fires again cannot jump the queue
|
|
294
|
+
* over a genuinely newer answer.
|
|
295
|
+
*/
|
|
296
|
+
function ownedTier() {
|
|
297
|
+
const entries = ref<OwnedZone[]>([]);
|
|
298
|
+
return {
|
|
299
|
+
set(owner: symbol, zone: string | null): void {
|
|
300
|
+
const cur = entries.value.find((e) => e.owner === owner);
|
|
301
|
+
if (zone === null) {
|
|
302
|
+
if (cur) entries.value = entries.value.filter((e) => e !== cur);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (cur && cur.zone === zone) return;
|
|
306
|
+
entries.value = [...entries.value.filter((e) => e.owner !== owner), { owner, zone }];
|
|
307
|
+
},
|
|
308
|
+
of(owner: symbol): string | null {
|
|
309
|
+
return entries.value.find((e) => e.owner === owner)?.zone ?? null;
|
|
310
|
+
},
|
|
311
|
+
current(): string {
|
|
312
|
+
const list = entries.value;
|
|
313
|
+
return list.length ? list[list.length - 1].zone : '';
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const hostTier = ownedTier();
|
|
319
|
+
const accountTier = ownedTier();
|
|
320
|
+
/** Owners whose account entry is the localStorage copy, and follows it. */
|
|
321
|
+
const rememberingOwners = new Set<symbol>();
|
|
322
|
+
|
|
323
|
+
const resolved = computed<ResolvedTimeZone>(() =>
|
|
324
|
+
resolveTimeZone({
|
|
325
|
+
viewer: viewerZone.value,
|
|
326
|
+
host: hostTier.current(),
|
|
327
|
+
account: accountTier.current(),
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The zone to hand `Intl` — the resolved one. Reactive: reading it inside a
|
|
333
|
+
* render or a computed subscribes that surface to every tier.
|
|
334
|
+
*
|
|
335
|
+
* `undefined` is returned for the device rather than the resolved name, so the
|
|
336
|
+
* formatter takes `Intl`'s own live answer (a laptop carried across an ocean
|
|
337
|
+
* keeps telling the truth without anybody re-saving anything).
|
|
338
|
+
*/
|
|
339
|
+
export function activeTimeZone(): string | undefined {
|
|
340
|
+
return resolved.value.zone;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The resolved zone AND the tier it came from. Reactive. */
|
|
344
|
+
export function resolvedTimeZone(): ResolvedTimeZone {
|
|
345
|
+
return resolved.value;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** What each tier currently holds (`''` = nothing). Reactive. */
|
|
349
|
+
export function timeZoneSources(): Required<TimeZoneSources> {
|
|
350
|
+
return {
|
|
351
|
+
viewer: viewerZone.value,
|
|
352
|
+
host: hostTier.current(),
|
|
353
|
+
account: accountTier.current(),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Tier `viewer`, as stored in this browser. `''` = no choice. Reactive. */
|
|
358
|
+
export function viewerTimeZone(): string {
|
|
359
|
+
return viewerZone.value;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Set (or, with `''`, clear) the viewer's own choice in this browser. */
|
|
363
|
+
export function setViewerTimeZone(tz: string): void {
|
|
364
|
+
const valid = tz && isValidTimeZone(tz) ? tz : '';
|
|
365
|
+
viewerZone.value = valid;
|
|
366
|
+
writeOrClear(TIMEZONE_VIEWER_LS_KEY, valid);
|
|
367
|
+
// A window never receives its own `storage` event, and a second bundle on
|
|
368
|
+
// this page holds a different copy of this module. Say it out loud.
|
|
369
|
+
try {
|
|
370
|
+
window.dispatchEvent(new CustomEvent('filex:timezone', { detail: valid }));
|
|
371
|
+
} catch {
|
|
372
|
+
/* non-DOM environment, or an engine without the CustomEvent constructor */
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Tier `host`: the zone `owner` (an explorer instance) was configured with.
|
|
378
|
+
* `''`/`null`/an engine-rejected id withdraws it — an explorer with no
|
|
379
|
+
* `config.timeZone` has no opinion, and must not hide another one's.
|
|
380
|
+
*/
|
|
381
|
+
export function setHostTimeZone(owner: symbol, tz: string | null | undefined): void {
|
|
382
|
+
hostTier.set(owner, tz && isValidTimeZone(tz) ? tz : null);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Tier `account`: what `owner` learned about the account behind its
|
|
387
|
+
* credential.
|
|
388
|
+
*
|
|
389
|
+
* `''` is an answer — "this person has not chosen a zone" — and is recorded,
|
|
390
|
+
* so it overrides an older answer from the same page. `null`/`undefined` means
|
|
391
|
+
* "I have no account to speak for" (an `app` token, a failed lookup) and
|
|
392
|
+
* withdraws the entry. A zone the engine rejects is recorded as `''`: the
|
|
393
|
+
* account did say something, and it was not usable.
|
|
394
|
+
*
|
|
395
|
+
* `remember` mirrors the value into `TIMEZONE_ACCOUNT_LS_KEY` for the next
|
|
396
|
+
* first paint, and makes this owner follow that key when another tab changes
|
|
397
|
+
* it. For the admin web app, whose session is always a person.
|
|
398
|
+
*/
|
|
399
|
+
export function setAccountTimeZone(
|
|
400
|
+
owner: symbol,
|
|
401
|
+
tz: string | null | undefined,
|
|
402
|
+
opts: { remember?: boolean } = {},
|
|
403
|
+
): void {
|
|
404
|
+
if (tz === null || tz === undefined) {
|
|
405
|
+
accountTier.set(owner, null);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const valid = tz && isValidTimeZone(tz) ? tz : '';
|
|
409
|
+
accountTier.set(owner, valid);
|
|
410
|
+
if (opts.remember) {
|
|
411
|
+
rememberingOwners.add(owner);
|
|
412
|
+
writeOrClear(TIMEZONE_ACCOUNT_LS_KEY, valid);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The account zone `owner` last recorded (`null` = none recorded). Reactive. */
|
|
417
|
+
export function accountTimeZoneOf(owner: symbol): string | null {
|
|
418
|
+
return accountTier.of(owner);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The account zone remembered in this browser by the last session. */
|
|
422
|
+
export function rememberedAccountTimeZone(): string {
|
|
423
|
+
return hasWindow ? readValid(TIMEZONE_ACCOUNT_LS_KEY) : '';
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Withdraw everything `owner` registered — call it on unmount. */
|
|
427
|
+
export function releaseTimeZoneOwner(owner: symbol): void {
|
|
428
|
+
hostTier.set(owner, null);
|
|
429
|
+
accountTier.set(owner, null);
|
|
430
|
+
rememberingOwners.delete(owner);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (hasWindow) {
|
|
434
|
+
try {
|
|
435
|
+
window.addEventListener('storage', (e) => {
|
|
436
|
+
// `key === null` is a whole-storage clear, which is also news.
|
|
437
|
+
if (e.key === null || e.key === TIMEZONE_VIEWER_LS_KEY) {
|
|
438
|
+
viewerZone.value = readValid(TIMEZONE_VIEWER_LS_KEY);
|
|
439
|
+
}
|
|
440
|
+
if (e.key === null || e.key === TIMEZONE_ACCOUNT_LS_KEY) {
|
|
441
|
+
const v = readValid(TIMEZONE_ACCOUNT_LS_KEY);
|
|
442
|
+
for (const owner of rememberingOwners) accountTier.set(owner, v);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
window.addEventListener('filex:timezone', (e) => {
|
|
446
|
+
// ⚠ From the event's own detail, not re-read from storage: in a private
|
|
447
|
+
// window storage refuses the write, and re-reading would undo — in this
|
|
448
|
+
// very bundle — the choice that was just made.
|
|
449
|
+
const d = (e as CustomEvent).detail;
|
|
450
|
+
viewerZone.value =
|
|
451
|
+
typeof d === 'string' ? (d && isValidTimeZone(d) ? d : '') : readValid(TIMEZONE_VIEWER_LS_KEY);
|
|
452
|
+
});
|
|
453
|
+
} catch {
|
|
454
|
+
/* non-DOM environment */
|
|
455
|
+
}
|
|
456
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uiProfile — how much of the explorer to draw. Two names, and one rule for
|
|
3
|
+
* everything that is not one of them.
|
|
4
|
+
*
|
|
5
|
+
* ⚠⚠ The profile is a REDUCTION switch, never a LOOK switch. The shell — the
|
|
6
|
+
* header with its one search field, the filter row, "+ New", the Folders /
|
|
7
|
+
* Files sections, the info panel's tabs, the storage line — is what filex IS;
|
|
8
|
+
* nothing reads a profile to decide whether to draw it. `'simple'` turns OFF
|
|
9
|
+
* the tab strip, the split pane, the gallery view mode and the panel's
|
|
10
|
+
* "How to connect" / "API keys" entries, and removes nothing from the build.
|
|
11
|
+
*
|
|
12
|
+
* ⚠⚠ WHY THERE IS A RESOLVER AND NOT A UNION CHECK AT EACH CALL SITE. This
|
|
13
|
+
* value comes from OUTSIDE: a `config` object typed by an embedder who may not
|
|
14
|
+
* use TypeScript at all, or a `ui-profile="…"` attribute on a custom element,
|
|
15
|
+
* which is a string the DOM will hand over unexamined. So "what does an
|
|
16
|
+
* unrecognised value mean" is a real question with real users behind it, and
|
|
17
|
+
* it is answered here, once, instead of by whatever each reader's `===` chain
|
|
18
|
+
* happened to fall through to.
|
|
19
|
+
*
|
|
20
|
+
* THE ANSWER: `'simple'` and nothing else turns the reduction on. Every other
|
|
21
|
+
* value — `'standard'`, `undefined`, a typo, a name a later version knows and
|
|
22
|
+
* this one does not — is `'standard'`, the documented default, and a string we
|
|
23
|
+
* did not recognise is said out loud once in the console.
|
|
24
|
+
*
|
|
25
|
+
* ⚠ WHICH RETIRES `'drive'`, deliberately and with its cost stated. It shipped
|
|
26
|
+
* as a third profile in v0.32.0 and became an alias of `'simple'` in v0.40.0;
|
|
27
|
+
* the owner's ruling on 2026-09-13 was to take it out ("kaldıralım direk").
|
|
28
|
+
* filex is open source, so an embed somewhere may still be passing it, and
|
|
29
|
+
* what that embed gets is now the FULL explorer rather than the reduced one —
|
|
30
|
+
* it gains the tab strip, the split pane, the gallery mode and the connection
|
|
31
|
+
* guides. That is a visible change in one direction and a one-word fix
|
|
32
|
+
* (`uiProfile: 'simple'`), and it is the honest one: mapping the retired name
|
|
33
|
+
* onto `'simple'` here would be the alias again, wearing a resolver's coat,
|
|
34
|
+
* and it would also mean a plain typo silently REDUCED somebody's UI — a
|
|
35
|
+
* failure that looks like features going missing and points at nothing. The
|
|
36
|
+
* console line names the value so the fix takes a search rather than a bisect.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** The two profiles. There is no third, and no alias of either. */
|
|
40
|
+
export type UiProfile = 'standard' | 'simple';
|
|
41
|
+
|
|
42
|
+
/** Both of them, as data — so a doc, a test and a resolver cannot disagree
|
|
43
|
+
* about how many there are. */
|
|
44
|
+
export const UI_PROFILES: readonly UiProfile[] = ['standard', 'simple'] as const;
|
|
45
|
+
|
|
46
|
+
/** The one this package draws when nobody says otherwise. */
|
|
47
|
+
export const DEFAULT_UI_PROFILE: UiProfile = 'standard';
|
|
48
|
+
|
|
49
|
+
/* One line per distinct offending value, not one per render: this is read
|
|
50
|
+
* inside a `computed` that re-runs whenever the config object changes. */
|
|
51
|
+
const warned = new Set<string>();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The profile to draw for whatever the host passed.
|
|
55
|
+
*
|
|
56
|
+
* `undefined` / `null` / `''` are "nothing was passed" and resolve to the
|
|
57
|
+
* default in silence. Any OTHER unrecognised value is a mistake worth a line
|
|
58
|
+
* in the console, because it means somebody asked for something by name and
|
|
59
|
+
* got something else.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveUiProfile(value: unknown): UiProfile {
|
|
62
|
+
if (value === 'simple') return 'simple';
|
|
63
|
+
if (value === 'standard' || value === undefined || value === null || value === '') {
|
|
64
|
+
return DEFAULT_UI_PROFILE;
|
|
65
|
+
}
|
|
66
|
+
const seen = typeof value === 'string' ? value : String(value);
|
|
67
|
+
if (!warned.has(seen) && typeof console !== 'undefined') {
|
|
68
|
+
warned.add(seen);
|
|
69
|
+
console.warn(
|
|
70
|
+
`[filex] unknown uiProfile ${JSON.stringify(seen)} — using "${DEFAULT_UI_PROFILE}". ` +
|
|
71
|
+
`Valid values: ${UI_PROFILES.map((p) => `"${p}"`).join(', ')}. ` +
|
|
72
|
+
`(The former "drive" profile was removed; pass "simple" for the reduced explorer.)`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return DEFAULT_UI_PROFILE;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Test seam — the console line is once per value per page, so a suite that
|
|
79
|
+
* asserts on it has to be able to start over. Not used by the app. */
|
|
80
|
+
export function __resetUiProfileWarnings(): void {
|
|
81
|
+
warned.clear();
|
|
82
|
+
}
|