diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index d5fea3d..21eb92b 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -5,6 +5,7 @@ import { useSessionStore } from "../stores/session"; import { useConfigStore } from "../stores/config"; import { useLabelsStore } from "../stores/labels"; import { useUiStore } from "../stores/ui"; +import CommandPalette from "./CommandPalette.vue"; import Icon from "./Icon.vue"; import LabelsModal from "./LabelsModal.vue"; @@ -17,11 +18,13 @@ const ui = useUiStore(); const managing = ref(false); const showShortcuts = ref(false); +const paletteOpen = ref(false); const searchInput = ref(null); const searchText = ref(typeof route.query.q === "string" ? route.query.q : ""); let searchTimer: ReturnType | undefined; const shortcuts = [ + { label: "Command palette", keys: ["⌘/Ctrl", "K"] }, { label: "New note", keys: ["c"] }, { label: "Search", keys: ["/"] }, { label: "Go to Board", keys: ["g", "b"] }, @@ -57,6 +60,12 @@ function compose() { // typing in a field (except Esc, which blurs it). `g` starts a two-key jump // (g b / g g / g r) with a short timeout. function onKeydown(e: KeyboardEvent) { + // Cmd/Ctrl+K toggles the command palette — works even while typing. + if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) { + e.preventDefault(); + paletteOpen.value = !paletteOpen.value; + return; + } if (isTyping(e)) { if (e.key === "Escape") (e.target as HTMLElement).blur(); return; @@ -238,6 +247,8 @@ async function signOut() { + +
+import { computed, nextTick, onMounted, ref, watch } from "vue"; +import { useRouter } from "vue-router"; +import { useSessionStore } from "../stores/session"; +import { useTitlesStore } from "../stores/titles"; +import { useUiStore } from "../stores/ui"; + +const emit = defineEmits<{ (e: "close"): void }>(); + +const router = useRouter(); +const session = useSessionStore(); +const titles = useTitlesStore(); +const ui = useUiStore(); + +const query = ref(""); +const selected = ref(0); +const input = ref(null); + +interface Row { + id: string; + label: string; + hint: string; + run: () => void; +} + +function go(path: string) { + void router.push(path); + emit("close"); +} +function compose() { + void router.push("/").then(() => ui.requestCompose()); + emit("close"); +} +function openNote(id: string) { + void router.push({ path: "/", query: { open: id } }); + emit("close"); +} + +// Static command actions, filtered by query alongside note titles. +const commands = computed(() => { + const list: Row[] = [ + { id: "cmd:new", label: "New note", hint: "Action", run: compose }, + { id: "cmd:board", label: "Go to Board", hint: "Navigate", run: () => go("/") }, + { id: "cmd:graph", label: "Go to Graph", hint: "Navigate", run: () => go("/graph") }, + { id: "cmd:reminders", label: "Go to Reminders", hint: "Navigate", run: () => go("/reminders") }, + { id: "cmd:archive", label: "Go to Archive", hint: "Navigate", run: () => go("/archive") }, + { id: "cmd:trash", label: "Go to Trash", hint: "Navigate", run: () => go("/trash") }, + ]; + if (session.user?.is_admin) { + list.push({ id: "cmd:settings", label: "Open Settings", hint: "Navigate", run: () => go("/settings") }); + } + return list; +}); + +const results = computed(() => { + const q = query.value.trim().toLowerCase(); + const cmds = commands.value.filter((c) => !q || c.label.toLowerCase().includes(q)); + const notes: Row[] = titles.items + .filter((t) => t.title && (!q || t.title.toLowerCase().includes(q))) + .slice(0, q ? 12 : 6) + .map((t) => ({ id: `note:${t.id}`, label: t.title, hint: "Note", run: () => openNote(t.id) })); + // When searching text, surface matching notes first; when empty, lead with actions. + return q ? [...notes, ...cmds] : [...cmds, ...notes]; +}); + +watch(query, () => (selected.value = 0)); + +function activate() { + results.value[selected.value]?.run(); +} + +function onKeydown(e: KeyboardEvent) { + if (e.key === "ArrowDown") { + e.preventDefault(); + selected.value = Math.min(selected.value + 1, results.value.length - 1); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + selected.value = Math.max(selected.value - 1, 0); + } else if (e.key === "Enter") { + e.preventDefault(); + activate(); + } else if (e.key === "Escape") { + e.preventDefault(); + emit("close"); + } +} + +onMounted(async () => { + await titles.load(); + await nextTick(); + input.value?.focus(); +}); + + + diff --git a/frontend/src/views/BoardView.vue b/frontend/src/views/BoardView.vue index 67bc498..5c4ec51 100644 --- a/frontend/src/views/BoardView.vue +++ b/frontend/src/views/BoardView.vue @@ -1,6 +1,6 @@