gitdataai/src/app/search/page.tsx
2026-04-15 09:08:09 +08:00

365 lines
15 KiB
TypeScript

import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
FolderGit2,
GitPullRequest,
Hexagon,
Search,
Users,
Loader2,
} from 'lucide-react';
import { client } from '@/client/client.gen';
import { search } from '@/client/sdk.gen';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { formatDistanceToNow, parseISO } from 'date-fns';
import type {
ProjectSearchItem,
RepoSearchItem,
IssueSearchItem,
UserSearchItem,
SearchResponse,
} from '@/client/types.gen';
// ─── Helpers ──────────────────────────────────────────────────────────────────
const ALL_TYPES = ['projects', 'repos', 'issues', 'users'] as const;
type SearchType = typeof ALL_TYPES[number];
const TYPE_LABELS: Record<SearchType, string> = {
projects: 'Projects',
repos: 'Repositories',
issues: 'Issues',
users: 'Users',
};
const TYPE_ICONS: Record<SearchType, React.ComponentType<{ className?: string }>> = {
projects: Hexagon,
repos: FolderGit2,
issues: GitPullRequest,
users: Users,
};
function getTotal(results: SearchResponse): number {
return [results.projects, results.repos, results.issues, results.users]
.filter(Boolean)
.reduce((sum, r) => sum + (r?.total ?? 0), 0);
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function ProjectItem({ item }: { item: ProjectSearchItem }) {
return (
<a
href={`/project/${item.name}`}
className="flex items-start gap-3 rounded-md p-3 hover:bg-muted/50 transition-colors -mx-3"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-semibold text-sm">
{item.display_name?.charAt(0)?.toUpperCase() ?? 'P'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm hover:underline">{item.display_name || item.name}</span>
<span className="text-xs text-muted-foreground truncate">{item.name}</span>
<Badge variant={item.is_public ? 'default' : 'secondary'} className="text-xs shrink-0">
{item.is_public ? 'Public' : 'Private'}
</Badge>
</div>
{item.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{item.description}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Updated {formatDistanceToNow(parseISO(item.updated_at), { addSuffix: true })}
</p>
</div>
</a>
);
}
function RepoItem({ item }: { item: RepoSearchItem }) {
return (
<a
href={`/repository/${item.project_name}/${item.name}`}
className="flex items-start gap-3 rounded-md p-3 hover:bg-muted/50 transition-colors -mx-3"
>
<FolderGit2 className="h-9 w-9 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm hover:underline">{item.name}</span>
<span className="text-xs text-muted-foreground truncate">{item.project_name}</span>
<Badge variant={item.is_private ? 'secondary' : 'outline'} className="text-xs shrink-0">
{item.is_private ? 'Private' : 'Public'}
</Badge>
</div>
{item.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{item.description}</p>
)}
</div>
</a>
);
}
function IssueItem({ item }: { item: IssueSearchItem }) {
return (
<a
href={`/project/${item.project_name}/issues/${item.number}`}
className="flex items-start gap-3 rounded-md p-3 hover:bg-muted/50 transition-colors -mx-3"
>
<GitPullRequest className="h-9 w-9 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm hover:underline">{item.title}</span>
<span className="text-xs text-muted-foreground shrink-0">#{item.number}</span>
<Badge
variant={item.state === 'open' ? 'default' : 'secondary'}
className={cn('text-xs shrink-0', item.state === 'open' ? 'bg-green-500/20 text-green-700 dark:bg-green-500/30 dark:text-green-400 border-green-500/30' : '')}
>
{item.state}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{item.project_name} · Updated {formatDistanceToNow(parseISO(item.updated_at), { addSuffix: true })}
</p>
</div>
</a>
);
}
function UserItem({ item }: { item: UserSearchItem }) {
return (
<a
href={`/user/${item.username}`}
className="flex items-start gap-3 rounded-md p-3 hover:bg-muted/50 transition-colors -mx-3"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-semibold text-sm">
{item.display_name?.charAt(0)?.toUpperCase() ?? item.username.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm hover:underline">{item.display_name || item.username}</span>
<span className="text-xs text-muted-foreground">@{item.username}</span>
</div>
{item.organization && (
<p className="text-xs text-muted-foreground mt-0.5">{item.organization}</p>
)}
</div>
</a>
);
}
function ResultSection<T>({
type,
result,
renderer,
}: {
type: SearchType;
result: { items: T[]; total: number; page: number; per_page: number } | null;
renderer: (item: T) => React.ReactNode;
}) {
const Icon = TYPE_ICONS[type];
const label = TYPE_LABELS[type];
if (!result || result.items.length === 0) return null;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
<Icon className="h-4 w-4" />
{label}
<Badge variant="secondary" className="ml-auto text-xs">{result.total}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="divide-y">
{result.items.map((item) => (
<div key={(item as { uid?: string; number?: number }).uid ?? (item as { number?: number }).number}>
{renderer(item)}
</div>
))}
{result.items.length < result.total && (
<p className="px-3 py-2 text-xs text-muted-foreground text-center">
Showing {result.items.length} of {result.total} {label.toLowerCase()}
</p>
)}
</CardContent>
</Card>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const q = searchParams.get('q') ?? '';
const typeParam = searchParams.get('type') ?? '';
const page = parseInt(searchParams.get('page') ?? '1', 10);
const perPage = 20;
// Parse active types from query param
const activeTypes: SearchType[] = typeParam
? (typeParam.split(',').filter((t): t is SearchType => ALL_TYPES.includes(t as SearchType)))
: [...ALL_TYPES];
const { data, isLoading, error } = useQuery({
queryKey: ['search', q, typeParam, page],
queryFn: async () => {
const resp = await search({
client,
query: {
q,
page,
per_page: perPage,
...(typeParam ? { type: typeParam } : {}),
},
});
return resp.data!.data as SearchResponse;
},
enabled: q.trim().length > 0,
});
const results = data ?? null;
function handleSearchSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const newQ = fd.get('q') as string;
if (newQ.trim()) {
setSearchParams({ q: newQ.trim(), type: typeParam });
}
}
function toggleType(t: SearchType) {
let next: SearchType[];
if (activeTypes.includes(t)) {
next = activeTypes.filter((x) => x !== t);
} else {
next = [...activeTypes, t];
}
if (next.length === 0) next = [...ALL_TYPES];
setSearchParams({ q, type: next.join(','), page: '1' });
}
return (
<div className="flex-1 overflow-y-auto">
{/* Search header */}
<div className="border-b bg-background/80 backdrop-blur-sm sticky top-0 z-10">
<div className="mx-auto max-w-3xl px-6 py-4">
<form onSubmit={handleSearchSubmit} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="q"
defaultValue={q}
placeholder="Search projects, repositories, issues, users..."
className="pl-9 h-10"
autoFocus
/>
</div>
<Button type="submit">Search</Button>
</form>
{/* Type filters */}
<div className="flex items-center gap-1 mt-3">
{ALL_TYPES.map((t) => {
const Icon = TYPE_ICONS[t];
const active = activeTypes.includes(t);
return (
<Button
key={t}
variant={active ? 'default' : 'outline'}
size="sm"
className="gap-1.5 h-7 text-xs"
onClick={() => toggleType(t)}
>
<Icon className="h-3.5 w-3.5" />
{TYPE_LABELS[t]}
</Button>
);
})}
</div>
</div>
</div>
{/* Results */}
<div className="mx-auto max-w-3xl px-6 py-6">
{isLoading && (
<div className="flex items-center justify-center py-24">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<div className="text-center py-16 text-muted-foreground">
<Search className="h-10 w-10 mx-auto mb-3 opacity-20" />
<p className="text-sm font-medium">Search failed</p>
<p className="text-xs mt-1">{error.message}</p>
</div>
)}
{!isLoading && !error && !q.trim() && (
<div className="text-center py-24">
<Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/20" />
<p className="text-muted-foreground text-sm">Enter a keyword to search across all content.</p>
</div>
)}
{!isLoading && !error && results && q.trim() && (
<>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{getTotal(results) > 0
? `${getTotal(results)} results for "${q}"`
: `No results for "${q}"`}
</p>
</div>
<div className="space-y-6">
{activeTypes.includes('projects') && (
<ResultSection
type="projects"
result={results?.projects ?? null}
renderer={(item) => <ProjectItem item={item} />}
/>
)}
{activeTypes.includes('repos') && (
<ResultSection
type="repos"
result={results?.repos ?? null}
renderer={(item) => <RepoItem item={item} />}
/>
)}
{activeTypes.includes('issues') && (
<ResultSection
type="issues"
result={results?.issues ?? null}
renderer={(item) => <IssueItem item={item} />}
/>
)}
{activeTypes.includes('users') && (
<ResultSection
type="users"
result={results?.users ?? null}
renderer={(item) => <UserItem item={item} />}
/>
)}
</div>
{getTotal(results) === 0 && (
<div className="text-center py-16">
<Search className="h-10 w-10 mx-auto mb-3 text-muted-foreground/20" />
<p className="font-medium">No results found</p>
<p className="text-sm text-muted-foreground mt-1">
Try different keywords or check your spelling.
</p>
</div>
)}
</>
)}
</div>
</div>
);
}