Files
flowdeck/app/templates/local_workspace.html
T
bruno 8497a36da9
FlowDeck CI / test (push) Failing after 8s
FlowDeck CI / docker (push) Has been skipped
fix: bouton delete cassé + preview PDF cliquable + icônes distinctes FlowDeck vs upload
1. Delete: ev.target pouvait être un noeud texte (emoji 🗑)
   → Text.closest() n'existe pas → handleTreeAction échouait.
   Fix: fallback sur ev.target.parentElement.closest().

2. Preview PDF: ajout lien 'Open viewer →' vers /pages/{id}
   (le viewer standalone rend le PDF dans une iframe).

3. Icônes distinctes:
   - Pages FlowDeck (content_format='blocks'/'markdown') → 📝
   - Fichiers uploadés → icône basée sur l'extension
   - _fileIcon() accepte contentFormat, propage depuis l'API tree
   - Appliqué dans sidebar (board.py) + page workspace (x-for + renderChildren)
2026-07-11 14:00:57 -04:00

953 lines
40 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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);}
/* Filter chips */
.filter-chips{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
.filter-chip{padding:4px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;font-size:12px;color:var(--text-secondary);cursor:pointer;transition:all 150ms;}
.filter-chip:hover{background:var(--bg-hover);color:var(--text);}
.filter-chip.active{background:var(--accent);color:#fff;border-color:var(--accent);}
/* 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="expandAll()" title="Expand all">⊞</button>
<button class="btn-icon" @click="collapseAll()" title="Collapse all">⊟</button>
<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>
<!-- Filter chips -->
<div class="filter-chips" x-show="tree.length>0">
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="FlowDeck pages">📝 Pages</button>
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF">📕 PDF</button>
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images">🖼️ Img</button>
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code">📜 Code</button>
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text">📄 Text</button>
</div>
<ul class="ws-tree" x-show="displayTree.length>0" @click="handleTreeAction($event)">
<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="_fileIcon(node.name, node.is_folder, node.content_format)"></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: (function() {
try { return JSON.parse(localStorage.getItem('fd_ws_expanded') || '{}'); }
catch(e) { return {}; }
})(),
currentFolder: {{ current_folder_id }},
searchQuery: '',
filterType: '',
// 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;
// Expose globally for renderChildren onclick handlers
window._wsData = this;
} 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 = this._fileIcon(c.name, c.is_folder, c.content_format);
var escName = this.esc(c.name);
html += '<li><div class="ws-tree-item" style="padding-left:'+(depth*20)+'px" draggable="true"'
+ ' data-folder-id="'+(c.is_folder?c.id:'')+'"'
+ ' data-ws-id="'+c.id+'"'
+ ' ondragstart="var d=window._wsData;if(d)d.onInternalDragStart(event,'+c.id+')"'
+ ' ondragend="event.target.style.opacity=\'\'">';
if (c.is_folder && hasKids) {
html += '<button class="ws-chevron ws-expand-btn" data-ws-id="'+c.id+'" onclick="event.stopPropagation();var d=window._wsData;if(d)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=window._wsData;if(d)d.showPreview(event,{id:'+c.id+',name:\''+escName+'\',is_folder:false,content_format:\''+(c.content_format||'')+'\'})"'
+ ' onmouseleave="var d=window._wsData;if(d)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" data-ws-action="new-file" data-ws-id="'+c.id+'" data-ws-name="'+escName+'">📄</button>';
html += '<button class="ws-act" data-ws-action="new-folder" data-ws-id="'+c.id+'" data-ws-name="'+escName+'">📁</button>';
html += '</span>';
}
html += '<button class="ws-act" data-ws-action="rename" data-ws-id="'+c.id+'" data-ws-name="'+escName+'">✏️</button>';
html += '<button class="ws-act danger" data-ws-action="delete" data-ws-id="'+c.id+'" data-ws-name="'+escName+'">🗑</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;');
},
// ── Event delegation for renderChildren action buttons ──
handleTreeAction(ev) {
var btn = (ev.target.closest && ev.target.closest('[data-ws-action]'))
|| (ev.target.parentElement && ev.target.parentElement.closest('[data-ws-action]'));
if (!btn) return;
ev.stopPropagation();
var action = btn.getAttribute('data-ws-action');
var id = parseInt(btn.getAttribute('data-ws-id'));
var name = btn.getAttribute('data-ws-name') || '';
if (!id) return;
switch(action) {
case 'new-file':
this.openModal('file', {db_id: id, name: name});
break;
case 'new-folder':
this.openModal('folder', {db_id: id, name: name});
break;
case 'rename':
this.startRename({db_id: id, name: name});
break;
case 'delete':
this.startDelete({db_id: id, name: name});
break;
}
},
toggleExpand(id, btn) {
this.expanded[id] = !(this.expanded[id] || false);
this.expanded = Object.assign({}, this.expanded);
this._saveExpandedState();
// 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) {
this._saveExpandedState();
window.location = '/local-workspace?folder=' + id;
},
_saveExpandedState() {
try { localStorage.setItem('fd_ws_expanded', JSON.stringify(this.expanded)); }
catch(e) {}
},
_reloadAfterAction() {
this._saveExpandedState();
this._reloadAfterAction();
},
// ── Expand / Collapse all ──
expandAll() {
var self = this;
this._walkTree(this.tree, function(n) {
if (n.is_folder && n.children && n.children.length > 0) {
self.expanded[n.id] = true;
}
});
this.expanded = Object.assign({}, this.expanded);
// Toggle static HTML for renderChildren
this._toggleAllUl(this.$el, true);
},
collapseAll() {
this.expanded = {};
this._toggleAllUl(this.$el, false);
},
_walkTree(nodes, fn) {
for (var i=0; i<nodes.length; i++) {
fn(nodes[i]);
if (nodes[i].children) this._walkTree(nodes[i].children, fn);
}
},
_toggleAllUl(el, show) {
var uls = el.querySelectorAll('ul.ws-tree');
for (var i=0; i<uls.length; i++) {
uls[i].style.display = show ? '' : 'none';
}
// Also toggle chevrons
var chevs = el.querySelectorAll('.ws-chevron');
for (var j=0; j<chevs.length; j++) {
if (show) chevs[j].classList.add('open');
else chevs[j].classList.remove('open');
}
},
// ── File type icon ──
_fileIcon(name, isFolder, contentFormat) {
if (isFolder) return '📁';
// FlowDeck internal pages (no extension, blocks/markdown format)
if (contentFormat && contentFormat !== 'file') return '📝';
var n = (name || '').toLowerCase();
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return '🖼️';
if (/\.pdf$/.test(n)) return '📕';
if (/\.(md|markdown)$/.test(n)) return '📝';
if (/\.(py)$/.test(n)) return '🐍';
if (/\.(js|jsx|ts|tsx)$/.test(n)) return '📜';
if (/\.(html?|xml)$/.test(n)) return '🌐';
if (/\.css$/.test(n)) return '🎨';
if (/\.json$/.test(n)) return '📋';
if (/\.sql$/.test(n)) return '🗃️';
if (/\.(sh|bash|zsh)$/.test(n)) return '💻';
if (/\.ps1$/.test(n)) return '⚡';
if (/\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$/.test(n)) return '📜';
if (/\.(txt|log)$/.test(n)) return '📄';
if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return '📦';
return '📄';
},
// ── Search / Filter ──
doFilter() {
var q = (this.searchQuery || '').toLowerCase().trim();
var ft = this.filterType || '';
if (!q && !ft) { this.displayTree = this.tree; return; }
var self = this;
this.displayTree = this._filterTree(this.tree, q, ft);
},
filterByType(type) {
this.filterType = (this.filterType === type) ? '' : type;
this.doFilter();
},
_filterTree(nodes, query, fileType) {
var result = [];
for (var i=0; i<nodes.length; i++) {
var n = nodes[i];
var filteredChildren = n.children ? this._filterTree(n.children, query, fileType) : [];
var selfMatches = (n.name || '').toLowerCase().indexOf(query || '') !== -1;
// File type filter
var typeMatch = true;
if (fileType && !n.is_folder) {
var nm = (n.name || '').toLowerCase();
switch(fileType) {
case 'pdf': typeMatch = /\.pdf$/.test(nm); break;
case 'image': typeMatch = /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(nm); break;
case 'code': typeMatch = /\.(py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql)$/.test(nm); break;
case 'text': typeMatch = /\.(txt|md|markdown|log)$/.test(nm); break;
case 'page': typeMatch = !/\.(pdf|png|jpe?g|gif|webp|svg|bmp|ico|py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql|txt|md|markdown|log)$/.test(nm); break;
}
}
if ((selfMatches || filteredChildren.length > 0) && typeMatch) {
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 if ((node.name||'').toLowerCase().endsWith('.pdf')) {
this.previewContent = '<div style="text-align:center;padding:16px;"><span style="font-size:48px;">📕</span><br><span style="color:var(--text-secondary);font-size:12px;">PDF Document</span><br><a href="/pages/'+node.id+'" style="color:var(--accent);font-size:11px;margin-top:4px;display:inline-block;">Open viewer →</a></div>';
} 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 = self._extractPreview(d.content || '', d.format);
})
.catch(function() { self.previewContent = '(preview not available)'; });
}
},
_extractPreview(content, format) {
if (!content) return '(empty)';
// Try to extract text from Notion block JSON
if (format === 'blocks' || format === 'markdown') {
try {
var blocks = typeof content === 'string' ? JSON.parse(content) : content;
if (blocks && blocks.blocks && Array.isArray(blocks.blocks)) {
var lines = [];
for (var i=0; i<blocks.blocks.length && lines.length<20; i++) {
var b = blocks.blocks[i];
if (b.content) lines.push(b.content);
else if (b.text) lines.push(b.text);
else if (typeof b === 'string') lines.push(b);
}
return lines.join('\n').substring(0, 500) || '(empty)';
}
} catch(e) {}
}
// Fallback: raw text
return String(content).substring(0, 500);
},
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 = '';
this._reloadAfterAction();
}
},
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) { this._reloadAfterAction(); }
},
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) { this._reloadAfterAction(); }
},
// ── 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) {
this._reloadAfterAction();
} 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;
this._reloadAfterAction();
}, 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 %}