@@ -221,6 +221,55 @@
. 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()"
@@ -292,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 >
@@ -316,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 >
@@ -408,6 +464,7 @@
< / li >
< / template >
< / ul >
< / div >
<!-- Tag filter bar -->
< div class = "tag-filter-bar" x-show = "workspaceTags && workspaceTags.length > 0" >
@@ -476,27 +533,106 @@
< / tbody >
< / table >
<!-- Regular table view (toggle) -->
< table class = "ws-table" x-show = "viewMode==='table ' && !searchQuery && !filterType && !tagFilter && disp lay Tree.length>0" >
<!-- List view — compact flat list -->
< table class = "ws-table-compact " x-show = "viewMode==='list ' && !searchQuery && !filterType && !tagFilter && f lat Tree.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: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 = "'t '+node.id" >
< tr :class = "{ selected: !!selectedIds[node.id] } ">
< td > < input type = "checkbox" :checked = "!!selectedIds[node.id]" @ click . stop = "toggleSelect(no de, $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 >
< 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_fol der , 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 >
@@ -563,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 >
@@ -608,6 +769,10 @@ document.addEventListener('alpine:init', function() {
tagFilter : '' ,
tagInputId : null ,
// Preview panel
previewItem : null ,
previewPanelContent : '' ,
// Multi-select
selectedIds : { } ,
selectedIdsCount : 0 ,
@@ -830,11 +995,66 @@ document.addEventListener('alpine:init', function() {
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 ) ;
}
@@ -1060,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 ;
} ,