add dashboard widget

This commit is contained in:
pa
2026-03-13 13:43:13 +09:00
parent 0135d9bb29
commit 1ffb2c8b95
12 changed files with 711 additions and 46 deletions

View File

@@ -107,7 +107,25 @@
},
"selector": {
"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": {
"delete_title": "Delete Dashboard",

View File

@@ -8,6 +8,21 @@ import {
DEFAULT_DASHBOARD_ICON
} 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) {
if (!Array.isArray(rows)) {
return [];
@@ -15,11 +30,7 @@ function cloneRows(rows) {
return rows
.map((row) => {
const panels = Array.isArray(row?.panels)
? row.panels
.slice(0, 2)
.map((panel) =>
typeof panel === 'string' && panel ? panel : null
)
? row.panels.slice(0, 2).map(clonePanel)
: [];
if (!panels.length) {
return null;

View File

@@ -67,6 +67,9 @@ export const useGameLogStore = defineStore('GameLog', () => {
const lastResourceloadUrl = ref('');
// Latest entry ref for GameLog Widget to watch
const latestGameLogEntry = ref(null);
watch(
() => watchState.isLoggedIn,
() => {
@@ -355,6 +358,9 @@ export const useGameLogStore = defineStore('GameLog', () => {
entry.isFriend = gameLogIsFriend(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 (
gameLogTable.value.vip &&
@@ -461,6 +467,7 @@ export const useGameLogStore = defineStore('GameLog', () => {
gameLogTableData,
lastVideoUrl,
lastResourceloadUrl,
latestGameLogEntry,
clearNowPlaying,
resetLastMediaUrls,

View File

@@ -171,11 +171,11 @@
}
};
const handleUpdatePanel = (rowIndex, panelIndex, panelKey) => {
const handleUpdatePanel = (rowIndex, panelIndex, panelValue) => {
if (!editRows.value[rowIndex]?.panels) {
return;
}
editRows.value[rowIndex].panels[panelIndex] = panelKey;
editRows.value[rowIndex].panels[panelIndex] = panelValue;
};
const handleSave = async () => {

View File

@@ -14,6 +14,45 @@
<i v-if="panelIcon" :class="panelIcon" class="text-base" />
<span>{{ panelLabel || t('dashboard.panel.not_selected') }}</span>
</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">
{{ panelKey ? t('dashboard.panel.replace') : t('dashboard.panel.select') }}
</Button>
@@ -22,7 +61,7 @@
<template v-else-if="panelKey && panelComponent">
<div class="dashboard-panel h-full w-full overflow-y-auto">
<component :is="panelComponent" />
<component :is="panelComponent" v-bind="widgetProps" />
</div>
</template>
@@ -32,7 +71,7 @@
<PanelSelector
:open="selectorOpen"
:current-key="panelKey"
:current-key="panelData"
@select="handleSelect"
@close="selectorOpen = false" />
</div>
@@ -49,9 +88,13 @@
import PanelSelector from './PanelSelector.vue';
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({
panelKey: {
type: String,
panelData: {
type: [String, Object],
default: null
},
isEditing: {
@@ -68,29 +111,110 @@
const { t } = useI18n();
const selectorOpen = ref(false);
const panelComponent = computed(() => {
if (!props.panelKey) {
return null;
}
return panelComponentMap[props.panelKey] || null;
// Extract key from string or object format
const panelKey = computed(() => {
if (!props.panelData) return null;
if (typeof props.panelData === 'string') return props.panelData;
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(() => {
if (!props.panelKey) {
return null;
}
return navDefinitions.find((def) => def.key === props.panelKey) || null;
if (!panelKey.value) return null;
if (widgetDefs[panelKey.value]) return widgetDefs[panelKey.value];
return navDefinitions.find((def) => def.key === panelKey.value) || null;
});
const panelLabel = computed(() => {
if (!panelOption.value?.labelKey) {
return props.panelKey || '';
}
if (!panelOption.value?.labelKey) return panelKey.value || '';
return t(panelOption.value.labelKey);
});
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 = () => {
selectorOpen.value = true;
};

View File

@@ -5,13 +5,13 @@
class="flex h-full gap-2"
:class="isVertical ? 'flex-col' : 'flex-row'">
<DashboardPanel
v-for="(panelKey, panelIndex) in row.panels"
v-for="(panelItem, panelIndex) in row.panels"
:key="panelIndex"
:panel-key="panelKey"
:panel-data="panelItem"
:is-editing="true"
:show-remove="true"
:class="panelEditClass"
@select="(key) => emit('update-panel', rowIndex, panelIndex, key)"
@select="(value) => emit('update-panel', rowIndex, panelIndex, value)"
@remove="emit('remove-panel', rowIndex, panelIndex)" />
</div>
@@ -21,16 +21,16 @@
:auto-save-id="`dashboard-${dashboardId}-row-${rowIndex}`"
class="h-full min-h-[180px]">
<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>
<ResizableHandle />
<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>
</ResizablePanelGroup>
<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>
</template>

View File

@@ -5,17 +5,44 @@
<DialogTitle>{{ t('dashboard.selector.title') }}</DialogTitle>
</DialogHeader>
<div class="grid grid-cols-2 gap-2 max-h-[50vh] overflow-y-auto">
<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 === currentKey ? 'border-primary bg-primary/5 ring-1 ring-primary/40' : ''"
@click="emit('select', option.key)">
<i :class="option.icon" class="text-base" />
<span>{{ t(option.labelKey) }}</span>
</button>
<div class="max-h-[50vh] overflow-y-auto">
<!-- Widget section -->
<div class="mb-3">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide px-1">
{{ t('dashboard.selector.widgets') }}
</span>
<div class="grid grid-cols-2 gap-2 mt-1.5">
<button
v-for="option in widgetOptions"
: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' : '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>
<DialogFooter>
@@ -34,13 +61,34 @@
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
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: {
type: Boolean,
default: false
},
currentKey: {
type: String,
type: [String, Object],
default: null
}
});
@@ -48,5 +96,20 @@
const emit = defineEmits(['select', 'close']);
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));
function handleSelectWidget(option) {
emit('select', {
key: option.key,
config: { ...option.defaultConfig }
});
}
</script>

View File

@@ -1,3 +1,5 @@
import { defineAsyncComponent } from 'vue';
import Feed from '../../Feed/Feed.vue';
import FavoritesAvatar from '../../Favorites/FavoritesAvatar.vue';
import FavoritesFriend from '../../Favorites/FavoritesFriend.vue';
@@ -27,7 +29,10 @@ export const panelComponentMap = {
moderation: Moderation,
notification: Notification,
'my-avatars': MyAvatars,
'charts-instance': () => import('../../Charts/components/InstanceActivity.vue'),
'charts-mutual': () => import('../../Charts/components/MutualFriends.vue'),
tools: Tools
'charts-instance': defineAsyncComponent(() => import('../../Charts/components/InstanceActivity.vue')),
'charts-mutual': defineAsyncComponent(() => import('../../Charts/components/MutualFriends.vue')),
tools: Tools,
'widget:feed': defineAsyncComponent(() => import('../widgets/FeedWidget.vue')),
'widget:game-log': defineAsyncComponent(() => import('../widgets/GameLogWidget.vue')),
'widget:instance': defineAsyncComponent(() => import('../widgets/InstanceWidget.vue'))
};

View 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>

View 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>

View 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>

View 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>