mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat(search): implement search API with rate limiting and results fetching
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 “{query}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user