Remplacé li.querySelector(':scope > ul.ws-tree') par une boucle sur
itemDiv.parentElement.children. Évite les problèmes de compatibilité
de :scope et trouve le <ul> frère du .ws-tree-item de manière fiable.
Chemin: btn → .ws-tree-item → parentElement (<li>) → itère children
→ premier UL.ws-tree → toggle display.
786 lines
33 KiB
HTML
786 lines
33 KiB
HTML
{% extends "base.html" %}
|
||
{% block page_title %}{{ workspace_name }}{% endblock %}
|
||
{% block page_icon %}📁{% endblock %}
|
||
|
||
{% block topbar %}
|
||
<header class="topbar">
|
||
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu">
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||
</button>
|
||
<div class="topbar-left">
|
||
<span class="breadcrumb" style="display:flex;align-items:center;gap:4px;color:rgba(255,255,255,.5);font-size:13px;">
|
||
<a href="/local-workspace" style="color:rgba(255,255,255,.5);text-decoration:none;">📁 {{ workspace_name }}</a>
|
||
</span>
|
||
<span class="page-title-topbar">
|
||
{% if breadcrumb %}
|
||
{% for b in breadcrumb %}
|
||
/ <a href="/local-workspace?folder={{ b.id }}" style="color:rgba(255,255,255,.8);text-decoration:none;">{{ b.name }}</a>
|
||
{% endfor %}
|
||
{% endif %}
|
||
</span>
|
||
</div>
|
||
<div class="topbar-right">
|
||
<a href="/workspaces" class="page-action-btn" title="All workspaces">🏠</a>
|
||
</div>
|
||
</header>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<style>
|
||
.ws-layout{max-width:800px;margin:0 auto;padding:40px 24px;}
|
||
@media (max-width: 768px) { .ws-layout { padding: 20px 12px; } }
|
||
.ws-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;flex-wrap:wrap;gap:12px;}
|
||
.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 .name.folder{font-weight:500;cursor:pointer;}
|
||
.ws-tree-item .name.folder:hover{color:var(--accent);}
|
||
.ws-tree-item .name.file{cursor:pointer;}
|
||
.ws-tree-item .name.file:hover{color:var(--accent);}
|
||
.ws-tree-item .actions{display:none;gap:2px;}
|
||
.ws-tree-item:hover .actions{display:flex;}
|
||
|
||
/* Chevron in main tree */
|
||
.ws-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;transition:transform 150ms,color 100ms;}
|
||
.ws-chevron:hover{color:var(--text);background:var(--bg-hover);}
|
||
.ws-chevron.open{transform:rotate(90deg);}
|
||
|
||
.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);}
|
||
|
||
.btn-icon{width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:16px;color:var(--text-secondary);transition:all 150ms;}
|
||
.btn-icon:hover{background:var(--bg-hover);color:var(--text);border-color:var(--border-strong);}
|
||
|
||
.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);}
|
||
|
||
/* Breadcrumb in page */
|
||
.breadcrumb-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:20px;font-size:14px;color:var(--text-secondary);}
|
||
.breadcrumb-bar a{color:var(--text-secondary);text-decoration:none;padding:2px 6px;border-radius:4px;}
|
||
.breadcrumb-bar a:hover{color:var(--text);background:var(--bg-tertiary);}
|
||
.breadcrumb-bar .sep{color:var(--text-tertiary);}
|
||
.breadcrumb-bar .current{color:var(--text);font-weight:500;}
|
||
|
||
/* Drag & drop */
|
||
.ws-layout.drop-active { outline: 2px dashed var(--accent); outline-offset: -4px; border-radius: 8px; }
|
||
.drop-overlay{display:none;position:fixed;inset:0;background:rgba(35,131,226,.08);z-index:100;pointer-events:none;align-items:center;justify-content:center;}
|
||
.drop-overlay.active{display:flex;}
|
||
.drop-overlay .drop-hint{background:var(--bg);border:2px dashed var(--accent);border-radius:16px;padding:40px 60px;text-align:center;color:var(--text);font-size:16px;}
|
||
.drop-overlay .drop-hint .icon{font-size:48px;display:block;margin-bottom:12px;}
|
||
.drop-target-highlight{outline:2px solid var(--accent)!important;outline-offset:-2px;border-radius:6px;background:rgba(35,131,226,.1)!important;}
|
||
.upload-progress{position:fixed;bottom:24px;right:24px;background:var(--bg);border:1px solid var(--border-strong);border-radius:12px;padding:16px 20px;z-index:200;box-shadow:0 8px 30px rgba(0,0,0,.4);min-width:200px;}
|
||
.upload-progress .up-bar{height:4px;background:var(--bg-tertiary);border-radius:2px;margin-top:8px;overflow:hidden;}
|
||
.upload-progress .up-bar-fill{height:100%;background:var(--accent);border-radius:2px;transition:width 200ms;}
|
||
|
||
/* Search bar */
|
||
.search-bar{display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;}
|
||
.search-bar:focus-within{border-color:var(--accent);}
|
||
.search-icon{font-size:14px;color:var(--text-tertiary);flex-shrink:0;}
|
||
.search-input{flex:1;background:none;border:none;color:var(--text);font-size:13px;outline:none;}
|
||
.search-input::placeholder{color:var(--text-tertiary);}
|
||
.search-clear{background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:16px;padding:2px 4px;border-radius:3px;}
|
||
.search-clear:hover{color:var(--text);background:var(--bg-hover);}
|
||
|
||
/* File preview tooltip */
|
||
.file-preview{position:fixed;z-index:300;background:#1a1a1a;background:var(--bg,#1a1a1a);border:1px solid var(--border-strong,#333);border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.5);max-width:400px;min-width:200px;pointer-events:auto;}
|
||
.file-preview-header{padding:8px 12px;font-size:12px;font-weight:600;color:var(--text-secondary,#888);border-bottom:1px solid var(--border,#333);}
|
||
.file-preview-body{padding:10px 12px;margin:0;font-size:12px;line-height:1.5;color:var(--text,#ddd);max-height:200px;overflow:auto;font-family:'Fira Code','Cascadia Code',monospace;white-space:pre-wrap;word-wrap:break-word;background:#141414;}
|
||
</style>
|
||
|
||
<div class="ws-layout" x-data="wsInit()"
|
||
@dragover.prevent="onDragOver($event)"
|
||
@dragleave="onDragLeave($event)"
|
||
@drop.prevent="onDrop($event)">
|
||
|
||
<!-- Drop overlay -->
|
||
<div class="drop-overlay" :class="{ active: isDragging }">
|
||
<div class="drop-hint">
|
||
<span class="icon">📥</span>
|
||
<div>Drop files here</div>
|
||
<div style="font-size:12px;color:var(--text-tertiary);margin-top:6px;" x-show="dropTarget">
|
||
Into: <strong x-text="dropTargetName"></strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Upload progress -->
|
||
<div class="upload-progress" x-show="uploading" x-transition>
|
||
<div style="display:flex;align-items:center;gap:8px;">
|
||
<span x-text="uploading ? '⏳' : ''"></span>
|
||
<span x-text="uploadStatus"></span>
|
||
</div>
|
||
<div class="up-bar">
|
||
<div class="up-bar-fill" :style="{ width: uploadProgress + '%' }"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Breadcrumb -->
|
||
<div class="breadcrumb-bar" x-show="breadcrumb.length > 0">
|
||
<a href="/local-workspace">📁 {{ workspace_name }}</a>
|
||
<template x-for="(b, i) in breadcrumb" :key="b.id">
|
||
<span style="display:contents;">
|
||
<span class="sep">/</span>
|
||
<a :href="'/local-workspace?folder=' + b.id" x-text="b.name"
|
||
:class="{ current: i === breadcrumb.length - 1 }"></a>
|
||
</span>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="ws-header">
|
||
<h1 x-show="breadcrumb.length===0">📁 {{ workspace_name }}</h1>
|
||
<h1 x-show="breadcrumb.length>0">
|
||
📁 <span x-text="breadcrumb.length > 0 ? breadcrumb[breadcrumb.length-1].name : ''"></span>
|
||
</h1>
|
||
<div class="ws-toolbar">
|
||
<button class="btn-icon" @click="openModal('file')" title="New file">📄</button>
|
||
<button class="btn-icon" @click="openModal('folder')" title="New folder">📁</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tree -->
|
||
<!-- Search bar -->
|
||
<div class="search-bar" x-show="tree.length>0 || searchQuery">
|
||
<span class="search-icon">🔍</span>
|
||
<input class="search-input" type="text" placeholder="Filter files & folders..."
|
||
x-model="searchQuery" @input="doFilter" autocomplete="off">
|
||
<button class="search-clear" x-show="searchQuery" @click="searchQuery='';doFilter()" title="Clear">×</button>
|
||
</div>
|
||
|
||
<ul class="ws-tree" x-show="displayTree.length>0">
|
||
<template x-for="node in displayTree" :key="node.id">
|
||
<li>
|
||
<div class="ws-tree-item" :style="{paddingLeft:(node.depth||0)*20+'px'}"
|
||
draggable="true"
|
||
:data-folder-id="node.is_folder ? node.id : null"
|
||
@dragstart="onInternalDragStart($event, node.id)"
|
||
@dragend="draggedItemId = null; $el.style.opacity = ''">
|
||
<!-- chevron -->
|
||
<template x-if="node.is_folder && node.children && node.children.length > 0">
|
||
<button class="ws-chevron"
|
||
:class="{ open: expanded[node.id] }"
|
||
@click.stop="toggleExpand(node.id)">▶</button>
|
||
</template>
|
||
<template x-if="!(node.is_folder && node.children && node.children.length > 0)">
|
||
<span style="width:16px;flex-shrink:0;"></span>
|
||
</template>
|
||
|
||
<span class="icon" x-text="node.is_folder ? '📁' : '📄'"></span>
|
||
|
||
<template x-if="node.is_folder">
|
||
<span class="name folder" @click.stop="navigateToFolder(node.id)" x-text="node.name"></span>
|
||
</template>
|
||
<template x-if="!node.is_folder">
|
||
<span class="name file" @click.stop="openPage(node.id)" x-text="node.name"
|
||
@mouseenter="showPreview($event, node)"
|
||
@mouseleave="hidePreview()"></span>
|
||
</template>
|
||
|
||
<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="startRename(node)" title="Rename">✏️</button>
|
||
<button class="ws-act danger" @click.stop="startDelete(node)" title="Delete">🗑</button>
|
||
</div>
|
||
</div>
|
||
<!-- Children -->
|
||
<ul class="ws-tree" x-show="expanded[node.id]" x-transition
|
||
x-html="renderChildren(node.children, (node.depth||0)+1)">
|
||
</ul>
|
||
</li>
|
||
</template>
|
||
</ul>
|
||
|
||
<!-- Empty -->
|
||
<div class="empty-state" x-show="tree.length===0&&loaded">
|
||
<div class="empty-icon">📁</div>
|
||
<h2>Empty folder</h2>
|
||
<p>Create a new file or folder to get started</p>
|
||
<div class="empty-actions">
|
||
<button class="btn-icon" @click="openModal('file')" title="New file">📄</button>
|
||
<button class="btn-icon" @click="openModal('folder')" title="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. Children will also be deleted.</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>
|
||
|
||
<!-- Preview tooltip -->
|
||
<div class="file-preview" x-show="previewVisible"
|
||
:style="{ left: previewX + 'px', top: previewY + 'px' }"
|
||
@mouseenter="previewVisible = true" @mouseleave="previewVisible = false">
|
||
<div class="file-preview-header" x-text="previewName"></div>
|
||
<div class="file-preview-body" x-html="previewContent"></div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
document.addEventListener('alpine:init', function() {
|
||
Alpine.data('wsInit', function() {
|
||
return {
|
||
tree:[],
|
||
displayTree:[],
|
||
breadcrumb: [],
|
||
loaded:false,
|
||
showCreate:false,
|
||
showRename:false,
|
||
showDelete:false,
|
||
createType:'file',
|
||
newName:'',
|
||
parentFolder:null,
|
||
target:null,
|
||
expanded: {},
|
||
currentFolder: {{ current_folder_id }},
|
||
searchQuery: '',
|
||
|
||
// Preview popup
|
||
previewVisible: false,
|
||
previewContent: '',
|
||
previewName: '',
|
||
previewX: 0,
|
||
previewY: 0,
|
||
|
||
// Drag & drop state
|
||
isDragging: false,
|
||
dropTarget: null,
|
||
dropTargetName: '',
|
||
uploading: false,
|
||
uploadStatus: '',
|
||
uploadProgress: 0,
|
||
draggedItemId: null,
|
||
|
||
async init() {
|
||
try {
|
||
var url = '/api/local-workspace/tree';
|
||
if (this.currentFolder && this.currentFolder > 0) {
|
||
url += '?folder=' + this.currentFolder;
|
||
}
|
||
var r = await fetch(url);
|
||
var d = await r.json();
|
||
// Keep tree hierarchical, only roots in displayTree
|
||
var roots = d.tree || [];
|
||
this._markTree(roots, 0);
|
||
this.tree = roots;
|
||
this.displayTree = roots;
|
||
this.breadcrumb = d.breadcrumb || [];
|
||
this.loaded = true;
|
||
} catch(e) { console.error('Tree load error:', e); this.loaded = true; }
|
||
},
|
||
|
||
_markTree(nodes, depth) {
|
||
for (var i=0; i<nodes.length; i++) {
|
||
var n = nodes[i];
|
||
n.depth = depth;
|
||
n.db_id = n.id;
|
||
if (n.children && n.children.length > 0) {
|
||
this._markTree(n.children, depth + 1);
|
||
}
|
||
}
|
||
},
|
||
|
||
renderChildren(children, depth) {
|
||
if (!children || children.length === 0) return '';
|
||
var html = '';
|
||
for (var i=0; i<children.length; i++) {
|
||
var c = children[i];
|
||
var hasKids = c.children && c.children.length > 0;
|
||
var icon = c.is_folder ? '📁' : '📄';
|
||
html += '<li><div class="ws-tree-item" style="padding-left:'+(depth*20)+'px" draggable="true" data-folder-id="'+(c.is_folder?c.id:'')+'" ondragstart="var d=document.querySelector(\'[x-data]\').__x.$data;d.onInternalDragStart(event,'+c.id+')" ondragend="event.target.style.opacity=\'\'">';
|
||
if (c.is_folder && hasKids) {
|
||
html += '<button class="ws-chevron" onclick="event.stopPropagation();var d=document.querySelector(\'[x-data]\').__x.$data;d.toggleExpand('+c.id+',this)">▶</button>';
|
||
} else {
|
||
html += '<span style="width:16px;flex-shrink:0;"></span>';
|
||
}
|
||
html += '<span class="icon">'+icon+'</span>';
|
||
if (c.is_folder) {
|
||
html += '<span class="name folder" onclick="window.location=\'/local-workspace?folder='+c.id+'\'">'+c.name+'</span>';
|
||
} else {
|
||
html += '<span class="name file" onclick="window.location=\'/pages/'+c.id+'\'" onmouseenter="var d=document.querySelector(\'[x-data]\').__x.$data;d.showPreview(event,{name:\''+this.esc(c.name)+'\',is_folder:false})" onmouseleave="var d=document.querySelector(\'[x-data]\').__x.$data;d.hidePreview()">'+c.name+'</span>';
|
||
}
|
||
html += '<div class="actions">';
|
||
if (c.is_folder) {
|
||
html += '<span style="display:flex;gap:2px">';
|
||
html += '<button class="ws-act" onclick="event.stopPropagation();document.querySelector(\'[x-data]\').__x.$data.openModal(\'file\',{db_id:'+c.id+',name:\''+this.esc(c.name)+'\'})">📄</button>';
|
||
html += '<button class="ws-act" onclick="event.stopPropagation();document.querySelector(\'[x-data]\').__x.$data.openModal(\'folder\',{db_id:'+c.id+',name:\''+this.esc(c.name)+'\'})">📁</button>';
|
||
html += '</span>';
|
||
}
|
||
html += '<button class="ws-act" onclick="event.stopPropagation();document.querySelector(\'[x-data]\').__x.$data.startRename({db_id:'+c.id+',name:\''+this.esc(c.name)+'\'})">✏️</button>';
|
||
html += '<button class="ws-act danger" onclick="event.stopPropagation();document.querySelector(\'[x-data]\').__x.$data.startDelete({db_id:'+c.id+',name:\''+this.esc(c.name)+'\'})">🗑</button>';
|
||
html += '</div></div>';
|
||
if (hasKids) {
|
||
html += '<ul class="ws-tree" style="display:none">'+this.renderChildren(c.children, depth+1)+'</ul>';
|
||
}
|
||
html += '</li>';
|
||
}
|
||
return html;
|
||
},
|
||
|
||
esc(s) {
|
||
if (!s) return '';
|
||
return s.replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"');
|
||
},
|
||
|
||
toggleExpand(id, btn) {
|
||
this.expanded[id] = !(this.expanded[id] || false);
|
||
this.expanded = Object.assign({}, this.expanded);
|
||
// For renderChildren static HTML: toggle chevron + sibling <ul>
|
||
if (btn) {
|
||
btn.classList.toggle('open', this.expanded[id]);
|
||
// Navigate: btn → .ws-tree-item → <li> → <ul.ws-tree>
|
||
var itemDiv = btn.closest('.ws-tree-item');
|
||
if (itemDiv && itemDiv.parentElement) {
|
||
var children = itemDiv.parentElement.children;
|
||
for (var i = 0; i < children.length; i++) {
|
||
if (children[i].tagName === 'UL' && children[i].classList.contains('ws-tree')) {
|
||
children[i].style.display = this.expanded[id] ? '' : 'none';
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
navigateToFolder(id) {
|
||
window.location = '/local-workspace?folder=' + id;
|
||
},
|
||
|
||
// ── Search / Filter ──
|
||
doFilter() {
|
||
var q = (this.searchQuery || '').toLowerCase().trim();
|
||
if (!q) { this.displayTree = this.tree; return; }
|
||
this.displayTree = this._filterTree(this.tree, q);
|
||
},
|
||
_filterTree(nodes, query) {
|
||
var result = [];
|
||
for (var i=0; i<nodes.length; i++) {
|
||
var n = nodes[i];
|
||
var filteredChildren = n.children ? this._filterTree(n.children, query) : [];
|
||
var selfMatches = (n.name || '').toLowerCase().indexOf(query) !== -1;
|
||
if (selfMatches || filteredChildren.length > 0) {
|
||
var clone = Object.assign({}, n);
|
||
clone.children = filteredChildren;
|
||
result.push(clone);
|
||
}
|
||
}
|
||
return result;
|
||
},
|
||
|
||
// ── Preview on hover ──
|
||
showPreview(ev, node) {
|
||
if (node.is_folder) return;
|
||
this.previewName = node.name;
|
||
this.previewX = ev.clientX + 12;
|
||
this.previewY = ev.clientY + 12;
|
||
this.previewVisible = true;
|
||
var self = this;
|
||
var wsId = {{ workspace_id }} || 1;
|
||
|
||
// Image preview
|
||
var imgExts = ['.png','.jpg','.jpeg','.gif','.webp','.svg','.bmp','.ico'];
|
||
var isImg = false;
|
||
for (var i=0; i<imgExts.length; i++) {
|
||
if ((node.name||'').toLowerCase().endsWith(imgExts[i])) { isImg = true; break; }
|
||
}
|
||
|
||
if (node.content_format === 'file') {
|
||
// Uploaded file: serve from disk
|
||
var url = '/api/files/' + wsId + '/' + encodeURIComponent(node.name);
|
||
if (isImg) {
|
||
this.previewContent = '<img src="'+url+'" style="max-width:380px;max-height:180px;display:block;">';
|
||
} else {
|
||
this.previewContent = 'Loading...';
|
||
fetch(url)
|
||
.then(function(r) { return r.text(); })
|
||
.then(function(t) { self.previewContent = t.substring(0, 500); })
|
||
.catch(function() { self.previewContent = '(preview not available)'; });
|
||
}
|
||
} else {
|
||
// FlowDeck page: fetch content via API
|
||
this.previewContent = 'Loading...';
|
||
fetch('/api/local-workspace/page-content/' + node.id)
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(d) {
|
||
self.previewContent = (d.content || '').substring(0, 500);
|
||
})
|
||
.catch(function() { self.previewContent = '(preview not available)'; });
|
||
}
|
||
},
|
||
hidePreview() {
|
||
this.previewVisible = false;
|
||
},
|
||
|
||
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(); el.select(); }, 100);
|
||
});
|
||
},
|
||
|
||
async doRename() {
|
||
var n = this.newName.trim();
|
||
if (!n||!this.target) return;
|
||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {
|
||
method:'PUT',
|
||
headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({name:n})
|
||
});
|
||
if (r.ok) { window.location.reload(); }
|
||
},
|
||
|
||
startDelete(node) {
|
||
this.target = node;
|
||
this.showDelete = true;
|
||
},
|
||
|
||
async doDelete() {
|
||
if (!this.target) return;
|
||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, { method:'DELETE' });
|
||
if (r.ok) { window.location.reload(); }
|
||
},
|
||
|
||
// ── Drag & Drop ──
|
||
onDragOver(ev) {
|
||
this.isDragging = true;
|
||
// Detect if hovering over a folder item
|
||
var folderEl = ev.target.closest('.ws-tree-item');
|
||
var parentFolder = folderEl ? folderEl.querySelector('.name.folder') : null;
|
||
// Clear previous highlight
|
||
document.querySelectorAll('.drop-target-highlight').forEach(function(el){ el.classList.remove('drop-target-highlight'); });
|
||
if (parentFolder) {
|
||
var name = parentFolder.textContent.trim();
|
||
this.dropTargetName = name;
|
||
// Find parent node id from the onclick attribute or data
|
||
var li = parentFolder.closest('.ws-tree-item');
|
||
if (li) {
|
||
var clickEl = li.querySelector('.name.folder');
|
||
// Try to extract ID from onclick or the onclick handler
|
||
// Use data attributes if available, otherwise fall back
|
||
}
|
||
folderEl.classList.add('drop-target-highlight');
|
||
} else {
|
||
this.dropTargetName = 'root';
|
||
}
|
||
},
|
||
|
||
onDragLeave(ev) {
|
||
// Only reset if leaving the layout entirely
|
||
if (!ev.currentTarget.contains(ev.relatedTarget)) {
|
||
this.isDragging = false;
|
||
this.dropTargetName = '';
|
||
document.querySelectorAll('.drop-target-highlight').forEach(function(el){ el.classList.remove('drop-target-highlight'); });
|
||
}
|
||
},
|
||
|
||
onInternalDragStart(ev, id) {
|
||
this.draggedItemId = id;
|
||
ev.dataTransfer.effectAllowed = 'move';
|
||
ev.dataTransfer.setData('text/plain', String(id));
|
||
// Add a visual class to the dragged element
|
||
ev.target.style.opacity = '0.5';
|
||
},
|
||
|
||
onDrop(ev) {
|
||
this.isDragging = false;
|
||
document.querySelectorAll('.drop-target-highlight').forEach(function(el){ el.classList.remove('drop-target-highlight'); });
|
||
// Reset any opacity from drag
|
||
document.querySelectorAll('.ws-tree-item').forEach(function(el){ el.style.opacity = ''; });
|
||
|
||
// ── Internal drag (move item to another folder) ──
|
||
if (this.draggedItemId !== null) {
|
||
var draggedId = this.draggedItemId;
|
||
this.draggedItemId = null;
|
||
// Determine target parent
|
||
var folderEl = ev.target.closest('.ws-tree-item');
|
||
var targetParentId = this.currentFolder > 0 ? this.currentFolder : null;
|
||
if (folderEl) {
|
||
var folderLink = folderEl.querySelector('.name.folder');
|
||
if (folderLink) {
|
||
var fid = folderEl.getAttribute('data-folder-id');
|
||
if (fid) targetParentId = parseInt(fid);
|
||
}
|
||
}
|
||
// Don't drop on self or same parent
|
||
if (draggedId === targetParentId) return;
|
||
this._moveItem(draggedId, targetParentId);
|
||
return;
|
||
}
|
||
|
||
// ── External drop (files from computer) ──
|
||
var folderEl = ev.target.closest('.ws-tree-item');
|
||
var parentId = this.currentFolder > 0 ? this.currentFolder : null;
|
||
var parentName = 'root';
|
||
|
||
if (folderEl) {
|
||
var folderLink = folderEl.querySelector('.name.folder');
|
||
if (folderLink) {
|
||
parentName = folderLink.textContent.trim();
|
||
// Use data-folder-id attribute (works for both x-for and renderChildren)
|
||
var fid = folderEl.getAttribute('data-folder-id');
|
||
if (fid) parentId = parseInt(fid);
|
||
}
|
||
}
|
||
|
||
var items = ev.dataTransfer.items;
|
||
if (!items) return;
|
||
|
||
var self = this;
|
||
var dtFiles = ev.dataTransfer.files;
|
||
|
||
// Check if any item is a directory (WebKit only)
|
||
var hasDirectory = false;
|
||
for (var i = 0; i < items.length; i++) {
|
||
var entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
|
||
if (entry && entry.isDirectory) { hasDirectory = true; break; }
|
||
}
|
||
|
||
if (hasDirectory) {
|
||
// Walk directory tree and upload structure
|
||
this._processDirectoryDrop(items, parentId, parentName);
|
||
} else if (dtFiles.length > 0) {
|
||
// Simple file upload
|
||
this._uploadFiles(dtFiles, parentId, parentName);
|
||
}
|
||
},
|
||
|
||
_processDirectoryDrop(items, parentId, parentName) {
|
||
var self = this;
|
||
var allItems = []; // {path: string, type: 'file'|'folder', entry: FileSystemEntry}
|
||
var pending = items.length;
|
||
|
||
function walkEntry(entry, basePath) {
|
||
var path = basePath ? basePath + '/' + entry.name : entry.name;
|
||
if (entry.isFile) {
|
||
allItems.push({path: path, type: 'file', entry: entry});
|
||
pending--;
|
||
if (pending === 0) doUpload();
|
||
} else if (entry.isDirectory) {
|
||
allItems.push({path: path, type: 'folder', entry: entry});
|
||
var reader = entry.createReader();
|
||
reader.readEntries(function(entries) {
|
||
pending += entries.length;
|
||
for (var i = 0; i < entries.length; i++) {
|
||
walkEntry(entries[i], path);
|
||
}
|
||
pending--;
|
||
if (pending === 0) doUpload();
|
||
});
|
||
}
|
||
}
|
||
|
||
for (var i = 0; i < items.length; i++) {
|
||
var entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
|
||
if (entry) walkEntry(entry, '');
|
||
else pending--;
|
||
}
|
||
if (pending === 0) doUpload();
|
||
|
||
function doUpload() {
|
||
// Build structure
|
||
var structure = [];
|
||
var files = [];
|
||
for (var i = 0; i < allItems.length; i++) {
|
||
structure.push({path: allItems[i].path, type: allItems[i].type});
|
||
}
|
||
|
||
// Resolve file entries to actual File objects
|
||
var fileEntries = [];
|
||
for (var i = 0; i < allItems.length; i++) {
|
||
if (allItems[i].type === 'file') fileEntries.push(allItems[i]);
|
||
}
|
||
|
||
var resolved = 0;
|
||
var formData = new FormData();
|
||
formData.append('structure', JSON.stringify(structure));
|
||
if (parentId) formData.append('parent_id', parentId);
|
||
|
||
function addFile(idx) {
|
||
if (idx >= fileEntries.length) {
|
||
// All resolved, upload
|
||
self._doUpload(formData, parentId, parentName);
|
||
return;
|
||
}
|
||
var fe = fileEntries[idx];
|
||
fe.entry.file(function(file) {
|
||
formData.append('files', file, fe.path);
|
||
resolved++;
|
||
addFile(idx + 1);
|
||
}, function() {
|
||
resolved++;
|
||
addFile(idx + 1);
|
||
});
|
||
}
|
||
addFile(0);
|
||
}
|
||
},
|
||
|
||
_uploadFiles(fileList, parentId, parentName) {
|
||
var formData = new FormData();
|
||
for (var i = 0; i < fileList.length; i++) {
|
||
formData.append('files', fileList[i]);
|
||
}
|
||
if (parentId) formData.append('parent_id', parentId);
|
||
this._doUpload(formData, parentId, parentName);
|
||
},
|
||
|
||
async _moveItem(itemId, newParentId) {
|
||
var body = { parent_id: newParentId || null };
|
||
try {
|
||
var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (r.ok) {
|
||
window.location.reload();
|
||
} else {
|
||
alert('Move failed');
|
||
}
|
||
} catch(e) {
|
||
alert('Move failed: ' + e.message);
|
||
}
|
||
},
|
||
|
||
async _doUpload(formData, parentId, parentName) {
|
||
var self = this;
|
||
this.uploading = true;
|
||
this.uploadStatus = 'Uploading to ' + parentName + '...';
|
||
this.uploadProgress = 10;
|
||
|
||
var folderHandled = formData.has('structure');
|
||
var url = folderHandled ? '/api/local-workspace/upload-folder' : '/api/local-workspace/upload';
|
||
|
||
try {
|
||
var r = await fetch(url, { method: 'POST', body: formData });
|
||
this.uploadProgress = 80;
|
||
var d = await r.json();
|
||
if (r.ok) {
|
||
this.uploadStatus = '✓ ' + d.items.length + ' item(s) uploaded';
|
||
this.uploadProgress = 100;
|
||
var items = d.items || [];
|
||
setTimeout(function() {
|
||
self.uploading = false;
|
||
window.location.reload();
|
||
}, 800);
|
||
} else {
|
||
this.uploadStatus = '✗ ' + (d.error || 'Upload failed');
|
||
this.uploadProgress = 0;
|
||
setTimeout(function() { self.uploading = false; }, 2000);
|
||
}
|
||
} catch(e) {
|
||
this.uploadStatus = '✗ Network error';
|
||
this.uploadProgress = 0;
|
||
setTimeout(function() { self.uploading = false; }, 2000);
|
||
}
|
||
}
|
||
|
||
};
|
||
});
|
||
});
|
||
</script>
|
||
{% endblock %}
|