feat: add dashboard

This commit is contained in:
pa
2026-03-12 23:23:27 +09:00
parent 6e8f9543eb
commit e817d7392f
31 changed files with 2765 additions and 894 deletions

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

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

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

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

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

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

View File

@@ -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';

View File

@@ -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 />' } }));