UX: Résumés RSS par article + Optimisation 1920x1080

Résumés RSS par article (100 mots max):
- Modifié route POST /api/rss/summarize pour générer un résumé PAR article
- Chaque résumé limité à 100 mots (max_tokens: 200)
- Format: **Titre** + Résumé + 🔗 [Lire l'article](lien)
- Résumés générés en parallèle pour meilleure performance
- Supprimé requireAdmin pour accès à tous utilisateurs authentifiés
- Timeout 30s par article au lieu de 60s global

Frontend - Affichage résumés:
- renderRssSummaries() : Détection auto ancien/nouveau format
- formatSummary() : Support liens markdown [texte](url)
- summarizeRss() : Gère tableau de résumés au lieu d'un seul
- Styles CSS pour .rss-link (hover avec underline)
- Nouveau style .rss-summary-article pour résumés individuels

Optimisation pour écran 1920x1080:
- max-width: 1400px → 1800px (utilise mieux l'espace horizontal)
- todo-box-width: 340px → 420px (+80px de largeur)
- Boxes TODO et RSS élargies de 23%
- Grille: ~960px notes + 420px todo + 420px rss = 1800px
- Layout optimisé pour grand écran

CSS - Nouveaux styles:
- .rss-link : liens cliquables avec hover effect
- .rss-summary-article : carte compacte pour résumé individuel
- Hover effects sur liens et cartes
- Responsive maintenu pour petits écrans

Impact utilisateur:
✓ Un résumé par article avec lien direct
✓ Plus concis (100 mots max vs résumé global)
✓ Interface plus large et spacieuse sur 1920x1080
✓ Boxes TODO et RSS 23% plus larges
✓ Meilleure lisibilité des contenus

Files:
- routes/rss.routes.js: Nouveau système de résumés par article
- public/js/complete-app.js: Rendu et gestion résumés
- public/css/variables.css: Dimensions optimisées 1920x1080
- public/css/layout.css: Styles liens + résumés individuels
This commit is contained in:
Claude committed 2025-11-11 18:37:40 +00:00
1 parent 852d631c25
commit 73391def6c
4 files changed
+139 -50

No files matched your search

+43
View File
@@ -551,6 +551,49 @@ body {
font-style: italic;
}
.rss-summary-content a.rss-link {
color: var(--color-accent);
text-decoration: none;
font-weight: var(--font-weight-medium);
transition: all var(--transition-fast);
border-bottom: 1px solid transparent;
}
.rss-summary-content a.rss-link:hover {
border-bottom-color: var(--color-accent);
opacity: 0.8;
}
/* Nouveau style pour résumés par article */
.rss-summary-article {
padding: var(--spacing-md);
background: var(--color-background);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
margin-bottom: var(--spacing-md);
transition: all var(--transition-fast);
}
.rss-summary-article:hover {
border-color: var(--color-accent);
box-shadow: var(--shadow-sm);
}
.rss-summary-article .rss-summary-content {
font-size: var(--font-size-sm);
line-height: 1.6;
}
.rss-summary-article .rss-summary-meta {
margin-top: var(--spacing-sm);
padding-top: var(--spacing-sm);
border-top: 1px solid var(--color-border);
display: flex;
gap: var(--spacing-md);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
}
/* Responsive */
@media (max-width: 1200px) {
.content-container {
+3 -3
View File
@@ -67,12 +67,12 @@
--transition-base: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 0.35s cubic-bezier(0.4, 0, 0.2, 1);
/* Dimensions */
/* Dimensions - Optimisé pour 1920x1080 */
--header-height: 70px;
--max-width: 1400px;
--max-width: 1800px;
--note-width: 320px;
--note-expanded-width: 680px;
--todo-box-width: 340px;
--todo-box-width: 420px;
/* Z-index */
--z-header: 100;
+41 -8
View File
@@ -1569,7 +1569,24 @@ function renderRssSummaries(summaries) {
return;
}
container.innerHTML = summaries.map(summary => `
// Nouvelle structure : afficher les résumés PAR ARTICLE avec titre + résumé + lien
container.innerHTML = summaries.map(summary => {
// Le résumé peut être soit l'ancien format (global) soit le nouveau (par article depuis DB)
// Si c'est un résumé DB, il contient déjà le titre et le lien formatés
if (summary.summary && summary.summary.includes('🔗')) {
// Ancien format sauvegardé en DB avec titre et lien
return `
<div class="rss-summary rss-summary-article">
<div class="rss-summary-content">${formatSummary(summary.summary)}</div>
<div class="rss-summary-meta">
${summary.created_at ? `<span>${new Date(summary.created_at).toLocaleDateString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</span>` : ''}
${summary.model ? `<span>${escapeHtml(summary.model.split('/')[1] || summary.model)}</span>` : ''}
</div>
</div>
`;
} else {
// Ancien format (résumé global)
return `
<div class="rss-summary">
<div class="rss-summary-header">
<div class="rss-summary-title">📰 Résumé ${new Date(summary.created_at).toLocaleDateString('fr-FR')}</div>
@@ -1580,7 +1597,9 @@ function renderRssSummaries(summaries) {
</div>
<div class="rss-summary-content">${formatSummary(summary.summary)}</div>
</div>
`).join('');
`;
}
}).join('');
}
function formatSummary(summary) {
@@ -1598,12 +1617,18 @@ function formatSummary(summary) {
// Listes numérotées
html = html.replace(/\n\d+\. (.*?)(?=\n|$)/g, '<li>$1</li>');
// Gras
// Gras (avant les liens pour éviter les conflits)
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Italique
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
// Liens markdown [texte](url) - convertir en liens cliquables
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="rss-link">$1</a>');
// Emoji lien avec texte - gérer 🔗 suivi d'espace
html = html.replace(/🔗\s*/g, '🔗 ');
// Sauts de ligne
html = html.replace(/\n\n/g, '<br><br>');
html = html.replace(/\n/g, '<br>');
@@ -1628,21 +1653,29 @@ async function summarizeRss() {
if (summaryEnabled) {
// Si les résumés sont activés, juste recharger le bloc RSS
alert('Résumé généré avec succès !');
alert(`${result.articles_count} résumés générés avec succès !`);
await loadRssArticles();
} else {
// Sinon créer une note avec le résumé
// Sinon créer une note avec TOUS les résumés par article
if (result.summaries && result.summaries.length > 0) {
const allSummaries = result.summaries.map(s =>
`**${s.title}**\n_${s.feed_title} - ${new Date(s.pub_date).toLocaleDateString('fr-FR')}_\n\n${s.summary}\n\n🔗 [Lire l'article](${s.link})`
).join('\n\n---\n\n');
const newNote = await api.post('/api/notes', {
title: `📰 Résumé RSS - ${new Date().toLocaleDateString('fr-FR')}`,
content: result.summary
title: `📰 Résumés RSS - ${new Date().toLocaleDateString('fr-FR')}`,
content: allSummaries
});
alert('Résumé généré et sauvegardé dans une nouvelle note !');
alert(`${result.articles_count} résumés générés et sauvegardés dans une nouvelle note !`);
await loadNotes();
// Expand the new note
state.expandedNoteId = newNote.id;
renderNotes();
} else {
alert('Aucun résumé généré');
}
}
} catch (error) {
console.error('Erreur génération résumé RSS:', error);
+52 -39
View File
@@ -173,14 +173,13 @@ router.get('/articles', async (req, res) => {
/**
* POST /api/rss/summarize
* Générer un résumé des 5 derniers articles avec OpenRouter
* Générer un résumé PAR ARTICLE (100 mots max) avec lien
*/
router.post('/summarize', requireAdmin, async (req, res) => {
router.post('/summarize', async (req, res) => {
try {
// Récupérer les paramètres
const apiKey = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_api_key']);
const model = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_model']);
const prompt = await getOne('SELECT value FROM settings WHERE key = ?', ['rss_summary_prompt']);
if (!apiKey || !apiKey.value) {
return res.status(400).json({ error: 'Clé API OpenRouter non configurée' });
@@ -189,10 +188,11 @@ router.post('/summarize', requireAdmin, async (req, res) => {
// Récupérer les 5 derniers articles
const articles = await getAll(`
SELECT
a.title, a.description, a.link, a.pub_date,
f.title as feed_title
a.id, a.title, a.description, a.link, a.pub_date, a.content,
COALESCE(f.title, f.url) as feed_title
FROM rss_articles a
JOIN rss_feeds f ON a.feed_id = f.id
LEFT JOIN rss_feeds f ON a.feed_id = f.id
WHERE a.pub_date IS NOT NULL
ORDER BY a.pub_date DESC
LIMIT 5
`);
@@ -201,33 +201,21 @@ router.post('/summarize', requireAdmin, async (req, res) => {
return res.status(400).json({ error: 'Aucun article à résumer' });
}
// Construire le prompt
const defaultPrompt = prompt?.value || `Tu es un assistant spécialisé dans la synthèse d'actualités. Analyse les articles suivants et crée un résumé structuré et informatif.
Pour chaque article, identifie:
- Le sujet principal
- Les points clés
- L'importance de l'information
Ensuite, génère un résumé global qui:
1. Regroupe les thèmes communs
2. Hiérarchise les informations par importance
3. Présente une vue d'ensemble claire et concise
4. Utilise un style professionnel mais accessible
Format de sortie en Markdown avec des sections claires.`;
const articlesText = articles.map((a, i) =>
`## Article ${i + 1}: ${a.title}\n**Source**: ${a.feed_title}\n**Date**: ${new Date(a.pub_date).toLocaleDateString('fr-FR')}\n**Description**: ${a.description}\n**Lien**: ${a.link}\n`
).join('\n---\n');
const fullPrompt = `${defaultPrompt}\n\n# Articles à résumer:\n\n${articlesText}`;
// Appeler OpenRouter
// Générer un résumé pour CHAQUE article (en parallèle)
const selectedModel = model?.value || 'openai/gpt-3.5-turbo';
logger.info(`Génération de ${articles.length} résumés avec ${selectedModel}...`);
logger.info(`Génération du résumé avec le modèle ${selectedModel}...`);
const summaryPromises = articles.map(async (article) => {
const prompt = `Résume cet article en maximum 100 mots. Sois concis et informatif.
Titre: ${article.title}
Source: ${article.feed_title}
Description: ${article.description || ''}
${article.content ? `Contenu: ${article.content.substring(0, 1000)}` : ''}
Résumé (100 mots max):`;
try {
const response = await axios.post(
'https://openrouter.ai/api/v1/chat/completions',
{
@@ -235,9 +223,10 @@ Format de sortie en Markdown avec des sections claires.`;
messages: [
{
role: 'user',
content: fullPrompt
content: prompt
}
]
],
max_tokens: 200
},
{
headers: {
@@ -246,27 +235,51 @@ Format de sortie en Markdown avec des sections claires.`;
'X-Title': 'NoteFlow RSS Summarizer',
'Content-Type': 'application/json'
},
timeout: 60000
timeout: 30000
}
);
const summary = response.data.choices[0].message.content;
// Sauvegarder le résumé en base de données
// Sauvegarder le résumé individuel
await runQuery(
'INSERT INTO rss_summaries (summary, model, articles_count) VALUES (?, ?, ?)',
[summary, selectedModel, articles.length]
'INSERT INTO rss_summaries (summary, model, articles_count, created_at) VALUES (?, ?, ?, datetime("now"))',
[`**${article.title}**\n\n${summary}\n\n🔗 [Lire l'article](${article.link})`, selectedModel, 1]
);
logger.info('Résumé généré et sauvegardé avec succès');
return {
article_id: article.id,
title: article.title,
link: article.link,
feed_title: article.feed_title,
summary: summary,
pub_date: article.pub_date
};
} catch (err) {
logger.error(`Erreur résumé article "${article.title}":`, err.message);
return {
article_id: article.id,
title: article.title,
link: article.link,
feed_title: article.feed_title,
summary: article.description || 'Résumé non disponible',
pub_date: article.pub_date,
error: true
};
}
});
const summaries = await Promise.all(summaryPromises);
logger.info(`${summaries.length} résumés générés avec succès`);
res.json({
summary,
summaries,
model: selectedModel,
articles_count: articles.length
});
} catch (error) {
logger.error('Erreur lors de la génération du résumé:', error);
logger.error('Erreur lors de la génération des résumés:', error);
if (error.response) {
logger.error('Réponse OpenRouter:', error.response.data);