404 lines
19 KiB
TypeScript
404 lines
19 KiB
TypeScript
/* eslint-disable @next/next/no-img-element */
|
|
import React, { useState } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ArrowUpRight, Calendar, ChevronLeft, ChevronRight, Clock, Globe, ImageIcon, Newspaper, Search, X } from 'lucide-react';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
|
|
type SearchImage = {
|
|
url: string;
|
|
description: string;
|
|
};
|
|
|
|
type SearchResult = {
|
|
url: string;
|
|
title: string;
|
|
content: string;
|
|
raw_content: string;
|
|
published_date?: string;
|
|
};
|
|
|
|
type SearchQueryResult = {
|
|
query: string;
|
|
results: SearchResult[];
|
|
images: SearchImage[];
|
|
};
|
|
|
|
type MultiSearchResponse = {
|
|
searches: SearchQueryResult[];
|
|
};
|
|
|
|
type MultiSearchArgs = {
|
|
queries: string[];
|
|
maxResults: number[];
|
|
topic: ("general" | "news")[];
|
|
searchDepth: ("basic" | "advanced")[];
|
|
};
|
|
|
|
interface ResultCardProps {
|
|
result: SearchResult;
|
|
index: number;
|
|
}
|
|
|
|
interface GalleryProps {
|
|
images: SearchImage[];
|
|
onClose: () => void;
|
|
}
|
|
|
|
interface SearchResultsProps {
|
|
searchData: SearchQueryResult;
|
|
topicType: string;
|
|
onImageClick: (index: number) => void;
|
|
}
|
|
|
|
const SearchQueryTab: React.FC<{ query: string; count: number; isActive: boolean }> = ({ query, count, isActive }) => (
|
|
<div className="flex items-center gap-2">
|
|
<Search className="h-4 w-4" />
|
|
<span className="text-sm font-medium truncate max-w-[120px]">{query}</span>
|
|
<Badge variant="secondary" className={isActive ? 'dark:bg-white/20 dark:text-white bg-gray-200 text-gray-700' : 'dark:bg-neutral-800 bg-gray-100'}>
|
|
{count}
|
|
</Badge>
|
|
</div>
|
|
);
|
|
|
|
const ResultCard: React.FC<ResultCardProps> = ({ result, index }) => {
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3, delay: index * 0.1 }}
|
|
className="relative flex flex-col w-[280px] dark:bg-neutral-900 bg-white rounded-xl dark:border-neutral-800 border-gray-200 border overflow-hidden flex-shrink-0"
|
|
>
|
|
<div className="flex items-start gap-3 p-3">
|
|
<div className="w-8 h-8 rounded-lg dark:bg-neutral-800 bg-gray-100 flex items-center justify-center flex-shrink-0">
|
|
<img
|
|
src={`https://www.google.com/s2/favicons?sz=128&domain=${new URL(result.url).hostname}`}
|
|
alt=""
|
|
className="w-5 h-5"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="text-sm font-medium dark:text-neutral-100 text-gray-900 line-clamp-2 leading-tight">
|
|
{result.title}
|
|
</h3>
|
|
<div className="flex items-center justify-between mt-1">
|
|
<a
|
|
href={result.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs dark:text-neutral-400 text-gray-500 hover:text-gray-700 dark:hover:text-neutral-300 flex items-center gap-1"
|
|
>
|
|
{new URL(result.url).hostname}
|
|
<ArrowUpRight className="h-3 w-3" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-3 pb-3">
|
|
<p className="text-sm dark:text-neutral-300 text-gray-600 line-clamp-3 leading-relaxed">
|
|
{result.content}
|
|
</p>
|
|
</div>
|
|
|
|
{result.published_date && (
|
|
<div className="px-3 py-2 mt-auto border-t dark:border-neutral-800 border-gray-200 dark:bg-neutral-900/50 bg-gray-50">
|
|
<time className="text-xs dark:text-neutral-500 text-gray-500 flex items-center gap-1.5">
|
|
<Calendar className="h-3 w-3" />
|
|
{new Date(result.published_date).toLocaleDateString()}
|
|
</time>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
const ImageGrid: React.FC<{ images: SearchImage[]; onImageClick: (index: number) => void }> = ({ images, onImageClick }) => (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 p-4">
|
|
{images.slice(0, 4).map((image, index) => (
|
|
<motion.div
|
|
key={index}
|
|
className="relative aspect-square rounded-xl overflow-hidden cursor-pointer group"
|
|
onClick={() => onImageClick(index)}
|
|
whileHover={{ scale: 1.02 }}
|
|
>
|
|
<img
|
|
src={image.url}
|
|
alt={image.description}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
|
<p className="absolute bottom-2 left-2 right-2 text-xs text-white line-clamp-2">
|
|
{image.description}
|
|
</p>
|
|
</div>
|
|
{index === 3 && images.length > 4 && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
|
|
<span className="text-xl font-medium text-white">+{images.length - 4}</span>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
const SearchResults: React.FC<SearchResultsProps> = ({ searchData, topicType, onImageClick }) => (
|
|
<div className="space-y-6">
|
|
<div className="dark:bg-neutral-900 bg-white rounded-xl dark:border-neutral-800 border-gray-200 border">
|
|
<div className="p-3 border-b dark:border-neutral-800 border-gray-200">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-lg dark:bg-neutral-800 bg-gray-100">
|
|
<Newspaper className="h-4 w-4 dark:text-neutral-400 text-gray-500" />
|
|
</div>
|
|
<div>
|
|
<h2 className="dark:text-neutral-100 text-gray-900 font-medium">Results for “{searchData.query}“</h2>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<Badge variant="secondary" className="dark:bg-neutral-800 bg-gray-100 dark:text-neutral-300 text-gray-600">
|
|
{topicType}
|
|
</Badge>
|
|
<span className="text-xs dark:text-neutral-500 text-gray-500">{searchData.results.length} results</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<ScrollArea>
|
|
<div className="flex gap-3 p-3 overflow-y-scroll">
|
|
{searchData.results.map((result, index) => (
|
|
<ResultCard key={index} result={result} index={index} />
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
|
|
{searchData.images.length > 0 && (
|
|
<div className="dark:bg-neutral-900 bg-white rounded-xl dark:border-neutral-800 border-gray-200 border">
|
|
<div className="p-4 border-b dark:border-neutral-800 border-gray-200">
|
|
<div className="flex items-center gap-3">
|
|
<ImageIcon className="h-5 w-5 dark:text-neutral-400 text-gray-500" />
|
|
<h3 className="dark:text-neutral-100 text-gray-900 font-medium">Related Images</h3>
|
|
</div>
|
|
</div>
|
|
<ImageGrid images={searchData.images} onImageClick={onImageClick} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
interface ContentDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
result: SearchResult;
|
|
}
|
|
|
|
const ContentDialog: React.FC<ContentDialogProps> = ({ isOpen, onClose, result }) => (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent className="max-w-3xl h-fit p-0 dark:bg-neutral-900 bg-white dark:border-neutral-800 border-gray-200">
|
|
<div className="p-6 space-y-4">
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-10 h-10 rounded-lg dark:bg-neutral-800 bg-gray-100 flex items-center justify-center flex-shrink-0">
|
|
<img
|
|
src={`https://www.google.com/s2/favicons?sz=128&domain=${new URL(result.url).hostname}`}
|
|
alt=""
|
|
className="w-6 h-6"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h2 className="text-xl font-semibold dark:text-neutral-100 text-gray-900">
|
|
{result.title}
|
|
</h2>
|
|
<a
|
|
href={result.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="mt-2 text-sm dark:text-neutral-400 text-gray-500 hover:text-gray-700 dark:hover:text-neutral-300 flex items-center gap-1 w-fit"
|
|
>
|
|
{new URL(result.url).hostname}
|
|
<ArrowUpRight className="h-4 w-4" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<ScrollArea className="h-[60vh] w-full pr-4">
|
|
<div className="space-y-4">
|
|
<div className="prose prose-invert max-w-none">
|
|
<p className="dark:text-neutral-200 text-gray-700 leading-relaxed whitespace-pre-wrap">
|
|
{result.content}
|
|
</p>
|
|
</div>
|
|
{result.published_date && (
|
|
<div className="flex items-center gap-2 text-sm dark:text-neutral-500 text-gray-500 pt-4 border-t dark:border-neutral-800 border-gray-200">
|
|
<Calendar className="h-4 w-4" />
|
|
<time>
|
|
Published on {new Date(result.published_date).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
})}
|
|
</time>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
|
|
|
|
const MultiSearch: React.FC<{ result: MultiSearchResponse | null; args: MultiSearchArgs }> = ({ result, args }) => {
|
|
const [activeTab, setActiveTab] = useState("0");
|
|
const [galleryOpen, setGalleryOpen] = useState(false);
|
|
const [selectedSearch, setSelectedSearch] = useState(0);
|
|
const [selectedImage, setSelectedImage] = useState(0);
|
|
|
|
// Replace the current loading state in MultiSearch component with this:
|
|
if (!result) {
|
|
return (
|
|
<div className="flex items-center justify-between w-full dark:bg-neutral-900 bg-white rounded-xl dark:border-neutral-800 border-gray-200 border p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-lg dark:bg-neutral-800 bg-gray-100">
|
|
<Globe className="h-5 w-5 dark:text-neutral-400 text-gray-500 animate-spin" />
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="dark:text-neutral-100 text-gray-900 font-medium">
|
|
Running searches...
|
|
</span>
|
|
<span className="text-sm dark:text-neutral-500 text-gray-500">
|
|
Processing {args.queries.length} queries
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<motion.div className="flex items-center gap-2">
|
|
<Clock className="h-4 w-4 dark:text-neutral-400 text-gray-500" />
|
|
<div className="flex gap-1">
|
|
{[0, 1, 2].map((index) => (
|
|
<motion.div
|
|
key={index}
|
|
className="w-2 h-2 rounded-full dark:bg-neutral-700 bg-gray-300"
|
|
initial={{ opacity: 0.3 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{
|
|
repeat: Infinity,
|
|
duration: 0.8,
|
|
delay: index * 0.2,
|
|
repeatType: "reverse",
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full max-w-2xl">
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
|
<div className="relative w-full">
|
|
<div className="w-full overflow-y-scroll">
|
|
<TabsList className="inline-flex h-auto gap-2 dark:bg-neutral-900 bg-white p-2 rounded-xl dark:border-neutral-800 border-gray-200 border">
|
|
{result.searches.map((search, index) => (
|
|
<TabsTrigger
|
|
key={index}
|
|
value={index.toString()}
|
|
className="flex-shrink-0 px-3 py-2 rounded-lg transition-all data-[state=active]:dark:bg-neutral-800 data-[state=active]:bg-gray-200 data-[state=active]:text-gray-900 data-[state=active]:dark:text-white text-gray-400 dark:text-neutral-400 hover:text-gray-700 dark:hover:text-neutral-200"
|
|
>
|
|
<SearchQueryTab
|
|
query={search.query}
|
|
count={search.results.length}
|
|
isActive={activeTab === index.toString()}
|
|
/>
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</div>
|
|
</div>
|
|
|
|
<AnimatePresence mode="wait">
|
|
{result.searches.map((search, index) => (
|
|
<TabsContent key={index} value={index.toString()}>
|
|
<SearchResults
|
|
searchData={search}
|
|
topicType={args.topic[index] || args.topic[0]}
|
|
onImageClick={(imageIndex) => {
|
|
setSelectedSearch(index);
|
|
setSelectedImage(imageIndex);
|
|
setGalleryOpen(true);
|
|
}}
|
|
/>
|
|
</TabsContent>
|
|
))}
|
|
</AnimatePresence>
|
|
</Tabs>
|
|
|
|
{galleryOpen && result.searches[selectedSearch].images && (
|
|
<Dialog open={galleryOpen} onOpenChange={setGalleryOpen}>
|
|
<DialogContent className="max-w-3xl max-h-[85vh] p-0 dark:bg-neutral-900 bg-white dark:border-neutral-800 border-gray-200">
|
|
<div className="relative w-full h-full">
|
|
<div className="absolute right-3 top-3 z-50 flex items-center gap-2">
|
|
<span className="px-2 py-1 rounded-md dark:bg-neutral-800 bg-gray-100 text-xs dark:text-neutral-300 text-gray-600">
|
|
{selectedImage + 1} / {result.searches[selectedSearch].images.length}
|
|
</span>
|
|
<button
|
|
onClick={() => setGalleryOpen(false)}
|
|
className="p-1.5 rounded-md dark:bg-neutral-800 bg-gray-100 dark:text-neutral-400 text-gray-500 hover:text-gray-700 dark:hover:text-neutral-200"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="relative w-full h-full">
|
|
<img
|
|
src={result.searches[selectedSearch].images[selectedImage].url}
|
|
alt={result.searches[selectedSearch].images[selectedImage].description}
|
|
className="max-h-[70vh] object-contain rounded-md mx-auto p-4"
|
|
/>
|
|
{result.searches[selectedSearch].images[selectedImage].description && (
|
|
<div className="absolute inset-x-0 bottom-4 mx-4 p-2 bg-gradient-to-t from-black/80 to-transparent rounded-b-md">
|
|
<p className="text-xs text-white/90 text-center">
|
|
{result.searches[selectedSearch].images[selectedImage].description}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="absolute inset-y-0 left-0 flex items-center">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 ml-2 dark:bg-neutral-800 bg-gray-100 dark:hover:bg-neutral-700 hover:bg-gray-200"
|
|
onClick={() => setSelectedImage(prev =>
|
|
prev === 0 ? result.searches[selectedSearch].images.length - 1 : prev - 1
|
|
)}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="absolute inset-y-0 right-0 flex items-center">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 mr-2 dark:bg-neutral-800 bg-gray-100 dark:hover:bg-neutral-700 hover:bg-gray-200"
|
|
onClick={() => setSelectedImage(prev =>
|
|
prev === result.searches[selectedSearch].images.length - 1 ? 0 : prev + 1
|
|
)}
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MultiSearch; |