@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.
- package/README.md +14 -2
- package/dist/ArchiveViewer-BqU761Wk.js +149 -0
- package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
- package/dist/CsvViewer-ByHVK5ga.js +172 -0
- package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
- package/dist/{DrawioViewer-CC6rJXeP.js → DrawioViewer-Bgrj8-zG.js} +45 -44
- package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
- package/dist/{EpubViewer-CAoTSXTO.js → EpubViewer-DJwUKlHW.js} +50 -50
- package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
- package/dist/MermaidViewer-CEDTD_pp.js +136 -0
- package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
- package/dist/PsdViewer-CZcxUpZO.js +121 -0
- package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
- package/dist/TiffViewer-BwI62CbQ.js +141 -0
- package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
- package/dist/{Viewer3D-CdZeK4j4.js → Viewer3D-DoeZcuMm.js} +16 -16
- package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
- package/dist/filex-core.js +475 -219
- package/dist/filex-core.umd.cjs +165 -108
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/fonts/caveat-latin-ext.woff2 +0 -0
- package/dist/fonts/caveat-latin.woff2 +0 -0
- package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/dist/fonts/dancing-script-latin.woff2 +0 -0
- package/dist/fonts/homemade-apple-latin.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin.woff2 +0 -0
- package/dist/index-DuWnGuv5.js +35901 -0
- package/dist/index-DuWnGuv5.js.map +1 -0
- package/dist/index.d.ts +5227 -553
- package/dist/pdf-CaPYsOUo.js +21302 -0
- package/dist/pdf-CaPYsOUo.js.map +1 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +1055 -253
- package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
- package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
- package/src/assets/fonts/README.md +58 -0
- package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
- package/src/assets/fonts/caveat-latin.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
- package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
- package/src/components/AdvancedSearch.vue +49 -0
- package/src/components/Breadcrumb.vue +5 -5
- package/src/components/ChoiceButtons.vue +152 -0
- package/src/components/CommandPalette.vue +5 -5
- package/src/components/ConnectionGuideView.vue +1 -1
- package/src/components/ConnectionNotice.vue +59 -0
- package/src/components/ConnectionsPanel.vue +30 -568
- package/src/components/ContextMenu.vue +31 -5
- package/src/components/DataTable.vue +1331 -0
- package/src/components/FilePane.vue +3 -0
- package/src/components/FilterBar.vue +12 -5
- package/src/components/GalleryView.vue +50 -2
- package/src/components/GridView.vue +51 -3
- package/src/components/HomeView.vue +18 -6
- package/src/components/InspectorPanel.vue +134 -17
- package/src/components/ListView.vue +366 -900
- package/src/components/LogoMark.vue +57 -0
- package/src/components/NFSExportsPanel.vue +76 -28
- package/src/components/OnboardingTour.vue +19 -7
- package/src/components/OperationsCenter.vue +77 -1
- package/src/components/PendingOpsTray.vue +59 -5
- package/src/components/PresenceBar.vue +7 -7
- package/src/components/ProductVersion.vue +31 -0
- package/src/components/QuickLook.vue +10 -14
- package/src/components/RecentlyOpened.vue +31 -15
- package/src/components/RecoveryKeyModal.vue +1 -1
- package/src/components/RowActions.vue +134 -0
- package/src/components/S3KeysPanel.vue +98 -62
- package/src/components/SSHKeysPanel.vue +79 -30
- package/src/components/SideNav.vue +260 -96
- package/src/components/StarButton.vue +1 -0
- package/src/components/StorageFields.vue +74 -6
- package/src/components/StorageTags.vue +62 -0
- package/src/components/TabBar.vue +1 -1
- package/src/components/TagKindIcon.vue +56 -0
- package/src/components/TagPicker.vue +226 -40
- package/src/components/ThemePalette.vue +4 -4
- package/src/components/TimeZonePicker.vue +29 -10
- package/src/components/TokensPanel.vue +82 -31
- package/src/components/Toolbar.vue +28 -6
- package/src/components/plugin/PluginConfirmModal.vue +54 -0
- package/src/components/plugin/PluginInspectorSection.vue +149 -0
- package/src/components/plugin/PluginPageView.vue +339 -0
- package/src/components/plugin/PluginViewModal.vue +159 -0
- package/src/components/plugin/SurfaceConversation.vue +76 -0
- package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
- package/src/components/plugin/SurfaceRenderer.vue +299 -0
- package/src/components/plugin/SurfaceSections.vue +91 -0
- package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
- package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
- package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
- package/src/components/plugin/nodes/SurfaceList.vue +127 -0
- package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
- package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
- package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
- package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
- package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
- package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
- package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
- package/src/components/public/PublicLanguagePicker.vue +58 -0
- package/src/components/public/PublicLinkPage.vue +286 -0
- package/src/components/public/PublicLinkPreview.vue +85 -0
- package/src/components/public/PublicPinGate.vue +108 -0
- package/src/components/public/PublicRequestBody.vue +204 -0
- package/src/components/public/PublicShareBody.vue +188 -0
- package/src/components/public/PublicShell.vue +332 -0
- package/src/composables/useConnections.ts +8 -2
- package/src/composables/useFileApi.ts +183 -44
- package/src/composables/useLocale.ts +277 -41
- package/src/composables/useNFSExports.ts +8 -10
- package/src/composables/useOperations.ts +44 -3
- package/src/composables/usePendingOps.ts +59 -8
- package/src/composables/usePluginActions.ts +121 -0
- package/src/composables/usePluginSurface.ts +223 -0
- package/src/composables/usePublicBranding.ts +172 -0
- package/src/composables/usePublicLink.ts +573 -0
- package/src/composables/usePublicPage.ts +61 -0
- package/src/composables/useS3Keys.ts +17 -12
- package/src/composables/useSSHKeys.ts +8 -10
- package/src/composables/useTokens.ts +8 -12
- package/src/composables/useUploadChunked.ts +18 -8
- package/src/composables/useViewerFetch.ts +6 -3
- package/src/index.ts +404 -8
- package/src/lib/actionIcons.ts +46 -1
- package/src/lib/advSearch.ts +14 -7
- package/src/lib/appLock.ts +130 -0
- package/src/lib/connection.ts +110 -0
- package/src/lib/destinationTree.ts +19 -3
- package/src/lib/direction.ts +353 -0
- package/src/lib/dragGhost.ts +6 -0
- package/src/lib/dropLimits.ts +69 -0
- package/src/lib/errorWords.ts +394 -0
- package/src/lib/externalReach.ts +5 -1
- package/src/lib/fileFilters.ts +7 -2
- package/src/lib/fileIcons.ts +4 -0
- package/src/lib/internalPaths.ts +77 -0
- package/src/lib/listInput.ts +64 -0
- package/src/lib/listing.ts +58 -23
- package/src/lib/modalStack.ts +26 -0
- package/src/lib/nodeRow.ts +18 -3
- package/src/lib/pdfFieldRules.ts +126 -0
- package/src/lib/pdfFields.ts +589 -0
- package/src/lib/pdfFieldsGeom.ts +203 -0
- package/src/lib/pdfjsLoader.ts +51 -0
- package/src/lib/personName.ts +47 -0
- package/src/lib/pluginApplies.ts +123 -0
- package/src/lib/pluginLabel.ts +72 -0
- package/src/lib/pluginMenu.ts +253 -0
- package/src/lib/pluginPage.ts +58 -0
- package/src/lib/plural.ts +181 -0
- package/src/lib/popupLayer.ts +103 -0
- package/src/lib/prefs.ts +563 -0
- package/src/lib/productVersion.ts +35 -0
- package/src/lib/publicLayout.ts +39 -0
- package/src/lib/serviceGate.ts +104 -0
- package/src/lib/shareTtl.ts +25 -11
- package/src/lib/signFonts.ts +85 -0
- package/src/lib/signaturePad.ts +111 -0
- package/src/lib/sortOrder.ts +100 -38
- package/src/lib/star.ts +7 -1
- package/src/lib/surfaceCell.ts +41 -0
- package/src/lib/surfaceConditions.ts +156 -0
- package/src/lib/surfaceOpen.ts +106 -0
- package/src/lib/surfaceSteps.ts +68 -0
- package/src/lib/surfaceValues.ts +191 -0
- package/src/lib/symlink.ts +150 -0
- package/src/lib/tableColumns.ts +493 -0
- package/src/lib/tableEnv.ts +34 -0
- package/src/lib/tablePrefs.ts +84 -0
- package/src/lib/tags.ts +121 -12
- package/src/lib/themes.ts +286 -18
- package/src/lib/tour.ts +152 -0
- package/src/lib/trashEmpty.ts +111 -0
- package/src/lib/trashTimeLeft.ts +23 -0
- package/src/lib/uiLocales.ts +460 -0
- package/src/lib/viewPrefs.ts +696 -660
- package/src/lib/viewPrefsHttp.ts +78 -0
- package/src/locales/en.ts +515 -117
- package/src/locales/resolve.ts +17 -8
- package/src/locales/tr.ts +536 -152
- package/src/modals/ConvertModal.vue +62 -11
- package/src/modals/DestinationPickerModal.vue +59 -16
- package/src/modals/Modal.vue +113 -23
- package/src/modals/NewDocumentModal.vue +16 -6
- package/src/modals/NewFolderModal.vue +8 -0
- package/src/modals/PermissionsModal.vue +272 -163
- package/src/modals/PreviewModal.vue +119 -20
- package/src/modals/RenameModal.vue +37 -1
- package/src/styles/base.css +2890 -260
- package/src/styles/sign-fonts.css +118 -0
- package/src/styles/variables.css +60 -0
- package/src/types/Connections.ts +17 -0
- package/src/types/ExplorerConfig.ts +117 -1
- package/src/types/FileNode.ts +50 -0
- package/src/types/Plugins.ts +494 -0
- package/src/types/Public.ts +212 -0
- package/src/viewers/ArchiveViewer.vue +93 -48
- package/src/viewers/CsvViewer.vue +99 -53
- package/src/viewers/DrawioViewer.vue +35 -9
- package/src/viewers/EpubViewer.vue +7 -8
- package/src/viewers/IpynbViewer.vue +3 -1
- package/src/viewers/MermaidViewer.vue +4 -5
- package/src/viewers/PdfViewer.vue +8 -17
- package/src/viewers/PsdViewer.vue +2 -4
- package/src/viewers/TiffViewer.vue +4 -5
- package/src/viewers/Viewer3D.vue +1 -1
- package/LICENSE +0 -21
- package/dist/ArchiveViewer-BUQWLhwX.js +0 -124
- package/dist/ArchiveViewer-BUQWLhwX.js.map +0 -1
- package/dist/CsvViewer-Bg2_aqzc.js +0 -141
- package/dist/CsvViewer-Bg2_aqzc.js.map +0 -1
- package/dist/DrawioViewer-CC6rJXeP.js.map +0 -1
- package/dist/EpubViewer-CAoTSXTO.js.map +0 -1
- package/dist/IpynbViewer-D0GwGpnE.js +0 -184
- package/dist/IpynbViewer-D0GwGpnE.js.map +0 -1
- package/dist/MermaidViewer-DGwAvRZg.js +0 -137
- package/dist/MermaidViewer-DGwAvRZg.js.map +0 -1
- package/dist/PsdViewer-DhGweA0m.js +0 -122
- package/dist/PsdViewer-DhGweA0m.js.map +0 -1
- package/dist/TiffViewer-D0AurvAh.js +0 -142
- package/dist/TiffViewer-D0AurvAh.js.map +0 -1
- package/dist/Viewer3D-CdZeK4j4.js.map +0 -1
- package/dist/index-BArI-B1M.js +0 -26911
- package/dist/index-BArI-B1M.js.map +0 -1
- package/dist/useViewerFetch-czqbd2Lj.js +0 -25
- package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
- 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
|
|
80
|
-
if (Number.isNaN(
|
|
81
|
-
const diff = Date.now() -
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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>
|
|
111
|
+
<h3>{{ t('recents.title') }}</h3>
|
|
96
112
|
<button
|
|
97
113
|
class="filex-recent-refresh"
|
|
98
114
|
type="button"
|
|
99
|
-
title="
|
|
100
|
-
aria-label="
|
|
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">
|
|
126
|
-
<p v-else class="filex-recent-empty">
|
|
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:
|
|
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-
|
|
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="
|
|
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 {
|
|
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
|
-
<
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
.
|
|
379
|
-
|
|
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 {
|
|
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
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|