feat(content): v4.6.0 Content Blocks enrichis — Callout, TOC, Math (KaTeX), Toggle, Multi-colonnes
FlowDeck CI / test (push) Failing after 20s
FlowDeck CI / docker (push) Skipped

- 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/<slug>): TOC, KaTeX, colonnes, toggle <details>
- 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
This commit is contained in:
2026-09-03 00:05:33 -04:00
parent d12681720d
commit 814dbe8c2e
77 changed files with 1141 additions and 88 deletions
+19
View File
@@ -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/<slug>`): TOC ancré, KaTeX, colonnes flex, toggle `<details>`
- 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
+16 -8
View File
@@ -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/<slug>`) — TOC ancré, KaTeX, colonnes, toggle `<details>`
- 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é ✅*
+1 -1
View File
@@ -1 +1 @@
4.0.3
4.6.0
+7
View File
@@ -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():
+3 -2
View File
@@ -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")
+9 -5
View File
@@ -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 = ""):
+5
View File
@@ -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))
+69 -7
View File
@@ -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}</style></head><body><h1>404</h1></body></html>""",
)
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'<h1 style="font-size:2.5rem;font-weight:700;margin:32px 0 8px;">{c}</h1>')
html_parts.append(f'<h1 id="h-{_sanitize_id(b.get("id",""))}" 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>')
html_parts.append(f'<h2 id="h-{_sanitize_id(b.get("id",""))}" 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>')
html_parts.append(f'<h3 id="h-{_sanitize_id(b.get("id",""))}" 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>')
html_parts.append(f'<h4 id="h-{_sanitize_id(b.get("id",""))}" 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":
@@ -2068,13 +2091,52 @@ def _render_blocks_public(blocks: list) -> str:
f'</div>'
)
elif t == "toggle":
children_html = ""
if b.get("children"):
children_html = '<div style="margin-left:22px;padding-left:12px;border-left:1px solid rgba(255,255,255,.1);margin-top:4px;">'
children_html += _render_blocks_public(b["children"])
children_html += "</div>"
html_parts.append(
f'<details style="margin:8px 0;"><summary style="cursor:pointer;font-weight:500;">{c}</summary></details>'
f'<details style="margin:8px 0;" open><summary style="cursor:pointer;font-weight:500;">{c}</summary>{children_html}</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 == "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'<div style="margin-left:{max(0, lvl - 1) * 14}px;padding:5px 8px;font-size:14px;">'
f'<a href="#h-{_sanitize_id(h.get("id",""))}" style="color:inherit;text-decoration:none;display:block;">{h.get("content","")}</a></div>'
)
html_parts.append(
'<div style="border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:16px 20px;margin:4px 0;">'
'<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;opacity:.5;margin-bottom:10px;">On this page</div>'
+ "".join(items) + "</div>"
)
elif t == "math":
tex = c.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
html_parts.append(
f'<div data-katex="{tex}" style="margin:12px 0;padding:12px 16px;background:rgba(255,255,255,.04);border-radius:8px;overflow-x:auto;"></div>'
)
elif t == "columns":
cols_html = ""
for child in b.get("children") or []:
cols_html += (
'<div style="flex:1;min-width:0;padding:10px 12px;background:rgba(255,255,255,.05);'
'border-radius:8px;box-sizing:border-box;">'
+ _render_blocks_public([child]) + "</div>"
)
html_parts.append(
f'<div style="display:flex;gap:12px;margin:8px 0 16px;align-items:stretch;">{cols_html}</div>'
)
elif t == "callout":
icon = b.get("icon", "💡")
bg = (b.get("style") or {}).get("bgColor", "rgba(76,154,255,.1)")
+115
View File
@@ -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}
+93 -18
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
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 `<div class="block-wrapper"><hr class="block-divider"></div>`;
if(b.type==='image')return `<div class="block-wrapper"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`;
if(b.type==='table_of_contents')return renderTOC();
if(b.type==='math')return `<div class="block-wrapper"><div class="block-content block-math">${renderMathBlock(b)}</div></div>`;
if(b.type==='columns')return renderColumnsBlock(b);
if(b.type==='embed'){
if(b.embed_type==='inline_dbs')return `<div class="block-wrapper"><div class="block-content block-inline-dbs"><div style="padding:20px;text-align:center;color:var(--text-secondary);">📊 Inline Databases</div><div class="inline-dbs-grid">${(b.dbs||[]).map(db=>`<a href="/db/${db.id}/view/${db.view_type||'table'}" class="inline-db-card" target="_blank" style="display:block;padding:12px 16px;background:var(--bg-secondary);border-radius:8px;margin:8px 0;text-decoration:none;color:var(--text-primary);border:1px solid var(--border);"><span style="font-weight:500">📋 ${db.name}</span><span style="float:right;font-size:12px;color:var(--text-tertiary);">${db.view_type==='form'?'📝 Form':'📊 Table'} →</span></a>`).join('')}</div></div></div>`;
if(b.embed_type==='collection')return `<div class="block-wrapper"><div class="block-content block-embed-collection" id="inline-db-${b.collection_id}" style="padding:0;min-height:200px"><div style="padding:40px;text-align:center;color:var(--text-dim)">Loading database...</div></div><`+`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></div>`;
@@ -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=`<input type="checkbox" class="todo-checkbox" ${b.checked?'checked':''} onchange="E._doToggle('${b.id}',this.checked)"><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
else if(b.type==='toggle')inner=`<button class="toggle-chevron ${b.expanded?'open':''}" onclick="E._doToggleExpand('${b.id}')">▶</button><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
else if(b.type==='toggle')inner=`<div class="toggle-title-row"><button class="toggle-chevron ${b.expanded?'open':''}" onclick="E._doToggleExpand('${b.id}')">▶</button><div class="toggle-title" data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div></div><div class="toggle-children" ${(b.expanded===false)?'style="display:none"':''}>${(b.children||[]).map((ch)=>renderNestedBlock(ch)).join('')}</div>`;
else if(b.type==='code')inner=`<div class="code-lang-label">${b.language||'Plain Text'}</div><pre><code data-bid="${b.id}" contenteditable="true" spellcheck="false">${esc(b.content)}</code></pre>`;
else if(b.type==='callout')inner=`<span class="callout-icon">${b.icon||'lightbulb'}</span><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
else if(b.type==='callout')inner=`<span class="callout-icon" onclick="E._toggleCalloutIcon('${b.id}')" title="Change icon">${b.icon||'💡'}</span><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div><div class="callout-icon-picker" id="callout-picker-${b.id}" style="display:none">${CALLOUT_ICONS.map(function(i){return '<button type="button" class="callout-icon-opt" onclick="E._setCalloutIcon(\''+b.id+'\',\''+i+'\')">'+i+'</button>';}).join('')}</div>`;
else inner=`<div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
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?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
return `<div class="block-wrapper">
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>
<${tag}${style} class="${cls}">${inner}</${tag}>
${actions}
<${tag}${style}${bidAttr} class="${cls}">${inner}</${tag}>
</div>`;
}
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(`<div class="block-content block-toc block-toc-empty"><span style="color:var(--text-dim);font-size:13px;">Add headings to generate a table of contents</span></div>`);
}else{
out.push(`<div class="block-content block-toc"><div class="block-toc-title">On this page</div>`);
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(`<div class="block-toc-item" style="margin-left:${(lvl-1)*12}px" onclick="var el=document.getElementById('${id}');if(el){el.scrollIntoView({behavior:'smooth',block:'start'});}">${esc(h.content)}</div>`);
});
out.push(`</div>`);
}
return `<div class="block-wrapper">${out.join('')}</div>`;
}
function renderMathBlock(b){
var html=`<div class="math-display" data-bid="${b.id}"></div>`;
html+=`<textarea class="math-editor" data-math-bid="${b.id}" data-placeholder="Type a LaTeX math expression (e.g. E = mc^2)..." spellcheck="false">${esc(b.content)}</textarea>`;
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 `<div class="column-cell">${renderChildColumn(c,b.id)}</div>`;
}).join('');
return `<div class="block-wrapper"><div class="block-content block-columns"><div class="columns-add-bar"><button class="columns-add-btn" onclick="E._addColumn('${b.id}')">+ Add column</button></div><div class="columns-row">${cells}</div></div></div>`;
}
function renderChildColumn(c,parentId){
return `<div class="block-column-wrapper" data-parent="${parentId}">
<div class="block-actions"><button class="block-add-btn" onclick="E._removeColumn('${parentId}','${c.id}')" title="Remove column">×</button></div>
<div class="block-content block-column" data-bid="${c.id}" contenteditable="true" data-placeholder="${c.type==='heading_1'?'Heading 1':c.type==='heading_2'?'Heading 2':c.type==='heading_3'?'Heading 3':'Type...'}" spellcheck="false">${esc(c.content)}</div>
</div>`;
}
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 `<div class="block-wrapper nested-block"><div class="block-content block-nested" data-bid="${c.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(c.content)}</div></div>`;
}
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<this.blocks.length;i++)html+=renderBlock(this.blocks[i],i);ct.innerHTML=html;
// 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);
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(/<br[^>]*>/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(/<br[^>]*>/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='<span style="color:var(--text-dim);font-style:italic;">Empty math block</span>';return;}
if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='<span style="color:#e5484d;">Invalid LaTeX</span>';}}
else{el.innerHTML='<span>'+esc(tex)+'</span>';}
},
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;
}
}
+244 -34
View File
@@ -6,6 +6,7 @@
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/app.css?v=3.0.0">
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
<style>
/* ── Mobile responsive (v4.0.2) ── */
@media (max-width: 768px) {
@@ -222,10 +223,10 @@
<div class="sidebar-scroll">
<!-- Workspace / Private -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('workspace')">
<div class="sidebar-section" x-show="isSectionVisible('workspace')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('workspace')" @contextmenu.prevent="openSectionMenu($event, 'workspace')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.workspace }" x-text="sectionsOpen.workspace ? '▼' : '▶'"></span>
<span class="chevron" :class="{ open: sectionsOpen.workspace }">▶</span>
{% if has_active_workspace %}
<span><span style="vertical-align:sub;margin-right:4px;">{{ fd_icon("folder",16) }}</span>{{ active_ws_name if active_ws_name else "" }}{% if gitea_workspace or (workspace_key and "/" in workspace_key) %} {{ fd_icon("link",12) }}{% endif %}</span>
{% else %}
@@ -237,6 +238,7 @@
<button class="section-action-btn" title="New Page" @click.stop="window.FlowDeck.createPage()">{{ fd_icon("file",16) }}</button>
<button class="section-action-btn" title="New Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">{{ fd_icon("folder",16) }}</button>
<a class="section-action-btn" href="/local-workspace" title="Open workspace page" style="text-decoration:none;">{{ fd_icon("external-link",16) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'workspace')" @contextmenu.stop="openSectionMenu($event, 'workspace')">⋮</button>
</div>
{% endif %}
</div>
@@ -261,14 +263,16 @@
<!-- Repository (Gitea) — only visible for Gitea workspaces -->
{% if gitea_workspace %}
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('gitea')">
<div class="sidebar-section" x-show="isSectionVisible('gitea')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('gitea')" @contextmenu.prevent="openSectionMenu($event, 'gitea')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.gitea }" x-text="sectionsOpen.gitea ? '▼' : '▶'"></span>
<span>{{ fd_icon("link",14) }} Repository (Gitea)</span>
<span class="chevron" :class="{ open: sectionsOpen.gitea }">▶</span>
<span class="section-icon">{{ fd_icon("link",14) }}</span>
<span class="section-label">Repository (Gitea)</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">{{ fd_icon("refresh",16) }}</button>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'gitea')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.gitea" x-transition>
@@ -280,16 +284,18 @@
{% endif %}
<!-- Meetings -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('meetings')">
<div class="sidebar-section" x-show="isSectionVisible('meetings')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('meetings')" @contextmenu.prevent="openSectionMenu($event, 'meetings')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.meetings }" x-text="sectionsOpen.meetings ? '▼' : '▶'"></span>
<span>Meetings</span>
<span class="chevron" :class="{ open: sectionsOpen.meetings }">▶</span>
<span class="section-icon">{{ fd_icon("calendar",14) }}</span>
<span class="section-label">Meetings</span>
</div>
<div class="sidebar-section-actions">
{% if has_active_workspace %}
<button class="section-action-btn" title="Add" @click.stop="window.FlowDeck.createPage()">+</button>
{% endif %}
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'meetings')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.meetings" x-transition>
@@ -298,14 +304,16 @@
</div>
<!-- Recents -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('recents')">
<div class="sidebar-section" x-show="isSectionVisible('recents')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('recents')" @contextmenu.prevent="openSectionMenu($event, 'recents')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.recents }" x-text="sectionsOpen.recents ? '▼' : '▶'"></span>
<span>Recents</span>
<span class="chevron" :class="{ open: sectionsOpen.recents }">▶</span>
<span class="section-icon">{{ fd_icon("clock",14) }}</span>
<span class="section-label">Recents</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Recents" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'recents')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.recents" x-transition>
@@ -321,14 +329,16 @@
</div>
<!-- Favorites -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('favorites')">
<div class="sidebar-section" x-show="isSectionVisible('favorites')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('favorites')" @contextmenu.prevent="openSectionMenu($event, 'favorites')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.favorites }" x-text="sectionsOpen.favorites ? '▼' : '▶'"></span>
<span>Favorites</span>
<span class="chevron" :class="{ open: sectionsOpen.favorites }">▶</span>
<span class="section-icon">{{ fd_icon("star",14) }}</span>
<span class="section-label">Favorites</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Favorites" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'favorites')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.favorites" x-transition>
@@ -344,9 +354,17 @@
</div>
<!-- Agents -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('agents')">
<div class="sidebar-section-title"><span>Agents</span></div>
<div class="sidebar-section" x-show="isSectionVisible('agents')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('agents')" @contextmenu.prevent="openSectionMenu($event, 'agents')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.agents }">▶</span>
<span class="section-icon">{{ fd_icon("bot",14) }}</span>
<span class="section-label">Agents</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="New agent" @click.stop="">+</button>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'agents')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.agents" x-transition>
<ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("bot",16) }}</span><span class="page-name text-dim">No agents configured</span></li></ul>
@@ -354,14 +372,16 @@
</div>
<!-- Shared -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('shared')">
<div class="sidebar-section" x-show="isSectionVisible('shared')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('shared')" @contextmenu.prevent="openSectionMenu($event, 'shared')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.shared }" x-text="sectionsOpen.shared ? '▼' : '▶'"></span>
<span>Shared</span>
<span class="chevron" :class="{ open: sectionsOpen.shared }">▶</span>
<span class="section-icon">{{ fd_icon("users",14) }}</span>
<span class="section-label">Shared</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Shared" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'shared')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition>
@@ -377,14 +397,16 @@
</div>
<!-- Published -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('published')">
<div class="sidebar-section" x-show="isSectionVisible('published')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('published')" @contextmenu.prevent="openSectionMenu($event, 'published')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.published }" x-text="sectionsOpen.published ? '▼' : '▶'"></span>
<span>Published</span>
<span class="chevron" :class="{ open: sectionsOpen.published }">▶</span>
<span class="section-icon">{{ fd_icon("globe",14) }}</span>
<span class="section-label">Published</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Published" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'published')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.published" x-transition>
@@ -401,14 +423,16 @@
<!-- Private -->
{% if auth_method != 'local' or '/' in workspace_key %}
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('private')">
<div class="sidebar-section" x-show="isSectionVisible('private')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('private')" @contextmenu.prevent="openSectionMenu($event, 'private')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.private }" x-text="sectionsOpen.private ? '▼' : '▶'"></span>
<span>Private</span>
<span class="chevron" :class="{ open: sectionsOpen.private }">▶</span>
<span class="section-icon">{{ fd_icon("lock",14) }}</span>
<span class="section-label">Private</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Private" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'private')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.private" x-transition>
@@ -424,6 +448,30 @@
</div>
{% endif %}
<!-- Customize Sidebar → shown in customize mode -->
<div class="sidebar-customize-panel" x-show="customizing" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0">
<div class="scp-header">
<span class="scp-title">Customize Sidebar</span>
<button class="scp-done" @click="toggleCustomize()">Done</button>
</div>
<div class="scp-list">
<template x-for="(cfg, key) in (Object.keys(sidebarConfig).length ? sidebarConfig : {
workspace:{visible:true,order:0},meetings:{visible:true,order:1},
recents:{visible:true,order:2},favorites:{visible:true,order:3},
agents:{visible:true,order:4},shared:{visible:true,order:5},published:{visible:true,order:6}
})" :key="key">
<div class="scp-item" @click="toggleSectionVisibility(key)">
<div class="scp-item-left">
<span class="scp-item-icon" x-text="getSectionIcon(key)"></span>
<span class="scp-item-name" x-text="getSectionLabel(key)"></span>
</div>
<span class="scp-item-eye" x-show="cfg.visible !== false">👁</span>
<span class="scp-item-eye scp-item-eye-off" x-show="cfg.visible === false">👁‍🗨</span>
</div>
</template>
</div>
</div>
<!-- Notion apps / Utilities → replaced by static links footer (v4.0.2) -->
<!-- Bottom links (always visible, no section wrapper) -->
<div class="sidebar-static-links" style="padding: 4px 12px;">
@@ -456,6 +504,51 @@
</div>
</div>
</div><!-- /sidebar-scroll -->
<!-- Section options menu (⋮ dropdown) -->
<div class="section-menu-overlay" x-show="sectionMenu.visible" @click="closeSectionMenu()" @contextmenu.prevent="closeSectionMenu()"></div>
<div class="section-menu" x-show="sectionMenu.visible" x-cloak
:style="{ top: sectionMenu.y + 'px', left: Math.min(sectionMenu.x, window.innerWidth - 220) + 'px' }"
@click.outside="closeSectionMenu()">
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 5)">
<span class="smi-label">Show 5 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 5">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 10)">
<span class="smi-label">Show 10 items</span>
<span class="smi-check" x-show="!sidebarConfig[sectionMenu.section] || sidebarConfig[sectionMenu.section].show_count === 10 || sidebarConfig[sectionMenu.section].show_count == null">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 15)">
<span class="smi-label">Show 15 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 15">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 20)">
<span class="smi-label">Show 20 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 20">✓</span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'up')">
<span class="smi-icon">↑</span>
<span class="smi-label">Move up</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'down')">
<span class="smi-icon">↓</span>
<span class="smi-label">Move down</span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item" @click="closeSectionMenu(); toggleSectionVisibility(sectionMenu.section)">
<span class="smi-icon">👁</span>
<span class="smi-label" x-text="isSectionVisible(sectionMenu.section) ? 'Hide section' : 'Show section'"></span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item section-menu-customize" @click="closeSectionMenu(); toggleCustomize()">
<span class="smi-icon">⚙️</span>
<span class="smi-label">Customize sidebar</span>
</div>
</div>
<!-- Customize sidebar overlay + panel (full sidebar takeover) -->
<div class="customize-overlay" x-show="customizing" @click="toggleCustomize()" x-cloak></div>
</aside>
<!-- Uncollapse button → inside topbar, no overlap -->
@@ -832,6 +925,7 @@
return {
init() {
window.appState = this;
this.loadSidebarConfig();
},
sidebarCollapsed: false,
mobileSidebarOpen: false,
@@ -993,6 +1087,122 @@
try { localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen)); }
catch(e) {}
},
// ── Sidebar customization ──
customizing: false,
sidebarConfig: {},
sectionMenu: { section: null, visible: false, x: 0, y: 0 },
loadSidebarConfig() {
var self = this;
try {
var raw = {{ sidebar_config|default('{}')|safe }};
if (raw && raw.config) {
self.sidebarConfig = raw.config;
} else if (typeof raw === 'object') {
self.sidebarConfig = raw;
}
} catch(e) {
self.sidebarConfig = {};
}
},
saveSidebarConfig() {
var self = this;
fetch('/api/sidebar/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.FlowDeck.getCsrfToken() },
body: JSON.stringify({ config: this.sidebarConfig })
}).then(function(r) {
if (!r.ok) self.toast('Failed to save sidebar config', 'error');
}).catch(function() {
self.toast('Failed to save sidebar config', 'error');
});
},
isSectionVisible(key) {
var cfg = this.sidebarConfig[key];
return cfg ? cfg.visible !== false : true;
},
getSectionOrder(key) {
var cfg = this.sidebarConfig[key];
var sections = ['workspace','gitea','meetings','recents','favorites','agents','shared','published'];
if (cfg && typeof cfg.order === 'number') return cfg.order;
return sections.indexOf(key);
},
toggleSectionVisibility(key) {
if (!this.sidebarConfig[key]) this.sidebarConfig[key] = {};
this.sidebarConfig[key].visible = !this.isSectionVisible(key);
this.sidebarConfig = Object.assign({}, this.sidebarConfig);
this.saveSidebarConfig();
},
moveSection(key, direction) {
var self = this;
var sections = Object.keys(this.sidebarConfig).length ?
Object.keys(this.sidebarConfig).sort(function(a,b) {
return (self.sidebarConfig[a] && self.sidebarConfig[a].order || 99) -
(self.sidebarConfig[b] && self.sidebarConfig[b].order || 99);
}) :
['workspace','gitea','meetings','recents','favorites','agents','shared','published'];
var idx = sections.indexOf(key);
if (idx < 0) return;
var swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= sections.length) return;
var swapKey = sections[swapIdx];
var aOrder = this.sidebarConfig[key] && this.sidebarConfig[key].order;
var bOrder = this.sidebarConfig[swapKey] && this.sidebarConfig[swapKey].order;
if (aOrder == null) aOrder = idx;
if (bOrder == null) bOrder = swapIdx;
if (!this.sidebarConfig[key]) this.sidebarConfig[key] = {};
if (!this.sidebarConfig[swapKey]) this.sidebarConfig[swapKey] = {};
this.sidebarConfig[key].order = bOrder;
this.sidebarConfig[swapKey].order = aOrder;
this.sidebarConfig = Object.assign({}, this.sidebarConfig);
this.saveSidebarConfig();
},
setShowCount(key, count) {
if (!this.sidebarConfig[key]) this.sidebarConfig[key] = {};
this.sidebarConfig[key].show_count = count;
this.sidebarConfig = Object.assign({}, this.sidebarConfig);
this.saveSidebarConfig();
},
toggleCustomize() {
this.customizing = !this.customizing;
this.sectionMenu.visible = false;
},
openSectionMenu(ev, section) {
ev.stopPropagation();
this.sectionMenu = { section: section, visible: true, x: ev.clientX, y: ev.clientY };
},
closeSectionMenu() {
this.sectionMenu.visible = false;
},
getSectionIcon(key) {
var icons = {
workspace: '📁', meetings: '📅', recents: '🕐', favorites: '⭐',
agents: '🤖', shared: '👥', published: '🌐', gitea: '🔗',
private: '🔒', library: '📚', 'my-tasks': '✅', marketplace: '🛒',
help: '❓', trash: '🗑️'
};
return icons[key] || '📋';
},
getSectionLabel(key) {
var labels = {
workspace: 'Workspace', gitea: 'Repository (Gitea)', meetings: 'Meetings',
recents: 'Recents', favorites: 'Favorites', agents: 'Agents',
shared: 'Shared', published: 'Published', private: 'Private',
library: 'Library', 'my-tasks': 'My Tasks', marketplace: 'Marketplace',
help: 'Help', trash: 'Trash'
};
return labels[key] || key;
},
workspaceMenuOpen: false,
toggleWorkspaceMenu() {
this.workspaceMenuOpen = !this.workspaceMenuOpen;
+1
View File
@@ -7,6 +7,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% endblock %} {% block content %}
{% include "_page_editor_content.html" %}
{% endblock %} {% block scripts %}
<script src="/static/js/katex.min.js?v=0.16.11" defer></script>
{% include "_page_editor_scripts.html" %}
{% include "_database_table_scripts.html" %}
{% endblock %}
+16
View File
@@ -2,6 +2,7 @@
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
{% from '_icons.html' import fd_icon %}
<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">
@@ -149,5 +150,20 @@
Made with <a href="/">FlowDeck</a> — a Notion-style workspace
</footer>
<script src="/static/js/katex.min.js?v=0.16.11"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
if (!window.katex) return;
document.querySelectorAll('div[data-katex]').forEach(function (el) {
var tex = el.getAttribute('data-katex') || '';
try {
el.innerHTML = window.katex.renderToString(tex, { displayMode: true, throwOnError: false });
} catch (e) {
el.textContent = tex;
}
});
});
</script>
</body>
</html>
+429 -9
View File
@@ -380,39 +380,85 @@ input, select, textarea { font-family: inherit; color: inherit; }
justify-content: space-between;
padding: 4px 6px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition);
user-select: none;
}
.sidebar-section-header:hover {
background: var(--bg-hover);
}
.sidebar-section-header:active {
background: var(--bg-active);
transform: scale(0.995);
}
.sidebar-section-title {
display: flex;
align-items: center;
gap: var(--space-xs);
gap: 3px;
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
text-transform: none;
letter-spacing: 0;
min-width: 0;
}
.sidebar-section-title .chevron {
font-size: 10px;
transition: transform var(--transition);
font-size: 8px;
width: 14px;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-muted);
transition: transform 180ms ease, color var(--transition);
will-change: transform;
}
.sidebar-section-title .chevron.open {
transform: rotate(90deg);
color: var(--text-secondary);
}
.sidebar-section-title .section-icon {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-muted);
transition: color var(--transition);
}
.sidebar-section-header:hover .section-icon {
color: var(--text-secondary);
}
.sidebar-section-title .section-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-dim);
transition: color var(--transition);
}
.sidebar-section-header:hover .section-label {
color: var(--text-secondary);
}
.sidebar-section-actions {
display: flex;
align-items: center;
gap: 2px;
gap: 1px;
opacity: 0;
transition: opacity var(--transition);
transition: opacity 120ms ease;
}
.sidebar-section-header:hover .sidebar-section-actions {
@@ -426,15 +472,185 @@ input, select, textarea { font-family: inherit; color: inherit; }
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
color: var(--text-muted);
font-size: 14px;
transition: background var(--transition);
background: none;
border: none;
cursor: pointer;
transition: background var(--transition), color var(--transition);
}
.section-action-btn:hover {
background: var(--bg-tertiary);
color: var(--text);
}
/* Section menu (⋮) button */
.section-menu-btn {
font-size: 16px;
font-weight: 700;
letter-spacing: 1px;
opacity: 0.6;
}
.section-menu-btn:hover {
opacity: 1;
}
/* Section menu dropdown */
.section-menu-overlay {
position: fixed;
inset: 0;
z-index: 999;
}
.section-menu {
position: fixed;
z-index: 1000;
min-width: 200px;
background: var(--bg-modal);
border: 1px solid var(--border);
border-radius: 8px;
padding: 4px;
box-shadow: 0 8px 24px rgba(0,0,0,0.3), 0 2px 6px rgba(0,0,0,0.15);
}
.section-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
color: var(--text);
transition: background 120ms;
}
.section-menu-item:hover {
background: var(--bg-hover);
}
.section-menu-item .smi-icon {
width: 18px;
text-align: center;
font-size: 14px;
flex-shrink: 0;
}
.section-menu-item .smi-label {
flex: 1;
}
.section-menu-item .smi-check {
color: var(--accent);
font-size: 14px;
}
.section-menu-sep {
height: 1px;
background: var(--border);
margin: 4px 8px;
}
.section-menu-customize {
border-top: 1px solid var(--border);
margin-top: 2px;
padding-top: 8px;
}
/* Customize Sidebar Panel */
.sidebar-customize-panel {
padding: 8px 12px;
}
.scp-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding: 4px 0;
}
.scp-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.scp-done {
font-size: 13px;
color: var(--accent);
background: none;
border: none;
cursor: pointer;
padding: 4px 12px;
border-radius: 6px;
transition: background 120ms;
font-weight: 500;
}
.scp-done:hover {
background: var(--bg-hover);
}
.scp-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.scp-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 120ms;
}
.scp-item:hover {
background: var(--bg-hover);
}
.scp-item-left {
display: flex;
align-items: center;
gap: 8px;
}
.scp-item-icon {
font-size: 16px;
width: 22px;
text-align: center;
flex-shrink: 0;
}
.scp-item-name {
font-size: 13px;
color: var(--text);
}
.scp-item-eye {
font-size: 16px;
opacity: 0.7;
}
.scp-item-eye-off {
opacity: 0.3;
}
.customize-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
z-index: 998;
}
/* x-cloak for Alpine.js */
[x-cloak] { display: none !important; }
/* Sidebar items (pages) */
.sidebar-items {
list-style: none;
@@ -2197,6 +2413,12 @@ button.breadcrumb-current { max-width: 260px; }
/* Toggle */
.block-toggle {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 2px;
}
.toggle-title-row {
display: flex;
align-items: flex-start;
gap: 4px;
@@ -2221,7 +2443,7 @@ button.breadcrumb-current { max-width: 260px; }
.toggle-chevron:hover {
background: var(--bg-hover);
}
.block-toggle div {
.block-toggle .toggle-title {
flex: 1;
outline: none;
font-size: 16px;
@@ -2289,18 +2511,216 @@ button.breadcrumb-current { max-width: 260px; }
padding: 14px 16px;
border-radius: 4px;
margin: 4px 0;
position: relative;
}
.block-callout .callout-icon {
font-size: 20px;
flex-shrink: 0;
cursor: pointer;
transition: transform .1s ease;
}
.block-callout div {
.block-callout .callout-icon:hover {
transform: scale(1.1);
}
.block-callout > div[data-bid] {
flex: 1;
outline: none;
font-size: 16px;
line-height: 1.6;
min-height: 24px;
}
.callout-icon-picker {
position: absolute;
top: calc(100% + 4px);
left: 12px;
z-index: 50;
display: flex;
flex-wrap: wrap;
gap: 2px;
width: 236px;
max-width: 90%;
padding: 8px;
background: var(--bg-modal);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow-modal);
}
.callout-icon-opt {
background: transparent;
border: none;
font-size: 18px;
width: 34px;
height: 34px;
border-radius: 5px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.callout-icon-opt:hover {
background: var(--bg-hover);
}
/* Toggle children */
.toggle-children {
margin-left: 22px;
padding-left: 12px;
border-left: 1px solid var(--border);
margin-top: 2px;
}
/* Table of Contents */
.block-toc {
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 20px;
margin: 4px 0;
}
.block-toc-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .5px;
color: var(--text-dim);
margin-bottom: 10px;
}
.block-toc-item {
font-size: 14px;
color: var(--text-primary);
padding: 5px 8px;
border-radius: 4px;
cursor: pointer;
line-height: 1.4;
}
.block-toc-item:hover {
background: var(--bg-hover);
color: var(--accent);
}
.block-toc-empty {
color: var(--text-dim);
font-size: 14px;
font-style: italic;
padding: 12px 16px;
}
/* Math block */
.block-math {
margin: 8px 0;
}
.math-display {
min-height: 40px;
padding: 10px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
overflow-x: auto;
font-size: 15px;
}
.math-editor {
width: 100%;
margin-top: 6px;
padding: 8px 10px;
background: var(--bg-tertiary);
border: 1px dashed var(--border);
border-radius: 6px;
color: var(--text-primary);
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 13px;
outline: none;
resize: vertical;
min-height: 40px;
box-sizing: border-box;
}
.math-editor:focus {
border-color: var(--accent);
}
/* Column list */
.block-columns {
margin: 8px 0 16px;
}
.columns-add-bar {
margin-bottom: 4px;
}
.columns-add-btn {
background: transparent;
border: 1px dashed var(--border);
color: var(--text-dim);
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: border-color .15s, color .15s;
}
.columns-add-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.columns-row {
display: flex;
gap: 12px;
align-items: stretch;
}
.column-cell {
flex: 1;
min-width: 0;
}
.block-column-wrapper {
position: relative;
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
height: 100%;
box-sizing: border-box;
}
.block-column-wrapper:hover .block-handle {
opacity: 1;
}
.block-column-wrapper .block-actions {
right: -8px;
top: -8px;
}
.block-column-wrapper .block-add-btn {
background: var(--bg-modal);
border: 1px solid var(--border);
width: 20px;
height: 20px;
font-size: 13px;
border-radius: 50%;
line-height: 1;
}
.block-column {
outline: none;
min-height: 24px;
font-size: 15px;
line-height: 1.6;
color: var(--text-primary);
word-break: break-word;
}
.block-columns .block-wrapper {
margin: 0;
background: transparent;
}
.block-columns .block-handle {
opacity: 0;
}
.block-nested {
outline: none;
min-height: 24px;
font-size: 15px;
line-height: 1.6;
color: var(--text-primary);
word-break: break-word;
}
.nested-block {
margin: 2px 0;
}
.nested-block .block-handle {
display: none;
}
/* Image block */
.block-image-wrapper {
+1
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
File diff suppressed because one or more lines are too long
+112 -4
View File
@@ -619,9 +619,9 @@ def test_views_calendar(client):
r = client.post("/db/api", json={"name": "Cal DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Event 1", "properties": {"date": "2026-07-15"}})
resp = client.get(f"/db/{cid}/view/calendar")
resp = client.get(f"/db/{cid}/view/calendar?year=2026&month=7")
assert resp.status_code == 200
assert "July" in resp.text or "juillet" in resp.text
assert "July 2026" in resp.text or "juillet 2026" in resp.text
client.delete(f"/db/api/{cid}")
@@ -674,7 +674,7 @@ def test_views_calendar_navigation(client):
cid = r.json()["id"]
resp = client.get(f"/db/{cid}/view/calendar?year=2026&month=8")
assert resp.status_code == 200
assert "August" in resp.text or "août" in resp.text
assert "August 2026" in resp.text or "août 2026" in resp.text
client.delete(f"/db/api/{cid}")
@@ -2757,4 +2757,112 @@ def test_my_tasks_page_render(client):
resp = client.get("/my-tasks?view=all")
assert resp.status_code == 200
assert "Urgent fix" in resp.text
assert "My Tasks DB" in resp.text
assert "My Tasks DB" in resp.text
# ── v4.6.0: Content Blocks Enriched ──
def _make_published_page(client, blocks, title="Enriched Page"):
"""Create a user + published page directly in DB with blocks, return public HTML."""
import uuid
import json as _json
from app.db import get_conn
login = "v460_" + uuid.uuid4().hex[:10]
slug = "v460-" + uuid.uuid4().hex[:8]
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES (?, 'V460', ?)",
(login, login + "@t.com"))
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, is_published, publish_slug) "
"VALUES (?, ?, ?, 'blocks', 1, ?)",
(login, title, _json.dumps(blocks), slug),
)
pid = cur.lastrowid
conn.commit()
resp = client.get(f"/p/{slug}")
with get_conn() as conn:
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
conn.commit()
return resp
def test_v460_public_table_of_contents(client):
"""Table of contents block renders a list of page headings."""
blocks = [
{"id": "h1", "type": "heading_1", "content": "Overview"},
{"id": "b1", "type": "table_of_contents", "content": ""},
{"id": "h2", "type": "heading_2", "content": "Installation"},
]
resp = _make_published_page(client, blocks)
assert resp.status_code == 200
assert "On this page" in resp.text
assert "Overview" in resp.text
assert "Installation" in resp.text
def test_v460_public_math_block(client):
"""Math block renders a KaTeX-display marker with the LaTeX source."""
blocks = [
{"id": "m1", "type": "math", "content": "E = mc^2"},
]
resp = _make_published_page(client, blocks)
assert resp.status_code == 200
assert 'data-katex="E = mc^2"' in resp.text
def test_v460_public_columns(client):
"""Column blocks render children in a flex row."""
blocks = [
{"id": "cb", "type": "columns",
"children": [
{"id": "c1", "type": "paragraph", "content": "Left cell"},
{"id": "c2", "type": "paragraph", "content": "Right cell"},
]},
]
resp = _make_published_page(client, blocks)
assert resp.status_code == 200
assert "Left cell" in resp.text
assert "Right cell" in resp.text
def test_v460_public_toggle_children(client):
"""Toggle block renders nested children."""
blocks = [
{"id": "tg", "type": "toggle", "content": "Details", "expanded": True,
"children": [{"id": "t1", "type": "bulleted_list", "content": "Nested item"}]},
]
resp = _make_published_page(client, blocks)
assert resp.status_code == 200
assert "Details" in resp.text
assert "Nested item" in resp.text
def test_v460_save_load_blocks_preserves_children(client):
"""Blocks API round-trip preserves children for columns and toggles."""
from app.db import get_conn
import json as _json
r = client.post("/board/api/pages?title=Block RT&section=Private&project=test/test")
pid = r.json()["id"]
client.post(f"/board/api/pages/{pid}/blocks", json={
"title": "Block RT",
"blocks": [
{"id": "cb", "type": "columns", "children": [
{"id": "c1", "type": "paragraph", "content": "A"},
{"id": "c2", "type": "paragraph", "content": "B"},
]},
{"id": "tg", "type": "toggle", "content": "T", "children": [
{"id": "t1", "type": "paragraph", "content": "X"},
]},
{"id": "mt", "type": "math", "content": "x^2"},
],
})
with get_conn() as conn:
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", (pid,)).fetchone()
assert row["content_format"] == "blocks"
data = _json.loads(row["content"])
types = {b["type"] for b in data}
assert "columns" in types and "toggle" in types and "math" in types
col = next(b for b in data if b["type"] == "columns")
assert len(col["children"]) == 2