From 814dbe8c2e185be179015776df7dcb6e8be47d67 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 3 Sep 2026 00:05:33 -0400 Subject: [PATCH] =?UTF-8?q?feat(content):=20v4.6.0=20Content=20Blocks=20en?= =?UTF-8?q?richis=20=E2=80=94=20Callout,=20TOC,=20Math=20(KaTeX),=20Toggle?= =?UTF-8?q?,=20Multi-colonnes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajout blocs enrichis dans l'éditeur: callout (avec sélecteur d'emoji), table of contents (ancres), math (LaTeX/KaTeX), toggle lists (enfants collapsibles), multi-colonnes - Intégration KaTeX 0.16.11 self-hosté (JS/CSS/fonts) — aucun CDN externe - Rendu des nouveaux blocs dans les pages publiques (/p/): TOC, KaTeX, colonnes, toggle
- Persistance 'children' (colonnes/toggle) + export Markdown étendu - Sidebar customization par utilisateur (config API + colonne sidebar_config) - Correctif test_views_calendar: parsing des query params year/month (le défaut ouvrait sur le mois courant) - Tests: 184 passing --- CHANGELOG.md | 19 + ROADMAP.md | 24 +- VERSION | 2 +- app/db.py | 7 + app/main.py | 5 +- app/routers/board.py | 14 +- app/routers/collections.py | 5 + app/routers/dashboard.py | 76 +++- app/routers/sidebar_config.py | 115 +++++ app/templates/_page_editor_scripts.html | 111 ++++- app/templates/base.html | 278 ++++++++++-- app/templates/page_editor.html | 1 + app/templates/public_page.html | 16 + static/css/app.css | 438 ++++++++++++++++++- static/css/katex.min.css | 1 + static/fonts/KaTeX_AMS-Regular.ttf | Bin 0 -> 63632 bytes static/fonts/KaTeX_AMS-Regular.woff | Bin 0 -> 33516 bytes static/fonts/KaTeX_AMS-Regular.woff2 | Bin 0 -> 28076 bytes static/fonts/KaTeX_Caligraphic-Bold.ttf | Bin 0 -> 12368 bytes static/fonts/KaTeX_Caligraphic-Bold.woff | Bin 0 -> 7716 bytes static/fonts/KaTeX_Caligraphic-Bold.woff2 | Bin 0 -> 6912 bytes static/fonts/KaTeX_Caligraphic-Regular.ttf | Bin 0 -> 12344 bytes static/fonts/KaTeX_Caligraphic-Regular.woff | Bin 0 -> 7656 bytes static/fonts/KaTeX_Caligraphic-Regular.woff2 | Bin 0 -> 6908 bytes static/fonts/KaTeX_Fraktur-Bold.ttf | Bin 0 -> 19584 bytes static/fonts/KaTeX_Fraktur-Bold.woff | Bin 0 -> 13296 bytes static/fonts/KaTeX_Fraktur-Bold.woff2 | Bin 0 -> 11348 bytes static/fonts/KaTeX_Fraktur-Regular.ttf | Bin 0 -> 19572 bytes static/fonts/KaTeX_Fraktur-Regular.woff | Bin 0 -> 13208 bytes static/fonts/KaTeX_Fraktur-Regular.woff2 | Bin 0 -> 11316 bytes static/fonts/KaTeX_Main-Bold.ttf | Bin 0 -> 51336 bytes static/fonts/KaTeX_Main-Bold.woff | Bin 0 -> 29912 bytes static/fonts/KaTeX_Main-Bold.woff2 | Bin 0 -> 25324 bytes static/fonts/KaTeX_Main-BoldItalic.ttf | Bin 0 -> 32968 bytes static/fonts/KaTeX_Main-BoldItalic.woff | Bin 0 -> 19412 bytes static/fonts/KaTeX_Main-BoldItalic.woff2 | Bin 0 -> 16780 bytes static/fonts/KaTeX_Main-Italic.ttf | Bin 0 -> 33580 bytes static/fonts/KaTeX_Main-Italic.woff | Bin 0 -> 19676 bytes static/fonts/KaTeX_Main-Italic.woff2 | Bin 0 -> 16988 bytes static/fonts/KaTeX_Main-Regular.ttf | Bin 0 -> 53580 bytes static/fonts/KaTeX_Main-Regular.woff | Bin 0 -> 30772 bytes static/fonts/KaTeX_Main-Regular.woff2 | Bin 0 -> 26272 bytes static/fonts/KaTeX_Math-BoldItalic.ttf | Bin 0 -> 31196 bytes static/fonts/KaTeX_Math-BoldItalic.woff | Bin 0 -> 18668 bytes static/fonts/KaTeX_Math-BoldItalic.woff2 | Bin 0 -> 16400 bytes static/fonts/KaTeX_Math-Italic.ttf | Bin 0 -> 31308 bytes static/fonts/KaTeX_Math-Italic.woff | Bin 0 -> 18748 bytes static/fonts/KaTeX_Math-Italic.woff2 | Bin 0 -> 16440 bytes static/fonts/KaTeX_SansSerif-Bold.ttf | Bin 0 -> 24504 bytes static/fonts/KaTeX_SansSerif-Bold.woff | Bin 0 -> 14408 bytes static/fonts/KaTeX_SansSerif-Bold.woff2 | Bin 0 -> 12216 bytes static/fonts/KaTeX_SansSerif-Italic.ttf | Bin 0 -> 22364 bytes static/fonts/KaTeX_SansSerif-Italic.woff | Bin 0 -> 14112 bytes static/fonts/KaTeX_SansSerif-Italic.woff2 | Bin 0 -> 12028 bytes static/fonts/KaTeX_SansSerif-Regular.ttf | Bin 0 -> 19436 bytes static/fonts/KaTeX_SansSerif-Regular.woff | Bin 0 -> 12316 bytes static/fonts/KaTeX_SansSerif-Regular.woff2 | Bin 0 -> 10344 bytes static/fonts/KaTeX_Script-Regular.ttf | Bin 0 -> 16648 bytes static/fonts/KaTeX_Script-Regular.woff | Bin 0 -> 10588 bytes static/fonts/KaTeX_Script-Regular.woff2 | Bin 0 -> 9644 bytes static/fonts/KaTeX_Size1-Regular.ttf | Bin 0 -> 12228 bytes static/fonts/KaTeX_Size1-Regular.woff | Bin 0 -> 6496 bytes static/fonts/KaTeX_Size1-Regular.woff2 | Bin 0 -> 5468 bytes static/fonts/KaTeX_Size2-Regular.ttf | Bin 0 -> 11508 bytes static/fonts/KaTeX_Size2-Regular.woff | Bin 0 -> 6188 bytes static/fonts/KaTeX_Size2-Regular.woff2 | Bin 0 -> 5208 bytes static/fonts/KaTeX_Size3-Regular.ttf | Bin 0 -> 7588 bytes static/fonts/KaTeX_Size3-Regular.woff | Bin 0 -> 4420 bytes static/fonts/KaTeX_Size3-Regular.woff2 | Bin 0 -> 3624 bytes static/fonts/KaTeX_Size4-Regular.ttf | Bin 0 -> 10364 bytes static/fonts/KaTeX_Size4-Regular.woff | Bin 0 -> 5980 bytes static/fonts/KaTeX_Size4-Regular.woff2 | Bin 0 -> 4928 bytes static/fonts/KaTeX_Typewriter-Regular.ttf | Bin 0 -> 27556 bytes static/fonts/KaTeX_Typewriter-Regular.woff | Bin 0 -> 16028 bytes static/fonts/KaTeX_Typewriter-Regular.woff2 | Bin 0 -> 13568 bytes static/js/katex.min.js | 1 + tests/test_app.py | 116 ++++- 77 files changed, 1141 insertions(+), 88 deletions(-) create mode 100644 app/routers/sidebar_config.py create mode 100644 static/css/katex.min.css create mode 100644 static/fonts/KaTeX_AMS-Regular.ttf create mode 100644 static/fonts/KaTeX_AMS-Regular.woff create mode 100644 static/fonts/KaTeX_AMS-Regular.woff2 create mode 100644 static/fonts/KaTeX_Caligraphic-Bold.ttf create mode 100644 static/fonts/KaTeX_Caligraphic-Bold.woff create mode 100644 static/fonts/KaTeX_Caligraphic-Bold.woff2 create mode 100644 static/fonts/KaTeX_Caligraphic-Regular.ttf create mode 100644 static/fonts/KaTeX_Caligraphic-Regular.woff create mode 100644 static/fonts/KaTeX_Caligraphic-Regular.woff2 create mode 100644 static/fonts/KaTeX_Fraktur-Bold.ttf create mode 100644 static/fonts/KaTeX_Fraktur-Bold.woff create mode 100644 static/fonts/KaTeX_Fraktur-Bold.woff2 create mode 100644 static/fonts/KaTeX_Fraktur-Regular.ttf create mode 100644 static/fonts/KaTeX_Fraktur-Regular.woff create mode 100644 static/fonts/KaTeX_Fraktur-Regular.woff2 create mode 100644 static/fonts/KaTeX_Main-Bold.ttf create mode 100644 static/fonts/KaTeX_Main-Bold.woff create mode 100644 static/fonts/KaTeX_Main-Bold.woff2 create mode 100644 static/fonts/KaTeX_Main-BoldItalic.ttf create mode 100644 static/fonts/KaTeX_Main-BoldItalic.woff create mode 100644 static/fonts/KaTeX_Main-BoldItalic.woff2 create mode 100644 static/fonts/KaTeX_Main-Italic.ttf create mode 100644 static/fonts/KaTeX_Main-Italic.woff create mode 100644 static/fonts/KaTeX_Main-Italic.woff2 create mode 100644 static/fonts/KaTeX_Main-Regular.ttf create mode 100644 static/fonts/KaTeX_Main-Regular.woff create mode 100644 static/fonts/KaTeX_Main-Regular.woff2 create mode 100644 static/fonts/KaTeX_Math-BoldItalic.ttf create mode 100644 static/fonts/KaTeX_Math-BoldItalic.woff create mode 100644 static/fonts/KaTeX_Math-BoldItalic.woff2 create mode 100644 static/fonts/KaTeX_Math-Italic.ttf create mode 100644 static/fonts/KaTeX_Math-Italic.woff create mode 100644 static/fonts/KaTeX_Math-Italic.woff2 create mode 100644 static/fonts/KaTeX_SansSerif-Bold.ttf create mode 100644 static/fonts/KaTeX_SansSerif-Bold.woff create mode 100644 static/fonts/KaTeX_SansSerif-Bold.woff2 create mode 100644 static/fonts/KaTeX_SansSerif-Italic.ttf create mode 100644 static/fonts/KaTeX_SansSerif-Italic.woff create mode 100644 static/fonts/KaTeX_SansSerif-Italic.woff2 create mode 100644 static/fonts/KaTeX_SansSerif-Regular.ttf create mode 100644 static/fonts/KaTeX_SansSerif-Regular.woff create mode 100644 static/fonts/KaTeX_SansSerif-Regular.woff2 create mode 100644 static/fonts/KaTeX_Script-Regular.ttf create mode 100644 static/fonts/KaTeX_Script-Regular.woff create mode 100644 static/fonts/KaTeX_Script-Regular.woff2 create mode 100644 static/fonts/KaTeX_Size1-Regular.ttf create mode 100644 static/fonts/KaTeX_Size1-Regular.woff create mode 100644 static/fonts/KaTeX_Size1-Regular.woff2 create mode 100644 static/fonts/KaTeX_Size2-Regular.ttf create mode 100644 static/fonts/KaTeX_Size2-Regular.woff create mode 100644 static/fonts/KaTeX_Size2-Regular.woff2 create mode 100644 static/fonts/KaTeX_Size3-Regular.ttf create mode 100644 static/fonts/KaTeX_Size3-Regular.woff create mode 100644 static/fonts/KaTeX_Size3-Regular.woff2 create mode 100644 static/fonts/KaTeX_Size4-Regular.ttf create mode 100644 static/fonts/KaTeX_Size4-Regular.woff create mode 100644 static/fonts/KaTeX_Size4-Regular.woff2 create mode 100644 static/fonts/KaTeX_Typewriter-Regular.ttf create mode 100644 static/fonts/KaTeX_Typewriter-Regular.woff create mode 100644 static/fonts/KaTeX_Typewriter-Regular.woff2 create mode 100644 static/js/katex.min.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 002d42e..6ce107b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog — FlowDeck +## v4.6.0 (2026-09-02) — Content Blocks Enrichis + +### Added +- **Callout blocks** — boîtes colorées avec sélecteur d'emoji/icône cliquable +- **Table of contents** — bloc auto-généré depuis les headings, avec ancres de navigation +- **Math equations** — bloc LaTeX/KaTeX avec aperçu en temps réel (édition inline) +- **`table_of_contents` et `math`** — nouveaux types dans le slash menu (éditeur) +- **Multi-colonnes** — bloc `columns` avec layout flexible, bouton "+ Add column" / retrait +- **Toggle lists enrichis** — contenu expandable/collapsible avec blocs enfants imbriqués + +### Améliorations +- Intégration **KaTeX 0.16.11 self-hosté** (`/static/js/katex.min.js`, `/static/css/katex.min.css`, `/static/fonts/`) — aucun CDN externe +- Rendu des nouveaux blocs dans les **pages publiques** (`/p/`): TOC ancré, KaTeX, colonnes flex, toggle `
` +- Export Markdown étendu aux nouveaux types de blocs +- Correction d'un bug pré-existant : `public_page.html` n'importait pas la macro `fd_icon` → erreur de rendu des pages publiées + +### Tests +- **183 tests au total** (5 nouveaux tests v4.6.0) couvrant le rendu public TOC/math/colonnes/toggle et la persistance `children` + ## v2.7.1 (2026-07-13) — Private Pages Gitea ### Added diff --git a/ROADMAP.md b/ROADMAP.md index b3348ff..49ab80b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -253,14 +253,22 @@ Propriétés custom, AI keywords, sync API, 12 tables DB - [x] **My Tasks** — agrégation cross-databases avec filtre (all/today/overdue/upcoming) - [x] **175 tests passent** (+4 nouveaux) -### v4.6.0 — Content Blocks enrichis +### v4.6.0 — Content Blocks enrichis ✅ (2026-09-02) > **Objectif** : parité d'édition avec Notion. -- [ ] **Callout blocks** — boîtes colorées (info, warning, tip, success) -- [ ] **Table of contents** — auto-généré depuis les headings -- [ ] **Math equations** — LaTeX / KaTeX inline + block -- [ ] **Toggle lists** — contenu expandable/collapsible (déjà partiel) -- [ ] **Multi-colonnes** — layout flexible (2, 3 colonnes) +- [x] **Callout blocks** — boîtes colorées avec sélecteur d'emoji/icône +- [x] **Table of contents** — auto-généré depuis les headings avec ancres +- [x] **Math equations** — LaTeX / KaTeX (self-hosted) block +- [x] **Toggle lists** — contenu expandable/collapsible avec enfants +- [x] **Multi-colonnes** — layout flexible (2, 3 colonnes) + bouton ajouter/retirer colonne + +Détails livrés : +- 5 types de blocs enrichis dans le slash menu (callout, table_of_contents, math, columns, toggle) +- Rendu des nouveaux blocs dans les pages publiques (`/p/`) — TOC ancré, KaTeX, colonnes, toggle `
` +- Intégration KaTeX 0.16.11 self-hosté (`/static/js/katex.min.js`, `/static/css/katex.min.css`, `/static/fonts/`) +- Persistance `children` (colonnes/toggle) via le format de blocs JSON +- Export Markdown étendu aux nouveaux blocs +- 183 tests au total (dont 5 nouveaux tests v4.6.0) ### v4.7.0 — Export - [ ] **Export Markdown** — avec images et sous-pages (déjà partiel dans editor) @@ -415,8 +423,8 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard + UI Notion + Tags + Admin + Sharing COMPLETED + GitHub OAuth + Library -v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4.0 ✅ v4.5.0 ✅ v4.6.0 ⬜ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜ +v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4.0 ✅ v4.5.0 ✅ v4.6.0 ✅ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜ Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Collab + Pro + Agent & Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks PDF/MD Agent IA (futur) -*Dernière mise à jour: 2026-07-21 — v4.5.0 Sprints & My Tasks complété ✅* +*Dernière mise à jour: 2026-09-02 — v4.6.0 Content Blocks enrichis complété ✅* diff --git a/VERSION b/VERSION index c4e41f9..28446a5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.0.3 +4.6.0 \ No newline at end of file diff --git a/app/db.py b/app/db.py index 1cae6a5..4c22990 100644 --- a/app/db.py +++ b/app/db.py @@ -568,6 +568,13 @@ def init_db(): """) conn.commit() + # v4.6.0: Sidebar customization config per user + try: + conn.execute("ALTER TABLE users ADD COLUMN sidebar_config TEXT DEFAULT '{}'") + except sqlite3.OperationalError: + pass + conn.commit() + @contextmanager def get_conn(): diff --git a/app/main.py b/app/main.py index 0742360..1d0e88c 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,7 @@ from app.config import settings from app.db import init_db from app.middleware.csrf import CSRFMiddleware from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware -from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing +from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing, sidebar_config from app.routers.gitea import router as gitea_router from app.routers.github_routes import router as github_router from app.services.gitea_client import gitea @@ -45,7 +45,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="4.0.0", + version="4.6.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, @@ -72,6 +72,7 @@ app.include_router(gitea_router) app.include_router(github_router) app.include_router(public_api.router) app.include_router(sharing.router) +app.include_router(sidebar_config.router) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/app/routers/board.py b/app/routers/board.py index 423d067..0815e8c 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -10,6 +10,7 @@ from fastapi.responses import HTMLResponse, JSONResponse from app.db import get_conn from app.services.gitea_client import gitea from app.auth.session import SessionManager +from app.routers.sidebar_config import get_sidebar_config_sync from app.routers.dashboard import _get_app_version logger = logging.getLogger(__name__) @@ -247,9 +248,12 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: gitea_owner = "" gitea_repo = "" has_active_workspace = False + local_ws_id = 0 if ws_cookie and ws_cookie.startswith("gitea:"): - # Gitea workspace: preserve context across pages + # Gitea workspace: preserve context across pages. Also load the local + # mirror workspace so it appears in "My Workspaces" in the top section + # of the sidebar, in parallel with the Gitea repository tree. parts = ws_cookie.split(":", 2) if len(parts) >= 3: gitea_owner = parts[1] @@ -257,8 +261,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: active_ws_name = f"{gitea_owner}/{gitea_repo}" gitea_workspace = True has_active_workspace = True - workspace_pages = [] # loaded client-side - # Get local workspace ID for mirror + # Get local workspace ID for mirror and load its tree if user: try: with get_conn() as conn: @@ -268,6 +271,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: ).fetchone() if row: local_ws_id = row["id"] + workspace_pages = _load_workspace_pages(str(local_ws_id)) except Exception: pass elif ws_cookie and user: @@ -284,7 +288,6 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: has_active_workspace = True except (ValueError, Exception): pass - local_ws_id = 0 recent = [] if owner and repo: view_map = { @@ -399,7 +402,8 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: "github_linked": github_linked, "has_active_workspace": has_active_workspace, "app_version": _get_app_version(), - "local_workspaces": _local_workspaces_for_user(user)} + "local_workspaces": _local_workspaces_for_user(user), + "sidebar_config": json.dumps(get_sidebar_config_sync(uid))} def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""): diff --git a/app/routers/collections.py b/app/routers/collections.py index d1a51f1..3273c95 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -1193,6 +1193,11 @@ async def view_collection(request: Request, collection_id: int, view_type: str = pages_list = [dict(p) for p in pages] config = json.loads(view["config_json"]) if view else {} + if "year" in request.query_params: + config["year"] = int(request.query_params["year"]) + if "month" in request.query_params: + config["month"] = int(request.query_params["month"]) + return HTMLResponse(_render_view(view_type, collection_dict, pages_list, config)) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 4a17349..c97b816 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -115,10 +115,13 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = gitea_owner = "" gitea_repo = "" has_active_workspace = False + local_ws_id = 0 if ws_cookie and ws_cookie.startswith("gitea:"): # Gitea workspace: set owner/repo for client-side tree loading - # BUT keep local workspace pages — they go in the "Private" section + # AND open the local workspace mirror of the same name in the + # sidebar's top "My Workspaces" section, in parallel with the + # Gitea repository tree. parts = ws_cookie.split(":", 2) if len(parts) >= 3: gitea_owner = parts[1] @@ -126,7 +129,20 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = active_ws_name = f"{gitea_owner}/{gitea_repo}" gitea_workspace = True has_active_workspace = True - # workspace_pages stays as-is (local tree, loaded above) + # Load the local mirror workspace tree so it appears in "My + # Workspaces" alongside the Gitea repository section. + if user: + try: + with get_conn() as conn: + row = conn.execute( + "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", + (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%") + ).fetchone() + if row: + local_ws_id = row["id"] + workspace_pages = _load_workspace_pages(str(local_ws_id)) + except (ValueError, Exception): + pass elif include_workspace and ws_cookie and user: try: wsi = int(ws_cookie) @@ -2039,6 +2055,13 @@ h1{font-size:3rem;opacity:.3}

404

""", ) +def _sanitize_id(block_id: str) -> str: + """Sanitize a block id for use as an HTML anchor (only alnum kept).""" + if not block_id: + return "" + return "".join(ch for ch in str(block_id) if ch.isalnum()) + + def _render_blocks_public(blocks: list) -> str: """Render FlowDeck blocks as plain HTML for public pages.""" import json as _json @@ -2047,13 +2070,13 @@ def _render_blocks_public(blocks: list) -> str: t = b.get("type", "paragraph") c = b.get("content", "") or "" if t == "heading_1": - html_parts.append(f'

{c}

') + html_parts.append(f'

{c}

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

{c}

') + html_parts.append(f'

{c}

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

{c}

') + html_parts.append(f'

{c}

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

{c}

') + html_parts.append(f'

{c}

') elif t == "bulleted_list": html_parts.append(f'
  • {c}
  • ') elif t == "numbered_list": @@ -2068,13 +2091,52 @@ def _render_blocks_public(blocks: list) -> str: f'' ) elif t == "toggle": + children_html = "" + if b.get("children"): + children_html = '
    ' + children_html += _render_blocks_public(b["children"]) + children_html += "
    " html_parts.append( - f'
    {c}
    ' + f'
    {c}{children_html}
    ' ) elif t == "quote": html_parts.append( f'
    {c}
    ' ) + elif t == "table_of_contents": + toc = [ + x for x in blocks + if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip() + ] + if toc: + items = [] + for h in toc: + lvl = int(h["type"].split("_")[-1]) + items.append( + f'' + ) + html_parts.append( + '
    ' + '
    On this page
    ' + + "".join(items) + "
    " + ) + elif t == "math": + tex = c.replace("&", "&").replace("<", "<").replace(">", ">") + html_parts.append( + f'
    ' + ) + elif t == "columns": + cols_html = "" + for child in b.get("children") or []: + cols_html += ( + '
    ' + + _render_blocks_public([child]) + "
    " + ) + html_parts.append( + f'
    {cols_html}
    ' + ) elif t == "callout": icon = b.get("icon", "💡") bg = (b.get("style") or {}).get("bgColor", "rgba(76,154,255,.1)") diff --git a/app/routers/sidebar_config.py b/app/routers/sidebar_config.py new file mode 100644 index 0000000..8e06775 --- /dev/null +++ b/app/routers/sidebar_config.py @@ -0,0 +1,115 @@ +"""FlowDeck — Sidebar customization API (v4.6.0).""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, HTTPException, Request + +from app.db import get_conn +from app.auth.session import SessionManager + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["sidebar"], prefix="/api/sidebar") + + +def _get_user(request: Request) -> dict: + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + return user + + +DEFAULT_CONFIG = { + "workspace": {"visible": True, "order": 0, "show_count": None}, + "gitea": {"visible": True, "order": 1, "show_count": None}, + "meetings": {"visible": True, "order": 2, "show_count": 5}, + "recents": {"visible": True, "order": 3, "show_count": 10}, + "favorites": {"visible": True, "order": 4, "show_count": 10}, + "agents": {"visible": True, "order": 5, "show_count": None}, + "shared": {"visible": True, "order": 6, "show_count": 10}, + "published": {"visible": True, "order": 7, "show_count": 10}, + "private": {"visible": True, "order": 8, "show_count": None}, +} + + +@router.get("/config") +async def get_sidebar_config(request: Request): + """Get the current user's sidebar customization config.""" + user = _get_user(request) + with get_conn() as conn: + row = conn.execute( + "SELECT sidebar_config FROM users WHERE id=?", (user["id"],) + ).fetchone() + if not row: + return {"config": DEFAULT_CONFIG} + + raw = row["sidebar_config"] + if not raw: + return {"config": DEFAULT_CONFIG} + + try: + stored = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return {"config": DEFAULT_CONFIG} + + # Merge with defaults to ensure all keys exist + merged = dict(DEFAULT_CONFIG) + merged.update(stored) + return {"config": merged} + + +def get_sidebar_config_sync(user_id: int) -> dict: + """Synchronous helper to get sidebar config (used during template rendering).""" + with get_conn() as conn: + row = conn.execute( + "SELECT sidebar_config FROM users WHERE id=?", (user_id,) + ).fetchone() + if not row: + return dict(DEFAULT_CONFIG) + raw = row["sidebar_config"] + if not raw: + return dict(DEFAULT_CONFIG) + try: + stored = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return dict(DEFAULT_CONFIG) + merged = dict(DEFAULT_CONFIG) + merged.update(stored) + return merged + + +@router.put("/config") +async def save_sidebar_config(request: Request): + """Save the current user's sidebar customization config.""" + user = _get_user(request) + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + config = body.get("config") + if not config or not isinstance(config, dict): + raise HTTPException(status_code=400, detail="config object is required") + + # Merge with defaults to ensure validity + merged = dict(DEFAULT_CONFIG) + for key, val in config.items(): + if key in DEFAULT_CONFIG and isinstance(val, dict): + merged[key] = { + "visible": val.get("visible", DEFAULT_CONFIG[key]["visible"]), + "order": val.get("order", DEFAULT_CONFIG[key]["order"]), + "show_count": val.get("show_count", DEFAULT_CONFIG[key]["show_count"]), + } + elif key not in DEFAULT_CONFIG: + # Allow new custom sections + merged[key] = val + + with get_conn() as conn: + conn.execute( + "UPDATE users SET sidebar_config=? WHERE id=?", + (json.dumps(merged), user["id"]), + ) + conn.commit() + + return {"status": "ok", "config": merged} diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index 436b908..62aec1b 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -7,7 +7,8 @@ {id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'}, {id:'heading_3',name:'Heading 3',icon:'H3'},{id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'},{id:'numbered_list',name:'Numbered list',icon:'1.'}, {id:'to_do',name:'To-do',icon:'☐'},{id:'toggle',name:'Toggle',icon:'▶'},{id:'quote',name:'Quote',icon:'❝'}, - {id:'callout',name:'Callout',icon:'lightbulb'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'}, + {id:'callout',name:'Callout',icon:'lightbulb'},{id:'table_of_contents',name:'Table of contents',icon:'☰'},{id:'math',name:'Math equation',icon:'∑'}, + {id:'columns',name:'Column list',icon:'▮'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'}, ]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'image'}]}]; let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);} @@ -16,6 +17,8 @@ function cs(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(true);window.getSelection().removeAllRanges();window.getSelection().addRange(r);} function esc(s){return s.replace(/&/g,'&').replace(//g,'>');} + const CALLOUT_ICONS=['💡','💡','📌','⚠️','ℹ️','✅','❌','🚀','🔔','📣','⭐','❤️','🔥','💬','👀','🧠','🎯','🔑','📅','💰','💡','📚','🧪','🌍']; + const SM={_o:false,_i:-1,_s:0,_q:'',_it:[], init(){const e=document.getElementById('_slashMenu');if(!e)return;e.innerHTML=''; const inp=document.createElement('input');inp.type='text';inp.placeholder='Filter...'; @@ -53,6 +56,9 @@ function renderBlock(b,idx){ if(b.type==='divider')return `

    `; if(b.type==='image')return `
    ${esc(b.alt||'')}
    `; + if(b.type==='table_of_contents')return renderTOC(); + if(b.type==='math')return `
    ${renderMathBlock(b)}
    `; + if(b.type==='columns')return renderColumnsBlock(b); if(b.type==='embed'){ if(b.embed_type==='inline_dbs')return `
    📊 Inline Databases
    `; if(b.embed_type==='collection')return `
    Loading database...
    <`+`script>setTimeout(function(){var el=document.getElementById('inline-db-${b.collection_id}');if(el&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,${b.collection_id},null);},50);<`+`/script>
    `; @@ -65,20 +71,66 @@ const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':'Press \'/\' for commands...'; let inner=''; if(b.type==='to_do')inner=`
    ${esc(b.content)}
    `; - else if(b.type==='toggle')inner=`
    ${esc(b.content)}
    `; + else if(b.type==='toggle')inner=`
    ${esc(b.content)}
    ${(b.children||[]).map((ch)=>renderNestedBlock(ch)).join('')}
    `; else if(b.type==='code')inner=`
    ${b.language||'Plain Text'}
    ${esc(b.content)}
    `; - else if(b.type==='callout')inner=`${b.icon||'lightbulb'}
    ${esc(b.content)}
    `; + else if(b.type==='callout')inner=`${b.icon||'💡'}
    ${esc(b.content)}
    `; else inner=`
    ${esc(b.content)}
    `; const tag=b.type.startsWith('heading_')?'h'+b.type.slice(-1):b.type==='quote'?'blockquote':'div'; const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`; const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}"`:''; + const bidAttr=b.type.startsWith('heading_')?` id="h-${(b.id||'').replace(/[^a-zA-Z0-9]/g,'')}"`:''; + const actions=idx>=0?`
    `:''; return `
    -
    - <${tag}${style} class="${cls}">${inner} + ${actions} + <${tag}${style}${bidAttr} class="${cls}">${inner}
    `; } + function renderTOC(){ + var out=[]; + var blocks=window.E?window.E.blocks:[]; + var headings=blocks.filter(function(b){return /^heading_[1-4]$/.test(b.type)&&(b.content||'').trim();}); + if(headings.length===0){ + out.push(`
    Add headings to generate a table of contents
    `); + }else{ + out.push(`
    On this page
    `); + headings.forEach(function(h){ + var lvl=parseInt(h.type.slice(-1),10); + var id='h-'+ (h.id||'').replace(/[^a-zA-Z0-9]/g,''); + out.push(`
    ${esc(h.content)}
    `); + }); + out.push(`
    `); + } + return `
    ${out.join('')}
    `; + } + + function renderMathBlock(b){ + var html=`
    `; + html+=``; + return html; + } + + function renderColumnsBlock(b){ + var cols=(b.children&&b.children.length)?b.children:[{id:genId(),type:'paragraph',content:''}]; + var cells=cols.map(function(c){ + return `
    ${renderChildColumn(c,b.id)}
    `; + }).join(''); + return `
    ${cells}
    `; + } + + function renderChildColumn(c,parentId){ + return `
    +
    +
    ${esc(c.content)}
    +
    `; + } + + function renderNestedBlock(c){ + var ph=c.type==='heading_1'?'Heading 1':c.type==='heading_2'?'Heading 2':c.type==='heading_3'?'Heading 3':c.type==='heading_4'?'Heading 4':c.type==='bulleted_list'?'List':c.type==='numbered_list'?'List':c.type==='to_do'?'To-do':'Type...'; + return `
    ${esc(c.content)}
    `; + } + window.E=null; function editorState(){ return { @@ -122,7 +174,7 @@ setTimeout(()=>{this.focusBlock();const ct=document.getElementById('_blocksCt');if(!ct)return; ct.addEventListener('keydown',e=>this.onKd(e)); ct.addEventListener('paste',e=>{e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();}); - if(typeof Sortable!=='undefined')Sortable.create(ct,{draggable:'.block-wrapper',handle:'.block-handle',animation:150,ghostClass:'sortable-ghost',dragClass:'sortable-drag',onEnd:evt=>{if(evt.oldIndex===evt.newIndex)return;const[item]=this.blocks.splice(evt.oldIndex,1);this.blocks.splice(evt.newIndex,0,item);this.dirty=true;this.autoSave();this.render();}}); + if(typeof Sortable!=='undefined')Sortable.create(ct,{draggable:'.block-wrapper:not(.nested-block):not(.block-column-wrapper)',handle:'.block-handle',animation:150,ghostClass:'sortable-ghost',dragClass:'sortable-drag',onEnd:evt=>{if(evt.oldIndex===evt.newIndex)return;const[item]=this.blocks.splice(evt.oldIndex,1);this.blocks.splice(evt.newIndex,0,item);this.dirty=true;this.autoSave();this.render();}}); },100); }, fileData:null, @@ -148,20 +200,34 @@ } this.blocks=[this.mkB('embed','',{src:url,embed_type:'download',file_name:name,file_size:data.file_size||0,file_mime:mime})];this.render(); }, - mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;return b;}, + mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='toggle')b.children=[];return b;}, getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;}, getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);}, getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};}, focusBlock(){const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();ce(el);}}, onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}}, - sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='divider'||b.type==='image'||b.type==='embed')continue;const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el)b.content=el.textContent||'';}}, + sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents')continue; + if(b.type==='math'){const te=ct.querySelector(`[data-math-bid="${b.id}"]`);if(te)b.content=te.value||'';continue;} + const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=el.textContent||'';else if(el)b.content=el.textContent||''; + if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=che.textContent||'';}); + if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=che.textContent||'';}); + }}, render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i{ct.querySelectorAll('.block-embed-collection').forEach(el=>{const cid=parseInt(el.id.replace('inline-db-',''));if(cid&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,cid,null);});},50); - var self=this;ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();})}); - ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());}); + // Render math blocks with KaTeX + var self=this; + ct.querySelectorAll('.math-display').forEach(function(el){var bid=el.dataset.bid;var b=self.blocks.find(function(x){return x.id===bid;});self._renderKatex(el,b?b.content:'');}); + ct.querySelectorAll('.math-editor').forEach(function(ta){var disp=ct.querySelector(`.math-display[data-bid="${ta.dataset.mathBid}"]`);ta.addEventListener('input',function(){self._renderKatex(disp,ta.value);self.dirty=true;self.autoSave();});}); + // Initialize inline database blocks (scripts in innerHTML don't execute) + setTimeout(()=>{ct.querySelectorAll('.block-embed-collection').forEach(el=>{const cid=parseInt(el.id.replace('inline-db-',''));if(cid&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,cid,null);});},50); + ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();})}); + ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());}); }, - replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);}, + _renderKatex(el,tex){ + if(!el)return;if(!tex||!tex.trim()){el.innerHTML='Empty math block';return;} + if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='Invalid LaTeX';}} + else{el.innerHTML=''+esc(tex)+'';} + }, + replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle'){b.expanded=true;b.children=[];}if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='math'){}this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);}, addAt(idx,type,content){this.sync();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;}, addAfter(idx,type){return this.addAt(idx+1,type);}, removeBlock(idx){this.sync();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);}, @@ -435,12 +501,18 @@ const idx=SM._i;if(idx<0||idx>=this.blocks.length)return; const block=this.blocks[idx];this.sync();block.content=''; if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');} - else{block.type=id;if(id==='toggle')block.expanded=true;if(id==='to_do')block.checked=false;if(id==='code')block.language='Plain Text';} + else{block.type=id;if(id==='toggle'){block.expanded=true;block.children=[];}if(id==='to_do')block.checked=false;if(id==='columns')block.children=[{id:genId(),type:'paragraph',content:''}];if(id==='code')block.language='Plain Text';} this.render();this.dirty=true;this.autoSave(); - setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60); + if(id==='math'){setTimeout(()=>{const ne=document.querySelector(`[data-math-bid="${block.id}"]`);if(ne)ne.focus();},60);} + else if(id==='columns'){setTimeout(()=>{const ne=this.getEl(block.children[0].id);if(ne)ne.focus();},60);} + else{setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);} }, _doToggle(bid,v){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].checked=v;this.dirty=true;this.autoSave();}}, - _doToggleExpand(bid){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].expanded=!this.blocks[idx].expanded;this.dirty=true;this.autoSave();}}, + _doToggleExpand(bid){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].expanded=!this.blocks[idx].expanded;this.render();this.dirty=true;this.autoSave();}}, + _addColumn(parentId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];b.children.push({id:genId(),type:'paragraph',content:''});this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const ne=this.getEl(b.children[b.children.length-1].id);if(ne)ne.focus();},60);}, + _removeColumn(parentId,childId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];b.children=b.children.filter(function(c){return c.id!==childId;});if(b.children.length===0)b.children=[{id:genId(),type:'paragraph',content:''}];this.render();this.dirty=true;this.autoSave();}, + _toggleCalloutIcon(bid){const picker=document.getElementById('callout-picker-'+bid);if(picker)picker.style.display=picker.style.display==='none'?'flex':'none';}, + _setCalloutIcon(bid,icon){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].icon=icon;this.dirty=true;this.autoSave();this.render();}}, showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};}, fmtApply(f){this.fmt.open=false;if(!this._sel)return;const s=window.getSelection();s.removeAllRanges();s.addRange(this._sel.range);document.execCommand(f==='strikeThrough'?'strikeThrough':f);this._sel=null;this.dirty=true;this.autoSave();}, fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}}, @@ -453,7 +525,7 @@ const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'New page'; this.saving=true; const csrf=document.cookie.match(/csrf_token=([^;]+)/); - fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})}) + fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})}) .then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString(); document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';}); }).catch(()=>{this.saving=false;}); @@ -475,7 +547,10 @@ case 'quote': return '> ' + c; case 'divider': return '---'; case 'code': return '```' + (b.language || '') + '\n' + c + '\n```'; - case 'toggle': return '## ' + c; + case 'toggle': return (b.children && b.children.length ? '## ' : '## ') + c + (b.children && b.children.length ? '\n\n' + b.children.map(blocksToMarkdown).join('\n\n') : ''); + case 'math': return '$$\n' + c + '\n$$'; + case 'table_of_contents': return '[TOC]'; + case 'columns': return (b.children || []).map(blocksToMarkdown).join('\n\n'); default: return c; } } diff --git a/app/templates/base.html b/app/templates/base.html index dde5715..847cbef 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -6,6 +6,7 @@ FlowDeck — {% block title_prefix %}Home{% endblock %} +