@@ -189,6 +189,87 @@
. file-preview { position : fixed ; z-index : 300 ; background : #1a1a1a ; background : var ( - - bg , #1a1a1a ) ; border : 1 px solid var ( - - border - strong , #333 ) ; border-radius : 10 px ; box-shadow : 0 8 px 30 px rgba ( 0 , 0 , 0 , .5 ) ; max-width : 400 px ; min-width : 200 px ; pointer-events : auto ; }
. file-preview-header { padding : 8 px 12 px ; font-size : 12 px ; font-weight : 600 ; color : var ( - - text - secondary , #888 ) ; border-bottom : 1 px solid var ( - - border , #333 ) ; }
. file-preview-body { padding : 10 px 12 px ; margin : 0 ; font-size : 12 px ; line-height : 1.5 ; color : var ( - - text , #ddd ) ; max-height : 200 px ; 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 : 3 px ; padding : 1 px 7 px ; background : rgba ( 120 , 119 , 116 , .15 ) ; border-radius : 10 px ; font-size : 11 px ; color : var ( - - text - secondary ) ; white-space : nowrap ; margin : 1 px 2 px ; transition : all 150 ms ; }
. 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 : 13 px ; line-height : 1 ; padding : 0 2 px ; 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 : 3 px ; padding : 1 px 7 px ; background : none ; border : 1 px dashed var ( - - border ) ; border-radius : 10 px ; font-size : 11 px ; color : var ( - - text - tertiary ) ; cursor : pointer ; white-space : nowrap ; transition : all 150 ms ; }
. tag-add-btn : hover { border-color : var ( - - accent ) ; color : var ( - - accent ) ; }
. tag-input { background : var ( - - bg ) ; border : 1 px solid var ( - - accent ) ; border-radius : 8 px ; color : var ( - - text ) ; font-size : 11 px ; padding : 2 px 6 px ; outline : none ; min-width : 80 px ; }
. tag-filter-bar { display : flex ; align-items : center ; gap : 6 px ; flex-wrap : wrap ; margin-bottom : 8 px ; padding : 4 px 0 ; }
/* Enhanced table */
. ws-table-enhanced { width : 100 % ; border-collapse : collapse ; font-size : 13 px ; margin-top : 4 px ; }
. ws-table-enhanced th { text-align : left ; padding : 10 px 12 px ; color : var ( - - text - tertiary ) ; font-weight : 500 ; border-bottom : 2 px solid var ( - - border ) ; font-size : 11 px ; text-transform : uppercase ; letter-spacing : .5 px ; position : sticky ; top : 0 ; background : var ( - - bg ) ; z-index : 1 ; }
. ws-table-enhanced td { padding : 8 px 12 px ; border-bottom : 1 px 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 : 150 px ; }
. ws-table-enhanced . col-name : hover { color : var ( - - accent ) ; }
. ws-table-enhanced . col-path { color : var ( - - text - tertiary ) ; font-size : 12 px ; font-family : 'Fira Code' , monospace ; max-width : 200 px ; overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; }
. ws-table-enhanced . col-size { color : var ( - - text - secondary ) ; font-size : 12 px ; white-space : nowrap ; }
. ws-table-enhanced . col-date { color : var ( - - text - tertiary ) ; font-size : 12 px ; white-space : nowrap ; }
. ws-table-enhanced . col-type { font-size : 12 px ; white-space : nowrap ; }
. ws-table-enhanced . col-author { font-size : 12 px ; color : var ( - - text - secondary ) ; white-space : nowrap ; }
. ws-table-enhanced . col-tags { min-width : 120 px ; }
. ws-table-enhanced . table-icon { font-size : 16 px ; margin-right : 6 px ; vertical-align : -2 px ; }
/* Table compact mode (inside filter/search) */
. ws-table-compact { width : 100 % ; border-collapse : collapse ; font-size : 13 px ; }
. ws-table-compact th { text-align : left ; padding : 6 px 10 px ; color : var ( - - text - tertiary ) ; font-weight : 500 ; border-bottom : 2 px solid var ( - - border ) ; font-size : 11 px ; text-transform : uppercase ; }
. ws-table-compact td { padding : 5 px 10 px ; border-bottom : 1 px solid var ( - - border ) ; }
. ws-table-compact tr : hover td { background : var ( - - bg - hover ) ; }
/* View selector */
. view-selector { display : flex ; gap : 2 px ; background : var ( - - bg - tertiary ) ; border-radius : 8 px ; padding : 2 px ; }
. view-btn { width : 32 px ; height : 32 px ; display : inline-flex ; align-items : center ; justify-content : center ; background : none ; border : none ; border-radius : 6 px ; cursor : pointer ; font-size : 14 px ; color : var ( - - text - tertiary ) ; transition : all 150 ms ; }
. 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 ( 160 px , 1 fr ) ) ; gap : 8 px ; margin-top : 8 px ; }
. title-card { display : flex ; flex-direction : column ; align-items : center ; padding : 16 px 8 px ; background : var ( - - bg - tertiary ) ; border : 1 px solid var ( - - border ) ; border-radius : 10 px ; cursor : pointer ; transition : all 150 ms ; min-height : 100 px ; 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 : 28 px ; margin-bottom : 8 px ; }
. title-card-name { font-size : 13 px ; 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 : 8 px ; margin-top : 6 px ; font-size : 11 px ; color : var ( - - text - tertiary ) ; }
/* Content list view */
. content-list { display : flex ; flex-direction : column ; gap : 4 px ; margin-top : 8 px ; }
. content-item { padding : 10 px 12 px ; background : var ( - - bg - tertiary ) ; border : 1 px solid var ( - - border ) ; border-radius : 8 px ; cursor : pointer ; transition : all 150 ms ; }
. 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 : 8 px ; font-size : 14 px ; }
. content-item-preview { font-size : 12 px ; color : var ( - - text - secondary ) ; margin-top : 6 px ; padding-left : 24 px ; overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; max-width : 600 px ; }
. content-item-tags { display : flex ; gap : 4 px ; margin-top : 6 px ; padding-left : 24 px ; }
/* Selected row highlight */
. selected-row { background : rgba ( 35 , 131 , 226 , .08 ) !important ; }
/* Preview panel */
. preview-panel { position : fixed ; top : 44 px ; right : 0 ; width : 380 px ; max-width : 90 vw ; height : calc ( 100 vh - 44 px ) ; background : var ( - - bg ) ; border-left : 1 px solid var ( - - border - strong ) ; box-shadow : -4 px 0 20 px rgba ( 0 , 0 , 0 , .3 ) ; z-index : 200 ; display : flex ; flex-direction : column ; overflow : hidden ; }
@ media ( max-width : 768px ) { . preview-panel { width : 100 vw ; max-width : 100 vw ; } }
. preview-panel-header { display : flex ; align-items : center ; gap : 10 px ; padding : 14 px 16 px ; border-bottom : 1 px solid var ( - - border ) ; flex-shrink : 0 ; }
. preview-panel-title { font-size : 15 px ; 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 : 20 px ; cursor : pointer ; padding : 2 px 6 px ; border-radius : 4 px ; line-height : 1 ; }
. preview-close : hover { color : var ( - - text ) ; background : var ( - - bg - hover ) ; }
. preview-panel-meta { display : flex ; gap : 12 px ; padding : 8 px 16 px ; font-size : 12 px ; color : var ( - - text - tertiary ) ; border-bottom : 1 px solid var ( - - border ) ; flex-shrink : 0 ; }
. preview-panel-tags { display : flex ; gap : 4 px ; flex-wrap : wrap ; padding : 8 px 16 px ; border-bottom : 1 px solid var ( - - border ) ; flex-shrink : 0 ; }
. preview-panel-body { flex : 1 ; overflow : auto ; padding : 16 px ; font-size : 13 px ; line-height : 1.6 ; color : var ( - - text ) ; }
. preview-panel-body pre { background : var ( - - bg - tertiary ) ; border-radius : 8 px ; padding : 12 px ; overflow : auto ; font-size : 12 px ; font-family : 'Fira Code' , monospace ; white-space : pre-wrap ; word-wrap : break-word ; }
. preview-panel-body img { max-width : 100 % ; border-radius : 8 px ; }
. preview-panel-actions { display : flex ; gap : 8 px ; padding : 12 px 16 px ; border-top : 1 px solid var ( - - border ) ; flex-shrink : 0 ; }
/* Transitions */
. preview-enter { transition : transform .2 s ease , opacity .2 s ease ; }
. preview-enter-start { transform : translateX ( 100 % ) ; opacity : 0 ; }
. preview-enter-end { transform : translateX ( 0 ) ; opacity : 1 ; }
. preview-leave { transition : transform .15 s ease , opacity .15 s 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 >