v4.0.0 — Route publique /p/<slug>, correctifs publish/share UI
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- 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:
2026-07-18 00:27:14 -04:00
parent fe64617677
commit e85161dfc1
5 changed files with 325 additions and 41 deletions
+35 -34
View File
@@ -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.
> **Complété** : 2026-07-14 (v2.7.1–v3.0.0) + route publique 2026-07-18.
### 4.0 Account Model
- [ ] **`auth_method`** — colonne `users.auth_method ∈ {local, gitea, github}`
- [ ] **Auto-détection** — migrer les comptes existants (gitea_token → gitea, sinon local)
- [ ] **Badge OAuth** — 🦎/🐙 dans sidebar SI `auth_method` = provider
- [ ] **Pas de badge** — un compte local avec intégration ne montre PAS de badge
### 4.0 Account Model ✅
- [x] **`auth_method`** — colonne `users.auth_method ∈ {local, gitea, github}`
- [x] **Auto-détection** — migrer les comptes existants (gitea_token → gitea, sinon local)
- [x] **Badge OAuth** — 🦎/🐙 dans sidebar SI `auth_method` = provider
- [x] **Pas de badge** — un compte local avec intégration ne montre PAS de badge
### 4.0a Intégrations & Déconnexion
- [ ] **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
- [ ] **Provider principal** — pas de bouton Disconnect sur auth_method
- [ ] **Compte local** — peut connecter Gitea + GitHub simultanément
### 4.0a Intégrations & Déconnexion ✅
- [x] **Settings page** — montre toutes les intégrations avec bouton Connect/Disconnect
- [x] **Matrice déconnexion** : local→déco ok · gitea→déco github ok · github→déco gitea ok
- [x] **Provider principal** — pas de bouton Disconnect sur auth_method
- [x] **Compte local** — peut connecter Gitea + GitHub simultanément
### 4.0b Sidebar — règles par type de compte
- [ ] **Private visible** UNIQUEMENT si workspace actif est distant (Gitea/GitHub)
- [ ] **Private caché** pour Scénario A (local pur)
- [ ] **Trash local-only** — caché pour compte OAuth pur, restreint au contenu local
- [ ] **Boutons → Library** sur chaque section (recents, favorites, shared, published…)
### 4.0b Sidebar — règles par type de compte ✅
- [x] **Private visible** UNIQUEMENT si workspace actif est distant (Gitea/GitHub)
- [x] **Private caché** pour Scénario A (local pur)
- [x] **Trash local-only** — caché pour compte OAuth pur, restreint au contenu local
- [x] **Boutons → Library** sur chaque section (recents, favorites, shared, published…)
### 4.0c Share & Publish
- [ ] **Modale Share** — 2 tabs (Share + Publish) fonctionnels
- [ ] **Share tab** — inviter email/user, général access lien
- [ ] **Publish tab** — activer/désactiver, slug généré
- [ ] **Page publique** — `/p/<slug>` sert le contenu publié
- [ ] **Tables DB** — `page_shares`, colonnes `is_published`, `is_shared`, `share_mode`
### 4.0c Share & Publish ✅
- [x] **Modale Share** — 2 tabs (Share + Publish) fonctionnels
- [x] **Share tab** — inviter email/user, général access lien
- [x] **Publish tab** — activer/désactiver, slug généré
- [x] **Page publique** — `/p/<slug>` sert le contenu publié (no auth)
- [x] **Tables DB** — `page_shares`, colonnes `is_published`, `is_shared`, `share_mode`
### 4.0d Library
- [ ] **Page `/library?tab=`** — tabs Recents, Favorites, Shared, Published, Private, Workspace
- [ ] **Filtres source** — All | Local | Gitea | GitHub
- [ ] **Table `recents`** — tracking automatique des documents consultés
### 4.0d Library ✅
- [x] **Page `/library?tab=`** — tabs Recents, Favorites, Shared, Published, Private, Workspace
- [x] **Filtres source** — All | Local | Gitea | GitHub
- [x] **Table `recents`** — tracking automatique des documents consultés
### 4.0e Édition unifiée
- [ ] **Même UI** pour fichiers locaux ET distants
- [ ] **Bouton Commit** — visible uniquement pour fichiers distants
- [ ] **Champ message** — pour les commits Gitea/GitHub
### 4.0e Édition unifiée ✅
- [x] **Même UI** pour fichiers locaux ET distants
- [x] **Bouton Commit** — visible uniquement pour fichiers distants
- [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
Local Panel
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
Pages
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 Accounts+ Pro
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
View File
@@ -42,7 +42,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="2.5.0",
version="4.0.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
+120
View File
@@ -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)
+16 -6
View File
@@ -734,8 +734,13 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }
}).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'); }
else { self.showToast(d.detail || 'Publish failed'); }
if (d.is_published) {
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'); });
},
unpublishPage() {
@@ -745,8 +750,13 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
method: 'DELETE',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
}).then(function(r){ return r.json(); }).then(function(d){
if (d.status === 'ok') { self.pagePublished = false; self.publishedUrl = ''; self.showToast('Unpublished'); }
else { self.showToast(d.detail || 'Unpublish failed'); }
if (!d.is_published) {
self.pagePublished = false;
self.publishedUrl = '';
self.showToast('Unpublished');
} else {
self.showToast(d.detail || 'Unpublish failed');
}
}).catch(function(){ self.showToast('Unpublish failed'); });
},
shareInvite() {
@@ -758,7 +768,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
body: JSON.stringify({ email: this.inviteEmail.trim(), permission: this.invitePermission })
}).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'); }
}).catch(function(){ self.showToast('Share failed'); });
},
@@ -775,7 +785,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
method: 'DELETE',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
}).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'); }
}).catch(function(){ self.showToast('Remove failed'); });
},
+153
View File
@@ -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>