Added x-cloak directive and [x-cloak] CSS so button never flashes before Alpine loads. Button is in base.html so appears on all pages that extend the base template.
703 lines
31 KiB
HTML
703 lines
31 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr" data-theme="dark">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>FlowDeck</title>
|
||
<link rel="stylesheet" href="/static/css/app.css">
|
||
<script src="https://unpkg.com/[email protected]"></script>
|
||
<script src="https://unpkg.com/[email protected]" defer></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js" defer></script>
|
||
</head>
|
||
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'>
|
||
<div class="app-layout" x-data="appState()">
|
||
|
||
{# ─── 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' }}">
|
||
{# 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">{{ page.icon }}</span>
|
||
<span class="page-name">{{ page.name }}</span>
|
||
<span class="item-actions">
|
||
<button class="item-action-btn" @click.stop="openLibrary('{{ page.id }}')">📚</button>
|
||
<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 %}
|
||
|
||
<!-- ═══════════ SIDEBAR Notion ═══════════ -->
|
||
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }" id="sidebar"
|
||
x-data x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
|
||
<!-- Workspace header -->
|
||
<div class="sidebar-workspace" @click="toggleWorkspaceMenu">
|
||
<div class="workspace-info">
|
||
<div class="workspace-avatar">{{ workspaceInitial }}</div>
|
||
<span class="workspace-name">{{ workspaceName }}</span>
|
||
</div>
|
||
<span class="workspace-chevron">▾</span>
|
||
</div>
|
||
|
||
<!-- Navigation icons row -->
|
||
<div class="sidebar-nav-row">
|
||
<a href="/workspaces" 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">
|
||
<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">
|
||
<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>
|
||
<button class="nav-icon-btn collapse-btn" @click="sidebarCollapsed = !sidebarCollapsed" title="Collapse sidebar">
|
||
<svg width="16" height="16" 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>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Workspace -->
|
||
<div class="sidebar-section">
|
||
<div class="sidebar-section-header" @click="toggleSection('workspace')">
|
||
<div class="sidebar-section-title">
|
||
<span class="chevron" :class="{ open: sectionsOpen.workspace }" x-text="sectionsOpen.workspace ? '▼' : '▶'"></span>
|
||
<span>📁 {{ active_ws_name or 'Workspace' }}</span>
|
||
</div>
|
||
<div class="sidebar-section-actions">
|
||
<button class="section-action-btn" title="New Page" @click.stop="newPageInWorkspace()">📄</button>
|
||
<button class="section-action-btn" title="New Folder" @click.stop="newFolderInWorkspace()">📁</button>
|
||
</div>
|
||
</div>
|
||
<div class="sidebar-section-items" x-show="sectionsOpen.workspace" x-transition>
|
||
<ul class="sidebar-items" data-section="workspace">
|
||
{% for page in workspace_pages %}
|
||
<li class="sidebar-item" data-id="{{ page.db_id }}">
|
||
<span class="page-icon">{{ page.icon or '📄' }}</span>
|
||
<a href="/pages/{{ page.db_id }}" class="page-name">{{ page.name }}</a>
|
||
<button class="sidebar-item-delete" @click="deleteWorkspacePage({{ page.db_id }})" title="Delete">×</button>
|
||
</li>
|
||
{% endfor %}
|
||
{% if not workspace_pages %}
|
||
<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>
|
||
{% endif %}
|
||
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.location='/workspaces'">
|
||
<span class="page-icon">⚙</span><span class="page-name text-dim">Manage Workspaces</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Meetings -->
|
||
<div class="sidebar-section">
|
||
<div class="sidebar-section-header" @click="toggleSection('meetings')">
|
||
<div class="sidebar-section-title">
|
||
<span class="chevron" :class="{ open: sectionsOpen.meetings }" x-text="sectionsOpen.meetings ? '▼' : '▶'"></span>
|
||
<span>Meetings</span>
|
||
</div>
|
||
<div class="sidebar-section-actions">
|
||
<button class="section-action-btn" title="Add" @click.stop="newPage('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">📅</span><span class="page-name text-dim">No meetings</span></li></ul>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Recents -->
|
||
<div class="sidebar-section">
|
||
<div class="sidebar-section-header" @click="toggleSection('recents')">
|
||
<div class="sidebar-section-title">
|
||
<span class="chevron" :class="{ open: sectionsOpen.recents }" x-text="sectionsOpen.recents ? '▼' : '▶'"></span>
|
||
<span>Recents</span>
|
||
</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">📄</span><span class="page-name text-dim">No recent pages</span></li>
|
||
{% endif %}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Favorites -->
|
||
<div class="sidebar-section">
|
||
<div class="sidebar-section-header" @click="toggleSection('favorites')">
|
||
<div class="sidebar-section-title">
|
||
<span class="chevron" :class="{ open: sectionsOpen.favorites }" x-text="sectionsOpen.favorites ? '▼' : '▶'"></span>
|
||
<span>Favorites</span>
|
||
</div>
|
||
<div class="sidebar-section-actions">
|
||
<button class="section-action-btn" title="Add" @click.stop="newPage('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">⭐</span><span class="page-name text-dim">No favorites yet</span></li>
|
||
{% endif %}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Agents -->
|
||
<div class="sidebar-section">
|
||
<div class="sidebar-section-header" @click="toggleSection('agents')">
|
||
<div class="sidebar-section-title"><span>Agents</span></div>
|
||
</div>
|
||
<div class="sidebar-section-items" x-show="sectionsOpen.agents" x-transition>
|
||
<ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon">🤖</span><span class="page-name text-dim">No agents configured</span></li></ul>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Shared -->
|
||
<div class="sidebar-section">
|
||
<div class="sidebar-section-header" @click="toggleSection('shared')">
|
||
<div class="sidebar-section-title">
|
||
<span class="chevron" :class="{ open: sectionsOpen.shared }" x-text="sectionsOpen.shared ? '▼' : '▶'"></span>
|
||
<span>Shared</span>
|
||
</div>
|
||
<div class="sidebar-section-actions">
|
||
<button class="section-action-btn" title="Add" @click.stop="newPage('Shared')">+</button>
|
||
</div>
|
||
</div>
|
||
<div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition>
|
||
<ul class="sidebar-items" data-section="shared">
|
||
{% for page in shared_pages %}
|
||
{{ render_tree_item(page) }}
|
||
{% endfor %}
|
||
{% if not shared_pages %}
|
||
<li class="sidebar-item empty-hint"><span class="page-icon">👥</span><span class="page-name text-dim">No shared pages</span></li>
|
||
{% endif %}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Private -->
|
||
<div class="sidebar-section">
|
||
<div class="sidebar-section-header" @click="toggleSection('private')">
|
||
<div class="sidebar-section-title">
|
||
<span class="chevron" :class="{ open: sectionsOpen.private }" x-text="sectionsOpen.private ? '▼' : '▶'"></span>
|
||
<span>Private</span>
|
||
</div>
|
||
<div class="sidebar-section-actions">
|
||
<button class="section-action-btn" title="View" @click.stop="">⊞</button>
|
||
<button class="section-action-btn" title="More" @click.stop="">⋯</button>
|
||
<button class="section-action-btn" title="Add page" @click.stop="newPage('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">📄</span><span class="page-name text-dim">No pages yet</span></li>
|
||
{% endif %}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Notion apps / Utilities -->
|
||
<div class="sidebar-section">
|
||
<div class="sidebar-section-header" @click="toggleSection('apps')">
|
||
<div class="sidebar-section-title">
|
||
<span class="chevron" :class="{ open: sectionsOpen.apps }" x-text="sectionsOpen.apps ? '▼' : '▶'"></span>
|
||
<span>Notion apps</span>
|
||
</div>
|
||
</div>
|
||
<div class="sidebar-section-items" x-show="sectionsOpen.apps" x-transition>
|
||
<ul class="sidebar-items">
|
||
<li class="sidebar-item" @click="navigateTo('/library')"><span class="page-icon">📚</span><span class="page-name">Library</span></li>
|
||
<li class="sidebar-item"><span class="page-icon">✅</span><span class="page-name">My Tasks</span></li>
|
||
<li class="sidebar-item" @click="navigateTo('/trash')"><span class="page-icon">🗑️</span><span class="page-name">Trash</span></li>
|
||
<li class="sidebar-item"><span class="page-icon">❓</span><span class="page-name">Help</span></li>
|
||
</ul>
|
||
</div>
|
||
</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="font-size:14px;">💬</span> New chat
|
||
<span class="shortcut">Ctrl+O</span>
|
||
</button>
|
||
<button class="sidebar-footer-btn" style="width:36px; justify-content:center; background:var(--bg-hover); border-radius:8px;" @click="newPage('Private')" 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>
|
||
</div>
|
||
<div style="display: flex; gap: 4px; margin-top: 4px;">
|
||
{% if user %}
|
||
<a href="/auth/logout" class="sidebar-footer-btn" style="flex:1; justify-content:center;">🚪</a>
|
||
{% else %}
|
||
<a href="/auth/login" class="sidebar-footer-btn" style="flex:1; justify-content:center;">🔑</a>
|
||
{% endif %}
|
||
<button class="sidebar-footer-btn" style="width:36px; justify-content:center;" @click="toggleTheme()">🌓</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Uncollapse button (visible when sidebar hidden) -->
|
||
<button class="sidebar-uncollapse-btn" x-show="sidebarCollapsed" x-cloak @click="sidebarCollapsed = false" title="Show sidebar">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||
</button>
|
||
|
||
<style>
|
||
[x-cloak] { display: none !important; }
|
||
</style>
|
||
|
||
<!-- ═══════════ MAIN ═══════════ -->
|
||
<div class="main-wrapper">
|
||
|
||
<!-- Topbar -->
|
||
{% block topbar %}
|
||
<header class="topbar">
|
||
<div class="topbar-left">
|
||
<div class="topbar-breadcrumb">
|
||
<span x-text="workspaceName"></span>
|
||
<span class="text-dim">/</span>
|
||
<span class="text-dim" x-text="currentPage"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="topbar-center">
|
||
<div class="topbar-page-title">
|
||
<span class="page-icon" x-text="pageIcon"></span>
|
||
<span x-text="pageTitle"></span>
|
||
</div>
|
||
<div class="topbar-page-status">
|
||
<span>🔒</span> Private
|
||
<span style="cursor:pointer;">▾</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="topbar-right">
|
||
<span class="text-xs text-dim">Edited <span x-text="lastEdited"></span></span>
|
||
<button class="topbar-btn">🔔</button>
|
||
<button class="topbar-btn">🕒</button>
|
||
<button class="topbar-btn">🔍</button>
|
||
<button class="topbar-btn share-btn">🔗 Share ▾</button>
|
||
<button class="topbar-btn">⭐</button>
|
||
<button class="topbar-btn" @click="showNewPageMenu = !showNewPageMenu" style="position:relative;">
|
||
New ▾
|
||
<div class="dropdown-panel" :class="{ visible: showNewPageMenu }" style="top:100%; right:0; left:auto; min-width:200px;" @click.stop>
|
||
<div class="dropdown-option" @click="createEmptyPage()"><span class="option-icon">📄</span> Empty page</div>
|
||
<div class="dropdown-option" @click="createDatabase()"><span class="option-icon">📊</span> Database</div>
|
||
<div class="dropdown-option" @click="createBoard()"><span class="option-icon">📋</span> Kanban board</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
{% endblock %}
|
||
|
||
<!-- Content -->
|
||
<div class="content-area" id="main-content">
|
||
{% block content %}{% 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.away="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">👁️</span> Remove from Recents
|
||
</div>
|
||
</div>
|
||
<div class="context-menu-section">
|
||
<div class="context-menu-item" @click="contextAction('copyLink')">
|
||
<span class="menu-icon">🔗</span> Copy link
|
||
</div>
|
||
<div class="context-menu-item" @click="contextAction('duplicate')">
|
||
<span class="menu-icon">📋</span> Duplicate
|
||
<span class="menu-shortcut">▶</span>
|
||
</div>
|
||
<div class="context-menu-item" @click="contextAction('rename')">
|
||
<span class="menu-icon">✏️</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">🗑️</span> Move to Trash
|
||
</div>
|
||
</div>
|
||
<div class="context-menu-section">
|
||
<div class="context-menu-item" @click="contextAction('openTab')">
|
||
<span class="menu-icon">🔲</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">📑</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">❓</span> Learn about databases
|
||
</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">×</button>
|
||
</div>
|
||
<div id="card-modal-content" class="modal-body"></div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
// Inject CSRF token into HTMX headers
|
||
(function() {
|
||
const getCsrf = () => {
|
||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||
return m ? m[1] : '';
|
||
};
|
||
document.body.setAttribute('hx-headers', JSON.stringify({'X-CSRF-Token': getCsrf()}));
|
||
document.addEventListener('htmx:configRequest', function(evt) {
|
||
evt.detail.headers['X-CSRF-Token'] = getCsrf();
|
||
});
|
||
})();
|
||
|
||
function appState() {
|
||
return {
|
||
sidebarCollapsed: false,
|
||
showInvite: true,
|
||
workspaceName: '{{ workspace_name }}',
|
||
workspaceInitial: '{{ workspace_initial }}',
|
||
currentPage: '{{ current_page }}',
|
||
pageIcon: '{% block page_icon %}📁{% endblock %}',
|
||
pageTitle: '{% block page_title %}FlowDeck{% endblock %}',
|
||
lastEdited: '{{ last_edited }}',
|
||
workspaceKey: '{{ workspace_key }}',
|
||
currentView: 'home',
|
||
contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' },
|
||
contextActions: {},
|
||
showNewPageMenu: false,
|
||
|
||
// ── Favorites tracking ──
|
||
favoriteIds: new Set([{% for p in favorite_pages %}'{{ p.id }}'{% if not loop.last %},{% endif %}{% endfor %}]),
|
||
|
||
// ── Sections collapsible ──
|
||
sectionsOpen: { recents: true, private: true, meetings: true, favorites: true, agents: true, shared: true, apps: true },
|
||
toggleSection(key) { this.sectionsOpen[key] = !this.sectionsOpen[key]; this.sectionsOpen = Object.assign({}, this.sectionsOpen); },
|
||
toggleWorkspaceMenu() { this.sidebarCollapsed = !this.sidebarCollapsed; },
|
||
openQuickFind() { alert('Quick Find — Ctrl+K'); },
|
||
|
||
// ── 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) {
|
||
// 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('/')}`;
|
||
}
|
||
window.location.href = url;
|
||
},
|
||
|
||
showContextMenu(event, pageId, pageName) {
|
||
this.contextMenu = {
|
||
visible: true,
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
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;
|
||
// Reload to update sidebar
|
||
location.reload();
|
||
})
|
||
.catch(e => alert('Favorite action failed'));
|
||
break;
|
||
case 'copyLink':
|
||
const url = window.location.origin + '/' + pageId;
|
||
navigator.clipboard.writeText(url).then(() => alert('Link copied!'));
|
||
break;
|
||
case 'duplicate':
|
||
fetch(`/board/api/pages?title=${encodeURIComponent(pageName + ' copy')}§ion=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 => alert('Duplicate failed'));
|
||
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;
|
||
alert(`Moved to ${section}`);
|
||
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 => alert('Delete failed: ' + e.message));
|
||
break;
|
||
case 'openTab':
|
||
window.open('/' + pageId, '_blank');
|
||
break;
|
||
case 'openPeek':
|
||
alert('Side peek not available in browser');
|
||
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() {
|
||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||
return m ? m[1] : '';
|
||
},
|
||
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 => { alert('Failed to create page: ' + err.message); });
|
||
},
|
||
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 => { alert('Failed to create sub-page: ' + err.message); });
|
||
},
|
||
newPageInWorkspace() {
|
||
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' })
|
||
})
|
||
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||
.catch(err => { alert('Failed: ' + err.message); });
|
||
},
|
||
newFolderInWorkspace() {
|
||
const name = prompt('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' })
|
||
})
|
||
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||
.then(() => { window.location.reload(); })
|
||
.catch(err => { alert('Failed: ' + err.message); });
|
||
},
|
||
deleteWorkspacePage(pageId) {
|
||
if (!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(() => { window.location.reload(); })
|
||
.catch(err => { alert('Failed: ' + err.message); });
|
||
},
|
||
|
||
createEmptyPage() {
|
||
this.showNewPageMenu = false;
|
||
this.newPage('Private');
|
||
},
|
||
createDatabase() {
|
||
this.showNewPageMenu = false;
|
||
alert('Database creation: use a Gitea repo as a database. Navigate to a project board from the dashboard.');
|
||
},
|
||
createBoard() {
|
||
this.showNewPageMenu = false;
|
||
window.location.href = '/board/bruno/flowdeck';
|
||
},
|
||
|
||
toggleTheme() {
|
||
const html = document.documentElement;
|
||
const current = html.getAttribute('data-theme');
|
||
html.setAttribute('data-theme', current === 'dark' ? 'light' : 'dark');
|
||
localStorage.setItem('theme', current === 'dark' ? 'light' : 'dark');
|
||
}
|
||
};
|
||
}
|
||
</script>
|
||
<script src="/static/js/app.js" defer></script>
|
||
{% block scripts %}{% endblock %}
|
||
</body>
|
||
</html>
|