Ajout page Veille avec agrégation RSS automatique

- Création d'un plugin Docusaurus pour agréger les flux RSS au build
  * Récupère 37 flux RSS depuis le fichier OPML
  * Filtre les articles des dernières 24h
  * Génère un fichier JSON statique pour chargement instantané

- Page Veille avec composant React
  * Affichage des articles groupés par catégorie
  * Menus dépliables (repliés par défaut)
  * Chargement ultra-rapide depuis JSON pré-généré
  * Support bilingue FR/EN

- GitHub Actions pour rebuild automatique quotidien
  * Workflow déclenché tous les jours à 6h UTC
  * Met à jour les flux RSS via l'API Cloudflare Pages
  * Déclenchement manuel possible

- Configuration Webpack pour compatibilité navigateur
  * Désactivation des polyfills Node.js côté client
  * Correction du warning onBrokenMarkdownLinks

- Icône RSS dans la navbar
  * Lien vers le flux Atom du blog
  * Style cohérent avec les autres icônes

125 articles trouvés dans les dernières 24h lors du dernier build.
This commit is contained in:
Tellsanguis 2025-12-06 09:33:43 +01:00
parent aaf03916d4
commit df63713055
16 changed files with 1148 additions and 5 deletions

View file

@ -0,0 +1,165 @@
import React, { useState, useEffect } from 'react';
import { useLocation } from '@docusaurus/router';
import styles from './styles.module.css';
interface FeedItem {
title: string;
link: string;
pubDate: string;
source: string;
category: string;
}
interface CategoryGroup {
category: string;
items: FeedItem[];
}
interface RSSCacheData {
groups: CategoryGroup[];
generatedAt: string;
totalArticles: number;
}
const RSSFeedWidget: React.FC = () => {
const location = useLocation();
const isEnglish = location.pathname.startsWith('/en');
const [categoryGroups, setCategoryGroups] = useState<CategoryGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [generatedAt, setGeneratedAt] = useState<string>('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
const t = {
loading: isEnglish ? 'Loading RSS feeds...' : 'Chargement des flux RSS...',
error: isEnglish ? 'Error loading RSS feeds' : 'Erreur lors du chargement des flux RSS',
noArticles: isEnglish ? 'No articles published in the last 24 hours in monitored RSS feeds.' : 'Aucun article publié dans les dernières 24h dans les flux RSS suivis.',
comeBack: isEnglish ? 'Come back later for new updates!' : 'Revenez plus tard pour de nouvelles actualités !',
articlesCount: (count: number) => isEnglish
? `${count} article${count > 1 ? 's' : ''} in the last 24 hours`
: `${count} article${count > 1 ? 's' : ''} publié${count > 1 ? 's' : ''} dans les dernières 24h`,
};
useEffect(() => {
const fetchFeeds = async () => {
try {
setLoading(true);
// Chargement du fichier JSON pré-généré au build
const response = await fetch('/rss-feed-cache.json');
const data: RSSCacheData = await response.json();
setCategoryGroups(data.groups);
setGeneratedAt(data.generatedAt);
setLoading(false);
} catch (err) {
console.error('Erreur lors du chargement des flux RSS:', err);
setError('Erreur lors du chargement des flux RSS');
setLoading(false);
}
};
fetchFeeds();
}, []);
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleTimeString(isEnglish ? 'en-US' : 'fr-FR', {
hour: '2-digit',
minute: '2-digit'
});
} catch {
return dateString;
}
};
const getTotalArticles = () => {
return categoryGroups.reduce((total, group) => total + group.items.length, 0);
};
const toggleCategory = (category: string) => {
setExpandedCategories((prev) => {
const newSet = new Set(prev);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
return newSet;
});
};
if (loading) {
return (
<div className={styles.loading}>
<div className={styles.spinner}></div>
<p>{t.loading}</p>
</div>
);
}
if (error) {
return (
<div className={styles.error}>
<p>{t.error}</p>
</div>
);
}
if (categoryGroups.length === 0) {
return (
<div className={styles.noArticles}>
<p>{t.noArticles}</p>
<p>{t.comeBack}</p>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.summary}>
<p>{t.articlesCount(getTotalArticles())}</p>
</div>
{categoryGroups.map((group) => {
const isExpanded = expandedCategories.has(group.category);
return (
<div key={group.category} className={styles.categorySection}>
<h2
className={styles.categoryTitle}
onClick={() => toggleCategory(group.category)}
style={{ cursor: 'pointer', userSelect: 'none' }}
>
<span className={styles.categoryTitleContent}>
<span className={styles.expandIcon}>{isExpanded ? '▼' : '▶'}</span>
{group.category}
</span>
<span className={styles.categoryCount}>{group.items.length}</span>
</h2>
{isExpanded && (
<div className={styles.feedList}>
{group.items.map((item, index) => (
<article key={`${item.link}-${index}`} className={styles.feedItem}>
<div className={styles.feedHeader}>
<span className={styles.source}>{item.source}</span>
<time className={styles.date}>{formatDate(item.pubDate)}</time>
</div>
<h3 className={styles.title}>
<a href={item.link} target="_blank" rel="noopener noreferrer">
{item.title}
</a>
</h3>
</article>
))}
</div>
)}
</div>
);
})}
</div>
);
};
export default RSSFeedWidget;

View file

@ -0,0 +1,212 @@
/* Conteneur principal */
.container {
margin: 2rem 0;
}
/* Résumé du jour */
.summary {
padding: 1rem 1.5rem;
margin-bottom: 2rem;
background: linear-gradient(135deg, var(--ifm-color-primary-lightest) 0%, var(--ifm-color-primary-lighter) 100%);
border-left: 4px solid var(--ifm-color-primary);
border-radius: 8px;
font-size: 1.1rem;
font-weight: 600;
color: var(--ifm-color-primary-darker);
}
.summary p {
margin: 0;
}
/* Section par catégorie */
.categorySection {
margin-bottom: 3rem;
}
.categoryTitle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 0.75rem 1rem;
background-color: var(--ifm-background-surface-color);
border: 2px solid var(--ifm-color-primary-lighter);
border-radius: 8px;
font-size: 1.5rem;
color: var(--ifm-color-content);
transition: all 0.2s ease;
}
.categoryTitle:hover {
background-color: var(--ifm-color-primary-lightest);
border-color: var(--ifm-color-primary);
}
.categoryTitleContent {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.expandIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
color: var(--ifm-color-primary);
font-size: 0.9rem;
transition: transform 0.2s ease;
}
.categoryCount {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
padding: 0 0.75rem;
background-color: var(--ifm-color-primary);
color: white;
border-radius: 12px;
font-size: 1rem;
font-weight: 700;
}
/* Liste des flux */
.feedList {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Article individuel */
.feedItem {
padding: 1.5rem;
background-color: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
transition: all 0.2s ease;
}
.feedItem:hover {
border-color: var(--ifm-color-primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
/* En-tête de l'article */
.feedHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.source {
display: inline-block;
padding: 0.25rem 0.75rem;
background-color: var(--ifm-color-emphasis-100);
color: var(--ifm-color-content-secondary);
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
}
.date {
color: var(--ifm-color-content-secondary);
font-size: 0.85rem;
white-space: nowrap;
}
/* Titre de l'article */
.title {
margin: 0;
font-size: 1.1rem;
line-height: 1.4;
}
.title a {
color: var(--ifm-color-content);
text-decoration: none;
transition: color 0.2s ease;
}
.title a:hover {
color: var(--ifm-color-primary);
text-decoration: underline;
}
/* État de chargement */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--ifm-color-content-secondary);
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid var(--ifm-color-emphasis-300);
border-top-color: var(--ifm-color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* État d'erreur */
.error {
padding: 2rem;
background-color: var(--ifm-color-danger-contrast-background);
border: 1px solid var(--ifm-color-danger);
border-radius: 8px;
color: var(--ifm-color-danger-darker);
text-align: center;
}
/* Aucun article */
.noArticles {
padding: 3rem 2rem;
background-color: var(--ifm-background-surface-color);
border: 2px dashed var(--ifm-color-emphasis-300);
border-radius: 8px;
text-align: center;
color: var(--ifm-color-content-secondary);
}
.noArticles p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
/* Responsive */
@media (max-width: 768px) {
.categoryTitle {
font-size: 1.4rem;
}
.feedItem {
padding: 1rem;
}
.title {
font-size: 1rem;
}
.summary {
font-size: 1rem;
}
}