CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 44s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m51s
Android (Tauri) / Android APK (debug) (push) Successful in 3m37s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m57s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Which lens you're looking at is a property of the space, not of a page you navigated to — so the name now sits in the bar that never moves, beside the app name, and stays put while everything beneath it re-filters. It replaces three per-view <h1>s that each sat in a different place with slightly different markup (timeline, reminders, graph) and, more to the point, were absent entirely on the board and in search — the two lenses people spend the most time in had no name at all. A label lens is named by the label itself, because "Groceries" is what the user came looking for and "Label" tells them nothing. Shown at every width rather than hidden on small screens, which was my first cut and would have been a regression: deleting the per-view titles while hiding the shell one leaves a phone with no lens name anywhere, and Android is a peer surface now. Below `sm` the app name is already hidden, so the lens name simply takes the space it vacates — you know which app you're in; what you need is which lens. The h1s on Settings, Sync, Account, Login and Register are untouched: those routes render outside the shell entirely, so they have no chrome to be named by. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
533 lines
20 KiB
Vue
533 lines
20 KiB
Vue
<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;
|
||
},
|
||
);
|
||
|
||
/**
|
||
* What to call the lens currently in view.
|
||
*
|
||
* Keyed off the route name rather than each view declaring its own title, so the
|
||
* label sits in one place and can't go missing (the board and search never had one)
|
||
* or drift in styling (timeline, reminders and graph each had their own h1).
|
||
*
|
||
* A label lens is named by the label itself — "Groceries" is what the user came
|
||
* looking for; "Label" would tell them nothing they didn't already know.
|
||
*/
|
||
const lensName = computed<string>(() => {
|
||
switch (route.name) {
|
||
case "archive":
|
||
return "Archive";
|
||
case "trash":
|
||
return "Trash";
|
||
case "search":
|
||
return "Search";
|
||
case "timeline":
|
||
return "Timeline";
|
||
case "reminders":
|
||
return "Reminders";
|
||
case "graph":
|
||
return "Graph";
|
||
case "label":
|
||
// The store may not have loaded yet on a deep link; fall back rather than
|
||
// flashing an empty slot.
|
||
return labels.items.find((l) => l.id === String(route.params.id))?.name ?? "Label";
|
||
default:
|
||
return "Notes";
|
||
}
|
||
});
|
||
|
||
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>
|
||
|
||
<!-- The active lens, named in the persistent chrome rather than inside each
|
||
view. Which lens you're looking at is a property of the SPACE, not of a
|
||
page you navigated to — so it sits in the bar that never moves, beside
|
||
the app name, and stays in one place while everything beneath it
|
||
re-filters. Replaces the per-view <h1>s, which sat in a different spot
|
||
in each view and were absent entirely on the board and in search. -->
|
||
<span aria-live="polite" class="flex min-w-0 shrink items-center gap-2 text-sm text-neutral-400">
|
||
<!-- The separator only makes sense next to the app name, which is itself
|
||
hidden on narrow screens. There, the lens name simply takes the space
|
||
the app name vacated — you already know which app you're in. -->
|
||
<span aria-hidden="true" class="hidden sm:inline">/</span>
|
||
<span class="truncate font-medium text-neutral-600 dark:text-neutral-300">{{ lensName }}</span>
|
||
</span>
|
||
|
||
<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>
|