M2 search: Postgres FTS backend + top search bar + results
- 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:
@@ -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" />
|
||||
|
||||
@@ -6,28 +6,17 @@ const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
// Persistent authed shell (sidebar + top bar + search); children render in it.
|
||||
path: "/",
|
||||
name: "board",
|
||||
component: () => import("../views/BoardView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/archive",
|
||||
name: "archive",
|
||||
component: () => import("../views/BoardView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/trash",
|
||||
name: "trash",
|
||||
component: () => import("../views/BoardView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/label/:id",
|
||||
name: "label",
|
||||
component: () => import("../views/BoardView.vue"),
|
||||
component: () => import("../components/AppShell.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: "", name: "board", component: () => import("../views/BoardView.vue") },
|
||||
{ path: "archive", name: "archive", component: () => import("../views/BoardView.vue") },
|
||||
{ path: "trash", name: "trash", component: () => import("../views/BoardView.vue") },
|
||||
{ path: "label/:id", name: "label", component: () => import("../views/BoardView.vue") },
|
||||
{ path: "search", name: "search", component: () => import("../views/SearchView.vue") },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
@@ -63,8 +52,6 @@ router.beforeEach(async (to) => {
|
||||
if (to.meta.requiresAdmin && !session.user?.is_admin) {
|
||||
return { name: "board" };
|
||||
}
|
||||
// Registration closed: keep people out of the register screen (the very first
|
||||
// account is still creatable because allow_registration defaults to true).
|
||||
if (to.name === "register" && !config.allowRegistration) {
|
||||
return { name: "login" };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
|
||||
import AppShell from "../components/AppShell.vue";
|
||||
import QuickAdd from "../components/QuickAdd.vue";
|
||||
import NoteCard from "../components/NoteCard.vue";
|
||||
import NoteEditor from "../components/NoteEditor.vue";
|
||||
@@ -50,46 +49,41 @@ function closeEditor() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="mx-auto w-full max-w-6xl px-4 py-6">
|
||||
<QuickAdd v-if="isMainBoard" autofocus class="mb-8" />
|
||||
<div class="mx-auto w-full max-w-6xl px-4 py-6">
|
||||
<QuickAdd v-if="isMainBoard" autofocus class="mb-8" />
|
||||
|
||||
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading…</div>
|
||||
<div v-if="notes.loading" class="py-24 text-center text-sm text-neutral-400">Loading…</div>
|
||||
|
||||
<div v-else-if="notes.items.length === 0" class="py-24 text-center">
|
||||
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">{{ emptyState.title }}</h2>
|
||||
<p class="mt-1 text-sm text-neutral-400">{{ emptyState.subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<template v-if="isMainBoard">
|
||||
<section v-if="pinnedNotes.length">
|
||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
|
||||
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in pinnedNotes" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
|
||||
<h2
|
||||
v-if="pinnedNotes.length"
|
||||
class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400"
|
||||
>
|
||||
Others
|
||||
</h2>
|
||||
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in otherNotes" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in notes.items" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="notes.items.length === 0" class="py-24 text-center">
|
||||
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">{{ emptyState.title }}</h2>
|
||||
<p class="mt-1 text-sm text-neutral-400">{{ emptyState.subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<template v-if="editing">
|
||||
<NoteEditor :note="editing" @close="closeEditor" />
|
||||
<template v-else>
|
||||
<template v-if="isMainBoard">
|
||||
<section v-if="pinnedNotes.length">
|
||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
|
||||
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in pinnedNotes" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
|
||||
<h2 v-if="pinnedNotes.length" class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
Others
|
||||
</h2>
|
||||
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in otherNotes" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in notes.items" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
</template>
|
||||
</AppShell>
|
||||
</div>
|
||||
|
||||
<template v-if="editing">
|
||||
<NoteEditor :note="editing" @close="closeEditor" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { api } from "../api/client";
|
||||
import type { Note } from "../stores/notes";
|
||||
import NoteCard from "../components/NoteCard.vue";
|
||||
import NoteEditor from "../components/NoteEditor.vue";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const results = ref<Note[]>([]);
|
||||
const loading = ref(false);
|
||||
const editing = ref<Note | null>(null);
|
||||
|
||||
const query = computed(() => (typeof route.query.q === "string" ? route.query.q : ""));
|
||||
|
||||
async function run() {
|
||||
const q = query.value.trim();
|
||||
if (!q) {
|
||||
results.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api.get<{ notes: Note[] }>(`/api/notes/search?q=${encodeURIComponent(q)}`);
|
||||
results.value = res.notes;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(query, run, { immediate: true });
|
||||
|
||||
function openEditor(n: Note) {
|
||||
editing.value = n;
|
||||
}
|
||||
async function closeEditor() {
|
||||
editing.value = null;
|
||||
await run(); // reflect any edits made from a result
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-6xl px-4 py-6">
|
||||
<p class="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<template v-if="query"
|
||||
>Results for <span class="font-semibold text-neutral-800 dark:text-neutral-200">{{ query }}</span></template
|
||||
>
|
||||
<template v-else>Type in the search box to find your notes.</template>
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="py-20 text-center text-sm text-neutral-400">Searching…</div>
|
||||
|
||||
<div v-else-if="query && results.length === 0" class="py-20 text-center">
|
||||
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">No matches</h2>
|
||||
<p class="mt-1 text-sm text-neutral-400">Nothing found for "{{ query }}".</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
|
||||
<NoteCard v-for="n in results" :key="n.id" :note="n" @open="openEditor" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="editing">
|
||||
<NoteEditor :note="editing" @close="closeEditor" />
|
||||
</template>
|
||||
</template>
|
||||
Reference in New Issue
Block a user