diff --git a/app/actions.ts b/app/actions.ts index 5600e1e..6a1d3b9 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -89,10 +89,14 @@ export async function fetchMetadata(url: string) { try { const response = await fetch(url, { next: { revalidate: 3600 } }); // Cache for 1 hour const html = await response.text(); - const $ = load(html); - const title = $('head title').text() || $('meta[property="og:title"]').attr('content') || ''; - const description = $('meta[name="description"]').attr('content') || $('meta[property="og:description"]').attr('content') || ''; + const titleMatch = html.match(/(.*?)<\/title>/i); + const descMatch = html.match( + /<meta\s+name=["']description["']\s+content=["'](.*?)["']/i + ); + + const title = titleMatch ? titleMatch[1] : ''; + const description = descMatch ? descMatch[1] : ''; return { title, description }; } catch (error) { @@ -101,6 +105,7 @@ export async function fetchMetadata(url: string) { } } + type SearchGroupId = 'web' | 'academic' | 'shopping' | 'youtube' | 'x' | 'writing'; const groupTools = { @@ -216,6 +221,10 @@ When asked a "What is" question, maintain the same format as the question and an Focus on peer-reviewed papers, citations, and academic sources. Do not talk in bullet points or lists at all costs as it unpresentable. Provide summaries, key points, and references. + Latex should be wrapped with $ symbol for inline and $$ for block equations as they are supported in the response. + No matter what happens, always provide the citations at the end of each paragraph and in the end of sentences where you use it in which they are referred to with the given format to the information provided. + Citation format: [Author et al. (Year) Title](URL) + Always run the tools first and then write the response. `, shopping: `You are a shopping assistant that helps users find and compare products. The current date is ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "short", day: "2-digit", weekday: "short" })}. @@ -230,13 +239,19 @@ When asked a "What is" question, maintain the same format as the question and an Do not talk in bullet points or lists at all costs. Provide important details and summaries of the videos in paragraphs. Give citations with timestamps and video links to insightful content. Don't just put timestamp at 0:00. + Citation format: [Title](URL ending with parameter t=<no_of_seconds>) Do not provide the video thumbnail in the response at all costs.`, x: `You are a X/Twitter content curator that helps find relevant posts. The current date is ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "short", day: "2-digit", weekday: "short" })}. Once you get the content from the tools only write in paragraphs. No need to say that you are calling the tool, just call the tools first and run the search; - then talk in long details in 2-6 paragraphs.`, - writing: `You are a writing assistant that helps users with writing, conversation, coding, poems, haikus, long essays or intellectual topics.`, + then talk in long details in 2-6 paragraphs. + Always provide the citations at the end of each paragraph and in the end of sentences where you use it in which they are referred to with the given format to the information provided. + Citation format: [Post Title](URL) + `, + writing: `You are a writing assistant that helps users with writing, conversation, coding, poems, haikus, long essays or intellectual topics. + Latex should be wrapped with $ symbol for inline and $$ for block equations as they are supported in the response. + Do not use the \( and \) for inline equations, use the $ symbol instead at all costs!!`, } as const; diff --git a/app/api/trending/route.ts b/app/api/trending/route.ts index 561285d..adebe59 100644 --- a/app/api/trending/route.ts +++ b/app/api/trending/route.ts @@ -1,4 +1,7 @@ import { NextResponse } from 'next/server'; +import { generateObject } from 'ai'; +import { groq } from '@ai-sdk/groq' +import { z } from 'zod'; export interface TrendingQuery { icon: string; @@ -28,11 +31,30 @@ async function fetchGoogleTrends(): Promise<TrendingQuery[]> { const xmlText = await response.text(); const items = xmlText.match(/<title>(?!Daily Search Trends)(.*?)<\/title>/g) || []; - return items.map(item => ({ - icon: 'trending', - text: item.replace(/<\/?title>/g, ''), - category: 'trending' // TODO: add category based on the query results + const categories = ['trending', 'community', 'science', 'tech', 'travel', 'politics', 'health', 'sports', 'finance', 'football'] as const; + + const schema = z.object({ + category: z.enum(categories), + }); + + const itemsWithCategoryAndIcon = await Promise.all(items.map(async item => { + const { object } = await generateObject({ + model: groq("llama-3.2-3b-preview"), + prompt: `Give the category for the topic from the existing values only in lowercase only: ${item.replace(/<\/?title>/g, '')} + + - if the topic category isn't present in the list, please select 'trending' only!`, + schema, + temperature: 0, + }); + + return { + icon: object.category, + text: item.replace(/<\/?title>/g, ''), + category: object.category + }; })); + + return itemsWithCategoryAndIcon; } catch (error) { console.error(`Failed to fetch Google Trends for geo: ${geo}`, error); return []; @@ -55,7 +77,7 @@ async function fetchRedditQuestions(): Promise<TrendingQuery[]> { } } ); - + const data = await response.json(); const maxLength = 50; @@ -74,16 +96,16 @@ async function fetchRedditQuestions(): Promise<TrendingQuery[]> { } async function fetchFromMultipleSources() { - const [googleTrends, + const [googleTrends, // redditQuestions -] = await Promise.all([ + ] = await Promise.all([ fetchGoogleTrends(), // fetchRedditQuestions(), ]); - const allQueries = [...googleTrends, - // ...redditQuestions -]; + const allQueries = [...googleTrends, + // ...redditQuestions + ]; return allQueries .sort(() => Math.random() - 0.5); } @@ -91,7 +113,7 @@ async function fetchFromMultipleSources() { export async function GET() { try { const trends = await fetchFromMultipleSources(); - + if (trends.length === 0) { // Fallback queries if both sources fail return NextResponse.json([ @@ -112,7 +134,7 @@ export async function GET() { } ]); } - + return NextResponse.json(trends); } catch (error) { console.error('Failed to fetch trends:', error); diff --git a/app/search/page.tsx b/app/search/page.tsx index a640b13..e514f87 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -15,6 +15,7 @@ React, import ReactMarkdown from 'react-markdown'; import { useTheme } from 'next-themes'; import Marked, { ReactRenderer } from 'marked-react'; +import Latex from 'react-latex-next'; import { track } from '@vercel/analytics'; import { useSearchParams } from 'next/navigation'; import { useChat } from 'ai/react'; @@ -101,7 +102,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import { Sheet, SheetContent, SheetHeader, SheetPortal, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer"; import { GitHubLogoIcon } from '@radix-ui/react-icons'; import Link from 'next/link'; @@ -120,7 +121,7 @@ import WeatherChart from '@/components/weather-chart'; import InteractiveChart from '@/components/interactive-charts'; import { MapComponent, MapContainer, MapSkeleton } from '@/components/map-components'; import MultiSearch from '@/components/multi-search'; -import { RedditLogo, RoadHorizon, XLogo } from '@phosphor-icons/react'; +import { CurrencyDollar, Flag, RedditLogo, RoadHorizon, SoccerBall, TennisBall, XLogo } from '@phosphor-icons/react'; import { BorderTrail } from '@/components/core/border-trail'; import { TextShimmer } from '@/components/core/text-shimmer'; import { Tweet } from 'react-tweet'; @@ -587,6 +588,32 @@ const HomeContent = () => { const initializedRef = useRef(false); const [selectedGroup, setSelectedGroup] = useState<SearchGroupId>('web'); + const CACHE_KEY = 'trendingQueriesCache'; + const CACHE_DURATION = 5 * 60 * 60 * 1000; // 5 hours in milliseconds + + // Add this type definition + interface TrendingQueriesCache { + data: TrendingQuery[]; + timestamp: number; + } + + const getTrendingQueriesFromCache = (): TrendingQueriesCache | null => { + if (typeof window === 'undefined') return null; + + const cached = localStorage.getItem(CACHE_KEY); + if (!cached) return null; + + const parsedCache = JSON.parse(cached) as TrendingQueriesCache; + const now = Date.now(); + + if (now - parsedCache.timestamp > CACHE_DURATION) { + localStorage.removeItem(CACHE_KEY); + return null; + } + + return parsedCache; + }; + const { theme } = useTheme(); const [openChangelog, setOpenChangelog] = useState(false); @@ -633,10 +660,25 @@ const HomeContent = () => { useEffect(() => { const fetchTrending = async () => { + // Check cache first + const cached = getTrendingQueriesFromCache(); + if (cached) { + setTrendingQueries(cached.data); + return; + } + try { const res = await fetch('/api/trending'); if (!res.ok) throw new Error('Failed to fetch trending queries'); const data = await res.json(); + + // Store in cache + const cacheData: TrendingQueriesCache = { + data, + timestamp: Date.now() + }; + localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData)); + setTrendingQueries(data); } catch (error) { console.error('Error fetching trending queries:', error); @@ -1918,6 +1960,48 @@ The new Anthropic models: Claude 3.5 Sonnet and 3.5 Haiku models are now availab })); }, [content]); + const inlineMathRegex = /\$([^\$]+)\$/g; + const blockMathRegex = /\$\$([^\$]+)\$\$/g; + + const isValidLatex = (text: string): boolean => { + // Basic validation - checks for balanced delimiters + return !(text.includes('\\') && !text.match(/\\[a-zA-Z{}\[\]]+/)); + } + + const renderLatexString = (text: string) => { + let parts = []; + let lastIndex = 0; + let match; + + // Try to match inline math first ($...$) + while ((match = /\$([^\$]+)\$/g.exec(text.slice(lastIndex))) !== null) { + const mathText = match[1]; + const fullMatch = match[0]; + const matchIndex = lastIndex + match.index; + + // Add text before math + if (matchIndex > lastIndex) { + parts.push(text.slice(lastIndex, matchIndex)); + } + + // Only render as LaTeX if valid + if (isValidLatex(mathText)) { + parts.push(<Latex key={matchIndex}>{fullMatch}</Latex>); + } else { + parts.push(fullMatch); + } + + lastIndex = matchIndex + fullMatch.length; + } + + // Add remaining text + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts.length > 0 ? parts : text; + }; + const fetchMetadataWithCache = useCallback(async (url: string) => { if (metadataCache[url]) { return metadataCache[url]; @@ -2039,8 +2123,36 @@ The new Anthropic models: Claude 3.5 Sonnet and 3.5 Haiku models are now availab }; const renderer: Partial<ReactRenderer> = { + text(text: string) { + if (!text.includes('$')) return text; + + return ( + <Latex + delimiters={[ + { left: '$$', right: '$$', display: true }, + { left: '$', right: '$', display: false } + ]} + > + {text} + </Latex> + ); + }, paragraph(children) { - return <p className="my-4 text-neutral-800 dark:text-neutral-200">{children}</p>; + if (typeof children === 'string' && children.includes('$')) { + return ( + <p className="my-4"> + <Latex + delimiters={[ + { left: '$$', right: '$$', display: true }, + { left: '$', right: '$', display: false } + ]} + > + {children} + </Latex> + </p> + ); + } + return <p className="my-4">{children}</p>; }, code(children, language) { return <CodeBlock language={language}>{String(children)}</CodeBlock>; @@ -2195,58 +2307,71 @@ The new Anthropic models: Claude 3.5 Sonnet and 3.5 Haiku models are now availab ); }; - const SuggestionCards: React.FC<{ - selectedModel: string; - trendingQueries: TrendingQuery[]; + const SuggestionCards: React.FC<{ + selectedModel: string; + trendingQueries: TrendingQuery[]; }> = ({ selectedModel, trendingQueries }) => { const [isLoading, setIsLoading] = useState(true); const scrollRef = useRef<HTMLDivElement>(null); const [isPaused, setIsPaused] = useState(false); const scrollIntervalRef = useRef<NodeJS.Timeout>(); + const [isTouchDevice, setIsTouchDevice] = useState(false); useEffect(() => { setIsLoading(false); + setIsTouchDevice('ontouchstart' in window); }, [trendingQueries]); useEffect(() => { + if (isTouchDevice) return; // Disable auto-scroll on touch devices + const startScrolling = () => { if (!scrollRef.current || isPaused) return; - scrollRef.current.scrollLeft += 2; + scrollRef.current.scrollLeft += 1; // Reduced speed + + // Reset scroll when reaching end + if (scrollRef.current.scrollLeft >= + (scrollRef.current.scrollWidth - scrollRef.current.clientWidth)) { + scrollRef.current.scrollLeft = 0; + } }; - scrollIntervalRef.current = setInterval(startScrolling, 20); + scrollIntervalRef.current = setInterval(startScrolling, 30); return () => { if (scrollIntervalRef.current) { clearInterval(scrollIntervalRef.current); } }; - }, [isPaused]); - - const getCardWidth = (text: string) => { - const charWidth = 8; - const padding = 32; - const iconWidth = 28; - return Math.min( - padding + iconWidth + (text.length * charWidth), - 400 - ); - }; + }, [isPaused, isTouchDevice]); if (isLoading || trendingQueries.length === 0) { return ( - <div className="flex gap-2 mt-4"> - {[1, 2, 3].map((_, index) => ( - <div - key={index} - className="flex-shrink-0 w-[200px] bg-neutral-100 dark:bg-neutral-800 rounded-xl p-4 animate-pulse" - > - <div className="flex items-center space-x-2"> - <div className="w-5 h-5 bg-neutral-200 dark:bg-neutral-700 rounded-full" /> - <div className="h-4 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" /> + <div className="relative mt-4 px-0"> + {/* Overlay with Loading Text */} + <div className="absolute inset-0 z-10 flex items-center justify-center"> + <div className="backdrop-blur-sm bg-white/30 dark:bg-black/30 rounded-2xl px-6 py-3 shadow-lg"> + <div className="flex items-center gap-2 text-sm font-medium text-neutral-600 dark:text-neutral-300"> + <Loader2 className="w-4 h-4 animate-spin" /> + <span>Loading trending queries</span> </div> </div> - ))} + </div> + + {/* Background Cards */} + <div className="flex gap-2"> + {[1, 2, 3].map((_, index) => ( + <div + key={index} + className="flex-shrink-0 w-[140px] md:w-[220px] bg-neutral-100 dark:bg-neutral-800 rounded-xl p-4 animate-pulse" + > + <div className="flex items-center space-x-2"> + <div className="w-5 h-5 bg-neutral-200 dark:bg-neutral-700 rounded-full" /> + <div className="h-4 w-32 bg-neutral-200 dark:bg-neutral-700 rounded" /> + </div> + </div> + ))} + </div> </div> ); } @@ -2258,28 +2383,53 @@ The new Anthropic models: Claude 3.5 Sonnet and 3.5 Haiku models are now availab science: <Brain className="w-5 h-5" />, tech: <Code className="w-5 h-5" />, travel: <Globe className="w-5 h-5" />, + politics: <Flag className="w-5 h-5" />, + health: <Heart className="w-5 h-5" />, + sports: <TennisBall className="w-5 h-5" />, + finance: <CurrencyDollar className="w-5 h-5" />, + football: <SoccerBall className="w-5 h-5" />, }; return iconMap[category as keyof typeof iconMap] || <Sparkles className="w-5 h-5" />; }; return ( <div className="relative"> - <div + {/* Gradient Fades */} + <div className="absolute left-0 top-0 bottom-0 w-12 bg-gradient-to-r from-background to-transparent z-10" /> + <div className="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-background to-transparent z-10" /> + + <div ref={scrollRef} - className="flex gap-2 mt-4 overflow-x-auto pb-3 relative scroll-smooth no-scrollbar" - onMouseEnter={() => setIsPaused(true)} - onMouseLeave={() => setIsPaused(false)} + className="flex gap-4 mt-4 overflow-x-auto pb-4 px-4 md:px-0 relative scroll-smooth no-scrollbar" + onMouseEnter={() => !isTouchDevice && setIsPaused(true)} + onMouseLeave={() => !isTouchDevice && setIsPaused(false)} + onTouchStart={() => setIsPaused(true)} + onTouchEnd={() => setIsPaused(false)} > {Array(20).fill(trendingQueries).flat().map((query, index) => ( <button key={`${index}-${query.text}`} onClick={() => handleExampleClick(query)} - className="flex-shrink-0 bg-neutral-100 dark:bg-neutral-800 rounded-xl p-3 text-left hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors duration-200" - style={{ width: `${getCardWidth(query.text)}px` }} + className="group flex-shrink-0 bg-neutral-50/50 dark:bg-neutral-800/50 + backdrop-blur-sm rounded-xl p-3.5 text-left + hover:bg-neutral-100 dark:hover:bg-neutral-700/70 + transition-all duration-200 ease-out + hover:scale-102 origin-center + h-[52px] min-w-fit + hover:shadow-lg + border border-neutral-200/50 dark:border-neutral-700/50 + hover:border-neutral-300 dark:hover:border-neutral-600" > - <div className="flex items-center gap-2 text-neutral-700 dark:text-neutral-300"> - <span className="flex-shrink-0">{getIconForCategory(query.category)}</span> - <span className="text-sm font-medium whitespace-nowrap pr-1"> + + <div className="flex items-center gap-3 text-neutral-700 dark:text-neutral-300"> + <span + className="flex-shrink-0 transition-transform duration-200 group-hover:scale-110 group-hover:rotate-3" + > + {getIconForCategory(query.category)} + </span> + <span + className="text-sm font-medium truncate max-w-[180px] group-hover:text-neutral-900 dark:group-hover:text-neutral-100" + > {query.text} </span> </div> @@ -2301,11 +2451,11 @@ The new Anthropic models: Claude 3.5 Sonnet and 3.5 Haiku models are now availab }, []); - // const memoizedMessages = useMemo(() => messages, [messages]); + const memoizedMessages = useMemo(() => messages, [messages]); const memoizedSuggestionCards = useMemo(() => ( - <SuggestionCards - selectedModel={selectedModel} + <SuggestionCards + selectedModel={selectedModel} trendingQueries={trendingQueries} /> ), [selectedModel, trendingQueries]); @@ -2349,7 +2499,7 @@ The new Anthropic models: Claude 3.5 Sonnet and 3.5 Haiku models are now availab fileInputRef={fileInputRef} inputRef={inputRef} stop={stop} - messages={messages} + messages={memoizedMessages} append={append} selectedModel={selectedModel} setSelectedModel={handleModelChange} @@ -2365,7 +2515,7 @@ The new Anthropic models: Claude 3.5 Sonnet and 3.5 Haiku models are now availab <div className="space-y-4 sm:space-y-6 mb-32"> - {messages.map((message, index) => ( + {memoizedMessages.map((message, index) => ( <div key={index}> {message.role === 'user' && ( <motion.div diff --git a/components/flight-tracker.tsx b/components/flight-tracker.tsx index ef8e60e..7cd4366 100644 --- a/components/flight-tracker.tsx +++ b/components/flight-tracker.tsx @@ -50,8 +50,8 @@ export function FlightTracker({ data }: FlightTrackerProps) { const formatTime = (timestamp: string) => { const date = new Date(timestamp); - return date.toLocaleTimeString('en-US', { - hour: '2-digit', + return date.toLocaleTimeString('en-US', { + hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'UTC' @@ -82,16 +82,38 @@ export function FlightTracker({ data }: FlightTrackerProps) { const getStatusColor = (status: string) => { switch (status) { case "LANDED": - return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"; + return "bg-green-100 hover:bg-green-200 text-green-800 dark:bg-green-900 dark:hover:bg-green-800 dark:text-green-200"; case "DEPARTING ON TIME": - return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"; + return "bg-green-100 hover:bg-green-200 text-green-800 dark:bg-green-900 dark:hover:bg-green-800 dark:text-green-200"; case "DELAYED": - return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"; + return "bg-red-100 hover:bg-red-200 text-red-800 dark:bg-red-900 dark:hover:bg-red-800 dark:text-red-200"; default: - return "bg-neutral-100 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"; + return "bg-neutral-100 hover:bg-neutral-200 text-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200"; } }; + const getPlanePosition = (status: string) => { + switch (status.toLowerCase()) { + case 'landed': return 'right-0'; + case 'active': return 'left-1/2 -translate-x-1/2'; + default: return 'left-0'; + } + }; + + const calculateDuration = (departureTime: string, arrivalTime: string): string => { + const departure = new Date(departureTime); + const arrival = new Date(arrivalTime); + const durationInMinutes = Math.floor((arrival.getTime() - departure.getTime()) / (1000 * 60)); + + if (durationInMinutes < 0) return 'N/A'; + + const hours = Math.floor(durationInMinutes / 60); + const minutes = durationInMinutes % 60; + + if (hours === 0) return `${minutes}m`; + return `${hours}h ${minutes}m`; + }; + const flightInfo = { flightNumber: flight.flight.iata, status: mapStatus(flight.flight_status), @@ -111,7 +133,7 @@ export function FlightTracker({ data }: FlightTrackerProps) { terminal: flight.arrival.terminal || undefined, gate: flight.arrival.gate || undefined }, - duration: flight.flight.duration ? `${flight.flight.duration} minutes` : 'N/A', + duration: calculateDuration(flight.departure.scheduled, flight.arrival.scheduled), lastUpdated: new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', @@ -121,76 +143,135 @@ export function FlightTracker({ data }: FlightTrackerProps) { }; return ( - <Card className="w-full max-w-2xl bg-card dark:bg-card"> - <CardContent className="p-6"> - <div className="flex justify-between items-center mb-6"> - <div> - <h2 className="text-2xl font-bold">{flightInfo.flightNumber}</h2> - <Badge className={getStatusColor(flightInfo.status)}> - {flightInfo.status} - </Badge> + <Card className="w-full max-w-3xl bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border-neutral-200/50 dark:border-neutral-800/50 shadow-none"> + <CardContent className="p-4 md:p-8"> + {/* Header */} + <div className="flex flex-col md:flex-row md:items-center gap-4 md:gap-0 md:justify-between mb-6 pb-6 border-b border-neutral-200/50 dark:border-neutral-800/50"> + <div className="flex items-center gap-3"> + <div className="w-10 h-10 md:w-12 md:h-12 rounded-full bg-blue-500/10 dark:bg-blue-500/20 flex items-center justify-center"> + <Plane className="h-5 w-5 md:h-6 md:w-6 text-blue-600 dark:text-blue-400" /> + </div> + <div> + <h2 className="text-xl md:text-2xl font-bold tracking-tight">{flightInfo.flightNumber}</h2> + <p className="text-sm text-neutral-500 dark:text-neutral-400">{flight.airline.name}</p> + </div> + </div> + <Badge className={`${getStatusColor(flightInfo.status)} px-3 py-1 md:px-4 md:py-1.5 text-sm font-medium self-start md:self-aut shadow-none`}> + {flightInfo.status} + </Badge> + </div> + + {/* Flight Route */} + <div className="py-4 md:py-8"> + <div className="flex flex-col md:flex-row gap-6 md:gap-8 md:items-center"> + {/* Departure */} + <div className="flex-1 min-w-0"> + <div className="text-2xl md:text-3xl font-mono font-bold mb-2 truncate"> + {flightInfo.departure.code} + </div> + <div className="space-y-0.5 md:space-y-1"> + <p className="font-medium text-sm truncate">{flightInfo.departure.airport}</p> + <p className="text-lg md:text-xl font-bold">{flightInfo.departure.time}</p> + <p className="text-xs text-neutral-500">{flightInfo.departure.date}</p> + </div> + </div> + + {/* Flight Path - Hidden on mobile */} + <div className="hidden md:block flex-1 relative h-[2px] mx-4"> + <div className="absolute left-0 top-1/2 w-2 h-2 bg-blue-600 dark:bg-blue-400 rounded-full -translate-y-1/2" /> + <div className="w-full h-[2px] border-t-2 border-dotted border-blue-500/50 dark:border-blue-400/50" /> + <div className="absolute right-0 top-1/2 w-2 h-2 bg-blue-600 dark:bg-blue-400 rounded-full -translate-y-1/2" /> + <div className={`absolute top-1/2 -translate-y-1/2 ${getPlanePosition(flightInfo.status)} transition-all duration-1000`}> + <div className="bg-white dark:bg-neutral-800 p-2 rounded-full border"> + <Plane className="h-5 w-5 text-blue-600 dark:text-blue-400 transform rotate-45" /> + </div> + </div> + </div> + + {/* Mobile Flight Progress */} + <div className="md:hidden relative w-[97%] h-8 flex items-center"> + {/* Background Track */} + <div className="absolute inset-0 h-1 top-1/2 -translate-y-1/2 bg-neutral-100 dark:bg-neutral-800 rounded-full" /> + + {/* Progress Bar */} + <div + className={`absolute h-1 top-1/2 -translate-y-1/2 bg-blue-500 rounded-full transition-all duration-1000 ${flightInfo.status === 'LANDED' ? 'w-full' : + flightInfo.status === 'DEPARTING ON TIME' ? 'w-[5%]' : 'w-1/2' + }`} + /> + + {/* Animated Plane */} + <div + className={`absolute top-1/2 -translate-y-1/2 transition-all duration-1000`} + style={{ + left: flightInfo.status === 'LANDED' ? '100%' : + flightInfo.status === 'DEPARTING ON TIME' ? '5%' : '50%', + transform: 'translate(-50%, -50%)' + }} + > + <div className="bg-white dark:bg-neutral-800 p-1.5 rounded-full border"> + <Plane className="h-4 w-4 text-blue-600 dark:text-blue-400 transform rotate-45" /> + </div> + </div> + </div> + + {/* Arrival */} + <div className="flex-1 min-w-0 md:text-right"> + <div className="text-2xl md:text-3xl font-mono font-bold mb-2 truncate"> + {flightInfo.arrival.code} + </div> + <div className="space-y-0.5 md:space-y-1"> + <p className="font-medium text-sm truncate">{flightInfo.arrival.airport}</p> + <p className="text-lg md:text-xl font-bold">{flightInfo.arrival.time}</p> + <p className="text-xs text-neutral-500">{flightInfo.arrival.date}</p> + </div> + </div> </div> </div> - <div className="relative"> - <div className="flex justify-between items-center"> - <div className="text-4xl font-mono">{flightInfo.departure.code}</div> - <motion.div - className="text-primary" - initial={{ x: -50 }} - animate={{ x: 50 }} - transition={{ duration: 2, repeat: Infinity, ease: "linear" }} - > - <Plane className="h-6 w-6" /> - </motion.div> - <div className="text-4xl font-mono">{flightInfo.arrival.code}</div> - </div> - <div className="mt-8 grid grid-cols-2 gap-8"> - <div> - <p className="text-lg font-medium">{flightInfo.departure.airport}</p> - <p className="text-3xl font-bold mt-1">{flightInfo.departure.time}</p> - <p className="text-sm text-muted-foreground">{flightInfo.departure.date}</p> - {(flightInfo.departure.terminal || flightInfo.departure.gate) && ( - <div className="mt-2 flex items-center gap-4"> - {flightInfo.departure.terminal && ( - <div className="flex items-center gap-1"> - <Terminal className="h-4 w-4" /> - <span>Terminal {flightInfo.departure.terminal}</span> - </div> - )} - {flightInfo.departure.gate && ( - <div>Gate {flightInfo.departure.gate}</div> - )} - </div> - )} - </div> - - <div> - <p className="text-lg font-medium">{flightInfo.arrival.airport}</p> - <p className="text-3xl font-bold mt-1">{flightInfo.arrival.time}</p> - <p className="text-sm text-muted-foreground">{flightInfo.arrival.date}</p> - {(flightInfo.arrival.terminal || flightInfo.arrival.gate) && ( - <div className="mt-2 flex items-center gap-4"> - {flightInfo.arrival.terminal && ( - <div className="flex items-center gap-1"> - <Terminal className="h-4 w-4" /> - <span>Terminal {flightInfo.arrival.terminal}</span> - </div> - )} - {flightInfo.arrival.gate && ( - <div>Gate {flightInfo.arrival.gate}</div> - )} - </div> - )} - </div> + {/* Flight Details */} + <div className="mt-6 md:mt-8 grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8 bg-neutral-50/50 dark:bg-neutral-800/50 rounded-xl p-4"> + {/* Departure Details */} + <div className="space-y-3 max-w-full"> + {flightInfo.departure.terminal && ( + <div className="flex items-center gap-2 overflow-hidden"> + <Terminal className="h-4 w-4 flex-shrink-0 text-neutral-500" /> + <span className="text-sm truncate">Terminal {flightInfo.departure.terminal}</span> + </div> + )} + {flightInfo.departure.gate && ( + <div className="flex items-center gap-2 overflow-hidden"> + <div className="h-4 w-4 flex-shrink-0 rounded bg-blue-500/10 flex items-center justify-center text-[10px] text-blue-600">G</div> + <span className="text-sm truncate">Gate {flightInfo.departure.gate}</span> + </div> + )} </div> - <div className="mt-6 flex items-center gap-2 text-sm text-muted-foreground"> + {/* Arrival Details */} + <div className="space-y-3 max-w-full"> + {flightInfo.arrival.terminal && ( + <div className="flex items-center gap-2 overflow-hidden"> + <Terminal className="h-4 w-4 flex-shrink-0 text-neutral-500" /> + <span className="text-sm truncate">Terminal {flightInfo.arrival.terminal}</span> + </div> + )} + {flightInfo.arrival.gate && ( + <div className="flex items-center gap-2 overflow-hidden"> + <div className="h-4 w-4 flex-shrink-0 rounded bg-blue-500/10 flex items-center justify-center text-[10px] text-blue-600">G</div> + <span className="text-sm truncate">Gate {flightInfo.arrival.gate}</span> + </div> + )} + </div> + </div> + + {/* Footer */} + <div className="mt-4 md:mt-6 flex flex-col md:flex-row items-start md:items-center gap-2 md:gap-3 text-sm text-neutral-500"> + <div className="flex items-center gap-2"> <Clock className="h-4 w-4" /> - <span>Duration: {flightInfo.duration}</span> - <span className="mx-2">•</span> - <span>Last updated: {flightInfo.lastUpdated}</span> + <span>Flight duration: {flightInfo.duration}</span> </div> + <span className="hidden md:inline text-neutral-300">•</span> + <span>Last updated: {flightInfo.lastUpdated}</span> </div> </CardContent> </Card> diff --git a/package.json b/package.json index d8322d6..4e9a612 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@ai-sdk/anthropic": "^1.0.5", "@ai-sdk/azure": "^1.0.10", "@ai-sdk/cohere": "^1.0.3", - "@ai-sdk/google": "^1.0.7", + "@ai-sdk/google": "^1.0.11", "@ai-sdk/groq": "^0.0.1", "@ai-sdk/mistral": "^0.0.41", "@ai-sdk/openai": "^0.0.58", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ecb604..93c2e65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ dependencies: specifier: ^1.0.3 version: 1.0.3(zod@3.24.1) '@ai-sdk/google': - specifier: ^1.0.7 - version: 1.0.7(zod@3.24.1) + specifier: ^1.0.11 + version: 1.0.11(zod@3.24.1) '@ai-sdk/groq': specifier: ^0.0.1 version: 0.0.1(zod@3.24.1) @@ -311,8 +311,8 @@ packages: zod: 3.24.1 dev: false - /@ai-sdk/google@1.0.7(zod@3.24.1): - resolution: {integrity: sha512-D2R/VFA0zpcWYnAoqYGaZn7XqHb8ASt1hZJ86u7BOVoBnQTRPRUYHb4lSXjrMcj/QYMXIJkKojfY4isenkku5Q==} + /@ai-sdk/google@1.0.11(zod@3.24.1): + resolution: {integrity: sha512-snp66p4BurhOmy2QUTlkZR8nFizx+F60t9v/2ld/fhxTK4G+QMHBUZpBujkW1gQEfE13fEOd43wCE1SQgP46Tw==} engines: {node: '>=18'} peerDependencies: zod: ^3.0.0