From ac6df22258d5609fafc65d9af494dd4b47bbe7f2 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sat, 11 Jul 2026 16:35:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(billets):=20funky/party=20public=20webapp?= =?UTF-8?q?=20=E2=80=94=20inline=20media=20+=20link=20chips=20+=20quick-sh?= =?UTF-8?q?are?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public feed + permalink redesigned funky/party (theme-aware gradients, emoji accents, playful cards). Embeds now render INLINE in the feed too (responsive 16:9-ish media, CSP frame-src augmented for self-hosted hosts). Inline embedded reference links shown as ๐ŸŒ chips. Prominent emoji quick-share row (Bluesky/X/ WhatsApp/Telegram/Reddit/LinkedIn/email + Mastodon/copy via billets.js) on every billet in the feed and permalink (replaces the hidden Republier details). Asset URLs versioned (?v=2) to bust the sbxwaf media cache. 90 tests. Co-Authored-By: Gerald KERMA --- packages/secubox-billets/api/main.py | 32 +++- .../secubox-billets/api/static/billets.css | 161 ++++++++++++------ .../api/templates/_share_quick.html | 12 ++ .../secubox-billets/api/templates/base.html | 4 +- .../secubox-billets/api/templates/billet.html | 19 +-- .../secubox-billets/api/templates/feed.html | 3 + packages/secubox-billets/tests/test_feeds.py | 2 +- packages/secubox-billets/tests/test_public.py | 15 ++ 8 files changed, 172 insertions(+), 76 deletions(-) create mode 100644 packages/secubox-billets/api/templates/_share_quick.html diff --git a/packages/secubox-billets/api/main.py b/packages/secubox-billets/api/main.py index 91759f12..87360bf6 100644 --- a/packages/secubox-billets/api/main.py +++ b/packages/secubox-billets/api/main.py @@ -85,6 +85,19 @@ def _csp(frame_src: str) -> str: ) +def _extra_frame_hosts(rows) -> tuple[str, ...]: + """Self-hosted embed hosts (Mastodon/PeerTube) among the given billet rows, + to add to frame-src beyond the static provider allowlist.""" + from urllib.parse import urlparse + hosts: list[str] = [] + for r in rows: + if r["embed_html"] and r["embed_url"]: + h = urlparse(r["embed_url"]).hostname + if h and h not in hosts and not any(h == d or h.endswith("." + d) for d in _FRAME_HOSTS): + hosts.append(h) + return tuple(hosts) + + _SECURITY_HEADERS = { "X-Content-Type-Options": "nosniff", "Referrer-Policy": "strict-origin-when-cross-origin", @@ -93,9 +106,15 @@ _SECURITY_HEADERS = { } -def _billet_view(row: aiosqlite.Row) -> dict: +def _billet_view(row: aiosqlite.Row, base: str = "") -> dict: + from urllib.parse import urlparse d = dict(row) d["body_html"] = render_markdown(d["body"]) + d["ref_host"] = (urlparse(d["ref_url"]).hostname if d.get("ref_url") else None) + title = feeds.billet_title(d["body"]) + d["title"] = title + d["permalink"] = f"{base}/b/{d['slug']}" if base else f"/b/{d['slug']}" + d["share"] = _share_intents(d["permalink"], title) if base else {} return d @@ -135,10 +154,15 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None = @app.get("/", response_class=HTMLResponse) async def feed(request: Request, cursor: str | None = None): rows, next_cursor = await repo.list_published(app.state.conn, limit=PAGE_SIZE, cursor=cursor) - return templates.TemplateResponse(request, "feed.html", { + base = _base(request) + resp = templates.TemplateResponse(request, "feed.html", { "site_title": SITE_TITLE, "tagline": SITE_TAGLINE, - "billets": [_billet_view(r) for r in rows], "next_cursor": next_cursor, + "billets": [_billet_view(r, base) for r in rows], "next_cursor": next_cursor, }) + # Embeds render inline in the feed too; allow any self-hosted embed hosts + # of the shown billets in frame-src (static providers already covered). + resp.headers["Content-Security-Policy"] = _csp(_frame_src(_extra_frame_hosts(rows))) + return resp @app.get("/b/{slug}", response_class=HTMLResponse) async def permalink(request: Request, slug: str): @@ -157,7 +181,7 @@ def create_app(conn: aiosqlite.Connection | None = None, *, secret: str | None = permalink_url = f"{base}/b/{row['slug']}" resp = templates.TemplateResponse(request, "billet.html", { "site_title": SITE_TITLE, "tagline": SITE_TAGLINE, - "billet": _billet_view(row), "reactions": rctx, + "billet": _billet_view(row, base), "reactions": rctx, "comments": comment_views, "pcsrf": pcsrf, "ts_token": ts_token, "flash": request.query_params.get("c"), "og": {"title": feeds.billet_title(row["body"]), diff --git a/packages/secubox-billets/api/static/billets.css b/packages/secubox-billets/api/static/billets.css index 23e9fc10..36ff6e61 100644 --- a/packages/secubox-billets/api/static/billets.css +++ b/packages/secubox-billets/api/static/billets.css @@ -1,76 +1,131 @@ /* SPDX-License-Identifier: LicenseRef-CMSD-1.0 */ +/* billets โ€” funky / party public theme, theme-aware, emoji-friendly */ :root{ - --bg:#fbfbfa; --fg:#1a1a1f; --muted:#6b6b7a; --accent:#0a5840; --border:#e6e6e0; - --card:#ffffff; --link:#0a5840; + --bg:#fff7fb; --fg:#1c1330; --muted:#7a6a90; --card:#ffffff; + --border:#f0e2f2; --link:#c0246b; + --p1:#7c3aed; --p2:#ec4899; --p3:#06b6d4; --p4:#f59e0b; + --grad:linear-gradient(120deg,#7c3aed,#ec4899 45%,#f59e0b); } @media (prefers-color-scheme:dark){ - :root{ --bg:#0b0f14; --fg:#e8ecf2; --muted:#8592a3; --accent:#2fae86; - --border:#1e2a38; --card:#141a24; --link:#4fd1a5; } + :root{ --bg:#0d0a18; --fg:#f0ecff; --muted:#9a8ec2; --card:#171226; + --border:#271d3f; --link:#ff8ac4; + --grad:linear-gradient(120deg,#a855f7,#ec4899 45%,#22d3ee); } } *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--fg); - font:16px/1.6 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; - max-width:44rem;margin-inline:auto;padding:0 1rem} -a{color:var(--link)} -.site-header{display:flex;align-items:baseline;gap:.6rem;padding:1.4rem 0 .8rem; - border-bottom:1px solid var(--border);margin-bottom:1.4rem} -.site-title{font-weight:700;font-size:1.35rem;text-decoration:none;letter-spacing:-.01em} + font:16px/1.65 "Segoe UI",system-ui,-apple-system,Roboto,sans-serif; + max-width:46rem;margin-inline:auto;padding:0 1rem;overflow-x:hidden} +body::before{content:"";position:fixed;inset:0;z-index:-1;pointer-events:none; + background: + radial-gradient(60vw 40vw at 90% -5%,rgba(236,72,153,.16),transparent 60%), + radial-gradient(55vw 40vw at -10% 10%,rgba(124,58,237,.16),transparent 60%), + radial-gradient(50vw 40vw at 50% 110%,rgba(6,182,212,.13),transparent 60%)} +a{color:var(--link);text-decoration:none} +a:hover{text-decoration:underline} + +/* header */ +.site-header{display:flex;align-items:center;gap:.7rem;padding:1.5rem 0 1rem;flex-wrap:wrap} +.site-title{font-weight:900;font-size:1.7rem;letter-spacing:-.02em; + background:var(--grad);-webkit-background-clip:text;background-clip:text;color:transparent} +.site-title::before{content:"๐Ÿ“ฎ "} .tagline{color:var(--muted);font-size:.85rem} -.billet{background:var(--card);border:1px solid var(--border);border-radius:12px; - padding:1rem 1.2rem;margin-bottom:1rem} +.tagline::after{content:" โœจ"} + +/* billet cards */ +.billet{position:relative;background:var(--card);border:1px solid var(--border); + border-radius:20px;padding:1.15rem 1.3rem;margin-bottom:1.15rem; + box-shadow:0 10px 30px rgba(124,58,237,.07);transition:transform .16s,box-shadow .16s} +.billet:hover{transform:translateY(-3px);box-shadow:0 16px 40px rgba(236,72,153,.13)} +.billet::before{content:"";position:absolute;left:0;top:16px;bottom:16px;width:4px; + border-radius:4px;background:var(--grad)} .billet .e-content>:first-child{margin-top:0} .billet .e-content>:last-child{margin-bottom:0} -.billet-meta{display:flex;gap:.8rem;align-items:center;color:var(--muted); - font-size:.8rem;margin-top:.6rem} -.billet-full{padding:1.4rem 1.6rem} -blockquote{border-left:3px solid var(--border);margin:.6rem 0;padding:.2rem 0 .2rem 1rem;color:var(--muted)} -code{background:color-mix(in srgb,var(--border) 60%,transparent);padding:.1em .35em;border-radius:5px;font-size:.9em} -.pager{margin:1.2rem 0 2rem} -.pager a{text-decoration:none;font-weight:600} -.empty{color:var(--muted)} -.embed{margin:.8rem 0} -.embed iframe{max-width:100%;border:0;border-radius:10px} -.site-footer{border-top:1px solid var(--border);margin-top:2rem;padding:1rem 0 2rem; - color:var(--muted);font-size:.8rem} +.billet-full{padding:1.5rem 1.7rem} +blockquote{border-left:3px solid var(--p2);margin:.6rem 0;padding:.2rem 0 .2rem 1rem;color:var(--muted)} +code{background:color-mix(in srgb,var(--p1) 14%,transparent);padding:.1em .4em;border-radius:6px;font-size:.9em} +.billet-meta{display:flex;gap:.8rem;align-items:center;color:var(--muted);font-size:.8rem;margin-top:.7rem;flex-wrap:wrap} +.billet-meta .u-url::before{content:"๐Ÿ”— "} + +/* inline embedded link chip */ +.ref-chip{display:inline-flex;align-items:center;gap:.4rem;margin:.5rem 0;padding:.4rem .8rem; + border-radius:999px;background:color-mix(in srgb,var(--p3) 12%,transparent); + border:1px solid color-mix(in srgb,var(--p3) 40%,transparent);font-size:.85rem;font-weight:600} +.ref-chip::before{content:"๐ŸŒ"} + +/* inline embedded media (oEmbed / link card) */ +.embed{margin:.9rem 0} +.embed iframe{width:100%;max-width:100%;min-height:320px;border:0;border-radius:16px; + box-shadow:0 8px 26px rgba(0,0,0,.18);background:#000} +.embed blockquote{background:var(--card);border:1px solid var(--border);border-left:3px solid var(--p2); + border-radius:14px;padding:1rem;margin:0} +.link-card{display:flex;gap:.9rem;align-items:center;border:1px solid var(--border); + border-radius:16px;overflow:hidden;background:var(--card);text-decoration:none;color:var(--fg)} +.link-card:hover{border-color:var(--p2);text-decoration:none} +.link-card .card-img{width:120px;height:90px;object-fit:cover;flex:none} +.link-card .card-body{display:flex;flex-direction:column;gap:.15rem;padding:.6rem .3rem} +.link-card .card-title{font-weight:700} +.link-card .card-desc{color:var(--muted);font-size:.85rem} +.link-card .card-host{color:var(--p3);font-size:.75rem} /* reactions */ -.reactions{display:flex;flex-wrap:wrap;gap:.4rem;margin:.6rem 0} +.reactions{display:flex;flex-wrap:wrap;gap:.45rem;margin:.8rem 0} .reactions form.react{margin:0} -.react-btn{cursor:pointer;background:var(--card);border:1px solid var(--border); - border-radius:999px;padding:.2rem .6rem;font-size:.95rem;color:var(--fg)} -.react-btn.on{border-color:var(--accent);color:var(--accent);font-weight:600} -.react-btn .count{color:var(--muted);font-size:.8rem;margin-left:.2rem} -.react-btn.on .count{color:var(--accent)} +.react-btn{cursor:pointer;background:var(--card);border:1.5px solid var(--border);border-radius:999px; + padding:.3rem .75rem;font-size:1.05rem;color:var(--fg);transition:transform .12s,border-color .12s} +.react-btn:hover{transform:scale(1.12) rotate(-4deg)} +.react-btn.on{border-color:var(--p2);background:color-mix(in srgb,var(--p2) 12%,transparent);font-weight:700} +.react-btn .count{color:var(--muted);font-size:.78rem;margin-left:.25rem} +.react-btn.on .count{color:var(--p2)} + +/* quick share */ +.share-quick{display:flex;flex-wrap:wrap;gap:.4rem;align-items:center;margin:.7rem 0} +.share-quick .lbl{color:var(--muted);font-size:.8rem;font-weight:700;margin-right:.1rem} +.share-quick a,.share-quick button{cursor:pointer;text-decoration:none;font-size:1.05rem;line-height:1; + border:1px solid var(--border);background:var(--card);color:var(--fg);border-radius:999px; + padding:.32rem .55rem;transition:transform .12s,border-color .12s} +.share-quick a:hover,.share-quick button:hover{transform:translateY(-2px) scale(1.06);border-color:var(--p1)} /* comments */ -.comments{margin-top:1.6rem;border-top:1px solid var(--border);padding-top:1rem} -.comment{background:var(--card);border:1px solid var(--border);border-radius:10px; - padding:.6rem .9rem;margin-bottom:.6rem} +.comments{margin-top:1.8rem;border-top:2px dashed var(--border);padding-top:1.1rem} +.comments h2::before{content:"๐Ÿ’ฌ "} +.comment{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:.7rem 1rem;margin-bottom:.6rem} .comment .c-meta{margin:0 0 .3rem;font-size:.85rem;color:var(--muted)} -.comment .c-body{margin:0;white-space:normal} -.comment-form{display:flex;flex-direction:column;gap:.5rem;margin-top:1rem} -.comment-form label{display:flex;flex-direction:column;gap:.2rem;font-size:.85rem;color:var(--muted)} -.comment-form input,.comment-form textarea{padding:.5rem;border:1px solid var(--border); - border-radius:8px;background:var(--bg);color:var(--fg);font:inherit} -.comment-form button,.mod-actions button,.admin-form button,.editor-actions button, -.row-actions button{cursor:pointer;padding:.4rem .8rem;border:1px solid var(--border); - border-radius:8px;background:var(--accent);color:#fff;font:inherit} -.danger{background:var(--cinnabar,#c0392b)!important;border-color:transparent!important} +.comment .c-meta strong::before{content:"๐Ÿ™‚ "} +.comment .c-body{margin:0} +.comment-form{display:flex;flex-direction:column;gap:.55rem;margin-top:1rem} +.comment-form label{display:flex;flex-direction:column;gap:.25rem;font-size:.85rem;color:var(--muted)} +.comment-form input,.comment-form textarea{padding:.6rem;border:1.5px solid var(--border);border-radius:12px; + background:var(--bg);color:var(--fg);font:inherit} +.comment-form input:focus,.comment-form textarea:focus{outline:none;border-color:var(--p2)} +.comment-form button,.mod-actions button,.admin-form button,.editor-actions button,.row-actions button{ + cursor:pointer;padding:.55rem 1rem;border:none;border-radius:12px;background:var(--grad);color:#fff; + font:inherit;font-weight:700} +.comment-form button:hover{transform:translateY(-2px)} +.danger{background:#e11d48!important} .hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden} -.note{background:color-mix(in srgb,var(--accent) 12%,transparent);padding:.5rem .8rem;border-radius:8px} -.note.err{background:color-mix(in srgb,#c0392b 14%,transparent)} +.note{background:color-mix(in srgb,var(--p1) 13%,transparent);padding:.55rem .9rem;border-radius:12px} +.note::before{content:"๐ŸŽ‰ "} +.note.err{background:color-mix(in srgb,#e11d48 15%,transparent)} +.note.err::before{content:"๐Ÿ˜… "} -/* admin */ -.admin-form{display:flex;flex-direction:column;gap:.6rem;max-width:32rem} -.admin-form label{display:flex;flex-direction:column;gap:.2rem} -.admin-form input,.admin-form textarea{padding:.5rem;border:1px solid var(--border); - border-radius:8px;background:var(--bg);color:var(--fg);font:inherit} +/* pager / footer / misc */ +.pager{margin:1.4rem 0 2.5rem} +.pager a{font-weight:800;font-size:1.02rem} +.pager a::after{content:" ๐Ÿ•ฐ๏ธ"} +.empty{color:var(--muted)} +.site-footer{border-top:1px solid var(--border);margin-top:2.2rem;padding:1rem 0 2.5rem;color:var(--muted);font-size:.8rem} + +/* admin (kept compact) */ +.admin-form{display:flex;flex-direction:column;gap:.6rem;max-width:34rem} +.admin-form label{display:flex;flex-direction:column;gap:.25rem} +.admin-form input,.admin-form textarea{padding:.55rem;border:1.5px solid var(--border);border-radius:12px;background:var(--bg);color:var(--fg);font:inherit} .admin-list{width:100%;border-collapse:collapse} -.admin-list th,.admin-list td{text-align:left;padding:.4rem;border-bottom:1px solid var(--border);font-size:.9rem} +.admin-list th,.admin-list td{text-align:left;padding:.45rem;border-bottom:1px solid var(--border);font-size:.9rem} .row-actions{display:flex;gap:.4rem;align-items:center} .mod-actions{display:flex;gap:.5rem;margin-top:.4rem} -.pill{font-size:.7rem;padding:.1rem .5rem;border-radius:999px;border:1px solid var(--border);text-transform:uppercase} -.pill.published{color:var(--accent);border-color:var(--accent)} +.pill{font-size:.68rem;padding:.12rem .55rem;border-radius:999px;border:1px solid var(--border);text-transform:uppercase;font-weight:700} +.pill.published{color:#fff;background:var(--p1);border-color:transparent} .pill.draft{color:var(--muted)} .editor-actions{display:flex;gap:.5rem;flex-wrap:wrap} -.error{background:color-mix(in srgb,#c0392b 14%,transparent);padding:.5rem .8rem;border-radius:8px} +.error{background:color-mix(in srgb,#e11d48 14%,transparent);padding:.55rem .9rem;border-radius:12px} +@media(max-width:480px){.link-card .card-img{width:84px;height:72px}} diff --git a/packages/secubox-billets/api/templates/_share_quick.html b/packages/secubox-billets/api/templates/_share_quick.html new file mode 100644 index 00000000..1cb17513 --- /dev/null +++ b/packages/secubox-billets/api/templates/_share_quick.html @@ -0,0 +1,12 @@ + diff --git a/packages/secubox-billets/api/templates/base.html b/packages/secubox-billets/api/templates/base.html index 43688a46..b144aa16 100644 --- a/packages/secubox-billets/api/templates/base.html +++ b/packages/secubox-billets/api/templates/base.html @@ -4,7 +4,7 @@ {% block title %}{{ site_title }}{% endblock %} - + {% block head %}{% endblock %} @@ -20,6 +20,6 @@ - + diff --git a/packages/secubox-billets/api/templates/billet.html b/packages/secubox-billets/api/templates/billet.html index b516a673..c606f7c7 100644 --- a/packages/secubox-billets/api/templates/billet.html +++ b/packages/secubox-billets/api/templates/billet.html @@ -17,6 +17,7 @@ {% block content %}
{{ billet.body_html|safe }}
+ {% if billet.ref_host %}{{ billet.ref_host }}{% endif %} {% if billet.embed_html %}
{{ billet.embed_html|safe }}
{% endif %}
{% if billet.published_at %}{% endif %} @@ -25,22 +26,8 @@
{% if reactions %}{% include "_reactions.html" %}{% endif %} -{% if share %} - +{% if billet.share %} + {% with share=billet.share, permalink=billet.permalink, title=billet.title %}{% include "_share_quick.html" %}{% endwith %} {% endif %}
diff --git a/packages/secubox-billets/api/templates/feed.html b/packages/secubox-billets/api/templates/feed.html index dc6fd4f4..e1ed00dc 100644 --- a/packages/secubox-billets/api/templates/feed.html +++ b/packages/secubox-billets/api/templates/feed.html @@ -4,6 +4,9 @@ {% for b in billets %}
{{ b.body_html|safe }}
+ {% if b.ref_host %}{{ b.ref_host }}{% endif %} + {% if b.embed_html %}
{{ b.embed_html|safe }}
{% endif %} + {% with share=b.share, permalink=b.permalink, title=b.title %}{% include "_share_quick.html" %}{% endwith %}