Début du projet

This commit is contained in:
TopScritch committed 2026-10-25 20:05:12 +01:00
commit e7d4e607f6
23 files changed
+4855

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
const Database = require('better-sqlite3');
const fs = require('fs');
const path = require('path');
const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = process.env.DB_PATH || path.join(dataDir, 'articles.db');
const db = new Database(dbPath);
function initDb() {
const migrationPath = path.join(__dirname, '..', 'migrations', '001_init.sql');
const sql = fs.readFileSync(migrationPath, 'utf8');
db.exec(sql);
console.log('[DB] Base de données initialisée:', dbPath);
}
module.exports = { db, initDb };
+170
View File
@@ -0,0 +1,170 @@
const express = require('express');
const router = express.Router();
const { db } = require('../db');
const { v4: uuidv4 } = require('uuid');
function requireAuth(req, res, next) {
if (req.session && req.session.authenticated) {
return next();
}
res.redirect('/admin/login');
}
router.get('/login', (req, res) => {
if (req.session && req.session.authenticated) {
return res.redirect('/admin');
}
res.render('admin/login', { error: null });
});
router.post('/login', (req, res) => {
const { username, password } = req.body;
const adminUser = process.env.ADMIN_USERNAME || 'admin';
const adminPass = process.env.ADMIN_PASSWORD || 'changeme123';
if (username === adminUser && password === adminPass) {
console.log("[DEBUG] Connexion OK")
req.session.authenticated = true;
req.session.username = username;
return req.session.save((err) => {
if (err) {
console.error('Session save error:', err);
}
return res.redirect('/admin');
});
}
res.render('admin/login', { error: 'Identifiants incorrects' });
});
router.get('/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/admin/login');
});
});
router.use(requireAuth);
router.get('/', (req, res) => {
const status = req.query.status || 'all';
const language = req.query.language || 'all';
// Filtre langue identique au site public (repli sur la langue de la source)
const languageExpr = `COALESCE(language, source_language, 'fr')`;
const conditions = [];
const params = [];
if (status !== 'all') {
conditions.push(`status = ?`);
params.push(status);
}
if (language !== 'all') {
conditions.push(`${languageExpr} = ?`);
params.push(language);
}
const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
const articles = db
.prepare(`SELECT * FROM articles${where} ORDER BY datetime(created_at) DESC`)
.all(...params);
// Stats (filtrées sur la langue sélectionnée)
const stats = {};
['pending', 'published', 'hidden', 'rejected'].forEach(s => {
const statParams = [s];
let statQuery = `SELECT COUNT(*) as c FROM articles WHERE status = ?`;
if (language !== 'all') {
statQuery += ` AND ${languageExpr} = ?`;
statParams.push(language);
}
stats[s] = db.prepare(statQuery).get(...statParams).c;
});
let totalQuery = `SELECT COUNT(*) as c FROM articles`;
const totalParams = [];
if (language !== 'all') {
totalQuery += ` WHERE ${languageExpr} = ?`;
totalParams.push(language);
}
stats.total = db.prepare(totalQuery).get(...totalParams).c;
// Langues disponibles dans l'ensemble des articles
const languages = db
.prepare(`SELECT ${languageExpr} as lang, COUNT(*) as c FROM articles GROUP BY lang ORDER BY c DESC, lang`)
.all();
res.render('admin/dashboard', { articles, status, stats, languages, language });
});
router.post('/article/:id/validate', (req, res) => {
const { id } = req.params;
const { categories, summary } = req.body;
try {
const categoriesJson = categories ? JSON.stringify(categories.split(',').map(c => c.trim()).filter(Boolean)) : null;
db.prepare(`UPDATE articles SET status='published', categories=COALESCE(?, categories), summary=COALESCE(?, summary), updated_at=datetime('now') WHERE id=?`)
.run(categoriesJson, summary || null, id);
db.prepare(`INSERT INTO activity_log (action, article_id, details) VALUES ('validate', ?, 'Article publié')`).run(id);
res.redirect(req.get('referer') || '/admin');
} catch(e) {
console.error(e);
res.redirect('/admin');
}
});
router.post('/article/:id/hide', (req, res) => {
const { id } = req.params;
db.prepare(`UPDATE articles SET status='hidden', updated_at=datetime('now') WHERE id=?`).run(id);
db.prepare(`INSERT INTO activity_log (action, article_id, details) VALUES ('hide', ?, 'Article masqué')`).run(id);
res.redirect(req.get('referer') || '/admin');
});
router.post('/article/:id/reject', (req, res) => {
const { id } = req.params;
db.prepare(`UPDATE articles SET status='rejected', updated_at=datetime('now') WHERE id=?`).run(id);
db.prepare(`INSERT INTO activity_log (action, article_id, details) VALUES ('reject', ?, 'Article rejeté')`).run(id);
res.redirect(req.get('referer') || '/admin');
});
router.post('/article/:id/publish', (req, res) => {
const { id } = req.params;
db.prepare(`UPDATE articles SET status='published', updated_at=datetime('now') WHERE id=?`).run(id);
db.prepare(`INSERT INTO activity_log (action, article_id, details) VALUES ('publish', ?, 'Article republié')`).run(id);
res.redirect(req.get('referer') || '/admin');
});
router.post('/article/:id/delete', (req, res) => {
const { id } = req.params;
db.prepare(`DELETE FROM articles WHERE id=?`).run(id);
db.prepare(`INSERT INTO activity_log (action, article_id, details) VALUES ('delete', ?, 'Article supprimé')`).run(id);
res.redirect(req.get('referer') || '/admin');
});
router.post('/collect', (req, res) => {
const { collect } = require('../../collector/index');
collect().then(() => {
db.prepare(`INSERT INTO activity_log (action, details) VALUES ('collect', 'Collecte manuelle déclenchée')`).run();
res.redirect('/admin?status=pending');
}).catch(err => {
console.error(err);
res.redirect('/admin');
});
});
router.get('/sources', (req, res) => {
const sourcesPath = require('path').join(__dirname, '..', '..', 'config', 'sources.json');
const sources = JSON.parse(require('fs').readFileSync(sourcesPath, 'utf8'));
res.render('admin/sources', { sources });
});
router.get('/log', (req, res) => {
const logs = db.prepare(`SELECT * FROM activity_log ORDER BY datetime(created_at) DESC LIMIT 100`).all();
res.render('admin/log', { logs });
});
module.exports = router;
+53
View File
@@ -0,0 +1,53 @@
const express = require('express');
const router = express.Router();
const { db } = require('../db');
router.get('/', (req, res) => {
const category = req.query.category;
const sort = req.query.sort || 'desc';
const lang = req.query.lang;
let query = `SELECT * FROM articles WHERE status = 'published'`;
const params = [];
if (category) {
query += ` AND categories LIKE ?`;
params.push(`%"${category}"%`);
}
if (lang) {
query += ` AND COALESCE(language, source_language, 'fr') = ?`;
params.push(lang);
}
query += ` ORDER BY datetime(published_at) ${sort === 'asc' ? 'ASC' : 'DESC'}, datetime(created_at) DESC`;
const articles = db.prepare(query).all(...params);
// Récupérer les catégories uniques
let catQuery = `SELECT categories FROM articles WHERE status = 'published' AND categories IS NOT NULL`;
const catParams = [];
if (lang) {
catQuery += ` AND COALESCE(language, source_language, 'fr') = ?`;
catParams.push(lang);
}
const allArticles = db.prepare(catQuery).all(...catParams);
const categoriesSet = new Set();
allArticles.forEach(a => {
try {
const cats = JSON.parse(a.categories);
if (Array.isArray(cats)) {
cats.forEach(c => categoriesSet.add(c));
}
} catch(e) {}
});
const categories = Array.from(categoriesSet).sort();
// Récupérer les langues disponibles
const langRows = db.prepare(`SELECT DISTINCT COALESCE(language, source_language, 'fr') as lang FROM articles WHERE status = 'published' ORDER BY lang`).all();
const languages = langRows.map(r => r.lang).filter(Boolean);
res.render('index', { articles, categories, selectedCategory: category, sort, languages, selectedLang: lang });
});
module.exports = router;
+69
View File
@@ -0,0 +1,69 @@
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const express = require('express');
const session = require('express-session');
const helmet = require('helmet');
const compression = require('compression');
const path = require('path');
const fs = require('fs');
const { initDb } = require('./db');
const publicRoutes = require('./routes/public');
const adminRoutes = require('./routes/admin');
const cron = require('node-cron');
const app = express();
const PORT = process.env.PORT || 3000;
// Initialisation BDD
initDb();
// Middlewares
app.use(helmet({
contentSecurityPolicy: false, // simple pour ce projet
}));
app.use(compression());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'static')));
app.use(session({
secret: process.env.SESSION_SECRET || 'dev-secret-change-me',
resave: false,
saveUninitialized: false,
cookie: {
secure: false,
httpOnly: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000 // 24h
}
}));
// Vues
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'templates'));
// Routes
app.use('/', publicRoutes);
app.use('/admin', adminRoutes);
// Démarrage du collecteur automatique
if (process.env.NODE_ENV !== 'test') {
const interval = parseInt(process.env.COLLECT_INTERVAL || '60');
// Lancer toutes les X minutes
cron.schedule(`*/${interval} * * * *`, () => {
console.log('[CRON] Lancement de la collecte automatique...');
const { collect } = require('../collector/index');
collect().catch(err => console.error('[CRON] Erreur collecte:', err.message));
});
// Lancer une première collecte au démarrage après 5 secondes
setTimeout(() => {
console.log('[STARTUP] Première collecte...');
const { collect } = require('../collector/index');
collect().catch(err => console.error('[STARTUP] Erreur collecte:', err.message));
}, 5000);
}
app.listen(PORT, () => {
console.log(`Serveur démarré sur http://localhost:${PORT}`);
console.log(`Admin: http://localhost:${PORT}/admin`);
});
+412
View File
@@ -0,0 +1,412 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
background-color: #f5f7fa;
line-height: 1.6;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
width: 100%;
max-width: 400px;
}
.login-container h1 {
color: #667eea;
margin-bottom: 5px;
text-align: center;
}
.login-container h2 {
color: #666;
font-weight: 400;
font-size: 1.2em;
margin-bottom: 30px;
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #333;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 1em;
font-family: inherit;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.9em;
font-family: inherit;
transition: opacity 0.2s;
}
.btn:hover {
opacity: 0.9;
}
.btn-primary {
background: #667eea;
color: white;
width: 100%;
padding: 12px;
font-size: 1em;
}
.btn-collect {
background: #4caf50;
color: white;
padding: 6px 14px;
font-size: 0.85em;
}
.btn-publish {
background: #4caf50;
color: white;
}
.btn-success {
background: #4caf50;
color: white;
padding: 6px 12px;
}
.btn-warning {
background: #ff9800;
color: white;
padding: 6px 12px;
}
.btn-danger {
background: #f44336;
color: white;
padding: 6px 12px;
}
.error {
background: #ffebee;
color: #c62828;
padding: 10px;
border-radius: 5px;
margin-bottom: 20px;
text-align: center;
}
.back-link {
text-align: center;
margin-top: 20px;
}
.back-link a {
color: #667eea;
text-decoration: none;
}
.admin-header {
background: #2c3e50;
color: white;
padding: 20px 0;
margin-bottom: 30px;
}
.admin-header h1 {
margin-bottom: 15px;
font-size: 1.8em;
}
.admin-nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.admin-nav a {
color: white;
text-decoration: none;
padding: 6px 12px;
background: rgba(255,255,255,0.1);
border-radius: 5px;
transition: background 0.2s;
font-size: 0.9em;
}
.admin-nav a:hover,
.admin-nav a.active {
background: rgba(255,255,255,0.2);
}
.admin-nav .logout {
margin-left: auto;
background: #e74c3c;
}
.filters {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
background: white;
padding: 12px 18px;
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.filters label {
font-weight: 500;
color: #333;
}
.filters select {
padding: 7px 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-family: inherit;
font-size: 0.9em;
background: white;
cursor: pointer;
}
.btn-filter {
background: #667eea;
color: white;
padding: 7px 14px;
font-size: 0.85em;
}
.stats {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin-bottom: 30px;
}
.stat-card {
background: white;
padding: 15px 25px;
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.articles-admin {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 40px;
}
.article-admin-card {
background: white;
padding: 20px;
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.article-admin-header h3 {
color: #333;
margin-bottom: 8px;
font-size: 1.2em;
}
.article-meta {
color: #666;
font-size: 0.85em;
margin-bottom: 10px;
}
.article-meta a {
color: #667eea;
}
.article-admin-summary {
color: #444;
margin: 10px 0;
padding: 10px;
background: #f9f9f9;
border-left: 3px solid #667eea;
}
.validate-form {
margin: 15px 0;
padding: 15px;
background: #f0f8ff;
border-radius: 5px;
}
.form-row {
display: flex;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.form-row input {
flex: 1;
min-width: 200px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: inherit;
}
.article-admin-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 0.75em;
font-weight: 600;
}
.badge-pending {
background: #fff3cd;
color: #856404;
}
.badge-published {
background: #d4edda;
color: #155724;
}
.badge-hidden {
background: #e2e3e5;
color: #383d41;
}
.badge-rejected {
background: #f8d7da;
color: #721c24;
}
.badge-collect {
background: #d1ecf1;
color: #0c5460;
}
.badge-validate {
background: #d4edda;
color: #155724;
}
.badge-hide {
background: #e2e3e5;
color: #383d41;
}
.empty {
text-align: center;
padding: 40px;
background: white;
color: #666;
}
.sources-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 15px;
margin-top: 20px;
}
.source-card {
background: white;
padding: 15px;
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.source-card h3 {
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.source-card p {
margin: 5px 0;
font-size: 0.9em;
word-break: break-all;
}
.source-card a {
color: #667eea;
}
.info {
background: #e7f3ff;
padding: 10px 15px;
border-radius: 5px;
margin: 15px 0;
color: #004085;
}
.log-table {
width: 100%;
background: white;
border-collapse: collapse;
margin-top: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.log-table thead {
background: #f8f9fa;
}
.log-table th,
.log-table td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #dee2e6;
font-size: 0.9em;
}
.log-table tbody tr:hover {
background: #f8f9fa;
}
+239
View File
@@ -0,0 +1,239 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
background-color: #f5f7fa;
line-height: 1.6;
color: #333;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 0 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px 0 30px;
margin-bottom: 40px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.logo {
font-size: 2.5em;
margin-bottom: 5px;
font-weight: 700;
}
.subtitle {
font-size: 1.1em;
opacity: 0.95;
margin-bottom: 25px;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
}
.nav a {
color: white;
text-decoration: none;
padding: 8px 16px;
background: rgba(255,255,255,0.2);
border-radius: 20px;
transition: background 0.3s;
font-size: 0.9em;
}
.nav a:hover,
.nav a.active {
background: rgba(255,255,255,0.4);
}
.lang-filter {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.9em;
flex-wrap: wrap;
margin-bottom: 12px;
}
.lang-filter a {
color: white;
text-decoration: none;
padding: 6px 12px;
background: rgba(255,255,255,0.2);
border-radius: 15px;
transition: background 0.3s;
}
.lang-filter a:hover,
.lang-filter a.active {
background: rgba(255,255,255,0.4);
}
.lang-badge {
display: inline-block;
background: rgba(102, 126, 234, 0.1);
color: #667eea;
padding: 2px 6px;
border-radius: 8px;
font-size: 0.75em;
font-weight: 600;
margin-left: 5px;
}
.sort {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.9em;
flex-wrap: wrap;
}
.sort a {
color: white;
text-decoration: none;
padding: 6px 12px;
background: rgba(255,255,255,0.2);
border-radius: 15px;
transition: background 0.3s;
}
.sort a:hover,
.sort a.active {
background: rgba(255,255,255,0.4);
}
.articles {
display: flex;
flex-direction: column;
gap: 25px;
margin-bottom: 40px;
}
.article-card {
background: white;
padding: 25px;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
transition: transform 0.2s, box-shadow 0.2s;
}
.article-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
}
.article-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 10px;
font-size: 0.9em;
}
.article-source {
color: #667eea;
font-weight: 600;
}
.article-date {
color: #666;
}
.article-title {
font-size: 1.5em;
margin-bottom: 12px;
color: #222;
line-height: 1.4;
}
.article-summary {
color: #444;
margin-bottom: 15px;
line-height: 1.7;
}
.article-categories {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 15px;
}
.category-tag {
background: #e8eaf6;
color: #5c6bc0;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8em;
}
.article-link {
color: #667eea;
text-decoration: none;
font-weight: 600;
transition: color 0.2s;
}
.article-link:hover {
color: #764ba2;
text-decoration: underline;
}
.empty {
text-align: center;
padding: 60px 20px;
background: white;
border-radius: 10px;
color: #666;
}
.empty .small {
margin-top: 10px;
font-size: 0.9em;
}
.footer {
text-align: center;
padding: 30px 0;
color: #666;
border-top: 1px solid #e0e0e0;
margin-top: 60px;
}
.footer .small {
margin-top: 5px;
font-size: 0.9em;
}
.footer a {
color: #667eea;
text-decoration: none;
}
@media (max-width: 600px) {
.logo {
font-size: 2em;
}
.article-card {
padding: 18px;
}
.article-title {
font-size: 1.3em;
}
}
+137
View File
@@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tableau de bord - Administration</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<header class="admin-header">
<div class="container">
<h1>Administration - Veille Tech</h1>
<nav class="admin-nav">
<a href="/admin<%= language !== 'all' ? '?language=' + encodeURIComponent(language) : '' %>" class="<%= status === 'all' ? 'active' : '' %>">Tous (<%= stats.total %>)</a>
<a href="/admin?status=pending<%= language !== 'all' ? '&language=' + encodeURIComponent(language) : '' %>" class="<%= status === 'pending' ? 'active' : '' %>">En attente (<%= stats.pending %>)</a>
<a href="/admin?status=published<%= language !== 'all' ? '&language=' + encodeURIComponent(language) : '' %>" class="<%= status === 'published' ? 'active' : '' %>">Publiés (<%= stats.published %>)</a>
<a href="/admin?status=hidden<%= language !== 'all' ? '&language=' + encodeURIComponent(language) : '' %>" class="<%= status === 'hidden' ? 'active' : '' %>">Masqués (<%= stats.hidden %>)</a>
<a href="/admin?status=rejected<%= language !== 'all' ? '&language=' + encodeURIComponent(language) : '' %>" class="<%= status === 'rejected' ? 'active' : '' %>">Rejetés (<%= stats.rejected %>)</a>
<a href="/admin/sources">Sources</a>
<a href="/admin/log">Journal</a>
<form method="POST" action="/admin/collect" style="display:inline">
<button type="submit" class="btn btn-collect">Collecter maintenant</button>
</form>
<a href="/admin/logout" class="logout">Déconnexion</a>
</nav>
</div>
</header>
<main class="container">
<% const langLabel = (l) => l === 'fr' ? 'Français' : l === 'en' ? 'Anglais' : (l || '').toUpperCase(); %>
<form method="GET" action="/admin" class="filters">
<% if (status !== 'all') { %>
<input type="hidden" name="status" value="<%= status %>">
<% } %>
<label for="language">Langue :</label>
<select id="language" name="language" onchange="this.form.submit()">
<option value="all" <%= language === 'all' ? 'selected' : '' %>>Toutes les langues</option>
<% languages.forEach(l => { %>
<option value="<%= l.lang %>" <%= language === l.lang ? 'selected' : '' %>><%= langLabel(l.lang) %> (<%= l.c %>)</option>
<% }); %>
</select>
<noscript><button type="submit" class="btn btn-filter">Filtrer</button></noscript>
</form>
<div class="stats">
<div class="stat-card">En attente: <strong><%= stats.pending %></strong></div>
<div class="stat-card">Publiés: <strong><%= stats.published %></strong></div>
<div class="stat-card">Masqués: <strong><%= stats.hidden %></strong></div>
<div class="stat-card">Rejetés: <strong><%= stats.rejected %></strong></div>
</div>
<% if (articles.length === 0) { %>
<div class="empty">Aucun article dans cette catégorie.</div>
<% } else { %>
<div class="articles-admin">
<% articles.forEach(article => { %>
<div class="article-admin-card">
<div class="article-admin-header">
<div>
<h3><%= article.title %></h3>
<div class="article-meta">
<span>Source: <%= article.source %></span> |
<span>URL: <a href="<%= article.url %>" target="_blank"><%= article.url.substring(0, 60) %>...</a></span> |
<span>Statut: <span class="badge badge-<%= article.status %>"><%= article.status %></span></span> |
<span>Langue: <%= langLabel(article.language || article.source_language || 'fr') %></span> |
<span>Collecté par: <%= article.collected_by %></span>
</div>
</div>
</div>
<% if (article.summary) { %>
<p class="article-admin-summary"><%= article.summary %></p>
<% } %>
<% if (article.status === 'pending') { %>
<form method="POST" action="/admin/article/<%= article.id %>/validate" class="validate-form">
<div class="form-row">
<input type="text" name="categories" placeholder="Catégories (séparées par virgule)"
value="<% try { const cats = JSON.parse(article.categories||'[]'); %><%= cats.join(', ') %><% } catch(e) {} %>">
<input type="text" name="summary" placeholder="Résumé à afficher (optionnel)" value="<%= article.summary || '' %>">
</div>
<button type="submit" class="btn btn-publish">Valider et publier</button>
</form>
<% } %>
<div class="article-admin-actions">
<% if (article.status !== 'published') { %>
<form method="POST" action="/admin/article/<%= article.id %>/publish" style="display:inline">
<button type="submit" class="btn btn-success">Publier</button>
</form>
<% } %>
<% if (article.status !== 'hidden') { %>
<form method="POST" action="/admin/article/<%= article.id %>/hide" style="display:inline">
<button type="submit" class="btn btn-warning">Masquer</button>
</form>
<% } %>
<% if (article.status !== 'rejected') { %>
<form method="POST" action="/admin/article/<%= article.id %>/reject" style="display:inline">
<button type="submit" class="btn btn-danger">Rejeter</button>
</form>
<% } %>
<form method="POST" action="/admin/article/<%= article.id %>/delete" style="display:inline" onsubmit="return confirm('Supprimer définitivement cet article ?')">
<button type="submit" class="btn btn-danger">Supprimer</button>
</form>
</div>
</div>
<% }); %>
</div>
<% } %>
</main>
<script>
// Conserve la position de défilement lors des actions (publier, masquer, rejeter, supprimer)
(function () {
var storageKey = 'admin-scroll:' + location.pathname + location.search;
document.querySelectorAll('form').forEach(function (form) {
if (form.method.toLowerCase() !== 'post') return;
form.addEventListener('submit', function (event) {
if (event.defaultPrevented) return; // ex. suppression annulée
sessionStorage.setItem(storageKey, String(window.scrollY));
});
});
var savedPosition = sessionStorage.getItem(storageKey);
if (savedPosition === null) return;
sessionStorage.removeItem(storageKey);
window.addEventListener('load', function () {
window.scrollTo(0, parseInt(savedPosition, 10) || 0);
});
})();
</script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Journal d'activité - Administration</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<header class="admin-header">
<div class="container">
<h1>Administration - Veille Tech</h1>
<nav class="admin-nav">
<a href="/admin">Tableau de bord</a>
<a href="/admin/sources">Sources</a>
<a href="/admin/log" class="active">Journal</a>
<a href="/admin/logout" class="logout">Déconnexion</a>
</nav>
</div>
</header>
<main class="container">
<h2>Journal d'activité (100 dernières entrées)</h2>
<table class="log-table">
<thead>
<tr>
<th>Date</th>
<th>Action</th>
<th>Article ID</th>
<th>Détails</th>
</tr>
</thead>
<tbody>
<% logs.forEach(log => { %>
<tr>
<td><%= log.created_at %></td>
<td><span class="badge badge-<%= log.action %>"><%= log.action %></span></td>
<td><%= log.article_id ? log.article_id.substring(0, 8) + '...' : '-' %></td>
<td><%= log.details || '-' %></td>
</tr>
<% }); %>
</tbody>
</table>
</main>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion - Administration</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="login-page">
<div class="login-container">
<h1>Administration</h1>
<h2>Veille Tech</h2>
<% if (error) { %>
<div class="error"><%= error %></div>
<% } %>
<form method="POST" action="/admin/login">
<div class="form-group">
<label for="username">Identifiant</label>
<input type="text" id="username" name="username" required autocomplete="username">
</div>
<div class="form-group">
<label for="password">Mot de passe</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary">Se connecter</button>
</form>
<p class="back-link"><a href="/">← Retour au site</a></p>
</div>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sources - Administration</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<header class="admin-header">
<div class="container">
<h1>Administration - Veille Tech</h1>
<nav class="admin-nav">
<a href="/admin">Tableau de bord</a>
<a href="/admin/sources" class="active">Sources</a>
<a href="/admin/log">Journal</a>
<a href="/admin/logout" class="logout">Déconnexion</a>
</nav>
</div>
</header>
<main class="container">
<h2>Sources RSS configurées</h2>
<p class="info">Les sources sont configurées dans <code>config/sources.json</code>. Vous pouvez les modifier directement dans ce fichier.</p>
<div class="sources-list">
<% sources.forEach(source => { %>
<div class="source-card">
<h3><%= source.name %> <span class="badge <%= source.enabled ? 'badge-published' : 'badge-rejected' %>"><%= source.enabled ? 'Activé' : 'Désactivé' %></span></h3>
<p><strong>ID:</strong> <%= source.id %></p>
<p><strong>URL:</strong> <a href="<%= source.url %>" target="_blank"><%= source.url %></a></p>
<p><strong>Type:</strong> <%= source.type %></p>
<p><strong>Catégorie suggérée:</strong> <%= source.category_hint || '-' %></p>
</div>
<% }); %>
</div>
</main>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agrégateur d'Actualités Informatiques</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="header">
<div class="container">
<h1 class="logo">💻 Veille Tech</h1>
<p class="subtitle">Agrégateur d'actualités informatiques</p>
<% if (typeof languages !== 'undefined' && languages.length > 0) { %>
<div class="lang-filter">
<span>Langue:</span>
<a href="/?sort=<%= sort %><%= selectedCategory ? '&category=' + encodeURIComponent(selectedCategory) : '' %>" class="<%= !selectedLang ? 'active' : '' %>">Toutes</a>
<% languages.forEach(l => { %>
<a href="/?lang=<%= encodeURIComponent(l) %>&sort=<%= sort %><%= selectedCategory ? '&category=' + encodeURIComponent(selectedCategory) : '' %>" class="<%= selectedLang === l ? 'active' : '' %>"><%= l === 'fr' ? 'Français' : l === 'en' ? 'English' : l.toUpperCase() %></a>
<% }); %>
</div>
<% } %>
<nav class="nav">
<a href="/?sort=<%= sort %><%= selectedLang ? '&lang=' + encodeURIComponent(selectedLang) : '' %>" class="<%= !selectedCategory ? 'active' : '' %>">Tous</a>
<% categories.forEach(cat => { %>
<a href="/?category=<%= encodeURIComponent(cat) %>&sort=<%= sort %><%= selectedLang ? '&lang=' + encodeURIComponent(selectedLang) : '' %>" class="<%= selectedCategory === cat ? 'active' : '' %>"><%= cat %></a>
<% }); %>
</nav>
<div class="sort">
<span>Trier par date:</span>
<a href="/?sort=desc<%= selectedCategory ? '&category=' + encodeURIComponent(selectedCategory) : '' %><%= selectedLang ? '&lang=' + encodeURIComponent(selectedLang) : '' %>" class="<%= sort === 'desc' ? 'active' : '' %>">Plus récent</a>
<a href="/?sort=asc<%= selectedCategory ? '&category=' + encodeURIComponent(selectedCategory) : '' %><%= selectedLang ? '&lang=' + encodeURIComponent(selectedLang) : '' %>" class="<%= sort === 'asc' ? 'active' : '' %>">Plus ancien</a>
</div>
</div>
</header>
<main class="container">
<% if (articles.length === 0) { %>
<div class="empty">
<p>Aucun article publié pour le moment.</p>
<p class="small">Les articles en attente doivent être validés depuis l'<a href="/admin">interface d'administration</a>.</p>
</div>
<% } else { %>
<div class="articles">
<% articles.forEach(article => { %>
<article class="article-card">
<div class="article-header">
<span class="article-source"><%= article.source || 'Source inconnue' %> <% if (article.language || article.source_language) { %><span class="lang-badge"><%= (article.language || article.source_language) === 'fr' ? 'FR' : (article.language || article.source_language).toUpperCase() %></span><% } %></span>
<span class="article-date">
<%= article.published_at ? new Date(article.published_at).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' }) : '' %>
</span>
</div>
<h2 class="article-title"><%= article.title %></h2>
<% if (article.summary) { %>
<p class="article-summary"><%= article.summary %></p>
<% } %>
<% if (article.categories) { %>
<div class="article-categories">
<% try {
const cats = JSON.parse(article.categories);
if (Array.isArray(cats)) {
cats.forEach(cat => { %>
<span class="category-tag"><%= cat %></span>
<% });
}
} catch(e) {} %>
</div>
<% } %>
<a href="<%= article.url %>" target="_blank" rel="noopener noreferrer" class="article-link">
Lire l'article original ↗
</a>
</article>
<% }); %>
</div>
<% } %>
</main>
<footer class="footer">
<div class="container">
<p>Veille Tech - Agrégateur d'actualités informatiques</p>
<p class="small"><a href="/admin">Administration</a></p>
</div>
</footer>
</body>
</html>
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.