mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-19 14:53:50 +02:00
improve dashboard widget
This commit is contained in:
@@ -101,7 +101,6 @@
|
|||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"not_selected": "No Panel Selected",
|
"not_selected": "No Panel Selected",
|
||||||
"replace": "Replace Panel",
|
|
||||||
"select": "Select Panel",
|
"select": "Select Panel",
|
||||||
"not_configured": "Panel Not Configured"
|
"not_configured": "Panel Not Configured"
|
||||||
},
|
},
|
||||||
@@ -118,7 +117,7 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"filters": "Event Types",
|
"filters": "Event Types",
|
||||||
"columns": "Columns",
|
"columns": "Columns",
|
||||||
"show_detail": "Show Detail",
|
"detail": "Detail",
|
||||||
"show_type": "Show Type"
|
"show_type": "Show Type"
|
||||||
},
|
},
|
||||||
"no_data": "No data",
|
"no_data": "No data",
|
||||||
|
|||||||
@@ -12,7 +12,11 @@
|
|||||||
<ResizablePanelGroup direction="vertical" :auto-save-id="`dashboard-${id}`" class="flex-1 min-h-0">
|
<ResizablePanelGroup direction="vertical" :auto-save-id="`dashboard-${id}`" class="flex-1 min-h-0">
|
||||||
<template v-for="(row, rowIndex) in displayRows" :key="rowIndex">
|
<template v-for="(row, rowIndex) in displayRows" :key="rowIndex">
|
||||||
<ResizablePanel :default-size="100 / displayRows.length" :min-size="10">
|
<ResizablePanel :default-size="100 / displayRows.length" :min-size="10">
|
||||||
<DashboardRow :row="row" :row-index="rowIndex" :dashboard-id="id" />
|
<DashboardRow
|
||||||
|
:row="row"
|
||||||
|
:row-index="rowIndex"
|
||||||
|
:dashboard-id="id"
|
||||||
|
@update-panel="handleLiveUpdatePanel" />
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle v-if="rowIndex < displayRows.length - 1" />
|
<ResizableHandle v-if="rowIndex < displayRows.length - 1" />
|
||||||
</template>
|
</template>
|
||||||
@@ -179,10 +183,16 @@
|
|||||||
editRows.value[rowIndex].panels[panelIndex] = panelValue;
|
editRows.value[rowIndex].panels[panelIndex] = panelValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLiveUpdatePanel = async (rowIndex, panelIndex, panelValue) => {
|
||||||
|
if (!dashboard.value?.rows?.[rowIndex]?.panels) return;
|
||||||
|
const rows = JSON.parse(JSON.stringify(dashboard.value.rows));
|
||||||
|
rows[rowIndex].panels[panelIndex] = panelValue;
|
||||||
|
await dashboardStore.updateDashboard(props.id, { rows });
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const isFirstSave =
|
const isFirstSave =
|
||||||
dashboardStore.dashboards.length === 1 &&
|
dashboardStore.dashboards.length === 1 && (!dashboard.value?.rows || dashboard.value.rows.length === 0);
|
||||||
(!dashboard.value?.rows || dashboard.value.rows.length === 0);
|
|
||||||
|
|
||||||
await dashboardStore.updateDashboard(props.id, {
|
await dashboardStore.updateDashboard(props.id, {
|
||||||
name: editName.value.trim() || dashboard.value?.name || 'Dashboard',
|
name: editName.value.trim() || dashboard.value?.name || 'Dashboard',
|
||||||
|
|||||||
@@ -9,71 +9,22 @@
|
|||||||
@click="emit('remove')">
|
@click="emit('remove')">
|
||||||
<X class="size-4" />
|
<X class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex w-full min-h-0 flex-col gap-2 p-3">
|
<div class="flex w-full min-h-0 flex-col items-center justify-center gap-2 p-3">
|
||||||
<div class="flex flex-1 items-center justify-center gap-2 text-base text-muted-foreground">
|
<template v-if="panelKey">
|
||||||
<i v-if="panelIcon" :class="panelIcon" class="text-base" />
|
<div class="flex items-center gap-2 text-base text-muted-foreground">
|
||||||
<span>{{ panelLabel || t('dashboard.panel.not_selected') }}</span>
|
<i v-if="panelIcon" :class="panelIcon" class="text-base" />
|
||||||
</div>
|
<span>{{ panelLabel }}</span>
|
||||||
|
<Button variant="ghost" size="icon-sm" @click="clearPanel">
|
||||||
<!-- Widget config section -->
|
<Trash2 class="size-3.5" />
|
||||||
<div v-if="isWidget && panelKey" class="border-t border-border/50 py-1">
|
</Button>
|
||||||
<!-- Feed/GameLog: event type filters -->
|
</div>
|
||||||
<template v-if="widgetType === 'feed' || widgetType === 'game-log'">
|
</template>
|
||||||
<span class="text-xs text-muted-foreground">{{ t('dashboard.widget.config.filters') }}</span>
|
<template v-else>
|
||||||
<div class="flex flex-wrap gap-1.5 mt-1">
|
<span class="text-base text-muted-foreground">{{ t('dashboard.panel.not_selected') }}</span>
|
||||||
<label
|
<Button variant="outline" @click="openSelector">
|
||||||
v-for="filterType in availableFilters"
|
{{ t('dashboard.panel.select') }}
|
||||||
:key="filterType"
|
</Button>
|
||||||
class="flex items-center gap-1 text-xs cursor-pointer">
|
</template>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="isFilterActive(filterType)"
|
|
||||||
@change="toggleFilter(filterType)" />
|
|
||||||
{{ filterType }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2">
|
|
||||||
<label
|
|
||||||
v-if="widgetType === 'game-log'"
|
|
||||||
class="flex items-center gap-1 text-xs cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="panelConfig.showDetail || false"
|
|
||||||
@change="toggleBooleanConfig('showDetail')" />
|
|
||||||
{{ t('dashboard.widget.config.show_detail') }}
|
|
||||||
</label>
|
|
||||||
<label v-if="widgetType === 'feed'" class="flex items-center gap-1 text-xs cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="panelConfig.showType || false"
|
|
||||||
@change="toggleBooleanConfig('showType')" />
|
|
||||||
{{ t('dashboard.widget.config.show_type') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Instance: column visibility -->
|
|
||||||
<template v-if="widgetType === 'instance'">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ t('dashboard.widget.config.columns') }}</span>
|
|
||||||
<div class="flex flex-wrap gap-1.5 mt-1">
|
|
||||||
<label
|
|
||||||
v-for="col in availableColumns"
|
|
||||||
:key="col"
|
|
||||||
class="flex items-center gap-1 text-xs cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="isColumnActive(col)"
|
|
||||||
:disabled="col === 'displayName'"
|
|
||||||
@change="toggleColumn(col)" />
|
|
||||||
{{ t(`table.playerList.${col}`) }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="outline" class="w-full" @click="openSelector">
|
|
||||||
{{ panelKey ? t('dashboard.panel.replace') : t('dashboard.panel.select') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -97,7 +48,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { X } from 'lucide-vue-next';
|
import { Trash2, X } from 'lucide-vue-next';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -106,18 +57,6 @@
|
|||||||
import PanelSelector from './PanelSelector.vue';
|
import PanelSelector from './PanelSelector.vue';
|
||||||
import { panelComponentMap } from './panelRegistry';
|
import { panelComponentMap } from './panelRegistry';
|
||||||
|
|
||||||
const FEED_TYPES = ['GPS', 'Online', 'Offline', 'Status', 'Avatar', 'Bio'];
|
|
||||||
const GAMELOG_TYPES = [
|
|
||||||
'Location',
|
|
||||||
'OnPlayerJoined',
|
|
||||||
'OnPlayerLeft',
|
|
||||||
'VideoPlay',
|
|
||||||
'PortalSpawn',
|
|
||||||
'Event',
|
|
||||||
'External'
|
|
||||||
];
|
|
||||||
const INSTANCE_COLUMNS = ['icon', 'displayName', 'rank', 'timer', 'platform', 'language', 'status'];
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
panelData: {
|
panelData: {
|
||||||
type: [String, Object],
|
type: [String, Object],
|
||||||
@@ -153,11 +92,6 @@
|
|||||||
return panelKey.value && panelKey.value.startsWith('widget:');
|
return panelKey.value && panelKey.value.startsWith('widget:');
|
||||||
});
|
});
|
||||||
|
|
||||||
const widgetType = computed(() => {
|
|
||||||
if (!isWidget.value) return null;
|
|
||||||
return panelKey.value.replace('widget:', '');
|
|
||||||
});
|
|
||||||
|
|
||||||
const panelComponent = computed(() => {
|
const panelComponent = computed(() => {
|
||||||
if (!panelKey.value) return null;
|
if (!panelKey.value) return null;
|
||||||
return panelComponentMap[panelKey.value] || null;
|
return panelComponentMap[panelKey.value] || null;
|
||||||
@@ -165,7 +99,10 @@
|
|||||||
|
|
||||||
const widgetProps = computed(() => {
|
const widgetProps = computed(() => {
|
||||||
if (!isWidget.value) return {};
|
if (!isWidget.value) return {};
|
||||||
return { config: panelConfig.value };
|
return {
|
||||||
|
config: panelConfig.value,
|
||||||
|
configUpdater: (newConfig) => emitConfigUpdate(newConfig)
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const widgetDefs = {
|
const widgetDefs = {
|
||||||
@@ -187,60 +124,6 @@
|
|||||||
|
|
||||||
const panelIcon = computed(() => panelOption.value?.icon || '');
|
const panelIcon = computed(() => panelOption.value?.icon || '');
|
||||||
|
|
||||||
// Filter config helpers
|
|
||||||
const availableFilters = computed(() => {
|
|
||||||
if (widgetType.value === 'feed') return FEED_TYPES;
|
|
||||||
if (widgetType.value === 'game-log') return GAMELOG_TYPES;
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
function isFilterActive(filterType) {
|
|
||||||
const filters = panelConfig.value.filters;
|
|
||||||
if (!filters || !Array.isArray(filters) || filters.length === 0) return true;
|
|
||||||
return filters.includes(filterType);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleFilter(filterType) {
|
|
||||||
const currentFilters = panelConfig.value.filters;
|
|
||||||
let filters;
|
|
||||||
if (!currentFilters || !Array.isArray(currentFilters) || currentFilters.length === 0) {
|
|
||||||
filters = availableFilters.value.filter((f) => f !== filterType);
|
|
||||||
} else if (currentFilters.includes(filterType)) {
|
|
||||||
filters = currentFilters.filter((f) => f !== filterType);
|
|
||||||
if (filters.length === 0) filters = [];
|
|
||||||
} else {
|
|
||||||
filters = [...currentFilters, filterType];
|
|
||||||
if (filters.length === availableFilters.value.length) filters = [];
|
|
||||||
}
|
|
||||||
emitConfigUpdate({ ...panelConfig.value, filters });
|
|
||||||
}
|
|
||||||
|
|
||||||
const availableColumns = computed(() => INSTANCE_COLUMNS);
|
|
||||||
|
|
||||||
function isColumnActive(col) {
|
|
||||||
const columns = panelConfig.value.columns;
|
|
||||||
if (!columns || !Array.isArray(columns) || columns.length === 0) {
|
|
||||||
return ['icon', 'displayName', 'timer'].includes(col);
|
|
||||||
}
|
|
||||||
return columns.includes(col);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleColumn(col) {
|
|
||||||
if (col === 'displayName') return; // Always visible
|
|
||||||
const currentColumns = panelConfig.value.columns || ['icon', 'displayName', 'timer'];
|
|
||||||
let columns;
|
|
||||||
if (currentColumns.includes(col)) {
|
|
||||||
columns = currentColumns.filter((c) => c !== col);
|
|
||||||
} else {
|
|
||||||
columns = [...currentColumns, col];
|
|
||||||
}
|
|
||||||
emitConfigUpdate({ ...panelConfig.value, columns });
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleBooleanConfig(key) {
|
|
||||||
emitConfigUpdate({ ...panelConfig.value, [key]: !panelConfig.value[key] });
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitConfigUpdate(newConfig) {
|
function emitConfigUpdate(newConfig) {
|
||||||
emit('select', { key: panelKey.value, config: newConfig });
|
emit('select', { key: panelKey.value, config: newConfig });
|
||||||
}
|
}
|
||||||
@@ -249,6 +132,10 @@
|
|||||||
selectorOpen.value = true;
|
selectorOpen.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearPanel = () => {
|
||||||
|
emit('select', null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelect = (value) => {
|
const handleSelect = (value) => {
|
||||||
emit('select', value);
|
emit('select', value);
|
||||||
selectorOpen.value = false;
|
selectorOpen.value = false;
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative h-full min-h-[180px]">
|
<div class="relative h-full min-h-[180px]">
|
||||||
<div
|
<div v-if="isEditing" class="flex h-full gap-2" :class="isVertical ? 'flex-col' : 'flex-row'">
|
||||||
v-if="isEditing"
|
|
||||||
class="flex h-full gap-2"
|
|
||||||
:class="isVertical ? 'flex-col' : 'flex-row'">
|
|
||||||
<DashboardPanel
|
<DashboardPanel
|
||||||
v-for="(panelItem, panelIndex) in row.panels"
|
v-for="(panelItem, panelIndex) in row.panels"
|
||||||
:key="panelIndex"
|
:key="panelIndex"
|
||||||
@@ -21,16 +18,25 @@
|
|||||||
:auto-save-id="`dashboard-${dashboardId}-row-${rowIndex}`"
|
:auto-save-id="`dashboard-${dashboardId}-row-${rowIndex}`"
|
||||||
class="h-full min-h-[180px]">
|
class="h-full min-h-[180px]">
|
||||||
<ResizablePanel :default-size="50" :min-size="20">
|
<ResizablePanel :default-size="50" :min-size="20">
|
||||||
<DashboardPanel :panel-data="row.panels[0]" class="h-full" />
|
<DashboardPanel
|
||||||
|
:panel-data="row.panels[0]"
|
||||||
|
class="h-full"
|
||||||
|
@select="(value) => emit('update-panel', rowIndex, 0, value)" />
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle />
|
<ResizableHandle />
|
||||||
<ResizablePanel :default-size="50" :min-size="20">
|
<ResizablePanel :default-size="50" :min-size="20">
|
||||||
<DashboardPanel :panel-data="row.panels[1]" class="h-full" />
|
<DashboardPanel
|
||||||
|
:panel-data="row.panels[1]"
|
||||||
|
class="h-full"
|
||||||
|
@select="(value) => emit('update-panel', rowIndex, 1, value)" />
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|
||||||
<div v-else class="h-full">
|
<div v-else class="h-full">
|
||||||
<DashboardPanel :panel-data="row.panels[0]" class="h-full" />
|
<DashboardPanel
|
||||||
|
:panel-data="row.panels[0]"
|
||||||
|
class="h-full"
|
||||||
|
@select="(value) => emit('update-panel', rowIndex, 0, value)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
<WidgetHeader :title="t('dashboard.widget.feed')" icon="ri-rss-line" route-name="feed" />
|
<WidgetHeader :title="t('dashboard.widget.feed')" icon="ri-rss-line" route-name="feed">
|
||||||
|
<DropdownMenu v-if="configUpdater">
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button variant="ghost" size="icon-sm">
|
||||||
|
<Settings class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-48">
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
v-for="filterType in FEED_TYPES"
|
||||||
|
:key="filterType"
|
||||||
|
:model-value="isFilterActive(filterType)"
|
||||||
|
@select.prevent
|
||||||
|
@update:modelValue="toggleFilter(filterType)">
|
||||||
|
{{ t(`view.feed.filters.${filterType}`) }}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
:model-value="config.showType || false"
|
||||||
|
@select.prevent
|
||||||
|
@update:modelValue="toggleBooleanConfig('showType')">
|
||||||
|
{{ t('dashboard.widget.config.show_type') }}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</WidgetHeader>
|
||||||
|
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto" ref="listRef">
|
<div class="min-h-0 flex-1 overflow-y-auto" ref="listRef">
|
||||||
<Table v-if="filteredData.length" class="is-compact-table">
|
<Table v-if="filteredData.length" class="is-compact-table">
|
||||||
@@ -21,11 +46,9 @@
|
|||||||
<TableCell class="truncate">
|
<TableCell class="truncate">
|
||||||
<template v-if="item.type === 'GPS'">
|
<template v-if="item.type === 'GPS'">
|
||||||
<MapPin class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<MapPin class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span
|
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||||
class="cursor-pointer font-medium hover:underline"
|
item.displayName
|
||||||
@click="openUser(item.userId)"
|
}}</span>
|
||||||
>{{ item.displayName }}</span
|
|
||||||
>
|
|
||||||
<span class="text-muted-foreground"> → </span>
|
<span class="text-muted-foreground"> → </span>
|
||||||
<Location
|
<Location
|
||||||
class="inline [&>div]:inline-flex"
|
class="inline [&>div]:inline-flex"
|
||||||
@@ -36,11 +59,9 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'Online'">
|
<template v-else-if="item.type === 'Online'">
|
||||||
<i class="x-user-status online mr-1"></i>
|
<i class="x-user-status online mr-1"></i>
|
||||||
<span
|
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||||
class="cursor-pointer font-medium hover:underline"
|
item.displayName
|
||||||
@click="openUser(item.userId)"
|
}}</span>
|
||||||
>{{ item.displayName }}</span
|
|
||||||
>
|
|
||||||
<template v-if="item.location">
|
<template v-if="item.location">
|
||||||
<span class="text-muted-foreground"> → </span>
|
<span class="text-muted-foreground"> → </span>
|
||||||
<Location
|
<Location
|
||||||
@@ -53,45 +74,35 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'Offline'">
|
<template v-else-if="item.type === 'Offline'">
|
||||||
<i class="x-user-status mr-1"></i>
|
<i class="x-user-status mr-1"></i>
|
||||||
<span
|
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||||
class="cursor-pointer font-medium text-muted-foreground/70 hover:underline"
|
item.displayName
|
||||||
@click="openUser(item.userId)"
|
}}</span>
|
||||||
>{{ item.displayName }}</span
|
|
||||||
>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'Status'">
|
<template v-else-if="item.type === 'Status'">
|
||||||
<i class="x-user-status mr-1" :class="statusClass(item.status)"></i>
|
<i class="x-user-status mr-1" :class="statusClass(item.status)"></i>
|
||||||
<span
|
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||||
class="cursor-pointer font-medium hover:underline"
|
item.displayName
|
||||||
@click="openUser(item.userId)"
|
}}</span>
|
||||||
>{{ item.displayName }}</span
|
|
||||||
>
|
|
||||||
<span class="text-muted-foreground"> {{ item.statusDescription }}</span>
|
<span class="text-muted-foreground"> {{ item.statusDescription }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'Avatar'">
|
<template v-else-if="item.type === 'Avatar'">
|
||||||
<Box class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<Box class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span
|
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||||
class="cursor-pointer font-medium hover:underline"
|
item.displayName
|
||||||
@click="openUser(item.userId)"
|
}}</span>
|
||||||
>{{ item.displayName }}</span
|
|
||||||
>
|
|
||||||
<span class="text-muted-foreground"> → {{ item.avatarName }}</span>
|
<span class="text-muted-foreground"> → {{ item.avatarName }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'Bio'">
|
<template v-else-if="item.type === 'Bio'">
|
||||||
<Pencil class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<Pencil class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span
|
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||||
class="cursor-pointer font-medium hover:underline"
|
item.displayName
|
||||||
@click="openUser(item.userId)"
|
}}</span>
|
||||||
>{{ item.displayName }}</span
|
|
||||||
>
|
|
||||||
<span class="ml-1 text-muted-foreground">{{ t('dashboard.widget.feed_bio') }}</span>
|
<span class="ml-1 text-muted-foreground">{{ t('dashboard.widget.feed_bio') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span
|
<span class="cursor-pointer" @click="openUser(item.userId)">{{
|
||||||
class="cursor-pointer font-medium hover:underline"
|
item.displayName
|
||||||
@click="openUser(item.userId)"
|
}}</span>
|
||||||
>{{ item.displayName }}</span
|
|
||||||
>
|
|
||||||
<span class="text-muted-foreground"> {{ item.type }}</span>
|
<span class="text-muted-foreground"> {{ item.type }}</span>
|
||||||
</template>
|
</template>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -108,13 +119,21 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Box, MapPin, Pencil } from 'lucide-vue-next';
|
import { Box, MapPin, Pencil, Settings } from 'lucide-vue-next';
|
||||||
|
|
||||||
import { statusClass } from '@/shared/utils/user';
|
import { statusClass } from '@/shared/utils/user';
|
||||||
import { formatDateFilter } from '@/shared/utils';
|
import { formatDateFilter } from '@/shared/utils';
|
||||||
import { showUserDialog } from '@/coordinators/userCoordinator';
|
import { showUserDialog } from '@/coordinators/userCoordinator';
|
||||||
import { useFeedStore } from '@/stores';
|
import { useFeedStore } from '@/stores';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import Location from '@/components/Location.vue';
|
import Location from '@/components/Location.vue';
|
||||||
import { TooltipWrapper } from '@/components/ui/tooltip';
|
import { TooltipWrapper } from '@/components/ui/tooltip';
|
||||||
import WidgetHeader from './WidgetHeader.vue';
|
import WidgetHeader from './WidgetHeader.vue';
|
||||||
@@ -126,6 +145,10 @@
|
|||||||
config: {
|
config: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({})
|
default: () => ({})
|
||||||
|
},
|
||||||
|
configUpdater: {
|
||||||
|
type: Function,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,6 +163,33 @@
|
|||||||
return FEED_TYPES;
|
return FEED_TYPES;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isFilterActive(filterType) {
|
||||||
|
const filters = props.config.filters;
|
||||||
|
if (!filters || !Array.isArray(filters) || filters.length === 0) return true;
|
||||||
|
return filters.includes(filterType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFilter(filterType) {
|
||||||
|
if (!props.configUpdater) return;
|
||||||
|
const currentFilters = props.config.filters;
|
||||||
|
let filters;
|
||||||
|
if (!currentFilters || !Array.isArray(currentFilters) || currentFilters.length === 0) {
|
||||||
|
filters = FEED_TYPES.filter((f) => f !== filterType);
|
||||||
|
} else if (currentFilters.includes(filterType)) {
|
||||||
|
filters = currentFilters.filter((f) => f !== filterType);
|
||||||
|
if (filters.length === 0) filters = [];
|
||||||
|
} else {
|
||||||
|
filters = [...currentFilters, filterType];
|
||||||
|
if (filters.length === FEED_TYPES.length) filters = [];
|
||||||
|
}
|
||||||
|
props.configUpdater({ ...props.config, filters });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBooleanConfig(key) {
|
||||||
|
if (!props.configUpdater) return;
|
||||||
|
props.configUpdater({ ...props.config, [key]: !props.config[key] });
|
||||||
|
}
|
||||||
|
|
||||||
const showType = computed(() => {
|
const showType = computed(() => {
|
||||||
return props.config.showType || false;
|
return props.config.showType || false;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
<WidgetHeader :title="t('dashboard.widget.game_log')" icon="ri-history-line" route-name="game-log" />
|
<WidgetHeader :title="t('dashboard.widget.game_log')" icon="ri-history-line" route-name="game-log">
|
||||||
|
<DropdownMenu v-if="configUpdater">
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button variant="ghost" size="icon-sm">
|
||||||
|
<Settings class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-48">
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
v-for="filterType in GAMELOG_TYPES"
|
||||||
|
:key="filterType"
|
||||||
|
:model-value="isFilterActive(filterType)"
|
||||||
|
@select.prevent
|
||||||
|
@update:modelValue="toggleFilter(filterType)">
|
||||||
|
{{ t(`view.game_log.filters.${filterType}`) }}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
:model-value="config.showDetail || false"
|
||||||
|
@select.prevent
|
||||||
|
@update:modelValue="toggleBooleanConfig('showDetail')">
|
||||||
|
{{ t('dashboard.widget.config.detail') }}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</WidgetHeader>
|
||||||
|
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||||
<Table v-if="filteredData.length" class="is-compact-table">
|
<Table v-if="filteredData.length" class="is-compact-table">
|
||||||
@@ -8,7 +33,7 @@
|
|||||||
<TableRow
|
<TableRow
|
||||||
v-for="(item, index) in filteredData"
|
v-for="(item, index) in filteredData"
|
||||||
:key="`${item.type}-${item.created_at}-${index}`"
|
:key="`${item.type}-${item.created_at}-${index}`"
|
||||||
class="cursor-default"
|
class="cursor-default hover:bg-transparent"
|
||||||
:class="{ 'border-l-2 border-l-chart-4': item.isFavorite }">
|
:class="{ 'border-l-2 border-l-chart-4': item.isFavorite }">
|
||||||
<TableCell class="w-28 text-[11px] tabular-nums text-muted-foreground">
|
<TableCell class="w-28 text-[11px] tabular-nums text-muted-foreground">
|
||||||
<TooltipWrapper :content="formatExactTime(item.created_at)" side="top">
|
<TooltipWrapper :content="formatExactTime(item.created_at)" side="top">
|
||||||
@@ -22,37 +47,57 @@
|
|||||||
class="inline [&>div]:inline-flex"
|
class="inline [&>div]:inline-flex"
|
||||||
:location="item.location"
|
:location="item.location"
|
||||||
:hint="item.worldName"
|
:hint="item.worldName"
|
||||||
|
:grouphint="item.groupName"
|
||||||
disable-tooltip />
|
disable-tooltip />
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'OnPlayerJoined'">
|
<template v-else-if="item.type === 'OnPlayerJoined'">
|
||||||
<LogIn class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<LogIn class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span
|
<span
|
||||||
class="cursor-pointer font-medium hover:underline"
|
class="cursor-pointer"
|
||||||
:style="item.tagColour ? { color: item.tagColour } : null"
|
:style="item.tagColour ? { color: item.tagColour } : null"
|
||||||
@click="openUser(item.userId)"
|
@click="openUser(item.userId)"
|
||||||
>{{ item.displayName }}</span
|
>{{ item.displayName }}</span
|
||||||
>
|
>
|
||||||
|
<span v-if="item.isFriend">{{ item.isFavorite ? '⭐' : '💚' }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'OnPlayerLeft'">
|
<template v-else-if="item.type === 'OnPlayerLeft'">
|
||||||
<LogOut class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<LogOut class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span
|
<span
|
||||||
class="cursor-pointer font-medium text-muted-foreground/70 hover:underline"
|
class="cursor-pointer text-muted-foreground/70 hover:underline"
|
||||||
:style="item.tagColour ? { color: item.tagColour } : null"
|
:style="item.tagColour ? { color: item.tagColour } : null"
|
||||||
@click="openUser(item.userId)"
|
@click="openUser(item.userId)"
|
||||||
>{{ item.displayName }}</span
|
>{{ item.displayName }}</span
|
||||||
>
|
>
|
||||||
|
<span v-if="item.isFriend">{{ item.isFavorite ? '⭐' : '💚' }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'VideoPlay'">
|
<template v-else-if="item.type === 'VideoPlay'">
|
||||||
<Video class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<Video class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span class="text-muted-foreground">{{ item.videoName || item.videoUrl }}</span>
|
<TooltipWrapper
|
||||||
|
:content="
|
||||||
|
item.videoId
|
||||||
|
? `${item.videoId}: ${item.videoName || item.videoUrl}`
|
||||||
|
: item.videoName || item.videoUrl
|
||||||
|
"
|
||||||
|
side="top">
|
||||||
|
<span>
|
||||||
|
<span v-if="item.videoId" class="mr-1 text-muted-foreground"
|
||||||
|
>{{ item.videoId }}:</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="item.videoId !== 'LSMedia' && item.videoId !== 'PopcornPalace'"
|
||||||
|
class="cursor-pointer text-muted-foreground hover:underline"
|
||||||
|
@click="openExternalLink(item.videoUrl)"
|
||||||
|
>{{ item.videoName || item.videoUrl }}</span
|
||||||
|
>
|
||||||
|
<span v-else class="text-muted-foreground">{{ item.videoName }}</span>
|
||||||
|
</span>
|
||||||
|
</TooltipWrapper>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'PortalSpawn'">
|
<template v-else-if="item.type === 'PortalSpawn'">
|
||||||
<Waypoints class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<Waypoints class="mr-1 inline-block h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span
|
<span class="cursor-pointer hover:underline" @click="openUser(item.userId)">{{
|
||||||
class="cursor-pointer font-medium hover:underline"
|
item.displayName
|
||||||
@click="openUser(item.userId)"
|
}}</span>
|
||||||
>{{ item.displayName }}</span
|
|
||||||
>
|
|
||||||
<span class="text-muted-foreground"> → </span>
|
<span class="text-muted-foreground"> → </span>
|
||||||
<Location
|
<Location
|
||||||
v-if="item.location"
|
v-if="item.location"
|
||||||
@@ -68,12 +113,12 @@
|
|||||||
:content="item.data || item.message || ''"
|
:content="item.data || item.message || ''"
|
||||||
side="top">
|
side="top">
|
||||||
<span>
|
<span>
|
||||||
<span class="font-medium">{{ item.displayName }}</span>
|
<span>{{ item.displayName }}</span>
|
||||||
<span class="text-muted-foreground"> {{ item.type }}</span>
|
<span class="text-muted-foreground"> {{ item.type }}</span>
|
||||||
</span>
|
</span>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="font-medium">{{ item.displayName }}</span>
|
<span>{{ item.displayName }}</span>
|
||||||
<span class="text-muted-foreground"> {{ item.type }}</span>
|
<span class="text-muted-foreground"> {{ item.type }}</span>
|
||||||
<span v-if="item.data || item.message" class="ml-1 text-muted-foreground"
|
<span v-if="item.data || item.message" class="ml-1 text-muted-foreground"
|
||||||
>— {{ item.data || item.message }}</span
|
>— {{ item.data || item.message }}</span
|
||||||
@@ -94,14 +139,22 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, shallowRef, watch } from 'vue';
|
import { computed, onMounted, shallowRef, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { LogIn, LogOut, MapPin, Video, Waypoints } from 'lucide-vue-next';
|
import { LogIn, LogOut, MapPin, Settings, Video, Waypoints } from 'lucide-vue-next';
|
||||||
|
|
||||||
import { database } from '@/services/database';
|
import { database } from '@/services/database';
|
||||||
import { showUserDialog } from '@/coordinators/userCoordinator';
|
import { showUserDialog } from '@/coordinators/userCoordinator';
|
||||||
import { useFriendStore, useGameLogStore } from '@/stores';
|
import { useFriendStore, useGameLogStore } from '@/stores';
|
||||||
import { formatDateFilter } from '@/shared/utils';
|
import { formatDateFilter, openExternalLink } from '@/shared/utils';
|
||||||
import { watchState } from '@/services/watchState';
|
import { watchState } from '@/services/watchState';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import Location from '@/components/Location.vue';
|
import Location from '@/components/Location.vue';
|
||||||
import { TooltipWrapper } from '@/components/ui/tooltip';
|
import { TooltipWrapper } from '@/components/ui/tooltip';
|
||||||
import WidgetHeader from './WidgetHeader.vue';
|
import WidgetHeader from './WidgetHeader.vue';
|
||||||
@@ -121,6 +174,10 @@
|
|||||||
config: {
|
config: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({})
|
default: () => ({})
|
||||||
|
},
|
||||||
|
configUpdater: {
|
||||||
|
type: Function,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,6 +195,33 @@
|
|||||||
return GAMELOG_TYPES;
|
return GAMELOG_TYPES;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isFilterActive(filterType) {
|
||||||
|
const filters = props.config.filters;
|
||||||
|
if (!filters || !Array.isArray(filters) || filters.length === 0) return true;
|
||||||
|
return filters.includes(filterType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFilter(filterType) {
|
||||||
|
if (!props.configUpdater) return;
|
||||||
|
const currentFilters = props.config.filters;
|
||||||
|
let filters;
|
||||||
|
if (!currentFilters || !Array.isArray(currentFilters) || currentFilters.length === 0) {
|
||||||
|
filters = GAMELOG_TYPES.filter((f) => f !== filterType);
|
||||||
|
} else if (currentFilters.includes(filterType)) {
|
||||||
|
filters = currentFilters.filter((f) => f !== filterType);
|
||||||
|
if (filters.length === 0) filters = [];
|
||||||
|
} else {
|
||||||
|
filters = [...currentFilters, filterType];
|
||||||
|
if (filters.length === GAMELOG_TYPES.length) filters = [];
|
||||||
|
}
|
||||||
|
props.configUpdater({ ...props.config, filters });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBooleanConfig(key) {
|
||||||
|
if (!props.configUpdater) return;
|
||||||
|
props.configUpdater({ ...props.config, [key]: !props.config[key] });
|
||||||
|
}
|
||||||
|
|
||||||
const showDetail = computed(() => {
|
const showDetail = computed(() => {
|
||||||
return props.config.showDetail || false;
|
return props.config.showDetail || false;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
<WidgetHeader :title="t('dashboard.widget.instance')" icon="ri-group-3-line" route-name="player-list" />
|
<WidgetHeader :title="t('dashboard.widget.instance')" icon="ri-group-3-line" route-name="player-list">
|
||||||
|
<DropdownMenu v-if="configUpdater">
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button variant="ghost" size="icon-sm">
|
||||||
|
<Settings class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-48">
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
v-for="col in ALL_COLUMNS"
|
||||||
|
:key="col"
|
||||||
|
:model-value="isColumnVisible(col)"
|
||||||
|
:disabled="col === 'displayName'"
|
||||||
|
@select.prevent
|
||||||
|
@update:modelValue="toggleColumn(col)">
|
||||||
|
{{ t(`table.playerList.${col}`) }}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</WidgetHeader>
|
||||||
|
|
||||||
<template v-if="hasPlayers">
|
<template v-if="hasPlayers">
|
||||||
<!-- Info bar -->
|
<!-- Info bar -->
|
||||||
@@ -85,7 +104,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onActivated, onMounted } from 'vue';
|
import { computed, onActivated, onMounted } from 'vue';
|
||||||
import { Apple, IdCard, Monitor, Smartphone } from 'lucide-vue-next';
|
import { Apple, IdCard, Monitor, Settings, Smartphone } from 'lucide-vue-next';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
@@ -94,6 +113,13 @@
|
|||||||
import { showUserDialog, lookupUser } from '@/coordinators/userCoordinator';
|
import { showUserDialog, lookupUser } from '@/coordinators/userCoordinator';
|
||||||
import { displayLocation } from '@/shared/utils/locationParser';
|
import { displayLocation } from '@/shared/utils/locationParser';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import Timer from '@/components/Timer.vue';
|
import Timer from '@/components/Timer.vue';
|
||||||
import WidgetHeader from './WidgetHeader.vue';
|
import WidgetHeader from './WidgetHeader.vue';
|
||||||
import { Table, TableBody, TableRow, TableCell } from '@/components/ui/table';
|
import { Table, TableBody, TableRow, TableCell } from '@/components/ui/table';
|
||||||
@@ -105,6 +131,10 @@
|
|||||||
config: {
|
config: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({})
|
default: () => ({})
|
||||||
|
},
|
||||||
|
configUpdater: {
|
||||||
|
type: Function,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,6 +154,18 @@
|
|||||||
return activeColumns.value.includes(col);
|
return activeColumns.value.includes(col);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleColumn(col) {
|
||||||
|
if (!props.configUpdater || col === 'displayName') return;
|
||||||
|
const currentColumns = props.config.columns || DEFAULT_COLUMNS;
|
||||||
|
let columns;
|
||||||
|
if (currentColumns.includes(col)) {
|
||||||
|
columns = currentColumns.filter((c) => c !== col);
|
||||||
|
} else {
|
||||||
|
columns = [...currentColumns, col];
|
||||||
|
}
|
||||||
|
props.configUpdater({ ...props.config, columns });
|
||||||
|
}
|
||||||
|
|
||||||
const hasPlayers = computed(() => {
|
const hasPlayers = computed(() => {
|
||||||
return lastLocation.value.playerList && lastLocation.value.playerList.size > 0;
|
return lastLocation.value.playerList && lastLocation.value.playerList.size > 0;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex shrink-0 items-center justify-between border-b px-2.5 py-1.5">
|
<div class="group/header flex shrink-0 items-center justify-between border-b px-2.5 py-1.5">
|
||||||
<div
|
<div
|
||||||
class="group flex cursor-pointer items-center gap-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
class="flex cursor-pointer items-center gap-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||||||
@click="navigateToPage">
|
@click="navigateToPage">
|
||||||
<i :class="icon" class="text-sm"></i>
|
<i :class="icon" class="text-sm"></i>
|
||||||
<span>{{ title }}</span>
|
<span>{{ title }}</span>
|
||||||
<ExternalLink class="size-3 opacity-0 transition-opacity group-hover:opacity-100" />
|
<ExternalLink class="size-3 opacity-0 transition-opacity group-hover/header:opacity-100" />
|
||||||
|
</div>
|
||||||
|
<div class="opacity-0 transition-opacity group-hover/header:opacity-100">
|
||||||
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user