mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
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:
parent
084120c858
commit
5e77b9ebbd
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
9
packages/secubox-billets/menu.d/415-billets.json
Normal file
9
packages/secubox-billets/menu.d/415-billets.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"id": "billets",
|
||||
"name": "Billets",
|
||||
"category": "mind",
|
||||
"icon": "📮",
|
||||
"path": "/billets/",
|
||||
"order": 415,
|
||||
"description": "Micro-blog gateway inter-médias sociaux"
|
||||
}
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
138
packages/secubox-billets/www/billets/index.html
Normal file
138
packages/secubox-billets/www/billets/index.html
Normal 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 {'<':'<','>':'>','&':'&'}[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>
|
||||
Loading…
Reference in New Issue
Block a user