fix: workspace page redesign — modals, folder distinction, no duplication
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped

- Complete redesign of local_workspace.html
- Modal dialogs (Create File, Create Folder, Rename, Delete) — no prompt()
- Folder creation fixed: parent_section='Workspace' → 📁 icon
- Tree API properly detects folders via parent_section
- No duplicate empty state — unified header + empty message
- Professional UI with backdrop blur, focus auto, disabled btn
This commit is contained in:
2026-07-10 20:10:35 -04:00
parent 1639884800
commit eddb69e58e
2 changed files with 185 additions and 101 deletions
+4 -2
View File
@@ -528,9 +528,11 @@ async def local_workspace_tree(request: Request):
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"],)
"SELECT id, title, parent_section FROM pages WHERE parent_id=? ORDER BY created_at", (r["id"],)
).fetchall()
item = {"id": r["id"], "name": r["title"] or "Untitled", "type": "page",
is_folder = r.get("parent_section") == "Workspace"
rtype = "folder" if is_folder else "page"
item = {"id": r["id"], "name": r["title"] or "Untitled", "type": rtype,
"children": [{"id": c["id"], "name": c["title"] or "Untitled", "type": "page"} for c in children]}
tree.append(item)
return {"tree": tree}
+181 -99
View File
@@ -1,15 +1,16 @@
{% extends "base.html" %}
{% block page_title %}My Workspace{% endblock %}
{% block page_title %}{{ workspace_name }}{% 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>
<span class="breadcrumb" style="color:rgba(255,255,255,.5)">{{ workspace_name }}</span>
<span class="page-title-topbar">Files</span>
</div>
<div class="topbar-right">
<button class="page-action-btn" onclick="window.location='/accounts/settings'" title="Settings">⚙</button>
<button class="page-action-btn" title="New File" @click="showFileModal=true">📄 New File</button>
<button class="page-action-btn" title="New Folder" @click="showFolderModal=true">📁 New Folder</button>
</div>
</header>
{% endblock %}
@@ -17,141 +18,222 @@
{% block content %}
<style>
.ws-layout{max-width:800px;margin:0 auto;padding:40px 24px;}
.ws-header{margin-bottom:32px;}
.ws-header{margin-bottom:24px;}
.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);}
.ws-item{display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:8px;cursor:pointer;transition:background 150ms;border:1px solid transparent;}
.ws-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
.ws-item .icon{font-size:18px;width:24px;text-align:center;}
.ws-item .name{flex:1;font-size:14px;color:var(--text);}
.ws-item .actions{display:flex;gap:4px;opacity:0;transition:opacity 150ms;}
.ws-item:hover .actions{opacity:1;}
.ws-act{background:none;border:none;color:var(--text-tertiary);cursor:pointer;padding:4px 8px;border-radius:4px;font-size:12px;}
.ws-act:hover{background:var(--bg-hover);color:var(--text);}
.ws-act.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;}
.empty-state{text-align:center;padding:80px 20px;color:var(--text-tertiary);}
.empty-state .empty-icon{font-size:64px;margin-bottom:16px;opacity:.5;}
.empty-state h2{font-size:18px;color:var(--text-secondary);margin-bottom:8px;}
.empty-state p{font-size:13px;margin-bottom:24px;max-width:400px;margin-left:auto;margin-right:auto;}
.empty-actions{display:flex;gap:10px;justify-content:center;}
.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);}
.btn{padding:10px 20px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;transition:background 150ms;}
.btn-primary{background:var(--accent);color:#fff;}
.btn-primary:hover{background:var(--accent-hover);}
.btn-secondary{background:var(--bg-tertiary);color:var(--text);border:1px solid var(--border);}
.btn-secondary:hover{background:var(--bg-hover);}
.btn-danger{background:rgba(255,80,80,.1);color:var(--danger);}
.btn-danger:hover{background:rgba(255,80,80,.2);}
.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;}
/* Modal */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:500;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-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;}
.modal-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(35,131,226,.15);}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:20px;}
/* Delete confirm */
.delete-confirm{padding:12px;background:rgba(255,80,80,.08);border-radius:8px;margin-top:12px;font-size:13px;color:var(--text-secondary);}
</style>
<div class="ws-layout" x-data="workspaceEditor()">
<div class="ws-layout" x-data="wsPage()">
<div class="ws-header">
<h1>📁 {{ workspace_name }}</h1>
<p>{{ items | length }} item(s)</p>
<p x-text="items.length+' item(s)'">{{ 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>
<!-- File/Folder list -->
<template x-if="items.length > 0">
<div>
<template x-for="item in items" :key="item.id">
<div class="ws-item">
<span class="icon" x-text="item.icon||'📄'"></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>
<button class="ws-act" @click="openItem(item)" title="Open">Open</button>
<button class="ws-act" @click="rename(item)" title="Rename">✏️</button>
<button class="ws-act danger" @click="confirmDelete(item)" title="Delete">🗑</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>
</template>
</div>
</template>
<!-- Empty state -->
<div class="empty-workspace" x-show="!tree.length">
<div class="icon">📁</div>
<div class="empty-state" x-show="!items.length">
<div class="empty-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>
<p>Create a new file to start writing, or a folder to organize your content</p>
<div class="empty-actions">
<button class="btn btn-primary" @click="showFileModal=true">📄 New File</button>
<button class="btn btn-secondary" @click="showFolderModal=true">📁 New Folder</button>
</div>
</div>
<!-- Create File Modal -->
<div class="modal-overlay" x-show="showFileModal" @click.away="showFileModal=false" @keydown.escape="showFileModal=false">
<div class="modal-box">
<h3>📄 New File</h3>
<p class="modal-sub">Create a new document in <strong>{{ workspace_name }}</strong></p>
<input class="modal-input" x-model="newName" placeholder="File name" @keydown.enter="createFile" x-ref="fileInput" x-init="$watch('showFileModal',v=>{if(v)setTimeout(()=>$refs.fileInput.focus(),100)})">
<div class="modal-actions">
<button class="btn btn-secondary" @click="showFileModal=false">Cancel</button>
<button class="btn btn-primary" @click="createFile" :disabled="!newName.trim()">Create File</button>
</div>
</div>
</div>
<!-- Create Folder Modal -->
<div class="modal-overlay" x-show="showFolderModal" @click.away="showFolderModal=false" @keydown.escape="showFolderModal=false">
<div class="modal-box">
<h3>📁 New Folder</h3>
<p class="modal-sub">Create a new folder in <strong>{{ workspace_name }}</strong></p>
<input class="modal-input" x-model="newName" placeholder="Folder name" @keydown.enter="createFolder" x-ref="folderInput" x-init="$watch('showFolderModal',v=>{if(v)setTimeout(()=>$refs.folderInput.focus(),100)})">
<div class="modal-actions">
<button class="btn btn-secondary" @click="showFolderModal=false">Cancel</button>
<button class="btn btn-primary" @click="createFolder" :disabled="!newName.trim()">Create Folder</button>
</div>
</div>
</div>
<!-- Rename Modal -->
<div class="modal-overlay" x-show="showRenameModal" @click.away="showRenameModal=false" @keydown.escape="showRenameModal=false">
<div class="modal-box">
<h3>✏️ Rename</h3>
<p class="modal-sub">Rename <strong x-text="renameTarget?.name"></strong></p>
<input class="modal-input" x-model="newName" placeholder="New name" @keydown.enter="doRename" x-ref="renameInput" x-init="$watch('showRenameModal',v=>{if(v)setTimeout(()=>$refs.renameInput.focus(),100)})">
<div class="modal-actions">
<button class="btn btn-secondary" @click="showRenameModal=false">Cancel</button>
<button class="btn btn-primary" @click="doRename">Rename</button>
</div>
</div>
</div>
<!-- Delete Confirm Modal -->
<div class="modal-overlay" x-show="showDeleteModal" @click.away="showDeleteModal=false" @keydown.escape="showDeleteModal=false">
<div class="modal-box">
<h3>🗑 Delete</h3>
<p class="modal-sub">Are you sure you want to delete <strong x-text="deleteTarget?.name"></strong>?</p>
<div class="delete-confirm">This action cannot be undone. All content will be permanently deleted.</div>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showDeleteModal=false">Cancel</button>
<button class="btn btn-danger" @click="doDelete">Delete</button>
</div>
</div>
</div>
</div>
<script>
function workspaceEditor() {
function wsPage() {
return {
tree: [],
items: [],
showFileModal: false,
showFolderModal: false,
showRenameModal: false,
showDeleteModal: false,
newName: '',
renameTarget: null,
deleteTarget: null,
async init() {
await this.loadTree();
await this.load();
},
async loadTree() {
async load() {
try {
const r = await fetch('/api/local-workspace/tree');
const data = await r.json();
this.tree = data.tree || [];
// Flatten tree for display
const flat = [];
for (const node of (data.tree || [])) {
flat.push({
id: node.id, name: node.name,
icon: node.type === 'folder' ? '📁' : (node.icon || '📄'),
type: node.type || 'page'
});
for (const child of (node.children || [])) {
flat.push({
id: child.id, name: ' ' + child.name,
icon: child.type === 'folder' ? '📁' : '📄',
type: child.type || 'page'
});
}
}
this.items = flat;
} 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})
async createFile() {
if (!this.newName.trim()) return;
await fetch('/api/local-workspace/items', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:this.newName.trim(), type:'page'})
});
if (r.ok) await this.loadTree();
this.newName = ''; this.showFileModal = false;
await this.load();
},
openItem(item) {
if (item.type === 'folder') {
// Expand/collapse folder
async createFolder() {
if (!this.newName.trim()) return;
const r = await fetch('/api/local-workspace/items', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:this.newName.trim(), type:'folder'})
});
if (r.ok) {
this.newName = ''; this.showFolderModal = false;
await this.load();
} else {
window.location = `/pages/${item.id}`;
alert('Failed to create folder');
}
},
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();
openItem(item) {
window.location = `/pages/${item.id}`;
},
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();
rename(item) {
this.renameTarget = item;
this.newName = item.name.trim();
this.showRenameModal = true;
},
async doRename() {
if (!this.newName.trim() || !this.renameTarget) return;
await fetch(`/api/local-workspace/items/${this.renameTarget.id}`, {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:this.newName.trim()})
});
this.newName = ''; this.showRenameModal = false; this.renameTarget = null;
await this.load();
},
confirmDelete(item) {
this.deleteTarget = item;
this.showDeleteModal = true;
},
async doDelete() {
if (!this.deleteTarget) return;
await fetch(`/api/local-workspace/items/${this.deleteTarget.id}`, {method:'DELETE'});
this.showDeleteModal = false; this.deleteTarget = null;
await this.load();
}
};
}