* trending queries UI improved
* Modern look on Flight Tracker tool
* added Latex rendering support
This commit is contained in:
zaidmukaddam 2024-12-20 12:00:46 +05:30
parent cd965a895a
commit 326e385993
6 changed files with 403 additions and 135 deletions

View File

@ -89,10 +89,14 @@ export async function fetchMetadata(url: string) {
try { try {
const response = await fetch(url, { next: { revalidate: 3600 } }); // Cache for 1 hour const response = await fetch(url, { next: { revalidate: 3600 } }); // Cache for 1 hour
const html = await response.text(); const html = await response.text();
const $ = load(html);
const title = $('head title').text() || $('meta[property="og:title"]').attr('content') || ''; const titleMatch = html.match(/<title>(.*?)<\/title>/i);
const description = $('meta[name="description"]').attr('content') || $('meta[property="og:description"]').attr('content') || ''; const descMatch = html.match(
/<meta\s+name=["']description["']\s+content=["'](.*?)["']/i
);
const title = titleMatch ? titleMatch[1] : '';
const description = descMatch ? descMatch[1] : '';
return { title, description }; return { title, description };
} catch (error) { } catch (error) {
@ -101,6 +105,7 @@ export async function fetchMetadata(url: string) {
} }
} }
type SearchGroupId = 'web' | 'academic' | 'shopping' | 'youtube' | 'x' | 'writing'; type SearchGroupId = 'web' | 'academic' | 'shopping' | 'youtube' | 'x' | 'writing';
const groupTools = { 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. Focus on peer-reviewed papers, citations, and academic sources.
Do not talk in bullet points or lists at all costs as it unpresentable. Do not talk in bullet points or lists at all costs as it unpresentable.
Provide summaries, key points, and references. 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. 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" })}. 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. Do not talk in bullet points or lists at all costs.
Provide important details and summaries of the videos in paragraphs. 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. 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.`, 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. 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" })}. 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. 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; 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.`, 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.`, 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; } as const;

View File

@ -1,4 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { generateObject } from 'ai';
import { groq } from '@ai-sdk/groq'
import { z } from 'zod';
export interface TrendingQuery { export interface TrendingQuery {
icon: string; icon: string;
@ -28,11 +31,30 @@ async function fetchGoogleTrends(): Promise<TrendingQuery[]> {
const xmlText = await response.text(); const xmlText = await response.text();
const items = xmlText.match(/<title>(?!Daily Search Trends)(.*?)<\/title>/g) || []; const items = xmlText.match(/<title>(?!Daily Search Trends)(.*?)<\/title>/g) || [];
return items.map(item => ({ const categories = ['trending', 'community', 'science', 'tech', 'travel', 'politics', 'health', 'sports', 'finance', 'football'] as const;
icon: 'trending',
text: item.replace(/<\/?title>/g, ''), const schema = z.object({
category: 'trending' // TODO: add category based on the query results 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) { } catch (error) {
console.error(`Failed to fetch Google Trends for geo: ${geo}`, error); console.error(`Failed to fetch Google Trends for geo: ${geo}`, error);
return []; return [];
@ -55,7 +77,7 @@ async function fetchRedditQuestions(): Promise<TrendingQuery[]> {
} }
} }
); );
const data = await response.json(); const data = await response.json();
const maxLength = 50; const maxLength = 50;
@ -74,16 +96,16 @@ async function fetchRedditQuestions(): Promise<TrendingQuery[]> {
} }
async function fetchFromMultipleSources() { async function fetchFromMultipleSources() {
const [googleTrends, const [googleTrends,
// redditQuestions // redditQuestions
] = await Promise.all([ ] = await Promise.all([
fetchGoogleTrends(), fetchGoogleTrends(),
// fetchRedditQuestions(), // fetchRedditQuestions(),
]); ]);
const allQueries = [...googleTrends, const allQueries = [...googleTrends,
// ...redditQuestions // ...redditQuestions
]; ];
return allQueries return allQueries
.sort(() => Math.random() - 0.5); .sort(() => Math.random() - 0.5);
} }
@ -91,7 +113,7 @@ async function fetchFromMultipleSources() {
export async function GET() { export async function GET() {
try { try {
const trends = await fetchFromMultipleSources(); const trends = await fetchFromMultipleSources();
if (trends.length === 0) { if (trends.length === 0) {
// Fallback queries if both sources fail // Fallback queries if both sources fail
return NextResponse.json([ return NextResponse.json([
@ -112,7 +134,7 @@ export async function GET() {
} }
]); ]);
} }
return NextResponse.json(trends); return NextResponse.json(trends);
} catch (error) { } catch (error) {
console.error('Failed to fetch trends:', error); console.error('Failed to fetch trends:', error);

View File

@ -15,6 +15,7 @@ React,
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import Marked, { ReactRenderer } from 'marked-react'; import Marked, { ReactRenderer } from 'marked-react';
import Latex from 'react-latex-next';
import { track } from '@vercel/analytics'; import { track } from '@vercel/analytics';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useChat } from 'ai/react'; import { useChat } from 'ai/react';
@ -101,7 +102,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } 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 { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer";
import { GitHubLogoIcon } from '@radix-ui/react-icons'; import { GitHubLogoIcon } from '@radix-ui/react-icons';
import Link from 'next/link'; import Link from 'next/link';
@ -120,7 +121,7 @@ import WeatherChart from '@/components/weather-chart';
import InteractiveChart from '@/components/interactive-charts'; import InteractiveChart from '@/components/interactive-charts';
import { MapComponent, MapContainer, MapSkeleton } from '@/components/map-components'; import { MapComponent, MapContainer, MapSkeleton } from '@/components/map-components';
import MultiSearch from '@/components/multi-search'; 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 { BorderTrail } from '@/components/core/border-trail';
import { TextShimmer } from '@/components/core/text-shimmer'; import { TextShimmer } from '@/components/core/text-shimmer';
import { Tweet } from 'react-tweet'; import { Tweet } from 'react-tweet';
@ -587,6 +588,32 @@ const HomeContent = () => {
const initializedRef = useRef(false); const initializedRef = useRef(false);
const [selectedGroup, setSelectedGroup] = useState<SearchGroupId>('web'); 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 { theme } = useTheme();
const [openChangelog, setOpenChangelog] = useState(false); const [openChangelog, setOpenChangelog] = useState(false);
@ -633,10 +660,25 @@ const HomeContent = () => {
useEffect(() => { useEffect(() => {
const fetchTrending = async () => { const fetchTrending = async () => {
// Check cache first
const cached = getTrendingQueriesFromCache();
if (cached) {
setTrendingQueries(cached.data);
return;
}
try { try {
const res = await fetch('/api/trending'); const res = await fetch('/api/trending');
if (!res.ok) throw new Error('Failed to fetch trending queries'); if (!res.ok) throw new Error('Failed to fetch trending queries');
const data = await res.json(); const data = await res.json();
// Store in cache
const cacheData: TrendingQueriesCache = {
data,
timestamp: Date.now()
};
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
setTrendingQueries(data); setTrendingQueries(data);
} catch (error) { } catch (error) {
console.error('Error fetching trending queries:', 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]); }, [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) => { const fetchMetadataWithCache = useCallback(async (url: string) => {
if (metadataCache[url]) { if (metadataCache[url]) {
return 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> = { 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) { 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) { code(children, language) {
return <CodeBlock language={language}>{String(children)}</CodeBlock>; 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<{ const SuggestionCards: React.FC<{
selectedModel: string; selectedModel: string;
trendingQueries: TrendingQuery[]; trendingQueries: TrendingQuery[];
}> = ({ selectedModel, trendingQueries }) => { }> = ({ selectedModel, trendingQueries }) => {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [isPaused, setIsPaused] = useState(false); const [isPaused, setIsPaused] = useState(false);
const scrollIntervalRef = useRef<NodeJS.Timeout>(); const scrollIntervalRef = useRef<NodeJS.Timeout>();
const [isTouchDevice, setIsTouchDevice] = useState(false);
useEffect(() => { useEffect(() => {
setIsLoading(false); setIsLoading(false);
setIsTouchDevice('ontouchstart' in window);
}, [trendingQueries]); }, [trendingQueries]);
useEffect(() => { useEffect(() => {
if (isTouchDevice) return; // Disable auto-scroll on touch devices
const startScrolling = () => { const startScrolling = () => {
if (!scrollRef.current || isPaused) return; 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 () => { return () => {
if (scrollIntervalRef.current) { if (scrollIntervalRef.current) {
clearInterval(scrollIntervalRef.current); clearInterval(scrollIntervalRef.current);
} }
}; };
}, [isPaused]); }, [isPaused, isTouchDevice]);
const getCardWidth = (text: string) => {
const charWidth = 8;
const padding = 32;
const iconWidth = 28;
return Math.min(
padding + iconWidth + (text.length * charWidth),
400
);
};
if (isLoading || trendingQueries.length === 0) { if (isLoading || trendingQueries.length === 0) {
return ( return (
<div className="flex gap-2 mt-4"> <div className="relative mt-4 px-0">
{[1, 2, 3].map((_, index) => ( {/* Overlay with Loading Text */}
<div <div className="absolute inset-0 z-10 flex items-center justify-center">
key={index} <div className="backdrop-blur-sm bg-white/30 dark:bg-black/30 rounded-2xl px-6 py-3 shadow-lg">
className="flex-shrink-0 w-[200px] bg-neutral-100 dark:bg-neutral-800 rounded-xl p-4 animate-pulse" <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" />
<div className="flex items-center space-x-2"> <span>Loading trending queries</span>
<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>
))} </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> </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" />, science: <Brain className="w-5 h-5" />,
tech: <Code className="w-5 h-5" />, tech: <Code className="w-5 h-5" />,
travel: <Globe 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 iconMap[category as keyof typeof iconMap] || <Sparkles className="w-5 h-5" />;
}; };
return ( return (
<div className="relative"> <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} ref={scrollRef}
className="flex gap-2 mt-4 overflow-x-auto pb-3 relative scroll-smooth no-scrollbar" className="flex gap-4 mt-4 overflow-x-auto pb-4 px-4 md:px-0 relative scroll-smooth no-scrollbar"
onMouseEnter={() => setIsPaused(true)} onMouseEnter={() => !isTouchDevice && setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)} onMouseLeave={() => !isTouchDevice && setIsPaused(false)}
onTouchStart={() => setIsPaused(true)}
onTouchEnd={() => setIsPaused(false)}
> >
{Array(20).fill(trendingQueries).flat().map((query, index) => ( {Array(20).fill(trendingQueries).flat().map((query, index) => (
<button <button
key={`${index}-${query.text}`} key={`${index}-${query.text}`}
onClick={() => handleExampleClick(query)} 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" className="group flex-shrink-0 bg-neutral-50/50 dark:bg-neutral-800/50
style={{ width: `${getCardWidth(query.text)}px` }} 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> <div className="flex items-center gap-3 text-neutral-700 dark:text-neutral-300">
<span className="text-sm font-medium whitespace-nowrap pr-1"> <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} {query.text}
</span> </span>
</div> </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(() => ( const memoizedSuggestionCards = useMemo(() => (
<SuggestionCards <SuggestionCards
selectedModel={selectedModel} selectedModel={selectedModel}
trendingQueries={trendingQueries} trendingQueries={trendingQueries}
/> />
), [selectedModel, 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} fileInputRef={fileInputRef}
inputRef={inputRef} inputRef={inputRef}
stop={stop} stop={stop}
messages={messages} messages={memoizedMessages}
append={append} append={append}
selectedModel={selectedModel} selectedModel={selectedModel}
setSelectedModel={handleModelChange} 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"> <div className="space-y-4 sm:space-y-6 mb-32">
{messages.map((message, index) => ( {memoizedMessages.map((message, index) => (
<div key={index}> <div key={index}>
{message.role === 'user' && ( {message.role === 'user' && (
<motion.div <motion.div

View File

@ -50,8 +50,8 @@ export function FlightTracker({ data }: FlightTrackerProps) {
const formatTime = (timestamp: string) => { const formatTime = (timestamp: string) => {
const date = new Date(timestamp); const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', { return date.toLocaleTimeString('en-US', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: false, hour12: false,
timeZone: 'UTC' timeZone: 'UTC'
@ -82,16 +82,38 @@ export function FlightTracker({ data }: FlightTrackerProps) {
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case "LANDED": 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": 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": 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: 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 = { const flightInfo = {
flightNumber: flight.flight.iata, flightNumber: flight.flight.iata,
status: mapStatus(flight.flight_status), status: mapStatus(flight.flight_status),
@ -111,7 +133,7 @@ export function FlightTracker({ data }: FlightTrackerProps) {
terminal: flight.arrival.terminal || undefined, terminal: flight.arrival.terminal || undefined,
gate: flight.arrival.gate || 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', { lastUpdated: new Date().toLocaleTimeString('en-US', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
@ -121,76 +143,135 @@ export function FlightTracker({ data }: FlightTrackerProps) {
}; };
return ( return (
<Card className="w-full max-w-2xl bg-card dark:bg-card"> <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-6"> <CardContent className="p-4 md:p-8">
<div className="flex justify-between items-center mb-6"> {/* Header */}
<div> <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">
<h2 className="text-2xl font-bold">{flightInfo.flightNumber}</h2> <div className="flex items-center gap-3">
<Badge className={getStatusColor(flightInfo.status)}> <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">
{flightInfo.status} <Plane className="h-5 w-5 md:h-6 md:w-6 text-blue-600 dark:text-blue-400" />
</Badge> </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> </div>
<div className="relative"> {/* Flight Details */}
<div className="flex justify-between items-center"> <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">
<div className="text-4xl font-mono">{flightInfo.departure.code}</div> {/* Departure Details */}
<motion.div <div className="space-y-3 max-w-full">
className="text-primary" {flightInfo.departure.terminal && (
initial={{ x: -50 }} <div className="flex items-center gap-2 overflow-hidden">
animate={{ x: 50 }} <Terminal className="h-4 w-4 flex-shrink-0 text-neutral-500" />
transition={{ duration: 2, repeat: Infinity, ease: "linear" }} <span className="text-sm truncate">Terminal {flightInfo.departure.terminal}</span>
> </div>
<Plane className="h-6 w-6" /> )}
</motion.div> {flightInfo.departure.gate && (
<div className="text-4xl font-mono">{flightInfo.arrival.code}</div> <div className="flex items-center gap-2 overflow-hidden">
</div> <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>
<div className="mt-8 grid grid-cols-2 gap-8"> <span className="text-sm truncate">Gate {flightInfo.departure.gate}</span>
<div> </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>
</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" /> <Clock className="h-4 w-4" />
<span>Duration: {flightInfo.duration}</span> <span>Flight duration: {flightInfo.duration}</span>
<span className="mx-2"></span>
<span>Last updated: {flightInfo.lastUpdated}</span>
</div> </div>
<span className="hidden md:inline text-neutral-300"></span>
<span>Last updated: {flightInfo.lastUpdated}</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -12,7 +12,7 @@
"@ai-sdk/anthropic": "^1.0.5", "@ai-sdk/anthropic": "^1.0.5",
"@ai-sdk/azure": "^1.0.10", "@ai-sdk/azure": "^1.0.10",
"@ai-sdk/cohere": "^1.0.3", "@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/groq": "^0.0.1",
"@ai-sdk/mistral": "^0.0.41", "@ai-sdk/mistral": "^0.0.41",
"@ai-sdk/openai": "^0.0.58", "@ai-sdk/openai": "^0.0.58",

View File

@ -15,8 +15,8 @@ dependencies:
specifier: ^1.0.3 specifier: ^1.0.3
version: 1.0.3(zod@3.24.1) version: 1.0.3(zod@3.24.1)
'@ai-sdk/google': '@ai-sdk/google':
specifier: ^1.0.7 specifier: ^1.0.11
version: 1.0.7(zod@3.24.1) version: 1.0.11(zod@3.24.1)
'@ai-sdk/groq': '@ai-sdk/groq':
specifier: ^0.0.1 specifier: ^0.0.1
version: 0.0.1(zod@3.24.1) version: 0.0.1(zod@3.24.1)
@ -311,8 +311,8 @@ packages:
zod: 3.24.1 zod: 3.24.1
dev: false dev: false
/@ai-sdk/google@1.0.7(zod@3.24.1): /@ai-sdk/google@1.0.11(zod@3.24.1):
resolution: {integrity: sha512-D2R/VFA0zpcWYnAoqYGaZn7XqHb8ASt1hZJ86u7BOVoBnQTRPRUYHb4lSXjrMcj/QYMXIJkKojfY4isenkku5Q==} resolution: {integrity: sha512-snp66p4BurhOmy2QUTlkZR8nFizx+F60t9v/2ld/fhxTK4G+QMHBUZpBujkW1gQEfE13fEOd43wCE1SQgP46Tw==}
engines: {node: '>=18'} engines: {node: '>=18'}
peerDependencies: peerDependencies:
zod: ^3.0.0 zod: ^3.0.0