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:
+35
-34
@@ -172,44 +172,45 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v4.0.0 — Accounts, Integrations & Sidebar (priorité haute)
|
## v4.0.0 — Accounts, Integrations & Sidebar (priorité haute) ✅ COMPLETED
|
||||||
|
|
||||||
> **Rappel** : Section 6.5 de ARCHITECTURE.md documente le design complet.
|
> **Rappel** : Section 6.5 de ARCHITECTURE.md documente le design complet.
|
||||||
|
> **Complété** : 2026-07-14 (v2.7.1–v3.0.0) + route publique 2026-07-18.
|
||||||
|
|
||||||
### 4.0 Account Model
|
### 4.0 Account Model ✅
|
||||||
- [ ] **`auth_method`** — colonne `users.auth_method ∈ {local, gitea, github}`
|
- [x] **`auth_method`** — colonne `users.auth_method ∈ {local, gitea, github}`
|
||||||
- [ ] **Auto-détection** — migrer les comptes existants (gitea_token → gitea, sinon local)
|
- [x] **Auto-détection** — migrer les comptes existants (gitea_token → gitea, sinon local)
|
||||||
- [ ] **Badge OAuth** — 🦎/🐙 dans sidebar SI `auth_method` = provider
|
- [x] **Badge OAuth** — 🦎/🐙 dans sidebar SI `auth_method` = provider
|
||||||
- [ ] **Pas de badge** — un compte local avec intégration ne montre PAS de badge
|
- [x] **Pas de badge** — un compte local avec intégration ne montre PAS de badge
|
||||||
|
|
||||||
### 4.0a Intégrations & Déconnexion
|
### 4.0a Intégrations & Déconnexion ✅
|
||||||
- [ ] **Settings page** — montre toutes les intégrations avec bouton Connect/Disconnect
|
- [x] **Settings page** — montre toutes les intégrations avec bouton Connect/Disconnect
|
||||||
- [ ] **Matrice déconnexion** : local→déco ok · gitea→déco github ok · github→déco gitea ok
|
- [x] **Matrice déconnexion** : local→déco ok · gitea→déco github ok · github→déco gitea ok
|
||||||
- [ ] **Provider principal** — pas de bouton Disconnect sur auth_method
|
- [x] **Provider principal** — pas de bouton Disconnect sur auth_method
|
||||||
- [ ] **Compte local** — peut connecter Gitea + GitHub simultanément
|
- [x] **Compte local** — peut connecter Gitea + GitHub simultanément
|
||||||
|
|
||||||
### 4.0b Sidebar — règles par type de compte
|
### 4.0b Sidebar — règles par type de compte ✅
|
||||||
- [ ] **Private visible** UNIQUEMENT si workspace actif est distant (Gitea/GitHub)
|
- [x] **Private visible** UNIQUEMENT si workspace actif est distant (Gitea/GitHub)
|
||||||
- [ ] **Private caché** pour Scénario A (local pur)
|
- [x] **Private caché** pour Scénario A (local pur)
|
||||||
- [ ] **Trash local-only** — caché pour compte OAuth pur, restreint au contenu local
|
- [x] **Trash local-only** — caché pour compte OAuth pur, restreint au contenu local
|
||||||
- [ ] **Boutons → Library** sur chaque section (recents, favorites, shared, published…)
|
- [x] **Boutons → Library** sur chaque section (recents, favorites, shared, published…)
|
||||||
|
|
||||||
### 4.0c Share & Publish
|
### 4.0c Share & Publish ✅
|
||||||
- [ ] **Modale Share** — 2 tabs (Share + Publish) fonctionnels
|
- [x] **Modale Share** — 2 tabs (Share + Publish) fonctionnels
|
||||||
- [ ] **Share tab** — inviter email/user, général access lien
|
- [x] **Share tab** — inviter email/user, général access lien
|
||||||
- [ ] **Publish tab** — activer/désactiver, slug généré
|
- [x] **Publish tab** — activer/désactiver, slug généré
|
||||||
- [ ] **Page publique** — `/p/<slug>` sert le contenu publié
|
- [x] **Page publique** — `/p/<slug>` sert le contenu publié (no auth)
|
||||||
- [ ] **Tables DB** — `page_shares`, colonnes `is_published`, `is_shared`, `share_mode`
|
- [x] **Tables DB** — `page_shares`, colonnes `is_published`, `is_shared`, `share_mode`
|
||||||
|
|
||||||
### 4.0d Library
|
### 4.0d Library ✅
|
||||||
- [ ] **Page `/library?tab=`** — tabs Recents, Favorites, Shared, Published, Private, Workspace
|
- [x] **Page `/library?tab=`** — tabs Recents, Favorites, Shared, Published, Private, Workspace
|
||||||
- [ ] **Filtres source** — All | Local | Gitea | GitHub
|
- [x] **Filtres source** — All | Local | Gitea | GitHub
|
||||||
- [ ] **Table `recents`** — tracking automatique des documents consultés
|
- [x] **Table `recents`** — tracking automatique des documents consultés
|
||||||
|
|
||||||
### 4.0e Édition unifiée
|
### 4.0e Édition unifiée ✅
|
||||||
- [ ] **Même UI** pour fichiers locaux ET distants
|
- [x] **Même UI** pour fichiers locaux ET distants
|
||||||
- [ ] **Bouton Commit** — visible uniquement pour fichiers distants
|
- [x] **Bouton Commit** — visible uniquement pour fichiers distants
|
||||||
- [ ] **Champ message** — pour les commits Gitea/GitHub
|
- [x] **Champ message** — pour les commits Gitea/GitHub
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -326,9 +327,9 @@ v2.0.0 ✅ v2.1.0 ✅ v2.2.0 ✅ v2.3.0 ✅ v2.4.0 ✅ v2.5.0 ✅ v2.6.0
|
|||||||
Editor+ Workspace Tags Views5 Tags/user Admin Account Gitea P1
|
Editor+ Workspace Tags Views5 Tags/user Admin Account Gitea P1
|
||||||
Local Panel
|
Local Panel
|
||||||
|
|
||||||
v2.7.1 ✅ v2.8.0 ✅ v2.9.0 ✅ v3.0.0 ✅ v3.0.1 ✅ v4.0.0 ⬜ v4.1.0 ⬜
|
v2.7.1 ✅ v2.8.0 ✅ v2.9.0 ✅ v3.0.0 ✅ v3.0.1 ✅ v4.0.0 ✅ v4.1.0 ⬜
|
||||||
Private Gitea P2 GitHub Pro UX Quality Notion Pro
|
Private Gitea P2 GitHub Pro UX Quality Accounts+ Pro
|
||||||
Pages
|
Pages Parité
|
||||||
```
|
```
|
||||||
|
|
||||||
*Dernière mise à jour: 2026-07-14 — v4.0.0 planifié*
|
*Dernière mise à jour: 2026-07-18 — v4.0.0 Accounts/Integrations complété, route publique /p/<slug> ajoutée*
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="2.5.0",
|
version="4.0.0",
|
||||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -1724,3 +1724,123 @@ async def sidebar_workspace_tree(request: Request):
|
|||||||
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
||||||
'<span class="page-name text-dim">No pages yet</span></li>'
|
'<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)
|
||||||
|
|||||||
@@ -734,8 +734,13 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||||
}).then(function(r){ return r.json(); }).then(function(d){
|
}).then(function(r){ return r.json(); }).then(function(d){
|
||||||
if (d.status === 'ok') { self.pagePublished = true; self.publishedUrl = d.url || self.publishedUrl; self.showToast('Published to web'); }
|
if (d.is_published) {
|
||||||
else { self.showToast(d.detail || 'Publish failed'); }
|
self.pagePublished = true;
|
||||||
|
self.publishedUrl = window.location.origin + '/p/' + d.publish_slug;
|
||||||
|
self.showToast('Published to web — ' + self.publishedUrl);
|
||||||
|
} else {
|
||||||
|
self.showToast(d.detail || 'Publish failed');
|
||||||
|
}
|
||||||
}).catch(function(){ self.showToast('Publish failed'); });
|
}).catch(function(){ self.showToast('Publish failed'); });
|
||||||
},
|
},
|
||||||
unpublishPage() {
|
unpublishPage() {
|
||||||
@@ -745,8 +750,13 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||||
}).then(function(r){ return r.json(); }).then(function(d){
|
}).then(function(r){ return r.json(); }).then(function(d){
|
||||||
if (d.status === 'ok') { self.pagePublished = false; self.publishedUrl = ''; self.showToast('Unpublished'); }
|
if (!d.is_published) {
|
||||||
else { self.showToast(d.detail || 'Unpublish failed'); }
|
self.pagePublished = false;
|
||||||
|
self.publishedUrl = '';
|
||||||
|
self.showToast('Unpublished');
|
||||||
|
} else {
|
||||||
|
self.showToast(d.detail || 'Unpublish failed');
|
||||||
|
}
|
||||||
}).catch(function(){ self.showToast('Unpublish failed'); });
|
}).catch(function(){ self.showToast('Unpublish failed'); });
|
||||||
},
|
},
|
||||||
shareInvite() {
|
shareInvite() {
|
||||||
@@ -758,7 +768,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
|||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||||
body: JSON.stringify({ email: this.inviteEmail.trim(), permission: this.invitePermission })
|
body: JSON.stringify({ email: this.inviteEmail.trim(), permission: this.invitePermission })
|
||||||
}).then(function(r){ return r.json(); }).then(function(d){
|
}).then(function(r){ return r.json(); }).then(function(d){
|
||||||
if (d.status === 'ok') { self.inviteEmail = ''; self.showToast('Invitation sent'); self.loadShares(); }
|
if (d.status === 'shared') { self.inviteEmail = ''; self.showToast('Invitation sent'); self.loadShares(); }
|
||||||
else { self.showToast(d.detail || 'Share failed'); }
|
else { self.showToast(d.detail || 'Share failed'); }
|
||||||
}).catch(function(){ self.showToast('Share failed'); });
|
}).catch(function(){ self.showToast('Share failed'); });
|
||||||
},
|
},
|
||||||
@@ -775,7 +785,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||||
}).then(function(r){ return r.json(); }).then(function(d){
|
}).then(function(r){ return r.json(); }).then(function(d){
|
||||||
if (d.status === 'ok') { self.showToast('Share removed'); self.loadShares(); }
|
if (d.status === 'removed') { self.showToast('Share removed'); self.loadShares(); }
|
||||||
else { self.showToast(d.detail || 'Remove failed'); }
|
else { self.showToast(d.detail || 'Remove failed'); }
|
||||||
}).catch(function(){ self.showToast('Remove failed'); });
|
}).catch(function(){ self.showToast('Remove failed'); });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ title }} — FlowDeck</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #191919;
|
||||||
|
--bg-card: #1e1e1e;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-dim: #999;
|
||||||
|
--accent: #4c9aff;
|
||||||
|
--radius: 12px;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.7;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.pub-header {
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.06);
|
||||||
|
padding: 14px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: rgba(255,255,255,.01);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.pub-brand {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pub-brand span { font-size: 18px; }
|
||||||
|
.pub-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
background: rgba(76,154,255,.12);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.pub-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 24px 80px;
|
||||||
|
}
|
||||||
|
.pub-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -.5px;
|
||||||
|
}
|
||||||
|
.pub-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.pub-content {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.pub-content p {
|
||||||
|
margin: 4px 0;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.pub-content h1, .pub-content h2, .pub-content h3, .pub-content h4 {
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.pub-content pre {
|
||||||
|
background: rgba(255,255,255,.05);
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.pub-content code {
|
||||||
|
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.pub-content blockquote {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 4px 16px;
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
.pub-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.pub-content hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid rgba(255,255,255,.08);
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.pub-content details {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.pub-content summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.pub-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,.06);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.pub-footer a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="pub-header">
|
||||||
|
<a href="/" class="pub-brand">
|
||||||
|
<span>📚</span> FlowDeck
|
||||||
|
</a>
|
||||||
|
<span class="pub-badge">🌐 Published</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="pub-container">
|
||||||
|
<h1 class="pub-title">{{ title }}</h1>
|
||||||
|
{% if updated_at %}
|
||||||
|
<div class="pub-meta">Last updated: {{ updated_at[:10] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="pub-content">
|
||||||
|
{{ content_html | safe }}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="pub-footer">
|
||||||
|
Made with <a href="/">FlowDeck</a> — a Notion-style workspace
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user