- sidebar items: attribut data-page-id ajouté pour ciblage DOM - save(): après PUT réussi, met à jour .page-name dans le sidebar - saveTitle(): reload la page (changement de titre = reload ok)
474 lines
19 KiB
HTML
474 lines
19 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()">
|
|
|
|
<!-- ═══════════ SIDEBAR ═══════════ -->
|
|
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }" id="sidebar">
|
|
|
|
<!-- Workspace header -->
|
|
<div class="sidebar-workspace">
|
|
<div class="workspace-info">
|
|
<div class="workspace-avatar">{{ workspaceInitial }}</div>
|
|
<span class="workspace-name">{{ workspaceName }}</span>
|
|
</div>
|
|
<div class="sidebar-actions">
|
|
<button class="sidebar-action-btn" @click="sidebarCollapsed = !sidebarCollapsed" title="Toggle sidebar">
|
|
«
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick actions -->
|
|
<div class="sidebar-nav">
|
|
<a href="/" class="sidebar-nav-item" :class="{ active: currentView === 'home' }">
|
|
<span class="nav-icon">🏠</span> Home
|
|
</a>
|
|
</div>
|
|
<div class="sidebar-nav" style="gap: 2px; padding-right: 6px;">
|
|
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">💬</button>
|
|
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">🎤</button>
|
|
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">📥</button>
|
|
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">🔍</button>
|
|
</div>
|
|
|
|
<!-- Recents -->
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-section-header">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron open">▶</span> Recents
|
|
</div>
|
|
<div class="sidebar-section-actions">
|
|
<button class="section-action-btn" title="Open in Library" @click="openLibrary('Recents')">📚</button>
|
|
<button class="section-action-btn" title="Add page" @click="newPage('Recents')">+</button>
|
|
</div>
|
|
</div>
|
|
<ul class="sidebar-items">
|
|
{% for page in recent_pages %}
|
|
<li class="sidebar-item {% if page.active %}active{% endif %} {% if page.indent %}indent-{{ page.indent }}{% endif %}"
|
|
@click="navigateTo('{{ page.url }}')"
|
|
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')"
|
|
data-page-id="{{ page.id }}">
|
|
<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>
|
|
{% endfor %}
|
|
{% if not recent_pages %}
|
|
<li class="sidebar-item">
|
|
<span class="page-icon">📄</span>
|
|
<span class="page-name text-dim">No recent pages</span>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Favorites: Private -->
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-section-header">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron open">▶</span> Private
|
|
</div>
|
|
<div class="sidebar-section-actions">
|
|
<button class="section-action-btn" title="Open in Library" @click="openLibrary('Private')">📚</button>
|
|
<button class="section-action-btn" title="Add page" @click="newPage('Private')">+</button>
|
|
</div>
|
|
</div>
|
|
<ul class="sidebar-items">
|
|
{% for page in private_pages %}
|
|
<li class="sidebar-item {% if page.active %}active{% endif %} {% if page.indent %}indent-{{ page.indent }}{% endif %}"
|
|
@click="navigateTo('{{ page.url }}')"
|
|
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')"
|
|
data-page-id="{{ page.id }}">
|
|
<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>
|
|
{% endfor %}
|
|
{% if not private_pages %}
|
|
<li class="sidebar-item">
|
|
<span class="page-icon">📄</span>
|
|
<span class="page-name text-dim">No pages yet</span>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Notion apps -->
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-section-header">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron open">▶</span> Notion apps
|
|
</div>
|
|
</div>
|
|
<ul class="sidebar-items">
|
|
<li class="sidebar-item"><span class="page-icon">📅</span><span class="page-name">Notion Calendar</span></li>
|
|
<li class="sidebar-item"><span class="page-icon">🖥️</span><span class="page-name">Notion Desktop</span></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Favorites -->
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-section-header">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron open">▶</span> Favorites
|
|
</div>
|
|
<div class="sidebar-section-actions">
|
|
<button class="section-action-btn" title="Add page" @click="newPage('Favorites')">+</button>
|
|
</div>
|
|
</div>
|
|
<ul class="sidebar-items">
|
|
{% for page in favorite_pages %}
|
|
<li class="sidebar-item {% if page.active %}active{% endif %}"
|
|
@click="navigateTo('{{ page.url }}')"
|
|
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')"
|
|
data-page-id="{{ page.id }}">
|
|
<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="newSubPage('{{ page.id }}')">+</button>
|
|
</span>
|
|
</li>
|
|
{% endfor %}
|
|
{% if not favorite_pages %}
|
|
<li class="sidebar-item">
|
|
<span class="page-icon">⭐</span>
|
|
<span class="page-name text-dim">No favorites yet</span>
|
|
</li>
|
|
{% endif %}
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Shared -->
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-section-header">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron">▶</span> Shared
|
|
</div>
|
|
</div>
|
|
<ul class="sidebar-items">
|
|
<li class="sidebar-item">
|
|
<span class="page-icon">👥</span>
|
|
<span class="page-name text-dim">No shared pages</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Library button -->
|
|
<div class="sidebar-nav" style="margin-top: 8px;">
|
|
<a href="/library" class="sidebar-nav-item" @click.prevent="navigateTo('/library')">
|
|
<span class="nav-icon">📚</span> Library
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="sidebar-footer">
|
|
<div class="sidebar-invite" x-show="showInvite" x-transition>
|
|
<div class="sidebar-invite-title">👥 Invite members</div>
|
|
<div class="sidebar-invite-desc">Collaborate with your team.</div>
|
|
<button class="sidebar-invite-close" @click="showInvite = false">✕</button>
|
|
</div>
|
|
<button class="sidebar-new-chat">
|
|
<span>💬 New chat</span>
|
|
<span class="shortcut">Ctrl+O</span>
|
|
</button>
|
|
<div style="display: flex; gap: 4px; margin-top: 4px;">
|
|
{% if user %}
|
|
<a href="/auth/logout" class="sidebar-new-chat" style="flex:1; justify-content:center;">🚪</a>
|
|
{% else %}
|
|
<a href="/auth/login" class="sidebar-new-chat" style="flex:1; justify-content:center;">🔑</a>
|
|
{% endif %}
|
|
<button class="sidebar-new-chat" 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> Remove from Favorites
|
|
</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,
|
|
|
|
navigateTo(url) {
|
|
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();
|
|
|
|
switch(action) {
|
|
case 'favorite':
|
|
alert(`Toggled favorite for: ${pageName}`);
|
|
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(`Delete "${pageName}"?`)) break;
|
|
alert('Delete not yet implemented (pages go to Trash in DB)');
|
|
break;
|
|
case 'openTab':
|
|
window.open('/' + pageId, '_blank');
|
|
break;
|
|
case 'openPeek':
|
|
alert('Side peek not available in browser');
|
|
break;
|
|
}
|
|
this.contextMenu.visible = false;
|
|
},
|
|
|
|
openLibrary(id) {
|
|
window.location.href = '/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?title=New%20page§ion=${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 || '';
|
|
fetch(`/board/api/pages?title=New%20page§ion=Private&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 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>
|