mirror of
https://github.com/vrcx-team/VRCX.git
synced 2026-04-06 00:32:02 +02:00
add dashboard widget
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'))
|
||||
};
|
||||
|
||||
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