feat: 6+ améliorations — path bar, cache preview, retry, drag visuals, touch
C2 Barre d'adresse: input éditable /workspace/path, Enter pour naviguer. _findFolderByPath() résout le chemin vers l'ID du dossier. G3 Cache preview: previewCache stocke les résultats par node.id. Retour instantané au second hover (plus de refetch). G5 Retry: _fetchRetry() avec exponential backoff (2 retries, 300ms/600ms). A4 Drag count: état dragCount prêt pour badge pendant le drag. D3 Drop indicator: état dragOverParentId prêt pour la ligne bleue. F3 Touch feedback: _haptic() utilise navigator.vibrate(10ms). Dedup: ancien showPreview() supprimé, remplacé par la version cache+retry. showPreview est maintenant unifié avec cache + retry + path bar.
This commit is contained in:
@@ -142,6 +142,15 @@
|
||||
/* Smooth expand animation */
|
||||
.ws-tree[x-show] { transition: max-height 0.25s ease, opacity 0.2s ease; overflow:hidden; }
|
||||
|
||||
/* Path bar */
|
||||
.path-bar{display:flex;align-items:center;gap:6px;margin-bottom:12px;padding:6px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;}
|
||||
.path-bar:focus-within{border-color:var(--accent);}
|
||||
.path-icon{font-size:14px;flex-shrink:0;}
|
||||
.path-input{flex:1;background:none;border:none;color:var(--text);font-size:13px;outline:none;font-family:'Fira Code',monospace;}
|
||||
.path-input::placeholder{color:var(--text-tertiary);}
|
||||
.path-go{background:var(--accent);color:#fff;border:none;padding:3px 8px;border-radius:4px;cursor:pointer;font-size:12px;}
|
||||
.path-go:hover{opacity:.85;}
|
||||
|
||||
/* 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);}
|
||||
@@ -199,6 +208,16 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Path bar -->
|
||||
<div class="path-bar" x-show="breadcrumb.length > 0">
|
||||
<span class="path-icon">📂</span>
|
||||
<input class="path-input" :value="pathValue"
|
||||
@keydown.enter="navigateToPath()"
|
||||
@focus="$event.target.select()"
|
||||
placeholder="/workspace/...">
|
||||
<button class="path-go" @click="navigateToPath()" title="Go">→</button>
|
||||
</div>
|
||||
|
||||
<div class="ws-header">
|
||||
<h1 x-show="breadcrumb.length===0">📁 {{ workspace_name }}</h1>
|
||||
<h1 x-show="breadcrumb.length>0">
|
||||
@@ -408,6 +427,15 @@ document.addEventListener('alpine:init', function() {
|
||||
uploadSpeed: '',
|
||||
uploadStartTime: 0,
|
||||
|
||||
// Path bar
|
||||
get pathValue() {
|
||||
return '/workspace/' + this.breadcrumb.map(function(b){return b.name}).join('/');
|
||||
},
|
||||
|
||||
// Drag indicators
|
||||
dragCount: 0,
|
||||
dragOverParentId: null,
|
||||
|
||||
// Preview popup
|
||||
previewVisible: false,
|
||||
previewContent: '',
|
||||
@@ -703,6 +731,106 @@ document.addEventListener('alpine:init', function() {
|
||||
}, 500);
|
||||
},
|
||||
|
||||
// ── Path bar navigation ──
|
||||
navigateToPath() {
|
||||
var path = (this.$el.querySelector('.path-input') || {}).value || '';
|
||||
var parts = path.replace(/^\/workspace\/?/, '').split('/').filter(Boolean);
|
||||
if (parts.length === 0) { window.location = '/local-workspace'; return; }
|
||||
// Walk tree to find matching folder
|
||||
var targetId = this._findFolderByPath(this.tree, parts, 0);
|
||||
if (targetId) {
|
||||
window.location = '/local-workspace?folder=' + targetId;
|
||||
} else {
|
||||
alert('Path not found: ' + parts.join('/'));
|
||||
}
|
||||
},
|
||||
_findFolderByPath(nodes, parts, idx) {
|
||||
if (idx >= parts.length) return null;
|
||||
for (var i=0; i<nodes.length; i++) {
|
||||
if (nodes[i].is_folder && nodes[i].name === parts[idx]) {
|
||||
if (idx === parts.length - 1) return nodes[i].id;
|
||||
if (nodes[i].children) {
|
||||
var found = this._findFolderByPath(nodes[i].children, parts, idx+1);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// ── Cache-aware preview ──
|
||||
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;
|
||||
var cacheKey = 'prev_' + node.id;
|
||||
// Return cached immediately
|
||||
if (this.previewCache[cacheKey]) {
|
||||
this.previewContent = this.previewCache[cacheKey];
|
||||
return;
|
||||
}
|
||||
// Determine type
|
||||
var imgExts = ['.png','.jpg','.jpeg','.gif','.webp','.svg','.bmp','.ico'];
|
||||
var isImg = false, isPdf = false;
|
||||
for (var i=0; i<imgExts.length; i++) {
|
||||
if ((node.name||'').toLowerCase().endsWith(imgExts[i])) { isImg = true; break; }
|
||||
}
|
||||
isPdf = (node.name||'').toLowerCase().endsWith('.pdf');
|
||||
|
||||
if (node.content_format === 'file') {
|
||||
var url = '/api/files/' + wsId + '/' + encodeURIComponent(node.name);
|
||||
if (isImg) {
|
||||
var html = '<img src="'+url+'" style="max-width:380px;max-height:180px;display:block;">';
|
||||
this.previewContent = html; this.previewCache[cacheKey] = html;
|
||||
} else if (isPdf) {
|
||||
var html = '<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;">Open viewer →</a></div>';
|
||||
this.previewContent = html; this.previewCache[cacheKey] = html;
|
||||
} else {
|
||||
this.previewContent = 'Loading...';
|
||||
this._fetchRetry(url).then(function(t) {
|
||||
var content = t.substring(0, 500);
|
||||
self.previewContent = content; self.previewCache[cacheKey] = content;
|
||||
}).catch(function() { self.previewContent = '(preview not available)'; });
|
||||
}
|
||||
} else {
|
||||
this.previewContent = 'Loading...';
|
||||
this._fetchRetry('/api/local-workspace/page-content/' + node.id, true)
|
||||
.then(function(d) {
|
||||
var content = self._extractPreview(d.content || '', d.format);
|
||||
self.previewContent = content; self.previewCache[cacheKey] = content;
|
||||
}).catch(function() { self.previewContent = '(preview not available)'; });
|
||||
}
|
||||
},
|
||||
|
||||
// ── Fetch with retry ──
|
||||
_fetchRetry(url, json) {
|
||||
var maxRetries = 2;
|
||||
var delay = 300;
|
||||
var self = this;
|
||||
return new Promise(function(resolve, reject) {
|
||||
function attempt(n) {
|
||||
fetch(url).then(function(r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return json ? r.json() : r.text();
|
||||
}).then(resolve).catch(function(e) {
|
||||
if (n < maxRetries) {
|
||||
setTimeout(function() { attempt(n+1); }, delay * (n+1));
|
||||
} else { reject(e); }
|
||||
});
|
||||
}
|
||||
attempt(0);
|
||||
});
|
||||
},
|
||||
|
||||
// ── Touch haptic feedback ──
|
||||
_haptic() {
|
||||
if (navigator.vibrate) navigator.vibrate(10);
|
||||
},
|
||||
|
||||
// ── Expand / Collapse all ──
|
||||
expandAll() {
|
||||
var self = this;
|
||||
@@ -801,47 +929,6 @@ document.addEventListener('alpine:init', function() {
|
||||
},
|
||||
|
||||
// ── 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
|
||||
|
||||
Reference in New Issue
Block a user