mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-25 17:53:48 +02:00
feat: add dashboard
This commit is contained in:
186
src/views/Dashboard/Dashboard.vue
Normal file
186
src/views/Dashboard/Dashboard.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="x-container flex h-full min-h-0 flex-col gap-3 py-3">
|
||||
<DashboardEditToolbar
|
||||
v-if="isEditing"
|
||||
v-model:name="editName"
|
||||
@save="handleSave"
|
||||
@cancel="handleCancelEdit"
|
||||
@delete="handleDelete" />
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||
<template v-if="displayRows.length && !isEditing">
|
||||
<ResizablePanelGroup direction="vertical" :auto-save-id="`dashboard-${id}`" class="flex-1 min-h-0">
|
||||
<template v-for="(row, rowIndex) in displayRows" :key="rowIndex">
|
||||
<ResizablePanel :default-size="100 / displayRows.length" :min-size="10">
|
||||
<DashboardRow :row="row" :row-index="rowIndex" :dashboard-id="id" />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle v-if="rowIndex < displayRows.length - 1" />
|
||||
</template>
|
||||
</ResizablePanelGroup>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isEditing">
|
||||
<DashboardRow
|
||||
v-for="(row, rowIndex) in displayRows"
|
||||
:key="rowIndex"
|
||||
:row="row"
|
||||
:row-index="rowIndex"
|
||||
:dashboard-id="id"
|
||||
:is-editing="true"
|
||||
@update-panel="handleUpdatePanel"
|
||||
@remove-row="handleRemoveRow" />
|
||||
|
||||
<div
|
||||
class="mt-auto flex min-h-[80px] flex-1 items-center justify-center rounded-md border-2 border-dashed border-muted-foreground/20 text-muted-foreground transition-colors hover:border-primary/40 hover:bg-primary/5"
|
||||
:class="showAddRowOptions ? 'items-start p-4' : 'cursor-pointer'"
|
||||
@click="handleAddRowAreaClick">
|
||||
<div v-if="showAddRowOptions" class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-xs text-muted-foreground">{{ t('dashboard.actions.add_row') }}:</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-16 items-center justify-center rounded-md border-2 border-dashed border-muted-foreground/30 transition-colors hover:border-primary/50 hover:bg-primary/5"
|
||||
@click.stop="handleAddRow(1)">
|
||||
<div class="h-6 w-12 rounded bg-muted-foreground/20" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-16 items-center justify-center gap-1 rounded-md border-2 border-dashed border-muted-foreground/30 transition-colors hover:border-primary/50 hover:bg-primary/5"
|
||||
@click.stop="handleAddRow(2)">
|
||||
<div class="h-6 w-5 rounded bg-muted-foreground/20" />
|
||||
<div class="h-6 w-5 rounded bg-muted-foreground/20" />
|
||||
</button>
|
||||
</div>
|
||||
<Plus v-else class="size-6 opacity-50" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-1 items-center justify-center rounded-md border border-dashed text-muted-foreground">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<p>{{ t('dashboard.empty') }}</p>
|
||||
<Button @click="isEditing = true">{{ t('dashboard.actions.start_editing') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Plus } from 'lucide-vue-next';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
|
||||
import { useDashboardStore, useModalStore } from '@/stores';
|
||||
|
||||
import DashboardEditToolbar from './components/DashboardEditToolbar.vue';
|
||||
import DashboardRow from './components/DashboardRow.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const dashboardStore = useDashboardStore();
|
||||
const modalStore = useModalStore();
|
||||
|
||||
const isEditing = ref(false);
|
||||
const showAddRowOptions = ref(false);
|
||||
const editRows = ref([]);
|
||||
const editName = ref('');
|
||||
|
||||
const dashboard = computed(() => dashboardStore.getDashboard(props.id));
|
||||
const displayRows = computed(() => (isEditing.value ? editRows.value : dashboard.value?.rows || []));
|
||||
|
||||
const cloneRows = (rows) => JSON.parse(JSON.stringify(Array.isArray(rows) ? rows : []));
|
||||
|
||||
watch(
|
||||
() => props.id,
|
||||
() => {
|
||||
isEditing.value = false;
|
||||
showAddRowOptions.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
dashboard,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
router.replace({ name: 'feed' });
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(isEditing, (editing) => {
|
||||
if (!editing || !dashboard.value) {
|
||||
showAddRowOptions.value = false;
|
||||
return;
|
||||
}
|
||||
editRows.value = cloneRows(dashboard.value.rows);
|
||||
editName.value = dashboard.value.name || '';
|
||||
});
|
||||
|
||||
watch(
|
||||
() => dashboardStore.editingDashboardId,
|
||||
(editingId) => {
|
||||
if (editingId === props.id) {
|
||||
isEditing.value = true;
|
||||
dashboardStore.clearEditingDashboardId();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleAddRowAreaClick = () => {
|
||||
showAddRowOptions.value = !showAddRowOptions.value;
|
||||
};
|
||||
|
||||
const handleAddRow = (panelCount) => {
|
||||
const panels = Array(panelCount).fill(null);
|
||||
editRows.value.push({ panels });
|
||||
showAddRowOptions.value = false;
|
||||
};
|
||||
|
||||
const handleRemoveRow = (rowIndex) => {
|
||||
editRows.value.splice(rowIndex, 1);
|
||||
};
|
||||
|
||||
const handleUpdatePanel = (rowIndex, panelIndex, panelKey) => {
|
||||
if (!editRows.value[rowIndex]?.panels) {
|
||||
return;
|
||||
}
|
||||
editRows.value[rowIndex].panels[panelIndex] = panelKey;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
await dashboardStore.updateDashboard(props.id, {
|
||||
name: editName.value.trim() || dashboard.value?.name || 'Dashboard',
|
||||
rows: editRows.value
|
||||
});
|
||||
isEditing.value = false;
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
isEditing.value = false;
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const { ok } = await modalStore.confirm({
|
||||
title: t('dashboard.confirmations.delete_title'),
|
||||
description: t('dashboard.confirmations.delete_description')
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
await dashboardStore.deleteDashboard(props.id);
|
||||
router.replace({ name: 'feed' });
|
||||
};
|
||||
</script>
|
||||
31
src/views/Dashboard/components/DashboardEditToolbar.vue
Normal file
31
src/views/Dashboard/components/DashboardEditToolbar.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 rounded-md border bg-card px-3 py-2">
|
||||
<span class="text-sm font-medium text-muted-foreground">{{ t('dashboard.toolbar.editing') }}</span>
|
||||
<Input
|
||||
:model-value="name"
|
||||
:placeholder="t('dashboard.name_placeholder')"
|
||||
class="mx-2 h-7 max-w-[200px] text-sm"
|
||||
@update:model-value="emit('update:name', $event)" />
|
||||
<div class="flex gap-2">
|
||||
<Button variant="secondary" size="sm" @click="emit('cancel')">{{ t('dashboard.actions.cancel') }}</Button>
|
||||
<Button variant="destructive" size="sm" @click="emit('delete')">{{ t('dashboard.actions.delete') }}</Button>
|
||||
</div>
|
||||
<Button class="ml-auto" size="sm" @click="emit('save')">{{ t('dashboard.actions.save') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
const emit = defineEmits(['save', 'cancel', 'delete', 'update:name']);
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
89
src/views/Dashboard/components/DashboardPanel.vue
Normal file
89
src/views/Dashboard/components/DashboardPanel.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="relative flex min-h-0 flex-1 overflow-hidden rounded-md border bg-card">
|
||||
<template v-if="isEditing">
|
||||
<div class="flex w-full min-h-0 flex-col gap-2 p-3">
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<i v-if="panelIcon" :class="panelIcon" class="text-base" />
|
||||
<span>{{ panelLabel || t('dashboard.panel.not_selected') }}</span>
|
||||
</div>
|
||||
<Button variant="outline" class="w-full" @click="openSelector">
|
||||
{{ panelKey ? t('dashboard.panel.replace') : t('dashboard.panel.select') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="panelKey && panelComponent">
|
||||
<div class="h-full w-full overflow-y-auto">
|
||||
<component :is="panelComponent" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="flex w-full items-center justify-center text-sm text-muted-foreground">
|
||||
{{ t('dashboard.panel.not_configured') }}
|
||||
</div>
|
||||
|
||||
<PanelSelector
|
||||
:open="selectorOpen"
|
||||
:current-key="panelKey"
|
||||
@select="handleSelect"
|
||||
@close="selectorOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { navDefinitions } from '@/shared/constants/ui';
|
||||
|
||||
import PanelSelector from './PanelSelector.vue';
|
||||
import { panelComponentMap } from './panelRegistry';
|
||||
|
||||
const props = defineProps({
|
||||
panelKey: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
isEditing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
const { t } = useI18n();
|
||||
const selectorOpen = ref(false);
|
||||
|
||||
const panelComponent = computed(() => {
|
||||
if (!props.panelKey) {
|
||||
return null;
|
||||
}
|
||||
return panelComponentMap[props.panelKey] || null;
|
||||
});
|
||||
|
||||
const panelOption = computed(() => {
|
||||
if (!props.panelKey) {
|
||||
return null;
|
||||
}
|
||||
return navDefinitions.find((def) => def.key === props.panelKey) || null;
|
||||
});
|
||||
|
||||
const panelLabel = computed(() => {
|
||||
if (!panelOption.value?.labelKey) {
|
||||
return props.panelKey || '';
|
||||
}
|
||||
return t(panelOption.value.labelKey);
|
||||
});
|
||||
|
||||
const panelIcon = computed(() => panelOption.value?.icon || '');
|
||||
|
||||
const openSelector = () => {
|
||||
selectorOpen.value = true;
|
||||
};
|
||||
|
||||
const handleSelect = (value) => {
|
||||
emit('select', value);
|
||||
selectorOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
69
src/views/Dashboard/components/DashboardRow.vue
Normal file
69
src/views/Dashboard/components/DashboardRow.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="relative h-full min-h-[180px]">
|
||||
<div v-if="isEditing" class="flex h-full gap-2">
|
||||
<DashboardPanel
|
||||
v-for="(panelKey, panelIndex) in row.panels"
|
||||
:key="panelIndex"
|
||||
:panel-key="panelKey"
|
||||
:is-editing="true"
|
||||
:class="row.panels.length === 1 ? 'w-full' : 'w-1/2'"
|
||||
@select="(key) => emit('update-panel', rowIndex, panelIndex, key)" />
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="absolute -right-1 top-2 z-20 bg-background/80"
|
||||
@click="emit('remove-row', rowIndex)">
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ResizablePanelGroup
|
||||
v-else-if="row.panels.length === 2"
|
||||
direction="horizontal"
|
||||
: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" />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel :default-size="50" :min-size="20">
|
||||
<DashboardPanel :panel-key="row.panels[1]" class="h-full" />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<div v-else class="h-full">
|
||||
<DashboardPanel :panel-key="row.panels[0]" class="h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { X } from 'lucide-vue-next';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
|
||||
|
||||
import DashboardPanel from './DashboardPanel.vue';
|
||||
|
||||
defineProps({
|
||||
row: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
rowIndex: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
dashboardId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
isEditing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update-panel', 'remove-row']);
|
||||
</script>
|
||||
52
src/views/Dashboard/components/PanelSelector.vue
Normal file
52
src/views/Dashboard/components/PanelSelector.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="(value) => !value && emit('close')">
|
||||
<DialogContent class="sm:max-w-110">
|
||||
<DialogHeader>
|
||||
<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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" @click="emit('select', null)">{{ t('dashboard.selector.clear') }}</Button>
|
||||
<Button variant="secondary" @click="emit('close')">{{ t('dashboard.actions.cancel') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { navDefinitions } from '@/shared/constants/ui';
|
||||
|
||||
defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
currentKey: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'close']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const panelOptions = computed(() => navDefinitions.filter((def) => def.routeName));
|
||||
</script>
|
||||
33
src/views/Dashboard/components/panelRegistry.js
Normal file
33
src/views/Dashboard/components/panelRegistry.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import Feed from '../../Feed/Feed.vue';
|
||||
import FavoritesAvatar from '../../Favorites/FavoritesAvatar.vue';
|
||||
import FavoritesFriend from '../../Favorites/FavoritesFriend.vue';
|
||||
import FavoritesWorld from '../../Favorites/FavoritesWorld.vue';
|
||||
import FriendList from '../../FriendList/FriendList.vue';
|
||||
import FriendLog from '../../FriendLog/FriendLog.vue';
|
||||
import FriendsLocations from '../../FriendsLocations/FriendsLocations.vue';
|
||||
import GameLog from '../../GameLog/GameLog.vue';
|
||||
import Moderation from '../../Moderation/Moderation.vue';
|
||||
import MyAvatars from '../../MyAvatars/MyAvatars.vue';
|
||||
import Notification from '../../Notifications/Notification.vue';
|
||||
import PlayerList from '../../PlayerList/PlayerList.vue';
|
||||
import Search from '../../Search/Search.vue';
|
||||
import Tools from '../../Tools/Tools.vue';
|
||||
|
||||
export const panelComponentMap = {
|
||||
feed: Feed,
|
||||
'friends-locations': FriendsLocations,
|
||||
'game-log': GameLog,
|
||||
'player-list': PlayerList,
|
||||
search: Search,
|
||||
'favorite-friends': FavoritesFriend,
|
||||
'favorite-worlds': FavoritesWorld,
|
||||
'favorite-avatars': FavoritesAvatar,
|
||||
'friend-log': FriendLog,
|
||||
'friend-list': FriendList,
|
||||
moderation: Moderation,
|
||||
notification: Notification,
|
||||
'my-avatars': MyAvatars,
|
||||
'charts-instance': () => import('../../Charts/components/InstanceActivity.vue'),
|
||||
'charts-mutual': () => import('../../Charts/components/MutualFriends.vue'),
|
||||
tools: Tools
|
||||
};
|
||||
@@ -108,7 +108,7 @@
|
||||
import LaunchDialog from '../../components/dialogs/LaunchDialog.vue';
|
||||
import LaunchOptionsDialog from '../Settings/dialogs/LaunchOptionsDialog.vue';
|
||||
import MainDialogContainer from '../../components/dialogs/MainDialogContainer.vue';
|
||||
import NavMenu from '../../components/NavMenu.vue';
|
||||
import NavMenu from '../../components/nav-menu/NavMenu.vue';
|
||||
import PrimaryPasswordDialog from '../Settings/dialogs/PrimaryPasswordDialog.vue';
|
||||
import SendBoopDialog from '../../components/dialogs/SendBoopDialog.vue';
|
||||
import Sidebar from '../Sidebar/Sidebar.vue';
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock('../../../stores', () => ({ useAppearanceSettingsStore: () => ({ navWidt
|
||||
vi.mock('../../../composables/useMainLayoutResizable', () => ({ useMainLayoutResizable: () => ({ asideDefaultSize: 30, asideMinSize: 0, asideMaxPx: 480, mainDefaultSize: 70, handleLayout: vi.fn(), isAsideCollapsed: () => false, isAsideCollapsedStatic: false, isSideBarTabShow: ref(true) }) }));
|
||||
vi.mock('../../../components/ui/resizable', () => ({ ResizablePanelGroup: { template: '<div><slot :layout="[]" /></div>' }, ResizablePanel: { template: '<div><slot /></div>' }, ResizableHandle: { template: '<div />' } }));
|
||||
vi.mock('../../../components/ui/sidebar', () => ({ SidebarProvider: { template: '<div><slot /></div>' }, SidebarInset: { template: '<div><slot /></div>' } }));
|
||||
vi.mock('../../../components/NavMenu.vue', () => ({ default: { template: '<div />' } }));
|
||||
vi.mock('../../../components/nav-menu/NavMenu.vue', () => ({ default: { template: '<div />' } }));
|
||||
vi.mock('../../Sidebar/Sidebar.vue', () => ({ default: { template: '<div />' } }));
|
||||
vi.mock('../../../components/StatusBar.vue', () => ({ default: { template: '<div />' } }));
|
||||
vi.mock('../../../components/dialogs/MainDialogContainer.vue', () => ({ default: { template: '<div />' } }));
|
||||
|
||||
Reference in New Issue
Block a user