Files
flowdeck/app/templates/local_workspace.html
T
bruno f4ad4f5b6d
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
feat: système de tags + vue table enrichie pour recherche/filtre workspace
- DB: tables tags + page_tags avec index
- API: CRUD tags (GET/POST/DELETE items/{id}/tags, GET /tags, GET /tags/search)
- Tree endpoint enrichi: size, dates, author, tags par nœud
- Table enrichie: colonnes Name, Path, Size, Modified, Type, Author, Tags
- Tag chips avec compteurs + filtrage par clic
- Ajout/retrait tags inline dans la table
- Filtre Folders + auto-switch table quand recherche active
2026-07-12 10:52:42 -04:00

1819 lines
77 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block page_title %}{{ workspace_name }}{% endblock %}
{% block page_icon %}📁{% endblock %}
{% block topbar %}
<header class="topbar">
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="topbar-left">
<span class="breadcrumb" style="display:flex;align-items:center;gap:4px;color:rgba(255,255,255,.5);font-size:13px;">
<a href="/local-workspace" style="color:rgba(255,255,255,.5);text-decoration:none;">📁 {{ workspace_name }}</a>
</span>
<span class="page-title-topbar">
{% if breadcrumb %}
{% for b in breadcrumb %}
/ <a href="/local-workspace?folder={{ b.id }}" style="color:rgba(255,255,255,.8);text-decoration:none;">{{ b.name }}</a>
{% endfor %}
{% endif %}
</span>
</div>
<div class="topbar-right">
<a href="/workspaces" class="page-action-btn" title="All workspaces">🏠</a>
</div>
</header>
{% endblock %}
{% block content %}
<style>
.ws-layout{max-width:800px;margin:0 auto;padding:40px 24px;}
@media (max-width: 768px) { .ws-layout { padding: 20px 12px; } }
.ws-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;flex-wrap:wrap;gap:12px;}
.ws-header h1{font-size:24px;}
.ws-toolbar{display:flex;gap:8px;}
.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;}
/* Chevron in main tree */
.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-tertiary);}
.empty-state .empty-icon{font-size:64px;margin-bottom:16px;opacity:.5;}
.empty-state h2{font-size:18px;color:var(--text-secondary);margin-bottom:8px;}
.empty-state p{font-size:13px;margin-bottom:24px;max-width:400px;margin-left:auto;margin-right:auto;}
.empty-actions{display:flex;gap:10px;justify-content:center;}
.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:#f85149;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);}
/* Multi-select */
.item-checkbox{width:14px;height:14px;flex-shrink:0;cursor:pointer;accent-color:var(--accent);opacity:0;transition:opacity 150ms;}
.ws-tree-item:hover .item-checkbox,.ws-tree-item.selected .item-checkbox{opacity:1;}
.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);}
/* 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,.6);display:flex;align-items:center;justify-content:center;z-index:999;backdrop-filter:blur(4px);}
.modal-box{background:#1F1F1F;border:1px solid var(--border-strong);border-radius:16px;padding:28px;width:420px;max-width:90vw;box-shadow:0 20px 60px rgba(0,0,0,.5);}
.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; }
.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);border:1px solid var(--border-strong);border-radius:12px;padding:16px 20px;z-index:200;box-shadow:0 8px 30px rgba(0,0,0,.4);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;}
/* Loading skeleton */
.skeleton{background:linear-gradient(90deg,var(--bg-tertiary) 25%,var(--bg-hover) 50%,var(--bg-tertiary) 75%);background-size:200% 100%;animation:shimmer 1.5s infinite;border-radius:4px;height:16px;margin:4px 0;}
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
/* 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);border:1px solid var(--border-strong);border-radius:12px;padding:10px 20px;z-index:300;box-shadow:0 8px 30px rgba(0,0,0,.5);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:#1a1a1a;background:var(--bg,#1a1a1a);border:1px solid var(--border-strong,#333);border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.5);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,#888);border-bottom:1px solid var(--border,#333);}
.file-preview-body{padding:10px 12px;margin:0;font-size:12px;line-height:1.5;color:var(--text,#ddd);max-height:200px;overflow:auto;font-family:'Fira Code','Cascadia Code',monospace;white-space:pre-wrap;word-wrap:break-word;background:#141414;}
/* Tag chips */
.tag-chip{display:inline-flex;align-items:center;gap:3px;padding:1px 7px;background:rgba(120,119,116,.15);border-radius:10px;font-size:11px;color:var(--text-secondary);white-space:nowrap;margin:1px 2px;transition:all 150ms;}
.tag-chip:hover{background:rgba(120,119,116,.25);}
.tag-chip.rm-btn{background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:13px;line-height:1;padding:0 2px;border-radius:50%;}
.tag-chip.rm-btn:hover{color:var(--danger);background:rgba(255,80,80,.15);}
.tag-chip.active{background:var(--accent);color:#fff;}
.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);}
</style>
<div class="ws-layout" x-data="wsInit()"
@dragover.prevent="onDragOver($event)"
@dragleave="onDragLeave($event)"
@drop.prevent="onDrop($event)"
@keydown="onKeydown($event)"
tabindex="0"
x-ref="layout">
<!-- Drop overlay -->
<div class="drop-overlay" :class="{ active: isDragging }">
<div class="drop-hint">
<span class="icon">📥</span>
<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 -->
<div class="breadcrumb-bar" x-show="breadcrumb.length > 0">
<a href="/local-workspace">📁 {{ workspace_name }}</a>
<template x-for="(b, i) in breadcrumb" :key="b.id">
<span style="display:contents;">
<span class="sep">/</span>
<a :href="'/local-workspace?folder=' + b.id" x-text="b.name"
:class="{ current: i === breadcrumb.length - 1 }"
@dblclick.prevent="startInlineRename(b, $event)"></a>
</span>
</template>
</div>
<!-- Path bar -->
<div class="path-bar" x-show="breadcrumb.length > 0">
<span class="path-icon">📂</span>
<input class="path-input" :value="pathValue"
@keydown.enter="navigateToPath()"
@focus="$event.target.select()"
placeholder="/workspace/...">
<button class="path-go" @click="navigateToPath()" title="Go">→</button>
</div>
<div class="ws-header">
<h1 x-show="breadcrumb.length===0">📁 {{ workspace_name }}</h1>
<h1 x-show="breadcrumb.length>0">
📁 <span x-text="breadcrumb.length > 0 ? breadcrumb[breadcrumb.length-1].name : ''"></span>
</h1>
<div class="ws-toolbar">
<button class="btn-icon" @click="goBack()" title="Back" :disabled="!canGoBack">←</button>
<button class="btn-icon" @click="goForward()" title="Forward" :disabled="!canGoForward">→</button>
<button class="btn-icon" @click="viewMode = (viewMode==='tree'?'table':'tree')" :title="viewMode==='tree'?'Table view':'Tree view'">
<span x-text="viewMode==='tree'?'📋':'🌳'"></span>
</button>
<select class="sort-select" x-model="sortBy" @change="doSort()">
<option value="">Sort by...</option>
<option value="name-asc">Name A→Z</option>
<option value="name-desc">Name Z→A</option>
<option value="type">Type</option>
</select>
<button class="btn-icon" @click="expandAll()" title="Expand all">⊞</button>
<button class="btn-icon" @click="collapseAll()" title="Collapse all">⊟</button>
<button class="btn-icon" @click="openModal('file')" title="New file">📄</button>
<button class="btn-icon" @click="openModal('folder')" title="New folder">📁</button>
</div>
</div>
<!-- Bulk actions bar -->
<div class="bulk-bar" x-show="selectedIdsCount > 0" x-transition>
<span x-text="selectedIdsCount + ' selected'"></span>
<button class="btn btn-danger" @click="bulkDelete()">🗑 Delete</button>
<button class="btn btn-secondary" @click="for(var k in selectedIds) delete selectedIds[k]; selectedIdsCount=0">✕ Clear</button>
</div>
<!-- Tree -->
<!-- Search bar -->
<div class="search-bar" x-show="tree.length>0 || searchQuery">
<span class="search-icon">🔍</span>
<input class="search-input" type="text" placeholder="Filter files & folders..."
x-model="searchQuery" @input="doFilter" autocomplete="off">
<button class="search-clear" x-show="searchQuery" @click="searchQuery='';doFilter()" title="Clear">×</button>
</div>
<!-- Filter chips -->
<div class="filter-chips" x-show="tree.length>0">
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders only">📁 Folders</button>
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="FlowDeck pages">📝 Pages</button>
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF">📕 PDF</button>
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images">🖼️ Img</button>
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code">📜 Code</button>
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text">📄 Text</button>
</div>
<ul class="ws-tree" x-show="viewMode==='tree' && (displayTree.length>0 || (currentFolder > 0))" @click="handleTreeAction($event)">
<!-- Parent folder ".." -->
<li x-show="currentFolder > 0">
<div class="ws-tree-item" @click="goToParent()" style="padding-left:4px;">
<span style="width:16px;flex-shrink:0;"></span>
<span class="icon">📂</span>
<span class="name folder" style="font-weight:600;">..</span>
</div>
</li>
<template x-for="node in displayTree" :key="node.id">
<li>
<div class="ws-tree-item" :style="{paddingLeft:(node.depth||0)*20+'px'}"
draggable="true"
: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">
<!-- 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-text="_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="openPage(node.id)"
@dblclick.stop="startInlineRename(node, $event)"
@mouseenter="showPreview($event, node)"
@mouseleave="hidePreview()">
<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="openModal('file',node)" title="New file">📄</button>
<button class="ws-act" @click.stop="openModal('folder',node)" title="New folder">📁</button>
</span>
</template>
<button class="ws-act" @click.stop="startRename(node)" title="Rename">✏️</button>
<button class="ws-act danger" @click.stop="deleteWithUndo(node)" title="Delete">🗑</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>
</ul>
<!-- 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 }"
@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-text="_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">
<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">✏️</button>
<button class="ws-act danger" @click.stop="deleteWithUndo(node)" title="Delete">🗑</button>
</td>
</tr>
</template>
</tbody>
</table>
<!-- Regular table view (toggle) -->
<table class="ws-table" x-show="viewMode==='table' && !searchQuery && !filterType && !tagFilter && displayTree.length>0">
<thead><tr>
<th style="width:30px"><input type="checkbox" @click="selectAll($event)"></th>
<th>Name</th><th>Type</th><th style="width:100px">Size</th>
</tr></thead>
<tbody>
<template x-for="node in flatTree" :key="'t'+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-text="_fileIcon(node.name, node.is_folder, node.content_format)"></span>
<span class="table-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
</td>
<td x-text="node.is_folder ? 'Folder' : 'File'"></td>
<td class="table-meta" x-text="node.is_folder ? (node.child_count ? node.child_count+' items' : 'Empty') : (node.size_display || '—')"></td>
</tr>
</template>
</tbody>
</table>
<!-- Empty -->
<div class="empty-state" x-show="tree.length===0&&loaded">
<div class="empty-icon">📁</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" @click="openModal('file')" title="New file">📄</button>
<button class="btn-icon" @click="openModal('folder')" title="New folder">📁</button>
</div>
</div>
<!-- Offline banner -->
<div class="offline-banner" x-show="!online" x-transition>
<span>⚠️ You are offline. Changes will sync when connection is restored.</span>
</div>
<!-- Create Modal -->
<div class="modal-overlay" x-show="showCreate" @click.away="showCreate=false" @keydown.escape="showCreate=false">
<div class="modal-box">
<h3><span x-text="createType==='folder'?'📁':'📄'"></span> <span x-text="createType==='folder'?'New Folder':'New File'"></span></h3>
<p class="modal-sub">
In <strong>{{ workspace_name }}</strong>
<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.away="showRename=false" @keydown.escape="showRename=false">
<div class="modal-box">
<h3>✏️ 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.away="showDelete=false" @keydown.escape="showDelete=false">
<div class="modal-box">
<h3>🗑 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>
<!-- 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>
</div>
<script>
document.addEventListener('alpine:init', function() {
Alpine.data('wsInit', function() {
return {
tree:[],
displayTree:[],
breadcrumb: [],
loaded:false,
showCreate:false,
showRename:false,
showDelete: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: '',
// Inline rename
renamingId: null,
renamingName: '',
renameInputRef: null,
// Preview cache
previewCache: {},
// Clipboard for copy/paste
clipboard: [],
// Sort
sortBy: '',
viewMode: 'tree',
// Tags
workspaceTags: [],
tagFilter: '',
tagInputId: null,
// Multi-select
selectedIds: {},
selectedIdsCount: 0,
lastSelectedId: null,
// Undo
undoVisible: false,
undoMessage: '',
undoItemId: null,
undoTimeout: null,
// Upload tracking
uploadSpeed: '',
uploadStartTime: 0,
// Path bar
get pathValue() {
return '/workspace/' + this.breadcrumb.map(function(b){return b.name}).join('/');
},
// 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,
get canGoBack() { return this.navIndex > 0; },
get canGoForward() { return this.navIndex < this.navHistory.length - 1; },
// 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 {
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 nav history
this.navHistory = [this.currentFolder || 0];
this.navIndex = 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; }
},
// ── 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;
for (var i=0; i<ids.length; i++) {
await fetch('/api/local-workspace/items/' + ids[i], { method: 'DELETE' });
}
for (var k in this.selectedIds) delete this.selectedIds[k];
this.selectedIdsCount = 0;
this._reloadAfterAction();
},
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 ──
get flatTree() {
var result = [];
this._flattenTree(this.tree, result);
return result;
},
_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) ──
get filteredTableItems() {
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) 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
return 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;
},
_markTree(nodes, depth) {
for (var i=0; i<nodes.length; i++) {
var n = nodes[i];
n.depth = depth;
n.db_id = n.id;
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);
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="'+c.id+'"'
+ ' ondragstart="var d=window._wsData;if(d)d.onInternalDragStart(event,'+c.id+')"'
+ ' ondragend="event.target.style.opacity=\'\'">';
if (c.is_folder && hasKids) {
html += '<button class="ws-chevron ws-expand-btn" data-ws-id="'+c.id+'" onclick="event.stopPropagation();var d=window._wsData;if(d)d.toggleExpand('+c.id+',this)">▶</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.location=\'/local-workspace?folder='+c.id+'\'">'+c.name+countBadge+'</span>';
} else {
html += '<span class="name file" onclick="window.location=\'/pages/'+c.id+'\'"'
+ ' onmouseenter="var d=window._wsData;if(d)d.showPreview(event,{id:'+c.id+',name:\''+escName+'\',is_folder:false,content_format:\''+(c.content_format||'')+'\'})"'
+ ' onmouseleave="var d=window._wsData;if(d)d.hidePreview()">'+c.name+'</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="'+c.id+'" data-ws-name="'+escName+'">📄</button>';
html += '<button class="ws-act" data-ws-action="new-folder" data-ws-id="'+c.id+'" data-ws-name="'+escName+'">📁</button>';
html += '</span>';
}
html += '<button class="ws-act" data-ws-action="rename" data-ws-id="'+c.id+'" data-ws-name="'+escName+'">✏️</button>';
html += '<button class="ws-act danger" data-ws-action="delete" data-ws-id="'+c.id+'" data-ws-name="'+escName+'">🗑</button>';
html += '</div></div>';
if (hasKids) {
html += '<ul class="ws-tree" style="display:none">'+this.renderChildren(c.children, depth+1)+'</ul>';
}
html += '</li>';
}
return html;
},
esc(s) {
if (!s) return '';
return s.replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'&quot;');
},
// ── 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':
this.openModal('file', {db_id: id, name: name});
break;
case 'new-folder':
this.openModal('folder', {db_id: id, name: name});
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;
}
}
}
}
},
navigateToFolder(id) {
// Push to nav history
if (this.navIndex < this.navHistory.length - 1) {
this.navHistory = this.navHistory.slice(0, this.navIndex + 1);
}
this.navHistory.push(id);
this.navIndex = this.navHistory.length - 1;
this._saveExpandedState();
window.location = '/local-workspace?folder=' + id;
},
goBack() {
if (this.navIndex <= 0) return;
this.navIndex--;
var id = this.navHistory[this.navIndex];
this._saveExpandedState();
window.location = id ? '/local-workspace?folder=' + id : '/local-workspace';
},
goForward() {
if (this.navIndex >= this.navHistory.length - 1) return;
this.navIndex++;
var id = this.navHistory[this.navIndex];
this._saveExpandedState();
window.location = id ? '/local-workspace?folder=' + id : '/local-workspace';
},
goToParent() {
var bc = this.breadcrumb;
if (bc.length <= 1) { window.location = '/local-workspace'; return; }
var parentId = bc[bc.length - 2].id;
this._saveExpandedState();
window.location = '/local-workspace?folder=' + parentId;
},
_saveExpandedState() {
try { localStorage.setItem('fd_ws_expanded', JSON.stringify(this.expanded)); }
catch(e) {}
},
_reloadAfterAction() {
this._saveExpandedState();
window.location.reload();
},
// ── 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]; 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 === 'type') sorted.sort(function(a,b) { return (a.is_folder===b.is_folder) ? 0 : a.is_folder ? -1 : 1; });
this.displayTree = sorted;
},
// ── Undo delete ──
async deleteWithUndo(node) {
var self = this;
this.undoItemId = node.db_id || node.id;
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
this.undoVisible = true;
// Soft-delete: actually delete after timeout
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, { method:'DELETE' });
if (!r.ok) { this.undoVisible = false; return; }
// Remove from tree immediately
this._removeFromTree(this.tree, this.undoItemId);
if (!this.searchQuery && !this.filterType && !this.sortBy) this.displayTree = [...this.tree];
else if (this.sortBy) this.doSort();
else this.doFilter();
// Auto-dismiss + permanent after 5s
this.undoTimeout = setTimeout(function() {
self.undoVisible = false;
self.undoItemId = null;
}, 5000);
},
undoDelete() {
if (this.undoTimeout) clearTimeout(this.undoTimeout);
this.undoVisible = false;
var id = this.undoItemId;
if (!id) return;
// Recreate the item (we can't really undo a DELETE, so reload)
this._reloadAfterAction();
this.undoItemId = null;
},
_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 {
alert('Path not found: ' + parts.join('/'));
}
},
_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;"><span style="font-size:48px;">📕</span><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 '📁';
// FlowDeck internal pages (no extension, blocks/markdown format)
if (contentFormat && contentFormat !== 'file') return '📝';
var n = (name || '').toLowerCase();
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return '🖼️';
if (/\.pdf$/.test(n)) return '📕';
if (/\.(md|markdown)$/.test(n)) return '📝';
if (/\.(py)$/.test(n)) return '🐍';
if (/\.(js|jsx|ts|tsx)$/.test(n)) return '📜';
if (/\.(html?|xml)$/.test(n)) return '🌐';
if (/\.css$/.test(n)) return '🎨';
if (/\.json$/.test(n)) return '📋';
if (/\.sql$/.test(n)) return '🗃️';
if (/\.(sh|bash|zsh)$/.test(n)) return '💻';
if (/\.ps1$/.test(n)) return '⚡';
if (/\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$/.test(n)) return '📜';
if (/\.(txt|log)$/.test(n)) return '📄';
if (/\.(zip|tar|gz|rar|7z)$/.test(n)) return '📦';
return '📄';
},
// ── Search / Filter ──
doFilter() {
var q = (this.searchQuery || '').toLowerCase().trim();
var ft = this.filterType || '';
if (!q && !ft && !this.tagFilter) { this.displayTree = [...this.tree]; return; }
// filteredTableItems is a computed getter — just trigger reactivity
this.displayTree = [...this.tree];
},
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();
}
},
_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 {
alert('Move failed');
}
} catch(e) {
alert('Move failed: ' + e.message);
}
},
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/local-workspace/tags');
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 = '';
},
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;
}
};
});
});
</script>
{% endblock %}