From 1ffb2c8b9539acd3eaca08d5b708ef4d331cabe3 Mon Sep 17 00:00:00 2001 From: pa Date: Fri, 13 Mar 2026 13:43:13 +0900 Subject: [PATCH] add dashboard widget --- src/localization/en.json | 20 ++- src/stores/dashboard.js | 21 ++- src/stores/gameLog/index.js | 7 + src/views/Dashboard/Dashboard.vue | 4 +- .../Dashboard/components/DashboardPanel.vue | 156 ++++++++++++++++-- .../Dashboard/components/DashboardRow.vue | 12 +- .../Dashboard/components/PanelSelector.vue | 89 ++++++++-- .../Dashboard/components/panelRegistry.js | 11 +- src/views/Dashboard/widgets/FeedWidget.vue | 106 ++++++++++++ src/views/Dashboard/widgets/GameLogWidget.vue | 153 +++++++++++++++++ .../Dashboard/widgets/InstanceWidget.vue | 139 ++++++++++++++++ src/views/Dashboard/widgets/WidgetHeader.vue | 39 +++++ 12 files changed, 711 insertions(+), 46 deletions(-) create mode 100644 src/views/Dashboard/widgets/FeedWidget.vue create mode 100644 src/views/Dashboard/widgets/GameLogWidget.vue create mode 100644 src/views/Dashboard/widgets/InstanceWidget.vue create mode 100644 src/views/Dashboard/widgets/WidgetHeader.vue diff --git a/src/localization/en.json b/src/localization/en.json index 6da11e77..f0e2aa26 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -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", diff --git a/src/stores/dashboard.js b/src/stores/dashboard.js index e7e27792..0b63b10d 100644 --- a/src/stores/dashboard.js +++ b/src/stores/dashboard.js @@ -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; diff --git a/src/stores/gameLog/index.js b/src/stores/gameLog/index.js index 15e1e0fe..0305338c 100644 --- a/src/stores/gameLog/index.js +++ b/src/stores/gameLog/index.js @@ -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, diff --git a/src/views/Dashboard/Dashboard.vue b/src/views/Dashboard/Dashboard.vue index 3498b336..42607814 100644 --- a/src/views/Dashboard/Dashboard.vue +++ b/src/views/Dashboard/Dashboard.vue @@ -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 () => { diff --git a/src/views/Dashboard/components/DashboardPanel.vue b/src/views/Dashboard/components/DashboardPanel.vue index 4a11ef0f..5e83197b 100644 --- a/src/views/Dashboard/components/DashboardPanel.vue +++ b/src/views/Dashboard/components/DashboardPanel.vue @@ -14,6 +14,45 @@ {{ panelLabel || t('dashboard.panel.not_selected') }} + + +
+ + + + + +
+ @@ -22,7 +61,7 @@ @@ -32,7 +71,7 @@ @@ -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; }; diff --git a/src/views/Dashboard/components/DashboardRow.vue b/src/views/Dashboard/components/DashboardRow.vue index 09d9433f..f804bd8d 100644 --- a/src/views/Dashboard/components/DashboardRow.vue +++ b/src/views/Dashboard/components/DashboardRow.vue @@ -5,13 +5,13 @@ class="flex h-full gap-2" :class="isVertical ? 'flex-col' : 'flex-row'"> @@ -21,16 +21,16 @@ :auto-save-id="`dashboard-${dashboardId}-row-${rowIndex}`" class="h-full min-h-[180px]"> - + - +
- +
diff --git a/src/views/Dashboard/components/PanelSelector.vue b/src/views/Dashboard/components/PanelSelector.vue index dc27f11b..ac674dad 100644 --- a/src/views/Dashboard/components/PanelSelector.vue +++ b/src/views/Dashboard/components/PanelSelector.vue @@ -5,17 +5,44 @@ {{ t('dashboard.selector.title') }} -
- +
+ +
+ + {{ t('dashboard.selector.widgets') }} + +
+ +
+
+ + +
+ + {{ t('dashboard.selector.pages') }} + +
+ +
+
@@ -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 } + }); + } diff --git a/src/views/Dashboard/components/panelRegistry.js b/src/views/Dashboard/components/panelRegistry.js index 872c1f6f..cee729f9 100644 --- a/src/views/Dashboard/components/panelRegistry.js +++ b/src/views/Dashboard/components/panelRegistry.js @@ -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')) }; diff --git a/src/views/Dashboard/widgets/FeedWidget.vue b/src/views/Dashboard/widgets/FeedWidget.vue new file mode 100644 index 00000000..449b4917 --- /dev/null +++ b/src/views/Dashboard/widgets/FeedWidget.vue @@ -0,0 +1,106 @@ + + + diff --git a/src/views/Dashboard/widgets/GameLogWidget.vue b/src/views/Dashboard/widgets/GameLogWidget.vue new file mode 100644 index 00000000..899d53e4 --- /dev/null +++ b/src/views/Dashboard/widgets/GameLogWidget.vue @@ -0,0 +1,153 @@ + + + diff --git a/src/views/Dashboard/widgets/InstanceWidget.vue b/src/views/Dashboard/widgets/InstanceWidget.vue new file mode 100644 index 00000000..2126d11c --- /dev/null +++ b/src/views/Dashboard/widgets/InstanceWidget.vue @@ -0,0 +1,139 @@ + + + diff --git a/src/views/Dashboard/widgets/WidgetHeader.vue b/src/views/Dashboard/widgets/WidgetHeader.vue new file mode 100644 index 00000000..ba5fb3d7 --- /dev/null +++ b/src/views/Dashboard/widgets/WidgetHeader.vue @@ -0,0 +1,39 @@ + + +