'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, 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([]) const [tags, setTags] = useState([]) const [bookmarks, setBookmarks] = useState([]) // 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. useEffect(() => { if (!open) return const q = query.trim() const t = setTimeout(async () => { if (q.length < 2) { setBookmarks([]); return } try { const params = new URLSearchParams({ search: q, limit: '6' }) const res = await fetch(`/api/bookmarks?${params}`) if (res.ok) setBookmarks(((await res.json()) as BookmarkHit[]).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 ( { onOpenChange(next); if (!next) setQuery('') }}> { setQuery(v); }} /> No results found. New page {onAddBookmark && ( { onOpenChange(false); setQuery(''); onAddBookmark() }}> Add bookmark )} { onOpenChange(false); setTheme(theme === 'dark' ? 'light' : 'dark') }}> {theme === 'dark' ? : } Toggle dark mode {views.map((v) => ( go(v.href)}> {v.label} ))} {isAdmin && ( go('/admin')}> Admin )} {pages.length > 0 && ( <> {pages.map((p) => ( go(`/pages?id=${p.id}`)}> {p.title} ))} )} {tags.length > 0 && ( <> {tags.map((t) => ( go(`/tags?tag=${t.id}`)}> {t.name} {t._count?.bookmarks ?? 0} ))} )} {query.trim().length >= 2 && ( <> go(`/bookmarks?search=${encodeURIComponent(query.trim())}`)}> Search bookmarks for “{query.trim()}” )} {bookmarks.length > 0 && ( <> {bookmarks.map((b) => ( go(`/bookmarks?open=${b.id}`)}> {b.favicon ? ( // eslint-disable-next-line @next/next/no-img-element { (e.target as HTMLImageElement).style.visibility = 'hidden' }} /> ) : ( )} {b.title || b.ogTitle || b.url} {host(b.url)} ))} )} ) }