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,85 +1,94 @@
|
||||
<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 flex-wrap items-center gap-3">
|
||||
<RouterLink :to="{ name: 'groups' }" class="text-sm text-zinc-400 hover:text-zinc-200">← {{ t('groups.title') }}</RouterLink>
|
||||
<h1 v-if="group" class="flex items-center gap-2 text-lg font-semibold text-zinc-100">
|
||||
<span class="h-3 w-3 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
|
||||
{{ group.label }}
|
||||
</h1>
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
<button v-if="group" class="btn text-xs" :class="editingRepos ? 'border-sky-700 text-sky-300' : ''" @click="editingRepos = !editingRepos">
|
||||
{{ editingRepos ? t('groups.editDone') : t('groups.edit') }}
|
||||
</button>
|
||||
<button class="btn text-xs" :disabled="groupRepos.length === 0" @click="showFeatureModal = true">{{ t('groups.newFeature') }}</button>
|
||||
<div class="flex overflow-hidden rounded border border-zinc-800">
|
||||
<button class="px-2 py-1 text-xs" :class="view === 'list' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400'" @click="view = 'list'">
|
||||
{{ t('groups.viewList') }}
|
||||
</button>
|
||||
<button class="px-2 py-1 text-xs" :class="view === 'grid' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400'" @click="view = 'grid'">
|
||||
{{ t('groups.viewGrid') }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn text-xs" :disabled="worktrees.loading" @click="refresh">{{ t('sessions.refresh') }}</button>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</header>
|
||||
<PageHeader>
|
||||
<template #title>
|
||||
<BaseButton variant="ghost" size="sm" icon-only :icon="ChevronLeft" :to="{ name: 'groups' }" :aria-label="t('common.back')" />
|
||||
<h1 v-if="group" class="flex items-center gap-2 truncate text-lg font-semibold text-zinc-100">
|
||||
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
|
||||
{{ group.label }}
|
||||
</h1>
|
||||
<span v-else class="text-sm text-zinc-500">{{ t('common.loading') }}</span>
|
||||
</template>
|
||||
|
||||
<p v-if="!group" class="text-sm text-zinc-500">{{ t('common.loading') }}</p>
|
||||
<template v-if="group">
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:icon="Pencil"
|
||||
:class="editingRepos ? 'text-sky-300' : ''"
|
||||
@click="editingRepos = !editingRepos"
|
||||
>
|
||||
{{ editingRepos ? t('groups.editDone') : t('groups.edit') }}
|
||||
</BaseButton>
|
||||
<BaseButton size="sm" :icon="Combine" :disabled="groupRepos.length === 0" @click="showFeatureModal = true">
|
||||
{{ t('groups.newFeature') }}
|
||||
</BaseButton>
|
||||
<SegmentedControl v-model="view" :options="viewOptions" />
|
||||
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="worktrees.loading" @click="refresh" />
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<template v-else>
|
||||
<!-- édition de la composition : ajouter/retirer des repos déjà enregistrés -->
|
||||
<div v-if="editingRepos" class="flex flex-col gap-1 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 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 type="checkbox" :checked="group.repoIds.includes(repo.id)" @change="toggleRepo(repo.id)" />
|
||||
<span class="text-zinc-300">{{ repo.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- mode liste : réutilise RepoSection (worktrees + sessions + dialogues inline) -->
|
||||
<template v-if="view === 'list'">
|
||||
<p v-if="groupRepos.length === 0 && missingRepoIds.length === 0" class="text-sm text-zinc-500">{{ t('groups.noReposInGroup') }}</p>
|
||||
<RepoSection v-for="repo in groupRepos" :key="repo.id" :repo="repo" />
|
||||
<div
|
||||
v-for="id in missingRepoIds"
|
||||
:key="id"
|
||||
class="flex items-center gap-2 rounded-lg border border-amber-900/50 bg-amber-950/20 p-3 text-sm text-amber-300"
|
||||
>
|
||||
<span class="flex-1">{{ t('groups.missingRepo') }} <span class="font-mono text-xs text-amber-500/70">{{ id }}</span></span>
|
||||
<button class="btn text-xs" @click="removeRepo(id)">{{ t('groups.removeFromGroup') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- mode grille : terminaux côte à côte -->
|
||||
<TerminalGrid v-else :sessions="activeGroupSessions" />
|
||||
</template>
|
||||
<template v-if="group">
|
||||
<!-- édition de la composition : ajouter/retirer des repos déjà enregistrés -->
|
||||
<div v-if="editingRepos" class="card 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"
|
||||
>
|
||||
<input type="checkbox" :checked="group.repoIds.includes(repo.id)" @change="toggleRepo(repo.id)" />
|
||||
<span class="text-zinc-300">{{ repo.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CrossRepoFeatureModal
|
||||
v-if="showFeatureModal && group"
|
||||
:repos="groupRepos"
|
||||
@close="showFeatureModal = false"
|
||||
/>
|
||||
</div>
|
||||
<!-- mode liste : réutilise RepoSection (worktrees + sessions + dialogues inline) -->
|
||||
<template v-if="view === 'list'">
|
||||
<EmptyState
|
||||
v-if="groupRepos.length === 0 && missingRepoIds.length === 0"
|
||||
:icon="Boxes"
|
||||
:title="t('groups.noReposInGroup')"
|
||||
/>
|
||||
<RepoSection v-for="repo in groupRepos" :key="repo.id" :repo="repo" />
|
||||
<div
|
||||
v-for="id in missingRepoIds"
|
||||
:key="id"
|
||||
class="flex items-center gap-2 rounded-xl border border-amber-900/50 bg-amber-950/20 p-3 text-sm text-amber-300"
|
||||
>
|
||||
<span class="flex-1">{{ t('groups.missingRepo') }} <span class="font-mono text-xs text-amber-500/70">{{ id }}</span></span>
|
||||
<BaseButton size="sm" @click="removeRepo(id)">{{ t('groups.removeFromGroup') }}</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- mode grille : toolbar (tri waiting-first + recherche) en amont du cap dur de TerminalGrid -->
|
||||
<template v-else>
|
||||
<ListToolbar v-if="activeGroupSessions.length" :controls="gridControls" />
|
||||
<TerminalGrid :sessions="gridControls.processed.value" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<CrossRepoFeatureModal v-if="showFeatureModal && group" :repos="groupRepos" @close="showFeatureModal = false" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ChevronLeft, Pencil, Combine, RefreshCw, List, LayoutGrid, Boxes } from '@lucide/vue';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
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 { sessionSorts } from '../composables/listDefs';
|
||||
import PageHeader from '../components/layout/PageHeader.vue';
|
||||
import BaseButton from '../components/ui/BaseButton.vue';
|
||||
import EmptyState from '../components/ui/EmptyState.vue';
|
||||
import SegmentedControl from '../components/ui/SegmentedControl.vue';
|
||||
import ListToolbar from '../components/ListToolbar.vue';
|
||||
import RepoSection from '../components/RepoSection.vue';
|
||||
import TerminalGrid from '../components/TerminalGrid.vue';
|
||||
import CrossRepoFeatureModal from '../components/CrossRepoFeatureModal.vue';
|
||||
@@ -89,13 +98,19 @@ const route = useRoute();
|
||||
const groups = useGroupsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
const toasts = useToastsStore();
|
||||
|
||||
const groupId = computed(() => String(route.params.id));
|
||||
const group = computed(() => groups.byId(groupId.value));
|
||||
const view = ref<'list' | 'grid'>('list');
|
||||
const view = ref('list');
|
||||
const editingRepos = ref(false);
|
||||
const showFeatureModal = ref(false);
|
||||
|
||||
const viewOptions = computed(() => [
|
||||
{ value: 'list', label: t('groups.viewList'), icon: List },
|
||||
{ value: 'grid', label: t('groups.viewGrid'), icon: LayoutGrid },
|
||||
]);
|
||||
|
||||
const groupRepos = computed(() => groups.reposInGroup(groupId.value));
|
||||
|
||||
// repoIds du groupe absents du store worktrees (repo supprimé mais membership pas encore purgée côté UI).
|
||||
@@ -116,19 +131,12 @@ const activeGroupSessions = computed<SessionSummary[]>(() => {
|
||||
return [...byId.values()];
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void groups.fetchGroups();
|
||||
void worktrees.fetchAll();
|
||||
void sessions.fetchSessions();
|
||||
groups.startRealtime();
|
||||
worktrees.startRealtime();
|
||||
sessions.startRealtime();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
groups.stopRealtime();
|
||||
worktrees.stopRealtime();
|
||||
sessions.stopRealtime();
|
||||
// mode grille : tri (waiting d'abord) + recherche, sans pagination ; TerminalGrid conserve son cap dur.
|
||||
const gridControls = useListControls<SessionSummary>({
|
||||
source: () => activeGroupSessions.value,
|
||||
sorts: sessionSorts(),
|
||||
searchAccessor: (s) => `${s.cwd} ${s.title ?? ''}`,
|
||||
defaultPageSize: 0,
|
||||
});
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
@@ -138,12 +146,23 @@ async function refresh(): Promise<void> {
|
||||
async function toggleRepo(repoId: string): Promise<void> {
|
||||
const g = group.value;
|
||||
if (!g) return;
|
||||
if (g.repoIds.includes(repoId)) await groups.removeRepoFromGroup(g.id, repoId);
|
||||
else await groups.addRepoToGroup(g.id, repoId);
|
||||
try {
|
||||
if (g.repoIds.includes(repoId)) await groups.removeRepoFromGroup(g.id, repoId);
|
||||
else await groups.addRepoToGroup(g.id, repoId);
|
||||
toasts.success(t('toast.groupUpdated'));
|
||||
} catch (err) {
|
||||
toasts.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRepo(repoId: string): Promise<void> {
|
||||
const g = group.value;
|
||||
if (g) await groups.removeRepoFromGroup(g.id, repoId);
|
||||
if (!g) return;
|
||||
try {
|
||||
await groups.removeRepoFromGroup(g.id, repoId);
|
||||
toasts.success(t('toast.groupUpdated'));
|
||||
} catch (err) {
|
||||
toasts.error(err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user