mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-19 06:43:51 +02:00
add dashboard widget
This commit is contained in:
@@ -107,7 +107,25 @@
|
|||||||
},
|
},
|
||||||
"selector": {
|
"selector": {
|
||||||
"title": "Select Panel Content",
|
"title": "Select Panel Content",
|
||||||
"clear": "Clear Panel"
|
"clear": "Clear Panel",
|
||||||
|
"widgets": "Widgets",
|
||||||
|
"pages": "Full Pages"
|
||||||
|
},
|
||||||
|
"widget": {
|
||||||
|
"feed": "Feed Widget",
|
||||||
|
"game_log": "Game Log Widget",
|
||||||
|
"instance": "Instance Widget",
|
||||||
|
"config": {
|
||||||
|
"filters": "Event Types",
|
||||||
|
"columns": "Columns"
|
||||||
|
},
|
||||||
|
"no_data": "No data",
|
||||||
|
"unknown_world": "Unknown World",
|
||||||
|
"feed_online": "online",
|
||||||
|
"feed_offline": "offline",
|
||||||
|
"feed_avatar": "→",
|
||||||
|
"feed_bio": "updated bio",
|
||||||
|
"instance_not_in_game": "Not in game"
|
||||||
},
|
},
|
||||||
"confirmations": {
|
"confirmations": {
|
||||||
"delete_title": "Delete Dashboard",
|
"delete_title": "Delete Dashboard",
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ import {
|
|||||||
DEFAULT_DASHBOARD_ICON
|
DEFAULT_DASHBOARD_ICON
|
||||||
} from '../shared/constants/dashboard';
|
} from '../shared/constants/dashboard';
|
||||||
|
|
||||||
|
function clonePanel(panel) {
|
||||||
|
if (typeof panel === 'string' && panel) {
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
if (panel && typeof panel === 'object' && typeof panel.key === 'string' && panel.key) {
|
||||||
|
return {
|
||||||
|
key: panel.key,
|
||||||
|
config: panel.config && typeof panel.config === 'object'
|
||||||
|
? JSON.parse(JSON.stringify(panel.config))
|
||||||
|
: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function cloneRows(rows) {
|
function cloneRows(rows) {
|
||||||
if (!Array.isArray(rows)) {
|
if (!Array.isArray(rows)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -15,11 +30,7 @@ function cloneRows(rows) {
|
|||||||
return rows
|
return rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const panels = Array.isArray(row?.panels)
|
const panels = Array.isArray(row?.panels)
|
||||||
? row.panels
|
? row.panels.slice(0, 2).map(clonePanel)
|
||||||
.slice(0, 2)
|
|
||||||
.map((panel) =>
|
|
||||||
typeof panel === 'string' && panel ? panel : null
|
|
||||||
)
|
|
||||||
: [];
|
: [];
|
||||||
if (!panels.length) {
|
if (!panels.length) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ export const useGameLogStore = defineStore('GameLog', () => {
|
|||||||
|
|
||||||
const lastResourceloadUrl = ref('');
|
const lastResourceloadUrl = ref('');
|
||||||
|
|
||||||
|
// Latest entry ref for GameLog Widget to watch
|
||||||
|
const latestGameLogEntry = ref(null);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => watchState.isLoggedIn,
|
() => watchState.isLoggedIn,
|
||||||
() => {
|
() => {
|
||||||
@@ -355,6 +358,9 @@ export const useGameLogStore = defineStore('GameLog', () => {
|
|||||||
entry.isFriend = gameLogIsFriend(entry);
|
entry.isFriend = gameLogIsFriend(entry);
|
||||||
entry.isFavorite = gameLogIsFavorite(entry);
|
entry.isFavorite = gameLogIsFavorite(entry);
|
||||||
|
|
||||||
|
// Update ref for GameLog Widget (independent data stream)
|
||||||
|
latestGameLogEntry.value = entry;
|
||||||
|
|
||||||
// If the VIP friend filter is enabled, logs from other friends will be ignored.
|
// If the VIP friend filter is enabled, logs from other friends will be ignored.
|
||||||
if (
|
if (
|
||||||
gameLogTable.value.vip &&
|
gameLogTable.value.vip &&
|
||||||
@@ -461,6 +467,7 @@ export const useGameLogStore = defineStore('GameLog', () => {
|
|||||||
gameLogTableData,
|
gameLogTableData,
|
||||||
lastVideoUrl,
|
lastVideoUrl,
|
||||||
lastResourceloadUrl,
|
lastResourceloadUrl,
|
||||||
|
latestGameLogEntry,
|
||||||
|
|
||||||
clearNowPlaying,
|
clearNowPlaying,
|
||||||
resetLastMediaUrls,
|
resetLastMediaUrls,
|
||||||
|
|||||||
@@ -171,11 +171,11 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdatePanel = (rowIndex, panelIndex, panelKey) => {
|
const handleUpdatePanel = (rowIndex, panelIndex, panelValue) => {
|
||||||
if (!editRows.value[rowIndex]?.panels) {
|
if (!editRows.value[rowIndex]?.panels) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
editRows.value[rowIndex].panels[panelIndex] = panelKey;
|
editRows.value[rowIndex].panels[panelIndex] = panelValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
|||||||
@@ -14,6 +14,45 @@
|
|||||||
<i v-if="panelIcon" :class="panelIcon" class="text-base" />
|
<i v-if="panelIcon" :class="panelIcon" class="text-base" />
|
||||||
<span>{{ panelLabel || t('dashboard.panel.not_selected') }}</span>
|
<span>{{ panelLabel || t('dashboard.panel.not_selected') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Widget config section -->
|
||||||
|
<div v-if="isWidget && panelKey" class="border-t border-border/50 py-1">
|
||||||
|
<!-- Feed/GameLog: event type filters -->
|
||||||
|
<template v-if="widgetType === 'feed' || widgetType === 'game-log'">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ t('dashboard.widget.config.filters') }}</span>
|
||||||
|
<div class="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
<label
|
||||||
|
v-for="filterType in availableFilters"
|
||||||
|
:key="filterType"
|
||||||
|
class="flex items-center gap-1 text-xs cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isFilterActive(filterType)"
|
||||||
|
@change="toggleFilter(filterType)" />
|
||||||
|
{{ filterType }}
|
||||||
|
</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">
|
<Button variant="outline" class="w-full" @click="openSelector">
|
||||||
{{ panelKey ? t('dashboard.panel.replace') : t('dashboard.panel.select') }}
|
{{ panelKey ? t('dashboard.panel.replace') : t('dashboard.panel.select') }}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -22,7 +61,7 @@
|
|||||||
|
|
||||||
<template v-else-if="panelKey && panelComponent">
|
<template v-else-if="panelKey && panelComponent">
|
||||||
<div class="dashboard-panel h-full w-full overflow-y-auto">
|
<div class="dashboard-panel h-full w-full overflow-y-auto">
|
||||||
<component :is="panelComponent" />
|
<component :is="panelComponent" v-bind="widgetProps" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -32,7 +71,7 @@
|
|||||||
|
|
||||||
<PanelSelector
|
<PanelSelector
|
||||||
:open="selectorOpen"
|
:open="selectorOpen"
|
||||||
:current-key="panelKey"
|
:current-key="panelData"
|
||||||
@select="handleSelect"
|
@select="handleSelect"
|
||||||
@close="selectorOpen = false" />
|
@close="selectorOpen = false" />
|
||||||
</div>
|
</div>
|
||||||
@@ -49,9 +88,13 @@
|
|||||||
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({
|
||||||
panelKey: {
|
panelData: {
|
||||||
type: String,
|
type: [String, Object],
|
||||||
default: null
|
default: null
|
||||||
},
|
},
|
||||||
isEditing: {
|
isEditing: {
|
||||||
@@ -68,29 +111,110 @@
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const selectorOpen = ref(false);
|
const selectorOpen = ref(false);
|
||||||
|
|
||||||
const panelComponent = computed(() => {
|
// Extract key from string or object format
|
||||||
if (!props.panelKey) {
|
const panelKey = computed(() => {
|
||||||
return null;
|
if (!props.panelData) return null;
|
||||||
}
|
if (typeof props.panelData === 'string') return props.panelData;
|
||||||
return panelComponentMap[props.panelKey] || null;
|
return props.panelData.key || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const panelConfig = computed(() => {
|
||||||
|
if (!props.panelData || typeof props.panelData === 'string') return {};
|
||||||
|
return props.panelData.config || {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const isWidget = computed(() => {
|
||||||
|
return panelKey.value && panelKey.value.startsWith('widget:');
|
||||||
|
});
|
||||||
|
|
||||||
|
const widgetType = computed(() => {
|
||||||
|
if (!isWidget.value) return null;
|
||||||
|
return panelKey.value.replace('widget:', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const panelComponent = computed(() => {
|
||||||
|
if (!panelKey.value) return null;
|
||||||
|
return panelComponentMap[panelKey.value] || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const widgetProps = computed(() => {
|
||||||
|
if (!isWidget.value) return {};
|
||||||
|
return { config: panelConfig.value };
|
||||||
|
});
|
||||||
|
|
||||||
|
const widgetDefs = {
|
||||||
|
'widget:feed': { icon: 'ri-rss-line', labelKey: 'dashboard.widget.feed' },
|
||||||
|
'widget:game-log': { icon: 'ri-history-line', labelKey: 'dashboard.widget.game_log' },
|
||||||
|
'widget:instance': { icon: 'ri-group-3-line', labelKey: 'dashboard.widget.instance' }
|
||||||
|
};
|
||||||
|
|
||||||
const panelOption = computed(() => {
|
const panelOption = computed(() => {
|
||||||
if (!props.panelKey) {
|
if (!panelKey.value) return null;
|
||||||
return null;
|
if (widgetDefs[panelKey.value]) return widgetDefs[panelKey.value];
|
||||||
}
|
return navDefinitions.find((def) => def.key === panelKey.value) || null;
|
||||||
return navDefinitions.find((def) => def.key === props.panelKey) || null;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const panelLabel = computed(() => {
|
const panelLabel = computed(() => {
|
||||||
if (!panelOption.value?.labelKey) {
|
if (!panelOption.value?.labelKey) return panelKey.value || '';
|
||||||
return props.panelKey || '';
|
|
||||||
}
|
|
||||||
return t(panelOption.value.labelKey);
|
return t(panelOption.value.labelKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
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', 'rank', 'timer'].includes(col);
|
||||||
|
}
|
||||||
|
return columns.includes(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleColumn(col) {
|
||||||
|
if (col === 'displayName') return; // Always visible
|
||||||
|
const currentColumns = panelConfig.value.columns || ['icon', 'displayName', 'rank', 'timer'];
|
||||||
|
let columns;
|
||||||
|
if (currentColumns.includes(col)) {
|
||||||
|
columns = currentColumns.filter((c) => c !== col);
|
||||||
|
} else {
|
||||||
|
columns = [...currentColumns, col];
|
||||||
|
}
|
||||||
|
emitConfigUpdate({ ...panelConfig.value, columns });
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitConfigUpdate(newConfig) {
|
||||||
|
emit('select', { key: panelKey.value, config: newConfig });
|
||||||
|
}
|
||||||
|
|
||||||
const openSelector = () => {
|
const openSelector = () => {
|
||||||
selectorOpen.value = true;
|
selectorOpen.value = true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
class="flex h-full gap-2"
|
class="flex h-full gap-2"
|
||||||
:class="isVertical ? 'flex-col' : 'flex-row'">
|
:class="isVertical ? 'flex-col' : 'flex-row'">
|
||||||
<DashboardPanel
|
<DashboardPanel
|
||||||
v-for="(panelKey, panelIndex) in row.panels"
|
v-for="(panelItem, panelIndex) in row.panels"
|
||||||
:key="panelIndex"
|
:key="panelIndex"
|
||||||
:panel-key="panelKey"
|
:panel-data="panelItem"
|
||||||
:is-editing="true"
|
:is-editing="true"
|
||||||
:show-remove="true"
|
:show-remove="true"
|
||||||
:class="panelEditClass"
|
:class="panelEditClass"
|
||||||
@select="(key) => emit('update-panel', rowIndex, panelIndex, key)"
|
@select="(value) => emit('update-panel', rowIndex, panelIndex, value)"
|
||||||
@remove="emit('remove-panel', rowIndex, panelIndex)" />
|
@remove="emit('remove-panel', rowIndex, panelIndex)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,16 +21,16 @@
|
|||||||
: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-key="row.panels[0]" class="h-full" />
|
<DashboardPanel :panel-data="row.panels[0]" class="h-full" />
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle />
|
<ResizableHandle />
|
||||||
<ResizablePanel :default-size="50" :min-size="20">
|
<ResizablePanel :default-size="50" :min-size="20">
|
||||||
<DashboardPanel :panel-key="row.panels[1]" class="h-full" />
|
<DashboardPanel :panel-data="row.panels[1]" class="h-full" />
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|
||||||
<div v-else class="h-full">
|
<div v-else class="h-full">
|
||||||
<DashboardPanel :panel-key="row.panels[0]" class="h-full" />
|
<DashboardPanel :panel-data="row.panels[0]" class="h-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,17 +5,44 @@
|
|||||||
<DialogTitle>{{ t('dashboard.selector.title') }}</DialogTitle>
|
<DialogTitle>{{ t('dashboard.selector.title') }}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-2 max-h-[50vh] overflow-y-auto">
|
<div class="max-h-[50vh] overflow-y-auto">
|
||||||
<button
|
<!-- Widget section -->
|
||||||
v-for="option in panelOptions"
|
<div class="mb-3">
|
||||||
:key="option.key"
|
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide px-1">
|
||||||
type="button"
|
{{ t('dashboard.selector.widgets') }}
|
||||||
class="flex items-center gap-2 rounded-md border p-2 text-left text-sm hover:bg-accent"
|
</span>
|
||||||
:class="option.key === currentKey ? 'border-primary bg-primary/5 ring-1 ring-primary/40' : ''"
|
<div class="grid grid-cols-2 gap-2 mt-1.5">
|
||||||
@click="emit('select', option.key)">
|
<button
|
||||||
<i :class="option.icon" class="text-base" />
|
v-for="option in widgetOptions"
|
||||||
<span>{{ t(option.labelKey) }}</span>
|
:key="option.key"
|
||||||
</button>
|
type="button"
|
||||||
|
class="flex items-center gap-2 rounded-md border p-2 text-left text-sm hover:bg-accent"
|
||||||
|
:class="option.key === currentPanelKey ? 'border-primary bg-primary/5 ring-1 ring-primary/40' : 'border-primary/20'"
|
||||||
|
@click="handleSelectWidget(option)">
|
||||||
|
<i :class="option.icon" class="text-base"></i>
|
||||||
|
<span>{{ t(option.labelKey) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full pages section -->
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide px-1">
|
||||||
|
{{ t('dashboard.selector.pages') }}
|
||||||
|
</span>
|
||||||
|
<div class="grid grid-cols-2 gap-2 mt-1.5">
|
||||||
|
<button
|
||||||
|
v-for="option in panelOptions"
|
||||||
|
:key="option.key"
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 rounded-md border p-2 text-left text-sm hover:bg-accent"
|
||||||
|
:class="option.key === currentPanelKey ? 'border-primary bg-primary/5 ring-1 ring-primary/40' : ''"
|
||||||
|
@click="emit('select', option.key)">
|
||||||
|
<i :class="option.icon" class="text-base"></i>
|
||||||
|
<span>{{ t(option.labelKey) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -34,13 +61,34 @@
|
|||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { navDefinitions } from '@/shared/constants/ui';
|
import { navDefinitions } from '@/shared/constants/ui';
|
||||||
|
|
||||||
defineProps({
|
const widgetDefinitions = [
|
||||||
|
{
|
||||||
|
key: 'widget:feed',
|
||||||
|
icon: 'ri-rss-line',
|
||||||
|
labelKey: 'dashboard.widget.feed',
|
||||||
|
defaultConfig: { filters: [] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'widget:game-log',
|
||||||
|
icon: 'ri-history-line',
|
||||||
|
labelKey: 'dashboard.widget.game_log',
|
||||||
|
defaultConfig: { filters: [] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'widget:instance',
|
||||||
|
icon: 'ri-group-3-line',
|
||||||
|
labelKey: 'dashboard.widget.instance',
|
||||||
|
defaultConfig: { columns: ['icon', 'displayName', 'rank', 'timer'] }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
open: {
|
open: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
currentKey: {
|
currentKey: {
|
||||||
type: String,
|
type: [String, Object],
|
||||||
default: null
|
default: null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -48,5 +96,20 @@
|
|||||||
const emit = defineEmits(['select', 'close']);
|
const emit = defineEmits(['select', 'close']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const currentPanelKey = computed(() => {
|
||||||
|
if (!props.currentKey) return null;
|
||||||
|
if (typeof props.currentKey === 'string') return props.currentKey;
|
||||||
|
return props.currentKey.key || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const widgetOptions = computed(() => widgetDefinitions);
|
||||||
|
|
||||||
const panelOptions = computed(() => navDefinitions.filter((def) => def.routeName));
|
const panelOptions = computed(() => navDefinitions.filter((def) => def.routeName));
|
||||||
|
|
||||||
|
function handleSelectWidget(option) {
|
||||||
|
emit('select', {
|
||||||
|
key: option.key,
|
||||||
|
config: { ...option.defaultConfig }
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { defineAsyncComponent } from 'vue';
|
||||||
|
|
||||||
import Feed from '../../Feed/Feed.vue';
|
import Feed from '../../Feed/Feed.vue';
|
||||||
import FavoritesAvatar from '../../Favorites/FavoritesAvatar.vue';
|
import FavoritesAvatar from '../../Favorites/FavoritesAvatar.vue';
|
||||||
import FavoritesFriend from '../../Favorites/FavoritesFriend.vue';
|
import FavoritesFriend from '../../Favorites/FavoritesFriend.vue';
|
||||||
@@ -27,7 +29,10 @@ export const panelComponentMap = {
|
|||||||
moderation: Moderation,
|
moderation: Moderation,
|
||||||
notification: Notification,
|
notification: Notification,
|
||||||
'my-avatars': MyAvatars,
|
'my-avatars': MyAvatars,
|
||||||
'charts-instance': () => import('../../Charts/components/InstanceActivity.vue'),
|
'charts-instance': defineAsyncComponent(() => import('../../Charts/components/InstanceActivity.vue')),
|
||||||
'charts-mutual': () => import('../../Charts/components/MutualFriends.vue'),
|
'charts-mutual': defineAsyncComponent(() => import('../../Charts/components/MutualFriends.vue')),
|
||||||
tools: Tools
|
tools: Tools,
|
||||||
|
'widget:feed': defineAsyncComponent(() => import('../widgets/FeedWidget.vue')),
|
||||||
|
'widget:game-log': defineAsyncComponent(() => import('../widgets/GameLogWidget.vue')),
|
||||||
|
'widget:instance': defineAsyncComponent(() => import('../widgets/InstanceWidget.vue'))
|
||||||
};
|
};
|
||||||
|
|||||||
106
src/views/Dashboard/widgets/FeedWidget.vue
Normal file
106
src/views/Dashboard/widgets/FeedWidget.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
|
<WidgetHeader
|
||||||
|
:title="t('dashboard.widget.feed')"
|
||||||
|
icon="ri-rss-line"
|
||||||
|
route-name="feed" />
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto" ref="listRef">
|
||||||
|
<template v-if="filteredData.length">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in filteredData"
|
||||||
|
:key="`${item.type}-${item.created_at}-${index}`"
|
||||||
|
class="flex items-center gap-1.5 border-b border-border/30 px-2.5 py-0.75 text-[13px] leading-snug hover:bg-accent/50"
|
||||||
|
:class="{ 'border-l-2 border-l-chart-4': item.isFavorite }">
|
||||||
|
<span class="shrink-0 text-[11px] tabular-nums text-muted-foreground">{{ formatTime(item.created_at) }}</span>
|
||||||
|
|
||||||
|
<template v-if="item.type === 'GPS'">
|
||||||
|
<span class="shrink-0 max-w-[140px] cursor-pointer truncate font-medium hover:underline" @click="openUser(item.userId)">{{ item.displayName }}</span>
|
||||||
|
<span class="truncate text-muted-foreground">→ {{ item.worldName || t('dashboard.widget.unknown_world') }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'Online'">
|
||||||
|
<span class="shrink-0 max-w-[140px] cursor-pointer truncate font-medium hover:underline" @click="openUser(item.userId)">{{ item.displayName }}</span>
|
||||||
|
<span class="truncate text-chart-2">{{ t('dashboard.widget.feed_online') }}</span>
|
||||||
|
<span v-if="item.worldName" class="truncate text-muted-foreground">→ {{ item.worldName }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'Offline'">
|
||||||
|
<span class="shrink-0 max-w-[140px] cursor-pointer truncate font-medium hover:underline" @click="openUser(item.userId)">{{ item.displayName }}</span>
|
||||||
|
<span class="truncate text-muted-foreground/60">{{ t('dashboard.widget.feed_offline') }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'Status'">
|
||||||
|
<span class="shrink-0 max-w-[140px] cursor-pointer truncate font-medium hover:underline" @click="openUser(item.userId)">{{ item.displayName }}</span>
|
||||||
|
<i class="x-user-status" :class="statusClass(item.status)"></i>
|
||||||
|
<span class="truncate text-muted-foreground">{{ item.statusDescription }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'Avatar'">
|
||||||
|
<span class="shrink-0 max-w-[140px] cursor-pointer truncate font-medium hover:underline" @click="openUser(item.userId)">{{ item.displayName }}</span>
|
||||||
|
<span class="truncate text-muted-foreground">{{ t('dashboard.widget.feed_avatar') }} {{ item.avatarName }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'Bio'">
|
||||||
|
<span class="shrink-0 max-w-[140px] cursor-pointer truncate font-medium hover:underline" @click="openUser(item.userId)">{{ item.displayName }}</span>
|
||||||
|
<span class="truncate text-muted-foreground">{{ t('dashboard.widget.feed_bio') }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="shrink-0 max-w-[140px] cursor-pointer truncate font-medium hover:underline" @click="openUser(item.userId)">{{ item.displayName }}</span>
|
||||||
|
<span class="truncate text-muted-foreground">{{ item.type }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="flex h-full items-center justify-center text-[13px] text-muted-foreground">
|
||||||
|
{{ t('dashboard.widget.no_data') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { statusClass } from '@/shared/utils/user';
|
||||||
|
import { showUserDialog } from '@/coordinators/userCoordinator';
|
||||||
|
import { useFeedStore } from '@/stores';
|
||||||
|
|
||||||
|
import WidgetHeader from './WidgetHeader.vue';
|
||||||
|
|
||||||
|
const FEED_TYPES = ['GPS', 'Online', 'Offline', 'Status', 'Avatar', 'Bio'];
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
config: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const listRef = ref(null);
|
||||||
|
|
||||||
|
const activeFilters = computed(() => {
|
||||||
|
if (props.config.filters && Array.isArray(props.config.filters) && props.config.filters.length > 0) {
|
||||||
|
return props.config.filters;
|
||||||
|
}
|
||||||
|
return FEED_TYPES;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredData = computed(() => {
|
||||||
|
const filters = activeFilters.value;
|
||||||
|
return feedStore.feedTableData.filter((item) => filters.includes(item.type));
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatTime(dateStr) {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUser(userId) {
|
||||||
|
if (userId) {
|
||||||
|
showUserDialog(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ FEED_TYPES });
|
||||||
|
</script>
|
||||||
153
src/views/Dashboard/widgets/GameLogWidget.vue
Normal file
153
src/views/Dashboard/widgets/GameLogWidget.vue
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<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" />
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<template v-if="filteredData.length">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in filteredData"
|
||||||
|
:key="`${item.type}-${item.created_at}-${index}`"
|
||||||
|
class="flex items-center gap-1.5 border-b border-border/30 px-2.5 py-0.75 text-[13px] leading-snug hover:bg-accent/50"
|
||||||
|
:class="{ 'border-l-2 border-l-chart-4': item.isFavorite }">
|
||||||
|
<span class="shrink-0 text-[11px] tabular-nums text-muted-foreground">{{ formatTime(item.created_at) }}</span>
|
||||||
|
|
||||||
|
<template v-if="item.type === 'Location'">
|
||||||
|
<span class="truncate font-medium text-foreground">🌍 {{ item.worldName || item.location }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'OnPlayerJoined'">
|
||||||
|
<span class="shrink-0 font-semibold text-chart-2">→</span>
|
||||||
|
<span
|
||||||
|
class="shrink-0 max-w-[140px] cursor-pointer truncate font-medium hover:underline"
|
||||||
|
:style="item.tagColour ? { color: item.tagColour } : null"
|
||||||
|
@click="openUser(item.userId)">{{ item.displayName }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'OnPlayerLeft'">
|
||||||
|
<span class="shrink-0 font-semibold text-muted-foreground/60">←</span>
|
||||||
|
<span
|
||||||
|
class="shrink-0 max-w-[140px] cursor-pointer truncate font-medium hover:underline"
|
||||||
|
:style="item.tagColour ? { color: item.tagColour } : null"
|
||||||
|
@click="openUser(item.userId)">{{ item.displayName }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'VideoPlay'">
|
||||||
|
<span class="truncate text-muted-foreground">🎬 {{ item.videoName || item.videoUrl }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'PortalSpawn'">
|
||||||
|
<span class="shrink-0 max-w-[140px] cursor-pointer truncate font-medium hover:underline" @click="openUser(item.userId)">{{ item.displayName }}</span>
|
||||||
|
<span class="truncate text-muted-foreground">🌀 {{ item.worldName || '' }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="shrink-0 max-w-[140px] truncate font-medium">{{ item.displayName }}</span>
|
||||||
|
<span class="truncate text-muted-foreground">{{ item.type }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="flex h-full items-center justify-center text-[13px] text-muted-foreground">
|
||||||
|
{{ t('dashboard.widget.no_data') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, shallowRef, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { database } from '@/services/database';
|
||||||
|
import { showUserDialog } from '@/coordinators/userCoordinator';
|
||||||
|
import { useFriendStore, useGameLogStore } from '@/stores';
|
||||||
|
import { watchState } from '@/services/watchState';
|
||||||
|
|
||||||
|
import WidgetHeader from './WidgetHeader.vue';
|
||||||
|
|
||||||
|
const GAMELOG_TYPES = ['Location', 'OnPlayerJoined', 'OnPlayerLeft', 'VideoPlay', 'PortalSpawn', 'Event', 'External'];
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
config: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const friendStore = useFriendStore();
|
||||||
|
const gameLogStore = useGameLogStore();
|
||||||
|
|
||||||
|
const widgetData = shallowRef([]);
|
||||||
|
const maxEntries = 100;
|
||||||
|
|
||||||
|
const activeFilters = computed(() => {
|
||||||
|
if (props.config.filters && Array.isArray(props.config.filters) && props.config.filters.length > 0) {
|
||||||
|
return props.config.filters;
|
||||||
|
}
|
||||||
|
return GAMELOG_TYPES;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredData = computed(() => {
|
||||||
|
const filters = activeFilters.value;
|
||||||
|
return widgetData.value.filter((item) => filters.includes(item.type));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadInitialData() {
|
||||||
|
try {
|
||||||
|
const rows = await database.lookupGameLogDatabase([], []);
|
||||||
|
for (const row of rows) {
|
||||||
|
row.isFriend = row.userId ? friendStore.friends.has(row.userId) : false;
|
||||||
|
row.isFavorite = row.userId ? friendStore.localFavoriteFriends.has(row.userId) : false;
|
||||||
|
}
|
||||||
|
widgetData.value = rows;
|
||||||
|
} catch {
|
||||||
|
widgetData.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => gameLogStore.latestGameLogEntry,
|
||||||
|
(entry) => {
|
||||||
|
if (!entry) return;
|
||||||
|
const newEntry = { ...entry };
|
||||||
|
newEntry.isFriend = newEntry.userId ? friendStore.friends.has(newEntry.userId) : false;
|
||||||
|
newEntry.isFavorite = newEntry.userId ? friendStore.localFavoriteFriends.has(newEntry.userId) : false;
|
||||||
|
widgetData.value = [newEntry, ...widgetData.value];
|
||||||
|
if (widgetData.value.length > maxEntries) {
|
||||||
|
widgetData.value = widgetData.value.slice(0, maxEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => watchState.isLoggedIn,
|
||||||
|
(isLoggedIn) => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
loadInitialData();
|
||||||
|
} else {
|
||||||
|
widgetData.value = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'sync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (watchState.isLoggedIn) {
|
||||||
|
loadInitialData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatTime(dateStr) {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUser(userId) {
|
||||||
|
if (userId) {
|
||||||
|
showUserDialog(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ GAMELOG_TYPES });
|
||||||
|
</script>
|
||||||
139
src/views/Dashboard/widgets/InstanceWidget.vue
Normal file
139
src/views/Dashboard/widgets/InstanceWidget.vue
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
|
<WidgetHeader
|
||||||
|
:title="headerTitle"
|
||||||
|
icon="ri-group-3-line"
|
||||||
|
route-name="player-list" />
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto" v-if="hasPlayers">
|
||||||
|
<div
|
||||||
|
v-for="player in playersData"
|
||||||
|
:key="player.ref?.id || player.displayName"
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 border-b border-border/30 px-2.5 py-0.75 text-[13px] leading-snug hover:bg-accent/50"
|
||||||
|
@click="openUser(player)">
|
||||||
|
<span v-if="isColumnVisible('icon')" class="shrink-0 min-w-5 text-[11px]">
|
||||||
|
<span v-if="player.isMaster">👑</span>
|
||||||
|
<span v-else-if="player.isModerator">⚔️</span>
|
||||||
|
<span v-if="player.isFriend">💚</span>
|
||||||
|
<span v-if="player.isBlocked" class="text-destructive">⛔</span>
|
||||||
|
<span v-if="player.isMuted" class="text-muted-foreground">🔇</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="flex-1 truncate font-medium" :class="player.ref?.$trustClass">
|
||||||
|
{{ player.displayName }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span v-if="isColumnVisible('rank')" class="shrink-0 max-w-20 truncate text-[11px]" :class="player.ref?.$trustClass">
|
||||||
|
{{ player.ref?.$trustLevel || '' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span v-if="isColumnVisible('platform')" class="flex shrink-0 items-center">
|
||||||
|
<Monitor v-if="player.ref?.$platform === 'standalonewindows'" class="h-3 w-3 x-tag-platform-pc" />
|
||||||
|
<Smartphone v-else-if="player.ref?.$platform === 'android'" class="h-3 w-3 x-tag-platform-quest" />
|
||||||
|
<Apple v-else-if="player.ref?.$platform === 'ios'" class="h-3 w-3 x-tag-platform-ios" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span v-if="isColumnVisible('language')" class="flex shrink-0 items-center">
|
||||||
|
<span
|
||||||
|
v-for="lang in (player.ref?.$languages || []).slice(0, 2)"
|
||||||
|
:key="lang.key"
|
||||||
|
:class="['flags', 'inline-block', 'mr-0.5', languageClass(lang.key)]">
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span v-if="isColumnVisible('status')" class="shrink-0">
|
||||||
|
<i class="x-user-status shrink-0" :class="player.ref?.status ? statusClass(player.ref.status) : ''"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span v-if="isColumnVisible('timer')" class="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
<Timer v-if="player.timer" :epoch="player.timer" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex h-full items-center justify-center text-[13px] text-muted-foreground">
|
||||||
|
{{ t('dashboard.widget.instance_not_in_game') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onActivated, onMounted } from 'vue';
|
||||||
|
import { Apple, Monitor, Smartphone } from 'lucide-vue-next';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { useInstanceStore, useLocationStore } from '@/stores';
|
||||||
|
import { languageClass, statusClass } from '@/shared/utils/user';
|
||||||
|
import { showUserDialog, lookupUser } from '@/coordinators/userCoordinator';
|
||||||
|
import { displayLocation } from '@/shared/utils/locationParser';
|
||||||
|
|
||||||
|
import Timer from '@/components/Timer.vue';
|
||||||
|
import WidgetHeader from './WidgetHeader.vue';
|
||||||
|
|
||||||
|
const ALL_COLUMNS = ['icon', 'displayName', 'rank', 'timer', 'platform', 'language', 'status'];
|
||||||
|
const DEFAULT_COLUMNS = ['icon', 'displayName', 'rank', 'timer'];
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
config: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const instanceStore = useInstanceStore();
|
||||||
|
const { currentInstanceUsersData, currentInstanceWorld } = storeToRefs(instanceStore);
|
||||||
|
const { lastLocation } = storeToRefs(useLocationStore());
|
||||||
|
|
||||||
|
const activeColumns = computed(() => {
|
||||||
|
if (props.config.columns && Array.isArray(props.config.columns) && props.config.columns.length > 0) {
|
||||||
|
return props.config.columns;
|
||||||
|
}
|
||||||
|
return DEFAULT_COLUMNS;
|
||||||
|
});
|
||||||
|
|
||||||
|
function isColumnVisible(col) {
|
||||||
|
return activeColumns.value.includes(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPlayers = computed(() => {
|
||||||
|
return lastLocation.value.playerList && lastLocation.value.playerList.size > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const playersData = computed(() => {
|
||||||
|
return currentInstanceUsersData.value || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerTitle = computed(() => {
|
||||||
|
if (!hasPlayers.value) {
|
||||||
|
return t('dashboard.widget.instance');
|
||||||
|
}
|
||||||
|
const loc = lastLocation.value;
|
||||||
|
const worldName = loc.name || t('dashboard.widget.unknown_world');
|
||||||
|
const playerCount = loc.playerList?.size || 0;
|
||||||
|
const locationInfo = loc.location ? displayLocation(loc.location, worldName) : '';
|
||||||
|
if (locationInfo) {
|
||||||
|
return `${worldName} · ${locationInfo} · ${playerCount}`;
|
||||||
|
}
|
||||||
|
return `${worldName} · ${playerCount}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function openUser(player) {
|
||||||
|
const ref = player?.ref;
|
||||||
|
if (ref?.id) {
|
||||||
|
showUserDialog(ref.id);
|
||||||
|
} else if (ref) {
|
||||||
|
lookupUser(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
instanceStore.getCurrentInstanceUserList();
|
||||||
|
});
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
instanceStore.getCurrentInstanceUserList();
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({ ALL_COLUMNS, DEFAULT_COLUMNS });
|
||||||
|
</script>
|
||||||
39
src/views/Dashboard/widgets/WidgetHeader.vue
Normal file
39
src/views/Dashboard/widgets/WidgetHeader.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex shrink-0 items-center justify-between border-b px-2.5 py-1.5">
|
||||||
|
<div
|
||||||
|
class="group flex cursor-pointer items-center gap-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
@click="navigateToPage">
|
||||||
|
<i :class="icon" class="text-sm"></i>
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
<ExternalLink class="size-3 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ExternalLink } from 'lucide-vue-next';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
routeName: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function navigateToPage() {
|
||||||
|
if (props.routeName) {
|
||||||
|
router.push({ name: props.routeName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user