feat: workspaces system — create, select, rename, delete workspaces
- 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:
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user