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:
37
packages/web/src/components/AttentionSection.vue
Normal file
37
packages/web/src/components/AttentionSection.vue
Normal 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>
|
||||
169
packages/web/src/components/CommandPalette.vue
Normal file
169
packages/web/src/components/CommandPalette.vue
Normal 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>
|
||||
87
packages/web/src/components/ListToolbar.vue
Normal file
87
packages/web/src/components/ListToolbar.vue
Normal 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>
|
||||
47
packages/web/src/components/Pagination.vue
Normal file
47
packages/web/src/components/Pagination.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
60
packages/web/src/components/ToastContainer.vue
Normal file
60
packages/web/src/components/ToastContainer.vue
Normal 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>
|
||||
49
packages/web/src/components/layout/AppShell.vue
Normal file
49
packages/web/src/components/layout/AppShell.vue
Normal 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>
|
||||
57
packages/web/src/components/layout/AppShellFooter.vue
Normal file
57
packages/web/src/components/layout/AppShellFooter.vue
Normal 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>
|
||||
45
packages/web/src/components/layout/AppSidebar.vue
Normal file
45
packages/web/src/components/layout/AppSidebar.vue
Normal 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>
|
||||
40
packages/web/src/components/layout/MobileTabBar.vue
Normal file
40
packages/web/src/components/layout/MobileTabBar.vue
Normal 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>
|
||||
24
packages/web/src/components/layout/MoreSheet.vue
Normal file
24
packages/web/src/components/layout/MoreSheet.vue
Normal 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>
|
||||
50
packages/web/src/components/layout/NavItem.vue
Normal file
50
packages/web/src/components/layout/NavItem.vue
Normal 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>
|
||||
21
packages/web/src/components/layout/PageHeader.vue
Normal file
21
packages/web/src/components/layout/PageHeader.vue
Normal 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>
|
||||
11
packages/web/src/components/layout/WsBanner.vue
Normal file
11
packages/web/src/components/layout/WsBanner.vue
Normal 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>
|
||||
35
packages/web/src/components/ui/BaseBadge.vue
Normal file
35
packages/web/src/components/ui/BaseBadge.vue
Normal 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>
|
||||
80
packages/web/src/components/ui/BaseButton.vue
Normal file
80
packages/web/src/components/ui/BaseButton.vue
Normal 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>
|
||||
17
packages/web/src/components/ui/EmptyState.vue
Normal file
17
packages/web/src/components/ui/EmptyState.vue
Normal 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>
|
||||
30
packages/web/src/components/ui/SegmentedControl.vue
Normal file
30
packages/web/src/components/ui/SegmentedControl.vue
Normal 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>
|
||||
15
packages/web/src/components/ui/SkeletonRow.vue
Normal file
15
packages/web/src/components/ui/SkeletonRow.vue
Normal 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>
|
||||
Reference in New Issue
Block a user