@brftech/filex-core 0.42.2 → 0.43.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 (234) hide show
  1. package/README.md +14 -2
  2. package/dist/ArchiveViewer-BqU761Wk.js +149 -0
  3. package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
  4. package/dist/CsvViewer-ByHVK5ga.js +172 -0
  5. package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
  6. package/dist/{DrawioViewer-CC6rJXeP.js → DrawioViewer-Bgrj8-zG.js} +45 -44
  7. package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
  8. package/dist/{EpubViewer-CAoTSXTO.js → EpubViewer-DJwUKlHW.js} +50 -50
  9. package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
  10. package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
  11. package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
  12. package/dist/MermaidViewer-CEDTD_pp.js +136 -0
  13. package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
  14. package/dist/PsdViewer-CZcxUpZO.js +121 -0
  15. package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
  16. package/dist/TiffViewer-BwI62CbQ.js +141 -0
  17. package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
  18. package/dist/{Viewer3D-CdZeK4j4.js → Viewer3D-DoeZcuMm.js} +16 -16
  19. package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
  20. package/dist/filex-core.js +475 -219
  21. package/dist/filex-core.umd.cjs +165 -108
  22. package/dist/filex-core.umd.cjs.map +1 -1
  23. package/dist/fonts/caveat-latin-ext.woff2 +0 -0
  24. package/dist/fonts/caveat-latin.woff2 +0 -0
  25. package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
  26. package/dist/fonts/dancing-script-latin.woff2 +0 -0
  27. package/dist/fonts/homemade-apple-latin.woff2 +0 -0
  28. package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
  29. package/dist/fonts/source-serif-4-latin.woff2 +0 -0
  30. package/dist/index-DuWnGuv5.js +35901 -0
  31. package/dist/index-DuWnGuv5.js.map +1 -0
  32. package/dist/index.d.ts +5227 -553
  33. package/dist/pdf-CaPYsOUo.js +21302 -0
  34. package/dist/pdf-CaPYsOUo.js.map +1 -0
  35. package/dist/style.css +1 -1
  36. package/package.json +1 -1
  37. package/src/FileExplorer.vue +1055 -253
  38. package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
  39. package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
  40. package/src/assets/fonts/README.md +58 -0
  41. package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
  42. package/src/assets/fonts/caveat-latin.woff2 +0 -0
  43. package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
  44. package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
  45. package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
  46. package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
  47. package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
  48. package/src/components/AdvancedSearch.vue +49 -0
  49. package/src/components/Breadcrumb.vue +5 -5
  50. package/src/components/ChoiceButtons.vue +152 -0
  51. package/src/components/CommandPalette.vue +5 -5
  52. package/src/components/ConnectionGuideView.vue +1 -1
  53. package/src/components/ConnectionNotice.vue +59 -0
  54. package/src/components/ConnectionsPanel.vue +30 -568
  55. package/src/components/ContextMenu.vue +31 -5
  56. package/src/components/DataTable.vue +1331 -0
  57. package/src/components/FilePane.vue +3 -0
  58. package/src/components/FilterBar.vue +12 -5
  59. package/src/components/GalleryView.vue +50 -2
  60. package/src/components/GridView.vue +51 -3
  61. package/src/components/HomeView.vue +18 -6
  62. package/src/components/InspectorPanel.vue +134 -17
  63. package/src/components/ListView.vue +366 -900
  64. package/src/components/LogoMark.vue +57 -0
  65. package/src/components/NFSExportsPanel.vue +76 -28
  66. package/src/components/OnboardingTour.vue +19 -7
  67. package/src/components/OperationsCenter.vue +77 -1
  68. package/src/components/PendingOpsTray.vue +59 -5
  69. package/src/components/PresenceBar.vue +7 -7
  70. package/src/components/ProductVersion.vue +31 -0
  71. package/src/components/QuickLook.vue +10 -14
  72. package/src/components/RecentlyOpened.vue +31 -15
  73. package/src/components/RecoveryKeyModal.vue +1 -1
  74. package/src/components/RowActions.vue +134 -0
  75. package/src/components/S3KeysPanel.vue +98 -62
  76. package/src/components/SSHKeysPanel.vue +79 -30
  77. package/src/components/SideNav.vue +260 -96
  78. package/src/components/StarButton.vue +1 -0
  79. package/src/components/StorageFields.vue +74 -6
  80. package/src/components/StorageTags.vue +62 -0
  81. package/src/components/TabBar.vue +1 -1
  82. package/src/components/TagKindIcon.vue +56 -0
  83. package/src/components/TagPicker.vue +226 -40
  84. package/src/components/ThemePalette.vue +4 -4
  85. package/src/components/TimeZonePicker.vue +29 -10
  86. package/src/components/TokensPanel.vue +82 -31
  87. package/src/components/Toolbar.vue +28 -6
  88. package/src/components/plugin/PluginConfirmModal.vue +54 -0
  89. package/src/components/plugin/PluginInspectorSection.vue +149 -0
  90. package/src/components/plugin/PluginPageView.vue +339 -0
  91. package/src/components/plugin/PluginViewModal.vue +159 -0
  92. package/src/components/plugin/SurfaceConversation.vue +76 -0
  93. package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
  94. package/src/components/plugin/SurfaceRenderer.vue +299 -0
  95. package/src/components/plugin/SurfaceSections.vue +91 -0
  96. package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
  97. package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
  98. package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
  99. package/src/components/plugin/nodes/SurfaceList.vue +127 -0
  100. package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
  101. package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
  102. package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
  103. package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
  104. package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
  105. package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
  106. package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
  107. package/src/components/public/PublicLanguagePicker.vue +58 -0
  108. package/src/components/public/PublicLinkPage.vue +286 -0
  109. package/src/components/public/PublicLinkPreview.vue +85 -0
  110. package/src/components/public/PublicPinGate.vue +108 -0
  111. package/src/components/public/PublicRequestBody.vue +204 -0
  112. package/src/components/public/PublicShareBody.vue +188 -0
  113. package/src/components/public/PublicShell.vue +332 -0
  114. package/src/composables/useConnections.ts +8 -2
  115. package/src/composables/useFileApi.ts +183 -44
  116. package/src/composables/useLocale.ts +277 -41
  117. package/src/composables/useNFSExports.ts +8 -10
  118. package/src/composables/useOperations.ts +44 -3
  119. package/src/composables/usePendingOps.ts +59 -8
  120. package/src/composables/usePluginActions.ts +121 -0
  121. package/src/composables/usePluginSurface.ts +223 -0
  122. package/src/composables/usePublicBranding.ts +172 -0
  123. package/src/composables/usePublicLink.ts +573 -0
  124. package/src/composables/usePublicPage.ts +61 -0
  125. package/src/composables/useS3Keys.ts +17 -12
  126. package/src/composables/useSSHKeys.ts +8 -10
  127. package/src/composables/useTokens.ts +8 -12
  128. package/src/composables/useUploadChunked.ts +18 -8
  129. package/src/composables/useViewerFetch.ts +6 -3
  130. package/src/index.ts +404 -8
  131. package/src/lib/actionIcons.ts +46 -1
  132. package/src/lib/advSearch.ts +14 -7
  133. package/src/lib/appLock.ts +130 -0
  134. package/src/lib/connection.ts +110 -0
  135. package/src/lib/destinationTree.ts +19 -3
  136. package/src/lib/direction.ts +353 -0
  137. package/src/lib/dragGhost.ts +6 -0
  138. package/src/lib/dropLimits.ts +69 -0
  139. package/src/lib/errorWords.ts +394 -0
  140. package/src/lib/externalReach.ts +5 -1
  141. package/src/lib/fileFilters.ts +7 -2
  142. package/src/lib/fileIcons.ts +4 -0
  143. package/src/lib/internalPaths.ts +77 -0
  144. package/src/lib/listInput.ts +64 -0
  145. package/src/lib/listing.ts +58 -23
  146. package/src/lib/modalStack.ts +26 -0
  147. package/src/lib/nodeRow.ts +18 -3
  148. package/src/lib/pdfFieldRules.ts +126 -0
  149. package/src/lib/pdfFields.ts +589 -0
  150. package/src/lib/pdfFieldsGeom.ts +203 -0
  151. package/src/lib/pdfjsLoader.ts +51 -0
  152. package/src/lib/personName.ts +47 -0
  153. package/src/lib/pluginApplies.ts +123 -0
  154. package/src/lib/pluginLabel.ts +72 -0
  155. package/src/lib/pluginMenu.ts +253 -0
  156. package/src/lib/pluginPage.ts +58 -0
  157. package/src/lib/plural.ts +181 -0
  158. package/src/lib/popupLayer.ts +103 -0
  159. package/src/lib/prefs.ts +563 -0
  160. package/src/lib/productVersion.ts +35 -0
  161. package/src/lib/publicLayout.ts +39 -0
  162. package/src/lib/serviceGate.ts +104 -0
  163. package/src/lib/shareTtl.ts +25 -11
  164. package/src/lib/signFonts.ts +85 -0
  165. package/src/lib/signaturePad.ts +111 -0
  166. package/src/lib/sortOrder.ts +100 -38
  167. package/src/lib/star.ts +7 -1
  168. package/src/lib/surfaceCell.ts +41 -0
  169. package/src/lib/surfaceConditions.ts +156 -0
  170. package/src/lib/surfaceOpen.ts +106 -0
  171. package/src/lib/surfaceSteps.ts +68 -0
  172. package/src/lib/surfaceValues.ts +191 -0
  173. package/src/lib/symlink.ts +150 -0
  174. package/src/lib/tableColumns.ts +493 -0
  175. package/src/lib/tableEnv.ts +34 -0
  176. package/src/lib/tablePrefs.ts +84 -0
  177. package/src/lib/tags.ts +121 -12
  178. package/src/lib/themes.ts +286 -18
  179. package/src/lib/tour.ts +152 -0
  180. package/src/lib/trashEmpty.ts +111 -0
  181. package/src/lib/trashTimeLeft.ts +23 -0
  182. package/src/lib/uiLocales.ts +460 -0
  183. package/src/lib/viewPrefs.ts +696 -660
  184. package/src/lib/viewPrefsHttp.ts +78 -0
  185. package/src/locales/en.ts +515 -117
  186. package/src/locales/resolve.ts +17 -8
  187. package/src/locales/tr.ts +536 -152
  188. package/src/modals/ConvertModal.vue +62 -11
  189. package/src/modals/DestinationPickerModal.vue +59 -16
  190. package/src/modals/Modal.vue +113 -23
  191. package/src/modals/NewDocumentModal.vue +16 -6
  192. package/src/modals/NewFolderModal.vue +8 -0
  193. package/src/modals/PermissionsModal.vue +272 -163
  194. package/src/modals/PreviewModal.vue +119 -20
  195. package/src/modals/RenameModal.vue +37 -1
  196. package/src/styles/base.css +2890 -260
  197. package/src/styles/sign-fonts.css +118 -0
  198. package/src/styles/variables.css +60 -0
  199. package/src/types/Connections.ts +17 -0
  200. package/src/types/ExplorerConfig.ts +117 -1
  201. package/src/types/FileNode.ts +50 -0
  202. package/src/types/Plugins.ts +494 -0
  203. package/src/types/Public.ts +212 -0
  204. package/src/viewers/ArchiveViewer.vue +93 -48
  205. package/src/viewers/CsvViewer.vue +99 -53
  206. package/src/viewers/DrawioViewer.vue +35 -9
  207. package/src/viewers/EpubViewer.vue +7 -8
  208. package/src/viewers/IpynbViewer.vue +3 -1
  209. package/src/viewers/MermaidViewer.vue +4 -5
  210. package/src/viewers/PdfViewer.vue +8 -17
  211. package/src/viewers/PsdViewer.vue +2 -4
  212. package/src/viewers/TiffViewer.vue +4 -5
  213. package/src/viewers/Viewer3D.vue +1 -1
  214. package/LICENSE +0 -21
  215. package/dist/ArchiveViewer-BUQWLhwX.js +0 -124
  216. package/dist/ArchiveViewer-BUQWLhwX.js.map +0 -1
  217. package/dist/CsvViewer-Bg2_aqzc.js +0 -141
  218. package/dist/CsvViewer-Bg2_aqzc.js.map +0 -1
  219. package/dist/DrawioViewer-CC6rJXeP.js.map +0 -1
  220. package/dist/EpubViewer-CAoTSXTO.js.map +0 -1
  221. package/dist/IpynbViewer-D0GwGpnE.js +0 -184
  222. package/dist/IpynbViewer-D0GwGpnE.js.map +0 -1
  223. package/dist/MermaidViewer-DGwAvRZg.js +0 -137
  224. package/dist/MermaidViewer-DGwAvRZg.js.map +0 -1
  225. package/dist/PsdViewer-DhGweA0m.js +0 -122
  226. package/dist/PsdViewer-DhGweA0m.js.map +0 -1
  227. package/dist/TiffViewer-D0AurvAh.js +0 -142
  228. package/dist/TiffViewer-D0AurvAh.js.map +0 -1
  229. package/dist/Viewer3D-CdZeK4j4.js.map +0 -1
  230. package/dist/index-BArI-B1M.js +0 -26911
  231. package/dist/index-BArI-B1M.js.map +0 -1
  232. package/dist/useViewerFetch-czqbd2Lj.js +0 -25
  233. package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
  234. package/src/composables/useScrolledX.ts +0 -21
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { ref, onMounted, watch } from 'vue';
7
7
  import { actionIconSvg } from '../lib/actionIcons'; /* ikon:emoji */
8
+ import { localeTag, useLocale } from '../composables/useLocale';
8
9
 
9
10
  interface RecentNode {
10
11
  id: number;
@@ -30,8 +31,16 @@ const props = defineProps<{
30
31
  limit?: number;
31
32
  /** Optional refresh trigger — incrementing this re-fetches. */
32
33
  refreshKey?: number | string;
34
+ /**
35
+ * The explorer's language. ⚠ This tray printed English whatever the
36
+ * explorer spoke ("Recently opened", "5m ago") — strings no language pack
37
+ * could reach. Absent = English.
38
+ */
39
+ locale?: string;
33
40
  }>();
34
41
 
42
+ const { t } = useLocale(() => props.locale ?? 'en');
43
+
35
44
  const emit = defineEmits<{
36
45
  (e: 'open', node: RecentNode): void;
37
46
  /** Right-click on a row. The tray does not build a menu of its own — the
@@ -76,13 +85,20 @@ async function load() {
76
85
 
77
86
  function fmtTime(s?: string): string {
78
87
  if (!s) return '';
79
- const t = new Date(s).getTime();
80
- if (Number.isNaN(t)) return '';
81
- const diff = Date.now() - t;
82
- if (diff < 60_000) return 'just now';
83
- if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
84
- if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
85
- return `${Math.floor(diff / 86_400_000)}d ago`;
88
+ const ms = new Date(s).getTime();
89
+ if (Number.isNaN(ms)) return '';
90
+ const diff = Date.now() - ms;
91
+ // `Intl` says "5 minutes ago" in the viewer's language — every language,
92
+ // a pack's included — with no string in any catalogue.
93
+ try {
94
+ const rtf = new Intl.RelativeTimeFormat(localeTag(props.locale), { numeric: 'auto', style: 'short' });
95
+ if (diff < 60_000) return rtf.format(0, 'second');
96
+ if (diff < 3_600_000) return rtf.format(-Math.floor(diff / 60_000), 'minute');
97
+ if (diff < 86_400_000) return rtf.format(-Math.floor(diff / 3_600_000), 'hour');
98
+ return rtf.format(-Math.floor(diff / 86_400_000), 'day');
99
+ } catch {
100
+ return new Date(ms).toISOString().slice(0, 16).replace('T', ' ');
101
+ }
86
102
  }
87
103
 
88
104
  onMounted(load);
@@ -92,12 +108,12 @@ watch(() => props.refreshKey, load);
92
108
  <template>
93
109
  <div class="filex-recent">
94
110
  <header>
95
- <h3>Recently opened</h3>
111
+ <h3>{{ t('recents.title') }}</h3>
96
112
  <button
97
113
  class="filex-recent-refresh"
98
114
  type="button"
99
- title="Refresh"
100
- aria-label="Refresh"
115
+ :title="t('recents.refresh')"
116
+ :aria-label="t('recents.refresh')"
101
117
  @click="load"
102
118
  :disabled="loading"
103
119
  >
@@ -117,13 +133,13 @@ watch(() => props.refreshKey, load);
117
133
  @click="emit('open', n)"
118
134
  @contextmenu.prevent="emit('context', n, $event)"
119
135
  >
120
- <span class="filex-recent-name">{{ n.name }}</span>
136
+ <span class="filex-recent-name"><bdi>{{ n.name }}</bdi></span>
121
137
  <span class="filex-recent-meta">{{ fmtTime(n.last_opened) }}</span>
122
138
  </button>
123
139
  </li>
124
140
  </ul>
125
- <p v-else-if="!loading" class="filex-recent-empty">Nothing here yet.</p>
126
- <p v-else class="filex-recent-empty">Loading…</p>
141
+ <p v-else-if="!loading" class="filex-recent-empty">{{ t('recents.empty') }}</p>
142
+ <p v-else class="filex-recent-empty">{{ t('recents.loading') }}</p>
127
143
  </div>
128
144
  </template>
129
145
 
@@ -181,7 +197,7 @@ watch(() => props.refreshKey, load);
181
197
  padding: 6px 8px;
182
198
  cursor: pointer;
183
199
  border-radius: 4px;
184
- text-align: left;
200
+ text-align: start;
185
201
  }
186
202
  .filex-recent-item:hover {
187
203
  background: var(--fe-bg-hover, var(--filex-bg-soft, #f3f4f6));
@@ -195,7 +211,7 @@ watch(() => props.refreshKey, load);
195
211
  .filex-recent-meta {
196
212
  color: var(--fe-text-muted, var(--filex-text-muted, #9ca3af));
197
213
  font-size: 11px;
198
- margin-left: 8px;
214
+ margin-inline-start: 8px;
199
215
  flex-shrink: 0;
200
216
  }
201
217
  .filex-recent-empty {
@@ -97,7 +97,7 @@ function done() {
97
97
  {{ variant === 'upgraded' ? t('e2e.recovery.lead_upgraded') : t('e2e.recovery.lead') }}
98
98
  </p>
99
99
 
100
- <output class="fe-e2e-rk__key" aria-label="recovery key">{{ recoveryKey }}</output>
100
+ <output class="fe-e2e-rk__key" :aria-label="t('e2e.recovery_key_aria')">{{ recoveryKey }}</output>
101
101
 
102
102
  <div class="fe-e2e-rk__actions">
103
103
  <button type="button" class="fe-btn" @click="copy">
@@ -0,0 +1,134 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ONE control at the end of a row, and the row's actions behind it.
4
+ *
5
+ * The owner, 2026-09-20: "adminde aksiyonlar karma karışık; onlarda da aynı
6
+ * explore'da olduğu gibi en dibe sabitli tek buton olmalı. Adminde 'Aksiyon'
7
+ * diye yazmalı, explore'da ⋮ var, adminde aksiyon yazacak."
8
+ *
9
+ * What it replaces: a row that ended in two or three unlabelled icon buttons —
10
+ * a pencil, a key, a bin — in a column that had to be 180-260px wide to hold
11
+ * them, differently ordered in every table, and the first thing to fall off
12
+ * the right-hand edge on a narrow window. Users had three unnamed buttons;
13
+ * Webhooks had `test / pencil / bin`, Users `pencil / key / bin`, Shares
14
+ * `link / ban / bin`, and no two agreed on what a ghost button meant.
15
+ *
16
+ * ⚠⚠ THE SAME MENU THE EXPLORER OPENS. This wraps `ContextMenu` — the one the
17
+ * file list's ⋮ opens — rather than building a second popover, for the reason
18
+ * filex lesson #67 records: a menu assembled beside the real one drifts from
19
+ * it the first time an action is added. The only difference between the two
20
+ * surfaces is the handle: the explorer's is the ⋮ glyph, the admin panel's is
21
+ * a LABELLED button reading "Actions" / "Aksiyon", because an admin row's
22
+ * verbs (revoke a token, reset a password, purge a version) are not guessable
23
+ * from a glyph the way "open / rename / delete" are.
24
+ *
25
+ * ⚠ NOTHING MAY BE LOST IN THE MOVE. Every loose button a row used to draw
26
+ * becomes one entry here, disabled entries included: a verb that is currently
27
+ * impossible stays visible and greyed with its reason in `title`, because a
28
+ * hidden verb reads as a missing feature (the same rule ContextMenu already
29
+ * states for the explorer). Destructive verbs keep `danger: true`, which is
30
+ * what paints them apart inside the menu, and keep whatever confirmation
31
+ * dialog the page already put behind them — this control does not confirm
32
+ * anything itself, it only says which verb was chosen.
33
+ *
34
+ * ⚠ `shortcutId: ''` on every entry. `ContextMenu` prints the keyboard hint
35
+ * the shortcut registry holds for an action key, and that registry is the
36
+ * EXPLORER's: a row offering "Delete" in the admin panel would otherwise
37
+ * advertise the file manager's Del binding, which does nothing on that page.
38
+ * An entry that genuinely has an admin shortcut can still name one.
39
+ */
40
+ import { computed, ref } from 'vue';
41
+ import ContextMenu, { type ContextAction } from './ContextMenu.vue';
42
+ import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
43
+ import { useLocale } from '../composables/useLocale';
44
+ import { inlineStartX } from '../lib/direction';
45
+
46
+ const props = withDefaults(
47
+ defineProps<{
48
+ /** The row's verbs, in the order they should be offered. */
49
+ actions: ContextAction[];
50
+ locale?: LocaleCode;
51
+ /** Overrides the built-in "Actions" / "Aksiyon" label. */
52
+ label?: string;
53
+ /** The whole control is off (a row being saved, a read-only demo). */
54
+ disabled?: boolean;
55
+ theme?: ThemeMode;
56
+ /** A test hook, so a spec can address one row's control without guessing. */
57
+ testid?: string;
58
+ }>(),
59
+ { locale: 'en' as LocaleCode },
60
+ );
61
+
62
+ const emit = defineEmits<{
63
+ (e: 'select', key: string, action: ContextAction): void;
64
+ }>();
65
+
66
+ const { t, dir } = useLocale(() => props.locale);
67
+
68
+ const menu = ref<{ show: (ev: { clientX: number; clientY: number }, nodes: []) => void } | null>(
69
+ null,
70
+ );
71
+
72
+ const text = computed(() => props.label || t('row.actions'));
73
+
74
+ /**
75
+ * ⚠ Every entry gets an address. The menu teleports to <body>, so an entry
76
+ * cannot be reached through the component that opened it, and the verbs that
77
+ * used to be loose buttons each had a `data-testid` of their own — losing
78
+ * those would have made the move untestable and quietly un-automatable. An
79
+ * entry's own `testid` wins; otherwise it is this control's testid plus the
80
+ * action key, so `user-actions-3` yields `user-actions-3-delete`.
81
+ */
82
+ const items = computed<ContextAction[]>(() =>
83
+ props.actions
84
+ .filter((a) => !a.hidden)
85
+ .map((a) => ({
86
+ ...a,
87
+ shortcutId: a.shortcutId ?? '',
88
+ testid: a.testid ?? (props.testid ? `${props.testid}-${a.key}` : undefined),
89
+ })),
90
+ );
91
+
92
+ /** A control with nothing behind it is not a control. A row whose every verb
93
+ * is conditional (Queue: retry only when failed, cancel only when pending)
94
+ * can legitimately have none, and then the button is disabled rather than
95
+ * absent, so the column does not go ragged from row to row. */
96
+ const usable = computed(() => items.value.some((a) => !a.divider && !a.disabled));
97
+
98
+ function open(ev: MouseEvent) {
99
+ // Anchored to the BUTTON's own corner, exactly as the explorer's ⋮ is
100
+ // (ListView `onRowMenu`): the menu drops from the control the person
101
+ // clicked, and ContextMenu flips it left/up itself when the viewport edge
102
+ // is close — which, for a control pinned to the right edge, is always.
103
+ const box = (ev.currentTarget as HTMLElement | null)?.getBoundingClientRect();
104
+ menu.value?.show(
105
+ // ⚠ RTL: the START corner (right in RTL), as ListView `onRowMenu` does.
106
+ { clientX: box ? inlineStartX(box, dir.value) : ev.clientX, clientY: box ? box.bottom : ev.clientY },
107
+ [],
108
+ );
109
+ }
110
+
111
+ function pick(a: ContextAction) {
112
+ emit('select', a.key, a);
113
+ }
114
+ </script>
115
+
116
+ <template>
117
+ <!-- ⚠ `click.stop`: the shared table emits `row-click` from the cell, and a
118
+ row that navigates would otherwise fire it under the menu. -->
119
+ <div class="tbl-rowactions" @click.stop>
120
+ <button
121
+ type="button"
122
+ class="tbl-rowactions__btn"
123
+ :disabled="disabled || !usable"
124
+ :data-testid="testid"
125
+ data-fe-control
126
+ aria-haspopup="menu"
127
+ @click.stop="open"
128
+ >
129
+ <span>{{ text }}</span>
130
+ <span class="tbl-rowactions__caret" aria-hidden="true">▾</span>
131
+ </button>
132
+ <ContextMenu ref="menu" :locale="locale" :theme="theme" :actions="items" @select="pick" />
133
+ </div>
134
+ </template>
@@ -22,7 +22,8 @@ import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
22
22
  import type { S3AccessKey } from '../types/S3Keys';
23
23
  import { useLocale } from '../composables/useLocale';
24
24
  import { useS3Keys } from '../composables/useS3Keys';
25
- import { useScrolledX } from '../composables/useScrolledX';
25
+ import DataTable, { type DataColumn } from './DataTable.vue';
26
+ import type { ContextAction } from './ContextMenu.vue';
26
27
  import { resolveLocale } from '../locales/resolve';
27
28
 
28
29
  const props = defineProps<{
@@ -38,13 +39,13 @@ const emit = defineEmits<{
38
39
 
39
40
  const locale = computed<LocaleCode>(() => resolveLocale(props.config.locale));
40
41
  const { t, formatDate } = useLocale(locale);
41
- const { scrolledX, onScroll } = useScrolledX();
42
42
 
43
43
  const {
44
44
  keys,
45
45
  connection,
46
46
  loading,
47
47
  error,
48
+ errorHint,
48
49
  canMint,
49
50
  revealed,
50
51
  guideKey,
@@ -97,6 +98,70 @@ async function mint() {
97
98
  }
98
99
  }
99
100
 
101
+ /**
102
+ * The row's verbs. They used to be two loose text buttons in the last cell;
103
+ * they are now the entries behind the row's one `Actions` control
104
+ * (RowActions → ContextMenu, the menu the explorer's ⋮ opens).
105
+ *
106
+ * ⚠ `revoke` keeps its two-step confirmation exactly as it was: the first
107
+ * pick arms it and the label becomes "Confirm", the second pick revokes.
108
+ * Nothing about a credential's destruction got easier in the move.
109
+ */
110
+ function rowActions(k: S3AccessKey): ContextAction[] {
111
+ /* The whole control used to be `:disabled="busy"`; the table draws the
112
+ control now, so each verb carries it and RowActions greys a button with
113
+ nothing usable behind it. */
114
+ return [
115
+ {
116
+ key: 'toggle',
117
+ label: k.disabled_at ? t('conn.s3keys.enable') : t('conn.s3keys.disable'),
118
+ icon: k.disabled_at ? 'check' : 'lock',
119
+ disabled: busy.value,
120
+ },
121
+ {
122
+ key: 'revoke',
123
+ label: confirmRevoke.value === k.id ? t('conn.s3keys.confirm') : t('conn.s3keys.revoke'),
124
+ icon: 'delete',
125
+ danger: true,
126
+ disabled: busy.value,
127
+ },
128
+ ];
129
+ }
130
+
131
+ /** The list's columns — the product's one table (DataTable). */
132
+ const columns = computed<DataColumn<S3AccessKey>[]>(() => [
133
+ {
134
+ id: 'label',
135
+ label: t('conn.s3keys.col.label'),
136
+ sortable: true,
137
+ width: 180,
138
+ format: (k) => k.label || t('conn.s3keys.noLabel'),
139
+ },
140
+ {
141
+ id: 'key',
142
+ label: t('conn.s3keys.col.key'),
143
+ sortable: true,
144
+ width: 200,
145
+ sortValue: (k) => k.access_key_id,
146
+ title: (k) => k.access_key_id,
147
+ },
148
+ { id: 'scope', label: t('conn.s3keys.col.scope'), sortable: true, width: 160, format: scopeOf },
149
+ {
150
+ id: 'lastUsed',
151
+ label: t('conn.s3keys.col.lastUsed'),
152
+ sortable: true,
153
+ sortDir: 'desc',
154
+ width: 140,
155
+ format: (k) => (k.last_used_at ? shortDate(k.last_used_at) : t('conn.s3keys.neverUsed')),
156
+ sortValue: (k) => (k.last_used_at ? new Date(k.last_used_at).getTime() : null),
157
+ },
158
+ ]);
159
+
160
+ function onRowAction(key: string, k: S3AccessKey) {
161
+ if (key === 'toggle') void toggle(k);
162
+ else if (key === 'revoke') void revoke(k);
163
+ }
164
+
100
165
  async function toggle(k: S3AccessKey) {
101
166
  busy.value = true;
102
167
  try {
@@ -183,6 +248,8 @@ function scopeOf(k: S3AccessKey): string {
183
248
 
184
249
  <p v-if="canMint === false" class="fe-s3keys__muted">{{ t('conn.s3keys.cannotMint') }}</p>
185
250
  <p v-if="error" class="fe-s3keys__warn">{{ error }}</p>
251
+ <!-- The fix, for an administrator the server chose to tell (admin_hint). -->
252
+ <p v-if="error && errorHint" class="fe-s3keys__hint" data-testid="s3keys-admin-hint">{{ errorHint }}</p>
186
253
 
187
254
  <!-- ── mint ─────────────────────────────────────────────────── -->
188
255
  <div v-if="canMint" class="fe-s3keys__form">
@@ -232,37 +299,28 @@ function scopeOf(k: S3AccessKey): string {
232
299
  </div>
233
300
 
234
301
  <!-- ── the keys ─────────────────────────────────────────────── -->
302
+ <!-- ⚠ THE table (DataTable — the explorer's own), not a table of its own:
303
+ reachable from the admin panel's Connections menu and from the
304
+ explorer, so it resizes, sorts, hides and moves columns and remembers
305
+ that on the account (`conn.s3keys`), like every other table. -->
235
306
  <p v-if="loading" class="fe-s3keys__muted">…</p>
236
- <div v-else-if="keys.length" class="fe-s3keys__scroll" :class="{ 'is-scrolled-x': scrolledX }" @scroll.passive="onScroll">
237
- <table class="fe-s3keys__table">
238
- <thead>
239
- <tr>
240
- <th>{{ t('conn.s3keys.col.label') }}</th>
241
- <th>{{ t('conn.s3keys.col.key') }}</th>
242
- <th>{{ t('conn.s3keys.col.scope') }}</th>
243
- <th>{{ t('conn.s3keys.col.lastUsed') }}</th>
244
- <th></th>
245
- </tr>
246
- </thead>
247
- <tbody>
248
- <tr v-for="k in keys" :key="k.id" :class="{ 'is-off': !!k.disabled_at }">
249
- <td>{{ k.label || t('conn.s3keys.noLabel') }}</td>
250
- <td><code>{{ k.access_key_id }}</code></td>
251
- <td>{{ scopeOf(k) }}</td>
252
- <td>{{ k.last_used_at ? shortDate(k.last_used_at) : t('conn.s3keys.neverUsed') }}</td>
253
- <td class="fe-s3keys__actions">
254
- <button class="fe-s3keys__link" :disabled="busy" @click="toggle(k)">
255
- {{ k.disabled_at ? t('conn.s3keys.enable') : t('conn.s3keys.disable') }}
256
- </button>
257
- <button class="fe-s3keys__link is-danger" :disabled="busy" @click="revoke(k)">
258
- {{ confirmRevoke === k.id ? t('conn.s3keys.confirm') : t('conn.s3keys.revoke') }}
259
- </button>
260
- </td>
261
- </tr>
262
- </tbody>
263
- </table>
264
- </div>
265
- <p v-else-if="canMint" class="fe-s3keys__muted">{{ t('conn.s3keys.empty') }}</p>
307
+ <DataTable
308
+ v-else
309
+ table-id="conn.s3keys"
310
+ :columns="columns"
311
+ :rows="keys"
312
+ row-key="id"
313
+ :locale="locale"
314
+ :empty="t('conn.s3keys.empty')"
315
+ :row-class="(k: S3AccessKey) => (k.disabled_at ? 'is-muted' : undefined)"
316
+ :row-actions="rowActions"
317
+ :row-actions-test-id="(k: S3AccessKey) => `s3-key-actions-${k.id}`"
318
+ @row-action="(key: string, k: S3AccessKey) => onRowAction(key, k)"
319
+ >
320
+ <template #cell-key="{ row }">
321
+ <code class="tbl-mono">{{ row.access_key_id }}</code>
322
+ </template>
323
+ </DataTable>
266
324
  </section>
267
325
  </template>
268
326
 
@@ -355,8 +413,7 @@ function scopeOf(k: S3AccessKey): string {
355
413
  color: var(--fe-text-muted);
356
414
  }
357
415
  .fe-s3keys__copy,
358
- .fe-s3keys__dismiss,
359
- .fe-s3keys__link {
416
+ .fe-s3keys__dismiss {
360
417
  background: none;
361
418
  border: none;
362
419
  padding: 0 4px;
@@ -364,35 +421,14 @@ function scopeOf(k: S3AccessKey): string {
364
421
  font-size: 12px;
365
422
  cursor: pointer;
366
423
  }
367
- .fe-s3keys__link.is-danger {
368
- color: var(--fe-danger);
369
- }
370
424
  .fe-s3keys__dismiss {
371
425
  align-self: flex-start;
372
426
  }
373
- .fe-s3keys__table {
374
- width: 100%;
375
- border-collapse: collapse;
376
- font-size: 12px;
377
- }
378
- .fe-s3keys__table th {
379
- text-align: left;
380
- font-weight: 500;
381
- color: var(--fe-text-muted);
382
- padding: 4px 6px;
383
- }
384
- .fe-s3keys__table td {
385
- padding: 5px 6px;
386
- border-top: 1px solid var(--fe-border);
387
- overflow-wrap: anywhere;
388
- }
389
- .fe-s3keys__table tr.is-off {
390
- opacity: 0.55;
391
- }
392
- .fe-s3keys__actions {
393
- white-space: nowrap;
394
- text-align: right;
395
- }
396
- /* Its pinning (sticky right, ground, scroll divider) is in styles/base.css
397
- next to the list view's `.fe-list__col--menu`, so the two stay one model. */
427
+ /* ⚠ The key table's own look USED to be written here — 12px type, 4-6px
428
+ padding, `--fe-border` instead of `--fe-border-soft`, its own disabled-row
429
+ opacity and its own right-aligned actions cell. All of it is deleted rather
430
+ than tuned: the panel now draws `.tbl`, and the one place that says what an
431
+ admin table looks like is packages/core/src/styles/base.css ("THE ADMIN
432
+ TABLE"). Four panels shared this block, so four tables drifted together;
433
+ sharing the real stylesheet is what stops the next drift. */
398
434
  </style>
@@ -18,7 +18,8 @@ import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
18
18
  import type { FTPSFacts, SSHPublicKey } from '../types/SSHKeys';
19
19
  import { useLocale } from '../composables/useLocale';
20
20
  import { useSSHKeys } from '../composables/useSSHKeys';
21
- import { useScrolledX } from '../composables/useScrolledX';
21
+ import DataTable, { type DataColumn } from './DataTable.vue';
22
+ import type { ContextAction } from './ContextMenu.vue';
22
23
  import { resolveLocale } from '../locales/resolve';
23
24
 
24
25
  const props = defineProps<{
@@ -51,7 +52,6 @@ const emit = defineEmits<{
51
52
 
52
53
  const locale = computed<LocaleCode>(() => resolveLocale(props.config.locale));
53
54
  const { t, formatDate } = useLocale(locale);
54
- const { scrolledX, onScroll } = useScrolledX();
55
55
 
56
56
  const { keys, connection, loading, error, canAdd, hasUsableKey, load, add, setDisabled, remove } =
57
57
  useSSHKeys(props.config);
@@ -96,6 +96,62 @@ async function submit() {
96
96
  }
97
97
  }
98
98
 
99
+ /** The row's verbs, behind its one `Actions` control. ⚠ `remove` keeps the
100
+ * two-step confirmation it had as a loose button: the first pick arms it and
101
+ * the label becomes "Confirm", the second pick deletes the key. */
102
+ function rowActions(k: SSHPublicKey): ContextAction[] {
103
+ /* The whole control used to be `:disabled="busy"`; the table draws the
104
+ control now, so each verb carries it. */
105
+ return [
106
+ {
107
+ key: 'toggle',
108
+ label: k.disabled_at ? t('conn.sshkeys.enable') : t('conn.sshkeys.disable'),
109
+ icon: k.disabled_at ? 'check' : 'lock',
110
+ disabled: busy.value,
111
+ },
112
+ {
113
+ key: 'remove',
114
+ label: confirmRemove.value === k.id ? t('conn.sshkeys.confirm') : t('conn.sshkeys.remove'),
115
+ icon: 'delete',
116
+ danger: true,
117
+ disabled: busy.value,
118
+ },
119
+ ];
120
+ }
121
+
122
+ /** The list's columns — the product's one table (DataTable). */
123
+ const columns = computed<DataColumn<SSHPublicKey>[]>(() => [
124
+ {
125
+ id: 'name',
126
+ label: t('conn.sshkeys.col.name'),
127
+ sortable: true,
128
+ width: 180,
129
+ format: (k) => k.name || t('conn.sshkeys.noName'),
130
+ },
131
+ {
132
+ id: 'fingerprint',
133
+ label: t('conn.sshkeys.col.fingerprint'),
134
+ sortable: true,
135
+ width: 240,
136
+ sortValue: (k) => fingerprintOf(k),
137
+ title: (k) => fingerprintOf(k),
138
+ },
139
+ {
140
+ id: 'lastUsed',
141
+ label: t('conn.sshkeys.col.lastUsed'),
142
+ sortable: true,
143
+ sortDir: 'desc',
144
+ width: 140,
145
+ format: (k) => (k.last_used_at ? shortDate(k.last_used_at) : t('conn.sshkeys.neverUsed')),
146
+ sortValue: (k) => (k.last_used_at ? new Date(k.last_used_at).getTime() : null),
147
+ },
148
+ ]);
149
+
150
+ function onRowAction(key: string, k: SSHPublicKey) {
151
+ if (key === 'toggle') void toggle(k);
152
+ else if (key === 'remove') void drop(k);
153
+ }
154
+
99
155
  async function toggle(k: SSHPublicKey) {
100
156
  busy.value = true;
101
157
  try {
@@ -172,35 +228,28 @@ function fingerprintOf(k: SSHPublicKey): string {
172
228
  <p class="fe-s3keys__hint">{{ t('conn.sshkeys.noCopyId') }}</p>
173
229
  </div>
174
230
 
231
+ <!-- ⚠ THE table (DataTable — the explorer's own), not a table of its own:
232
+ reachable from the admin panel's Connections menu and from the
233
+ explorer, so it resizes, sorts, hides and moves columns and remembers
234
+ that on the account (`conn.sshkeys`), like every other table. -->
175
235
  <p v-if="loading" class="fe-s3keys__muted">…</p>
176
- <div v-else-if="keys.length" class="fe-s3keys__scroll" :class="{ 'is-scrolled-x': scrolledX }" @scroll.passive="onScroll">
177
- <table class="fe-s3keys__table">
178
- <thead>
179
- <tr>
180
- <th>{{ t('conn.sshkeys.col.name') }}</th>
181
- <th>{{ t('conn.sshkeys.col.fingerprint') }}</th>
182
- <th>{{ t('conn.sshkeys.col.lastUsed') }}</th>
183
- <th></th>
184
- </tr>
185
- </thead>
186
- <tbody>
187
- <tr v-for="k in keys" :key="k.id" :class="{ 'is-off': !!k.disabled_at }">
188
- <td>{{ k.name || t('conn.sshkeys.noName') }}</td>
189
- <td><code>{{ fingerprintOf(k) }}</code></td>
190
- <td>{{ k.last_used_at ? shortDate(k.last_used_at) : t('conn.sshkeys.neverUsed') }}</td>
191
- <td class="fe-s3keys__actions">
192
- <button class="fe-s3keys__link" :disabled="busy" @click="toggle(k)">
193
- {{ k.disabled_at ? t('conn.sshkeys.enable') : t('conn.sshkeys.disable') }}
194
- </button>
195
- <button class="fe-s3keys__link is-danger" :disabled="busy" @click="drop(k)">
196
- {{ confirmRemove === k.id ? t('conn.sshkeys.confirm') : t('conn.sshkeys.remove') }}
197
- </button>
198
- </td>
199
- </tr>
200
- </tbody>
201
- </table>
202
- </div>
203
- <p v-else-if="canAdd" class="fe-s3keys__muted">{{ t('conn.sshkeys.empty') }}</p>
236
+ <DataTable
237
+ v-else
238
+ table-id="conn.sshkeys"
239
+ :columns="columns"
240
+ :rows="keys"
241
+ row-key="id"
242
+ :locale="locale"
243
+ :empty="t('conn.sshkeys.empty')"
244
+ :row-class="(k: SSHPublicKey) => (k.disabled_at ? 'is-muted' : undefined)"
245
+ :row-actions="rowActions"
246
+ :row-actions-test-id="(k: SSHPublicKey) => `ssh-key-actions-${k.id}`"
247
+ @row-action="(key: string, k: SSHPublicKey) => onRowAction(key, k)"
248
+ >
249
+ <template #cell-fingerprint="{ row }">
250
+ <code class="tbl-mono">{{ fingerprintOf(row) }}</code>
251
+ </template>
252
+ </DataTable>
204
253
  </section>
205
254
  </template>
206
255