feat: workspaces system — create, select, rename, delete workspaces
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- DB: pages.workspace_id column linking pages to workspaces table
- /workspaces: list, create, rename, delete workspaces page
- API: GET/POST/PUT/DELETE /api/workspaces + POST select
- Session: flowdeck_workspace cookie for active workspace tracking
- Sidebar: Workspace section shows active workspace name + page tree
- Local workspace APIs filter by active workspace_id
- Home redirects non-Gitea users to /workspaces
This commit is contained in:
2026-07-10 16:41:40 -04:00
parent eb417d4ed5
commit 3e6323a856
4 changed files with 279 additions and 4 deletions
+5
View File
@@ -152,6 +152,7 @@ def init_db():
CREATE TABLE IF NOT EXISTS pages ( CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace TEXT NOT NULL, workspace TEXT NOT NULL,
workspace_id INTEGER REFERENCES workspaces(id),
title TEXT NOT NULL DEFAULT 'New page', title TEXT NOT NULL DEFAULT 'New page',
content TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '',
parent_section TEXT DEFAULT 'Private', parent_section TEXT DEFAULT 'Private',
@@ -322,6 +323,10 @@ def init_db():
conn.execute("ALTER TABLE pages ADD COLUMN published INTEGER DEFAULT 0") conn.execute("ALTER TABLE pages ADD COLUMN published INTEGER DEFAULT 0")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
try:
conn.execute("ALTER TABLE pages ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)")
except sqlite3.OperationalError:
pass
# v2.2: Auth locale # v2.2: Auth locale
for col in ["password_hash", "is_active", "last_login", "login_attempts", "locked_until"]: for col in ["password_hash", "is_active", "last_login", "login_attempts", "locked_until"]:
try: try:
+115
View File
@@ -547,3 +547,118 @@ async def delete_local_workspace_item(request: Request, item_id: int):
conn.execute("DELETE FROM pages WHERE id=? OR parent_id=?", (item_id, item_id)) conn.execute("DELETE FROM pages WHERE id=? OR parent_id=?", (item_id, item_id))
conn.commit() conn.commit()
return {"status": "ok"} return {"status": "ok"}
# ═══════════ Workspaces CRUD ═══════════
WORKSPACE_COOKIE = "flowdeck_workspace"
def _get_active_workspace(request: Request) -> dict | None:
"""Get the active workspace ID from the cookie, or first 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:
return dict(ws)
except (ValueError, Exception):
pass
# Fallback: first workspace
with get_conn() as conn:
ws = conn.execute("SELECT * FROM workspaces ORDER BY id LIMIT 1").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, [])
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}
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)
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()
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:
cursor = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
(name, uid),
)
conn.commit()
ws_id = cursor.lastrowid
# Add owner as member
conn.execute(
"INSERT 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
+10 -4
View File
@@ -85,16 +85,22 @@
<div class="sidebar-section-header" @click="toggleSection('workspace')"> <div class="sidebar-section-header" @click="toggleSection('workspace')">
<div class="sidebar-section-title"> <div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.workspace }" x-text="sectionsOpen.workspace ? '▼' : '▶'"></span> <span class="chevron" :class="{ open: sectionsOpen.workspace }" x-text="sectionsOpen.workspace ? '▼' : '▶'"></span>
<span>📁 Workspace</span> <span>📁 {{ workspace_name or 'Workspace' }}</span>
</div> </div>
<div class="sidebar-section-actions"> <div class="sidebar-section-actions">
<button class="section-action-btn" title="New File" @click.stop="newPage('Private')">+</button> <button class="section-action-btn" title="New Page" @click.stop="newPageInWorkspace()">+</button>
</div> </div>
</div> </div>
<div class="sidebar-section-items" x-show="sectionsOpen.workspace" x-transition> <div class="sidebar-section-items" x-show="sectionsOpen.workspace" x-transition>
<ul class="sidebar-items" data-section="workspace"> <ul class="sidebar-items" data-section="workspace">
<li class="sidebar-item" onclick="window.location='/local-workspace'"> {% for page in workspace_pages %}
<span class="page-icon">📁</span><span class="page-name">My Workspace</span> {{ render_tree_item(page) }}
{% endfor %}
{% if not workspace_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>
{% endif %}
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.location='/workspaces'">
<span class="page-icon">⚙</span><span class="page-name text-dim">Manage Workspaces</span>
</li> </li>
</ul> </ul>
</div> </div>
+149
View File
@@ -0,0 +1,149 @@
{% extends "base.html" %}
{% block page_title %}Workspaces{% endblock %}
{% block page_icon %}🏠{% endblock %}
{% block topbar %}
<header class="topbar">
<div class="topbar-left">
<span class="breadcrumb" style="color:rgba(255,255,255,.5)">Home</span>
<span class="page-title-topbar">Workspaces</span>
</div>
<div class="topbar-right">
<button class="page-action-btn" onclick="window.location='/accounts/settings'" title="Settings">⚙</button>
</div>
</header>
{% endblock %}
{% block content %}
<style>
.ws-page{max-width:800px;margin:0 auto;padding:40px 24px;}
.ws-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;}
.ws-header h1{font-size:24px;}
.ws-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;}
.ws-card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:20px;cursor:pointer;transition:background 150ms;}
.ws-card:hover{background:var(--bg-tertiary);border-color:var(--border-strong);}
.ws-card.active{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
.ws-card-name{font-size:16px;font-weight:500;margin-bottom:4px;}
.ws-card-count{font-size:12px;color:var(--text-tertiary);}
.ws-card-actions{display:flex;gap:4px;margin-top:8px;opacity:0;transition:opacity 150ms;}
.ws-card:hover .ws-card-actions{opacity:1;}
.dialog-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:500;}
.dialog-box{background:#1F1F1F;border:1px solid var(--border);border-radius:18px;padding:24px;width:400px;max-width:90vw;}
.dialog-box h3{margin-bottom:16px;}
.dialog-input{width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;margin-bottom:12px;outline:none;}
.dialog-input:focus{border-color:var(--accent);}
.dialog-actions{display:flex;gap:8px;justify-content:flex-end;}
.btn{padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
.btn-primary{background:var(--accent);color:#fff;}
.btn-primary:hover{background:var(--accent-hover);}
.btn-secondary{background:var(--bg-tertiary);color:var(--text);}
.btn-secondary:hover{background:var(--bg-hover);}
.btn-danger{background:rgba(255,80,80,.15);color:var(--danger);}
.btn-danger:hover{background:rgba(255,80,80,.25);}
.btn-new{border:1px dashed var(--border-strong);background:transparent;color:var(--text-secondary);display:flex;align-items:center;justify-content:center;min-height:120px;}
.btn-new:hover{background:var(--bg-tertiary);color:var(--text);}
</style>
<div class="ws-page" x-data="workspacesPage()">
<div class="ws-header">
<h1>🏠 Workspaces</h1>
<button class="btn btn-primary" @click="showCreate=true">+ New Workspace</button>
</div>
<div class="ws-grid">
<template x-for="ws in workspaces" :key="ws.id">
<div class="ws-card" :class="{active:ws.id===activeId}" @click="select(ws)">
<div class="ws-card-name" x-text="ws.name"></div>
<div class="ws-card-count" x-text="ws.page_count+' page(s)'"></div>
<div class="ws-card-actions" @click.stop>
<button class="btn btn-secondary" style="padding:4px 8px;font-size:11px" @click="renameWs(ws)">✏️</button>
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px" @click="deleteWs(ws)">🗑</button>
</div>
</div>
</template>
<div class="ws-card btn-new" @click="showCreate=true">
<span style="font-size:32px">+</span>
</div>
</div>
<!-- Create/Rename dialog -->
<div class="dialog-overlay" x-show="showCreate||showRename" @click.away="showCreate=false;showRename=false">
<div class="dialog-box">
<h3 x-text="showRename?'Rename Workspace':'New Workspace'"></h3>
<input class="dialog-input" x-model="wsName" placeholder="Workspace name" @keydown.enter="showRename?doRename():doCreate()">
<div class="dialog-actions">
<button class="btn btn-secondary" @click="showCreate=false;showRename=false">Cancel</button>
<button class="btn btn-primary" @click="showRename?doRename():doCreate()" x-text="showRename?'Rename':'Create'"></button>
</div>
</div>
</div>
</div>
<script>
function workspacesPage() {
return {
workspaces: [],
activeId: null,
showCreate: false,
showRename: false,
wsName: '',
renameTarget: null,
async init() {
await this.load();
},
async load() {
const r = await fetch('/api/workspaces');
const d = await r.json();
this.workspaces = d.workspaces || [];
this.activeId = d.active_id;
},
async select(ws) {
await fetch(`/api/workspaces/${ws.id}/select`, {method:'POST'});
if (ws.page_count > 0) {
window.location = '/local-workspace';
} else {
window.location = '/local-workspace';
}
},
async doCreate() {
if (!this.wsName.trim()) return;
await fetch('/api/workspaces', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
this.showCreate = false;
await this.load();
},
renameWs(ws) {
this.renameTarget = ws;
this.wsName = ws.name;
this.showRename = true;
},
async doRename() {
if (!this.wsName.trim()||!this.renameTarget) return;
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
this.showRename = false;
this.renameTarget = null;
await this.load();
},
async deleteWs(ws) {
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
await fetch(`/api/workspaces/${ws.id}`, {method:'DELETE'});
await this.load();
}
};
}
</script>
{% endblock %}