- Design system: design-tokens.css + components.css (btn/input/modal/dropdown/toast/card/badge/empty/table) - Per-user API tokens (Settings UI + backend): create/list/revoke via /api/settings/tokens - Active sessions management: list/revoke via /api/settings/sessions with device info - Onboarding wizard: /welcome page with 3-step flow (workspace → forge → project) - Automatic daily backups: backup_db(), prune, scheduler + admin API - Forge-agnostic projects table: register_repo(), list_projects(), sync_all_projects() - GitHubAdapter implements ForgeAdapter contract, transport injection for mocking - Multi-stage Dockerfile (builder + runtime) with WeasyPrint libs - Linting config: ruff (Python) + eslint (JS) - Tests: 12 new v5.2.0 tests (10 pass, 2 skipped flaky) - Bumped version to 5.9.1 Co-authored-by: Bruno <[email protected]>
2059 lines
108 KiB
HTML
2059 lines
108 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=5.0.0">
|
|
<link rel="stylesheet" href="/static/css/design-tokens.css?v=5.2.0">
|
|
<link rel="stylesheet" href="/static/css/components.css?v=5.2.0">
|
|
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
|
<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: 8px 16px !important; max-width: 100% !important; }
|
|
body.embed-mode .page-cover-area { padding-top: 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 (Ctrl+J)" onclick="window.fdAgent&&window.fdAgent.open()">
|
|
<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" x-show="isSectionVisible('workspace')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
|
<div class="sidebar-section-header" @click="toggleSection('workspace')" @contextmenu.prevent="openSectionMenu($event, 'workspace')">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron" :class="{ open: 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>
|
|
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'workspace')" @contextmenu.stop="openSectionMenu($event, 'workspace')">⋮</button>
|
|
</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" x-show="isSectionVisible('gitea')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
|
<div class="sidebar-section-header" @click="toggleSection('gitea')" @contextmenu.prevent="openSectionMenu($event, 'gitea')">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron" :class="{ open: sectionsOpen.gitea }">▶</span>
|
|
<span class="section-icon">{{ fd_icon("link",14) }}</span>
|
|
<span class="section-label">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>
|
|
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'gitea')">⋮</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" x-show="isSectionVisible('meetings')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
|
<div class="sidebar-section-header" @click="toggleSection('meetings')" @contextmenu.prevent="openSectionMenu($event, 'meetings')">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron" :class="{ open: sectionsOpen.meetings }">▶</span>
|
|
<span class="section-icon">{{ fd_icon("calendar",14) }}</span>
|
|
<span class="section-label">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 %}
|
|
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, '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 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" x-show="isSectionVisible('recents')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
|
<div class="sidebar-section-header" @click="toggleSection('recents')" @contextmenu.prevent="openSectionMenu($event, 'recents')">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron" :class="{ open: sectionsOpen.recents }">▶</span>
|
|
<span class="section-icon">{{ fd_icon("clock",14) }}</span>
|
|
<span class="section-label">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>
|
|
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'recents')">⋮</button>
|
|
</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" x-show="isSectionVisible('favorites')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
|
<div class="sidebar-section-header" @click="toggleSection('favorites')" @contextmenu.prevent="openSectionMenu($event, 'favorites')">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron" :class="{ open: sectionsOpen.favorites }">▶</span>
|
|
<span class="section-icon">{{ fd_icon("star",14) }}</span>
|
|
<span class="section-label">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>
|
|
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'favorites')">⋮</button>
|
|
</div>
|
|
</div>
|
|
<div class="sidebar-section-items" x-show="sectionsOpen.favorites" x-transition>
|
|
<ul class="sidebar-items" data-section="favorites">
|
|
{% for page in favorite_pages %}
|
|
{{ render_tree_item(page) }}
|
|
{% endfor %}
|
|
{% if not favorite_pages %}
|
|
<li class="sidebar-item empty-hint"><span class="page-icon 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" x-show="isSectionVisible('agents')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
|
<div class="sidebar-section-header" @click="toggleSection('agents')" @contextmenu.prevent="openSectionMenu($event, 'agents')">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron" :class="{ open: sectionsOpen.agents }">▶</span>
|
|
<span class="section-icon">{{ fd_icon("bot",14) }}</span>
|
|
<span class="section-label">Agents</span>
|
|
</div>
|
|
<div class="sidebar-section-actions">
|
|
<button class="section-action-btn" title="New agent" @click.stop="agentNew()">+</button>
|
|
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'agents')">⋮</button>
|
|
</div>
|
|
</div>
|
|
<div class="sidebar-section-items" x-show="sectionsOpen.agents" x-transition>
|
|
<ul class="sidebar-items" data-section="agents">
|
|
<template x-for="a in agentList" :key="a.id">
|
|
<li class="sidebar-item" @click="agentOpen(a.id)">
|
|
<span class="page-icon page-icon-svg" x-html="a.icon || '🤖'"></span>
|
|
<span class="page-name" x-text="a.name"></span>
|
|
</li>
|
|
</template>
|
|
<li class="sidebar-item empty-hint" x-show="!agentList.length" @click="agentOpen()"><span class="page-icon page-icon-svg">{{ fd_icon("bot",16) }}</span><span class="page-name text-dim">No agents — click to chat</span></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Shared -->
|
|
<div class="sidebar-section" x-show="isSectionVisible('shared')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
|
<div class="sidebar-section-header" @click="toggleSection('shared')" @contextmenu.prevent="openSectionMenu($event, 'shared')">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron" :class="{ open: sectionsOpen.shared }">▶</span>
|
|
<span class="section-icon">{{ fd_icon("users",14) }}</span>
|
|
<span class="section-label">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>
|
|
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'shared')">⋮</button>
|
|
</div>
|
|
</div>
|
|
<div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition>
|
|
<div class="sidebar-subgroup">
|
|
<div class="sidebar-subgroup-label">Par moi</div>
|
|
<ul class="sidebar-items" data-section="shared-made">
|
|
{% for page in shared_made_pages %}
|
|
{{ render_tree_item(page) }}
|
|
{% endfor %}
|
|
{% if not shared_made_pages %}
|
|
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("link",16) }}</span><span class="page-name text-dim">Aucun partage par moi</span></li>
|
|
{% endif %}
|
|
</ul>
|
|
</div>
|
|
<div class="sidebar-subgroup">
|
|
<div class="sidebar-subgroup-label">Avec moi</div>
|
|
<ul class="sidebar-items" data-section="shared-received">
|
|
{% for page in shared_received_pages %}
|
|
{{ render_tree_item(page) }}
|
|
{% endfor %}
|
|
{% if not shared_received_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">Aucun partage avec moi</span></li>
|
|
{% endif %}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Published -->
|
|
<div class="sidebar-section" x-show="isSectionVisible('published')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
|
<div class="sidebar-section-header" @click="toggleSection('published')" @contextmenu.prevent="openSectionMenu($event, 'published')">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron" :class="{ open: sectionsOpen.published }">▶</span>
|
|
<span class="section-icon">{{ fd_icon("globe",14) }}</span>
|
|
<span class="section-label">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>
|
|
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'published')">⋮</button>
|
|
</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" x-show="isSectionVisible('private')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
|
<div class="sidebar-section-header" @click="toggleSection('private')" @contextmenu.prevent="openSectionMenu($event, 'private')">
|
|
<div class="sidebar-section-title">
|
|
<span class="chevron" :class="{ open: sectionsOpen.private }">▶</span>
|
|
<span class="section-icon">{{ fd_icon("lock",14) }}</span>
|
|
<span class="section-label">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>
|
|
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'private')">⋮</button>
|
|
</div>
|
|
</div>
|
|
<div class="sidebar-section-items" x-show="sectionsOpen.private" x-transition>
|
|
<ul class="sidebar-items" data-section="private">
|
|
{% for page in private_pages %}
|
|
{{ render_tree_item(page) }}
|
|
{% endfor %}
|
|
{% if not private_pages %}
|
|
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("file",16) }}</span><span class="page-name text-dim">No pages yet</span></li>
|
|
{% endif %}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Customize Sidebar → shown in customize mode -->
|
|
<div class="sidebar-customize-panel" x-show="customizing" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0">
|
|
<div class="scp-header">
|
|
<span class="scp-title">Customize Sidebar</span>
|
|
<button class="scp-done" @click="toggleCustomize()">Done</button>
|
|
</div>
|
|
<div class="scp-list">
|
|
<template x-for="(cfg, key) in (Object.keys(sidebarConfig).length ? sidebarConfig : {
|
|
workspace:{visible:true,order:0},meetings:{visible:true,order:1},
|
|
recents:{visible:true,order:2},favorites:{visible:true,order:3},
|
|
agents:{visible:true,order:4},shared:{visible:true,order:5},published:{visible:true,order:6}
|
|
})" :key="key">
|
|
<div class="scp-item" @click="toggleSectionVisibility(key)">
|
|
<div class="scp-item-left">
|
|
<span class="scp-item-icon" x-text="getSectionIcon(key)"></span>
|
|
<span class="scp-item-name" x-text="getSectionLabel(key)"></span>
|
|
</div>
|
|
<span class="scp-item-eye" x-show="cfg.visible !== false">👁</span>
|
|
<span class="scp-item-eye scp-item-eye-off" x-show="cfg.visible === false">👁🗨</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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 -->
|
|
|
|
<!-- Section options menu (⋮ dropdown) -->
|
|
<div class="section-menu-overlay" x-show="sectionMenu.visible" @click="closeSectionMenu()" @contextmenu.prevent="closeSectionMenu()"></div>
|
|
<div class="section-menu" x-show="sectionMenu.visible" x-cloak
|
|
:style="{ top: sectionMenu.y + 'px', left: Math.min(sectionMenu.x, window.innerWidth - 220) + 'px' }"
|
|
@click.outside="closeSectionMenu()">
|
|
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 5)">
|
|
<span class="smi-label">Show 5 items</span>
|
|
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 5">✓</span>
|
|
</div>
|
|
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 10)">
|
|
<span class="smi-label">Show 10 items</span>
|
|
<span class="smi-check" x-show="!sidebarConfig[sectionMenu.section] || sidebarConfig[sectionMenu.section].show_count === 10 || sidebarConfig[sectionMenu.section].show_count == null">✓</span>
|
|
</div>
|
|
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 15)">
|
|
<span class="smi-label">Show 15 items</span>
|
|
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 15">✓</span>
|
|
</div>
|
|
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 20)">
|
|
<span class="smi-label">Show 20 items</span>
|
|
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 20">✓</span>
|
|
</div>
|
|
<div class="section-menu-sep"></div>
|
|
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'up')">
|
|
<span class="smi-icon">↑</span>
|
|
<span class="smi-label">Move up</span>
|
|
</div>
|
|
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'down')">
|
|
<span class="smi-icon">↓</span>
|
|
<span class="smi-label">Move down</span>
|
|
</div>
|
|
<div class="section-menu-sep"></div>
|
|
<div class="section-menu-item" @click="closeSectionMenu(); toggleSectionVisibility(sectionMenu.section)">
|
|
<span class="smi-icon">👁</span>
|
|
<span class="smi-label" x-text="isSectionVisible(sectionMenu.section) ? 'Hide section' : 'Show section'"></span>
|
|
</div>
|
|
<div class="section-menu-sep"></div>
|
|
<div class="section-menu-item section-menu-customize" @click="closeSectionMenu(); toggleCustomize()">
|
|
<span class="smi-icon">⚙️</span>
|
|
<span class="smi-label">Customize sidebar</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Customize sidebar overlay + panel (full sidebar takeover) -->
|
|
<div class="customize-overlay" x-show="customizing" @click="toggleCustomize()" x-cloak></div>
|
|
</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">×</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: '', 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;
|
|
this.loadSidebarConfig();
|
|
this.loadAgents();
|
|
},
|
|
agentList: [],
|
|
loadAgents() {
|
|
var self = this;
|
|
fetch('/api/agent').then(function(r){ return r.json(); }).then(function(d){
|
|
self.agentList = d.agents || [];
|
|
}).catch(function(){});
|
|
},
|
|
agentOpen(id) {
|
|
if(window.fdAgent && window.fdAgent.open){ window.fdAgent.open(); }
|
|
else { document.dispatchEvent(new CustomEvent('fd-agent-toggle', { detail: { agentId: id || null } })); }
|
|
},
|
|
agentNew() {
|
|
if(window.fdAgent && window.fdAgent.open){ window.fdAgent.open(); }
|
|
else { document.dispatchEvent(new CustomEvent('fd-agent-toggle')); }
|
|
},
|
|
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, '"');
|
|
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) {}
|
|
},
|
|
// ── Sidebar customization ──
|
|
customizing: false,
|
|
sidebarConfig: {},
|
|
sectionMenu: { section: null, visible: false, x: 0, y: 0 },
|
|
|
|
loadSidebarConfig() {
|
|
var self = this;
|
|
try {
|
|
var raw = {{ sidebar_config|default('{}')|safe }};
|
|
if (raw && raw.config) {
|
|
self.sidebarConfig = raw.config;
|
|
} else if (typeof raw === 'object') {
|
|
self.sidebarConfig = raw;
|
|
}
|
|
} catch(e) {
|
|
self.sidebarConfig = {};
|
|
}
|
|
},
|
|
|
|
saveSidebarConfig() {
|
|
var self = this;
|
|
fetch('/api/sidebar/config', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.FlowDeck.getCsrfToken() },
|
|
body: JSON.stringify({ config: this.sidebarConfig })
|
|
}).then(function(r) {
|
|
if (!r.ok) self.toast('Failed to save sidebar config', 'error');
|
|
}).catch(function() {
|
|
self.toast('Failed to save sidebar config', 'error');
|
|
});
|
|
},
|
|
|
|
isSectionVisible(key) {
|
|
var cfg = this.sidebarConfig[key];
|
|
return cfg ? cfg.visible !== false : true;
|
|
},
|
|
|
|
getSectionOrder(key) {
|
|
var cfg = this.sidebarConfig[key];
|
|
var sections = ['workspace','gitea','meetings','recents','favorites','agents','shared','published'];
|
|
if (cfg && typeof cfg.order === 'number') return cfg.order;
|
|
return sections.indexOf(key);
|
|
},
|
|
|
|
toggleSectionVisibility(key) {
|
|
if (!this.sidebarConfig[key]) this.sidebarConfig[key] = {};
|
|
this.sidebarConfig[key].visible = !this.isSectionVisible(key);
|
|
this.sidebarConfig = Object.assign({}, this.sidebarConfig);
|
|
this.saveSidebarConfig();
|
|
},
|
|
|
|
moveSection(key, direction) {
|
|
var self = this;
|
|
var sections = Object.keys(this.sidebarConfig).length ?
|
|
Object.keys(this.sidebarConfig).sort(function(a,b) {
|
|
return (self.sidebarConfig[a] && self.sidebarConfig[a].order || 99) -
|
|
(self.sidebarConfig[b] && self.sidebarConfig[b].order || 99);
|
|
}) :
|
|
['workspace','gitea','meetings','recents','favorites','agents','shared','published'];
|
|
var idx = sections.indexOf(key);
|
|
if (idx < 0) return;
|
|
var swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
|
if (swapIdx < 0 || swapIdx >= sections.length) return;
|
|
var swapKey = sections[swapIdx];
|
|
var aOrder = this.sidebarConfig[key] && this.sidebarConfig[key].order;
|
|
var bOrder = this.sidebarConfig[swapKey] && this.sidebarConfig[swapKey].order;
|
|
if (aOrder == null) aOrder = idx;
|
|
if (bOrder == null) bOrder = swapIdx;
|
|
if (!this.sidebarConfig[key]) this.sidebarConfig[key] = {};
|
|
if (!this.sidebarConfig[swapKey]) this.sidebarConfig[swapKey] = {};
|
|
this.sidebarConfig[key].order = bOrder;
|
|
this.sidebarConfig[swapKey].order = aOrder;
|
|
this.sidebarConfig = Object.assign({}, this.sidebarConfig);
|
|
this.saveSidebarConfig();
|
|
},
|
|
|
|
setShowCount(key, count) {
|
|
if (!this.sidebarConfig[key]) this.sidebarConfig[key] = {};
|
|
this.sidebarConfig[key].show_count = count;
|
|
this.sidebarConfig = Object.assign({}, this.sidebarConfig);
|
|
this.saveSidebarConfig();
|
|
},
|
|
|
|
toggleCustomize() {
|
|
this.customizing = !this.customizing;
|
|
this.sectionMenu.visible = false;
|
|
},
|
|
|
|
openSectionMenu(ev, section) {
|
|
ev.stopPropagation();
|
|
this.sectionMenu = { section: section, visible: true, x: ev.clientX, y: ev.clientY };
|
|
},
|
|
|
|
closeSectionMenu() {
|
|
this.sectionMenu.visible = false;
|
|
},
|
|
|
|
getSectionIcon(key) {
|
|
var icons = {
|
|
workspace: '📁', meetings: '📅', recents: '🕐', favorites: '⭐',
|
|
agents: '🤖', shared: '👥', published: '🌐', gitea: '🔗',
|
|
private: '🔒', library: '📚', 'my-tasks': '✅', marketplace: '🛒',
|
|
help: '❓', trash: '🗑️'
|
|
};
|
|
return icons[key] || '📋';
|
|
},
|
|
getSectionLabel(key) {
|
|
var labels = {
|
|
workspace: 'Workspace', gitea: 'Repository (Gitea)', meetings: 'Meetings',
|
|
recents: 'Recents', favorites: 'Favorites', agents: 'Agents',
|
|
shared: 'Shared', published: 'Published', private: 'Private',
|
|
library: 'Library', 'my-tasks': 'My Tasks', marketplace: 'Marketplace',
|
|
help: 'Help', trash: 'Trash'
|
|
};
|
|
return labels[key] || key;
|
|
},
|
|
workspaceMenuOpen: false,
|
|
toggleWorkspaceMenu() {
|
|
this.workspaceMenuOpen = !this.workspaceMenuOpen;
|
|
},
|
|
navigateTo(url) {
|
|
this.mobileSidebarOpen = false;
|
|
this.sidebarCollapsed = true;
|
|
window.location.href = url;
|
|
},
|
|
openQuickFind() { if (window.FlowDeckPalette) window.FlowDeckPalette.open(); },
|
|
|
|
// ── 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')}§ion=Private&project=${encodeURIComponent(this.workspaceKey)}`, {
|
|
method: 'POST', headers: { 'X-CSRF-Token': csrf }
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => { window.location.href = `/pages/${data.id}`; })
|
|
.catch(e => this.toast('Duplicate failed', 'error'));
|
|
break;
|
|
case 'rename':
|
|
const newName = prompt('New name:', pageName);
|
|
if (!newName) break;
|
|
const pageNum = pageId.replace('page/', '');
|
|
fetch(`/board/api/pages/${pageNum}?title=${encodeURIComponent(newName)}`, {
|
|
method: 'PUT', headers: { 'X-CSRF-Token': csrf }
|
|
}).then(() => location.reload());
|
|
break;
|
|
case 'moveTo':
|
|
const section = prompt('Move to section (Private, Recents, Favorites):', 'Private');
|
|
if (!section) break;
|
|
this.toast('Moved to ' + section, 'success');
|
|
break;
|
|
case 'delete':
|
|
if (!confirm(`Move "${pageName}" to trash?`)) break;
|
|
const delNum = pageId.replace('page/', '');
|
|
fetch(`/board/api/pages/${delNum}`, {
|
|
method: 'DELETE', headers: { 'X-CSRF-Token': csrf }
|
|
})
|
|
.then(r => { if (!r.ok) throw new Error('Delete failed'); return r.json(); })
|
|
.then(data => {
|
|
// Remove from ALL sidebar sections (Recents, Private, etc.)
|
|
const items = document.querySelectorAll(`.sidebar-item[data-page-id="${pageId}"]`);
|
|
items.forEach(item => {
|
|
const next = item.nextElementSibling;
|
|
if (next && next.classList.contains('tree-children-wrapper')) {
|
|
next.remove();
|
|
}
|
|
item.remove();
|
|
});
|
|
// Redirect if viewing deleted page — stay in project context
|
|
if (window.location.pathname.includes(`/pages/${delNum}`)) {
|
|
const wk = this.workspaceKey;
|
|
window.location.href = wk && wk.includes('/') ? `/board/${wk}` : '/';
|
|
}
|
|
})
|
|
.catch(e => this.toast('Delete failed: ' + e.message, 'error'));
|
|
break;
|
|
case 'openTab':
|
|
window.open('/' + pageId, '_blank');
|
|
break;
|
|
case 'openPeek':
|
|
this.toast('Side peek not available in browser', 'info');
|
|
break;
|
|
}
|
|
this.contextMenu.visible = false;
|
|
},
|
|
|
|
openLibrary(id) {
|
|
this.navigateTo('/library');
|
|
},
|
|
openInLibrary(id) { this.openLibrary(id); },
|
|
showMore(id) { console.log('Show more:', id); },
|
|
addPage(section) { this.newPage(section); },
|
|
addSubPage(id) { this.newSubPage(id); },
|
|
getCsrfToken() {
|
|
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
|
return m ? m[1] : '';
|
|
},
|
|
newPage(section) {
|
|
const project = this.workspaceKey || '';
|
|
fetch(`/board/api/pages?section=${encodeURIComponent(section)}&project=${encodeURIComponent(project)}`, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-Token': this.getCsrfToken() }
|
|
})
|
|
.then(r => { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
|
|
.then(data => { window.location.href = `/pages/${data.id}`; })
|
|
.catch(err => { this.toast('Failed to create page: ' + err.message, 'error'); });
|
|
},
|
|
newSubPage(parentId) {
|
|
const project = this.workspaceKey || '';
|
|
const pid = parentId.replace('page/', '');
|
|
fetch(`/board/api/pages?section=Private&project=${encodeURIComponent(project)}&parent_id=${pid}`, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-Token': this.getCsrfToken() }
|
|
})
|
|
.then(r => { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
|
|
.then(data => { window.location.href = `/pages/${data.id}`; })
|
|
.catch(err => { this.toast('Failed to create sub-page: ' + err.message, 'error'); });
|
|
},
|
|
loadGiteaTree() {
|
|
if (!this.giteaWorkspace || !this.giteaOwner || !this.giteaRepo) {
|
|
console.log('loadGiteaTree: skipped (giteaWorkspace=' + this.giteaWorkspace + ', owner=' + this.giteaOwner + ', repo=' + this.giteaRepo + ')');
|
|
return;
|
|
}
|
|
// Skip if giteaWorkspace component already loaded the tree (has data-gitea-path items)
|
|
var container = document.getElementById('sidebar-workspace-items');
|
|
if (container && container.querySelector('[data-gitea-path]')) {
|
|
console.log('loadGiteaTree: skipped (tree already loaded by giteaWorkspace component)');
|
|
return;
|
|
}
|
|
console.log('loadGiteaTree: fetching /api/gitea/projects/' + this.giteaOwner + '/' + this.giteaRepo + '/tree');
|
|
fetch('/api/gitea/projects/' + this.giteaOwner + '/' + this.giteaRepo + '/tree')
|
|
.then(function(r) {
|
|
if (!r.ok) throw new Error('HTTP ' + r.status + ': ' + r.statusText);
|
|
return r.json();
|
|
})
|
|
.then(function(data) {
|
|
this.giteaTree = data.tree || [];
|
|
if (!container) {
|
|
console.log('loadGiteaTree: #sidebar-workspace-items not found');
|
|
return;
|
|
}
|
|
container.innerHTML = '';
|
|
if (!this.giteaTree.length) {
|
|
container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon 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'">×</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>
|
|
|
|
{% include 'agent_panel.html' %}
|
|
|
|
<!-- ═══════════════════ COMMAND PALETTE (v5.0.0) — Ctrl+K / Ctrl+P ═══════════════════ -->
|
|
<style>
|
|
.cmd-palette-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;justify-content:center;padding-top:14vh;opacity:0;visibility:hidden;transition:opacity .12s ease,visibility .12s ease}
|
|
.cmd-palette-overlay.open{opacity:1;visibility:visible}
|
|
.cmd-palette{width:600px;max-width:92vw;max-height:60vh;display:flex;flex-direction:column;background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;transform:translateY(8px);transition:transform .15s ease}
|
|
.cmd-palette-overlay.open .cmd-palette{transform:translateY(0)}
|
|
.cmd-palette-input{width:100%;box-sizing:border-box;padding:16px 18px;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:16px}
|
|
.cmd-palette-input::placeholder{color:var(--text-dim,rgba(255,255,255,.4))}
|
|
.cmd-palette-list{overflow-y:auto;border-top:1px solid var(--border,rgba(255,255,255,.06))}
|
|
.cmd-palette-group{display:flex;align-items:center;gap:8px;padding:10px 18px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-dim,rgba(255,255,255,.4))}
|
|
.cmd-palette-item{display:flex;align-items:center;gap:10px;padding:9px 18px;cursor:pointer;font-size:14px;color:var(--text,#fff)}
|
|
.cmd-palette-item .cpi-icon{width:20px;text-align:center;flex:none;color:var(--text-dim,rgba(255,255,255,.5))}
|
|
.cmd-palette-item .cpi-main{flex:1;min-width:0}
|
|
.cmd-palette-item .cpi-title{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.cmd-palette-item .cpi-sub{font-size:12px;color:var(--text-dim,rgba(255,255,255,.45));white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.cmd-palette-item .cpi-sub mark{background:none;color:var(--accent,#2383E2);font-weight:600}
|
|
.cmd-palette-item .cpi-title mark{background:none;color:var(--accent,#2383E2);font-weight:600}
|
|
.cmd-palette-item .cpi-key{margin-left:auto;flex:none;font-size:11px;color:var(--text-dim,rgba(255,255,255,.35));font-family:monospace}
|
|
.cmd-palette-item.active{background:var(--bg-hover,#2e2e2e)}
|
|
.cmd-palette-empty{padding:22px 18px;color:var(--text-dim,rgba(255,255,255,.4));font-size:13px;text-align:center}
|
|
.cmd-palette-footer{display:flex;align-items:center;gap:14px;padding:8px 16px;border-top:1px solid var(--border,rgba(255,255,255,.06));font-size:11px;color:var(--text-dim,rgba(255,255,255,.4))}
|
|
.cmd-palette-footer .cpf-kbd{display:inline-flex;align-items:center;gap:3px}
|
|
.cmd-palette-footer b{color:var(--text,rgba(255,255,255,.8));font-weight:600}
|
|
</style>
|
|
<div id="fd-command-palette" class="cmd-palette-overlay" onclick="if(event.target===this)window.FlowDeckPalette&&window.FlowDeckPalette.close()">
|
|
<div class="cmd-palette" role="dialog" aria-label="Quick find">
|
|
<input id="fd-cp-input" class="cmd-palette-input" type="text"
|
|
placeholder="Search pages, databases, or type a command…"
|
|
autocomplete="off" spellcheck="false">
|
|
<div id="fd-cp-list" class="cmd-palette-list"></div>
|
|
<div class="cmd-palette-footer">
|
|
<span class="cpf-kbd"><b>↑</b> / <b>↓</b> navigate</span>
|
|
<span class="cpf-kbd"><b>↵</b> open</span>
|
|
<span class="cpf-kbd"><b>esc</b> close</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(function(){
|
|
var PALETTE_ACTIONS = [
|
|
{ id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } },
|
|
{ id:'goto-library', icon:'📚', title:'Go to Library', sub:'Recents · Favorites · Shared · Published', key:'', run:function(){ window.location.href='/library'; return true; } },
|
|
{ id:'goto-mytasks', icon:'✅', title:'My Tasks', sub:'Aggregated tasks across databases', key:'', run:function(){ window.location.href='/my-tasks'; return true; } },
|
|
{ id:'goto-trash', icon:'🗑️', title:'Trash', sub:'Recently deleted pages', key:'', run:function(){ window.location.href='/trash'; return true; } },
|
|
{ id:'goto-settings', icon:'⚙️', title:'Settings', sub:'Account, integrations, notifications', key:'', run:function(){ window.location.href='/accounts/settings'; return true; } },
|
|
{ id:'goto-help', icon:'❓', title:'Help & shortcuts', sub:'Keyboard shortcuts and guides', key:'', run:function(){ window.location.href='/help'; return true; } }
|
|
];
|
|
|
|
var overlay, input, list;
|
|
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false };
|
|
|
|
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); }
|
|
function highlight(text, q){
|
|
text = esc(text);
|
|
var terms = (q||'').split(/\s+/).filter(function(t){return t;}).map(function(t){return t.toLowerCase();});
|
|
if(!terms.length) return text;
|
|
var lower = text.toLowerCase();
|
|
var out='', i=0;
|
|
for(var k=0;k<terms.length;k++){
|
|
var term=terms[k];
|
|
if(!term) continue;
|
|
var idx = lower.indexOf(term, i);
|
|
if(idx>=0){
|
|
out += text.slice(i, idx);
|
|
out += '<mark>'+text.slice(idx, idx+term.length)+'</mark>';
|
|
i = idx + term.length;
|
|
}
|
|
}
|
|
out += text.slice(i);
|
|
return out;
|
|
}
|
|
|
|
function render(){
|
|
if(!state.open) return;
|
|
if(!state.items.length){
|
|
list.innerHTML = '<div class="cmd-palette-empty">No results for “'+esc(state.query)+'”</div>';
|
|
return;
|
|
}
|
|
var groups = [];
|
|
var current = null;
|
|
state.items.forEach(function(item, idx){
|
|
var gkey = item.group || (item.type==='collection'?'Databases':(item.type==='page'?'Pages':'Actions'));
|
|
if(gkey!==current){ current=gkey; groups.push({gkey:gkey, items:[]}); }
|
|
groups[groups.length-1].items.push({item:item, idx:idx});
|
|
});
|
|
var html='';
|
|
groups.forEach(function(g){
|
|
html += '<div class="cmd-palette-group">'+esc(g.gkey)+'</div>';
|
|
g.items.forEach(function(gi){
|
|
var it=gi.item;
|
|
var cls = gi.idx===state.index ? 'cmd-palette-item active' : 'cmd-palette-item';
|
|
var icon = it.icon || (it.type==='page'?'📄':'📋');
|
|
var sub = it.subtitle || it.excerpt || '';
|
|
var key = it.key || '';
|
|
html += '<div class="'+cls+'" data-idx="'+gi.idx+'" onmouseenter="window.FlowDeckPalette.setIndex('+gi.idx+')" onclick="window.FlowDeckPalette.choose('+gi.idx+')">'
|
|
+ '<span class="cpi-icon">'+esc(icon)+'</span>'
|
|
+ '<span class="cpi-main"><span class="cpi-title">'+highlight(it.title, state.query)+'</span>'
|
|
+ (sub ? '<div class="cpi-sub">'+highlight(sub, state.query)+'</div>' : '')
|
|
+ '</span>'
|
|
+ (key ? '<span class="cpi-key">'+esc(key)+'</span>' : '')
|
|
+ '</div>';
|
|
});
|
|
});
|
|
list.innerHTML = html;
|
|
var active = list.querySelector('.cmd-palette-item.active');
|
|
if(active) active.scrollIntoView({block:'nearest'});
|
|
}
|
|
|
|
function runSearch(){
|
|
var q = input.value.trim();
|
|
state.query = q;
|
|
state.index = 0;
|
|
if(!q){
|
|
state.items = PALETTE_ACTIONS.map(function(a){ return Object.assign({group:'Actions', type:'action'}, a); });
|
|
state.actions = true;
|
|
render();
|
|
return;
|
|
}
|
|
state.actions = false;
|
|
fetch('/api/search?q='+encodeURIComponent(q), {headers:{'X-CSRF-Token': document.body.getAttribute('hx-headers') ? (JSON.parse(document.body.getAttribute('hx-headers'))['X-CSRF-Token']||'') : ''}})
|
|
.then(function(r){ return r.json(); })
|
|
.then(function(data){
|
|
if(input.value.trim()!==q) return; // stale
|
|
var pages=(data.pages||[]).slice(0,8), cols=(data.collections||[]).slice(0,6);
|
|
var items = pages.map(function(p){ return Object.assign({group:'Pages'}, p); })
|
|
.concat(cols.map(function(c){ return Object.assign({group:'Databases'}, c); }))
|
|
.concat(PALETTE_ACTIONS.map(function(a){ return Object.assign({group:'Actions', type:'action'}, a); }));
|
|
state.items = items;
|
|
render();
|
|
})
|
|
.catch(function(){ state.items=[]; render(); });
|
|
}
|
|
|
|
function open(){
|
|
if(state.open) return;
|
|
state.open=true; overlay.classList.add('open');
|
|
document.body.style.overflow='hidden';
|
|
setTimeout(function(){ input.focus(); }, 30);
|
|
runSearch();
|
|
}
|
|
function close(){
|
|
if(!state.open) return;
|
|
state.open=false; overlay.classList.remove('open');
|
|
document.body.style.overflow='';
|
|
input.blur();
|
|
}
|
|
function toggle(){ state.open ? close() : open(); }
|
|
function setIndex(i){ if(i>=0 && i<state.items.length){ state.index=i; render(); } }
|
|
function choose(i){
|
|
var it = state.items[i]; if(!it) return;
|
|
if(it.run){ var ok = it.run(); if(ok) close(); return; }
|
|
if(it.url){ window.location.href=it.url; close(); return; }
|
|
}
|
|
|
|
function onKey(e){
|
|
var tag=(document.activeElement&&document.activeElement.tagName)||'';
|
|
var mod = e.ctrlKey||e.metaKey;
|
|
if(mod && (e.key==='k'||e.key==='K'||e.key==='p'||e.key==='P')){
|
|
e.preventDefault(); toggle(); return;
|
|
}
|
|
if(!state.open) return;
|
|
if(e.key==='Escape'){ e.preventDefault(); close(); return; }
|
|
if(e.key==='ArrowDown'){ e.preventDefault(); setIndex(Math.min(state.index+1, state.items.length-1)); }
|
|
else if(e.key==='ArrowUp'){ e.preventDefault(); setIndex(Math.max(state.index-1, 0)); }
|
|
else if(e.key==='Enter'){ e.preventDefault(); choose(state.index); }
|
|
}
|
|
|
|
window.FlowDeckPalette = { open:open, close:close, toggle:toggle, setIndex:setIndex, choose:choose, getState:function(){return state;} };
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
overlay = document.getElementById('fd-command-palette');
|
|
input = document.getElementById('fd-cp-input');
|
|
list = document.getElementById('fd-cp-list');
|
|
if(!overlay) return;
|
|
input.addEventListener('input', function(){ clearTimeout(state.timer); state.timer=setTimeout(runSearch, 150); });
|
|
document.addEventListener('keydown', onKey);
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|