feat: local workspace — file/folder tree with CRUD, empty for local accounts
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- /local-workspace: page with file tree for local accounts (no forge)
- API: GET/POST/PUT/DELETE /api/local-workspace/items
- Workspace is empty when no pages exist, shows create buttons
- File tree shows nested items with rename/delete actions
- Local accounts are NOT linked to Gitea (separate workspace space)
This commit is contained in:
2026-07-10 16:23:14 -04:00
parent 47463a62e0
commit 3417506062
2 changed files with 238 additions and 0 deletions
+79
View File
@@ -454,3 +454,82 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
)
conn.commit()
return {"status": "ok"}
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
@router.get("/local-workspace", response_class=HTMLResponse)
async def local_workspace_page(request: Request):
"""Local workspace page with file/folder tree."""
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)
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_id IS NULL ORDER BY created_at DESC"
).fetchall()
items = [{"id": r["id"], "name": r["title"] or "Untitled", "type": "page"} for r in rows]
ctx = {**sidebar, "user": user, "workspace_name": "My Workspace", "items": items}
template = env.get_template("local_workspace.html")
return template.render(**ctx)
@router.get("/api/local-workspace/tree")
async def local_workspace_tree(request: Request):
"""Return the file/folder tree."""
with get_conn() as conn:
roots = conn.execute(
"SELECT id, title FROM pages WHERE parent_id IS NULL ORDER BY created_at DESC"
).fetchall()
tree = []
for r in roots:
with get_conn() as conn:
children = conn.execute(
"SELECT id, title FROM pages WHERE parent_id=? ORDER BY created_at", (r["id"],)
).fetchall()
item = {"id": r["id"], "name": r["title"] or "Untitled", "type": "page",
"children": [{"id": c["id"], "name": c["title"] or "Untitled", "type": "page"} for c in children]}
tree.append(item)
return {"tree": tree}
@router.post("/api/local-workspace/items")
async def create_local_workspace_item(request: Request):
"""Create a new file."""
import json
body = await request.json()
name = body.get("name", "Untitled").strip()
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, "type": "page"}
@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):
"""Delete a file (and its children)."""
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=? OR parent_id=?", (item_id, item_id))
conn.commit()
return {"status": "ok"}
+159
View File
@@ -0,0 +1,159 @@
{% extends "base.html" %}
{% block page_title %}My Workspace{% endblock %}
{% block page_icon %}📁{% endblock %}
{% block topbar %}
<header class="topbar">
<div class="topbar-left">
<span class="breadcrumb" style="color:rgba(255,255,255,.5)">Workspace</span>
<span class="page-title-topbar">{{ workspace_name }}</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-layout{max-width:800px;margin:0 auto;padding:40px 24px;}
.ws-header{margin-bottom:32px;}
.ws-header h1{font-size:24px;margin-bottom:4px;}
.ws-header p{color:var(--text-secondary);font-size:13px;}
.ws-tree{margin-top:20px;}
.ws-tree-item{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:6px;cursor:pointer;font-size:14px;color:var(--text-secondary);transition:background 150ms;}
.ws-tree-item:hover{background:var(--bg-tertiary);color:var(--text);}
.ws-tree-item.folder{font-weight:500;}
.ws-tree-item.file{margin-left:24px;}
.ws-tree-item .icon{font-size:16px;width:20px;text-align:center;}
.ws-tree-item .name{flex:1;}
.ws-tree-item .actions{display:none;gap:4px;}
.ws-tree-item:hover .actions{display:flex;}
.ws-action-btn{background:none;border:none;color:var(--text-tertiary);cursor:pointer;padding:4px 6px;border-radius:4px;font-size:12px;}
.ws-action-btn:hover{background:var(--bg-hover);color:var(--text);}
.ws-action-btn.danger:hover{color:var(--danger);}
.empty-workspace{text-align:center;padding:60px 20px;color:var(--text-tertiary);}
.empty-workspace .icon{font-size:48px;margin-bottom:12px;}
.empty-workspace h2{font-size:16px;color:var(--text-secondary);margin-bottom:8px;}
.empty-workspace p{font-size:13px;margin-bottom:20px;}
.empty-workspace .actions{display:flex;gap:8px;justify-content:center;flex-wrap:wrap;}
.ws-btn{padding:10px 20px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
.ws-btn-primary{background:var(--accent);color:#fff;}
.ws-btn-primary:hover{background:var(--accent-hover);}
.ws-btn-secondary{background:var(--bg-tertiary);color:var(--text);border:1px solid var(--border);}
.ws-btn-secondary:hover{background:var(--bg-hover);}
.inline-edit{background:transparent;border:1px solid var(--accent);border-radius:4px;padding:4px 8px;color:var(--text);font-size:14px;outline:none;width:200px;}
</style>
<div class="ws-layout" x-data="workspaceEditor()">
<div class="ws-header">
<h1>📁 {{ workspace_name }}</h1>
<p>{{ items | length }} item(s)</p>
</div>
<!-- Toolbar -->
<div style="display:flex;gap:8px;margin-bottom:20px;">
<button class="ws-btn ws-btn-primary" @click="createItem('page')">📄 New File</button>
<button class="ws-btn ws-btn-secondary" @click="createItem('folder')">📁 New Folder</button>
</div>
<!-- Tree -->
<div class="ws-tree" x-show="tree.length">
<template x-for="item in tree" :key="item.id">
<div>
<div class="ws-tree-item" :class="item.type">
<span class="icon" x-text="item.type==='folder'?'📁':'📄'"></span>
<span class="name" x-text="item.name" @dblclick="rename(item)"></span>
<div class="actions">
<button class="ws-action-btn" @click="openItem(item)">Open</button>
<button class="ws-action-btn" @click="rename(item)">✏️</button>
<button class="ws-action-btn danger" @click="deleteItem(item)">🗑</button>
</div>
</div>
<!-- Nested items -->
<template x-if="item.type==='folder'&&item.children.length">
<div style="margin-left:24px;">
<template x-for="child in item.children" :key="child.id">
<div class="ws-tree-item file" @click="openItem(child)">
<span class="icon">📄</span>
<span class="name" x-text="child.name"></span>
<div class="actions">
<button class="ws-action-btn" @click.stop="rename(child)">✏️</button>
<button class="ws-action-btn danger" @click.stop="deleteItem(child)">🗑</button>
</div>
</div>
</template>
</div>
</template>
</div>
</template>
</div>
<!-- Empty state -->
<div class="empty-workspace" x-show="!tree.length">
<div class="icon">📁</div>
<h2>Empty workspace</h2>
<p>Create a new file or folder to get started</p>
<div class="actions">
<button class="ws-btn ws-btn-primary" @click="createItem('page')">📄 New File</button>
<button class="ws-btn ws-btn-secondary" @click="createItem('folder')">📁 New Folder</button>
</div>
</div>
</div>
<script>
function workspaceEditor() {
return {
tree: [],
async init() {
await this.loadTree();
},
async loadTree() {
try {
const r = await fetch('/api/local-workspace/tree');
const data = await r.json();
this.tree = data.tree || [];
} catch(e) { console.error(e); }
},
async createItem(type) {
const name = prompt(type==='folder'?'Folder name:':'File name:');
if (!name || !name.trim()) return;
const r = await fetch('/api/local-workspace/items', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:name.trim(), type})
});
if (r.ok) await this.loadTree();
},
openItem(item) {
if (item.type === 'folder') {
// Expand/collapse folder
} else {
window.location = `/pages/${item.id}`;
}
},
async rename(item) {
const name = prompt('New name:', item.name);
if (!name || !name.trim()) return;
const r = await fetch(`/api/local-workspace/items/${item.id}`, {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:name.trim()})
});
if (r.ok) await this.loadTree();
},
async deleteItem(item) {
if (!confirm(`Delete "${item.name}"?`)) return;
const r = await fetch(`/api/local-workspace/items/${item.id}`, {method:'DELETE'});
if (r.ok) await this.loadTree();
}
};
}
</script>
{% endblock %}