@brftech/filex-core 0.42.1 → 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 (233) 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-BuhZOAx6.js → DrawioViewer-Bgrj8-zG.js} +45 -44
  7. package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
  8. package/dist/{EpubViewer-DS3mb3k8.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-CIrOn63I.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 +5241 -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 +1207 -343
  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 +47 -17
  73. package/src/components/RecoveryKeyModal.vue +1 -1
  74. package/src/components/RowActions.vue +134 -0
  75. package/src/components/S3KeysPanel.vue +98 -56
  76. package/src/components/SSHKeysPanel.vue +79 -26
  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 -27
  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 +121 -0
  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 +522 -119
  186. package/src/locales/resolve.ts +17 -8
  187. package/src/locales/tr.ts +539 -155
  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 +2895 -241
  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 +58 -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-CLNevfPE.js +0 -124
  216. package/dist/ArchiveViewer-CLNevfPE.js.map +0 -1
  217. package/dist/CsvViewer-K8XA3z7N.js +0 -141
  218. package/dist/CsvViewer-K8XA3z7N.js.map +0 -1
  219. package/dist/DrawioViewer-BuhZOAx6.js.map +0 -1
  220. package/dist/EpubViewer-DS3mb3k8.js.map +0 -1
  221. package/dist/IpynbViewer-BTk7p4Dn.js +0 -184
  222. package/dist/IpynbViewer-BTk7p4Dn.js.map +0 -1
  223. package/dist/MermaidViewer-DqucH_io.js +0 -137
  224. package/dist/MermaidViewer-DqucH_io.js.map +0 -1
  225. package/dist/PsdViewer-CwiVENmq.js +0 -122
  226. package/dist/PsdViewer-CwiVENmq.js.map +0 -1
  227. package/dist/TiffViewer-CpewSVmQ.js +0 -142
  228. package/dist/TiffViewer-CpewSVmQ.js.map +0 -1
  229. package/dist/Viewer3D-CIrOn63I.js.map +0 -1
  230. package/dist/index-Dx0IbBcI.js +0 -26831
  231. package/dist/index-Dx0IbBcI.js.map +0 -1
  232. package/dist/useViewerFetch-czqbd2Lj.js +0 -25
  233. package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
@@ -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;
@@ -13,6 +14,10 @@ interface RecentNode {
13
14
  name: string;
14
15
  mime?: string;
15
16
  last_opened?: string;
17
+ /** The raw row carries more (`storage`, `perm`, `read_only`, `type`,
18
+ * the mtimes…) — the explorer turns it into a FileNode for the context
19
+ * menu with the same converter Recent / Starred / a tag view use. */
20
+ [k: string]: unknown;
16
21
  }
17
22
 
18
23
  const props = defineProps<{
@@ -26,10 +31,23 @@ const props = defineProps<{
26
31
  limit?: number;
27
32
  /** Optional refresh trigger — incrementing this re-fetches. */
28
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;
29
40
  }>();
30
41
 
42
+ const { t } = useLocale(() => props.locale ?? 'en');
43
+
31
44
  const emit = defineEmits<{
32
45
  (e: 'open', node: RecentNode): void;
46
+ /** Right-click on a row. The tray does not build a menu of its own — the
47
+ * explorer opens the ONE menu every listing row gets (owner: "CONTEXT
48
+ * MENÜ HER YERDE AYNI OLMALI"), so a file here offers exactly what the
49
+ * same file offers in its folder. */
50
+ (e: 'context', node: RecentNode, ev: MouseEvent): void;
33
51
  (e: 'error', message: string): void;
34
52
  }>();
35
53
 
@@ -67,13 +85,20 @@ async function load() {
67
85
 
68
86
  function fmtTime(s?: string): string {
69
87
  if (!s) return '';
70
- const t = new Date(s).getTime();
71
- if (Number.isNaN(t)) return '';
72
- const diff = Date.now() - t;
73
- if (diff < 60_000) return 'just now';
74
- if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
75
- if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
76
- 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
+ }
77
102
  }
78
103
 
79
104
  onMounted(load);
@@ -83,12 +108,12 @@ watch(() => props.refreshKey, load);
83
108
  <template>
84
109
  <div class="filex-recent">
85
110
  <header>
86
- <h3>Recently opened</h3>
111
+ <h3>{{ t('recents.title') }}</h3>
87
112
  <button
88
113
  class="filex-recent-refresh"
89
114
  type="button"
90
- title="Refresh"
91
- aria-label="Refresh"
115
+ :title="t('recents.refresh')"
116
+ :aria-label="t('recents.refresh')"
92
117
  @click="load"
93
118
  :disabled="loading"
94
119
  >
@@ -101,15 +126,20 @@ watch(() => props.refreshKey, load);
101
126
  </header>
102
127
 
103
128
  <ul v-if="items.length">
104
- <li v-for="n in items" :key="n.id">
105
- <button type="button" @click="emit('open', n)" class="filex-recent-item">
106
- <span class="filex-recent-name">{{ n.name }}</span>
129
+ <li v-for="n in items" :key="n.id" data-testid="recent-tray-item" :data-fe-path="n.path">
130
+ <button
131
+ type="button"
132
+ class="filex-recent-item"
133
+ @click="emit('open', n)"
134
+ @contextmenu.prevent="emit('context', n, $event)"
135
+ >
136
+ <span class="filex-recent-name"><bdi>{{ n.name }}</bdi></span>
107
137
  <span class="filex-recent-meta">{{ fmtTime(n.last_opened) }}</span>
108
138
  </button>
109
139
  </li>
110
140
  </ul>
111
- <p v-else-if="!loading" class="filex-recent-empty">Nothing here yet.</p>
112
- <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>
113
143
  </div>
114
144
  </template>
115
145
 
@@ -167,7 +197,7 @@ watch(() => props.refreshKey, load);
167
197
  padding: 6px 8px;
168
198
  cursor: pointer;
169
199
  border-radius: 4px;
170
- text-align: left;
200
+ text-align: start;
171
201
  }
172
202
  .filex-recent-item:hover {
173
203
  background: var(--fe-bg-hover, var(--filex-bg-soft, #f3f4f6));
@@ -181,7 +211,7 @@ watch(() => props.refreshKey, load);
181
211
  .filex-recent-meta {
182
212
  color: var(--fe-text-muted, var(--filex-text-muted, #9ca3af));
183
213
  font-size: 11px;
184
- margin-left: 8px;
214
+ margin-inline-start: 8px;
185
215
  flex-shrink: 0;
186
216
  }
187
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,6 +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 DataTable, { type DataColumn } from './DataTable.vue';
26
+ import type { ContextAction } from './ContextMenu.vue';
25
27
  import { resolveLocale } from '../locales/resolve';
26
28
 
27
29
  const props = defineProps<{
@@ -43,6 +45,7 @@ const {
43
45
  connection,
44
46
  loading,
45
47
  error,
48
+ errorHint,
46
49
  canMint,
47
50
  revealed,
48
51
  guideKey,
@@ -95,6 +98,70 @@ async function mint() {
95
98
  }
96
99
  }
97
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
+
98
165
  async function toggle(k: S3AccessKey) {
99
166
  busy.value = true;
100
167
  try {
@@ -181,6 +248,8 @@ function scopeOf(k: S3AccessKey): string {
181
248
 
182
249
  <p v-if="canMint === false" class="fe-s3keys__muted">{{ t('conn.s3keys.cannotMint') }}</p>
183
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>
184
253
 
185
254
  <!-- ── mint ─────────────────────────────────────────────────── -->
186
255
  <div v-if="canMint" class="fe-s3keys__form">
@@ -230,35 +299,28 @@ function scopeOf(k: S3AccessKey): string {
230
299
  </div>
231
300
 
232
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. -->
233
306
  <p v-if="loading" class="fe-s3keys__muted">…</p>
234
- <table v-else-if="keys.length" class="fe-s3keys__table">
235
- <thead>
236
- <tr>
237
- <th>{{ t('conn.s3keys.col.label') }}</th>
238
- <th>{{ t('conn.s3keys.col.key') }}</th>
239
- <th>{{ t('conn.s3keys.col.scope') }}</th>
240
- <th>{{ t('conn.s3keys.col.lastUsed') }}</th>
241
- <th></th>
242
- </tr>
243
- </thead>
244
- <tbody>
245
- <tr v-for="k in keys" :key="k.id" :class="{ 'is-off': !!k.disabled_at }">
246
- <td>{{ k.label || t('conn.s3keys.noLabel') }}</td>
247
- <td><code>{{ k.access_key_id }}</code></td>
248
- <td>{{ scopeOf(k) }}</td>
249
- <td>{{ k.last_used_at ? shortDate(k.last_used_at) : t('conn.s3keys.neverUsed') }}</td>
250
- <td class="fe-s3keys__actions">
251
- <button class="fe-s3keys__link" :disabled="busy" @click="toggle(k)">
252
- {{ k.disabled_at ? t('conn.s3keys.enable') : t('conn.s3keys.disable') }}
253
- </button>
254
- <button class="fe-s3keys__link is-danger" :disabled="busy" @click="revoke(k)">
255
- {{ confirmRevoke === k.id ? t('conn.s3keys.confirm') : t('conn.s3keys.revoke') }}
256
- </button>
257
- </td>
258
- </tr>
259
- </tbody>
260
- </table>
261
- <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>
262
324
  </section>
263
325
  </template>
264
326
 
@@ -351,8 +413,7 @@ function scopeOf(k: S3AccessKey): string {
351
413
  color: var(--fe-text-muted);
352
414
  }
353
415
  .fe-s3keys__copy,
354
- .fe-s3keys__dismiss,
355
- .fe-s3keys__link {
416
+ .fe-s3keys__dismiss {
356
417
  background: none;
357
418
  border: none;
358
419
  padding: 0 4px;
@@ -360,33 +421,14 @@ function scopeOf(k: S3AccessKey): string {
360
421
  font-size: 12px;
361
422
  cursor: pointer;
362
423
  }
363
- .fe-s3keys__link.is-danger {
364
- color: var(--fe-danger);
365
- }
366
424
  .fe-s3keys__dismiss {
367
425
  align-self: flex-start;
368
426
  }
369
- .fe-s3keys__table {
370
- width: 100%;
371
- border-collapse: collapse;
372
- font-size: 12px;
373
- }
374
- .fe-s3keys__table th {
375
- text-align: left;
376
- font-weight: 500;
377
- color: var(--fe-text-muted);
378
- padding: 4px 6px;
379
- }
380
- .fe-s3keys__table td {
381
- padding: 5px 6px;
382
- border-top: 1px solid var(--fe-border);
383
- overflow-wrap: anywhere;
384
- }
385
- .fe-s3keys__table tr.is-off {
386
- opacity: 0.55;
387
- }
388
- .fe-s3keys__actions {
389
- white-space: nowrap;
390
- text-align: right;
391
- }
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. */
392
434
  </style>
@@ -18,6 +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 DataTable, { type DataColumn } from './DataTable.vue';
22
+ import type { ContextAction } from './ContextMenu.vue';
21
23
  import { resolveLocale } from '../locales/resolve';
22
24
 
23
25
  const props = defineProps<{
@@ -94,6 +96,62 @@ async function submit() {
94
96
  }
95
97
  }
96
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
+
97
155
  async function toggle(k: SSHPublicKey) {
98
156
  busy.value = true;
99
157
  try {
@@ -170,33 +228,28 @@ function fingerprintOf(k: SSHPublicKey): string {
170
228
  <p class="fe-s3keys__hint">{{ t('conn.sshkeys.noCopyId') }}</p>
171
229
  </div>
172
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. -->
173
235
  <p v-if="loading" class="fe-s3keys__muted">…</p>
174
- <table v-else-if="keys.length" class="fe-s3keys__table">
175
- <thead>
176
- <tr>
177
- <th>{{ t('conn.sshkeys.col.name') }}</th>
178
- <th>{{ t('conn.sshkeys.col.fingerprint') }}</th>
179
- <th>{{ t('conn.sshkeys.col.lastUsed') }}</th>
180
- <th></th>
181
- </tr>
182
- </thead>
183
- <tbody>
184
- <tr v-for="k in keys" :key="k.id" :class="{ 'is-off': !!k.disabled_at }">
185
- <td>{{ k.name || t('conn.sshkeys.noName') }}</td>
186
- <td><code>{{ fingerprintOf(k) }}</code></td>
187
- <td>{{ k.last_used_at ? shortDate(k.last_used_at) : t('conn.sshkeys.neverUsed') }}</td>
188
- <td class="fe-s3keys__actions">
189
- <button class="fe-s3keys__link" :disabled="busy" @click="toggle(k)">
190
- {{ k.disabled_at ? t('conn.sshkeys.enable') : t('conn.sshkeys.disable') }}
191
- </button>
192
- <button class="fe-s3keys__link is-danger" :disabled="busy" @click="drop(k)">
193
- {{ confirmRemove === k.id ? t('conn.sshkeys.confirm') : t('conn.sshkeys.remove') }}
194
- </button>
195
- </td>
196
- </tr>
197
- </tbody>
198
- </table>
199
- <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>
200
253
  </section>
201
254
  </template>
202
255