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