Files
inkwell/frontend/src/components/AppShell.vue
T
bvandeusenandClaude Opus 4.8 3c78060051
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 33s
Header: use the app icon instead of the "TS" text badge (links home)
The header showed a plain "TS" text badge while the real brand icon
(public/icon.svg — the yellow knowledge-graph tile) was only used as the
favicon/PWA icon. Swap the badge for the actual icon, and make the logo +
name a RouterLink to the board (standard "logo goes home").

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

424 lines
15 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, nextTick, 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 { useSavedFiltersStore, type SavedFilter } from "../stores/savedFilters";
import { useReminderStore } from "../stores/reminders";
import { useUiStore } from "../stores/ui";
import CommandPalette from "./CommandPalette.vue";
import Icon from "./Icon.vue";
import ImportNotes from "./ImportNotes.vue";
import LabelsModal from "./LabelsModal.vue";
import { facetsToQuery } from "../notes/facets";
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
const route = useRoute();
const router = useRouter();
const session = useSessionStore();
const config = useConfigStore();
const labels = useLabelsStore();
const savedFilters = useSavedFiltersStore();
const reminders = useReminderStore();
const ui = useUiStore();
async function removeView(f: SavedFilter) {
if (!window.confirm(`Delete the "${f.name}" view?`)) return;
try {
await savedFilters.remove(f.id);
} catch {
ui.showToast("Couldn't delete that view.");
}
}
const managing = ref(false);
const showShortcuts = ref(false);
const paletteOpen = ref(false);
const drawer = 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: "Command palette", keys: ["⌘/Ctrl", "K"] },
{ label: "New note (or just start typing)", keys: ["Enter", "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: "Go to Timeline", keys: ["g", "t"] },
{ label: "Browse cards", keys: ["↑", "↓", "←", "→"] },
{ label: "Move / open focused card", keys: ["j", "k", "Enter"] },
{ label: "Pin / archive / trash card", keys: ["#", "e", "x"] },
{ label: "Finish & close note", keys: ["⌘/Ctrl", "Enter"] },
{ label: "Save & new (composing)", keys: ["Shift", "Enter"] },
{ label: "Stop browsing / back to Notes", 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();
}
// New note (the "+ New" button and the `c` shortcut): ensure we're on the board,
// then open the compose modal. Type-to-compose seeds it instead (see onKeydown).
async function newNote() {
if (route.name !== "board") {
await router.push("/");
await nextTick();
}
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) {
// 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;
}
if (e.key === "Escape" && showShortcuts.value) {
showShortcuts.value = false;
return;
}
if (e.key === "Escape" && drawer.value) {
drawer.value = false;
return;
}
// Esc with nothing open = back to the base Notes board. (An open editor gets Esc
// first via its own handler, which stops propagation so it doesn't also navigate.)
if (e.key === "Escape") {
if (route.name !== "board") void router.push("/");
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 === "t") {
e.preventDefault();
void router.push("/timeline");
return;
}
}
if (e.key === "/") {
e.preventDefault();
focusSearch();
return;
}
if (e.key === "c") {
e.preventDefault();
void newNote();
return;
}
if (e.key === "?") {
e.preventDefault();
showShortcuts.value = true;
return;
}
if (e.key === "g") {
gPending = true;
clearTimeout(gTimer);
gTimer = setTimeout(() => (gPending = false), 800);
return;
}
// Type-to-compose: any OTHER single printable key, on the board with no card
// focused (browse mode owns j/k/e/x/#), opens a new note seeded with that key.
if (route.name === "board" && !ui.boardCardFocused && e.key.length === 1 && /\S/.test(e.key)) {
e.preventDefault();
ui.requestCompose(e.key);
}
}
onMounted(() => {
if (!labels.loaded) void labels.load();
if (!savedFilters.loaded) void savedFilters.load();
reminders.start(); // foreground reminder delivery while the app is open
window.addEventListener("keydown", onKeydown);
});
onBeforeUnmount(() => {
reminders.stop();
window.removeEventListener("keydown", onKeydown);
});
const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));
function labelDot(color: string): string {
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
}
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 = "";
drawer.value = false;
},
);
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">
<button
type="button"
class="icon-btn sm:hidden"
title="Menu"
aria-label="Open menu"
@click="drawer = true"
>
<svg
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<RouterLink to="/" class="flex shrink-0 items-center gap-2" title="Home" aria-label="Home">
<img src="/icon.svg" alt="" class="h-8 w-8 rounded-lg" width="32" height="32" />
<span class="hidden font-semibold sm:inline">{{ config.siteName }}</span>
</RouterLink>
<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">
<button
type="button"
class="inline-flex items-center gap-1 rounded-lg bg-brand px-2.5 py-1.5 text-sm font-semibold text-neutral-900 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
title="New note (or just start typing)"
@click="newNote"
>
<Icon name="plus" /> <span class="hidden sm:inline">New</span>
</button>
<span class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400">{{
session.user?.display_name
}}</span>
<RouterLink to="/account" class="icon-btn" title="Linked devices" aria-label="Linked devices">
<Icon name="device" />
</RouterLink>
<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">
<div
v-if="drawer"
class="fixed inset-0 z-30 bg-black/40 sm:hidden"
aria-hidden="true"
@click="drawer = false"
></div>
<aside
class="fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 dark:border-neutral-800 dark:bg-neutral-950"
:class="drawer ? 'translate-x-0' : ''"
>
<nav class="flex flex-col gap-0.5 text-sm" @click="drawer = false">
<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' : ''"
>
<span
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
:class="labelDot(lb.color)"
></span>
<span class="truncate">{{ lb.name }}</span>
</RouterLink>
<template v-if="savedFilters.items.length">
<div class="mt-3 px-3 pb-1">
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Views</span>
</div>
<RouterLink
v-for="f in savedFilters.items"
:key="f.id"
:to="{ path: '/', query: facetsToQuery(f.params) }"
class="nav-link group/view"
>
<Icon name="filter" />
<span class="flex-1 truncate">{{ f.name }}</span>
<button
type="button"
class="text-neutral-400 opacity-0 hover:text-red-500 group-hover/view:opacity-100"
:aria-label="`Delete view ${f.name}`"
@click.prevent.stop="removeView(f)"
>
×
</button>
</RouterLink>
</template>
<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>
<RouterLink
to="/timeline"
class="nav-link"
:class="route.name === 'timeline' ? 'nav-link-active' : ''"
>
<Icon name="calendar" /> Timeline
</RouterLink>
<!-- Direct download (same-origin GET, session cookie sent); not a route. -->
<a href="/api/notes/export" download class="nav-link" title="Download all your notes as a zip">
<Icon name="download" /> Export
</a>
<ImportNotes />
</nav>
</aside>
<main class="min-w-0 flex-1"><RouterView /></main>
</div>
<LabelsModal v-if="managing" @close="managing = false" />
<CommandPalette v-if="paletteOpen" @close="paletteOpen = 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>