188 lines
6.2 KiB
TypeScript
188 lines
6.2 KiB
TypeScript
import { createFileRoute } from '@tanstack/react-router'
|
|
import { useInfiniteQuery } from '@tanstack/react-query'
|
|
import { fetchArticles } from '@/api/articles'
|
|
import { Button } from '@/components/ui/button'
|
|
import { addArticleToCollection, createCollection, fetchCollections } from '@/api/collections';
|
|
import { useUser } from '@/store/user';
|
|
import { useNavigate } from '@tanstack/react-router';
|
|
import { Article } from '@/types';
|
|
import { useEffect, useRef, useCallback } from 'react';
|
|
|
|
export const Route = createFileRoute('/articles')({
|
|
component: ArticlesPage
|
|
})
|
|
|
|
async function handleAddToCollection(
|
|
article: Article,
|
|
userId: string | undefined,
|
|
navigate: ReturnType<typeof useNavigate>
|
|
) {
|
|
if (!userId) {
|
|
alert('Please login to add articles to collections');
|
|
navigate({ to: '/login' });
|
|
return;
|
|
}
|
|
|
|
const collectionName = prompt('Enter Collection Name:');
|
|
if (!collectionName) return;
|
|
|
|
try {
|
|
// Fetch existing collections
|
|
const collections = await fetchCollections();
|
|
let collection = collections.find((col) => col.name === collectionName);
|
|
|
|
// If collection does not exist, create it
|
|
if (!collection) {
|
|
collection = await createCollection({ name: collectionName, userId });
|
|
alert(`Collection '${collectionName}' created successfully!`);
|
|
}
|
|
|
|
// Add article to the collection
|
|
await addArticleToCollection(collection.id, {
|
|
title: article.title,
|
|
content: article.content,
|
|
source: article.source,
|
|
url: article.url,
|
|
publishedAt: new Date(article.publishedAt)
|
|
});
|
|
alert(`Article added to collection '${collectionName}' successfully!`);
|
|
} catch (error: unknown) {
|
|
console.error('Failed to add article to collection:', error);
|
|
alert('Failed to add article to collection. Please try again.');
|
|
}
|
|
}
|
|
|
|
function ArticlesPage() {
|
|
const {
|
|
data,
|
|
isLoading,
|
|
isError,
|
|
error,
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isFetchingNextPage
|
|
} = useInfiniteQuery({
|
|
queryKey: ['articles'],
|
|
queryFn: async ({ pageParam = undefined }) => {
|
|
const response = await fetchArticles(pageParam);
|
|
return response;
|
|
},
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
initialPageParam: undefined as string | undefined,
|
|
staleTime: 1000 * 60 // Cache for 1 minute
|
|
});
|
|
|
|
const user = useUser();
|
|
const navigate = useNavigate();
|
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
|
|
|
const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => {
|
|
const [target] = entries;
|
|
if (target.isIntersecting && hasNextPage && !isFetchingNextPage) {
|
|
void fetchNextPage();
|
|
}
|
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
|
|
|
useEffect(() => {
|
|
const element = loadMoreRef.current;
|
|
const observer = new IntersectionObserver(handleObserver, {
|
|
root: null,
|
|
rootMargin: '0px',
|
|
threshold: 0.1,
|
|
});
|
|
|
|
if (element) {
|
|
observer.observe(element);
|
|
}
|
|
|
|
return () => {
|
|
if (element) {
|
|
observer.unobserve(element);
|
|
}
|
|
};
|
|
}, [handleObserver]);
|
|
|
|
// Show initial loading state
|
|
if (isLoading && !data?.pages?.length) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin w-8 h-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isError) {
|
|
return (
|
|
<div className="p-6 bg-red-50 border border-red-200 rounded-lg">
|
|
<h2 className="text-xl font-semibold text-red-800">Error</h2>
|
|
<p className="text-red-600">Failed to load articles: {error?.message || 'Unknown error'}</p>
|
|
<Button variant="outline" className="mt-4" onClick={() => window.location.reload()}>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const allArticles = data?.pages.flatMap(page => page.articles) ?? [];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">News Articles</h1>
|
|
<p className="text-muted-foreground mt-2">Browse the latest news from various sources</p>
|
|
</div>
|
|
|
|
{allArticles.length > 0 ? (
|
|
<>
|
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
{allArticles.map(article => (
|
|
<div
|
|
key={article.id}
|
|
className="rounded-lg border bg-card shadow-sm overflow-hidden hover:shadow-md transition-shadow"
|
|
>
|
|
<div className="p-6">
|
|
<h2 className="text-xl font-semibold mb-2">{article.title}</h2>
|
|
<p className="text-muted-foreground text-sm mb-4 line-clamp-2">
|
|
{article.content?.substring(0, 120)}...
|
|
</p>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-muted-foreground">{article.source}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{new Date(article.publishedAt).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-4 w-full"
|
|
onClick={() => handleAddToCollection(article, user?.id, navigate)}
|
|
>
|
|
Add to Collection
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Loading more trigger and indicator */}
|
|
<div ref={loadMoreRef} className="flex justify-center py-8">
|
|
{isFetchingNextPage ? (
|
|
<div className="animate-spin w-6 h-6 border-3 border-primary border-t-transparent rounded-full"></div>
|
|
) : hasNextPage ? (
|
|
<span className="text-sm text-muted-foreground">Scroll to load more</span>
|
|
) : (
|
|
<span className="text-sm text-muted-foreground">No more articles to load</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-center p-12 border rounded-lg bg-background">
|
|
<h3 className="text-lg font-medium">No articles found</h3>
|
|
<p className="text-muted-foreground mt-1">Check back later for new content</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|