M2 search: Postgres FTS backend + top search bar + results
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 31s

- Migration 0005: generated tsvector column (title A + body B) + GIN index on
  notes; GET /api/notes/search?q= (websearch_to_tsquery, ts_rank, ACL-scoped,
  excludes trash), labels merged into results.
- Persistent AppShell layout (parent route + <RouterView> children) so the new
  top search box keeps focus across board/search/label navigation.
- SearchView (debounced live search from the shell → /search?q=, results masonry,
  no-match empty state); BoardView/SearchView render inside the shared shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-19 22:03:19 -04:00
co-authored by Claude Opus 4.8
parent 2046600a95
commit ffc008bf4d
7 changed files with 216 additions and 67 deletions
+38 -6
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config";
@@ -14,6 +14,8 @@ const config = useConfigStore();
const labels = useLabelsStore();
const managing = ref(false);
const searchText = ref(typeof route.query.q === "string" ? route.query.q : "");
let searchTimer: ReturnType<typeof setTimeout> | undefined;
onMounted(() => {
if (!labels.loaded) void labels.load();
@@ -21,6 +23,24 @@ onMounted(() => {
const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));
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 = "";
},
);
async function signOut() {
await session.logout();
await router.replace("/login");
@@ -32,15 +52,27 @@ async function signOut() {
<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 justify-between gap-4 px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex items-center gap-3 px-4 py-3">
<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 items-center gap-3">
<span class="hidden text-sm text-neutral-500 sm:inline dark:text-neutral-400">{{
<div class="flex flex-1 justify-center">
<input
: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
@@ -98,7 +130,7 @@ async function signOut() {
</nav>
</aside>
<main class="min-w-0 flex-1"><slot /></main>
<main class="min-w-0 flex-1"><RouterView /></main>
</div>
<LabelsModal v-if="managing" @close="managing = false" />