CSS: - Ajouté: ws-container, ws-tabs-row, ws-tab, ws-icon-btn, ws-btn-primary - Ajouté: ws-dropdown, ws-search-bar, ws-select-bar, ws-row - Ajouté: row-name, row-icon, row-title, hover-only, ws-checkbox - Ajouté: tree-chevron, btn-open, more-menu (Library-style) - Conservé: ws-tree, ws-tree-item, ws-act, breadcrumb-row pour compatibilité Navbar: - Breadcrumb + toolbar fusionnés en une seule row propre - Dropdowns style Library (View, Sort) - Search toggle avec barre coulissante - Bulk bar → ws-select-bar avec icônes SVG Context menu: - .ctx-menu → .more-menu (style Library) - Toutes les refs JS mises à jour 143 tests passent
2808 lines
137 KiB
HTML
2808 lines
137 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;}
|
||
|
||
/* ── 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;}
|
||
|
||
/* ── 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:auto;}
|
||
.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 .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:100px;}
|
||
|
||
/* 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>
|
||
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: '',
|
||
editingPath: false,
|
||
folderStack: [],
|
||
|
||
// Inline rename
|
||
renamingId: null,
|
||
renamingName: '',
|
||
renameInputRef: null,
|
||
|
||
// Preview cache
|
||
previewCache: {},
|
||
|
||
// Clipboard for copy/paste
|
||
clipboard: [],
|
||
|
||
// Sort
|
||
sortBy: '',
|
||
viewMode: localStorage.getItem('fd_default_view') || 'tree',
|
||
viewMenuOpen: false,
|
||
sortOpen: false,
|
||
searchOpen: false,
|
||
viewIcons: {tree:'',list:'',details:'',title:'',content:''},
|
||
viewLabels: {tree:'Tree',list:'List',details:'Details',title:'Cards',content:'Content'},
|
||
|
||
// Context menu
|
||
ctxMenu: {visible:false,x:0,y:0,node:null,tags:[]},
|
||
ctxTagAdding: false,
|
||
ctxTagExistingOpen: false,
|
||
ctxAvailTags: [],
|
||
ctxNewTagColor: '#787774',
|
||
ctxTagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
|
||
|
||
// Tags
|
||
workspaceTags: [],
|
||
tagFilter: '',
|
||
tagInputId: null,
|
||
|
||
// Preview panel
|
||
previewItem: null,
|
||
previewPanelContent: '',
|
||
|
||
// Multi-select
|
||
selectedIds: {},
|
||
selectedIdsCount: 0,
|
||
lastSelectedId: null,
|
||
|
||
// Undo
|
||
undoVisible: false,
|
||
undoMessage: '',
|
||
undoItemId: null,
|
||
undoTimeout: null,
|
||
|
||
// Upload tracking
|
||
uploadSpeed: '',
|
||
uploadStartTime: 0,
|
||
|
||
// Path bar
|
||
pathValue: '/workspace/',
|
||
|
||
async navigateToRoot() {
|
||
this.folderStack = [];
|
||
this.navIndex = 0;
|
||
this._saveExpandedState();
|
||
await this._loadFolder(0);
|
||
},
|
||
|
||
async navigateToBreadcrumb(idx) {
|
||
this.folderStack = this.folderStack.slice(0, idx + 1);
|
||
this.navIndex = idx;
|
||
this._saveExpandedState();
|
||
var entry = this.folderStack[idx];
|
||
await this._loadFolder(entry ? entry.id : 0);
|
||
},
|
||
|
||
startPathEdit() {
|
||
this.editingPath = true;
|
||
var self = this;
|
||
this.$nextTick(function() {
|
||
var inp = self.$refs.pathEdit;
|
||
if (inp) { inp.focus(); inp.select(); }
|
||
});
|
||
},
|
||
|
||
// Drag indicators
|
||
dragCount: 0,
|
||
dragOverParentId: null,
|
||
|
||
// Online/offline
|
||
online: navigator.onLine,
|
||
pinnedIds: (function() {
|
||
try { return JSON.parse(localStorage.getItem('fd_ws_pinned') || '[]'); }
|
||
catch(e) { return []; }
|
||
})(),
|
||
|
||
// Navigation history
|
||
navHistory: [],
|
||
navIndex: -1,
|
||
canGoBack: false,
|
||
canGoForward: false,
|
||
|
||
// Preview popup
|
||
previewVisible: false,
|
||
previewContent: '',
|
||
previewName: '',
|
||
previewX: 0,
|
||
previewY: 0,
|
||
|
||
// Drag & drop state
|
||
isDragging: false,
|
||
dropTarget: null,
|
||
dropTargetName: '',
|
||
uploading: false,
|
||
uploadStatus: '',
|
||
uploadProgress: 0,
|
||
draggedItemId: null,
|
||
|
||
async init() {
|
||
try {
|
||
// Expose globally for event handlers (BEFORE await)
|
||
window._wsData = this;
|
||
var url = '/api/local-workspace/tree';
|
||
if (this.currentFolder && this.currentFolder > 0) {
|
||
url += '?folder=' + this.currentFolder;
|
||
}
|
||
var r = await fetch(url);
|
||
var d = await r.json();
|
||
// Keep tree hierarchical, only roots in displayTree
|
||
var roots = d.tree || [];
|
||
this._markTree(roots, 0);
|
||
this.tree = roots;
|
||
this.displayTree = roots;
|
||
this.breadcrumb = d.breadcrumb || [];
|
||
this.loaded = true;
|
||
// Initialize folder stack from breadcrumb
|
||
this.folderStack = (this.breadcrumb || []).map(function(b) { return {id: b.id, name: b.name}; });
|
||
this.navIndex = this.folderStack.length > 0 ? this.folderStack.length - 1 : 0;
|
||
// Expose globally for renderChildren onclick handlers
|
||
window._wsData = this;
|
||
// Listen for online/offline
|
||
var self = this;
|
||
window.addEventListener('online', function() { self.online = true; });
|
||
window.addEventListener('offline', function() { self.online = false; });
|
||
// Load workspace tags
|
||
this.loadWorkspaceTags();
|
||
} catch(e) { console.error('Tree load error:', e); this.loaded = true; }
|
||
this._recompute();
|
||
},
|
||
|
||
_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();
|
||
},
|
||
|
||
// ── 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) {
|
||
if (ev.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 (ev.ctrlKey || ev.metaKey) {
|
||
// Toggle single
|
||
if (!!this.selectedIds[node.id]) delete this.selectedIds[node.id];
|
||
else this.selectedIds[node.id] = true;
|
||
} else {
|
||
// Replace selection
|
||
for (var k in this.selectedIds) delete this.selectedIds[k];
|
||
this.selectedIds[node.id] = true;
|
||
}
|
||
this.lastSelectedId = node.id;
|
||
// Force reactivity
|
||
this.selectedIds = Object.assign({}, this.selectedIds); this.selectedIdsCount = Object.keys(this.selectedIds).length;
|
||
},
|
||
_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.selectedIdsCount = 0;
|
||
},
|
||
selectAll(ev) {
|
||
if (ev.target.checked) {
|
||
var all = this._collectVisibleIds(this.displayTree);
|
||
for (var i=0; i<all.length; i++) this.selectedIds[all[i]] = true;
|
||
} else {
|
||
for (var k in this.selectedIds) delete this.selectedIds[k];
|
||
}
|
||
this.selectedIds = Object.assign({}, this.selectedIds); this.selectedIdsCount = Object.keys(this.selectedIds).length;
|
||
},
|
||
|
||
// ── 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 ──
|
||
selectForPreview(node) {
|
||
this.previewItem = node;
|
||
this.previewPanelContent = '<div style="text-align:center;padding:40px;color:var(--text-tertiary)">Loading...</div>';
|
||
this._loadPreviewContent(node);
|
||
},
|
||
|
||
async _loadPreviewContent(node) {
|
||
if (node.is_folder) {
|
||
this.previewPanelContent = '<div style="text-align:center;padding:40px;color:var(--text-secondary)">'
|
||
+ '<span class="empty-state-icon">'+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();
|
||
// Image preview
|
||
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(name)) {
|
||
var wsId = 1;
|
||
var imgUrl = '/api/files/' + wsId + '/' + encodeURIComponent(node.name);
|
||
this.previewPanelContent = '<div style="text-align:center;padding:12px;">'
|
||
+ '<img src="' + imgUrl + '" style="max-width:100%;max-height:50vh;border-radius:8px;" onerror="this.style.display=\'none\'">'
|
||
+ '</div>';
|
||
return;
|
||
}
|
||
// PDF preview
|
||
if (/\.pdf$/.test(name)) {
|
||
this.previewPanelContent = '<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;
|
||
}
|
||
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') {
|
||
try {
|
||
var meta = JSON.parse(content);
|
||
if (meta.file_path && /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(meta.file_path)) {
|
||
var wsId2 = 1;
|
||
var imgUrl2 = '/api/files/' + wsId2 + '/' + encodeURIComponent(meta.file_path);
|
||
this.previewPanelContent = '<img src="' + imgUrl2 + '" style="max-width:100%;max-height:50vh;border-radius:8px;">';
|
||
return;
|
||
}
|
||
} catch(e) {}
|
||
preview = '<div style="text-align:center;padding:20px;color:var(--text-secondary)">'
|
||
+ '<span class="empty-state-icon">'+getSvgIcon('file',32)+'</span>'
|
||
+ (node.size_display || '') + ' — open to view</div>';
|
||
} else if (format === 'blocks') {
|
||
try {
|
||
var blocks = typeof content === 'string' ? JSON.parse(content) : content;
|
||
if (blocks && blocks.blocks) {
|
||
preview = blocks.blocks.slice(0, 30).map(function(b) {
|
||
var txt = b.content || b.text || (typeof b === 'string' ? b : '');
|
||
return '<div style="margin-bottom:6px;line-height:1.5">' + this._escHtml(txt) + '</div>';
|
||
}, this).join('');
|
||
}
|
||
} catch(e) { preview = '<pre>' + this._escHtml(String(content).substring(0, 2000)) + '</pre>'; }
|
||
} else {
|
||
preview = '<pre>' + this._escHtml(String(content).substring(0, 2000)) + '</pre>';
|
||
}
|
||
this.previewPanelContent = preview || '<div style="color:var(--text-tertiary);text-align:center;padding:20px">(empty)</div>';
|
||
} catch(e) {
|
||
this.previewPanelContent = '<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 ──
|
||
onContextMenu(ev) {
|
||
var el = ev.target.closest('[data-ws-id]');
|
||
var menu = document.querySelector('.more-menu');
|
||
if (!el || !menu) { if (menu) menu.style.display = 'none'; return; }
|
||
var id = parseInt(el.getAttribute('data-ws-id'));
|
||
if (!id) { menu.style.display = 'none'; return; }
|
||
var node = this._findNodeById(this.displayTree, id);
|
||
if (!node) { menu.style.display = 'none'; return; }
|
||
// Ensure workspace tags are loaded
|
||
if (!this.workspaceTags || this.workspaceTags.length === 0) {
|
||
this.loadWorkspaceTags();
|
||
}
|
||
// Store in Alpine data for menu actions (ctxMenuOpenPage, etc.)
|
||
this.ctxMenu = {visible:true, x:ev.clientX, y:ev.clientY, node:node, tags:[...(node.tags||[])]};
|
||
this.ctxTagAdding = false;
|
||
this.ctxTagExistingOpen = false;
|
||
this.ctxNewTagColor = '#787774';
|
||
this.ctxAvailTags = (this.workspaceTags || []).filter(function(t) {
|
||
return !(node.tags||[]).some(function(nt) { return nt.id === t.id; });
|
||
});
|
||
// DOM manipulation (bypasses Alpine reactivity issues)
|
||
menu.style.display = 'block';
|
||
menu.style.left = ev.clientX + 'px';
|
||
menu.style.top = ev.clientY + 'px';
|
||
// Reposition if offscreen
|
||
var rect = menu.getBoundingClientRect();
|
||
var x = ev.clientX, y = ev.clientY;
|
||
if (x + rect.width > window.innerWidth - 4) x = window.innerWidth - rect.width - 4;
|
||
if (y + rect.height > window.innerHeight - 4) y = window.innerHeight - rect.height - 4;
|
||
if (x < 4) x = 4;
|
||
if (y < 4) y = 4;
|
||
menu.style.left = x + 'px';
|
||
menu.style.top = y + 'px';
|
||
this.ctxMenu.x = x;
|
||
this.ctxMenu.y = y;
|
||
// Sync to _ctxMenuData for Alpine reactivity
|
||
var c = window._ctxMenuData;
|
||
c.ctxMenu = this.ctxMenu;
|
||
c.ctxTagAdding = this.ctxTagAdding;
|
||
c.ctxTagExistingOpen = this.ctxTagExistingOpen;
|
||
c.ctxAvailTags = this.ctxAvailTags;
|
||
c.ctxNewTagColor = this.ctxNewTagColor;
|
||
// Also force Alpine update on the menu element
|
||
var menuEl = document.querySelector('.more-menu');
|
||
if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) {
|
||
var d = menuEl._x_dataStack[0];
|
||
d.ctxMenu = this.ctxMenu;
|
||
d.ctxTagAdding = this.ctxTagAdding;
|
||
d.ctxTagExistingOpen = this.ctxTagExistingOpen;
|
||
d.ctxAvailTags = [...this.ctxAvailTags];
|
||
d.ctxNewTagColor = this.ctxNewTagColor;
|
||
}
|
||
},
|
||
|
||
// ── Long-press (mobile context menu) ──
|
||
_touchStartX: 0,
|
||
_touchStartY: 0,
|
||
_touchStartTime: 0,
|
||
_touchMoved: false,
|
||
_touchTarget: null,
|
||
onTouchStart(ev) {
|
||
if (!ev.touches || ev.touches.length !== 1) return;
|
||
this._touchStartX = ev.touches[0].clientX;
|
||
this._touchStartY = ev.touches[0].clientY;
|
||
this._touchStartTime = Date.now();
|
||
this._touchMoved = false;
|
||
this._touchTarget = ev.target;
|
||
},
|
||
onTouchMove(ev) {
|
||
if (!ev.touches || ev.touches.length !== 1) return;
|
||
var dx = Math.abs(ev.touches[0].clientX - this._touchStartX);
|
||
var dy = Math.abs(ev.touches[0].clientY - this._touchStartY);
|
||
if (dx > 10 || dy > 10) this._touchMoved = true;
|
||
},
|
||
onTouchEnd(ev) {
|
||
if (this._touchMoved) return;
|
||
var elapsed = Date.now() - this._touchStartTime;
|
||
if (elapsed < 600) return;
|
||
// Long press detected — find target element
|
||
var el = this._touchTarget;
|
||
if (!el) return;
|
||
var idEl = el.closest('[data-ws-id]');
|
||
if (!idEl) return;
|
||
// Prevent default context menu if it hasn't fired yet
|
||
ev.preventDefault();
|
||
var fakeEv = {
|
||
clientX: this._touchStartX,
|
||
clientY: this._touchStartY,
|
||
target: idEl,
|
||
preventDefault: function(){}
|
||
};
|
||
this.onContextMenu(fakeEv);
|
||
},
|
||
|
||
ctxAddExistingTag(tag) {
|
||
this.ctxAddNewTag(tag.name, tag.color);
|
||
},
|
||
|
||
ctxMenuOpenPage() {
|
||
if (this.ctxMenu.node) this.openPage(this.ctxMenu.node.id);
|
||
document.querySelector('.more-menu').style.display = 'none';
|
||
},
|
||
|
||
ctxMenuOpenFolder() {
|
||
if (this.ctxMenu.node) this.navigateToFolder(this.ctxMenu.node.id);
|
||
document.querySelector('.more-menu').style.display = 'none';
|
||
},
|
||
|
||
ctxMenuRename() {
|
||
if (this.ctxMenu.node) { this.startRename(this.ctxMenu.node); document.querySelector('.more-menu').style.display = 'none'; }
|
||
},
|
||
|
||
ctxMenuDuplicate() {
|
||
if (this.ctxMenu.node) { this.duplicateItem(this.ctxMenu.node); document.querySelector('.more-menu').style.display = 'none'; }
|
||
},
|
||
|
||
ctxMenuDelete() {
|
||
if (this.ctxMenu.node) { this.deleteWithUndo(this.ctxMenu.node); document.querySelector('.more-menu').style.display = 'none'; }
|
||
},
|
||
|
||
ctxRemoveTag(tagId) {
|
||
var node = this.ctxMenu.node;
|
||
if (!node) return;
|
||
this.removeTag(node.id, tagId);
|
||
this.ctxMenu.tags = this.ctxMenu.tags.filter(function(t) { return t.id !== tagId; });
|
||
// Refresh available tags
|
||
this.ctxAvailTags = (this.workspaceTags || []).filter(function(t) {
|
||
return !(this.ctxMenu.tags||[]).some(function(nt) { return nt.id === t.id; });
|
||
}.bind(this));
|
||
// Sync to _ctxMenuData
|
||
window._ctxMenuData.ctxAvailTags = this.ctxAvailTags;
|
||
window._ctxMenuData.ctxMenu = this.ctxMenu;
|
||
// Also force Alpine update
|
||
var menuEl = document.querySelector('.more-menu');
|
||
if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) {
|
||
menuEl._x_dataStack[0].ctxAvailTags = [...this.ctxAvailTags];
|
||
menuEl._x_dataStack[0].ctxMenu = this.ctxMenu;
|
||
}
|
||
},
|
||
|
||
async ctxAddNewTag(tagName, color) {
|
||
tagName = (tagName || '').trim();
|
||
if (!tagName) return;
|
||
var node = this.ctxMenu.node;
|
||
if (!node) return;
|
||
color = color || window._ctxMenuData.ctxNewTagColor;
|
||
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({name: tagName.trim(), color: color})
|
||
});
|
||
if (r.ok) {
|
||
var d = await r.json();
|
||
this.ctxMenu.tags.push(d.tag);
|
||
this._updateNodeTags(this.tree, node.id, d.tag, 'add');
|
||
this.displayTree = [...this.tree];
|
||
this.loadWorkspaceTags();
|
||
// Refresh available tags
|
||
var self = this;
|
||
setTimeout(function() { self.loadWorkspaceTags().then(function() {
|
||
self.ctxAvailTags = (self.workspaceTags || []).filter(function(t) {
|
||
return !(self.ctxMenu.tags||[]).some(function(nt) { return nt.id === t.id; });
|
||
});
|
||
// Sync to _ctxMenuData
|
||
window._ctxMenuData.ctxAvailTags = self.ctxAvailTags;
|
||
window._ctxMenuData.ctxMenu = self.ctxMenu;
|
||
// Also force Alpine update
|
||
var menuEl = document.querySelector('.more-menu');
|
||
if (menuEl && menuEl._x_dataStack && menuEl._x_dataStack[0]) {
|
||
menuEl._x_dataStack[0].ctxAvailTags = [...self.ctxAvailTags];
|
||
menuEl._x_dataStack[0].ctxMenu = self.ctxMenu;
|
||
}
|
||
// Close menu after successful add
|
||
document.querySelector('.more-menu').style.display = 'none';
|
||
}); }, 200);
|
||
}
|
||
this.ctxTagAdding = false;
|
||
},
|
||
|
||
_markTree(nodes, depth) {
|
||
for (var i=0; i<nodes.length; i++) {
|
||
var n = nodes[i];
|
||
n.depth = depth;
|
||
n.db_id = n.id;
|
||
n._path = '/' + n.name;
|
||
if (n.children && n.children.length > 0) {
|
||
this._markTree(n.children, depth + 1);
|
||
}
|
||
}
|
||
},
|
||
|
||
renderChildren(children, depth) {
|
||
if (!children || children.length === 0) return '';
|
||
var html = '';
|
||
for (var i=0; i<children.length; i++) {
|
||
var c = children[i];
|
||
var hasKids = c.children && c.children.length > 0;
|
||
var icon = this._fileIcon(c.name, c.is_folder, c.content_format);
|
||
var escName = this.esc(c.name);
|
||
var escId = c.id;
|
||
html += '<li><div class="ws-tree-item" style="padding-left:'+(depth*20)+'px" draggable="true"'
|
||
+ ' data-folder-id="'+(c.is_folder?c.id:'')+'"'
|
||
+ ' data-ws-id="'+escId+'"'
|
||
+ ' ondragstart="var d=window._wsData;if(d)d.onInternalDragStart(event,'+escId+')"'
|
||
+ ' ondragend="event.target.style.opacity=\'\'">';
|
||
// Checkbox
|
||
html += '<input type="checkbox" class="item-checkbox"'
|
||
+ ' data-ws-id="'+escId+'"'
|
||
+ ' onclick="event.stopPropagation();var d=window._wsData;if(d)d.toggleSelect({id:'+escId+',name:\''+escName+'\'},event)"'
|
||
+ ' title="Select item">';
|
||
// Drag handle
|
||
html += '<div class="drag-handle" title="Drag to move">'
|
||
+ '<span class="dot"></span><span class="dot"></span>'
|
||
+ '<span class="dot"></span><span class="dot"></span>'
|
||
+ '<span class="dot"></span><span class="dot"></span></div>';
|
||
if (c.is_folder && hasKids) {
|
||
html += '<button class="ws-chevron ws-expand-btn" data-ws-id="'+escId+'" onclick="event.stopPropagation();var d=window._wsData;if(d)d.toggleExpand('+escId+',this)">\u25b6</button>';
|
||
} else {
|
||
html += '<span style="width:16px;flex-shrink:0;"></span>';
|
||
}
|
||
html += '<span class="icon">'+icon+'</span>';
|
||
if (c.is_folder) {
|
||
var countBadge = (c.child_count > 0) ? ' <span class="child-count">('+c.child_count+')</span>' : '';
|
||
html += '<span class="name folder" onclick="window._wsData.navigateToFolder('+escId+')">'+c.name+countBadge+'</span>';
|
||
} else {
|
||
// Click to rename, double-click to open
|
||
html += '<span class="name file" onclick="event.stopPropagation();window._wsData.startRenderChildRename('+escId+',\''+escName+'\')"'
|
||
+ ' ondblclick="window.location=\'/pages/'+escId+'\'"'
|
||
+ ' title="Click to rename, double-click to open"'
|
||
+ ' onmouseenter="var d=window._wsData;if(d)d.showPreview(event,{id:'+escId+',name:\''+escName+'\',is_folder:false,content_format:\''+(c.content_format||'')+'\'})"'
|
||
+ ' onmouseleave="var d=window._wsData;if(d)d.hidePreview()">'
|
||
+ '<span id="rc-name-'+escId+'">'+c.name+'</span>'
|
||
+ '<input id="rc-input-'+escId+'" class="inline-rename-input" style="display:none;width:100%"'
|
||
+ ' onkeydown="if(event.key===\'Enter\')window._wsData.commitRenderChildRename('+escId+')"'
|
||
+ ' onblur="window._wsData.commitRenderChildRename('+escId+')"'
|
||
+ ' onclick="event.stopPropagation()">'
|
||
+ '</span>';
|
||
}
|
||
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>';
|
||
// Open button for files
|
||
if (!c.is_folder) {
|
||
html += '<a href="/pages/'+escId+'" class="btn-open" style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border:1px solid var(--border);border-radius:4px;font-size:12px;color:var(--text-secondary);background:var(--bg-tertiary);cursor:pointer;text-decoration:none;margin-left:4px;opacity:0;"'
|
||
+ ' onmouseover="this.style.opacity=\'1\'" onmouseout="this.style.opacity=\'0\'"'
|
||
+ ' onclick="event.stopPropagation();return true">\u2931 Open</a>';
|
||
}
|
||
// 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;
|
||
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,'"');
|
||
},
|
||
|
||
// ── Event delegation for renderChildren action buttons ──
|
||
handleTreeAction(ev) {
|
||
var btn = (ev.target.closest && ev.target.closest('[data-ws-action]'))
|
||
|| (ev.target.parentElement && ev.target.parentElement.closest('[data-ws-action]'));
|
||
if (!btn) return;
|
||
ev.stopPropagation();
|
||
var action = btn.getAttribute('data-ws-action');
|
||
var id = parseInt(btn.getAttribute('data-ws-id'));
|
||
var name = btn.getAttribute('data-ws-name') || '';
|
||
if (!id) return;
|
||
|
||
switch(action) {
|
||
case 'new-file':
|
||
window.FlowDeck.createPage();
|
||
break;
|
||
case 'new-folder':
|
||
window.FlowDeck.showCreateFolderModal();
|
||
break;
|
||
case 'rename':
|
||
this.startRename({db_id: id, name: name});
|
||
break;
|
||
case 'delete':
|
||
this.deleteWithUndo({db_id: id, name: name});
|
||
break;
|
||
}
|
||
},
|
||
|
||
toggleExpand(id, btn) {
|
||
this.expanded[id] = !(this.expanded[id] || false);
|
||
this.expanded = Object.assign({}, this.expanded);
|
||
this._saveExpandedState();
|
||
// For renderChildren static HTML: toggle chevron + sibling <ul>
|
||
if (btn) {
|
||
btn.classList.toggle('open', this.expanded[id]);
|
||
// Navigate: btn → .ws-tree-item → <li> → <ul.ws-tree>
|
||
var itemDiv = btn.closest('.ws-tree-item');
|
||
if (itemDiv && itemDiv.parentElement) {
|
||
var children = itemDiv.parentElement.children;
|
||
for (var i = 0; i < children.length; i++) {
|
||
if (children[i].tagName === 'UL' && children[i].classList.contains('ws-tree')) {
|
||
children[i].style.display = this.expanded[id] ? '' : 'none';
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
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();
|
||
} 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) { inp.focus(); inp.select(); }
|
||
});
|
||
},
|
||
async commitRename(node) {
|
||
var n = this.renamingName.trim();
|
||
this.renamingId = null;
|
||
if (!n || n === node.name) return;
|
||
var r = await fetch('/api/local-workspace/items/' + node.id, {
|
||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({name: n})
|
||
});
|
||
if (r.ok) {
|
||
node.name = n; node.db_name = n;
|
||
this._saveExpandedState();
|
||
}
|
||
},
|
||
cancelRename() { this.renamingId = null; },
|
||
|
||
// ── Keyboard shortcuts ──
|
||
onKeydown(ev) {
|
||
if (this.renamingId) return; // Don't intercept during rename
|
||
if (ev.key === 'Delete' && !ev.ctrlKey && !ev.metaKey) {
|
||
ev.preventDefault();
|
||
// Delete selected item — implement when multi-select exists
|
||
}
|
||
if (ev.key === 'F2' && !ev.ctrlKey) {
|
||
ev.preventDefault();
|
||
// Find hovered or first file item and rename
|
||
var hovered = document.querySelector('.ws-tree-item:hover');
|
||
if (hovered) {
|
||
var id = parseInt(hovered.getAttribute('data-ws-id') || hovered.getAttribute('data-folder-id'));
|
||
var name = (hovered.querySelector('.name') || {}).textContent || '';
|
||
if (id) this.startInlineRename({id: id, name: name}, ev);
|
||
}
|
||
}
|
||
if (ev.ctrlKey && ev.key === 'c') {
|
||
// Copy selected items to clipboard
|
||
ev.preventDefault();
|
||
this.clipboard = []; // Will implement with multi-select
|
||
}
|
||
if (ev.ctrlKey && ev.key === 'v' && this.clipboard.length > 0) {
|
||
ev.preventDefault();
|
||
// Paste items — move them to current folder
|
||
var self = this;
|
||
var targetId = this.currentFolder > 0 ? this.currentFolder : null;
|
||
this.clipboard.forEach(function(id) {
|
||
fetch('/api/local-workspace/items/' + id + '/move', {
|
||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({parent_id: targetId})
|
||
}).then(function(r) { if (r.ok) self._reloadAfterAction(); });
|
||
});
|
||
this.clipboard = [];
|
||
}
|
||
},
|
||
|
||
// ── Duplicate ──
|
||
async duplicateItem(node) {
|
||
var r = await fetch('/api/local-workspace/items', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({name: node.name + ' (copy)', type: node.type || 'page',
|
||
parent_id: this.currentFolder > 0 ? this.currentFolder : null})
|
||
});
|
||
if (r.ok) this._reloadAfterAction();
|
||
},
|
||
|
||
// ── Sort ──
|
||
doSort() {
|
||
var s = this.sortBy;
|
||
if (!s) { this.displayTree = [...this.tree]; this.showAll = false; return; }
|
||
var sorted = this.tree.slice();
|
||
if (s === 'name-asc') sorted.sort(function(a,b) { return (a.name||'').localeCompare(b.name||''); });
|
||
else if (s === 'name-desc') sorted.sort(function(a,b) { return (b.name||'').localeCompare(a.name||''); });
|
||
else if (s === 'date-asc') sorted.sort(function(a,b) { return (a.updated_at||'').localeCompare(b.updated_at||''); });
|
||
else if (s === 'date-desc') sorted.sort(function(a,b) { return (b.updated_at||'').localeCompare(a.updated_at||''); });
|
||
else if (s === 'type') sorted.sort(function(a,b) { return (a.is_folder===b.is_folder) ? 0 : a.is_folder ? -1 : 1; });
|
||
this.displayTree = sorted;
|
||
this._recompute();
|
||
},
|
||
|
||
// ── Undo delete ──
|
||
async deleteWithUndo(node) {
|
||
var self = this;
|
||
this.undoItemId = node.db_id || node.id;
|
||
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
|
||
this.undoVisible = true;
|
||
// Delete via API
|
||
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, { method:'DELETE' });
|
||
if (!r.ok) { this.undoVisible = false; return; }
|
||
// Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
|
||
this._reloadAfterAction();
|
||
// Auto-dismiss undo banner after 5s
|
||
this.undoTimeout = setTimeout(function() {
|
||
self.undoVisible = false;
|
||
self.undoItemId = null;
|
||
}, 5000);
|
||
},
|
||
undoDelete() {
|
||
if (this.undoTimeout) clearTimeout(this.undoTimeout);
|
||
this.undoVisible = false;
|
||
var ids = (this.undoItemId || '').toString().split(',');
|
||
this.undoItemId = null;
|
||
if (!ids.length || !ids[0]) return;
|
||
// Restore via API (soft-delete undo) — single or bulk
|
||
var self = this;
|
||
var restored = 0;
|
||
var total = ids.length;
|
||
function restoreOne(i) {
|
||
if (i >= total) {
|
||
self._reloadAfterAction();
|
||
return;
|
||
}
|
||
fetch('/api/local-workspace/items/' + ids[i] + '/restore', { method: 'POST' })
|
||
.then(function(r) { if (r.ok) restored++; })
|
||
.finally(function() { restoreOne(i + 1); });
|
||
}
|
||
restoreOne(0);
|
||
},
|
||
_removeFromTree(nodes, id) {
|
||
for (var i=0; i<nodes.length; i++) {
|
||
if (nodes[i].id === id) { nodes.splice(i, 1); return true; }
|
||
if (nodes[i].children && this._removeFromTree(nodes[i].children, id)) {
|
||
nodes[i].child_count = nodes[i].children.length;
|
||
nodes[i].has_children = nodes[i].child_count > 0;
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
},
|
||
|
||
// ── Upload speed tracking ──
|
||
_trackUploadSpeed(totalSize) {
|
||
var self = this;
|
||
this.uploadStartTime = Date.now();
|
||
this.uploadSpeed = '';
|
||
var checkSpeed = setInterval(function() {
|
||
if (!self.uploading) { clearInterval(checkSpeed); return; }
|
||
var elapsed = (Date.now() - self.uploadStartTime) / 1000;
|
||
if (elapsed > 0) {
|
||
self.uploadSpeed = (totalSize / elapsed / 1024).toFixed(0) + ' KB/s';
|
||
}
|
||
}, 500);
|
||
},
|
||
|
||
// ── Path bar navigation ──
|
||
navigateToPath() {
|
||
var path = (this.$el.querySelector('.path-input') || {}).value || '';
|
||
var parts = path.replace(/^\/workspace\/?/, '').split('/').filter(Boolean);
|
||
if (parts.length === 0) { window.location = '/local-workspace'; return; }
|
||
// Walk tree to find matching folder
|
||
var targetId = this._findFolderByPath(this.tree, parts, 0);
|
||
if (targetId) {
|
||
window.location = '/local-workspace?folder=' + targetId;
|
||
} else {
|
||
window.showToast('Path not found: ' + parts.join('/'), 'error');
|
||
}
|
||
},
|
||
_findFolderByPath(nodes, parts, idx) {
|
||
if (idx >= parts.length) return null;
|
||
for (var i=0; i<nodes.length; i++) {
|
||
if (nodes[i].is_folder && nodes[i].name === parts[idx]) {
|
||
if (idx === parts.length - 1) return nodes[i].id;
|
||
if (nodes[i].children) {
|
||
var found = this._findFolderByPath(nodes[i].children, parts, idx+1);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// ── Cache-aware preview ──
|
||
showPreview(ev, node) {
|
||
if (node.is_folder) return;
|
||
this.previewName = node.name;
|
||
this.previewX = ev.clientX + 12;
|
||
this.previewY = ev.clientY + 12;
|
||
this.previewVisible = true;
|
||
var self = this;
|
||
var wsId = {{ workspace_id }} || 1;
|
||
var cacheKey = 'prev_' + node.id;
|
||
// Return cached immediately
|
||
if (this.previewCache[cacheKey]) {
|
||
this.previewContent = this.previewCache[cacheKey];
|
||
return;
|
||
}
|
||
// Determine type
|
||
var imgExts = ['.png','.jpg','.jpeg','.gif','.webp','.svg','.bmp','.ico'];
|
||
var isImg = false, isPdf = false;
|
||
for (var i=0; i<imgExts.length; i++) {
|
||
if ((node.name||'').toLowerCase().endsWith(imgExts[i])) { isImg = true; break; }
|
||
}
|
||
isPdf = (node.name||'').toLowerCase().endsWith('.pdf');
|
||
|
||
if (node.content_format === 'file') {
|
||
var url = '/api/files/' + wsId + '/' + encodeURIComponent(node.name);
|
||
if (isImg) {
|
||
var html = '<img src="'+url+'" style="max-width:380px;max-height:180px;display:block;">';
|
||
this.previewContent = html; this.previewCache[cacheKey] = html;
|
||
} else if (isPdf) {
|
||
var html = '<div style="text-align:center;padding:16px;">'+getSvgIcon('file',48)+'<br><span style="color:var(--text-secondary);font-size:12px;">PDF Document</span><br><a href="/pages/'+node.id+'" style="color:var(--accent);font-size:11px;">Open viewer →</a></div>';
|
||
this.previewContent = html; this.previewCache[cacheKey] = html;
|
||
} else {
|
||
this.previewContent = 'Loading...';
|
||
this._fetchRetry(url).then(function(t) {
|
||
var content = t.substring(0, 500);
|
||
self.previewContent = content; self.previewCache[cacheKey] = content;
|
||
}).catch(function() { self.previewContent = '(preview not available)'; });
|
||
}
|
||
} else {
|
||
this.previewContent = 'Loading...';
|
||
this._fetchRetry('/api/local-workspace/page-content/' + node.id, true)
|
||
.then(function(d) {
|
||
var content = self._extractPreview(d.content || '', d.format);
|
||
self.previewContent = content; self.previewCache[cacheKey] = content;
|
||
}).catch(function() { self.previewContent = '(preview not available)'; });
|
||
}
|
||
},
|
||
|
||
// ── Fetch with retry ──
|
||
_fetchRetry(url, json) {
|
||
var maxRetries = 2;
|
||
var delay = 300;
|
||
var self = this;
|
||
return new Promise(function(resolve, reject) {
|
||
function attempt(n) {
|
||
fetch(url).then(function(r) {
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
return json ? r.json() : r.text();
|
||
}).then(resolve).catch(function(e) {
|
||
if (n < maxRetries) {
|
||
setTimeout(function() { attempt(n+1); }, delay * (n+1));
|
||
} else { reject(e); }
|
||
});
|
||
}
|
||
attempt(0);
|
||
});
|
||
},
|
||
|
||
// ── Touch haptic feedback ──
|
||
_haptic() {
|
||
if (navigator.vibrate) navigator.vibrate(10);
|
||
},
|
||
|
||
// ── Expand / Collapse all ──
|
||
expandAll() {
|
||
var self = this;
|
||
this._walkTree(this.tree, function(n) {
|
||
if (n.is_folder && n.children && n.children.length > 0) {
|
||
self.expanded[n.id] = true;
|
||
}
|
||
});
|
||
this.expanded = Object.assign({}, this.expanded);
|
||
// Toggle static HTML for renderChildren
|
||
this._toggleAllUl(this.$el, true);
|
||
},
|
||
collapseAll() {
|
||
this.expanded = {};
|
||
this._toggleAllUl(this.$el, false);
|
||
},
|
||
_walkTree(nodes, fn) {
|
||
for (var i=0; i<nodes.length; i++) {
|
||
fn(nodes[i]);
|
||
if (nodes[i].children) this._walkTree(nodes[i].children, fn);
|
||
}
|
||
},
|
||
_toggleAllUl(el, show) {
|
||
var uls = el.querySelectorAll('ul.ws-tree');
|
||
for (var i=0; i<uls.length; i++) {
|
||
uls[i].style.display = show ? '' : 'none';
|
||
}
|
||
// Also toggle chevrons
|
||
var chevs = el.querySelectorAll('.ws-chevron');
|
||
for (var j=0; j<chevs.length; j++) {
|
||
if (show) chevs[j].classList.add('open');
|
||
else chevs[j].classList.remove('open');
|
||
}
|
||
},
|
||
|
||
// ── File type icon ──
|
||
_fileIcon(name, isFolder, contentFormat) {
|
||
if (isFolder) return '<span class="page-icon-svg">'+getSvgIcon('folder',14)+'</span>';
|
||
// FlowDeck internal pages (no extension, blocks/markdown format)
|
||
if (contentFormat && contentFormat !== 'file') return '<span class="page-icon-svg">'+getSvgIcon('edit',14)+'</span>';
|
||
var n = (name || '').toLowerCase();
|
||
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('image',14)+'</span>';
|
||
if (/\.pdf$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||
if (/\.(md|markdown)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('edit',14)+'</span>';
|
||
if (/\.(py)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||
if (/\.(js|jsx|ts|tsx)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||
if (/\.(html?|xml)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||
if (/\.css$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||
if (/\.json$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||
if (/\.sql$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||
if (/\.(sh|bash|zsh)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||
if (/\.ps1$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||
if (/\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||
if (/\.(txt|log)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||
if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||
return '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||
},
|
||
|
||
// ── Search / Filter ──
|
||
doFilter() {
|
||
var q = (this.searchQuery || '').toLowerCase().trim();
|
||
var ft = this.filterType || '';
|
||
if (!q && !ft && !this.tagFilter) { this.displayTree = [...this.tree]; this.showAll = false; this._computeFilteredTableItems(); return; }
|
||
if (this.tagFilter) {
|
||
// Filter tree by tag
|
||
this.displayTree = this._filterTreeByTag([...this.tree], this.tagFilter);
|
||
} else {
|
||
this.displayTree = [...this.tree];
|
||
}
|
||
this._computeFilteredTableItems();
|
||
},
|
||
_filterTreeByTag(nodes, tagName) {
|
||
var result = [];
|
||
for (var i=0; i<nodes.length; i++) {
|
||
var n = nodes[i];
|
||
var hasTag = (n.tags || []).some(function(t) { return t.name === tagName; });
|
||
var filteredChildren = n.children ? this._filterTreeByTag(n.children, tagName) : [];
|
||
if (hasTag || filteredChildren.length > 0) {
|
||
var clone = Object.assign({}, n);
|
||
clone.children = filteredChildren;
|
||
result.push(clone);
|
||
}
|
||
}
|
||
return result;
|
||
},
|
||
filterByType(type) {
|
||
this.filterType = (this.filterType === type) ? '' : type;
|
||
this.doFilter();
|
||
},
|
||
_filterTree(nodes, query, fileType) {
|
||
var result = [];
|
||
for (var i=0; i<nodes.length; i++) {
|
||
var n = nodes[i];
|
||
var filteredChildren = n.children ? this._filterTree(n.children, query, fileType) : [];
|
||
var selfMatches = (n.name || '').toLowerCase().indexOf(query || '') !== -1;
|
||
// File type filter
|
||
var typeMatch = true;
|
||
if (fileType && !n.is_folder) {
|
||
var nm = (n.name || '').toLowerCase();
|
||
switch(fileType) {
|
||
case 'folder': typeMatch = n.is_folder; break;
|
||
case 'pdf': typeMatch = /\.pdf$/.test(nm); break;
|
||
case 'image': typeMatch = /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(nm); break;
|
||
case 'code': typeMatch = /\.(py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql)$/.test(nm); break;
|
||
case 'text': typeMatch = /\.(txt|md|markdown|log)$/.test(nm); break;
|
||
case 'page': typeMatch = !/\.(pdf|png|jpe?g|gif|webp|svg|bmp|ico|py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql|txt|md|markdown|log)$/.test(nm); break;
|
||
}
|
||
}
|
||
if ((selfMatches || filteredChildren.length > 0) && typeMatch) {
|
||
var clone = Object.assign({}, n);
|
||
clone.children = filteredChildren;
|
||
result.push(clone);
|
||
}
|
||
}
|
||
return result;
|
||
},
|
||
|
||
// ── Preview on hover ──
|
||
_extractPreview(content, format) {
|
||
if (!content) return '(empty)';
|
||
// Try to extract text from Notion block JSON
|
||
if (format === 'blocks' || format === 'markdown') {
|
||
try {
|
||
var blocks = typeof content === 'string' ? JSON.parse(content) : content;
|
||
if (blocks && blocks.blocks && Array.isArray(blocks.blocks)) {
|
||
var lines = [];
|
||
for (var i=0; i<blocks.blocks.length && lines.length<20; i++) {
|
||
var b = blocks.blocks[i];
|
||
if (b.content) lines.push(b.content);
|
||
else if (b.text) lines.push(b.text);
|
||
else if (typeof b === 'string') lines.push(b);
|
||
}
|
||
return lines.join('\n').substring(0, 500) || '(empty)';
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
// Fallback: raw text
|
||
return String(content).substring(0, 500);
|
||
},
|
||
hidePreview() {
|
||
this.previewVisible = false;
|
||
},
|
||
|
||
openModal(type, parent) {
|
||
this.createType = type;
|
||
this.parentFolder = parent||null;
|
||
this.newName = '';
|
||
this.showCreate = true;
|
||
var self = this;
|
||
this.$nextTick(function() {
|
||
var el = self.$refs.cinp;
|
||
if (el) setTimeout(function() { el.focus(); }, 100);
|
||
});
|
||
},
|
||
|
||
async doCreate() {
|
||
var n = this.newName.trim();
|
||
if (!n) return;
|
||
var body = { name:n, type:this.createType };
|
||
if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
|
||
var r = await fetch('/api/local-workspace/items', {
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify(body)
|
||
});
|
||
if (r.ok) {
|
||
var d = await r.json();
|
||
// Save parent ref BEFORE clearing so we can insert into tree
|
||
var parent = this.parentFolder;
|
||
this.showCreate = false;
|
||
this.parentFolder = null;
|
||
this.newName = '';
|
||
// Insert new node into tree without reload
|
||
var isFolder = this.createType === 'folder';
|
||
var newNode = {
|
||
id: d.id, db_id: d.id, name: d.name || n,
|
||
is_folder: isFolder, type: isFolder ? 'folder' : 'page',
|
||
content_format: isFolder ? null : 'blocks',
|
||
children: [], has_children: false, depth: 0
|
||
};
|
||
if (parent && parent.db_id) {
|
||
// Insert as child of parent folder
|
||
this._insertIntoParent(this.tree, parent.db_id, newNode);
|
||
// Auto-expand parent so the new item is visible
|
||
this.expanded[parent.db_id] = true;
|
||
this.expanded = Object.assign({}, this.expanded);
|
||
} else {
|
||
// Insert at root
|
||
this.tree.unshift(newNode);
|
||
}
|
||
// Force new reference so Alpine x-for re-renders
|
||
if (!this.searchQuery && !this.filterType) {
|
||
this.displayTree = [...this.tree];
|
||
} else if (this.sortBy) {
|
||
// Re-sort with the new item
|
||
this.doSort();
|
||
} else {
|
||
// Re-apply filter with updated tree
|
||
this.doFilter();
|
||
}
|
||
this._saveExpandedState();
|
||
// Directly refresh sidebar tree
|
||
if (window.appState && window.appState.refreshSidebarTree) {
|
||
window.appState.refreshSidebarTree();
|
||
}
|
||
}
|
||
},
|
||
|
||
_insertIntoParent(nodes, parentId, newNode) {
|
||
for (var i=0; i<nodes.length; i++) {
|
||
if (nodes[i].id === parentId) {
|
||
if (!nodes[i].children) nodes[i].children = [];
|
||
nodes[i].children.unshift(newNode);
|
||
nodes[i].has_children = true;
|
||
newNode.depth = (nodes[i].depth || 0) + 1;
|
||
return true;
|
||
}
|
||
if (nodes[i].children && this._insertIntoParent(nodes[i].children, parentId, newNode)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
},
|
||
|
||
openPage(id) { window.location = '/pages/'+id; },
|
||
|
||
startRename(node) {
|
||
this.target = node;
|
||
this.newName = node.name.trim();
|
||
this.showRename = true;
|
||
var self = this;
|
||
this.$nextTick(function() {
|
||
var el = self.$refs.rinp;
|
||
if (el) setTimeout(function() { el.focus(); el.select(); }, 100);
|
||
});
|
||
},
|
||
|
||
async doRename() {
|
||
var n = this.newName.trim();
|
||
if (!n||!this.target) return;
|
||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {
|
||
method:'PUT',
|
||
headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({name:n})
|
||
});
|
||
if (r.ok) { this._reloadAfterAction(); }
|
||
},
|
||
|
||
startDelete(node) {
|
||
this.target = node;
|
||
this.showDelete = true;
|
||
},
|
||
|
||
async doDelete() {
|
||
if (!this.target) return;
|
||
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, { method:'DELETE' });
|
||
if (r.ok) { this._reloadAfterAction(); }
|
||
},
|
||
|
||
// ── Drag & Drop ──
|
||
onDragOver(ev) {
|
||
this.isDragging = true;
|
||
// Detect if hovering over a folder item
|
||
var folderEl = ev.target.closest('.ws-tree-item');
|
||
var parentFolder = folderEl ? folderEl.querySelector('.name.folder') : null;
|
||
// Clear previous highlight
|
||
document.querySelectorAll('.drop-target-highlight').forEach(function(el){ el.classList.remove('drop-target-highlight'); });
|
||
if (parentFolder) {
|
||
var name = parentFolder.textContent.trim();
|
||
this.dropTargetName = name;
|
||
// Find parent node id from the onclick attribute or data
|
||
var li = parentFolder.closest('.ws-tree-item');
|
||
if (li) {
|
||
var clickEl = li.querySelector('.name.folder');
|
||
// Try to extract ID from onclick or the onclick handler
|
||
// Use data attributes if available, otherwise fall back
|
||
}
|
||
folderEl.classList.add('drop-target-highlight');
|
||
} else {
|
||
this.dropTargetName = 'root';
|
||
}
|
||
},
|
||
|
||
onDragLeave(ev) {
|
||
// Only reset if leaving the layout entirely
|
||
if (!ev.currentTarget.contains(ev.relatedTarget)) {
|
||
this.isDragging = false;
|
||
this.dropTargetName = '';
|
||
document.querySelectorAll('.drop-target-highlight').forEach(function(el){ el.classList.remove('drop-target-highlight'); });
|
||
}
|
||
},
|
||
|
||
onInternalDragStart(ev, id) {
|
||
this.draggedItemId = id;
|
||
ev.dataTransfer.effectAllowed = 'move';
|
||
ev.dataTransfer.setData('text/plain', String(id));
|
||
// Add a visual class to the dragged element
|
||
ev.target.style.opacity = '0.5';
|
||
},
|
||
|
||
onDrop(ev) {
|
||
this.isDragging = false;
|
||
document.querySelectorAll('.drop-target-highlight').forEach(function(el){ el.classList.remove('drop-target-highlight'); });
|
||
// Reset any opacity from drag
|
||
document.querySelectorAll('.ws-tree-item').forEach(function(el){ el.style.opacity = ''; });
|
||
|
||
// ── Internal drag (move item to another folder) ──
|
||
if (this.draggedItemId !== null) {
|
||
var draggedId = this.draggedItemId;
|
||
this.draggedItemId = null;
|
||
// Determine target parent
|
||
var folderEl = ev.target.closest('.ws-tree-item');
|
||
var targetParentId = this.currentFolder > 0 ? this.currentFolder : null;
|
||
if (folderEl) {
|
||
var folderLink = folderEl.querySelector('.name.folder');
|
||
if (folderLink) {
|
||
var fid = folderEl.getAttribute('data-folder-id');
|
||
if (fid) targetParentId = parseInt(fid);
|
||
}
|
||
}
|
||
// Don't drop on self or same parent
|
||
if (draggedId === targetParentId) return;
|
||
this._moveItem(draggedId, targetParentId);
|
||
return;
|
||
}
|
||
|
||
// ── External drop (files from computer) ──
|
||
var folderEl = ev.target.closest('.ws-tree-item');
|
||
var parentId = this.currentFolder > 0 ? this.currentFolder : null;
|
||
var parentName = 'root';
|
||
|
||
if (folderEl) {
|
||
var folderLink = folderEl.querySelector('.name.folder');
|
||
if (folderLink) {
|
||
parentName = folderLink.textContent.trim();
|
||
// Use data-folder-id attribute (works for both x-for and renderChildren)
|
||
var fid = folderEl.getAttribute('data-folder-id');
|
||
if (fid) parentId = parseInt(fid);
|
||
}
|
||
}
|
||
|
||
var items = ev.dataTransfer.items;
|
||
if (!items) return;
|
||
|
||
var self = this;
|
||
var dtFiles = ev.dataTransfer.files;
|
||
|
||
// Check if any item is a directory (WebKit only)
|
||
var hasDirectory = false;
|
||
for (var i = 0; i < items.length; i++) {
|
||
var entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
|
||
if (entry && entry.isDirectory) { hasDirectory = true; break; }
|
||
}
|
||
|
||
if (hasDirectory) {
|
||
// Walk directory tree and upload structure
|
||
this._processDirectoryDrop(items, parentId, parentName);
|
||
} else if (dtFiles.length > 0) {
|
||
// Simple file upload
|
||
this._uploadFiles(dtFiles, parentId, parentName);
|
||
}
|
||
},
|
||
|
||
_processDirectoryDrop(items, parentId, parentName) {
|
||
var self = this;
|
||
var allItems = []; // {path: string, type: 'file'|'folder', entry: FileSystemEntry}
|
||
var pending = items.length;
|
||
|
||
function walkEntry(entry, basePath) {
|
||
var path = basePath ? basePath + '/' + entry.name : entry.name;
|
||
if (entry.isFile) {
|
||
allItems.push({path: path, type: 'file', entry: entry});
|
||
pending--;
|
||
if (pending === 0) doUpload();
|
||
} else if (entry.isDirectory) {
|
||
allItems.push({path: path, type: 'folder', entry: entry});
|
||
var reader = entry.createReader();
|
||
reader.readEntries(function(entries) {
|
||
pending += entries.length;
|
||
for (var i = 0; i < entries.length; i++) {
|
||
walkEntry(entries[i], path);
|
||
}
|
||
pending--;
|
||
if (pending === 0) doUpload();
|
||
});
|
||
}
|
||
}
|
||
|
||
for (var i = 0; i < items.length; i++) {
|
||
var entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
|
||
if (entry) walkEntry(entry, '');
|
||
else pending--;
|
||
}
|
||
if (pending === 0) doUpload();
|
||
|
||
function doUpload() {
|
||
// Build structure
|
||
var structure = [];
|
||
var files = [];
|
||
for (var i = 0; i < allItems.length; i++) {
|
||
structure.push({path: allItems[i].path, type: allItems[i].type});
|
||
}
|
||
|
||
// Resolve file entries to actual File objects
|
||
var fileEntries = [];
|
||
for (var i = 0; i < allItems.length; i++) {
|
||
if (allItems[i].type === 'file') fileEntries.push(allItems[i]);
|
||
}
|
||
|
||
var resolved = 0;
|
||
var formData = new FormData();
|
||
formData.append('structure', JSON.stringify(structure));
|
||
if (parentId) formData.append('parent_id', parentId);
|
||
|
||
function addFile(idx) {
|
||
if (idx >= fileEntries.length) {
|
||
// All resolved, upload
|
||
self._doUpload(formData, parentId, parentName);
|
||
return;
|
||
}
|
||
var fe = fileEntries[idx];
|
||
fe.entry.file(function(file) {
|
||
formData.append('files', file, fe.path);
|
||
resolved++;
|
||
addFile(idx + 1);
|
||
}, function() {
|
||
resolved++;
|
||
addFile(idx + 1);
|
||
});
|
||
}
|
||
addFile(0);
|
||
}
|
||
},
|
||
|
||
_uploadFiles(fileList, parentId, parentName) {
|
||
var formData = new FormData();
|
||
for (var i = 0; i < fileList.length; i++) {
|
||
formData.append('files', fileList[i]);
|
||
}
|
||
if (parentId) formData.append('parent_id', parentId);
|
||
this._doUpload(formData, parentId, parentName);
|
||
},
|
||
|
||
async _moveItem(itemId, newParentId) {
|
||
var body = { parent_id: newParentId || null };
|
||
try {
|
||
var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (r.ok) {
|
||
this._reloadAfterAction();
|
||
} else {
|
||
window.showToast('Move failed', 'error');
|
||
}
|
||
} catch(e) {
|
||
window.showToast('Move failed: ' + e.message, 'error');
|
||
}
|
||
},
|
||
|
||
async _doUpload(formData, parentId, parentName) {
|
||
var self = this;
|
||
this.uploading = true;
|
||
this.uploadStatus = 'Uploading to ' + parentName + '...';
|
||
this.uploadProgress = 10;
|
||
|
||
var folderHandled = formData.has('structure');
|
||
var url = folderHandled ? '/api/local-workspace/upload-folder' : '/api/local-workspace/upload';
|
||
|
||
try {
|
||
var r = await fetch(url, { method: 'POST', body: formData });
|
||
this.uploadProgress = 80;
|
||
var d = await r.json();
|
||
if (r.ok) {
|
||
this.uploadStatus = '✓ ' + d.items.length + ' item(s) uploaded';
|
||
this.uploadProgress = 100;
|
||
var items = d.items || [];
|
||
setTimeout(function() {
|
||
self.uploading = false;
|
||
self._reloadAfterAction();
|
||
}, 800);
|
||
} else {
|
||
this.uploadStatus = '✗ ' + (d.error || 'Upload failed');
|
||
this.uploadProgress = 0;
|
||
setTimeout(function() { self.uploading = false; }, 2000);
|
||
}
|
||
} catch(e) {
|
||
this.uploadStatus = '✗ Network error';
|
||
this.uploadProgress = 0;
|
||
setTimeout(function() { self.uploading = false; }, 2000);
|
||
}
|
||
},
|
||
|
||
// ── Tag management ──
|
||
async loadWorkspaceTags() {
|
||
try {
|
||
var r = await fetch('/api/settings/tags/all');
|
||
var d = await r.json();
|
||
this.workspaceTags = d.tags || [];
|
||
} catch(e) { this.workspaceTags = []; }
|
||
},
|
||
|
||
toggleTagFilter(tagName) {
|
||
this.tagFilter = (this.tagFilter === tagName) ? '' : tagName;
|
||
this.filterType = '';
|
||
this.searchQuery = '';
|
||
this.doFilter();
|
||
},
|
||
|
||
startTagInput(node) {
|
||
this.tagInputId = node.id;
|
||
var self = this;
|
||
this.$nextTick(function() {
|
||
var inp = self.$el.querySelector('.tag-input:not([style*="display: none"])');
|
||
if (inp) inp.focus();
|
||
});
|
||
},
|
||
|
||
async addTag(itemId, tagName) {
|
||
tagName = (tagName || '').trim();
|
||
if (!tagName) { this.tagInputId = null; return; }
|
||
try {
|
||
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({name: tagName})
|
||
});
|
||
if (r.ok) {
|
||
var d = await r.json();
|
||
// Update node tags in tree
|
||
this._updateNodeTags(this.tree, itemId, d.tag, 'add');
|
||
this.displayTree = [...this.tree];
|
||
this.loadWorkspaceTags();
|
||
}
|
||
} catch(e) {}
|
||
this.tagInputId = null;
|
||
},
|
||
|
||
async removeTag(itemId, tagId) {
|
||
try {
|
||
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {
|
||
method: 'DELETE'
|
||
});
|
||
if (r.ok) {
|
||
this._updateNodeTags(this.tree, itemId, null, 'remove', tagId);
|
||
this.displayTree = [...this.tree];
|
||
this.loadWorkspaceTags();
|
||
}
|
||
} catch(e) {}
|
||
},
|
||
|
||
_updateNodeTags(nodes, itemId, tag, action, removeTagId) {
|
||
for (var i=0; i<nodes.length; i++) {
|
||
if (nodes[i].id === itemId) {
|
||
if (action === 'add') {
|
||
if (!nodes[i].tags) nodes[i].tags = [];
|
||
// Avoid duplicates
|
||
if (!nodes[i].tags.some(function(t) { return t.id === tag.id; })) {
|
||
nodes[i].tags.push(tag);
|
||
}
|
||
} else if (action === 'remove') {
|
||
nodes[i].tags = (nodes[i].tags || []).filter(function(t) { return t.id !== removeTagId; });
|
||
}
|
||
return true;
|
||
}
|
||
if (nodes[i].children && this._updateNodeTags(nodes[i].children, itemId, tag, action, removeTagId)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
},
|
||
|
||
// ── Helpers ──
|
||
_formatDate(dt) {
|
||
if (!dt) return '—';
|
||
var d = new Date(dt.replace(' ', 'T') + 'Z');
|
||
if (isNaN(d.getTime())) return '—';
|
||
var now = new Date();
|
||
var diffMs = now - d;
|
||
var diffMin = Math.floor(diffMs / 60000);
|
||
var diffHrs = Math.floor(diffMs / 3600000);
|
||
var diffDays = Math.floor(diffMs / 86400000);
|
||
if (diffMin < 1) return 'Just now';
|
||
if (diffMin < 60) return diffMin + 'm ago';
|
||
if (diffHrs < 24) return diffHrs + 'h ago';
|
||
if (diffDays < 7) return diffDays + 'd ago';
|
||
return d.toLocaleDateString('en-CA');
|
||
},
|
||
|
||
_fileTypeLabel(name, contentFormat) {
|
||
if (contentFormat && contentFormat !== 'file') return 'Page';
|
||
var n = (name || '').toLowerCase();
|
||
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return 'Image';
|
||
if (/\.pdf$/.test(n)) return 'PDF';
|
||
if (/\.(py|js|jsx|ts|tsx|rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r|sh|bash|ps1|sql)$/.test(n)) return 'Code';
|
||
if (/\.(txt|md|markdown|log)$/.test(n)) return 'Text';
|
||
if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return 'Archive';
|
||
if (/\.(mp3|wav|ogg|flac|aac)$/.test(n)) return 'Audio';
|
||
if (/\.(mp4|webm|mov|avi)$/.test(n)) return 'Video';
|
||
return 'File';
|
||
},
|
||
|
||
selectAllFiltered(ev) {
|
||
if (ev.target.checked) {
|
||
for (var i=0; i<this.filteredTableItems.length; i++) {
|
||
this.selectedIds[this.filteredTableItems[i].id] = true;
|
||
}
|
||
} else {
|
||
for (var k in this.selectedIds) delete this.selectedIds[k];
|
||
}
|
||
this.selectedIds = Object.assign({}, this.selectedIds);
|
||
this.selectedIdsCount = Object.keys(this.selectedIds).length;
|
||
}
|
||
|
||
};
|
||
})();
|
||
// Injecter toutes les props comme globales pour Alpine (résout les 36 erreurs de scope)
|
||
var _wsKeys = Object.keys(window._wsInitData);
|
||
for (var _i = 0; _i < _wsKeys.length; _i++) {
|
||
try { window[_wsKeys[_i]] = window._wsInitData[_wsKeys[_i]]; } catch(e) {}
|
||
}
|
||
_wsInitData = window._wsInitData;
|
||
// Objet léger pour le ctx-menu (évite le conflit $nextTick avec Alpine)
|
||
window._ctxMenuData = {
|
||
ctxMenu: {visible:false,x:0,y:0,node:null,tags:[]},
|
||
ctxTagAdding: false,
|
||
ctxTagExistingOpen: false,
|
||
ctxAvailTags: [],
|
||
ctxNewTagColor: '#787774',
|
||
ctxTagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
|
||
// Use Alpine proxy (window._wsData) for reactivity, not raw object (window._wsInitData)
|
||
_ws() { return window._wsData || window._wsInitData; },
|
||
ctxMenuOpenPage() { this._ws().ctxMenuOpenPage(); },
|
||
ctxMenuOpenFolder() { this._ws().ctxMenuOpenFolder(); },
|
||
ctxMenuRename() { this._ws().ctxMenuRename(); },
|
||
ctxMenuDuplicate() { this._ws().ctxMenuDuplicate(); },
|
||
ctxMenuDelete() { this._ws().ctxMenuDelete(); },
|
||
ctxAddExistingTag(t) { this._ws().ctxAddExistingTag(t); },
|
||
ctxAddNewTag(n, c) { this._ws().ctxAddNewTag(n, c); },
|
||
ctxRemoveTag(id) { this._ws().ctxRemoveTag(id); },
|
||
};
|
||
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
|
||
</script>
|
||
<script>console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
|
||
|
||
<div class="ws-split"
|
||
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
|
||
@click.self="ctxMenu.visible = false"
|
||
@touchstart="onTouchStart($event)"
|
||
@touchend="onTouchEnd($event)"
|
||
@touchmove="onTouchMove($event)"
|
||
tabindex="0"
|
||
x-ref="layout">
|
||
<div class="ws-main">
|
||
|
||
<!-- Drop overlay -->
|
||
<div class="drop-overlay" :class="{ active: isDragging }">
|
||
<div class="drop-hint">
|
||
{{ fd_icon("download",16) }}
|
||
<div>Drop files here</div>
|
||
<div style="font-size:12px;color:var(--text-tertiary);margin-top:6px;" x-show="dropTarget">
|
||
Into: <strong x-text="dropTargetName"></strong>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Upload progress -->
|
||
<div class="upload-progress" x-show="uploading" x-transition>
|
||
<div style="display:flex;align-items:center;gap:8px;">
|
||
<span x-text="uploading ? '⏳' : ''"></span>
|
||
<span x-text="uploadStatus"></span>
|
||
<span style="font-size:11px;color:var(--text-tertiary);" x-show="uploadSpeed" x-text="uploadSpeed"></span>
|
||
</div>
|
||
<div class="up-bar">
|
||
<div class="up-bar-fill" :style="{ width: uploadProgress + '%' }"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Undo toast -->
|
||
<div class="undo-toast" x-show="undoVisible" x-transition>
|
||
<span x-text="undoMessage"></span>
|
||
<button class="undo-btn" @click="undoDelete()">Undo</button>
|
||
<button class="undo-dismiss" @click="undoVisible=false">×</button>
|
||
</div>
|
||
|
||
<!-- Breadcrumb + toolbar row (Library-style) -->
|
||
<div class="breadcrumb-row">
|
||
<button class="btn-icon nav-arrow" x-show="sidebarCollapsed" x-cloak @click="sidebarCollapsed = false" title="Show sidebar">{{ fd_icon("chevron-right",14) }}</button>
|
||
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
|
||
<button class="btn-icon nav-arrow" @click="goBack()" title="Back" :disabled="!canGoBack">{{ fd_icon("chevron-left",14) }}</button>
|
||
<button class="btn-icon nav-arrow" @click="goForward()" title="Forward" :disabled="!canGoForward">{{ fd_icon("chevron-right",14) }}</button>
|
||
{% if breadcrumbs %}
|
||
<div class="unified-breadcrumb">
|
||
<span class="bc-icon">{{ fd_icon("folder",13) }}</span>
|
||
<a href="/local-workspace" @click.prevent="navigateToRoot()" class="bc-root">{{ workspace_name }}</a>
|
||
<template x-for="(b, i) in folderStack" :key="'bc'+b.id">
|
||
<span style="display:contents;">
|
||
<span class="bc-sep">/</span>
|
||
<a href="#" @click.prevent="navigateToBreadcrumb(i)" x-text="b.name"
|
||
:class="{ 'bc-current': i === folderStack.length - 1 }"
|
||
@dblclick.prevent="startInlineRename(b, $event)"></a>
|
||
</span>
|
||
</template>
|
||
<div class="bc-edit-wrap" x-show="editingPath" @click.outside="editingPath=false" style="display:contents;">
|
||
<input class="bc-edit-input" x-ref="pathEdit" :value="pathValue"
|
||
@keydown.enter="navigateToPath(), editingPath=false"
|
||
@keydown.escape="editingPath=false"
|
||
placeholder="/workspace/...">
|
||
<button class="bc-edit-go" @click="navigateToPath(), editingPath=false">{{ fd_icon("corner-down-right",13) }}</button>
|
||
</div>
|
||
<button class="bc-edit-trigger" x-show="!editingPath" @click="startPathEdit()" title="Edit path">{{ fd_icon("edit",11) }}</button>
|
||
</div>
|
||
{% endif %}
|
||
<div class="ws-toolbar" style="margin-left:auto;">
|
||
<!-- View dropdown -->
|
||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="viewMenuOpen=!viewMenuOpen; sortOpen=false; searchOpen=false">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
|
||
</button>
|
||
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
|
||
<div class="dd-label">View</div>
|
||
<div class="dd-item" :class="{active: viewMode==='tree'}" @click="viewMode='tree', previewItem=null, viewMenuOpen=false"><span class="check" x-text="viewMode==='tree'?'✓':''"></span>{{ fd_icon("folder",14) }} Tree</div>
|
||
<div class="dd-item" :class="{active: viewMode==='list'}" @click="viewMode='list', previewItem=null, viewMenuOpen=false"><span class="check" x-text="viewMode==='list'?'✓':''"></span>{{ fd_icon("list",14) }} List</div>
|
||
<div class="dd-item" :class="{active: viewMode==='details'}" @click="viewMode='details', previewItem=null, viewMenuOpen=false"><span class="check" x-text="viewMode==='details'?'✓':''"></span>{{ fd_icon("bar-chart",14) }} Details</div>
|
||
<div class="dd-item" :class="{active: viewMode==='title'}" @click="viewMode='title', previewItem=null, viewMenuOpen=false"><span class="check" x-text="viewMode==='title'?'✓':''"></span>{{ fd_icon("grid",14) }} Cards</div>
|
||
<div class="dd-item" :class="{active: viewMode==='content'}" @click="viewMode='content', previewItem=null, viewMenuOpen=false"><span class="check" x-text="viewMode==='content'?'✓':''"></span>{{ fd_icon("align-left",14) }} Content</div>
|
||
</div>
|
||
</div>
|
||
<!-- Sort -->
|
||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; viewMenuOpen=false">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
|
||
</button>
|
||
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
|
||
<div class="dd-label">Sort by</div>
|
||
<div class="dd-item" :class="{active: sortBy==='name-asc'}" @click="sortBy='name-asc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='name-asc'?'✓':''"></span> Name A→Z</div>
|
||
<div class="dd-item" :class="{active: sortBy==='name-desc'}" @click="sortBy='name-desc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='name-desc'?'✓':''"></span> Name Z→A</div>
|
||
<div class="dd-item" :class="{active: sortBy==='type'}" @click="sortBy='type', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='type'?'✓':''"></span> Type</div>
|
||
<div class="dd-item" :class="{active: sortBy==='date-asc'}" @click="sortBy='date-asc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='date-asc'?'✓':''"></span> Oldest</div>
|
||
<div class="dd-item" :class="{active: sortBy==='date-desc'}" @click="sortBy='date-desc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='date-desc'?'✓':''"></span> Newest</div>
|
||
</div>
|
||
</div>
|
||
<!-- 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-btn-primary" onclick="window.FlowDeck.createPage()" title="New page">{{ fd_icon("file",14) }} New</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 -->
|
||
<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"><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="bulkDelete()" style="margin-left:auto;color:var(--danger);" title="Delete">
|
||
<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>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Tree -->
|
||
<div x-show="viewMode==='tree'" style="display:contents;">
|
||
|
||
<!-- Compact filter row -->
|
||
<div class="filter-row" x-show="tree.length>0 || searchQuery || filterType">
|
||
<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 x-show="filterType || searchQuery" class="filter-clear" @click="filterType='', searchQuery='', doFilter()">clear</span>
|
||
</div>
|
||
|
||
<ul class="ws-tree" x-show="viewMode==='tree' && (displayTree.length>0 || (currentFolder > 0))" @click="handleTreeAction($event)">
|
||
<!-- Parent folder ".." -->
|
||
<li x-show="currentFolder > 0" style="list-style:none;">
|
||
<div class="ws-tree-item" @click="goToParent()" style="padding-left:4px;">
|
||
<span style="width:16px;flex-shrink:0;"></span>
|
||
<span class="icon">{{ fd_icon("folder",16) }}</span>
|
||
<span class="name folder" style="font-weight:600;">..</span>
|
||
</div>
|
||
</li>
|
||
<template x-for="node in (showAll ? displayTree : displayTree.slice(0, 5))" :key="node.id">
|
||
<li>
|
||
<div class="ws-tree-item" :style="{paddingLeft:(node.depth||0)*20+'px'}"
|
||
draggable="true"
|
||
:data-ws-id="node.id"
|
||
:data-folder-id="node.is_folder ? node.id : null"
|
||
@dragstart="onInternalDragStart($event, node.id)"
|
||
@dragend="draggedItemId = null, $el.style.opacity = ''"
|
||
:class="{ selected: !!selectedIds[node.id] }">
|
||
<input type="checkbox" class="item-checkbox"
|
||
:checked="!!selectedIds[node.id]"
|
||
@click.stop="toggleSelect(node, $event)"
|
||
title="Select item">
|
||
<!-- drag handle -->
|
||
<div class="drag-handle" style="margin-right:2px;" title="Drag to move">
|
||
<span class="dot"></span><span class="dot"></span>
|
||
<span class="dot"></span><span class="dot"></span>
|
||
<span class="dot"></span><span class="dot"></span>
|
||
</div>
|
||
<!-- chevron -->
|
||
<template x-if="node.is_folder && node.children && node.children.length > 0">
|
||
<button class="ws-chevron"
|
||
:class="{ open: expanded[node.id] }"
|
||
@click.stop="toggleExpand(node.id)">▶</button>
|
||
</template>
|
||
<template x-if="!(node.is_folder && node.children && node.children.length > 0)">
|
||
<span style="width:16px;flex-shrink:0;"></span>
|
||
</template>
|
||
|
||
<span class="icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
|
||
|
||
<template x-if="node.is_folder">
|
||
<span class="name folder" @click.stop="navigateToFolder(node.id)"
|
||
@dblclick.prevent.stop="startInlineRename(node, $event)">
|
||
<span x-show="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 style="width:100%">
|
||
</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="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 style="width:100%">
|
||
</span>
|
||
</template>
|
||
|
||
<div class="actions">
|
||
<template x-if="node.is_folder">
|
||
<span style="display:flex;gap:2px">
|
||
<button class="ws-act" @click.stop="window.FlowDeck.createPage()" title="New file">{{ fd_icon("file",14) }}</button>
|
||
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal()" title="New folder">{{ fd_icon("folder",14) }}</button>
|
||
<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">{{ fd_icon("external-link",14) }}</a>
|
||
</span>
|
||
</template>
|
||
<button class="ws-act" @click.stop="startRename(node)" title="Rename">{{ fd_icon("edit",14) }}</button>
|
||
<button class="ws-act danger" @click.stop="deleteWithUndo(node)" title="Delete">{{ fd_icon("trash",14) }}</button>
|
||
</div>
|
||
</div>
|
||
<!-- Children -->
|
||
<ul class="ws-tree" x-show="expanded[node.id]" x-transition
|
||
x-html="renderChildren(node.children, (node.depth||0)+1)">
|
||
</ul>
|
||
</li>
|
||
</template>
|
||
<!-- "X more" toggle -->
|
||
<li x-show="displayTree.length > 5 && !showAll" style="list-style:none;">
|
||
<div class="ws-tree-item ws-more-item" @click="showAll = true" style="padding-left:4px;cursor:pointer;">
|
||
<span style="width:16px;flex-shrink:0;"></span>
|
||
<span class="icon">⋯</span>
|
||
<span class="name folder" style="color:var(--text-secondary);font-style:italic;"
|
||
x-text="displayTree.length - 5 + ' more...'"></span>
|
||
</div>
|
||
</li>
|
||
<!-- "Show less" toggle -->
|
||
<li x-show="displayTree.length > 5 && showAll" style="list-style:none;">
|
||
<div class="ws-tree-item ws-more-item" @click="showAll = false" style="padding-left:4px;cursor:pointer;">
|
||
<span style="width:16px;flex-shrink:0;"></span>
|
||
<span class="icon">🔼</span>
|
||
<span class="name folder" style="color:var(--text-secondary);">Show less</span>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- Tag filter bar -->
|
||
<div class="tag-filter-bar" x-show="workspaceTags && workspaceTags.length > 0">
|
||
<span style="font-size:11px;color:var(--text-tertiary);margin-right:4px;">Tags:</span>
|
||
<template x-for="t in workspaceTags" :key="'tf'+t.id">
|
||
<button class="tag-chip" :class="{ active: tagFilter === t.name }"
|
||
:style="{background: t.color}"
|
||
@click="toggleTagFilter(t.name)">
|
||
<span x-text="t.name"></span>
|
||
<span style="font-size:10px;opacity:.7" x-text="'('+t.count+')'"></span>
|
||
</button>
|
||
</template>
|
||
<button x-show="tagFilter" class="tag-chip rm-btn" @click="tagFilter='', doFilter()" style="margin-left:4px;">clear</button>
|
||
</div>
|
||
|
||
<!-- Filter table (shown when search/filter/tag is active) -->
|
||
<table class="ws-table-enhanced" x-show="(searchQuery || filterType || tagFilter) && filteredTableItems.length > 0">
|
||
<thead><tr>
|
||
<th style="width:30px"><input type="checkbox" @click="selectAllFiltered($event)"></th>
|
||
<th>Name</th>
|
||
<th>Path</th>
|
||
<th>Size</th>
|
||
<th>Modified</th>
|
||
<th>Type</th>
|
||
<th>Author</th>
|
||
<th>Tags</th>
|
||
<th style="width:60px"></th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<template x-for="node in filteredTableItems" :key="'ft'+node.id">
|
||
<tr :class="{ selected: !!selectedIds[node.id] }">
|
||
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td>
|
||
<td>
|
||
<span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
|
||
<span class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
|
||
</td>
|
||
<td class="col-path" x-text="node._path || '—'"></td>
|
||
<td class="col-size" x-text="node.size_display || '—'"></td>
|
||
<td class="col-date" x-text="_formatDate(node.updated_at)"></td>
|
||
<td class="col-type" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
|
||
<td class="col-author" x-text="node.author || '—'"></td>
|
||
<td class="col-tags">
|
||
<span style="display:flex;align-items:center;gap:2px;flex-wrap:wrap;">
|
||
<template x-for="t in (node.tags||[])" :key="'ftag'+t.id">
|
||
<span class="tag-chip" :style="{background: t.color}">
|
||
<span x-text="t.name"></span>
|
||
<button class="tag-chip rm-btn" @click.stop="removeTag(node.id, t.id)" title="Remove tag">×</button>
|
||
</span>
|
||
</template>
|
||
<button class="tag-add-btn" @click.stop="startTagInput(node)" title="Add tag">+</button>
|
||
<span x-show="tagInputId === node.id" style="display:contents;">
|
||
<input class="tag-input" x-ref="tagInputEl"
|
||
@keydown.enter="addTag(node.id, $event.target.value), $event.target.value=''"
|
||
@keydown.escape="tagInputId=null"
|
||
@blur="tagInputId=null"
|
||
@click.stop
|
||
placeholder="tag...">
|
||
</span>
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<button class="ws-act" @click.stop="startRename(node)" title="Rename">{{ fd_icon("edit",14) }}</button>
|
||
<button class="ws-act danger" @click.stop="deleteWithUndo(node)" title="Delete">{{ fd_icon("trash",14) }}</button>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
|
||
<!-- List view — compact flat list -->
|
||
<table class="ws-table-compact" x-show="viewMode==='list' && !searchQuery && !filterType && !tagFilter && displayTree.length>0">
|
||
<thead><tr>
|
||
<th style="width:24px"></th>
|
||
<th>Name</th>
|
||
<th style="width:80px">Type</th>
|
||
<th style="width:80px">Size</th>
|
||
<th style="width:100px">Modified</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<!-- Parent folder ".." -->
|
||
<tr x-show="currentFolder > 0" @click="goToParent()" style="cursor:pointer" class="parent-folder-row">
|
||
<td>{{ fd_icon("folder",16) }}</td>
|
||
<td class="col-name"><span style="font-weight:600;color:var(--text);">..</span></td>
|
||
<td style="font-size:12px;color:var(--text-tertiary)">Folder</td>
|
||
<td style="font-size:12px;color:var(--text-secondary)"></td>
|
||
<td style="font-size:12px;color:var(--text-tertiary)"></td>
|
||
</tr>
|
||
<template x-for="node in displayTree" :key="'l'+node.id">
|
||
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer">
|
||
<td><span x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span></td>
|
||
<td class="col-name" x-text="node.name"></td>
|
||
<td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
|
||
<td style="font-size:12px;color:var(--text-secondary)" x-text="node.is_folder ? (node.child_count||0)+' items' : (node.size_display||'—')"></td>
|
||
<td style="font-size:12px;color:var(--text-tertiary)" x-text="_formatDate(node.updated_at)"></td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
|
||
<!-- Details view — full table with all columns -->
|
||
<table class="ws-table-enhanced" x-show="viewMode==='details' && !searchQuery && !filterType && !tagFilter && displayTree.length>0">
|
||
<thead><tr>
|
||
<th style="width:24px"></th>
|
||
<th>Name</th>
|
||
<th>Path</th>
|
||
<th>Size</th>
|
||
<th>Modified</th>
|
||
<th>Type</th>
|
||
<th>Author</th>
|
||
<th>Tags</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<!-- Parent folder ".." -->
|
||
<tr x-show="currentFolder > 0" @click="goToParent()" style="cursor:pointer" class="parent-folder-row">
|
||
<td>{{ fd_icon("folder",16) }}</td>
|
||
<td class="col-name"><span style="font-weight:600;color:var(--text);">..</span></td>
|
||
<td class="col-path"></td>
|
||
<td class="col-size"></td>
|
||
<td class="col-date"></td>
|
||
<td class="col-type">Folder</td>
|
||
<td class="col-author"></td>
|
||
<td class="col-tags"></td>
|
||
</tr>
|
||
<template x-for="node in displayTree" :key="'d'+node.id">
|
||
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"
|
||
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
|
||
<td><span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span></td>
|
||
<td class="col-name" x-text="node.name"></td>
|
||
<td class="col-path" x-text="node._path || '—'"></td>
|
||
<td class="col-size" x-text="node.size_display || '—'"></td>
|
||
<td class="col-date" x-text="_formatDate(node.updated_at)"></td>
|
||
<td class="col-type" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
|
||
<td class="col-author" x-text="node.author || '—'"></td>
|
||
<td class="col-tags">
|
||
<span style="display:flex;align-items:center;gap:2px;flex-wrap:wrap;">
|
||
<template x-for="t in (node.tags||[])" :key="'dtag'+t.id">
|
||
<span class="tag-chip" :style="{background: t.color}"><span x-text="t.name"></span></span>
|
||
</template>
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
|
||
<!-- Title view — card grid -->
|
||
<div class="title-grid" x-show="viewMode==='title' && !searchQuery && !filterType && !tagFilter && displayTree.length>0">
|
||
<!-- Parent folder ".." -->
|
||
<div class="title-card parent-folder-card" x-show="currentFolder > 0" @click="goToParent()" style="cursor:pointer">
|
||
<div class="title-card-icon">{{ fd_icon("folder",24) }}</div>
|
||
<div class="title-card-name" style="font-weight:600;">..</div>
|
||
<div class="title-card-meta"><span>Parent folder</span></div>
|
||
</div>
|
||
<template x-for="node in displayTree" :key="'g'+node.id">
|
||
<div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
|
||
:class="{ 'selected-card': previewItem && previewItem.id === node.id }">
|
||
<div class="title-card-icon" x-html="node.is_folder ? getSvgIcon('folder',24) : _fileIcon(node.name, node.is_folder, node.content_format)"></div>
|
||
<div class="title-card-name" x-text="node.name"></div>
|
||
<div class="title-card-meta">
|
||
<span x-text="_formatDate(node.updated_at)"></span>
|
||
<span x-text="node.size_display || '—'"></span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Content view — list with inline preview -->
|
||
<div class="content-list" x-show="viewMode==='content' && !searchQuery && !filterType && !tagFilter && displayTree.length>0">
|
||
<!-- Parent folder ".." -->
|
||
<div class="content-item parent-folder-item" x-show="currentFolder > 0" @click="goToParent()" style="cursor:pointer">
|
||
<div class="content-item-header">
|
||
{{ fd_icon("folder",16) }}
|
||
<span class="col-name" style="font-weight:600;">..</span>
|
||
</div>
|
||
<div class="content-item-preview">Parent folder</div>
|
||
</div>
|
||
<template x-for="node in displayTree" :key="'c'+node.id">
|
||
<div class="content-item" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
|
||
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
|
||
<div class="content-item-header">
|
||
<span x-html="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
|
||
<span 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>
|
||
|
||
<!-- 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) -->
|
||
<div class="more-menu" x-data="_ctxMenuData" style="display:none"
|
||
@click.outside="document.querySelector('.more-menu').style.display='none'"
|
||
@keydown.escape="document.querySelector('.more-menu').style.display='none'"
|
||
x-transition>
|
||
<div class="menu-item" @click="ctxMenuOpenPage()">{{ fd_icon("file",14) }} Open</div>
|
||
<div class="menu-item" x-show="ctxMenu.node && ctxMenu.node.is_folder" @click="ctxMenuOpenFolder()">{{ fd_icon("folder",14) }} Open folder</div>
|
||
<div class="menu-item" @click="ctxMenuRename()">{{ fd_icon("edit",14) }} Rename</div>
|
||
<div class="menu-item" @click="ctxMenuDuplicate()">{{ fd_icon("copy",14) }} Duplicate</div>
|
||
<div class="more-sep"></div>
|
||
<!-- Current tags -->
|
||
<template x-for="t in (ctxMenu.tags||[])" :key="'ctx-tag'+t.id">
|
||
<div class="menu-item" style="justify-content:space-between;">
|
||
<span style="display:flex;align-items:center;gap:8px;"><span class="ctx-tag-color" :style="{background: t.color}"></span><span style="font-size:13px;" x-text="t.name"></span></span>
|
||
<button class="tag-chip rm-btn" @click.stop="ctxRemoveTag(t.id)" title="Remove">×</button>
|
||
</div>
|
||
</template>
|
||
<div x-show="(ctxMenu.tags||[]).length === 0" style="padding:4px 10px;font-size:11px;color:var(--text-dim);">No tags</div>
|
||
<div class="more-sep"></div>
|
||
<!-- Add existing tag -->
|
||
<div class="menu-item" @click.stop="ctxTagExistingOpen=!ctxTagExistingOpen" x-show="ctxAvailTags.length > 0">
|
||
<span style="flex:1;">Add existing tag</span>
|
||
<span x-text="ctxTagExistingOpen ? '▾' : '▸'" style="font-size:11px;"></span>
|
||
</div>
|
||
<div x-show="ctxTagExistingOpen && ctxAvailTags.length > 0" style="padding:0 0 4px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;">
|
||
<template x-for="t in ctxAvailTags" :key="'ctx-avail'+t.id">
|
||
<div class="menu-item" @click.stop="ctxAddExistingTag(t), ctxTagExistingOpen=false" style="justify-content:space-between;">
|
||
<span style="display:flex;align-items:center;gap:8px;"><span class="ctx-tag-color" :style="{background: t.color}"></span><span style="font-size:13px;" x-text="t.name"></span></span>
|
||
<span style="font-size:10px;color:var(--text-dim);" x-text="t.count||''"></span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<!-- Create new tag -->
|
||
<div class="menu-item" @click.stop="ctxTagAdding=!ctxTagAdding">
|
||
<span style="flex:1;">{{ fd_icon("plus",14) }} New tag</span>
|
||
<span x-text="ctxTagAdding ? '▾' : '▸'" style="font-size:11px;"></span>
|
||
</div>
|
||
<div x-show="ctxTagAdding" style="padding:6px 10px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;">
|
||
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:4px;margin-bottom:6px;">
|
||
<template x-for="c in ctxTagColors" :key="c">
|
||
<div class="color-swatch-ctx" :class="{ selected: ctxNewTagColor === c }"
|
||
:style="{background: c}" @click.stop="ctxNewTagColor=c"></div>
|
||
</template>
|
||
</div>
|
||
<div style="display:flex;gap:4px;">
|
||
<input class="tag-input" x-ref="ctxTagInput"
|
||
@keydown.enter="ctxAddNewTag($event.target.value), $event.target.value=''"
|
||
@keydown.escape="ctxTagAdding=false"
|
||
@click.stop placeholder="Tag name..." style="flex:1;">
|
||
<button class="btn btn-primary" @click.stop="ctxAddNewTag($refs.ctxTagInput.value), $refs.ctxTagInput.value=''" style="padding:2px 10px;font-size:12px;">Add</button>
|
||
</div>
|
||
</div>
|
||
<div class="more-sep"></div>
|
||
<div class="menu-item menu-danger" @click="ctxMenuDelete()">{{ fd_icon("trash",14) }} Delete</div>
|
||
</div>
|
||
|
||
<!-- 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 — right side panel for file preview -->
|
||
<template x-if="previewItem">
|
||
<div class="preview-panel" x-show="previewItem" x-transition:enter="preview-enter" x-transition:leave="preview-leave">
|
||
<div class="preview-panel-header">
|
||
<span x-html="_fileIcon(previewItem.name, previewItem.is_folder, previewItem.content_format)"></span>
|
||
<span class="preview-panel-title" x-text="previewItem.name"></span>
|
||
<button class="preview-close" @click="previewItem=null" title="Close preview">×</button>
|
||
</div>
|
||
<div class="preview-panel-meta">
|
||
<span x-text="previewItem.is_folder ? 'Folder' : _fileTypeLabel(previewItem.name, previewItem.content_format)"></span>
|
||
<span x-text="previewItem.size_display || '—'"></span>
|
||
<span x-text="_formatDate(previewItem.updated_at)"></span>
|
||
</div>
|
||
<div class="preview-panel-tags" x-show="(previewItem.tags||[]).length > 0">
|
||
<template x-for="t in (previewItem.tags||[])" :key="'ptag'+t.id">
|
||
<span class="tag-chip" :style="{background: t.color}"><span x-text="t.name"></span></span>
|
||
</template>
|
||
</div>
|
||
<div class="preview-panel-body" x-html="previewPanelContent || '<div style=text-align:center;padding:40px;color:var(--text-tertiary)>Loading preview...</div>'"></div>
|
||
<div class="preview-panel-actions">
|
||
<button class="btn btn-secondary" @click="openPage(previewItem.id)">{{ fd_icon("file",14) }} Open</button>
|
||
<button class="btn btn-secondary" @click="startRename(previewItem)">{{ fd_icon("edit",14) }} Rename</button>
|
||
<button class="btn btn-danger" @click="deleteWithUndo(previewItem)">{{ fd_icon("trash",14) }} Delete</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
</div><!-- /ws-split -->
|
||
|
||
</div><!-- /_wsInitData wrapper -->
|
||
|
||
{% endblock %}
|