Files
inkwell/frontend/src/components/AppShell.vue
T
bvandeusenandClaude Opus 5 67b9ea2938
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 59s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android (Tauri) / Android APK (debug) (push) Successful in 4m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m32s
Desktop (Tauri) / Update manifest (push) Successful in 5s
frontend: one grid for every lens, and a cross-fade between surfaces (task 1913)
"The same board, re-filtered" has to be literally true to read as true. The
column classes were copy-pasted into five places — the board's pinned and other
sections, its non-board branch, search, and timeline — so a lens could drift from
home by a single edit. One already had: the FLIP reflow from 1914 landed on the
board's three grids and left search and timeline popping. NoteGrid is now the only
file that knows how the masonry is laid out or how it moves, and search and
timeline gained the motion by adopting it.

It takes activeId rather than an index. The board splits its notes across two
grids, so index-based focus made the call site do offset arithmetic
(focusedIndex === pinnedNotes.length + i) against a list the grid didn't own.

The lens cross-fade is deliberately UNKEYED, which is the whole trick. Board,
archive, trash and label all render the same BoardView; keying the transition on
the route would remount it, blanking the board and refetching — exactly the
page-change feeling this is meant to remove. Unkeyed, Vue transitions only when
the component TYPE changes (board to search to timeline to graph), and moving
between the board's own lenses stays an in-place reflow that NoteGrid animates.
The two behaviours fall out of one rule rather than needing to be special-cased.

Out is quicker than in because mode="out-in" makes the durations additive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 13:39:49 -04:00

486 lines
18 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 BaseModal from "./BaseModal.vue";
import CommandPalette from "./CommandPalette.vue";
import Icon from "./Icon.vue";
import ImportNotes from "./ImportNotes.vue";
import LabelsModal from "./LabelsModal.vue";
import { isDesktop } from "../desktop/bridge";
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();
// Sync is a desktop-app concern: the web build already IS the server's UI.
const desktopApp = isDesktop();
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">
<!-- First tab stop on every page. The header and sidebar are a dozen-odd tab
stops that repeat on every navigation; without this a keyboard user walks
all of them again to reach their own notes. Hidden until focused. -->
<a
href="#main"
class="sr-only focus:not-sr-only focus:absolute focus:left-3 focus:top-3 focus:z-50 focus:rounded-md focus:bg-white focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus:ring-2 focus:ring-brand dark:focus:bg-neutral-900"
>
Skip to notes
</a>
<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>
<!-- Whose account this is only means something when there IS an account.
The desktop signs in as a synthetic local user so the shared router's
auth guard resolves; naming it would invent a login the app doesn't
have. -->
<span
v-if="!desktopApp"
class="hidden text-sm text-neutral-500 md:inline dark:text-neutral-400"
>{{ session.user?.display_name }}</span
>
<RouterLink
v-if="desktopApp"
to="/sync"
class="icon-btn"
title="Sync"
aria-label="Sync"
>
<Icon name="sync" />
</RouterLink>
<!-- Server-side concept: it lists the tokens a server has issued to native
clients. The desktop IS one of those clients, so here the list is always
empty and issuing a token rejects — its own relationship with a server
lives under /sync instead. -->
<RouterLink
v-if="!desktopApp"
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>
<!-- Hidden on the desktop, where it was a trap rather than an action:
logout nulls the synthetic local user and redirects to /login, but the
offline adapter rejects every sign-in ("there's no account to sign in
to"), leaving no way back in short of restarting the app. There is
nothing to sign out OF — the notes are on this machine either way. -->
<button
v-if="!desktopApp"
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="hover-reveal 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>
<!-- tabindex="-1" so the skip link above actually moves FOCUS here, not just
the viewport — several browsers scroll to a plain anchor without focusing
it, which leaves the next Tab back at the top of the page. -->
<!-- Cross-fade between lenses so switching reads as the same space
re-framing rather than a page swap.
Deliberately UNKEYED: board / archive / trash / label all render the same
BoardView, so keying on the route would remount it — blanking the board
and refetching, which is precisely the page-change feeling this is meant
to remove. Unkeyed, Vue only transitions when the component TYPE changes
(board ↔ search ↔ timeline ↔ graph), and moving between the board's own
lenses stays an in-place reflow that NoteGrid animates. -->
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
<RouterView v-slot="{ Component }">
<Transition name="lens" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</main>
</div>
<LabelsModal v-if="managing" @close="managing = false" />
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
<BaseModal
v-if="showShortcuts"
panel-class="w-full max-w-sm p-5 shadow-xl"
align="center"
aria-label="Keyboard shortcuts"
@close="showShortcuts = false"
>
<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>
</BaseModal>
</div>
</template>