Files
flowdeck/app/templates/base.html
T
bruno aa88cf6665
FlowDeck CI / docker (push) Successful in 1m52s
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Successful in 15m22s
fix: anti-FOUC au chargement complet (x-cloak sur .app-layout) + nettoyage (v7.45.4)
- Symptôme rapporté : clic sur Home → rafale de menus/fenêtres + bande rouge
  « You are offline. Changes will sync when connection is restored. ».
- Constat : le masquage de v7.45.3 ne couvrait QUE les swaps partiels fdLoad.
  Un chargement COMPLET de document (F5, première visite, navigation servie par
  le service worker) peignait toute l'app tant qu'Alpine n'avait pas initialisé
  .app-layout : sidebar brute (sections ouvertes, menu utilisateur) + zone brute
  dont la bande hors ligne (aucun x-cloak dessus). Le symptôme Home n'est par
  ailleurs PAS rejouable en navigateur frais (nav, clic Home en ligne/hors
  ligne, plein chargement → 0 frame de contenu brut, 0 erreur console).
- Fix : x-cloak sur le nœud racine Alpine .app-layout (base.html) — sidebar +
  zone + bandeau masqués jusqu'au montage d'appState(), retrait par Alpine à
  l'init. Le probe asserte que l'attribut est bien retiré et que le node est
  visible en fin de parcours (garde-fou contre la page blanche).
- Suppression de la classe fd-navigating (posée à chaque fdLoad, aucune règle
  CSS dans le dépôt → code mort).
- e2e/probe_nav_perf.spec.js : 4 scénarios avec assertions (nav 5 pages, clic
  Home réel, Home hors ligne avec SW, plein chargement 3 pages) + capture
  screenshot auto si contenu brut + collecte console.

Gates : probe_nav_perf 4/4 · regression_partial_nav + regression_editor_mount
+ regression_logout_dnd + smoke = 6 passed (errs=0 sur 7 pages × 3 passages) ·
pytest 1094 passed · ruff OK · OpenAPI 7.45.4.
2026-10-03 00:54:28 -04:00

2545 lines
132 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#191919">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
<style>
/* ── Mobile responsive (v4.0.2) ── */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
width: 280px;
transform: translateX(-100%);
transition: transform .2s ease;
}
.sidebar.mobile-open {
transform: translateX(0);
box-shadow: 4px 0 24px rgba(0,0,0,.5);
}
.sidebar-overlay.visible {
position: fixed;
inset: 0;
background: rgba(0,0,0,.5);
z-index: 99;
}
.main-wrapper {
margin-left: 0 !important;
width: 100%;
}
.unified-header {
padding: 8px 12px;
}
.page-editor-wrapper {
padding: 16px 8px;
}
.page-title-block {
padding: 16px 8px;
}
.page-title-block::before {
display: none;
}
.page-title-block .page-icon-emoji {
margin-left: 0;
}
.page-title-block .page-title-input {
padding-left: 0;
}
.blocks-container {
padding: 0 4px;
}
.share-dialog, .more-menu, .activity-popover {
position: fixed !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
width: 92vw;
max-width: 400px;
max-height: 80vh;
overflow-y: auto;
}
.landing-nav {
padding: 12px 16px;
}
.hero {
padding: 40px 16px 30px;
}
.hero h1 {
font-size: 1.8rem;
}
.features {
grid-template-columns: 1fr;
padding: 0 16px 30px;
}
.library-card-grid {
grid-template-columns: 1fr;
}
.view-tabs {
overflow-x: auto;
flex-wrap: nowrap;
}
}
@media (max-width: 480px) {
.hero h1 {
font-size: 1.5rem;
}
.hero p {
font-size: 15px;
}
.btn {
padding: 8px 16px;
font-size: 13px;
}
}
/* Embed mode — hide sidebar + header for peek panels */
body.embed-mode .sidebar { display: none !important; }
body.embed-mode .topbar, body.embed-mode .unified-header { display: none !important; }
body.embed-mode .topbar-right, body.embed-mode .header-actions { display: none !important; }
body.embed-mode .app-layout { margin-left: 0 !important; padding-top: 0 !important; }
body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; }
body.embed-mode .page-cover-area { padding-top: 0 !important; }
</style>
{# A20 : htmx copie les <script nonce="{{ csp_nonce() }}"> des réponses boostées — il remet leur nonce
depuis cette config (le nonce de la réponse courante, pas celui du fetch). #}
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}", "allowEval": false}'>
<script nonce="{{ csp_nonce() }}">
// A38 : helper CSRF unique — défini le plus tôt possible (head) pour
// tous les scripts inline/externes de la page (welcome.html, isolé de
// base, garde sa propre lecture du cookie).
window.getCsrf = function() {
var m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
};
</script>
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
</head>
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
<div class="app-layout" x-data="appState()" x-cloak>
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
<div class="sidebar-overlay"
:class="{ visible: mobileSidebarOpen }"
@click="mobileSidebarOpen = false">
</div>
<!-- ═══════════ SIDEBAR PEEK ZONE (collapsed, desktop) ═══════════ -->
<div class="sidebar-peek-zone"
x-show="sidebarCollapsed" x-cloak
@mouseenter="sidebarPeek = true"></div>
{# ─── Recursive tree macro ─── #}
{% macro render_tree_item(page, depth=0) %}
{% set indent_px = depth * 16 %}
<li class="sidebar-item {% if page.active %}active{% endif %}"
style="padding-left: {{ 8 + indent_px }}px;"
@click="navigateTo('{{ page.url }}')"
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')"
data-page-id="{{ page.id }}"
data-page-depth="{{ depth }}"
data-has-children="{{ 'true' if page.has_children else 'false' }}"
{% if page._gitea_path %}data-gitea-path="{{ page._gitea_path }}"{% endif %}
{% if page._gitea_type %}data-gitea-type="{{ page._gitea_type }}"{% endif %}
{% if page._gitea_sha %}data-gitea-sha="{{ page._gitea_sha }}"{% endif %}>
{# Toggle chevron — only visible for items with children #}
{% if page.has_children %}
<button class="tree-toggle"
@click.stop="toggleTreeNode('{{ page.id }}')"
:class="{ expanded: expandedNodes['{{ page.id }}'] }">▶</button>
{% else %}
<span class="tree-toggle-placeholder"></span>
{% endif %}
<span class="page-icon">{{ fd_page_icon(page.icon, 14) }}</span>
<span class="page-name">{{ page.name }}</span>
<span class="item-actions">
<button class="item-action-btn" @click.stop="newSubPage('{{ page.id }}')">+</button>
</span>
</li>
{% if page.has_children %}
<li class="tree-children-wrapper" x-show="expandedNodes['{{ page.id }}']" x-transition>
<ul class="sidebar-items" data-parent="{{ page.id }}">
{% for child in page.children %}
{{ render_tree_item(child, depth + 1) }}
{% endfor %}
</ul>
</li>
{% endif %}
{% endmacro %}
{% from '_icons.html' import fd_icon, fd_page_icon %}
<!-- ═══════════ SIDEBAR Notion ═══════════ -->
<aside class="sidebar"
:class="{ collapsed: sidebarCollapsed && !sidebarPeek, 'mobile-open': mobileSidebarOpen, peeking: sidebarPeek }"
id="sidebar"
@mouseleave="!sidebarResizing && (sidebarPeek = false)"
x-effect="syncSidebarClass()"
x-init="initSidebarSort()"
<!-- Header + dropdown wrapper -->
<div style="position:relative;">
<div class="sidebar-workspace-header" @click="workspaceMenuOpen = !workspaceMenuOpen" @mouseenter="wsHeaderHover=true" @mouseleave="wsHeaderHover=false"
:class="{ hover: wsHeaderHover || workspaceMenuOpen }">
<div class="workspace-info">
<div class="workspace-avatar" style="{% if avatar_url %}background-image:url('{{ avatar_url }}');background-size:cover;{% else %}background:{{ avatar_color }};{% endif %}">{% if not avatar_url %}{{ user.full_name[:1].upper() if user and user.get('full_name') else workspaceInitial }}{% endif %}</div>
<span class="workspace-name">{{ user.full_name or user.login or workspaceName }}</span>
{% if auth_method != 'local' %}
<span class="oauth-badge" title="Connected via {{ auth_method|title }}">
{% if auth_method == 'gitea' %}Gitea{% elif auth_method == 'github' %}GitHub{% else %}{{ auth_method|title }}{% endif %}
</span>
{% endif %}
</div>
<span class="workspace-chevron" :class="{ open: workspaceMenuOpen }">▾</span>
<button class="sidebar-collapse-inline" @click.stop="toggleSidebar()"
:title="sidebarPeek ? 'Pin sidebar open' : 'Close sidebar'">
<svg x-show="!sidebarPeek" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></svg>
<svg x-show="sidebarPeek" x-cloak width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg>
</button>
</div>
<div class="user-menu-dropdown" x-show="workspaceMenuOpen" @click.outside="workspaceMenuOpen = false" x-transition>
<div class="um-user-info">
<div class="um-avatar" style="{% if avatar_url %}background-image:url('{{ avatar_url }}');background-size:cover;{% else %}background:{{ avatar_color }};{% endif %}">{% if not avatar_url %}{{ user.full_name[:1].upper() if user and user.get('full_name') else workspaceInitial }}{% endif %}</div>
<div>
<div class="um-name">{{ user.full_name or user.login }}</div>
<div class="um-plan">{{ user.email or '' }}</div>
</div>
</div>
<div class="um-sep"></div>
<a href="/settings" class="um-item" @click="workspaceMenuOpen=false">{{ fd_icon("settings", 16) }} Settings</a>
<a href="/workspaces" class="um-item" @click="workspaceMenuOpen=false">{{ fd_icon("refresh", 16) }} Switch workspace</a>
{% if local_workspaces %}
<div class="um-sep"></div>
<div class="um-section-label">Workspaces</div>
{% for ws in local_workspaces %}
<a href="/local-workspace?ws={{ ws.id }}" class="um-item" @click="workspaceMenuOpen=false"
style="padding-left:24px;font-size:13px;">
<span class="um-ws-dot" style="width:8px;height:8px;border-radius:50%;background:var(--accent);display:inline-block;margin-right:8px;flex-shrink:0;"></span>{{ ws.name }}
{% if active_ws_name == ws.name %}
<span style="margin-left:auto;color:var(--accent);">✓</span>
{% endif %}
</a>
{% endfor %}
{% endif %}
<div class="um-sep"></div>
<div class="um-version">FlowDeck v{{ app_version }}</div>
<div class="um-sep"></div>
<a href="/auth/logout" class="um-item um-danger" hx-boost="false" @click="workspaceMenuOpen=false">{{ fd_icon("log-out", 16) }} Log out</a>
</div>
</div>
<!-- Navigation icons row -->
<div class="sidebar-nav-row">
<a href="{% if local_workspaces and local_workspaces|length > 0 %}/local-workspace?ws={{ local_workspaces[0].id }}{% else %}/workspaces{% endif %}" class="nav-icon-btn home-btn" :class="{ active: currentView === 'home' }" title="Home">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
Home
</a>
<button class="nav-icon-btn" title="Chat (Ctrl+J)" onclick="window.fdAgent&&window.fdAgent.open()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
</button>
<button class="nav-icon-btn" title="Inbox">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/></svg>
</button>
<button class="nav-icon-btn" @click="openQuickFind" title="Search" style="margin-left:auto;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
</div>
<div class="sidebar-scroll">
<!-- Workspace / Private -->
<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 }">▶</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 %}
<span><span style="vertical-align:sub;margin-right:4px;">{{ fd_icon("folder",16) }}</span>No workspace open</span>
{% endif %}
</div>
{% if has_active_workspace %}
<div class="sidebar-section-actions">
<button class="section-action-btn" title="New Page" @click.stop="fdCreatePage()">{{ fd_icon("file",16) }}</button>
<button class="section-action-btn" title="New Folder" @click.stop="fdCreateFolder()">{{ 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>
{% from '_workspace_tree_macro.html' import render_workspace_tree %}
<div class="sidebar-section-items" x-show="sectionsOpen.workspace" x-transition>
<ul class="sidebar-items" data-section="workspace" id="sidebar-workspace-items">
{% if has_active_workspace %}
{% if workspace_pages %}
{{ render_workspace_tree(workspace_pages) }}
{% else %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("file",16) }}</span><span class="page-name text-dim">No pages yet</span></li>
{% endif %}
{% else %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("folder",16) }}</span><span class="page-name text-dim">Open a workspace to see your files</span></li>
{% endif %}
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.fdNavigate ? window.fdNavigate('/workspaces') : window.location='/workspaces'">
<span class="page-icon page-icon-svg">{{ fd_icon("settings",16) }}</span><span class="page-name text-dim">Manage Workspaces</span>
</li>
</ul>
</div>
</div>
<!-- Repository (Gitea) — only visible for Gitea workspaces -->
{% if gitea_workspace %}
<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 }">▶</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="fdGwRefresh()">{{ 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>
<ul class="sidebar-items" data-section="gitea" id="sidebar-gitea-items">
<li class="sidebar-item empty-hint"><span class="page-icon">{{ fd_icon("link",14) }}</span><span class="page-name text-dim">Loading...</span></li>
</ul>
</div>
</div>
{% endif %}
<!-- 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 }">▶</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="fdCreatePage()">+</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>
</div>
</div>
<!-- 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 }">▶</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>
<ul class="sidebar-items" data-section="recents">
{% for page in recent_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not recent_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("clock",16) }}</span><span class="page-name text-dim">No recent pages</span></li>
{% endif %}
</ul>
</div>
</div>
<!-- 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 }">▶</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>
<ul class="sidebar-items" data-section="favorites">
{% for page in favorite_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not favorite_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("star",16) }}</span><span class="page-name text-dim">No favorites yet</span></li>
{% endif %}
</ul>
</div>
</div>
<!-- Agents -->
<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="agentNew()">+</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" data-section="agents">
<template x-for="a in agentList" :key="a.id">
<li class="sidebar-item" @click="agentOpen(a.id)">
<span class="page-icon page-icon-svg" x-init="bindAgentIcon($el, a)"></span>
<span class="page-name" x-text="a.name"></span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!agentList.length" @click="agentOpen()"><span class="page-icon page-icon-svg">{{ fd_icon("bot",16) }}</span><span class="page-name text-dim">No agents — click to chat</span></li>
</ul>
</div>
</div>
<!-- Teamspaces -->
<div class="sidebar-section" x-show="isSectionVisible('teamspaces')" 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('teamspaces')" @contextmenu.prevent="openSectionMenu($event, 'teamspaces')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.teamspaces }">▶</span>
<span class="section-icon">{{ fd_icon("home",14) }}</span>
<span class="section-label">Teamspaces</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Refresh" @click.stop="loadTeamspaces()">{{ fd_icon("refresh",16) }}</button>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'teamspaces')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.teamspaces" x-transition>
<ul class="sidebar-items" data-section="teamspaces">
<template x-for="t in teamspaces" :key="t.id">
<li class="sidebar-item" @click="openTeamspace(t.id)">
<span class="page-icon page-icon-svg">{{ fd_icon("home",16) }}</span>
<span class="page-name" x-text="t.name"></span>
<span class="page-name text-dim" style="font-size:11px;" x-text="t.item_count + (t.private ? ' · 🔒' : '')"></span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!teamspaces.length"><span class="page-icon page-icon-svg">{{ fd_icon("home",16) }}</span><span class="page-name text-dim">No teamspaces yet</span></li>
</ul>
</div>
</div>
<!-- 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 }">▶</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>
<div class="sidebar-subgroup">
<div class="sidebar-subgroup-label">Par moi</div>
<ul class="sidebar-items" data-section="shared-made">
{% for page in shared_made_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not shared_made_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("link",16) }}</span><span class="page-name text-dim">Aucun partage par moi</span></li>
{% endif %}
</ul>
</div>
<div class="sidebar-subgroup">
<div class="sidebar-subgroup-label">Avec moi</div>
<ul class="sidebar-items" data-section="shared-received">
{% for page in shared_received_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not shared_received_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("users",16) }}</span><span class="page-name text-dim">Aucun partage avec moi</span></li>
{% endif %}
</ul>
</div>
</div>
</div>
<!-- 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 }">▶</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>
<ul class="sidebar-items" data-section="published">
{% for page in published_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not published_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("globe",16) }}</span><span class="page-name text-dim">No published pages</span></li>
{% endif %}
</ul>
</div>
</div>
<!-- Private -->
{% if auth_method != 'local' or '/' in workspace_key %}
<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 }">▶</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>
<ul class="sidebar-items" data-section="private">
{% for page in private_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not private_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("file",16) }}</span><span class="page-name text-dim">No pages yet</span></li>
{% endif %}
</ul>
</div>
</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 sidebarSections()" :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;">
<a href="/library" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("book",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Library</span>
</a>
<a href="/my-tasks" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("check-square",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">My Tasks</span>
</a>
<a href="/trash" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("trash",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Trash</span>
</a>
<a href="/help" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("help-circle",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Help</span>
</a>
</div>
<!-- Footer -->
<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>
</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 -->
<!-- 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="sectionMenuPos()"
@click.outside="closeSectionMenu()">
<div class="section-menu-item" @click="closeAndSetCount(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="closeAndSetCount(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="closeAndSetCount(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="closeAndSetCount(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="closeAndMove('up')">
<span class="smi-icon">↑</span>
<span class="smi-label">Move up</span>
</div>
<div class="section-menu-item" @click="closeAndMove('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="closeAndToggleVisibility()">
<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="menuCustomize()">
<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>
<!-- Right-edge resize handle: click to close, drag to resize -->
<div class="sidebar-resize-handle"
@mouseenter="sidebarResizeHover = true"
@mouseleave="sidebarResizeHover = false"
@pointerdown.prevent="startSidebarResize($event)"
title=""></div>
</aside>
<!-- Resize / close contextual tooltip -->
<div class="sidebar-resize-tip"
x-show="sidebarResizeHover && (!sidebarCollapsed || sidebarPeek)" x-cloak
:style="'left:' + (sidebarWidth + 12) + 'px'">
<div class="srt-row">
<span class="srt-key">Close</span>
<span class="srt-action">Click · <span class="srt-kbd">Ctrl+\</span></span>
</div>
<div class="srt-row">
<span class="srt-key">Resize</span>
<span class="srt-action">Drag ↔</span>
</div>
</div>
<!-- Uncollapse button → inside topbar, no overlap -->
<!-- (Moved into topbar block below) -->
<!-- ═══════════ MAIN ═══════════ -->
<div class="main-wrapper">
<!-- Topbar -->
{% block topbar %}
{% include '_header.html' %}
{% endblock %}
<!-- Content -->
<div class="content-area" id="main-content">
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
</div>
</div>
<!-- ═══════════ CONTEXT MENU ═══════════ -->
<div class="context-menu" id="context-menu"
:class="{ visible: contextMenu.visible }"
:style="'left:' + contextMenu.x + 'px; top:' + contextMenu.y + 'px'"
@click.outside="contextMenu.visible = false">
<div class="context-menu-section">
<div class="context-menu-label">Database</div>
</div>
<div class="context-menu-section">
<div class="context-menu-item" @click="contextAction('favorite')">
<span class="menu-icon">⭐</span>
<span x-text="favoriteIds.has(contextMenu.pageId) ? 'Remove from Favorites' : 'Add to Favorites'"></span>
</div>
<div class="context-menu-item" @click="contextAction('recent')">
<span class="menu-icon">{{ fd_icon("eye",14) }}</span> Remove from Recents
</div>
</div>
<div class="context-menu-section">
<div class="context-menu-item" @click="contextAction('copyLink')">
<span class="menu-icon">{{ fd_icon("link",14) }}</span> Copy link
</div>
<div class="context-menu-item" @click="contextAction('duplicate')">
<span class="menu-icon">{{ fd_icon("copy",14) }}</span> Duplicate
<span class="menu-shortcut">▶</span>
</div>
<div class="context-menu-item" @click="contextAction('rename')">
<span class="menu-icon">{{ fd_icon("edit",14) }}</span> Rename
<span class="menu-shortcut">Ctrl+Shift+R</span>
</div>
<div class="context-menu-item" @click="contextAction('moveTo')">
<span class="menu-icon">↗️</span> Move to
<span class="menu-shortcut">Ctrl+Shift+P</span>
</div>
<div class="context-menu-item" @click="contextAction('delete')">
<span class="menu-icon">{{ fd_icon("trash",14) }}</span> Move to Trash
</div>
</div>
<div class="context-menu-section">
<div class="context-menu-item" @click="contextAction('openTab')">
<span class="menu-icon">{{ fd_icon("external-link",14) }}</span> Open in new tab
<span class="menu-shortcut">Ctrl+Shift+O</span>
</div>
<div class="context-menu-item" @click="contextAction('openPeek')">
<span class="menu-icon">{{ fd_icon("file",14) }}</span> Open in side peek
<span class="menu-shortcut">Alt+Click</span>
</div>
</div>
<div class="context-menu-section">
<div class="context-menu-item text-dim" style="font-size:11px; cursor:default;">
Last edited by <span x-text="workspaceName"></span>
</div>
<div class="context-menu-item text-dim" style="font-size:11px; cursor:default;">
<span x-text="lastEdited"></span>
</div>
<div class="context-menu-item" @click="contextAction('learnDb')">
<span class="menu-icon">{{ fd_icon("help-circle",14) }}</span> Learn about databases
</div>
</div>
</div>
{% include '_icon_picker.html' %}
<!-- ═══════════ WORKSPACE TREE CONTEXT MENU ═══════════ -->
<div class="context-menu" id="ws-context-menu"
:class="{ visible: wsCtx.visible }"
:style="'left:' + wsCtx.x + 'px; top:' + wsCtx.y + 'px'"
@click.outside="wsCtx.visible = false">
<template x-if="wsCtx.isFolder">
<div class="context-menu-section">
<div class="context-menu-item" onclick="window.FlowDeck.createPage()">
<span class="menu-icon">{{ fd_icon("file",14) }}</span> New File
</div>
<div class="context-menu-item" onclick="window.FlowDeck.showCreateFolderModal()">
<span class="menu-icon">{{ fd_icon("folder",14) }}</span> New Folder
</div>
</div>
</template>
<div class="context-menu-section">
<div class="context-menu-item" @click="wsCtxAction('rename')">
<span class="menu-icon">{{ fd_icon("edit",14) }}</span> Rename
</div>
<div class="context-menu-item" @click="wsCtxAction('delete')">
<span class="menu-icon">{{ fd_icon("trash",14) }}</span> Delete
</div>
</div>
<div class="context-menu-section">
<div class="context-menu-item text-dim" style="font-size:11px;cursor:default;">
<span x-text="wsCtx.name"></span>
</div>
</div>
</div>
<!-- ═══════════ TOOLTIP ═══════════ -->
<div class="tooltip" id="tooltip" x-ref="tooltip"></div>
<!-- ═══════════ CARD MODAL ═══════════ -->
<div id="card-modal" class="modal-overlay" style="display:none">
<div class="modal-container">
<div class="modal-header">
<h3 id="card-modal-title">Détail</h3>
<button onclick="closeModal()" class="modal-close">&times;</button>
</div>
<div id="card-modal-content" class="modal-body"></div>
</div>
</div>
<!-- ============ TOAST NOTIFICATIONS ============ -->
<div class="toast-container">
<template x-for="t in toasts" :key="t.id">
<div class="toast" :class="'toast-' + t.type"
x-show="true"
x-transition:enter=""
x-transition:leave=""
x-text="t.msg"></div>
</template>
</div>
<!-- ============ PWA SYNC BADGE (v6.0.0) ============ -->
<div id="fd-sync-badge" class="fd-sync-badge" style="display:none" role="status" aria-live="polite">
<span class="fd-sync-spinner"></span><span>Synchronisation…</span>
</div>
</div>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Inject CSRF token into HTMX headers
(function() {
document.body.setAttribute('hx-headers', JSON.stringify({'X-CSRF-Token': getCsrf()}));
document.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRF-Token'] = getCsrf();
});
})();
// ── Global SVG icon helper (used by JS-rendered elements) ─────
function getSvgIcon(name, size) {
size = size || 16;
var svgs = {
folder: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
file: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
star: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
trash: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
image: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
download: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
search: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
home: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
key: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>',
paperclip: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>',
'external-link': '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>',
save: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>',
upload: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>',
link: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>',
lock: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
globe: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
edit: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
clock: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
users: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
book: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
settings: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
tag: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>',
'bar-chart': '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>',
grid: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>',
'align-left': '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>',
calendar: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
bot: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16.01"/><line x1="16" y1="16" x2="16" y2="16.01"/></svg>',
refresh: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>',
inbox: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>',
'help-circle': '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
'check-square': '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>',
plus: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
zap: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
user: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
};
return svgs[name] || '';
}
// ── Global FlowDeck namespace ────────────────────────────────
// All "New Page" and "New Folder" buttons across the app call
// these functions for consistent behaviour.
window.FlowDeck = {
getCsrfToken: function() {
return getCsrf();
},
/** Reflète le rename d'une page/dossier dans toute l'UI : sidebar gauche
(Récents, Privé, Favoris + arbre du workspace), breadcrumb du header
(event flowdeck:page-renamed) et frame parente (postMessage). */
syncPageTitle: function(pid, title) {
var t = (title || '').trim() || 'Untitled';
document.querySelectorAll('.sidebar-item[data-page-id="page/' + pid + '"]').forEach(function(item) {
var n = item.querySelector('.page-name');
if (n) n.textContent = t;
});
document.querySelectorAll('.sidebar-item.ws-tree-item[data-id="' + pid + '"]').forEach(function(item) {
item.setAttribute('data-name', t);
var n = item.querySelector('.page-name');
if (n) n.textContent = t;
});
window.dispatchEvent(new CustomEvent('flowdeck:page-renamed', { detail: { id: pid, title: t } }));
if (window.parent && window.parent !== window) {
try { window.parent.postMessage({ type: 'fd-page-renamed', id: pid, title: t }, window.location.origin); } catch (e) {}
}
},
/** Refresh CSRF token from the server. Returns a promise resolving to the new token. */
refreshCsrfToken: async function() {
try {
var resp = await fetch('/api/csrf-token', { method: 'GET', credentials: 'same-origin' });
var data = await resp.json();
if (data.csrf_token) {
return data.csrf_token;
}
} catch (e) {
console.warn('[FlowDeck] CSRF refresh failed:', e);
}
return null;
},
/** Fetch wrapper that auto-refreshes CSRF token on 403 and retries once. */
csrfFetch: async function(url, options) {
options = options || {};
options.credentials = options.credentials || 'same-origin';
if (!options.headers) options.headers = {};
if (!options.headers['X-CSRF-Token']) {
options.headers['X-CSRF-Token'] = this.getCsrfToken();
}
var response = await fetch(url, options);
// Auto-refresh on CSRF failure and retry once
if (response.status === 403) {
var body = '';
try { body = await response.clone().text(); } catch(e) {}
if (body.indexOf('CSRF') !== -1 || body.indexOf('csrf') !== -1) {
var newToken = await this.refreshCsrfToken();
if (newToken) {
options.headers['X-CSRF-Token'] = newToken;
return fetch(url, options);
}
}
}
return response;
},
/** Detect the active workspace context from multiple sources.
* Returns { wsId: number, isGitea: boolean, workspaceKey: string } */
_getContext: function() {
// 1) Library page context (most specific)
if (window._libData && window._libData.workspaceId) {
return {
wsId: window._libData.workspaceId,
isGitea: !!window._libData.isGiteaActive,
workspaceKey: (window.appState && window.appState.workspaceKey) || ''
};
}
// 2) local-workspace page (hidden element)
var wsEl = document.getElementById('fd-local-ws-id');
if (wsEl) {
var wid = parseInt(wsEl.textContent) || 0;
if (wid > 0) {
return {
wsId: wid,
isGitea: false,
workspaceKey: (window.appState && window.appState.workspaceKey) || ''
};
}
}
// 3) Sidebar Gitea workspace (localWsId is set)
if (window.appState && window.appState.localWsId) {
return {
wsId: window.appState.localWsId,
isGitea: true,
workspaceKey: window.appState.workspaceKey || ''
};
}
// 4) Sidebar has workspaceKey but no localWsId (possibly a named local ws?)
var wk = (window.appState && window.appState.workspaceKey) || '';
if (wk && wk.includes('/')) {
return { wsId: 0, isGitea: true, workspaceKey: wk };
}
// 5) No workspace context
return { wsId: 0, isGitea: false, workspaceKey: wk };
},
/** Folder currently open on the local-workspace page (0 when at root). */
_currentFolderId: function() {
var ws = window._wsData;
if (ws && typeof ws.currentFolder === 'number' && ws.currentFolder > 0) {
return ws.currentFolder;
}
return 0;
},
/** Return a toast function that works across pages. */
_toast: function(msg, type) {
if (window.appState && window.appState.toast) window.appState.toast(msg, type);
else console.log('[FlowDeck]', type, msg);
},
/**
* Create a new page: opens the v5.12.0 template picker (built-in + the
* user's saved templates), then instantiates the chosen one.
*/
createPage: function(parentId) {
var self = this;
// Explicit folder (row/context-menu button) wins; otherwise create in the
// folder the user is currently browsing on the local-workspace page.
if (parentId == null || parentId === '') parentId = this._currentFolderId();
this._createParentId = parentId || 0;
// Direct creation path (fallback / no-template choice).
this._createPlainPage = function() {
var ctx = self._getContext();
if (ctx.wsId > 0) {
var body = { name: '', type: 'page' };
if (ctx.isGitea) body.workspace_id = ctx.wsId;
if (self._createParentId) body.parent_id = self._createParentId;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': self.getCsrfToken() },
body: JSON.stringify(body)
})
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
return;
}
var project = ctx.workspaceKey;
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-Token': self.getCsrfToken() }
})
.then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
};
this._openTemplatePicker();
},
_openTemplatePicker: function() {
var self = this;
function escAttr(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
if (document.getElementById('fd-tpl-picker')) { document.getElementById('fd-tpl-picker').style.display = 'flex'; return; }
var ov = document.createElement('div');
ov.id = 'fd-tpl-picker';
ov.className = 'tpl-picker-overlay';
ov.innerHTML = '<div class="tpl-picker-modal"><div class="tpl-picker-title">New page from template</div>'
+ '<div class="tpl-picker-sub">Pick a starting point — or start blank.</div>'
+ '<div class="tpl-grid" id="fd-tpl-grid"><div style="color:var(--text-dim);font-size:13px;padding:12px">Loading…</div></div>'
+ '<div style="text-align:right;margin-top:14px"><button class="btn-sm" id="fd-tpl-close" style="cursor:pointer;padding:6px 14px">Cancel</button></div></div>';
document.body.appendChild(ov);
ov.addEventListener('click', function(e){ if(e.target===ov) ov.style.display='none'; });
document.getElementById('fd-tpl-close').onclick = function(){ ov.style.display='none'; };
fetch('/board/api/page-templates', {credentials:'same-origin'})
.then(function(r){ return r.json(); })
.then(function(d){
var list = d.templates || [];
var h = '';
list.forEach(function(t){
var use = t.builtin
? "key:"+t.key
: "id:"+t.id;
h += '<div class="tpl-card" data-use="'+escAttr(use)+'">'
+ '<span class="tpl-card-icon">'+escAttr(t.icon||'📄')+'</span>'
+ '<span><span class="tpl-card-name">'+escAttr(t.name)+'</span>'
+ '<div class="tpl-card-desc">'+escAttr(t.description||'')+'</div></span></div>';
});
document.getElementById('fd-tpl-grid').innerHTML = h || '<div style="color:var(--text-dim)">No templates</div>';
document.querySelectorAll('#fd-tpl-grid .tpl-card').forEach(function(card){
card.onclick = function(){ self._useTemplate(card.dataset.use); };
});
}).catch(function(){ document.getElementById('fd-tpl-grid').innerHTML='<div style="color:var(--text-dim)">Failed to load templates</div>'; });
},
_useTemplate: function(use){
var ov = document.getElementById('fd-tpl-picker');
if (ov) ov.style.display = 'none';
var self = this;
if (!use || use === 'key:empty') { this._createPlainPage(); return; }
var parts = use.split(':');
var body = {};
var url;
// Pass the current workspace context so the created page is persisted
// in the active local/Gitea workspace (mirrors _createPlainPage()).
var ctx = self._getContext();
if (ctx.wsId > 0) { body.workspace_id = ctx.wsId; }
else if (ctx.workspaceKey) { body.workspace = ctx.workspaceKey; }
if (self._createParentId) { body.parent_id = self._createParentId; }
if (parts[0] === 'key') { body.key = parts.slice(1).join(':'); url = '/board/api/page-templates/0/use'; }
else { url = '/board/api/page-templates/' + parts[1] + '/use'; }
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify(body)
})
.then(function(r){ if (!r.ok) throw new Error('Template failed'); return r.json(); })
.then(function(d){ window.location.href = '/pages/' + d.id; })
.catch(function(){ self._createPlainPage(); });
},
/** Show the global New Folder modal (parentId = target folder, optional). */
showCreateFolderModal: function(parentId) {
if (parentId == null || parentId === '') parentId = this._currentFolderId();
this._folderParentId = parentId || 0;
var modal = document.getElementById('fd-global-folder-modal');
if (!modal) return;
modal.style.display = 'flex';
var input = document.getElementById('fd-global-folder-input');
if (input) { input.value = ''; setTimeout(function() { input.focus(); }, 100); }
},
/** Called by the global New Folder modal's Create button. */
_confirmCreateFolder: function() {
var input = document.getElementById('fd-global-folder-input');
var name = (input && input.value || '').trim();
if (!name) return;
var modal = document.getElementById('fd-global-folder-modal');
if (modal) modal.style.display = 'none';
var ctx = this._getContext();
var body = { name: name, type: 'folder' };
if (ctx.wsId > 0 && ctx.isGitea) body.workspace_id = ctx.wsId;
if (this._folderParentId) body.parent_id = this._folderParentId;
this._folderParentId = 0;
var self = this;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify(body)
})
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(function() {
// Refresh sidebar tree
if (window.appState && window.appState.refreshSidebarTree) window.appState.refreshSidebarTree();
// Notify workspace page
document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed'));
if (window._wsData && window._wsData._reloadAfterAction) window._wsData._reloadAfterAction();
// Show toast
self._toast('Folder created', 'success');
})
.catch(function(err) {
self._toast('Failed: ' + err.message, 'error');
alert('Failed to create folder: ' + err.message);
});
}
};
// A20 phase 3 : le build CSP ne résout que le registre Alpine.data
// (probe : globale window → « Undefined variable: appState »).
document.addEventListener('alpine:init', function () {
Alpine.data('appState', appState);
});
function appState() {
return {
init() {
window.appState = this;
this.loadSidebarConfig();
this.loadAgents();
this.loadTeamspaces();
this.initSidebarWidth();
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
this.startClipAutoRefresh();
},
// ── Auto-refresh sidebar after a web clip (no manual reload) ──
_clipRefreshTimer: null,
_clipRefreshInterval: null,
startClipAutoRefresh() {
const self = this;
// Instant refresh when extension notifies (content script forwards event)
window.addEventListener('flowdeck:clip-created', () => {
clearTimeout(self._clipRefreshTimer);
self._clipRefreshTimer = setTimeout(() => self.refreshSidebarTree(), 400);
});
document.addEventListener('flowdeck:workspace-changed', () => {
clearTimeout(self._clipRefreshTimer);
self._clipRefreshTimer = setTimeout(() => self.refreshSidebarTree(), 400);
});
// Also refresh when tab becomes visible (user returns to FlowDeck after clipping)
document.addEventListener('visibilitychange', () => {
if (!document.hidden) self.refreshSidebarTree();
});
window.addEventListener('focus', () => self.refreshSidebarTree());
// Polling fallback every 15s while tab is visible (covers extension clip without message)
try {
self._clipRefreshInterval = setInterval(() => {
if (!document.hidden) self.refreshSidebarTree();
}, 15000);
} catch (_e) {}
// BroadcastChannel cross-tab sync (all FlowDeck tabs refresh together)
try {
if (window.BroadcastChannel) {
const bc = new BroadcastChannel('flowdeck');
bc.onmessage = (ev) => {
if (ev.data && ev.data.type === 'clip-created') self.refreshSidebarTree();
};
window._fdBC = bc;
// Bridge DOM event → BroadcastChannel for extension-triggered clips
window.addEventListener('flowdeck:clip-created', () => {
try { bc.postMessage({ type: 'clip-created', at: Date.now() }); } catch (_e) {}
});
}
} catch (_e) {}
},
agentList: [],
loadAgents() {
var self = this;
fetch('/api/agent').then(function(r){ return r.json(); }).then(function(d){
self.agentList = d.agents || [];
}).catch(function(){});
},
agentOpen(id) {
if(window.fdAgent && window.fdAgent.open){ window.fdAgent.open(); }
else { document.dispatchEvent(new CustomEvent('fd-agent-toggle', { detail: { agentId: id || null } })); }
},
agentNew() {
if(window.fdAgent && window.fdAgent.open){ window.fdAgent.open(); }
else { document.dispatchEvent(new CustomEvent('fd-agent-toggle')); }
},
teamspaces: [],
loadTeamspaces() {
var self = this;
fetch('/api/v2/wiki/teamspaces').then(function(r){ return r.json(); }).then(function(d){
self.teamspaces = d.teamspaces || [];
}).catch(function(){});
},
openTeamspace(id) {
var target = '/wiki/teamspaces/' + id;
if (window.fdNavigate) window.fdNavigate(target); else window.location = target;
},
sidebarCollapsed: false,
sidebarPeek: false,
mobileSidebarOpen: false,
sidebarWidth: 240,
sidebarResizing: false,
sidebarResizeHover: false,
sidebarResizeMoved: false,
sidebarResizeStartX: 0,
sidebarResizeStartW: 0,
showInvite: true,
// -- Toast notifications --
toasts: [],
toast(msg, type) {
type = type || 'info';
var id = Date.now() + Math.random();
this.toasts.push({ id: id, msg: msg, type: type });
var self = this;
setTimeout(function() {
self.toasts = self.toasts.filter(function(t) { return t.id !== id; });
}, 3000);
},
wsHeaderHover: false,
workspaceName: '{{ workspace_name }}',
workspaceInitial: '{{ workspace_initial }}',
currentPage: '{{ current_page }}',
pageIcon: '{% block page_icon %}folder{% endblock %}',
pageTitle: '{% block page_title %}FlowDeck{% endblock %}',
lastEdited: '{{ last_edited }}',
workspaceKey: '{{ workspace_key }}',
localWsId: {{ local_ws_id|default(0) }},
giteaWorkspace: {{ 'true' if gitea_workspace else 'false' }},
giteaOwner: '{{ gitea_owner|default("") }}',
giteaRepo: '{{ gitea_repo|default("") }}',
giteaTree: [], // loaded client-side for sidebar
currentView: 'home',
contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' },
contextActions: {},
showNewPageMenu: false,
selectedItems: [],
lastClickedIndex: -1,
// ── Workspace tree context menu ──
wsCtx: { visible: false, x: 0, y: 0, id: null, name: '', isFolder: false },
wsTouchTimer: null,
activeNodeId: (function() {
var m = window.location.search.match(/[?&]folder=(\d+)/);
return m ? parseInt(m[1]) : null;
})(),
showWsContext(ev, id, name, isFolder) {
this.wsCtx = { visible: true, x: ev.clientX, y: ev.clientY, id: id, name: name, isFolder: isFolder };
},
hideWsContext() { this.wsCtx.visible = false; },
wsTouchStart(ev) {
var self = this;
this.wsTouchTimer = setTimeout(function() {
self.wsTouchTimer = null;
var el = ev.target.closest('.ws-tree-item');
if (el) {
var id = parseInt(el.getAttribute('data-id'));
var name = el.getAttribute('data-name') || '';
var isFolder = el.getAttribute('data-is-folder') === 'true';
var touch = ev.touches[0];
self.wsCtx = { visible: true, x: touch.clientX, y: touch.clientY, id: id, name: name, isFolder: isFolder };
}
}, 500);
},
wsTouchEnd(ev, id, name, isFolder) {
if (this.wsTouchTimer) { clearTimeout(this.wsTouchTimer); this.wsTouchTimer = null; return; }
// Already opened by touchstart; prevent Alpine @touchend from also firing the context menu
ev.preventDefault();
},
wsTouchMove(ev) {
if (this.wsTouchTimer) { clearTimeout(this.wsTouchTimer); this.wsTouchTimer = null; }
},
wsCtxAction(action) {
var id = this.wsCtx.id;
var name = this.wsCtx.name;
this.wsCtx.visible = false;
if (!id) return;
switch(action) {
case 'new-file':
this.newPageInFolder(id);
break;
case 'new-folder':
this.newFolderInFolder(id);
break;
case 'rename':
var newName = prompt('Rename:', name);
if (newName && newName.trim()) {
var self = this;
fetch('/api/local-workspace/items/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: newName.trim() })
}).then(function(r) {
if (r.ok) {
if (window.FlowDeck && window.FlowDeck.syncPageTitle) window.FlowDeck.syncPageTitle(id, newName.trim());
self.refreshSidebarTree();
document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed'));
if (window._wsData && window._wsData._reloadAfterAction) {
window._wsData._reloadAfterAction();
}
}
});
}
break;
case 'delete':
if (!id) break;
if (!confirm('Delete "' + name + '"?')) break;
this.deleteWorkspacePage(id, true);
break;
}
},
// ── Favorites tracking ──
favoriteIds: new Set([{% for p in favorite_pages %}'{{ p.id }}'{% if not loop.last %},{% endif %}{% endfor %}]),
refreshFavorites() {
const self = this;
fetch('/api/library/favorites', {credentials:'same-origin'})
.then(r => r.json())
.then(data => {
const items = data.items || [];
self.favoriteIds = new Set(items.map(i => i.id));
const favList = document.querySelector('.sidebar-items[data-section="favorites"]');
if (!favList) return;
if (items.length === 0) {
favList.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">'+getSvgIcon('star',16)+'</span><span class="page-name text-dim">No favorites yet</span></li>';
} else {
favList.innerHTML = items.map(function(item) {
var iconName = item.icon || 'file';
var icon = '<span class="page-icon-svg">' + (window.fdIconHtml ? window.fdIconHtml(iconName, 14) : getSvgIcon('file',14)) + '</span>';
const name = (item.title || 'Untitled');
const safeName = name.replace(/'/g, "\\'").replace(/"/g, '&quot;');
const url = item.url || ('/pages/' + item.id);
return '<li class="sidebar-item" style="padding-left:8px;cursor:pointer" ' +
'data-page-id="' + item.id + '" data-page-depth="0" data-has-children="false" ' +
'onclick="window.appState.navigateTo(\'' + url + '\')" ' +
'oncontextmenu="event.preventDefault();window.appState.showContextMenu(event,\'' + item.id + '\',\'' + safeName + '\')">' +
'<span class="tree-toggle-placeholder"></span>' +
'<span class="page-icon">' + icon + '</span>' +
'<span class="page-name">' + safeName + '</span>' +
'<span class="item-actions">' +
'<button class="item-action-btn" title="New sub-page" onclick="event.stopPropagation();window.appState.newSubPage(\'' + item.id + '\')">+</button>' +
'</span>' +
'</li>';
}).join('');
}
});
},
// ── Sections collapsible ──
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 };
try {
var saved = JSON.parse(localStorage.getItem('fd_sections') || '{}');
return Object.assign(def, saved);
} catch(e) { return def; }
})(),
toggleSection(key) {
this.sectionsOpen[key] = !this.sectionsOpen[key];
this.sectionsOpen = Object.assign({}, this.sectionsOpen);
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({})|tojson }};
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','teamspaces','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','teamspaces','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 };
},
menuCustomize() { this.closeSectionMenu(); this.toggleCustomize(); },
closeAndSetCount(n) { this.closeSectionMenu(); this.setShowCount(this.sectionMenu.section, n); },
closeAndMove(dir) { this.closeSectionMenu(); this.moveSection(this.sectionMenu.section, dir); },
closeAndToggleVisibility() { this.closeSectionMenu(); this.toggleSectionVisibility(this.sectionMenu.section); },
closeSectionMenu() {
this.sectionMenu.visible = false;
},
getSectionIcon(key) {
var icons = {
workspace: '📁', meetings: '📅', recents: '🕐', favorites: '⭐',
agents: '🤖', teamspaces: '🏛️', 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',
teamspaces: 'Teamspaces', 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;
},
openQuickFind() { if (window.FlowDeckPalette) window.FlowDeckPalette.open(); },
// ── Tree state ──
expandedNodes: {},
isExpanded(id) {
return this.expandedNodes[id] || false;
},
toggleTreeNode(id) {
const current = this.expandedNodes[id] || false;
this.expandedNodes[id] = !current;
// Trigger Alpine.js reactivity by reassigning
this.expandedNodes = Object.assign({}, this.expandedNodes);
// If expanding, init SortableJS on the newly visible children
if (!current) {
this.$nextTick(() => this.initTreeSortable());
}
},
expandAll() {
document.querySelectorAll('[data-has-children="true"]').forEach(el => {
this.expandedNodes[el.dataset.pageId] = true;
});
this.expandedNodes = Object.assign({}, this.expandedNodes);
this.$nextTick(() => this.initTreeSortable());
},
// ── Drag & drop tree ──
treeSortables: [],
initTreeSortable() {
if (typeof Sortable === 'undefined') return;
// Initialize SortableJS on every .sidebar-items list
const self = this;
document.querySelectorAll('.sidebar-items').forEach(function(el) {
if (el._sortableTree) return; // Already initialized
el._sortableTree = new Sortable(el, {
group: 'pages-tree',
handle: '.page-icon',
animation: 150,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
fallbackOnBody: true,
swapThreshold: 0.5,
onEnd: function(evt) {
// Get the dragged item's page ID
const item = evt.item;
const pageId = item.dataset.pageId;
if (!pageId || !pageId.startsWith('page/')) return;
const dbId = pageId.replace('page/', '');
// Determine new parent from the target list's data-parent
const targetList = evt.to;
let newParentId = 0;
const parentAttr = targetList.dataset.parent;
if (parentAttr && parentAttr.startsWith('page/')) {
newParentId = parentAttr.replace('page/', '');
}
const newIndex = evt.newIndex;
// Call API to persist the move
const csrf = self.getCsrfToken();
fetch(`/board/api/pages/${dbId}/move?new_parent_id=${newParentId}&new_order=${newIndex}`, {
method: 'PUT',
headers: { 'X-CSRF-Token': csrf }
}).catch(e => console.error('Move failed:', e));
}
});
});
},
navigateTo(url) {
this.mobileSidebarOpen = false;
this.sidebarPeek = false;
// Preserve workspace context when navigating to library/trash
if ((url === '/library' || url === '/trash') && this.workspaceKey && this.workspaceKey.includes('/')) {
const parts = this.workspaceKey.split('/');
url = `${url}?owner=${parts[0]}&repo=${parts.slice(1).join('/')}`;
}
if (window.fdNavigate) window.fdNavigate(url);
else window.location.href = url;
},
// ── A20 phase 3 : expressions → méthode (build CSP : appel seul ;
// document/Math/window/FlowsDeck = JS réel, hors évaluateur) ──
bindProjectIcon(el, p) {
Alpine.effect(() => { el.innerHTML = getSvgIcon(p.icon || 'folder', 20); });
},
// ── A20 ph3 : délégués du topbar éditeur (window.E hors portée CSP,
// scope du topbar = appState) — voir right_actions de page_editor ──
edCall(name) {
if (window.E && typeof window.E[name] === 'function') window.E[name]();
},
edTimeAgo() { return (window.E && window.E.timeAgo) || ''; },
edCommentCount() {
return (window.E && window.E.commentCount > 0) ? window.E.commentCount : '';
},
edShared() { return !!(window.E && window.E.pageIsShared); },
// les 2 branches du ternaire favorited étaient identiques → rendu 1×
bindStar(el) { Alpine.effect(() => { el.innerHTML = getSvgIcon('star', 14); }); },
bindAgentIcon(el, a) {
Alpine.effect(() => { el.innerHTML = a.icon || '🤖'; });
},
syncSidebarClass() {
document.documentElement.classList.toggle(
'fd-sidebar-collapsed', this.sidebarCollapsed && !this.sidebarPeek);
},
initSidebarSort() {
Alpine.nextTick(() => {
if (typeof initTreeSortable === 'function') initTreeSortable();
});
},
fdCreatePage() { window.FlowDeck.createPage(); },
fdCreateFolder() { window.FlowDeck.showCreateFolderModal(); },
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); },
sidebarSections() {
if (Object.keys(this.sidebarConfig).length) return this.sidebarConfig;
return {
workspace: { visible: true, order: 0 }, teamspaces: { visible: true, order: 1 },
meetings: { visible: true, order: 2 }, recents: { visible: true, order: 3 },
favorites: { visible: true, order: 4 }, agents: { visible: true, order: 5 },
shared: { visible: true, order: 6 }, published: { visible: true, order: 7 },
};
},
sectionMenuPos() {
return {
top: this.sectionMenu.y + 'px',
left: Math.min(this.sectionMenu.x, window.innerWidth - 220) + 'px',
};
},
toggleSidebar() {
this.sidebarPeek = false;
this.sidebarCollapsed = !this.sidebarCollapsed;
},
closeSidebar() {
if (this.sidebarPeek) { this.sidebarPeek = false; return; }
this.sidebarCollapsed = true;
},
// ── Sidebar width: persisted, applies to fixed + floating ──
initSidebarWidth() {
var saved = parseInt(localStorage.getItem('fd.sidebarWidth') || '', 10);
if (!isNaN(saved) && saved >= 200 && saved <= 560) {
this.sidebarWidth = saved;
this.applySidebarWidth();
} else {
var el = document.getElementById('sidebar');
if (el) {
var w = el.getBoundingClientRect().width;
if (w > 0) { this.sidebarWidth = Math.round(w); }
}
}
},
applySidebarWidth() {
document.documentElement.style.setProperty('--sidebar-width', this.sidebarWidth + 'px');
},
startSidebarResize(e) {
e.preventDefault();
var self = this;
this.sidebarResizing = true;
this.sidebarResizeMoved = false;
this.sidebarResizeStartX = e.clientX;
var el = document.getElementById('sidebar');
this.sidebarResizeStartW = el ? el.getBoundingClientRect().width : this.sidebarWidth;
document.body.classList.add('fd-sidebar-resizing');
function onMove(ev) {
var dx = ev.clientX - self.sidebarResizeStartX;
if (Math.abs(dx) > 3) { self.sidebarResizeMoved = true; }
var w = Math.max(200, Math.min(560, self.sidebarResizeStartW + dx));
self.sidebarWidth = w;
self.applySidebarWidth();
}
function onUp() {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
document.body.classList.remove('fd-sidebar-resizing');
self.sidebarResizing = false;
if (self.sidebarResizeMoved) {
try { localStorage.setItem('fd.sidebarWidth', String(Math.round(self.sidebarWidth))); } catch (err) {}
} else {
self.closeSidebar();
}
}
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
},
showContextMenu(event, pageId, pageName) {
var x = event.clientX, y = event.clientY;
// Prevent menu from overflowing right edge
var viewW = window.innerWidth;
var viewH = window.innerHeight;
var menuW = 210; // max-width ~240, safe estimate
var menuH = 360; // approximate max height
if (x + menuW > viewW) x = viewW - menuW - 8;
if (y + menuH > viewH) y = viewH - menuH - 8;
if (x < 0) x = 8;
if (y < 0) y = 8;
this.contextMenu = {
visible: true,
x: x,
y: y,
pageId: pageId,
pageName: pageName
};
},
contextAction(action) {
const { pageId, pageName } = this.contextMenu;
const self = this;
const csrf = this.getCsrfToken();
const dbId = pageId.startsWith('page/') ? parseInt(pageId.split('/')[1]) : parseInt(pageId);
switch(action) {
case 'favorite':
const isFav = this.favoriteIds.has(pageId);
const method = isFav ? 'DELETE' : 'POST';
fetch(`/board/api/favorites/${dbId}`, { method, headers: { 'X-CSRF-Token': csrf } })
.then(r => r.json())
.then(data => {
if (isFav) self.favoriteIds.delete(pageId);
else self.favoriteIds.add(pageId);
self.contextMenu.visible = false;
self.refreshFavorites();
})
.catch(e => this.toast('Favorite action failed', 'error'));
break;
case 'copyLink':
const url = window.location.origin + '/' + pageId;
navigator.clipboard.writeText(url).then(() => this.toast('Link copied!', 'success'));
break;
case 'duplicate':
fetch(`/board/api/pages?title=${encodeURIComponent(pageName + ' copy')}&section=Private&project=${encodeURIComponent(this.workspaceKey)}`, {
method: 'POST', headers: { 'X-CSRF-Token': csrf }
})
.then(r => r.json())
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(e => this.toast('Duplicate failed', 'error'));
break;
case 'rename':
const newName = prompt('New name:', pageName);
if (!newName) break;
const pageNum = pageId.replace('page/', '');
fetch(`/board/api/pages/${pageNum}?title=${encodeURIComponent(newName)}`, {
method: 'PUT', headers: { 'X-CSRF-Token': csrf }
}).then(() => location.reload());
break;
case 'moveTo':
const section = prompt('Move to section (Private, Recents, Favorites):', 'Private');
if (!section) break;
this.toast('Moved to ' + section, 'success');
break;
case 'delete':
if (!confirm(`Move "${pageName}" to trash?`)) break;
const delNum = pageId.replace('page/', '');
fetch(`/board/api/pages/${delNum}`, {
method: 'DELETE', headers: { 'X-CSRF-Token': csrf }
})
.then(r => { if (!r.ok) throw new Error('Delete failed'); return r.json(); })
.then(data => {
// Remove from ALL sidebar sections (Recents, Private, etc.)
const items = document.querySelectorAll(`.sidebar-item[data-page-id="${pageId}"]`);
items.forEach(item => {
const next = item.nextElementSibling;
if (next && next.classList.contains('tree-children-wrapper')) {
next.remove();
}
item.remove();
});
// Redirect if viewing deleted page — stay in project context
if (window.location.pathname.includes(`/pages/${delNum}`)) {
const wk = this.workspaceKey;
window.location.href = wk && wk.includes('/') ? `/board/${wk}` : '/';
}
})
.catch(e => this.toast('Delete failed: ' + e.message, 'error'));
break;
case 'openTab':
window.open('/' + pageId, '_blank');
break;
case 'openPeek':
this.toast('Side peek not available in browser', 'info');
break;
}
this.contextMenu.visible = false;
},
openLibrary(id) {
this.navigateTo('/library');
},
openInLibrary(id) { this.openLibrary(id); },
showMore(id) { console.log('Show more:', id); },
addPage(section) { this.newPage(section); },
addSubPage(id) { this.newSubPage(id); },
getCsrfToken() {
return getCsrf();
},
newPage(section) {
const project = this.workspaceKey || '';
fetch(`/board/api/pages?section=${encodeURIComponent(section)}&project=${encodeURIComponent(project)}`, {
method: 'POST',
headers: { 'X-CSRF-Token': this.getCsrfToken() }
})
.then(r => { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { this.toast('Failed to create page: ' + err.message, 'error'); });
},
newSubPage(parentId) {
const project = this.workspaceKey || '';
const pid = parentId.replace('page/', '');
fetch(`/board/api/pages?section=Private&project=${encodeURIComponent(project)}&parent_id=${pid}`, {
method: 'POST',
headers: { 'X-CSRF-Token': this.getCsrfToken() }
})
.then(r => { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { this.toast('Failed to create sub-page: ' + err.message, 'error'); });
},
loadGiteaTree() {
if (!this.giteaWorkspace || !this.giteaOwner || !this.giteaRepo) {
console.log('loadGiteaTree: skipped (giteaWorkspace=' + this.giteaWorkspace + ', owner=' + this.giteaOwner + ', repo=' + this.giteaRepo + ')');
return;
}
// Skip if giteaWorkspace component already loaded the tree (has data-gitea-path items)
var container = document.getElementById('sidebar-workspace-items');
if (container && container.querySelector('[data-gitea-path]')) {
console.log('loadGiteaTree: skipped (tree already loaded by giteaWorkspace component)');
return;
}
console.log('loadGiteaTree: fetching /api/gitea/projects/' + this.giteaOwner + '/' + this.giteaRepo + '/tree');
fetch('/api/gitea/projects/' + this.giteaOwner + '/' + this.giteaRepo + '/tree')
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status + ': ' + r.statusText);
return r.json();
})
.then(function(data) {
this.giteaTree = data.tree || [];
if (!container) {
console.log('loadGiteaTree: #sidebar-workspace-items not found');
return;
}
container.innerHTML = '';
if (!this.giteaTree.length) {
container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">'+getSvgIcon('file',16)+'</span><span class="page-name text-dim">No files yet</span></li>';
return;
}
this.giteaTree.forEach(function(item) {
var li = document.createElement('li');
li.className = 'sidebar-item';
var icon = item.type === 'dir' ? '<span class="page-icon-svg">'+getSvgIcon('folder',14)+'</span>' : '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
li.innerHTML = '<span class="page-icon">' + icon + '</span><span class="page-name">' + item.name + '</span>';
container.appendChild(li);
});
console.log('loadGiteaTree: rendered ' + this.giteaTree.length + ' items');
}.bind(this))
.catch(function(err) {
console.error('loadGiteaTree failed:', err.message || err);
if (container) {
container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">{{ fd_icon("alert-triangle",14) }}</span><span class="page-name text-dim">Could not load tree: ' + (err.message || 'Unknown error') + '</span></li>';
}
});
},
newPageInWorkspace() {
// Guard: no workspace active — redirect to workspaces page
if (!this.workspaceKey || this.workspaceKey === this.workspaceName) {
if (!this.giteaWorkspace && !this.localWsId) {
this.toast('No workspace open — create one first', 'info');
setTimeout(() => { window.location.href = '/workspaces'; }, 1200);
return;
}
}
const name = prompt('File name:');
if (!name || !name.trim()) return;
const wk = this.workspaceKey || '';
// Gitea/remote workspace: create local file in mirror workspace, keep remote focus
if (wk.includes('/')) {
const wsId = this.localWsId || 0;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: name.trim(), type: 'page', workspace_id: wsId })
})
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
return;
}
// Local workspace
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: name.trim(), type: 'page' })
})
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
},
newFolderInWorkspace() {
// Guard: no workspace active — redirect to workspaces page
if (!this.workspaceKey || this.workspaceKey === this.workspaceName) {
if (!this.giteaWorkspace && !this.localWsId) {
this.toast('No workspace open — create one first', 'info');
setTimeout(() => { window.location.href = '/workspaces'; }, 1200);
return;
}
}
const name = prompt('Folder name:');
if (!name || !name.trim()) return;
const wk = this.workspaceKey || '';
// Gitea workspace: create folder in local mirror workspace
if (wk.includes('/')) {
const wsId = this.localWsId || 0;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: name.trim(), type: 'folder', workspace_id: wsId })
})
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(data => { this.toast('Folder created in local workspace', 'success'); })
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
return;
}
// Local workspace
fetch('/api/local-workspace/items', {
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: name.trim(), type: 'folder' })
})
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(() => { this.refreshSidebarTree(); document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed')); if (window._wsData && window._wsData._reloadAfterAction) { window._wsData._reloadAfterAction(); } })
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
},
deleteWorkspacePage(pageId, skipConfirm) {
if (!skipConfirm && !confirm('Delete this page?')) return;
fetch(`/api/local-workspace/items/${pageId}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': this.getCsrfToken() }
})
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(() => { this.refreshSidebarTree(); document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed')); if (window._wsData && window._wsData._reloadAfterAction) { window._wsData._reloadAfterAction(); } })
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
},
refreshSidebarTree() {
// Reload the sidebar workspace tree after CRUD in the main content area
fetch('/api/sidebar/workspace-tree')
.then(r => r.text())
.then(html => {
const ul = document.getElementById('sidebar-workspace-items');
if (!ul) return;
// Preserve the "Manage Workspaces" link (last <li>)
const manageLink = ul.querySelector('li:last-child');
ul.innerHTML = html;
if (manageLink) ul.appendChild(manageLink);
})
.catch(e => console.error('Sidebar refresh failed:', e));
},
// ── Drag & Drop ──
dragOverId: null,
dragStart(ev, id) {
ev.dataTransfer.setData('text/plain', id);
ev.target.classList.add('dragging');
},
dragOver(ev, id, isFolder) {
this.dragOverId = id;
if (isFolder) ev.target.closest('.ws-tree-item')?.classList.add('drop-target');
},
dragLeave(ev) {
this.dragOverId = null;
ev.target.closest('.ws-tree-item')?.classList.remove('drop-target');
},
drop(ev, targetId) {
ev.target.closest('.ws-tree-item')?.classList.remove('drop-target');
const draggedId = ev.dataTransfer.getData('text/plain');
if (!draggedId || draggedId == targetId) return;
const isFolder = ev.target.closest('.ws-tree-item')?.dataset?.isFolder === 'true';
const parentId = isFolder && draggedId != targetId ? targetId : null;
fetch(`/api/local-workspace/items/${draggedId}/move`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ parent_id: parentId })
})
.then(r => { if (!r.ok) throw new Error('Failed'); })
.then(() => {
this.refreshSidebarTree();
document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed'));
// Also directly notify main content if on local-workspace page
if (window._wsData && window._wsData._reloadAfterAction) {
window._wsData._reloadAfterAction();
}
})
.catch(err => { this.toast('Move failed: ' + err.message, 'error'); });
},
// ── Sub-item creation in folders ──
newPageInFolder(parentId) {
const name = prompt('File name:');
if (!name || !name.trim()) return;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: name.trim(), type: 'page', parent_id: parentId })
})
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
},
newFolderInFolder(parentId) {
const name = prompt('Sub-folder name:');
if (!name || !name.trim()) return;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify({ name: name.trim(), type: 'folder', parent_id: parentId })
})
.then(r => { if (!r.ok) throw new Error('Failed'); window.location.reload(); })
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
},
openWorkspacePage(pageId) {
if (window.fdNavigate) window.fdNavigate('/pages/' + pageId);
else window.location = `/pages/${pageId}`;
},
// ── Tree expand/collapse + folder navigation ──
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 + sections state before navigating
try {
localStorage.setItem('fd_expanded_folders', JSON.stringify(this.expandedFolders));
localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen));
} catch(e) {}
if (window.fdNavigate) window.fdNavigate(`/local-workspace?folder=${id}`);
else window.location = `/local-workspace?folder=${id}`;
},
createEmptyPage() {
this.showNewPageMenu = false;
this.newPage('Private');
},
createDatabase() {
this.showNewPageMenu = false;
this.toast('Database creation: use a Gitea repo as a database. Navigate to a project board from the dashboard.', 'info');
},
createBoard() {
this.showNewPageMenu = false;
window.location.href = '/board/bruno/flowdeck';
},
toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('fd_theme', next);
},
initTheme() {
var saved = localStorage.getItem('fd_theme') || 'light';
document.documentElement.setAttribute('data-theme', saved);
}
};
}
// Auto-save sidebar state before navigation (covers <a href> clicks)
window.addEventListener('beforeunload', function() {
try {
var el = document.querySelector('[x-data]');
if (el && el.__x) {
var data = el.__x.$data;
if (data.expandedFolders) localStorage.setItem('fd_expanded_folders', JSON.stringify(data.expandedFolders));
if (data.sectionsOpen) localStorage.setItem('fd_sections', JSON.stringify(data.sectionsOpen));
}
} catch(e) {}
});
// Auto-scroll sidebar to active folder
document.addEventListener('alpine:initialized', function() {
setTimeout(function() {
var active = document.querySelector('.sidebar-item.active');
if (active) active.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
// Load Gitea tree in sidebar if workspace is remote
var root = document.querySelector('[x-data]');
if (root && root.__x) {
var state = root.__x.$data;
if (state && typeof state.loadGiteaTree === 'function') {
state.loadGiteaTree();
}
}
}, 300);
});
// Apply saved theme on page load (AND on bfcache restore)
function applySavedTheme() {
var saved = localStorage.getItem('fd_theme') || 'light';
document.documentElement.setAttribute('data-theme', saved);
}
applySavedTheme();
// Re-apply when page is restored from bfcache (e.g. browser back button)
window.addEventListener('pageshow', function(e) {
if (e.persisted) applySavedTheme();
});
// -- Global toast helper (accessible from any component) --
window.showToast = function(msg, type) {
var el = document.querySelector('[x-data]');
if (el && el.__x && el.__x.$data && el.__x.$data.toast) {
el.__x.$data.toast(msg, type || 'info');
}
};
// -- Global sidebar refresh handler (called after any CRUD) --
function refreshSidebarFromEvent() {
// Try appState first (outer x-data), then any x-data with refreshSidebarTree
var appEl = document.querySelector('.app-layout[x-data]');
if (!appEl) appEl = document.querySelector('[x-data]');
var data = appEl && appEl.__x ? appEl.__x.$data : null;
if (data && data.refreshSidebarTree) data.refreshSidebarTree();
}
document.addEventListener('flowdeck:sidebar-refresh', refreshSidebarFromEvent);
document.addEventListener('flowdeck:workspace-changed', refreshSidebarFromEvent);
// -- Global keyboard shortcuts --
document.addEventListener('keydown', function(e) {
var tag = (document.activeElement && document.activeElement.tagName) || '';
var editable = tag === 'INPUT' || tag === 'TEXTAREA' || (document.activeElement && document.activeElement.isContentEditable);
// Don't intercept when typing in inputs unless it's Escape
if (editable && e.key !== 'Escape') return;
// Don't intercept when a modal prompt is open
if (document.querySelector('.modal-overlay:not([style*="display:none"])')) return;
var appEl = document.querySelector('[x-data]');
var data = appEl && appEl.__x ? appEl.__x.$data : null;
if (e.ctrlKey && e.key === 'n') {
e.preventDefault();
window.FlowDeck.createPage();
}
else if (e.key === 'Enter' && !editable) {
var activeSidebar = document.querySelector('.sidebar-item.active');
if (activeSidebar) {
e.preventDefault();
activeSidebar.click();
}
}
else if (e.key === 'F2') {
e.preventDefault();
// Trigger rename: dispatch dblclick on active item's name span
var nameSpan = document.querySelector('.sidebar-item.active .page-name');
if (nameSpan) {
nameSpan.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
} else if (data && data.wsCtx && data.wsCtx.id) {
// Workspace tree item: trigger rename via context menu
data.wsCtxAction('rename');
}
}
else if (e.key === 'Delete' && !editable) {
e.preventDefault();
var delItem = document.querySelector('.sidebar-item.active');
if (delItem && delItem.dataset.pageId && data) {
data.contextMenu.pageId = delItem.dataset.pageId;
data.contextMenu.pageName = delItem.dataset.pageName || delItem.querySelector('.page-name')?.textContent || '';
data.contextAction('delete');
} else if (delItem && delItem.dataset.giteaPath) {
// Gitea file: dispatch custom event
delItem.dispatchEvent(new CustomEvent('gitea-delete', { bubbles: true }));
}
}
else if (e.key === 'Escape') {
// Close card modal
if (typeof closeModal === 'function') closeModal();
// Close context menus
if (data) {
if (data.contextMenu) data.contextMenu.visible = false;
if (data.wsCtx) data.wsCtx.visible = false;
if (data.showNewPageMenu) data.showNewPageMenu = false;
}
}
else if (e.ctrlKey && e.key === 's') {
e.preventDefault();
// Try to find and click a save/commit button
var saveBtn = document.querySelector('.btn-primary, button:contains("Save"), button:contains("Commit")');
if (saveBtn) saveBtn.click();
}
});
</script>
<script src="/static/js/app.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<style>
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
.flowdeck-modal{background:var(--bg-secondary,#222);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;width:100%;max-width:420px;box-shadow:0 20px 60px rgba(0,0,0,0.5);}
.flowdeck-modal-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));}
.flowdeck-modal-header h3{margin:0;font-size:16px;color:var(--text,#fff);}
.flowdeck-modal-close{background:none;border:none;color:var(--text-dim, rgba(255,255,255,.4));font-size:20px;cursor:pointer;padding:4px 8px;border-radius:6px;}
.flowdeck-modal-close:hover{background:var(--bg-hover,#333);color:var(--text,#fff);}
.flowdeck-modal-body{padding:20px;}
.flowdeck-modal-body p{color:var(--text-dim, rgba(255,255,255,.6));font-size:14px;margin:0 0 12px 0;}
.flowdeck-modal-input{width:100%;padding:10px 12px;background:var(--bg-tertiary,#2A2A2A);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:8px;color:var(--text,#fff);font-size:14px;outline:none;box-sizing:border-box;}
.flowdeck-modal-input:focus{border-color:var(--accent,#2383E2);box-shadow:0 0 0 1px var(--accent,#2383E2);}
.flowdeck-modal-footer{display:flex;gap:8px;justify-content:flex-end;padding:12px 20px;border-top:1px solid var(--border,rgba(255,255,255,.08));}
.flowdeck-modal-btn{padding:8px 20px;border-radius:8px;font-size:13px;cursor:pointer;border:none;}
.flowdeck-modal-btn.cancel{background:var(--bg-tertiary,#333);color:var(--text-dim, rgba(255,255,255,.6));}
.flowdeck-modal-btn.cancel:hover{background:var(--bg-hover,#444);}
.flowdeck-modal-btn.confirm{background:var(--accent,#2383E2);color:#fff;}
.flowdeck-modal-btn.confirm:hover{background:#2C8CEB;}
</style>
<!-- Global New Folder Modal -->
<div id="fd-global-folder-modal" class="flowdeck-modal-overlay" style="display:none" onclick="if(event.target===this)this.style.display='none'">
<div class="flowdeck-modal">
<div class="flowdeck-modal-header">
<h3>{{ fd_icon("folder",16) }} New Folder</h3>
<button class="flowdeck-modal-close" onclick="document.getElementById('fd-global-folder-modal').style.display='none'">&times;</button>
</div>
<div class="flowdeck-modal-body">
<p>Enter a name for the new folder.</p>
<input class="flowdeck-modal-input" id="fd-global-folder-input" type="text" placeholder="Folder name" onkeydown="if(event.key==='Enter')window.FlowDeck._confirmCreateFolder()">
</div>
<div class="flowdeck-modal-footer">
<button class="flowdeck-modal-btn cancel" onclick="document.getElementById('fd-global-folder-modal').style.display='none'">Cancel</button>
<button class="flowdeck-modal-btn confirm" onclick="window.FlowDeck._confirmCreateFolder()">Create</button>
</div>
</div>
</div>
{% include 'agent_panel.html' %}
<!-- ═══════════════════ COMMAND PALETTE (v5.0.0) — Ctrl+K / Ctrl+P ═══════════════════ -->
<style>
.cmd-palette-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;justify-content:center;padding-top:14vh;opacity:0;visibility:hidden;transition:opacity .12s ease,visibility .12s ease}
.cmd-palette-overlay.open{opacity:1;visibility:visible}
.cmd-palette{width:600px;max-width:92vw;max-height:60vh;display:flex;flex-direction:column;background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;transform:translateY(8px);transition:transform .15s ease}
.cmd-palette-overlay.open .cmd-palette{transform:translateY(0)}
.cmd-palette-input{width:100%;box-sizing:border-box;padding:16px 18px;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:16px}
.cmd-palette-input::placeholder{color:var(--text-dim,rgba(255,255,255,.4))}
.cmd-palette-tabs{display:flex;gap:6px;padding:10px 16px 0}
.cp-tab{background:none;border:1px solid var(--border,rgba(255,255,255,.12));color:var(--text-dim,rgba(255,255,255,.55));border-radius:999px;padding:4px 12px;font-size:12px;cursor:pointer}
.cp-tab.active{background:var(--bg-hover,#2a2a2a);color:var(--text,#eee);border-color:var(--accent,#2383e2)}
.cmd-palette-ai{padding:14px 18px;font-size:13.5px;line-height:1.55;white-space:pre-wrap;color:var(--text,#eee)}
.cmd-palette-ai .cp-cites{margin-top:10px;display:flex;flex-direction:column;gap:4px;font-size:12.5px;white-space:normal}
a.cp-cite{color:var(--accent,#529ffa);text-decoration:none}
a.cp-cite:hover{text-decoration:underline}
.cp-loading{color:var(--text-dim,rgba(255,255,255,.5))}
.cmd-palette-list{overflow-y:auto;border-top:1px solid var(--border,rgba(255,255,255,.06))}
.cmd-palette-group{display:flex;align-items:center;gap:8px;padding:10px 18px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-dim,rgba(255,255,255,.4))}
.cmd-palette-item{display:flex;align-items:center;gap:10px;padding:9px 18px;cursor:pointer;font-size:14px;color:var(--text,#fff)}
.cmd-palette-item .cpi-icon{width:20px;text-align:center;flex:none;color:var(--text-dim,rgba(255,255,255,.5))}
.cmd-palette-item .cpi-main{flex:1;min-width:0}
.cmd-palette-item .cpi-title{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cmd-palette-item .cpi-sub{font-size:12px;color:var(--text-dim,rgba(255,255,255,.45));white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cmd-palette-item .cpi-sub mark{background:none;color:var(--accent,#2383E2);font-weight:600}
.cmd-palette-item .cpi-title mark{background:none;color:var(--accent,#2383E2);font-weight:600}
.cmd-palette-item .cpi-key{margin-left:auto;flex:none;font-size:11px;color:var(--text-dim,rgba(255,255,255,.35));font-family:monospace}
.cmd-palette-item.active{background:var(--bg-hover,#2e2e2e)}
.cmd-palette-empty{padding:22px 18px;color:var(--text-dim,rgba(255,255,255,.4));font-size:13px;text-align:center}
.cmd-palette-footer{display:flex;align-items:center;gap:14px;padding:8px 16px;border-top:1px solid var(--border,rgba(255,255,255,.06));font-size:11px;color:var(--text-dim,rgba(255,255,255,.4))}
.cmd-palette-footer .cpf-kbd{display:inline-flex;align-items:center;gap:3px}
.cmd-palette-footer b{color:var(--text,rgba(255,255,255,.8));font-weight:600}
</style>
<div id="fd-command-palette" class="cmd-palette-overlay" onclick="if(event.target===this)window.FlowDeckPalette&&window.FlowDeckPalette.close()">
<div class="cmd-palette" role="dialog" aria-label="Quick find">
<input id="fd-cp-input" class="cmd-palette-input" type="text"
placeholder="Search pages, databases, or type a command…"
autocomplete="off" spellcheck="false">
<div class="cmd-palette-tabs" id="fd-cp-tabs" role="tablist">
<button type="button" class="cp-tab active" data-tab="pages">Pages</button>
<button type="button" class="cp-tab" data-tab="ai">✨ Réponses IA</button>
</div>
<div id="fd-cp-list" class="cmd-palette-list"></div>
<div class="cmd-palette-footer">
<span class="cpf-kbd"><b>↑</b> / <b>↓</b> navigate</span>
<span class="cpf-kbd"><b>↵</b> open</span>
<span class="cpf-kbd"><b>esc</b> close</span>
</div>
</div>
</div>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){
var PALETTE_ACTIONS = [
{ id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } },
{ id:'goto-library', icon:'📚', title:'Go to Library', sub:'Recents · Favorites · Shared · Published', key:'', run:function(){ window.location.href='/library'; return true; } },
{ id:'goto-mytasks', icon:'✅', title:'My Tasks', sub:'Aggregated tasks across databases', key:'', run:function(){ window.location.href='/my-tasks'; return true; } },
{ id:'goto-trash', icon:'🗑️', title:'Trash', sub:'Recently deleted pages', key:'', run:function(){ window.location.href='/trash'; return true; } },
{ id:'goto-settings', icon:'⚙️', title:'Settings', sub:'Account, integrations, notifications', key:'', run:function(){ window.location.href='/accounts/settings'; return true; } },
{ id:'goto-help', icon:'❓', title:'Help & shortcuts', sub:'Keyboard shortcuts and guides', key:'', run:function(){ window.location.href='/help'; return true; } }
];
var overlay, input, list;
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false, tab:'pages', ai:null };
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }
function highlight(text, q){
text = esc(text);
var terms = (q||'').split(/\s+/).filter(function(t){return t;}).map(function(t){return t.toLowerCase();});
if(!terms.length) return text;
var lower = text.toLowerCase();
var out='', i=0;
for(var k=0;k<terms.length;k++){
var term=terms[k];
if(!term) continue;
var idx = lower.indexOf(term, i);
if(idx>=0){
out += text.slice(i, idx);
out += '<mark>'+text.slice(idx, idx+term.length)+'</mark>';
i = idx + term.length;
}
}
out += text.slice(i);
return out;
}
function render(){
if(state.tab==='ai'){ renderAi(); return; }
if(!state.open) return;
if(!state.items.length){
list.innerHTML = '<div class="cmd-palette-empty">No results for “'+esc(state.query)+'”</div>';
return;
}
var groups = [];
var current = null;
state.items.forEach(function(item, idx){
var gkey = item.group || (item.type==='collection'?'Databases':(item.type==='page'?'Pages':'Actions'));
if(gkey!==current){ current=gkey; groups.push({gkey:gkey, items:[]}); }
groups[groups.length-1].items.push({item:item, idx:idx});
});
var html='';
groups.forEach(function(g){
html += '<div class="cmd-palette-group">'+esc(g.gkey)+'</div>';
g.items.forEach(function(gi){
var it=gi.item;
var cls = gi.idx===state.index ? 'cmd-palette-item active' : 'cmd-palette-item';
var icon = it.icon || (it.type==='page'?'📄':'📋');
var sub = it.subtitle || it.excerpt || '';
var key = it.key || '';
html += '<div class="'+cls+'" data-idx="'+gi.idx+'" onmouseenter="window.FlowDeckPalette.setIndex('+gi.idx+')" onclick="window.FlowDeckPalette.choose('+gi.idx+')">'
+ '<span class="cpi-icon">'+esc(icon)+'</span>'
+ '<span class="cpi-main"><span class="cpi-title">'+highlight(it.title, state.query)+'</span>'
+ (sub ? '<div class="cpi-sub">'+highlight(sub, state.query)+'</div>' : '')
+ '</span>'
+ (key ? '<span class="cpi-key">'+esc(key)+'</span>' : '')
+ '</div>';
});
});
list.innerHTML = html;
var active = list.querySelector('.cmd-palette-item.active');
if(active) active.scrollIntoView({block:'nearest'});
}
function runSearch(){
if(state.tab==='ai'){ runAi(); return; }
var q = input.value.trim();
state.query = q;
state.index = 0;
if(!q){
state.items = PALETTE_ACTIONS.map(function(a){ return Object.assign({group:'Actions', type:'action'}, a); });
state.actions = true;
render();
return;
}
state.actions = false;
fetch('/api/search?q='+encodeURIComponent(q))
.then(function(r){ return r.json(); })
.then(function(data){
if(input.value.trim()!==q) return; // stale
var pages=(data.pages||[]).slice(0,8), cols=(data.collections||[]).slice(0,6);
var items = pages.map(function(p){ return Object.assign({group:'Pages'}, p); })
.concat(cols.map(function(c){ return Object.assign({group:'Databases'}, c); }))
.concat(PALETTE_ACTIONS.map(function(a){ return Object.assign({group:'Actions', type:'action'}, a); }));
state.items = items;
render();
})
.catch(function(){ state.items=[]; render(); });
}
function runAi(){
var q = input.value.trim();
state.ai = null;
if(!q){ state.ai = {hint:1}; renderAi(); return; }
state.ai = {loading:1}; renderAi();
fetch('/api/v2/search/ask', {
method:'POST',
headers: {'Content-Type':'application/json', 'X-CSRF-Token': getCsrf()},
body: JSON.stringify({question: q})
}).then(function(r){ if(!r.ok) throw 0; return r.json(); })
.then(function(d){
if(state.tab!=='ai' || input.value.trim()!==q) return;
state.ai = d; renderAi();
})
.catch(function(){
if(state.tab!=='ai') return;
state.ai = {error:1}; renderAi();
});
}
function renderAi(){
var a = state.ai;
if(!a){ list.innerHTML=''; return; }
if(a.loading){ list.innerHTML='<div class="cmd-palette-ai"><span class="cp-loading">Recherche de la réponse…</span></div>'; return; }
if(a.error){ list.innerHTML='<div class="cmd-palette-ai">Demande impossible — réessayez.</div>'; return; }
if(a.hint){ list.innerHTML='<div class="cmd-palette-ai">Posez une question sur votre espace : la réponse cite vos pages.</div>'; return; }
var cits = a.citations || [], byId = {};
cits.forEach(function(c){ byId[String(c.id)] = c; });
// échappement AVANT injection des liens (ids = chiffres, type = [a-z]+)
var md = esc(a.answer_markdown || '')
.replace(/\[\[([a-z]+):(\d+)\]\]/g, function(m, type, id){
var c = byId[id];
var label = esc((c && (c.title || c.name)) || (type + ' #' + id));
var href = type === 'collection' ? '/db/' + id : '/pages/' + id;
return '<a class="cp-cite" href="' + href + '">' + label + '</a>';
})
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
var cites = cits.map(function(c){
var href = c.type === 'collection' ? '/db/' + c.id : '/pages/' + c.id;
return '<a class="cp-cite" href="' + href + '">' + esc(c.title || c.name || 'page #' + c.id) + '</a>';
}).join('');
list.innerHTML = '<div class="cmd-palette-ai">' + md +
(cites ? '<div class="cp-cites"><b>Sources</b> ' + cites + '</div>' : '') + '</div>';
}
function open(){
if(state.open) return;
state.open=true; overlay.classList.add('open');
document.body.style.overflow='hidden';
setTimeout(function(){ input.focus(); }, 30);
runSearch();
}
function close(){
if(!state.open) return;
state.open=false; overlay.classList.remove('open');
document.body.style.overflow='';
input.blur();
}
function toggle(){ state.open ? close() : open(); }
function setIndex(i){
if(i>=0 && i<state.items.length) state.index=i;
// Toggle the active class without rebuilding #fd-cp-list: re-rendering on
// hover destroys the element under the cursor and breaks the next click.
var nodes = list.querySelectorAll('.cmd-palette-item');
for(var j=0;j<nodes.length;j++){
if(parseInt(nodes[j].getAttribute('data-idx'),10)===state.index){
nodes[j].classList.add('active');
nodes[j].scrollIntoView({block:'nearest'});
} else {
nodes[j].classList.remove('active');
}
}
}
function choose(i){
var it = state.items[i]; if(!it) return;
if(it.run){ var ok = it.run(); if(ok) close(); return; }
if(it.url){ if(window.fdNavigate) window.fdNavigate(it.url); else window.location.href=it.url; close(); return; }
}
function onKey(e){
var tag=(document.activeElement&&document.activeElement.tagName)||'';
var mod = e.ctrlKey||e.metaKey;
if(mod && (e.key==='k'||e.key==='K'||e.key==='p'||e.key==='P')){
e.preventDefault(); toggle(); return;
}
if(!state.open) return;
if(e.key==='Escape'){ e.preventDefault(); close(); return; }
if(e.key==='ArrowDown'){ e.preventDefault(); setIndex(Math.min(state.index+1, state.items.length-1)); }
else if(e.key==='ArrowUp'){ e.preventDefault(); setIndex(Math.max(state.index-1, 0)); }
else if(e.key==='Enter'){ e.preventDefault(); choose(state.index); }
}
window.FlowDeckPalette = { open:open, close:close, toggle:toggle, setIndex:setIndex, choose:choose, getState:function(){return state;} };
document.addEventListener('DOMContentLoaded', function(){
overlay = document.getElementById('fd-command-palette');
input = document.getElementById('fd-cp-input');
list = document.getElementById('fd-cp-list');
if(!overlay) return;
input.addEventListener('input', function(){ clearTimeout(state.timer); state.timer=setTimeout(runSearch, 150); });
var tabs = document.getElementById('fd-cp-tabs');
if(tabs) tabs.addEventListener('click', function(e){
var b = e.target && e.target.closest ? e.target.closest('.cp-tab') : null;
if(!b) return;
state.tab = b.getAttribute('data-tab');
var all = tabs.querySelectorAll('.cp-tab');
for(var i=0;i<all.length;i++) all[i].classList.toggle('active', all[i]===b);
state.index = 0;
runSearch();
});
document.addEventListener('keydown', onKey);
});
})();
</script>
{# ─── PWA: sync indicators (v6.0.0) ─── #}
<style>
.fd-sync-badge{position:fixed;top:10px;right:14px;z-index:600;display:flex;align-items:center;gap:8px;
background:var(--bg-secondary,#262626);border:1px solid var(--border,rgba(255,255,255,.1));
color:var(--text-secondary,#b8b8b8);border-radius:20px;padding:5px 12px;font-size:12px;
box-shadow:0 4px 16px rgba(0,0,0,.35);}
.fd-sync-spinner{width:12px;height:12px;border:2px solid rgba(255,255,255,.25);
border-top-color:var(--accent,#2383E2);border-radius:50%;display:inline-block;
animation:fd-spin .8s linear infinite;}
@keyframes fd-spin{to{transform:rotate(360deg)}}
.sidebar-item.fd-dirty .page-name::after{content:'⟳';margin-left:6px;font-size:11px;
color:var(--accent,#2383E2);opacity:.85;}
</style>
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
<script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').then(function(reg) {
if ('sync' in reg && reg.sync) {
reg.sync.register('sync-flowdeck').catch(function() {});
}
}).catch(function() {});
});
})();
</script>
{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function() {
if (!window.FlowOffline) return;
window.FlowOffline.onChange(function(s) {
var badge = document.getElementById('fd-sync-badge');
if (badge) badge.style.display = s.isSyncing ? 'flex' : 'none';
});
window.addEventListener('flowdeck:sync-done', function(ev) {
var d = ev.detail || {};
if (d.failed) {
if (window.showToast) window.showToast(d.failed + ' modification(s) non synchronisée(s)', 'error');
} else if (d.conflicts) {
if (window.showToast) window.showToast('Synchronisé — ' + d.conflicts + ' conflit(s) résolu(s)', 'info');
} else if (d.synced) {
if (window.showToast) window.showToast('Modifications synchronisées', 'success');
}
});
});
</script>
</body>
</html>