feat(content): v4.6.0 Content Blocks enrichis — Callout, TOC, Math (KaTeX), Toggle, Multi-colonnes
- 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:
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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 = ""):
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
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)")
|
||||
|
||||
@@ -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}
|
||||
@@ -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,'<').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 `<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
@@ -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;
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user