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,87 @@
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<!-- recherche -->
<div class="relative flex-1">
<Search :size="15" class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
v-model="searchModel"
type="search"
class="input pl-8"
:placeholder="t(searchPlaceholderKey ?? 'controls.searchPlaceholder')"
/>
</div>
<!-- tri : clé + direction -->
<div class="flex items-center gap-1.5">
<label class="sr-only" :for="sortId">{{ t('controls.sortBy') }}</label>
<select :id="sortId" class="input w-auto" :value="controls.sortKey.value" @change="onSortChange">
<option v-for="def in controls.sortDefs" :key="def.key" :value="def.key">{{ t(def.labelKey) }}</option>
</select>
<BaseButton
variant="ghost"
size="sm"
icon-only
:icon="controls.sortDir.value === 'asc' ? ArrowUp : ArrowDown"
:aria-label="t(controls.sortDir.value === 'asc' ? 'sort.asc' : 'sort.desc')"
@click="controls.setSort(controls.sortKey.value)"
/>
</div>
</div>
<!-- chips de filtre + compteur (le compteur s'affiche même sans filtres) -->
<div class="flex flex-wrap items-center gap-1.5">
<template v-for="def in controls.filterDefs.value" :key="def.key">
<button
v-for="opt in def.options"
:key="def.key + ':' + opt.value"
type="button"
class="rounded-full border px-2 py-0.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
:class="isActive(def.key, opt.value)
? 'border-sky-700 bg-sky-950/40 text-sky-300'
: 'border-zinc-700 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'"
@click="controls.toggleFilter(def.key, opt.value)"
>
{{ opt.labelKey ? t(opt.labelKey) : opt.label }}
</button>
</template>
<BaseButton
v-if="controls.activeFilterCount.value > 0 || controls.search.value"
variant="ghost"
size="sm"
:icon="X"
@click="controls.clearAll()"
>
{{ t('controls.clearAll') }}
</BaseButton>
<span class="ml-auto text-xs text-zinc-500">{{ t('controls.results', controls.total.value) }}</span>
</div>
</div>
</template>
<script setup lang="ts" generic="T">
import { computed, useId } from 'vue';
import { useI18n } from 'vue-i18n';
import { Search, ArrowUp, ArrowDown, X } from '@lucide/vue';
import BaseButton from './ui/BaseButton.vue';
import type { ListToolbarControls } from '../composables/useListControls';
const props = defineProps<{ controls: ListToolbarControls<T>; searchPlaceholderKey?: string }>();
const { t } = useI18n();
const sortId = useId();
const searchModel = computed({
get: () => props.controls.search.value,
set: (v: string) => props.controls.setSearch(v),
});
function onSortChange(e: Event): void {
props.controls.setSort((e.target as HTMLSelectElement).value);
}
function isActive(key: string, value: string): boolean {
return props.controls.filters.value[key]?.has(value) ?? false;
}
</script>