Compare commits

..
1 Commits
Author SHA1 Message Date
bruno e36dead312 feat: multi-vues workspace + preview panel (tree/list/details/title/content)
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- Sélecteur de vue avec 5 modes: Tree, List, Details, Title, Content
- Vue List: compacte (Name, Type, Size, Modified)
- Vue Details: table complète (Path, Author, Tags)
- Vue Title: grille de cartes avec icônes
- Vue Content: liste avec aperçu inline
- Preview Panel: panneau latéral avec rendu contenu + métadonnées + actions
- Animation slide-in/out pour le preview panel
- Tri par date (Oldest/Newest)
2026-07-12 11:18:57 -04:00
3 changed files with 258 additions and 19 deletions
+17
View File
@@ -1,5 +1,22 @@
# Changelog — FlowDeck
## v2.3.0 (2026-07-12) — Multi-Vues Workspace & Preview Panel
### Added
- **Sélecteur de vue**: 🌳 Tree, 📋 List, 📊 Details, 🏷️ Title, 📝 Content — 5 modes d'affichage
- **Vue List**: liste compacte avec Name, Type, Size, Modified
- **Vue Details**: table complète avec Path, Author, Tags
- **Vue Title**: grille de cartes avec icônes et métadonnées
- **Vue Content**: liste avec aperçu inline (taille ou nombre d'items)
- **Preview Panel**: panneau latéral droit avec contenu du fichier sélectionné
- Header avec icône + titre + bouton fermer
- Métadonnées (type, taille, date)
- Tags affichés en chips
- Corps avec rendu du contenu (blocks JSON → HTML, texte brut)
- Actions: Open, Rename, Delete
- Animation slide-in/out
- **Tri par date**: options Oldest/Newest dans le selecteur de tri
## v2.2.0 (2026-07-12) — Tags & Recherche Avancée
### Added
+1 -1
View File
@@ -39,7 +39,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="2.2.0",
version="2.3.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
+240 -18
View File
@@ -221,6 +221,55 @@
.ws-table-compact th{text-align:left;padding:6px 10px;color:var(--text-tertiary);font-weight:500;border-bottom:2px solid var(--border);font-size:11px;text-transform:uppercase;}
.ws-table-compact td{padding:5px 10px;border-bottom:1px solid var(--border);}
.ws-table-compact tr:hover td{background:var(--bg-hover);}
/* View selector */
.view-selector{display:flex;gap:2px;background:var(--bg-tertiary);border-radius:8px;padding:2px;}
.view-btn{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;border-radius:6px;cursor:pointer;font-size:14px;color:var(--text-tertiary);transition:all 150ms;}
.view-btn:hover{background:var(--bg-hover);color:var(--text);}
.view-btn.active{background:var(--accent);color:#fff;}
/* Title grid */
.title-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px;margin-top:8px;}
.title-card{display:flex;flex-direction:column;align-items:center;padding:16px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:all 150ms;min-height:100px;justify-content:center;}
.title-card:hover{background:var(--bg-hover);border-color:var(--accent);}
.title-card.selected-card{background:rgba(35,131,226,.1);border-color:var(--accent);}
.title-card-icon{font-size:28px;margin-bottom:8px;}
.title-card-name{font-size:13px;font-weight:500;text-align:center;word-break:break-word;max-width:100%;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;}
.title-card-meta{display:flex;gap:8px;margin-top:6px;font-size:11px;color:var(--text-tertiary);}
/* Content list view */
.content-list{display:flex;flex-direction:column;gap:4px;margin-top:8px;}
.content-item{padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;cursor:pointer;transition:all 150ms;}
.content-item:hover{background:var(--bg-hover);border-color:var(--accent);}
.content-item.selected-row{background:rgba(35,131,226,.08);border-color:var(--accent);}
.content-item-header{display:flex;align-items:center;gap:8px;font-size:14px;}
.content-item-preview{font-size:12px;color:var(--text-secondary);margin-top:6px;padding-left:24px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:600px;}
.content-item-tags{display:flex;gap:4px;margin-top:6px;padding-left:24px;}
/* Selected row highlight */
.selected-row{background:rgba(35,131,226,.08)!important;}
/* Preview panel */
.preview-panel{position:fixed;top:44px;right:0;width:380px;max-width:90vw;height:calc(100vh - 44px);background:var(--bg);border-left:1px solid var(--border-strong);box-shadow:-4px 0 20px rgba(0,0,0,.3);z-index:200;display:flex;flex-direction:column;overflow:hidden;}
@media(max-width:768px){.preview-panel{width:100vw;max-width:100vw;}}
.preview-panel-header{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--border);flex-shrink:0;}
.preview-panel-title{font-size:15px;font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.preview-close{background:none;border:none;color:var(--text-tertiary);font-size:20px;cursor:pointer;padding:2px 6px;border-radius:4px;line-height:1;}
.preview-close:hover{color:var(--text);background:var(--bg-hover);}
.preview-panel-meta{display:flex;gap:12px;padding:8px 16px;font-size:12px;color:var(--text-tertiary);border-bottom:1px solid var(--border);flex-shrink:0;}
.preview-panel-tags{display:flex;gap:4px;flex-wrap:wrap;padding:8px 16px;border-bottom:1px solid var(--border);flex-shrink:0;}
.preview-panel-body{flex:1;overflow:auto;padding:16px;font-size:13px;line-height:1.6;color:var(--text);}
.preview-panel-body pre{background:var(--bg-tertiary);border-radius:8px;padding:12px;overflow:auto;font-size:12px;font-family:'Fira Code',monospace;white-space:pre-wrap;word-wrap:break-word;}
.preview-panel-body img{max-width:100%;border-radius:8px;}
.preview-panel-actions{display:flex;gap:8px;padding:12px 16px;border-top:1px solid var(--border);flex-shrink:0;}
/* Transitions */
.preview-enter{transition:transform .2s ease,opacity .2s ease;}
.preview-enter-start{transform:translateX(100%);opacity:0;}
.preview-enter-end{transform:translateX(0);opacity:1;}
.preview-leave{transition:transform .15s ease,opacity .15s ease;}
.preview-leave-start{transform:translateX(0);opacity:1;}
.preview-leave-end{transform:translateX(100%);opacity:0;}
</style>
<div class="ws-layout" x-data="wsInit()"
@@ -292,17 +341,23 @@
<div class="ws-toolbar">
<button class="btn-icon" @click="goBack()" title="Back" :disabled="!canGoBack">←</button>
<button class="btn-icon" @click="goForward()" title="Forward" :disabled="!canGoForward">→</button>
<button class="btn-icon" @click="viewMode = (viewMode==='tree'?'table':'tree')" :title="viewMode==='tree'?'Table view':'Tree view'">
<span x-text="viewMode==='tree'?'📋':'🌳'"></span>
</button>
<div class="view-selector">
<button class="view-btn" :class="{ active: viewMode==='tree' }" @click="viewMode='tree'; previewItem=null" title="Tree view">🌳</button>
<button class="view-btn" :class="{ active: viewMode==='list' }" @click="viewMode='list'; previewItem=null" title="List view">📋</button>
<button class="view-btn" :class="{ active: viewMode==='details' }" @click="viewMode='details'; previewItem=null" title="Details view">📊</button>
<button class="view-btn" :class="{ active: viewMode==='title' }" @click="viewMode='title'; previewItem=null" title="Title cards">🏷️</button>
<button class="view-btn" :class="{ active: viewMode==='content' }" @click="viewMode='content'; previewItem=null" title="Content preview">📝</button>
</div>
<select class="sort-select" x-model="sortBy" @change="doSort()">
<option value="">Sort by...</option>
<option value="name-asc">Name A→Z</option>
<option value="name-desc">Name Z→A</option>
<option value="type">Type</option>
<option value="date-asc">Oldest</option>
<option value="date-desc">Newest</option>
</select>
<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="expandAll()" title="Expand all" x-show="viewMode==='tree'">⊞</button>
<button class="btn-icon" @click="collapseAll()" title="Collapse all" x-show="viewMode==='tree'">⊟</button>
<button class="btn-icon" @click="openModal('file')" title="New file">📄</button>
<button class="btn-icon" @click="openModal('folder')" title="New folder">📁</button>
</div>
@@ -316,6 +371,7 @@
</div>
<!-- Tree -->
<div x-show="viewMode==='tree'" style="display:contents;">
<!-- Search bar -->
<div class="search-bar" x-show="tree.length>0 || searchQuery">
<span class="search-icon">🔍</span>
@@ -408,6 +464,7 @@
</li>
</template>
</ul>
</div>
<!-- Tag filter bar -->
<div class="tag-filter-bar" x-show="workspaceTags && workspaceTags.length > 0">
@@ -476,27 +533,106 @@
</tbody>
</table>
<!-- Regular table view (toggle) -->
<table class="ws-table" x-show="viewMode==='table' && !searchQuery && !filterType && !tagFilter && displayTree.length>0">
<!-- List view — compact flat list -->
<table class="ws-table-compact" x-show="viewMode==='list' && !searchQuery && !filterType && !tagFilter && flatTree.length>0">
<thead><tr>
<th style="width:30px"><input type="checkbox" @click="selectAll($event)"></th>
<th>Name</th><th>Type</th><th style="width:100px">Size</th>
<th style="width:24px"></th>
<th>Name</th>
<th style="width:80px">Type</th>
<th style="width:80px">Size</th>
<th style="width:100px">Modified</th>
</tr></thead>
<tbody>
<template x-for="node in flatTree" :key="'t'+node.id">
<tr :class="{ selected: !!selectedIds[node.id] }">
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td>
<td>
<span class="table-icon" x-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<span class="table-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
</td>
<td x-text="node.is_folder ? 'Folder' : 'File'"></td>
<td class="table-meta" x-text="node.is_folder ? (node.child_count ? node.child_count+' items' : 'Empty') : (node.size_display || '—')"></td>
<template x-for="node in flatTree" :key="'l'+node.id">
<tr @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" style="cursor:pointer">
<td><span x-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span></td>
<td class="col-name" x-text="node.name"></td>
<td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? '📁 Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
<td style="font-size:12px;color:var(--text-secondary)" x-text="node.is_folder ? (node.child_count||0)+' items' : (node.size_display||'—')"></td>
<td style="font-size:12px;color:var(--text-tertiary)" x-text="_formatDate(node.updated_at)"></td>
</tr>
</template>
</tbody>
</table>
<!-- Details view — full table with all columns -->
<table class="ws-table-enhanced" x-show="viewMode==='details' && !searchQuery && !filterType && !tagFilter && flatTree.length>0">
<thead><tr>
<th style="width:24px"></th>
<th>Name</th>
<th>Path</th>
<th>Size</th>
<th>Modified</th>
<th>Type</th>
<th>Author</th>
<th>Tags</th>
</tr></thead>
<tbody>
<template x-for="node in flatTree" :key="'d'+node.id">
<tr @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" style="cursor:pointer"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<td><span class="table-icon" x-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span></td>
<td class="col-name" x-text="node.name"></td>
<td class="col-path" x-text="node._path || '—'"></td>
<td class="col-size" x-text="node.size_display || '—'"></td>
<td class="col-date" x-text="_formatDate(node.updated_at)"></td>
<td class="col-type" x-text="node.is_folder ? '📁 Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
<td class="col-author" x-text="node.author || '—'"></td>
<td class="col-tags">
<span style="display:flex;align-items:center;gap:2px;flex-wrap:wrap;">
<template x-for="t in (node.tags||[])" :key="'dtag'+t.id">
<span class="tag-chip"><span x-text="t.name"></span></span>
</template>
</span>
</td>
</tr>
</template>
</tbody>
</table>
<!-- Title view — card grid -->
<div class="title-grid" x-show="viewMode==='title' && !searchQuery && !filterType && !tagFilter && flatTree.length>0">
<template x-for="node in flatTree" :key="'g'+node.id">
<div class="title-card" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)"
:class="{ 'selected-card': previewItem && previewItem.id === node.id }">
<div class="title-card-icon" x-text="node.is_folder ? '📁' : _fileIcon(node.name, node.is_folder, node.content_format)"></div>
<div class="title-card-name" x-text="node.name"></div>
<div class="title-card-meta">
<span x-text="_formatDate(node.updated_at)"></span>
<span x-text="node.size_display || '—'"></span>
</div>
</div>
</template>
</div>
<!-- Content view — list with inline preview -->
<div class="content-list" x-show="viewMode==='content' && !searchQuery && !filterType && !tagFilter && flatTree.length>0">
<template x-for="node in flatTree" :key="'c'+node.id">
<div class="content-item" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<div class="content-item-header">
<span x-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<span class="col-name" x-text="node.name"></span>
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span>
</div>
<div class="content-item-preview" x-text="node.is_folder ? (node.child_count||0)+' items' : (node.size_display||'—')"></div>
<div class="content-item-tags" x-show="(node.tags||[]).length > 0">
<template x-for="t in (node.tags||[])" :key="'ctag'+t.id">
<span class="tag-chip"><span x-text="t.name"></span></span>
</template>
</div>
</div>
</template>
</div>
<!-- Old table view kept for backward compat, hidden -->
<table class="ws-table" x-show="false" style="display:none">
<thead><tr><th>Name</th><th>Type</th><th>Size</th></tr></thead>
<tbody><template x-for="node in flatTree" :key="'t'+node.id">
<tr><td x-text="node.name"></td></tr>
</template></tbody>
</table>
<!-- Empty -->
<div class="empty-state" x-show="tree.length===0&&loaded">
<div class="empty-icon">📁</div>
@@ -563,6 +699,31 @@
<div class="file-preview-body" x-html="previewContent"></div>
</div>
<!-- Preview Panel — right side panel for file preview -->
<div class="preview-panel" x-show="previewItem && (viewMode==='list' || viewMode==='details' || viewMode==='title' || viewMode==='content')" x-transition:enter="preview-enter" x-transition:leave="preview-leave">
<div class="preview-panel-header">
<span x-text="_fileIcon(previewItem.name, previewItem.is_folder, previewItem.content_format)"></span>
<span class="preview-panel-title" x-text="previewItem.name"></span>
<button class="preview-close" @click="previewItem=null" title="Close preview">×</button>
</div>
<div class="preview-panel-meta">
<span x-text="previewItem.is_folder ? '📁 Folder' : _fileTypeLabel(previewItem.name, previewItem.content_format)"></span>
<span x-text="previewItem.size_display || '—'"></span>
<span x-text="_formatDate(previewItem.updated_at)"></span>
</div>
<div class="preview-panel-tags" x-show="(previewItem.tags||[]).length > 0">
<template x-for="t in (previewItem.tags||[])" :key="'ptag'+t.id">
<span class="tag-chip"><span x-text="t.name"></span></span>
</template>
</div>
<div class="preview-panel-body" x-html="previewPanelContent || '<div style=text-align:center;padding:40px;color:var(--text-tertiary)>Loading preview...</div>'"></div>
<div class="preview-panel-actions">
<button class="btn btn-secondary" @click="openPage(previewItem.id)">📄 Open</button>
<button class="btn btn-secondary" @click="startRename(previewItem)">✏️ Rename</button>
<button class="btn btn-danger" @click="deleteWithUndo(previewItem)">🗑 Delete</button>
</div>
</div>
</div>
<script>
@@ -608,6 +769,10 @@ document.addEventListener('alpine:init', function() {
tagFilter: '',
tagInputId: null,
// Preview panel
previewItem: null,
previewPanelContent: '',
// Multi-select
selectedIds: {},
selectedIdsCount: 0,
@@ -830,11 +995,66 @@ document.addEventListener('alpine:init', function() {
return null;
},
// ── Preview panel ──
selectForPreview(node) {
this.previewItem = node;
this.previewPanelContent = '<div style="text-align:center;padding:40px;color:var(--text-tertiary)">Loading...</div>';
this._loadPreviewContent(node);
},
async _loadPreviewContent(node) {
if (node.is_folder) {
this.previewPanelContent = '<div style="text-align:center;padding:40px;color:var(--text-secondary)">'
+ '<span style="font-size:48px;display:block;margin-bottom:12px">📁</span>'
+ '<div style="font-size:14px">' + (node.child_count||0) + ' item(s)</div>'
+ '<button class="btn btn-secondary" style="margin-top:16px" onclick="window.location=\'/local-workspace?folder=' + node.id + '\'">Open folder</button>'
+ '</div>';
return;
}
try {
var r = await this._fetchRetry('/api/local-workspace/page-content/' + node.id, true);
var content = r.content || '';
var format = r.format || '';
var preview = '';
if (format === 'file') {
preview = '<div style="text-align:center;padding:20px;color:var(--text-secondary)">'
+ '<span style="font-size:48px;display:block;margin-bottom:8px">📄</span>'
+ 'Uploaded file — open to view</div>';
} else if (format === 'blocks') {
try {
var blocks = typeof content === 'string' ? JSON.parse(content) : content;
if (blocks && blocks.blocks) {
preview = blocks.blocks.slice(0, 30).map(function(b) {
var txt = b.content || b.text || (typeof b === 'string' ? b : '');
return '<div style="margin-bottom:6px;line-height:1.5">' + this._escHtml(txt) + '</div>';
}, this).join('');
}
} catch(e) { preview = '<pre>' + this._escHtml(String(content).substring(0, 2000)) + '</pre>'; }
} else {
preview = '<pre>' + this._escHtml(String(content).substring(0, 2000)) + '</pre>';
}
this.previewPanelContent = preview || '<div style="color:var(--text-tertiary);text-align:center;padding:20px">(empty)</div>';
} catch(e) {
this.previewPanelContent = '<div style="text-align:center;padding:20px;color:var(--text-tertiary)">Preview not available</div>';
}
},
_escHtml(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
},
// ── Content snippet for content view ──
get contentSnippetMap() {
// Preload snippets? Too expensive. Return empty, load on hover.
return {};
},
_markTree(nodes, depth) {
for (var i=0; i<nodes.length; i++) {
var n = nodes[i];
n.depth = depth;
n.db_id = n.id;
n._path = '/' + n.name;
if (n.children && n.children.length > 0) {
this._markTree(n.children, depth + 1);
}
@@ -1060,6 +1280,8 @@ document.addEventListener('alpine:init', function() {
var sorted = this.tree.slice();
if (s === 'name-asc') sorted.sort(function(a,b) { return (a.name||'').localeCompare(b.name||''); });
else if (s === 'name-desc') sorted.sort(function(a,b) { return (b.name||'').localeCompare(a.name||''); });
else if (s === 'date-asc') sorted.sort(function(a,b) { return (a.updated_at||'').localeCompare(b.updated_at||''); });
else if (s === 'date-desc') sorted.sort(function(a,b) { return (b.updated_at||'').localeCompare(a.updated_at||''); });
else if (s === 'type') sorted.sort(function(a,b) { return (a.is_folder===b.is_folder) ? 0 : a.is_folder ? -1 : 1; });
this.displayTree = sorted;
},