Files
thoughtsync/frontend/src/components/AppShell.vue
T
bvandeusenandClaude Opus 4.8 4bd4ce5a1f
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 27s
keyboard: global shortcuts + ? cheat-sheet
App-shell keydown dispatcher (active only inside the authed shell):
- c        new note (jump to board / reopen composer via a ui-store signal)
- /        focus the search box
- g b/g/r  jump to Board / Graph / Reminders (two-key, 800ms window)
- ?        open a keyboard-shortcuts cheat-sheet overlay
- Esc      blur a focused field / close the cheat-sheet

Ignored while typing in an input/textarea/contenteditable (except Esc).
New stores/ui.ts carries the compose signal; QuickAdd exposes open(); the
board reopens the composer on the signal when already on the board.

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

280 lines
9.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config";
import { useLabelsStore } from "../stores/labels";
import { useUiStore } from "../stores/ui";
import Icon from "./Icon.vue";
import LabelsModal from "./LabelsModal.vue";
const route = useRoute();
const router = useRouter();
const session = useSessionStore();
const config = useConfigStore();
const labels = useLabelsStore();
const ui = useUiStore();
const managing = ref(false);
const showShortcuts = ref(false);
const searchInput = ref<HTMLInputElement | null>(null);
const searchText = ref(typeof route.query.q === "string" ? route.query.q : "");
let searchTimer: ReturnType<typeof setTimeout> | undefined;
const shortcuts = [
{ label: "New note", keys: ["c"] },
{ label: "Search", keys: ["/"] },
{ label: "Go to Board", keys: ["g", "b"] },
{ label: "Go to Graph", keys: ["g", "g"] },
{ label: "Go to Reminders", keys: ["g", "r"] },
{ label: "Save & close (editor)", keys: ["⌘/Ctrl", "Enter"] },
{ label: "Save & new (composer)", keys: ["Shift", "Enter"] },
{ label: "Dismiss / close", keys: ["Esc"] },
{ label: "This help", keys: ["?"] },
];
let gPending = false;
let gTimer: ReturnType<typeof setTimeout> | undefined;
function isTyping(e: KeyboardEvent): boolean {
const el = e.target as HTMLElement | null;
return !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
}
function focusSearch() {
searchInput.value?.focus();
searchInput.value?.select();
}
// `c` composes: jump to the board (which autofocuses its quick-add) or, if
// already there, ask the board to reopen the composer.
function compose() {
if (route.name !== "board") void router.push("/");
else ui.requestCompose();
}
// Global keyboard shortcuts, active only inside the authed shell. Ignored while
// 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) {
if (isTyping(e)) {
if (e.key === "Escape") (e.target as HTMLElement).blur();
return;
}
if (e.key === "Escape" && showShortcuts.value) {
showShortcuts.value = false;
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (gPending) {
gPending = false;
clearTimeout(gTimer);
if (e.key === "b") {
e.preventDefault();
void router.push("/");
return;
}
if (e.key === "g") {
e.preventDefault();
void router.push("/graph");
return;
}
if (e.key === "r") {
e.preventDefault();
void router.push("/reminders");
return;
}
}
if (e.key === "/") {
e.preventDefault();
focusSearch();
return;
}
if (e.key === "c") {
e.preventDefault();
compose();
return;
}
if (e.key === "?") {
e.preventDefault();
showShortcuts.value = true;
return;
}
if (e.key === "g") {
gPending = true;
clearTimeout(gTimer);
gTimer = setTimeout(() => (gPending = false), 800);
}
}
onMounted(() => {
if (!labels.loaded) void labels.load();
window.addEventListener("keydown", onKeydown);
});
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));
function onSearch(value: string) {
searchText.value = value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
const q = searchText.value.trim();
if (q) router.push({ name: "search", query: { q } });
else if (route.name === "search") router.push("/");
}, 250);
}
// Clear the search box when navigating to a non-search view.
watch(
() => route.name,
(name) => {
if (name !== "search") searchText.value = "";
},
);
async function signOut() {
await session.logout();
await router.replace("/login");
}
</script>
<template>
<div class="flex min-h-full flex-col">
<header
class="sticky top-0 z-20 border-b border-neutral-200 bg-neutral-50/90 backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/90"
>
<div class="flex items-center gap-3 px-4 py-3">
<div class="flex shrink-0 items-center gap-2">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-brand text-sm font-black text-neutral-900">
TS
</div>
<span class="hidden font-semibold sm:inline">{{ config.siteName }}</span>
</div>
<div class="flex flex-1 justify-center">
<input
ref="searchInput"
:value="searchText"
type="search"
placeholder="Search notes…"
aria-label="Search notes"
class="w-full max-w-md rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
@input="onSearch(($event.target as HTMLInputElement).value)"
/>
</div>
<div class="flex shrink-0 items-center gap-3">
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">{{
session.user?.display_name
}}</span>
<RouterLink
v-if="session.user?.is_admin"
to="/settings"
class="icon-btn"
title="Settings"
aria-label="Settings"
>
<Icon name="settings" />
</RouterLink>
<button type="button" class="icon-btn" title="Sign out" aria-label="Sign out" @click="signOut">
<Icon name="logout" />
</button>
</div>
</div>
</header>
<div class="flex flex-1">
<aside class="hidden w-56 shrink-0 border-r border-neutral-200 p-3 sm:block dark:border-neutral-800">
<nav class="flex flex-col gap-0.5 text-sm">
<RouterLink to="/" class="nav-link" :class="route.name === 'board' ? 'nav-link-active' : ''">
<Icon name="note" /> Notes
</RouterLink>
<RouterLink to="/graph" class="nav-link" :class="route.name === 'graph' ? 'nav-link-active' : ''">
<Icon name="graph" /> Graph
</RouterLink>
<div class="mt-3 flex items-center justify-between px-3 pb-1">
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span>
<button
type="button"
class="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200"
title="Edit labels"
aria-label="Edit labels"
@click="managing = true"
>
<Icon name="pencil" />
</button>
</div>
<p v-if="!labels.items.length" class="px-3 py-1 text-xs text-neutral-400">No labels yet</p>
<RouterLink
v-for="lb in labels.items"
:key="lb.id"
:to="`/label/${lb.id}`"
class="nav-link"
:class="currentLabelId === lb.id ? 'nav-link-active' : ''"
>
<Icon name="tag" /> <span class="truncate">{{ lb.name }}</span>
</RouterLink>
<RouterLink to="/archive" class="nav-link mt-3" :class="route.name === 'archive' ? 'nav-link-active' : ''">
<Icon name="archive" /> Archive
</RouterLink>
<RouterLink to="/trash" class="nav-link" :class="route.name === 'trash' ? 'nav-link-active' : ''">
<Icon name="trash" /> Trash
</RouterLink>
<RouterLink
to="/reminders"
class="nav-link"
:class="route.name === 'reminders' ? 'nav-link-active' : ''"
>
<Icon name="bell" /> Reminders
</RouterLink>
</nav>
</aside>
<main class="min-w-0 flex-1"><RouterView /></main>
</div>
<LabelsModal v-if="managing" @close="managing = false" />
<div
v-if="showShortcuts"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
@click.self="showShortcuts = false"
>
<div
class="w-full max-w-sm rounded-xl border border-neutral-200 bg-white p-5 shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold">Keyboard shortcuts</h2>
<button
type="button"
class="text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-100"
aria-label="Close"
@click="showShortcuts = false"
>
×
</button>
</div>
<dl class="flex flex-col gap-2 text-sm">
<div v-for="s in shortcuts" :key="s.label" class="flex items-center justify-between gap-4">
<dt class="text-neutral-600 dark:text-neutral-300">{{ s.label }}</dt>
<dd class="flex items-center gap-1">
<kbd
v-for="(k, i) in s.keys"
:key="i"
class="rounded border border-neutral-300 bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-700 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-200"
>{{ k }}</kbd
>
</dd>
</div>
</dl>
</div>
</div>
</div>
</template>