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:
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>
|
||||
Reference in New Issue
Block a user