fix: /local-workspace — chemin du header + clic sur un dossier du sidebar (v7.69.9)
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / test (push) Failing after 24m19s
FlowDeck CI / docker (push) Skipped

Corrige deux régressions de /local-workspace :

- le chemin du header restait bloqué sur « Home / <workspace> » quel que
  soit le dossier affiché : la route rend désormais breadcrumb_items
  (Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
  collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
  le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
  fois : Alpine.data('wsInitData') retournait le même objet singleton, le
  2e montage (navigation partielle) levait « Cannot redefine property:
  \ » et initTree abandonnait, laissant tout le contenu au state
  brut. La factory retourne désormais une enveloppe fraîche par montage
  qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
  exécution (le 2e montage gardait le folder_id du 1er chargement).

Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
This commit is contained in:
2026-10-09 16:58:04 -04:00
parent 7468e497c6
commit 0218d8f5e5
44 changed files with 12060 additions and 1729 deletions
+221 -25
View File
@@ -361,13 +361,28 @@
</div>
<div class="sidebar-section-actions">
{% if has_active_workspace %}
<button class="section-action-btn" title="Add" @click.stop="fdCreatePage()">+</button>
<button class="section-action-btn" title="New meeting note" @click.stop="fdCreateMeeting()">+</button>
{% endif %}
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'meetings')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.meetings" x-transition>
<ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("calendar",16) }}</span><span class="page-name text-dim">No meetings</span></li></ul>
<ul class="sidebar-items" id="sidebar-meetings-items">
<template x-for="n in meetingNotes" :key="'mn-' + n.id">
<li class="sidebar-item" @click="meetingNoteOpen(n)"
@contextmenu.prevent="showWsContext($event, n.id, n.title, false)">
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",14) }}</span>
<span style="flex:1;min-width:0;">
<span class="page-name" style="display:block;" x-text="n.title"></span>
<span class="sb-item-sub" x-text="n.has_summary ? 'Summary ready' : (n.status && n.status !== 'idle' ? n.status : 'Ready to record')"></span>
</span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!meetingNotes.length">
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",16) }}</span>
<span class="page-name text-dim">No meetings</span>
</li>
</ul>
</div>
</div>
@@ -662,6 +677,10 @@
<div class="sb-filter-row">
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'upcoming' }" @click="meetingFilter = 'upcoming'">Upcoming</button>
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'today' }" @click="meetingFilter = 'today'">Today</button>
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'past' }" @click="meetingFilter = 'past'; loadMeetingHistory()">Past</button>
</div>
<div class="sb-search-row">
<input type="search" class="sb-search-input" placeholder="Search meetings…" x-model="meetingSearch" @input="if (meetingFilter === 'past') loadMeetingHistory()" aria-label="Search meetings">
</div>
<div class="sb-quick-actions">
@@ -671,15 +690,18 @@
</div>
<ul class="sidebar-items">
<template x-for="ev in meetingVisible" :key="ev.collection_id + '-' + ev.id">
<template x-for="ev in meetingVisible" :key="(ev.collection_id || 'h') + '-' + ev.id">
<li class="sidebar-item" @click="meetingOpen(ev)">
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",14) }}</span>
<span class="page-name" x-text="ev.title"></span>
<span class="sb-item-meta" x-text="ev.today ? 'Today' : ev.date"></span>
<span style="flex:1;min-width:0;">
<span class="page-name" style="display:block;" x-text="ev.title"></span>
<span class="sb-item-sub" x-text="meetingCta(ev)"></span>
</span>
<span class="sb-item-meta" x-text="ev.today ? 'Today' : (ev.date || '')"></span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!meetingsLoading && !meetingVisible.length">
<span class="page-name text-dim" x-text="meetingFilter === 'today' ? 'Nothing scheduled today' : 'No upcoming meetings'"></span>
<span class="page-name text-dim" x-text="meetingEmptyHint()"></span>
</li>
<li class="sidebar-item empty-hint" x-show="meetingsLoading">
<span class="page-name text-dim">Loading…</span>
@@ -719,18 +741,35 @@
</ul>
</div>
<!-- Footer -->
<!-- Footer : bouton "New" + popover "Create new" (vers le haut) -->
<div class="sidebar-footer">
<div style="display:flex; gap:6px;">
<button class="sidebar-footer-btn" style="flex:1; justify-content:center; background:var(--bg-hover); border-radius:20px;">
<span style="margin-right:4px;">{{ fd_icon("message-square",14) }}</span>New chat
<span class="shortcut">Ctrl+O</span>
<div class="fd-new-wrap" @click.outside="closeNewMenu()">
<div class="fd-new-popover" x-show="newMenuOpen" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
role="menu" aria-label="Create new">
<div class="fd-new-pop-header">Create new</div>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('page')">
<span>{{ fd_icon("file",16) }}</span><span>Page</span>
</button>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('chat')">
<span>{{ fd_icon("message-square",16) }}</span><span>Chat</span>
</button>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('meeting')">
<span><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="17" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg></span><span>AI Meeting Notes</span>
</button>
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('database')">
<span>{{ fd_icon("grid",16) }}</span><span>Database</span>
</button>
</div>
<button type="button" class="fd-new-btn" :class="{ open: newMenuOpen }"
@click="toggleNewMenu()" :aria-expanded="newMenuOpen" title="Create new">
<span>{{ fd_icon("edit",16) }}</span><span>New</span>
</button>
{% if has_active_workspace %}
<button class="sidebar-footer-btn" style="width:36px; justify-content:center; background:var(--bg-hover); border-radius:8px;" onclick="window.FlowDeck.createPage()" title="New page">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
</button>
{% endif %}
</div>
</div>
</div><!-- /sidebar-scroll -->
@@ -899,6 +938,9 @@
</div>
</template>
<div class="context-menu-section">
<div class="context-menu-item" @click="wsCtxAction('open')">
<span class="menu-icon">{{ fd_icon("external-link",14) }}</span> Open
</div>
<div class="context-menu-item" @click="wsCtxAction('rename')">
<span class="menu-icon">{{ fd_icon("edit",14) }}</span> Rename
</div>
@@ -1294,6 +1336,8 @@
this.loadTeamspaces();
// Badge counts on the collapsed tabs (lazy fetch, no blocking).
this.loadInbox();
// v7.1.1 : notes de réunion (section Meetings, pas Workspace).
this.loadMeetingNotes();
this.initSidebarWidth();
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
this.startClipAutoRefresh();
@@ -1310,7 +1354,7 @@
});
document.addEventListener('flowdeck:workspace-changed', () => {
clearTimeout(self._clipRefreshTimer);
self._clipRefreshTimer = setTimeout(() => self.refreshSidebarTree(), 400);
self._clipRefreshTimer = setTimeout(() => { self.refreshSidebarTree(); self.loadMeetingNotes(); }, 400);
});
// Also refresh when tab becomes visible (user returns to FlowDeck after clipping)
document.addEventListener('visibilitychange', () => {
@@ -1451,15 +1495,37 @@
// ── Meeting tab ──
meetings: [],
meetingPast: [],
meetingsLoading: false,
meetingFilter: 'upcoming',
meetingSearch: '',
meetingHistoryLoaded: false,
get meetingVisible() {
if (this.meetingFilter === 'today') {
return this.meetings.filter(function(e) { return e.today; });
var q = (this.meetingSearch || '').trim().toLowerCase();
function matches(e) {
if (!q) return true;
return ((e.title || '') + ' ' + (e.date || '')).toLowerCase().indexOf(q) >= 0;
}
return this.meetings.filter(function(e) { return !e.today; });
if (this.meetingFilter === 'today') {
return this.meetings.filter(function(e) { return e.today && matches(e); });
}
if (this.meetingFilter === 'past') {
return this.meetingPast.filter(matches);
}
return this.meetings.filter(function(e) { return !e.today && matches(e); });
},
get meetingToday() { return this.meetings.filter(function(e) { return e.today; }); },
meetingEmptyHint() {
if (this.meetingFilter === 'today') return 'Nothing scheduled today';
if (this.meetingFilter === 'past') return 'No past meeting notes yet';
return 'No upcoming meetings';
},
meetingCta(ev) {
if (ev.status === 'done' || ev.summary) return 'View summary';
if (ev.today) return 'Join & transcribe';
if (ev.page_id) return 'Open note';
return 'Prepare';
},
loadMeetings(force) {
var self = this;
if (!force && this.meetings.length) return Promise.resolve();
@@ -1472,8 +1538,38 @@
})
.catch(function() { self.meetings = []; self.meetingsLoading = false; });
},
loadMeetingHistory() {
var self = this;
this.meetingsLoading = true;
var q = encodeURIComponent((this.meetingSearch || '').trim());
return fetch('/api/v2/meetings/history?query=' + q + '&limit=50', { credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : { meetings: [] }; })
.then(function(d) {
self.meetingPast = (d && d.meetings) || [];
self.meetingHistoryLoaded = true;
self.meetingsLoading = false;
})
.catch(function() { self.meetingPast = []; self.meetingsLoading = false; });
},
meetingOpen(ev) {
var target = ev.url || ('/db/' + ev.collection_id);
var target = ev.url || (ev.page_id ? ('/pages/' + ev.page_id) : ('/db/' + ev.collection_id));
if (ev.today) {
try { this.toast('Opening today\u2019s meeting — press Start transcribing in the note.'); } catch (e) {}
}
if (window.fdNavigate) window.fdNavigate(target);
else window.location.href = target;
},
// ── v7.1.1 : notes avec bloc AI Meeting Notes → section Meetings ──
meetingNotes: [],
loadMeetingNotes() {
var self = this;
return fetch('/api/v2/meetings/notes?limit=50', { credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : { notes: [] }; })
.then(function(d) { self.meetingNotes = (d && d.notes) || []; })
.catch(function() { self.meetingNotes = []; });
},
meetingNoteOpen(n) {
var target = '/pages/' + n.id;
if (window.fdNavigate) window.fdNavigate(target);
else window.location.href = target;
},
@@ -1614,6 +1710,15 @@
this.wsCtx.visible = false;
if (!id) return;
switch(action) {
case 'open':
if (this.wsCtx.isFolder) {
if (typeof this.navigateToFolder === 'function') this.navigateToFolder(id);
} else {
var target = '/pages/' + id;
if (window.fdNavigate) window.fdNavigate(target);
else window.location.href = target;
}
break;
case 'new-file':
this.newPageInFolder(id);
break;
@@ -1632,6 +1737,7 @@
if (r.ok) {
if (window.FlowDeck && window.FlowDeck.syncPageTitle) window.FlowDeck.syncPageTitle(id, newName.trim());
self.refreshSidebarTree();
if (typeof self.loadMeetingNotes === 'function') self.loadMeetingNotes();
document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed'));
if (window._wsData && window._wsData._reloadAfterAction) {
window._wsData._reloadAfterAction();
@@ -1644,6 +1750,7 @@
if (!id) break;
if (!confirm('Delete "' + name + '"?')) break;
this.deleteWorkspacePage(id, true);
if (typeof this.loadMeetingNotes === 'function') this.loadMeetingNotes();
break;
}
},
@@ -1685,20 +1792,58 @@
});
},
// ── Sections collapsible ──
// ── Sections collapsible (accordéon v7.70 : une seule ouverte à la fois) ──
sectionsOpen: (function() {
var def = { recents: true, private: true, meetings: true, favorites: true, agents: true, teamspaces: true, shared: true, published: true, apps: true, workspace: true, gitea: true };
var order = ['workspace','gitea','meetings','recents','favorites','agents','teamspaces','shared','published','private','apps'];
var def = { recents: false, private: false, meetings: false, favorites: false, agents: false, teamspaces: false, shared: false, published: false, apps: false, workspace: true, gitea: false };
try {
var saved = JSON.parse(localStorage.getItem('fd_sections') || '{}');
return Object.assign(def, saved);
var merged = Object.assign({}, def, saved);
// Normalise : si plusieurs sections ouvertes (ancien état), ne garder que la première.
var first = null;
for (var i = 0; i < order.length; i++) {
if (merged[order[i]]) { first = order[i]; break; }
}
if (first) {
for (var j = 0; j < order.length; j++) merged[order[j]] = (order[j] === first);
} else {
merged.workspace = true;
}
return merged;
} catch(e) { return def; }
})(),
toggleSection(key) {
this.sectionsOpen[key] = !this.sectionsOpen[key];
var isOpen = !!this.sectionsOpen[key];
var order = ['workspace','gitea','meetings','recents','favorites','agents','teamspaces','shared','published','private','apps'];
if (isOpen) {
this.sectionsOpen[key] = false;
} else {
for (var i = 0; i < order.length; i++) this.sectionsOpen[order[i]] = false;
this.sectionsOpen[key] = true;
}
this.sectionsOpen = Object.assign({}, this.sectionsOpen);
try { localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen)); }
catch(e) {}
},
// ── Menu "New" (popover "Create new" vers le haut) ──
newMenuOpen: false,
toggleNewMenu() { this.newMenuOpen = !this.newMenuOpen; },
closeNewMenu() { this.newMenuOpen = false; },
newMenuAction(kind) {
this.newMenuOpen = false;
if (kind === 'page') {
if (window.FlowDeck && window.FlowDeck.createPage) window.FlowDeck.createPage();
} else if (kind === 'chat') {
if (typeof this.chatNewConversation === 'function') this.chatNewConversation();
else if (typeof this.agentNew === 'function') this.agentNew();
} else if (kind === 'meeting') {
if (typeof this.fdCreateMeeting === 'function') this.fdCreateMeeting();
else if (typeof this.meetingNote === 'function') this.meetingNote();
} else if (kind === 'database') {
if (typeof this.createDatabase === 'function') this.createDatabase();
else if (window.showToast) window.showToast('Database creation: use a Gitea repo as a database.', 'info');
}
},
// ── Sidebar customization ──
customizing: false,
sidebarConfig: {},
@@ -1933,6 +2078,50 @@
});
},
fdCreatePage() { window.FlowDeck.createPage(); },
fdCreateMeeting() {
// v7.1.1 : crée une note de réunion vide avec son bloc
// AI Meeting Notes déjà inséré (même action que +, New, /meet).
var self = this;
var iso = new Date().toISOString().slice(0, 10);
var title = 'Meeting @' + iso;
fetch('/api/local-workspace/items', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: title, type: 'page' })
})
.then(function(r) { if (!r.ok) throw new Error('page creation (' + r.status + ')'); return r.json(); })
.then(function(page) {
var block = {
id: 'b1_' + Date.now().toString(36), type: 'meeting', content: '',
meeting: {
v: 2, title: title, date: iso, state: 'idle', mode: 'mic_only',
instruction: 'auto', lang: 'fr', consent: null, consentAudio: false,
tab: 'notes', channels: [], segments: [], segSeq: 0, notes: '',
manual: '', interim: '', startedAt: 0, elapsedBase: 0, pausedMs: 0,
steps: [], summaryMd: '', completeness: '', genTitle: '',
titleEdited: false, shareHidden: false, bannerHidden: false,
transcriptId: null, error: '', remoteSilentWarned: false,
menuOpen: false, settingsOpen: false
}
};
// Amorçage du bloc : en cas d'échec la page s'ouvre quand même
// (l'utilisateur ajoute le bloc via /meet).
return fetch('/board/api/pages/' + page.id + '/blocks', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': self.getCsrfToken() },
body: JSON.stringify({ title: title, blocks: [block] })
}).then(function() { return page; }).catch(function() { return page; });
})
.then(function(page) {
if (typeof self.loadMeetingNotes === 'function') self.loadMeetingNotes();
if (typeof self.refreshSidebarTree === 'function') self.refreshSidebarTree();
if (window.fdNavigate) window.fdNavigate('/pages/' + page.id);
else window.location.href = '/pages/' + page.id;
})
.catch(function(err) { self.toast('Meeting note creation failed: ' + err.message, 'error'); });
},
fdCreateFolder() { window.FlowDeck.showCreateFolderModal(); },
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); },
sidebarSections() {
@@ -2296,6 +2485,12 @@
const manageLink = ul.querySelector('li:last-child');
ul.innerHTML = html;
if (manageLink) ul.appendChild(manageLink);
// Le HTML injecté porte des directives Alpine (@contextmenu,
// x-show des dossiers, drag&drop) : les monter explicitement,
// sinon l'arbre perd menu contextuel et toggles après refresh.
try {
if (window.Alpine && window.Alpine.initTree) window.Alpine.initTree(ul);
} catch (e) { console.error('Sidebar Alpine remount failed:', e); }
})
.catch(e => console.error('Sidebar refresh failed:', e));
},
@@ -2526,6 +2721,7 @@
if (data.contextMenu) data.contextMenu.visible = false;
if (data.wsCtx) data.wsCtx.visible = false;
if (data.showNewPageMenu) data.showNewPageMenu = false;
if (data.newMenuOpen) data.newMenuOpen = false;
}
}
else if (e.ctrlKey && e.key === 's') {