@@ -189,6 +189,87 @@
.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);}
/* View selector */
.view-selector{display:flex;gap:2px;background:var(--bg-tertiary);border-radius:8px;padding:2px;}
.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 */
.preview-panel{position:fixed;top:44px;right:0;width:380px;max-width:90vw;height:calc(100vh - 44px);background:var(--bg);border-left:1px solid var(--border-strong);box-shadow:-4px 0 20px rgba(0,0,0,.3);z-index:200;display:flex;flex-direction:column;overflow:hidden;}
@media(max-width:768px){.preview-panel{width:100vw;max-width:100vw;}}
.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 >
< div class = "ws-layout" x-data = "wsInit()"
@@ -260,17 +341,23 @@
< 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 >
< div class = "view-selector " >
< button class = "view-btn" :class = "{ active: viewMode==='tree' }" @ click = "viewMode='tree'; previewItem=null" title = "Tree view "> 🌳 < / button >
< button class = "view-btn" :class = "{ active: viewMode==='list' }" @ click = "viewMode='list'; previewItem=null" title = "List view" > 📋 < / button >
< button class = "view-btn" :class = "{ active: viewMode==='details' }" @ click = "viewMode='details'; previewItem=null" title = "Details view" > 📊< / button >
< button class = "view-btn" :class = "{ active: viewMode==='title' }" @ click = "viewMode='title'; previewItem=null" title = "Title cards" > 🏷️< / button >
< button class = "view-btn" :class = "{ active: viewMode==='content' }" @ click = "viewMode='content'; previewItem=null" title = "Content preview" > 📝< / button >
< / div >
< 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 >
< option value = "date-asc" > Oldest< / option >
< option value = "date-desc" > Newest< / 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 = "expandAll()" title = "Expand all" x-show = "viewMode==='tree'" > ⊞< / button >
< button class = "btn-icon" @ click = "collapseAll()" title = "Collapse all" x-show = "viewMode==='tree'" > ⊟< / button >
< button class = "btn-icon" @ click = "openModal('file')" title = "New file" > 📄< / button >
< button class = "btn-icon" @ click = "openModal('folder')" title = "New folder" > 📁< / button >
< / div >
@@ -284,6 +371,7 @@
< / div >
<!-- Tree -->
< div x-show = "viewMode==='tree'" style = "display:contents;" >
<!-- Search bar -->
< div class = "search-bar" x-show = "tree.length>0 || searchQuery" >
< span class = "search-icon" > 🔍< / span >
@@ -294,6 +382,7 @@
<!-- 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 >
@@ -375,28 +464,175 @@
< / li >
< / template >
< / ul >
< / div >
<!-- Table view -->
< table class = "ws-table" x-show = "viewMode==='table' && displayTree .length> 0">
<!-- 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 = "selectAll($event)" > < / th >
< th > Name< / th > < th > Type < / th > < th style = "width:100px" > Size < / th >
< 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 flatTree " :key = "'t'+node.id" >
< template x-for = "node in filteredTableItems " :key = "'f 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 >
< 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 >
< 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') : '—'" > < / td >
< / tr >
< / template >
< / tbody >
< / table >
<!-- List view — compact flat list -->
< table class = "ws-table-compact" x-show = "viewMode==='list' && !searchQuery && !filterType && !tagFilter && flatTree.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 >
< template x-for = "node in flatTree" :key = "'l'+node.id" >
< tr @ click = "node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" style = "cursor:pointer" >
< td > < span x-text = "_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 && flatTree.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 >
< template x-for = "node in flatTree" :key = "'d'+node.id" >
< tr @ click = "node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" style = "cursor:pointer"
:class="{ 'selected-row': previewItem & & previewItem.id === node.id }">
< td > < span class = "table-icon" x-text = "_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" > < 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 && flatTree.length>0" >
< template x-for = "node in flatTree" :key = "'g'+node.id" >
< div class = "title-card" @ click = "node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)"
:class="{ 'selected-card': previewItem & & previewItem.id === node.id }">
< div class = "title-card-icon" x-text = "node.is_folder ? '📁' : _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 && flatTree.length>0" >
< template x-for = "node in flatTree" :key = "'c'+node.id" >
< div class = "content-item" @ click = "node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)"
:class="{ 'selected-row': previewItem & & previewItem.id === node.id }">
< div class = "content-item-header" >
< span x-text = "_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" > < span x-text = "t.name" > < / span > < / span >
< / template >
< / div >
< / div >
< / template >
< / 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 > < template x-for = "node in flatTree" :key = "'t'+node.id" >
< tr > < td x-text = "node.name" > < / td > < / tr >
< / template > < / tbody >
< / table >
<!-- Empty -->
< div class = "empty-state" x-show = "tree.length===0&&loaded" >
< div class = "empty-icon" > 📁< / div >
@@ -463,6 +699,31 @@
< div class = "file-preview-body" x-html = "previewContent" > < / div >
< / div >
<!-- Preview Panel — right side panel for file preview -->
< div class = "preview-panel" x-show = "previewItem && (viewMode==='list' || viewMode==='details' || viewMode==='title' || viewMode==='content')" x-transition:enter = "preview-enter" x-transition:leave = "preview-leave" >
< div class = "preview-panel-header" >
< span x-text = "_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" > < 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)" > 📄 Open< / button >
< button class = "btn btn-secondary" @ click = "startRename(previewItem)" > ✏️ Rename< / button >
< button class = "btn btn-danger" @ click = "deleteWithUndo(previewItem)" > 🗑 Delete< / button >
< / div >
< / div >
< / div >
< script >
@@ -503,6 +764,15 @@ document.addEventListener('alpine:init', function() {
sortBy: '',
viewMode: 'tree',
// Tags
workspaceTags: [],
tagFilter: '',
tagInputId: null,
// Preview panel
previewItem: null,
previewPanelContent: '',
// Multi-select
selectedIds: {},
selectedIdsCount: 0,
@@ -580,6 +850,8 @@ document.addEventListener('alpine:init', function() {
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; }
},
@@ -663,11 +935,126 @@ document.addEventListener('alpine:init', function() {
}
},
// ── 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;
},
// ── 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 style = "font-size:48px;display:block;margin-bottom:12px" > 📁< / span > '
+ '< div style = "font-size:14px" > ' + (node.child_count||0) + ' item(s)< / div > '
+ '< button class = "btn btn-secondary" style = "margin-top:16px" onclick = "window.location=\'/local-workspace?folder=' + node.id + '\'" > Open folder< / button > '
+ '< / div > ';
return;
}
try {
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') {
preview = '< div style = "text-align:center;padding:20px;color:var(--text-secondary)" > '
+ '< span style = "font-size:48px;display:block;margin-bottom:8px" > 📄< / span > '
+ 'Uploaded file — 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 ──
get contentSnippetMap() {
// Preload snippets? Too expensive. Return empty, load on hover.
return {};
},
_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);
}
@@ -893,6 +1280,8 @@ document.addEventListener('alpine:init', function() {
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;
},
@@ -1114,9 +1503,9 @@ document.addEventListener('alpine:init', function() {
doFilter() {
var q = (this.searchQuery || '').toLowerCase().trim();
var ft = this.filterType || '';
if (!q & & !ft) { this.displayTree = [...this.tree]; return; }
var self = this;
this.displayTree = this._filterTree( this.tree, q, ft) ;
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;
@@ -1133,6 +1522,7 @@ document.addEventListener('alpine:init', function() {
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;
@@ -1520,9 +1910,130 @@ document.addEventListener('alpine:init', function() {
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 >