feat: drag-and-drop interne, file viewer multi-type, sidebar persistante
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

1. Drag & drop interne: déplacer fichiers/dossiers existants vers d'autres
   dossiers ou racine via l'API /api/local-workspace/items/{id}/move.
   Items rendus draggable dans le template x-for et renderChildren.

2. File viewer pour fichiers uploadés: détection content_format='file',
   visualiseur intégré avec rendu adapté au type MIME:
   - Images: affichage direct
   - PDF: iframe
   - Texte/Code (.py .js .md .html .ps1 etc.): <pre> avec fetch du contenu
   - Autres: lien de téléchargement
   Route GET /api/files/{ws_id}/{filename:path} pour servir les fichiers.

3. Sidebar persistante: expandedFolders sauvegardé dans localStorage,
   restauré au chargement, préservé avant navigation vers un dossier.

4. Suppression: nettoie aussi le fichier disque pour content_format='file'
This commit is contained in:
2026-07-11 09:04:26 -04:00
parent 4810ff18eb
commit 4be4c3432b
4 changed files with 221 additions and 7 deletions
+9 -1
View File
@@ -803,12 +803,20 @@
},
// ── Tree expand/collapse + folder navigation ──
expandedFolders: {},
expandedFolders: (function() {
try { return JSON.parse(localStorage.getItem('fd_expanded_folders') || '{}'); }
catch(e) { return {}; }
})(),
toggleTreeFolder(id) {
this.expandedFolders[id] = !(this.expandedFolders[id] || false);
this.expandedFolders = Object.assign({}, this.expandedFolders);
try { localStorage.setItem('fd_expanded_folders', JSON.stringify(this.expandedFolders)); }
catch(e) {}
},
navigateToFolder(id) {
// Save expansion state before navigating
try { localStorage.setItem('fd_expanded_folders', JSON.stringify(this.expandedFolders)); }
catch(e) {}
window.location = `/local-workspace?folder=${id}`;
},
+56 -3
View File
@@ -150,7 +150,10 @@
<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'}">
<div class="ws-tree-item" :style="{paddingLeft:(node.depth||0)*20+'px'}"
draggable="true"
@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"
@@ -268,6 +271,7 @@ document.addEventListener('alpine:init', function() {
uploading: false,
uploadStatus: '',
uploadProgress: 0,
draggedItemId: null,
async init() {
try {
@@ -303,7 +307,7 @@ document.addEventListener('alpine:init', function() {
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">';
html += '<li><div class="ws-tree-item" style="padding-left:'+(depth*20)+'px" draggable="true" 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();document.querySelector(\'[x-data]\').__x.$data.toggleExpand('+c.id+')">▶</button>';
} else {
@@ -445,11 +449,42 @@ document.addEventListener('alpine:init', function() {
}
},
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 = ''; });
// Determine target parent
// ── 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 onclick = folderLink.getAttribute('onclick') || '';
var m = onclick.match(/folder=(\d+)/);
if (m) targetParentId = parseInt(m[1]);
}
}
// 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';
@@ -567,6 +602,24 @@ document.addEventListener('alpine:init', function() {
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;