Files
flowdeck/app/templates/base.html
T
bruno 99fee56089
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
embed mode: hide header breadcrumb + right actions, show only editor
- Hide .unified-header and .header-actions in embed-mode (not just .topbar)
- Hide .topbar-right specifically (Edited, Share, Copy link, Favorite, ...)
- Set .page-editor-wrapper padding-top to 12px (no header gap)
- Only the document editor/content is visible in the iframe now
2026-07-22 10:21:53 -04:00

1628 lines
84 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">
<style>
/* ── Mobile responsive (v4.0.2) ── */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
width: 280px;
transform: translateX(-100%);
transition: transform .2s ease;
}
.sidebar.mobile-open {
transform: translateX(0);
box-shadow: 4px 0 24px rgba(0,0,0,.5);
}
.sidebar-overlay.visible {
position: fixed;
inset: 0;
background: rgba(0,0,0,.5);
z-index: 99;
}
.main-wrapper {
margin-left: 0 !important;
width: 100%;
}
.unified-header {
padding: 8px 12px;
}
.page-editor-wrapper {
padding: 16px 8px;
}
.page-title-block {
padding: 16px 8px;
}
.blocks-container {
padding: 0 4px;
}
.share-dialog, .more-menu, .activity-popover {
position: fixed !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
width: 92vw;
max-width: 400px;
max-height: 80vh;
overflow-y: auto;
}
.landing-nav {
padding: 12px 16px;
}
.hero {
padding: 40px 16px 30px;
}
.hero h1 {
font-size: 1.8rem;
}
.features {
grid-template-columns: 1fr;
padding: 0 16px 30px;
}
.library-card-grid {
grid-template-columns: 1fr;
}
.view-tabs {
overflow-x: auto;
flex-wrap: nowrap;
}
}
@media (max-width: 480px) {
.hero h1 {
font-size: 1.5rem;
}
.hero p {
font-size: 15px;
}
.btn {
padding: 8px 16px;
font-size: 13px;
}
}
/* Embed mode — hide sidebar + header for peek panels */
body.embed-mode .sidebar { display: none !important; }
body.embed-mode .topbar, body.embed-mode .unified-header { display: none !important; }
body.embed-mode .topbar-right, body.embed-mode .header-actions { display: none !important; }
body.embed-mode .app-layout { margin-left: 0 !important; padding-top: 0 !important; }
body.embed-mode .page-editor-wrapper { padding-top: 12px !important; }
body.embed-mode .sidebar-collapsed .app-layout { margin-left: 0 !important; }
</style>
<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__"}'{% if embed_mode %} class="embed-mode"{% endif %}>
<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">{% if page.icon in ("folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap") %}{{ fd_icon(page.icon,14) }}{% elif page.icon and page.icon|length <= 2 %}{{ fd_icon("file",14) }}{% else %}{{ fd_icon("folder",14) }}{% endif %}</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 %}
{% from '_icons.html' import fd_icon %}
<!-- ═══════════ 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">{{ fd_icon("settings", 16) }} Settings</a>
<a href="/workspaces" class="um-item" @click="workspaceMenuOpen=false">{{ fd_icon("refresh", 16) }} Switch workspace</a>
{% if local_workspaces %}
<div class="um-sep"></div>
<div class="um-section-label">Workspaces</div>
{% for ws in local_workspaces %}
<a href="/local-workspace?ws={{ ws.id }}" class="um-item" @click="workspaceMenuOpen=false"
style="padding-left:24px;font-size:13px;">
<span class="um-ws-dot" style="width:8px;height:8px;border-radius:50%;background:var(--accent);display:inline-block;margin-right:8px;flex-shrink:0;"></span>{{ ws.name }}
{% if active_ws_name == ws.name %}
<span style="margin-left:auto;color:var(--accent);">✓</span>
{% endif %}
</a>
{% endfor %}
{% endif %}
<div class="um-sep"></div>
<div class="um-version">FlowDeck v{{ app_version }}</div>
<div class="um-sep"></div>
<a href="/auth/logout" class="um-item um-danger" @click="workspaceMenuOpen=false">{{ fd_icon("log-out", 16) }} Log out</a>
</div>
</div>
<!-- Navigation icons row -->
<div class="sidebar-nav-row">
<a href="{% if local_workspaces and local_workspaces|length > 0 %}/local-workspace?ws={{ local_workspaces[0].id }}{% else %}/workspaces{% endif %}" 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" style="margin-left:auto;">
<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>
{% if has_active_workspace %}
<span><span style="vertical-align:sub;margin-right:4px;">{{ fd_icon("folder",16) }}</span>{{ active_ws_name if active_ws_name else "" }}{% if gitea_workspace or (workspace_key and "/" in workspace_key) %} {{ fd_icon("link",12) }}{% endif %}</span>
{% else %}
<span><span style="vertical-align:sub;margin-right:4px;">{{ fd_icon("folder",16) }}</span>No workspace open</span>
{% endif %}
</div>
{% if has_active_workspace %}
<div class="sidebar-section-actions">
<button class="section-action-btn" title="New Page" @click.stop="window.FlowDeck.createPage()">{{ fd_icon("file",16) }}</button>
<button class="section-action-btn" title="New Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">{{ fd_icon("folder",16) }}</button>
<a class="section-action-btn" href="/local-workspace" title="Open workspace page" style="text-decoration:none;">{{ fd_icon("external-link",16) }}</a>
</div>
{% endif %}
</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 has_active_workspace %}
{% if workspace_pages %}
{{ render_workspace_tree(workspace_pages) }}
{% else %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("file",16) }}</span><span class="page-name text-dim">No pages yet</span></li>
{% endif %}
{% else %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("folder",16) }}</span><span class="page-name text-dim">Open a workspace to see your files</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 page-icon-svg">{{ fd_icon("settings",16) }}</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>{{ fd_icon("link",14) }} Repository (Gitea)</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">{{ fd_icon("refresh",16) }}</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">{{ fd_icon("link",14) }}</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">
{% if has_active_workspace %}
<button class="section-action-btn" title="Add" @click.stop="window.FlowDeck.createPage()">+</button>
{% endif %}
</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 page-icon-svg">{{ fd_icon("calendar",16) }}</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>{{ fd_icon("book",14) }}</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 page-icon-svg">{{ fd_icon("clock",16) }}</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>{{ fd_icon("book",14) }}</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 page-icon-svg">{{ fd_icon("star",16) }}</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 page-icon-svg">{{ fd_icon("bot",16) }}</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>{{ fd_icon("book",14) }}</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 page-icon-svg">{{ fd_icon("users",16) }}</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>{{ fd_icon("book",14) }}</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 page-icon-svg">{{ fd_icon("globe",16) }}</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>{{ fd_icon("book",14) }}</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 page-icon-svg">{{ fd_icon("file",16) }}</span><span class="page-name text-dim">No pages yet</span></li>
{% endif %}
</ul>
</div>
</div>
{% endif %}
<!-- Notion apps / Utilities → replaced by static links footer (v4.0.2) -->
<!-- Bottom links (always visible, no section wrapper) -->
<div class="sidebar-static-links" style="padding: 4px 12px;">
<a href="/library" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("book",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Library</span>
</a>
<a href="/my-tasks" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("check-square",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">My Tasks</span>
</a>
<a href="/trash" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("trash",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Trash</span>
</a>
<a href="/help" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
<span class="page-icon page-icon-svg">{{ fd_icon("help-circle",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Help</span>
</a>
</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="margin-right:4px;">{{ fd_icon("message-square",14) }}</span>New chat
<span class="shortcut">Ctrl+O</span>
</button>
{% if has_active_workspace %}
<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>
{% endif %}
</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">{{ fd_icon("eye",14) }}</span> Remove from Recents
</div>
</div>
<div class="context-menu-section">
<div class="context-menu-item" @click="contextAction('copyLink')">
<span class="menu-icon">{{ fd_icon("link",14) }}</span> Copy link
</div>
<div class="context-menu-item" @click="contextAction('duplicate')">
<span class="menu-icon">{{ fd_icon("copy",14) }}</span> Duplicate
<span class="menu-shortcut">▶</span>
</div>
<div class="context-menu-item" @click="contextAction('rename')">
<span class="menu-icon">{{ fd_icon("edit",14) }}</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">{{ fd_icon("trash",14) }}</span> Move to Trash
</div>
</div>
<div class="context-menu-section">
<div class="context-menu-item" @click="contextAction('openTab')">
<span class="menu-icon">{{ fd_icon("external-link",14) }}</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">{{ fd_icon("file",14) }}</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">{{ fd_icon("help-circle",14) }}</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">{{ fd_icon("file",14) }}</span> New File
</div>
<div class="context-menu-item" onclick="window.FlowDeck.showCreateFolderModal()">
<span class="menu-icon">{{ fd_icon("folder",14) }}</span> New Folder
</div>
</div>
</template>
<div class="context-menu-section">
<div class="context-menu-item" @click="wsCtxAction('rename')">
<span class="menu-icon">{{ fd_icon("edit",14) }}</span> Rename
</div>
<div class="context-menu-item" @click="wsCtxAction('delete')">
<span class="menu-icon">{{ fd_icon("trash",14) }}</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">&times;</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 SVG icon helper (used by JS-rendered elements) ─────
function getSvgIcon(name, size) {
size = size || 16;
var svgs = {
folder: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
file: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
star: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
trash: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
image: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
download: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
search: '<svg width="'+size+'" height="'+size+'" 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>',
home: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
key: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>',
paperclip: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>',
'external-link': '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>',
save: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>',
upload: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>',
link: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>',
lock: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
globe: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
edit: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
clock: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
users: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
book: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
settings: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
tag: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>',
'bar-chart': '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>',
grid: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>',
'align-left': '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>',
calendar: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
bot: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16.01"/><line x1="16" y1="16" x2="16" y2="16.01"/></svg>',
refresh: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>',
inbox: '<svg width="'+size+'" height="'+size+'" 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 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>',
'help-circle': '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
'check-square': '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>',
plus: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
zap: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
user: '<svg width="'+size+'" height="'+size+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
};
return svgs[name] || '';
}
// ── 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] : '';
},
/** Refresh CSRF token from the server. Returns a promise resolving to the new token. */
refreshCsrfToken: async function() {
try {
var resp = await fetch('/api/csrf-token', { method: 'GET', credentials: 'same-origin' });
var data = await resp.json();
if (data.csrf_token) {
return data.csrf_token;
}
} catch (e) {
console.warn('[FlowDeck] CSRF refresh failed:', e);
}
return null;
},
/** Fetch wrapper that auto-refreshes CSRF token on 403 and retries once. */
csrfFetch: async function(url, options) {
options = options || {};
options.credentials = options.credentials || 'same-origin';
if (!options.headers) options.headers = {};
if (!options.headers['X-CSRF-Token']) {
options.headers['X-CSRF-Token'] = this.getCsrfToken();
}
var response = await fetch(url, options);
// Auto-refresh on CSRF failure and retry once
if (response.status === 403) {
var body = '';
try { body = await response.clone().text(); } catch(e) {}
if (body.indexOf('CSRF') !== -1 || body.indexOf('csrf') !== -1) {
var newToken = await this.refreshCsrfToken();
if (newToken) {
options.headers['X-CSRF-Token'] = newToken;
return fetch(url, options);
}
}
}
return response;
},
/** 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 %}folder{% 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 page-icon-svg">'+getSvgIcon('star',16)+'</span><span class="page-name text-dim">No favorites yet</span></li>';
} else {
favList.innerHTML = items.map(function(item) {
var iconName = item.icon || 'file';
// Map known icon names, anything else (including emojis) → file
var validNames = ['folder','file','star','link','trash','book','home','settings','lock','globe','image','edit','calendar','users','user','search','tag'];
if (validNames.indexOf(iconName) === -1) iconName = 'file';
var icon = '<span class="page-icon-svg">'+getSvgIcon(iconName,14)+'</span>';
const name = (item.title || 'Untitled');
const safeName = name.replace(/'/g, "\\'").replace(/"/g, '&quot;');
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) {
var x = event.clientX, y = event.clientY;
// Prevent menu from overflowing right edge
var viewW = window.innerWidth;
var viewH = window.innerHeight;
var menuW = 210; // max-width ~240, safe estimate
var menuH = 360; // approximate max height
if (x + menuW > viewW) x = viewW - menuW - 8;
if (y + menuH > viewH) y = viewH - menuH - 8;
if (x < 0) x = 8;
if (y < 0) y = 8;
this.contextMenu = {
visible: true,
x: x,
y: y,
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')}&section=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 page-icon-svg">'+getSvgIcon('file',16)+'</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' ? '<span class="page-icon-svg">'+getSvgIcon('folder',14)+'</span>' : '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
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">{{ fd_icon("alert-triangle",14) }}</span><span class="page-name text-dim">Could not load tree: ' + (err.message || 'Unknown error') + '</span></li>';
}
});
},
newPageInWorkspace() {
// Guard: no workspace active — redirect to workspaces page
if (!this.workspaceKey || this.workspaceKey === this.workspaceName) {
if (!this.giteaWorkspace && !this.localWsId) {
this.toast('No workspace open — create one first', 'info');
setTimeout(() => { window.location.href = '/workspaces'; }, 1200);
return;
}
}
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() {
// Guard: no workspace active — redirect to workspaces page
if (!this.workspaceKey || this.workspaceKey === this.workspaceName) {
if (!this.giteaWorkspace && !this.localWsId) {
this.toast('No workspace open — create one first', 'info');
setTimeout(() => { window.location.href = '/workspaces'; }, 1200);
return;
}
}
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>{{ fd_icon("folder",16) }} New Folder</h3>
<button class="flowdeck-modal-close" onclick="document.getElementById('fd-global-folder-modal').style.display='none'">&times;</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>