fix(gridfs): support bulk download and paged browsing (#2743)
This commit is contained in:
13 files changed
+493
-48
No files matched your search
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Download, Filter, Plus, RefreshCcw, Trash2, Upload, X } from "@lucide/vue";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, Download, Filter, LoaderCircle, Plus, RefreshCcw, Trash2, Upload, X } from "@lucide/vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -9,12 +9,16 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { buildDocumentFilterCondition, currentDocumentFilterJson, currentDocumentSortJson, documentFilterModeNeedsValue, documentFilterModeOptions, documentStoreProviderFor, type DocumentFilterMode, type DocumentFilterRule } from "@/lib/app/documentStoreProvider";
|
||||
import { downloadBinaryCellPayload, type BinaryCellDownloadPayload } from "@/lib/dataGrid/binaryCellDownload";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { buildGridFsFilesStructuredFilter, createGridFsFileFilterRule, gridFsFileFieldDisplayOption, gridFsFileFieldDisplayOptions, currentGridFsFileSortDirection, gridFsFilesQueryPreview } from "@/lib/document/gridFsBrowser";
|
||||
import { downloadBinaryCellPayload, type BinaryCellDownloadPayload } from "@/lib/dataGrid/binaryCellDownload";
|
||||
import { normalizeResultPageSize, resultPageSizeMenuOptions } from "@/lib/dataGrid/paginationPageSize";
|
||||
import { buildGridFsFilesStructuredFilter, createGridFsFileFilterRule, currentGridFsFileSortDirection, gridFsFileFieldDisplayOption, gridFsFileFieldDisplayOptions, gridFsFilesQueryPreview } from "@/lib/document/gridFsBrowser";
|
||||
import { buildGridFsDownloadArchive, defaultGridFsArchiveFileName, formatGridFsUploadDate } from "@/lib/document/gridfsFiles";
|
||||
import { clampGridFsPage, gridFsTotalPages, paginateGridFsItems } from "@/lib/document/gridfsPagination";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
@@ -25,22 +29,38 @@ const props = defineProps<{
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const downloading = ref(false);
|
||||
const deleting = ref(false);
|
||||
const error = ref("");
|
||||
const files = ref<Awaited<ReturnType<typeof api.documentListGridFsFiles>>>([]);
|
||||
const selectedFileId = ref("");
|
||||
const checkedFileIds = ref<Set<string>>(new Set());
|
||||
const showDeleteConfirm = ref(false);
|
||||
const filterInput = ref("");
|
||||
const sortInput = ref("");
|
||||
const filterBuilderOpen = ref(false);
|
||||
const filterRules = ref<DocumentFilterRule[]>([createGridFsFileFilterRule(uuid())]);
|
||||
const appliedStructuredFilter = ref<Record<string, unknown> | null>(null);
|
||||
const page = ref(0);
|
||||
const pageSize = ref(normalizeResultPageSize(settingsStore.editorSettings.pageSize));
|
||||
|
||||
const totalBytes = computed(() => files.value.reduce((sum, file) => sum + (file.length || 0), 0));
|
||||
const totalFileCount = computed(() => files.value.length);
|
||||
const totalPages = computed(() => gridFsTotalPages(totalFileCount.value, pageSize.value));
|
||||
const pagedFiles = computed(() => paginateGridFsItems(files.value, page.value, pageSize.value));
|
||||
const pageSizeOptions = computed(() => resultPageSizeMenuOptions(pageSize.value));
|
||||
const selectedFile = computed(() => files.value.find((file) => file.id === selectedFileId.value) || null);
|
||||
const checkedFiles = computed(() => files.value.filter((file) => checkedFileIds.value.has(file.id)));
|
||||
const checkedFileCount = computed(() => checkedFiles.value.length);
|
||||
const canToggleAllFiles = computed(() => files.value.length > 0);
|
||||
const allFilesChecked = computed(() => files.value.length > 0 && checkedFileIds.value.size === files.value.length);
|
||||
const partiallyChecked = computed(() => checkedFileIds.value.size > 0 && checkedFileIds.value.size < files.value.length);
|
||||
const downloadTargets = computed(() => (checkedFileCount.value > 0 ? checkedFiles.value : selectedFile.value ? [selectedFile.value] : []));
|
||||
const downloadButtonLabel = computed(() => (checkedFileCount.value > 0 ? t("gridfsBrowser.downloadSelected") : t("gridfsBrowser.downloadFile")));
|
||||
const selectedMetadata = computed(() => (selectedFile.value?.metadata ? JSON.stringify(selectedFile.value.metadata, null, 2) : ""));
|
||||
const isReadonly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
|
||||
const mongoProvider = documentStoreProviderFor("mongodb");
|
||||
@@ -85,6 +105,10 @@ function gridFsFileFieldLabel(fieldName: string): string {
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
function formattedUploadDate(value?: string | null): string {
|
||||
return formatGridFsUploadDate(value);
|
||||
}
|
||||
|
||||
function currentFilesFilter(): string | undefined {
|
||||
return currentDocumentFilterJson(filterInput.value, appliedStructuredFilter.value, "mongodb");
|
||||
}
|
||||
@@ -121,15 +145,57 @@ function resetFilterBuilder() {
|
||||
filterRules.value = [createGridFsFileFilterRule(uuid())];
|
||||
}
|
||||
|
||||
function isFileChecked(fileId: string): boolean {
|
||||
return checkedFileIds.value.has(fileId);
|
||||
}
|
||||
|
||||
function setFileChecked(fileId: string, checked: boolean) {
|
||||
const next = new Set(checkedFileIds.value);
|
||||
if (checked) next.add(fileId);
|
||||
else next.delete(fileId);
|
||||
checkedFileIds.value = next;
|
||||
}
|
||||
|
||||
function toggleAllFiles(checked: boolean) {
|
||||
checkedFileIds.value = checked ? new Set(files.value.map((file) => file.id)) : new Set();
|
||||
}
|
||||
|
||||
function ensurePageInRange() {
|
||||
page.value = clampGridFsPage(page.value, totalFileCount.value, pageSize.value);
|
||||
}
|
||||
|
||||
function resetToFirstPage() {
|
||||
page.value = 0;
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (page.value <= 0) return;
|
||||
page.value -= 1;
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (page.value + 1 >= totalPages.value) return;
|
||||
page.value += 1;
|
||||
}
|
||||
|
||||
function updatePageSize(nextValue: unknown) {
|
||||
const normalized = normalizeResultPageSize(nextValue, pageSize.value);
|
||||
pageSize.value = normalized;
|
||||
resetToFirstPage();
|
||||
settingsStore.updateEditorSettings({ pageSize: normalized });
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const nextFiles = await api.documentListGridFsFiles(props.connectionId, props.database, props.bucket, currentFilesFilter(), currentDocumentSortJson(sortInput.value));
|
||||
files.value = nextFiles;
|
||||
checkedFileIds.value = new Set(nextFiles.filter((file) => checkedFileIds.value.has(file.id)).map((file) => file.id));
|
||||
if (selectedFileId.value && !nextFiles.some((file) => file.id === selectedFileId.value)) {
|
||||
selectedFileId.value = "";
|
||||
}
|
||||
ensurePageInRange();
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || String(e);
|
||||
} finally {
|
||||
@@ -138,6 +204,7 @@ async function loadFiles() {
|
||||
}
|
||||
|
||||
function applyQuery() {
|
||||
resetToFirstPage();
|
||||
void loadFiles();
|
||||
}
|
||||
|
||||
@@ -152,6 +219,7 @@ function toggleSortForColumn(column: string) {
|
||||
const current = currentSortDirection(column);
|
||||
const nextDirection: SortDirection = current === "asc" ? "desc" : current === "desc" ? null : "asc";
|
||||
sortInput.value = mongoProvider.sortInputForColumn(column, nextDirection);
|
||||
resetToFirstPage();
|
||||
void loadFiles();
|
||||
}
|
||||
|
||||
@@ -167,6 +235,7 @@ function sortIconClass(column: string): string {
|
||||
function applyStructuredFilters() {
|
||||
appliedStructuredFilter.value = buildGridFsFilesStructuredFilter(filterRules.value);
|
||||
filterBuilderOpen.value = false;
|
||||
resetToFirstPage();
|
||||
void loadFiles();
|
||||
}
|
||||
|
||||
@@ -174,23 +243,64 @@ function clearAllFilters() {
|
||||
filterInput.value = "";
|
||||
appliedStructuredFilter.value = null;
|
||||
filterRules.value = [createGridFsFileFilterRule(uuid())];
|
||||
resetToFirstPage();
|
||||
void loadFiles();
|
||||
}
|
||||
|
||||
async function downloadFile(file: (typeof files.value)[number]) {
|
||||
async function downloadSingleFile(file: (typeof files.value)[number]) {
|
||||
const bytes = await api.documentDownloadGridFsFile(props.connectionId, props.database, props.bucket, file.id);
|
||||
const payload: BinaryCellDownloadPayload = {
|
||||
data: bytes,
|
||||
mimeType: file.contentType || "application/octet-stream",
|
||||
extension: file.filename?.includes(".") ? file.filename.split(".").pop() || "bin" : "bin",
|
||||
};
|
||||
return downloadBinaryCellPayload(payload, displayName(file));
|
||||
}
|
||||
|
||||
async function downloadMultipleFiles(selectedFiles: typeof files.value) {
|
||||
const archiveEntries: Array<{ id: string; filename?: string; data: Uint8Array }> = [];
|
||||
for (const file of selectedFiles) {
|
||||
archiveEntries.push({
|
||||
id: file.id,
|
||||
filename: file.filename,
|
||||
data: await api.documentDownloadGridFsFile(props.connectionId, props.database, props.bucket, file.id),
|
||||
});
|
||||
}
|
||||
const payload: BinaryCellDownloadPayload = {
|
||||
data: buildGridFsDownloadArchive(archiveEntries),
|
||||
mimeType: "application/zip",
|
||||
extension: "zip",
|
||||
};
|
||||
return downloadBinaryCellPayload(payload, defaultGridFsArchiveFileName(props.bucket));
|
||||
}
|
||||
|
||||
async function downloadSpecificFile(file: (typeof files.value)[number]) {
|
||||
if (downloading.value) return;
|
||||
downloading.value = true;
|
||||
try {
|
||||
const bytes = await api.documentDownloadGridFsFile(props.connectionId, props.database, props.bucket, file.id);
|
||||
const payload: BinaryCellDownloadPayload = {
|
||||
data: bytes,
|
||||
mimeType: file.contentType || "application/octet-stream",
|
||||
extension: file.filename?.includes(".") ? file.filename.split(".").pop() || "bin" : "bin",
|
||||
};
|
||||
const result = await downloadBinaryCellPayload(payload, displayName(file));
|
||||
const result = await downloadSingleFile(file);
|
||||
if (result.kind === "saved") {
|
||||
toast(t("grid.exported"), 2500);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
downloading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadCurrentSelection() {
|
||||
if (downloading.value || downloadTargets.value.length === 0) return;
|
||||
downloading.value = true;
|
||||
try {
|
||||
const result = downloadTargets.value.length === 1 ? await downloadSingleFile(downloadTargets.value[0]) : await downloadMultipleFiles(downloadTargets.value);
|
||||
if (result.kind === "saved") {
|
||||
toast(t("grid.exported"), 2500);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
downloading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +360,7 @@ async function deleteSelectedFile() {
|
||||
await api.documentDeleteGridFsFile(props.connectionId, props.database, props.bucket, file.id);
|
||||
showDeleteConfirm.value = false;
|
||||
selectedFileId.value = "";
|
||||
setFileChecked(file.id, false);
|
||||
await loadFiles();
|
||||
toast(t("gridfsBrowser.fileDeleted", { fileName: displayName(file) }), 2500);
|
||||
} catch (e: any) {
|
||||
@@ -262,24 +373,53 @@ async function deleteSelectedFile() {
|
||||
onMounted(() => {
|
||||
void loadFiles();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => settingsStore.editorSettings.pageSize,
|
||||
(value) => {
|
||||
pageSize.value = normalizeResultPageSize(value, pageSize.value);
|
||||
ensurePageInRange();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div class="border-b border-border px-4 py-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold">{{ database }}.{{ bucket }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ files.length }} {{ t("gridfsBrowser.fileCount") }} / {{ formatBytes(totalBytes) }}</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ totalFileCount }} {{ t("gridfsBrowser.fileCount") }} / {{ formatBytes(totalBytes) }}
|
||||
<span v-if="checkedFileCount > 0"> / {{ t("gridfsBrowser.selectedCount", { count: checkedFileCount }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="totalFileCount > 0" class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="page <= 0" @click="prevPage">
|
||||
<ChevronLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span class="tabular-nums">{{ page + 1 }} / {{ totalPages }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="page + 1 >= totalPages" @click="nextPage">
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Select :model-value="String(pageSize)" @update:model-value="(value) => updatePageSize(value)">
|
||||
<SelectTrigger class="h-7 w-[106px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem v-for="size in pageSizeOptions" :key="size" :value="String(size)"> {{ size }}{{ t("grid.rowsPerPageShort") }} </SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="isReadonly || uploading" @click="uploadFile">
|
||||
<Upload class="h-3.5 w-3.5" />
|
||||
{{ t("gridfsBrowser.uploadFile") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="!selectedFile" @click="selectedFile && downloadFile(selectedFile)">
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
{{ t("gridfsBrowser.downloadFile") }}
|
||||
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="downloadTargets.length === 0 || downloading" @click="downloadCurrentSelection">
|
||||
<LoaderCircle v-if="downloading" class="h-3.5 w-3.5 animate-spin" />
|
||||
<Download v-else class="h-3.5 w-3.5" />
|
||||
{{ downloadButtonLabel }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" class="h-8 gap-1.5" :disabled="isReadonly || !selectedFile || deleting" @click="showDeleteConfirm = true">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
@@ -442,49 +582,52 @@ onMounted(() => {
|
||||
{{ t("gridfsBrowser.emptyFiles") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="min-h-0 flex-1 flex-col xl:flex-row xl:divide-x xl:divide-border">
|
||||
<div v-else class="min-h-0 flex flex-1 flex-col overflow-hidden xl:flex-row xl:divide-x xl:divide-border">
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<table class="min-w-full border-collapse text-sm">
|
||||
<table class="min-w-full border-collapse text-[13px]">
|
||||
<thead class="sticky top-0 z-10 bg-background">
|
||||
<tr class="border-b border-border text-left text-xs text-muted-foreground">
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('filename')">
|
||||
<th class="w-11 px-3 py-2 font-medium">
|
||||
<input type="checkbox" class="h-3.5 w-3.5 accent-primary" :checked="allFilesChecked" :indeterminate="partiallyChecked" :disabled="!canToggleAllFiles" @click.stop @change="toggleAllFiles(($event.target as HTMLInputElement).checked)" />
|
||||
</th>
|
||||
<th class="min-w-[320px] px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('filename')">
|
||||
<span>{{ gridFsFileFieldLabel("filename") }}</span>
|
||||
<component :is="sortIconForColumn('filename')" class="h-3.5 w-3.5" :class="sortIconClass('filename')" />
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('_id')">
|
||||
<th class="min-w-[180px] px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('_id')">
|
||||
<span>{{ gridFsFileFieldLabel("_id") }}</span>
|
||||
<component :is="sortIconForColumn('_id')" class="h-3.5 w-3.5" :class="sortIconClass('_id')" />
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('length')">
|
||||
<th class="min-w-[96px] px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('length')">
|
||||
<span>{{ gridFsFileFieldLabel("length") }}</span>
|
||||
<component :is="sortIconForColumn('length')" class="h-3.5 w-3.5" :class="sortIconClass('length')" />
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('chunkSize')">
|
||||
<th class="min-w-[96px] px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('chunkSize')">
|
||||
<span>{{ gridFsFileFieldLabel("chunkSize") }}</span>
|
||||
<component :is="sortIconForColumn('chunkSize')" class="h-3.5 w-3.5" :class="sortIconClass('chunkSize')" />
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('uploadDate')">
|
||||
<th class="min-w-[158px] px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('uploadDate')">
|
||||
<span>{{ gridFsFileFieldLabel("uploadDate") }}</span>
|
||||
<component :is="sortIconForColumn('uploadDate')" class="h-3.5 w-3.5" :class="sortIconClass('uploadDate')" />
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('contentType')">
|
||||
<th class="min-w-[104px] px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('contentType')">
|
||||
<span>{{ gridFsFileFieldLabel("contentType") }}</span>
|
||||
<component :is="sortIconForColumn('contentType')" class="h-3.5 w-3.5" :class="sortIconClass('contentType')" />
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('md5')">
|
||||
<th class="min-w-[220px] px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('md5')">
|
||||
<span>{{ gridFsFileFieldLabel("md5") }}</span>
|
||||
<component :is="sortIconForColumn('md5')" class="h-3.5 w-3.5" :class="sortIconClass('md5')" />
|
||||
</button>
|
||||
@@ -492,23 +635,36 @@ onMounted(() => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="file in files" :key="file.id" class="cursor-pointer border-b border-border/60 transition-colors hover:bg-muted/40" :class="{ 'bg-accent/45': selectedFileId === file.id }" @click="selectedFileId = file.id" @dblclick="downloadFile(file)">
|
||||
<td class="px-4 py-2 align-top">
|
||||
<div class="font-medium">{{ displayName(file) }}</div>
|
||||
<div v-if="file.aliases?.length" class="mt-1 text-xs text-muted-foreground">{{ file.aliases.join(", ") }}</div>
|
||||
<tr
|
||||
v-for="file in pagedFiles"
|
||||
:key="file.id"
|
||||
class="cursor-pointer border-b border-border/60 transition-colors hover:bg-muted/40"
|
||||
:class="{
|
||||
'bg-accent/45': selectedFileId === file.id,
|
||||
'bg-muted/35': selectedFileId !== file.id && isFileChecked(file.id),
|
||||
}"
|
||||
@click="selectedFileId = file.id"
|
||||
@dblclick="downloadSpecificFile(file)"
|
||||
>
|
||||
<td class="px-3 py-2 align-top">
|
||||
<input type="checkbox" class="h-3.5 w-3.5 accent-primary" :checked="isFileChecked(file.id)" @click.stop @change="setFileChecked(file.id, ($event.target as HTMLInputElement).checked)" />
|
||||
</td>
|
||||
<td class="px-4 py-2 align-top text-xs text-muted-foreground">{{ file.id }}</td>
|
||||
<td class="px-4 py-2 align-top text-muted-foreground">{{ formatBytes(file.length) }}</td>
|
||||
<td class="px-4 py-2 align-top text-muted-foreground">{{ formatBytes(file.chunkSize || 0) }}</td>
|
||||
<td class="px-4 py-2 align-top text-muted-foreground">{{ file.uploadDate || "-" }}</td>
|
||||
<td class="px-4 py-2 align-top text-muted-foreground">{{ file.contentType || "-" }}</td>
|
||||
<td class="px-4 py-2 align-top text-xs text-muted-foreground">{{ file.md5 || "-" }}</td>
|
||||
<td class="max-w-[440px] px-4 py-2 align-top">
|
||||
<div class="truncate font-medium" :title="displayName(file)">{{ displayName(file) }}</div>
|
||||
<div v-if="file.aliases?.length" class="mt-1 truncate text-xs text-muted-foreground" :title="file.aliases.join(', ')">{{ file.aliases.join(", ") }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2 align-top whitespace-nowrap text-[12px] text-muted-foreground">{{ file.id }}</td>
|
||||
<td class="px-4 py-2 align-top whitespace-nowrap tabular-nums text-muted-foreground">{{ formatBytes(file.length) }}</td>
|
||||
<td class="px-4 py-2 align-top whitespace-nowrap tabular-nums text-muted-foreground">{{ formatBytes(file.chunkSize || 0) }}</td>
|
||||
<td class="px-4 py-2 align-top whitespace-nowrap tabular-nums text-muted-foreground">{{ formattedUploadDate(file.uploadDate) }}</td>
|
||||
<td class="px-4 py-2 align-top whitespace-nowrap text-muted-foreground">{{ file.contentType || "-" }}</td>
|
||||
<td class="px-4 py-2 align-top whitespace-nowrap text-[12px] text-muted-foreground">{{ file.md5 || "-" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<aside class="border-t border-border px-4 py-4 xl:w-80 xl:shrink-0 xl:border-t-0">
|
||||
<aside class="overflow-auto border-t border-border px-4 py-4 xl:w-80 xl:shrink-0 xl:border-t-0">
|
||||
<template v-if="selectedFile">
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{{ t("tabs.gridfs") }}</div>
|
||||
<div class="mt-2 break-all text-lg font-semibold">{{ displayName(selectedFile) }}</div>
|
||||
@@ -525,7 +681,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.uploadDate") }}</div>
|
||||
<div class="mt-1 font-medium">{{ selectedFile.uploadDate || "-" }}</div>
|
||||
<div class="mt-1 font-medium">{{ formattedUploadDate(selectedFile.uploadDate) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.contentType") }}</div>
|
||||
|
||||
@@ -152,7 +152,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div class="border-b border-border px-4 py-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
@@ -263,7 +263,7 @@ onMounted(() => {
|
||||
{{ t("gridfsBrowser.emptyBuckets") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="min-h-0 flex-1 flex-col xl:flex-row xl:divide-x xl:divide-border">
|
||||
<div v-else class="min-h-0 flex flex-1 flex-col overflow-hidden xl:flex-row xl:divide-x xl:divide-border">
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<table class="min-w-full border-collapse text-sm">
|
||||
<thead class="sticky top-0 z-10 bg-background">
|
||||
@@ -298,7 +298,7 @@ onMounted(() => {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<aside class="border-t border-border px-4 py-4 xl:w-72 xl:shrink-0 xl:border-t-0">
|
||||
<aside class="overflow-auto border-t border-border px-4 py-4 xl:w-72 xl:shrink-0 xl:border-t-0">
|
||||
<template v-if="selectedBucket">
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{{ t("tabs.gridfs") }}</div>
|
||||
<div class="mt-2 break-all text-lg font-semibold">{{ selectedBucket.name }}</div>
|
||||
|
||||
Reference in new issue
Block a user