Skip to content

Commit f95c9a7

Browse files
feat: full-text search across pages (#30)
Co-authored-by: Ona <no-reply@ona.com>
1 parent 42cb0c4 commit f95c9a7

6 files changed

Lines changed: 515 additions & 3 deletions

File tree

.agents/architecture.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ workspaces
4646
├── parent_id → pages.id (nullable, enables nesting)
4747
├── content: jsonb (Lexical editor state — NOT a separate blocks table)
4848
├── position: integer (ordering among siblings)
49-
└── created_by → profiles.id
49+
├── created_by → profiles.id
50+
└── search_vector: tsvector (generated, title weight A + content text weight B, GIN indexed)
5051
5152
Sign-up flow (atomic, via DB trigger):
5253
1. auth.users row created by Supabase Auth
@@ -70,7 +71,7 @@ Sign-up flow (atomic, via DB trigger):
7071
| Session management | Next.js 16 proxy (not middleware) | `src/proxy.ts` with `updateSession` — Next.js 16 convention replacing middleware |
7172
| Floating UI | `@floating-ui/react` | Positioning for slash command menu, floating toolbar, link editor (same as Lexical playground) |
7273
| Image storage | Supabase Storage | Bucket for uploaded images, public URL stored in ImageNode |
73-
| Full-text search | PostgreSQL `tsvector` + `tsquery` | Generated column or trigger on page title + extracted content text |
74+
| Full-text search | PostgreSQL `tsvector` + `tsquery` | Generated column on pages combining title (weight A) + extracted content text (weight B), GIN index, `search_pages` RPC |
7475

7576
## Lexical Editor — Implementation Plan
7677

@@ -158,7 +159,8 @@ src/
158159
│ │ ├── page.tsx # /[workspaceSlug] — workspace home (page list or empty state)
159160
│ │ └── [pageId]/page.tsx # /[workspaceSlug]/[pageId] — page with title + editor placeholder
160161
│ └── api/
161-
│ └── health/route.ts # Health check endpoint (DB connectivity)
162+
│ ├── health/route.ts # Health check endpoint (DB connectivity)
163+
│ └── search/route.ts # Full-text search (GET ?q=&workspace_id=) → calls search_pages RPC
162164
├── components/
163165
│ ├── auth/
164166
│ │ ├── oauth-buttons.tsx # GitHub + Google buttons (disabled, "coming soon" tooltip)
@@ -169,6 +171,7 @@ src/
169171
│ │ ├── sidebar-context.tsx # React context for sidebar open/close state + ⌘+\ shortcut
170172
│ │ ├── workspace-switcher.tsx # Dropdown listing all workspaces, create workspace trigger
171173
│ │ ├── create-workspace-dialog.tsx # Dialog for creating a new workspace
174+
│ │ ├── page-search.tsx # Full-text search input + results dropdown (debounced, 300ms)
172175
│ │ ├── page-tree.tsx # Hierarchical page tree with CRUD, drag-and-drop, nest/unnest
173176
│ │ └── user-menu.tsx # User dropdown with settings link + sign-out
174177
│ ├── page-title.tsx # Inline-editable page title (saves on blur/Enter)

.agents/conventions.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,27 @@ await supabase.auth.signUp({
404404
});
405405
```
406406

407+
## Supabase RPC (database functions)
408+
409+
When a query requires features not available through the Supabase query builder
410+
(e.g., `ts_rank`, `ts_headline`, complex joins with computed columns), create a
411+
PostgreSQL function and call it via `supabase.rpc()`.
412+
413+
```typescript
414+
// Calling an RPC function from a route handler
415+
const { data, error } = await supabase.rpc("search_pages", {
416+
query: "search term",
417+
ws_id: workspaceId,
418+
result_limit: 20,
419+
});
420+
```
421+
422+
Rules:
423+
- Define the function in a migration with `security definer` and `set search_path = ''`.
424+
- Use `stable` for read-only functions, `volatile` for mutations.
425+
- Keep the function focused — one purpose per function.
426+
- Return a `table(...)` type for multi-row results so the client gets typed arrays.
427+
407428
## This file evolves
408429

409430
When you discover a new pattern that should be replicated, or an anti-pattern that

src/app/api/search/route.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { NextResponse, type NextRequest } from "next/server";
2+
import { createClient } from "@/lib/supabase/server";
3+
4+
export async function GET(request: NextRequest) {
5+
if (
6+
!process.env.NEXT_PUBLIC_SUPABASE_URL ||
7+
!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
8+
) {
9+
return NextResponse.json(
10+
{ error: "Supabase not configured" },
11+
{ status: 503 }
12+
);
13+
}
14+
15+
const { searchParams } = request.nextUrl;
16+
const query = searchParams.get("q")?.trim();
17+
const workspaceId = searchParams.get("workspace_id");
18+
19+
if (!query || !workspaceId) {
20+
return NextResponse.json(
21+
{ error: "Missing required parameters: q, workspace_id" },
22+
{ status: 400 }
23+
);
24+
}
25+
26+
if (query.length > 200) {
27+
return NextResponse.json(
28+
{ error: "Query too long (max 200 characters)" },
29+
{ status: 400 }
30+
);
31+
}
32+
33+
try {
34+
const supabase = await createClient();
35+
36+
const { data: user } = await supabase.auth.getUser();
37+
if (!user.user) {
38+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
39+
}
40+
41+
const { data, error } = await supabase.rpc("search_pages", {
42+
query,
43+
ws_id: workspaceId,
44+
result_limit: 20,
45+
});
46+
47+
if (error) {
48+
return NextResponse.json(
49+
{ error: "Search failed" },
50+
{ status: 500 }
51+
);
52+
}
53+
54+
return NextResponse.json({ results: data ?? [] });
55+
} catch {
56+
return NextResponse.json(
57+
{ error: "Internal server error" },
58+
{ status: 500 }
59+
);
60+
}
61+
}

src/components/sidebar/app-sidebar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "@/components/ui/sheet";
1111
import { useSidebar } from "@/components/sidebar/sidebar-context";
1212
import { WorkspaceSwitcher } from "@/components/sidebar/workspace-switcher";
13+
import { PageSearch } from "@/components/sidebar/page-search";
1314
import { PageTree } from "@/components/sidebar/page-tree";
1415
import { UserMenu } from "@/components/sidebar/user-menu";
1516

@@ -28,6 +29,8 @@ function SidebarContent({
2829
<div className="flex h-full flex-col gap-2 p-2">
2930
<WorkspaceSwitcher userId={userId} />
3031
<Separator className="bg-white/[0.06]" />
32+
<PageSearch />
33+
<Separator className="bg-white/[0.06]" />
3134
<PageTree userId={userId} />
3235
<Separator className="bg-white/[0.06]" />
3336
<UserMenu displayName={displayName} email={email} />

0 commit comments

Comments
 (0)