- Ajout flag has_active_workspace dans _sidebar_data (dashboard.py + board.py)
- Template base.html: quand has_active_workspace=False:
- Titre section → '📁 No workspace open'
- Boutons New File/New Folder cachés
- Message 'Open a workspace to see your files'
- Guard JS newPageInWorkspace()/newFolderInWorkspace(): toast + redirect si pas de workspace
- 143 tests passent
1925 lines
76 KiB
Python
1925 lines
76 KiB
Python
"""FlowDeck — Dashboard: liste des projets Gitea + sidebar data."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Request, Query
|
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
|
|
|
from app.services.gitea_client import gitea, get_user_gitea_client
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["dashboard"])
|
|
|
|
|
|
def _get_user_or_redirect(request: Request):
|
|
"""Return decoded user or a RedirectResponse to login page.
|
|
Skips redirect when DB has no users (fresh install / test env)."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
# Allow through if no users exist yet (fresh install / tests)
|
|
try:
|
|
with get_conn() as conn:
|
|
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
|
if count == 0:
|
|
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
|
|
except Exception:
|
|
pass
|
|
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
|
|
return user
|
|
|
|
|
|
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict:
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
ws = user.get("login", "Bruno") if user else "Bruno"
|
|
initial = ws[0].upper() if ws else "B"
|
|
|
|
# Get avatar info from DB
|
|
avatar_url = ""
|
|
avatar_color = "#3A3A3A"
|
|
if user:
|
|
try:
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT avatar_url, avatar_color FROM users WHERE id = ?", (user["id"],)).fetchone()
|
|
if row:
|
|
avatar_url = row["avatar_url"] or ""
|
|
avatar_color = row["avatar_color"] or "#3A3A3A"
|
|
except Exception:
|
|
pass
|
|
|
|
recent_pages = []
|
|
for repo in repos[:10]:
|
|
full_name = repo.get("full_name", "")
|
|
recent_pages.append({
|
|
"id": full_name,
|
|
"name": repo.get("name", full_name),
|
|
"icon": "📁",
|
|
"url": f"/board/{full_name}",
|
|
"active": False,
|
|
"indent": 0,
|
|
"depth": 0,
|
|
"has_children": False,
|
|
"children": [],
|
|
})
|
|
|
|
# Active workspace from cookie (skip on pages like /workspaces where no
|
|
# workspace context should be shown)
|
|
from app.routers.board import _load_workspace_pages
|
|
ws_cookie = request.cookies.get("flowdeck_workspace", "")
|
|
active_ws_name = "Workspace"
|
|
workspace_pages = []
|
|
gitea_workspace = False
|
|
gitea_owner = ""
|
|
gitea_repo = ""
|
|
has_active_workspace = False
|
|
|
|
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
|
|
parts = ws_cookie.split(":", 2)
|
|
if len(parts) >= 3:
|
|
gitea_owner = parts[1]
|
|
gitea_repo = parts[2]
|
|
active_ws_name = f"{gitea_owner}/{gitea_repo}"
|
|
gitea_workspace = True
|
|
has_active_workspace = True
|
|
# workspace_pages stays as-is (local tree, loaded above)
|
|
elif include_workspace and ws_cookie and user:
|
|
try:
|
|
wsi = int(ws_cookie)
|
|
with get_conn() as conn:
|
|
# Verify this workspace belongs to the current user
|
|
row = conn.execute(
|
|
"SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?",
|
|
(wsi, user["id"])
|
|
).fetchone()
|
|
if row:
|
|
active_ws_name = row["name"]
|
|
workspace_pages = _load_workspace_pages(ws_cookie)
|
|
has_active_workspace = True
|
|
# else: stale cookie from another user — ignore
|
|
except (ValueError, Exception):
|
|
pass
|
|
|
|
# Auth method & OAuth badge data
|
|
auth_method = "local"
|
|
gitea_linked = False
|
|
github_linked = False
|
|
if user and user.get("id"):
|
|
try:
|
|
with get_conn() as conn:
|
|
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
|
|
if am_row and am_row["auth_method"]:
|
|
auth_method = am_row["auth_method"]
|
|
tokens = conn.execute(
|
|
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
|
).fetchall()
|
|
for t in tokens:
|
|
if t["provider"] == "gitea":
|
|
gitea_linked = True
|
|
elif t["provider"] == "github":
|
|
github_linked = True
|
|
except Exception:
|
|
pass
|
|
|
|
# Get local workspace ID for Gitea workspace mirror
|
|
local_ws_id = 0
|
|
if gitea_workspace and gitea_owner and gitea_repo:
|
|
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"]
|
|
except Exception:
|
|
pass
|
|
|
|
# Private pages for mirror workspace (when Gitea remote active)
|
|
private_pages = []
|
|
if gitea_workspace and local_ws_id:
|
|
try:
|
|
with get_conn() as conn:
|
|
pp_rows = conn.execute(
|
|
"SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20",
|
|
(local_ws_id,)
|
|
).fetchall()
|
|
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"workspace_name": ws, "workspace_initial": initial,
|
|
"active_ws_name": active_ws_name,
|
|
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "",
|
|
"gitea_workspace": gitea_workspace,
|
|
"gitea_owner": gitea_owner,
|
|
"gitea_repo": gitea_repo,
|
|
"local_ws_id": local_ws_id,
|
|
"workspace_pages": workspace_pages,
|
|
"current_page": "Dashboard", "last_edited": "now",
|
|
"recent_pages": recent_pages,
|
|
"private_pages": private_pages,
|
|
"favorite_pages": [],
|
|
"shared_pages": [],
|
|
"published_pages": [],
|
|
"user": user,
|
|
"avatar_url": avatar_url,
|
|
"avatar_color": avatar_color,
|
|
"auth_method": auth_method,
|
|
"gitea_linked": gitea_linked,
|
|
"github_linked": github_linked,
|
|
"has_active_workspace": has_active_workspace,
|
|
}
|
|
|
|
|
|
@router.get("/trash", response_class=HTMLResponse)
|
|
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
|
"""Trash page — scoped to workspace if owner/repo provided."""
|
|
from jinja2 import Environment, FileSystemLoader
|
|
from app.routers.board import _sidebar_data as board_sidebar
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
sidebar = board_sidebar(request, owner, repo)
|
|
with get_conn() as conn:
|
|
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
|
# Pages are soft-deleted via parent_section='Trash'
|
|
if ws_key:
|
|
rows = conn.execute(
|
|
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC",
|
|
(ws_key,),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC",
|
|
).fetchall()
|
|
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows]
|
|
# Pass active workspace for breadcrumb nav menu
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
sidebar["nav_workspace_id"] = ws["id"] if ws else 0
|
|
template = env.get_template("trash.html")
|
|
return template.render(**sidebar)
|
|
|
|
|
|
@router.get("/library", response_class=HTMLResponse)
|
|
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
|
"""Library page — tabbed view (recents, favorites, shared, published, private, workspace).
|
|
|
|
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
|
"""
|
|
from jinja2 import Environment, FileSystemLoader
|
|
from app.routers.board import _sidebar_data as board_sidebar
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
sidebar = board_sidebar(request, owner, repo)
|
|
# Pass active workspace for breadcrumb nav menu
|
|
ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
|
|
if ws_key_ws:
|
|
with get_conn() as conn:
|
|
ws_row = conn.execute("SELECT id FROM workspaces WHERE name=? AND owner_id=?", (ws_key_ws, _get_user_id(request))).fetchone()
|
|
sidebar["nav_workspace_id"] = ws_row["id"] if ws_row else 0
|
|
else:
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
sidebar["nav_workspace_id"] = ws["id"] if ws else 0
|
|
|
|
template = env.get_template("library.html")
|
|
sidebar["active_workspace_id"] = sidebar.get("nav_workspace_id", 0)
|
|
# Gitea workspace context for Repository tab
|
|
sidebar["is_gitea_workspace"] = bool(owner and repo)
|
|
sidebar["gitea_workspace_owner"] = owner
|
|
sidebar["gitea_workspace_repo"] = repo
|
|
return template.render(**sidebar)
|
|
|
|
|
|
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
|
async def view_page_root(request: Request, page_id: int):
|
|
"""Render a Markdown page at root level with workspace context — or file viewer."""
|
|
from jinja2 import Environment, FileSystemLoader
|
|
from app.routers.board import _sidebar_data as board_sidebar
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
|
if not row:
|
|
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
|
page = dict(row)
|
|
|
|
ws = page.get("workspace", "")
|
|
parts = ws.split("/") if "/" in ws else ["", ""]
|
|
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
|
|
sidebar = board_sidebar(request, owner, repo)
|
|
# Load sub-pages
|
|
with get_conn() as conn:
|
|
subs = conn.execute(
|
|
"SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC",
|
|
(page_id,),
|
|
).fetchall()
|
|
fav = conn.execute(
|
|
"SELECT id FROM favorites WHERE user_id=? AND page_id=?",
|
|
(1, page_id),
|
|
).fetchone()
|
|
|
|
# Build page_data, including file metadata for uploaded files
|
|
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))}
|
|
|
|
# For file pages, extract file metadata and add to page_data
|
|
if page.get("content_format") == "file":
|
|
import json as _json
|
|
try:
|
|
meta = _json.loads(page.get("content", "{}"))
|
|
except _json.JSONDecodeError:
|
|
meta = {}
|
|
file_path = meta.get("file_path", "").replace("\\", "/")
|
|
mime_type = meta.get("mime_type", "application/octet-stream")
|
|
file_size = meta.get("size", 0)
|
|
fp_parts = file_path.split("/")
|
|
ws_id = ""
|
|
for p in fp_parts:
|
|
if p.startswith("workspace_"):
|
|
ws_id = p.replace("workspace_", "")
|
|
break
|
|
filename = fp_parts[-1] if fp_parts else page.get("title", "File")
|
|
file_url = f"/api/files/{ws_id}/{filename}" if ws_id else ""
|
|
page_data["file_url"] = file_url
|
|
page_data["file_mime"] = mime_type
|
|
page_data["file_size"] = file_size
|
|
page_data["file_name"] = filename
|
|
|
|
with get_conn() as conn:
|
|
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
|
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
|
|
"page_favorited": fav is not None,
|
|
"page_share_mode": page.get("share_mode", "private"),
|
|
"page_published": bool(page.get("published", 0)),
|
|
"page_data": page_data,
|
|
"breadcrumb_items": nav_crumbs,
|
|
"nav_workspace_id": page.get("workspace_id") or 0,
|
|
"nav_page_id": page_id}
|
|
template = env.get_template("page_editor.html")
|
|
response = template.render(**ctx)
|
|
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
|
|
|
|
|
|
@router.get("/accounts", response_class=HTMLResponse)
|
|
async def accounts_page(request: Request):
|
|
"""Account management panel."""
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
sidebar = _sidebar_data(request, [])
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
with get_conn() as conn:
|
|
users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall()
|
|
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
|
|
template = env.get_template("accounts.html")
|
|
return template.render(**ctx)
|
|
|
|
|
|
@router.get("/accounts/settings", response_class=HTMLResponse)
|
|
async def settings_page(request: Request):
|
|
"""User settings page — profile, forges, tokens."""
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
sidebar = _sidebar_data(request, [])
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
from fastapi.responses import RedirectResponse
|
|
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
|
# Check forge connections
|
|
gitea_connected = False
|
|
github_connected = False
|
|
if user.get("id"):
|
|
with get_conn() as conn:
|
|
tokens = conn.execute(
|
|
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
|
|
).fetchall()
|
|
for t in tokens:
|
|
if t["provider"] == "gitea":
|
|
gitea_connected = True
|
|
elif t["provider"] == "github":
|
|
github_connected = True
|
|
ctx = {**sidebar, "user": user, "gitea_connected": gitea_connected, "github_connected": github_connected}
|
|
template = env.get_template("settings.html")
|
|
response = template.render(**ctx)
|
|
return HTMLResponse(content=response)
|
|
|
|
|
|
# ═══════════ User API endpoints ═══════════
|
|
|
|
def _get_user_id(request: Request) -> int:
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
return user["id"] if user and user.get("id") else 1
|
|
|
|
|
|
@router.put("/api/user/profile")
|
|
async def update_profile(request: Request):
|
|
import json
|
|
body = await request.json()
|
|
full_name = body.get("full_name", "").strip()
|
|
uid = _get_user_id(request)
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.put("/api/user/password")
|
|
async def update_password(request: Request):
|
|
import json
|
|
from app.password_utils import hash_password
|
|
body = await request.json()
|
|
password = body.get("password", "").strip()
|
|
if len(password) < 6:
|
|
return {"error": "Password must be at least 6 characters"}
|
|
uid = _get_user_id(request)
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/api/user/token")
|
|
async def generate_token(request: Request):
|
|
import secrets
|
|
uid = _get_user_id(request)
|
|
token = secrets.token_hex(32)
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
|
|
(uid, token),
|
|
)
|
|
conn.commit()
|
|
return {"token": f"fd_{token}"}
|
|
|
|
|
|
@router.delete("/api/user/forge/{provider}")
|
|
async def disconnect_forge(request: Request, provider: str):
|
|
uid = _get_user_id(request)
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider)
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
async def dashboard(
|
|
request: Request,
|
|
search: str = Query(default=""),
|
|
show_archived: bool = Query(default=False),
|
|
):
|
|
"""Smart root route: landing for visitors, local workspace for new users, dashboard for Gitea users."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
|
|
# ── Not authenticated → show landing page ──
|
|
if not user:
|
|
# Allow through if DB is empty (fresh install)
|
|
try:
|
|
with get_conn() as conn:
|
|
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
|
if count == 0:
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
template = env.get_template("landing.html")
|
|
return template.render()
|
|
except Exception:
|
|
pass
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
template = env.get_template("landing.html")
|
|
return template.render()
|
|
|
|
# ── Authenticated ──
|
|
user_id = user.get("id", 1)
|
|
has_gitea = False
|
|
try:
|
|
with get_conn() as conn:
|
|
tok = conn.execute(
|
|
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'",
|
|
(user_id,),
|
|
).fetchone()
|
|
has_gitea = bool(tok)
|
|
except Exception:
|
|
pass
|
|
|
|
if not has_gitea:
|
|
# Check if user has any workspace
|
|
try:
|
|
with get_conn() as conn:
|
|
ws_count = conn.execute(
|
|
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,)
|
|
).fetchone()[0]
|
|
if ws_count == 0:
|
|
return RedirectResponse("/workspaces", status_code=302)
|
|
except Exception:
|
|
pass
|
|
return RedirectResponse("/local-workspace", status_code=302)
|
|
|
|
# ── Gitea user → full dashboard ──
|
|
try:
|
|
repos = await gitea.get_user_repos(page=1, limit=50)
|
|
if search:
|
|
q = search.lower()
|
|
repos = [r for r in repos if q in r.get("full_name", "").lower()
|
|
or q in (r.get("description") or "").lower()]
|
|
if not show_archived:
|
|
repos = [r for r in repos if not r.get("archived", False)]
|
|
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
|
|
except Exception as e:
|
|
logger.error("Dashboard error: %s", e)
|
|
repos = []
|
|
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
sidebar = _sidebar_data(request, repos)
|
|
template = env.get_template("dashboard.html")
|
|
return template.render(request=request, repos=repos, search=search,
|
|
show_archived=show_archived, **sidebar)
|
|
|
|
|
|
# ═══════════ Workspace ═══════════
|
|
|
|
@router.get("/workspace", response_class=HTMLResponse)
|
|
async def workspace_page(request: Request):
|
|
"""Unified workspace showing all projects."""
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
sidebar = _sidebar_data(request, [])
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
|
ctx = {**sidebar, "user": user}
|
|
# Pass active workspace for breadcrumb nav menu
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ctx["nav_workspace_id"] = ws["id"] if ws else 0
|
|
template = env.get_template("workspace.html")
|
|
return template.render(**ctx)
|
|
|
|
|
|
@router.get("/gitea-workspace", response_class=HTMLResponse)
|
|
async def gitea_workspace_page(request: Request):
|
|
"""Gitea workspace — browse repo files."""
|
|
import json
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
sidebar = _sidebar_data(request, [])
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
|
owner = request.query_params.get("owner", "")
|
|
repo = request.query_params.get("repo", "")
|
|
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
|
ws_name = ws_key or "Gitea Workspace"
|
|
# Auto-create local workspace mirror for storing local files
|
|
local_ws_id = None
|
|
if ws_key:
|
|
with get_conn() as conn:
|
|
existing = conn.execute(
|
|
"SELECT id, owner_id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
|
(user["id"], ws_key, "%gitea_repo%")
|
|
).fetchone()
|
|
if existing:
|
|
local_ws_id = existing["id"]
|
|
else:
|
|
c = conn.execute(
|
|
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
|
|
(ws_key, user["id"], json.dumps({"gitea_repo": ws_key, "gitea_owner": owner}))
|
|
)
|
|
local_ws_id = c.lastrowid
|
|
conn.execute(
|
|
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
|
|
(local_ws_id, user["id"], "admin")
|
|
)
|
|
conn.commit()
|
|
ctx = {
|
|
**sidebar,
|
|
"user": user,
|
|
"active_ws_name": ws_name,
|
|
"workspace_key": ws_key,
|
|
"gitea_workspace": True, # always true on this page
|
|
"gitea_owner": owner,
|
|
"gitea_repo": repo,
|
|
"workspace_name": ws_name,
|
|
"workspace_initial": repo[0].upper() if repo else "G",
|
|
"owner": owner,
|
|
"repo": repo,
|
|
"nav_workspace_id": local_ws_id or 0, # for breadcrumb nav menu
|
|
}
|
|
template = env.get_template("gitea_workspace.html")
|
|
resp = HTMLResponse(content=template.render(**ctx))
|
|
resp.set_cookie("flowdeck_workspace", f"gitea:{owner}:{repo}", path="/", samesite="lax")
|
|
return resp
|
|
|
|
|
|
@router.get("/api/workspace/projects")
|
|
async def list_workspace_projects(request: Request):
|
|
"""List all projects: built-in + Gitea + GitHub.
|
|
Uses the user's own Gitea token if connected, not the global admin token."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
|
|
builtin = []
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, title FROM pages WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 20"
|
|
).fetchall()
|
|
for r in rows:
|
|
count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0]
|
|
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": count, "forge": "builtin"})
|
|
|
|
gitea_repos = []
|
|
# Use per-user token if available, otherwise return empty
|
|
user_gitea = get_user_gitea_client(request) if user else None
|
|
if user_gitea:
|
|
try:
|
|
repos = await user_gitea.get_user_repos(page=1, limit=50)
|
|
for repo in repos:
|
|
gitea_repos.append({
|
|
"id": str(repo.get("id", "")),
|
|
"name": repo.get("name", ""),
|
|
"full_name": repo.get("full_name", ""),
|
|
"description": repo.get("description", ""),
|
|
"html_url": repo.get("html_url", ""),
|
|
"language": repo.get("language", ""),
|
|
"forge": "gitea",
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
return {"builtin": builtin, "gitea": gitea_repos, "github": []}
|
|
|
|
|
|
@router.post("/api/workspace/projects")
|
|
async def create_workspace_project(request: Request):
|
|
import json
|
|
body = await request.json()
|
|
name = body.get("name", "").strip()
|
|
if not name:
|
|
return {"error": "Name required"}
|
|
with get_conn() as conn:
|
|
cursor = conn.execute(
|
|
"INSERT INTO pages (workspace, title, content, content_format, parent_section) VALUES ('', ?, '', 'blocks', 'Private')",
|
|
(name,),
|
|
)
|
|
conn.commit()
|
|
pid = cursor.lastrowid
|
|
return {"id": pid, "name": name, "forge": "builtin"}
|
|
|
|
|
|
# ═══════════ Workspace Members API ═══════════
|
|
|
|
@router.get("/api/workspace/{ws_id:int}/members")
|
|
async def list_members(request: Request, ws_id: int):
|
|
"""List all members of a workspace."""
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"""SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at
|
|
FROM workspace_members wm JOIN users u ON u.id = wm.user_id
|
|
WHERE wm.workspace_id=? ORDER BY wm.joined_at""", (ws_id,)
|
|
).fetchall()
|
|
return {"members": [dict(r) for r in rows]}
|
|
|
|
|
|
@router.post("/api/workspace/{ws_id:int}/members")
|
|
async def invite_member(request: Request, ws_id: int):
|
|
"""Invite a user to a workspace by email."""
|
|
import json
|
|
body = await request.json()
|
|
email = body.get("email", "").strip()
|
|
role = body.get("role", "editor")
|
|
if role not in ("owner", "admin", "editor", "viewer"):
|
|
return {"error": "Invalid role"}, 400
|
|
with get_conn() as conn:
|
|
user = conn.execute("SELECT id FROM users WHERE login=? OR email=?", (email, email)).fetchone()
|
|
if not user:
|
|
return {"error": "User not found"}, 404
|
|
try:
|
|
conn.execute(
|
|
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
|
|
(ws_id, user["id"], role),
|
|
)
|
|
conn.commit()
|
|
except Exception:
|
|
return {"error": "Already a member"}, 409
|
|
return {"status": "ok", "user_id": user["id"], "role": role}
|
|
|
|
|
|
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
|
|
async def update_member_role(request: Request, ws_id: int, user_id: int):
|
|
"""Change a member's role."""
|
|
import json
|
|
body = await request.json()
|
|
role = body.get("role", "editor")
|
|
if role not in ("owner", "admin", "editor", "viewer"):
|
|
return {"error": "Invalid role"}
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?",
|
|
(role, ws_id, user_id),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}")
|
|
async def remove_member(request: Request, ws_id: int, user_id: int):
|
|
"""Remove a member from a workspace."""
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
|
(ws_id, user_id),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
|
|
|
@router.get("/local-workspace", response_class=HTMLResponse)
|
|
async def local_workspace_page(request: Request, folder: int = None):
|
|
"""Local workspace page with file/folder tree.
|
|
|
|
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
|
"""
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
sidebar = _sidebar_data(request, [])
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
|
|
|
ws = _get_active_workspace(request, user_id=user["id"])
|
|
ws_id = ws["id"] if ws else None
|
|
|
|
# If no workspace exists for this user, redirect to workspaces page
|
|
if not ws_id:
|
|
return RedirectResponse("/workspaces", status_code=302)
|
|
|
|
# Build breadcrumb if navigating into a folder
|
|
breadcrumb = []
|
|
current_folder_id = folder
|
|
if folder and ws_id:
|
|
with get_conn() as conn:
|
|
breadcrumb = _build_breadcrumb(conn, folder)
|
|
|
|
ctx = {
|
|
**sidebar,
|
|
"user": user,
|
|
"workspace_name": sidebar.get("active_ws_name", "My Workspace"),
|
|
"current_folder_id": current_folder_id or 0,
|
|
"workspace_id": ws_id or 0,
|
|
"nav_workspace_id": ws_id or 0,
|
|
"breadcrumb": breadcrumb,
|
|
}
|
|
template = env.get_template("local_workspace.html")
|
|
return HTMLResponse(
|
|
content=template.render(**ctx),
|
|
headers={
|
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
"Pragma": "no-cache",
|
|
"Expires": "0",
|
|
}
|
|
)
|
|
|
|
|
|
@router.get("/api/local-workspace/tree")
|
|
async def local_workspace_tree(request: Request, folder: int = None):
|
|
"""Return the file/folder tree filtered by active workspace.
|
|
|
|
If ?folder=ID is provided, returns only that folder's children.
|
|
Otherwise returns the full recursive tree from root.
|
|
"""
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ws_id = ws["id"] if ws else None
|
|
if not ws_id:
|
|
return {"tree": [], "breadcrumb": []}
|
|
|
|
with get_conn() as conn:
|
|
if folder:
|
|
# Show only this folder's children + build breadcrumb
|
|
children = _build_tree_children(conn, folder, ws_id)
|
|
breadcrumb = _build_breadcrumb(conn, folder)
|
|
return {"tree": children, "breadcrumb": breadcrumb, "current_folder": folder}
|
|
else:
|
|
# Full tree from root
|
|
roots = _build_tree_children(conn, None, ws_id)
|
|
return {"tree": roots, "breadcrumb": [], "current_folder": None}
|
|
|
|
|
|
@router.get("/api/local-workspace/page-content/{page_id:int}")
|
|
async def get_page_content(page_id: int):
|
|
"""Return the raw content of a page (for preview)."""
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT content, content_format FROM pages WHERE id=?", (page_id,)
|
|
).fetchone()
|
|
if not row:
|
|
return JSONResponse({"error": "Not found"}, status_code=404)
|
|
fmt = row["content_format"]
|
|
if fmt == "file":
|
|
return JSONResponse({"content": "(uploaded file)", "format": fmt})
|
|
return {"content": row["content"] or "", "format": fmt}
|
|
|
|
|
|
@router.get("/api/local-workspace/breadcrumb")
|
|
async def local_workspace_breadcrumb(request: Request, folder: int):
|
|
"""Return breadcrumb trail for a folder."""
|
|
with get_conn() as conn:
|
|
breadcrumb = _build_breadcrumb(conn, folder)
|
|
return {"breadcrumb": breadcrumb}
|
|
|
|
|
|
def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
|
|
"""Recursively build the tree of children for a node."""
|
|
if parent_id is None:
|
|
rows = conn.execute(
|
|
"SELECT id, title, parent_section, content_format, content, "
|
|
"created_at, updated_at FROM pages "
|
|
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL "
|
|
"ORDER BY created_at DESC",
|
|
(ws_id,),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT id, title, parent_section, content_format, content, "
|
|
"created_at, updated_at FROM pages "
|
|
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
|
|
"ORDER BY created_at DESC",
|
|
(parent_id, ws_id),
|
|
).fetchall()
|
|
|
|
# Get workspace owner name for author display
|
|
ws_owner = conn.execute(
|
|
"SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?",
|
|
(ws_id,),
|
|
).fetchone()
|
|
author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—"
|
|
|
|
# Collect all page IDs to fetch tags in one query
|
|
all_ids = [r["id"] for r in rows]
|
|
tags_map = {}
|
|
if all_ids:
|
|
placeholders = ",".join("?" for _ in all_ids)
|
|
tag_rows = conn.execute(
|
|
f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt "
|
|
f"JOIN tags t ON t.id=pt.tag_id WHERE pt.page_id IN ({placeholders})",
|
|
all_ids,
|
|
).fetchall()
|
|
for tr in tag_rows:
|
|
tags_map.setdefault(tr["page_id"], []).append({
|
|
"id": tr["id"], "name": tr["name"], "color": tr["color"],
|
|
})
|
|
|
|
tree = []
|
|
for r in rows:
|
|
is_folder = r["parent_section"] == "Workspace"
|
|
children = _build_tree_children(conn, r["id"], ws_id)
|
|
|
|
# Compute size
|
|
size = 0
|
|
if r["content_format"] == "file":
|
|
import json as _json
|
|
try:
|
|
meta = _json.loads(r["content"])
|
|
size = meta.get("size", 0)
|
|
except Exception:
|
|
size = len(r["content"] or "")
|
|
else:
|
|
size = len(r["content"] or "")
|
|
|
|
tree.append({
|
|
"id": r["id"],
|
|
"name": r["title"] or "Untitled",
|
|
"type": "folder" if is_folder else "page",
|
|
"is_folder": is_folder,
|
|
"content_format": r["content_format"] if not is_folder else None,
|
|
"children": children,
|
|
"has_children": len(children) > 0,
|
|
"child_count": len(children),
|
|
"size": size,
|
|
"size_display": _format_size(size),
|
|
"created_at": r["created_at"],
|
|
"updated_at": r["updated_at"],
|
|
"author": author,
|
|
"tags": tags_map.get(r["id"], []),
|
|
})
|
|
return tree
|
|
|
|
|
|
def _format_size(size_bytes: int) -> str:
|
|
"""Human-readable file size."""
|
|
if size_bytes < 1024:
|
|
return f"{size_bytes} B"
|
|
elif size_bytes < 1024 * 1024:
|
|
return f"{size_bytes / 1024:.1f} KB"
|
|
elif size_bytes < 1024 * 1024 * 1024:
|
|
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
|
|
|
|
|
|
def _build_breadcrumb(conn, folder_id: int) -> list:
|
|
"""Build breadcrumb trail from root to folder_id."""
|
|
breadcrumb = []
|
|
current = folder_id
|
|
seen = set()
|
|
while current and current not in seen:
|
|
seen.add(current)
|
|
row = conn.execute(
|
|
"SELECT id, title, parent_id, parent_section FROM pages WHERE id=?",
|
|
(current,),
|
|
).fetchone()
|
|
if row:
|
|
breadcrumb.insert(0, {
|
|
"id": row["id"],
|
|
"name": row["title"] or "Untitled",
|
|
"is_folder": row["parent_section"] == "Workspace",
|
|
})
|
|
current = row["parent_id"]
|
|
else:
|
|
break
|
|
return breadcrumb
|
|
|
|
|
|
def _nav_breadcrumb(conn, page_id: int) -> list:
|
|
"""Build a Notion-style breadcrumb chain (root -> page) for the header.
|
|
|
|
Returns a list of dicts: {id, label, url, icon, menu}. The last item is the
|
|
current page (url = None). Every item has ``menu: True`` so the header can
|
|
open a sibling-navigation dropdown for it.
|
|
"""
|
|
from app.routers.board import _file_icon
|
|
chain = []
|
|
current = page_id
|
|
seen = set()
|
|
while current and current not in seen:
|
|
seen.add(current)
|
|
row = conn.execute(
|
|
"SELECT id, title, parent_id, parent_section, content_format "
|
|
"FROM pages WHERE id=? AND deleted_at IS NULL",
|
|
(current,),
|
|
).fetchone()
|
|
if not row:
|
|
break
|
|
is_folder = row["parent_section"] == "Workspace"
|
|
title = row["title"] or "Untitled"
|
|
chain.insert(0, {
|
|
"id": row["id"],
|
|
"label": title,
|
|
"url": None,
|
|
"icon": "\U0001F4C1" if is_folder else _file_icon(title, row["content_format"]),
|
|
"menu": True,
|
|
})
|
|
current = row["parent_id"]
|
|
# All items except the current page are navigable links.
|
|
for i, item in enumerate(chain):
|
|
if i < len(chain) - 1:
|
|
item["url"] = f"/pages/{item['id']}"
|
|
return chain
|
|
|
|
|
|
@router.get("/api/nav/menu")
|
|
async def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
|
"""Return the pages at one level for the header breadcrumb navigation menu.
|
|
|
|
If ``parent_id`` is given, returns that page's children; otherwise the
|
|
workspace's root pages. Each item includes ``has_children`` so the frontend
|
|
can render an expandable sub-menu.
|
|
"""
|
|
from app.routers.board import _file_icon
|
|
ws_id = workspace_id
|
|
if not ws_id:
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ws_id = ws["id"] if ws else None
|
|
if not ws_id:
|
|
return {"items": []}
|
|
with get_conn() as conn:
|
|
if parent_id:
|
|
rows = conn.execute(
|
|
"SELECT id, title, parent_section, content_format FROM pages "
|
|
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
|
|
"ORDER BY sort_order ASC, created_at DESC",
|
|
(parent_id, ws_id),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT id, title, parent_section, content_format FROM pages "
|
|
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL "
|
|
"ORDER BY sort_order ASC, created_at DESC",
|
|
(ws_id,),
|
|
).fetchall()
|
|
ids = [r["id"] for r in rows]
|
|
child_counts = {}
|
|
if ids:
|
|
placeholders = ",".join("?" for _ in ids)
|
|
cc_rows = conn.execute(
|
|
f"SELECT parent_id, COUNT(*) AS c FROM pages "
|
|
f"WHERE parent_id IN ({placeholders}) AND deleted_at IS NULL "
|
|
f"GROUP BY parent_id",
|
|
ids,
|
|
).fetchall()
|
|
for cr in cc_rows:
|
|
child_counts[cr["parent_id"]] = cr["c"]
|
|
items = []
|
|
for r in rows:
|
|
is_folder = r["parent_section"] == "Workspace"
|
|
title = r["title"] or "Untitled"
|
|
items.append({
|
|
"id": r["id"],
|
|
"name": title,
|
|
"icon": "\U0001F4C1" if is_folder else _file_icon(title, r["content_format"]),
|
|
"has_children": child_counts.get(r["id"], 0) > 0,
|
|
"url": f"/pages/{r['id']}",
|
|
})
|
|
return {"items": items}
|
|
|
|
|
|
@router.post("/api/local-workspace/items")
|
|
async def create_local_workspace_item(request: Request):
|
|
"""Create a new file in the active workspace."""
|
|
import json
|
|
body = await request.json()
|
|
name = body.get("name", "Untitled").strip()
|
|
item_type = body.get("type", "page")
|
|
parent_id = body.get("parent_id")
|
|
explicit_ws_id = body.get("workspace_id")
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ws_id = explicit_ws_id or (ws["id"] if ws else None)
|
|
ws_key = (ws["name"] if ws else "Workspace") if not explicit_ws_id else ""
|
|
section = 'Workspace' if item_type == 'folder' else 'Private'
|
|
with get_conn() as conn:
|
|
cursor = conn.execute(
|
|
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) "
|
|
"VALUES (?, ?, ?, '', 'blocks', ?, ?)",
|
|
(ws_key, ws_id, name, section, parent_id)
|
|
)
|
|
conn.commit()
|
|
pid = cursor.lastrowid
|
|
return {"id": pid, "name": name, "type": item_type}
|
|
|
|
|
|
@router.put("/api/local-workspace/items/{item_id:int}")
|
|
async def rename_local_workspace_item(request: Request, item_id: int):
|
|
"""Rename a file."""
|
|
import json
|
|
body = await request.json()
|
|
name = body.get("name", "Untitled").strip()
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.delete("/api/local-workspace/items/{item_id:int}")
|
|
async def delete_local_workspace_item(request: Request, item_id: int):
|
|
"""Soft-delete a file/folder (sets deleted_at)."""
|
|
from datetime import datetime
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
|
|
(datetime.utcnow().isoformat(), item_id),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
|
async def restore_local_workspace_item(request: Request, item_id: int):
|
|
"""Restore a soft-deleted file/folder."""
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET deleted_at=NULL WHERE id=?",
|
|
(item_id,),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/api/files/{ws_id:int}/{filename:path}")
|
|
async def serve_uploaded_file(ws_id: int, filename: str):
|
|
"""Serve an uploaded file from disk."""
|
|
from pathlib import Path
|
|
import mimetypes
|
|
base_dir = Path(f"/data/uploads/workspace_{ws_id}").resolve()
|
|
fp = (base_dir / filename).resolve()
|
|
try:
|
|
fp.relative_to(base_dir)
|
|
except ValueError:
|
|
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
|
|
if not fp.exists():
|
|
return JSONResponse({"error": "File not found"}, status_code=404)
|
|
mime, _ = mimetypes.guess_type(str(fp))
|
|
content = fp.read_bytes()
|
|
from fastapi.responses import Response
|
|
return Response(content=content, media_type=mime or "application/octet-stream")
|
|
|
|
|
|
@router.put("/api/local-workspace/items/{item_id:int}/move")
|
|
async def move_local_workspace_item(request: Request, item_id: int):
|
|
"""Move an item to a new parent (drag & drop)."""
|
|
import json
|
|
body = await request.json()
|
|
new_parent_id = body.get("parent_id") # None = move to root
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET parent_id=? WHERE id=?",
|
|
(new_parent_id, item_id),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/api/local-workspace/upload")
|
|
async def upload_local_workspace_file(request: Request):
|
|
"""Upload one or more files via drag-and-drop.
|
|
|
|
Accepts multipart form with 'files' field (one or multiple files).
|
|
Optional: 'parent_id' to place files in a specific folder.
|
|
Stores files on disk at /data/uploads/workspace_{id}/ and creates DB records.
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ws_id = ws["id"] if ws else None
|
|
if not ws_id:
|
|
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
|
|
|
try:
|
|
form = await request.form()
|
|
except Exception:
|
|
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
|
|
|
parent_id_raw = form.get("parent_id")
|
|
parent_id = int(parent_id_raw) if parent_id_raw else None
|
|
files = form.getlist("files")
|
|
|
|
if not files:
|
|
return JSONResponse({"error": "No files provided"}, status_code=400)
|
|
|
|
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
|
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
results = []
|
|
with get_conn() as conn:
|
|
for f in files:
|
|
filename = f.filename or "untitled"
|
|
# Sanitize filename: only keep basename, prevent path traversal
|
|
safe_name = Path(filename).name
|
|
if not safe_name:
|
|
safe_name = "untitled"
|
|
|
|
# Unique filename on disk
|
|
file_path = upload_dir / safe_name
|
|
stem, suffix = file_path.stem, file_path.suffix
|
|
counter = 1
|
|
while file_path.exists():
|
|
file_path = upload_dir / f"{stem} ({counter}){suffix}"
|
|
counter += 1
|
|
|
|
content = await f.read()
|
|
file_path.write_bytes(content)
|
|
|
|
# Determine if this is a folder marker or actual file
|
|
rel_path = str(file_path.relative_to("/data"))
|
|
size = len(content)
|
|
mime = f.content_type or "application/octet-stream"
|
|
|
|
cursor = conn.execute(
|
|
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
|
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
|
(ws_id, file_path.name,
|
|
json.dumps({"file_path": rel_path, "size": size, "mime_type": mime}),
|
|
parent_id),
|
|
)
|
|
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": size})
|
|
|
|
conn.commit()
|
|
|
|
return {"status": "ok", "items": results}
|
|
|
|
|
|
@router.post("/api/local-workspace/upload-folder")
|
|
async def upload_local_workspace_folder(request: Request):
|
|
"""Handle recursive folder upload.
|
|
|
|
Frontend walks the directory tree with webkitGetAsEntry and sends:
|
|
- 'structure': JSON array of {path: str, type: 'folder'|'file'}
|
|
- 'files': multipart files (one per file in the structure)
|
|
- 'parent_id': target folder (optional)
|
|
|
|
Creates folders first, then uploads files into their respective folders.
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ws_id = ws["id"] if ws else None
|
|
if not ws_id:
|
|
return JSONResponse({"error": "No active workspace"}, status_code=400)
|
|
|
|
try:
|
|
form = await request.form()
|
|
except Exception:
|
|
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
|
|
|
parent_id_raw = form.get("parent_id")
|
|
root_parent_id = int(parent_id_raw) if parent_id_raw else None
|
|
structure_raw = form.get("structure")
|
|
|
|
if not structure_raw:
|
|
return JSONResponse({"error": "No structure provided"}, status_code=400)
|
|
|
|
try:
|
|
structure = json.loads(structure_raw)
|
|
except json.JSONDecodeError:
|
|
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
|
|
|
|
upload_dir = Path(f"/data/uploads/workspace_{ws_id}")
|
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
results = []
|
|
created_folders = {} # relative_path -> db_id
|
|
|
|
with get_conn() as conn:
|
|
# Phase 1: Create all folders
|
|
for item in structure:
|
|
if item.get("type") != "folder":
|
|
continue
|
|
path_parts = item["path"].strip("/").split("/")
|
|
folder_name = path_parts[-1]
|
|
# Determine parent: parent of this folder in the tree
|
|
if len(path_parts) == 1:
|
|
actual_parent = root_parent_id
|
|
else:
|
|
parent_path = "/".join(path_parts[:-1])
|
|
actual_parent = created_folders.get(parent_path)
|
|
|
|
cursor = conn.execute(
|
|
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
|
VALUES ('', ?, ?, '', 'blocks', 'Workspace', ?)""",
|
|
(ws_id, folder_name, actual_parent),
|
|
)
|
|
fid = cursor.lastrowid
|
|
created_folders[item["path"].strip("/")] = fid
|
|
results.append({"id": fid, "name": folder_name, "type": "folder"})
|
|
|
|
# Phase 2: Upload files into their respective folders
|
|
for item in structure:
|
|
if item.get("type") != "file":
|
|
continue
|
|
path_parts = item["path"].strip("/").split("/")
|
|
file_name = path_parts[-1]
|
|
if len(path_parts) == 1:
|
|
file_parent = root_parent_id
|
|
else:
|
|
parent_path = "/".join(path_parts[:-1])
|
|
file_parent = created_folders.get(parent_path)
|
|
|
|
# Find the matching file in multipart data
|
|
matched = None
|
|
for f in form.getlist("files"):
|
|
if f.filename and (f.filename == item["path"] or f.filename.endswith("/" + file_name)):
|
|
matched = f
|
|
break
|
|
if not matched:
|
|
continue
|
|
|
|
safe_name = Path(file_name).name
|
|
file_path = upload_dir / safe_name
|
|
stem, suffix = file_path.stem, file_path.suffix
|
|
counter = 1
|
|
while file_path.exists():
|
|
file_path = upload_dir / f"{stem} ({counter}){suffix}"
|
|
counter += 1
|
|
|
|
content = await matched.read()
|
|
file_path.write_bytes(content)
|
|
|
|
rel_path = str(file_path.relative_to("/data"))
|
|
cursor = conn.execute(
|
|
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
|
|
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
|
|
(ws_id, file_path.name,
|
|
json.dumps({"file_path": rel_path, "size": len(content), "mime_type": matched.content_type or "application/octet-stream"}),
|
|
file_parent),
|
|
)
|
|
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": len(content)})
|
|
|
|
conn.commit()
|
|
|
|
return {"status": "ok", "items": results}
|
|
|
|
|
|
# ═══════════ Workspaces CRUD ═══════════
|
|
|
|
WORKSPACE_COOKIE = "flowdeck_workspace"
|
|
|
|
|
|
def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
|
|
"""Get the active workspace ID from the cookie (verified for current user), or first user workspace, or None."""
|
|
ws_id = request.cookies.get(WORKSPACE_COOKIE)
|
|
if ws_id:
|
|
try:
|
|
with get_conn() as conn:
|
|
ws = conn.execute("SELECT * FROM workspaces WHERE id=?",
|
|
(int(ws_id),)).fetchone()
|
|
if ws:
|
|
ws_dict = dict(ws)
|
|
# Verify ownership — only return if it belongs to the current user
|
|
if user_id is None or ws_dict.get("owner_id") == user_id:
|
|
return ws_dict
|
|
except (ValueError, Exception):
|
|
pass
|
|
# Fallback: first workspace owned by this user
|
|
if user_id:
|
|
with get_conn() as conn:
|
|
ws = conn.execute(
|
|
"SELECT * FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1",
|
|
(user_id,)
|
|
).fetchone()
|
|
if ws:
|
|
return dict(ws)
|
|
return None
|
|
|
|
|
|
@router.get("/workspaces", response_class=HTMLResponse)
|
|
async def workspaces_page(request: Request):
|
|
"""Workspaces list page."""
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
sidebar = _sidebar_data(request, [], include_workspace=False)
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
|
|
ctx = {**sidebar, "user": user}
|
|
# Pass active workspace for breadcrumb nav menu (null on workspaces home)
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ctx["nav_workspace_id"] = ws["id"] if ws else 0
|
|
template = env.get_template("workspaces.html")
|
|
return template.render(**ctx)
|
|
|
|
|
|
@router.get("/api/workspaces")
|
|
async def list_workspaces(request: Request):
|
|
"""List all workspaces for the current user."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
uid = user["id"] if user and user.get("id") else 1
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id) as page_count "
|
|
"FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC",
|
|
(uid,),
|
|
).fetchall()
|
|
workspaces = []
|
|
for r in rows:
|
|
d = dict(r)
|
|
workspaces.append(d)
|
|
active = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
return {"workspaces": workspaces, "active_id": active["id"] if active else None}
|
|
|
|
|
|
@router.post("/api/workspaces")
|
|
async def create_workspace(request: Request):
|
|
"""Create a new workspace."""
|
|
import json
|
|
body = await request.json()
|
|
name = body.get("name", "New Workspace").strip()
|
|
if not name:
|
|
return {"error": "Name required"}
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
uid = user["id"] if user and user.get("id") else 1
|
|
with get_conn() as conn:
|
|
# Ensure user exists (FK constraint)
|
|
uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
|
|
if not uid_ok:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?, ?, ?, 1)",
|
|
(uid, user.get("login", "admin") if user else "admin",
|
|
user.get("full_name", "Admin") if user else "Admin"),
|
|
)
|
|
cursor = conn.execute(
|
|
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
|
|
(name, uid),
|
|
)
|
|
ws_id = cursor.lastrowid
|
|
# Add owner as member
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')",
|
|
(ws_id, uid),
|
|
)
|
|
conn.commit()
|
|
return {"id": ws_id, "name": name}
|
|
|
|
|
|
@router.put("/api/workspaces/{ws_id:int}")
|
|
async def rename_workspace(request: Request, ws_id: int):
|
|
"""Rename a workspace."""
|
|
import json
|
|
body = await request.json()
|
|
name = body.get("name", "").strip()
|
|
if not name:
|
|
return {"error": "Name required"}
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.delete("/api/workspaces/{ws_id:int}")
|
|
async def delete_workspace(request: Request, ws_id: int):
|
|
"""Delete a workspace and all its pages."""
|
|
with get_conn() as conn:
|
|
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
|
|
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
|
|
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/api/workspaces/{ws_id:int}/select")
|
|
async def select_workspace(request: Request, ws_id: int):
|
|
"""Set the active workspace via cookie."""
|
|
from fastapi.responses import JSONResponse
|
|
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
|
|
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
|
|
return response
|
|
|
|
|
|
# ═══════════ Settings Page ═══════════
|
|
|
|
@router.get("/settings", response_class=HTMLResponse)
|
|
async def app_settings_page(request: Request):
|
|
"""Settings & configuration page."""
|
|
from jinja2 import Environment, FileSystemLoader
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
sidebar = _sidebar_data(request, [])
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
|
ctx = {**sidebar, "user": user}
|
|
# Pass active workspace for breadcrumb nav menu
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ctx["nav_workspace_id"] = ws["id"] if ws else 0
|
|
template = env.get_template("settings.html")
|
|
return template.render(**ctx)
|
|
|
|
|
|
@router.post("/api/settings/avatar")
|
|
async def upload_avatar(request: Request):
|
|
"""Upload a user avatar image."""
|
|
from fastapi import UploadFile, File
|
|
from pathlib import Path
|
|
import uuid, os
|
|
form = await request.form()
|
|
file = form.get("file")
|
|
if not file:
|
|
return {"error": "No file"}, 400
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return {"error": "Not authenticated"}, 401
|
|
# Save to data/avatars
|
|
avatars_dir = Path("/data/avatars")
|
|
avatars_dir.mkdir(parents=True, exist_ok=True)
|
|
ext = os.path.splitext(file.filename)[1] or ".png"
|
|
filename = f"{user['id']}_{uuid.uuid4().hex[:8]}{ext}"
|
|
filepath = avatars_dir / filename
|
|
content = await file.read()
|
|
filepath.write_bytes(content)
|
|
# Update user avatar_url
|
|
avatar_url = f"/api/settings/avatar/{filename}"
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE users SET avatar_url = ? WHERE id = ?", (avatar_url, user["id"]))
|
|
conn.commit()
|
|
return {"avatar_url": avatar_url}
|
|
|
|
|
|
@router.get("/api/settings/avatar/{filename:path}")
|
|
async def serve_avatar_file(filename: str):
|
|
"""Serve an uploaded avatar image file."""
|
|
from fastapi.responses import FileResponse
|
|
from pathlib import Path
|
|
filepath = Path("/data/avatars") / filename
|
|
if not filepath.is_file():
|
|
return JSONResponse({"error": "Not found"}, status_code=404)
|
|
return FileResponse(filepath)
|
|
|
|
|
|
@router.get("/api/avatar/{user_id:int}")
|
|
async def get_avatar(user_id: int):
|
|
"""Redirect to the user's avatar."""
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
if row and row["avatar_url"]:
|
|
return RedirectResponse(row["avatar_url"], status_code=302)
|
|
return JSONResponse({"error": "No avatar"}, status_code=404)
|
|
|
|
|
|
@router.post("/api/settings/avatar-color")
|
|
async def set_avatar_color(request: Request):
|
|
"""Set the user's avatar background color."""
|
|
body = await request.json()
|
|
color = body.get("color", "#3A3A3A")
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return {"error": "Not authenticated"}, 401
|
|
with get_conn() as conn:
|
|
conn.execute("UPDATE users SET avatar_url = '', avatar_color = ? WHERE id = ?", (color, user["id"]))
|
|
conn.commit()
|
|
return {"status": "ok", "color": color}
|
|
|
|
|
|
# ═══════════ Tag Management API (per-user) ═══════════
|
|
|
|
@router.post("/api/settings/tags")
|
|
async def create_tag_global(request: Request):
|
|
"""Create a tag for the current user."""
|
|
import json
|
|
body = await request.json()
|
|
tag_name = body.get("name", "").strip().lower()
|
|
color = body.get("color", "#787774")
|
|
uid = _get_user_id(request)
|
|
if not tag_name or not uid:
|
|
return {"error": "Tag name required"}, 400
|
|
with get_conn() as conn:
|
|
tag = conn.execute("SELECT id FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone()
|
|
if tag:
|
|
conn.execute("UPDATE tags SET color = ? WHERE id = ?", (color, tag["id"]))
|
|
conn.commit()
|
|
return {"tag": {"id": tag["id"], "name": tag_name, "color": color}}
|
|
cursor = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, color, uid))
|
|
conn.commit()
|
|
return {"tag": {"id": cursor.lastrowid, "name": tag_name, "color": color}}
|
|
|
|
|
|
@router.put("/api/settings/tags/{tag_id:int}")
|
|
async def update_tag_global(tag_id: int, request: Request):
|
|
"""Update a tag (name or color) — only if owned by user."""
|
|
import json
|
|
body = await request.json()
|
|
uid = _get_user_id(request)
|
|
with get_conn() as conn:
|
|
if "name" in body:
|
|
conn.execute("UPDATE tags SET name = ? WHERE id = ? AND user_id = ?", (body["name"].strip().lower(), tag_id, uid))
|
|
if "color" in body:
|
|
conn.execute("UPDATE tags SET color = ? WHERE id = ? AND user_id = ?", (body["color"], tag_id, uid))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.delete("/api/settings/tags/{tag_id:int}")
|
|
async def delete_tag_global(tag_id: int, request: Request):
|
|
"""Delete a tag — only if owned by user."""
|
|
uid = _get_user_id(request)
|
|
with get_conn() as conn:
|
|
conn.execute("DELETE FROM page_tags WHERE tag_id = ?", (tag_id,))
|
|
conn.execute("DELETE FROM tags WHERE id = ? AND user_id = ?", (tag_id, uid))
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/api/settings/tags/all")
|
|
async def list_all_tags_global(request: Request):
|
|
"""List current user's tags with counts."""
|
|
uid = _get_user_id(request)
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count "
|
|
"FROM tags t LEFT JOIN page_tags pt ON pt.tag_id = t.id "
|
|
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
|
|
(uid,),
|
|
).fetchall()
|
|
return {"tags": [dict(r) for r in rows]}
|
|
|
|
|
|
# ═══════════ Workspace Tags API ═══════════
|
|
|
|
@router.get("/api/local-workspace/tags")
|
|
async def list_tags(request: Request):
|
|
"""List tags used in the active workspace (current user's tags only)."""
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ws_id = ws["id"] if ws else None
|
|
uid = _get_user_id(request)
|
|
if not ws_id:
|
|
return {"tags": []}
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT DISTINCT t.id, t.name, t.color, COUNT(pt.page_id) as count "
|
|
"FROM tags t "
|
|
"JOIN page_tags pt ON pt.tag_id = t.id "
|
|
"JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
|
|
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
|
|
(ws_id, uid),
|
|
).fetchall()
|
|
return {"tags": [dict(r) for r in rows]}
|
|
|
|
|
|
@router.get("/api/local-workspace/items/{item_id:int}/tags")
|
|
async def get_item_tags(item_id: int):
|
|
"""Get tags for a specific item."""
|
|
with get_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT t.id, t.name, t.color FROM tags t "
|
|
"JOIN page_tags pt ON pt.tag_id = t.id "
|
|
"WHERE pt.page_id = ? ORDER BY t.name",
|
|
(item_id,),
|
|
).fetchall()
|
|
return {"tags": [dict(r) for r in rows]}
|
|
|
|
|
|
@router.post("/api/local-workspace/items/{item_id:int}/tags")
|
|
async def add_item_tag(request: Request, item_id: int):
|
|
"""Add a tag to an item (creates tag if new, scoped to user)."""
|
|
import json
|
|
body = await request.json()
|
|
tag_name = body.get("name", "").strip().lower()
|
|
tag_color = body.get("color", "#787774")
|
|
uid = _get_user_id(request)
|
|
if not tag_name:
|
|
return {"error": "Tag name required"}, 400
|
|
|
|
with get_conn() as conn:
|
|
# Get or create tag (per user)
|
|
tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone()
|
|
if not tag:
|
|
cursor = conn.execute(
|
|
"INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, tag_color, uid)
|
|
)
|
|
conn.commit()
|
|
tag_id = cursor.lastrowid
|
|
tag = {"id": tag_id, "name": tag_name, "color": tag_color}
|
|
else:
|
|
tag_id = tag["id"]
|
|
|
|
# Link tag to page (ignore duplicate)
|
|
try:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)",
|
|
(item_id, tag_id),
|
|
)
|
|
conn.commit()
|
|
except Exception:
|
|
pass
|
|
|
|
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
|
|
|
|
|
|
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
|
|
async def remove_item_tag(item_id: int, tag_id: int):
|
|
"""Remove a tag from an item."""
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"DELETE FROM page_tags WHERE page_id = ? AND tag_id = ?",
|
|
(item_id, tag_id),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/api/local-workspace/tags/search")
|
|
async def search_by_tags(request: Request, tags: str = ""):
|
|
"""Search items by tags (comma-separated)."""
|
|
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
|
ws_id = ws["id"] if ws else None
|
|
if not ws_id:
|
|
return {"items": []}
|
|
import json
|
|
tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()]
|
|
if not tag_names:
|
|
return {"items": []}
|
|
with get_conn() as conn:
|
|
placeholders = ",".join("?" for _ in tag_names)
|
|
rows = conn.execute(
|
|
f"SELECT DISTINCT p.id, p.title, p.content_format, p.parent_section, "
|
|
f"p.content, p.created_at, p.updated_at "
|
|
f"FROM pages p "
|
|
f"JOIN page_tags pt ON pt.page_id = p.id "
|
|
f"JOIN tags t ON t.id = pt.tag_id "
|
|
f"WHERE t.name IN ({placeholders}) AND p.workspace_id = ? AND p.deleted_at IS NULL "
|
|
f"ORDER BY p.updated_at DESC",
|
|
tag_names + [ws_id],
|
|
).fetchall()
|
|
items = []
|
|
for r in rows:
|
|
is_folder = r["parent_section"] == "Workspace"
|
|
size = len(r["content"] or "")
|
|
items.append({
|
|
"id": r["id"],
|
|
"name": r["title"] or "Untitled",
|
|
"is_folder": is_folder,
|
|
"content_format": r["content_format"],
|
|
"created_at": r["created_at"],
|
|
"updated_at": r["updated_at"],
|
|
"size": size,
|
|
"size_display": _format_size(size),
|
|
})
|
|
return {"items": items}
|
|
|
|
|
|
# ── Account update ──
|
|
@router.put("/api/settings/account")
|
|
async def update_account(request: Request):
|
|
"""Update current user's profile: full_name, login, email, password."""
|
|
import json
|
|
from app.password_utils import hash_password
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
with get_conn() as conn:
|
|
uid = user["id"]
|
|
if "full_name" in body:
|
|
conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["full_name"].strip(), uid))
|
|
if "login" in body:
|
|
new_login = body["login"].strip()
|
|
if new_login and new_login != user.get("login"):
|
|
existing = conn.execute("SELECT id FROM users WHERE login=? AND id!=?", (new_login, uid)).fetchone()
|
|
if existing:
|
|
return JSONResponse({"error": "Username already taken"}, status_code=409)
|
|
conn.execute("UPDATE users SET login=? WHERE id=?", (new_login, uid))
|
|
if "email" in body:
|
|
conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"].strip(), uid))
|
|
if "password" in body and body["password"].strip():
|
|
pw = body["password"].strip()
|
|
if len(pw) < 6:
|
|
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
|
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), uid))
|
|
conn.commit()
|
|
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
|
user_data = dict(row)
|
|
# Refresh session cookie with updated data
|
|
new_session = SessionManager.create_session(user_data)
|
|
response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}})
|
|
response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
|
|
return response
|
|
|
|
|
|
# ═══════════ Sidebar Refresh API ═══════════
|
|
|
|
@router.get("/api/sidebar/workspace-tree")
|
|
async def sidebar_workspace_tree(request: Request):
|
|
"""Return the sidebar workspace tree as HTML fragment.
|
|
|
|
Called by appState().refreshSidebarTree() after CRUD operations
|
|
in the main content area to keep the sidebar in sync.
|
|
"""
|
|
from jinja2 import Environment, FileSystemLoader
|
|
from app.routers.board import _load_workspace_pages
|
|
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return HTMLResponse("")
|
|
|
|
ws_cookie = request.cookies.get("flowdeck_workspace", "")
|
|
if not ws_cookie:
|
|
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>')
|
|
|
|
# Gitea workspace — no server-side tree, loaded client-side
|
|
if ws_cookie.startswith("gitea:"):
|
|
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Remote workspace</span></li>')
|
|
|
|
try:
|
|
ws_id = int(ws_cookie)
|
|
with get_conn() as conn:
|
|
# Verify workspace belongs to user
|
|
row = conn.execute(
|
|
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
|
(ws_id, user["id"])
|
|
).fetchone()
|
|
if not row:
|
|
return HTMLResponse(
|
|
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
|
'<span class="page-name text-dim">No pages yet</span></li>'
|
|
)
|
|
|
|
pages = _load_workspace_pages(ws_cookie)
|
|
if not pages:
|
|
return HTMLResponse(
|
|
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
|
'<span class="page-name text-dim">No pages yet</span></li>'
|
|
)
|
|
|
|
# Render the tree using the extracted macro
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
template = env.from_string(
|
|
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
|
"{{ render_workspace_tree(pages) }}"
|
|
)
|
|
html = template.render(pages=pages)
|
|
return HTMLResponse(html)
|
|
except (ValueError, Exception) as e:
|
|
logger.error(f"sidebar_workspace_tree failed: {e}", exc_info=True)
|
|
return HTMLResponse(
|
|
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
|
'<span class="page-name text-dim">No pages yet</span></li>'
|
|
)
|
|
|
|
|
|
# ═══════════ Public Published Page ═══════════
|
|
|
|
|
|
@router.get("/p/{slug}", response_class=HTMLResponse)
|
|
async def public_published_page(request: Request, slug: str):
|
|
"""Serve a published page at /p/<slug> — no auth required."""
|
|
from jinja2 import Environment, FileSystemLoader
|
|
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT id, title, content, content_format, updated_at, created_at "
|
|
"FROM pages WHERE publish_slug=? AND is_published=1",
|
|
(slug,),
|
|
).fetchone()
|
|
|
|
if not row:
|
|
return HTMLResponse(
|
|
"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
|
<title>Not Found — FlowDeck</title>
|
|
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;
|
|
justify-content:center;height:100vh;margin:0;background:#191919;color:#ccc;}
|
|
h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
|
status_code=404,
|
|
)
|
|
|
|
page = dict(row)
|
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
|
|
|
# Convert blocks to HTML for rendering
|
|
content_html = ""
|
|
if page.get("content_format") == "blocks" and page.get("content"):
|
|
import json as _json
|
|
try:
|
|
blocks = _json.loads(page["content"])
|
|
content_html = _render_blocks_public(blocks)
|
|
except (_json.JSONDecodeError, Exception):
|
|
content_html = f"<p>{page.get('content', '')}</p>"
|
|
elif page.get("content"):
|
|
# Plain text / markdown
|
|
text = page["content"]
|
|
content_html = f"<pre style='white-space:pre-wrap;font-family:system-ui;font-size:16px;line-height:1.6;'>{text}</pre>"
|
|
|
|
template = env.get_template("public_page.html")
|
|
return template.render(
|
|
title=page["title"] or "Untitled",
|
|
content_html=content_html,
|
|
updated_at=page.get("updated_at", ""),
|
|
created_at=page.get("created_at", ""),
|
|
)
|
|
|
|
|
|
def _render_blocks_public(blocks: list) -> str:
|
|
"""Render FlowDeck blocks as plain HTML for public pages."""
|
|
import json as _json
|
|
html_parts = []
|
|
for b in blocks:
|
|
t = b.get("type", "paragraph")
|
|
c = b.get("content", "") or ""
|
|
if t == "heading_1":
|
|
html_parts.append(f'<h1 style="font-size:2.5rem;font-weight:700;margin:32px 0 8px;">{c}</h1>')
|
|
elif t == "heading_2":
|
|
html_parts.append(f'<h2 style="font-size:1.75rem;font-weight:600;margin:28px 0 6px;">{c}</h2>')
|
|
elif t == "heading_3":
|
|
html_parts.append(f'<h3 style="font-size:1.35rem;font-weight:600;margin:24px 0 4px;">{c}</h3>')
|
|
elif t == "heading_4":
|
|
html_parts.append(f'<h4 style="font-size:1.15rem;font-weight:600;margin:20px 0 4px;">{c}</h4>')
|
|
elif t == "bulleted_list":
|
|
html_parts.append(f'<li style="margin-left:24px;">{c}</li>')
|
|
elif t == "numbered_list":
|
|
html_parts.append(f'<li style="margin-left:24px;list-style:decimal;">{c}</li>')
|
|
elif t == "to_do":
|
|
checked = "checked" if b.get("checked") else ""
|
|
todo_style = "text-decoration:line-through;opacity:.5" if b.get("checked") else ""
|
|
html_parts.append(
|
|
f'<div style="display:flex;align-items:center;gap:8px;margin:4px 0;">'
|
|
f'<input type="checkbox" {checked} disabled>'
|
|
f'<span style="{todo_style}">{c}</span>'
|
|
f'</div>'
|
|
)
|
|
elif t == "toggle":
|
|
html_parts.append(
|
|
f'<details style="margin:8px 0;"><summary style="cursor:pointer;font-weight:500;">{c}</summary></details>'
|
|
)
|
|
elif t == "quote":
|
|
html_parts.append(
|
|
f'<blockquote style="border-left:3px solid var(--accent,#4c9aff);margin:12px 0;padding:4px 16px;opacity:.85;">{c}</blockquote>'
|
|
)
|
|
elif t == "callout":
|
|
icon = b.get("icon", "💡")
|
|
bg = (b.get("style") or {}).get("bgColor", "rgba(76,154,255,.1)")
|
|
html_parts.append(
|
|
f'<div style="display:flex;gap:10px;padding:14px 18px;margin:12px 0;border-radius:8px;'
|
|
f'background:{bg};align-items:flex-start;">'
|
|
f'<span style="font-size:20px;flex-shrink:0;">{icon}</span>'
|
|
f'<span>{c}</span></div>'
|
|
)
|
|
elif t == "code":
|
|
lang = b.get("language", "")
|
|
lang_label = f"<div style='font-size:11px;opacity:.4;margin-bottom:8px;'>{lang}</div>" if lang else ""
|
|
html_parts.append(
|
|
f'<pre style="background:rgba(255,255,255,.05);padding:16px 20px;border-radius:8px;'
|
|
f'overflow-x:auto;font-size:14px;line-height:1.5;margin:12px 0;">'
|
|
f'{lang_label}'
|
|
f'<code>{c}</code></pre>'
|
|
)
|
|
elif t == "divider":
|
|
html_parts.append('<hr style="border:none;border-top:1px solid rgba(255,255,255,.1);margin:16px 0;">')
|
|
elif t == "image":
|
|
src = b.get("src", "")
|
|
alt = b.get("alt", "")
|
|
html_parts.append(
|
|
f'<figure style="margin:16px 0;text-align:center;">'
|
|
f'<img src="{src}" alt="{alt}" style="max-width:100%;border-radius:8px;">'
|
|
f'</figure>'
|
|
)
|
|
else:
|
|
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
|
|
return "\n".join(html_parts)
|
|
|
|
|
|
# ═══════════ Library page actions API ═══════════
|
|
|
|
@router.get("/api/pages/{page_id:int}/content")
|
|
async def api_page_content(page_id: int):
|
|
"""Get page content for side peek preview."""
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT title, content, content_format FROM pages WHERE id=? AND deleted_at IS NULL",
|
|
(page_id,),
|
|
).fetchone()
|
|
if not row:
|
|
return JSONResponse({"error": "Not found"}, status_code=404)
|
|
return {
|
|
"title": row["title"],
|
|
"content": row["content"],
|
|
"format": row["content_format"] or "blocks",
|
|
}
|
|
|
|
|
|
@router.put("/api/pages/{page_id:int}/rename")
|
|
async def api_rename_page(page_id: int, request: Request):
|
|
"""Inline rename a page title."""
|
|
import json as _json
|
|
body = await request.json()
|
|
title = (body.get("title") or "").strip()
|
|
if not title:
|
|
return JSONResponse({"error": "Title required"}, status_code=400)
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
|
|
(title, page_id),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok", "title": title}
|
|
|
|
|
|
@router.post("/api/pages/{page_id:int}/trash")
|
|
async def api_trash_page(page_id: int):
|
|
"""Soft-delete a page (move to trash)."""
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(page_id,),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|