Files
brain/web/components/quick-find.tsx
T
Andrew S Erwin 4e421f6ea2
release gate / check (push) Failing after 6m43s
Address code review findings across API, web and deployment
High severity:
- wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup
- bucket public rate limits on effective client IP, add per-bookmark budget,
  and forward the signed browser identity from the share page
- disable first-run bootstrap in server deployments and document seeding
- serialize last-admin demotion in one advisory-locked transaction

Medium/low:
- cap and batch share recipient inserts
- add a shared outbound budget for preview/CardDAV/IMAP/signal work
- pin CardDAV dials to 443 and bound IMAP logout
- fix rightward kanban column drags and normalize cards on column delete
- classify backend outages in auth guards, add request timeouts and an
  error boundary, remove the dead login route, surface throttling
- enforce pinned/unpinned page reorder grouping server-side
- avoid preview work for stale bookmark versions; drop duplicate dialog fetch
- sequence-guard collection/tag/calendar/quick-find loads
- validate property config and fix MULTI_SELECT/DATE filter inputs
- document index maintenance; apply same-origin check to sign-out
- trim signup names, unique shared dates and kanban keys (migration 010),
  reset reference cache on identity changes, hide OIDC issuer, return 413
  for oversized direct bodies, escape Markdown link destinations
- remove dead template files and duplicate test helper

Verified with go build/vet, full PostgreSQL race suite, db convergence,
tsc, both ESLint configs and a Next production build.
2026-10-20 00:13:30 -05:00

234 lines
9.1 KiB
TypeScript

'use client'
// Notion-style "Quick Find": a Cmd/Ctrl+K palette to jump to any page,
// bookmark, tag, or view, and to run the few workspace commands
// (new page, add bookmark, toggle theme). Search is read-only; mutations
// stay on their owning screens.
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTheme } from 'next-themes'
import { toast } from 'sonner'
import {
Calendar,
ExternalLink,
FilePlus2,
FileText,
Kanban,
Link2,
Moon,
Plus,
Search,
Settings,
Shield,
Sun,
Tags,
Users,
} from 'lucide-react'
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import { nextUntitledTitle, notifyPagesChanged, type PageSummary } from '@/lib/pages'
import type { TagWithCount } from '@/components/app-shell'
type BookmarkHit = { id: string; url: string; title: string | null; favicon: string | null; ogTitle: string | null }
const views = [
{ href: '/bookmarks', label: 'All Bookmarks', icon: Link2 },
{ href: '/calendar', label: 'Calendar', icon: Calendar },
{ href: '/tags', label: 'Tags', icon: Tags },
{ href: '/kanban', label: 'Kanban', icon: Kanban },
{ href: '/contacts', label: 'Contacts', icon: Users },
{ href: '/settings', label: 'Settings', icon: Settings },
]
const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u } }
interface QuickFindProps {
open: boolean
onOpenChange: (open: boolean) => void
isAdmin?: boolean
onAddBookmark?: () => void
}
export function QuickFind({ open, onOpenChange, isAdmin = false, onAddBookmark }: QuickFindProps) {
const router = useRouter()
const { theme, setTheme } = useTheme()
const [query, setQuery] = useState('')
const [pages, setPages] = useState<PageSummary[]>([])
const [tags, setTags] = useState<TagWithCount[]>([])
const [bookmarks, setBookmarks] = useState<BookmarkHit[]>([])
// Notion binds both Cmd/Ctrl+K and Cmd/Ctrl+P to Quick Find.
useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault()
onOpenChange(!open)
}
}
document.addEventListener('keydown', down)
return () => document.removeEventListener('keydown', down)
}, [open, onOpenChange])
// Load the workspace index each time the palette opens; results stay fresh
// without polling while it is closed.
useEffect(() => {
if (!open) return
fetch('/api/pages').then((r) => (r.ok ? r.json() : [])).then((d: PageSummary[]) => setPages(d ?? [])).catch(() => {})
fetch('/api/tags').then((r) => (r.ok ? r.json() : [])).then((d: TagWithCount[]) => setTags(d ?? [])).catch(() => {})
}, [open])
// Bookmark search hits the API (debounced) because the server matches
// notes and URLs too, not just titles. Review M-11: requests can still
// resolve out of order after the debounce fires, so only the newest
// sequence may publish results.
const searchSeq = useRef(0)
useEffect(() => {
if (!open) return
const q = query.trim()
const t = setTimeout(async () => {
if (q.length < 2) { searchSeq.current++; setBookmarks([]); return }
const seq = ++searchSeq.current
try {
const params = new URLSearchParams({ search: q, limit: '6' })
const res = await fetch(`/api/bookmarks?${params}`)
if (!res.ok || seq !== searchSeq.current) return
const rows = (await res.json()) as BookmarkHit[]
if (seq !== searchSeq.current) return
setBookmarks(rows.slice(0, 6))
} catch { /* transient; retried on next keystroke */ }
}, q.length < 2 ? 0 : 200)
return () => clearTimeout(t)
}, [query, open])
const go = useCallback((href: string) => {
onOpenChange(false)
setQuery('')
router.push(href)
}, [onOpenChange, router])
const createPage = useCallback(async () => {
onOpenChange(false)
setQuery('')
const title = nextUntitledTitle(pages.map((p) => p.title))
try {
const res = await fetch('/api/pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) })
if (!res.ok) { toast.error('Could not create page'); return }
const p = await res.json()
notifyPagesChanged()
router.push(`/pages?id=${p.id}`)
} catch { toast.error('Could not create page') }
}, [onOpenChange, pages, router])
return (
<CommandDialog open={open} onOpenChange={(next) => { onOpenChange(next); if (!next) setQuery('') }}>
<CommandInput
placeholder="Search pages, bookmarks, tags, and commands…"
value={query}
onValueChange={(v) => { setQuery(v); }}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Commands">
<CommandItem value="new page create" onSelect={createPage}>
<FilePlus2 className="mr-2 h-4 w-4 text-muted-foreground" />
New page
</CommandItem>
{onAddBookmark && (
<CommandItem value="add bookmark new link url" onSelect={() => { onOpenChange(false); setQuery(''); onAddBookmark() }}>
<Plus className="mr-2 h-4 w-4 text-muted-foreground" />
Add bookmark
</CommandItem>
)}
<CommandItem value="toggle theme dark light mode appearance" onSelect={() => { onOpenChange(false); setTheme(theme === 'dark' ? 'light' : 'dark') }}>
{theme === 'dark' ? <Sun className="mr-2 h-4 w-4 text-muted-foreground" /> : <Moon className="mr-2 h-4 w-4 text-muted-foreground" />}
Toggle dark mode
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Views">
{views.map((v) => (
<CommandItem key={v.href} value={`view go to ${v.label}`} onSelect={() => go(v.href)}>
<v.icon className="mr-2 h-4 w-4 text-muted-foreground" />
{v.label}
</CommandItem>
))}
{isAdmin && (
<CommandItem value="view go to admin administration" onSelect={() => go('/admin')}>
<Shield className="mr-2 h-4 w-4 text-muted-foreground" />
Admin
</CommandItem>
)}
</CommandGroup>
{pages.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Pages">
{pages.map((p) => (
<CommandItem key={p.id} value={`page ${p.title}`} onSelect={() => go(`/pages?id=${p.id}`)}>
<FileText className="mr-2 h-4 w-4 text-muted-foreground" />
<span className="truncate">{p.title}</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
{tags.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Tags">
{tags.map((t) => (
<CommandItem key={t.id} value={`tag ${t.name}`} onSelect={() => go(`/tags?tag=${t.id}`)}>
<Tags className="mr-2 h-4 w-4" style={{ color: t.color }} />
<span className="truncate">{t.name}</span>
<span className="ml-auto text-xs text-muted-foreground">{t._count?.bookmarks ?? 0}</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
{query.trim().length >= 2 && (
<>
<CommandSeparator />
<CommandGroup heading="Search">
<CommandItem value={`zz-search-bookmarks-for-${query}`} onSelect={() => go(`/bookmarks?search=${encodeURIComponent(query.trim())}`)}>
<Search className="mr-2 h-4 w-4 text-muted-foreground" />
Search bookmarks for “{query.trim()}”
</CommandItem>
</CommandGroup>
</>
)}
{bookmarks.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Bookmarks">
{bookmarks.map((b) => (
<CommandItem key={b.id} value={`bookmark ${b.title ?? ''} ${b.ogTitle ?? ''} ${b.url}`} onSelect={() => go(`/bookmarks?open=${b.id}`)}>
{b.favicon ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={b.favicon} alt="" referrerPolicy="no-referrer" className="mr-2 h-4 w-4 rounded" onError={(e) => { (e.target as HTMLImageElement).style.visibility = 'hidden' }} />
) : (
<Link2 className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate">{b.title || b.ogTitle || b.url}</span>
<span className="block truncate text-xs text-muted-foreground">{host(b.url)}</span>
</span>
<ExternalLink className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</CommandDialog>
)
}