diff --git a/app/config.py b/app/config.py index 2383c73..0096d63 100644 --- a/app/config.py +++ b/app/config.py @@ -13,8 +13,8 @@ class Settings(BaseSettings): # Gitea gitea_url: str = "https://git.dracodev.net" gitea_token: str = "change-me" - gitea_oauth_client_id: str = "" - gitea_oauth_client_secret: str = "" + gitea_oauth_client_id: str = "test-id" + gitea_oauth_client_secret: str = "test-secret" gitea_webhook_secret: str = "" # GitHub @@ -53,7 +53,12 @@ class Settings(BaseSettings): if self.database_url == "sqlite:///:memory:": return Path(":memory:") # Special SQLite in-memory if self.database_url.startswith("sqlite:///"): - return Path(self.database_url.replace("sqlite:///", "/")) + p = self.database_url.replace("sqlite:///", "", 1) + # On Windows, don't prepend / if path starts with a drive letter + import re + if re.match(r'^[a-zA-Z]:', p): + return Path(p) + return Path("/" + p) return Path("/data/flowdeck.db") diff --git a/app/routers/auth.py b/app/routers/auth.py index 2019c4f..8d263c2 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -153,9 +153,11 @@ async def register(request: Request): if existing: from fastapi.responses import JSONResponse return JSONResponse({"error": "Account already exists"}, status_code=409) - # First user is admin - user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] - is_admin = 1 if user_count == 0 else 0 + # First real user (excluding default admin with no password) is admin + real_user_count = conn.execute( + "SELECT COUNT(*) FROM users WHERE password_hash IS NOT NULL AND password_hash != ''" + ).fetchone()[0] + is_admin = 1 if real_user_count == 0 else 0 conn.execute( "INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)", (email, name, email, hash_password(password), is_admin), diff --git a/app/routers/board.py b/app/routers/board.py index 9b629ae..45f3c20 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -926,10 +926,6 @@ async def view_page(request: Request, page_id: int): return HTMLResponse("

Page not found

", status_code=404) page = dict(row) - # ── File viewer for uploaded files ── - if page.get("content_format") == "file": - return _render_file_viewer(page, request) - ws = page.get("workspace", "") parts = ws.split("/") if "/" in ws else ["", ""] owner, repo = parts[0], parts[1] if len(parts) > 1 else "" @@ -941,10 +937,38 @@ async def view_page(request: Request, page_id: int): fav = conn.execute( "SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, 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) + # Build workspace_id from file_path + 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 + ctx = {**sidebar, "page": page, "page_favorited": fav is not None, "page_share_mode": page.get("share_mode", "private"), "page_published": bool(page.get("published", 0)), - "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))}} + "page_data": page_data} template = env.get_template("page_editor.html") response = template.render(**ctx) return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"}) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 5529d27..623e257 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -216,7 +216,6 @@ 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 - from app.routers.board import _render_file_viewer env = Environment(loader=FileSystemLoader("app/templates")) with get_conn() as conn: row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() @@ -224,9 +223,6 @@ async def view_page_root(request: Request, page_id: int): return HTMLResponse("

Page not found

", status_code=404) page = dict(row) - # ── File viewer for uploaded files ── - if page.get("content_format") == "file": - return _render_file_viewer(page, request) ws = page.get("workspace", "") parts = ws.split("/") if "/" in ws else ["", ""] owner, repo = parts[0], parts[1] if len(parts) > 1 else "" @@ -237,11 +233,42 @@ async def view_page_root(request: Request, page_id: int): "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 + ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs], - "page_favorited": False, + "page_favorited": fav is not None, "page_share_mode": page.get("share_mode", "private"), "page_published": bool(page.get("published", 0)), - "page_data": {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": False, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))}} + "page_data": page_data} template = env.get_template("page_editor.html") response = template.render(**ctx) return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"}) @@ -866,8 +893,11 @@ async def serve_uploaded_file(ws_id: int, filename: str): """Serve an uploaded file from disk.""" from pathlib import Path import mimetypes - fp = Path(f"/data/uploads/workspace_{ws_id}/{filename}").resolve() - if not str(fp).startswith(f"/data/uploads/workspace_{ws_id}/"): + 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) diff --git a/app/templates/base.html b/app/templates/base.html index 13590f9..935cccf 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,12 +1,11 @@ - + FlowDeck — {% block title_prefix %}Home{% endblock %} - - + @@ -1049,38 +1048,13 @@ toggleTheme() { const html = document.documentElement; const current = html.getAttribute('data-theme'); - html.setAttribute('data-theme', current === 'dark' ? 'light' : 'dark'); - localStorage.setItem('fd_theme', current === 'dark' ? 'light' : 'dark'); - if (current === 'dark') { - // Switching to light - html.style.setProperty('--bg-primary', '#FFFFFF'); - html.style.setProperty('--bg-secondary', '#F7F6F3'); - html.style.setProperty('--bg-tertiary', '#EDECE9'); - html.style.setProperty('--text', '#37352F'); - html.style.setProperty('--text-secondary', '#6B6966'); - html.style.setProperty('--text-dim', '#9B9A97'); - html.style.setProperty('--border', '#E0DFDC'); - html.style.setProperty('--border-strong', '#CCCBC8'); - } else { - // Switching to dark — remove overrides, use CSS defaults - ['bg-primary','bg-secondary','bg-tertiary','text','text-secondary','text-dim','border','border-strong'].forEach(function(v) { - html.style.removeProperty('--'+v); - }); - } + const next = current === 'dark' ? 'light' : 'dark'; + html.setAttribute('data-theme', next); + localStorage.setItem('fd_theme', next); }, initTheme() { - var saved = localStorage.getItem('fd_theme') || 'dark'; - if (saved === 'light') { - document.documentElement.setAttribute('data-theme', 'light'); - document.documentElement.style.setProperty('--bg-primary', '#FFFFFF'); - document.documentElement.style.setProperty('--bg-secondary', '#F7F6F3'); - document.documentElement.style.setProperty('--bg-tertiary', '#EDECE9'); - document.documentElement.style.setProperty('--text', '#37352F'); - document.documentElement.style.setProperty('--text-secondary', '#6B6966'); - document.documentElement.style.setProperty('--text-dim', '#9B9A97'); - document.documentElement.style.setProperty('--border', '#E0DFDC'); - document.documentElement.style.setProperty('--border-strong', '#CCCBC8'); - } + var saved = localStorage.getItem('fd_theme') || 'light'; + document.documentElement.setAttribute('data-theme', saved); } }; } @@ -1115,24 +1089,8 @@ // Apply saved theme on page load (AND on bfcache restore) function applySavedTheme() { - var saved = localStorage.getItem('fd_theme') || 'dark'; - var h = document.documentElement; - h.setAttribute('data-theme', saved); - if (saved === 'light') { - h.style.setProperty('--bg-primary', '#FFFFFF'); - h.style.setProperty('--bg-secondary', '#F7F6F3'); - h.style.setProperty('--bg-tertiary', '#EDECE9'); - h.style.setProperty('--text', '#37352F'); - h.style.setProperty('--text-secondary', '#6B6966'); - h.style.setProperty('--text-dim', '#9B9A97'); - h.style.setProperty('--border', '#E0DFDC'); - h.style.setProperty('--border-strong', '#CCCBC8'); - } else { - // Remove light overrides, use CSS defaults - ['bg-primary','bg-secondary','bg-tertiary','text','text-secondary','text-dim','border','border-strong'].forEach(function(v) { - h.style.removeProperty('--'+v); - }); - } + var saved = localStorage.getItem('fd_theme') || 'light'; + document.documentElement.setAttribute('data-theme', saved); } applySavedTheme(); // Re-apply when page is restored from bfcache (e.g. browser back button) diff --git a/app/templates/gitea_workspace.html b/app/templates/gitea_workspace.html index f4cac8c..f6455e1 100644 --- a/app/templates/gitea_workspace.html +++ b/app/templates/gitea_workspace.html @@ -758,7 +758,7 @@ document.addEventListener('alpine:init', () => { x-show="gwCtx.visible" :style="'left:' + gwCtx.x + 'px; top:' + gwCtx.y + 'px'" @click.outside="gwCtx.visible = false" - style="display:none;position:fixed;background:var(--bg-secondary);border:1px solid var(--border-strong);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:1100;min-width:160px;padding:4px;"> + style="display:none;position:fixed;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-popover);z-index:1100;min-width:160px;padding:4px;">
✏️ Rename
🗑 Delete
diff --git a/app/templates/library.html b/app/templates/library.html index 3d4b224..9b58304 100644 --- a/app/templates/library.html +++ b/app/templates/library.html @@ -110,7 +110,7 @@ .source-filter-btn.active { background: var(--bg-primary); color: var(--text-primary); - box-shadow: 0 1px 2px rgba(0,0,0,.2); + box-shadow: var(--shadow-popover); } /* ── Card grid ── */ @@ -171,15 +171,15 @@ } .source-local { background: rgba(100, 180, 100, 0.15); - color: #64b464; + color: var(--green); } .source-gitea { background: rgba(100, 160, 220, 0.15); - color: #64a0dc; + color: var(--blue); } .source-github { background: rgba(180, 140, 220, 0.15); - color: #b48cdc; + color: var(--purple); } .library-card-workspace { font-size: 12px; diff --git a/app/templates/local_workspace.html b/app/templates/local_workspace.html index b281808..89ae2e8 100644 --- a/app/templates/local_workspace.html +++ b/app/templates/local_workspace.html @@ -67,7 +67,7 @@ .modified-dot.show{display:block;} /* Offline banner */ -.offline-banner{position:fixed;top:0;left:0;right:0;background:#f85149;color:#fff;text-align:center;padding:6px 12px;font-size:12px;z-index:500;font-weight:500;} +.offline-banner{position:fixed;top:0;left:0;right:0;background:var(--danger);color:#fff;text-align:center;padding:6px 12px;font-size:12px;z-index:500;font-weight:500;} /* Pinned indicator */ .pin-icon{font-size:10px;color:var(--text-tertiary);cursor:pointer;margin-left:4px;opacity:0;transition:opacity 150ms;} @@ -83,6 +83,8 @@ .bulk-bar{display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--accent);color:#fff;border-radius:8px;margin-bottom:10px;font-size:13px;font-weight:500;} .bulk-bar .btn-danger{background:rgba(255,255,255,.15);color:#fff;border:1px solid rgba(255,255,255,.3);} .bulk-bar .btn-secondary{background:rgba(255,255,255,.1);color:#fff;border:1px solid rgba(255,255,255,.2);} +[data-theme="light"] .bulk-bar .btn-danger{background:rgba(0,0,0,.08);color:var(--text-primary);border:1px solid var(--border);} +[data-theme="light"] .bulk-bar .btn-secondary{background:rgba(0,0,0,.05);color:var(--text-primary);border:1px solid var(--border);} /* Table view */ .ws-table{width:100%;border-collapse:collapse;font-size:13px;} @@ -94,8 +96,8 @@ .ws-table .table-name:hover{color:var(--accent);} .ws-table .table-meta{color:var(--text-tertiary);font-size:12px;} -.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:999;backdrop-filter:blur(4px);} -.modal-box{background:#1F1F1F;border:1px solid var(--border-strong);border-radius:16px;padding:28px;width:420px;max-width:90vw;box-shadow:0 20px 60px rgba(0,0,0,.5);} +.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;z-index:999;backdrop-filter:blur(4px);} +.modal-box{background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);padding:28px;width:420px;max-width:90vw;box-shadow:var(--shadow-modal);} .modal-box h3{font-size:16px;margin-bottom:6px;display:flex;align-items:center;gap:8px;} .modal-box .modal-sub{font-size:12px;color:var(--text-tertiary);margin-bottom:20px;} .modal-input{width:100%;padding:12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:10px;color:var(--text);font-size:14px;outline:none;} @@ -117,7 +119,7 @@ .drop-overlay .drop-hint{background:var(--bg);border:2px dashed var(--accent);border-radius:16px;padding:40px 60px;text-align:center;color:var(--text);font-size:16px;} .drop-overlay .drop-hint .icon{font-size:48px;display:block;margin-bottom:12px;} .drop-target-highlight{outline:2px solid var(--accent)!important;outline-offset:-2px;border-radius:6px;background:rgba(35,131,226,.1)!important;} -.upload-progress{position:fixed;bottom:24px;right:24px;background:var(--bg);border:1px solid var(--border-strong);border-radius:12px;padding:16px 20px;z-index:200;box-shadow:0 8px 30px rgba(0,0,0,.4);min-width:200px;} +.upload-progress{position:fixed;bottom:24px;right:24px;background:var(--bg-modal);border:1px solid var(--border);border-radius:12px;padding:16px 20px;z-index:200;box-shadow:var(--shadow-popover);min-width:200px;} .upload-progress .up-bar{height:4px;background:var(--bg-tertiary);border-radius:2px;margin-top:8px;overflow:hidden;} .upload-progress .up-bar-fill{height:100%;background:var(--accent);border-radius:2px;transition:width 200ms;} @@ -153,7 +155,7 @@ .sort-select:focus{border-color:var(--accent);} /* Undo toast */ -.undo-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bg);border:1px solid var(--border-strong);border-radius:12px;padding:10px 20px;z-index:300;box-shadow:0 8px 30px rgba(0,0,0,.5);display:flex;align-items:center;gap:12px;font-size:13px;} +.undo-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bg-modal);border:1px solid var(--border);border-radius:12px;padding:10px 20px;z-index:300;box-shadow:var(--shadow-popover);display:flex;align-items:center;gap:12px;font-size:13px;} .undo-btn{background:var(--accent);color:#fff;border:none;padding:4px 12px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;} .undo-btn:hover{opacity:.85;} .undo-dismiss{background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:18px;padding:0 4px;} @@ -171,9 +173,9 @@ .path-go:hover{opacity:.85;} /* File preview tooltip */ -.file-preview{position:fixed;z-index:300;background:#1a1a1a;background:var(--bg,#1a1a1a);border:1px solid var(--border-strong,#333);border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.5);max-width:400px;min-width:200px;pointer-events:auto;} -.file-preview-header{padding:8px 12px;font-size:12px;font-weight:600;color:var(--text-secondary,#888);border-bottom:1px solid var(--border,#333);} -.file-preview-body{padding:10px 12px;margin:0;font-size:12px;line-height:1.5;color:var(--text,#ddd);max-height:200px;overflow:auto;font-family:'Fira Code','Cascadia Code',monospace;white-space:pre-wrap;word-wrap:break-word;background:#141414;} +.file-preview{position:fixed;z-index:300;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow-popover);max-width:400px;min-width:200px;pointer-events:auto;} +.file-preview-header{padding:8px 12px;font-size:12px;font-weight:600;color:var(--text-secondary);border-bottom:1px solid var(--border);} +.file-preview-body{padding:10px 12px;margin:0;font-size:12px;line-height:1.5;color:var(--text);max-height:200px;overflow:auto;font-family:'Fira Code','Cascadia Code',monospace;white-space:pre-wrap;word-wrap:break-word;background:var(--bg-secondary);} /* Tag chips */ .tag-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 8px;border-radius:10px;font-size:11px;color:#fff;white-space:nowrap;margin:1px 2px;transition:all 150ms;} @@ -254,13 +256,13 @@ .view-dropdown-btn{display:flex;align-items:center;gap:6px;padding:5px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;cursor:pointer;font-size:12px;color:var(--text);white-space:nowrap;} .view-dropdown-btn:hover{background:var(--bg-hover);border-color:var(--accent);} .dropdown-arrow{font-size:10px;color:var(--text-tertiary);} -.view-dropdown-menu{position:absolute;top:100%;left:0;margin-top:4px;background:#1E1E1E;border:1px solid var(--border-strong);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.6);z-index:250;min-width:140px;overflow:hidden;} +.view-dropdown-menu{position:absolute;top:100%;left:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-popover);z-index:250;min-width:140px;overflow:hidden;} .view-menu-item{display:flex;align-items:center;gap:8px;width:100%;padding:8px 12px;background:none;border:none;color:var(--text);font-size:13px;cursor:pointer;text-align:left;} .view-menu-item:hover{background:var(--bg-hover);} .view-menu-item.active{background:rgba(35,131,226,.12);color:var(--accent);font-weight:500;} /* Context menu */ -.ctx-menu{position:fixed;background:#1E1E1E;border:1px solid var(--border-strong);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.5);min-width:200px;z-index:600;padding:4px 0;max-height:80vh;overflow-y:auto;} +.ctx-menu{position:fixed;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow-popover);min-width:200px;z-index:600;padding:4px 0;max-height:80vh;overflow-y:auto;} .ctx-item{display:flex;align-items:center;gap:8px;padding:7px 14px;font-size:13px;color:var(--text);cursor:pointer;transition:background 100ms;} .ctx-item:hover{background:var(--bg-hover);} .ctx-item.ctx-danger{color:var(--danger);} diff --git a/app/templates/page_editor.html b/app/templates/page_editor.html index 5d02c08..7dc8ded 100644 --- a/app/templates/page_editor.html +++ b/app/templates/page_editor.html @@ -1,8 +1,8 @@ -{% extends "base.html" %} {% block page_icon %}📄{% endblock %} {% block +{% extends "base.html" %} {% block page_icon %}{% if page.content_format == 'file' %}📎{% else %}📄{% endif %}{% endblock %} {% block page_title %}{{ page.title }}{% endblock %} {% block topbar %} -{% set page_icon = "📄" %} +{% set page_icon = "📄" if page.content_format != 'file' else "📎" %} {% set page_title = page.title %} -{% set right_actions = 'Edited ▾' %} +{% set right_actions = 'Edited ▾' %} {% include '_header.html' %} {% endblock %} {% block content %}
@@ -12,7 +12,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
@@ -336,9 +336,9 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
+ style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:280px;padding:12px;">
Edited