@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.
- 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-BuhZOAx6.js → DrawioViewer-Bgrj8-zG.js} +45 -44
- package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
- package/dist/{EpubViewer-DS3mb3k8.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-CIrOn63I.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 +5241 -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 +1207 -343
- 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 +47 -17
- package/src/components/RecoveryKeyModal.vue +1 -1
- package/src/components/RowActions.vue +134 -0
- package/src/components/S3KeysPanel.vue +98 -56
- package/src/components/SSHKeysPanel.vue +79 -26
- 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 -27
- 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 +121 -0
- 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 +522 -119
- package/src/locales/resolve.ts +17 -8
- package/src/locales/tr.ts +539 -155
- 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 +2895 -241
- 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 +58 -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-CLNevfPE.js +0 -124
- package/dist/ArchiveViewer-CLNevfPE.js.map +0 -1
- package/dist/CsvViewer-K8XA3z7N.js +0 -141
- package/dist/CsvViewer-K8XA3z7N.js.map +0 -1
- package/dist/DrawioViewer-BuhZOAx6.js.map +0 -1
- package/dist/EpubViewer-DS3mb3k8.js.map +0 -1
- package/dist/IpynbViewer-BTk7p4Dn.js +0 -184
- package/dist/IpynbViewer-BTk7p4Dn.js.map +0 -1
- package/dist/MermaidViewer-DqucH_io.js +0 -137
- package/dist/MermaidViewer-DqucH_io.js.map +0 -1
- package/dist/PsdViewer-CwiVENmq.js +0 -122
- package/dist/PsdViewer-CwiVENmq.js.map +0 -1
- package/dist/TiffViewer-CpewSVmQ.js +0 -142
- package/dist/TiffViewer-CpewSVmQ.js.map +0 -1
- package/dist/Viewer3D-CIrOn63I.js.map +0 -1
- package/dist/index-Dx0IbBcI.js +0 -26831
- package/dist/index-Dx0IbBcI.js.map +0 -1
- package/dist/useViewerFetch-czqbd2Lj.js +0 -25
- 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
|
|
71
|
-
if (Number.isNaN(
|
|
72
|
-
const diff = Date.now() -
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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>
|
|
111
|
+
<h3>{{ t('recents.title') }}</h3>
|
|
87
112
|
<button
|
|
88
113
|
class="filex-recent-refresh"
|
|
89
114
|
type="button"
|
|
90
|
-
title="
|
|
91
|
-
aria-label="
|
|
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
|
|
106
|
-
|
|
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">
|
|
112
|
-
<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>
|
|
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:
|
|
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-
|
|
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="
|
|
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
|
-
<
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
.
|
|
375
|
-
|
|
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
|
-
<
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|