소스 검색

Run SQL to create tables

gpt-engineer-app[bot] 5 달 전
부모
커밋
cc176e0df5

+ 172 - 0
src/hooks/useEnhancedFeeds.tsx

@@ -0,0 +1,172 @@
+
+import { useState, useEffect } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { Feed } from '@/types/feed';
+import { useAuth } from './useAuth';
+import { toast } from 'sonner';
+
+export function useEnhancedFeeds() {
+  const [feeds, setFeeds] = useState<Feed[]>([]);
+  const [loading, setLoading] = useState(true);
+  const { user } = useAuth();
+
+  const fetchFeeds = async () => {
+    try {
+      setLoading(true);
+      
+      // Fetch all feeds with article count
+      const { data: feedsData, error: feedsError } = await supabase
+        .from('feeds')
+        .select(`
+          *,
+          articles(count)
+        `)
+        .order('name');
+
+      if (feedsError) {
+        toast.error('Erreur lors du chargement des flux');
+        console.error('Error fetching feeds:', feedsError);
+        return;
+      }
+
+      // If user is authenticated, fetch their subscriptions
+      let userFeedsData = null;
+      if (user) {
+        const { data, error } = await supabase
+          .from('user_feeds')
+          .select('feed_id, is_followed')
+          .eq('user_id', user.id);
+
+        if (error) {
+          console.error('Error fetching user feeds:', error);
+        } else {
+          userFeedsData = data;
+        }
+      }
+
+      // Combine feeds with user subscription status
+      const combinedFeeds = feedsData.map(feed => ({
+        id: feed.id,
+        name: feed.name,
+        url: feed.url,
+        type: feed.type as Feed['type'],
+        description: feed.description,
+        category: feed.category,
+        isFollowed: userFeedsData?.find(uf => uf.feed_id === feed.id)?.is_followed || false,
+        lastUpdated: feed.last_updated || feed.created_at,
+        articleCount: feed.articles?.[0]?.count || 0,
+        status: feed.status as Feed['status']
+      }));
+
+      setFeeds(combinedFeeds);
+    } catch (error) {
+      console.error('Error in fetchFeeds:', error);
+      toast.error('Erreur lors du chargement des flux');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const toggleFollow = async (feedId: string) => {
+    if (!user) {
+      toast.error('Vous devez être connecté pour suivre un flux');
+      return;
+    }
+
+    try {
+      const feed = feeds.find(f => f.id === feedId);
+      if (!feed) return;
+
+      // Check if subscription exists
+      const { data: existingSubscription } = await supabase
+        .from('user_feeds')
+        .select('*')
+        .eq('user_id', user.id)
+        .eq('feed_id', feedId)
+        .single();
+
+      if (existingSubscription) {
+        // Update existing subscription
+        const { error } = await supabase
+          .from('user_feeds')
+          .update({ is_followed: !feed.isFollowed })
+          .eq('user_id', user.id)
+          .eq('feed_id', feedId);
+
+        if (error) {
+          toast.error('Erreur lors de la mise à jour');
+          return;
+        }
+      } else {
+        // Create new subscription
+        const { error } = await supabase
+          .from('user_feeds')
+          .insert({
+            user_id: user.id,
+            feed_id: feedId,
+            is_followed: true
+          });
+
+        if (error) {
+          toast.error('Erreur lors de l\'ajout');
+          return;
+        }
+      }
+
+      // Update local state
+      setFeeds(prev => prev.map(f => 
+        f.id === feedId 
+          ? { ...f, isFollowed: !f.isFollowed }
+          : f
+      ));
+
+      toast.success(
+        feed.isFollowed 
+          ? `Vous ne suivez plus "${feed.name}"` 
+          : `Vous suivez maintenant "${feed.name}"`
+      );
+    } catch (error) {
+      console.error('Error toggling follow:', error);
+      toast.error('Erreur lors de la mise à jour');
+    }
+  };
+
+  const fetchFeedContent = async (feedId: string) => {
+    try {
+      const feed = feeds.find(f => f.id === feedId);
+      if (!feed) return;
+
+      toast.info(`Récupération du contenu de "${feed.name}"...`);
+      
+      const { data, error } = await supabase.functions.invoke('fetch-rss', {
+        body: { feedId, feedUrl: feed.url }
+      });
+
+      if (error) {
+        throw error;
+      }
+
+      if (data.success) {
+        toast.success(`${data.articlesProcessed} articles récupérés pour "${feed.name}"`);
+        await fetchFeeds(); // Refresh to update article counts
+      } else {
+        throw new Error(data.error || 'Erreur lors de la récupération RSS');
+      }
+    } catch (error) {
+      console.error('Error fetching feed content:', error);
+      toast.error('Erreur lors de la récupération du contenu');
+    }
+  };
+
+  useEffect(() => {
+    fetchFeeds();
+  }, [user]);
+
+  return {
+    feeds,
+    loading,
+    toggleFollow,
+    fetchFeedContent,
+    refetch: fetchFeeds
+  };
+}

+ 249 - 0
src/hooks/useRealArticles.tsx

@@ -0,0 +1,249 @@
+
+import { useState, useEffect } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { useAuth } from './useAuth';
+import { NewsItem } from '@/types/news';
+import { toast } from 'sonner';
+
+export function useRealArticles() {
+  const [articles, setArticles] = useState<NewsItem[]>([]);
+  const [loading, setLoading] = useState(true);
+  const { user } = useAuth();
+
+  const fetchArticles = async () => {
+    try {
+      setLoading(true);
+      
+      if (user) {
+        // For authenticated users, fetch articles from their followed feeds
+        const { data: userFeeds, error: userFeedsError } = await supabase
+          .from('user_feeds')
+          .select('feed_id')
+          .eq('user_id', user.id)
+          .eq('is_followed', true);
+
+        if (userFeedsError) {
+          console.error('Error fetching user feeds:', userFeedsError);
+          toast.error('Erreur lors du chargement de vos flux');
+          return;
+        }
+
+        const followedFeedIds = userFeeds?.map(uf => uf.feed_id) || [];
+        
+        if (followedFeedIds.length === 0) {
+          setArticles([]);
+          return;
+        }
+
+        // Fetch articles from followed feeds with user interactions
+        const { data: articlesData, error: articlesError } = await supabase
+          .from('articles')
+          .select(`
+            *,
+            feeds!inner(name, category),
+            user_articles(is_read, is_pinned)
+          `)
+          .in('feed_id', followedFeedIds)
+          .order('published_at', { ascending: false })
+          .limit(100);
+
+        if (articlesError) {
+          console.error('Error fetching articles:', articlesError);
+          toast.error('Erreur lors du chargement des articles');
+          return;
+        }
+
+        // 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: article.user_articles[0]?.is_pinned || false,
+          isRead: article.user_articles[0]?.is_read || false,
+          url: article.url || undefined,
+          imageUrl: article.image_url || undefined
+        }));
+
+        setArticles(transformedArticles);
+      } else {
+        // For visitors, show recent articles from all feeds
+        const { data: articlesData, error: articlesError } = await supabase
+          .from('articles')
+          .select(`
+            *,
+            feeds!inner(name, category)
+          `)
+          .order('published_at', { ascending: false })
+          .limit(50);
+
+        if (articlesError) {
+          console.error('Error fetching public articles:', articlesError);
+          toast.error('Erreur lors du chargement des articles');
+          return;
+        }
+
+        // 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: false,
+          isRead: false,
+          url: article.url || undefined,
+          imageUrl: article.image_url || undefined
+        }));
+
+        setArticles(transformedArticles);
+      }
+    } catch (error) {
+      console.error('Error in fetchArticles:', 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
+      ));
+    } 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 {
+      // Remove from user's view by deleting user_articles record
+      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');
+    }
+  };
+
+  const fetchRSSContent = async (feedId: string, feedUrl: string) => {
+    try {
+      toast.info('Récupération du contenu RSS...');
+      
+      const { data, error } = await supabase.functions.invoke('fetch-rss', {
+        body: { feedId, feedUrl }
+      });
+
+      if (error) {
+        throw error;
+      }
+
+      if (data.success) {
+        toast.success(`${data.articlesProcessed} articles récupérés`);
+        await fetchArticles(); // Refresh articles
+      } else {
+        throw new Error(data.error || 'Erreur lors de la récupération RSS');
+      }
+    } catch (error) {
+      console.error('Error fetching RSS:', error);
+      toast.error('Erreur lors de la récupération du contenu RSS');
+    }
+  };
+
+  useEffect(() => {
+    fetchArticles();
+  }, [user]);
+
+  return {
+    articles,
+    loading,
+    togglePin,
+    markAsRead,
+    deleteArticle,
+    refetch: fetchArticles,
+    fetchRSSContent
+  };
+}

+ 94 - 0
src/integrations/supabase/types.ts

@@ -9,6 +9,59 @@ export type Json =
 export type Database = {
   public: {
     Tables: {
+      articles: {
+        Row: {
+          content: string | null
+          created_at: string
+          description: string | null
+          feed_id: string
+          guid: string | null
+          id: string
+          image_url: string | null
+          published_at: string
+          read_time: number | null
+          title: string
+          updated_at: string
+          url: string | null
+        }
+        Insert: {
+          content?: string | null
+          created_at?: string
+          description?: string | null
+          feed_id: string
+          guid?: string | null
+          id?: string
+          image_url?: string | null
+          published_at: string
+          read_time?: number | null
+          title: string
+          updated_at?: string
+          url?: string | null
+        }
+        Update: {
+          content?: string | null
+          created_at?: string
+          description?: string | null
+          feed_id?: string
+          guid?: string | null
+          id?: string
+          image_url?: string | null
+          published_at?: string
+          read_time?: number | null
+          title?: string
+          updated_at?: string
+          url?: string | null
+        }
+        Relationships: [
+          {
+            foreignKeyName: "articles_feed_id_fkey"
+            columns: ["feed_id"]
+            isOneToOne: false
+            referencedRelation: "feeds"
+            referencedColumns: ["id"]
+          },
+        ]
+      }
       feeds: {
         Row: {
           article_count: number | null
@@ -16,6 +69,7 @@ export type Database = {
           created_at: string
           description: string | null
           id: string
+          last_fetched_at: string | null
           last_updated: string | null
           name: string
           status: string
@@ -29,6 +83,7 @@ export type Database = {
           created_at?: string
           description?: string | null
           id?: string
+          last_fetched_at?: string | null
           last_updated?: string | null
           name: string
           status?: string
@@ -42,6 +97,7 @@ export type Database = {
           created_at?: string
           description?: string | null
           id?: string
+          last_fetched_at?: string | null
           last_updated?: string | null
           name?: string
           status?: string
@@ -72,6 +128,44 @@ export type Database = {
         }
         Relationships: []
       }
+      user_articles: {
+        Row: {
+          article_id: string
+          created_at: string
+          id: string
+          is_pinned: boolean
+          is_read: boolean
+          read_at: string | null
+          user_id: string
+        }
+        Insert: {
+          article_id: string
+          created_at?: string
+          id?: string
+          is_pinned?: boolean
+          is_read?: boolean
+          read_at?: string | null
+          user_id: string
+        }
+        Update: {
+          article_id?: string
+          created_at?: string
+          id?: string
+          is_pinned?: boolean
+          is_read?: boolean
+          read_at?: string | null
+          user_id?: string
+        }
+        Relationships: [
+          {
+            foreignKeyName: "user_articles_article_id_fkey"
+            columns: ["article_id"]
+            isOneToOne: false
+            referencedRelation: "articles"
+            referencedColumns: ["id"]
+          },
+        ]
+      }
       user_feeds: {
         Row: {
           created_at: string

+ 3 - 2
src/pages/Index.tsx

@@ -1,6 +1,7 @@
+
 import { useState, useMemo } from 'react';
 import { categories } from '@/data/mockNews';
-import { useArticles } from '@/hooks/useArticles';
+import { useRealArticles } from '@/hooks/useRealArticles';
 import { useAuth } from '@/hooks/useAuth';
 import { NewsItem } from '@/types/news';
 import Header from '@/components/Header';
@@ -17,7 +18,7 @@ import { Link } from 'react-router-dom';
 
 const Index = () => {
   const { user } = useAuth();
-  const { articles, loading, togglePin, markAsRead, deleteArticle, refetch } = useArticles();
+  const { articles, loading, togglePin, markAsRead, deleteArticle, refetch } = useRealArticles();
   const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
   const [searchQuery, setSearchQuery] = useState('');
   const [showFilters, setShowFilters] = useState(true);

+ 166 - 0
supabase/functions/fetch-rss/index.ts

@@ -0,0 +1,166 @@
+
+import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
+
+interface RSSItem {
+  title: string;
+  description: string;
+  link: string;
+  pubDate: string;
+  guid?: string;
+  content?: string;
+  image?: string;
+}
+
+interface RSSFeed {
+  title: string;
+  description: string;
+  items: RSSItem[];
+}
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+}
+
+serve(async (req) => {
+  if (req.method === 'OPTIONS') {
+    return new Response('ok', { headers: corsHeaders })
+  }
+
+  try {
+    const supabaseClient = createClient(
+      Deno.env.get('SUPABASE_URL') ?? '',
+      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
+    )
+
+    const { feedId, feedUrl } = await req.json()
+
+    console.log(`Fetching RSS for feed: ${feedId}, URL: ${feedUrl}`)
+
+    // Fetch RSS content
+    const response = await fetch(feedUrl)
+    if (!response.ok) {
+      throw new Error(`Failed to fetch RSS: ${response.statusText}`)
+    }
+
+    const rssText = await response.text()
+    
+    // Parse RSS using DOMParser
+    const parser = new DOMParser()
+    const doc = parser.parseFromString(rssText, 'application/xml')
+    
+    if (!doc) {
+      throw new Error('Failed to parse RSS XML')
+    }
+
+    // Extract RSS items
+    const items = Array.from(doc.querySelectorAll('item, entry')).map(item => {
+      const title = item.querySelector('title')?.textContent?.trim() || ''
+      const description = item.querySelector('description, summary')?.textContent?.trim() || ''
+      const link = item.querySelector('link')?.textContent?.trim() || 
+                  item.querySelector('link')?.getAttribute('href') || ''
+      const pubDate = item.querySelector('pubDate, published')?.textContent?.trim() || ''
+      const guid = item.querySelector('guid')?.textContent?.trim() || link
+      
+      // Try to extract image from content or enclosure
+      let image = ''
+      const enclosure = item.querySelector('enclosure[type^="image"]')
+      if (enclosure) {
+        image = enclosure.getAttribute('url') || ''
+      } else {
+        // Try to find image in content
+        const content = item.querySelector('content\\:encoded, content')?.textContent
+        if (content) {
+          const imgMatch = content.match(/<img[^>]+src="([^">]+)"/i)
+          if (imgMatch) {
+            image = imgMatch[1]
+          }
+        }
+      }
+
+      return {
+        title,
+        description,
+        link,
+        pubDate,
+        guid,
+        image,
+        content: description
+      }
+    }).filter(item => item.title && item.guid)
+
+    console.log(`Found ${items.length} items`)
+
+    // Save articles to database
+    const articlesToInsert = items.map(item => {
+      // Calculate read time (rough estimate: 200 words per minute)
+      const wordCount = (item.description || '').split(' ').length
+      const readTime = Math.max(1, Math.ceil(wordCount / 200))
+
+      return {
+        feed_id: feedId,
+        title: item.title,
+        description: item.description,
+        content: item.content || item.description,
+        url: item.link,
+        image_url: item.image || null,
+        published_at: item.pubDate ? new Date(item.pubDate).toISOString() : new Date().toISOString(),
+        guid: item.guid,
+        read_time: readTime
+      }
+    })
+
+    // Insert articles (on conflict do nothing to avoid duplicates)
+    const { error: insertError } = await supabaseClient
+      .from('articles')
+      .upsert(articlesToInsert, { 
+        onConflict: 'feed_id,guid',
+        ignoreDuplicates: true 
+      })
+
+    if (insertError) {
+      console.error('Error inserting articles:', insertError)
+      throw insertError
+    }
+
+    // Update feed's last_fetched_at
+    const { error: updateError } = await supabaseClient
+      .from('feeds')
+      .update({ 
+        last_fetched_at: new Date().toISOString(),
+        status: 'active'
+      })
+      .eq('id', feedId)
+
+    if (updateError) {
+      console.error('Error updating feed:', updateError)
+      throw updateError
+    }
+
+    console.log(`Successfully processed ${articlesToInsert.length} articles for feed ${feedId}`)
+
+    return new Response(
+      JSON.stringify({ 
+        success: true, 
+        articlesProcessed: articlesToInsert.length 
+      }),
+      { 
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' } 
+      }
+    )
+
+  } catch (error) {
+    console.error('Error in fetch-rss function:', error)
+    return new Response(
+      JSON.stringify({ 
+        error: error.message,
+        success: false 
+      }),
+      { 
+        status: 500,
+        headers: { ...corsHeaders, 'Content-Type': 'application/json' }
+      }
+    )
+  }
+})

+ 67 - 0
supabase/migrations/20250611112005-a020aac0-375b-4fa5-bb38-1be2a4a63a80.sql

@@ -0,0 +1,67 @@
+
+-- Create articles table to store RSS feed content
+CREATE TABLE public.articles (
+  id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
+  feed_id UUID REFERENCES public.feeds(id) ON DELETE CASCADE NOT NULL,
+  title TEXT NOT NULL,
+  description TEXT,
+  content TEXT,
+  url TEXT,
+  image_url TEXT,
+  published_at TIMESTAMP WITH TIME ZONE NOT NULL,
+  guid TEXT, -- RSS GUID for deduplication
+  read_time INTEGER DEFAULT 5, -- estimated read time in minutes
+  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+  updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+  
+  -- Ensure we don't duplicate articles
+  UNIQUE(feed_id, guid)
+);
+
+-- Add indexes for better performance
+CREATE INDEX idx_articles_feed_id ON public.articles(feed_id);
+CREATE INDEX idx_articles_published_at ON public.articles(published_at DESC);
+CREATE INDEX idx_articles_guid ON public.articles(guid);
+
+-- Create user_articles table to track read status and pins
+CREATE TABLE public.user_articles (
+  id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
+  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
+  article_id UUID REFERENCES public.articles(id) ON DELETE CASCADE NOT NULL,
+  is_read BOOLEAN NOT NULL DEFAULT false,
+  is_pinned BOOLEAN NOT NULL DEFAULT false,
+  read_at TIMESTAMP WITH TIME ZONE,
+  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+  
+  -- Ensure one record per user per article
+  UNIQUE(user_id, article_id)
+);
+
+-- Add indexes for user_articles
+CREATE INDEX idx_user_articles_user_id ON public.user_articles(user_id);
+CREATE INDEX idx_user_articles_article_id ON public.user_articles(article_id);
+
+-- Enable RLS on articles (public read, but we'll filter by user subscriptions in code)
+ALTER TABLE public.articles ENABLE ROW LEVEL SECURITY;
+
+-- Allow authenticated users to read articles
+CREATE POLICY "Authenticated users can read articles" 
+  ON public.articles 
+  FOR SELECT 
+  TO authenticated
+  USING (true);
+
+-- Enable RLS on user_articles
+ALTER TABLE public.user_articles ENABLE ROW LEVEL SECURITY;
+
+-- Users can only access their own article interactions
+CREATE POLICY "Users can manage their own article interactions" 
+  ON public.user_articles 
+  FOR ALL 
+  TO authenticated
+  USING (auth.uid() = user_id)
+  WITH CHECK (auth.uid() = user_id);
+
+-- Update feeds table to track last fetch time
+ALTER TABLE public.feeds 
+ADD COLUMN IF NOT EXISTS last_fetched_at TIMESTAMP WITH TIME ZONE;