@brftech/filex-core 0.41.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 (40) hide show
  1. package/dist/{ArchiveViewer-BNqukFg8.js → ArchiveViewer-DxxjhYZD.js} +2 -2
  2. package/dist/{ArchiveViewer-BNqukFg8.js.map → ArchiveViewer-DxxjhYZD.js.map} +1 -1
  3. package/dist/{CsvViewer-ii_-MgmG.js → CsvViewer-CyuwENI_.js} +2 -2
  4. package/dist/{CsvViewer-ii_-MgmG.js.map → CsvViewer-CyuwENI_.js.map} +1 -1
  5. package/dist/{DrawioViewer-B2tuu4rX.js → DrawioViewer-BIYsTX97.js} +2 -2
  6. package/dist/{DrawioViewer-B2tuu4rX.js.map → DrawioViewer-BIYsTX97.js.map} +1 -1
  7. package/dist/{EpubViewer-p4B8iiWb.js → EpubViewer-K9PjCMmS.js} +2 -2
  8. package/dist/{EpubViewer-p4B8iiWb.js.map → EpubViewer-K9PjCMmS.js.map} +1 -1
  9. package/dist/{IpynbViewer-D_qYRJhu.js → IpynbViewer-DYU6Zgz2.js} +2 -2
  10. package/dist/{IpynbViewer-D_qYRJhu.js.map → IpynbViewer-DYU6Zgz2.js.map} +1 -1
  11. package/dist/{MermaidViewer-3ZkfNM8v.js → MermaidViewer-Bp73aId8.js} +2 -2
  12. package/dist/{MermaidViewer-3ZkfNM8v.js.map → MermaidViewer-Bp73aId8.js.map} +1 -1
  13. package/dist/{PsdViewer-CuV-OPSd.js → PsdViewer-C2eLivw1.js} +2 -2
  14. package/dist/{PsdViewer-CuV-OPSd.js.map → PsdViewer-C2eLivw1.js.map} +1 -1
  15. package/dist/{TiffViewer-BLeoF4Be.js → TiffViewer-DsHg5gcz.js} +2 -2
  16. package/dist/{TiffViewer-BLeoF4Be.js.map → TiffViewer-DsHg5gcz.js.map} +1 -1
  17. package/dist/{Viewer3D-DHhhU76E.js → Viewer3D-C_dc2HN9.js} +2 -2
  18. package/dist/{Viewer3D-DHhhU76E.js.map → Viewer3D-C_dc2HN9.js.map} +1 -1
  19. package/dist/filex-core.js +1 -1
  20. package/dist/filex-core.umd.cjs +45 -45
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/{index-BraG7Cz4.js → index-vxMZpYb-.js} +10094 -10058
  23. package/dist/index-vxMZpYb-.js.map +1 -0
  24. package/dist/index.d.ts +3 -38
  25. package/package.json +1 -1
  26. package/src/components/FilePane.vue +14 -2
  27. package/src/components/GalleryView.vue +10 -26
  28. package/src/components/GridView.vue +10 -25
  29. package/src/components/ListView.vue +17 -29
  30. package/src/components/TimeZoneDialog.vue +18 -10
  31. package/src/components/TokensPanel.vue +20 -11
  32. package/src/composables/useExplorerTimeZone.ts +7 -3
  33. package/src/composables/useLocale.ts +31 -15
  34. package/src/composables/useRowTouch.ts +95 -0
  35. package/src/lib/shareTtl.ts +21 -2
  36. package/src/lib/timezone.ts +46 -1
  37. package/src/locales/en.ts +2 -0
  38. package/src/locales/tr.ts +2 -0
  39. package/src/modals/PermissionsModal.vue +16 -5
  40. package/dist/index-BraG7Cz4.js.map +0 -1
package/dist/index.d.ts CHANGED
@@ -4614,7 +4614,9 @@ export declare function formatByteSize(bytes: number | null | undefined, opts?:
4614
4614
  * chosen clock, together, in one call. Every date on every surface goes
4615
4615
  * through here — that is the whole point of it being here.
4616
4616
  */
4617
- export declare function formatInstant(d: Date, code: LocaleCode | string | undefined, opts: Intl.DateTimeFormatOptions): string;
4617
+ export declare function formatInstant(d: Date, code: LocaleCode | string | undefined, opts: Intl.DateTimeFormatOptions,
4618
+ /** The explorer whose clock applies (EXPLORER_CLOCK); omitted = page-wide. */
4619
+ owner?: symbol): string;
4618
4620
 
4619
4621
  /**
4620
4622
  * Format 20 raw bytes as the string the user writes down:
@@ -6373,43 +6375,6 @@ export declare type TimeZoneSources = Partial<Record<Exclude<TimeZoneTier, 'devi
6373
6375
  /** What each tier currently holds (`''` = nothing). Reactive. */
6374
6376
  export declare function timeZoneSources(): Required<TimeZoneSources>;
6375
6377
 
6376
- /**
6377
- * timezone.ts — whose clock an instant is read on.
6378
- *
6379
- * A timestamp is an instant. `last_modified` is the same number for everybody
6380
- * on the planet; what differs is the clock it is read against. The owner's
6381
- * rule, verbatim: *"ben 03'te video yükledim, GMT 0 eleman o videonun yüklenme
6382
- * saatini kendi zaman diliminde görecek."* So the instant stays untouched on
6383
- * the wire and the READER picks the clock.
6384
- *
6385
- * ⚠⚠ WHICH reader is a question with several answers, and this module is the
6386
- * ONE place they are ranked. Before it, the admin web app followed the account
6387
- * and an embedded `<filex-explorer>` on another origin followed the browser —
6388
- * a person who chose Tokyo saw Tokyo in one and their laptop's zone in the
6389
- * other, for the same file. That disagreement is the bug this exists to end,
6390
- * so every surface resolves through `resolveTimeZone()` and nothing else
6391
- * decides.
6392
- *
6393
- * The owner's ruling (2026-09-14): three tiers — the embedder, then the
6394
- * account behind the key when it IS a person's key, then the browser — plus a
6395
- * setting inside the embed, kept in the browser, because an embed has no
6396
- * user-settings dialog. Where that setting ranks is `TIME_ZONE_TIERS` below,
6397
- * and the reasoning is on it.
6398
- *
6399
- * Scope: page-level, deliberately. The clock belongs to the person looking at
6400
- * the page, and there is one of them. Host and account zones are REGISTERED by
6401
- * whoever knows them (an explorer instance, the web app's auth store) under an
6402
- * owner key, so an explorer that unmounts takes its zones with it instead of
6403
- * leaving them behind for the next one. Two explorers on one page that
6404
- * disagree about a host zone, or hold different people's tokens, resolve to
6405
- * the most recently written value for both — one viewer, one clock.
6406
- *
6407
- * The same-tab `filex:timezone` event survives from the previous design and for
6408
- * the same reason: the admin app and a `<filex-explorer>` web component on one
6409
- * page can be two different BUNDLES, i.e. two copies of this module, and a ref
6410
- * in one says nothing to the other. The event crosses that gap for the one tier
6411
- * that is shared state by nature — the browser's.
6412
- */
6413
6378
  /**
6414
6379
  * The places a zone can come from.
6415
6380
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brftech/filex-core",
3
- "version": "0.41.0",
3
+ "version": "0.41.1",
4
4
  "description": "filex core — Vue 3 source of truth for the filex file manager (FileExplorer + ConnectionsPanel SFCs, composables, types)",
5
5
  "type": "module",
6
6
  "main": "./dist/filex-core.umd.cjs",
@@ -236,7 +236,7 @@ const emit = defineEmits<{
236
236
  /** The virtual `.trash` row was opened — the trash view carries the restore
237
237
  * actions and belongs to the main pane, so the host decides. */
238
238
  (e: 'open-trash'): void;
239
- (e: 'click-row', node: FileNode, mod: { ctrl: boolean; shift: boolean }): void;
239
+ (e: 'click-row', node: FileNode, mod: { ctrl: boolean; shift: boolean; touch?: boolean }): void;
240
240
  /** Right-click. `null` = empty space (nothing selected → "Paste" only). */
241
241
  (e: 'context', node: FileNode | null, ev: MouseEvent): void;
242
242
  (e: 'clear-selection'): void;
@@ -443,7 +443,19 @@ const paneSubfolders = computed(() =>
443
443
  * `useSelection` instance for this pane answers. One set of Ctrl/Shift
444
444
  * semantics for both panes — the old right pane had a simplified copy in
445
445
  * which Shift behaved as Ctrl. */
446
- function onViewClick(n: FileNode, mod: { ctrl: boolean; shift: boolean }) {
446
+ function onViewClick(n: FileNode, mod: { ctrl: boolean; shift: boolean; touch?: boolean }) {
447
+ /* issue #26 — a finger has no double-click, so a TAP is the open gesture:
448
+ * with nothing selected it opens what it lands on, exactly as a double-click
449
+ * would. Once something is selected (a long press selects, via its menu) a
450
+ * tap adds to or removes from the selection, so picking several files still
451
+ * works. A mouse keeps click-to-select; see composables/useRowTouch. */
452
+ if (mod.touch && !mod.ctrl && !mod.shift) {
453
+ if (props.selected.size === 0) {
454
+ onRowOpen(n);
455
+ return;
456
+ }
457
+ mod = { ...mod, ctrl: true };
458
+ }
447
459
  emit('activate');
448
460
  emit('click-row', n, mod);
449
461
  }
@@ -13,6 +13,7 @@ import { hasInternalDrag } from '../lib/dragOut';
13
13
  import type { FileNode } from '../types/FileNode';
14
14
  import type { LocaleCode } from '../types/ExplorerConfig';
15
15
  import { useLocale } from '../composables/useLocale';
16
+ import { useRowTouch } from '../composables/useRowTouch';
16
17
  import { encryptedFolderTile, fileIconTile, isEncryptedFolder } from '../lib/fileIcons';
17
18
  import {
18
19
  createFilePreviews,
@@ -76,7 +77,7 @@ const props = defineProps<{
76
77
  }>();
77
78
 
78
79
  const emit = defineEmits<{
79
- (e: 'click-card', node: FileNode, mod: { ctrl: boolean; shift: boolean }): void;
80
+ (e: 'click-card', node: FileNode, mod: { ctrl: boolean; shift: boolean; touch?: boolean }): void;
80
81
  (e: 'dbl-card', node: FileNode): void;
81
82
  (e: 'context-card', node: FileNode, ev: MouseEvent): void;
82
83
  (e: 'item-drag-start', node: FileNode, ev: DragEvent): void;
@@ -158,7 +159,7 @@ function isSelected(n: FileNode): boolean {
158
159
  }
159
160
 
160
161
  function onClick(n: FileNode, ev: MouseEvent) {
161
- emit('click-card', n, { ctrl: ev.ctrlKey || ev.metaKey, shift: ev.shiftKey });
162
+ emit('click-card', n, { ctrl: ev.ctrlKey || ev.metaKey, shift: ev.shiftKey, touch: touch.isTap(ev) });
162
163
  }
163
164
 
164
165
  function onDbl(n: FileNode) {
@@ -205,27 +206,10 @@ function onItemDrop(n: FileNode, ev: DragEvent) {
205
206
  emit('item-drop-into', n, ev);
206
207
  }
207
208
 
208
- // Long-press → context menu, same as GridView (touch parity).
209
- let pressTimer: ReturnType<typeof setTimeout> | undefined;
210
- let pressTarget: FileNode | null = null;
211
- function onTouchStart(n: FileNode, ev: TouchEvent) {
212
- pressTarget = n;
213
- if (pressTimer) clearTimeout(pressTimer);
214
- pressTimer = setTimeout(() => {
215
- if (pressTarget) {
216
- const t0 = ev.touches[0];
217
- emit('context-card', pressTarget, {
218
- clientX: t0.clientX,
219
- clientY: t0.clientY,
220
- preventDefault: () => {},
221
- } as unknown as MouseEvent);
222
- }
223
- }, 500);
224
- }
225
- function cancelPress() {
226
- if (pressTimer) clearTimeout(pressTimer);
227
- pressTarget = null;
228
- }
209
+ /* Long press → the card's menu; a tap is reported as a tap (issue #26). */
210
+ const touch = useRowTouch<FileNode>((n, at) =>
211
+ emit('context-card', n, { ...at, preventDefault: () => {}, stopPropagation: () => {} } as unknown as MouseEvent),
212
+ );
229
213
 
230
214
  /**
231
215
  * zaman:z1 — routed through useLocale.formatDate, the package's one date
@@ -283,9 +267,9 @@ function metaFor(n: FileNode): string {
283
267
  @dragover="onItemDragOver(n, $event)"
284
268
  @dragleave="onItemDragLeave(n)"
285
269
  @drop="onItemDrop(n, $event)"
286
- @touchstart.passive="onTouchStart(n, $event)"
287
- @touchend="cancelPress"
288
- @touchmove="cancelPress"
270
+ @touchstart.passive="touch.onTouchStart(n, $event)"
271
+ @touchend.passive="touch.onTouchEnd"
272
+ @touchmove.passive="touch.onTouchMove"
289
273
  >
290
274
  <div class="fe-gal__thumb" :ref="(el) => bindPreview(el, n)">
291
275
  <!-- gorunum:v1-preview — a text-readable file shows its own first
@@ -20,6 +20,7 @@ import type { FileNode } from '../types/FileNode';
20
20
  import { hasInternalDrag } from '../lib/dragOut';
21
21
  import type { LocaleCode } from '../types/ExplorerConfig';
22
22
  import { useLocale } from '../composables/useLocale';
23
+ import { useRowTouch } from '../composables/useRowTouch';
23
24
  import { encryptedFolderTile, fileIconTile, isEncryptedFolder } from '../lib/fileIcons';
24
25
  import {
25
26
  createFilePreviews,
@@ -110,7 +111,7 @@ const props = defineProps<{
110
111
  }>();
111
112
 
112
113
  const emit = defineEmits<{
113
- (e: 'click-card', node: FileNode, mod: { ctrl: boolean; shift: boolean }): void;
114
+ (e: 'click-card', node: FileNode, mod: { ctrl: boolean; shift: boolean; touch?: boolean }): void;
114
115
  (e: 'dbl-card', node: FileNode): void;
115
116
  (e: 'context-card', node: FileNode, ev: MouseEvent): void;
116
117
  (e: 'item-drag-start', node: FileNode, ev: DragEvent): void;
@@ -267,7 +268,7 @@ function isSelected(n: FileNode): boolean {
267
268
  }
268
269
 
269
270
  function onClick(n: FileNode, ev: MouseEvent) {
270
- emit('click-card', n, { ctrl: ev.ctrlKey || ev.metaKey, shift: ev.shiftKey });
271
+ emit('click-card', n, { ctrl: ev.ctrlKey || ev.metaKey, shift: ev.shiftKey, touch: touch.isTap(ev) });
271
272
  }
272
273
 
273
274
  function onDbl(n: FileNode) {
@@ -327,26 +328,10 @@ function onItemDrop(n: FileNode, ev: DragEvent) {
327
328
  emit('item-drop-into', n, ev);
328
329
  }
329
330
 
330
- let pressTimer: ReturnType<typeof setTimeout> | undefined;
331
- let pressTarget: FileNode | null = null;
332
- function onTouchStart(n: FileNode, ev: TouchEvent) {
333
- pressTarget = n;
334
- if (pressTimer) clearTimeout(pressTimer);
335
- pressTimer = setTimeout(() => {
336
- if (pressTarget) {
337
- const t0 = ev.touches[0];
338
- emit('context-card', pressTarget, {
339
- clientX: t0.clientX,
340
- clientY: t0.clientY,
341
- preventDefault: () => {},
342
- } as unknown as MouseEvent);
343
- }
344
- }, 500);
345
- }
346
- function cancelPress() {
347
- if (pressTimer) clearTimeout(pressTimer);
348
- pressTarget = null;
349
- }
331
+ /* Long press the card's menu; a tap is reported as a tap (issue #26). */
332
+ const touch = useRowTouch<FileNode>((n, at) =>
333
+ emit('context-card', n, { ...at, preventDefault: () => {}, stopPropagation: () => {} } as unknown as MouseEvent),
334
+ );
350
335
 
351
336
  /**
352
337
  * The card's second line. A file prints "1.7 MB • Sep 9, 2026"; a folder
@@ -432,9 +417,9 @@ function snippetTitle(snippet: string): string {
432
417
  @dragover="onItemDragOver(n, $event)"
433
418
  @dragleave="onItemDragLeave(n) /* wiring:c4 */"
434
419
  @drop="onItemDrop(n, $event)"
435
- @touchstart.passive="onTouchStart(n, $event)"
436
- @touchend="cancelPress"
437
- @touchmove="cancelPress"
420
+ @touchstart.passive="touch.onTouchStart(n, $event)"
421
+ @touchend.passive="touch.onTouchEnd"
422
+ @touchmove.passive="touch.onTouchMove"
438
423
  >
439
424
  <!-- gorunum:v1 — the preview, files only: 184×108. A thumbnail when
440
425
  there is one, otherwise the type tile centred on --fe-bg-elev.
@@ -11,6 +11,7 @@ import { hasInternalDrag } from '../lib/dragOut';
11
11
  import type { FileNode } from '../types/FileNode';
12
12
  import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
13
13
  import { useLocale } from '../composables/useLocale';
14
+ import { useRowTouch } from '../composables/useRowTouch';
14
15
  import {
15
16
  arrivedFromOutside,
16
17
  ownedByViewer,
@@ -138,7 +139,7 @@ const props = defineProps<{
138
139
  }>();
139
140
 
140
141
  const emit = defineEmits<{
141
- (e: 'click-row', node: FileNode, mod: { ctrl: boolean; shift: boolean }): void;
142
+ (e: 'click-row', node: FileNode, mod: { ctrl: boolean; shift: boolean; touch?: boolean }): void;
142
143
  (e: 'dbl-row', node: FileNode): void;
143
144
  (e: 'context-row', node: FileNode, ev: MouseEvent): void;
144
145
  (e: 'item-drag-start', node: FileNode, ev: DragEvent): void;
@@ -173,7 +174,7 @@ function isSelected(n: FileNode): boolean {
173
174
  }
174
175
 
175
176
  function onRowClick(n: FileNode, ev: MouseEvent) {
176
- emit('click-row', n, { ctrl: ev.ctrlKey || ev.metaKey, shift: ev.shiftKey });
177
+ emit('click-row', n, { ctrl: ev.ctrlKey || ev.metaKey, shift: ev.shiftKey, touch: touch.isTap(ev) });
177
178
  }
178
179
 
179
180
  function onRowDbl(n: FileNode) {
@@ -264,28 +265,10 @@ function onItemDrop(n: FileNode, ev: DragEvent) {
264
265
  emit('item-drop-into', n, ev);
265
266
  }
266
267
 
267
- let pressTimer: ReturnType<typeof setTimeout> | undefined;
268
- let pressTarget: FileNode | null = null;
269
-
270
- function onTouchStart(n: FileNode, ev: TouchEvent) {
271
- pressTarget = n;
272
- if (pressTimer) clearTimeout(pressTimer);
273
- pressTimer = setTimeout(() => {
274
- if (pressTarget) {
275
- const t0 = ev.touches[0];
276
- emit('context-row', pressTarget, {
277
- clientX: t0.clientX,
278
- clientY: t0.clientY,
279
- preventDefault: () => {},
280
- } as unknown as MouseEvent);
281
- }
282
- }, 500);
283
- }
284
-
285
- function cancelPress() {
286
- if (pressTimer) clearTimeout(pressTimer);
287
- pressTarget = null;
288
- }
268
+ /* Long press the row's menu; a tap is reported as a tap (issue #26). */
269
+ const touch = useRowTouch<FileNode>((n, at) =>
270
+ emit('context-row', n, { ...at, preventDefault: () => {}, stopPropagation: () => {} } as unknown as MouseEvent),
271
+ );
289
272
 
290
273
  function keepGlyph(b: 'kept' | 'syncing' | 'cloud' | 'partial'): string {
291
274
  if (b === 'kept') return '\u2713';
@@ -359,7 +342,12 @@ function thumbOf(n: FileNode): string | null {
359
342
  return props.thumbSrc ? props.thumbSrc(n) : (n.thumb_url ?? null);
360
343
  }
361
344
 
362
- /** The muted "Folder" said beside a directory's name not on the sentinels. */
345
+ /** Whether the Type column is on screen. While it is, it already says "Folder"
346
+ * in its own cell, and the word beside the name printed it twice per row. */
347
+ const typeColumnShown = computed(() => visibleCols.value.includes('type'));
348
+
349
+ /** The muted "Folder" said beside a directory's name — not on the sentinels,
350
+ * and only while no Type column says it (see typeColumnShown). */
363
351
  function isPlainDir(n: FileNode): boolean {
364
352
  return n.type === 'dir' && !isPinnedSpecial(n);
365
353
  }
@@ -1237,9 +1225,9 @@ const segments = computed<DateRun<FileNode>[]>(() =>
1237
1225
  @dragover="onItemDragOver(n, $event)"
1238
1226
  @dragleave="onItemDragLeave(n) /* wiring:c4 */"
1239
1227
  @drop="onItemDrop(n, $event)"
1240
- @touchstart.passive="onTouchStart(n, $event)"
1241
- @touchend="cancelPress"
1242
- @touchmove="cancelPress"
1228
+ @touchstart.passive="touch.onTouchStart(n, $event)"
1229
+ @touchend.passive="touch.onTouchEnd"
1230
+ @touchmove.passive="touch.onTouchMove"
1243
1231
  >
1244
1232
  <!-- gorunum:v1 — the tick drives the SAME selection the row click
1245
1233
  does (onCheckClick → click-row → useSelection.click), so shift
@@ -1324,7 +1312,7 @@ const segments = computed<DateRun<FileNode>[]>(() =>
1324
1312
  `.fe-list__name`: the shot scripts read that element's
1325
1313
  textContent as the filename (e2e/shots/starstags.mjs), and a
1326
1314
  row whose name reads "Reports Folder" breaks them silently. -->
1327
- <span v-if="isPlainDir(n)" class="fe-list__kind">{{ t('node.folder') }}</span>
1315
+ <span v-if="isPlainDir(n) && !typeColumnShown" class="fe-list__kind">{{ t('node.folder') }}</span>
1328
1316
  </div>
1329
1317
  <!-- tablo:t1 — one loop over the SAME list the header draws, so a
1330
1318
  column cannot exist in one and not the other, and the values land
@@ -17,15 +17,16 @@
17
17
  * device's — by asking the resolver with the viewer tier blanked. A reset
18
18
  * whose outcome you have to guess is not a reset.
19
19
  */
20
- import { computed, onBeforeUnmount, ref, watch } from 'vue';
20
+ import { computed, inject, onBeforeUnmount, ref, watch } from 'vue';
21
21
  import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
22
22
  import { formatInstant, useLocale } from '../composables/useLocale';
23
23
  import {
24
+ EXPLORER_CLOCK,
24
25
  deviceTimeZone,
25
26
  resolveTimeZone,
26
- resolvedTimeZone,
27
+ resolvedTimeZoneFor,
27
28
  setViewerTimeZone,
28
- timeZoneSources,
29
+ timeZoneSourcesFor,
29
30
  viewerTimeZone,
30
31
  } from '../lib/timezone';
31
32
  import Modal from '../modals/Modal.vue';
@@ -43,8 +44,10 @@ const { t } = useLocale(() => props.locale);
43
44
 
44
45
  const inputId = `fe-tzdialog-${Math.random().toString(36).slice(2, 9)}`;
45
46
 
46
- const fallback = computed(() => resolveTimeZone({ ...timeZoneSources(), viewer: '' }));
47
- const inForce = computed(() => resolvedTimeZone());
47
+ // This explorer's clock, not the page's newest (lib/timezone, timeZoneSourcesFor).
48
+ const clock = inject(EXPLORER_CLOCK, undefined);
49
+ const fallback = computed(() => resolveTimeZone({ ...timeZoneSourcesFor(clock), viewer: '' }));
50
+ const inForce = computed(() => resolvedTimeZoneFor(clock));
48
51
  const inForceZone = computed(() => inForce.value.zone ?? deviceTimeZone());
49
52
 
50
53
  /** "Right now in Asia/Tokyo: 14:05:09" — the fastest way to see a wrong pick. */
@@ -54,11 +57,16 @@ function refresh(): void {
54
57
  // Through the shared formatter, which reads the RESOLVED zone — the readout
55
58
  // must say what every date on the page is actually drawn in, not what this
56
59
  // dialog thinks it chose.
57
- now.value = formatInstant(new Date(), props.locale, {
58
- hour: '2-digit',
59
- minute: '2-digit',
60
- second: '2-digit',
61
- });
60
+ now.value = formatInstant(
61
+ new Date(),
62
+ props.locale,
63
+ {
64
+ hour: '2-digit',
65
+ minute: '2-digit',
66
+ second: '2-digit',
67
+ },
68
+ clock,
69
+ );
62
70
  }
63
71
  watch(
64
72
  () => props.open,
@@ -101,19 +101,28 @@ onMounted(async () => {
101
101
  emit('active', { hasToken: tokens.value.length > 0 });
102
102
  });
103
103
 
104
+ /**
105
+ * The name a token gets when the field is left empty — one the user will
106
+ * recognise in the list later.
107
+ *
108
+ * A protocol guide knows both halves: `WebDAV — files.example.com`, the host
109
+ * being the PUBLIC address the guide resolved (connectionsOrigin). The API
110
+ * keys page knows neither, and it used to make a host up from `apiBase`,
111
+ * which is whatever the page happens to talk to: every key on a local
112
+ * instance was called `127.0.0.1:5297`, and that address was printed as the
113
+ * placeholder in the README screenshots. It names the key by the day it was
114
+ * made instead.
115
+ */
104
116
  function defaultLabel(): string {
105
117
  const p = (props.protocol || '').toUpperCase();
106
- return p ? `${p} — ${hostLabel()}` : hostLabel();
118
+ if (p) return props.host ? `${p} — ${props.host}` : p;
119
+ return t('conn.tokens.defaultName', { date: new Date().toISOString().slice(0, 10) });
107
120
  }
108
121
 
109
- /** A name the user will recognise in the list later. */
110
- function hostLabel(): string {
111
- if (props.host) return props.host;
112
- try {
113
- return new URL(props.config.apiBase || window.location.origin).host;
114
- } catch {
115
- return 'filex';
116
- }
122
+ /** What the empty name field suggests: the saved default on a protocol guide,
123
+ * an example of a useful name on the API keys page. */
124
+ function labelPlaceholder(): string {
125
+ return props.protocol ? defaultLabel() : t('conn.tokens.namePlaceholder');
117
126
  }
118
127
 
119
128
  async function mint(): Promise<void> {
@@ -206,7 +215,7 @@ function usedLabel(row: ApiToken): string {
206
215
  <input
207
216
  v-model="label"
208
217
  class="fe-cfield__input"
209
- :placeholder="defaultLabel()"
218
+ :placeholder="labelPlaceholder()"
210
219
  data-testid="token-label"
211
220
  />
212
221
  <button class="fe-s3keys__btn" :disabled="busy" data-testid="token-mint" @click="mint">
@@ -220,7 +229,7 @@ function usedLabel(row: ApiToken): string {
220
229
  <input
221
230
  v-model="label"
222
231
  class="fe-cfield__input"
223
- :placeholder="defaultLabel()"
232
+ :placeholder="labelPlaceholder()"
224
233
  data-testid="token-label"
225
234
  />
226
235
 
@@ -32,10 +32,10 @@
32
32
  * the browser's zone answers. A clock must never throw.
33
33
  */
34
34
 
35
- import { onBeforeUnmount, watch, type Ref } from 'vue';
35
+ import { onBeforeUnmount, provide, watch, type Ref } from 'vue';
36
36
  import type { ExplorerConfig } from '../types/ExplorerConfig';
37
37
  import type { Capabilities } from '../types/FileNode';
38
- import { releaseTimeZoneOwner, setAccountTimeZone, setHostTimeZone } from '../lib/timezone';
38
+ import { EXPLORER_CLOCK, releaseTimeZoneOwner, setAccountTimeZone, setHostTimeZone } from '../lib/timezone';
39
39
 
40
40
  export interface ExplorerTimeZoneDeps {
41
41
  config: () => ExplorerConfig;
@@ -45,8 +45,11 @@ export interface ExplorerTimeZoneDeps {
45
45
  fetchMe: () => Promise<{ user?: { timezone?: unknown } | null } | undefined>;
46
46
  }
47
47
 
48
- export function useExplorerTimeZone(deps: ExplorerTimeZoneDeps): void {
48
+ export function useExplorerTimeZone(deps: ExplorerTimeZoneDeps): symbol {
49
49
  const owner = Symbol('filex-explorer');
50
+ // Every component under this explorer reads its dates on this owner's
51
+ // tiers, not the page's newest (lib/timezone, timeZoneSourcesFor).
52
+ provide(EXPLORER_CLOCK, owner);
50
53
 
51
54
  watch(
52
55
  () => deps.config().timeZone,
@@ -82,4 +85,5 @@ export function useExplorerTimeZone(deps: ExplorerTimeZoneDeps): void {
82
85
  );
83
86
 
84
87
  onBeforeUnmount(() => releaseTimeZoneOwner(owner));
88
+ return owner;
85
89
  }
@@ -25,10 +25,10 @@
25
25
  * printing the viewer's chosen one.
26
26
  */
27
27
 
28
- import { computed, type Ref } from 'vue';
28
+ import { computed, getCurrentInstance, inject, type Ref } from 'vue';
29
29
  import type { LocaleCode } from '../types/ExplorerConfig';
30
30
  import { messages } from '../locales';
31
- import { activeTimeZone, deviceTimeZone } from '../lib/timezone';
31
+ import { EXPLORER_CLOCK, activeTimeZoneFor, deviceTimeZone } from '../lib/timezone';
32
32
 
33
33
  /* ── the tag ──────────────────────────────────────────────────────────── */
34
34
 
@@ -181,8 +181,12 @@ const dtfCache = new Map<string, Intl.DateTimeFormat>();
181
181
  * was opened in. Reading `activeTimeZone()` on every call is also what keeps
182
182
  * a template that formats a date reactive to the preference changing.
183
183
  */
184
- function zonedFormatter(tag: string, opts: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
185
- const zone = activeTimeZone();
184
+ function zonedFormatter(
185
+ tag: string,
186
+ opts: Intl.DateTimeFormatOptions,
187
+ owner?: symbol,
188
+ ): Intl.DateTimeFormat {
189
+ const zone = activeTimeZoneFor(owner);
186
190
  const key = `${tag}|${zone ?? `device:${deviceTimeZone()}`}|${JSON.stringify(opts)}`;
187
191
  let fmt = dtfCache.get(key);
188
192
  if (!fmt) {
@@ -201,9 +205,11 @@ export function formatInstant(
201
205
  d: Date,
202
206
  code: LocaleCode | string | undefined,
203
207
  opts: Intl.DateTimeFormatOptions,
208
+ /** The explorer whose clock applies (EXPLORER_CLOCK); omitted = page-wide. */
209
+ owner?: symbol,
204
210
  ): string {
205
211
  try {
206
- return zonedFormatter(localeTag(code), opts).format(d);
212
+ return zonedFormatter(localeTag(code), opts, owner).format(d);
207
213
  } catch {
208
214
  return d.toISOString();
209
215
  }
@@ -260,6 +266,11 @@ export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
260
266
 
261
267
  const lookup = computed(() => messages[code()] ?? messages.en);
262
268
 
269
+ // The explorer this component sits in, whose clock its dates are read on
270
+ // (lib/timezone, timeZoneSourcesFor). Outside a component, or outside any
271
+ // explorer, the page-wide answer.
272
+ const clock = getCurrentInstance() ? inject(EXPLORER_CLOCK, undefined) : undefined;
273
+
263
274
  function t(key: string, vars: Record<string, string | number> = {}): string {
264
275
  const table = lookup.value;
265
276
  const raw = table[countedKey(key, vars, (k) => k in table)] ?? key;
@@ -319,13 +330,18 @@ export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
319
330
  function formatDate(ms: number | undefined | null, opts: { time?: boolean } = {}): string {
320
331
  const d = toDate(ms);
321
332
  if (!d) return '';
322
- const date = formatInstant(d, code(), {
323
- month: 'short',
324
- day: 'numeric',
325
- year: 'numeric',
326
- });
333
+ const date = formatInstant(
334
+ d,
335
+ code(),
336
+ {
337
+ month: 'short',
338
+ day: 'numeric',
339
+ year: 'numeric',
340
+ },
341
+ clock,
342
+ );
327
343
  if (!opts.time) return date;
328
- return `${date}, ${formatInstant(d, code(), { hour: 'numeric', minute: '2-digit' })}`;
344
+ return `${date}, ${formatInstant(d, code(), { hour: 'numeric', minute: '2-digit' }, clock)}`;
329
345
  }
330
346
 
331
347
  /**
@@ -342,8 +358,8 @@ export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
342
358
  function formatDateFull(ms: number | undefined | null): string {
343
359
  const d = toDate(ms);
344
360
  if (!d) return '';
345
- const zone = activeTimeZone() ?? deviceTimeZone();
346
- const stamp = formatInstant(d, code(), { dateStyle: 'full', timeStyle: 'long' });
361
+ const zone = activeTimeZoneFor(clock) ?? deviceTimeZone();
362
+ const stamp = formatInstant(d, code(), { dateStyle: 'full', timeStyle: 'long' }, clock);
347
363
  return `${stamp} (${zone})`;
348
364
  }
349
365
 
@@ -358,7 +374,7 @@ export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
358
374
  function formatMonthYear(value: number | Date | undefined | null): string {
359
375
  const d = value instanceof Date ? value : toDate(value);
360
376
  if (!d) return '';
361
- return formatInstant(d, code(), { month: 'long', year: 'numeric' });
377
+ return formatInstant(d, code(), { month: 'long', year: 'numeric' }, clock);
362
378
  }
363
379
 
364
380
  /**
@@ -373,7 +389,7 @@ export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
373
389
  const d = value instanceof Date ? value : toDate(value);
374
390
  if (!d) return '';
375
391
  try {
376
- return zonedFormatter('en-CA', { year: 'numeric', month: '2-digit' }).format(d);
392
+ return zonedFormatter('en-CA', { year: 'numeric', month: '2-digit' }, clock).format(d);
377
393
  } catch {
378
394
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
379
395
  }