v1.6.8 — scripts CSP-conformes, densité L/M/S, RSS XML #76
@@ -9,6 +9,31 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.6.8] - 2026-05-15
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Tous les scripts inline déplacés vers des fichiers JS statiques (`density-fouc.js`, `density.js`, `trending-home.js`, `admin-stats.js`) — conformité CSP `script-src 'self'` (varlog)
|
||||||
|
- `onclick` / `onchange` inline dans `admin.php` migrés vers `admin.js`
|
||||||
|
- Densité M (980 px) définie comme valeur par défaut au lieu de L (pleine largeur)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.6.7] - 2026-05-15
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Sélecteur de densité L / M / S sur la page liste : pleine largeur (défaut), normal (980 px), compact (660 px) — préférence persistée dans `localStorage`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.6.6] - 2026-05-15
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- Page d'accueil "Meilleures audiences" : chargement AJAX depuis le flux RSS XML `/trending?period=1h` (DOMParser côté client, plus de rendu PHP)
|
||||||
|
- `/admin/stats` section "Pages les plus visitées" : chargement AJAX depuis le flux RSS XML `/trending?period=14d` — plus de parsing de logs direct pour cette colonne
|
||||||
|
- `/admin/stats` : suppression de `topGrouped` pour les pages ; seuls les livres (`/book/`) et l'ASN conservent le parsing log côté serveur
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.6.5] - 2026-05-15
|
## [1.6.5] - 2026-05-15
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|||||||
@@ -133,12 +133,20 @@ Ou créer directement `$DATA_PATH/site_settings.json` :
|
|||||||
|
|
||||||
## Mise à jour
|
## Mise à jour
|
||||||
|
|
||||||
|
### Manuelle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
git pull
|
||||||
composer install --no-dev
|
composer install --no-dev
|
||||||
php database/migrate.php
|
php database/migrate.php
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Via le bouton admin ("Mettre à jour")
|
||||||
|
|
||||||
|
L'interface d'administration propose un bouton **Mettre à jour** qui déclenche un déploiement complet via `sudo /usr/local/bin/folio-upgrade.sh`. Une configuration sudoers est requise une fois par serveur.
|
||||||
|
|
||||||
|
→ Voir **[docs/deployment.md](docs/deployment.md)** pour la procédure complète.
|
||||||
|
|
||||||
## Structure du projet
|
## Structure du projet
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Déploiement et mise à jour
|
||||||
|
|
||||||
|
## Mise à jour via le bouton admin
|
||||||
|
|
||||||
|
L'interface d'administration propose un bouton **Mettre à jour** (onglet Dashboard). Il appelle `sudo /usr/local/bin/folio-upgrade.sh` depuis PHP (`www-data`) et exécute la séquence complète :
|
||||||
|
|
||||||
|
1. Sauvegarde du `.env`
|
||||||
|
2. `git clone --depth=1` dans un répertoire temporaire
|
||||||
|
3. Remplacement atomique du répertoire applicatif
|
||||||
|
4. `chown -R www-data:www-data` + `chmod g+rwX,o=`
|
||||||
|
5. Restauration du `.env`
|
||||||
|
6. `composer install --no-dev --optimize-autoloader`
|
||||||
|
7. `php database/migrate.php` (migrations SQL)
|
||||||
|
8. Création de `.sessions/` avec les bons droits
|
||||||
|
9. `git config --system --add safe.directory`
|
||||||
|
|
||||||
|
### Pré-requis serveur (à faire une fois)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Installer le script (copié depuis le dépôt)
|
||||||
|
sudo install -o root -m 750 /var/www/mon-site/scripts/server/folio-upgrade.sh \
|
||||||
|
/usr/local/bin/folio-upgrade.sh
|
||||||
|
|
||||||
|
# 2. Adapter APP_DIR et REPO_URL en tête du script
|
||||||
|
sudo nano /usr/local/bin/folio-upgrade.sh
|
||||||
|
|
||||||
|
# 3. Créer la règle sudoers (www-data sans mot de passe)
|
||||||
|
echo "www-data ALL=(root) NOPASSWD: /usr/local/bin/folio-upgrade.sh" \
|
||||||
|
| sudo tee /etc/sudoers.d/folio-upgrade
|
||||||
|
sudo chmod 440 /etc/sudoers.d/folio-upgrade
|
||||||
|
|
||||||
|
# 4. Vérifier la syntaxe sudoers
|
||||||
|
sudo visudo -c
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables à configurer dans le script :
|
||||||
|
|
||||||
|
| Variable | Exemple |
|
||||||
|
|---|---|
|
||||||
|
| `APP_DIR` | `/var/www/lan.acegrp.abonnel-www` |
|
||||||
|
| `REPO_URL` | `https://git.abonnel.fr/cedricAbonnel/folio.git` |
|
||||||
|
|
||||||
|
> **Sans cette configuration**, le bouton retourne :
|
||||||
|
> `sudo: a terminal is required to read the password`
|
||||||
|
|
||||||
|
### Fonctionnement du cache de mise à jour
|
||||||
|
|
||||||
|
Le résultat de la dernière mise à jour est conservé dans `DATA_PATH/.upgrade-log` et affiché en `<details>` dans l'admin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mise à jour manuelle
|
||||||
|
|
||||||
|
Si le bouton admin n'est pas configuré ou si une mise à jour d'urgence est nécessaire :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sauvegarde du .env
|
||||||
|
cp /var/www/mon-site/.env /tmp/.env.bak
|
||||||
|
|
||||||
|
# Clone fresh
|
||||||
|
sudo rm -rf /var/www/mon-site
|
||||||
|
sudo git clone --depth=1 https://git.abonnel.fr/cedricAbonnel/folio.git /var/www/mon-site
|
||||||
|
|
||||||
|
# Permissions
|
||||||
|
sudo chown -R www-data:www-data /var/www/mon-site
|
||||||
|
sudo chmod -R g+rwX,o= /var/www/mon-site
|
||||||
|
|
||||||
|
# Restaurer .env
|
||||||
|
cp /tmp/.env.bak /var/www/mon-site/.env
|
||||||
|
|
||||||
|
# Dépendances et migrations
|
||||||
|
cd /var/www/mon-site
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
php database/migrate.php
|
||||||
|
|
||||||
|
# Répertoire de sessions
|
||||||
|
sudo mkdir -p /var/www/mon-site/.sessions
|
||||||
|
sudo chown www-data:www-data /var/www/mon-site/.sessions
|
||||||
|
sudo chmod 700 /var/www/mon-site/.sessions
|
||||||
|
|
||||||
|
# Autoriser git (accès multi-utilisateurs)
|
||||||
|
sudo git config --system --add safe.directory /var/www/mon-site
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flux RSS des tendances (`/trending`)
|
||||||
|
|
||||||
|
Le flux RSS des articles les plus consultés est alimenté par `TrendingParser` qui lit les logs Apache.
|
||||||
|
|
||||||
|
- **Source** : `GET /trending?period=<période>` — parse les logs et écrit `DATA_PATH/_cache/trending_<période>.json`
|
||||||
|
- **Consommateurs** (lecture seule du cache) : page d'accueil (rubrique "Meilleures audiences") et `/tendances`
|
||||||
|
|
||||||
|
### Périodes disponibles
|
||||||
|
|
||||||
|
| Paramètre | Fenêtre | Cache TTL |
|
||||||
|
|---|---|---|
|
||||||
|
| `10m` | 10 min | 2 min |
|
||||||
|
| `20m` | 20 min | 4 min |
|
||||||
|
| `30m` | 30 min | 6 min |
|
||||||
|
| `1h` | 1 heure | 12 min |
|
||||||
|
| `8h` | 8 heures | 96 min |
|
||||||
|
| `1d` | 24 heures | 5 h |
|
||||||
|
| `7d` | 7 jours | 8 h |
|
||||||
|
| `14d` | 14 jours | 8 h |
|
||||||
|
| `30d` | 30 jours | 8 h |
|
||||||
|
| `1y` | 1 an | 8 h |
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
|
||||||
|
`www-data` doit appartenir au groupe `adm` pour lire `/var/log/apache2/` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo usermod -aG adm www-data
|
||||||
|
```
|
||||||
@@ -1266,6 +1266,40 @@ footer.mt-5 { margin-top: 0 !important; }
|
|||||||
letter-spacing: .01em;
|
letter-spacing: .01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Densité d'affichage L / M / S ──────── */
|
||||||
|
main { transition: max-width .22s ease; }
|
||||||
|
|
||||||
|
/* Widget fixe haut-droite */
|
||||||
|
.density-widget {
|
||||||
|
position: fixed;
|
||||||
|
top: 3.6rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 1010;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--vl-surface);
|
||||||
|
border: 1px solid var(--vl-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px;
|
||||||
|
box-shadow: var(--vl-shadow-sm);
|
||||||
|
}
|
||||||
|
.density-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--vl-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
transition: background .15s, color .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.density-btn:hover { background: rgba(0,0,0,.06); color: var(--vl-text); }
|
||||||
|
.density-btn.active { background: var(--vl-text); color: var(--vl-bg); }
|
||||||
|
@media (max-width: 576px) { .density-widget { display: none; } }
|
||||||
|
|
||||||
/* ─── Page de recherche ───────────────────── */
|
/* ─── Page de recherche ───────────────────── */
|
||||||
.search-page { max-width: 780px; margin: 0 auto; }
|
.search-page { max-width: 780px; margin: 0 auto; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/* Admin stats : groupes AS + chargement pages via flux RSS XML /trending?period=14d */
|
||||||
|
|
||||||
|
// ── Groupes de réseaux ────────────────────────────────────────────────────────
|
||||||
|
(function () {
|
||||||
|
var addBtn = document.getElementById('as-group-add');
|
||||||
|
if (!addBtn) { return; }
|
||||||
|
|
||||||
|
addBtn.addEventListener('click', function () {
|
||||||
|
var tpl = document.getElementById('as-group-tpl').content.cloneNode(true);
|
||||||
|
document.getElementById('as-groups-list').appendChild(tpl);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('as-groups-list').addEventListener('click', function (e) {
|
||||||
|
if (e.target.classList.contains('as-group-delete')) {
|
||||||
|
e.target.closest('.as-group-row').remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
|
||||||
|
// ── Pages les plus visitées (RSS XML) ────────────────────────────────────────
|
||||||
|
(function () {
|
||||||
|
var container = document.getElementById('stats-pages-container');
|
||||||
|
var badge = document.getElementById('stats-pages-count');
|
||||||
|
if (!container) { return; }
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/trending?period=14d')
|
||||||
|
.then(function (r) { return r.ok ? r.text() : Promise.reject(); })
|
||||||
|
.then(function (xml) {
|
||||||
|
var doc = new DOMParser().parseFromString(xml, 'application/xml');
|
||||||
|
var items = Array.from(doc.querySelectorAll('item'));
|
||||||
|
if (!items.length) {
|
||||||
|
container.innerHTML = '<p class="text-muted p-3 mb-0">Aucune donnée.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var rows = items.map(function (item) {
|
||||||
|
var raw = (item.querySelector('title') || { textContent: '' }).textContent;
|
||||||
|
var link = ((item.querySelector('link') || {}).textContent || '').trim();
|
||||||
|
var m = raw.match(/\((\d+)\s+visiteurs?\)$/);
|
||||||
|
var vis = m ? parseInt(m[1], 10) : 0;
|
||||||
|
var title = raw.replace(/\s*\(\d+\s+visiteurs?\)$/, '');
|
||||||
|
var slug = decodeURIComponent(link.replace(/.*\/post\//, ''));
|
||||||
|
return { title: title, link: link, slug: slug, vis: vis };
|
||||||
|
});
|
||||||
|
var maxV = Math.max.apply(null, rows.map(function (r) { return r.vis; })) || 1;
|
||||||
|
var html = '<div class="table-responsive"><table class="table table-sm table-hover mb-0 small"><tbody>';
|
||||||
|
rows.forEach(function (row, i) {
|
||||||
|
var pct = Math.round(row.vis / maxV * 100);
|
||||||
|
var vis = row.vis.toLocaleString('fr-FR');
|
||||||
|
html += '<tr>'
|
||||||
|
+ '<td class="text-muted ps-3" style="width:2rem">' + (i + 1) + '</td>'
|
||||||
|
+ '<td><a href="' + esc(row.link) + '" target="_blank" class="text-decoration-none text-truncate d-block" style="max-width:260px" title="' + esc(row.slug) + '">'
|
||||||
|
+ esc(row.title || row.slug) + '</a>'
|
||||||
|
+ '<div class="progress mt-1" style="height:3px"><div class="progress-bar" style="width:' + pct + '%"></div></div></td>'
|
||||||
|
+ '<td class="text-end fw-semibold pe-3">' + vis + ' <span class="text-muted fw-normal">vis.</span></td>'
|
||||||
|
+ '</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
if (badge) { badge.textContent = rows.length + ' URLs'; }
|
||||||
|
container.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
container.innerHTML = '<p class="text-muted p-3 mb-0">Impossible de charger le flux.</p>';
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -45,4 +45,28 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suppression groupée avec confirmation (remplace onclick inline)
|
||||||
|
var bulkDeleteBtn = document.getElementById('bulk-delete-btn');
|
||||||
|
if (bulkDeleteBtn) {
|
||||||
|
bulkDeleteBtn.addEventListener('click', function (e) {
|
||||||
|
var checked = document.querySelectorAll('.bulk-check:checked').length;
|
||||||
|
if (checked === 0) { e.preventDefault(); return; }
|
||||||
|
var msg = bulkDeleteBtn.getAttribute('data-confirm-bulk') || 'Confirmer ?';
|
||||||
|
if (!window.confirm(msg)) { e.preventDefault(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajout d'un article à un livre (remplace onchange="bookAddArticle(this)")
|
||||||
|
var bookArticleSel = document.getElementById('book-article-select');
|
||||||
|
if (bookArticleSel) {
|
||||||
|
bookArticleSel.addEventListener('change', function () {
|
||||||
|
var slug = bookArticleSel.value;
|
||||||
|
if (!slug) { return; }
|
||||||
|
var ta = document.getElementById('book-articles-ta');
|
||||||
|
var lines = ta.value.split('\n').map(function (s) { return s.trim(); }).filter(Boolean);
|
||||||
|
if (lines.indexOf(slug) === -1) { lines.push(slug); ta.value = lines.join('\n'); }
|
||||||
|
bookArticleSel.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/* Anti-FOUC densité — chargé tôt dans <head> pour appliquer max-width avant rendu de <main> */
|
||||||
|
(function () {
|
||||||
|
var d = localStorage.getItem('folio_density') || 'm';
|
||||||
|
if (d !== 'l') {
|
||||||
|
var mw = d === 'm' ? '980px' : '660px';
|
||||||
|
var s = document.createElement('style');
|
||||||
|
s.id = 'density-fouc';
|
||||||
|
s.textContent = 'main[role="main"]{max-width:' + mw + '!important;margin-left:auto!important;margin-right:auto!important}';
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
}());
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/* Sélecteur de densité L/M/S — persisté dans localStorage */
|
||||||
|
(function () {
|
||||||
|
var KEY = 'folio_density';
|
||||||
|
var cur = localStorage.getItem(KEY) || 'm';
|
||||||
|
|
||||||
|
function applyDensity(d) {
|
||||||
|
var fouc = document.getElementById('density-fouc');
|
||||||
|
if (d !== 'l') {
|
||||||
|
var mw = d === 'm' ? '980px' : '660px';
|
||||||
|
if (!fouc) {
|
||||||
|
fouc = document.createElement('style');
|
||||||
|
fouc.id = 'density-fouc';
|
||||||
|
document.head.appendChild(fouc);
|
||||||
|
}
|
||||||
|
fouc.textContent = 'main[role="main"]{max-width:' + mw + '!important;margin-left:auto!important;margin-right:auto!important}';
|
||||||
|
} else {
|
||||||
|
if (fouc) { fouc.parentNode.removeChild(fouc); }
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.density-btn').forEach(function (btn) {
|
||||||
|
btn.classList.toggle('active', btn.getAttribute('data-d') === d);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDensity(cur);
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var el = e.target;
|
||||||
|
while (el && el !== document) {
|
||||||
|
if (el.classList && el.classList.contains('density-btn')) {
|
||||||
|
cur = el.getAttribute('data-d') || 'l';
|
||||||
|
try { localStorage.setItem(KEY, cur); } catch (ignore) {}
|
||||||
|
applyDensity(cur);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el = el.parentNode;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/* Chargement AJAX de la section "Meilleures audiences" via le flux RSS XML /trending?period=1h */
|
||||||
|
(function () {
|
||||||
|
var grid = document.getElementById('home-audiences-grid');
|
||||||
|
if (!grid) { return; }
|
||||||
|
|
||||||
|
var gradients = [
|
||||||
|
'linear-gradient(135deg,#667eea 0%,#764ba2 100%)',
|
||||||
|
'linear-gradient(135deg,#f093fb 0%,#f5576c 100%)',
|
||||||
|
'linear-gradient(135deg,#4facfe 0%,#00f2fe 100%)',
|
||||||
|
'linear-gradient(135deg,#43e97b 0%,#38f9d7 100%)',
|
||||||
|
'linear-gradient(135deg,#fa709a 0%,#fee140 100%)',
|
||||||
|
'linear-gradient(135deg,#a18cd1 0%,#fbc2eb 100%)'
|
||||||
|
];
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/trending?period=1h')
|
||||||
|
.then(function (r) { return r.ok ? r.text() : Promise.reject(); })
|
||||||
|
.then(function (xml) {
|
||||||
|
var doc = new DOMParser().parseFromString(xml, 'application/xml');
|
||||||
|
var items = Array.from(doc.querySelectorAll('item')).slice(0, 6);
|
||||||
|
if (!items.length) { return; }
|
||||||
|
|
||||||
|
grid.innerHTML = items.map(function (item, i) {
|
||||||
|
var raw = (item.querySelector('title') || { textContent: '' }).textContent;
|
||||||
|
var title = raw.replace(/\s*\(\d+\s+visiteurs?\)$/, '');
|
||||||
|
var link = ((item.querySelector('link') || {}).textContent || '#').trim();
|
||||||
|
var pd = (item.querySelector('pubDate') || { textContent: '' }).textContent;
|
||||||
|
var date = '';
|
||||||
|
try { if (pd) { date = new Date(pd).toLocaleDateString('fr-FR'); } } catch (err) {}
|
||||||
|
var grad = gradients[i % gradients.length];
|
||||||
|
|
||||||
|
return '<article class="card">'
|
||||||
|
+ '<div class="card-cover" style="background:' + grad + '"></div>'
|
||||||
|
+ '<div class="card-body d-flex flex-column">'
|
||||||
|
+ '<h2 class="card-title"><a href="' + esc(link) + '">' + esc(title) + '</a></h2>'
|
||||||
|
+ '<div class="post-entry-meta mt-auto">'
|
||||||
|
+ (date ? '<span>' + esc(date) + '</span>' : '')
|
||||||
|
+ '<a href="' + esc(link) + '" class="post-entry-read">→ lire</a>'
|
||||||
|
+ '</div></div>'
|
||||||
|
+ '<a href="' + esc(link) + '" class="stretched-link"></a>'
|
||||||
|
+ '</article>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
var section = document.getElementById('home-audiences-section');
|
||||||
|
if (section) { section.hidden = false; }
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}());
|
||||||
+4
-9
@@ -2559,25 +2559,20 @@ switch ($action) {
|
|||||||
$statsRaw = json_decode((string) file_get_contents($statsCacheFile), true) ?: null;
|
$statsRaw = json_decode((string) file_get_contents($statsCacheFile), true) ?: null;
|
||||||
}
|
}
|
||||||
if ($statsRaw === null) {
|
if ($statsRaw === null) {
|
||||||
$cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400);
|
$cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400);
|
||||||
$tParser = new TrendingParser('/var/log/apache2', apacheAccessLog());
|
$tParser = new TrendingParser('/var/log/apache2', apacheAccessLog());
|
||||||
$grouped = $tParser->topGrouped($cutoff14, ['/post/' => 30, '/book/' => 20]);
|
|
||||||
|
|
||||||
// IPs pour le lookup ASN (AccessLogParser conserve le comptage brut par IP)
|
|
||||||
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog());
|
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog());
|
||||||
$topIps = array_slice($accessParser->stats()['ips'], 0, 200, true);
|
$topIps = array_slice($accessParser->stats()['ips'], 0, 200, true);
|
||||||
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
|
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
|
||||||
|
|
||||||
$statsRaw = [
|
$statsRaw = [
|
||||||
'readable' => $tParser->isReadable(),
|
'readable' => $accessParser->isReadable(),
|
||||||
'pages' => $grouped['/post/'],
|
'books' => $tParser->top($cutoff14, 20, ['/book/']),
|
||||||
'books' => $grouped['/book/'],
|
|
||||||
'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
|
'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
|
||||||
];
|
];
|
||||||
@file_put_contents($statsCacheFile, json_encode($statsRaw));
|
@file_put_contents($statsCacheFile, json_encode($statsRaw));
|
||||||
}
|
}
|
||||||
$adminData['stats_readable'] = $statsRaw['readable'];
|
$adminData['stats_readable'] = $statsRaw['readable'];
|
||||||
$adminData['stats_pages'] = $statsRaw['pages'];
|
|
||||||
$adminData['stats_books'] = $statsRaw['books'];
|
$adminData['stats_books'] = $statsRaw['books'];
|
||||||
$adminData['stats_as'] = $statsRaw['as'];
|
$adminData['stats_as'] = $statsRaw['as'];
|
||||||
$adminData['stats_as_groups'] = AsnLookup::applyGroups($statsRaw['as'], asGroups());
|
$adminData['stats_as_groups'] = AsnLookup::applyGroups($statsRaw['as'], asGroups());
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.6.5
|
1.6.8
|
||||||
|
|||||||
+3
-16
@@ -264,8 +264,8 @@ function adminStatusBadge(array $a, int $now): string
|
|||||||
<input class="form-check-input" type="checkbox" id="check-all">
|
<input class="form-check-input" type="checkbox" id="check-all">
|
||||||
<label class="form-check-label small text-muted" for="check-all">Tout sélectionner</label>
|
<label class="form-check-label small text-muted" for="check-all">Tout sélectionner</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-danger btn-sm"
|
<button type="submit" id="bulk-delete-btn" class="btn btn-danger btn-sm"
|
||||||
onclick="return document.querySelectorAll('.bulk-check:checked').length > 0 && confirm('Supprimer les articles sélectionnés ? Cette action est irréversible.')">
|
data-confirm-bulk="Supprimer les articles sélectionnés ? Cette action est irréversible.">
|
||||||
Supprimer la sélection
|
Supprimer la sélection
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1273,7 +1273,7 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label small fw-medium">Ajouter une page existante</label>
|
<label class="form-label small fw-medium">Ajouter une page existante</label>
|
||||||
<select class="form-select" onchange="bookAddArticle(this)">
|
<select class="form-select" id="book-article-select">
|
||||||
<option value="">— Choisir un article —</option>
|
<option value="">— Choisir un article —</option>
|
||||||
<?php
|
<?php
|
||||||
$alreadyIn = $eb['articles'] ?? [];
|
$alreadyIn = $eb['articles'] ?? [];
|
||||||
@@ -1304,19 +1304,6 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
|
|||||||
<button type="submit" class="btn btn-outline-danger btn-sm">🗑 Supprimer ce livre</button>
|
<button type="submit" class="btn btn-outline-danger btn-sm">🗑 Supprimer ce livre</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
|
||||||
function bookAddArticle(sel) {
|
|
||||||
var slug = sel.value;
|
|
||||||
if (!slug) return;
|
|
||||||
var ta = document.getElementById('book-articles-ta');
|
|
||||||
var lines = ta.value.split('\n').map(function(s) { return s.trim(); }).filter(Boolean);
|
|
||||||
if (lines.indexOf(slug) === -1) {
|
|
||||||
lines.push(slug);
|
|
||||||
ta.value = lines.join('\n');
|
|
||||||
}
|
|
||||||
sel.value = '';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<?php elseif (isset($_GET['new'])): ?>
|
<?php elseif (isset($_GET['new'])): ?>
|
||||||
<h5>Nouveau livre</h5>
|
<h5>Nouveau livre</h5>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
$_statsSaved = isset($_GET['saved']);
|
$_statsSaved = isset($_GET['saved']);
|
||||||
$_statsError = ($_GET['error'] ?? '') === 'write';
|
$_statsError = ($_GET['error'] ?? '') === 'write';
|
||||||
$_readable = $adminData['stats_readable'] ?? false;
|
$_readable = $adminData['stats_readable'] ?? false;
|
||||||
$_pages = $adminData['stats_pages'] ?? [];
|
|
||||||
$_books = $adminData['stats_books'] ?? [];
|
$_books = $adminData['stats_books'] ?? [];
|
||||||
$_asList = $adminData['stats_as'] ?? [];
|
$_asList = $adminData['stats_as'] ?? [];
|
||||||
$_asGroups = $adminData['stats_as_groups'] ?? [];
|
$_asGroups = $adminData['stats_as_groups'] ?? [];
|
||||||
@@ -23,51 +22,19 @@ $_activeGroup = trim($_GET['group'] ?? '');
|
|||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
|
||||||
<p class="text-muted small mb-4">14 derniers jours · visiteurs uniques · cache 60 s</p>
|
<p class="text-muted small mb-4">14 derniers jours · visiteurs uniques · flux RSS XML</p>
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
|
|
||||||
<!-- Pages -->
|
<!-- Pages (chargées via le flux RSS XML /trending?period=14d) -->
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header bg-transparent py-2 small fw-semibold d-flex justify-content-between">
|
<div class="card-header bg-transparent py-2 small fw-semibold d-flex justify-content-between">
|
||||||
<span>Pages les plus visitées</span>
|
<span>Pages les plus visitées</span>
|
||||||
<span class="text-muted"><?= count($_pages) ?> URLs</span>
|
<span class="text-muted" id="stats-pages-count"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0" id="stats-pages-container">
|
||||||
<?php if (empty($_pages)): ?>
|
<p class="text-muted p-3 mb-0">Chargement…</p>
|
||||||
<p class="text-muted p-3 mb-0">Aucune donnée.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm table-hover mb-0 small">
|
|
||||||
<tbody>
|
|
||||||
<?php
|
|
||||||
$maxP = max($_pages) ?: 1;
|
|
||||||
$rankP = 0;
|
|
||||||
foreach ($_pages as $url => $hits):
|
|
||||||
$rankP++;
|
|
||||||
$slug = rawurldecode(substr($url, 6));
|
|
||||||
$pct = round($hits / $maxP * 100);
|
|
||||||
?>
|
|
||||||
<tr>
|
|
||||||
<td class="text-muted ps-3" style="width:2rem"><?= $rankP ?></td>
|
|
||||||
<td>
|
|
||||||
<a href="<?= htmlspecialchars($url) ?>" target="_blank"
|
|
||||||
class="text-decoration-none text-truncate d-block" style="max-width:260px"
|
|
||||||
title="<?= htmlspecialchars($slug) ?>">
|
|
||||||
<?= htmlspecialchars($slug) ?>
|
|
||||||
</a>
|
|
||||||
<div class="progress mt-1" style="height:3px">
|
|
||||||
<div class="progress-bar" style="width:<?= $pct ?>%"></div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-end fw-semibold pe-3"><?= number_format($hits, 0, ',', '\u{202F}') ?> <span class="text-muted fw-normal">vis.</span></td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,14 +195,4 @@ $_activeGroup = trim($_GET['group'] ?? '');
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script src="/assets/js/admin-stats.js" defer></script>
|
||||||
document.getElementById('as-group-add').addEventListener('click', () => {
|
|
||||||
const tpl = document.getElementById('as-group-tpl').content.cloneNode(true);
|
|
||||||
document.getElementById('as-groups-list').appendChild(tpl);
|
|
||||||
});
|
|
||||||
document.getElementById('as-groups-list').addEventListener('click', e => {
|
|
||||||
if (e.target.classList.contains('as-group-delete')) {
|
|
||||||
e.target.closest('.as-group-row').remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body<?php if (!empty($bodyClass ?? '')): ?> class="<?= htmlspecialchars($bodyClass) ?>"<?php endif; ?>>
|
<body<?php if (!empty($bodyClass ?? '')): ?> class="<?= htmlspecialchars($bodyClass) ?>"<?php endif; ?>>
|
||||||
|
<script src="/assets/js/density-fouc.js"></script>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark mb-0" role="navigation" aria-label="Navigation principale">
|
<nav class="navbar navbar-expand-lg navbar-dark mb-0" role="navigation" aria-label="Navigation principale">
|
||||||
|
|||||||
+13
-12
@@ -88,9 +88,7 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown
|
|||||||
</form>
|
</form>
|
||||||
<p class="hero-search-stats">
|
<p class="hero-search-stats">
|
||||||
<?= $totalPublished ?> article<?= $totalPublished > 1 ? 's' : '' ?>
|
<?= $totalPublished ?> article<?= $totalPublished > 1 ? 's' : '' ?>
|
||||||
<?php if ($totalUpcoming > 0): ?>
|
<?php if ($totalUpcoming > 0): ?>· <?= $totalUpcoming ?> à venir<?php endif; ?>
|
||||||
· <?= $totalUpcoming ?> à venir
|
|
||||||
<?php endif; ?>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -155,19 +153,14 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown
|
|||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php /* ─── Tendances ───────────────────────────────────────────────────── */ ?>
|
<?php /* ─── Meilleures audiences (AJAX — flux RSS XML /trending?period=1h) ── */ ?>
|
||||||
<?php if (!empty($popularPosts)): ?>
|
<section class="home-section" id="home-audiences-section" hidden>
|
||||||
<section class="home-section">
|
|
||||||
<h2 class="home-section-title">
|
<h2 class="home-section-title">
|
||||||
Meilleures audiences <span class="home-section-title-sub">· 1 heure</span>
|
Meilleures audiences <span class="home-section-title-sub">· 1 heure</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="post-grid">
|
<div class="post-grid" id="home-audiences-grid"></div>
|
||||||
<?php foreach ($popularPosts as $_pp): ?>
|
|
||||||
<?php _renderCard($_pp, $privateCats ?? [], $allCats ?? [], $Parsedown); ?>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<script src="/assets/js/trending-home.js"></script>
|
||||||
|
|
||||||
<?php /* ─── Récemment mis à jour ──────────────────────────────────────── */ ?>
|
<?php /* ─── Récemment mis à jour ──────────────────────────────────────── */ ?>
|
||||||
<?php if (!empty($recentlyUpdated)): ?>
|
<?php if (!empty($recentlyUpdated)): ?>
|
||||||
@@ -302,6 +295,14 @@ if (!empty($_tagCats)):
|
|||||||
<a href="/new" class="fab-new" title="Nouvel article" aria-label="Nouvel article">+</a>
|
<a href="/new" class="fab-new" title="Nouvel article" aria-label="Nouvel article">+</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="density-widget" id="density-toggle-widget" role="group" aria-label="Taille d'affichage">
|
||||||
|
<button type="button" class="density-btn" data-d="l" title="Pleine largeur">L</button>
|
||||||
|
<button type="button" class="density-btn" data-d="m" title="Normal">M</button>
|
||||||
|
<button type="button" class="density-btn" data-d="s" title="Compact">S</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/assets/js/density.js"></script>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
$content = ob_get_clean();
|
$content = ob_get_clean();
|
||||||
$title = siteTitle();
|
$title = siteTitle();
|
||||||
|
|||||||
Reference in New Issue
Block a user