Changes:
* trending queries UI improved * Modern look on Flight Tracker tool * added Latex rendering support
This commit is contained in:
parent
cd965a895a
commit
326e385993
@ -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>(.*?)<\/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;
|
||||
|
||||
|
||||
|
||||
@ -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',
|
||||
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: 'trending' // TODO: add category based on the query results
|
||||
category: object.category
|
||||
};
|
||||
}));
|
||||
|
||||
return itemsWithCategoryAndIcon;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch Google Trends for geo: ${geo}`, error);
|
||||
return [];
|
||||
|
||||
@ -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>;
|
||||
@ -2203,43 +2315,55 @@ The new Anthropic models: Claude 3.5 Sonnet and 3.5 Haiku models are now availab
|
||||
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">
|
||||
<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-[200px] bg-neutral-100 dark:bg-neutral-800 rounded-xl p-4 animate-pulse"
|
||||
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" />
|
||||
@ -2248,6 +2372,7 @@ The new Anthropic models: Claude 3.5 Sonnet and 3.5 Haiku models are now availab
|
||||
</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">
|
||||
{/* 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-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"
|
||||
>
|
||||
<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">
|
||||
{query.text}
|
||||
</span>
|
||||
</div>
|
||||
@ -2301,7 +2451,7 @@ 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
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
<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-2xl font-bold">{flightInfo.flightNumber}</h2>
|
||||
<Badge className={getStatusColor(flightInfo.status)}>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{/* 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 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">
|
||||
</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>
|
||||
|
||||
{/* 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-1">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span>Terminal {flightInfo.departure.terminal}</span>
|
||||
<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>Gate {flightInfo.departure.gate}</div>
|
||||
)}
|
||||
<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>
|
||||
<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">
|
||||
{/* Arrival Details */}
|
||||
<div className="space-y-3 max-w-full">
|
||||
{flightInfo.arrival.terminal && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span>Terminal {flightInfo.arrival.terminal}</span>
|
||||
<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>Gate {flightInfo.arrival.gate}</div>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<div className="mt-6 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{/* 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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user