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:
2026-06-18 11:47:36 +02:00
parent c1390bfe06
commit d6c110fc3b
41 changed files with 2416 additions and 448 deletions

View File

@@ -0,0 +1,49 @@
<template>
<div class="flex min-h-0 w-full flex-1 overflow-hidden">
<AppSidebar />
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<main v-if="fullbleed" class="flex min-h-0 flex-1 flex-col pb-14 md:pb-0">
<slot />
</main>
<main v-else class="flex-1 overflow-y-auto pb-14 md:pb-0">
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-4 md:py-6">
<slot />
</div>
</main>
<MobileTabBar />
</div>
</div>
</template>
<script setup lang="ts">
// Coquille des vues authentifiées. Propriétaire unique du chargement initial et des abonnements
// temps réel (sessions/worktrees/groups) pour toute la durée de la session : fiabilise le badge
// de navigation, la palette ⌘K et la section « À traiter » quelle que soit la vue active.
import { onMounted, onUnmounted } from 'vue';
import { useSessionsStore } from '../../stores/sessions';
import { useWorktreesStore } from '../../stores/worktrees';
import { useGroupsStore } from '../../stores/groups';
import AppSidebar from './AppSidebar.vue';
import MobileTabBar from './MobileTabBar.vue';
defineProps<{ fullbleed?: boolean }>();
const sessions = useSessionsStore();
const worktrees = useWorktreesStore();
const groups = useGroupsStore();
onMounted(() => {
void worktrees.fetchAll();
void sessions.fetchSessions();
void groups.fetchGroups();
worktrees.startRealtime();
sessions.startRealtime();
groups.startRealtime();
});
onUnmounted(() => {
worktrees.stopRealtime();
sessions.stopRealtime();
groups.stopRealtime();
});
</script>

View File

@@ -0,0 +1,57 @@
<template>
<div class="flex flex-col gap-2">
<p v-if="pushErrorText" class="text-xs text-amber-400">{{ pushErrorText }}</p>
<div class="flex items-center gap-2">
<LanguageSwitcher />
<BaseButton
v-if="push.supported"
variant="ghost"
size="sm"
icon-only
:icon="push.enabled ? Bell : BellOff"
:class="push.enabled ? 'text-emerald-300' : ''"
:disabled="push.busy"
:title="pushErrorText || undefined"
:aria-label="push.enabled ? t('push.disable') : t('push.enable')"
@click="push.toggle()"
/>
<BaseButton
variant="ghost"
size="sm"
icon-only
:icon="LogOut"
class="ml-auto"
:aria-label="t('common.logout')"
@click="logout"
/>
</div>
<span v-if="auth.serverVersion" class="text-[11px] text-zinc-600">v{{ auth.serverVersion }}</span>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { Bell, BellOff, LogOut } from '@lucide/vue';
import { useAuthStore } from '../../stores/auth';
import { usePushStore } from '../../stores/push';
import { useSession } from '../../composables/useSession';
import BaseButton from '../ui/BaseButton.vue';
import LanguageSwitcher from '../LanguageSwitcher.vue';
const { t } = useI18n();
const auth = useAuthStore();
const push = usePushStore();
const { logout } = useSession();
// '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(() => {
void push.refresh();
});
</script>

View File

@@ -0,0 +1,45 @@
<template>
<aside class="hidden w-56 shrink-0 flex-col border-r border-zinc-800/80 bg-zinc-900/40 md:flex">
<RouterLink :to="{ name: 'dashboard' }" class="flex items-center gap-2 px-4 py-3.5">
<img src="/icon.svg" class="h-7 w-7" alt="" />
<span class="text-base font-semibold text-zinc-100">{{ t('common.appName') }}</span>
</RouterLink>
<button
type="button"
class="mx-2 mb-2 flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 px-2.5 py-1.5 text-sm text-zinc-500 transition-colors hover:border-zinc-700 hover:text-zinc-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
@click="palette.show()"
>
<Search :size="15" />
<span class="flex-1 text-left">{{ t('common.search') }}</span>
<kbd class="rounded border border-zinc-700 px-1 text-[10px] text-zinc-500">K</kbd>
</button>
<nav class="flex flex-1 flex-col gap-1 px-2">
<NavItem
v-for="item in items"
:key="item.key"
:to="item.to"
:icon="item.icon"
:label="item.label"
:badge="item.badge"
:active="isActive(item.match)"
orientation="row"
/>
</nav>
<div class="border-t border-zinc-800/80 p-3">
<AppShellFooter />
</div>
</aside>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { Search } from '@lucide/vue';
import { useNav } from '../../composables/useNav';
import { useCommandPalette } from '../../composables/useCommandPalette';
import NavItem from './NavItem.vue';
import AppShellFooter from './AppShellFooter.vue';
const { t } = useI18n();
const { items, isActive } = useNav();
const palette = useCommandPalette();
</script>

View File

@@ -0,0 +1,40 @@
<template>
<nav
class="fixed inset-x-0 bottom-0 z-40 flex border-t border-zinc-800 bg-zinc-950/95 backdrop-blur md:hidden"
style="padding-bottom: env(safe-area-inset-bottom)"
>
<NavItem
v-for="item in items"
:key="item.key"
:to="item.to"
:icon="item.icon"
:label="item.label"
:badge="item.badge"
:active="isActive(item.match)"
orientation="col"
class="flex-1"
/>
<button
type="button"
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none"
@click="showMore = true"
>
<Settings :size="20" :stroke-width="1.75" />
<span class="text-[10px]">{{ t('nav.settings') }}</span>
</button>
<MoreSheet v-if="showMore" @close="showMore = false" />
</nav>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Settings } from '@lucide/vue';
import { useNav } from '../../composables/useNav';
import NavItem from './NavItem.vue';
import MoreSheet from './MoreSheet.vue';
const { t } = useI18n();
const { items, isActive } = useNav();
const showMore = ref(false);
</script>

View File

@@ -0,0 +1,24 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 md:hidden" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-black/60" @click="emit('close')" />
<div
class="absolute inset-x-0 bottom-0 rounded-t-2xl border-t border-zinc-800 bg-zinc-900 p-4 shadow-pop"
style="padding-bottom: calc(1rem + env(safe-area-inset-bottom))"
>
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-zinc-700" />
<AppShellFooter />
<BaseButton class="mt-3 w-full" @click="emit('close')">{{ t('common.close') }}</BaseButton>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import AppShellFooter from './AppShellFooter.vue';
import BaseButton from '../ui/BaseButton.vue';
const { t } = useI18n();
const emit = defineEmits<{ close: [] }>();
</script>

View File

@@ -0,0 +1,50 @@
<template>
<RouterLink
:to="to"
:aria-current="active ? 'page' : undefined"
:class="orientation === 'col' ? colClass : rowClass"
>
<span class="relative">
<component :is="icon" :size="orientation === 'col' ? 20 : 18" :stroke-width="1.75" />
<span
v-if="badge"
class="absolute -right-1.5 -top-1.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-1 text-[9px] font-semibold text-amber-950"
>
{{ badge }}
</span>
</span>
<span :class="orientation === 'col' ? 'text-[10px]' : 'flex-1'">{{ label }}</span>
<span
v-if="badge && orientation === 'row'"
class="rounded-full bg-amber-500/20 px-1.5 text-[11px] font-semibold text-amber-300"
>
{{ badge }}
</span>
</RouterLink>
</template>
<script setup lang="ts">
import { computed, type Component } from 'vue';
import type { RouteLocationRaw } from 'vue-router';
const props = withDefaults(
defineProps<{
to: RouteLocationRaw;
icon: Component;
label: string;
badge?: number;
active?: boolean;
orientation?: 'row' | 'col';
}>(),
{ orientation: 'row' },
);
const rowClass = computed(() => [
'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70',
props.active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200',
]);
const colClass = computed(() => [
'flex flex-1 flex-col items-center gap-0.5 py-2 transition-colors focus-visible:outline-none',
props.active ? 'text-emerald-400' : 'text-zinc-500 hover:text-zinc-300',
]);
</script>

View File

@@ -0,0 +1,21 @@
<template>
<header class="flex flex-wrap items-center gap-x-3 gap-y-2">
<div class="flex min-w-0 items-center gap-2">
<slot name="title">
<h1 class="truncate text-lg font-semibold text-zinc-100">{{ title }}</h1>
</slot>
<span v-if="subtitle" class="truncate font-mono text-xs text-zinc-500" :title="subtitle">{{ subtitle }}</span>
</div>
<div v-if="$slots.default" class="ml-auto flex flex-wrap items-center gap-2">
<slot />
</div>
<div v-if="$slots.search" class="w-full">
<slot name="search" />
</div>
</header>
</template>
<script setup lang="ts">
// En-tête de contenu : titre (ou slot #title pour un titre riche) + slot d'actions + slot recherche.
defineProps<{ title?: string; subtitle?: string }>();
</script>

View File

@@ -0,0 +1,11 @@
<template>
<div class="border-b border-amber-900/50 bg-amber-950/80 px-4 py-1.5 text-center text-xs text-amber-300">
{{ t('ws.reconnecting') }}
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>