feat(search): implement search API with rate limiting and results fetching

This commit is contained in:
Yusuf İpek
2026-03-01 19:18:32 +03:00
parent 75535e8cda
commit 490e800e69
5 changed files with 500 additions and 3 deletions
+120
View File
@@ -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();
}
}
+6
View File
@@ -18,6 +18,12 @@ interface ShortcutGroup {
} }
const shortcutGroups: ShortcutGroup[] = [ const shortcutGroups: ShortcutGroup[] = [
{
title: 'Navigation',
shortcuts: [
{ keys: ['Ctrl', 'K'], description: 'Open search' },
],
},
{ {
title: 'Playback', title: 'Playback',
shortcuts: [ shortcuts: [
+45 -3
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
@@ -8,7 +8,6 @@ import {
Video, Video,
FolderOpen, FolderOpen,
Building2, Building2,
Settings, Settings,
LogOut, LogOut,
User, User,
@@ -16,6 +15,7 @@ import {
Keyboard, Keyboard,
LayoutDashboard, LayoutDashboard,
MessageSquareQuote, MessageSquareQuote,
Search,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -27,6 +27,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Sheet, SheetContent, SheetTrigger, SheetTitle, SheetDescription } from '@/components/ui/sheet'; 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 { ThemeToggle } from '@/components/theme-toggle';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -35,6 +36,11 @@ const KeyboardShortcutsModal = dynamic(
{ ssr: false } { ssr: false }
); );
const SearchModal = dynamic(
() => import('@/components/search-modal').then(mod => mod.SearchModal),
{ ssr: false }
);
interface NavItem { interface NavItem {
href: string; href: string;
label: string; label: string;
@@ -58,13 +64,26 @@ interface HeaderProps {
export function Header({ user }: HeaderProps) { export function Header({ user }: HeaderProps) {
const pathname = usePathname(); const pathname = usePathname();
const [shortcutsOpen, setShortcutsOpen] = useState(false); 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 // Hide header on video player pages — they use full viewport with their own back button
const isVideoPage = /\/videos\/[^/]+($|\/compare)/.test(pathname) || pathname.startsWith('/watch/'); const isVideoPage = /\/videos\/[^/]+($|\/compare)/.test(pathname) || pathname.startsWith('/watch/');
if (isVideoPage) return null; if (isVideoPage) return null;
return ( return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-[60] w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="px-4 md:px-6 lg:px-8 flex h-14 items-center w-full"> <div className="px-4 md:px-6 lg:px-8 flex h-14 items-center w-full">
{/* Mobile menu */} {/* Mobile menu */}
<Sheet> <Sheet>
@@ -152,6 +171,28 @@ export function Header({ user }: HeaderProps) {
{/* Right side */} {/* Right side */}
<div className="flex items-center gap-2 ml-auto"> <div className="flex items-center gap-2 ml-auto">
{user && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="Search"
onClick={() => setSearchOpen(true)}
>
<Search className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="flex items-center gap-1.5">
<span>Search</span>
<kbd className="inline-flex h-5 items-center rounded border border-background/30 bg-background/20 px-1 font-mono text-[10px] text-background">
Ctrl K
</kbd>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{user && ( {user && (
<Button asChild variant="outline" size="sm" className="hidden sm:inline-flex"> <Button asChild variant="outline" size="sm" className="hidden sm:inline-flex">
<Link href="/feedback"> <Link href="/feedback">
@@ -232,6 +273,7 @@ export function Header({ user }: HeaderProps) {
</div> </div>
</div> </div>
<KeyboardShortcutsModal open={shortcutsOpen} onOpenChange={setShortcutsOpen} /> <KeyboardShortcutsModal open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
{user && <SearchModal open={searchOpen} onOpenChange={setSearchOpen} />}
</header> </header>
); );
} }
+326
View File
@@ -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<ResultItem['kind'], string> = {
project: 'Projects',
workspace: 'Workspaces',
video: 'Videos',
};
const CategoryIcon: Record<ResultItem['kind'], React.ComponentType<{ className?: string }>> = {
project: FolderOpen,
workspace: Building2,
video: Video,
};
export function SearchModal({ open, onOpenChange }: SearchModalProps) {
const router = useRouter();
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResults | null>(null);
const [loading, setLoading] = useState(false);
const [activeIdx, setActiveIdx] = useState(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(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<HTMLInputElement>) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-3xl p-0 gap-0 overflow-hidden rounded-xl"
showCloseButton={false}
onKeyDown={handleKeyDown}
>
<VisuallyHidden>
<DialogTitle>Search</DialogTitle>
</VisuallyHidden>
{/* Single flex-col wrapper keeps the grid from producing a stray gap row */}
<div className="flex flex-col">
{/* Search input */}
<div className="flex items-center border-b px-4">
<Search className="h-5 w-5 shrink-0 text-muted-foreground mr-3" />
<Input
ref={inputRef}
value={query}
onChange={handleChange}
placeholder="Search projects, workspaces, videos…"
className="border-0 bg-transparent dark:bg-transparent shadow-none focus-visible:ring-0 h-14 text-base px-0"
/>
{loading && <Loader2 className="h-4 w-4 shrink-0 text-muted-foreground animate-spin ml-3" />}
</div>
{/* Results */}
<div className="h-[520px] overflow-y-auto">
{showInitial && (
<p className="text-sm text-muted-foreground text-center py-8 px-4">
Type at least 2 characters to search.
</p>
)}
{isEmpty && (
<p className="text-sm text-muted-foreground text-center py-8 px-4">
No results for &ldquo;{query}&rdquo;
</p>
)}
{sections.map(({ kind, items, startIdx }) => {
const Icon = CategoryIcon[kind];
return (
<div key={kind}>
<div className="px-3 pt-3 pb-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{CATEGORY_LABELS[kind]}
</span>
</div>
{items.map((item, localIdx) => {
const globalIdx = startIdx + localIdx;
const label = getItemLabel(item);
const sub = getItemSub(item);
const isActive = globalIdx === activeIdx;
return (
<button
key={`${kind}-${item.kind === 'project' ? item.data.id : item.kind === 'workspace' ? item.data.id : item.data.id}`}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'
)}
onMouseEnter={() => setActiveIdx(globalIdx)}
onClick={() => handleSelect(item)}
>
<Icon className={cn('h-4 w-4 shrink-0', isActive ? 'text-accent-foreground/70' : 'text-muted-foreground')} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{label}</p>
{sub && (
<p className={cn('text-xs truncate', isActive ? 'text-accent-foreground/60' : 'text-muted-foreground')}>{sub}</p>
)}
</div>
</button>
);
})}
</div>
);
})}
{sections.length > 0 && (
<div className="h-2" />
)}
</div>
{/* Footer hint */}
<div className="border-t px-4 py-2.5 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<kbd className="inline-flex h-5 items-center rounded border border-border bg-muted px-1 font-mono text-[10px]"></kbd>
<kbd className="inline-flex h-5 items-center rounded border border-border bg-muted px-1 font-mono text-[10px]"></kbd>
navigate
</span>
<span className="flex items-center gap-1.5">
<kbd className="inline-flex h-5 items-center rounded border border-border bg-muted px-1 font-mono text-[10px]"></kbd>
open
</span>
<span className="flex items-center gap-1.5">
<kbd className="inline-flex h-5 items-center rounded border border-border bg-muted px-1 font-mono text-[10px]">Esc</kbd>
close
</span>
</div>
</div>{/* end flex-col wrapper */}
</DialogContent>
</Dialog>
);
}
+3
View File
@@ -42,6 +42,9 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute 'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
'asset-bunny-init': { 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 — allow frequent updates but prevent abuse
'watch-progress': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute (pausing + periodic + visibility changes) 'watch-progress': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute (pausing + periodic + visibility changes)