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'">
emit('update-panel', rowIndex, panelIndex, key)"
+ @select="(value) => emit('update-panel', rowIndex, panelIndex, value)"
@remove="emit('remove-panel', rowIndex, panelIndex)" />
@@ -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 @@
+
+
+
+
+
+
+
+ {{ formatTime(item.created_at) }}
+
+
+ {{ item.displayName }}
+ โ {{ item.worldName || t('dashboard.widget.unknown_world') }}
+
+
+ {{ item.displayName }}
+ {{ t('dashboard.widget.feed_online') }}
+ โ {{ item.worldName }}
+
+
+ {{ item.displayName }}
+ {{ t('dashboard.widget.feed_offline') }}
+
+
+ {{ item.displayName }}
+
+ {{ item.statusDescription }}
+
+
+ {{ item.displayName }}
+ {{ t('dashboard.widget.feed_avatar') }} {{ item.avatarName }}
+
+
+ {{ item.displayName }}
+ {{ t('dashboard.widget.feed_bio') }}
+
+
+ {{ item.displayName }}
+ {{ item.type }}
+
+
+
+
+ {{ t('dashboard.widget.no_data') }}
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ {{ formatTime(item.created_at) }}
+
+
+ ๐ {{ item.worldName || item.location }}
+
+
+ โ
+ {{ item.displayName }}
+
+
+ โ
+ {{ item.displayName }}
+
+
+ ๐ฌ {{ item.videoName || item.videoUrl }}
+
+
+ {{ item.displayName }}
+ ๐ {{ item.worldName || '' }}
+
+
+ {{ item.displayName }}
+ {{ item.type }}
+
+
+
+
+ {{ t('dashboard.widget.no_data') }}
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ ๐
+ โ๏ธ
+ ๐
+ โ
+ ๐
+
+
+
+ {{ player.displayName }}
+
+
+
+ {{ player.ref?.$trustLevel || '' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('dashboard.widget.instance_not_in_game') }}
+
+
+
+
+
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 @@
+
+
+
+
+