diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..60eae12 --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,120 @@ +import { NextRequest } from 'next/server'; +import { db } from '@/lib/db'; +import { auth } from '@/lib/auth'; +import { apiErrors, successResponse } from '@/lib/api-response'; +import { checkRateLimit, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit'; + +const MAX_Q_LENGTH = 100; +const RESULTS_PER_CATEGORY = 5; + +// GET /api/search?q=term — search projects, workspaces, and videos accessible to the user +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return apiErrors.unauthorized(); + } + + const userId = session.user.id; + + const cfg = RATE_LIMIT_CONFIGS['search']; + const rl = await checkRateLimit(userId, 'search', cfg); + if (!rl.allowed) { + return new Response( + JSON.stringify({ error: 'Too many requests. Please try again later.' }), + { status: 429, headers: { 'Content-Type': 'application/json', ...rateLimitHeaders(rl, cfg.maxRequests) } } + ); + } + + const { searchParams } = new URL(request.url); + const term = (searchParams.get('q') ?? '').trim(); + + if (term.length < 2) { + return successResponse({ projects: [], workspaces: [], videos: [] }); + } + + if (term.length > MAX_Q_LENGTH) { + return apiErrors.badRequest('Query too long.'); + } + + // Access filter reused across queries + const projectAccessFilter = { + OR: [ + { ownerId: userId }, + { members: { some: { userId } } }, + { workspace: { members: { some: { userId } } } }, + ], + }; + + const workspaceAccessFilter = { + OR: [ + { ownerId: userId }, + { members: { some: { userId } } }, + ], + }; + + const [projects, workspaces, videos] = await Promise.all([ + db.project.findMany({ + where: { + AND: [ + projectAccessFilter, + { + OR: [ + { name: { contains: term, mode: 'insensitive' } }, + { description: { contains: term, mode: 'insensitive' } }, + ], + }, + ], + }, + select: { + id: true, + name: true, + description: true, + workspace: { select: { id: true, name: true } }, + }, + take: RESULTS_PER_CATEGORY, + }), + + db.workspace.findMany({ + where: { + AND: [ + workspaceAccessFilter, + { + OR: [ + { name: { contains: term, mode: 'insensitive' } }, + { description: { contains: term, mode: 'insensitive' } }, + ], + }, + ], + }, + select: { + id: true, + name: true, + description: true, + }, + take: RESULTS_PER_CATEGORY, + }), + + db.video.findMany({ + where: { + project: projectAccessFilter, + title: { contains: term, mode: 'insensitive' }, + }, + select: { + id: true, + title: true, + projectId: true, + project: { select: { id: true, name: true } }, + }, + take: RESULTS_PER_CATEGORY, + }), + ]); + + const response = successResponse({ projects, workspaces, videos }); + response.headers.set('Cache-Control', 'private, no-store'); + return response; + } catch (err) { + console.error('[search] error:', err); + return apiErrors.internalError(); + } +} diff --git a/components/keyboard-shortcuts-modal.tsx b/components/keyboard-shortcuts-modal.tsx index 546faca..49192ee 100644 --- a/components/keyboard-shortcuts-modal.tsx +++ b/components/keyboard-shortcuts-modal.tsx @@ -18,6 +18,12 @@ interface ShortcutGroup { } const shortcutGroups: ShortcutGroup[] = [ + { + title: 'Navigation', + shortcuts: [ + { keys: ['Ctrl', 'K'], description: 'Open search' }, + ], + }, { title: 'Playback', shortcuts: [ diff --git a/components/layout/header.tsx b/components/layout/header.tsx index d7ffc28..c5dcc18 100644 --- a/components/layout/header.tsx +++ b/components/layout/header.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import Link from 'next/link'; import dynamic from 'next/dynamic'; import { usePathname } from 'next/navigation'; @@ -8,7 +8,6 @@ import { Video, FolderOpen, Building2, - Settings, LogOut, User, @@ -16,6 +15,7 @@ import { Keyboard, LayoutDashboard, MessageSquareQuote, + Search, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -27,6 +27,7 @@ import { } from '@/components/ui/dropdown-menu'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Sheet, SheetContent, SheetTrigger, SheetTitle, SheetDescription } from '@/components/ui/sheet'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { ThemeToggle } from '@/components/theme-toggle'; import { cn } from '@/lib/utils'; @@ -35,6 +36,11 @@ const KeyboardShortcutsModal = dynamic( { ssr: false } ); +const SearchModal = dynamic( + () => import('@/components/search-modal').then(mod => mod.SearchModal), + { ssr: false } +); + interface NavItem { href: string; label: string; @@ -58,13 +64,26 @@ interface HeaderProps { export function Header({ user }: HeaderProps) { const pathname = usePathname(); const [shortcutsOpen, setShortcutsOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); + + // Global Ctrl+K / Cmd+K listener + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + if (user) setSearchOpen((v) => !v); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [user]); // Hide header on video player pages — they use full viewport with their own back button const isVideoPage = /\/videos\/[^/]+($|\/compare)/.test(pathname) || pathname.startsWith('/watch/'); if (isVideoPage) return null; return ( -
+
{/* Mobile menu */} @@ -152,6 +171,28 @@ export function Header({ user }: HeaderProps) { {/* Right side */}
+ {user && ( + + + + + + + Search + + Ctrl K + + + + + )} {user && (
+ {user && }
); } diff --git a/components/search-modal.tsx b/components/search-modal.tsx new file mode 100644 index 0000000..d2437db --- /dev/null +++ b/components/search-modal.tsx @@ -0,0 +1,326 @@ +'use client'; + +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; +import { Input } from '@/components/ui/input'; +import { Search, FolderOpen, Building2, Video, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface ProjectResult { + id: string; + name: string; + description: string | null; + workspace: { id: string; name: string }; +} + +interface WorkspaceResult { + id: string; + name: string; + description: string | null; +} + +interface VideoResult { + id: string; + title: string; + projectId: string; + project: { id: string; name: string }; +} + +interface SearchResults { + projects: ProjectResult[]; + workspaces: WorkspaceResult[]; + videos: VideoResult[]; +} + +type ResultItem = + | { kind: 'project'; data: ProjectResult } + | { kind: 'workspace'; data: WorkspaceResult } + | { kind: 'video'; data: VideoResult }; + +interface SearchModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function scoreMatch(name: string, term: string): number { + const n = name.toLowerCase(); + const t = term.toLowerCase(); + if (n === t) return 3; + if (n.startsWith(t)) return 2; + return 1; +} + +function buildFlatList(results: SearchResults, term: string): ResultItem[] { + const projects: ResultItem[] = results.projects + .map((p) => ({ kind: 'project' as const, data: p, score: scoreMatch(p.name, term) })) + .sort((a, b) => b.score - a.score) + .map(({ kind, data }) => ({ kind, data })); + + const workspaces: ResultItem[] = results.workspaces + .map((w) => ({ kind: 'workspace' as const, data: w, score: scoreMatch(w.name, term) })) + .sort((a, b) => b.score - a.score) + .map(({ kind, data }) => ({ kind, data })); + + const videos: ResultItem[] = results.videos + .map((v) => ({ kind: 'video' as const, data: v, score: scoreMatch(v.title, term) })) + .sort((a, b) => b.score - a.score) + .map(({ kind, data }) => ({ kind, data })); + + return [...projects, ...workspaces, ...videos]; +} + +function getItemHref(item: ResultItem): string { + if (item.kind === 'project') return `/dashboard?project=${item.data.id}`; + if (item.kind === 'workspace') return `/workspaces/${item.data.id}`; + return `/projects/${item.data.projectId}/videos/${item.data.id}`; +} + +function getItemLabel(item: ResultItem): string { + if (item.kind === 'project') return item.data.name; + if (item.kind === 'workspace') return item.data.name; + return item.data.title; +} + +function getItemSub(item: ResultItem): string | null { + if (item.kind === 'project') return item.data.workspace.name; + if (item.kind === 'workspace') return item.data.description ?? null; + return item.data.project.name; +} + +const CATEGORY_LABELS: Record = { + project: 'Projects', + workspace: 'Workspaces', + video: 'Videos', +}; + +const CategoryIcon: Record> = { + project: FolderOpen, + workspace: Building2, + video: Video, +}; + +export function SearchModal({ open, onOpenChange }: SearchModalProps) { + const router = useRouter(); + const [query, setQuery] = useState(''); + const [results, setResults] = useState(null); + const [loading, setLoading] = useState(false); + const [activeIdx, setActiveIdx] = useState(0); + const debounceRef = useRef | null>(null); + const inputRef = useRef(null); + const abortRef = useRef(null); + + // Reset when modal opens/closes + useEffect(() => { + if (!open) { + setQuery(''); + setResults(null); + setActiveIdx(0); + } else { + setTimeout(() => inputRef.current?.focus(), 0); + } + }, [open]); + + const doSearch = useCallback(async (term: string) => { + if (term.length < 2) { + setResults(null); + setLoading(false); + return; + } + + // Cancel previous request + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setLoading(true); + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(term)}`, { + signal: controller.signal, + }); + if (!res.ok) throw new Error('Search failed'); + const json = await res.json(); + setResults(json.data); + setActiveIdx(0); + } catch (err: unknown) { + if (err instanceof Error && err.name !== 'AbortError') { + setResults(null); + } + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }, []); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const val = e.target.value; + setQuery(val); + + if (debounceRef.current) clearTimeout(debounceRef.current); + + if (val.trim().length < 2) { + abortRef.current?.abort(); + setResults(null); + setLoading(false); + return; + } + + setLoading(true); + debounceRef.current = setTimeout(() => doSearch(val.trim()), 300); + }, + [doSearch] + ); + + const flatList = useMemo( + () => (results ? buildFlatList(results, query.trim()) : []), + [results, query] + ); + + const handleSelect = useCallback( + (item: ResultItem) => { + onOpenChange(false); + router.push(getItemHref(item)); + }, + [onOpenChange, router] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (flatList.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIdx((i) => Math.min(i + 1, flatList.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIdx((i) => Math.max(i - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const item = flatList[activeIdx]; + if (item) handleSelect(item); + } + }, + [flatList, activeIdx, handleSelect] + ); + + const isEmpty = !loading && results !== null && flatList.length === 0; + const showInitial = !loading && results === null && query.length < 2; + + // Group flat list by category for section headers + const sections: { kind: ResultItem['kind']; items: ResultItem[]; startIdx: number }[] = []; + let cursor = 0; + for (const kind of ['project', 'workspace', 'video'] as const) { + const items = flatList.filter((i) => i.kind === kind); + if (items.length > 0) { + sections.push({ kind, items, startIdx: cursor }); + cursor += items.length; + } + } + + return ( + + + + Search + + {/* Single flex-col wrapper keeps the grid from producing a stray gap row */} +
+ + {/* Search input */} +
+ + + {loading && } +
+ + {/* Results */} +
+ {showInitial && ( +

+ Type at least 2 characters to search. +

+ )} + + {isEmpty && ( +

+ No results for “{query}” +

+ )} + + {sections.map(({ kind, items, startIdx }) => { + const Icon = CategoryIcon[kind]; + return ( +
+
+ + {CATEGORY_LABELS[kind]} + +
+ {items.map((item, localIdx) => { + const globalIdx = startIdx + localIdx; + const label = getItemLabel(item); + const sub = getItemSub(item); + const isActive = globalIdx === activeIdx; + + return ( + + ); + })} +
+ ); + })} + + {sections.length > 0 && ( +
+ )} +
+ + {/* Footer hint */} +
+ + + + navigate + + + + open + + + Esc + close + +
+ +
{/* end flex-col wrapper */} + +
+ ); +} diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 4da30af..2b2109c 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -42,6 +42,9 @@ export const RATE_LIMIT_CONFIGS: Record = { 'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute 'asset-bunny-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute + // Search — debounced on client but protect against scripted callers + 'search': { windowMs: 60 * 1000, maxRequests: 60 }, // 60 per minute + // Watch progress — allow frequent updates but prevent abuse 'watch-progress': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute (pausing + periodic + visibility changes)