405 lines
20 KiB
Vue
405 lines
20 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
import { ArrowDown, ArrowUp, Check, Database, GripVertical, LayoutGrid, List, Loader2, RefreshCw, Search, X } from "@lucide/vue";
|
|
import { useI18n } from "vue-i18n";
|
|
import { Button } from "@/components/ui/button";
|
|
import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from "@/components/ui/dropdown-menu";
|
|
import { Input } from "@/components/ui/input";
|
|
import ToolbarOverflowMenu from "@/components/ui/ToolbarOverflowMenu.vue";
|
|
import { useToolbarOverflow } from "@/composables/useToolbarOverflow";
|
|
import * as api from "@/lib/backend/api";
|
|
import { filterDatabaseNamesForConnection } from "@/lib/database/visibleDatabases";
|
|
import { formatObjectBrowserBytes, formatObjectBrowserTimestamp } from "@/lib/table/objectBrowserRows";
|
|
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
|
import { useQueryStore } from "@/stores/queryStore";
|
|
import { useSettingsStore } from "@/stores/settingsStore";
|
|
import type { ConnectionConfig } from "@/types/database";
|
|
|
|
type DatabaseRow = {
|
|
name: string;
|
|
sizeBytes: number | null;
|
|
createdAt: string | null;
|
|
updatedAt: string | null;
|
|
comment: string | null;
|
|
defaultCharset: string | null;
|
|
defaultCollation: string | null;
|
|
};
|
|
|
|
type DatabaseBrowserColumnKey = "name" | "sizeBytes" | "createdAt" | "updatedAt" | "defaultCharset" | "defaultCollation" | "comment";
|
|
type DatabaseBrowserSortKey = DatabaseBrowserColumnKey;
|
|
type SortDirection = "asc" | "desc";
|
|
|
|
const DEFAULT_COLUMN_WIDTHS: Record<DatabaseBrowserColumnKey, number> = {
|
|
name: 260,
|
|
sizeBytes: 100,
|
|
createdAt: 150,
|
|
updatedAt: 150,
|
|
defaultCharset: 130,
|
|
defaultCollation: 170,
|
|
comment: 260,
|
|
};
|
|
const DATABASE_BROWSER_COLUMN_KEYS = Object.keys(DEFAULT_COLUMN_WIDTHS) as DatabaseBrowserColumnKey[];
|
|
|
|
interface DatabaseBrowserTabUiState {
|
|
search?: string;
|
|
sortKey?: DatabaseBrowserSortKey;
|
|
sortDirection?: SortDirection;
|
|
columnWidths?: Partial<Record<DatabaseBrowserColumnKey, number>>;
|
|
}
|
|
|
|
const props = defineProps<{
|
|
connection: ConnectionConfig;
|
|
}>();
|
|
|
|
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<DatabaseBrowserTabUiState>({}, "DatabaseBrowser");
|
|
const { t } = useI18n();
|
|
const queryStore = useQueryStore();
|
|
const settingsStore = useSettingsStore();
|
|
const searchInput = ref<InstanceType<typeof Input>>();
|
|
const search = ref(restoredUiState.search ?? "");
|
|
const rows = ref<DatabaseRow[]>([]);
|
|
const loading = ref(false);
|
|
const error = ref("");
|
|
const sortKey = ref<DatabaseBrowserSortKey>(DATABASE_BROWSER_COLUMN_KEYS.includes(restoredUiState.sortKey as DatabaseBrowserColumnKey) ? restoredUiState.sortKey! : "name");
|
|
const sortDirection = ref<SortDirection>(restoredUiState.sortDirection === "desc" ? "desc" : "asc");
|
|
const columnWidths = ref<Record<DatabaseBrowserColumnKey, number>>(
|
|
Object.fromEntries(
|
|
DATABASE_BROWSER_COLUMN_KEYS.map((key) => {
|
|
const restored = restoredUiState.columnWidths?.[key];
|
|
return [key, typeof restored === "number" && Number.isFinite(restored) ? Math.max(key === "name" || key === "comment" ? 120 : 72, restored) : DEFAULT_COLUMN_WIDTHS[key]];
|
|
}),
|
|
) as Record<DatabaseBrowserColumnKey, number>,
|
|
);
|
|
let stopColumnResize: (() => void) | null = null;
|
|
|
|
trackUiState(() => ({ search: search.value, sortKey: sortKey.value, sortDirection: sortDirection.value, columnWidths: columnWidths.value }));
|
|
|
|
const hasSize = computed(() => rows.value.some((row) => row.sizeBytes != null));
|
|
const hasCreatedAt = computed(() => rows.value.some((row) => !!row.createdAt?.trim()));
|
|
const hasUpdatedAt = computed(() => rows.value.some((row) => !!row.updatedAt?.trim()));
|
|
const hasComment = computed(() => rows.value.some((row) => !!row.comment?.trim()));
|
|
const hasDefaultCharset = computed(() => rows.value.some((row) => !!row.defaultCharset?.trim()));
|
|
const hasDefaultCollation = computed(() => rows.value.some((row) => !!row.defaultCollation?.trim()));
|
|
const isListView = computed(() => settingsStore.editorSettings.objectBrowserViewMode !== "grid");
|
|
// Measured condensation for the header row: tier 1 moves the sort/view
|
|
// controls into the overflow menu, tier 2 drops the connection chip (the tab
|
|
// title already carries it). See useToolbarOverflow for the tier contract.
|
|
const toolbarRef = ref<HTMLElement | null>(null);
|
|
const { tier: toolbarTier } = useToolbarOverflow(toolbarRef, [() => props.connection.id]);
|
|
const showToolbarOverflow = computed(() => toolbarTier.value >= 1);
|
|
const showConnectionChip = computed(() => toolbarTier.value < 2);
|
|
const showInlineSortAndView = computed(() => toolbarTier.value < 1);
|
|
|
|
const columns = computed<DatabaseBrowserColumnKey[]>(() => {
|
|
const next: DatabaseBrowserColumnKey[] = ["name"];
|
|
if (hasSize.value) next.push("sizeBytes");
|
|
if (hasCreatedAt.value) next.push("createdAt");
|
|
if (hasUpdatedAt.value) next.push("updatedAt");
|
|
if (hasDefaultCharset.value) next.push("defaultCharset");
|
|
if (hasDefaultCollation.value) next.push("defaultCollation");
|
|
if (hasComment.value) next.push("comment");
|
|
return next;
|
|
});
|
|
|
|
const gridTemplateColumns = computed(() => columns.value.map((key, index) => (index === columns.value.length - 1 ? `minmax(${columnWidths.value[key]}px, 1fr)` : `${columnWidths.value[key]}px`)).join(" "));
|
|
const gridMinWidth = computed(() => columns.value.reduce((total, key) => total + columnWidths.value[key], 0) + Math.max(0, columns.value.length - 1) * 12 + 24);
|
|
const sortKeyOptions = computed(() => columns.value);
|
|
|
|
const visibleRows = computed(() => {
|
|
const query = search.value.trim().toLocaleLowerCase();
|
|
const filtered = rows.value.filter((row) => !query || row.name.toLocaleLowerCase().includes(query) || row.comment?.toLocaleLowerCase().includes(query));
|
|
return [...filtered].sort((left, right) => compareRows(left, right, sortKey.value, sortDirection.value));
|
|
});
|
|
|
|
function openDatabase(database: string) {
|
|
queryStore.openObjectBrowser(props.connection.id, database);
|
|
}
|
|
|
|
function columnLabel(key: DatabaseBrowserColumnKey): string {
|
|
if (key === "name") return t("objects.name");
|
|
if (key === "sizeBytes") return t("objects.size");
|
|
if (key === "createdAt") return t("objects.createdAt");
|
|
if (key === "updatedAt") return t("objects.updatedAt");
|
|
if (key === "defaultCharset") return t("databaseBrowser.defaultCharset");
|
|
if (key === "defaultCollation") return t("databaseBrowser.defaultCollation");
|
|
return t("objects.comment");
|
|
}
|
|
|
|
function displayValue(row: DatabaseRow, key: DatabaseBrowserColumnKey): string {
|
|
if (key === "name") return row.name;
|
|
if (key === "sizeBytes") return row.sizeBytes == null ? "" : formatObjectBrowserBytes(row.sizeBytes);
|
|
if (key === "createdAt") return formatObjectBrowserTimestamp(row.createdAt);
|
|
if (key === "updatedAt") return formatObjectBrowserTimestamp(row.updatedAt);
|
|
if (key === "defaultCharset") return row.defaultCharset || "";
|
|
if (key === "defaultCollation") return row.defaultCollation || "";
|
|
return row.comment || "";
|
|
}
|
|
|
|
function compareRows(left: DatabaseRow, right: DatabaseRow, key: DatabaseBrowserSortKey, direction: SortDirection): number {
|
|
const leftValue = key === "sizeBytes" ? (left.sizeBytes ?? -1) : displayValue(left, key).toLocaleLowerCase();
|
|
const rightValue = key === "sizeBytes" ? (right.sizeBytes ?? -1) : displayValue(right, key).toLocaleLowerCase();
|
|
const compared = typeof leftValue === "number" && typeof rightValue === "number" ? leftValue - rightValue : String(leftValue).localeCompare(String(rightValue), undefined, { numeric: true, sensitivity: "base" });
|
|
if (compared !== 0) return direction === "asc" ? compared : -compared;
|
|
return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" });
|
|
}
|
|
|
|
function sortIconFor(key: DatabaseBrowserSortKey) {
|
|
if (sortKey.value !== key) return null;
|
|
return sortDirection.value === "asc" ? ArrowUp : ArrowDown;
|
|
}
|
|
|
|
function initialSortDirection(key: DatabaseBrowserSortKey): SortDirection {
|
|
return key === "sizeBytes" || key === "createdAt" || key === "updatedAt" ? "desc" : "asc";
|
|
}
|
|
|
|
function toggleSort(key: DatabaseBrowserSortKey) {
|
|
if (sortKey.value === key) {
|
|
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
|
return;
|
|
}
|
|
sortKey.value = key;
|
|
sortDirection.value = initialSortDirection(key);
|
|
}
|
|
|
|
function setViewMode(mode: "list" | "grid") {
|
|
settingsStore.updateEditorSettings({ objectBrowserViewMode: mode });
|
|
}
|
|
|
|
function minimumColumnWidth(key: DatabaseBrowserColumnKey): number {
|
|
return key === "name" || key === "comment" ? 120 : 72;
|
|
}
|
|
|
|
function onColumnResizeStart(key: DatabaseBrowserColumnKey, event: MouseEvent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
stopColumnResize?.();
|
|
const startX = event.clientX;
|
|
const startWidth = columnWidths.value[key];
|
|
const minWidth = minimumColumnWidth(key);
|
|
document.body.classList.add("select-none", "cursor-col-resize");
|
|
const onMove = (moveEvent: MouseEvent) => {
|
|
columnWidths.value = { ...columnWidths.value, [key]: Math.max(minWidth, startWidth + moveEvent.clientX - startX) };
|
|
};
|
|
const onUp = () => {
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
document.body.classList.remove("select-none", "cursor-col-resize");
|
|
stopColumnResize = null;
|
|
};
|
|
stopColumnResize = onUp;
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
}
|
|
|
|
function resetColumnWidth(key: DatabaseBrowserColumnKey, width: number, event: MouseEvent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
columnWidths.value = { ...columnWidths.value, [key]: width };
|
|
}
|
|
|
|
async function refresh(): Promise<boolean> {
|
|
loading.value = true;
|
|
error.value = "";
|
|
try {
|
|
let databases;
|
|
try {
|
|
databases = await api.listDatabaseMetadata(props.connection.id);
|
|
} catch (metadataError) {
|
|
try {
|
|
databases = await api.listDatabases(props.connection.id);
|
|
} catch {
|
|
throw metadataError;
|
|
}
|
|
}
|
|
const visibleNames = new Set(
|
|
filterDatabaseNamesForConnection(
|
|
databases.map((database) => database.name),
|
|
props.connection,
|
|
),
|
|
);
|
|
rows.value = databases
|
|
.filter((database) => database.name.trim() && visibleNames.has(database.name))
|
|
.map((database) => ({
|
|
name: database.name,
|
|
sizeBytes: database.size_bytes ?? null,
|
|
createdAt: database.created_at ?? null,
|
|
updatedAt: database.updated_at ?? null,
|
|
comment: database.comment ?? null,
|
|
defaultCharset: database.default_charset ?? null,
|
|
defaultCollation: database.default_collation ?? null,
|
|
}));
|
|
} catch (cause: any) {
|
|
rows.value = [];
|
|
error.value = cause?.message || String(cause);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function focusSearch(): boolean {
|
|
searchInput.value?.$el?.focus();
|
|
return true;
|
|
}
|
|
|
|
function clearDatabaseSearch() {
|
|
search.value = "";
|
|
searchInput.value?.$el?.focus();
|
|
}
|
|
|
|
watch(sortKeyOptions, (options) => {
|
|
if (!options.includes(sortKey.value)) {
|
|
sortKey.value = "name";
|
|
sortDirection.value = "asc";
|
|
}
|
|
});
|
|
|
|
let initialConnectionWatch = true;
|
|
watch(
|
|
() => props.connection.id,
|
|
() => {
|
|
if (initialConnectionWatch) initialConnectionWatch = false;
|
|
else search.value = "";
|
|
void refresh();
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
onBeforeUnmount(() => stopColumnResize?.());
|
|
|
|
defineExpose({ focusSearch, refresh });
|
|
</script>
|
|
|
|
<template>
|
|
<section class="flex h-full min-h-0 min-w-0 flex-col bg-background">
|
|
<div ref="toolbarRef" class="flex h-10 shrink-0 items-center gap-2 overflow-hidden border-b px-3">
|
|
<span v-if="showConnectionChip" class="inline-flex max-w-[14rem] min-w-12 items-center truncate rounded border border-border bg-muted/50 px-2 py-0.5 text-xs font-medium" :title="connection.name">
|
|
{{ connection.name }}
|
|
</span>
|
|
<div class="relative min-w-24 flex-1">
|
|
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
<Input ref="searchInput" v-model="search" class="h-7 pl-8 pr-6 text-xs" :placeholder="t('databaseBrowser.search')" />
|
|
<button v-if="search" type="button" class="absolute right-1.5 top-1/2 flex h-4 w-4 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('common.clear')" @click="clearDatabaseSearch">
|
|
<X class="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
<div v-if="showInlineSortAndView" class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
|
|
<select
|
|
class="h-6 cursor-pointer appearance-none rounded-sm bg-transparent px-1.5 text-xs text-muted-foreground outline-none hover:text-foreground focus:text-foreground"
|
|
:value="sortKey"
|
|
:aria-label="t('objects.sortBy')"
|
|
@change="toggleSort(($event.target as HTMLSelectElement).value as DatabaseBrowserSortKey)"
|
|
>
|
|
<option v-for="key in sortKeyOptions" :key="key" :value="key" class="bg-background text-foreground">{{ columnLabel(key) }}</option>
|
|
</select>
|
|
<button type="button" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground" :title="sortDirection === 'asc' ? t('objects.sortAsc') : t('objects.sortDesc')" @click="sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'">
|
|
<ArrowUp v-if="sortDirection === 'asc'" class="h-3 w-3" />
|
|
<ArrowDown v-else class="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
<div v-if="showInlineSortAndView" class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
|
|
<button type="button" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground" :class="{ 'bg-background text-foreground shadow-sm': isListView }" :title="t('objects.viewList')" @click="setViewMode('list')">
|
|
<List class="h-3.5 w-3.5" />
|
|
</button>
|
|
<button type="button" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground" :class="{ 'bg-background text-foreground shadow-sm': !isListView }" :title="t('objects.viewGrid')" @click="setViewMode('grid')">
|
|
<LayoutGrid class="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('contextMenu.refreshTab')" :disabled="loading" @click="refresh">
|
|
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" />
|
|
</Button>
|
|
<ToolbarOverflowMenu v-if="showToolbarOverflow" :label="t('toolbar.moreActions')" button-class="h-7 w-7">
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<ArrowDown class="h-3.5 w-3.5" />
|
|
{{ t("objects.sortBy") }}
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent>
|
|
<DropdownMenuItem v-for="key in sortKeyOptions" :key="key" @select="toggleSort(key)">
|
|
<Check v-if="sortKey === key" class="h-3.5 w-3.5" />
|
|
{{ columnLabel(key) }}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
<DropdownMenuItem @select="sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'">
|
|
<ArrowUp v-if="sortDirection === 'asc'" class="h-3.5 w-3.5" />
|
|
<ArrowDown v-else class="h-3.5 w-3.5" />
|
|
{{ sortDirection === "asc" ? t("objects.sortDesc") : t("objects.sortAsc") }}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem @select="setViewMode('list')">
|
|
<List class="h-3.5 w-3.5" />
|
|
{{ t("objects.viewList") }}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem @select="setViewMode('grid')">
|
|
<LayoutGrid class="h-3.5 w-3.5" />
|
|
{{ t("objects.viewGrid") }}
|
|
</DropdownMenuItem>
|
|
</ToolbarOverflowMenu>
|
|
</div>
|
|
|
|
<div v-if="loading && rows.length === 0" class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("objects.loading") }}</div>
|
|
<div v-else-if="error" class="flex flex-1 items-center justify-center px-6 text-center text-sm text-destructive">{{ t("databaseBrowser.loadFailed", { message: error }) }}</div>
|
|
<div v-else-if="visibleRows.length === 0" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">{{ t("databaseBrowser.empty") }}</div>
|
|
<div v-else class="flex min-h-0 min-w-0 flex-1">
|
|
<div v-if="isListView" class="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-auto overflow-y-hidden">
|
|
<div class="grid h-7 shrink-0 items-center gap-3 border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground" :style="{ gridTemplateColumns, minWidth: `${gridMinWidth}px` }">
|
|
<div v-for="key in columns" :key="key" class="relative flex min-w-0 items-center">
|
|
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort(key)">
|
|
<span class="truncate">{{ columnLabel(key) }}</span
|
|
><component :is="sortIconFor(key)" v-if="sortIconFor(key)" class="h-3 w-3 shrink-0" />
|
|
</button>
|
|
<div
|
|
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
|
|
@mousedown="onColumnResizeStart(key, $event)"
|
|
@dblclick="resetColumnWidth(key, key === 'name' || key === 'comment' ? 260 : key === 'defaultCollation' ? 170 : key === 'defaultCharset' ? 130 : key === 'sizeBytes' ? 100 : 150, $event)"
|
|
>
|
|
<GripVertical class="h-3 w-3" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="min-h-0 flex-1 overflow-auto">
|
|
<div
|
|
v-for="row in visibleRows"
|
|
:key="row.name"
|
|
tabindex="0"
|
|
class="grid h-[34px] cursor-pointer items-center gap-3 border-b px-3 outline-none hover:bg-accent/50 focus-visible:bg-accent/50"
|
|
:style="{ gridTemplateColumns, minWidth: `${gridMinWidth}px` }"
|
|
@dblclick="openDatabase(row.name)"
|
|
@keydown.enter.prevent="openDatabase(row.name)"
|
|
>
|
|
<div
|
|
v-for="key in columns"
|
|
:key="key"
|
|
class="min-w-0 truncate text-xs text-muted-foreground"
|
|
:class="{ 'flex items-center gap-2 text-[13px] font-medium text-foreground': key === 'name', 'tabular-nums': key === 'sizeBytes' || key === 'createdAt' || key === 'updatedAt' }"
|
|
:title="displayValue(row, key)"
|
|
>
|
|
<Database v-if="key === 'name'" class="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
|
{{ displayValue(row, key) || "-" }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="grid min-h-0 flex-1 grid-cols-[repeat(auto-fill,minmax(160px,1fr))] content-start gap-3 overflow-auto p-3">
|
|
<div
|
|
v-for="row in visibleRows"
|
|
:key="row.name"
|
|
tabindex="0"
|
|
class="flex min-h-28 cursor-pointer flex-col items-center gap-1 rounded-lg border bg-card p-3 text-center outline-none transition-all hover:border-primary/40 hover:shadow-sm focus-visible:border-primary"
|
|
@dblclick="openDatabase(row.name)"
|
|
@keydown.enter.prevent="openDatabase(row.name)"
|
|
>
|
|
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-emerald-500/10 shadow-sm"><Database class="h-6 w-6 text-emerald-600 dark:text-emerald-400" /></div>
|
|
<span class="w-full truncate text-sm font-medium text-foreground" :title="row.name">{{ row.name }}</span>
|
|
<div class="flex min-h-[15px] items-center gap-1 text-[10px] leading-[15px] text-muted-foreground">
|
|
<span v-if="row.sizeBytes != null">{{ formatObjectBrowserBytes(row.sizeBytes) }}</span
|
|
><span v-if="row.createdAt && row.sizeBytes != null">·</span><span v-if="row.createdAt">{{ formatObjectBrowserTimestamp(row.createdAt) }}</span>
|
|
</div>
|
|
<div v-if="hasComment" class="w-full truncate text-[10px] leading-[15px] text-muted-foreground/60" :title="row.comment || ''">{{ row.comment || "\u00A0" }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|