Bug 1: newPageInWorkspace() allait toujours vers local-workspace API → Maintenant détecte workspaceKey contient '/' → Gitea API PUT file Bug 2: workspace_name dans gitea_workspace ne persistait pas → Cookie flowdeck_workspace posé côté serveur au premier chargement Bug 3: icône 📁 fixe dans sidebar — pas d'indication remote → 🔗 affiché quand workspace_key contient '/' (remote), 📁 sinon newFolderInWorkspace() : avertissement si workspace distant (car les dossiers n'existent pas dans un repo Git)
1183 lines
53 KiB
HTML
1183 lines
53 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 — {% block title_prefix %}Home{% endblock %}</title>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
|
<link rel="stylesheet" href="/static/css/design-tokens.css?v=1.0">
|
|
<link rel="stylesheet" href="/static/css/app.css?v=2.4.6">
|
|
<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="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, '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 -->
|
|
<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 workspace_key and '/' in workspace_key %}🔗{% else %}📁{% endif %} {{ active_ws_name or 'Workspace' }}</span>
|
|
</div>
|
|
<div class="sidebar-section-actions">
|
|
<button class="section-action-btn" title="New Page" @click.stop="newPageInWorkspace()">📄</button>
|
|
<button class="section-action-btn" title="New Folder" @click.stop="newFolderInWorkspace()">📁</button>
|
|
</div>
|
|
</div>
|
|
{% macro render_workspace_tree(pages, depth=0) %}
|
|
{% for page in pages %}
|
|
<li class="sidebar-item ws-tree-item"
|
|
data-id="{{ page.db_id }}"
|
|
data-is-folder="{{ 'true' if page.is_folder else 'false' }}"
|
|
data-depth="{{ depth }}"
|
|
:class="{ active: activeNodeId === {{ page.db_id }} }"
|
|
draggable="true"
|
|
@dragstart="dragStart($event, {{ page.db_id }})"
|
|
@dragover.prevent="dragOver($event, {{ page.db_id }}, {{ 'true' if page.is_folder else 'false' }})"
|
|
@dragleave="dragLeave($event)"
|
|
@drop.prevent="drop($event, {{ page.db_id }})"
|
|
style="padding-left:{{ 4 + depth * 12 }}px;"
|
|
@contextmenu.prevent="showWsContext($event, {{ page.db_id }}, '{{ page.name | e }}', {{ 'true' if page.is_folder else 'false' }})"
|
|
@touchstart="wsTouchStart($event)"
|
|
@touchend="wsTouchEnd($event, {{ page.db_id }}, '{{ page.name | e }}', {{ 'true' if page.is_folder else 'false' }})"
|
|
@touchmove="wsTouchMove($event)">
|
|
{% if page.is_folder %}
|
|
<button class="tree-chevron"
|
|
@click.stop="toggleTreeFolder({{ page.db_id }})"
|
|
:class="{ open: expandedFolders[{{ page.db_id }}] }"
|
|
title="Toggle folder">▶</button>
|
|
{% else %}
|
|
<span style="width:12px;flex-shrink:0;"></span>
|
|
{% endif %}
|
|
<span class="page-icon">{{ page.icon }}</span>
|
|
{% if page.is_folder %}
|
|
<span class="page-name tree-folder-link"
|
|
@click.stop="navigateToFolder({{ page.db_id }})"
|
|
draggable="false"
|
|
title="Open folder">{{ page.name }}{% if page.child_count %} <span class="child-count">({{ page.child_count }})</span>{% endif %}</span>
|
|
{% else %}
|
|
<a href="/pages/{{ page.db_id }}" class="page-name" draggable="false">{{ page.name }}</a>
|
|
{% endif %}
|
|
</li>
|
|
{% if page.children %}
|
|
<li x-show="expandedFolders[{{ page.db_id }}]" x-transition style="list-style:none;margin:0;padding:0;">
|
|
<ul style="list-style:none;margin:0;padding:0;">
|
|
{{ render_workspace_tree(page.children, depth + 1) }}
|
|
</ul>
|
|
</li>
|
|
{% endif %}
|
|
{% endfor %}
|
|
{% endmacro %}
|
|
<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>
|
|
|
|
<!-- 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 class="sidebar-section-actions">
|
|
<a href="/library?tab=Recents" class="library-link-btn" title="View in Library" @click.stop>→ Library</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>→ Library</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>→ Library</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>→ Library</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>→ Library</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;" @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>
|
|
</div><!-- /sidebar-scroll -->
|
|
</aside>
|
|
|
|
<!-- Uncollapse button → inside topbar, no overlap -->
|
|
<!-- (Moved into topbar block below) -->
|
|
|
|
<!-- ═══════════ MAIN ═══════════ -->
|
|
<div class="main-wrapper">
|
|
|
|
<!-- Topbar -->
|
|
{% block topbar %}
|
|
<header class="topbar">
|
|
<!-- Hamburger / uncollapse sidebar -->
|
|
<button class="hamburger-btn" @click="sidebarCollapsed ? (sidebarCollapsed = false) : (mobileSidebarOpen = true, sidebarCollapsed = false)" title="Menu">
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
|
</button>
|
|
<div class="topbar-left">
|
|
<div class="topbar-breadcrumb">
|
|
<span x-text="workspaceName"></span>
|
|
<span class="text-dim">/</span>
|
|
<span class="text-dim" x-text="currentPage"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="topbar-center">
|
|
<div class="topbar-page-title">
|
|
<span class="page-icon" x-text="pageIcon"></span>
|
|
<span x-text="pageTitle"></span>
|
|
</div>
|
|
<div class="topbar-page-status">
|
|
<span>🔒</span> Private
|
|
<span style="cursor:pointer;">▾</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="topbar-right">
|
|
<span class="text-xs text-dim">Edited <span x-text="lastEdited"></span></span>
|
|
<button class="topbar-btn">🔔</button>
|
|
<button class="topbar-btn">🕒</button>
|
|
<button class="topbar-btn">🔍</button>
|
|
<button class="topbar-btn share-btn">🔗 Share ▾</button>
|
|
<button class="topbar-btn">⭐</button>
|
|
<button class="topbar-btn" @click="showNewPageMenu = !showNewPageMenu" style="position:relative;">
|
|
New ▾
|
|
<div class="dropdown-panel" :class="{ visible: showNewPageMenu }" style="top:100%; right:0; left:auto; min-width:200px;" @click.stop>
|
|
<div class="dropdown-option" @click="createEmptyPage()"><span class="option-icon">📄</span> Empty page</div>
|
|
<div class="dropdown-option" @click="createDatabase()"><span class="option-icon">📊</span> Database</div>
|
|
<div class="dropdown-option" @click="createBoard()"><span class="option-icon">📋</span> Kanban board</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
{% endblock %}
|
|
|
|
<!-- Content -->
|
|
<div class="content-area" id="main-content">
|
|
{% block content %}{% endblock %}
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- ═══════════ CONTEXT MENU ═══════════ -->
|
|
<div class="context-menu" id="context-menu"
|
|
:class="{ visible: contextMenu.visible }"
|
|
:style="'left:' + contextMenu.x + 'px; top:' + contextMenu.y + 'px'"
|
|
@click.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" @click="wsCtxAction('new-file')">
|
|
<span class="menu-icon">📄</span> New File
|
|
</div>
|
|
<div class="context-menu-item" @click="wsCtxAction('new-folder')">
|
|
<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();
|
|
});
|
|
})();
|
|
|
|
function appState() {
|
|
return {
|
|
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 }}',
|
|
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()) {
|
|
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) window.location.reload(); });
|
|
}
|
|
break;
|
|
case 'delete':
|
|
if (!id) break;
|
|
if (!confirm('Delete "' + name + '"?')) break;
|
|
this.deleteWorkspacePage(id);
|
|
break;
|
|
}
|
|
},
|
|
|
|
// ── Favorites tracking ──
|
|
favoriteIds: new Set([{% for p in favorite_pages %}'{{ p.id }}'{% if not loop.last %},{% endif %}{% endfor %}]),
|
|
|
|
// ── Sections collapsible ──
|
|
sectionsOpen: (function() {
|
|
var def = { recents: true, private: true, meetings: true, favorites: true, agents: true, shared: true, published: true, apps: true, workspace: 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;
|
|
// Reload to update sidebar
|
|
location.reload();
|
|
})
|
|
.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'); });
|
|
},
|
|
newPageInWorkspace() {
|
|
const name = prompt('File name:');
|
|
if (!name || !name.trim()) return;
|
|
const wk = this.workspaceKey || '';
|
|
// Gitea/remote workspace: create file in the repo
|
|
if (wk.includes('/')) {
|
|
const parts = wk.split('/');
|
|
fetch('/api/gitea/projects/' + parts[0] + '/' + parts[1] + '/file', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
|
body: JSON.stringify({ path: name.trim() + '.md', content: '# ' + name.trim(), message: 'Create via FlowDeck' })
|
|
})
|
|
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
|
.then(data => { window.location.reload(); })
|
|
.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: folders aren't a concept — just warn
|
|
if (wk.includes('/')) {
|
|
this.toast('Folders are not supported for remote workspaces. Use local workspace.', 'warning');
|
|
return;
|
|
}
|
|
fetch('/api/local-workspace/items', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
|
body: JSON.stringify({ name: name.trim(), type: 'folder' })
|
|
})
|
|
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
|
.then(() => { window.location.reload(); })
|
|
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
|
|
},
|
|
deleteWorkspacePage(pageId) {
|
|
if (!confirm('Delete this page?')) return;
|
|
fetch(`/api/local-workspace/items/${pageId}`, {
|
|
method: 'DELETE',
|
|
headers: { 'X-CSRF-Token': this.getCsrfToken() }
|
|
})
|
|
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
|
.then(() => { window.location.reload(); })
|
|
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
|
|
},
|
|
|
|
// ── 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'); window.location.reload(); })
|
|
.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');
|
|
html.setAttribute('data-theme', current === 'dark' ? 'light' : 'dark');
|
|
localStorage.setItem('fd_theme', current === 'dark' ? 'light' : 'dark');
|
|
if (current === 'dark') {
|
|
// Switching to light
|
|
html.style.setProperty('--bg-primary', '#FFFFFF');
|
|
html.style.setProperty('--bg-secondary', '#F7F6F3');
|
|
html.style.setProperty('--bg-tertiary', '#EDECE9');
|
|
html.style.setProperty('--text', '#37352F');
|
|
html.style.setProperty('--text-secondary', '#6B6966');
|
|
html.style.setProperty('--text-dim', '#9B9A97');
|
|
html.style.setProperty('--border', '#E0DFDC');
|
|
html.style.setProperty('--border-strong', '#CCCBC8');
|
|
} else {
|
|
// Switching to dark — remove overrides, use CSS defaults
|
|
['bg-primary','bg-secondary','bg-tertiary','text','text-secondary','text-dim','border','border-strong'].forEach(function(v) {
|
|
html.style.removeProperty('--'+v);
|
|
});
|
|
}
|
|
},
|
|
initTheme() {
|
|
var saved = localStorage.getItem('fd_theme') || 'dark';
|
|
if (saved === 'light') {
|
|
document.documentElement.setAttribute('data-theme', 'light');
|
|
document.documentElement.style.setProperty('--bg-primary', '#FFFFFF');
|
|
document.documentElement.style.setProperty('--bg-secondary', '#F7F6F3');
|
|
document.documentElement.style.setProperty('--bg-tertiary', '#EDECE9');
|
|
document.documentElement.style.setProperty('--text', '#37352F');
|
|
document.documentElement.style.setProperty('--text-secondary', '#6B6966');
|
|
document.documentElement.style.setProperty('--text-dim', '#9B9A97');
|
|
document.documentElement.style.setProperty('--border', '#E0DFDC');
|
|
document.documentElement.style.setProperty('--border-strong', '#CCCBC8');
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// 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' });
|
|
}, 300);
|
|
});
|
|
|
|
// Apply saved theme on page load (AND on bfcache restore)
|
|
function applySavedTheme() {
|
|
var saved = localStorage.getItem('fd_theme') || 'dark';
|
|
var h = document.documentElement;
|
|
h.setAttribute('data-theme', saved);
|
|
if (saved === 'light') {
|
|
h.style.setProperty('--bg-primary', '#FFFFFF');
|
|
h.style.setProperty('--bg-secondary', '#F7F6F3');
|
|
h.style.setProperty('--bg-tertiary', '#EDECE9');
|
|
h.style.setProperty('--text', '#37352F');
|
|
h.style.setProperty('--text-secondary', '#6B6966');
|
|
h.style.setProperty('--text-dim', '#9B9A97');
|
|
h.style.setProperty('--border', '#E0DFDC');
|
|
h.style.setProperty('--border-strong', '#CCCBC8');
|
|
} else {
|
|
// Remove light overrides, use CSS defaults
|
|
['bg-primary','bg-secondary','bg-tertiary','text','text-secondary','text-dim','border','border-strong'].forEach(function(v) {
|
|
h.style.removeProperty('--'+v);
|
|
});
|
|
}
|
|
}
|
|
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 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();
|
|
if (data && data.newPageInWorkspace) data.newPageInWorkspace();
|
|
else if (data && data.newPage) data.newPage('Private');
|
|
}
|
|
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 %}
|
|
</body>
|
|
</html>
|