@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.
Files changed (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-BNqukFg8.js +124 -0
  3. package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
  4. package/dist/CsvViewer-ii_-MgmG.js +141 -0
  5. package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
  7. package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
  8. package/dist/EpubViewer-p4B8iiWb.js +163 -0
  9. package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
  10. package/dist/IpynbViewer-D_qYRJhu.js +184 -0
  11. package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
  12. package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
  13. package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
  14. package/dist/PsdViewer-CuV-OPSd.js +122 -0
  15. package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
  16. package/dist/TiffViewer-BLeoF4Be.js +142 -0
  17. package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
  18. package/dist/Viewer3D-DHhhU76E.js +75 -0
  19. package/dist/Viewer3D-DHhhU76E.js.map +1 -0
  20. package/dist/filex-core.js +217 -20077
  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-BraG7Cz4.js +26664 -0
  25. package/dist/index-BraG7Cz4.js.map +1 -0
  26. package/dist/index.d.ts +4276 -851
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2170 -492
  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 +57 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +939 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +162 -30
  39. package/src/components/GridView.vue +322 -58
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1158 -167
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +34 -5
  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 +43 -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 +127 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +16 -4
  60. package/src/components/Toolbar.vue +965 -469
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +85 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useKeyboardShortcuts.ts +122 -0
  66. package/src/composables/useLocale.ts +365 -25
  67. package/src/index.ts +182 -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 +11 -1
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +152 -23
  80. package/src/lib/timezone.ts +456 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +432 -25
  84. package/src/locales/tr.ts +425 -25
  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 +543 -473
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5435 -8
  93. package/src/styles/variables.css +140 -60
  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
@@ -30,7 +30,7 @@ const emit = defineEmits<{
30
30
  }>();
31
31
 
32
32
  const locale = computed<LocaleCode>(() => resolveLocale(props.config.locale));
33
- const { t } = useLocale(locale);
33
+ const { t, formatDate } = useLocale(locale);
34
34
 
35
35
  const {
36
36
  exports,
@@ -156,10 +156,13 @@ async function copyLine() {
156
156
  copyTimer = setTimeout(() => (copied.value = false), 1600);
157
157
  }
158
158
 
159
+ /* zaman:z1 — one date formatter for the package. This was a bare
160
+ * `toLocaleDateString()`: the browser's locale and the browser's zone, so a
161
+ * key minted at 23:30 in Istanbul was dated a day early for a viewer reading
162
+ * UTC and came out in the wrong language besides. */
159
163
  function shortDate(v?: string | null): string {
160
- if (!v) return '';
161
- const d = new Date(v);
162
- return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString();
164
+ const ms = new Date(v ?? '').getTime();
165
+ return Number.isNaN(ms) ? '' : formatDate(ms);
163
166
  }
164
167
 
165
168
  function scopeOf(e: NFSExport): string {
@@ -46,59 +46,107 @@ const hintCombos = computed(() => ({
46
46
 
47
47
  // ------------------------------------------------------------------
48
48
  // Step definitions. `target` returns the element to spotlight (null =
49
- // centered card, e.g. the closing shortcuts step). Buttons without a
50
- // stable class are found via their localized title attribute — the
51
- // toolbar gives every icon-only button a title from the same catalogue.
49
+ // centered card, e.g. the closing shortcuts step).
50
+ //
51
+ // gorunum:v3-shell every target below is a `data-testid` or a class the
52
+ // shell owns, resolved from the live DOM. It used to find two of its
53
+ // buttons by matching their localized `title` attribute, which quietly
54
+ // stopped working the moment those verbs moved into a menu: `byTitle(r,
55
+ // t('toolbar.upload'))` matched nothing at 1440px, so the upload step was
56
+ // dropped from the walk and nobody could see that it had been. A step that
57
+ // disappears is indistinguishable from a step that was never written.
58
+ //
59
+ // ⚠ Two steps point at a control the narrow shell draws DIFFERENTLY — the
60
+ // panel (a column at 1440px, a closed drawer behind `toolbar-nav` at
61
+ // 390px) and the create button ("+ New" and its menu, versus the upload
62
+ // FAB). They say a different sentence there, and the sentence is chosen
63
+ // from THE ELEMENT THAT ACTUALLY RESOLVED, never from a width: one branch,
64
+ // no media query to keep in sync with the stylesheet.
52
65
  // ------------------------------------------------------------------
53
66
 
54
- function byTitle(root: HTMLElement, label: string): HTMLElement | null {
55
- const nodes = root.querySelectorAll<HTMLElement>('button[title]');
56
- for (const el of Array.from(nodes)) {
57
- if (el.getAttribute('title') === label) return el;
58
- }
59
- return null;
60
- }
61
-
62
67
  interface TourStep {
63
68
  id: string;
64
69
  titleKey: string;
65
- descKey: string;
70
+ /** A key, or a key chosen from whichever element resolved (see above). */
71
+ descKey: string | ((el: HTMLElement | null) => string);
66
72
  target: (root: HTMLElement) => HTMLElement | null;
67
73
  }
68
74
 
69
75
  const STEPS: TourStep[] = [
70
76
  {
77
+ // The navigation panel: the standard views, then STORAGES, then
78
+ // CONNECTIONS. At 390px it is a drawer that starts closed, so there is
79
+ // nothing to spotlight and the button that opens it is the target.
71
80
  id: 'nav',
72
81
  titleKey: 'tour.step.nav.title',
73
- descKey: 'tour.step.nav.desc',
74
- target: (r) => r.querySelector<HTMLElement>('.fe-breadcrumb'),
82
+ descKey: (el) =>
83
+ el?.dataset.testid === 'sidenav' ? 'tour.step.nav.desc' : 'tour.step.nav.desc_closed',
84
+ target: (r) =>
85
+ r.querySelector<HTMLElement>('[data-testid="sidenav"]') ||
86
+ r.querySelector<HTMLElement>('[data-testid="toolbar-nav"]'),
75
87
  },
76
88
  {
77
- id: 'upload',
78
- titleKey: 'tour.step.upload.title',
79
- descKey: 'tour.step.upload.desc',
89
+ // "+ New" — the menu that replaced the Upload / New folder pair. The
90
+ // narrow shell has no panel on screen and draws the upload FAB instead,
91
+ // which does ONE of the three things the menu offers, so it gets a
92
+ // sentence that promises only that one.
93
+ id: 'new',
94
+ titleKey: 'tour.step.new.title',
95
+ descKey: (el) =>
96
+ el?.dataset.testid === 'sidenav-new' ? 'tour.step.new.desc' : 'tour.step.new.desc_fab',
80
97
  target: (r) =>
81
- byTitle(r, t('toolbar.upload')) || r.querySelector<HTMLElement>('.fe-fab'),
98
+ r.querySelector<HTMLElement>('[data-testid="sidenav-new"]') ||
99
+ r.querySelector<HTMLElement>('.fe-fab'),
82
100
  },
83
101
  {
102
+ // ⚠ The FIELD, not `.fe-search__input`. The wide header's input carries
103
+ // both class names and the narrow one carries only `fe-drivesearch__input`
104
+ // — so the old selector resolved at 1440px and matched nothing at 390px,
105
+ // where the field is the widest thing on the row. The testid is on the
106
+ // wrapper in both layouts, and spotlighting the wrapper also takes in the
107
+ // advanced-search and palette buttons the copy now names.
84
108
  id: 'search',
85
109
  titleKey: 'tour.step.search.title',
86
110
  descKey: 'tour.step.search.desc',
87
- target: (r) =>
88
- r.querySelector<HTMLElement>('.fe-search__input') ||
89
- r.querySelector<HTMLElement>('.fe-toolbar__search-toggle'),
111
+ target: (r) => r.querySelector<HTMLElement>('[data-testid="drive-search"]'),
112
+ },
113
+ {
114
+ // The breadcrumb — this was the old first step's target while its title
115
+ // said "Storages & folders", which is the panel. Now it has its own step
116
+ // and its own title. Absent on Home, which has no address; the step drops
117
+ // itself there rather than pointing at the heading that takes its place.
118
+ id: 'crumb',
119
+ titleKey: 'tour.step.crumb.title',
120
+ descKey: 'tour.step.crumb.desc',
121
+ target: (r) => r.querySelector<HTMLElement>('.fe-breadcrumb'),
122
+ },
123
+ {
124
+ // The filter row. Unconditional in the shell now, but only drawn where
125
+ // there is a listing to filter — not at the multi-storage virtual root,
126
+ // which is where a first run usually starts, so this step is often the
127
+ // one that drops itself.
128
+ id: 'filters',
129
+ titleKey: 'tour.step.filters.title',
130
+ descKey: 'tour.step.filters.desc',
131
+ target: (r) => r.querySelector<HTMLElement>('[data-testid="filterbar"]'),
90
132
  },
91
133
  {
92
134
  id: 'view',
93
135
  titleKey: 'tour.step.view.title',
94
136
  descKey: 'tour.step.view.desc',
137
+ // Still `.fe-toolbar__view`: ViewSwitcher kept its class when it moved
138
+ // from the header to the breadcrumb row.
95
139
  target: (r) => r.querySelector<HTMLElement>('.fe-toolbar__view'),
96
140
  },
97
141
  {
98
- id: 'share',
99
- titleKey: 'tour.step.share.title',
100
- descKey: 'tour.step.share.desc',
101
- target: (r) => r.querySelector<HTMLElement>('.fe__body'),
142
+ // Sharing used to spotlight `.fe__body` — the whole listing, which is to
143
+ // say nothing in particular — while talking about a context menu. The
144
+ // details panel is where a share link is actually minted, so the step
145
+ // points at the control that opens it and names the context menu too.
146
+ id: 'details',
147
+ titleKey: 'tour.step.details.title',
148
+ descKey: 'tour.step.details.desc',
149
+ target: (r) => r.querySelector<HTMLElement>('[data-testid="subhead-inspector"]'),
102
150
  },
103
151
  {
104
152
  id: 'help',
@@ -143,6 +191,24 @@ interface Rect {
143
191
  const spot = ref<Rect | null>(null);
144
192
  const cardStyle = ref<Record<string, string>>({});
145
193
  const cardEl = ref<HTMLElement | null>(null);
194
+ /**
195
+ * The element the CURRENT step resolved to, kept so a step whose control
196
+ * differs by width can pick its sentence from what is really there.
197
+ *
198
+ * Written at the top of `place()`, which runs before the card re-renders
199
+ * (the `stepIdx` watcher flushes 'pre') and again on every resize — so the
200
+ * copy follows the spotlight instead of lagging a step behind it. Assigning
201
+ * the same element twice is not a change, so a scroll storm costs nothing.
202
+ */
203
+ const targetEl = ref<HTMLElement | null>(null);
204
+
205
+ /** The step's sentence, with the shortcut names filled in. */
206
+ const stepDesc = computed(() => {
207
+ const s = step.value;
208
+ if (!s) return '';
209
+ const key = typeof s.descKey === 'function' ? s.descKey(targetEl.value) : s.descKey;
210
+ return t(key, hintCombos.value);
211
+ });
146
212
 
147
213
  const PAD = 6; // spotlight breathing room around the target
148
214
  const GAP = 12; // gap between spotlight and the card
@@ -152,6 +218,7 @@ async function place() {
152
218
  const r = props.root;
153
219
  if (!s) return;
154
220
  const el = r ? s.target(r) : null;
221
+ targetEl.value = el;
155
222
  if (!el) {
156
223
  spot.value = null; // centered card
157
224
  cardStyle.value = {};
@@ -169,17 +236,50 @@ async function place() {
169
236
  width: rect.width + PAD * 2,
170
237
  height: rect.height + PAD * 2,
171
238
  };
172
- // Card: below the spotlight when there's room, above otherwise;
173
- // clamped into the viewport horizontally.
239
+ // Card: below the spotlight, then above, then beside it.
240
+ //
241
+ // gorunum:v3-shell — the SIDE branches are why this is four cases and not
242
+ // two. The navigation panel is a full-height column, so neither "below" nor
243
+ // "above" fits and the old code clamped the card to the top of the viewport
244
+ // — which put it flat on top of the panel, hiding the very rows ("Home, My
245
+ // files, Shared with me…") the step was reading out. A coach mark that
246
+ // covers its own subject is worse than no coach mark: it looks like the
247
+ // product drew a dialog in the wrong place.
174
248
  await nextTick();
175
249
  const vw = window.innerWidth;
176
250
  const vh = window.innerHeight;
177
251
  const cw = Math.min(340, vw - 24);
178
252
  const ch = cardEl.value?.offsetHeight ?? 160;
179
- let top = rect.bottom + PAD + GAP;
180
- if (top + ch > vh - 12) top = Math.max(12, rect.top - PAD - GAP - ch);
181
- let left = rect.left + rect.width / 2 - cw / 2;
182
- left = Math.min(Math.max(12, left), vw - cw - 12);
253
+ const sTop = rect.top - PAD;
254
+ const sBottom = rect.bottom + PAD;
255
+ const sLeft = rect.left - PAD;
256
+ const sRight = rect.right + PAD;
257
+ // Centred on the target along the other axis, then clamped into view.
258
+ const clampX = (x: number) => Math.min(Math.max(12, x), Math.max(12, vw - cw - 12));
259
+ const clampY = (y: number) => Math.min(Math.max(12, y), Math.max(12, vh - ch - 12));
260
+ const midX = clampX(rect.left + rect.width / 2 - cw / 2);
261
+ const midY = clampY(rect.top + rect.height / 2 - ch / 2);
262
+
263
+ let top: number;
264
+ let left: number;
265
+ if (sBottom + GAP + ch <= vh - 12) {
266
+ top = sBottom + GAP;
267
+ left = midX;
268
+ } else if (sTop - GAP - ch >= 12) {
269
+ top = sTop - GAP - ch;
270
+ left = midX;
271
+ } else if (sRight + GAP + cw <= vw - 12) {
272
+ left = sRight + GAP;
273
+ top = midY;
274
+ } else if (sLeft - GAP - cw >= 12) {
275
+ left = sLeft - GAP - cw;
276
+ top = midY;
277
+ } else {
278
+ // Nothing fits — a target that fills the viewport. Clamp, and accept the
279
+ // overlap rather than drawing the card off screen.
280
+ top = clampY(sBottom + GAP);
281
+ left = midX;
282
+ }
183
283
  cardStyle.value = {
184
284
  top: `${Math.round(top)}px`,
185
285
  left: `${Math.round(left)}px`,
@@ -187,8 +287,30 @@ async function place() {
187
287
  };
188
288
  }
189
289
 
290
+ let placeRaf = 0;
291
+ /**
292
+ * Re-place after a resize or a scroll — but on the NEXT frame, never inline.
293
+ *
294
+ * ⚠ The explorer decides it is narrow from a ResizeObserver on its own root
295
+ * (FileExplorer.vue, `isNarrow`), and ResizeObserver callbacks run AFTER the
296
+ * frame's rAF callbacks — so neither this `window` listener nor a single rAF
297
+ * has seen the new layout yet. Measured: shrinking 1440 → 390 with the tour
298
+ * open left the spotlight on the navigation panel (the element was still in
299
+ * the DOM when this handler asked for it) both inline and after one frame;
300
+ * only a SECOND resize moved it onto the top bar's button. Two frames plus a
301
+ * tick puts this after the observer has fired and Vue has patched, so the
302
+ * first resize gets the right answer — and the step's sentence, which is
303
+ * chosen from that same element, changes with it.
304
+ */
190
305
  function onViewportChange() {
191
- if (props.open) void place();
306
+ if (!props.open) return;
307
+ if (placeRaf) cancelAnimationFrame(placeRaf);
308
+ placeRaf = requestAnimationFrame(() => {
309
+ placeRaf = requestAnimationFrame(() => {
310
+ placeRaf = 0;
311
+ void nextTick().then(place);
312
+ });
313
+ });
192
314
  }
193
315
 
194
316
  // ------------------------------------------------------------------
@@ -250,6 +372,7 @@ onMounted(() => {
250
372
  onBeforeUnmount(() => {
251
373
  window.removeEventListener('resize', onViewportChange);
252
374
  window.removeEventListener('scroll', onViewportChange, true);
375
+ if (placeRaf) cancelAnimationFrame(placeRaf);
253
376
  });
254
377
 
255
378
  // Theme cascade outside `.fe` — same pattern as ContextMenu.
@@ -309,7 +432,7 @@ const themeClass = computed(() => `fe-ctx-backdrop--theme-${props.theme || 'auto
309
432
  {{ t('tour.progress', { n: stepIdx + 1, m: total }) }}
310
433
  </p>
311
434
  <h3 class="fe-tour__title">{{ t(step.titleKey) }}</h3>
312
- <p class="fe-tour__desc">{{ t(step.descKey, hintCombos) }}</p>
435
+ <p class="fe-tour__desc">{{ stepDesc }}</p>
313
436
  <div class="fe-tour__dots" aria-hidden="true">
314
437
  <span
315
438
  v-for="(s, i) in activeSteps"
@@ -6,11 +6,15 @@
6
6
  * mode), so every viewer the modal knows (image/video/pdf/office/code/
7
7
  * 3D/archive/…) works in quick-look for free. What this layer adds:
8
8
  *
9
- * - Space closes again (macOS Quick Look convention)
9
+ * - the quick-look key closes the peek again (Space by default — macOS
10
+ * Quick Look convention), and the open key promotes it into the full
11
+ * open flow (Enter by default, emit 'open-full'). Both come from the
12
+ * shortcut registry rather than from a comparison against the default
13
+ * key, so a remap reaches the overlay too
10
14
  * - ← ↑ / → ↓ ask the host to move the selection; the host keeps the
11
- * `file` prop in sync so the preview follows the selection
12
- * - Enter promotes the peek into the full open flow (emit 'open-full')
13
- * - a small floating hint bar with the key legend
15
+ * `file` prop in sync so the preview follows the selection. These are
16
+ * the peek's own arrows, not registry actions
17
+ * - a small floating hint bar naming those keys, read from the registry
14
18
  *
15
19
  * Keys are handled in window CAPTURE phase with stopPropagation so the
16
20
  * global shortcut registry never double-handles them; events that
@@ -39,14 +43,31 @@ const props = defineProps<{
39
43
  pdfWorkerUrl?: string | null;
40
44
  viewerBaseUrl?: string | null;
41
45
  theme?: 'light' | 'dark' | 'auto';
46
+ /* === gorunum:v1-viewer — passed straight through to PreviewModal ===
47
+ * The peek knows nothing about the listing either; the host that answers
48
+ * `nav` is the one that can say "1 of 9". */
49
+ /** 1-based position of `file` in the host's file list. */
50
+ index?: number;
51
+ /** How many files that list holds. */
52
+ total?: number;
53
+ /** API origin for the star toggle. */
54
+ apiBase?: string;
55
+ /** Draw the share action (the host must listen to `@share`). */
56
+ shareEnabled?: boolean;
42
57
  }>();
43
58
 
44
59
  const emit = defineEmits<{
45
60
  (e: 'close'): void;
46
- /** Arrow navigation: -1 = previous file, +1 = next file. */
61
+ /** Arrow navigation: -1 = previous file, +1 = next file. Now raised by the
62
+ * overlay's edge chevrons as well as by the arrow keys — ONE contract, two
63
+ * triggers, because a second navigation path would drift from this one. */
47
64
  (e: 'nav', delta: number): void;
48
65
  /** Enter — close the peek and open the file for real. */
49
66
  (e: 'open-full'): void;
67
+ /** The viewer's share action (only reachable when `shareEnabled`). */
68
+ (e: 'share'): void;
69
+ /** The viewer's star toggle succeeded. */
70
+ (e: 'starred', value: boolean): void;
50
71
  }>();
51
72
 
52
73
  const { t } = useLocale(() => props.locale);
@@ -152,7 +173,15 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown, true));
152
173
  :drawio-url="drawioUrl"
153
174
  :pdf-worker-url="pdfWorkerUrl"
154
175
  :viewer-base-url="viewerBaseUrl"
176
+ :index="index"
177
+ :total="total"
178
+ :api-base="apiBase"
179
+ :share-enabled="shareEnabled"
180
+ :nav-enabled="true"
155
181
  @close="emit('close')"
182
+ @nav="(d: number) => emit('nav', d)"
183
+ @share="emit('share')"
184
+ @starred="(v: boolean) => emit('starred', v)"
156
185
  />
157
186
  <!--
158
187
  Teleported under <body> for the same reason as ContextMenu and
@@ -4,6 +4,7 @@
4
4
  * files. Backed by `GET /api/files/manager/recent?limit=20`.
5
5
  */
6
6
  import { ref, onMounted, watch } from 'vue';
7
+ import { actionIconSvg } from '../lib/actionIcons'; /* ikon:emoji */
7
8
 
8
9
  interface RecentNode {
9
10
  id: number;
@@ -83,8 +84,19 @@ watch(() => props.refreshKey, load);
83
84
  <div class="filex-recent">
84
85
  <header>
85
86
  <h3>Recently opened</h3>
86
- <button class="filex-recent-refresh" type="button" @click="load" :disabled="loading">
87
-
87
+ <button
88
+ class="filex-recent-refresh"
89
+ type="button"
90
+ title="Refresh"
91
+ aria-label="Refresh"
92
+ @click="load"
93
+ :disabled="loading"
94
+ >
95
+ <!-- ikon:emoji — ↻ was a bare arrow glyph with no label at all, so
96
+ the only thing naming this button was its shape. Same mark as the
97
+ toolbar's Refresh now, and it says what it is. -->
98
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
99
+ <span aria-hidden="true" v-html="actionIconSvg('refresh')"></span>
88
100
  </button>
89
101
  </header>
90
102
 
@@ -130,6 +142,9 @@ watch(() => props.refreshKey, load);
130
142
  color: var(--fe-text, var(--filex-text, #111827));
131
143
  }
132
144
  .filex-recent-refresh {
145
+ display: inline-flex;
146
+ align-items: center;
147
+ justify-content: center;
133
148
  background: transparent;
134
149
  border: none;
135
150
  color: var(--fe-text-muted, var(--filex-text-muted, #9ca3af));
@@ -36,7 +36,7 @@ const emit = defineEmits<{
36
36
  }>();
37
37
 
38
38
  const locale = computed<LocaleCode>(() => resolveLocale(props.config.locale));
39
- const { t } = useLocale(locale);
39
+ const { t, formatDate } = useLocale(locale);
40
40
 
41
41
  const {
42
42
  keys,
@@ -150,10 +150,13 @@ async function copy(text: string, id: string) {
150
150
  copyTimer = setTimeout(() => (copied.value = null), 1600);
151
151
  }
152
152
 
153
+ /* zaman:z1 — one date formatter for the package. This was a bare
154
+ * `toLocaleDateString()`: the browser's locale and the browser's zone, so a
155
+ * key minted at 23:30 in Istanbul was dated a day early for a viewer reading
156
+ * UTC and came out in the wrong language besides. */
153
157
  function shortDate(v?: string | null): string {
154
- if (!v) return '';
155
- const d = new Date(v);
156
- return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString();
158
+ const ms = new Date(v ?? '').getTime();
159
+ return Number.isNaN(ms) ? '' : formatDate(ms);
157
160
  }
158
161
 
159
162
  /** What a key is limited to, in one line. */
@@ -49,7 +49,7 @@ const emit = defineEmits<{
49
49
  }>();
50
50
 
51
51
  const locale = computed<LocaleCode>(() => resolveLocale(props.config.locale));
52
- const { t } = useLocale(locale);
52
+ const { t, formatDate } = useLocale(locale);
53
53
 
54
54
  const { keys, connection, loading, error, canAdd, hasUsableKey, load, add, setDisabled, remove } =
55
55
  useSSHKeys(props.config);
@@ -119,10 +119,13 @@ async function drop(k: SSHPublicKey) {
119
119
  }
120
120
  }
121
121
 
122
+ /* zaman:z1 — one date formatter for the package. This was a bare
123
+ * `toLocaleDateString()`: the browser's locale and the browser's zone, so a
124
+ * key minted at 23:30 in Istanbul was dated a day early for a viewer reading
125
+ * UTC and came out in the wrong language besides. */
122
126
  function shortDate(v?: string | null): string {
123
- if (!v) return '';
124
- const d = new Date(v);
125
- return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString();
127
+ const ms = new Date(v ?? '').getTime();
128
+ return Number.isNaN(ms) ? '' : formatDate(ms);
126
129
  }
127
130
 
128
131
  /** Shown the way OpenSSH prints it, so it can be compared with `ssh-keygen -lf`. */
@@ -20,10 +20,12 @@
20
20
  import { computed, onBeforeUnmount, ref, watch } from 'vue';
21
21
  import type { LocaleCode } from '../types/ExplorerConfig';
22
22
  import { useLocale } from '../composables/useLocale';
23
+ import { actionIconSvg } from '../lib/actionIcons'; /* ikon:emoji */
23
24
  import {
24
25
  comboFromEvent,
25
26
  effectiveCombo,
26
27
  findShortcutConflict,
28
+ isReservedCombo,
27
29
  resetAllShortcuts,
28
30
  resetShortcut,
29
31
  setShortcutOverride,
@@ -66,6 +68,8 @@ const anyOverridden = computed(() => list.value.some((s) => s.overridden));
66
68
 
67
69
  const capturingId = ref<string | null>(null);
68
70
  const conflict = ref<{ id: string; combo: string; conflictId: string; fixed: boolean } | null>(null);
71
+ /* tus:t1 — the user pressed something the browser keeps for itself. */
72
+ const reserved = ref<{ id: string; combo: string } | null>(null);
69
73
 
70
74
  function labelOf(id: string): string {
71
75
  const def = SHORTCUT_ACTIONS.find((a) => a.id === id);
@@ -74,6 +78,7 @@ function labelOf(id: string): string {
74
78
 
75
79
  function beginCapture(id: string) {
76
80
  conflict.value = null;
81
+ reserved.value = null;
77
82
  capturingId.value = id;
78
83
  }
79
84
 
@@ -84,6 +89,13 @@ function cancelCapture() {
84
89
  function attemptAssign(id: string, combo: string) {
85
90
  capturingId.value = null;
86
91
  if (combo === effectiveCombo(id)) return; // unchanged
92
+ /* tus:t1 — a combo the browser takes before the page sees it would be a key
93
+ * that does nothing where the user is standing. Refuse it and say why,
94
+ * rather than storing a binding that never fires. */
95
+ if (isReservedCombo(combo)) {
96
+ reserved.value = { id, combo };
97
+ return;
98
+ }
87
99
  const clash = findShortcutConflict(combo, id);
88
100
  if (clash) {
89
101
  conflict.value = { id, combo, conflictId: clash.id, fixed: clash.fixed };
@@ -104,13 +116,19 @@ function dismissConflict() {
104
116
  conflict.value = null;
105
117
  }
106
118
 
119
+ function dismissReserved() {
120
+ reserved.value = null;
121
+ }
122
+
107
123
  function onReset(id: string) {
108
124
  conflict.value = null;
125
+ reserved.value = null;
109
126
  resetShortcut(id);
110
127
  }
111
128
 
112
129
  function onResetAll() {
113
130
  conflict.value = null;
131
+ reserved.value = null;
114
132
  capturingId.value = null;
115
133
  resetAllShortcuts();
116
134
  }
@@ -145,6 +163,7 @@ watch(
145
163
  window.removeEventListener('keydown', onWindowKeydown, true);
146
164
  capturingId.value = null;
147
165
  conflict.value = null;
166
+ reserved.value = null;
148
167
  }
149
168
  },
150
169
  );
@@ -171,7 +190,9 @@ function onClose() {
171
190
  :disabled="!anyOverridden"
172
191
  @click="onResetAll"
173
192
  >
174
- {{ t('shortcuts.settings.reset_all') }}
193
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
194
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('restore')"></span>
195
+ {{ t('shortcuts.settings.reset_all') }}
175
196
  </button>
176
197
  </div>
177
198
 
@@ -201,6 +222,14 @@ function onClose() {
201
222
  </span>
202
223
  <template v-for="(combo, i) in s.keys" v-else :key="combo">
203
224
  <kbd class="fe-kbd">{{ combo }}</kbd>
225
+ <!-- tus:t1 — this combo never reaches a browser tab. Say so
226
+ on the row rather than letting the user discover it by
227
+ pressing the key and watching nothing happen. -->
228
+ <span
229
+ v-if="isReservedCombo(combo)"
230
+ class="fe-shortset__reserved"
231
+ :title="t('shortcuts.settings.reserved_title')"
232
+ >{{ t('shortcuts.settings.reserved_badge') }}</span>
204
233
  <span v-if="i < s.keys.length - 1" class="fe-shortcuts__or" aria-hidden="true">/</span>
205
234
  </template>
206
235
  </template>
@@ -231,7 +260,8 @@ function onClose() {
231
260
  :aria-label="t('shortcuts.settings.reset')"
232
261
  @click="onReset(s.id)"
233
262
  >
234
-
263
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
264
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('restore')"></span>
235
265
  </button>
236
266
  </template>
237
267
  <span v-else class="fe-shortset__fixed">{{ t('shortcuts.settings.fixed') }}</span>
@@ -260,6 +290,17 @@ function onClose() {
260
290
  </button>
261
291
  </span>
262
292
  </div>
293
+ <div v-if="reserved && reserved.id === s.id" class="fe-shortset__conflict" role="alert">
294
+ <span class="fe-shortset__conflict-msg">
295
+ <kbd class="fe-kbd">{{ reserved.combo }}</kbd>
296
+ {{ t('shortcuts.settings.reserved') }}
297
+ </span>
298
+ <span class="fe-shortset__conflict-actions">
299
+ <button type="button" class="fe-btn fe-shortset__btn" @click="dismissReserved">
300
+ {{ t('shortcuts.settings.conflict_cancel') }}
301
+ </button>
302
+ </span>
303
+ </div>
263
304
  </div>
264
305
  </section>
265
306
  </div>
@@ -11,6 +11,7 @@
11
11
  import { computed } from 'vue';
12
12
  import type { LocaleCode } from '../types/ExplorerConfig';
13
13
  import { useLocale } from '../composables/useLocale';
14
+ import { actionIconSvg } from '../lib/actionIcons'; /* ikon:emoji */
14
15
  import { useShortcutList, type ShortcutView } from '../composables/useKeyboardShortcuts';
15
16
  import Modal from '../modals/Modal.vue';
16
17
 
@@ -72,7 +73,9 @@ const groups = computed(() => {
72
73
  </div>
73
74
  <template #actions>
74
75
  <button type="button" class="fe-btn fe-btn--primary" @click="emit('customize')">
75
- {{ t('shortcuts.customize') }}
76
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
77
+ <span class="fe-icon" aria-hidden="true" v-html="actionIconSvg('shortcut-settings')"></span>
78
+ {{ t('shortcuts.customize') }}
76
79
  </button>
77
80
  <button type="button" class="fe-btn" @click="emit('close')">{{ t('viewer.close') }}</button>
78
81
  </template>