feat: refonte UX du dashboard (coquille, tri/filtre/pagination, ⌘K, toasts)
Refonte front (packages/web) : coquille applicative partagée (sidebar desktop + barre d'onglets mobile + PageHeader, langue/push/logout centralisés) pilotée par route.meta.layout ; design system (BaseButton/BaseBadge/SegmentedControl/EmptyState/ SkeletonRow, anneau de focus, icônes @lucide/vue) ; tri/filtre/recherche/pagination côté client via useListControls (+ synchro URL) sur sessions/worktrees/groupes ; section « À traiter », palette de commandes ⌘K, toasts. Aucune modif serveur/protocole/DB.
This commit is contained in:
@@ -1,103 +1,112 @@
|
||||
<template>
|
||||
<div class="overflow-y-auto">
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6">
|
||||
<header class="flex items-center gap-3">
|
||||
<h1 class="text-lg font-semibold text-zinc-100">{{ t('groups.title') }}</h1>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<RouterLink :to="{ name: 'dashboard' }" class="btn">{{ t('groups.dashboard') }}</RouterLink>
|
||||
<button class="btn" :disabled="groups.loading" @click="refresh">{{ t('sessions.refresh') }}</button>
|
||||
<LanguageSwitcher />
|
||||
<button class="btn" @click="onLogout">{{ t('common.logout') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
<PageHeader :title="t('nav.groups')">
|
||||
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="groups.loading" @click="refresh">
|
||||
{{ t('common.refresh') }}
|
||||
</BaseButton>
|
||||
</PageHeader>
|
||||
|
||||
<!-- création d'un groupe : nom + sélection des repos déjà enregistrés -->
|
||||
<form class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3" @submit.prevent="onCreate">
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('groups.nameLabel') }}
|
||||
<input v-model="newLabel" type="text" class="input" :placeholder="t('groups.namePlaceholder')" required />
|
||||
</label>
|
||||
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('groups.reposLabel') }}
|
||||
<p v-if="worktrees.repos.length === 0" class="text-zinc-600">{{ t('groups.noReposRegistered') }}</p>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="repo in worktrees.repos"
|
||||
:key="repo.id"
|
||||
class="flex items-center gap-1.5 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
||||
>
|
||||
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" />
|
||||
<span class="text-zinc-300">{{ repo.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="submit" class="btn-primary" :disabled="creating || newLabel.trim() === ''">
|
||||
{{ creating ? t('groups.creating') : t('groups.new') }}
|
||||
</button>
|
||||
<span v-if="createError" class="text-sm text-red-400">{{ createError }}</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p v-if="groups.loadError" class="text-sm text-red-400">{{ groups.loadError }}</p>
|
||||
<p v-else-if="groups.loading && groups.groups.length === 0" class="text-sm text-zinc-500">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="groups.groups.length === 0" class="text-sm text-zinc-500">{{ t('groups.empty') }}</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<article
|
||||
v-for="group in groups.groups"
|
||||
:key="group.id"
|
||||
class="flex items-center gap-3 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3"
|
||||
<!-- création d'un groupe : nom + sélection des repos déjà enregistrés -->
|
||||
<form class="card flex flex-col gap-2" @submit.prevent="onCreate">
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('groups.nameLabel') }}
|
||||
<input v-model="newLabel" type="text" class="input" :placeholder="t('groups.namePlaceholder')" required />
|
||||
</label>
|
||||
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('groups.reposLabel') }}
|
||||
<p v-if="worktrees.repos.length === 0" class="text-zinc-600">{{ t('groups.noReposRegistered') }}</p>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="repo in worktrees.repos"
|
||||
:key="repo.id"
|
||||
class="flex items-center gap-1.5 rounded-lg border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
||||
>
|
||||
<span
|
||||
class="h-3 w-3 shrink-0 rounded-full"
|
||||
:style="{ backgroundColor: group.color ?? '#52525b' }"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<RouterLink :to="{ name: 'group', params: { id: group.id } }" class="font-semibold text-zinc-100 hover:underline">
|
||||
{{ group.label }}
|
||||
</RouterLink>
|
||||
<p v-if="group.description" class="truncate text-xs text-zinc-500">{{ group.description }}</p>
|
||||
</div>
|
||||
<span class="text-xs text-zinc-500">{{ t('groups.repoCount', group.repoIds.length) }}</span>
|
||||
<span v-if="activeCount(group.id) > 0" class="badge bg-emerald-950 text-emerald-400">
|
||||
{{ t('groups.sessionsActive', activeCount(group.id)) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<RouterLink :to="{ name: 'group', params: { id: group.id } }" class="btn text-xs">{{ t('groups.open') }}</RouterLink>
|
||||
<template v-if="confirmingDelete === group.id">
|
||||
<button class="btn-danger text-xs" @click="onDelete(group.id)">{{ t('groups.confirmDelete') }}</button>
|
||||
<button class="btn text-xs" @click="confirmingDelete = null">{{ t('common.cancel') }}</button>
|
||||
</template>
|
||||
<button v-else class="btn-danger text-xs" @click="confirmingDelete = group.id">{{ t('groups.remove') }}</button>
|
||||
</div>
|
||||
</article>
|
||||
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" />
|
||||
<span class="text-zinc-300">{{ repo.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<BaseButton type="submit" variant="primary" :icon="Plus" :loading="creating" :disabled="newLabel.trim() === ''">
|
||||
{{ t('groups.new') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<SkeletonRow v-if="groups.loading && groups.groups.length === 0" />
|
||||
<EmptyState v-else-if="groups.groups.length === 0" :icon="Boxes" :title="t('groups.empty')" />
|
||||
|
||||
<template v-else>
|
||||
<ListToolbar :controls="controls" />
|
||||
<EmptyState v-if="controls.total.value === 0" :icon="Search" :title="t('controls.results', 0)" />
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<article v-for="group in controls.processed.value" :key="group.id" class="card flex items-center gap-3">
|
||||
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<RouterLink :to="{ name: 'group', params: { id: group.id } }" class="font-semibold text-zinc-100 hover:underline">
|
||||
{{ group.label }}
|
||||
</RouterLink>
|
||||
<p v-if="group.description" class="truncate text-xs text-zinc-500">{{ group.description }}</p>
|
||||
</div>
|
||||
<span class="text-xs text-zinc-500">{{ t('groups.repoCount', group.repoIds.length) }}</span>
|
||||
<BaseBadge v-if="activeCount(group.id) > 0" tone="emerald">
|
||||
{{ t('groups.sessionsActive', activeCount(group.id)) }}
|
||||
</BaseBadge>
|
||||
<div class="flex items-center gap-2">
|
||||
<BaseButton size="sm" :to="{ name: 'group', params: { id: group.id } }">{{ t('groups.open') }}</BaseButton>
|
||||
<template v-if="confirmingDelete === group.id">
|
||||
<BaseButton variant="danger" size="sm" :icon="Trash2" @click="onDelete(group.id)">{{ t('groups.confirmDelete') }}</BaseButton>
|
||||
<BaseButton variant="ghost" size="sm" @click="confirmingDelete = null">{{ t('common.cancel') }}</BaseButton>
|
||||
</template>
|
||||
<BaseButton v-else variant="danger" size="sm" :icon="Trash2" @click="confirmingDelete = group.id">{{ t('groups.remove') }}</BaseButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<Pagination
|
||||
:page="controls.page.value"
|
||||
:page-count="controls.pageCount.value"
|
||||
:page-size="controls.pageSize.value"
|
||||
:page-size-options="[25, 50, 100, 0]"
|
||||
:default-size="50"
|
||||
@update:page="controls.setPage"
|
||||
@update:page-size="controls.setPageSize"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import type { GroupSummary } from '@arboretum/shared';
|
||||
import { RefreshCw, Plus, Trash2, Boxes, Search } from '@lucide/vue';
|
||||
import { useGroupsStore } from '../stores/groups';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||
import { useToastsStore } from '../stores/toasts';
|
||||
import { useListControls } from '../composables/useListControls';
|
||||
import { groupSorts } from '../composables/listDefs';
|
||||
import PageHeader from '../components/layout/PageHeader.vue';
|
||||
import BaseButton from '../components/ui/BaseButton.vue';
|
||||
import BaseBadge from '../components/ui/BaseBadge.vue';
|
||||
import EmptyState from '../components/ui/EmptyState.vue';
|
||||
import SkeletonRow from '../components/ui/SkeletonRow.vue';
|
||||
import ListToolbar from '../components/ListToolbar.vue';
|
||||
import Pagination from '../components/Pagination.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const groups = useGroupsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
const toasts = useToastsStore();
|
||||
|
||||
const controls = useListControls<GroupSummary>({
|
||||
source: () => groups.groups,
|
||||
sorts: groupSorts({ activeCount: (id) => groups.sessionsInGroup(id).filter((s) => s.live).length }),
|
||||
searchAccessor: (g) => `${g.label} ${g.description ?? ''}`,
|
||||
defaultPageSize: 50,
|
||||
urlKey: 'groups',
|
||||
});
|
||||
|
||||
const newLabel = ref('');
|
||||
const selectedRepoIds = ref<string[]>([]);
|
||||
const creating = ref(false);
|
||||
const createError = ref<string | null>(null);
|
||||
const confirmingDelete = ref<string | null>(null);
|
||||
|
||||
// nombre de sessions vivantes corrélées aux repos du groupe (badge d'aperçu).
|
||||
@@ -105,34 +114,19 @@ function activeCount(groupId: string): number {
|
||||
return groups.sessionsInGroup(groupId).filter((s) => s.live).length;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void groups.fetchGroups();
|
||||
void worktrees.fetchAll();
|
||||
void sessions.fetchSessions();
|
||||
groups.startRealtime();
|
||||
worktrees.startRealtime();
|
||||
sessions.startRealtime();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
groups.stopRealtime();
|
||||
worktrees.stopRealtime();
|
||||
sessions.stopRealtime();
|
||||
});
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
await Promise.all([groups.fetchGroups(), worktrees.fetchAll(), sessions.fetchSessions()]);
|
||||
function refresh(): void {
|
||||
void Promise.all([groups.fetchGroups(), worktrees.fetchAll()]);
|
||||
}
|
||||
|
||||
async function onCreate(): Promise<void> {
|
||||
creating.value = true;
|
||||
createError.value = null;
|
||||
try {
|
||||
await groups.createGroup({ label: newLabel.value.trim(), repoIds: [...selectedRepoIds.value] });
|
||||
newLabel.value = '';
|
||||
selectedRepoIds.value = [];
|
||||
toasts.success(t('toast.groupCreated'));
|
||||
} catch (err) {
|
||||
createError.value = err instanceof Error ? err.message : String(err);
|
||||
toasts.error(err);
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
@@ -142,16 +136,9 @@ async function onDelete(id: string): Promise<void> {
|
||||
confirmingDelete.value = null;
|
||||
try {
|
||||
await groups.deleteGroup(id);
|
||||
toasts.success(t('toast.groupDeleted'));
|
||||
} catch (err) {
|
||||
createError.value = err instanceof Error ? err.message : String(err);
|
||||
toasts.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogout(): Promise<void> {
|
||||
groups.stopRealtime();
|
||||
worktrees.stopRealtime();
|
||||
sessions.stopRealtime();
|
||||
await auth.logout();
|
||||
await router.replace({ name: 'login' });
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user