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
This commit is contained in:
+95
-24
@@ -491,8 +491,11 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
||||
|
||||
@router.get("/local-workspace", response_class=HTMLResponse)
|
||||
async def local_workspace_page(request: Request):
|
||||
"""Local workspace page with file/folder tree."""
|
||||
async def local_workspace_page(request: Request, folder: int = None):
|
||||
"""Local workspace page with file/folder tree.
|
||||
|
||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request, [])
|
||||
@@ -502,40 +505,108 @@ async def local_workspace_page(request: Request):
|
||||
|
||||
ws = _get_active_workspace(request)
|
||||
ws_id = ws["id"] if ws else None
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_id IS NULL AND workspace_id=? ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall() if ws_id else []
|
||||
items = [{"id": r["id"], "name": r["title"] or "Untitled", "type": "page"} for r in rows]
|
||||
|
||||
ctx = {**sidebar, "user": user, "workspace_name": sidebar.get("active_ws_name", "My Workspace"), "items": items}
|
||||
# Build breadcrumb if navigating into a folder
|
||||
breadcrumb = []
|
||||
current_folder_id = folder
|
||||
if folder and ws_id:
|
||||
with get_conn() as conn:
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
|
||||
ctx = {
|
||||
**sidebar,
|
||||
"user": user,
|
||||
"workspace_name": sidebar.get("active_ws_name", "My Workspace"),
|
||||
"current_folder_id": current_folder_id or 0,
|
||||
"breadcrumb": breadcrumb,
|
||||
}
|
||||
template = env.get_template("local_workspace.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/tree")
|
||||
async def local_workspace_tree(request: Request):
|
||||
"""Return the file/folder tree filtered by active workspace."""
|
||||
async def local_workspace_tree(request: Request, folder: int = None):
|
||||
"""Return the file/folder tree filtered by active workspace.
|
||||
|
||||
If ?folder=ID is provided, returns only that folder's children.
|
||||
Otherwise returns the full recursive tree from root.
|
||||
"""
|
||||
ws = _get_active_workspace(request)
|
||||
ws_id = ws["id"] if ws else None
|
||||
if not ws_id:
|
||||
return {"tree": [], "breadcrumb": []}
|
||||
|
||||
with get_conn() as conn:
|
||||
roots = conn.execute(
|
||||
"SELECT id, title, parent_section FROM pages WHERE parent_id IS NULL AND workspace_id=? ORDER BY created_at DESC",
|
||||
if folder:
|
||||
# Show only this folder's children + build breadcrumb
|
||||
children = _build_tree_children(conn, folder, ws_id)
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
return {"tree": children, "breadcrumb": breadcrumb, "current_folder": folder}
|
||||
else:
|
||||
# Full tree from root
|
||||
roots = _build_tree_children(conn, None, ws_id)
|
||||
return {"tree": roots, "breadcrumb": [], "current_folder": None}
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/breadcrumb")
|
||||
async def local_workspace_breadcrumb(request: Request, folder: int):
|
||||
"""Return breadcrumb trail for a folder."""
|
||||
with get_conn() as conn:
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
return {"breadcrumb": breadcrumb}
|
||||
|
||||
|
||||
def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
|
||||
"""Recursively build the tree of children for a node."""
|
||||
if parent_id is None:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section FROM pages "
|
||||
"WHERE parent_id IS NULL AND workspace_id=? ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall() if ws_id else []
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section FROM pages "
|
||||
"WHERE parent_id=? AND workspace_id=? ORDER BY created_at DESC",
|
||||
(parent_id, ws_id),
|
||||
).fetchall()
|
||||
|
||||
tree = []
|
||||
for r in roots:
|
||||
with get_conn() as conn:
|
||||
children = conn.execute(
|
||||
"SELECT id, title, parent_section FROM pages WHERE parent_id=? ORDER BY created_at", (r["id"],)
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
rtype = "folder" if is_folder else "page"
|
||||
item = {"id": r["id"], "name": r["title"] or "Untitled", "type": rtype,
|
||||
"children": [{"id": c["id"], "name": c["title"] or "Untitled", "type": "page"} for c in children]}
|
||||
tree.append(item)
|
||||
return {"tree": tree}
|
||||
children = _build_tree_children(conn, r["id"], ws_id)
|
||||
tree.append({
|
||||
"id": r["id"],
|
||||
"name": r["title"] or "Untitled",
|
||||
"type": "folder" if is_folder else "page",
|
||||
"is_folder": is_folder,
|
||||
"children": children,
|
||||
"has_children": len(children) > 0,
|
||||
})
|
||||
return tree
|
||||
|
||||
|
||||
def _build_breadcrumb(conn, folder_id: int) -> list:
|
||||
"""Build breadcrumb trail from root to folder_id."""
|
||||
breadcrumb = []
|
||||
current = folder_id
|
||||
seen = set()
|
||||
while current and current not in seen:
|
||||
seen.add(current)
|
||||
row = conn.execute(
|
||||
"SELECT id, title, parent_id, parent_section FROM pages WHERE id=?",
|
||||
(current,),
|
||||
).fetchone()
|
||||
if row:
|
||||
breadcrumb.insert(0, {
|
||||
"id": row["id"],
|
||||
"name": row["title"] or "Untitled",
|
||||
"is_folder": row["parent_section"] == "Workspace",
|
||||
})
|
||||
current = row["parent_id"]
|
||||
else:
|
||||
break
|
||||
return breadcrumb
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items")
|
||||
|
||||
+32
-3
@@ -110,15 +110,30 @@
|
||||
<li class="sidebar-item ws-tree-item"
|
||||
data-id="{{ page.db_id }}"
|
||||
data-is-folder="{{ 'true' if page.is_folder else 'false' }}"
|
||||
data-depth="{{ depth }}"
|
||||
draggable="true"
|
||||
@dragstart="dragStart($event, {{ page.db_id }})"
|
||||
@dragover.prevent="dragOver($event, {{ page.db_id }}, {{ 'true' if page.is_folder else 'false' }})"
|
||||
@dragleave="dragLeave($event)"
|
||||
@drop.prevent="drop($event, {{ page.db_id }})"
|
||||
@dblclick="openWorkspacePage({{ page.db_id }})"
|
||||
style="padding-left:{{ 8 + depth * 16 }}px;">
|
||||
{% if page.is_folder %}
|
||||
<button class="tree-chevron"
|
||||
@click.stop="toggleTreeFolder({{ page.db_id }})"
|
||||
:class="{ open: expandedFolders[{{ page.db_id }}] }"
|
||||
title="Toggle folder">▶</button>
|
||||
{% else %}
|
||||
<span style="width:16px;flex-shrink:0;"></span>
|
||||
{% endif %}
|
||||
<span class="page-icon">{{ page.icon }}</span>
|
||||
{% if page.is_folder %}
|
||||
<span class="page-name tree-folder-link"
|
||||
@click.stop="navigateToFolder({{ page.db_id }})"
|
||||
draggable="false"
|
||||
title="Open folder">{{ page.name }}</span>
|
||||
{% else %}
|
||||
<a href="/pages/{{ page.db_id }}" class="page-name" draggable="false">{{ page.name }}</a>
|
||||
{% endif %}
|
||||
<div class="sidebar-item-actions">
|
||||
{% if page.is_folder %}
|
||||
<button class="ws-act" @click.stop="newPageInFolder({{ page.db_id }})" title="New file in folder">📄</button>
|
||||
@@ -128,10 +143,14 @@
|
||||
</div>
|
||||
</li>
|
||||
{% if page.children %}
|
||||
{{ render_workspace_tree(page.children, depth + 1) }}
|
||||
<li x-show="expandedFolders[{{ page.db_id }}]" x-transition style="list-style:none;margin:0;padding:0;">
|
||||
<ul style="list-style:none;margin:0;padding:0;">
|
||||
{{ render_workspace_tree(page.children, depth + 1) }}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
{% endmacro %}
|
||||
<div class="sidebar-section-items" x-show="sectionsOpen.workspace" x-transition>
|
||||
<ul class="sidebar-items" data-section="workspace">
|
||||
{% if workspace_pages %}
|
||||
@@ -783,6 +802,16 @@
|
||||
window.location = `/pages/${pageId}`;
|
||||
},
|
||||
|
||||
// ── Tree expand/collapse + folder navigation ──
|
||||
expandedFolders: {},
|
||||
toggleTreeFolder(id) {
|
||||
this.expandedFolders[id] = !(this.expandedFolders[id] || false);
|
||||
this.expandedFolders = Object.assign({}, this.expandedFolders);
|
||||
},
|
||||
navigateToFolder(id) {
|
||||
window.location = `/local-workspace?folder=${id}`;
|
||||
},
|
||||
|
||||
createEmptyPage() {
|
||||
this.showNewPageMenu = false;
|
||||
this.newPage('Private');
|
||||
|
||||
@@ -8,8 +8,16 @@
|
||||
<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="color:rgba(255,255,255,.5)">Workspace</span>
|
||||
<span class="page-title-topbar">{{ workspace_name }}</span>
|
||||
<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>
|
||||
@@ -30,9 +38,18 @@
|
||||
.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);}
|
||||
@@ -59,12 +76,34 @@
|
||||
.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>📁 {{ workspace_name }}</h1>
|
||||
<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>
|
||||
@@ -73,11 +112,28 @@
|
||||
|
||||
<!-- Tree -->
|
||||
<ul class="ws-tree" x-show="tree.length>0">
|
||||
<template x-for="(node,i) in tree" :key="i">
|
||||
<template x-for="node in tree" :key="node.id">
|
||||
<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="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">
|
||||
@@ -85,11 +141,14 @@
|
||||
<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>
|
||||
<!-- 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>
|
||||
@@ -97,7 +156,7 @@
|
||||
<!-- Empty -->
|
||||
<div class="empty-state" x-show="tree.length===0&&loaded">
|
||||
<div class="empty-icon">📁</div>
|
||||
<h2>Empty workspace</h2>
|
||||
<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>
|
||||
@@ -109,7 +168,10 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -136,7 +198,7 @@
|
||||
<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="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>
|
||||
@@ -147,21 +209,11 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
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;
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', function() {
|
||||
Alpine.data('wsInit', function() {
|
||||
return {
|
||||
tree:[],
|
||||
breadcrumb: [],
|
||||
loaded:false,
|
||||
showCreate:false,
|
||||
showRename:false,
|
||||
@@ -170,14 +222,85 @@ document.addEventListener('alpine:init', function() {
|
||||
newName:'',
|
||||
parentFolder:null,
|
||||
target:null,
|
||||
expanded: {},
|
||||
currentFolder: {{ current_folder_id }},
|
||||
|
||||
async init() {
|
||||
try {
|
||||
var r = await fetch('/api/local-workspace/tree');
|
||||
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 = flatten(d.tree||[],0);
|
||||
this.tree = this.addDepths(d.tree || [], 0);
|
||||
this.breadcrumb = d.breadcrumb || [];
|
||||
this.loaded = true;
|
||||
} catch(e) { 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,'"');
|
||||
},
|
||||
|
||||
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) {
|
||||
@@ -219,34 +342,34 @@ document.addEventListener('alpine:init', function() {
|
||||
var self = this;
|
||||
this.$nextTick(function() {
|
||||
var el = self.$refs.rinp;
|
||||
if (el) setTimeout(function() { el.focus(); }, 100);
|
||||
if (el) setTimeout(function() { el.focus(); el.select(); }, 100);
|
||||
});
|
||||
},
|
||||
|
||||
async doRename() {
|
||||
var n = this.newName.trim();
|
||||
if (!n||!this.target) return;
|
||||
await fetch('/api/local-workspace/items/'+this.target.db_id, {
|
||||
var r = 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();
|
||||
if (r.ok) { window.location.reload(); }
|
||||
},
|
||||
|
||||
startDelete(node) { this.target = node; this.showDelete = true; },
|
||||
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();
|
||||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, { method:'DELETE' });
|
||||
if (r.ok) { window.location.reload(); }
|
||||
}
|
||||
};});
|
||||
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+44
-1
@@ -397,8 +397,51 @@ input, select, textarea { font-family: inherit; color: inherit; }
|
||||
.sidebar-item .page-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Tree chevron for expand/collapse */
|
||||
.tree-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;
|
||||
}
|
||||
.tree-chevron:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.tree-chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Folder link styling */
|
||||
.tree-folder-link {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tree-folder-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-item .item-actions {
|
||||
|
||||
Reference in New Issue
Block a user