Преглед изворни кода

feat: Implement Feed Detail page with pagination

gpt-engineer-app[bot] пре 2 месеци
родитељ
комит
2c1b6a1468

+ 2 - 0
src/App.tsx

@@ -6,6 +6,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
 import Index from "./pages/Index";
 import FeedsManagement from "./pages/FeedsManagement";
 import Pinned from "./pages/Pinned";
+import FeedDetail from "./pages/FeedDetail";
 import NotFound from "./pages/NotFound";
 import Auth from "./pages/Auth";
 
@@ -21,6 +22,7 @@ const App = () => (
           <Route path="/" element={<Index />} />
           <Route path="/feeds" element={<FeedsManagement />} />
           <Route path="/pinned" element={<Pinned />} />
+          <Route path="/feed/:feedId" element={<FeedDetail />} />
           <Route path="/auth" element={<Auth />} />
           {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
           <Route path="*" element={<NotFound />} />

+ 16 - 2
src/components/NewsCard.tsx

@@ -12,13 +12,15 @@ interface NewsCardProps {
   onMarkAsRead: (id: string) => void;
   onDelete: (id: string) => void;
   onOpenArticle: (article: NewsItem) => void;
+  onSourceClick?: (feedId: string, feedName: string) => void;
 }
 const NewsCard = ({
   news,
   onTogglePin,
   onMarkAsRead,
   onDelete,
-  onOpenArticle
+  onOpenArticle,
+  onSourceClick
 }: NewsCardProps) => {
   const {
     user
@@ -111,7 +113,19 @@ const NewsCard = ({
         
         <div className="flex items-center justify-between pt-2">
           <div className="flex items-center gap-2 text-xs text-muted-foreground">
-            <Badge variant="outline" className={getSourceColor(news.category)}>
+            <Badge 
+              variant="outline" 
+              className={cn(
+                getSourceColor(news.category),
+                onSourceClick && news.feedId && "cursor-pointer hover:opacity-80 transition-opacity"
+              )}
+              onClick={(e) => {
+                if (onSourceClick && news.feedId) {
+                  e.stopPropagation();
+                  onSourceClick(news.feedId, news.source);
+                }
+              }}
+            >
               {news.source}
             </Badge>
             <span>

+ 220 - 0
src/hooks/useFeedArticles.tsx

@@ -0,0 +1,220 @@
+import { useState, useEffect } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { useAuth } from './useAuth';
+import { NewsItem } from '@/types/news';
+import { toast } from 'sonner';
+
+const ARTICLES_PER_PAGE = 20;
+
+interface FeedInfo {
+  name: string;
+  description: string | null;
+  category: string;
+  type: string;
+}
+
+export function useFeedArticles(feedId: string, page: number = 1) {
+  const [articles, setArticles] = useState<NewsItem[]>([]);
+  const [feedInfo, setFeedInfo] = useState<FeedInfo | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [totalCount, setTotalCount] = useState(0);
+  const { user } = useAuth();
+
+  const fetchFeedArticles = async () => {
+    try {
+      setLoading(true);
+      console.log('🔄 Fetching articles for feed:', feedId, 'page:', page);
+
+      // Fetch feed info
+      const { data: feed, error: feedError } = await supabase
+        .from('feeds')
+        .select('name, description, category, type')
+        .eq('id', feedId)
+        .single();
+
+      if (feedError) {
+        console.error('❌ Error fetching feed info:', feedError);
+        toast.error('Flux introuvable');
+        return;
+      }
+
+      setFeedInfo(feed);
+
+      // Get total count for pagination
+      const { count } = await supabase
+        .from('articles')
+        .select('*', { count: 'exact', head: true })
+        .eq('feed_id', feedId);
+
+      setTotalCount(count || 0);
+
+      // Calculate pagination
+      const from = (page - 1) * ARTICLES_PER_PAGE;
+      const to = from + ARTICLES_PER_PAGE - 1;
+
+      // Fetch articles with pagination
+      let query = supabase
+        .from('articles')
+        .select(`
+          *,
+          feeds!inner(name, category),
+          user_articles(is_read, is_pinned)
+        `)
+        .eq('feed_id', feedId)
+        .order('published_at', { ascending: false })
+        .range(from, to);
+
+      const { data: articlesData, error: articlesError } = await query;
+
+      if (articlesError) {
+        console.error('❌ Error fetching articles:', articlesError);
+        toast.error('Erreur lors du chargement des articles');
+        return;
+      }
+
+      console.log('📰 Articles found:', articlesData?.length || 0);
+
+      // Transform to NewsItem format
+      const transformedArticles: NewsItem[] = articlesData
+        ?.map(article => ({
+          id: article.id,
+          title: article.title,
+          description: article.description || '',
+          content: article.content || '',
+          source: article.feeds.name,
+          category: article.feeds.category as NewsItem['category'],
+          publishedAt: article.published_at,
+          readTime: article.read_time || 5,
+          isPinned: user ? (article.user_articles[0]?.is_pinned || false) : false,
+          isRead: user ? (article.user_articles[0]?.is_read || false) : false,
+          url: article.url || undefined,
+          imageUrl: article.image_url || undefined,
+          feedId: article.feed_id
+        })) || [];
+
+      setArticles(transformedArticles);
+    } catch (error) {
+      console.error('💥 Error in fetchFeedArticles:', error);
+      toast.error('Erreur lors du chargement des articles');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const togglePin = async (articleId: string) => {
+    if (!user) {
+      toast.error('Vous devez être connecté pour épingler un article');
+      return;
+    }
+
+    try {
+      const article = articles.find(a => a.id === articleId);
+      if (!article) return;
+
+      const { error } = await supabase
+        .from('user_articles')
+        .upsert({
+          user_id: user.id,
+          article_id: articleId,
+          is_pinned: !article.isPinned,
+          is_read: article.isRead
+        }, {
+          onConflict: 'user_id,article_id'
+        });
+
+      if (error) {
+        toast.error('Erreur lors de la mise à jour');
+        return;
+      }
+
+      setArticles(prev => prev.map(item => 
+        item.id === articleId ? { ...item, isPinned: !item.isPinned } : item
+      ));
+      
+      toast.success(article.isPinned ? "Article retiré des épinglés" : "Article épinglé");
+    } catch (error) {
+      console.error('Error toggling pin:', error);
+      toast.error('Erreur lors de la mise à jour');
+    }
+  };
+
+  const markAsRead = async (articleId: string) => {
+    if (!user) return;
+
+    try {
+      const article = articles.find(a => a.id === articleId);
+      if (!article || article.isRead) return;
+
+      const { error } = await supabase
+        .from('user_articles')
+        .upsert({
+          user_id: user.id,
+          article_id: articleId,
+          is_read: true,
+          is_pinned: article.isPinned,
+          read_at: new Date().toISOString()
+        }, {
+          onConflict: 'user_id,article_id'
+        });
+
+      if (error) {
+        console.error('Error marking as read:', error);
+        return;
+      }
+
+      setArticles(prev => prev.map(item => 
+        item.id === articleId ? { ...item, isRead: true } : item
+      ));
+      toast.success("Article marqué comme lu");
+    } catch (error) {
+      console.error('Error marking as read:', error);
+    }
+  };
+
+  const deleteArticle = async (articleId: string) => {
+    if (!user) {
+      toast.error('Vous devez être connecté pour supprimer un article');
+      return;
+    }
+
+    try {
+      const { error } = await supabase
+        .from('user_articles')
+        .delete()
+        .eq('user_id', user.id)
+        .eq('article_id', articleId);
+
+      if (error) {
+        toast.error('Erreur lors de la suppression');
+        return;
+      }
+
+      setArticles(prev => prev.filter(item => item.id !== articleId));
+      toast.success("Article supprimé de votre vue");
+    } catch (error) {
+      console.error('Error deleting article:', error);
+      toast.error('Erreur lors de la suppression');
+    }
+  };
+
+  useEffect(() => {
+    if (feedId) {
+      fetchFeedArticles();
+    }
+  }, [feedId, page, user]);
+
+  const totalPages = Math.ceil(totalCount / ARTICLES_PER_PAGE);
+
+  return {
+    articles,
+    feedInfo,
+    loading,
+    totalCount,
+    totalPages,
+    currentPage: page,
+    togglePin,
+    markAsRead,
+    deleteArticle,
+    refetch: fetchFeedArticles
+  };
+}

+ 4 - 2
src/hooks/useRealArticles.tsx

@@ -147,7 +147,8 @@ export function useRealArticles(dateFilter?: 'today' | 'yesterday' | null, showF
             isPinned: article.user_articles[0]?.is_pinned || false,
             isRead: article.user_articles[0]?.is_read || false,
             url: article.url || undefined,
-            imageUrl: article.image_url || undefined
+            imageUrl: article.image_url || undefined,
+            feedId: article.feed_id
           })) || [];
 
         setArticles(transformedArticles);
@@ -260,7 +261,8 @@ export function useRealArticles(dateFilter?: 'today' | 'yesterday' | null, showF
             isPinned: user ? (article.user_articles[0]?.is_pinned || false) : false,
             isRead: user ? (article.user_articles[0]?.is_read || false) : false,
             url: article.url || undefined,
-            imageUrl: article.image_url || undefined
+            imageUrl: article.image_url || undefined,
+            feedId: article.feed_id
            })) || [];
 
         console.log('✅ After filtering - Final articles:', {

+ 223 - 0
src/pages/FeedDetail.tsx

@@ -0,0 +1,223 @@
+import { useState } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useFeedArticles } from '@/hooks/useFeedArticles';
+import NewsCard from '@/components/NewsCard';
+import ArticleModal from '@/components/ArticleModal';
+import { NewsItem } from '@/types/news';
+import { Button } from '@/components/ui/button';
+import { ArrowLeft, Loader2 } from 'lucide-react';
+import {
+  Pagination,
+  PaginationContent,
+  PaginationEllipsis,
+  PaginationItem,
+  PaginationLink,
+  PaginationNext,
+  PaginationPrevious,
+} from '@/components/ui/pagination';
+
+const FeedDetail = () => {
+  const { feedId } = useParams<{ feedId: string }>();
+  const navigate = useNavigate();
+  const [currentPage, setCurrentPage] = useState(1);
+  const [selectedArticle, setSelectedArticle] = useState<NewsItem | null>(null);
+  
+  const {
+    articles,
+    feedInfo,
+    loading,
+    totalCount,
+    totalPages,
+    togglePin,
+    markAsRead,
+    deleteArticle
+  } = useFeedArticles(feedId || '', currentPage);
+
+  const handleOpenArticle = (article: NewsItem) => {
+    setSelectedArticle(article);
+  };
+
+  const handleCloseArticleModal = () => {
+    setSelectedArticle(null);
+  };
+
+  const handlePageChange = (page: number) => {
+    setCurrentPage(page);
+    window.scrollTo({ top: 0, behavior: 'smooth' });
+  };
+
+  const renderPaginationItems = () => {
+    const items = [];
+    const maxVisible = 5;
+    
+    if (totalPages <= maxVisible) {
+      for (let i = 1; i <= totalPages; i++) {
+        items.push(
+          <PaginationItem key={i}>
+            <PaginationLink
+              onClick={() => handlePageChange(i)}
+              isActive={currentPage === i}
+            >
+              {i}
+            </PaginationLink>
+          </PaginationItem>
+        );
+      }
+    } else {
+      // Always show first page
+      items.push(
+        <PaginationItem key={1}>
+          <PaginationLink
+            onClick={() => handlePageChange(1)}
+            isActive={currentPage === 1}
+          >
+            1
+          </PaginationLink>
+        </PaginationItem>
+      );
+
+      // Show ellipsis or pages around current
+      if (currentPage > 3) {
+        items.push(<PaginationEllipsis key="ellipsis-1" />);
+      }
+
+      const start = Math.max(2, currentPage - 1);
+      const end = Math.min(totalPages - 1, currentPage + 1);
+
+      for (let i = start; i <= end; i++) {
+        items.push(
+          <PaginationItem key={i}>
+            <PaginationLink
+              onClick={() => handlePageChange(i)}
+              isActive={currentPage === i}
+            >
+              {i}
+            </PaginationLink>
+          </PaginationItem>
+        );
+      }
+
+      if (currentPage < totalPages - 2) {
+        items.push(<PaginationEllipsis key="ellipsis-2" />);
+      }
+
+      // Always show last page
+      items.push(
+        <PaginationItem key={totalPages}>
+          <PaginationLink
+            onClick={() => handlePageChange(totalPages)}
+            isActive={currentPage === totalPages}
+          >
+            {totalPages}
+          </PaginationLink>
+        </PaginationItem>
+      );
+    }
+
+    return items;
+  };
+
+  if (loading) {
+    return (
+      <div className="min-h-screen flex items-center justify-center">
+        <Loader2 className="h-8 w-8 animate-spin text-primary" />
+      </div>
+    );
+  }
+
+  if (!feedInfo) {
+    return (
+      <div className="min-h-screen flex flex-col items-center justify-center gap-4">
+        <h1 className="text-2xl font-bold">Flux introuvable</h1>
+        <Button onClick={() => navigate('/')}>
+          <ArrowLeft className="h-4 w-4 mr-2" />
+          Retour à l'accueil
+        </Button>
+      </div>
+    );
+  }
+
+  return (
+    <div className="min-h-screen bg-background">
+      <div className="container mx-auto px-4 py-8">
+        {/* Header with back button and feed info */}
+        <div className="mb-8 space-y-4">
+          <Button 
+            variant="ghost" 
+            onClick={() => navigate('/')}
+            className="gap-2"
+          >
+            <ArrowLeft className="h-4 w-4" />
+            Retour
+          </Button>
+
+          <div>
+            <h1 className="text-3xl font-bold mb-2">{feedInfo.name}</h1>
+            {feedInfo.description && (
+              <p className="text-muted-foreground">{feedInfo.description}</p>
+            )}
+            <p className="text-sm text-muted-foreground mt-2">
+              {totalCount} article{totalCount > 1 ? 's' : ''} au total
+            </p>
+          </div>
+        </div>
+
+        {/* Articles grid */}
+        {articles.length === 0 ? (
+          <div className="text-center py-12">
+            <p className="text-muted-foreground">Aucun article disponible pour ce flux</p>
+          </div>
+        ) : (
+          <>
+            <div className="grid gap-6 mb-8">
+              {articles.map((article) => (
+                <NewsCard
+                  key={article.id}
+                  news={article}
+                  onTogglePin={togglePin}
+                  onMarkAsRead={markAsRead}
+                  onDelete={deleteArticle}
+                  onOpenArticle={handleOpenArticle}
+                />
+              ))}
+            </div>
+
+            {/* Pagination */}
+            {totalPages > 1 && (
+              <div className="flex justify-center">
+                <Pagination>
+                  <PaginationContent>
+                    <PaginationItem>
+                      <PaginationPrevious
+                        onClick={() => currentPage > 1 && handlePageChange(currentPage - 1)}
+                        className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
+                      />
+                    </PaginationItem>
+                    
+                    {renderPaginationItems()}
+                    
+                    <PaginationItem>
+                      <PaginationNext
+                        onClick={() => currentPage < totalPages && handlePageChange(currentPage + 1)}
+                        className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
+                      />
+                    </PaginationItem>
+                  </PaginationContent>
+                </Pagination>
+              </div>
+            )}
+          </>
+        )}
+      </div>
+
+      {/* Article Modal */}
+      <ArticleModal
+        article={selectedArticle}
+        isOpen={!!selectedArticle}
+        onClose={handleCloseArticleModal}
+      />
+    </div>
+  );
+};
+
+export default FeedDetail;

+ 7 - 2
src/pages/Index.tsx

@@ -15,11 +15,12 @@ import { Card, CardContent } from '@/components/ui/card';
 import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer';
 import { RefreshCw, Filter, User, Rss, Plus } from 'lucide-react';
 import { toast } from 'sonner';
-import { Link } from 'react-router-dom';
+import { Link, useNavigate } from 'react-router-dom';
 const Index = () => {
   const {
     user
   } = useAuth();
+  const navigate = useNavigate();
   const [dateFilter, setDateFilter] = useState<'today' | 'yesterday' | null>(null);
   const [showFollowedOnly, setShowFollowedOnly] = useState(false);
   const [showReadArticles, setShowReadArticles] = useState(false);
@@ -102,6 +103,10 @@ const Index = () => {
     setIsArticleModalOpen(false);
     setSelectedArticle(null);
   };
+
+  const handleSourceClick = (feedId: string, feedName: string) => {
+    navigate(`/feed/${feedId}`);
+  };
   if (loading) {
     return <div className="min-h-screen bg-background flex items-center justify-center">
         <div className="flex items-center gap-2">
@@ -294,7 +299,7 @@ const Index = () => {
                     </Button>
                   </div>}
               </div> : <div className="space-y-4">
-                {regularArticles.map(item => <NewsCard key={item.id} news={item} onTogglePin={togglePin} onMarkAsRead={markAsRead} onDelete={deleteArticle} onOpenArticle={handleOpenArticle} />)}
+                {regularArticles.map(item => <NewsCard key={item.id} news={item} onTogglePin={togglePin} onMarkAsRead={markAsRead} onDelete={deleteArticle} onOpenArticle={handleOpenArticle} onSourceClick={handleSourceClick} />)}
               </div>}
           </div>
         </div>

+ 1 - 0
src/types/news.ts

@@ -12,6 +12,7 @@ export interface NewsItem {
   isRead: boolean;
   url?: string;
   imageUrl?: string;
+  feedId?: string;
 }
 
 export interface NewsCategory {