feat: workspace page gets recursive tree + drag & drop + sub-folders
- Recursive tree rendering with depth-based indentation - HTML5 drag & drop: move files into folders, reorder - Folder hover: 📄 new file + 📁 sub-folder buttons - Uses same /api/local-workspace/items/{id}/move endpoint - flattenTree() utility for Alpine.js tree rendering - Visual: dragging opacity + drop target border
This commit is contained in:
@@ -19,13 +19,20 @@
|
||||
.ws-header h1{font-size:24px;}
|
||||
.ws-toolbar{display:flex;gap:8px;}
|
||||
|
||||
.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-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: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 .name{flex:1;font-size:14px;color:var(--text);}
|
||||
.ws-tree-item .actions{display:none;gap:2px;}
|
||||
.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:hover{background:var(--bg-hover);color:var(--text);}
|
||||
.ws-act.danger:hover{color:var(--danger);}
|
||||
|
||||
@@ -43,7 +50,6 @@
|
||||
.btn-danger{background:rgba(255,80,80,.1);color:var(--danger);}
|
||||
.btn-danger:hover{background:rgba(255,80,80,.2);}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:999;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;}
|
||||
@@ -54,7 +60,7 @@
|
||||
.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="wsPage()">
|
||||
<div class="ws-layout" x-data="wsPage()" @dragover.prevent>
|
||||
|
||||
<div class="ws-header">
|
||||
<h1>📁 {{ workspace_name }}</h1>
|
||||
@@ -64,43 +70,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File/Folder list -->
|
||||
<div x-show="items.length">
|
||||
<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-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>
|
||||
<!-- Recursive tree -->
|
||||
<ul class="ws-tree" x-show="tree.length">
|
||||
<template x-for="node in tree" :key="node.id">
|
||||
<li>
|
||||
<div class="ws-tree-item"
|
||||
: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="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="name" x-text="node.name" @dblclick="rename(node)"></span>
|
||||
<div class="actions">
|
||||
<template x-if="node.is_folder">
|
||||
<div style="display:flex;gap:2px;">
|
||||
<button class="ws-act" @click.stop="openCreateModal('file', node)" title="New file">📄</button>
|
||||
<button class="ws-act" @click.stop="openCreateModal('folder', node)" title="New folder">📁</button>
|
||||
</div>
|
||||
</template>
|
||||
<button class="ws-act" @click.stop="openItem(node)" title="Open">Open</button>
|
||||
<button class="ws-act" @click.stop="rename(node)" title="Rename">✏️</button>
|
||||
<button class="ws-act danger" @click.stop="confirmDelete(node)" title="Delete">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div class="empty-state" x-show="!items.length">
|
||||
<div class="empty-state" x-show="!tree.length">
|
||||
<div class="empty-icon">📁</div>
|
||||
<h2>Empty workspace</h2>
|
||||
<p>Create a new file to start writing, or a folder to organize your content</p>
|
||||
<p>Create a new file or folder to get started</p>
|
||||
<div class="empty-actions">
|
||||
<button class="btn btn-primary" @click="openCreateModal('file')">📄 New File</button>
|
||||
<button class="btn btn-secondary" @click="openCreateModal('folder')">📁 New Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Modal (shared for file and folder) -->
|
||||
<!-- Create Modal -->
|
||||
<div class="modal-overlay" x-show="showCreateModal" @click.away="showCreateModal=false" @keydown.escape="showCreateModal=false">
|
||||
<div class="modal-box">
|
||||
<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>
|
||||
<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>
|
||||
<input class="modal-input" x-model="newName" :placeholder="createType==='folder'?'Folder name':'File name'" @keydown.enter="doCreate" x-ref="createInput">
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showCreateModal=false">Cancel</button>
|
||||
@@ -122,7 +139,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirm Modal -->
|
||||
<!-- Delete Modal -->
|
||||
<div class="modal-overlay" x-show="showDeleteModal" @click.away="showDeleteModal=false" @keydown.escape="showDeleteModal=false">
|
||||
<div class="modal-box">
|
||||
<h3>🗑 Delete</h3>
|
||||
@@ -140,15 +157,11 @@
|
||||
<script>
|
||||
function wsPage() {
|
||||
return {
|
||||
items: [],
|
||||
showCreateModal: false,
|
||||
showRenameModal: false,
|
||||
showDeleteModal: false,
|
||||
createType: 'file',
|
||||
newName: '',
|
||||
parentFolder: null,
|
||||
renameTarget: null,
|
||||
deleteTarget: null,
|
||||
tree: [],
|
||||
showCreateModal: false, showRenameModal: false, showDeleteModal: false,
|
||||
createType: 'file', newName: '', parentFolder: null,
|
||||
renameTarget: null, deleteTarget: null,
|
||||
expandedFolders: {},
|
||||
|
||||
async init() { await this.load(); },
|
||||
|
||||
@@ -156,88 +169,87 @@ function wsPage() {
|
||||
try {
|
||||
const r = await fetch('/api/local-workspace/tree');
|
||||
const data = await r.json();
|
||||
const flat = [];
|
||||
for (const node of (data.tree || [])) {
|
||||
flat.push({id:node.id, name:node.name, icon:node.type==='folder'?'📁':'📄', type:node.type, parentId:null});
|
||||
for (const child of (node.children || [])) {
|
||||
flat.push({id:child.id, name:' '+child.name, icon:'📄', type:'page', parentId:node.id});
|
||||
}
|
||||
}
|
||||
this.items = flat;
|
||||
this.tree = this.flattenTree(data.tree || [], 0);
|
||||
} catch(e) { console.error(e); }
|
||||
},
|
||||
|
||||
flattenTree(nodes, depth) {
|
||||
const result = [];
|
||||
for (const node of nodes) {
|
||||
result.push({
|
||||
db_id: node.id, name: node.name, icon: node.is_folder ? '📁' : '📄',
|
||||
is_folder: node.is_folder, depth: depth,
|
||||
});
|
||||
if (node.children) {
|
||||
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.createType = type; this.parentFolder = parent || null; this.newName = '';
|
||||
this.showCreateModal = true;
|
||||
this.$nextTick(() => {
|
||||
const el = this.$refs.createInput;
|
||||
if (el) setTimeout(() => el.focus(), 100);
|
||||
});
|
||||
this.$nextTick(() => { const el = this.$refs.createInput; if (el) setTimeout(() => el.focus(), 100); });
|
||||
},
|
||||
|
||||
async doCreate() {
|
||||
if (!this.newName.trim()) return;
|
||||
const body = { name: this.newName.trim(), type: this.createType };
|
||||
if (this.parentFolder) body.parent_id = this.parentFolder.id;
|
||||
if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
|
||||
const 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) {
|
||||
this.newName = ''; this.showCreateModal = false; this.parentFolder = null;
|
||||
window.location.reload();
|
||||
} else {
|
||||
const e = await r.json();
|
||||
alert(e.error || 'Failed to create');
|
||||
}
|
||||
} else { alert('Failed to create'); }
|
||||
},
|
||||
|
||||
async createFileOrFolder(type, name) {
|
||||
// Used by sidebar buttons
|
||||
return await fetch('/api/local-workspace/items', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ name, type })
|
||||
});
|
||||
},
|
||||
openItem(node) { window.location = `/pages/${node.db_id}`; },
|
||||
|
||||
openItem(item) {
|
||||
window.location = `/pages/${item.id}`;
|
||||
},
|
||||
|
||||
rename(item) {
|
||||
this.renameTarget = item;
|
||||
this.newName = item.name.trim();
|
||||
rename(node) {
|
||||
this.renameTarget = node; this.newName = node.name.trim();
|
||||
this.showRenameModal = true;
|
||||
this.$nextTick(() => {
|
||||
const el = this.$refs.renameInput;
|
||||
if (el) setTimeout(() => el.focus(), 100);
|
||||
});
|
||||
this.$nextTick(() => { const el = this.$refs.renameInput; if (el) setTimeout(() => el.focus(), 100); });
|
||||
},
|
||||
|
||||
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()})
|
||||
await fetch(`/api/local-workspace/items/${this.renameTarget.db_id}`, {
|
||||
method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name:this.newName.trim()})
|
||||
});
|
||||
this.showRenameModal = false; this.renameTarget = null; this.newName = '';
|
||||
await this.load();
|
||||
},
|
||||
|
||||
confirmDelete(item) {
|
||||
this.deleteTarget = item;
|
||||
this.showDeleteModal = true;
|
||||
},
|
||||
confirmDelete(node) { this.deleteTarget = node; this.showDeleteModal = true; },
|
||||
|
||||
async doDelete() {
|
||||
if (!this.deleteTarget) return;
|
||||
await fetch(`/api/local-workspace/items/${this.deleteTarget.id}`, {method:'DELETE'});
|
||||
await fetch(`/api/local-workspace/items/${this.deleteTarget.db_id}`, {method:'DELETE'});
|
||||
this.showDeleteModal = false; this.deleteTarget = null;
|
||||
await this.load();
|
||||
}
|
||||
},
|
||||
|
||||
// ── 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');
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user