diff --git a/ROADMAP.md b/ROADMAP.md index b26daea..8bcf9a2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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/` 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/` 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/ ajoutée* diff --git a/app/main.py b/app/main.py index 16db288..9bd076d 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index f39aa5c..f9517e1 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -1724,3 +1724,123 @@ async def sidebar_workspace_tree(request: Request): '' ) + + +# ═══════════ 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/ — 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( + """ +Not Found — FlowDeck +

404

""", + 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"

{page.get('content', '')}

" + elif page.get("content"): + # Plain text / markdown + text = page["content"] + content_html = f"
{text}
" + + 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'

{c}

') + elif t == "heading_2": + html_parts.append(f'

{c}

') + elif t == "heading_3": + html_parts.append(f'

{c}

') + elif t == "heading_4": + html_parts.append(f'

{c}

') + elif t == "bulleted_list": + html_parts.append(f'
  • {c}
  • ') + elif t == "numbered_list": + html_parts.append(f'
  • {c}
  • ') + 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'
    ' + f'' + f'{c}' + f'
    ' + ) + elif t == "toggle": + html_parts.append( + f'
    {c}
    ' + ) + elif t == "quote": + html_parts.append( + f'
    {c}
    ' + ) + elif t == "callout": + icon = b.get("icon", "💡") + bg = (b.get("style") or {}).get("bgColor", "rgba(76,154,255,.1)") + html_parts.append( + f'
    ' + f'{icon}' + f'{c}
    ' + ) + elif t == "code": + lang = b.get("language", "") + lang_label = f"
    {lang}
    " if lang else "" + html_parts.append( + f'
    '
    +                f'{lang_label}'
    +                f'{c}
    ' + ) + elif t == "divider": + html_parts.append('
    ') + elif t == "image": + src = b.get("src", "") + alt = b.get("alt", "") + html_parts.append( + f'
    ' + f'{alt}' + f'
    ' + ) + else: + html_parts.append(f'

    {c}

    ') + return "\n".join(html_parts) diff --git a/app/templates/page_editor.html b/app/templates/page_editor.html index 2abcb65..e6ec9c7 100644 --- a/app/templates/page_editor.html +++ b/app/templates/page_editor.html @@ -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'); }); }, diff --git a/app/templates/public_page.html b/app/templates/public_page.html new file mode 100644 index 0000000..69c74f0 --- /dev/null +++ b/app/templates/public_page.html @@ -0,0 +1,153 @@ + + + + + + {{ title }} — FlowDeck + + + + + +
    + + 📚 FlowDeck + + 🌐 Published +
    + +
    +

    {{ title }}

    + {% if updated_at %} +
    Last updated: {{ updated_at[:10] }}
    + {% endif %} +
    + {{ content_html | safe }} +
    +
    + + + + +