6 files changed:
- db.py: migrate favorites table FK from collection_pages(id) to pages(id)
- board.py: add favorites API (POST/DELETE /board/api/favorites/{id}, GET list)
- board.py: _sidebar_data() now loads favorite_pages from DB via JOIN
- dashboard.py: library_page loads lib_favorites from DB (not parent_section)
- csrf.py: exclude /board/api/favorites from CSRF checks
- base.html: context menu toggles Add/Remove Favorites based on state
- base.html: favoriteIds Alpine set initialized from server-rendered favorites
- test_app.py: test_favorites_crud rewritten for new page-based favorites API
Favorites now work end-to-end:
- Right-click → Add to Favorites (or Remove if already favorited)
- Sidebar Favorites section shows favorited pages
- Library Favorites tab shows the same pages
- API: POST/DELETE /board/api/favorites/{page_id}, GET /board/api/favorites
620 lines
27 KiB
HTML
620 lines
27 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="/" 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>
|
|
|
|
<!-- 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"><li class="sidebar-item empty-hint"><span class="page-icon">👥</span><span class="page-name text-dim">No shared pages</span></li></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>
|
|
|
|
<!-- ═══════════ MAIN ═══════════ -->
|
|
<div class="main-wrapper">
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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); });
|
|
},
|
|
|
|
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>
|