Files
flowdeck/app/templates/local_workspace.html
T
bruno e9a419b83f
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
feat: arborescence interactive avec expand/collapse, breadcrumb et navigation par dossier
- API /api/local-workspace/tree: arbre récursif complet (support optionnel ?folder=ID)
- API /api/local-workspace/breadcrumb: fil d'Ariane parent pour un dossier
- Page /local-workspace: supporte ?folder=ID pour naviguer dans un dossier
- Sidebar: expand/collapse (▶/▼) sur les dossiers, clic dossier → /local-workspace?folder=ID
- CSS: styles chevron .tree-chevron, .tree-folder-link
- Alpine.js: expandedFolders, toggleTreeFolder, navigateToFolder
2026-07-11 08:24:57 -04:00

376 lines
16 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);}
.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;}
</style>
<div class="ws-layout" x-data="wsInit()">
<!-- 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 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 in tree" :key="node.id">
<li>
<div class="ws-tree-item" :style="{paddingLeft:(node.depth||0)*20+'px'}">
<!-- 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"></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 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. 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>
</div>
<script>
document.addEventListener('alpine:init', function() {
Alpine.data('wsInit', function() {
return {
tree:[],
breadcrumb: [],
loaded:false,
showCreate:false,
showRename:false,
showDelete:false,
createType:'file',
newName:'',
parentFolder:null,
target:null,
expanded: {},
currentFolder: {{ current_folder_id }},
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();
this.tree = this.addDepths(d.tree || [], 0);
this.breadcrumb = d.breadcrumb || [];
this.loaded = true;
} catch(e) { console.error('Tree load error:', e); this.loaded = true; }
},
addDepths(nodes, depth) {
var result = [];
for (var i=0; i<nodes.length; i++) {
var n = nodes[i];
n.depth = depth;
result.push(n);
if (n.children && n.children.length > 0) {
result = result.concat(this.addDepths(n.children, depth+1));
}
}
return result;
},
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">';
if (c.is_folder && hasKids) {
html += '<button class="ws-chevron" onclick="event.stopPropagation();document.querySelector(\'[x-data]\').__x.$data.toggleExpand('+c.id+')">▶</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+'\'">'+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,'&quot;');
},
toggleExpand(id) {
this.expanded[id] = !(this.expanded[id] || false);
this.expanded = Object.assign({}, this.expanded);
},
navigateToFolder(id) {
window.location = '/local-workspace?folder=' + id;
},
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(); }
}
};
});
});
</script>
{% endblock %}