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;
}
}

View file

@ -26,7 +26,8 @@
============================================ */
.header-github-link::before,
.header-forgejo-link::before {
.header-forgejo-link::before,
.header-rss-link::before {
content: '';
display: flex;
width: 24px;
@ -47,9 +48,15 @@
background-image: url('/img/forgejo-logo.svg');
}
/* RSS icon */
.header-rss-link::before {
background-image: url('/img/rss-color-svgrepo-com.svg');
}
/* Hide the default link text (if any) */
.header-github-link > span,
.header-forgejo-link > span {
.header-forgejo-link > span,
.header-rss-link > span {
display: none;
}
@ -68,7 +75,8 @@
============================================ */
.header-github-link:hover::before,
.header-forgejo-link:hover::before {
.header-forgejo-link:hover::before,
.header-rss-link:hover::before {
opacity: 0.8;
}
@ -90,6 +98,15 @@
filter: drop-shadow(0 0 8px rgba(255, 102, 0, 0.8));
}
/* RSS hover glow effect - orange RSS color */
.header-rss-link:hover::before {
filter: drop-shadow(0 0 6px rgba(255, 153, 0, 0.7));
}
[data-theme='dark'] .header-rss-link:hover::before {
filter: drop-shadow(0 0 8px rgba(255, 153, 0, 0.8));
}
/* ============================================
Search Bar Styling
============================================ */

155
src/pages/veille.module.css Normal file
View file

@ -0,0 +1,155 @@
.veillePage {
padding: 2rem 0;
min-height: calc(100vh - 60px);
}
.header {
text-align: center;
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 2px solid var(--ifm-color-emphasis-200);
}
.header h1 {
font-size: 3rem;
margin-bottom: 1rem;
color: var(--ifm-color-primary);
}
.subtitle {
font-size: 1.2rem;
color: var(--ifm-color-content-secondary);
max-width: 800px;
margin: 0 auto 2rem;
line-height: 1.6;
}
.actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
margin-top: 2rem;
}
.downloadButton {
display: inline-block;
padding: 0.75rem 2rem;
background: var(--ifm-color-primary);
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
font-size: 1.1rem;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.downloadButton:hover {
background: var(--ifm-color-primary-dark);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
color: white;
text-decoration: none;
}
.opmlInfo {
font-size: 0.9rem;
color: var(--ifm-color-content-secondary);
font-style: italic;
margin: 0;
}
.content {
margin: 3rem 0;
}
.footer {
margin-top: 4rem;
padding-top: 3rem;
border-top: 2px solid var(--ifm-color-emphasis-200);
}
.footer h2 {
text-align: center;
margin-bottom: 2rem;
color: var(--ifm-color-content);
}
.infoGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.infoCard {
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;
}
.infoCard:hover {
border-color: var(--ifm-color-primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.infoCard h3 {
margin-top: 0;
margin-bottom: 1rem;
color: var(--ifm-color-primary);
font-size: 1.3rem;
}
.infoCard p {
margin-bottom: 0;
line-height: 1.6;
color: var(--ifm-color-content);
}
.infoCard ul {
margin: 0;
padding-left: 1.5rem;
line-height: 1.8;
}
.infoCard li {
margin-bottom: 0.5rem;
color: var(--ifm-color-content);
}
/* Responsive */
@media (max-width: 996px) {
.header h1 {
font-size: 2.5rem;
}
.subtitle {
font-size: 1.1rem;
}
.infoGrid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.veillePage {
padding: 1rem 0;
}
.header h1 {
font-size: 2rem;
}
.subtitle {
font-size: 1rem;
}
.downloadButton {
padding: 0.6rem 1.5rem;
font-size: 1rem;
}
}

39
src/pages/veille.tsx Normal file
View file

@ -0,0 +1,39 @@
import React from 'react';
import Layout from '@theme/Layout';
import RSSFeedWidget from '@site/src/components/RSSFeedWidget';
import styles from './veille.module.css';
export default function Veille(): JSX.Element {
return (
<Layout
title="Veille Technologique"
description="Flux RSS quotidiens sur le SysAdmin, DevOps, SRE, Cloud et Sécurité">
<main className={styles.veillePage}>
<div className="container">
<header className={styles.header}>
<h1>Veille Technologique</h1>
<p className={styles.subtitle}>
Articles du jour provenant de sources reconnues en SysAdmin, DevOps, SRE, Cloud et Cybersécurité
</p>
<div className={styles.actions}>
<a
href="/veille-tech.opml"
download="veille-tech.opml"
className={styles.downloadButton}>
Télécharger le fichier OPML
</a>
<p className={styles.opmlInfo}>
Importez ce fichier dans votre lecteur RSS favori (Feedly, Inoreader, FreshRSS, etc.)
</p>
</div>
</header>
<section className={styles.content}>
<RSSFeedWidget />
</section>
</div>
</main>
</Layout>
);
}