v4.0.0 — Route publique /p/<slug>, correctifs publish/share UI
- Ajout route /p/<slug> qui sert les pages publiées (no auth, rendu HTML blocks)
- Template public_page.html — design épuré Notion-style
- Fix publishPage()/unpublishPage(): vérifient d.is_published au lieu de d.status==='ok'
- Fix shareInvite(): vérifie d.status==='shared'
- Fix removeShare(): vérifie d.status==='removed'
- ROADMAP.md: toutes les sections 4.0a-4.0e cochées ✅ (déjà implémentées)
- Version bump 2.5.0 → 4.0.0
This commit is contained in:
@@ -1724,3 +1724,123 @@ async def sidebar_workspace_tree(request: Request):
|
||||
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
||||
'<span class="page-name text-dim">No pages yet</span></li>'
|
||||
)
|
||||
|
||||
|
||||
# ═══════════ Public Published Page ═══════════
|
||||
|
||||
|
||||
@router.get("/p/{slug}", response_class=HTMLResponse)
|
||||
async def public_published_page(request: Request, slug: str):
|
||||
"""Serve a published page at /p/<slug> — no auth required."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format, updated_at, created_at "
|
||||
"FROM pages WHERE publish_slug=? AND is_published=1",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return HTMLResponse(
|
||||
"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||
<title>Not Found — FlowDeck</title>
|
||||
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;
|
||||
justify-content:center;height:100vh;margin:0;background:#191919;color:#ccc;}
|
||||
h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
page = dict(row)
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
|
||||
# Convert blocks to HTML for rendering
|
||||
content_html = ""
|
||||
if page.get("content_format") == "blocks" and page.get("content"):
|
||||
import json as _json
|
||||
try:
|
||||
blocks = _json.loads(page["content"])
|
||||
content_html = _render_blocks_public(blocks)
|
||||
except (_json.JSONDecodeError, Exception):
|
||||
content_html = f"<p>{page.get('content', '')}</p>"
|
||||
elif page.get("content"):
|
||||
# Plain text / markdown
|
||||
text = page["content"]
|
||||
content_html = f"<pre style='white-space:pre-wrap;font-family:system-ui;font-size:16px;line-height:1.6;'>{text}</pre>"
|
||||
|
||||
template = env.get_template("public_page.html")
|
||||
return template.render(
|
||||
title=page["title"] or "Untitled",
|
||||
content_html=content_html,
|
||||
updated_at=page.get("updated_at", ""),
|
||||
created_at=page.get("created_at", ""),
|
||||
)
|
||||
|
||||
|
||||
def _render_blocks_public(blocks: list) -> str:
|
||||
"""Render FlowDeck blocks as plain HTML for public pages."""
|
||||
import json as _json
|
||||
html_parts = []
|
||||
for b in blocks:
|
||||
t = b.get("type", "paragraph")
|
||||
c = b.get("content", "") or ""
|
||||
if t == "heading_1":
|
||||
html_parts.append(f'<h1 style="font-size:2.5rem;font-weight:700;margin:32px 0 8px;">{c}</h1>')
|
||||
elif t == "heading_2":
|
||||
html_parts.append(f'<h2 style="font-size:1.75rem;font-weight:600;margin:28px 0 6px;">{c}</h2>')
|
||||
elif t == "heading_3":
|
||||
html_parts.append(f'<h3 style="font-size:1.35rem;font-weight:600;margin:24px 0 4px;">{c}</h3>')
|
||||
elif t == "heading_4":
|
||||
html_parts.append(f'<h4 style="font-size:1.15rem;font-weight:600;margin:20px 0 4px;">{c}</h4>')
|
||||
elif t == "bulleted_list":
|
||||
html_parts.append(f'<li style="margin-left:24px;">{c}</li>')
|
||||
elif t == "numbered_list":
|
||||
html_parts.append(f'<li style="margin-left:24px;list-style:decimal;">{c}</li>')
|
||||
elif t == "to_do":
|
||||
checked = "checked" if b.get("checked") else ""
|
||||
todo_style = "text-decoration:line-through;opacity:.5" if b.get("checked") else ""
|
||||
html_parts.append(
|
||||
f'<div style="display:flex;align-items:center;gap:8px;margin:4px 0;">'
|
||||
f'<input type="checkbox" {checked} disabled>'
|
||||
f'<span style="{todo_style}">{c}</span>'
|
||||
f'</div>'
|
||||
)
|
||||
elif t == "toggle":
|
||||
html_parts.append(
|
||||
f'<details style="margin:8px 0;"><summary style="cursor:pointer;font-weight:500;">{c}</summary></details>'
|
||||
)
|
||||
elif t == "quote":
|
||||
html_parts.append(
|
||||
f'<blockquote style="border-left:3px solid var(--accent,#4c9aff);margin:12px 0;padding:4px 16px;opacity:.85;">{c}</blockquote>'
|
||||
)
|
||||
elif t == "callout":
|
||||
icon = b.get("icon", "💡")
|
||||
bg = (b.get("style") or {}).get("bgColor", "rgba(76,154,255,.1)")
|
||||
html_parts.append(
|
||||
f'<div style="display:flex;gap:10px;padding:14px 18px;margin:12px 0;border-radius:8px;'
|
||||
f'background:{bg};align-items:flex-start;">'
|
||||
f'<span style="font-size:20px;flex-shrink:0;">{icon}</span>'
|
||||
f'<span>{c}</span></div>'
|
||||
)
|
||||
elif t == "code":
|
||||
lang = b.get("language", "")
|
||||
lang_label = f"<div style='font-size:11px;opacity:.4;margin-bottom:8px;'>{lang}</div>" if lang else ""
|
||||
html_parts.append(
|
||||
f'<pre style="background:rgba(255,255,255,.05);padding:16px 20px;border-radius:8px;'
|
||||
f'overflow-x:auto;font-size:14px;line-height:1.5;margin:12px 0;">'
|
||||
f'{lang_label}'
|
||||
f'<code>{c}</code></pre>'
|
||||
)
|
||||
elif t == "divider":
|
||||
html_parts.append('<hr style="border:none;border-top:1px solid rgba(255,255,255,.1);margin:16px 0;">')
|
||||
elif t == "image":
|
||||
src = b.get("src", "")
|
||||
alt = b.get("alt", "")
|
||||
html_parts.append(
|
||||
f'<figure style="margin:16px 0;text-align:center;">'
|
||||
f'<img src="{src}" alt="{alt}" style="max-width:100%;border-radius:8px;">'
|
||||
f'</figure>'
|
||||
)
|
||||
else:
|
||||
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
|
||||
return "\n".join(html_parts)
|
||||
|
||||
Reference in New Issue
Block a user