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,37 @@
<template>
<section v-if="waiting.length" class="flex flex-col gap-2 rounded-xl border border-amber-900/50 bg-amber-950/20 p-3">
<div class="flex items-center gap-2">
<AlertTriangle :size="16" class="text-amber-400" />
<h2 class="text-sm font-semibold text-amber-200">{{ t('attention.title') }}</h2>
<BaseBadge tone="amber">{{ t('attention.count', waiting.length) }}</BaseBadge>
</div>
<div v-for="s in waiting" :key="s.id" class="flex flex-col gap-1">
<RouterLink
:to="{ name: 'session', params: { id: s.id } }"
class="truncate font-mono text-xs text-amber-300/80 transition-colors hover:text-amber-200"
:title="s.cwd"
>
{{ s.cwd }}
</RouterLink>
<DialogPrompt :session="s" />
</div>
</section>
</template>
<script setup lang="ts">
// Vue « À traiter » : regroupe en tête les sessions bloquées sur un dialogue (cœur de la
// supervision mobile). Réutilise DialogPrompt pour répondre sans ouvrir le terminal.
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { AlertTriangle } from '@lucide/vue';
import { useSessionsStore } from '../stores/sessions';
import DialogPrompt from './DialogPrompt.vue';
import BaseBadge from './ui/BaseBadge.vue';
const { t } = useI18n();
const sessions = useSessionsStore();
const waiting = computed(() =>
sessions.sessions.filter((s) => s.live && s.activity === 'waiting' && s.dialog),
);
</script>

View File

@@ -0,0 +1,169 @@
<template>
<Teleport to="body">
<div v-if="open" class="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[10vh]" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-black/60" @click="close" />
<div class="relative flex max-h-[70vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-pop">
<div class="flex items-center gap-2 border-b border-zinc-800 px-3">
<Search :size="16" class="text-zinc-500" />
<input
ref="inputEl"
v-model="query"
type="text"
class="w-full bg-transparent py-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-600"
:placeholder="t('palette.placeholder')"
:aria-activedescendant="activeId ? `cmd-${activeId}` : undefined"
role="combobox"
aria-controls="cmd-listbox"
aria-expanded="true"
@keydown.down.prevent="move(1)"
@keydown.up.prevent="move(-1)"
@keydown.enter.prevent="run(results[activeIndex])"
@keydown.esc.prevent="close"
/>
</div>
<ul v-if="results.length" id="cmd-listbox" role="listbox" class="min-h-0 flex-1 overflow-y-auto p-1.5">
<li
v-for="(item, i) in results"
:id="`cmd-${item.id}`"
:key="item.id"
role="option"
:aria-selected="i === activeIndex"
class="flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2"
:class="i === activeIndex ? 'bg-zinc-800' : 'hover:bg-zinc-800/50'"
@mousemove="activeIndex = i"
@click="run(item)"
>
<component :is="item.icon" :size="15" class="shrink-0 text-zinc-500" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-zinc-100">{{ item.label }}</span>
<span v-if="item.sublabel" class="block truncate font-mono text-[11px] text-zinc-500">{{ item.sublabel }}</span>
</span>
<span class="shrink-0 text-[10px] uppercase tracking-wide text-zinc-600">{{ t(`palette.types.${item.type}`) }}</span>
</li>
</ul>
<p v-else class="px-3 py-6 text-center text-sm text-zinc-500">{{ t('palette.empty') }}</p>
<div class="border-t border-zinc-800 px-3 py-1.5 text-[11px] text-zinc-600">{{ t('palette.hint') }}</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue';
import { useRouter, type RouteLocationRaw } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Search, GitBranch, TerminalSquare, Boxes, Plus, FolderPlus } from '@lucide/vue';
import { useSessionsStore } from '../stores/sessions';
import { useWorktreesStore } from '../stores/worktrees';
import { useGroupsStore } from '../stores/groups';
import { useCommandPalette } from '../composables/useCommandPalette';
interface PaletteItem {
id: string;
type: 'repo' | 'worktree' | 'session' | 'group' | 'action';
label: string;
sublabel?: string;
icon: Component;
keywords: string;
to?: RouteLocationRaw;
run?: () => void;
}
const { t } = useI18n();
const router = useRouter();
const { open, close, toggle } = useCommandPalette();
const sessions = useSessionsStore();
const worktrees = useWorktreesStore();
const groups = useGroupsStore();
const query = ref('');
const activeIndex = ref(0);
const inputEl = ref<HTMLInputElement | null>(null);
const basename = (p: string): string => p.split('/').filter(Boolean).pop() ?? p;
const items = computed<PaletteItem[]>(() => {
const out: PaletteItem[] = [];
for (const r of worktrees.repos) {
out.push({ id: `repo-${r.id}`, type: 'repo', label: r.label, sublabel: r.path, icon: GitBranch as Component, keywords: `${r.label} ${r.path}`.toLowerCase(), to: { name: 'dashboard' } });
}
for (const w of worktrees.worktrees) {
const target: RouteLocationRaw = w.sessions[0]
? { name: 'session', params: { id: w.sessions[0].id } }
: { name: 'dashboard' };
out.push({ id: `wt-${w.path}`, type: 'worktree', label: w.branch ?? basename(w.path), sublabel: w.path, icon: GitBranch as Component, keywords: `${w.branch ?? ''} ${w.path}`.toLowerCase(), to: target });
}
for (const s of sessions.sessions) {
out.push({ id: `ses-${s.id}`, type: 'session', label: s.title ?? basename(s.cwd), sublabel: s.cwd, icon: TerminalSquare as Component, keywords: `${s.title ?? ''} ${s.cwd} ${s.command}`.toLowerCase(), to: { name: 'session', params: { id: s.id } } });
}
for (const g of groups.groups) {
out.push({ id: `grp-${g.id}`, type: 'group', label: g.label, ...(g.description ? { sublabel: g.description } : {}), icon: Boxes as Component, keywords: `${g.label} ${g.description ?? ''}`.toLowerCase(), to: { name: 'group', params: { id: g.id } } });
}
// actions globales
out.push({ id: 'act-newSession', type: 'action', label: t('palette.actions.newSession'), icon: Plus as Component, keywords: t('palette.actions.newSession').toLowerCase(), to: { name: 'sessions' } });
out.push({ id: 'act-addRepo', type: 'action', label: t('palette.actions.addRepo'), icon: FolderPlus as Component, keywords: t('palette.actions.addRepo').toLowerCase(), to: { name: 'dashboard' } });
out.push({ id: 'act-newGroup', type: 'action', label: t('palette.actions.newGroup'), icon: Boxes as Component, keywords: t('palette.actions.newGroup').toLowerCase(), to: { name: 'groups' } });
return out;
});
// fuzzy minimal : tous les tokens présents (AND), score = somme des positions (plus tôt = mieux).
const results = computed<PaletteItem[]>(() => {
const tokens = query.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (tokens.length === 0) return items.value.slice(0, 30);
const scored: { item: PaletteItem; score: number }[] = [];
for (const item of items.value) {
let score = 0;
let ok = true;
for (const tok of tokens) {
const idx = item.keywords.indexOf(tok);
if (idx < 0) {
ok = false;
break;
}
score += idx;
}
if (ok) scored.push({ item, score });
}
return scored.sort((a, b) => a.score - b.score).slice(0, 30).map((s) => s.item);
});
watch(results, () => {
if (activeIndex.value >= results.value.length) activeIndex.value = 0;
});
const activeId = computed(() => results.value[activeIndex.value]?.id);
function move(delta: number): void {
const n = results.value.length;
if (n === 0) return;
activeIndex.value = (activeIndex.value + delta + n) % n;
}
function run(item: PaletteItem | undefined): void {
if (!item) return;
close();
if (item.run) item.run();
else if (item.to) void router.push(item.to);
}
watch(open, async (v) => {
if (v) {
query.value = '';
activeIndex.value = 0;
await nextTick();
inputEl.value?.focus();
}
});
// raccourci global ⌘K / Ctrl+K — en phase de capture pour passer devant xterm (SessionView).
function onKey(e: KeyboardEvent): void {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
toggle();
}
}
onMounted(() => window.addEventListener('keydown', onKey, { capture: true }));
onUnmounted(() => window.removeEventListener('keydown', onKey, { capture: true }));
</script>

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>

View File

@@ -0,0 +1,47 @@
<template>
<div v-if="pageCount > 1 || pageSize !== defaultSize" class="flex flex-wrap items-center justify-between gap-2 text-xs text-zinc-500">
<div class="flex items-center gap-2">
<label class="flex items-center gap-1.5">
{{ t('pagination.perPage') }}
<select class="input w-auto py-1" :value="pageSize" @change="onSize">
<option v-for="opt in pageSizeOptions" :key="opt" :value="opt">{{ opt === 0 ? t('pagination.all') : opt }}</option>
</select>
</label>
</div>
<div v-if="pageCount > 1" class="flex items-center gap-2">
<BaseButton variant="ghost" size="sm" :icon="ChevronLeft" :disabled="page <= 1" @click="emit('update:page', page - 1)">
{{ t('pagination.prev') }}
</BaseButton>
<span>{{ t('pagination.pageOf', { page, pageCount }) }}</span>
<BaseButton variant="ghost" size="sm" :disabled="page >= pageCount" @click="emit('update:page', page + 1)">
{{ t('pagination.next') }}
<ChevronRight :size="15" />
</BaseButton>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { ChevronLeft, ChevronRight } from '@lucide/vue';
import BaseButton from './ui/BaseButton.vue';
const { t } = useI18n();
withDefaults(
defineProps<{
page: number;
pageCount: number;
pageSize: number;
pageSizeOptions?: number[];
defaultSize?: number;
}>(),
{ pageSizeOptions: () => [25, 50, 100, 0], defaultSize: 25 },
);
const emit = defineEmits<{ 'update:page': [number]; 'update:pageSize': [number] }>();
function onSize(e: Event): void {
emit('update:pageSize', Number((e.target as HTMLSelectElement).value));
}
</script>

View File

@@ -1,20 +1,20 @@
<template>
<section class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3">
<header class="flex items-center gap-2">
<section class="card flex flex-col gap-2">
<header class="flex flex-wrap items-center gap-2">
<h2 class="font-semibold text-zinc-100">{{ repo.label }}</h2>
<span v-if="!repo.valid" class="badge bg-red-950 text-red-400">{{ t('repos.invalid') }}</span>
<BaseBadge v-if="!repo.valid" tone="red">{{ t('repos.invalid') }}</BaseBadge>
<span class="truncate font-mono text-xs text-zinc-500" :title="repo.path">{{ repo.path }}</span>
<div class="ml-auto flex items-center gap-2">
<button class="btn text-xs" @click="creating = !creating">{{ t('worktrees.new') }}</button>
<button class="btn text-xs" :disabled="busy" @click="onPrune">{{ t('worktrees.prune') }}</button>
<button class="btn-danger text-xs" @click="onRemove">{{ t('repos.remove') }}</button>
<BaseButton size="sm" :icon="Plus" @click="creating = !creating">{{ t('worktrees.new') }}</BaseButton>
<BaseButton size="sm" :icon="Scissors" :loading="busy" @click="onPrune">{{ t('worktrees.prune') }}</BaseButton>
<BaseButton variant="danger" size="sm" icon-only :icon="Trash2" :aria-label="t('repos.remove')" @click="onRemove" />
</div>
</header>
<form v-if="creating" class="flex flex-wrap items-end gap-2 rounded border border-zinc-800 bg-zinc-950/40 p-2" @submit.prevent="onCreate">
<form v-if="creating" class="card-inset flex flex-wrap items-end gap-2" @submit.prevent="onCreate">
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.branch') }}
<input v-model="branch" class="input font-mono" :placeholder="repo.defaultBranch ? `feature/…` : 'feature/…'" required />
<input v-model="branch" class="input font-mono" placeholder="feature/…" required />
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.start') }}
@@ -25,14 +25,15 @@
</select>
</label>
<label class="flex items-center gap-1 text-xs text-zinc-400"><input v-model="newBranch" type="checkbox" /> {{ t('worktrees.newBranch') }}</label>
<button type="submit" class="btn-primary text-xs" :disabled="busy || branch.trim() === ''">
{{ busy ? t('worktrees.creating') : t('worktrees.create') }}
</button>
<BaseButton type="submit" variant="primary" size="sm" :loading="busy" :disabled="branch.trim() === ''">
{{ t('worktrees.create') }}
</BaseButton>
</form>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<div class="flex flex-col gap-1.5">
<WorktreeCard v-for="w in worktrees" :key="w.path" :worktree="w" />
<p v-if="worktrees.length === 0" class="px-1 text-xs text-zinc-600">{{ t('worktrees.noSession') }}</p>
</div>
</section>
</template>
@@ -40,15 +41,23 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Plus, Scissors, Trash2 } from '@lucide/vue';
import type { RepoSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees';
import { useWorktreeView } from '../composables/useWorktreeView';
import { useToastsStore } from '../stores/toasts';
import WorktreeCard from './WorktreeCard.vue';
import BaseButton from './ui/BaseButton.vue';
import BaseBadge from './ui/BaseBadge.vue';
const props = defineProps<{ repo: RepoSummary }>();
const { t } = useI18n();
const store = useWorktreesStore();
const view = useWorktreeView();
const toasts = useToastsStore();
const worktrees = computed(() => store.worktreesForRepo(props.repo.id));
// applique le tri/filtre/recherche partagé (toolbar du dashboard) aux worktrees de ce repo.
const worktrees = computed(() => view.apply(store.worktreesForRepo(props.repo.id)));
const creating = ref(false);
const branch = ref('');
const newBranch = ref(true);
@@ -63,6 +72,7 @@ async function onCreate(): Promise<void> {
await store.createWorktree(props.repo.id, { branch: branch.value.trim(), newBranch: newBranch.value, startSession: startSession.value });
branch.value = '';
creating.value = false;
toasts.success(t('toast.worktreeCreated'));
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
@@ -75,19 +85,20 @@ async function onPrune(): Promise<void> {
error.value = null;
try {
await store.prune(props.repo.id);
toasts.success(t('toast.pruned'));
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
toasts.error(err);
} finally {
busy.value = false;
}
}
async function onRemove(): Promise<void> {
error.value = null;
try {
await store.removeRepo(props.repo.id);
toasts.success(t('toast.repoRemoved'));
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
toasts.error(err);
}
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-end gap-2 p-4 sm:bottom-4 sm:right-4 sm:left-auto sm:p-0">
<TransitionGroup name="toast">
<div
v-for="toast in store.toasts"
:key="toast.id"
class="pointer-events-auto flex w-full max-w-sm items-start gap-2 rounded-lg border px-3 py-2 shadow-pop"
:class="toneClass(toast.kind)"
:role="toast.kind === 'error' ? 'alert' : 'status'"
:aria-live="toast.kind === 'error' ? 'assertive' : 'polite'"
>
<component :is="icon(toast.kind)" :size="16" class="mt-0.5 shrink-0" />
<span class="min-w-0 flex-1 break-words text-sm">{{ toast.message }}</span>
<button
class="shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400/50"
:aria-label="t('toast.dismiss')"
@click="store.dismiss(toast.id)"
>
<X :size="14" />
</button>
</div>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { CheckCircle2, AlertTriangle, Info, X } from '@lucide/vue';
import { useToastsStore, type ToastKind } from '../stores/toasts';
const { t } = useI18n();
const store = useToastsStore();
function toneClass(kind: ToastKind): string {
if (kind === 'success') return 'border-emerald-800 bg-emerald-950 text-emerald-200';
if (kind === 'error') return 'border-red-800 bg-red-950 text-red-200';
return 'border-zinc-700 bg-zinc-900 text-zinc-200';
}
function icon(kind: ToastKind) {
return kind === 'success' ? CheckCircle2 : kind === 'error' ? AlertTriangle : Info;
}
</script>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateY(8px);
}
@media (prefers-reduced-motion: reduce) {
.toast-enter-active,
.toast-leave-active {
transition: none;
}
}
</style>

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>

View File

@@ -0,0 +1,35 @@
<template>
<span class="badge" :class="toneClass">
<span v-if="dot" class="inline-block h-1.5 w-1.5 rounded-full" :class="dotClass" />
<slot />
</span>
</template>
<script setup lang="ts">
// Pastille d'état : centralise les paires bg/text répétées (zinc/emerald/amber/sky/red).
import { computed } from 'vue';
type Tone = 'zinc' | 'emerald' | 'amber' | 'sky' | 'red';
const props = withDefaults(defineProps<{ tone?: Tone; dot?: boolean; pulse?: boolean }>(), {
tone: 'zinc',
});
const TONES: Record<Tone, string> = {
zinc: 'bg-zinc-800 text-zinc-400',
emerald: 'bg-emerald-950 text-emerald-400',
amber: 'bg-amber-950 text-amber-300',
sky: 'bg-sky-950 text-sky-300',
red: 'bg-red-950 text-red-400',
};
const DOTS: Record<Tone, string> = {
zinc: 'bg-zinc-500',
emerald: 'bg-emerald-400',
amber: 'bg-amber-300',
sky: 'bg-sky-300',
red: 'bg-red-400',
};
const toneClass = computed(() => TONES[props.tone]);
const dotClass = computed(() => [DOTS[props.tone], props.pulse ? 'animate-pulse' : '']);
</script>

View File

@@ -0,0 +1,80 @@
<template>
<component
:is="tag"
:class="classes"
v-bind="mergedAttrs"
@click="onClick"
>
<Loader2 v-if="loading" :size="iconSize" class="animate-spin" />
<component :is="icon" v-else-if="icon" :size="iconSize" />
<slot />
</component>
</template>
<script setup lang="ts">
// Bouton unifié : variantes + tailles + état loading + anneau de focus cohérent.
// Rend un <RouterLink> quand `to` est fourni (et actionnable), sinon un <button>.
import { computed, useAttrs, type Component } from 'vue';
import { RouterLink, type RouteLocationRaw } from 'vue-router';
import { Loader2 } from '@lucide/vue';
defineOptions({ inheritAttrs: false });
const props = withDefaults(
defineProps<{
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md';
loading?: boolean;
disabled?: boolean;
iconOnly?: boolean;
icon?: Component;
to?: RouteLocationRaw;
type?: 'button' | 'submit';
}>(),
{ variant: 'secondary', size: 'md', type: 'button' },
);
const emit = defineEmits<{ click: [MouseEvent] }>();
const attrs = useAttrs();
const isDisabled = computed(() => props.disabled || props.loading);
const asLink = computed(() => props.to !== undefined && !isDisabled.value);
const tag = computed(() => (asLink.value ? RouterLink : 'button'));
const iconSize = computed(() => (props.size === 'sm' ? 15 : 16));
const BASE =
'inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 disabled:cursor-not-allowed disabled:opacity-50';
const VARIANTS: Record<NonNullable<typeof props.variant>, string> = {
primary: 'bg-emerald-600 text-white hover:bg-emerald-500 focus-visible:ring-emerald-500/70',
secondary: 'border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 focus-visible:ring-emerald-500/70',
ghost: 'text-zinc-300 hover:bg-zinc-800/60 focus-visible:ring-emerald-500/70',
danger: 'border border-red-900 bg-red-950 text-red-300 hover:bg-red-900 focus-visible:ring-red-500/70',
};
const SIZES = {
sm: { text: 'h-8 px-2.5 text-xs', icon: 'h-8 w-8' },
md: { text: 'h-9 px-3 text-sm', icon: 'h-9 w-9' },
};
const classes = computed(() => {
const size = SIZES[props.size];
return [BASE, VARIANTS[props.variant], props.iconOnly ? size.icon : size.text];
});
// Attributs natifs propres au tag rendu (button : type/disabled/aria-busy ; lien : to).
const nativeProps = computed(() => {
if (asLink.value) return { to: props.to };
return { type: props.type, disabled: isDisabled.value, 'aria-busy': props.loading || undefined };
});
// useAttrs (class/aria-label/etc. du parent) + props natifs.
const mergedAttrs = computed(() => ({ ...attrs, ...nativeProps.value }));
function onClick(e: MouseEvent): void {
if (isDisabled.value) {
e.preventDefault();
return;
}
emit('click', e);
}
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-zinc-800 px-6 py-10 text-center">
<component :is="icon" v-if="icon" :size="32" class="text-zinc-600" :stroke-width="1.5" />
<p class="text-sm font-medium text-zinc-300">{{ title }}</p>
<p v-if="hint" class="max-w-sm text-xs text-zinc-500">{{ hint }}</p>
<div v-if="$slots.action" class="mt-1">
<slot name="action" />
</div>
</div>
</template>
<script setup lang="ts">
// État vide soigné (icône + titre + indice + CTA) — remplace les <p text-zinc-500> plats.
import type { Component } from 'vue';
defineProps<{ icon?: Component; title: string; hint?: string }>();
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="inline-flex rounded-lg border border-zinc-800 bg-zinc-950/40 p-0.5">
<button
v-for="opt in options"
:key="opt.value"
type="button"
class="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70"
:class="opt.value === modelValue ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'"
:aria-pressed="opt.value === modelValue"
@click="emit('update:modelValue', opt.value)"
>
<component :is="opt.icon" v-if="opt.icon" :size="15" />
<span v-if="opt.label">{{ opt.label }}</span>
</button>
</div>
</template>
<script setup lang="ts">
// Bascule segmentée (ex. liste / grille) : remplace le toggle bricolé inline.
import type { Component } from 'vue';
export interface SegmentOption {
value: string;
label?: string;
icon?: Component;
}
defineProps<{ modelValue: string; options: SegmentOption[] }>();
const emit = defineEmits<{ 'update:modelValue': [string] }>();
</script>

View File

@@ -0,0 +1,15 @@
<template>
<div class="flex flex-col gap-2">
<div
v-for="i in count"
:key="i"
class="animate-pulse rounded-xl border border-zinc-800 bg-zinc-900/40 motion-reduce:animate-none"
:style="{ height: `${height}px` }"
/>
</div>
</template>
<script setup lang="ts">
// Squelette de chargement : garde la hauteur stable, évite le layout shift.
withDefaults(defineProps<{ count?: number; height?: number }>(), { count: 3, height: 64 });
</script>