1396 lines
64 KiB
HTML
1396 lines
64 KiB
HTML
<!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="stylesheet" href="/static/css/app.css?v=3.0.0">
|
|
<script src="/static/js/htmx.min.js"></script>
|
|
<script src="/static/js/alpine.min.js" defer></script>
|
|
<script src="/static/js/sortable.min.js" defer></script>
|
|
</head>
|
|
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'>
|
|
<div class="app-layout" x-data="appState()">
|
|
|
|
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
|
<div class="sidebar-overlay"
|
|
:class="{ visible: mobileSidebarOpen }"
|
|
@click="mobileSidebarOpen = false, sidebarCollapsed = 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">{{ 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>
|
|
{% 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, 'mobile-open': mobileSidebarOpen }"
|
|
id="sidebar"
|
|
x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
|
|
<!-- 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="sidebarCollapsed = !sidebarCollapsed" title="Collapse sidebar">
|
|
<svg 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>
|
|
</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">⚙️ Settings</a>
|
|
<a href="/workspaces" class="um-item" @click="workspaceMenuOpen=false">🔄 Switch workspace</a>
|
|
<div class="um-sep"></div>
|
|
<a href="/auth/logout" class="um-item um-danger" @click="workspaceMenuOpen=false">🚪 Log out</a>
|
|
</div>
|
|
</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>
|
|
</div>
|
|
|
|
<div class="sidebar-scroll">
|
|
<!-- Workspace / Private -->
|
|
<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>{% if gitea_workspace %}📁 {{ active_ws_name }}{% elif workspace_key and '/' in workspace_key %}🔗{% else %}📁{% endif %} {{ active_ws_name if not gitea_workspace else '' }}</span>
|
|
</div>
|
|
<div class="sidebar-section-actions">
|
|
<button class="section-action-btn" title="New Page" @click.stop="window.FlowDeck.createPage()">📄</button>
|
|
<button class="section-action-btn" title="New Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">📁</button>
|
|
</div>
|
|
</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 workspace_pages %}
|
|
{{ render_workspace_tree(workspace_pages) }}
|
|
{% else %}
|
|
<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>
|
|
|
|
<!-- Repository (Gitea) — only visible for Gitea workspaces -->
|
|
{% if gitea_workspace %}
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-section-header" @click="toggleSection('gitea')">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron" :class="{ open: sectionsOpen.gitea }" x-text="sectionsOpen.gitea ? '▼' : '▶'"></span>
|
|
<span>🔗 Repository (Gitea)</span>
|
|
</div>
|
|
<div class="sidebar-section-actions">
|
|
<button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">🔄</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">🔗</span><span class="page-name text-dim">Loading...</span></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- 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="window.FlowDeck.createPage()">+</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 class="sidebar-section-actions">
|
|
<a href="/library?tab=Recents" class="library-link-btn" title="View in Library" @click.stop>📚</a>
|
|
</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">
|
|
<a href="/library?tab=Favorites" class="library-link-btn" title="View in Library" @click.stop>📚</a>
|
|
</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">
|
|
<a href="/library?tab=Shared" class="library-link-btn" title="View in Library" @click.stop>📚</a>
|
|
</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>
|
|
|
|
<!-- Published -->
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-section-header" @click="toggleSection('published')">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron" :class="{ open: sectionsOpen.published }" x-text="sectionsOpen.published ? '▼' : '▶'"></span>
|
|
<span>Published</span>
|
|
</div>
|
|
<div class="sidebar-section-actions">
|
|
<a href="/library?tab=Published" class="library-link-btn" title="View in Library" @click.stop>📚</a>
|
|
</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">🌐</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">
|
|
<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">
|
|
<a href="/library?tab=Private" class="library-link-btn" title="View in Library" @click.stop>📚</a>
|
|
</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>
|
|
{% endif %}
|
|
|
|
<!-- 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>
|
|
{% if auth_method == 'local' or (workspace_pages and workspace_pages|length > 0) %}
|
|
<li class="sidebar-item" @click="navigateTo('/trash')"><span class="page-icon">🗑️</span><span class="page-name">Trash</span></li>
|
|
{% endif %}
|
|
<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;" 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>
|
|
</div>
|
|
</div>
|
|
</div><!-- /sidebar-scroll -->
|
|
</aside>
|
|
|
|
<!-- 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 %}
|
|
</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">👁️</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>
|
|
|
|
<!-- ═══════════ 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">📄</span> New File
|
|
</div>
|
|
<div class="context-menu-item" onclick="window.FlowDeck.showCreateFolderModal()">
|
|
<span class="menu-icon">📁</span> New Folder
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="context-menu-section">
|
|
<div class="context-menu-item" @click="wsCtxAction('rename')">
|
|
<span class="menu-icon">✏️</span> Rename
|
|
</div>
|
|
<div class="context-menu-item" @click="wsCtxAction('delete')">
|
|
<span class="menu-icon">🗑</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">×</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>
|
|
|
|
</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();
|
|
});
|
|
})();
|
|
|
|
// ── Global FlowDeck namespace ────────────────────────────────
|
|
// All "New Page" and "New Folder" buttons across the app call
|
|
// these functions for consistent behaviour.
|
|
window.FlowDeck = {
|
|
getCsrfToken: function() {
|
|
var m = document.cookie.match(/csrf_token=([^;]+)/);
|
|
return m ? m[1] : '';
|
|
},
|
|
|
|
/** 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 };
|
|
},
|
|
|
|
/** 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/file in the correct workspace context.
|
|
* - Local workspace active → workspace item (prompts for name)
|
|
* - Gitea workspace active → workspace item with workspace_id (prompts for name)
|
|
* - No workspace → general Notion page (no prompt, title "New page")
|
|
*/
|
|
createPage: function() {
|
|
var ctx = this._getContext();
|
|
var self = this;
|
|
|
|
// ── Workspace context → create workspace item ──
|
|
if (ctx.wsId > 0) {
|
|
var body = { name: 'Untitled', type: 'page' };
|
|
if (ctx.isGitea) body.workspace_id = ctx.wsId;
|
|
|
|
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(data) {
|
|
window.location.href = '/pages/' + data.id;
|
|
})
|
|
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
|
|
return;
|
|
}
|
|
|
|
// ── No workspace → general Notion page ──
|
|
var project = ctx.workspaceKey;
|
|
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
|
|
fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-Token': this.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'); });
|
|
},
|
|
|
|
/** Show the global New Folder modal. */
|
|
showCreateFolderModal: function() {
|
|
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;
|
|
|
|
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);
|
|
});
|
|
}
|
|
};
|
|
|
|
function appState() {
|
|
return {
|
|
init() {
|
|
window.appState = this;
|
|
},
|
|
sidebarCollapsed: false,
|
|
mobileSidebarOpen: false,
|
|
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 %}📁{% 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) {
|
|
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">⭐</span><span class="page-name text-dim">No favorites yet</span></li>';
|
|
} else {
|
|
favList.innerHTML = items.map(function(item) {
|
|
const icon = item.icon || '📄';
|
|
const name = (item.title || 'Untitled');
|
|
const safeName = name.replace(/'/g, "\\'").replace(/"/g, '"');
|
|
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, 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) {}
|
|
},
|
|
workspaceMenuOpen: false,
|
|
toggleWorkspaceMenu() {
|
|
this.workspaceMenuOpen = !this.workspaceMenuOpen;
|
|
},
|
|
navigateTo(url) {
|
|
this.mobileSidebarOpen = false;
|
|
this.sidebarCollapsed = true;
|
|
window.location.href = url;
|
|
},
|
|
openQuickFind() { this.toast('Quick Find — Ctrl+K', 'info'); },
|
|
|
|
// ── 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;
|
|
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')}§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 => 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() {
|
|
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 => { 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">📄</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' ? '📁' : '📄';
|
|
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">⚠️</span><span class="page-name text-dim">Could not load tree: ' + (err.message || 'Unknown error') + '</span></li>';
|
|
}
|
|
});
|
|
},
|
|
newPageInWorkspace() {
|
|
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() {
|
|
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) {
|
|
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) {}
|
|
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=2.4.6" defer></script>
|
|
{% block scripts %}{% endblock %}
|
|
|
|
<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>📁 New Folder</h3>
|
|
<button class="flowdeck-modal-close" onclick="document.getElementById('fd-global-folder-modal').style.display='none'">×</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>
|
|
|
|
</body>
|
|
</html>
|