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. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,120 +1,125 @@
|
||||
<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('dashboard.title') }}</h1>
|
||||
<span v-if="auth.serverVersion" class="text-xs text-zinc-600">v{{ auth.serverVersion }}</span>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<RouterLink :to="{ name: 'groups' }" class="btn">{{ t('dashboard.groups') }}</RouterLink>
|
||||
<RouterLink :to="{ name: 'sessions' }" class="btn">{{ t('dashboard.allSessions') }}</RouterLink>
|
||||
<button class="btn" :disabled="store.loading" @click="store.fetchAll()">{{ t('sessions.refresh') }}</button>
|
||||
<button
|
||||
v-if="push.supported"
|
||||
class="btn"
|
||||
:class="push.enabled ? 'border-emerald-700 text-emerald-300' : ''"
|
||||
:disabled="push.busy"
|
||||
:title="pushErrorText"
|
||||
@click="push.toggle()"
|
||||
>
|
||||
{{ push.enabled ? t('push.disable') : t('push.enable') }}
|
||||
</button>
|
||||
<LanguageSwitcher />
|
||||
<button class="btn" @click="onLogout">{{ t('common.logout') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
<p v-if="pushErrorText" class="text-xs text-amber-400">{{ pushErrorText }}</p>
|
||||
<PageHeader :title="t('nav.worktrees')">
|
||||
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="store.loading" @click="refresh">
|
||||
{{ t('common.refresh') }}
|
||||
</BaseButton>
|
||||
</PageHeader>
|
||||
|
||||
<form class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 sm:flex-row sm:items-end" @submit.prevent="onAddRepo">
|
||||
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('repos.pathLabel') }}
|
||||
<input v-model="newPath" type="text" class="input font-mono" :placeholder="t('repos.pathPlaceholder')" required />
|
||||
</label>
|
||||
<button type="button" class="btn whitespace-nowrap" @click="showPicker = !showPicker">{{ t('fs.browse') }}</button>
|
||||
<button type="submit" class="btn-primary whitespace-nowrap" :disabled="adding || newPath.trim() === ''">
|
||||
{{ adding ? t('repos.adding') : t('repos.add') }}
|
||||
</button>
|
||||
</form>
|
||||
<DirectoryPicker
|
||||
v-if="showPicker"
|
||||
mode="repo"
|
||||
:initial-path="newPath"
|
||||
@select="onPickPath"
|
||||
@close="showPicker = false"
|
||||
/>
|
||||
<p v-if="addError" class="text-sm text-red-400">{{ addError }}</p>
|
||||
<AttentionSection />
|
||||
|
||||
<p v-if="store.loadError" class="text-sm text-red-400">{{ store.loadError }}</p>
|
||||
<p v-else-if="store.loading && store.repos.length === 0" class="text-sm text-zinc-500">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="store.repos.length === 0" class="text-sm text-zinc-500">{{ t('repos.empty') }}</p>
|
||||
<form class="card flex flex-col gap-2 sm:flex-row sm:items-end" @submit.prevent="onAddRepo">
|
||||
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('repos.pathLabel') }}
|
||||
<input v-model="newPath" type="text" class="input font-mono" :placeholder="t('repos.pathPlaceholder')" required />
|
||||
</label>
|
||||
<BaseButton type="button" :icon="FolderOpen" class="whitespace-nowrap" @click="showPicker = !showPicker">
|
||||
{{ t('fs.browse') }}
|
||||
</BaseButton>
|
||||
<BaseButton type="submit" variant="primary" :icon="Plus" class="whitespace-nowrap" :loading="adding" :disabled="newPath.trim() === ''">
|
||||
{{ t('repos.add') }}
|
||||
</BaseButton>
|
||||
</form>
|
||||
<DirectoryPicker
|
||||
v-if="showPicker"
|
||||
mode="repo"
|
||||
:initial-path="newPath"
|
||||
@select="onPickPath"
|
||||
@close="showPicker = false"
|
||||
/>
|
||||
|
||||
<RepoSection v-for="repo in store.repos" :key="repo.id" :repo="repo" />
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonRow v-if="store.loading && store.repos.length === 0" />
|
||||
<EmptyState v-else-if="store.repos.length === 0" :icon="GitBranch" :title="t('repos.empty')" />
|
||||
|
||||
<template v-else>
|
||||
<!-- une seule toolbar pilote le tri/filtre des worktrees de toutes les sections -->
|
||||
<ListToolbar :controls="view" search-placeholder-key="controls.searchPlaceholder" />
|
||||
<EmptyState v-if="visibleRepos.length === 0" :icon="Search" :title="t('controls.results', 0)" />
|
||||
<RepoSection v-for="repo in pagedRepos" :key="repo.id" :repo="repo" />
|
||||
<Pagination
|
||||
:page="repoPage"
|
||||
:page-count="repoPageCount"
|
||||
:page-size="repoPageSize"
|
||||
:page-size-options="[10, 25, 50, 0]"
|
||||
:default-size="10"
|
||||
@update:page="repoPage = $event"
|
||||
@update:page-size="onRepoPageSize"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { GitBranch, RefreshCw, FolderOpen, Plus, Search } from '@lucide/vue';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import { usePushStore } from '../stores/push';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||
import { useToastsStore } from '../stores/toasts';
|
||||
import { useWorktreeView } from '../composables/useWorktreeView';
|
||||
import PageHeader from '../components/layout/PageHeader.vue';
|
||||
import BaseButton from '../components/ui/BaseButton.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';
|
||||
import AttentionSection from '../components/AttentionSection.vue';
|
||||
import RepoSection from '../components/RepoSection.vue';
|
||||
import DirectoryPicker from '../components/DirectoryPicker.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const store = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
const push = usePushStore();
|
||||
const toasts = useToastsStore();
|
||||
const view = useWorktreeView();
|
||||
|
||||
const newPath = ref('');
|
||||
const showPicker = ref(false);
|
||||
const adding = ref(false);
|
||||
const addError = ref<string | null>(null);
|
||||
|
||||
// repos triés par libellé ; masqués quand un filtre/recherche worktree actif ne laisse rien dans le repo.
|
||||
const sortedRepos = computed(() => [...store.repos].sort((a, b) => a.label.localeCompare(b.label)));
|
||||
const visibleRepos = computed(() =>
|
||||
view.isActive()
|
||||
? sortedRepos.value.filter((r) => view.apply(store.worktreesForRepo(r.id)).length > 0)
|
||||
: sortedRepos.value,
|
||||
);
|
||||
|
||||
// pagination des repos (rarement nombreux → masquée par défaut).
|
||||
const repoPage = ref(1);
|
||||
const repoPageSize = ref(10);
|
||||
const repoPageCount = computed(() =>
|
||||
repoPageSize.value === 0 ? 1 : Math.max(1, Math.ceil(visibleRepos.value.length / repoPageSize.value)),
|
||||
);
|
||||
const pagedRepos = computed(() => {
|
||||
if (repoPageSize.value === 0) return visibleRepos.value;
|
||||
const start = (repoPage.value - 1) * repoPageSize.value;
|
||||
return visibleRepos.value.slice(start, start + repoPageSize.value);
|
||||
});
|
||||
watch(repoPageCount, (count) => {
|
||||
if (repoPage.value > count) repoPage.value = count;
|
||||
});
|
||||
|
||||
function onRepoPageSize(n: number): void {
|
||||
repoPageSize.value = n;
|
||||
repoPage.value = 1;
|
||||
}
|
||||
|
||||
function onPickPath(path: string): void {
|
||||
newPath.value = path;
|
||||
showPicker.value = false;
|
||||
}
|
||||
|
||||
// 'denied'/'unsupported' sont des clés i18n ; tout autre message d'erreur est affiché tel quel.
|
||||
const pushErrorText = computed(() => {
|
||||
const e = push.error;
|
||||
if (!e) return '';
|
||||
return e === 'denied' || e === 'unsupported' ? t(`push.${e}`) : e;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
function refresh(): void {
|
||||
void store.fetchAll();
|
||||
store.startRealtime();
|
||||
// les sessions alimentent la corrélation worktree↔session (cwd) côté serveur ; on charge et on
|
||||
// suit leurs mises à jour temps réel pour que les badges d'état (busy/waiting/idle) restent live.
|
||||
void sessions.fetchSessions();
|
||||
sessions.startRealtime();
|
||||
void push.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
async function onAddRepo(): Promise<void> {
|
||||
adding.value = true;
|
||||
addError.value = null;
|
||||
try {
|
||||
await store.addRepo(newPath.value.trim());
|
||||
newPath.value = '';
|
||||
toasts.success(t('toast.repoAdded'));
|
||||
} catch (err) {
|
||||
addError.value = err instanceof Error ? err.message : String(err);
|
||||
toasts.error(err);
|
||||
} finally {
|
||||
adding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogout(): Promise<void> {
|
||||
store.stopRealtime();
|
||||
sessions.stopRealtime();
|
||||
await auth.logout();
|
||||
await router.replace({ name: 'login' });
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user