Files
flowdeck/app/templates/local_workspace.html
T

3083 lines
154 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block page_title %}{{ workspace_name }}{% endblock %}
{% block title_prefix %}{{ workspace_name }}{% endblock %}
{% block page_icon %}{{ fd_icon("folder",18) }}{% endblock %}
{% block topbar %}
{% set page_icon = "folder" %}
{% set page_title = workspace_name %}
{% include '_header.html' %}
{% endblock %}
{% block content %}
<span id="fd-local-ws-id" hidden>{{ workspace_id }}</span>
<div x-data="_wsInitData"
@dragover.prevent="onDragOver($event)"
@dragleave="onDragLeave($event)"
@drop.prevent="onDrop($event)"
@keydown="onKeydown($event)">
<style>
/* ── Library-style layout ── */
.ws-container{max-width:1100px;margin:0 auto;padding:20px 24px;}
.ws-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;}
.ws-header h1{font-size:24px;font-weight:700;color:var(--text-primary);margin:0;}
/* ── Tabs row ── */
.ws-tabs-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;padding:4px 0;}
.ws-tabs{display:flex;gap:2px;align-items:center;overflow-x:auto;scrollbar-width:none;}
.ws-tabs::-webkit-scrollbar{display:none;}
.ws-tab{display:flex;align-items:center;gap:6px;padding:6px 12px;font-size:13px;font-weight:500;color:var(--text-secondary);background:transparent;border:none;border-radius:6px;cursor:pointer;transition:all 0.12s;white-space:nowrap;}
.ws-tab:hover{color:var(--text-primary);background:var(--bg-hover);}
.ws-tab.active{color:var(--text-primary);background:var(--bg-active);}
.ws-tab svg{width:14px;height:14px;flex-shrink:0;}
.ws-toolbar{display:flex;align-items:center;gap:2px;flex-shrink:0;}
.ws-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;transition:background 0.1s,color 0.1s;}
.ws-icon-btn:hover,.ws-icon-btn.active{background:var(--bg-hover);color:var(--text-primary);}
.ws-icon-btn svg{width:16px;height:16px;}
.ws-btn-primary{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;font-size:13px;font-weight:500;color:#fff;background:var(--accent);border:none;border-radius:6px;cursor:pointer;transition:background 0.1s;}
.ws-btn-primary:hover{background:var(--accent-hover);}
.ws-btn-primary svg{width:14px;height:14px;}
/* ── Dropdowns ── */
.ws-dropdown{position:absolute;top:calc(100% + 4px);right:0;min-width:210px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:200;padding:6px;}
.ws-dropdown .dd-label{padding:6px 8px 2px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.3px;}
.ws-dropdown .dd-item{padding:6px 8px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;transition:background 0.1s;}
.ws-dropdown .dd-item:hover{background:var(--bg-hover);}
.ws-dropdown .dd-item.active{color:var(--accent);}
.ws-dropdown .dd-item .check{width:14px;flex-shrink:0;color:var(--accent);}
.lib-toolbar-wrap{position:relative;}
/* ── Search bar ── */
.ws-search-bar{margin-bottom:10px;}
.ws-search-bar input{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);color:var(--text-primary);font-size:13px;outline:none;}
.ws-search-bar input:focus{border-color:var(--accent);}
/* ── Multi-select bar ── */
.ws-select-bar{display:flex;align-items:center;gap:4px;padding:6px 10px;background:var(--bg-secondary);border-radius:8px;margin-bottom:8px;font-size:13px;color:var(--text-secondary);min-height:38px;}
.ws-select-bar .sel-count{font-weight:500;color:var(--text-primary);margin-right:8px;white-space:nowrap;}
.ws-select-bar button{background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px 8px;border-radius:6px;font-size:13px;display:flex;align-items:center;gap:6px;transition:all 0.12s;}
.ws-select-bar button:hover{background:var(--bg-hover);color:var(--text-primary);}
.ws-select-bar button svg{width:14px;height:14px;}
/* ── Multi-select "…" dropdown (Library-style) ── */
.ws-select-menu{position:absolute;top:calc(100% + 4px);right:0;min-width:210px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:300;padding:6px;}
.ws-select-menu .menu-item{padding:6px 8px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;transition:background 0.1s;}
.ws-select-menu .menu-item:hover{background:var(--bg-hover);}
.ws-select-menu .menu-item svg{width:14px;height:14px;flex-shrink:0;}
.ws-select-menu .menu-item .shortcut{margin-left:auto;font-size:11px;color:var(--text-dim);}
.ws-select-menu .menu-danger{color:var(--danger);}
.ws-select-menu .more-sep{height:1px;background:var(--border);margin:4px 8px;}
/* ── Move-to modal list items ── */
.move-modal-item{padding:7px 10px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:6px;display:flex;align-items:center;gap:8px;}
.move-modal-item:hover{background:var(--bg-hover);}
/* ── Table (Notion-style rows) ── */
.ws-table{width:100%;}
.ws-table-header{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:0 4px;}
.ws-table-header .th{display:flex;align-items:center;gap:5px;padding:6px 8px;font-size:12px;font-weight:500;color:var(--text-dim);user-select:none;white-space:nowrap;}
.ws-table-header .th svg{width:13px;height:13px;flex-shrink:0;}
/* Column widths */
.ws-col-name{flex:1;min-width:0;}
.ws-col-type{width:80px;flex-shrink:0;}
.ws-col-date{width:110px;flex-shrink:0;justify-content:flex-end;}
/* ── Row ── */
.ws-row{display:flex;align-items:center;height:34px;transition:background 0.1s;position:relative;}
.ws-row:hover{background:var(--bg-hover);}
.ws-row.selected{background:rgba(35,131,226,0.08);}
.ws-row.drag-over{outline:2px solid var(--accent);outline-offset:-2px;background:rgba(35,131,226,0.12);}
.ws-row.dragging{opacity:0.4;}
.ws-row .row-cell{display:flex;align-items:center;padding:0 8px;height:100%;overflow:hidden;}
.ws-row .row-cell.ws-col-date{color:var(--text-secondary);font-size:13px;white-space:nowrap;}
/* ── Name cell ── */
.row-name{display:flex;align-items:center;gap:6px;min-width:0;flex:1;position:relative;}
.row-icon{font-size:15px;flex-shrink:0;width:20px;text-align:center;display:inline-flex;align-items:center;justify-content:center;}
.row-icon svg{width:16px;height:16px;color:var(--text-dim);}
.row-title{font-size:13px;font-weight:400;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;cursor:pointer;}
.row-title:hover{text-decoration:underline;}
.row-title input{width:100%;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;}
/* ── Hover-only elements ── */
.hover-only{opacity:0;transition:opacity 0.12s;flex-shrink:0;}
.ws-row:hover .hover-only,.ws-row.selected .hover-only{opacity:1;}
.ws-tree-item:hover .hover-only,.ws-tree-item.selected .hover-only{opacity:1;}
/* ── Drag handle ── */
.drag-handle{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;cursor:grab;flex-shrink:0;margin-right:2px;}
.drag-handle:active{cursor:grabbing;}
.drag-handle svg{width:14px;height:14px;color:var(--text-dim);opacity:0.75;}
/* ── Checkbox ── */
.ws-checkbox{width:15px;height:15px;border:1.5px solid var(--text-dim);border-radius:3px;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:border-color 0.1s;}
.ws-checkbox:hover{border-color:var(--text-primary);}
.ws-checkbox.checked{background:var(--accent);border-color:var(--accent);opacity:1!important;}
.ws-checkbox.checked svg{width:10px;height:10px;color:#fff;}
/* ── OPEN button ── */
.btn-open{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border:1px solid var(--border);border-radius:4px;font-size:11px;font-weight:500;color:var(--text-primary);background:var(--bg-tertiary);cursor:pointer;white-space:nowrap;transition:all 0.1s;flex-shrink:0;text-decoration:none;margin-left:6px;}
.btn-open:hover{background:var(--bg-active);}
.btn-open svg{width:12px;height:12px;}
/* ── Expand chevron ── */
.tree-chevron{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;background:none;border:none;color:var(--text-dim);cursor:pointer;padding:0;border-radius:3px;transition:transform 0.15s,background 0.1s;}
.tree-chevron:hover{color:var(--text-primary);background:var(--bg-active);}
.tree-chevron svg{width:11px;height:11px;transition:transform 0.15s;}
.tree-chevron.open svg{transform:rotate(90deg);}
/* Indent */
.row-indent{flex-shrink:0;}
/* ── "Add new" row ── */
.add-new-row{padding:8px 12px;color:var(--text-dim);font-size:13px;cursor:pointer;transition:color 0.15s;display:flex;align-items:center;gap:4px;}
.add-new-row:hover{color:var(--text-primary);}
.add-new-row svg{width:13px;height:13px;}
.add-new-under{padding:5px 8px;color:var(--text-dim);font-size:12px;cursor:pointer;transition:color 0.15s;display:flex;align-items:center;gap:4px;}
.add-new-under:hover{color:var(--text-primary);}
.add-new-under svg{width:12px;height:12px;}
/* ── Context menu ── */
.more-menu{position:fixed;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:600;padding:4px;}
.more-menu .menu-item{padding:6px 10px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;transition:background 0.1s;}
.more-menu .menu-item:hover{background:var(--bg-hover);}
.more-menu .menu-item svg{width:14px;height:14px;flex-shrink:0;}
.more-menu .menu-item .shortcut{margin-left:auto;font-size:11px;color:var(--text-dim);}
.more-menu .menu-danger{color:var(--danger);}
.more-sep{height:1px;background:var(--border);margin:4px 8px;}
/* ── Keep existing functional styles ── */
.breadcrumb-row{display:flex;align-items:center;gap:4px;padding:6px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;margin-bottom:10px;flex-wrap:wrap;}
.breadcrumb-row .nav-arrow{width:26px;height:26px;padding:0;font-size:13px;}
.unified-breadcrumb{flex:1;display:flex;align-items:center;gap:2px;min-width:0;font-size:13px;overflow:hidden;}
.unified-breadcrumb .bc-icon{font-size:15px;flex-shrink:0;}
.unified-breadcrumb .bc-root{color:var(--text-secondary);text-decoration:none;white-space:nowrap;font-weight:500;flex-shrink:0;}
.unified-breadcrumb .bc-root:hover{color:var(--accent);}
.unified-breadcrumb .bc-sep{color:var(--text-tertiary);margin:0 2px;flex-shrink:0;}
.unified-breadcrumb a{color:var(--text-secondary);text-decoration:none;white-space:nowrap;}
.unified-breadcrumb a:hover{color:var(--accent);}
.unified-breadcrumb .bc-current{color:var(--text);font-weight:600;}
.bc-edit-trigger{background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:11px;padding:2px 4px;border-radius:3px;flex-shrink:0;opacity:.5;transition:opacity 150ms;}
.bc-edit-trigger:hover{opacity:1;color:var(--accent);background:var(--bg-hover);}
.bc-edit-wrap{display:flex;align-items:center;gap:4px;flex-shrink:0;}
.bc-edit-input{width:200px;background:var(--bg);border:1px solid var(--accent);border-radius:5px;color:var(--text);font-size:12px;padding:3px 8px;outline:none;font-family:'Fira Code',monospace;}
.bc-edit-go{background:var(--accent);color:#fff;border:none;padding:3px 8px;border-radius:4px;cursor:pointer;font-size:12px;}
.bc-edit-go:hover{opacity:.85;}
/* ── Preserved styles ── */
.ws-tree{list-style:none;margin:0;padding:0;}
.ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
.ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
.ws-tree-item .icon{font-size:16px;width:20px;text-align:center;flex-shrink:0;}
.ws-tree-item .name{flex:1;font-size:14px;color:var(--text);}
.ws-tree-item .name.folder{font-weight:500;cursor:pointer;}
.ws-tree-item .name.folder:hover{color:var(--accent);}
.ws-tree-item .name.file{cursor:pointer;}
.ws-tree-item .name.file:hover{color:var(--accent);}
.ws-tree-item .name.file{display:inline-flex;align-items:center;max-width:100%;overflow:hidden;}
.ws-tree-item .name.file .inline-rename-input{flex:0 1 auto;}
.ws-tree-item .name.file .btn-open{flex:0 0 auto;}
.ws-shared-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;opacity:.9;filter:grayscale(1) brightness(1.6);}
.ws-tree-item .actions{display:none;gap:2px;}
.ws-tree-item:hover .actions{display:flex;}
.ws-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;transition:transform 150ms,color 100ms;}
.ws-chevron:hover{color:var(--text);background:var(--bg-hover);}
.ws-chevron.open{transform:rotate(90deg);}
.ws-act{background:none;border:none;color:var(--text-tertiary);cursor:pointer;padding:3px 6px;border-radius:4px;font-size:11px;}
.ws-act:hover{background:var(--bg-hover);color:var(--text);}
.ws-act.danger:hover{color:var(--danger);}
.empty-state{text-align:center;padding:80px 20px;color:var(--text-dim);}
.empty-state .empty-icon{font-size:48px;margin-bottom:12px;opacity:0.5;}
.empty-state h3{font-size:16px;color:var(--text-secondary);margin:0 0 4px;}
.empty-state p{font-size:13px;margin:0;}
.empty-actions{display:flex;gap:10px;justify-content:center;margin-top:16px;}
.btn{padding:10px 20px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
.btn-primary{background:var(--accent);color:#fff;}
.btn-primary:hover{background:var(--accent-hover);}
.btn-secondary{background:var(--bg-tertiary);color:var(--text);border:1px solid var(--border);}
.btn-secondary:hover{background:var(--bg-hover);}
.btn-danger{background:rgba(255,80,80,.1);color:var(--danger);}
.btn-danger:hover{background:rgba(255,80,80,.2);}
.btn-icon{width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:16px;color:var(--text-secondary);transition:all 150ms;}
.btn-icon:hover{background:var(--bg-hover);color:var(--text);border-color:var(--border-strong);}
.btn-icon:disabled{opacity:0.3;cursor:default;}
.btn-icon:disabled:hover{background:var(--bg-tertiary);color:var(--text-secondary);}
/* Recently modified dot */
.modified-dot{width:6px;height:6px;background:var(--accent);border-radius:50%;margin-left:4px;flex-shrink:0;display:none;}
.modified-dot.show{display:block;}
/* Offline banner */
.offline-banner{position:fixed;top:0;left:0;right:0;background:var(--danger);color:#fff;text-align:center;padding:6px 12px;font-size:12px;z-index:500;font-weight:500;}
/* Pinned indicator */
.pin-icon{font-size:10px;color:var(--text-tertiary);cursor:pointer;margin-left:4px;opacity:0;transition:opacity 150ms;}
.ws-tree-item:hover .pin-icon{opacity:1;}
.pin-icon.pinned{opacity:1;color:var(--accent);}
/* "X more" toggle in tree view */
.ws-more-item{cursor:pointer;opacity:.7;transition:opacity 150ms;}
.ws-more-item:hover{opacity:1;background:var(--bg-tertiary);}
/* Multi-select */
.item-checkbox{width:15px;height:15px;flex-shrink:0;cursor:pointer;accent-color:var(--accent);margin:0 4px;}
.ws-tree-item.selected{background:rgba(35,131,226,.1);}
/* Bulk bar */
.bulk-bar{display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--accent);color:#fff;border-radius:8px;margin-bottom:10px;font-size:13px;font-weight:500;}
.bulk-bar .btn-danger{background:rgba(255,255,255,.15);color:#fff;border:1px solid rgba(255,255,255,.3);}
.bulk-bar .btn-secondary{background:rgba(255,255,255,.1);color:#fff;border:1px solid rgba(255,255,255,.2);}
[data-theme="light"] .bulk-bar .btn-danger{background:rgba(0,0,0,.08);color:var(--text-primary);border:1px solid var(--border);}
[data-theme="light"] .bulk-bar .btn-secondary{background:rgba(0,0,0,.05);color:var(--text-primary);border:1px solid var(--border);}
/* Table view */
.ws-table{width:100%;border-collapse:collapse;font-size:13px;}
.ws-table th{text-align:left;padding:6px 12px;color:var(--text-tertiary);font-weight:500;border-bottom:1px solid var(--border);font-size:11px;text-transform:uppercase;letter-spacing:.5px;}
.ws-table td{padding:8px 12px;border-bottom:1px solid var(--border);}
.ws-table tr:hover td{background:var(--bg-hover);}
.ws-table .table-icon{font-size:16px;width:24px;}
.ws-table .table-name{font-weight:500;cursor:pointer;}
.ws-table .table-name:hover{color:var(--accent);}
.ws-table .table-meta{color:var(--text-tertiary);font-size:12px;}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;z-index:999;backdrop-filter:blur(4px);}
.modal-box{background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);padding:28px;width:420px;max-width:90vw;box-shadow:var(--shadow-modal);}
.modal-box h3{font-size:16px;margin-bottom:6px;display:flex;align-items:center;gap:8px;}
.modal-box .modal-sub{font-size:12px;color:var(--text-tertiary);margin-bottom:20px;}
.modal-input{width:100%;padding:12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:10px;color:var(--text);font-size:14px;outline:none;}
.modal-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(35,131,226,.15);}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:20px;}
.delete-confirm{padding:12px;background:rgba(255,80,80,.08);border-radius:8px;margin-top:12px;font-size:13px;color:var(--text-secondary);}
/* Breadcrumb in page */
.breadcrumb-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:20px;font-size:14px;color:var(--text-secondary);}
.breadcrumb-bar a{color:var(--text-secondary);text-decoration:none;padding:2px 6px;border-radius:4px;}
.breadcrumb-bar a:hover{color:var(--text);background:var(--bg-tertiary);}
.breadcrumb-bar .sep{color:var(--text-tertiary);}
.breadcrumb-bar .current{color:var(--text);font-weight:500;}
/* Drag & drop */
.ws-layout.drop-active { outline: 2px dashed var(--accent); outline-offset: -4px; border-radius: 8px; }
/* Drag handle — 6 dots */
.drag-handle{display:flex;flex-wrap:wrap;width:16px;height:16px;cursor:grab;align-content:space-between;justify-content:space-between;flex-shrink:0;opacity:0.45;margin:0 4px;}
.ws-tree-item:hover .drag-handle{opacity:1;}
.drag-handle .dot{width:3px;height:3px;background:var(--text-tertiary);border-radius:50%;}
.drop-overlay{display:none;position:fixed;inset:0;background:rgba(35,131,226,.08);z-index:100;pointer-events:none;align-items:center;justify-content:center;}
.drop-overlay.active{display:flex;}
.drop-overlay .drop-hint{background:var(--bg);border:2px dashed var(--accent);border-radius:16px;padding:40px 60px;text-align:center;color:var(--text);font-size:16px;}
.drop-overlay .drop-hint .icon{font-size:48px;display:block;margin-bottom:12px;}
.drop-target-highlight{outline:2px solid var(--accent)!important;outline-offset:-2px;border-radius:6px;background:rgba(35,131,226,.1)!important;}
.upload-progress{position:fixed;bottom:24px;right:24px;background:var(--bg-modal);border:1px solid var(--border);border-radius:12px;padding:16px 20px;z-index:200;box-shadow:var(--shadow-popover);min-width:200px;}
.upload-progress .up-bar{height:4px;background:var(--bg-tertiary);border-radius:2px;margin-top:8px;overflow:hidden;}
.upload-progress .up-bar-fill{height:100%;background:var(--accent);border-radius:2px;transition:width 200ms;}
/* Search bar */
.search-bar{display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;}
.search-bar:focus-within{border-color:var(--accent);}
.search-icon{font-size:14px;color:var(--text-tertiary);flex-shrink:0;}
.search-input{flex:1;background:none;border:none;color:var(--text);font-size:13px;outline:none;}
.search-input::placeholder{color:var(--text-tertiary);}
.search-clear{background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:16px;padding:2px 4px;border-radius:3px;}
.search-clear:hover{color:var(--text);background:var(--bg-hover);}
/* Filter chips */
.filter-chips{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
.filter-chip{padding:4px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;font-size:12px;color:var(--text-secondary);cursor:pointer;transition:all 150ms;}
.filter-chip:hover{background:var(--bg-hover);color:var(--text);}
.filter-chip.active{background:var(--accent);color:#fff;border-color:var(--accent);}
/* Child count badge */
.child-count{font-size:11px;color:var(--text-tertiary);margin-left:4px;}
/* Inline rename */
.inline-rename-input{background:var(--bg);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;padding:2px 6px;outline:none;min-width:60px;}
/* Drag count badge */
.drag-badge{position:fixed;pointer-events:none;z-index:999;background:var(--accent);color:#fff;font-size:12px;font-weight:700;padding:2px 8px;border-radius:10px;transform:translate(-50%,-50%);}
/* Drop indicator line */
.drop-indicator{position:absolute;left:0;right:0;height:2px;background:var(--accent);border-radius:1px;pointer-events:none;z-index:10;}
/* Sort select */
.sort-select{background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary);font-size:12px;padding:4px 8px;border-radius:6px;cursor:pointer;outline:none;}
.sort-select:focus{border-color:var(--accent);}
/* Undo toast */
.undo-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bg-modal);border:1px solid var(--border);border-radius:12px;padding:10px 20px;z-index:300;box-shadow:var(--shadow-popover);display:flex;align-items:center;gap:12px;font-size:13px;}
.undo-btn{background:var(--accent);color:#fff;border:none;padding:4px 12px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;}
.undo-btn:hover{opacity:.85;}
.undo-dismiss{background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:18px;padding:0 4px;}
/* Smooth expand animation */
.ws-tree[x-show] { transition: max-height 0.25s ease, opacity 0.2s ease; overflow:hidden; }
/* Path bar */
.path-bar{display:flex;align-items:center;gap:6px;margin-bottom:12px;padding:6px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;}
.path-bar:focus-within{border-color:var(--accent);}
.path-icon{font-size:14px;flex-shrink:0;}
.path-input{flex:1;background:none;border:none;color:var(--text);font-size:13px;outline:none;font-family:'Fira Code',monospace;}
.path-input::placeholder{color:var(--text-tertiary);}
.path-go{background:var(--accent);color:#fff;border:none;padding:3px 8px;border-radius:4px;cursor:pointer;font-size:12px;}
.path-go:hover{opacity:.85;}
/* File preview tooltip */
.file-preview{position:fixed;z-index:300;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow-popover);max-width:400px;min-width:200px;pointer-events:auto;}
.file-preview-header{padding:8px 12px;font-size:12px;font-weight:600;color:var(--text-secondary);border-bottom:1px solid var(--border);}
.file-preview-body{padding:10px 12px;margin:0;font-size:12px;line-height:1.5;color:var(--text);max-height:200px;overflow:auto;font-family:'Fira Code','Cascadia Code',monospace;white-space:pre-wrap;word-wrap:break-word;background:var(--bg-secondary);}
/* Tag chips */
.tag-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 8px;border-radius:10px;font-size:11px;color:#fff;white-space:nowrap;margin:1px 2px;transition:all 150ms;}
.tag-chip:hover{filter:brightness(1.15);}
.tag-chip.rm-btn{background:none !important;border:none;color:var(--text-tertiary);cursor:pointer;font-size:13px;line-height:1;padding:0 2px;border-radius:50%;margin-left:2px;}
.tag-chip.rm-btn:hover{color:var(--danger);background:rgba(255,80,80,.15) !important;}
.tag-chip.active{box-shadow:0 0 0 2px var(--accent);}
.tag-add-btn{display:inline-flex;align-items:center;gap:3px;padding:1px 7px;background:none;border:1px dashed var(--border);border-radius:10px;font-size:11px;color:var(--text-tertiary);cursor:pointer;white-space:nowrap;transition:all 150ms;}
.tag-add-btn:hover{border-color:var(--accent);color:var(--accent);}
.tag-input{background:var(--bg);border:1px solid var(--accent);border-radius:8px;color:var(--text);font-size:11px;padding:2px 6px;outline:none;min-width:80px;}
.tag-filter-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:8px;padding:4px 0;}
/* Enhanced table */
.ws-table-enhanced{width:100%;border-collapse:collapse;font-size:13px;margin-top:4px;}
.ws-table-enhanced th{text-align:left;padding:10px 12px;color:var(--text-tertiary);font-weight:500;border-bottom:2px solid var(--border);font-size:11px;text-transform:uppercase;letter-spacing:.5px;position:sticky;top:0;background:var(--bg);z-index:1;}
.ws-table-enhanced td{padding:8px 12px;border-bottom:1px solid var(--border);vertical-align:middle;}
.ws-table-enhanced tr:hover td{background:var(--bg-hover);}
.ws-table-enhanced .col-name{font-weight:500;cursor:pointer;min-width:150px;}
.ws-table-enhanced .col-name:hover{color:var(--accent);}
.ws-table-enhanced .col-path{color:var(--text-tertiary);font-size:12px;font-family:'Fira Code',monospace;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ws-table-enhanced .col-size{color:var(--text-secondary);font-size:12px;white-space:nowrap;}
.ws-table-enhanced .col-date{color:var(--text-tertiary);font-size:12px;white-space:nowrap;}
.ws-table-enhanced .col-type{font-size:12px;white-space:nowrap;}
.ws-table-enhanced .col-author{font-size:12px;color:var(--text-secondary);white-space:nowrap;}
.ws-table-enhanced .col-tags{min-width:120px;}
.ws-table-enhanced .table-icon{font-size:16px;margin-right:6px;vertical-align:-2px;}
/* Table compact mode (inside filter/search) */
.ws-table-compact{width:100%;border-collapse:collapse;font-size:13px;}
.ws-table-compact th{text-align:left;padding:6px 10px;color:var(--text-tertiary);font-weight:500;border-bottom:2px solid var(--border);font-size:11px;text-transform:uppercase;}
.ws-table-compact td{padding:5px 10px;border-bottom:1px solid var(--border);}
.ws-table-compact tr:hover td{background:var(--bg-hover);}
/* Split layout */
.ws-split{display:flex;max-width:100%;margin:0 auto;padding:0 16px 0;gap:0;min-height:calc(100vh - var(--topbar-height));}
.ws-main{flex:1;min-width:0;overflow:auto;padding-right:0;}
.preview-panel + .ws-main{margin-right:0;}
@media(max-width:768px){.ws-split{flex-direction:column;padding:20px 12px 0;}.preview-panel{width:100%;max-width:100%;min-width:100%;}.ws-main{width:100%;}}
/* Breadcrumb row */
.breadcrumb-row{display:flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px 8px 0 0;margin-bottom:0;flex-wrap:wrap;}
.breadcrumb-row .nav-arrow{width:22px;height:22px;padding:0;font-size:13px;}
.toolbar-row{display:flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg-secondary);border:1px solid var(--border);border-top:none;border-radius:0 0 8px 8px;margin-bottom:2px;flex-wrap:wrap;}
.nav-arrow{width:28px;height:28px;font-size:13px;flex-shrink:0;}
.unified-breadcrumb{flex:1;display:flex;align-items:center;gap:2px;min-width:0;font-size:13px;overflow:hidden;}
.unified-breadcrumb .bc-icon{font-size:15px;flex-shrink:0;}
.unified-breadcrumb .bc-root{color:var(--text-secondary);text-decoration:none;white-space:nowrap;font-weight:500;flex-shrink:0;}
.unified-breadcrumb .bc-root:hover{color:var(--accent);}
.unified-breadcrumb .bc-sep{color:var(--text-tertiary);margin:0 2px;flex-shrink:0;}
.unified-breadcrumb a{color:var(--text-secondary);text-decoration:none;white-space:nowrap;}
.unified-breadcrumb a:hover{color:var(--accent);}
.unified-breadcrumb .bc-current{color:var(--text);font-weight:600;}
.bc-edit-trigger{background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:11px;padding:2px 4px;border-radius:3px;flex-shrink:0;opacity:.5;transition:opacity 150ms;}
.bc-edit-trigger:hover{opacity:1;color:var(--accent);background:var(--bg-hover);}
.bc-edit-wrap{display:flex;align-items:center;gap:4px;flex-shrink:0;}
.bc-edit-input{width:200px;background:var(--bg);border:1px solid var(--accent);border-radius:5px;color:var(--text);font-size:12px;padding:3px 8px;outline:none;font-family:'Fira Code',monospace;}
.bc-edit-go{background:var(--accent);color:#fff;border:none;padding:3px 8px;border-radius:4px;cursor:pointer;font-size:12px;}
.bc-edit-go:hover{opacity:.85;}
.unified-actions{display:flex;align-items:center;gap:6px;flex-shrink:0;flex-wrap:wrap;} /* unused, superseded by toolbar-row */
/* Search bar inline (in unified nav) */
.search-bar-inline{display:flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;min-width:140px;}
.search-bar-inline:focus-within{border-color:var(--accent);}
.search-bar-inline .search-icon{font-size:12px;color:var(--text-tertiary);}
.search-bar-inline .search-input{width:80px;background:none;border:none;color:var(--text);font-size:12px;outline:none;}
.search-bar-inline .search-input::placeholder{color:var(--text-tertiary);}
.search-bar-inline .search-clear{background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:14px;padding:0 2px;}
.search-bar-inline .search-clear:hover{color:var(--text);}
/* Compact filter row */
.filter-row{display:flex;align-items:center;gap:4px;padding:2px 0;margin-bottom:4px;flex-wrap:wrap;}
.filter-row .filter-chip{font-size:12px;padding:3px 8px;}
.filter-clear{font-size:11px;color:var(--text-tertiary);cursor:pointer;padding:2px 6px;border-radius:4px;}
.filter-clear:hover{color:var(--danger);background:var(--bg-hover);}
/* View dropdown */
.view-dropdown{position:relative;}
.view-dropdown-btn{display:flex;align-items:center;gap:6px;padding:5px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;cursor:pointer;font-size:12px;color:var(--text);white-space:nowrap;}
.view-dropdown-btn:hover{background:var(--bg-hover);border-color:var(--accent);}
.dropdown-arrow{font-size:10px;color:var(--text-tertiary);}
.view-dropdown-menu{position:absolute;top:100%;left:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-popover);z-index:250;min-width:140px;overflow:hidden;}
.view-menu-item{display:flex;align-items:center;gap:8px;width:100%;padding:8px 12px;background:none;border:none;color:var(--text);font-size:13px;cursor:pointer;text-align:left;}
.view-menu-item:hover{background:var(--bg-hover);}
.view-menu-item.active{background:rgba(35,131,226,.12);color:var(--accent);font-weight:500;}
/* Context menu */
.ctx-item{display:flex;align-items:center;gap:8px;padding:7px 14px;font-size:13px;color:var(--text);cursor:pointer;transition:background 100ms;}
.ctx-item:hover{background:var(--bg-hover);}
.ctx-item.ctx-danger{color:var(--danger);}
.ctx-item.ctx-danger:hover{background:rgba(255,80,80,.1);}
.ctx-sep{height:1px;background:var(--border);margin:3px 0;}
.ctx-tag-item{justify-content:space-between;}
.ctx-tag-add{color:var(--text-tertiary);font-size:12px;padding:4px 14px;cursor:pointer;}
.ctx-tag-add:hover{color:var(--accent);}
.ctx-section{padding:5px 14px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
.ctx-tag-color{width:10px;height:10px;border-radius:50%;flex-shrink:0;}
.color-swatch-ctx{width:100%;padding-bottom:100%;border-radius:5px;cursor:pointer;border:2px solid transparent;transition:all 100ms;box-sizing:border-box;position:relative;}
.color-swatch-ctx:hover{transform:scale(1.15);z-index:1;}
.color-swatch-ctx.selected{border-color:var(--text);box-shadow:0 0 0 2px var(--accent);}
.ctx-add-tag-item:hover{background:rgba(35,131,226,.08);}
.ctx-has-sub{justify-content:space-between;}
/* Old view-selector kept for no conflict, but hidden */
.view-selector{display:none;}
.view-btn{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;border-radius:6px;cursor:pointer;font-size:14px;color:var(--text-tertiary);transition:all 150ms;}
.view-btn:hover{background:var(--bg-hover);color:var(--text);}
.view-btn.active{background:var(--accent);color:#fff;}
/* Title grid */
.title-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px;margin-top:8px;}
.title-card{display:flex;flex-direction:column;align-items:center;padding:16px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:all 150ms;min-height:100px;justify-content:center;}
.title-card:hover{background:var(--bg-hover);border-color:var(--accent);}
.title-card.selected-card{background:rgba(35,131,226,.1);border-color:var(--accent);}
.title-card-icon{font-size:28px;margin-bottom:8px;}
.title-card-name{font-size:13px;font-weight:500;text-align:center;word-break:break-word;max-width:100%;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;}
.title-card-meta{display:flex;gap:8px;margin-top:6px;font-size:11px;color:var(--text-tertiary);}
/* Content list view */
.content-list{display:flex;flex-direction:column;gap:4px;margin-top:8px;}
.content-item{padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;cursor:pointer;transition:all 150ms;}
.content-item:hover{background:var(--bg-hover);border-color:var(--accent);}
.content-item.selected-row{background:rgba(35,131,226,.08);border-color:var(--accent);}
.content-item-header{display:flex;align-items:center;gap:8px;font-size:14px;}
.content-item-preview{font-size:12px;color:var(--text-secondary);margin-top:6px;padding-left:24px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:600px;}
.content-item-tags{display:flex;gap:4px;margin-top:6px;padding-left:24px;}
/* Selected row highlight */
.selected-row{background:rgba(35,131,226,.08)!important;}
/* Preview panel — side panel in split layout */
.preview-panel{width:380px;min-width:300px;max-width:45vw;background:var(--bg-secondary);border-left:1px solid var(--border-strong);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;}
.preview-panel-header{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--border);flex-shrink:0;}
.preview-panel-title{font-size:15px;font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.preview-close{background:none;border:none;color:var(--text-tertiary);font-size:20px;cursor:pointer;padding:2px 6px;border-radius:4px;line-height:1;}
.preview-close:hover{color:var(--text);background:var(--bg-hover);}
.preview-panel-meta{display:flex;gap:12px;padding:8px 16px;font-size:12px;color:var(--text-tertiary);border-bottom:1px solid var(--border);flex-shrink:0;}
.preview-panel-tags{display:flex;gap:4px;flex-wrap:wrap;padding:8px 16px;border-bottom:1px solid var(--border);flex-shrink:0;}
.preview-panel-body{flex:1;overflow:auto;padding:16px;font-size:13px;line-height:1.6;color:var(--text);}
.preview-panel-body pre{background:var(--bg-tertiary);border-radius:8px;padding:12px;overflow:auto;font-size:12px;font-family:'Fira Code',monospace;white-space:pre-wrap;word-wrap:break-word;}
.preview-panel-body img{max-width:100%;border-radius:8px;}
.preview-panel-actions{display:flex;gap:8px;padding:12px 16px;border-top:1px solid var(--border);flex-shrink:0;}
/* Transitions */
.preview-enter{transition:transform .2s ease,opacity .2s ease;}
.preview-enter-start{transform:translateX(100%);opacity:0;}
.preview-enter-end{transform:translateX(0);opacity:1;}
.preview-leave{transition:transform .15s ease,opacity .15s ease;}
.preview-leave-start{transform:translateX(0);opacity:1;}
.preview-leave-end{transform:translateX(100%);opacity:0;}
</style>
<script data-cfasync="false">
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
window._wsInitData = (function() {
return {
tree:[],
displayTree:[],
breadcrumb: [],
loaded:false,
showCreate:false,
showRename:false,
showDelete:false,
showAll:false,
createType:'file',
newName:'',
parentFolder:null,
target:null,
expanded: (function() {
try { return JSON.parse(localStorage.getItem('fd_ws_expanded') || '{}'); }
catch(e) { return {}; }
})(),
currentFolder: {{ current_folder_id }},
searchQuery: '',
filterType: '',
filterOpen: false,
editingPath: false,
folderStack: [],
// Inline rename
renamingId: null,
renamingName: '',
renameInputRef: null,
// Preview cache
previewCache: {},
// Clipboard for copy/paste
clipboard: [],
// Sort
sortBy: '',
viewMode: localStorage.getItem('fd_default_view') || 'tree',
viewMenuOpen: false,
sortOpen: false,
searchOpen: false,
viewIcons: {tree:'',list:'',details:'',title:'',content:''},
viewLabels: {tree:'Tree',list:'List',details:'Details',title:'Cards',content:'Content'},
// Context menu
ctxMenu: {visible:false,x:0,y:0,node:null,tags:[]},
ctxTagAdding: false,
ctxTagExistingOpen: false,
ctxAvailTags: [],
ctxNewTagColor: '#787774',
ctxTagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
// Tags
workspaceTags: [],
tagFilter: '',
tagInputId: null,
// Preview panel
previewItem: null,
previewPanelContent: '',
// Multi-select
selectedIds: {},
selectedIdsCount: 0,
lastSelectedId: null,
allSelected: false,
moreOpen: false,
// Move-to modal
moveModalOpen: false,
moveIds: [],
moveSearchQuery: '',
moveCandidates: [],
_moveAllCandidates: [],
// Undo
undoVisible: false,
undoMessage: '',
undoItemId: null,
undoTimeout: null,
// Upload tracking
uploadSpeed: '',
uploadStartTime: 0,
// Path bar
pathValue: '/workspace/',
async navigateToRoot() {
this.folderStack = [];
this.navIndex = 0;
this._saveExpandedState();
await this._loadFolder(0);
},
async navigateToBreadcrumb(idx) {
this.folderStack = this.folderStack.slice(0, idx + 1);
this.navIndex = idx;
this._saveExpandedState();
var entry = this.folderStack[idx];
await this._loadFolder(entry ? entry.id : 0);
},
startPathEdit() {
this.editingPath = true;
var self = this;
this.$nextTick(function() {
var inp = self.$refs.pathEdit;
if (inp) { inp.focus(); inp.select(); }
});
},
// Drag indicators
dragCount: 0,
dragOverParentId: null,
// Online/offline
online: navigator.onLine,
pinnedIds: (function() {
try { return JSON.parse(localStorage.getItem('fd_ws_pinned') || '[]'); }
catch(e) { return []; }
})(),
// Navigation history
navHistory: [],
navIndex: -1,
canGoBack: false,
canGoForward: false,
// Preview popup
previewVisible: false,
previewContent: '',
previewName: '',
previewX: 0,
previewY: 0,
// Drag & drop state
isDragging: false,
dropTarget: null,
dropTargetName: '',
uploading: false,
uploadStatus: '',
uploadProgress: 0,
draggedItemId: null,
async init() {
try {
// Expose globally for event handlers (BEFORE await)
window._wsData = this;
var url = '/api/local-workspace/tree';
if (this.currentFolder && this.currentFolder > 0) {
url += '?folder=' + this.currentFolder;
}
var r = await fetch(url);
var d = await r.json();
// Keep tree hierarchical, only roots in displayTree
var roots = d.tree || [];
this._markTree(roots, 0);
this.tree = roots;
this.displayTree = roots;
this.breadcrumb = d.breadcrumb || [];
this.loaded = true;
// Initialize folder stack from breadcrumb
this.folderStack = (this.breadcrumb || []).map(function(b) { return {id: b.id, name: b.name}; });
this.navIndex = this.folderStack.length > 0 ? this.folderStack.length - 1 : 0;
// Expose globally for renderChildren onclick handlers
window._wsData = this;
// Listen for online/offline
var self = this;
window.addEventListener('online', function() { self.online = true; });
window.addEventListener('offline', function() { self.online = false; });
// Load workspace tags
this.loadWorkspaceTags();
} catch(e) { console.error('Tree load error:', e); this.loaded = true; }
this._recompute();
this._initResizeHandle();
},
_initResizeHandle() {
var panel = document.getElementById('ws-preview-panel');
var handle = document.getElementById('ws-preview-resize-handle');
if (!panel || !handle) return;
var startX, startWidth, dragging = false;
handle.addEventListener('pointerdown', function(e) {
e.preventDefault();
handle.setPointerCapture(e.pointerId);
startX = e.clientX;
startWidth = panel.offsetWidth;
dragging = true;
document.body.style.userSelect = 'none';
});
handle.addEventListener('pointermove', function(e) {
if (!dragging) return;
var deltaX = startX - e.clientX;
var newWidth = Math.max(300, Math.min(window.innerWidth * 0.9, startWidth + deltaX));
panel.style.width = newWidth + 'px';
});
handle.addEventListener('pointerup', function(e) {
if (!dragging) return;
document.body.style.userSelect = '';
var moved = Math.abs(e.clientX - startX);
if (moved < 3) {
window._wsData.closePreviewPanel();
}
dragging = false;
try { localStorage.setItem('fd_peek_width', panel.style.width); } catch(ex) {}
handle.releasePointerCapture(e.pointerId);
});
try {
var saved = localStorage.getItem('fd_peek_width');
if (saved) panel.style.width = saved;
} catch(ex) {}
},
_recompute() {
// Recompute all former getters as plain properties
this.canGoBack = this.navIndex > 0;
this.canGoForward = this.navIndex < this.folderStack.length - 1;
this.pathValue = '/workspace/' + this.folderStack.filter(function(b){return b.id > 0}).map(function(b){return b.name}).join('/');
// flatTree
var ft = [];
this._flattenTree(this.tree, ft);
this.flatTree = ft;
// filteredTableItems
this._computeFilteredTableItems();
this._syncSelection();
},
// ── Pin/Favorite items ──
togglePin(id) {
var idx = this.pinnedIds.indexOf(id);
if (idx === -1) this.pinnedIds.push(id);
else this.pinnedIds.splice(idx, 1);
try { localStorage.setItem('fd_ws_pinned', JSON.stringify(this.pinnedIds)); }
catch(e) {}
this._haptic();
},
isPinned(id) {
return this.pinnedIds.indexOf(id) !== -1;
},
// ── Multi-select ──
toggleSelect(node, ev) {
var e = ev || {};
if (e.shiftKey && this.lastSelectedId) {
// Range select
var allIds = this._collectVisibleIds(this.displayTree);
var start = allIds.indexOf(this.lastSelectedId);
var end = allIds.indexOf(node.id);
if (start !== -1 && end !== -1) {
var from = Math.min(start, end), to = Math.max(start, end);
for (var i = from; i <= to; i++) this.selectedIds[allIds[i]] = true;
}
} else if (e.ctrlKey || e.metaKey) {
// Toggle single, keep others
if (!!this.selectedIds[node.id]) delete this.selectedIds[node.id];
else this.selectedIds[node.id] = true;
} else {
// Notion-style: toggle this item, keep the rest of the selection
if (!!this.selectedIds[node.id]) delete this.selectedIds[node.id];
else this.selectedIds[node.id] = true;
}
this.lastSelectedId = node.id;
// Force reactivity
this.selectedIds = Object.assign({}, this.selectedIds);
this._syncSelection();
},
_collectVisibleIds(nodes) {
var ids = [];
for (var i=0; i<nodes.length; i++) {
ids.push(nodes[i].id);
if (nodes[i].children) ids = ids.concat(this._collectVisibleIds(nodes[i].children));
}
return ids;
},
async bulkDelete() {
var ids = Object.keys(this.selectedIds);
if (ids.length === 0) return;
if (!confirm('Delete ' + ids.length + ' selected items?')) return;
var self = this;
// Soft-delete all selected items
for (var i=0; i<ids.length; i++) {
await fetch('/api/local-workspace/items/' + ids[i], { method: 'DELETE' });
}
this.clearSelection();
this._reloadAfterAction();
// Show undo banner for bulk delete
this.undoItemId = ids.join(',');
this.undoMessage = 'Deleted ' + ids.length + ' item(s)';
this.undoVisible = true;
this.undoTimeout = setTimeout(function() {
self.undoVisible = false;
self.undoItemId = null;
}, 5000);
},
clearSelection() {
for (var k in this.selectedIds) delete this.selectedIds[k];
this.selectedIds = {};
this.moreOpen = false;
this._syncSelection();
},
selectAll(ev) {
// Toggle: select / unselect all currently visible items
if (this.allSelected) {
for (var k in this.selectedIds) delete this.selectedIds[k];
} else {
var all = this._collectVisibleIds(this.displayTree);
for (var i=0; i<all.length; i++) this.selectedIds[all[i]] = true;
}
this.selectedIds = Object.assign({}, this.selectedIds);
this._syncSelection();
},
// ── Table view data ──
flatTree: [],
_flattenTree(nodes, out) {
for (var i=0; i<nodes.length; i++) {
out.push(nodes[i]);
if (nodes[i].children) this._flattenTree(nodes[i].children, out);
}
},
// ── Filtered table items (for enhanced filter/search table) ──
filteredTableItems: [],
_computeFilteredTableItems() {
var self = this;
var q = (this.searchQuery || '').toLowerCase().trim();
var ft = this.filterType || '';
var tf = this.tagFilter || '';
// If no filter active, return empty (show regular views)
if (!q && !ft && !tf) { this.filteredTableItems = []; return; }
// Flatten entire tree
var all = [];
this._flattenTree(this.tree, all);
// Build path for each item
for (var i=0; i<all.length; i++) {
all[i]._path = self._getItemPath(all[i].id);
}
// Filter
this.filteredTableItems = all.filter(function(n) {
var nameMatch = !q || (n.name || '').toLowerCase().indexOf(q) !== -1;
var typeMatch = true;
if (ft) {
switch(ft) {
case 'folder': typeMatch = n.is_folder; break;
case 'pdf': typeMatch = /\.pdf$/.test((n.name||'').toLowerCase()); break;
case 'image': typeMatch = /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test((n.name||'').toLowerCase()); break;
case 'code': typeMatch = /\.(py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql)$/.test((n.name||'').toLowerCase()); break;
case 'text': typeMatch = /\.(txt|md|markdown|log)$/.test((n.name||'').toLowerCase()); break;
case 'page': typeMatch = !n.is_folder && !/\.(pdf|png|jpe?g|gif|webp|svg|bmp|ico|py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql|txt|md|markdown|log)$/.test((n.name||'').toLowerCase()); break;
}
}
var tagMatch = true;
if (tf) {
tagMatch = (n.tags || []).some(function(t) { return t.name === tf; });
}
return nameMatch && typeMatch && tagMatch;
});
},
_getItemPath(id) {
// Build folder path by walking up parent chain via tree
var path = [];
var found = this._findInTree(this.tree, id);
if (!found) return '—';
return '/' + found.path.reverse().join('/');
},
_findInTree(nodes, targetId, currentPath) {
if (!currentPath) currentPath = [];
for (var i=0; i<nodes.length; i++) {
var n = nodes[i];
if (n.id === targetId) {
return {node: n, path: currentPath.concat([n.name])};
}
if (n.children && n.children.length > 0) {
var r = this._findInTree(n.children, targetId, currentPath.concat([n.name]));
if (r) return r;
}
}
return null;
},
// ── Preview panel (loads editor in iframe via /pages/{id}?embed=1) ──
selectForPreview(node) {
var panel = document.getElementById('ws-preview-panel');
if (!panel) return;
document.getElementById('ws-preview-icon').innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format);
document.getElementById('ws-preview-title').textContent = node.name;
var openBtn = document.getElementById('ws-preview-open-btn');
openBtn.onclick = function() { window.location = '/pages/' + node.id; };
var favBtn = document.getElementById('ws-preview-fav-btn');
favBtn.onclick = function() { window.showToast('Favorite toggled', 'success'); };
var copyBtn = document.getElementById('ws-preview-copy-btn');
copyBtn.onclick = function() {
var url = window.location.origin + '/pages/' + node.id;
navigator.clipboard.writeText(url).then(function() {
window.showToast('Link copied', 'success');
}).catch(function() {
window.showToast('Failed to copy', 'error');
});
};
// Load editor in iframe
var iframe = document.getElementById('ws-preview-iframe');
if (iframe) iframe.src = '/pages/' + node.id + '?embed=1';
// Show panel
panel.style.display = 'flex';
requestAnimationFrame(function() {
requestAnimationFrame(function() {
panel.style.transform = 'translateX(0)';
});
});
},
closePreviewPanel() {
var panel = document.getElementById('ws-preview-panel');
if (!panel) return;
panel.style.transform = 'translateX(100%)';
var self = this;
setTimeout(function() {
panel.style.display = 'none';
self.previewItem = null;
}, 200);
},
async _loadPreviewContentVanilla(node) {
var body = document.getElementById('ws-preview-body');
if (!body) return;
if (node.is_folder) {
body.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-secondary)">'
+ '<span>' + getSvgIcon('folder',32) + '</span>'
+ '<div style="font-size:14px">' + (node.child_count||0) + ' item(s)</div>'
+ '<a href="/local-workspace?folder=' + node.id + '" class="btn btn-secondary" style="display:inline-block;text-decoration:none;margin-top:16px">Open folder</a>'
+ '</div>';
return;
}
try {
var name = (node.name || '').toLowerCase();
// Images: try direct file URL
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(name)) {
body.innerHTML = '<div style="text-align:center;padding:12px;">'
+ '<img src="/api/files/1/' + encodeURIComponent(node.name) + '" style="max-width:100%;max-height:50vh;border-radius:8px;">'
+ '</div>';
return;
}
// PDF: show icon + open link
if (/\.pdf$/.test(name)) {
body.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-secondary);">'
+ '<span style="font-size:48px;display:block;margin-bottom:8px;">' + getSvgIcon('file',32) + '</span>'
+ '<div style="font-size:14px;margin-bottom:4px;">' + this._escHtml(node.name) + '</div>'
+ '<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">' + (node.size_display || '') + '</div>'
+ '<a href="/pages/' + node.id + '" class="btn btn-secondary" style="display:inline-block;text-decoration:none;">Open in viewer</a>'
+ '</div>';
return;
}
// Fetch content from API
var r = await this._fetchRetry('/api/local-workspace/page-content/' + node.id, true);
var content = r.content || '';
var format = r.format || '';
var preview = '';
if (format === 'file') {
// Uploaded file — show metadata
preview = '<div style="text-align:center;padding:30px;color:var(--text-secondary)">'
+ '<div style="font-size:48px;margin-bottom:12px;">' + getSvgIcon('file',32) + '</div>'
+ '<div style="font-size:15px;font-weight:600;margin-bottom:4px;">' + this._escHtml(node.name) + '</div>'
+ '<div style="font-size:13px;color:var(--text-tertiary);margin-bottom:16px;">' + (node.size_display || '') + '</div>'
+ '<a href="/pages/' + node.id + '" class="btn btn-secondary" style="display:inline-block;text-decoration:none;">Open file</a>'
+ '</div>';
} else if (format === 'blocks') {
// FlowDeck internal page — blocks can be array or {blocks:[...]}
try {
var blocks = typeof content === 'string' ? JSON.parse(content) : content;
var blockList = Array.isArray(blocks) ? blocks : (blocks && blocks.blocks ? blocks.blocks : []);
if (blockList.length > 0) {
preview = blockList.slice(0, 50).map(function(b) {
var txt = b.content || b.text || (typeof b === 'string' ? b : '');
var tag = 'div';
var style = 'margin-bottom:6px;line-height:1.5;';
if (b.type && b.type.indexOf('heading') === 0) {
var lvl = parseInt(b.type.split('_')[1]) || 1;
tag = 'h' + Math.min(lvl + 2, 6);
style += 'font-weight:600;';
}
if (b.type === 'code' || b.type === 'code_block') {
tag = 'pre';
style += 'background:var(--bg-tertiary);padding:10px 14px;border-radius:6px;font-family:monospace;font-size:12px;white-space:pre-wrap;';
}
return '<' + tag + ' style="' + style + '">' + (txt ? this._escHtml(txt) : '<br>') + '</' + tag + '>';
}, this).join('');
} else {
preview = '<div style="text-align:center;padding:30px;color:var(--text-tertiary)">Empty document</div>';
}
} catch(e) {
preview = '<pre style="background:var(--bg-tertiary);padding:14px;border-radius:6px;overflow:auto;font-size:12px;font-family:monospace;white-space:pre-wrap;">' + this._escHtml(String(content).substring(0, 3000)) + '</pre>';
}
} else if (format === 'markdown') {
// Markdown — render as preformatted for now
preview = '<div style="white-space:pre-wrap;line-height:1.6;font-size:13px;">' + this._escHtml(String(content).substring(0, 5000)) + '</div>';
} else {
// Unknown format — show as text
var display = String(content || '').trim();
if (display) {
preview = '<pre style="background:var(--bg-tertiary);padding:14px;border-radius:6px;overflow:auto;font-size:12px;font-family:monospace;white-space:pre-wrap;">' + this._escHtml(display.substring(0, 5000)) + '</pre>';
} else {
preview = '<div style="text-align:center;padding:30px;color:var(--text-tertiary)">No content</div>';
}
}
body.innerHTML = preview || '<div style="color:var(--text-tertiary);text-align:center;padding:20px">(empty)</div>';
} catch(e) {
body.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-tertiary)">Preview not available</div>';
}
},
_escHtml(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
},
// ── Content snippet for content view ──
contentSnippetMap: {},
// ── Context menu ──
onContextMenu(ev) {
var el = ev.target.closest('[data-ws-id]');
var menu = document.querySelector('.more-menu');
if (!el || !menu) { if (menu) menu.style.display = 'none'; return; }
var id = parseInt(el.getAttribute('data-ws-id'));
if (!id) { menu.style.display = 'none'; return; }
var node = this._findNodeById(this.displayTree, id);
if (!node) { menu.style.display = 'none'; return; }
// Ensure workspace tags are loaded
if (!this.workspaceTags || this.workspaceTags.length === 0) {
this.loadWorkspaceTags();
}
// Store in Alpine data for menu actions (ctxMenuOpenPage, etc.)
this.ctxMenu = {visible:true, x:ev.clientX, y:ev.clientY, node:node, tags:[...(node.tags||[])]};
this.ctxTagAdding = false;
this.ctxTagExistingOpen = false;
this.ctxNewTagColor = '#787774';
this.ctxAvailTags = (this.workspaceTags || []).filter(function(t) {
return !(node.tags||[]).some(function(nt) { return nt.id === t.id; });
});
// DOM manipulation (bypasses Alpine reactivity issues)
menu.style.display = 'block';
menu.style.left = ev.clientX + 'px';
menu.style.top = ev.clientY + 'px';
// Reposition if offscreen
var rect = menu.getBoundingClientRect();
var x = ev.clientX, y = ev.clientY;
if (x + rect.width > window.innerWidth - 4) x = window.innerWidth - rect.width - 4;
if (y + rect.height > window.innerHeight - 4) y = window.innerHeight - rect.height - 4;
if (x < 4) x = 4;
if (y < 4) y = 4;
menu.style.left = x + 'px';
menu.style.top = y + 'px';
this.ctxMenu.x = x;
this.ctxMenu.y = y;
// Sync to _ctxMenuData for Alpine reactivity
var c = window._ctxMenuData;
c.ctxMenu = this.ctxMenu;
c.ctxTagAdding = this.ctxTagAdding;
c.ctxTagExistingOpen = this.ctxTagExistingOpen;
c.ctxAvailTags = this.ctxAvailTags;
c.ctxNewTagColor = this.ctxNewTagColor;
// Also force Alpine update on the menu element
var menuEl = document.querySelector('.more-menu');
if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) {
var d = menuEl._x_dataStack[0];
d.ctxMenu = this.ctxMenu;
d.ctxTagAdding = this.ctxTagAdding;
d.ctxTagExistingOpen = this.ctxTagExistingOpen;
d.ctxAvailTags = [...this.ctxAvailTags];
d.ctxNewTagColor = this.ctxNewTagColor;
}
},
// ── Long-press (mobile context menu) ──
_touchStartX: 0,
_touchStartY: 0,
_touchStartTime: 0,
_touchMoved: false,
_touchTarget: null,
onTouchStart(ev) {
if (!ev.touches || ev.touches.length !== 1) return;
this._touchStartX = ev.touches[0].clientX;
this._touchStartY = ev.touches[0].clientY;
this._touchStartTime = Date.now();
this._touchMoved = false;
this._touchTarget = ev.target;
},
onTouchMove(ev) {
if (!ev.touches || ev.touches.length !== 1) return;
var dx = Math.abs(ev.touches[0].clientX - this._touchStartX);
var dy = Math.abs(ev.touches[0].clientY - this._touchStartY);
if (dx > 10 || dy > 10) this._touchMoved = true;
},
onTouchEnd(ev) {
if (this._touchMoved) return;
var elapsed = Date.now() - this._touchStartTime;
if (elapsed < 600) return;
// Long press detected — find target element
var el = this._touchTarget;
if (!el) return;
var idEl = el.closest('[data-ws-id]');
if (!idEl) return;
// Prevent default context menu if it hasn't fired yet
ev.preventDefault();
var fakeEv = {
clientX: this._touchStartX,
clientY: this._touchStartY,
target: idEl,
preventDefault: function(){}
};
this.onContextMenu(fakeEv);
},
ctxAddExistingTag(tag) {
this.ctxAddNewTag(tag.name, tag.color);
},
ctxMenuOpenPage() {
if (this.ctxMenu.node) this.openPage(this.ctxMenu.node.id);
document.querySelector('.more-menu').style.display = 'none';
},
ctxMenuOpenFolder() {
if (this.ctxMenu.node) this.navigateToFolder(this.ctxMenu.node.id);
document.querySelector('.more-menu').style.display = 'none';
},
ctxMenuRename() {
if (this.ctxMenu.node) { this.startRename(this.ctxMenu.node); document.querySelector('.more-menu').style.display = 'none'; }
},
ctxMenuDuplicate() {
if (this.ctxMenu.node) { this.duplicateItem(this.ctxMenu.node); document.querySelector('.more-menu').style.display = 'none'; }
},
ctxMenuDelete() {
if (this.ctxMenu.node) { this.deleteWithUndo(this.ctxMenu.node); document.querySelector('.more-menu').style.display = 'none'; }
},
ctxRemoveTag(tagId) {
var node = this.ctxMenu.node;
if (!node) return;
this.removeTag(node.id, tagId);
this.ctxMenu.tags = this.ctxMenu.tags.filter(function(t) { return t.id !== tagId; });
// Refresh available tags
this.ctxAvailTags = (this.workspaceTags || []).filter(function(t) {
return !(this.ctxMenu.tags||[]).some(function(nt) { return nt.id === t.id; });
}.bind(this));
// Sync to _ctxMenuData
window._ctxMenuData.ctxAvailTags = this.ctxAvailTags;
window._ctxMenuData.ctxMenu = this.ctxMenu;
// Also force Alpine update
var menuEl = document.querySelector('.more-menu');
if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) {
menuEl._x_dataStack[0].ctxAvailTags = [...this.ctxAvailTags];
menuEl._x_dataStack[0].ctxMenu = this.ctxMenu;
}
},
async ctxAddNewTag(tagName, color) {
tagName = (tagName || '').trim();
if (!tagName) return;
var node = this.ctxMenu.node;
if (!node) return;
color = color || window._ctxMenuData.ctxNewTagColor;
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({name: tagName.trim(), color: color})
});
if (r.ok) {
var d = await r.json();
this.ctxMenu.tags.push(d.tag);
this._updateNodeTags(this.tree, node.id, d.tag, 'add');
this.displayTree = [...this.tree];
this.loadWorkspaceTags();
// Refresh available tags
var self = this;
setTimeout(function() { self.loadWorkspaceTags().then(function() {
self.ctxAvailTags = (self.workspaceTags || []).filter(function(t) {
return !(self.ctxMenu.tags||[]).some(function(nt) { return nt.id === t.id; });
});
// Sync to _ctxMenuData
window._ctxMenuData.ctxAvailTags = self.ctxAvailTags;
window._ctxMenuData.ctxMenu = self.ctxMenu;
// Also force Alpine update
var menuEl = document.querySelector('.more-menu');
if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) {
menuEl._x_dataStack[0].ctxAvailTags = [...self.ctxAvailTags];
menuEl._x_dataStack[0].ctxMenu = self.ctxMenu;
}
// Close menu after successful add
document.querySelector('.more-menu').style.display = 'none';
}); }, 200);
}
this.ctxTagAdding = false;
},
_markTree(nodes, depth) {
for (var i=0; i<nodes.length; i++) {
var n = nodes[i];
n.depth = depth;
n.db_id = n.id;
n._path = '/' + n.name;
if (n.children && n.children.length > 0) {
this._markTree(n.children, depth + 1);
}
}
},
renderChildren(children, depth) {
if (!children || children.length === 0) return '';
var html = '';
for (var i=0; i<children.length; i++) {
var c = children[i];
var hasKids = c.children && c.children.length > 0;
var icon = this._fileIcon(c.name, c.is_folder, c.content_format);
var escName = this.esc(c.name);
var escId = c.id;
html += '<li><div class="ws-tree-item" style="padding-left:'+(depth*20)+'px" draggable="true"'
+ ' data-folder-id="'+(c.is_folder?c.id:'')+'"'
+ ' data-ws-id="'+escId+'"'
+ ' ondragstart="var d=window._wsData;if(d)d.onInternalDragStart(event,'+escId+')"'
+ ' ondragend="event.target.style.opacity=\'\'">';
// Checkbox
html += '<input type="checkbox" class="item-checkbox"'
+ ' data-ws-id="'+escId+'"'
+ ' onclick="event.stopPropagation();var d=window._wsData;if(d)d.toggleSelect({id:'+escId+',name:\''+escName+'\'},event)"'
+ ' title="Select item">';
// Drag handle
html += '<div class="drag-handle" title="Drag to move">'
+ '<span class="dot"></span><span class="dot"></span>'
+ '<span class="dot"></span><span class="dot"></span>'
+ '<span class="dot"></span><span class="dot"></span></div>';
if (c.is_folder && hasKids) {
html += '<button class="ws-chevron ws-expand-btn" data-ws-id="'+escId+'" onclick="event.stopPropagation();var d=window._wsData;if(d)d.toggleExpand('+escId+',this)">\u25b6</button>';
} else {
html += '<span style="width:16px;flex-shrink:0;"></span>';
}
html += '<span class="icon">'+icon+'</span>';
if (c.is_folder) {
var countBadge = (c.child_count > 0) ? ' <span class="child-count">('+c.child_count+')</span>' : '';
html += '<span class="name folder" onclick="window._wsData.navigateToFolder('+escId+')">'+c.name+countBadge+'</span>';
} else {
// Click to rename, double-click to open
html += '<span class="name file" onclick="event.stopPropagation();window._wsData.startRenderChildRename('+escId+',\''+escName+'\')"'
+ ' ondblclick="window.location=\'/pages/'+escId+'\'"'
+ ' title="Click to rename, double-click to open"'
+ ' onmouseenter="var d=window._wsData;if(d)d.showPreview(event,{id:'+escId+',name:\''+escName+'\',is_folder:false,content_format:\''+(c.content_format||'')+'\'})"'
+ ' onmouseleave="var d=window._wsData;if(d)d.hidePreview()">'
+ (c.is_shared ? '<span class="ws-shared-badge" title="Partag\u00e9" onclick="event.stopPropagation()">👥</span>' : '')
+ '<span id="rc-name-'+escId+'">'+c.name+'</span>'
+ '<input id="rc-input-'+escId+'" class="inline-rename-input" style="display:none"'
+ ' onkeydown="if(event.key===\'Enter\')window._wsData.commitRenderChildRename('+escId+')"'
+ ' onblur="window._wsData.commitRenderChildRename('+escId+')"'
+ ' onclick="event.stopPropagation()">'
+ '<a href="#" class="btn-open hover-only"'
+ ' onclick="event.stopPropagation();event.preventDefault();var d=window._wsData;if(d)d.openSidePeek('+escId+')"'
+ ' title="Open in side preview">↢ Open</a>'
+ '</span>';
}
// Open button for files — to the right of title (like Library)
html += '<div class="actions">';
if (c.is_folder) {
html += '<span style="display:flex;gap:2px">';
html += '<button class="ws-act" data-ws-action="new-file" data-ws-id="'+escId+'" data-ws-name="'+escName+'" title="New file">'+getSvgIcon('file',14)+'</button>';
html += '<button class="ws-act" data-ws-action="new-folder" data-ws-id="'+escId+'" data-ws-name="'+escName+'" title="New folder">'+getSvgIcon('folder',14)+'</button>';
html += '<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">'+getSvgIcon('external-link',14)+'</a>';
html += '</span>';
}
html += '<button class="ws-act" data-ws-action="rename" data-ws-id="'+escId+'" data-ws-name="'+escName+'" title="Rename">'+getSvgIcon('edit',14)+'</button>';
html += '<button class="ws-act danger" data-ws-action="delete" data-ws-id="'+escId+'" data-ws-name="'+escName+'" title="Delete">'+getSvgIcon('trash',14)+'</button>';
// Tags
if (c.tags && c.tags.length > 0) {
html += '<span style="display:flex;gap:2px;margin-left:4px;">';
for (var ti=0; ti<c.tags.length; ti++) {
var t = c.tags[ti];
var tc = this.esc(t.color||'#787774');
var tn = this.esc(t.name);
html += '<span class="tag-chip" style="background:'+tc+'">'+tn+'</span>';
}
html += '</span>';
}
html += '</div></div>';
if (hasKids) {
html += '<ul class="ws-tree" style="display:none">'+this.renderChildren(c.children, depth+1)+'</ul>';
}
html += '</li>';
}
return html;
},
// Inline rename for renderChildren (static HTML)
startRenderChildRename(id, currentName) {
var nameSpan = document.getElementById('rc-name-' + id);
var input = document.getElementById('rc-input-' + id);
if (!nameSpan || !input) return;
nameSpan.style.display = 'none';
input.style.display = '';
input.value = currentName;
var w = this._fitNameWidth(currentName);
var avail = input.parentElement ? input.parentElement.clientWidth : 0;
if (avail > 60) input.style.width = Math.min(w, avail) + 'px'; else input.style.width = w + 'px';
input.focus();
input.select();
},
async commitRenderChildRename(id) {
var nameSpan = document.getElementById('rc-name-' + id);
var input = document.getElementById('rc-input-' + id);
if (!input) return;
var newName = (input.value || '').trim();
input.style.display = 'none';
if (nameSpan) nameSpan.style.display = '';
if (!newName) return;
try {
var r = await fetch('/api/local-workspace/items/' + id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({name: newName})
});
if (r.ok) {
if (nameSpan) nameSpan.textContent = newName;
// Update tree node
var node = this._findNodeById(this.tree, id);
if (node) { node.name = newName; }
this._reloadAfterAction();
}
} catch(e) {}
},
esc(s) {
if (!s) return '';
return s.replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'&quot;');
},
_fitNameWidth(text) {
var off = document.createElement('span');
off.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;font-size:13px;';
off.textContent = (text || '').toString();
document.body.appendChild(off);
var w = off.offsetWidth;
document.body.removeChild(off);
return Math.max(w + 16, 60);
},
// ── Event delegation for renderChildren action buttons ──
handleTreeAction(ev) {
var btn = (ev.target.closest && ev.target.closest('[data-ws-action]'))
|| (ev.target.parentElement && ev.target.parentElement.closest('[data-ws-action]'));
if (!btn) return;
ev.stopPropagation();
var action = btn.getAttribute('data-ws-action');
var id = parseInt(btn.getAttribute('data-ws-id'));
var name = btn.getAttribute('data-ws-name') || '';
if (!id) return;
switch(action) {
case 'new-file':
window.FlowDeck.createPage();
break;
case 'new-folder':
window.FlowDeck.showCreateFolderModal();
break;
case 'rename':
this.startRename({db_id: id, name: name});
break;
case 'delete':
this.deleteWithUndo({db_id: id, name: name});
break;
}
},
toggleExpand(id, btn) {
this.expanded[id] = !(this.expanded[id] || false);
this.expanded = Object.assign({}, this.expanded);
this._saveExpandedState();
// For renderChildren static HTML: toggle chevron + sibling <ul>
if (btn) {
btn.classList.toggle('open', this.expanded[id]);
// Navigate: btn → .ws-tree-item → <li> → <ul.ws-tree>
var itemDiv = btn.closest('.ws-tree-item');
if (itemDiv && itemDiv.parentElement) {
var children = itemDiv.parentElement.children;
for (var i = 0; i < children.length; i++) {
if (children[i].tagName === 'UL' && children[i].classList.contains('ws-tree')) {
children[i].style.display = this.expanded[id] ? '' : 'none';
break;
}
}
}
}
var self = this;
this.$nextTick(function() { self._syncCheckboxes(); });
},
async navigateToFolder(id) {
// Find the folder name for the stack
var folderNode = this._findNodeById(this.tree, id);
var folderName = folderNode ? folderNode.name : 'Folder';
// Push to folder stack for breadcrumb
if (this.navIndex < this.folderStack.length - 1) {
this.folderStack = this.folderStack.slice(0, this.navIndex + 1);
}
this.folderStack.push({id: id, name: folderName});
this.navIndex = this.folderStack.length - 1;
// Fetch children of this folder
await this._loadFolder(id);
},
async goToParent() {
if (this.folderStack.length <= 1) { await this._loadFolder(0); return; }
this.folderStack.pop();
this.navIndex = this.folderStack.length - 1;
var parentId = this.folderStack.length > 0 ? this.folderStack[this.folderStack.length - 1].id : 0;
await this._loadFolder(parentId);
},
async _loadFolder(folderId) {
var url = '/api/local-workspace/tree';
if (folderId && folderId > 0) url += '?folder=' + folderId;
try {
var r = await fetch(url);
var d = await r.json();
var roots = d.tree || [];
this._markTree(roots, 0);
this.tree = roots;
this.displayTree = [...roots];
this.showAll = false;
this.breadcrumb = d.breadcrumb || [];
this.currentFolder = folderId || 0;
this._saveExpandedState();
this.previewItem = null;
this._recompute();
var self = this;
this.$nextTick(function() { self._syncCheckboxes(); });
} catch(e) { console.error('Load folder error:', e); }
},
_findNodeById(nodes, id) {
for (var i=0; i<nodes.length; i++) {
if (nodes[i].id === id) return nodes[i];
if (nodes[i].children) {
var found = this._findNodeById(nodes[i].children, id);
if (found) return found;
}
}
return null;
},
async goBack() {
if (this.navIndex <= 0) return;
this.navIndex--;
var entry = this.folderStack[this.navIndex];
this._saveExpandedState();
await this._loadFolder(entry ? entry.id : 0);
},
async goForward() {
if (this.navIndex >= this.folderStack.length - 1) return;
this.navIndex++;
var entry = this.folderStack[this.navIndex];
this._saveExpandedState();
await this._loadFolder(entry ? entry.id : 0);
},
_saveExpandedState() {
try { localStorage.setItem('fd_ws_expanded', JSON.stringify(this.expanded)); }
catch(e) {}
},
_reloadAfterAction() {
this._saveExpandedState();
this._loadFolder(this.currentFolder);
// Directly refresh sidebar tree
if (window.appState && window.appState.refreshSidebarTree) {
window.appState.refreshSidebarTree();
}
},
// ── Inline Rename ──
startInlineRename(node, ev) {
this.renamingId = node.id;
this.renamingName = node.name;
var self = this;
this.$nextTick(function() {
var inp = self.$el.querySelector('.inline-rename-input:not([style*="display: none"])');
if (inp) {
var w = self._fitNameWidth(self.renamingName);
var avail = inp.parentElement ? inp.parentElement.clientWidth : 0;
if (avail > 60) inp.style.width = Math.min(w, avail) + 'px'; else inp.style.width = w + 'px';
inp.focus(); inp.select();
}
});
},
async commitRename(node) {
var n = this.renamingName.trim();
this.renamingId = null;
if (!n || n === node.name) return;
var r = await fetch('/api/local-workspace/items/' + node.id, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({name: n})
});
if (r.ok) {
node.name = n; node.db_name = n;
this._saveExpandedState();
}
},
cancelRename() { this.renamingId = null; },
// ── Keyboard shortcuts ──
onKeydown(ev) {
var tag = ev.target && ev.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return; // Don't hijack typing
if (this.renamingId) return; // Don't intercept during rename
if (ev.key === 'Delete' && !ev.ctrlKey && !ev.metaKey) {
ev.preventDefault();
// Delete selected item — implement when multi-select exists
}
if (ev.key === 'F2' && !ev.ctrlKey) {
ev.preventDefault();
// Find hovered or first file item and rename
var hovered = document.querySelector('.ws-tree-item:hover');
if (hovered) {
var id = parseInt(hovered.getAttribute('data-ws-id') || hovered.getAttribute('data-folder-id'));
var name = (hovered.querySelector('.name') || {}).textContent || '';
if (id) this.startInlineRename({id: id, name: name}, ev);
}
}
if (ev.ctrlKey && ev.key === 'c') {
// Copy selected items to clipboard
ev.preventDefault();
this.clipboard = []; // Will implement with multi-select
}
if (ev.ctrlKey && ev.key === 'v' && this.clipboard.length > 0) {
ev.preventDefault();
// Paste items — move them to current folder
var self = this;
var targetId = this.currentFolder > 0 ? this.currentFolder : null;
this.clipboard.forEach(function(id) {
fetch('/api/local-workspace/items/' + id + '/move', {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({parent_id: targetId})
}).then(function(r) { if (r.ok) self._reloadAfterAction(); });
});
this.clipboard = [];
}
},
// ── Duplicate ──
async duplicateItem(node) {
var r = await fetch('/api/local-workspace/items', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({name: node.name + ' (copy)', type: node.type || 'page',
parent_id: this.currentFolder > 0 ? this.currentFolder : null})
});
if (r.ok) this._reloadAfterAction();
},
// ── Sort ──
doSort() {
var s = this.sortBy;
if (!s) { this.displayTree = [...this.tree]; this.showAll = false; return; }
var sorted = this.tree.slice();
if (s === 'name-asc') sorted.sort(function(a,b) { return (a.name||'').localeCompare(b.name||''); });
else if (s === 'name-desc') sorted.sort(function(a,b) { return (b.name||'').localeCompare(a.name||''); });
else if (s === 'date-asc') sorted.sort(function(a,b) { return (a.updated_at||'').localeCompare(b.updated_at||''); });
else if (s === 'date-desc') sorted.sort(function(a,b) { return (b.updated_at||'').localeCompare(a.updated_at||''); });
else if (s === 'type') sorted.sort(function(a,b) { return (a.is_folder===b.is_folder) ? 0 : a.is_folder ? -1 : 1; });
this.displayTree = sorted;
this._recompute();
},
// ── Undo delete ──
async deleteWithUndo(node) {
var self = this;
this.undoItemId = node.db_id || node.id;
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
this.undoVisible = true;
// Delete via API
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, { method:'DELETE' });
if (!r.ok) { this.undoVisible = false; return; }
// Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
this._reloadAfterAction();
// Auto-dismiss undo banner after 5s
this.undoTimeout = setTimeout(function() {
self.undoVisible = false;
self.undoItemId = null;
}, 5000);
},
undoDelete() {
if (this.undoTimeout) clearTimeout(this.undoTimeout);
this.undoVisible = false;
var ids = (this.undoItemId || '').toString().split(',');
this.undoItemId = null;
if (!ids.length || !ids[0]) return;
// Restore via API (soft-delete undo) — single or bulk
var self = this;
var restored = 0;
var total = ids.length;
function restoreOne(i) {
if (i >= total) {
self._reloadAfterAction();
return;
}
fetch('/api/local-workspace/items/' + ids[i] + '/restore', { method: 'POST' })
.then(function(r) { if (r.ok) restored++; })
.finally(function() { restoreOne(i + 1); });
}
restoreOne(0);
},
_removeFromTree(nodes, id) {
for (var i=0; i<nodes.length; i++) {
if (nodes[i].id === id) { nodes.splice(i, 1); return true; }
if (nodes[i].children && this._removeFromTree(nodes[i].children, id)) {
nodes[i].child_count = nodes[i].children.length;
nodes[i].has_children = nodes[i].child_count > 0;
return true;
}
}
return false;
},
// ── Upload speed tracking ──
_trackUploadSpeed(totalSize) {
var self = this;
this.uploadStartTime = Date.now();
this.uploadSpeed = '';
var checkSpeed = setInterval(function() {
if (!self.uploading) { clearInterval(checkSpeed); return; }
var elapsed = (Date.now() - self.uploadStartTime) / 1000;
if (elapsed > 0) {
self.uploadSpeed = (totalSize / elapsed / 1024).toFixed(0) + ' KB/s';
}
}, 500);
},
// ── Path bar navigation ──
navigateToPath() {
var path = (this.$el.querySelector('.path-input') || {}).value || '';
var parts = path.replace(/^\/workspace\/?/, '').split('/').filter(Boolean);
if (parts.length === 0) { window.location = '/local-workspace'; return; }
// Walk tree to find matching folder
var targetId = this._findFolderByPath(this.tree, parts, 0);
if (targetId) {
window.location = '/local-workspace?folder=' + targetId;
} else {
window.showToast('Path not found: ' + parts.join('/'), 'error');
}
},
_findFolderByPath(nodes, parts, idx) {
if (idx >= parts.length) return null;
for (var i=0; i<nodes.length; i++) {
if (nodes[i].is_folder && nodes[i].name === parts[idx]) {
if (idx === parts.length - 1) return nodes[i].id;
if (nodes[i].children) {
var found = this._findFolderByPath(nodes[i].children, parts, idx+1);
if (found) return found;
}
}
}
return null;
},
// ── Cache-aware preview ──
showPreview(ev, node) {
if (node.is_folder) return;
this.previewName = node.name;
this.previewX = ev.clientX + 12;
this.previewY = ev.clientY + 12;
this.previewVisible = true;
var self = this;
var wsId = {{ workspace_id }} || 1;
var cacheKey = 'prev_' + node.id;
// Return cached immediately
if (this.previewCache[cacheKey]) {
this.previewContent = this.previewCache[cacheKey];
return;
}
// Determine type
var imgExts = ['.png','.jpg','.jpeg','.gif','.webp','.svg','.bmp','.ico'];
var isImg = false, isPdf = false;
for (var i=0; i<imgExts.length; i++) {
if ((node.name||'').toLowerCase().endsWith(imgExts[i])) { isImg = true; break; }
}
isPdf = (node.name||'').toLowerCase().endsWith('.pdf');
if (node.content_format === 'file') {
var url = '/api/files/' + wsId + '/' + encodeURIComponent(node.name);
if (isImg) {
var html = '<img src="'+url+'" style="max-width:380px;max-height:180px;display:block;">';
this.previewContent = html; this.previewCache[cacheKey] = html;
} else if (isPdf) {
var html = '<div style="text-align:center;padding:16px;">'+getSvgIcon('file',48)+'<br><span style="color:var(--text-secondary);font-size:12px;">PDF Document</span><br><a href="/pages/'+node.id+'" style="color:var(--accent);font-size:11px;">Open viewer →</a></div>';
this.previewContent = html; this.previewCache[cacheKey] = html;
} else {
this.previewContent = 'Loading...';
this._fetchRetry(url).then(function(t) {
var content = t.substring(0, 500);
self.previewContent = content; self.previewCache[cacheKey] = content;
}).catch(function() { self.previewContent = '(preview not available)'; });
}
} else {
this.previewContent = 'Loading...';
this._fetchRetry('/api/local-workspace/page-content/' + node.id, true)
.then(function(d) {
var content = self._extractPreview(d.content || '', d.format);
self.previewContent = content; self.previewCache[cacheKey] = content;
}).catch(function() { self.previewContent = '(preview not available)'; });
}
},
// ── Fetch with retry ──
_fetchRetry(url, json) {
var maxRetries = 2;
var delay = 300;
var self = this;
return new Promise(function(resolve, reject) {
function attempt(n) {
fetch(url).then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return json ? r.json() : r.text();
}).then(resolve).catch(function(e) {
if (n < maxRetries) {
setTimeout(function() { attempt(n+1); }, delay * (n+1));
} else { reject(e); }
});
}
attempt(0);
});
},
// ── Touch haptic feedback ──
_haptic() {
if (navigator.vibrate) navigator.vibrate(10);
},
// ── Expand / Collapse all ──
expandAll() {
var self = this;
this._walkTree(this.tree, function(n) {
if (n.is_folder && n.children && n.children.length > 0) {
self.expanded[n.id] = true;
}
});
this.expanded = Object.assign({}, this.expanded);
// Toggle static HTML for renderChildren
this._toggleAllUl(this.$el, true);
},
collapseAll() {
this.expanded = {};
this._toggleAllUl(this.$el, false);
},
_walkTree(nodes, fn) {
for (var i=0; i<nodes.length; i++) {
fn(nodes[i]);
if (nodes[i].children) this._walkTree(nodes[i].children, fn);
}
},
_toggleAllUl(el, show) {
var uls = el.querySelectorAll('ul.ws-tree');
for (var i=0; i<uls.length; i++) {
uls[i].style.display = show ? '' : 'none';
}
// Also toggle chevrons
var chevs = el.querySelectorAll('.ws-chevron');
for (var j=0; j<chevs.length; j++) {
if (show) chevs[j].classList.add('open');
else chevs[j].classList.remove('open');
}
},
// ── File type icon ──
_fileIcon(name, isFolder, contentFormat) {
if (isFolder) return '<span class="page-icon-svg">'+getSvgIcon('folder',14)+'</span>';
// FlowDeck internal pages (no extension, blocks/markdown format)
if (contentFormat && contentFormat !== 'file') return '<span class="page-icon-svg">'+getSvgIcon('edit',14)+'</span>';
var n = (name || '').toLowerCase();
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('image',14)+'</span>';
if (/\.pdf$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.(md|markdown)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('edit',14)+'</span>';
if (/\.(py)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.(js|jsx|ts|tsx)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.(html?|xml)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.css$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.json$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.sql$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.(sh|bash|zsh)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.ps1$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.(txt|log)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
},
// ── Search / Filter ──
doFilter() {
var q = (this.searchQuery || '').toLowerCase().trim();
var ft = this.filterType || '';
if (!q && !ft && !this.tagFilter) { this.displayTree = [...this.tree]; this.showAll = false; this._computeFilteredTableItems(); return; }
// Apply all filters (search + type + tag) to tree view
this.displayTree = this._filterTree([...this.tree], q, ft);
if (this.tagFilter) {
this.displayTree = this._filterTreeByTag(this.displayTree, this.tagFilter);
}
this._computeFilteredTableItems();
},
_filterTreeByTag(nodes, tagName) {
var result = [];
for (var i=0; i<nodes.length; i++) {
var n = nodes[i];
var hasTag = (n.tags || []).some(function(t) { return t.name === tagName; });
var filteredChildren = n.children ? this._filterTreeByTag(n.children, tagName) : [];
if (hasTag || filteredChildren.length > 0) {
var clone = Object.assign({}, n);
clone.children = filteredChildren;
result.push(clone);
}
}
return result;
},
filterByType(type) {
this.filterType = (this.filterType === type) ? '' : type;
this.doFilter();
},
_filterTree(nodes, query, fileType) {
var result = [];
for (var i=0; i<nodes.length; i++) {
var n = nodes[i];
var filteredChildren = n.children ? this._filterTree(n.children, query, fileType) : [];
var selfMatches = (n.name || '').toLowerCase().indexOf(query || '') !== -1;
// File type filter
var typeMatch = true;
if (fileType && !n.is_folder) {
var nm = (n.name || '').toLowerCase();
switch(fileType) {
case 'folder': typeMatch = n.is_folder; break;
case 'pdf': typeMatch = /\.pdf$/.test(nm); break;
case 'image': typeMatch = /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(nm); break;
case 'code': typeMatch = /\.(py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql)$/.test(nm); break;
case 'text': typeMatch = /\.(txt|md|markdown|log)$/.test(nm); break;
case 'page': typeMatch = !/\.(pdf|png|jpe?g|gif|webp|svg|bmp|ico|py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql|txt|md|markdown|log)$/.test(nm); break;
}
}
if ((selfMatches || filteredChildren.length > 0) && typeMatch) {
var clone = Object.assign({}, n);
clone.children = filteredChildren;
result.push(clone);
}
}
return result;
},
// ── Preview on hover ──
_extractPreview(content, format) {
if (!content) return '(empty)';
// Try to extract text from Notion block JSON
if (format === 'blocks' || format === 'markdown') {
try {
var blocks = typeof content === 'string' ? JSON.parse(content) : content;
if (blocks && blocks.blocks && Array.isArray(blocks.blocks)) {
var lines = [];
for (var i=0; i<blocks.blocks.length && lines.length<20; i++) {
var b = blocks.blocks[i];
if (b.content) lines.push(b.content);
else if (b.text) lines.push(b.text);
else if (typeof b === 'string') lines.push(b);
}
return lines.join('\n').substring(0, 500) || '(empty)';
}
} catch(e) {}
}
// Fallback: raw text
return String(content).substring(0, 500);
},
hidePreview() {
this.previewVisible = false;
},
openModal(type, parent) {
this.createType = type;
this.parentFolder = parent||null;
this.newName = '';
this.showCreate = true;
var self = this;
this.$nextTick(function() {
var el = self.$refs.cinp;
if (el) setTimeout(function() { el.focus(); }, 100);
});
},
async doCreate() {
var n = this.newName.trim();
if (!n) return;
var body = { name:n, type:this.createType };
if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
var r = await fetch('/api/local-workspace/items', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(body)
});
if (r.ok) {
var d = await r.json();
// Save parent ref BEFORE clearing so we can insert into tree
var parent = this.parentFolder;
this.showCreate = false;
this.parentFolder = null;
this.newName = '';
// Insert new node into tree without reload
var isFolder = this.createType === 'folder';
var newNode = {
id: d.id, db_id: d.id, name: d.name || n,
is_folder: isFolder, type: isFolder ? 'folder' : 'page',
content_format: isFolder ? null : 'blocks',
children: [], has_children: false, depth: 0
};
if (parent && parent.db_id) {
// Insert as child of parent folder
this._insertIntoParent(this.tree, parent.db_id, newNode);
// Auto-expand parent so the new item is visible
this.expanded[parent.db_id] = true;
this.expanded = Object.assign({}, this.expanded);
} else {
// Insert at root
this.tree.unshift(newNode);
}
// Force new reference so Alpine x-for re-renders
if (!this.searchQuery && !this.filterType) {
this.displayTree = [...this.tree];
} else if (this.sortBy) {
// Re-sort with the new item
this.doSort();
} else {
// Re-apply filter with updated tree
this.doFilter();
}
this._saveExpandedState();
// Directly refresh sidebar tree
if (window.appState && window.appState.refreshSidebarTree) {
window.appState.refreshSidebarTree();
}
}
},
_insertIntoParent(nodes, parentId, newNode) {
for (var i=0; i<nodes.length; i++) {
if (nodes[i].id === parentId) {
if (!nodes[i].children) nodes[i].children = [];
nodes[i].children.unshift(newNode);
nodes[i].has_children = true;
newNode.depth = (nodes[i].depth || 0) + 1;
return true;
}
if (nodes[i].children && this._insertIntoParent(nodes[i].children, parentId, newNode)) {
return true;
}
}
return false;
},
openPage(id) { window.location = '/pages/'+id; },
openSidePeek(id) {
var node = this._findNodeById(this.tree, id) || this._findNodeById(this.displayTree, id);
if (node) this.selectForPreview(node);
},
startRename(node) {
this.target = node;
this.newName = node.name.trim();
this.showRename = true;
var self = this;
this.$nextTick(function() {
var el = self.$refs.rinp;
if (el) setTimeout(function() { el.focus(); el.select(); }, 100);
});
},
async doRename() {
var n = this.newName.trim();
if (!n||!this.target) return;
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:n})
});
if (r.ok) { this._reloadAfterAction(); }
},
startDelete(node) {
this.target = node;
this.showDelete = true;
},
async doDelete() {
if (!this.target) return;
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, { method:'DELETE' });
if (r.ok) { this._reloadAfterAction(); }
},
// ── Drag & Drop ──
onDragOver(ev) {
this.isDragging = true;
// Detect if hovering over a folder item
var folderEl = ev.target.closest('.ws-tree-item');
var parentFolder = folderEl ? folderEl.querySelector('.name.folder') : null;
// Clear previous highlight
document.querySelectorAll('.drop-target-highlight').forEach(function(el){ el.classList.remove('drop-target-highlight'); });
if (parentFolder) {
var name = parentFolder.textContent.trim();
this.dropTargetName = name;
// Find parent node id from the onclick attribute or data
var li = parentFolder.closest('.ws-tree-item');
if (li) {
var clickEl = li.querySelector('.name.folder');
// Try to extract ID from onclick or the onclick handler
// Use data attributes if available, otherwise fall back
}
folderEl.classList.add('drop-target-highlight');
} else {
this.dropTargetName = 'root';
}
},
onDragLeave(ev) {
// Only reset if leaving the layout entirely
if (!ev.currentTarget.contains(ev.relatedTarget)) {
this.isDragging = false;
this.dropTargetName = '';
document.querySelectorAll('.drop-target-highlight').forEach(function(el){ el.classList.remove('drop-target-highlight'); });
}
},
onInternalDragStart(ev, id) {
this.draggedItemId = id;
ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData('text/plain', String(id));
// Add a visual class to the dragged element
ev.target.style.opacity = '0.5';
},
onDrop(ev) {
this.isDragging = false;
document.querySelectorAll('.drop-target-highlight').forEach(function(el){ el.classList.remove('drop-target-highlight'); });
// Reset any opacity from drag
document.querySelectorAll('.ws-tree-item').forEach(function(el){ el.style.opacity = ''; });
// ── Internal drag (move item to another folder) ──
if (this.draggedItemId !== null) {
var draggedId = this.draggedItemId;
this.draggedItemId = null;
// Determine target parent
var folderEl = ev.target.closest('.ws-tree-item');
var targetParentId = this.currentFolder > 0 ? this.currentFolder : null;
if (folderEl) {
var folderLink = folderEl.querySelector('.name.folder');
if (folderLink) {
var fid = folderEl.getAttribute('data-folder-id');
if (fid) targetParentId = parseInt(fid);
}
}
// Don't drop on self or same parent
if (draggedId === targetParentId) return;
this._moveItem(draggedId, targetParentId);
return;
}
// ── External drop (files from computer) ──
var folderEl = ev.target.closest('.ws-tree-item');
var parentId = this.currentFolder > 0 ? this.currentFolder : null;
var parentName = 'root';
if (folderEl) {
var folderLink = folderEl.querySelector('.name.folder');
if (folderLink) {
parentName = folderLink.textContent.trim();
// Use data-folder-id attribute (works for both x-for and renderChildren)
var fid = folderEl.getAttribute('data-folder-id');
if (fid) parentId = parseInt(fid);
}
}
var items = ev.dataTransfer.items;
if (!items) return;
var self = this;
var dtFiles = ev.dataTransfer.files;
// Check if any item is a directory (WebKit only)
var hasDirectory = false;
for (var i = 0; i < items.length; i++) {
var entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
if (entry && entry.isDirectory) { hasDirectory = true; break; }
}
if (hasDirectory) {
// Walk directory tree and upload structure
this._processDirectoryDrop(items, parentId, parentName);
} else if (dtFiles.length > 0) {
// Simple file upload
this._uploadFiles(dtFiles, parentId, parentName);
}
},
_processDirectoryDrop(items, parentId, parentName) {
var self = this;
var allItems = []; // {path: string, type: 'file'|'folder', entry: FileSystemEntry}
var pending = items.length;
function walkEntry(entry, basePath) {
var path = basePath ? basePath + '/' + entry.name : entry.name;
if (entry.isFile) {
allItems.push({path: path, type: 'file', entry: entry});
pending--;
if (pending === 0) doUpload();
} else if (entry.isDirectory) {
allItems.push({path: path, type: 'folder', entry: entry});
var reader = entry.createReader();
reader.readEntries(function(entries) {
pending += entries.length;
for (var i = 0; i < entries.length; i++) {
walkEntry(entries[i], path);
}
pending--;
if (pending === 0) doUpload();
});
}
}
for (var i = 0; i < items.length; i++) {
var entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
if (entry) walkEntry(entry, '');
else pending--;
}
if (pending === 0) doUpload();
function doUpload() {
// Build structure
var structure = [];
var files = [];
for (var i = 0; i < allItems.length; i++) {
structure.push({path: allItems[i].path, type: allItems[i].type});
}
// Resolve file entries to actual File objects
var fileEntries = [];
for (var i = 0; i < allItems.length; i++) {
if (allItems[i].type === 'file') fileEntries.push(allItems[i]);
}
var resolved = 0;
var formData = new FormData();
formData.append('structure', JSON.stringify(structure));
if (parentId) formData.append('parent_id', parentId);
function addFile(idx) {
if (idx >= fileEntries.length) {
// All resolved, upload
self._doUpload(formData, parentId, parentName);
return;
}
var fe = fileEntries[idx];
fe.entry.file(function(file) {
formData.append('files', file, fe.path);
resolved++;
addFile(idx + 1);
}, function() {
resolved++;
addFile(idx + 1);
});
}
addFile(0);
}
},
_uploadFiles(fileList, parentId, parentName) {
var formData = new FormData();
for (var i = 0; i < fileList.length; i++) {
formData.append('files', fileList[i]);
}
if (parentId) formData.append('parent_id', parentId);
this._doUpload(formData, parentId, parentName);
},
async _moveItem(itemId, newParentId) {
var body = { parent_id: newParentId || null };
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (r.ok) {
this._reloadAfterAction();
} else {
window.showToast('Move failed', 'error');
}
} catch(e) {
window.showToast('Move failed: ' + e.message, 'error');
}
},
async _doUpload(formData, parentId, parentName) {
var self = this;
this.uploading = true;
this.uploadStatus = 'Uploading to ' + parentName + '...';
this.uploadProgress = 10;
var folderHandled = formData.has('structure');
var url = folderHandled ? '/api/local-workspace/upload-folder' : '/api/local-workspace/upload';
try {
var r = await fetch(url, { method: 'POST', body: formData });
this.uploadProgress = 80;
var d = await r.json();
if (r.ok) {
this.uploadStatus = '✓ ' + d.items.length + ' item(s) uploaded';
this.uploadProgress = 100;
var items = d.items || [];
setTimeout(function() {
self.uploading = false;
self._reloadAfterAction();
}, 800);
} else {
this.uploadStatus = '✗ ' + (d.error || 'Upload failed');
this.uploadProgress = 0;
setTimeout(function() { self.uploading = false; }, 2000);
}
} catch(e) {
this.uploadStatus = '✗ Network error';
this.uploadProgress = 0;
setTimeout(function() { self.uploading = false; }, 2000);
}
},
// ── Tag management ──
async loadWorkspaceTags() {
try {
var r = await fetch('/api/settings/tags/all');
var d = await r.json();
this.workspaceTags = d.tags || [];
} catch(e) { this.workspaceTags = []; }
},
toggleTagFilter(tagName) {
this.tagFilter = (this.tagFilter === tagName) ? '' : tagName;
this.filterType = '';
this.searchQuery = '';
this.doFilter();
},
startTagInput(node) {
this.tagInputId = node.id;
var self = this;
this.$nextTick(function() {
var inp = self.$el.querySelector('.tag-input:not([style*="display: none"])');
if (inp) inp.focus();
});
},
async addTag(itemId, tagName) {
tagName = (tagName || '').trim();
if (!tagName) { this.tagInputId = null; return; }
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: tagName})
});
if (r.ok) {
var d = await r.json();
// Update node tags in tree
this._updateNodeTags(this.tree, itemId, d.tag, 'add');
this.displayTree = [...this.tree];
this.loadWorkspaceTags();
}
} catch(e) {}
this.tagInputId = null;
},
async removeTag(itemId, tagId) {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {
method: 'DELETE'
});
if (r.ok) {
this._updateNodeTags(this.tree, itemId, null, 'remove', tagId);
this.displayTree = [...this.tree];
this.loadWorkspaceTags();
}
} catch(e) {}
},
_updateNodeTags(nodes, itemId, tag, action, removeTagId) {
for (var i=0; i<nodes.length; i++) {
if (nodes[i].id === itemId) {
if (action === 'add') {
if (!nodes[i].tags) nodes[i].tags = [];
// Avoid duplicates
if (!nodes[i].tags.some(function(t) { return t.id === tag.id; })) {
nodes[i].tags.push(tag);
}
} else if (action === 'remove') {
nodes[i].tags = (nodes[i].tags || []).filter(function(t) { return t.id !== removeTagId; });
}
return true;
}
if (nodes[i].children && this._updateNodeTags(nodes[i].children, itemId, tag, action, removeTagId)) {
return true;
}
}
return false;
},
// ── Helpers ──
_formatDate(dt) {
if (!dt) return '—';
var d = new Date(dt.replace(' ', 'T') + 'Z');
if (isNaN(d.getTime())) return '—';
var now = new Date();
var diffMs = now - d;
var diffMin = Math.floor(diffMs / 60000);
var diffHrs = Math.floor(diffMs / 3600000);
var diffDays = Math.floor(diffMs / 86400000);
if (diffMin < 1) return 'Just now';
if (diffMin < 60) return diffMin + 'm ago';
if (diffHrs < 24) return diffHrs + 'h ago';
if (diffDays < 7) return diffDays + 'd ago';
return d.toLocaleDateString('en-CA');
},
_fileTypeLabel(name, contentFormat) {
if (contentFormat && contentFormat !== 'file') return 'Page';
var n = (name || '').toLowerCase();
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return 'Image';
if (/\.pdf$/.test(n)) return 'PDF';
if (/\.(py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql)$/.test(n)) return 'Code';
if (/\.(txt|md|markdown|log)$/.test(n)) return 'Text';
if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return 'Archive';
if (/\.(mp3|wav|ogg|flac|aac)$/.test(n)) return 'Audio';
if (/\.(mp4|webm|mov|avi)$/.test(n)) return 'Video';
return 'File';
},
selectAllFiltered(ev) {
if (ev.target.checked) {
for (var i=0; i<this.filteredTableItems.length; i++) {
this.selectedIds[this.filteredTableItems[i].id] = true;
}
} else {
for (var k in this.selectedIds) delete this.selectedIds[k];
}
this.selectedIds = Object.assign({}, this.selectedIds);
this._syncSelection();
},
// ── Selection sync (keeps checkboxes & counters in sync) ──
_syncSelection() {
this.selectedIdsCount = Object.keys(this.selectedIds).length;
var total = this._collectVisibleIds(this.displayTree).length;
this.allSelected = total > 0 && this.selectedIdsCount >= total;
this._syncCheckboxes();
},
_syncCheckboxes() {
var boxes = document.querySelectorAll('input.item-checkbox[data-ws-id]');
for (var i = 0; i < boxes.length; i++) {
boxes[i].checked = !!this.selectedIds[parseInt(boxes[i].getAttribute('data-ws-id'))];
}
},
// ── Multi-select actions (Library-style select bar) ──
_selectedIds() {
return Object.keys(this.selectedIds).map(Number);
},
copySelectedLinks() {
this.moreOpen = false;
var ids = Object.keys(this.selectedIds);
if (!ids.length) return;
var links = ids.map(function(id) { return window.location.origin + '/pages/' + id; });
navigator.clipboard.writeText(links.join('\n'));
if (window.showToast) window.showToast('Links copied', 'success');
},
async openMovePicker() {
var ids = this._selectedIds();
if (!ids.length) return;
this.moreOpen = false;
this.moveIds = ids;
this.moveSearchQuery = '';
this.moveModalOpen = true;
try {
// Fetch the full workspace tree so every folder is a valid destination
var r = await fetch('/api/local-workspace/tree');
var d = await r.json();
var folders = [];
this._collectFolders(d.tree || [], folders, 0);
this._moveAllCandidates = folders;
this.moveCandidates = folders.slice(0, 50);
} catch(e) {
this._moveAllCandidates = [];
this.moveCandidates = [];
}
},
_collectFolders(nodes, out, depth) {
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
if (n.is_folder) out.push({ id: n.id, name: n.name, depth: depth });
if (n.children) this._collectFolders(n.children, out, depth + 1);
}
},
doMoveSearch() {
var q = (this.moveSearchQuery || '').toLowerCase().trim();
if (!q) { this.moveCandidates = this._moveAllCandidates.slice(0, 50); return; }
this.moveCandidates = this._moveAllCandidates.filter(function(it) {
return (it.name || '').toLowerCase().indexOf(q) !== -1;
}).slice(0, 50);
},
async confirmBulkMove(parentId) {
this.moveModalOpen = false;
var ids = this.moveIds;
this.moveIds = [];
if (!ids || !ids.length) return;
for (var i = 0; i < ids.length; i++) {
try {
await fetch('/api/local-workspace/items/' + ids[i] + '/move', {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ parent_id: parentId || null })
});
} catch(e) {}
}
this.clearSelection();
this._reloadAfterAction();
if (window.showToast) window.showToast('Item' + (ids.length > 1 ? 's' : '') + ' moved', 'success');
}
};
})();
// Injecter toutes les props comme globales pour Alpine (résout les 36 erreurs de scope)
var _wsKeys = Object.keys(window._wsInitData);
for (var _i = 0; _i < _wsKeys.length; _i++) {
try { window[_wsKeys[_i]] = window._wsInitData[_wsKeys[_i]]; } catch(e) {}
}
_wsInitData = window._wsInitData;
// Objet léger pour le ctx-menu (évite le conflit $nextTick avec Alpine)
window._ctxMenuData = {
ctxMenu: {visible:false,x:0,y:0,node:null,tags:[]},
ctxTagAdding: false,
ctxTagExistingOpen: false,
ctxAvailTags: [],
ctxNewTagColor: '#787774',
ctxTagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
// Use Alpine proxy (window._wsData) for reactivity, not raw object (window._wsInitData)
_ws() { return window._wsData || window._wsInitData; },
ctxMenuOpenPage() { this._ws().ctxMenuOpenPage(); },
ctxMenuOpenFolder() { this._ws().ctxMenuOpenFolder(); },
ctxMenuRename() { this._ws().ctxMenuRename(); },
ctxMenuDuplicate() { this._ws().ctxMenuDuplicate(); },
ctxMenuDelete() { this._ws().ctxMenuDelete(); },
ctxAddExistingTag(t) { this._ws().ctxAddExistingTag(t); },
ctxAddNewTag(n, c) { this._ws().ctxAddNewTag(n, c); },
ctxRemoveTag(id) { this._ws().ctxRemoveTag(id); },
};
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
</script>
<script>console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
<div class="ws-split"
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
@click.self="ctxMenu.visible = false"
@touchstart="onTouchStart($event)"
@touchend="onTouchEnd($event)"
@touchmove="onTouchMove($event)"
tabindex="0"
x-ref="layout">
<div class="ws-main">
<!-- Drop overlay -->
<div class="drop-overlay" :class="{ active: isDragging }">
<div class="drop-hint">
{{ fd_icon("download",16) }}
<div>Drop files here</div>
<div style="font-size:12px;color:var(--text-tertiary);margin-top:6px;" x-show="dropTarget">
Into: <strong x-text="dropTargetName"></strong>
</div>
</div>
</div>
<!-- Upload progress -->
<div class="upload-progress" x-show="uploading" x-transition>
<div style="display:flex;align-items:center;gap:8px;">
<span x-text="uploading ? '⏳' : ''"></span>
<span x-text="uploadStatus"></span>
<span style="font-size:11px;color:var(--text-tertiary);" x-show="uploadSpeed" x-text="uploadSpeed"></span>
</div>
<div class="up-bar">
<div class="up-bar-fill" :style="{ width: uploadProgress + '%' }"></div>
</div>
</div>
<!-- Undo toast -->
<div class="undo-toast" x-show="undoVisible" x-transition>
<span x-text="undoMessage"></span>
<button class="undo-btn" @click="undoDelete()">Undo</button>
<button class="undo-dismiss" @click="undoVisible=false">×</button>
</div>
<!-- Breadcrumb + toolbar row (Library-style) -->
<div class="breadcrumb-row">
<button class="btn-icon nav-arrow" x-show="sidebarCollapsed" x-cloak @click="sidebarCollapsed = false" title="Show sidebar">{{ fd_icon("chevron-right",14) }}</button>
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
<button class="btn-icon nav-arrow" @click="goBack()" title="Back" :disabled="!canGoBack">{{ fd_icon("chevron-left",14) }}</button>
<button class="btn-icon nav-arrow" @click="goForward()" title="Forward" :disabled="!canGoForward">{{ fd_icon("chevron-right",14) }}</button>
{% if breadcrumbs %}
<div class="unified-breadcrumb">
<span class="bc-icon">{{ fd_icon("folder",13) }}</span>
<a href="/local-workspace" @click.prevent="navigateToRoot()" class="bc-root">{{ workspace_name }}</a>
<template x-for="(b, i) in folderStack" :key="'bc'+b.id">
<span style="display:contents;">
<span class="bc-sep">/</span>
<a href="#" @click.prevent="navigateToBreadcrumb(i)" x-text="b.name"
:class="{ 'bc-current': i === folderStack.length - 1 }"
@dblclick.prevent="startInlineRename(b, $event)"></a>
</span>
</template>
<div class="bc-edit-wrap" x-show="editingPath" @click.outside="editingPath=false" style="display:contents;">
<input class="bc-edit-input" x-ref="pathEdit" :value="pathValue"
@keydown.enter="navigateToPath(), editingPath=false"
@keydown.escape="editingPath=false"
placeholder="/workspace/...">
<button class="bc-edit-go" @click="navigateToPath(), editingPath=false">{{ fd_icon("corner-down-right",13) }}</button>
</div>
<button class="bc-edit-trigger" x-show="!editingPath" @click="startPathEdit()" title="Edit path">{{ fd_icon("edit",11) }}</button>
</div>
{% endif %}
<div class="ws-toolbar" style="margin-left:auto;">
<!-- View dropdown -->
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="viewMenuOpen=!viewMenuOpen; sortOpen=false; searchOpen=false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
</button>
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
<div class="dd-label">View</div>
<div class="dd-item" :class="{active: viewMode==='tree'}" @click="viewMode='tree', previewItem=null, viewMenuOpen=false"><span class="check" x-text="viewMode==='tree'?'✓':''"></span>{{ fd_icon("folder",14) }} Tree</div>
<div class="dd-item" :class="{active: viewMode==='list'}" @click="viewMode='list', previewItem=null, viewMenuOpen=false"><span class="check" x-text="viewMode==='list'?'✓':''"></span>{{ fd_icon("list",14) }} List</div>
<div class="dd-item" :class="{active: viewMode==='details'}" @click="viewMode='details', previewItem=null, viewMenuOpen=false"><span class="check" x-text="viewMode==='details'?'✓':''"></span>{{ fd_icon("bar-chart",14) }} Details</div>
<div class="dd-item" :class="{active: viewMode==='title'}" @click="viewMode='title', previewItem=null, viewMenuOpen=false"><span class="check" x-text="viewMode==='title'?'✓':''"></span>{{ fd_icon("grid",14) }} Cards</div>
<div class="dd-item" :class="{active: viewMode==='content'}" @click="viewMode='content', previewItem=null, viewMenuOpen=false"><span class="check" x-text="viewMode==='content'?'✓':''"></span>{{ fd_icon("align-left",14) }} Content</div>
</div>
</div>
<!-- Sort -->
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; viewMenuOpen=false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
</button>
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
<div class="dd-label">Sort by</div>
<div class="dd-item" :class="{active: sortBy==='name-asc'}" @click="sortBy='name-asc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='name-asc'?'✓':''"></span> Name A→Z</div>
<div class="dd-item" :class="{active: sortBy==='name-desc'}" @click="sortBy='name-desc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='name-desc'?'✓':''"></span> Name Z→A</div>
<div class="dd-item" :class="{active: sortBy==='type'}" @click="sortBy='type', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='type'?'✓':''"></span> Type</div>
<div class="dd-item" :class="{active: sortBy==='date-asc'}" @click="sortBy='date-asc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='date-asc'?'✓':''"></span> Oldest</div>
<div class="dd-item" :class="{active: sortBy==='date-desc'}" @click="sortBy='date-desc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='date-desc'?'✓':''"></span> Newest</div>
</div>
</div>
<!-- Filter -->
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="filterOpen=!filterOpen; viewMenuOpen=false; sortOpen=false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
</button>
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
<div class="dd-label">File type</div>
<div class="dd-item" :class="{active: filterType==='folder'}" @click="filterByType('folder'), filterOpen=false"><span class="check" x-text="filterType==='folder'?'✓':''"></span>{{ fd_icon("folder",14) }} Folders</div>
<div class="dd-item" :class="{active: filterType==='page'}" @click="filterByType('page'), filterOpen=false"><span class="check" x-text="filterType==='page'?'✓':''"></span>{{ fd_icon("file",14) }} Pages</div>
<div class="dd-item" :class="{active: filterType==='pdf'}" @click="filterByType('pdf'), filterOpen=false"><span class="check" x-text="filterType==='pdf'?'✓':''"></span>{{ fd_icon("file",14) }} PDF</div>
<div class="dd-item" :class="{active: filterType==='image'}" @click="filterByType('image'), filterOpen=false"><span class="check" x-text="filterType==='image'?'✓':''"></span>{{ fd_icon("image",14) }} Images</div>
<div class="dd-item" :class="{active: filterType==='code'}" @click="filterByType('code'), filterOpen=false"><span class="check" x-text="filterType==='code'?'✓':''"></span>{{ fd_icon("file",14) }} Code</div>
<div class="dd-item" :class="{active: filterType==='text'}" @click="filterByType('text'), filterOpen=false"><span class="check" x-text="filterType==='text'?'✓':''"></span>{{ fd_icon("file",14) }} Text</div>
<div class="more-sep" x-show="filterType"></div>
<div class="dd-item" x-show="filterType" @click="filterType='', doFilter(), filterOpen=false" style="color:var(--text-dim);">{{ fd_icon("x",14) }} Clear filter</div>
</div>
</div>
<!-- Search toggle -->
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; if(searchOpen) $nextTick(()=>$refs.searchInput?.focus())">
<svg 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>
<!-- Expand/Collapse (tree only) -->
<button class="ws-icon-btn" @click="expandAll()" title="Expand all" x-show="viewMode==='tree'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg></button>
<button class="ws-icon-btn" @click="collapseAll()" title="Collapse all" x-show="viewMode==='tree'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 15 12 9 18 15"/></svg></button>
<!-- Create buttons -->
<button class="ws-icon-btn" style="background:var(--bg-hover); border-radius:8px;" onclick="window.FlowDeck.createPage()" title="New page">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
</button>
</div>
</div>
<!-- Search bar (togglable) -->
<div class="ws-search-bar" x-show="searchOpen" x-transition>
<input x-ref="searchInput" type="text" placeholder="Filter files and folders..." x-model="searchQuery" @input="doFilter" autocomplete="off">
</div>
<!-- Bulk actions bar (Library-style) -->
<div class="ws-select-bar" x-show="selectedIdsCount > 0" x-transition>
<span class="sel-count" x-text="selectedIdsCount + ' selected'"></span>
<button @click="clearSelection()" title="Clear selection">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<button @click="copySelectedLinks()" title="Copy links">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</button>
<button @click="openMovePicker()" title="Move to">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
</button>
<div style="position:relative;margin-left:auto;">
<button @click.stop="moreOpen=!moreOpen" title="More actions">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
</button>
<div class="ws-select-menu" x-show="moreOpen" @click.outside="moreOpen=false" x-transition>
<div class="menu-item" @click="copySelectedLinks()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
Copy links to all
</div>
<div class="menu-item" @click="openMovePicker()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
Move to
</div>
<div class="more-sep"></div>
<div class="menu-item menu-danger" @click="bulkDelete()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
Move to Trash <span class="shortcut">Del</span>
</div>
</div>
</div>
</div>
<!-- Filter chips (visual indicator when filter is active — shown in all views) -->
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-html="getSvgIcon('folder',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-html="getSvgIcon('image',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-html="getSvgIcon('file',14)"></button>
<span class="filter-clear" @click="filterType='', doFilter()">clear filter</span>
</div>
<!-- Tree -->
<div x-show="viewMode==='tree'" style="display:contents;">
<!-- Select all toggle -->
<div x-show="viewMode==='tree' && displayTree.length>0"
style="display:flex;align-items:center;gap:6px;padding:4px 8px;margin-bottom:2px;">
<input type="checkbox" class="item-checkbox" :checked="allSelected" @click="selectAll($event)" title="Select / unselect all">
<span style="font-size:12px;color:var(--text-secondary);cursor:pointer;user-select:none;" @click="selectAll($event)"
x-text="allSelected ? 'Clear selection' : 'Select all'"></span>
<span x-show="selectedIdsCount > 0" style="font-size:12px;color:var(--text-dim);" x-text="'(' + selectedIdsCount + ' selected)'"></span>
</div>
<ul class="ws-tree" x-show="viewMode==='tree' && (displayTree.length>0 || (currentFolder > 0))" @click="handleTreeAction($event)">
<!-- Parent folder ".." -->
<li x-show="currentFolder > 0" style="list-style:none;">
<div class="ws-tree-item" @click="goToParent()" style="padding-left:4px;">
<span style="width:16px;flex-shrink:0;"></span>
<span class="icon">{{ fd_icon("folder",16) }}</span>
<span class="name folder" style="font-weight:600;">..</span>
</div>
</li>
<template x-for="node in (showAll ? displayTree : displayTree.slice(0, 5))" :key="node.id">
<li>
<div class="ws-tree-item" :style="{paddingLeft:(node.depth||0)*20+'px'}"
draggable="true"
:data-ws-id="node.id"
:data-folder-id="node.is_folder ? node.id : null"
@dragstart="onInternalDragStart($event, node.id)"
@dragend="draggedItemId = null, $el.style.opacity = ''"
:class="{ selected: !!selectedIds[node.id] }">
<input type="checkbox" class="item-checkbox"
:checked="!!selectedIds[node.id]"
@click.stop="toggleSelect(node, $event)"
title="Select item">
<!-- drag handle -->
<div class="drag-handle" style="margin-right:2px;" title="Drag to move">
<span class="dot"></span><span class="dot"></span>
<span class="dot"></span><span class="dot"></span>
<span class="dot"></span><span class="dot"></span>
</div>
<!-- chevron -->
<template x-if="node.is_folder && node.children && node.children.length > 0">
<button class="ws-chevron"
:class="{ open: expanded[node.id] }"
@click.stop="toggleExpand(node.id)">▶</button>
</template>
<template x-if="!(node.is_folder && node.children && node.children.length > 0)">
<span style="width:16px;flex-shrink:0;"></span>
</template>
<span class="icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<template x-if="node.is_folder">
<span class="name folder" @click.stop="navigateToFolder(node.id)"
@dblclick.prevent.stop="startInlineRename(node, $event)">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-show="renamingId !== node.id" x-text="node.name"></span>
<span class="child-count" x-show="node.child_count > 0 && renamingId !== node.id"
x-text="'(' + node.child_count + ')'"></span>
<input x-show="renamingId === node.id" class="inline-rename-input" x-model="renamingName"
@keydown.enter="commitRename(node)" @keydown.escape="cancelRename()"
@blur="commitRename(node)" @click.stop>
</span>
</template>
<template x-if="!node.is_folder">
<span class="name file" @click.stop="startInlineRename(node, $event)"
@mouseenter="showPreview($event, node)"
@mouseleave="hidePreview()"
title="Click to rename, double-click to open">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-show="renamingId !== node.id" x-text="node.name"></span>
<input x-show="renamingId === node.id" class="inline-rename-input" x-model="renamingName"
@keydown.enter="commitRename(node)" @keydown.escape="cancelRename()"
@blur="commitRename(node)" @click.stop>
<a href="#" class="btn-open hover-only" @click.prevent.stop="selectForPreview(node)" title="Open in side preview">↢ Open</a>
</span>
</template>
<div class="actions">
<template x-if="node.is_folder">
<span style="display:flex;gap:2px">
<button class="ws-act" @click.stop="window.FlowDeck.createPage()" title="New file">{{ fd_icon("file",14) }}</button>
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal()" title="New folder">{{ fd_icon("folder",14) }}</button>
<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">{{ fd_icon("external-link",14) }}</a>
</span>
</template>
<button class="ws-act" @click.stop="startRename(node)" title="Rename">{{ fd_icon("edit",14) }}</button>
<button class="ws-act danger" @click.stop="deleteWithUndo(node)" title="Delete">{{ fd_icon("trash",14) }}</button>
</div>
</div>
<!-- Children -->
<ul class="ws-tree" x-show="expanded[node.id]" x-transition
x-html="renderChildren(node.children, (node.depth||0)+1)">
</ul>
</li>
</template>
<!-- "X more" toggle -->
<li x-show="displayTree.length > 5 && !showAll" style="list-style:none;">
<div class="ws-tree-item ws-more-item" @click="showAll = true" style="padding-left:4px;cursor:pointer;">
<span style="width:16px;flex-shrink:0;"></span>
<span class="icon">⋯</span>
<span class="name folder" style="color:var(--text-secondary);font-style:italic;"
x-text="displayTree.length - 5 + ' more...'"></span>
</div>
</li>
<!-- "Show less" toggle -->
<li x-show="displayTree.length > 5 && showAll" style="list-style:none;">
<div class="ws-tree-item ws-more-item" @click="showAll = false" style="padding-left:4px;cursor:pointer;">
<span style="width:16px;flex-shrink:0;"></span>
<span class="icon">🔼</span>
<span class="name folder" style="color:var(--text-secondary);">Show less</span>
</div>
</li>
</ul>
</div>
<!-- Tag filter bar -->
<div class="tag-filter-bar" x-show="workspaceTags && workspaceTags.length > 0">
<span style="font-size:11px;color:var(--text-tertiary);margin-right:4px;">Tags:</span>
<template x-for="t in workspaceTags" :key="'tf'+t.id">
<button class="tag-chip" :class="{ active: tagFilter === t.name }"
:style="{background: t.color}"
@click="toggleTagFilter(t.name)">
<span x-text="t.name"></span>
<span style="font-size:10px;opacity:.7" x-text="'('+t.count+')'"></span>
</button>
</template>
<button x-show="tagFilter" class="tag-chip rm-btn" @click="tagFilter='', doFilter()" style="margin-left:4px;">clear</button>
</div>
<!-- Filter table (shown when search/filter/tag is active) -->
<table class="ws-table-enhanced" x-show="(searchQuery || filterType || tagFilter) && filteredTableItems.length > 0">
<thead><tr>
<th style="width:30px"><input type="checkbox" :checked="allSelected" @click="selectAllFiltered($event)"></th>
<th>Name</th>
<th>Path</th>
<th>Size</th>
<th>Modified</th>
<th>Type</th>
<th>Author</th>
<th>Tags</th>
<th style="width:60px"></th>
</tr></thead>
<tbody>
<template x-for="node in filteredTableItems" :key="'ft'+node.id">
<tr :class="{ selected: !!selectedIds[node.id] }">
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td>
<td>
<span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
</td>
<td class="col-path" x-text="node._path || '—'"></td>
<td class="col-size" x-text="node.size_display || '—'"></td>
<td class="col-date" x-text="_formatDate(node.updated_at)"></td>
<td class="col-type" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
<td class="col-author" x-text="node.author || '—'"></td>
<td class="col-tags">
<span style="display:flex;align-items:center;gap:2px;flex-wrap:wrap;">
<template x-for="t in (node.tags||[])" :key="'ftag'+t.id">
<span class="tag-chip" :style="{background: t.color}">
<span x-text="t.name"></span>
<button class="tag-chip rm-btn" @click.stop="removeTag(node.id, t.id)" title="Remove tag">×</button>
</span>
</template>
<button class="tag-add-btn" @click.stop="startTagInput(node)" title="Add tag">+</button>
<span x-show="tagInputId === node.id" style="display:contents;">
<input class="tag-input" x-ref="tagInputEl"
@keydown.enter="addTag(node.id, $event.target.value), $event.target.value=''"
@keydown.escape="tagInputId=null"
@blur="tagInputId=null"
@click.stop
placeholder="tag...">
</span>
</span>
</td>
<td>
<button class="ws-act" @click.stop="startRename(node)" title="Rename">{{ fd_icon("edit",14) }}</button>
<button class="ws-act danger" @click.stop="deleteWithUndo(node)" title="Delete">{{ fd_icon("trash",14) }}</button>
</td>
</tr>
</template>
</tbody>
</table>
<!-- List view — compact flat list -->
<table class="ws-table-compact" x-show="viewMode==='list' && !searchQuery && !filterType && !tagFilter && displayTree.length>0">
<thead><tr>
<th style="width:24px"></th>
<th>Name</th>
<th style="width:80px">Type</th>
<th style="width:80px">Size</th>
<th style="width:100px">Modified</th>
</tr></thead>
<tbody>
<!-- Parent folder ".." -->
<tr x-show="currentFolder > 0" @click="goToParent()" style="cursor:pointer" class="parent-folder-row">
<td>{{ fd_icon("folder",16) }}</td>
<td class="col-name"><span style="font-weight:600;color:var(--text);">..</span></td>
<td style="font-size:12px;color:var(--text-tertiary)">Folder</td>
<td style="font-size:12px;color:var(--text-secondary)"></td>
<td style="font-size:12px;color:var(--text-tertiary)"></td>
</tr>
<template x-for="node in displayTree" :key="'l'+node.id">
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer">
<td><span x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
<td class="col-name" x-text="node.name"></td>
<td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
<td style="font-size:12px;color:var(--text-secondary)" x-text="node.is_folder ? (node.child_count||0)+' items' : (node.size_display||'—')"></td>
<td style="font-size:12px;color:var(--text-tertiary)" x-text="_formatDate(node.updated_at)"></td>
</tr>
</template>
</tbody>
</table>
<!-- Details view — full table with all columns -->
<table class="ws-table-enhanced" x-show="viewMode==='details' && !searchQuery && !filterType && !tagFilter && displayTree.length>0">
<thead><tr>
<th style="width:24px"></th>
<th>Name</th>
<th>Path</th>
<th>Size</th>
<th>Modified</th>
<th>Type</th>
<th>Author</th>
<th>Tags</th>
</tr></thead>
<tbody>
<!-- Parent folder ".." -->
<tr x-show="currentFolder > 0" @click="goToParent()" style="cursor:pointer" class="parent-folder-row">
<td>{{ fd_icon("folder",16) }}</td>
<td class="col-name"><span style="font-weight:600;color:var(--text);">..</span></td>
<td class="col-path"></td>
<td class="col-size"></td>
<td class="col-date"></td>
<td class="col-type">Folder</td>
<td class="col-author"></td>
<td class="col-tags"></td>
</tr>
<template x-for="node in displayTree" :key="'d'+node.id">
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<td><span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
<td class="col-name" x-text="node.name"></td>
<td class="col-path" x-text="node._path || '—'"></td>
<td class="col-size" x-text="node.size_display || '—'"></td>
<td class="col-date" x-text="_formatDate(node.updated_at)"></td>
<td class="col-type" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
<td class="col-author" x-text="node.author || '—'"></td>
<td class="col-tags">
<span style="display:flex;align-items:center;gap:2px;flex-wrap:wrap;">
<template x-for="t in (node.tags||[])" :key="'dtag'+t.id">
<span class="tag-chip" :style="{background: t.color}"><span x-text="t.name"></span></span>
</template>
</span>
</td>
</tr>
</template>
</tbody>
</table>
<!-- Title view — card grid -->
<div class="title-grid" x-show="viewMode==='title' && !searchQuery && !filterType && !tagFilter && displayTree.length>0">
<!-- Parent folder ".." -->
<div class="title-card parent-folder-card" x-show="currentFolder > 0" @click="goToParent()" style="cursor:pointer">
<div class="title-card-icon">{{ fd_icon("folder",24) }}</div>
<div class="title-card-name" style="font-weight:600;">..</div>
<div class="title-card-meta"><span>Parent folder</span></div>
</div>
<template x-for="node in displayTree" :key="'g'+node.id">
<div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
:class="{ 'selected-card': previewItem && previewItem.id === node.id }">
<div class="title-card-icon" x-html="node.is_folder ? getSvgIcon('folder',24) : _fileIcon(node.name, node.is_folder, node.content_format)"></div>
<div class="title-card-name">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-text="node.name"></span>
</div>
<div class="title-card-meta">
<span x-text="_formatDate(node.updated_at)"></span>
<span x-text="node.size_display || '—'"></span>
</div>
</div>
</template>
</div>
<!-- Content view — list with inline preview -->
<div class="content-list" x-show="viewMode==='content' && !searchQuery && !filterType && !tagFilter && displayTree.length>0">
<!-- Parent folder ".." -->
<div class="content-item parent-folder-item" x-show="currentFolder > 0" @click="goToParent()" style="cursor:pointer">
<div class="content-item-header">
{{ fd_icon("folder",16) }}
<span class="col-name" style="font-weight:600;">..</span>
</div>
<div class="content-item-preview">Parent folder</div>
</div>
<template x-for="node in displayTree" :key="'c'+node.id">
<div class="content-item" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<div class="content-item-header">
<span x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span class="col-name" x-text="node.name"></span>
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span>
</div>
<div class="content-item-preview" x-text="node.is_folder ? (node.child_count||0)+' items' : (node.size_display||'—')"></div>
<div class="content-item-tags" x-show="(node.tags||[]).length > 0">
<template x-for="t in (node.tags||[])" :key="'ctag'+t.id">
<span class="tag-chip" :style="{background: t.color}"><span x-text="t.name"></span></span>
</template>
</div>
</template>
</div>
</div>
</div>
<!-- Old table view kept for backward compat, hidden -->
<table class="ws-table" x-show="false" style="display:none">
<thead><tr><th>Name</th><th>Type</th><th>Size</th></tr></thead>
<tbody></tbody>
</table>
<!-- Empty -->
<div class="empty-state" x-show="tree.length===0&&loaded">
<div class="empty-icon">{{ fd_icon("folder",48) }}</div>
<h2 x-text="currentFolder > 0 ? 'Empty folder' : 'Welcome to your Workspace'"></h2>
<p x-text="currentFolder > 0 ? 'Create a new file or folder to get started' : 'Drag & drop files from your computer, or create new ones.'"></p>
<div class="empty-actions">
<button class="btn-icon" onclick="window.FlowDeck.createPage()" title="New file">{{ fd_icon("file",16) }}</button>
<button class="btn-icon" onclick="window.FlowDeck.showCreateFolderModal()" title="New folder">{{ fd_icon("folder",16) }}</button>
</div>
</div>
<!-- Offline banner -->
<div class="offline-banner" x-show="!online" x-transition>
<span>{{ fd_icon('alert-triangle',14) }} You are offline. Changes will sync when connection is restored.</span>
</div>
<!-- Move-to modal -->
<div class="modal-overlay" x-show="moveModalOpen" @click.self="moveModalOpen=false" @keydown.escape="moveModalOpen=false" style="display:none">
<div class="modal-box" style="width:440px;max-width:90vw;padding:0;overflow:hidden;display:flex;flex-direction:column;max-height:60vh;">
<div style="padding:14px 16px;border-bottom:1px solid var(--border);font-size:14px;font-weight:600;color:var(--text-primary);">Move to…</div>
<div style="padding:8px 12px;border-bottom:1px solid var(--border);">
<input class="modal-input" type="text" placeholder="Search folders..." x-model="moveSearchQuery" @input="doMoveSearch()" style="padding:7px 10px;font-size:13px;">
</div>
<div style="flex:1;overflow-y:auto;padding:4px;">
<div class="move-modal-item" @click="confirmBulkMove(0)">{{ fd_icon("folder",14) }} <span>Root (no parent)</span></div>
<template x-for="it in moveCandidates" :key="'mv'+it.id">
<div class="move-modal-item" :style="{paddingLeft: (8 + (it.depth||0)*20) + 'px'}" @click="confirmBulkMove(it.id)">
{{ fd_icon("folder",14) }} <span x-text="it.name"></span>
</div>
</template>
<div x-show="moveCandidates.length === 0" style="padding:20px;text-align:center;color:var(--text-tertiary);font-size:13px;">No folders found</div>
</div>
</div>
</div>
<!-- Create Modal -->
<div class="modal-overlay" x-show="showCreate" @click.outside="showCreate=false" @keydown.escape="showCreate=false" style="display:none">
<div class="modal-box">
<h3><span x-show="createType==='folder'">{{ fd_icon('folder',16) }}</span><span x-show="createType!='folder'">{{ fd_icon('file',16) }}</span> <span x-text="createType==='folder'?'New Folder':'New File'"></span></h3>
<p class="modal-sub">
In <strong>{{ workspace_name }}</strong> <span x-show="folderStack.length>1">/ <span x-text="(folderStack[folderStack.length-1]||{}).name||''"></span></span>
<span x-show="parentFolder"> / <span x-text="parentFolder?.name"></span></span>
</p>
<input class="modal-input" x-model="newName" :placeholder="createType==='folder'?'Folder name':'File name'" @keydown.enter="doCreate" x-ref="cinp">
<div class="modal-actions">
<button class="btn btn-secondary" @click="showCreate=false">Cancel</button>
<button class="btn btn-primary" @click="doCreate" :disabled="!newName.trim()">Create</button>
</div>
</div>
</div>
<!-- Rename Modal -->
<div class="modal-overlay" x-show="showRename" @click.outside="showRename=false" @keydown.escape="showRename=false" style="display:none">
<div class="modal-box">
<h3>{{ fd_icon("edit",14) }} Rename</h3>
<p class="modal-sub">Rename <strong x-text="target?.name"></strong></p>
<input class="modal-input" x-model="newName" @keydown.enter="doRename" x-ref="rinp">
<div class="modal-actions">
<button class="btn btn-secondary" @click="showRename=false">Cancel</button>
<button class="btn btn-primary" @click="doRename">Rename</button>
</div>
</div>
</div>
<!-- Delete Modal -->
<div class="modal-overlay" x-show="showDelete" @click.outside="showDelete=false" @keydown.escape="showDelete=false" style="display:none">
<div class="modal-box">
<h3>{{ fd_icon("trash",16) }} Delete</h3>
<p class="modal-sub">Delete <strong x-text="target?.name"></strong>?</p>
<div class="delete-confirm">This cannot be undone. Children will also be deleted.</div>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showDelete=false">Cancel</button>
<button class="btn btn-danger" @click="doDelete">Delete</button>
</div>
</div>
</div>
</div><!-- /ws-main -->
<!-- Context menu (Library-style) — MENU UNIQUE partagé (workspace ⇄ library) -->
{% include "_ctx_menu.html" %}
<!-- Preview tooltip -->
<div class="file-preview" x-show="previewVisible"
:style="{ left: previewX + 'px', top: previewY + 'px' }"
@mouseenter="previewVisible = true" @mouseleave="previewVisible = false">
<div class="file-preview-header" x-text="previewName"></div>
<div class="file-preview-body" x-html="previewContent"></div>
</div>
<!-- Preview Panel — identical to Library peek-overlay -->
<div id="ws-preview-panel" style="position:fixed;top:0;right:0;width:560px;max-width:90vw;height:100vh;background:var(--bg-modal);border-left:1px solid var(--border);box-shadow:-8px 0 32px rgba(0,0,0,0.25);z-index:500;display:none;flex-direction:column;transform:translateX(100%);transition:transform 0.2s cubic-bezier(0.16,1,0.3,1);">
<div id="ws-preview-resize-handle" style="position:absolute;left:-4px;top:0;width:8px;height:100%;cursor:col-resize;z-index:501;" title="Drag to resize — click to close"></div>
<div class="peek-header" style="display:flex;align-items:center;gap:4px;padding:12px 16px;border-bottom:1px solid var(--border);">
<button onclick="window._wsData.closePreviewPanel()" title="Close" style="background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px;border-radius:4px;display:flex;align-items:center;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px;height:15px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<div class="peek-title" style="flex:1;font-size:15px;font-weight:600;color:var(--text-primary);display:flex;align-items:center;gap:8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">
<span id="ws-preview-icon"></span>
<span id="ws-preview-title"></span>
</div>
<button id="ws-preview-open-btn" title="Open full page" style="background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px;border-radius:4px;display:flex;align-items:center;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px;height:15px;"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</button>
<button id="ws-preview-fav-btn" title="Favorite" style="background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px;border-radius:4px;display:flex;align-items:center;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px;height:15px;"><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>
</button>
<button id="ws-preview-copy-btn" title="Copy link" style="background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px;border-radius:4px;display:flex;align-items:center;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px;height:15px;"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</button>
<div style="position:relative;">
<button onclick="var m=document.getElementById('ws-preview-more-menu');m.style.display=m.style.display==='block'?'none':'block';" title="More" style="background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px;border-radius:4px;display:flex;align-items:center;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px;height:15px;"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
</button>
<div id="ws-preview-more-menu" class="more-menu" style="display:none;right:0;top:100%;min-width:180px;">
<div class="menu-item" onclick="document.getElementById('ws-preview-open-btn').click();document.getElementById('ws-preview-more-menu').style.display='none';">{{ fd_icon("external-link",14) }} Open</div>
<div class="menu-item" onclick="document.getElementById('ws-preview-fav-btn').click();document.getElementById('ws-preview-more-menu').style.display='none';">{{ fd_icon("star",14) }} Favorite</div>
<div class="menu-item" onclick="document.getElementById('ws-preview-copy-btn').click();document.getElementById('ws-preview-more-menu').style.display='none';">{{ fd_icon("link",14) }} Copy link</div>
<div class="more-sep"></div>
<div class="menu-item menu-danger" onclick="window._wsData.closePreviewPanel();document.getElementById('ws-preview-more-menu').style.display='none';">{{ fd_icon("trash",14) }} Close</div>
</div>
</div>
</div>
<iframe id="ws-preview-iframe" src="" style="width:100%;height:100%;border:none;flex:1;"></iframe>
</div>
</div><!-- /ws-split -->
</div><!-- /_wsInitData wrapper -->
{% endblock %}