feat(content): v4.6.0 Content Blocks enrichis — Callout, TOC, Math (KaTeX), Toggle, Multi-colonnes
FlowDeck CI / test (push) Failing after 20s
FlowDeck CI / docker (push) Skipped

- Ajout blocs enrichis dans l'éditeur: callout (avec sélecteur d'emoji), table of contents (ancres), math (LaTeX/KaTeX), toggle lists (enfants collapsibles), multi-colonnes
- Intégration KaTeX 0.16.11 self-hosté (JS/CSS/fonts) — aucun CDN externe
- Rendu des nouveaux blocs dans les pages publiques (/p/<slug>): TOC, KaTeX, colonnes, toggle <details>
- Persistance 'children' (colonnes/toggle) + export Markdown étendu
- Sidebar customization par utilisateur (config API + colonne sidebar_config)
- Correctif test_views_calendar: parsing des query params year/month (le défaut ouvrait sur le mois courant)
- Tests: 184 passing
This commit is contained in:
2026-09-03 00:05:33 -04:00
parent d12681720d
commit 814dbe8c2e
77 changed files with 1141 additions and 88 deletions
+244 -34
View File
@@ -6,6 +6,7 @@
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/app.css?v=3.0.0">
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
<style>
/* ── Mobile responsive (v4.0.2) ── */
@media (max-width: 768px) {
@@ -222,10 +223,10 @@
<div class="sidebar-scroll">
<!-- Workspace / Private -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('workspace')">
<div class="sidebar-section" x-show="isSectionVisible('workspace')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('workspace')" @contextmenu.prevent="openSectionMenu($event, 'workspace')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.workspace }" x-text="sectionsOpen.workspace ? '▼' : '▶'"></span>
<span class="chevron" :class="{ open: sectionsOpen.workspace }">▶</span>
{% if has_active_workspace %}
<span><span style="vertical-align:sub;margin-right:4px;">{{ fd_icon("folder",16) }}</span>{{ active_ws_name if active_ws_name else "" }}{% if gitea_workspace or (workspace_key and "/" in workspace_key) %} {{ fd_icon("link",12) }}{% endif %}</span>
{% else %}
@@ -237,6 +238,7 @@
<button class="section-action-btn" title="New Page" @click.stop="window.FlowDeck.createPage()">{{ fd_icon("file",16) }}</button>
<button class="section-action-btn" title="New Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">{{ fd_icon("folder",16) }}</button>
<a class="section-action-btn" href="/local-workspace" title="Open workspace page" style="text-decoration:none;">{{ fd_icon("external-link",16) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'workspace')" @contextmenu.stop="openSectionMenu($event, 'workspace')">⋮</button>
</div>
{% endif %}
</div>
@@ -261,14 +263,16 @@
<!-- Repository (Gitea) — only visible for Gitea workspaces -->
{% if gitea_workspace %}
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('gitea')">
<div class="sidebar-section" x-show="isSectionVisible('gitea')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('gitea')" @contextmenu.prevent="openSectionMenu($event, 'gitea')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.gitea }" x-text="sectionsOpen.gitea ? '▼' : '▶'"></span>
<span>{{ fd_icon("link",14) }} Repository (Gitea)</span>
<span class="chevron" :class="{ open: sectionsOpen.gitea }">▶</span>
<span class="section-icon">{{ fd_icon("link",14) }}</span>
<span class="section-label">Repository (Gitea)</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">{{ fd_icon("refresh",16) }}</button>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'gitea')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.gitea" x-transition>
@@ -280,16 +284,18 @@
{% endif %}
<!-- Meetings -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('meetings')">
<div class="sidebar-section" x-show="isSectionVisible('meetings')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('meetings')" @contextmenu.prevent="openSectionMenu($event, 'meetings')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.meetings }" x-text="sectionsOpen.meetings ? '▼' : '▶'"></span>
<span>Meetings</span>
<span class="chevron" :class="{ open: sectionsOpen.meetings }">▶</span>
<span class="section-icon">{{ fd_icon("calendar",14) }}</span>
<span class="section-label">Meetings</span>
</div>
<div class="sidebar-section-actions">
{% if has_active_workspace %}
<button class="section-action-btn" title="Add" @click.stop="window.FlowDeck.createPage()">+</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>
@@ -298,14 +304,16 @@
</div>
<!-- Recents -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('recents')">
<div class="sidebar-section" x-show="isSectionVisible('recents')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('recents')" @contextmenu.prevent="openSectionMenu($event, 'recents')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.recents }" x-text="sectionsOpen.recents ? '▼' : '▶'"></span>
<span>Recents</span>
<span class="chevron" :class="{ open: sectionsOpen.recents }">▶</span>
<span class="section-icon">{{ fd_icon("clock",14) }}</span>
<span class="section-label">Recents</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Recents" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'recents')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.recents" x-transition>
@@ -321,14 +329,16 @@
</div>
<!-- Favorites -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('favorites')">
<div class="sidebar-section" x-show="isSectionVisible('favorites')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('favorites')" @contextmenu.prevent="openSectionMenu($event, 'favorites')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.favorites }" x-text="sectionsOpen.favorites ? '▼' : '▶'"></span>
<span>Favorites</span>
<span class="chevron" :class="{ open: sectionsOpen.favorites }">▶</span>
<span class="section-icon">{{ fd_icon("star",14) }}</span>
<span class="section-label">Favorites</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Favorites" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'favorites')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.favorites" x-transition>
@@ -344,9 +354,17 @@
</div>
<!-- Agents -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('agents')">
<div class="sidebar-section-title"><span>Agents</span></div>
<div class="sidebar-section" x-show="isSectionVisible('agents')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('agents')" @contextmenu.prevent="openSectionMenu($event, 'agents')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.agents }">▶</span>
<span class="section-icon">{{ fd_icon("bot",14) }}</span>
<span class="section-label">Agents</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="New agent" @click.stop="">+</button>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'agents')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.agents" x-transition>
<ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("bot",16) }}</span><span class="page-name text-dim">No agents configured</span></li></ul>
@@ -354,14 +372,16 @@
</div>
<!-- Shared -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('shared')">
<div class="sidebar-section" x-show="isSectionVisible('shared')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('shared')" @contextmenu.prevent="openSectionMenu($event, 'shared')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.shared }" x-text="sectionsOpen.shared ? '▼' : '▶'"></span>
<span>Shared</span>
<span class="chevron" :class="{ open: sectionsOpen.shared }">▶</span>
<span class="section-icon">{{ fd_icon("users",14) }}</span>
<span class="section-label">Shared</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Shared" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'shared')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition>
@@ -377,14 +397,16 @@
</div>
<!-- Published -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('published')">
<div class="sidebar-section" x-show="isSectionVisible('published')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('published')" @contextmenu.prevent="openSectionMenu($event, 'published')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.published }" x-text="sectionsOpen.published ? '▼' : '▶'"></span>
<span>Published</span>
<span class="chevron" :class="{ open: sectionsOpen.published }">▶</span>
<span class="section-icon">{{ fd_icon("globe",14) }}</span>
<span class="section-label">Published</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Published" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'published')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.published" x-transition>
@@ -401,14 +423,16 @@
<!-- Private -->
{% if auth_method != 'local' or '/' in workspace_key %}
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('private')">
<div class="sidebar-section" x-show="isSectionVisible('private')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('private')" @contextmenu.prevent="openSectionMenu($event, 'private')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.private }" x-text="sectionsOpen.private ? '▼' : '▶'"></span>
<span>Private</span>
<span class="chevron" :class="{ open: sectionsOpen.private }">▶</span>
<span class="section-icon">{{ fd_icon("lock",14) }}</span>
<span class="section-label">Private</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Private" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'private')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.private" x-transition>
@@ -424,6 +448,30 @@
</div>
{% endif %}
<!-- Customize Sidebar → shown in customize mode -->
<div class="sidebar-customize-panel" x-show="customizing" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0">
<div class="scp-header">
<span class="scp-title">Customize Sidebar</span>
<button class="scp-done" @click="toggleCustomize()">Done</button>
</div>
<div class="scp-list">
<template x-for="(cfg, key) in (Object.keys(sidebarConfig).length ? sidebarConfig : {
workspace:{visible:true,order:0},meetings:{visible:true,order:1},
recents:{visible:true,order:2},favorites:{visible:true,order:3},
agents:{visible:true,order:4},shared:{visible:true,order:5},published:{visible:true,order:6}
})" :key="key">
<div class="scp-item" @click="toggleSectionVisibility(key)">
<div class="scp-item-left">
<span class="scp-item-icon" x-text="getSectionIcon(key)"></span>
<span class="scp-item-name" x-text="getSectionLabel(key)"></span>
</div>
<span class="scp-item-eye" x-show="cfg.visible !== false">👁</span>
<span class="scp-item-eye scp-item-eye-off" x-show="cfg.visible === false">👁‍🗨</span>
</div>
</template>
</div>
</div>
<!-- Notion apps / Utilities → replaced by static links footer (v4.0.2) -->
<!-- Bottom links (always visible, no section wrapper) -->
<div class="sidebar-static-links" style="padding: 4px 12px;">
@@ -456,6 +504,51 @@
</div>
</div>
</div><!-- /sidebar-scroll -->
<!-- Section options menu (⋮ dropdown) -->
<div class="section-menu-overlay" x-show="sectionMenu.visible" @click="closeSectionMenu()" @contextmenu.prevent="closeSectionMenu()"></div>
<div class="section-menu" x-show="sectionMenu.visible" x-cloak
:style="{ top: sectionMenu.y + 'px', left: Math.min(sectionMenu.x, window.innerWidth - 220) + 'px' }"
@click.outside="closeSectionMenu()">
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 5)">
<span class="smi-label">Show 5 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 5">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 10)">
<span class="smi-label">Show 10 items</span>
<span class="smi-check" x-show="!sidebarConfig[sectionMenu.section] || sidebarConfig[sectionMenu.section].show_count === 10 || sidebarConfig[sectionMenu.section].show_count == null">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 15)">
<span class="smi-label">Show 15 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 15">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 20)">
<span class="smi-label">Show 20 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 20">✓</span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'up')">
<span class="smi-icon">↑</span>
<span class="smi-label">Move up</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'down')">
<span class="smi-icon">↓</span>
<span class="smi-label">Move down</span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item" @click="closeSectionMenu(); toggleSectionVisibility(sectionMenu.section)">
<span class="smi-icon">👁</span>
<span class="smi-label" x-text="isSectionVisible(sectionMenu.section) ? 'Hide section' : 'Show section'"></span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item section-menu-customize" @click="closeSectionMenu(); toggleCustomize()">
<span class="smi-icon">⚙️</span>
<span class="smi-label">Customize sidebar</span>
</div>
</div>
<!-- Customize sidebar overlay + panel (full sidebar takeover) -->
<div class="customize-overlay" x-show="customizing" @click="toggleCustomize()" x-cloak></div>
</aside>
<!-- Uncollapse button → inside topbar, no overlap -->
@@ -832,6 +925,7 @@
return {
init() {
window.appState = this;
this.loadSidebarConfig();
},
sidebarCollapsed: false,
mobileSidebarOpen: false,
@@ -993,6 +1087,122 @@
try { localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen)); }
catch(e) {}
},
// ── Sidebar customization ──
customizing: false,
sidebarConfig: {},
sectionMenu: { section: null, visible: false, x: 0, y: 0 },
loadSidebarConfig() {
var self = this;
try {
var raw = {{ sidebar_config|default('{}')|safe }};
if (raw && raw.config) {
self.sidebarConfig = raw.config;
} else if (typeof raw === 'object') {
self.sidebarConfig = raw;
}
} catch(e) {
self.sidebarConfig = {};
}
},
saveSidebarConfig() {
var self = this;
fetch('/api/sidebar/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.FlowDeck.getCsrfToken() },
body: JSON.stringify({ config: this.sidebarConfig })
}).then(function(r) {
if (!r.ok) self.toast('Failed to save sidebar config', 'error');
}).catch(function() {
self.toast('Failed to save sidebar config', 'error');
});
},
isSectionVisible(key) {
var cfg = this.sidebarConfig[key];
return cfg ? cfg.visible !== false : true;
},
getSectionOrder(key) {
var cfg = this.sidebarConfig[key];
var sections = ['workspace','gitea','meetings','recents','favorites','agents','shared','published'];
if (cfg && typeof cfg.order === 'number') return cfg.order;
return sections.indexOf(key);
},
toggleSectionVisibility(key) {
if (!this.sidebarConfig[key]) this.sidebarConfig[key] = {};
this.sidebarConfig[key].visible = !this.isSectionVisible(key);
this.sidebarConfig = Object.assign({}, this.sidebarConfig);
this.saveSidebarConfig();
},
moveSection(key, direction) {
var self = this;
var sections = Object.keys(this.sidebarConfig).length ?
Object.keys(this.sidebarConfig).sort(function(a,b) {
return (self.sidebarConfig[a] && self.sidebarConfig[a].order || 99) -
(self.sidebarConfig[b] && self.sidebarConfig[b].order || 99);
}) :
['workspace','gitea','meetings','recents','favorites','agents','shared','published'];
var idx = sections.indexOf(key);
if (idx < 0) return;
var swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= sections.length) return;
var swapKey = sections[swapIdx];
var aOrder = this.sidebarConfig[key] && this.sidebarConfig[key].order;
var bOrder = this.sidebarConfig[swapKey] && this.sidebarConfig[swapKey].order;
if (aOrder == null) aOrder = idx;
if (bOrder == null) bOrder = swapIdx;
if (!this.sidebarConfig[key]) this.sidebarConfig[key] = {};
if (!this.sidebarConfig[swapKey]) this.sidebarConfig[swapKey] = {};
this.sidebarConfig[key].order = bOrder;
this.sidebarConfig[swapKey].order = aOrder;
this.sidebarConfig = Object.assign({}, this.sidebarConfig);
this.saveSidebarConfig();
},
setShowCount(key, count) {
if (!this.sidebarConfig[key]) this.sidebarConfig[key] = {};
this.sidebarConfig[key].show_count = count;
this.sidebarConfig = Object.assign({}, this.sidebarConfig);
this.saveSidebarConfig();
},
toggleCustomize() {
this.customizing = !this.customizing;
this.sectionMenu.visible = false;
},
openSectionMenu(ev, section) {
ev.stopPropagation();
this.sectionMenu = { section: section, visible: true, x: ev.clientX, y: ev.clientY };
},
closeSectionMenu() {
this.sectionMenu.visible = false;
},
getSectionIcon(key) {
var icons = {
workspace: '📁', meetings: '📅', recents: '🕐', favorites: '⭐',
agents: '🤖', shared: '👥', published: '🌐', gitea: '🔗',
private: '🔒', library: '📚', 'my-tasks': '✅', marketplace: '🛒',
help: '❓', trash: '🗑️'
};
return icons[key] || '📋';
},
getSectionLabel(key) {
var labels = {
workspace: 'Workspace', gitea: 'Repository (Gitea)', meetings: 'Meetings',
recents: 'Recents', favorites: 'Favorites', agents: 'Agents',
shared: 'Shared', published: 'Published', private: 'Private',
library: 'Library', 'my-tasks': 'My Tasks', marketplace: 'Marketplace',
help: 'Help', trash: 'Trash'
};
return labels[key] || key;
},
workspaceMenuOpen: false,
toggleWorkspaceMenu() {
this.workspaceMenuOpen = !this.workspaceMenuOpen;