fix: rewrite workspace page — simplified Alpine, no syntax errors
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

Complete rewrite of local_workspace.html with:
- flatten() as standalone function (not in Alpine component)
- wsInit() uses x-init for async loading
- No optional chaining or template literals (broader compat)
- :key uses array index to avoid undefined keys
- Simple var declarations throughout
- All modals unified with x-show toggles
This commit is contained in:
2026-07-10 21:48:21 -04:00
parent 05369f1856
commit 1e0afb0e69
+114 -121
View File
@@ -20,17 +20,12 @@
.ws-toolbar{display:flex;gap:8px;} .ws-toolbar{display:flex;gap:8px;}
.ws-tree{list-style:none;margin:0;padding:0;} .ws-tree{list-style:none;margin:0;padding:0;}
.ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:grab;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;} .ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
.ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);} .ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
.ws-tree-item.dragging{opacity:.3;}
.ws-tree-item.drop-target{background:rgba(35,131,226,.1);outline:2px dashed var(--accent);}
.ws-tree-item .icon{font-size:16px;width:20px;text-align:center;flex-shrink:0;} .ws-tree-item .icon{font-size:16px;width:20px;text-align:center;flex-shrink:0;}
.ws-tree-item .name{flex:1;font-size:14px;color:var(--text);} .ws-tree-item .name{flex:1;font-size:14px;color:var(--text);}
.ws-tree-item .actions{display:none;gap:2px;} .ws-tree-item .actions{display:none;gap:2px;}
.ws-tree-item:hover .actions{display:flex;} .ws-tree-item:hover .actions{display:flex;}
.ws-tree-item .chevron{width:14px;font-size:11px;color:var(--text-tertiary);cursor:pointer;flex-shrink:0;}
.ws-tree-item .chevron:hover{color:var(--text);}
.ws-tree-children{margin:0;padding:0;}
.ws-act{background:none;border:none;color:var(--text-tertiary);cursor:pointer;padding:3px 6px;border-radius:4px;font-size:11px;} .ws-act{background:none;border:none;color:var(--text-tertiary);cursor:pointer;padding:3px 6px;border-radius:4px;font-size:11px;}
.ws-act:hover{background:var(--bg-hover);color:var(--text);} .ws-act:hover{background:var(--bg-hover);color:var(--text);}
@@ -60,93 +55,86 @@
.delete-confirm{padding:12px;background:rgba(255,80,80,.08);border-radius:8px;margin-top:12px;font-size:13px;color:var(--text-secondary);} .delete-confirm{padding:12px;background:rgba(255,80,80,.08);border-radius:8px;margin-top:12px;font-size:13px;color:var(--text-secondary);}
</style> </style>
<div class="ws-layout" x-data="wsPage()" @dragover.prevent> <div class="ws-layout"
x-data="{tree:[],loaded:false,showCreate:false,showRename:false,showDelete:false,createType:'file',newName:'',parentFolder:null,target:null}"
x-init="init()">
<div class="ws-header"> <div class="ws-header">
<h1>📁 {{ workspace_name }}</h1> <h1>📁 {{ workspace_name }}</h1>
<div class="ws-toolbar"> <div class="ws-toolbar">
<button class="btn btn-primary" @click="openCreateModal('file')">📄 New File</button> <button class="btn btn-primary" @click="openModal('file')">📄 New File</button>
<button class="btn btn-secondary" @click="openCreateModal('folder')">📁 New Folder</button> <button class="btn btn-secondary" @click="openModal('folder')">📁 New Folder</button>
</div> </div>
</div> </div>
<!-- Recursive tree --> <!-- Tree -->
<ul class="ws-tree" x-show="tree.length && _loaded"> <ul class="ws-tree" x-show="tree.length>0">
<template x-for="(node, idx) in tree" :key="node.db_id || 'item-'+idx"> <template x-for="(node,i) in tree" :key="i">
<li> <li>
<div class="ws-tree-item" <div class="ws-tree-item" :style="{paddingLeft:(8+node.depth*20)+'px'}">
:data-id="node.db_id"
:data-is-folder="node.is_folder"
draggable="true"
:style="{paddingLeft: (8 + node.depth * 20) + 'px'}"
@dragstart="dragStart($event, node.db_id)"
@dragover.prevent="dragOver($event, node.db_id, node.is_folder)"
@dragleave="dragLeave($event)"
@drop.prevent="doDrop($event, node.db_id)">
<span class="chevron" x-show="node.is_folder" @click.stop="toggleFolder(node)">▶</span>
<span class="icon" x-text="node.icon"></span> <span class="icon" x-text="node.icon"></span>
<span class="name" x-text="node.name" @dblclick="rename(node)"></span> <span class="name" x-text="node.name"></span>
<div class="actions"> <div class="actions">
<template x-if="node.is_folder"> <template x-if="node.is_folder">
<div style="display:flex;gap:2px;"> <span style="display:flex;gap:2px">
<button class="ws-act" @click.stop="openCreateModal('file', node)" title="New file">📄</button> <button class="ws-act" @click.stop="openModal('file',node)" title="New file">📄</button>
<button class="ws-act" @click.stop="openCreateModal('folder', node)" title="New folder">📁</button> <button class="ws-act" @click.stop="openModal('folder',node)" title="New folder">📁</button>
</div> </span>
</template> </template>
<button class="ws-act" @click.stop="openItem(node)" title="Open">Open</button> <button class="ws-act" @click.stop="openPage(node.db_id)" title="Open">Open</button>
<button class="ws-act" @click.stop="rename(node)" title="Rename">✏️</button> <button class="ws-act" @click.stop="startRename(node)" title="Rename">✏️</button>
<button class="ws-act danger" @click.stop="confirmDelete(node)" title="Delete">🗑</button> <button class="ws-act danger" @click.stop="startDelete(node)" title="Delete">🗑</button>
</div> </div>
</div> </div>
</li> </li>
</template> </template>
</ul> </ul>
<!-- Empty state --> <!-- Empty -->
<div class="empty-state" x-show="!tree.length"> <div class="empty-state" x-show="tree.length===0&&loaded">
<div class="empty-icon">📁</div> <div class="empty-icon">📁</div>
<h2>Empty workspace</h2> <h2>Empty workspace</h2>
<p>Create a new file or folder to get started</p> <p>Create a new file or folder to get started</p>
<div class="empty-actions"> <div class="empty-actions">
<button class="btn btn-primary" @click="openCreateModal('file')">📄 New File</button> <button class="btn btn-primary" @click="openModal('file')">📄 New File</button>
<button class="btn btn-secondary" @click="openCreateModal('folder')">📁 New Folder</button> <button class="btn btn-secondary" @click="openModal('folder')">📁 New Folder</button>
</div> </div>
</div> </div>
<!-- Create Modal --> <!-- Create Modal -->
<div class="modal-overlay" x-show="showCreateModal" @click.away="showCreateModal=false" @keydown.escape="showCreateModal=false"> <div class="modal-overlay" x-show="showCreate" @click.away="showCreate=false" @keydown.escape="showCreate=false">
<div class="modal-box"> <div class="modal-box">
<h3><span x-text="createType==='folder'?'📁':'📄'"></span> <span x-text="createType==='folder'?'New Folder':'New File'"></span></h3> <h3><span x-text="createType==='folder'?'📁':'📄'"></span> <span x-text="createType==='folder'?'New Folder':'New File'"></span></h3>
<p class="modal-sub">Create in <strong>{{ workspace_name }}</strong><span x-show="parentFolder"> / <span x-text="parentFolder?.name"></span></span></p> <p class="modal-sub">In <strong>{{ workspace_name }}</strong><span x-show="parentFolder"> / <span x-text="parentFolder?.name"></span></span></p>
<input class="modal-input" x-model="newName" :placeholder="createType==='folder'?'Folder name':'File name'" @keydown.enter="doCreate" x-ref="createInput"> <input class="modal-input" x-model="newName" :placeholder="createType==='folder'?'Folder name':'File name'" @keydown.enter="doCreate" x-ref="cinp">
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" @click="showCreateModal=false">Cancel</button> <button class="btn btn-secondary" @click="showCreate=false">Cancel</button>
<button class="btn btn-primary" @click="doCreate" :disabled="!newName.trim()">Create</button> <button class="btn btn-primary" @click="doCreate" :disabled="!newName.trim()">Create</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Rename Modal --> <!-- Rename Modal -->
<div class="modal-overlay" x-show="showRenameModal" @click.away="showRenameModal=false" @keydown.escape="showRenameModal=false"> <div class="modal-overlay" x-show="showRename" @click.away="showRename=false" @keydown.escape="showRename=false">
<div class="modal-box"> <div class="modal-box">
<h3>✏️ Rename</h3> <h3>✏️ Rename</h3>
<p class="modal-sub">Rename <strong x-text="renameTarget?.name"></strong></p> <p class="modal-sub">Rename <strong x-text="target?.name"></strong></p>
<input class="modal-input" x-model="newName" @keydown.enter="doRename" x-ref="renameInput"> <input class="modal-input" x-model="newName" @keydown.enter="doRename" x-ref="rinp">
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" @click="showRenameModal=false">Cancel</button> <button class="btn btn-secondary" @click="showRename=false">Cancel</button>
<button class="btn btn-primary" @click="doRename">Rename</button> <button class="btn btn-primary" @click="doRename">Rename</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Delete Modal --> <!-- Delete Modal -->
<div class="modal-overlay" x-show="showDeleteModal" @click.away="showDeleteModal=false" @keydown.escape="showDeleteModal=false"> <div class="modal-overlay" x-show="showDelete" @click.away="showDelete=false" @keydown.escape="showDelete=false">
<div class="modal-box"> <div class="modal-box">
<h3>🗑 Delete</h3> <h3>🗑 Delete</h3>
<p class="modal-sub">Delete <strong x-text="deleteTarget?.name"></strong>?</p> <p class="modal-sub">Delete <strong x-text="target?.name"></strong>?</p>
<div class="delete-confirm">This cannot be undone. All content will be permanently deleted.</div> <div class="delete-confirm">This cannot be undone.</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" @click="showDeleteModal=false">Cancel</button> <button class="btn btn-secondary" @click="showDelete=false">Cancel</button>
<button class="btn btn-danger" @click="doDelete">Delete</button> <button class="btn btn-danger" @click="doDelete">Delete</button>
</div> </div>
</div> </div>
@@ -155,105 +143,110 @@
</div> </div>
<script> <script>
function wsPage() { function wsInit() {
return { return {
tree: [], tree:[],
showCreateModal: false, showRenameModal: false, showDeleteModal: false, loaded:false,
createType: 'file', newName: '', parentFolder: null, showCreate:false,
renameTarget: null, deleteTarget: null, showRename:false,
expandedFolders: {}, showDelete:false,
_loaded: false, createType:'file',
newName:'',
parentFolder:null,
target:null,
async init() { await this.load(); }, async init() {
async load() {
try { try {
const r = await fetch('/api/local-workspace/tree'); var r = await fetch('/api/local-workspace/tree');
const data = await r.json(); var d = await r.json();
this.tree = this.flattenTree(data.tree || [], 0); this.tree = flatten(d.tree||[],0);
this._loaded = true; this.loaded = true;
} catch(e) { console.error(e); this._loaded = true; } } catch(e) { this.loaded = true; }
}, },
flattenTree(nodes, depth) { openModal(type, parent) {
const result = []; this.createType = type;
for (const node of nodes) { this.parentFolder = parent||null;
const isFolder = node.type === 'folder'; this.newName = '';
result.push({ this.showCreate = true;
db_id: node.id, name: node.name, icon: isFolder ? '📁' : '📄', var self = this;
is_folder: isFolder, depth: depth, this.$nextTick(function() {
}); var el = self.$refs.cinp;
if (node.children) { if (el) setTimeout(function() { el.focus(); }, 100);
result.push(...this.flattenTree(node.children, depth + 1)); });
}
}
return result;
},
toggleFolder(node) { /* Future: expand/collapse */ },
openCreateModal(type, parent) {
this.createType = type; this.parentFolder = parent || null; this.newName = '';
this.showCreateModal = true;
this.$nextTick(() => { const el = this.$refs.createInput; if (el) setTimeout(() => el.focus(), 100); });
}, },
async doCreate() { async doCreate() {
if (!this.newName.trim()) return; var n = this.newName.trim();
const body = { name: this.newName.trim(), type: this.createType }; if (!n) return;
var body = { name:n, type:this.createType };
if (this.parentFolder) body.parent_id = this.parentFolder.db_id; if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
const r = await fetch('/api/local-workspace/items', { var r = await fetch('/api/local-workspace/items', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(body)
}); });
if (r.ok) { if (r.ok) {
this.newName = ''; this.showCreateModal = false; this.parentFolder = null; this.showCreate = false;
this.parentFolder = null;
this.newName = '';
window.location.reload(); window.location.reload();
} else { alert('Failed to create'); } } else {
var e = await r.json();
alert(e.error||'Failed');
}
}, },
openItem(node) { window.location = `/pages/${node.db_id}`; }, openPage(id) { window.location = '/pages/'+id; },
rename(node) { startRename(node) {
this.renameTarget = node; this.newName = node.name.trim(); this.target = node;
this.showRenameModal = true; this.newName = node.name.trim();
this.$nextTick(() => { const el = this.$refs.renameInput; if (el) setTimeout(() => el.focus(), 100); }); this.showRename = true;
var self = this;
this.$nextTick(function() {
var el = self.$refs.rinp;
if (el) setTimeout(function() { el.focus(); }, 100);
});
}, },
async doRename() { async doRename() {
if (!this.newName.trim() || !this.renameTarget) return; var n = this.newName.trim();
await fetch(`/api/local-workspace/items/${this.renameTarget.db_id}`, { if (!n||!this.target) return;
method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name:this.newName.trim()}) await fetch('/api/local-workspace/items/'+this.target.db_id, {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:n})
}); });
this.showRenameModal = false; this.renameTarget = null; this.newName = ''; this.showRename = false;
await this.load(); this.target = null;
this.newName = '';
await this.init();
}, },
confirmDelete(node) { this.deleteTarget = node; this.showDeleteModal = true; }, startDelete(node) { this.target = node; this.showDelete = true; },
async doDelete() { async doDelete() {
if (!this.deleteTarget) return; if (!this.target) return;
await fetch(`/api/local-workspace/items/${this.deleteTarget.db_id}`, {method:'DELETE'}); await fetch('/api/local-workspace/items/'+this.target.db_id, {method:'DELETE'});
this.showDeleteModal = false; this.deleteTarget = null; this.showDelete = false;
await this.load(); this.target = null;
}, await this.init();
}
// ── Drag & Drop ──
dragStart(ev, id) { ev.dataTransfer.setData('text/plain', id); ev.target.classList.add('dragging'); },
dragOver(ev, id, isFolder) { if (isFolder) ev.target.closest('.ws-tree-item')?.classList.add('drop-target'); },
dragLeave(ev) { ev.target.closest('.ws-tree-item')?.classList.remove('drop-target'); },
async doDrop(ev, targetId) {
ev.target.closest('.ws-tree-item')?.classList.remove('drop-target');
const draggedId = ev.dataTransfer.getData('text/plain');
if (!draggedId || draggedId == targetId) return;
const isFolder = ev.target.closest('.ws-tree-item')?.dataset?.isFolder === 'true';
const parentId = isFolder && draggedId != targetId ? parseInt(targetId) : null;
const r = await fetch(`/api/local-workspace/items/${draggedId}/move`, {
method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({parent_id: parentId})
});
if (r.ok) window.location.reload();
else alert('Move failed');
},
}; };
} }
function flatten(nodes, depth) {
var result = [];
for (var i=0; i<nodes.length; i++) {
var n = nodes[i];
var isFolder = n.type==='folder';
result.push({db_id:n.id, name:n.name, icon:isFolder?'📁':'📄', is_folder:isFolder, depth:depth});
if (n.children) {
result = result.concat(flatten(n.children, depth+1));
}
}
return result;
}
</script> </script>
{% endblock %} {% endblock %}