365 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|