feat(billets): SecuBox webui panel + navbar integration

menu.d/415-billets.json (category mind) surfaces Billets in the SecuBox sidebar.
A funky, emoji-rich hybrid-dark panel at /billets/ (admin webui) with live stat
count-ups, reaction breakdown, latest billets and action buttons — fed by a new
CORS-enabled GET /stats.json (public aggregate counts). Fixed a latent import-time
BILLETS_DB read (now resolved at connect()). 89 tests.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-11 16:19:35 +02:00
parent 084120c858
commit 5e77b9ebbd
7 changed files with 202 additions and 2 deletions

View File

@ -19,7 +19,11 @@ from pathlib import Path
import aiosqlite
MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations"
DEFAULT_DB_PATH = os.environ.get("BILLETS_DB", "/var/lib/secubox/billets/billets.db")
def default_db_path() -> str:
# Resolved at call time (not import) so BILLETS_DB is honoured after import.
return os.environ.get("BILLETS_DB", "/var/lib/secubox/billets/billets.db")
_MIGRATION_RE = re.compile(r"^(\d{4})_.+\.sql$")
@ -64,7 +68,7 @@ async def run_migrations(conn: aiosqlite.Connection, *, now: str) -> int:
async def connect(db_path: str | None = None, *, now: str) -> aiosqlite.Connection:
"""Open (creating the parent dir), set WAL + FK enforcement, migrate, return
a connection with `row_factory = aiosqlite.Row`."""
path = db_path or DEFAULT_DB_PATH
path = db_path or default_db_path()
if path != ":memory:":
Path(path).parent.mkdir(parents=True, exist_ok=True)
conn = await aiosqlite.connect(path)

View File

@ -214,6 +214,20 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None =
return feeds.build_jsonfeed(site_title=SITE_TITLE, base_url=base,
feed_url=f"{base}/feed.json", items=items)
@app.get("/stats.json")
async def stats_json(request: Request):
# Public aggregate counts for the SecuBox admin panel (cross-origin).
from fastapi.responses import JSONResponse
base = _base(request)
s = await repo.stats(app.state.conn)
rows, _ = await repo.list_published(app.state.conn, limit=6)
s["latest"] = [{"title": feeds.billet_title(r["body"]),
"url": f"{base}/b/{r['slug']}",
"date": (r["published_at"] or r["updated_at"])[:10]} for r in rows]
s["site_url"] = f"{base}/"
return JSONResponse(s, headers={"Access-Control-Allow-Origin": "*",
"Cache-Control": "public, max-age=30"})
@app.get("/oembed")
async def oembed_out(request: Request, url: str, format: str = "json",
maxwidth: int | None = None, maxheight: int | None = None):

View File

@ -259,3 +259,23 @@ async def visitor_reactions(conn: aiosqlite.Connection, billet_id: str,
"SELECT emoji FROM reaction WHERE billet_id=? AND visitor_token_hash=?",
(billet_id, visitor_hash)) as cur:
return {r[0] for r in await cur.fetchall()}
async def stats(conn: aiosqlite.Connection) -> dict:
"""Aggregate counts for the SecuBox webui panel (all public/derived data)."""
async def _one(sql: str) -> int:
async with conn.execute(sql) as cur:
row = await cur.fetchone()
return int(row[0]) if row and row[0] is not None else 0
reactions_by = {}
async with conn.execute("SELECT emoji, COUNT(*) FROM reaction GROUP BY emoji") as cur:
for emoji, n in await cur.fetchall():
reactions_by[emoji] = int(n)
return {
"billets_published": await _one("SELECT COUNT(*) FROM billet WHERE status='published'"),
"billets_drafts": await _one("SELECT COUNT(*) FROM billet WHERE status='draft'"),
"comments_approved": await _one("SELECT COUNT(*) FROM comment WHERE status='approved'"),
"comments_pending": await _one("SELECT COUNT(*) FROM comment WHERE status='pending'"),
"reactions_total": await _one("SELECT COUNT(*) FROM reaction"),
"reactions_by": reactions_by,
}

View File

@ -11,3 +11,7 @@ override_dh_auto_install:
install -m 0644 requirements.txt debian/secubox-billets/usr/lib/secubox/billets/requirements.txt
install -d debian/secubox-billets/etc/nginx/sites-available
install -m 0644 deploy/nginx.conf debian/secubox-billets/etc/nginx/sites-available/billets.conf
install -d debian/secubox-billets/usr/share/secubox/menu.d
cp menu.d/*.json debian/secubox-billets/usr/share/secubox/menu.d/
install -d debian/secubox-billets/usr/share/secubox/www/billets
cp -r www/billets/. debian/secubox-billets/usr/share/secubox/www/billets/

View File

@ -0,0 +1,9 @@
{
"id": "billets",
"name": "Billets",
"category": "mind",
"icon": "📮",
"path": "/billets/",
"order": 415,
"description": "Micro-blog gateway inter-médias sociaux"
}

View File

@ -94,3 +94,14 @@ async def test_og_and_oembed_discovery_on_page(client):
assert 'name="twitter:card"' in r.text
assert 'type="application/json+oembed"' in r.text
assert "↗ Republier" in r.text and "bsky.app/intent" in r.text
async def test_stats_json_cors(client):
c, slug = client
r = await c.get("/stats.json")
assert r.status_code == 200
assert r.headers.get("access-control-allow-origin") == "*"
d = r.json()
assert d["billets_published"] >= 1
assert "reactions_by" in d and isinstance(d["latest"], list)
assert d["latest"][0]["url"].endswith(f"/b/{slug}")

View File

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="fr" class="hybrid-dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>📮 Billets — SecuBox</title>
<link rel="stylesheet" href="/shared/hybrid-dark.css">
<style>
:root{ --b1:#6e40c9; --b2:#e63946; --b3:#00d4ff; --b4:#c9a84c; --ink:#e8e6d9; }
.billets-wrap{max-width:1000px;margin:0 auto;padding:1rem 1rem 4rem}
/* HERO */
.hero{position:relative;overflow:hidden;border-radius:22px;padding:2.4rem 2rem;
background:linear-gradient(120deg,#1b1030,#241436 40%,#101b28);
border:1px solid rgba(110,64,201,.4);box-shadow:0 20px 60px rgba(0,0,0,.45)}
.hero h1{margin:0;font-size:2.9rem;font-weight:800;letter-spacing:-.02em;
background:linear-gradient(90deg,#b98bff,#6e40c9 40%,#00d4ff);-webkit-background-clip:text;
background-clip:text;color:transparent}
.hero p{margin:.4rem 0 0;color:#b8b0d8;font-size:1.05rem}
.emoji-strip{margin-top:1rem;font-size:1.7rem;letter-spacing:.35rem}
.emoji-strip span{display:inline-block;animation:bob 3s ease-in-out infinite}
.emoji-strip span:nth-child(2n){animation-delay:.4s}
.emoji-strip span:nth-child(3n){animation-delay:.8s}
@keyframes bob{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-9px) rotate(-6deg)}}
.hero .blob{position:absolute;border-radius:50%;filter:blur(42px);opacity:.5;z-index:0}
.hero .blob.a{width:220px;height:220px;background:#6e40c9;top:-70px;right:-40px}
.hero .blob.b{width:180px;height:180px;background:#00d4ff;bottom:-70px;left:20%}
.hero>*{position:relative;z-index:1}
/* STAT CARDS */
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:.9rem;margin:1.4rem 0}
.card{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);border-radius:16px;
padding:1.1rem;text-align:center;transition:transform .18s,border-color .18s}
.card:hover{transform:translateY(-4px);border-color:rgba(0,212,255,.5)}
.card .big{font-size:2.2rem;font-weight:800;color:var(--ink);font-variant-numeric:tabular-nums}
.card .lab{color:#9a93b8;font-size:.82rem;margin-top:.2rem}
.card .em{font-size:1.5rem}
/* REACTION BAR */
.reacts{display:flex;flex-wrap:wrap;gap:.5rem;margin:.2rem 0 1.4rem}
.reacts .chip{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.1);
border-radius:999px;padding:.35rem .8rem;font-size:1.05rem}
.reacts .chip b{color:var(--b3);font-variant-numeric:tabular-nums;margin-left:.2rem}
/* ACTIONS */
.actions{display:flex;flex-wrap:wrap;gap:.7rem;margin:1.2rem 0}
.btn{display:inline-flex;align-items:center;gap:.5rem;text-decoration:none;font-weight:700;
padding:.7rem 1.15rem;border-radius:12px;border:1px solid transparent;cursor:pointer;font-size:.98rem}
.btn.p{background:linear-gradient(120deg,#6e40c9,#8b5cf6);color:#fff}
.btn.s{background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.14);color:var(--ink)}
.btn:hover{transform:translateY(-2px);box-shadow:0 10px 26px rgba(110,64,201,.35)}
/* LATEST */
.latest h2{font-size:1.15rem;color:var(--ink);margin:1.4rem 0 .6rem}
.latest a{display:flex;justify-content:space-between;gap:1rem;text-decoration:none;
padding:.7rem .9rem;border-radius:12px;border:1px solid rgba(255,255,255,.07);
background:rgba(255,255,255,.02);margin-bottom:.5rem;color:var(--ink);transition:border-color .15s}
.latest a:hover{border-color:rgba(0,212,255,.5)}
.latest .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.latest .d{color:#8a83a8;font-size:.8rem;flex:none}
.muted{color:#8a83a8}
</style>
</head>
<body class="hybrid-dark">
<nav class="sidebar" id="sidebar"></nav>
<script src="/shared/sidebar.js"></script>
<main class="main">
<div class="billets-wrap">
<section class="hero">
<span class="blob a"></span><span class="blob b"></span>
<h1>📮 Billets</h1>
<p>Micro-blog <b>gateway</b> inter-médias sociaux — publie court, embarque, republie 🌍</p>
<div class="emoji-strip" aria-hidden="true">
<span>👍</span><span>❤️</span><span>😂</span><span>😮</span><span>😢</span><span>🔥</span>
<span></span><span>📮</span><span>🚀</span>
</div>
</section>
<div class="cards" id="cards">
<div class="card"><div class="em">📝</div><div class="big" data-k="billets_published"></div><div class="lab">Billets publiés</div></div>
<div class="card"><div class="em">📄</div><div class="big" data-k="billets_drafts"></div><div class="lab">Brouillons</div></div>
<div class="card"><div class="em">💬</div><div class="big" data-k="comments_approved"></div><div class="lab">Commentaires</div></div>
<div class="card"><div class="em"></div><div class="big" data-k="reactions_total"></div><div class="lab">Réactions</div></div>
</div>
<div class="reacts" id="reacts"></div>
<div class="actions" id="actions">
<a class="btn p" id="a-open" target="_blank" rel="noopener">🚀 Ouvrir le blog</a>
<a class="btn s" id="a-new" target="_blank" rel="noopener">✍️ Nouveau billet</a>
<a class="btn s" id="a-admin" target="_blank" rel="noopener">🔐 Admin</a>
<a class="btn s" id="a-feed" target="_blank" rel="noopener">📡 Flux Atom</a>
</div>
<section class="latest">
<h2>🕒 Derniers billets</h2>
<div id="latest"><p class="muted">Chargement… ⏳</p></div>
</section>
</div>
</main>
<script>
(function(){
var FALLBACK = "https://billets.gk2.secubox.in";
var EMOJI_ORDER = ["👍","❤️","😂","😮","😢","🔥"];
function bump(el, to){ // count-up animation
var from=0, t0=performance.now(), dur=700;
function step(t){ var k=Math.min(1,(t-t0)/dur); el.textContent=Math.round(from+(to-from)*(k*(2-k))); if(k<1)requestAnimationFrame(step); }
requestAnimationFrame(step);
}
function links(site){
document.getElementById('a-open').href = site + "/";
document.getElementById('a-new').href = site + "/admin/billets/new";
document.getElementById('a-admin').href = site + "/admin";
document.getElementById('a-feed').href = site + "/feed.xml";
}
links(FALLBACK);
fetch(FALLBACK + "/stats.json", {mode:"cors"}).then(function(r){return r.json();}).then(function(s){
var site = (s.site_url||FALLBACK+"/").replace(/\/$/,"");
links(site);
document.querySelectorAll('.card .big[data-k]').forEach(function(el){
var v = s[el.getAttribute('data-k')]; if(typeof v==='number') bump(el, v); else el.textContent = v||0;
});
var rb = s.reactions_by||{}, rc=document.getElementById('reacts');
rc.innerHTML = EMOJI_ORDER.map(function(e){ return '<span class="chip">'+e+'<b>'+(rb[e]||0)+'</b></span>'; }).join('');
var lt = document.getElementById('latest');
if(s.latest && s.latest.length){
lt.innerHTML = s.latest.map(function(b){
var t=(b.title||'billet').replace(/[<>&]/g,function(c){return {'<':'&lt;','>':'&gt;','&':'&amp;'}[c];});
return '<a href="'+b.url+'" target="_blank" rel="noopener"><span class="t">'+t+'</span><span class="d">'+(b.date||'')+'</span></a>';
}).join('');
} else { lt.innerHTML = '<p class="muted">Aucun billet publié — clique ✍️ Nouveau billet.</p>'; }
}).catch(function(){
document.getElementById('latest').innerHTML = '<p class="muted">Blog injoignable pour l\'instant 😴 — <a href="'+FALLBACK+'" target="_blank" rel="noopener">ouvrir le blog</a>.</p>';
document.getElementById('reacts').innerHTML = EMOJI_ORDER.map(function(e){return '<span class="chip">'+e+'</span>';}).join('');
});
})();
</script>
</body>
</html>