Alpine processes DOM top-to-bottom. x-data='wsInit()' was evaluated before the script defining wsInit() was parsed. Moved Alpine.data registration to a script tag BEFORE the x-data div. Also removed duplicate script block at the bottom.
323 lines
12 KiB
HTML
323 lines
12 KiB
HTML
{% extends "base.html" %}
|
|
{% 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>
|
|
</div>
|
|
<div class="topbar-right"></div>
|
|
</header>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
.ws-layout{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-toolbar{display:flex;gap:8px;}
|
|
|
|
.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: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 .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-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);}
|
|
|
|
.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;}
|
|
|
|
.btn{padding:10px 20px;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);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);}
|
|
|
|
.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;}
|
|
.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{padding:12px;background:rgba(255,80,80,.08);border-radius:8px;margin-top:12px;font-size:13px;color:var(--text-secondary);}
|
|
</style>
|
|
|
|
<script>
|
|
document.addEventListener('alpine:init', function() {
|
|
Alpine.data('wsInit', function() {
|
|
return {
|
|
tree:[],
|
|
loaded:false,
|
|
showCreate:false, showRename:false, showDelete:false,
|
|
createType:'file', newName:'', parentFolder:null, target:null,
|
|
|
|
async init() {
|
|
try {
|
|
var r = await fetch('/api/local-workspace/tree');
|
|
var d = await r.json();
|
|
this.tree = flatten(d.tree||[],0);
|
|
this.loaded = true;
|
|
} catch(e) { this.loaded = true; }
|
|
},
|
|
|
|
openModal(type, parent) {
|
|
this.createType = type; this.parentFolder = parent||null; this.newName = '';
|
|
this.showCreate = true;
|
|
var self = this;
|
|
this.$nextTick(function() {
|
|
var el = self.$refs.cinp;
|
|
if (el) setTimeout(function() { el.focus(); }, 100);
|
|
});
|
|
},
|
|
|
|
async doCreate() {
|
|
var n = this.newName.trim(); if (!n) return;
|
|
var body = { name:n, type:this.createType };
|
|
if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
|
|
var r = await fetch('/api/local-workspace/items', {
|
|
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
|
|
});
|
|
if (r.ok) {
|
|
this.showCreate = false; this.parentFolder = null; this.newName = '';
|
|
window.location.reload();
|
|
}
|
|
},
|
|
|
|
openPage(id) { window.location = '/pages/'+id; },
|
|
|
|
startRename(node) {
|
|
this.target = node; this.newName = node.name.trim(); this.showRename = true;
|
|
var self = this;
|
|
this.$nextTick(function() {
|
|
var el = self.$refs.rinp;
|
|
if (el) setTimeout(function() { el.focus(); }, 100);
|
|
});
|
|
},
|
|
|
|
async doRename() {
|
|
var n = this.newName.trim(); if (!n||!this.target) return;
|
|
await fetch('/api/local-workspace/items/'+this.target.db_id, {
|
|
method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name:n})
|
|
});
|
|
this.showRename = false; this.target = null; this.newName = '';
|
|
await this.init();
|
|
},
|
|
|
|
startDelete(node) { this.target = node; this.showDelete = true; },
|
|
|
|
async doDelete() {
|
|
if (!this.target) return;
|
|
await fetch('/api/local-workspace/items/'+this.target.db_id, {method:'DELETE'});
|
|
this.showDelete = false; this.target = null;
|
|
await this.init();
|
|
}
|
|
};});
|
|
});
|
|
</script>
|
|
|
|
<div class="ws-layout" x-data="wsInit()">
|
|
|
|
<div class="ws-header">
|
|
<h1>📁 {{ workspace_name }}</h1>
|
|
<div class="ws-toolbar">
|
|
<button class="btn btn-primary" @click="openModal('file')">📄 New File</button>
|
|
<button class="btn btn-secondary" @click="openModal('folder')">📁 New Folder</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tree -->
|
|
<ul class="ws-tree" x-show="tree.length>0">
|
|
<template x-for="(node,i) in tree" :key="i">
|
|
<li>
|
|
<div class="ws-tree-item" :style="{paddingLeft:(8+node.depth*20)+'px'}">
|
|
<span class="icon" x-text="node.icon"></span>
|
|
<span class="name" x-text="node.name"></span>
|
|
<div class="actions">
|
|
<template x-if="node.is_folder">
|
|
<span style="display:flex;gap:2px">
|
|
<button class="ws-act" @click.stop="openModal('file',node)" title="New file">📄</button>
|
|
<button class="ws-act" @click.stop="openModal('folder',node)" title="New folder">📁</button>
|
|
</span>
|
|
</template>
|
|
<button class="ws-act" @click.stop="openPage(node.db_id)" title="Open">Open</button>
|
|
<button class="ws-act" @click.stop="startRename(node)" title="Rename">✏️</button>
|
|
<button class="ws-act danger" @click.stop="startDelete(node)" title="Delete">🗑</button>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
|
|
<!-- Empty -->
|
|
<div class="empty-state" x-show="tree.length===0&&loaded">
|
|
<div class="empty-icon">📁</div>
|
|
<h2>Empty workspace</h2>
|
|
<p>Create a new file or folder to get started</p>
|
|
<div class="empty-actions">
|
|
<button class="btn btn-primary" @click="openModal('file')">📄 New File</button>
|
|
<button class="btn btn-secondary" @click="openModal('folder')">📁 New Folder</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Modal -->
|
|
<div class="modal-overlay" x-show="showCreate" @click.away="showCreate=false" @keydown.escape="showCreate=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">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="cinp">
|
|
<div class="modal-actions">
|
|
<button class="btn btn-secondary" @click="showCreate=false">Cancel</button>
|
|
<button class="btn btn-primary" @click="doCreate" :disabled="!newName.trim()">Create</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Rename Modal -->
|
|
<div class="modal-overlay" x-show="showRename" @click.away="showRename=false" @keydown.escape="showRename=false">
|
|
<div class="modal-box">
|
|
<h3>✏️ Rename</h3>
|
|
<p class="modal-sub">Rename <strong x-text="target?.name"></strong></p>
|
|
<input class="modal-input" x-model="newName" @keydown.enter="doRename" x-ref="rinp">
|
|
<div class="modal-actions">
|
|
<button class="btn btn-secondary" @click="showRename=false">Cancel</button>
|
|
<button class="btn btn-primary" @click="doRename">Rename</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete Modal -->
|
|
<div class="modal-overlay" x-show="showDelete" @click.away="showDelete=false" @keydown.escape="showDelete=false">
|
|
<div class="modal-box">
|
|
<h3>🗑 Delete</h3>
|
|
<p class="modal-sub">Delete <strong x-text="target?.name"></strong>?</p>
|
|
<div class="delete-confirm">This cannot be undone.</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-secondary" @click="showDelete=false">Cancel</button>
|
|
<button class="btn btn-danger" @click="doDelete">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
document.addEventListener('alpine:init', function() {
|
|
Alpine.data('wsInit', function() {
|
|
return {
|
|
tree:[],
|
|
loaded:false,
|
|
showCreate:false,
|
|
showRename:false,
|
|
showDelete:false,
|
|
createType:'file',
|
|
newName:'',
|
|
parentFolder:null,
|
|
target:null,
|
|
|
|
async init() {
|
|
try {
|
|
var r = await fetch('/api/local-workspace/tree');
|
|
var d = await r.json();
|
|
this.tree = flatten(d.tree||[],0);
|
|
this.loaded = true;
|
|
} catch(e) { this.loaded = true; }
|
|
},
|
|
|
|
openModal(type, parent) {
|
|
this.createType = type;
|
|
this.parentFolder = parent||null;
|
|
this.newName = '';
|
|
this.showCreate = true;
|
|
var self = this;
|
|
this.$nextTick(function() {
|
|
var el = self.$refs.cinp;
|
|
if (el) setTimeout(function() { el.focus(); }, 100);
|
|
});
|
|
},
|
|
|
|
async doCreate() {
|
|
var n = this.newName.trim();
|
|
if (!n) return;
|
|
var body = { name:n, type:this.createType };
|
|
if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
|
|
var r = await fetch('/api/local-workspace/items', {
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify(body)
|
|
});
|
|
if (r.ok) {
|
|
this.showCreate = false;
|
|
this.parentFolder = null;
|
|
this.newName = '';
|
|
window.location.reload();
|
|
} else {
|
|
var e = await r.json();
|
|
alert(e.error||'Failed');
|
|
}
|
|
},
|
|
|
|
openPage(id) { window.location = '/pages/'+id; },
|
|
|
|
startRename(node) {
|
|
this.target = node;
|
|
this.newName = node.name.trim();
|
|
this.showRename = true;
|
|
var self = this;
|
|
this.$nextTick(function() {
|
|
var el = self.$refs.rinp;
|
|
if (el) setTimeout(function() { el.focus(); }, 100);
|
|
});
|
|
},
|
|
|
|
async doRename() {
|
|
var n = this.newName.trim();
|
|
if (!n||!this.target) return;
|
|
await fetch('/api/local-workspace/items/'+this.target.db_id, {
|
|
method:'PUT',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({name:n})
|
|
});
|
|
this.showRename = false;
|
|
this.target = null;
|
|
this.newName = '';
|
|
await this.init();
|
|
},
|
|
|
|
startDelete(node) { this.target = node; this.showDelete = true; },
|
|
|
|
async doDelete() {
|
|
if (!this.target) return;
|
|
await fetch('/api/local-workspace/items/'+this.target.db_id, {method:'DELETE'});
|
|
this.showDelete = false;
|
|
this.target = null;
|
|
await this.init();
|
|
}
|
|
};});
|
|
});
|
|
|
|
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>
|
|
{% endblock %}
|