Files
thoughtsync/frontend/src/components/AppShell.vue
T
bvandeusen 7033995975
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
search is a facet on the board, not a place you go
Operator (note 2930): tags exist so you can *"filter during a search"*. The
server has always been able to do that — `GET /api/notes` composes `?q=` with
`?label=` and the rest into one AND-ed query. The frontend never reached it.

The header search box navigated to `/search`, and that view called a DIFFERENT
endpoint — `GET /api/notes/search?q=`, full text only, no facets at all. So the
one screen you landed on when you searched was the one screen where you could not
narrow by tag. Tag filtering lived on the board's FilterBar, which is where you
weren't searching. Two search boxes, two endpoints, and only the hidden one did
what tags are for.

Now the header box writes `?q=` into the board's URL beside whatever labels are
already there, and stays on the lens you're in — searching while looking at Trash
searches Trash. The box READS from the URL rather than holding its own copy, so
it stays in step with the Filters panel's Clear and with a saved view opened from
the sidebar.

Deleted: `SearchView.vue`, its route, `GET /api/notes/search`, `repo.notes.search`
and both adapter implementations, and the `notes_search` Tauri command whose only
caller was the adapter entry. FilterBar loses its own "Search text…" input — it
was the same facet, hidden behind a collapsed panel, duplicating a box that is
always on screen. Filters now does what its name says: narrowing. The header does
searching.

`core::store::search` STAYS. Android calls it through the FFI (`search_notes`) and
has its own search surface — which has the same no-tag-filter gap the web just
lost, and deserves the same fix on its own terms rather than as a rider here.
2026-08-23 10:58:23 -04:00

588 lines
23 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, type LocationQueryRaw } 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 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 === "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;
}
// The board lenses — the routes a search can happen *within*. Searching while looking
// at Trash should search Trash, not silently move you.
const BOARD_ROUTES = new Set(["board", "archive", "trash", "label"]);
/**
* Search is a FACET, not a destination.
*
* It used to navigate to a `/search` view backed by a different endpoint with no
* facets at all — so the one screen you landed on when you searched was the one
* screen where you could not also narrow by tag, which is precisely what tags are
* for (note 2930). Now it writes `?q=` into the board's URL, beside any labels
* already there, and the same AND-ed query serves both.
*
* Existing facets are preserved, so "filter by #grocery, then search" and the reverse
* both work.
*/
function onSearch(value: string) {
searchText.value = value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
const q = searchText.value.trim();
const onBoard = BOARD_ROUTES.has(String(route.name));
const query: LocationQueryRaw = onBoard ? { ...route.query } : {};
if (q) query.q = q;
else delete query.q;
void router.push({ path: onBoard ? route.path : "/", query });
}, 250);
}
// The URL is the filter state (see notes/facets.ts), so the box READS from it rather
// than holding its own copy — which is also what keeps it in step with the Filters
// panel's Clear button and with a saved view opened from the sidebar.
watch(
() => route.query.q,
(q) => {
searchText.value = typeof q === "string" ? q : "";
},
{ immediate: true },
);
watch(
() => route.name,
() => {
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 never had one)
* or drift in styling (timeline and reminders 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 "timeline":
return "Timeline";
case "reminders":
return "Reminders";
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>
<!-- `pt-[env(safe-area-inset-top)]`: with `viewport-fit=cover` the page draws
under the status bar / notch, and this is the one box that sits there a
sticky header pinned to y=0. Resolves to 0 wherever there is no cutout, so
it costs nothing on a desktop or a flat-topped phone. -->
<header
class="sticky top-0 z-20 border-b border-neutral-200 bg-neutral-50/90 pt-[env(safe-area-inset-top,0px)] backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/90"
>
<!-- Wraps, so the search field can take a line of its own on a narrow screen.
In one row it was sharing ~360px with a menu button, the logo, the lens
name and four icons, which left every one of them truncated the lens read
as "N…" and the search box as an empty pill. -->
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 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. -->
<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>
<!-- `order-last w-full` drops this onto its own line below sm (a full-width
flex item forces the wrap); from sm it returns to the middle of the row.
ONE input either way, moved by CSS rather than duplicated the `/`
shortcut focuses `searchInput`, and two of those would be one ref too
many. -->
<div class="order-last flex w-full justify-center sm:order-none sm:w-auto sm:flex-1">
<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-2 text-base outline-none focus-visible:ring-2 focus-visible:ring-brand sm:py-1.5 sm:text-sm dark:border-neutral-700 dark:bg-neutral-900"
@input="onSearch(($event.target as HTMLInputElement).value)"
/>
</div>
<div class="ml-auto flex shrink-0 items-center gap-1 sm:ml-0 sm: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 hidden sm:inline-flex"
title="Linked devices"
aria-label="Linked devices"
>
<Icon name="device" />
</RouterLink>
<RouterLink
v-if="session.user?.is_admin"
to="/settings"
class="icon-btn hidden sm:inline-flex"
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 hidden sm:inline-flex"
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 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-[calc(0.75rem+env(safe-area-inset-top,0px))] transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 sm:pb-3 sm:pt-3 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>
<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 />
<!-- Account, settings and sign-out, for the screens where they are NOT in
the header. Four icons plus a search field never fit one phone-width
row, and the header is the wrong place to lose: it holds the only way
back to the board. Here they get room to be named instead of guessed
at from a glyph.
Hidden from sm up, where the header carries them again so they are
in exactly one place at any width, never both. -->
<div
v-if="!desktopApp"
class="mt-3 flex flex-col gap-0.5 border-t border-neutral-200 pt-3 sm:hidden dark:border-neutral-800"
>
<p class="truncate px-3 pb-1 text-xs text-neutral-400">{{ session.user?.display_name }}</p>
<RouterLink to="/account" class="nav-link" :class="route.name === 'account' ? 'nav-link-active' : ''">
<Icon name="device" /> Linked devices
</RouterLink>
<RouterLink
v-if="session.user?.is_admin"
to="/settings"
class="nav-link"
:class="route.name === 'settings' ? 'nav-link-active' : ''"
>
<Icon name="settings" /> Settings
</RouterLink>
<button type="button" class="nav-link w-full text-left" @click="signOut">
<Icon name="logout" /> Sign out
</button>
</div>
</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 timeline reminders), 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>