10 İşlemeler 42aa43cef2 ... 33a8d20f73

Yazar SHA1 Mesaj Tarih
  gpt-engineer-app[bot] 33a8d20f73 Update changelog 3 hafta önce
  gpt-engineer-app[bot] fe4bf6cd7a Changes 3 hafta önce
  gpt-engineer-app[bot] dae0856350 Add dark theme switch 3 hafta önce
  gpt-engineer-app[bot] dc5f249b73 Changes 3 hafta önce
  gpt-engineer-app[bot] 9ec9bb8a61 Lovable update 3 hafta önce
  gpt-engineer-app[bot] ceb2d27b41 Changes 3 hafta önce
  gpt-engineer-app[bot] ee80f56613 Lovable update 3 hafta önce
  gpt-engineer-app[bot] eeeecd492e Changes 3 hafta önce
  gpt-engineer-app[bot] f6c9d2589e Move filters to mobile header 3 hafta önce
  gpt-engineer-app[bot] e57ad33939 Changes 3 hafta önce

+ 106 - 2
src/components/Header.tsx

@@ -3,27 +3,67 @@ import { Button } from '@/components/ui/button';
 import { Input } from '@/components/ui/input';
 import { Badge } from '@/components/ui/badge';
 import { Sheet, SheetContent, SheetTrigger, SheetHeader, SheetTitle } from '@/components/ui/sheet';
-import { Search, Settings, User, Rss, List, LogOut, Shield, Pin, FileText, Menu } from 'lucide-react';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
+import { Search, Settings, User, Rss, List, LogOut, Shield, Pin, FileText, Menu, Filter, ChevronDown } from 'lucide-react';
 import { Link } from 'react-router-dom';
 import { useAuth } from '@/hooks/useAuth';
 import { useSuperUser } from '@/hooks/useSuperUser';
 import { useIsMobile } from '@/hooks/use-mobile';
+import CategoryFilter from '@/components/CategoryFilter';
+import ThemeToggle from '@/components/ThemeToggle';
+import { NewsItem, NewsCategory } from '@/types/news';
 
 interface HeaderProps {
   searchQuery: string;
   onSearchChange: (query: string) => void;
   pinnedCount: number;
+  // Mobile filter props
+  categories?: NewsCategory[];
+  selectedCategory?: string | null;
+  onCategoryChange?: (category: string | null) => void;
+  articles?: NewsItem[];
+  pinnedArticles?: NewsItem[];
+  dateFilter?: 'today' | 'yesterday' | null;
+  onDateFilterChange?: (filter: 'today' | 'yesterday' | null) => void;
+  showFollowedOnly?: boolean;
+  showDiscoveryMode?: boolean;
+  onViewModeChange?: (mode: 'followed' | 'discovery' | 'all') => void;
+  showReadArticles?: boolean;
+  onShowReadArticlesChange?: (show: boolean) => void;
+  unreadCount?: number;
+  onTogglePin?: (id: string) => void;
+  onMarkAsRead?: (id: string) => void;
+  onDeleteArticle?: (id: string) => void;
+  onOpenArticle?: (article: NewsItem) => void;
 }
 
 const Header = ({
   searchQuery,
   onSearchChange,
-  pinnedCount
+  pinnedCount,
+  categories,
+  selectedCategory,
+  onCategoryChange,
+  articles = [],
+  pinnedArticles = [],
+  dateFilter,
+  onDateFilterChange,
+  showFollowedOnly,
+  showDiscoveryMode,
+  onViewModeChange,
+  showReadArticles,
+  onShowReadArticlesChange,
+  unreadCount = 0,
+  onTogglePin,
+  onMarkAsRead,
+  onDeleteArticle,
+  onOpenArticle,
 }: HeaderProps) => {
   const { user, signOut } = useAuth();
   const { isSuperUser } = useSuperUser();
   const isMobile = useIsMobile();
   const [isSheetOpen, setIsSheetOpen] = useState(false);
+  const [isFiltersOpen, setIsFiltersOpen] = useState(false);
 
   const handleSignOut = async () => {
     await signOut();
@@ -89,6 +129,8 @@ const Header = ({
                     </Button>
                   </Link>
                   
+                  <ThemeToggle />
+                  
                   <Button variant="ghost" size="sm">
                     <Settings className="h-4 w-4" />
                   </Button>
@@ -157,6 +199,63 @@ const Header = ({
                     />
                   </div>
 
+                  {/* Filtres section */}
+                  {categories && onCategoryChange && onDateFilterChange && onViewModeChange && onShowReadArticlesChange && onTogglePin && onMarkAsRead && onDeleteArticle && onOpenArticle && (
+                    <Collapsible open={isFiltersOpen} onOpenChange={setIsFiltersOpen} className="mb-4">
+                      <CollapsibleTrigger asChild>
+                        <Button variant="outline" className="w-full justify-between gap-2">
+                          <div className="flex items-center gap-2">
+                            <Filter className="h-4 w-4" />
+                            Filtres
+                          </div>
+                          <ChevronDown className={`h-4 w-4 transition-transform ${isFiltersOpen ? 'rotate-180' : ''}`} />
+                        </Button>
+                      </CollapsibleTrigger>
+                      <CollapsibleContent className="mt-3 space-y-4">
+                        <CategoryFilter 
+                          categories={categories} 
+                          selectedCategory={selectedCategory ?? null} 
+                          onCategoryChange={onCategoryChange} 
+                          newsCount={articles.length} 
+                          pinnedCount={pinnedCount} 
+                          articles={articles}
+                          pinnedArticles={pinnedArticles}
+                          dateFilter={dateFilter ?? null}
+                          onDateFilterChange={onDateFilterChange}
+                          showFollowedOnly={showFollowedOnly ?? false}
+                          showDiscoveryMode={showDiscoveryMode ?? false}
+                          onViewModeChange={onViewModeChange}
+                          showReadArticles={showReadArticles ?? false}
+                          onShowReadArticlesChange={onShowReadArticlesChange}
+                          onTogglePin={onTogglePin}
+                          onMarkAsRead={onMarkAsRead}
+                          onDeleteArticle={onDeleteArticle}
+                          onOpenArticle={onOpenArticle}
+                        />
+                        
+                        <div className="bg-card border rounded-lg p-4 space-y-3">
+                          <h3 className="font-semibold text-sm">Statistiques</h3>
+                          <div className="space-y-2 text-sm">
+                            <div className="flex justify-between">
+                              <span className="text-muted-foreground">Articles non lus</span>
+                              <Badge variant="outline">{unreadCount}</Badge>
+                            </div>
+                            <div className="flex justify-between">
+                              <span className="text-muted-foreground">Articles totaux</span>
+                              <Badge variant="outline">{articles.length}</Badge>
+                            </div>
+                            {user && (
+                              <div className="flex justify-between">
+                                <span className="text-muted-foreground">Épinglés</span>
+                                <Badge variant="secondary">{pinnedCount}</Badge>
+                              </div>
+                            )}
+                          </div>
+                        </div>
+                      </CollapsibleContent>
+                    </Collapsible>
+                  )}
+
                   <nav className="flex flex-col gap-2 flex-1">
                     <Link to="/changelog" onClick={closeSheet}>
                       <Button variant="ghost" className="w-full justify-start gap-3">
@@ -190,6 +289,11 @@ const Header = ({
                           <Settings className="h-4 w-4" />
                           Paramètres
                         </Button>
+
+                        <div className="flex items-center justify-between px-4 py-2">
+                          <span className="text-sm">Thème sombre</span>
+                          <ThemeToggle />
+                        </div>
                       </>
                     )}
                   </nav>

+ 43 - 0
src/components/ThemeToggle.tsx

@@ -0,0 +1,43 @@
+import { Moon, Sun } from 'lucide-react';
+import { useTheme } from 'next-themes';
+import { Button } from '@/components/ui/button';
+import { useEffect, useState } from 'react';
+
+const ThemeToggle = () => {
+  const { theme, setTheme } = useTheme();
+  const [mounted, setMounted] = useState(false);
+
+  // Éviter l'hydration mismatch
+  useEffect(() => {
+    setMounted(true);
+  }, []);
+
+  if (!mounted) {
+    return (
+      <Button variant="ghost" size="sm" disabled>
+        <Sun className="h-4 w-4" />
+      </Button>
+    );
+  }
+
+  const toggleTheme = () => {
+    setTheme(theme === 'dark' ? 'light' : 'dark');
+  };
+
+  return (
+    <Button 
+      variant="ghost" 
+      size="sm" 
+      onClick={toggleTheme}
+      aria-label={theme === 'dark' ? 'Activer le mode clair' : 'Activer le mode sombre'}
+    >
+      {theme === 'dark' ? (
+        <Sun className="h-4 w-4" />
+      ) : (
+        <Moon className="h-4 w-4" />
+      )}
+    </Button>
+  );
+};
+
+export default ThemeToggle;

+ 54 - 0
src/data/changelog.ts

@@ -8,6 +8,60 @@ export interface ChangelogEntry {
 }
 
 export const changelogData: ChangelogEntry[] = [
+  {
+    version: "1.10.0",
+    date: "2026-01-16",
+    category: "feature",
+    title: "Thème sombre avec switch",
+    description: "Ajout d'un mode sombre complet avec un switch de basculement accessible dans le header.",
+    details: [
+      "Composant ThemeToggle avec next-themes",
+      "Switch accessible dans le header desktop et menu mobile",
+      "Support du thème système par défaut",
+      "Icônes dynamiques Soleil/Lune selon le thème actif",
+      "Persistance du choix utilisateur dans le navigateur"
+    ]
+  },
+  {
+    version: "1.9.1",
+    date: "2026-01-15",
+    category: "security",
+    title: "Sécurisation des tâches automatisées",
+    description: "Mise en place d'une authentification sécurisée pour les cron jobs PostgreSQL.",
+    details: [
+      "Création de la table app_secrets pour les secrets applicatifs",
+      "Protection RLS stricte (aucun accès direct possible)",
+      "Authentification des fonctions trigger via x-cron-secret",
+      "Lecture sécurisée des secrets via fonctions SECURITY DEFINER"
+    ]
+  },
+  {
+    version: "1.9.0",
+    date: "2026-01-15",
+    category: "improvement",
+    title: "Automatisation des tâches planifiées",
+    description: "Mise en place de cron jobs PostgreSQL pour automatiser la maintenance de la base de données.",
+    details: [
+      "Cron jobs PostgreSQL avec extension pg_cron",
+      "Récupération automatique des flux RSS toutes les 10 minutes",
+      "Purge automatique des anciens articles à 3h du matin",
+      "Fonctions trigger dédiées (trigger_fetch_all_feeds, trigger_purge_articles)",
+      "Rapport de purge envoyé par email aux administrateurs"
+    ]
+  },
+  {
+    version: "1.8.4",
+    date: "2026-01-13",
+    category: "security",
+    title: "Protection renforcée des données",
+    description: "Renforcement des politiques de sécurité pour protéger les données sensibles.",
+    details: [
+      "Authentification requise pour accéder aux articles",
+      "Protection de la table super_users contre les accès directs",
+      "Vérification du statut admin via fonction is_super_user()",
+      "Renforcement des politiques RLS existantes"
+    ]
+  },
   {
     version: "1.8.3",
     date: "2026-01-06",

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

@@ -14,6 +14,27 @@ export type Database = {
   }
   public: {
     Tables: {
+      app_secrets: {
+        Row: {
+          created_at: string | null
+          key: string
+          updated_at: string | null
+          value: string
+        }
+        Insert: {
+          created_at?: string | null
+          key: string
+          updated_at?: string | null
+          value: string
+        }
+        Update: {
+          created_at?: string | null
+          key?: string
+          updated_at?: string | null
+          value?: string
+        }
+        Relationships: []
+      }
       articles: {
         Row: {
           content: string | null
@@ -231,6 +252,8 @@ export type Database = {
           sample_titles: string[]
         }[]
       }
+      trigger_fetch_all_feeds: { Args: never; Returns: undefined }
+      trigger_purge_articles: { Args: never; Returns: undefined }
     }
     Enums: {
       [_ in never]: never

+ 6 - 1
src/main.tsx

@@ -1,5 +1,10 @@
 import { createRoot } from 'react-dom/client'
+import { ThemeProvider } from 'next-themes'
 import App from './App.tsx'
 import './index.css'
 
-createRoot(document.getElementById("root")!).render(<App />);
+createRoot(document.getElementById("root")!).render(
+  <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
+    <App />
+  </ThemeProvider>
+);

+ 22 - 58
src/pages/Index.tsx

@@ -11,11 +11,6 @@ import AddFeedModal from '@/components/AddFeedModal';
 import ArticleModal from '@/components/ArticleModal';
 import { Badge } from '@/components/ui/badge';
 import { Button } from '@/components/ui/button';
-import {
-  DropdownMenu,
-  DropdownMenuContent,
-  DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
 import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, PaginationEllipsis } from '@/components/ui/pagination';
 import { RefreshCw, Filter, Rss, Plus } from 'lucide-react';
 import { toast } from 'sonner';
@@ -164,7 +159,28 @@ const Index = () => {
       </div>;
   }
   return <div className="min-h-screen bg-background">
-      <Header searchQuery={searchQuery} onSearchChange={setSearchQuery} pinnedCount={pinnedCount} />
+      <Header 
+        searchQuery={searchQuery} 
+        onSearchChange={setSearchQuery} 
+        pinnedCount={pinnedCount}
+        categories={categories}
+        selectedCategory={selectedCategory}
+        onCategoryChange={setSelectedCategory}
+        articles={articles}
+        pinnedArticles={pinnedArticles}
+        dateFilter={dateFilter}
+        onDateFilterChange={setDateFilter}
+        showFollowedOnly={showFollowedOnly}
+        showDiscoveryMode={showDiscoveryMode}
+        onViewModeChange={handleViewModeChange}
+        showReadArticles={showReadArticles}
+        onShowReadArticlesChange={setShowReadArticles}
+        unreadCount={unreadCount}
+        onTogglePin={togglePin}
+        onMarkAsRead={markAsRead}
+        onDeleteArticle={deleteArticle}
+        onOpenArticle={handleOpenArticle}
+      />
       
       <main className="container mx-auto px-4 py-6">
         {/* Message pour les utilisateurs non connectés */}
@@ -247,58 +263,6 @@ const Index = () => {
               </div>
               
               <div className="flex items-center gap-2">
-                {/* Mobile Filter Dropdown */}
-                {isMobile && (
-                  <DropdownMenu>
-                    <DropdownMenuTrigger asChild>
-                      <Button variant="outline" size="sm" className="gap-2">
-                        <Filter className="h-4 w-4" />
-                        Filtres
-                      </Button>
-                    </DropdownMenuTrigger>
-                    <DropdownMenuContent align="end" className="w-[320px] max-h-[70vh] overflow-y-auto p-4 space-y-4">
-                      <CategoryFilter 
-                        categories={categories} 
-                        selectedCategory={selectedCategory} 
-                        onCategoryChange={setSelectedCategory} 
-                        newsCount={articles.length} 
-                        pinnedCount={pinnedCount} 
-                        articles={articles}
-                        pinnedArticles={pinnedArticles}
-                        dateFilter={dateFilter}
-                        onDateFilterChange={setDateFilter}
-                        showFollowedOnly={showFollowedOnly}
-                        showDiscoveryMode={showDiscoveryMode}
-                        onViewModeChange={handleViewModeChange}
-                        showReadArticles={showReadArticles}
-                        onShowReadArticlesChange={setShowReadArticles}
-                        onTogglePin={togglePin}
-                        onMarkAsRead={markAsRead}
-                        onDeleteArticle={deleteArticle}
-                        onOpenArticle={handleOpenArticle}
-                      />
-                      
-                      <div className="bg-card border rounded-lg p-4 space-y-3">
-                        <h3 className="font-semibold text-sm">Statistiques</h3>
-                        <div className="space-y-2 text-sm">
-                          <div className="flex justify-between">
-                            <span className="text-muted-foreground">Articles non lus</span>
-                            <Badge variant="outline">{unreadCount}</Badge>
-                          </div>
-                          <div className="flex justify-between">
-                            <span className="text-muted-foreground">Articles totaux</span>
-                            <Badge variant="outline">{articles.length}</Badge>
-                          </div>
-                          {user && <div className="flex justify-between">
-                              <span className="text-muted-foreground">Épinglés</span>
-                              <Badge variant="secondary">{pinnedCount}</Badge>
-                            </div>}
-                        </div>
-                      </div>
-                    </DropdownMenuContent>
-                  </DropdownMenu>
-                )}
-                
                 {/* Desktop Filter Toggle - keep existing logic */}
                 {!isMobile && (
                   <Button variant="outline" size="sm" onClick={() => setShowFilters(!showFilters)} className="lg:hidden gap-2">

+ 80 - 0
supabase/migrations/20260115200016_77878c31-17d4-4bcb-a3f3-ba4338fed84d.sql

@@ -0,0 +1,80 @@
+-- Supprimer les anciens cron jobs s'ils existent
+SELECT cron.unschedule(jobname) FROM cron.job WHERE jobname IN ('fetch-active-feeds', 'purge-old-articles-daily');
+
+-- Créer une fonction pour récupérer les articles de tous les feeds actifs
+CREATE OR REPLACE FUNCTION public.trigger_fetch_all_feeds()
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path TO 'public'
+AS $func$
+DECLARE
+  feed_record RECORD;
+  cron_secret TEXT;
+BEGIN
+  -- Récupérer le secret depuis les paramètres de la base
+  cron_secret := current_setting('app.cron_secret', true);
+  
+  IF cron_secret IS NULL OR cron_secret = '' THEN
+    RAISE WARNING 'app.cron_secret not configured - skipping feed fetch';
+    RETURN;
+  END IF;
+  
+  FOR feed_record IN 
+    SELECT id, url FROM public.feeds WHERE status = 'active'
+  LOOP
+    PERFORM net.http_post(
+      url := 'https://wftyukugedtojizgatwj.supabase.co/functions/v1/fetch-rss',
+      headers := jsonb_build_object(
+        'Content-Type', 'application/json',
+        'x-cron-secret', cron_secret
+      ),
+      body := jsonb_build_object(
+        'feedId', feed_record.id,
+        'feedUrl', feed_record.url
+      )
+    );
+  END LOOP;
+END;
+$func$;
+
+-- Créer une fonction pour déclencher la purge des articles
+CREATE OR REPLACE FUNCTION public.trigger_purge_articles()
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path TO 'public'
+AS $func$
+DECLARE
+  cron_secret TEXT;
+BEGIN
+  cron_secret := current_setting('app.cron_secret', true);
+  
+  IF cron_secret IS NULL OR cron_secret = '' THEN
+    RAISE WARNING 'app.cron_secret not configured - skipping purge';
+    RETURN;
+  END IF;
+  
+  PERFORM net.http_post(
+    url := 'https://wftyukugedtojizgatwj.supabase.co/functions/v1/purge-articles',
+    headers := jsonb_build_object(
+      'Content-Type', 'application/json',
+      'x-cron-secret', cron_secret
+    ),
+    body := '{"scheduled": true}'::jsonb
+  );
+END;
+$func$;
+
+-- Programmer les cron jobs pour appeler ces fonctions
+SELECT cron.schedule(
+  'fetch-active-feeds',
+  '*/10 * * * *',
+  'SELECT public.trigger_fetch_all_feeds()'
+);
+
+SELECT cron.schedule(
+  'purge-old-articles-daily',
+  '0 3 * * *',
+  'SELECT public.trigger_purge_articles()'
+);

+ 80 - 0
supabase/migrations/20260115212437_b4b309e4-4ba2-42d4-846a-9f006e76b2a6.sql

@@ -0,0 +1,80 @@
+-- Créer une table sécurisée pour stocker les secrets de l'application
+CREATE TABLE IF NOT EXISTS public.app_secrets (
+  key TEXT PRIMARY KEY,
+  value TEXT NOT NULL,
+  created_at TIMESTAMPTZ DEFAULT now(),
+  updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- Activer RLS
+ALTER TABLE public.app_secrets ENABLE ROW LEVEL SECURITY;
+
+-- Aucun accès direct - seulement via SECURITY DEFINER functions
+CREATE POLICY "No direct access to app_secrets" ON public.app_secrets
+  FOR ALL USING (false);
+
+-- Mettre à jour la fonction trigger_fetch_all_feeds pour lire depuis la table
+CREATE OR REPLACE FUNCTION public.trigger_fetch_all_feeds()
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path TO 'public'
+AS $func$
+DECLARE
+  feed_record RECORD;
+  cron_secret TEXT;
+BEGIN
+  -- Récupérer le secret depuis la table app_secrets
+  SELECT value INTO cron_secret FROM public.app_secrets WHERE key = 'cron_secret';
+  
+  IF cron_secret IS NULL OR cron_secret = '' THEN
+    RAISE WARNING 'cron_secret not configured in app_secrets table';
+    RETURN;
+  END IF;
+  
+  FOR feed_record IN 
+    SELECT id, url FROM public.feeds WHERE status = 'active'
+  LOOP
+    PERFORM net.http_post(
+      url := 'https://wftyukugedtojizgatwj.supabase.co/functions/v1/fetch-rss',
+      headers := jsonb_build_object(
+        'Content-Type', 'application/json',
+        'x-cron-secret', cron_secret
+      ),
+      body := jsonb_build_object(
+        'feedId', feed_record.id,
+        'feedUrl', feed_record.url
+      )
+    );
+  END LOOP;
+END;
+$func$;
+
+-- Mettre à jour la fonction trigger_purge_articles pour lire depuis la table
+CREATE OR REPLACE FUNCTION public.trigger_purge_articles()
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path TO 'public'
+AS $func$
+DECLARE
+  cron_secret TEXT;
+BEGIN
+  -- Récupérer le secret depuis la table app_secrets
+  SELECT value INTO cron_secret FROM public.app_secrets WHERE key = 'cron_secret';
+  
+  IF cron_secret IS NULL OR cron_secret = '' THEN
+    RAISE WARNING 'cron_secret not configured in app_secrets table';
+    RETURN;
+  END IF;
+  
+  PERFORM net.http_post(
+    url := 'https://wftyukugedtojizgatwj.supabase.co/functions/v1/purge-articles',
+    headers := jsonb_build_object(
+      'Content-Type', 'application/json',
+      'x-cron-secret', cron_secret
+    ),
+    body := '{"scheduled": true}'::jsonb
+  );
+END;
+$func$;