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.
|
||||
> **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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'); });
|
||||
},
|
||||
|
||||
@@ -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