3112 lines
155 KiB
HTML
3112 lines
155 KiB
HTML
{% 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 — state lives in the shared fdCtx Alpine store (_ctx_menu.html)
|
||
|
||
// Tags
|
||
workspaceTags: [],
|
||
tagFilter: '',
|
||
tagInputId: null,
|
||
tagsVersion: 0,
|
||
|
||
// 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; });
|
||
// Side-peek editor posts renames here so the tree/peek title stay in sync.
|
||
window.addEventListener('message', function(ev) {
|
||
if (ev.origin !== window.location.origin) return;
|
||
var m = ev.data;
|
||
if (!m || m.type !== 'fd-page-renamed' || m.id == null) return;
|
||
var node = self._findNodeById(self.tree, m.id);
|
||
if (node) node.name = m.title || 'Untitled';
|
||
self.displayTree = self.displayTree.slice();
|
||
var t = document.getElementById('ws-preview-title');
|
||
if (t && self.previewItem && self.previewItem.id === m.id) t.textContent = m.title || 'Untitled';
|
||
var side = document.querySelector('.sidebar-item.ws-tree-item[data-id="' + m.id + '"] .page-name');
|
||
if (side) side.textContent = m.title || 'Untitled';
|
||
});
|
||
// 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;
|
||
this.previewItem = node;
|
||
document.getElementById('ws-preview-icon').innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
|
||
document.getElementById('ws-preview-title').textContent = node.name;
|
||
var openBtn = document.getElementById('ws-preview-open-btn');
|
||
openBtn.onclick = function() { if (window.fdNavigate) window.fdNavigate('/pages/' + node.id); else 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
},
|
||
|
||
// ── Content snippet for content view ──
|
||
contentSnippetMap: {},
|
||
|
||
// ── Context menu (menu unifié partagé via le store Alpine fdCtx) ──
|
||
onContextMenu(ev) {
|
||
var el = ev.target.closest('[data-ws-id]');
|
||
if (!el) return;
|
||
var id = parseInt(el.getAttribute('data-ws-id'));
|
||
if (!id) return;
|
||
var node = this._findNodeById(this.displayTree, id) || this._findNodeById(this.tree, id);
|
||
if (!node) return;
|
||
var store = window.Alpine && Alpine.store('fdCtx');
|
||
if (!store) return;
|
||
var self = this;
|
||
if (!this.workspaceTags || this.workspaceTags.length === 0) this.loadWorkspaceTags();
|
||
store.setAvail(this._ctxAvailTags(node));
|
||
var handlers = {
|
||
open: function (n) { self.openPage(n.id); },
|
||
openTab: function (n) { window.open('/pages/' + n.id, '_blank'); },
|
||
peek: function (n) { self.openSidePeek(n.id); },
|
||
folder: function (n) { if (n.is_folder) self.navigateToFolder(n.id); },
|
||
rename: function (n) { self.startRename(n); },
|
||
setIcon: function (icon) { self.setItemIcon(node, icon); },
|
||
duplicate: function (n) { self.duplicateItem(n); },
|
||
link: function (n) { self.copyItemLink(n); },
|
||
download: function (n) { self.downloadItem(n); },
|
||
copyContent: function (n) { self.copyItemContent(n); },
|
||
move: function (n) { self._ctxMove(n); },
|
||
fav: function (n) { self.toggleFavorite(n); },
|
||
delete: function (n) { self.deleteWithUndo(n); },
|
||
tagExisting: function (t) { self.ctxAddExistingTag(t); },
|
||
tagAdd: function (name, color) { self.ctxAddNewTag(name, color); },
|
||
tagRemove: function (tagId) { self.ctxRemoveTag(tagId); }
|
||
};
|
||
store.openMenu(ev, node, 'workspace', handlers);
|
||
},
|
||
|
||
downloadItem(node) {
|
||
if (!node || node.is_folder) return;
|
||
var url = '/api/pages/' + node.id + '/download';
|
||
var a = document.createElement('a');
|
||
a.href = url;
|
||
a.style.display = 'none';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
if (window.showToast) window.showToast('Download started', 'success');
|
||
},
|
||
|
||
async copyItemContent(node) {
|
||
if (!node || node.is_folder) return;
|
||
try {
|
||
var r = await fetch('/api/pages/' + node.id + '/file-content');
|
||
var d = await r.json();
|
||
if (d.ok && d.content !== undefined) {
|
||
await navigator.clipboard.writeText(d.content);
|
||
if (window.showToast) window.showToast('Content copied to clipboard', 'success');
|
||
} else {
|
||
if (window.showToast) window.showToast('Not a text file', 'error');
|
||
}
|
||
} catch (e) {
|
||
if (window.showToast) window.showToast('Copy failed', 'error');
|
||
}
|
||
},
|
||
|
||
_ctxAvailTags(node) {
|
||
return (this.workspaceTags || []).filter(function(t) {
|
||
return !((node && node.tags) || []).some(function(nt) { return nt.id === t.id; });
|
||
});
|
||
},
|
||
|
||
_ctxRefreshAvail() {
|
||
var store = window.Alpine && Alpine.store('fdCtx');
|
||
if (store && store.node) store.setAvail(this._ctxAvailTags(store.node));
|
||
},
|
||
|
||
copyItemLink(node) {
|
||
if (!node) return;
|
||
navigator.clipboard.writeText(window.location.origin + '/pages/' + node.id);
|
||
if (window.showToast) window.showToast('Link copied', 'success');
|
||
},
|
||
|
||
async toggleFavorite(node) {
|
||
if (!node) return;
|
||
try {
|
||
var method = node.favorited ? 'DELETE' : 'POST';
|
||
var r = await fetch('/board/api/favorites/' + node.id, { method: method });
|
||
if (r.ok) {
|
||
node.favorited = !node.favorited;
|
||
if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
|
||
if (window.showToast) window.showToast(node.favorited ? 'Added to Favorites' : 'Removed from Favorites', 'success');
|
||
}
|
||
} catch(e) {}
|
||
},
|
||
|
||
async setItemIcon(node, icon) {
|
||
if (!node || !node.id) return;
|
||
icon = (icon || '').trim();
|
||
try {
|
||
var r = await fetch('/board/api/pages/' + node.id + '/icon', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({icon: icon})
|
||
});
|
||
if (!r.ok) throw new Error('icon update failed');
|
||
node.page_icon = icon;
|
||
this.displayTree = [...this.tree];
|
||
this._reloadAfterAction();
|
||
if (window.showToast) window.showToast('Icon updated', 'success');
|
||
} catch(e) {
|
||
if (window.showToast) window.showToast('Failed to update icon', 'error');
|
||
}
|
||
},
|
||
|
||
_ctxMove(node) {
|
||
if (!node) return;
|
||
this.selectedIds = {};
|
||
this.selectedIds[node.id] = true;
|
||
this.openMovePicker();
|
||
},
|
||
|
||
// ── 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);
|
||
},
|
||
|
||
ctxRemoveTag(tagId) {
|
||
var store = window.Alpine && Alpine.store('fdCtx');
|
||
var node = store && store.node;
|
||
if (!node) return;
|
||
this.removeTag(node.id, tagId);
|
||
node.tags = (node.tags || []).filter(function(t) { return t.id !== tagId; });
|
||
this._ctxRefreshAvail();
|
||
},
|
||
|
||
async ctxAddNewTag(tagName, color) {
|
||
tagName = (tagName || '').trim();
|
||
if (!tagName) return;
|
||
var store = window.Alpine && Alpine.store('fdCtx');
|
||
var node = store && store.node;
|
||
if (!node) return;
|
||
color = color || (store && store.newTagColor) || '#787774';
|
||
try {
|
||
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({name: tagName, color: color})
|
||
});
|
||
if (r.ok) {
|
||
var d = await r.json();
|
||
var cur = node.tags || [];
|
||
if (!cur.some(function(t) { return t.id === d.tag.id; })) node.tags = cur.concat([d.tag]);
|
||
this._updateNodeTags(this.tree, node.id, d.tag, 'add');
|
||
this.displayTree = [...this.tree];
|
||
this.tagsVersion++;
|
||
await this.loadWorkspaceTags();
|
||
this._computeFilteredTableItems();
|
||
this._ctxRefreshAvail();
|
||
}
|
||
} catch(e) {}
|
||
},
|
||
|
||
_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, c.page_icon);
|
||
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.fdNavigate?window.fdNavigate(\'/pages/'+escId+'\'):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,'"');
|
||
},
|
||
|
||
_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(id);
|
||
break;
|
||
case 'new-folder':
|
||
window.FlowDeck.showCreateFolderModal(id);
|
||
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();
|
||
if (window.appState && window.appState.refreshSidebarTree) window.appState.refreshSidebarTree();
|
||
if (window.FlowDeck && window.FlowDeck.syncPageTitle) window.FlowDeck.syncPageTitle(node.id, n);
|
||
var self = this;
|
||
if (this.folderStack && this.folderStack.length) {
|
||
this.folderStack.forEach(function(f) { if (String(f.id) === String(node.id)) f.name = n; });
|
||
}
|
||
if (this.breadcrumb && this.breadcrumb.length) {
|
||
this.breadcrumb.forEach(function(b) { if (String(b.id) === String(node.id)) b.name = n; });
|
||
}
|
||
if (this.previewItem && String(this.previewItem.id) === String(node.id)) {
|
||
this.previewItem.name = n;
|
||
var pt = document.getElementById('ws-preview-title');
|
||
if (pt) pt.textContent = n;
|
||
}
|
||
}
|
||
},
|
||
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) { if (window.fdNavigate) window.fdNavigate('/local-workspace'); else window.location = '/local-workspace'; return; }
|
||
// Walk tree to find matching folder
|
||
var targetId = this._findFolderByPath(this.tree, parts, 0);
|
||
if (targetId) {
|
||
if (window.fdNavigate) window.fdNavigate('/local-workspace?folder=' + targetId);
|
||
else 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, customIcon) {
|
||
if (customIcon) {
|
||
var html = window.fdIconHtml ? window.fdIconHtml(customIcon, 14) : this.esc(customIcon);
|
||
return '<span class="page-icon-svg" style="font-size:14px;line-height:1;display:inline-flex;align-items:center;">'+html+'</span>';
|
||
}
|
||
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) { if (window.fdNavigate) window.fdNavigate('/pages/' + id); else 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.tagsVersion++;
|
||
await this.loadWorkspaceTags();
|
||
this._computeFilteredTableItems();
|
||
}
|
||
} 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.tagsVersion++;
|
||
await this.loadWorkspaceTags();
|
||
this._computeFilteredTableItems();
|
||
}
|
||
} catch(e) {}
|
||
},
|
||
|
||
_updateNodeTags(nodes, itemId, tag, action, removeTagId) {
|
||
for (var i=0; i<nodes.length; i++) {
|
||
if (nodes[i].id === itemId) {
|
||
if (action === 'add') {
|
||
// Réassigne un nouveau tableau → déclenche la réactivité Alpine
|
||
var cur = nodes[i].tags || [];
|
||
if (!cur.some(function(t) { return t.id === tag.id; })) {
|
||
nodes[i].tags = cur.concat([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;
|
||
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
|
||
</script>
|
||
<script data-cfasync="false">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)"
|
||
@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">
|
||
<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>
|
||
|
||
<!-- 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>
|
||
|
||
<!-- 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, node.page_icon)"></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(node.id)" title="New file">{{ fd_icon("file",14) }}</button>
|
||
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal(node.id)" 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, tagsVersion)">
|
||
</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" style="display:inline-flex;align-items:center;justify-content:center;"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;color:var(--text-dim);"><polyline points="18 15 12 9 6 15"/></svg></span>
|
||
<span class="name folder" style="color:var(--text-secondary);">Show less</span>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</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, node.page_icon)"></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, node.page_icon)"></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, node.page_icon)"></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, node.page_icon)"></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, node.page_icon)"></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 %}
|