Files
inkwell/frontend/src/components/AppShell.vue
T
bvandeusenandClaude Opus 4.8 d1bc91a54a
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 29s
M6 1908b: foreground reminder delivery + recurrence/snooze UI (frontend)
Completes 1908 without Web Push. While the app is open, due reminders now
actually surface; recurring reminders + snooze/done are manageable.

- reminders store (singleton): polls /api/notes/reminders every 45s while
  the app is open; each due reminder fires ONCE as a toast (with an "Open"
  action) and, if the user opts in, a page-context OS Notification — no
  service worker, no PWA. Silently primes a stale backlog on first load;
  only announces recently-due ones. AppShell starts/stops it.
- Editor reminder section: a Repeat picker (Does not repeat / Daily /
  Weekly / Monthly / Yearly) + Done (advances a recurring reminder / clears
  a one-off) + Snooze 1h/1d, shown when a reminder is set.
- RemindersView rebuilt as a chronological list: per row a due time +
  recurrence badge + Done / 1h / 1d, click to open; plus an "Enable
  notifications" opt-in and a note that background alerts come with native.
- Note type gains `recurrence`; notes store setRecurrence /
  completeReminder / snoozeReminder.

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

408 lines
14 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 { 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", 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: "Go to Timeline", keys: ["g", "t"] },
{ label: "Move card focus", keys: ["j", "k"] },
{ label: "Open focused card", keys: ["Enter"] },
{ label: "Pin / archive / trash card", keys: ["#", "e", "x"] },
{ label: "Finish & close note", keys: ["⌘/Ctrl", "Enter"] },
{ label: "Save & new (composer)", keys: ["Shift", "Enter"] },
{ label: "Close / 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();
}
// `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) {
// 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();
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();
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>
<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 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>