@brftech/filex-core 0.40.0 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-DxxjhYZD.js +124 -0
  3. package/dist/ArchiveViewer-DxxjhYZD.js.map +1 -0
  4. package/dist/CsvViewer-CyuwENI_.js +141 -0
  5. package/dist/CsvViewer-CyuwENI_.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-BIYsTX97.js} +48 -42
  7. package/dist/DrawioViewer-BIYsTX97.js.map +1 -0
  8. package/dist/EpubViewer-K9PjCMmS.js +163 -0
  9. package/dist/EpubViewer-K9PjCMmS.js.map +1 -0
  10. package/dist/IpynbViewer-DYU6Zgz2.js +184 -0
  11. package/dist/IpynbViewer-DYU6Zgz2.js.map +1 -0
  12. package/dist/MermaidViewer-Bp73aId8.js +137 -0
  13. package/dist/MermaidViewer-Bp73aId8.js.map +1 -0
  14. package/dist/PsdViewer-C2eLivw1.js +122 -0
  15. package/dist/PsdViewer-C2eLivw1.js.map +1 -0
  16. package/dist/TiffViewer-DsHg5gcz.js +142 -0
  17. package/dist/TiffViewer-DsHg5gcz.js.map +1 -0
  18. package/dist/Viewer3D-C_dc2HN9.js +75 -0
  19. package/dist/Viewer3D-C_dc2HN9.js.map +1 -0
  20. package/dist/filex-core.js +218 -20361
  21. package/dist/filex-core.js.map +1 -1
  22. package/dist/filex-core.umd.cjs +61 -62
  23. package/dist/filex-core.umd.cjs.map +1 -1
  24. package/dist/index-vxMZpYb-.js +26700 -0
  25. package/dist/index-vxMZpYb-.js.map +1 -0
  26. package/dist/index.d.ts +4212 -862
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2154 -506
  30. package/src/components/AdvancedSearch.vue +870 -0
  31. package/src/components/Breadcrumb.vue +191 -19
  32. package/src/components/CommandPalette.vue +66 -22
  33. package/src/components/ConnectionsPanel.vue +6 -2
  34. package/src/components/ContextMenu.vue +39 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +951 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +172 -56
  39. package/src/components/GridView.vue +331 -82
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1173 -194
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +26 -1
  46. package/src/components/RecentlyOpened.vue +17 -2
  47. package/src/components/S3KeysPanel.vue +7 -4
  48. package/src/components/SSHKeysPanel.vue +7 -4
  49. package/src/components/ShortcutSettings.vue +6 -2
  50. package/src/components/ShortcutsHelp.vue +4 -1
  51. package/src/components/SideNav.vue +206 -140
  52. package/src/components/StorageFields.vue +19 -1
  53. package/src/components/TabBar.vue +87 -1
  54. package/src/components/TagPicker.vue +77 -6
  55. package/src/components/ThemeGallery.vue +13 -198
  56. package/src/components/ThemePalette.vue +244 -0
  57. package/src/components/TimeZoneDialog.vue +135 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +35 -14
  60. package/src/components/Toolbar.vue +953 -465
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +89 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useLocale.ts +382 -26
  66. package/src/composables/useRowTouch.ts +95 -0
  67. package/src/index.ts +177 -3
  68. package/src/lib/actionIcons.ts +330 -0
  69. package/src/lib/advSearch.ts +162 -0
  70. package/src/lib/dateGroups.ts +197 -0
  71. package/src/lib/destinationTree.ts +235 -0
  72. package/src/lib/downloadSelection.ts +154 -0
  73. package/src/lib/fileFilters.ts +319 -12
  74. package/src/lib/fileIcons.ts +299 -5
  75. package/src/lib/filePreview.ts +605 -0
  76. package/src/lib/listing.ts +72 -0
  77. package/src/lib/shareTtl.ts +31 -2
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +151 -116
  80. package/src/lib/timezone.ts +501 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +416 -26
  84. package/src/locales/tr.ts +409 -26
  85. package/src/modals/ConvertModal.vue +23 -3
  86. package/src/modals/DestinationPickerModal.vue +341 -0
  87. package/src/modals/Modal.vue +18 -4
  88. package/src/modals/NewDocumentModal.vue +525 -0
  89. package/src/modals/NewFolderModal.vue +15 -9
  90. package/src/modals/PermissionsModal.vue +557 -476
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5409 -8
  93. package/src/styles/variables.css +139 -65
  94. package/src/types/Connections.ts +9 -0
  95. package/src/types/ExplorerConfig.ts +144 -39
  96. package/src/types/FileNode.ts +28 -0
  97. package/src/viewers/ArchiveViewer.vue +46 -8
  98. package/src/viewers/CsvViewer.vue +19 -2
  99. package/src/viewers/DrawioViewer.vue +12 -1
  100. package/src/viewers/EpubViewer.vue +50 -6
  101. package/src/viewers/IpynbViewer.vue +19 -2
  102. package/src/viewers/MermaidViewer.vue +20 -3
  103. package/src/viewers/PdfViewer.vue +24 -4
  104. package/src/viewers/PsdViewer.vue +28 -4
  105. package/src/viewers/TiffViewer.vue +19 -2
  106. package/src/viewers/Viewer3D.vue +36 -2
  107. package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
  108. package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
  109. package/dist/CsvViewer-CqWeV8VO.js +0 -131
  110. package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
  111. package/dist/DrawioViewer-BNALOB04.js.map +0 -1
  112. package/dist/EpubViewer-BPmlImG7.js +0 -145
  113. package/dist/EpubViewer-BPmlImG7.js.map +0 -1
  114. package/dist/IpynbViewer-CDlQmuCV.js +0 -175
  115. package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
  116. package/dist/MermaidViewer-sfZWdR-d.js +0 -127
  117. package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
  118. package/dist/PsdViewer-DwuySZsb.js +0 -112
  119. package/dist/PsdViewer-DwuySZsb.js.map +0 -1
  120. package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
  121. package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
  122. package/dist/Viewer3D-CJJJgkuv.js +0 -60
  123. package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
  124. package/src/components/SecondaryPane.vue +0 -447
  125. package/src/modals/ShareModal.vue +0 -139
@@ -0,0 +1,501 @@
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 InjectionKey, 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
+ /** The newest answer among the owners `pick` accepts. */
316
+ currentWhere(pick: (owner: symbol) => boolean): string {
317
+ const list = entries.value.filter((e) => pick(e.owner));
318
+ return list.length ? list[list.length - 1].zone : '';
319
+ },
320
+ };
321
+ }
322
+
323
+ const hostTier = ownedTier();
324
+ const accountTier = ownedTier();
325
+ /** Owners whose account entry is the localStorage copy, and follows it. */
326
+ const rememberingOwners = new Set<symbol>();
327
+
328
+ const resolved = computed<ResolvedTimeZone>(() =>
329
+ resolveTimeZone({
330
+ viewer: viewerZone.value,
331
+ host: hostTier.current(),
332
+ account: accountTier.current(),
333
+ }),
334
+ );
335
+
336
+ /**
337
+ * The zone to hand `Intl` — the resolved one. Reactive: reading it inside a
338
+ * render or a computed subscribes that surface to every tier.
339
+ *
340
+ * `undefined` is returned for the device rather than the resolved name, so the
341
+ * formatter takes `Intl`'s own live answer (a laptop carried across an ocean
342
+ * keeps telling the truth without anybody re-saving anything).
343
+ */
344
+ export function activeTimeZone(): string | undefined {
345
+ return resolved.value.zone;
346
+ }
347
+
348
+ /** The resolved zone AND the tier it came from. Reactive. */
349
+ export function resolvedTimeZone(): ResolvedTimeZone {
350
+ return resolved.value;
351
+ }
352
+
353
+ /**
354
+ * The owner key an explorer PROVIDES to its components, so the dates they
355
+ * print resolve against that explorer's own tiers (useLocale injects it).
356
+ */
357
+ export const EXPLORER_CLOCK: InjectionKey<symbol> = Symbol('filex-explorer-clock');
358
+
359
+ /**
360
+ * What the tiers hold for ONE explorer.
361
+ *
362
+ * ⚠⚠ The page-wide tiers answer with the NEWEST write, which is right for a
363
+ * surface outside every explorer and wrong inside one: two explorers on one
364
+ * page with different `config.timeZone` both printed the zone of whichever
365
+ * mounted last. The host tier is by definition one explorer's setting, so an
366
+ * explorer reads only its own. Its account tier is its own too; the only other
367
+ * account it may inherit is the one a HOST APP remembered for the person
368
+ * signed in (the admin app's session, `remember: true`) — never a sibling
369
+ * explorer's, which may belong to a different key altogether. The viewer tier
370
+ * is this browser's, shared by everything on the page.
371
+ *
372
+ * `owner` undefined is the page-wide answer, unchanged.
373
+ */
374
+ export function timeZoneSourcesFor(owner: symbol | undefined): Required<TimeZoneSources> {
375
+ if (!owner) return timeZoneSources();
376
+ return {
377
+ viewer: viewerZone.value,
378
+ host: hostTier.of(owner) ?? '',
379
+ account: accountTier.of(owner) ?? accountTier.currentWhere((o) => rememberingOwners.has(o)),
380
+ };
381
+ }
382
+
383
+ /** resolvedTimeZone for ONE explorer (see timeZoneSourcesFor). Reactive. */
384
+ export function resolvedTimeZoneFor(owner: symbol | undefined): ResolvedTimeZone {
385
+ return owner ? resolveTimeZone(timeZoneSourcesFor(owner)) : resolved.value;
386
+ }
387
+
388
+ /** activeTimeZone for ONE explorer (see timeZoneSourcesFor). Reactive. */
389
+ export function activeTimeZoneFor(owner: symbol | undefined): string | undefined {
390
+ return resolvedTimeZoneFor(owner).zone;
391
+ }
392
+
393
+ /** What each tier currently holds (`''` = nothing). Reactive. */
394
+ export function timeZoneSources(): Required<TimeZoneSources> {
395
+ return {
396
+ viewer: viewerZone.value,
397
+ host: hostTier.current(),
398
+ account: accountTier.current(),
399
+ };
400
+ }
401
+
402
+ /** Tier `viewer`, as stored in this browser. `''` = no choice. Reactive. */
403
+ export function viewerTimeZone(): string {
404
+ return viewerZone.value;
405
+ }
406
+
407
+ /** Set (or, with `''`, clear) the viewer's own choice in this browser. */
408
+ export function setViewerTimeZone(tz: string): void {
409
+ const valid = tz && isValidTimeZone(tz) ? tz : '';
410
+ viewerZone.value = valid;
411
+ writeOrClear(TIMEZONE_VIEWER_LS_KEY, valid);
412
+ // A window never receives its own `storage` event, and a second bundle on
413
+ // this page holds a different copy of this module. Say it out loud.
414
+ try {
415
+ window.dispatchEvent(new CustomEvent('filex:timezone', { detail: valid }));
416
+ } catch {
417
+ /* non-DOM environment, or an engine without the CustomEvent constructor */
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Tier `host`: the zone `owner` (an explorer instance) was configured with.
423
+ * `''`/`null`/an engine-rejected id withdraws it — an explorer with no
424
+ * `config.timeZone` has no opinion, and must not hide another one's.
425
+ */
426
+ export function setHostTimeZone(owner: symbol, tz: string | null | undefined): void {
427
+ hostTier.set(owner, tz && isValidTimeZone(tz) ? tz : null);
428
+ }
429
+
430
+ /**
431
+ * Tier `account`: what `owner` learned about the account behind its
432
+ * credential.
433
+ *
434
+ * `''` is an answer — "this person has not chosen a zone" — and is recorded,
435
+ * so it overrides an older answer from the same page. `null`/`undefined` means
436
+ * "I have no account to speak for" (an `app` token, a failed lookup) and
437
+ * withdraws the entry. A zone the engine rejects is recorded as `''`: the
438
+ * account did say something, and it was not usable.
439
+ *
440
+ * `remember` mirrors the value into `TIMEZONE_ACCOUNT_LS_KEY` for the next
441
+ * first paint, and makes this owner follow that key when another tab changes
442
+ * it. For the admin web app, whose session is always a person.
443
+ */
444
+ export function setAccountTimeZone(
445
+ owner: symbol,
446
+ tz: string | null | undefined,
447
+ opts: { remember?: boolean } = {},
448
+ ): void {
449
+ if (tz === null || tz === undefined) {
450
+ accountTier.set(owner, null);
451
+ return;
452
+ }
453
+ const valid = tz && isValidTimeZone(tz) ? tz : '';
454
+ accountTier.set(owner, valid);
455
+ if (opts.remember) {
456
+ rememberingOwners.add(owner);
457
+ writeOrClear(TIMEZONE_ACCOUNT_LS_KEY, valid);
458
+ }
459
+ }
460
+
461
+ /** The account zone `owner` last recorded (`null` = none recorded). Reactive. */
462
+ export function accountTimeZoneOf(owner: symbol): string | null {
463
+ return accountTier.of(owner);
464
+ }
465
+
466
+ /** The account zone remembered in this browser by the last session. */
467
+ export function rememberedAccountTimeZone(): string {
468
+ return hasWindow ? readValid(TIMEZONE_ACCOUNT_LS_KEY) : '';
469
+ }
470
+
471
+ /** Withdraw everything `owner` registered — call it on unmount. */
472
+ export function releaseTimeZoneOwner(owner: symbol): void {
473
+ hostTier.set(owner, null);
474
+ accountTier.set(owner, null);
475
+ rememberingOwners.delete(owner);
476
+ }
477
+
478
+ if (hasWindow) {
479
+ try {
480
+ window.addEventListener('storage', (e) => {
481
+ // `key === null` is a whole-storage clear, which is also news.
482
+ if (e.key === null || e.key === TIMEZONE_VIEWER_LS_KEY) {
483
+ viewerZone.value = readValid(TIMEZONE_VIEWER_LS_KEY);
484
+ }
485
+ if (e.key === null || e.key === TIMEZONE_ACCOUNT_LS_KEY) {
486
+ const v = readValid(TIMEZONE_ACCOUNT_LS_KEY);
487
+ for (const owner of rememberingOwners) accountTier.set(owner, v);
488
+ }
489
+ });
490
+ window.addEventListener('filex:timezone', (e) => {
491
+ // ⚠ From the event's own detail, not re-read from storage: in a private
492
+ // window storage refuses the write, and re-reading would undo — in this
493
+ // very bundle — the choice that was just made.
494
+ const d = (e as CustomEvent).detail;
495
+ viewerZone.value =
496
+ typeof d === 'string' ? (d && isValidTimeZone(d) ? d : '') : readValid(TIMEZONE_VIEWER_LS_KEY);
497
+ });
498
+ } catch {
499
+ /* non-DOM environment */
500
+ }
501
+ }
@@ -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
+ }