Files
thoughtsync/frontend/src/components/CommandPalette.vue
T
bvandeusenandClaude Opus 4.8 44a5466793
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 27s
M9 S5 (frontend): shared BaseModal for the standard modals + BaseInput in Account
- BaseModal.vue (new): the backdrop + dialog-panel shell (dimmed fixed overlay,
  bordered rounded panel, role=dialog, close on Escape + backdrop mousedown).
  Caller sizes/pads/shadows the panel via `panelClass`, picks start/center
  `align`, and sets an `ariaLabel` for header-less panels.
- LabelsModal, CommandPalette, and the AppShell keyboard-shortcuts overlay drop
  their hand-rolled backdrop+panel shells and slot their content into BaseModal
  (~12 lines of overlay boilerplate each → gone).
- NoteEditor deliberately keeps its own shell: its backdrop mousedown is
  drag-guarded and its Esc/⌘-Enter handling is bespoke (unsaved-edit safety),
  so folding it in would risk regressing the app's core editing surface (rule 28).
- AccountView's one device-name field now uses the shared BaseInput. SettingsView
  is intentionally NOT converted — its rows are a horizontal label+control pattern
  (checkbox/number/text, direct value mutation), a different shape than BaseInput's
  vertical form field.

Frontend-only; CI vue-tsc is the type/template gate (no local typecheck, rule 10).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-23 21:58:59 -04:00

136 lines
4.4 KiB
Vue

<script setup lang="ts">
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";
import BaseModal from "./BaseModal.vue";
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<HTMLInputElement | null>(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<Row[]>(() => {
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:timeline", label: "Go to Timeline", hint: "Navigate", run: () => go("/timeline") },
{ 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<Row[]>(() => {
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();
});
</script>
<template>
<BaseModal
panel-class="w-full max-w-lg overflow-hidden shadow-2xl"
aria-label="Command palette"
@close="emit('close')"
>
<input
ref="input"
v-model="query"
type="text"
placeholder="Jump to a note or run a command…"
aria-label="Jump to a note or run a command"
class="w-full border-b border-neutral-200 bg-transparent px-4 py-3 text-sm outline-none placeholder:text-neutral-400 dark:border-neutral-700"
@keydown="onKeydown"
/>
<ul v-if="results.length" class="max-h-80 overflow-y-auto py-1">
<li v-for="(row, i) in results" :key="row.id">
<button
type="button"
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-left text-sm"
:class="
i === selected
? 'bg-brand/15 text-brand-700 dark:text-brand'
: 'hover:bg-neutral-100 dark:hover:bg-neutral-800'
"
@mousemove="selected = i"
@click="
selected = i;
activate();
"
>
<span class="truncate">{{ row.label }}</span>
<span class="shrink-0 text-xs text-neutral-400">{{ row.hint }}</span>
</button>
</li>
</ul>
<p v-else class="px-4 py-6 text-center text-sm text-neutral-400">No matches</p>
</BaseModal>
</template>