Skip to content

Instantly share code, notes, and snippets.

@arnesacnussem
Last active July 10, 2026 16:16
Show Gist options
  • Select an option

  • Save arnesacnussem/8512f39f228aed79a0718e7b0913c7bf to your computer and use it in GitHub Desktop.

Select an option

Save arnesacnussem/8512f39f228aed79a0718e7b0913c7bf to your computer and use it in GitHub Desktop.
Caddy file_server custom browse template with sidebar tree
{{ $nonce := uuidv4 -}}
{{ $nonceAttribute := print "nonce=" (quote $nonce) -}}
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>{{range $i, $crumb := .Breadcrumbs}}{{if ne $i 0}}{{html $crumb.Text}}/{{end}}{{end}}</title>
<style {{ $nonceAttribute }}>
:root {
--bg: #0d1117; --bg-secondary: #161b22; --bg-hover: #1c2333;
--border: #30363d; --text: #c9d1d9; --text-secondary: #8b949e;
--link: #58a6ff; --tree-width: 260px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 13px; background: var(--bg); color: var(--text);
height: 100vh; overflow: hidden; display: flex; flex-direction: column;
}
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
.app { display: flex; flex: 1; min-height: 0; overflow: hidden; }
.sidebar {
width: var(--tree-width); min-width: var(--tree-width);
background: var(--bg-secondary); border-right: 1px solid var(--border);
display: flex; flex-direction: column; overflow: hidden;
transition: width 200ms ease, min-width 200ms ease;
}
.sidebar.collapsed { width: 0; min-width: 0; border-right: none; }
.sidebar-toggle {
background: none; border: none; color: var(--text-secondary);
cursor: pointer; padding: 2px 6px; border-radius: 4px;
display: flex; align-items: center; margin-right: 4px;
margin-left: -4px; flex-shrink: 0;
}
.sidebar-toggle:hover { background: var(--bg-hover); color: var(--text); }
.tree { flex: 1; overflow-y: auto; overflow-x: hidden; padding-top: 8px; }
.tree::-webkit-scrollbar { width: 5px; }
.tree::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.tree-node { user-select: none; }
.tree-row {
display: flex; align-items: center; height: 28px; cursor: pointer;
white-space: nowrap; transition: background 80ms; padding-right: 8px;
}
.tree-row:hover { background: var(--bg-hover); }
.tree-row.active { background: rgba(88,166,255,0.12); color: var(--link); }
.tree-row.active .tree-arrow,
.tree-row.active .tree-icon { color: var(--link); }
.tree-arrow {
width: 18px; min-width: 18px; height: 16px; display: inline-flex;
align-items: center; justify-content: center; color: var(--text-secondary);
transition: transform 120ms; font-size: 9px;
}
.tree-arrow.expanded { transform: rotate(90deg); }
.tree-arrow.hidden { visibility: hidden; }
.tree-icon {
width: 16px; min-width: 16px; height: 16px; display: inline-flex;
align-items: center; justify-content: center; color: var(--text-secondary); margin-right: 4px;
}
.tree-name { overflow: hidden; text-overflow: ellipsis; font-size: 12.5px; line-height: 28px; }
.tree-children { display: none; }
.tree-children.expanded { display: block; }
.tree-loading { padding: 2px 20px; color: var(--text-secondary); font-size: 11px; }
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.breadcrumbs {
padding: 10px 20px; font-size: 12px; color: var(--text-secondary);
border-bottom: 1px solid var(--border); flex-shrink: 0;
display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
background: var(--bg); z-index: 10;
}
.breadcrumbs a { color: var(--text-secondary); }
.breadcrumbs a:hover { color: var(--text); }
.breadcrumbs .sep { color: var(--border); }
.breadcrumbs .current { color: var(--text); font-weight: 500; }
.main-content { flex: 1; overflow-y: auto; padding: 0 20px 16px; }
.file-list { list-style: none; padding: 0; margin: 0; }
.file-header {
display: grid; grid-template-columns: 32px minmax(0, 1fr) 170px 100px;
align-items: center; gap: 8px; padding: 10px 12px;
border-bottom: 1px solid var(--border); position: sticky; top: 0;
background: var(--bg); z-index: 1;
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text-secondary);
}
.file-header a { color: var(--text-secondary); }
.file-header a:hover { color: var(--text); }
.file-row { border-bottom: 1px solid var(--border); }
.file-link {
display: grid; grid-template-columns: 32px minmax(0, 1fr) 170px 100px;
align-items: center; gap: 8px; padding: 10px 12px;
color: var(--text); text-decoration: none;
transition: transform 100ms ease, box-shadow 100ms ease, background 80ms;
}
.file-link:hover {
background: var(--bg-hover); text-decoration: none;
transform: translateX(3px); box-shadow: inset 2px 0 0 var(--link);
}
.file-link .col { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-link .col.name { display: flex; align-items: center; gap: 8px; }
.file-link .col.icon { display: flex; align-items: center; justify-content: center; color: var(--text-secondary); }
.file-link .col.icon svg { width: 16px; height: 16px; flex-shrink: 0; }
.file-link .col.modified { color: var(--text-secondary); font-size: 12.5px; }
.file-link .col.size { color: var(--text-secondary); font-size: 12.5px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 4px; padding-top: 4px; }
.grid .entry { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; cursor: pointer; transition: transform 120ms ease, box-shadow 120ms ease; }
.grid .entry:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.35); }
.grid .entry a { display: flex; flex-direction: column; align-items: center; padding: 14px 8px; color: var(--text); gap: 5px; }
.grid .entry a:hover { background: var(--bg-hover); text-decoration: none; }
.grid .entry img { width: 44px; height: 44px; object-fit: cover; border-radius: 4px; }
.grid .entry .icon-tabler { width: 36px; height: 36px; color: var(--text-secondary); }
.grid .entry .name { font-size: 12px; text-align: center; word-break: break-all; line-height: 1.3; }
.grid .entry .size { font-size: 11px; color: var(--text-secondary); }
.empty-msg { padding: 40px; text-align: center; color: var(--text-secondary); }
/* Preview dialog */
.modal-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.65);
display: flex; align-items: center; justify-content: center;
z-index: 100; backdrop-filter: blur(4px);
}
.modal-container {
background: var(--bg-secondary); border: 1px solid var(--border);
border-radius: 8px; width: min(90vw, 1200px); height: min(85vh, 800px);
display: flex; flex-direction: column; overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.modal-title {
font-size: 14px; font-weight: 500; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; margin-right: 12px;
}
.modal-close {
background: none; border: none; color: var(--text-secondary);
cursor: pointer; font-size: 20px; padding: 2px 8px;
border-radius: 4px; line-height: 1; flex-shrink: 0;
}
.modal-close:hover { background: var(--bg-hover); color: var(--text); }
.modal-body {
flex: 1; overflow: hidden; display: flex; align-items: center;
justify-content: center; padding: 0;
}
.modal-body img.modal-img {
max-width: 100%; max-height: 100%; object-fit: contain; padding: 16px;
}
.modal-body video.modal-video {
max-width: 100%; max-height: 100%; outline: none;
}
.modal-body audio.modal-audio {
width: 80%; max-width: 500px; outline: none; padding: 16px;
}
.modal-body iframe.modal-pdf {
width: 100%; height: 100%; border: none;
}
.modal-body pre.modal-text {
width: 100%; height: 100%; margin: 0; padding: 16px;
background: var(--bg); border: none; border-radius: 0;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12.5px; line-height: 1.55; overflow: auto; white-space: pre;
color: var(--text); align-self: stretch; text-align: left;
}
.modal-loading {
color: var(--text-secondary); font-size: 14px; display: flex;
align-items: center; gap: 8px;
}
.modal-info {
text-align: center; color: var(--text-secondary); padding: 40px 20px;
}
.modal-info a { color: var(--link); }
@media (max-width: 768px) {
.sidebar {
position: fixed; top: 35px; left: 0; z-index: 50;
width: 260px; min-width: 260px; height: calc(100% - 35px);
transform: translateX(-100%);
transition: transform 200ms ease; box-shadow: none;
}
.sidebar.open {
transform: translateX(0);
box-shadow: 4px 0 16px rgba(0,0,0,0.4);
}
.sidebar-overlay {
display: none; position: fixed; top: 35px; left: 0; right: 0; bottom: 0; z-index: 49;
background: rgba(0,0,0,0.5);
}
.sidebar-overlay.visible { display: block; }
.file-link { grid-template-columns: 32px minmax(0, 1fr); padding: 14px 12px; }
.file-header { grid-template-columns: 32px minmax(0, 1fr); }
.file-link .col.modified,
.file-link .col.size,
.file-header .col.modified,
.file-header .col.size { display: none; }
}
</style>
</head>
<body>
<div class="breadcrumbs">
<button class="sidebar-toggle" title="Toggle sidebar">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
{{range $i, $crumb := .Breadcrumbs}}
{{- if eq $i 0}}<a href="/" id="breadcrumb-root">~</a>
{{- else}}<span class="sep">/</span>
{{- if eq $i (sub (len $.Breadcrumbs) 1)}}<span class="current">{{html $crumb.Text}}</span>
{{- else}}<a href="{{html $crumb.Link}}">{{html $crumb.Text}}</a>{{end}}
{{- end}}
{{- end}}
<span style="margin-left:8px;font-size:11px;opacity:.5">{{.NumDirs}} dirs &middot; {{.NumFiles}} files</span>
</div>
<div class="app">
<aside class="sidebar">
<div class="tree" id="tree"><div class="tree-loading">Loading...</div></div>
</aside>
<div class="main">
<div class="main-content">
{{- if .Items}}
{{- if eq .Layout "grid"}}
<div class="grid">
{{- range .Items}}
<div class="entry"><a href="{{html .URL}}">
{{- if .IsDir}}<svg xmlns="http://www.w3.org/2000/svg" class="icon-tabler" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>
{{- else if .HasExt ".jpg" ".jpeg" ".png" ".gif" ".webp" ".svg" ".avif"}}<img loading="lazy" src="{{html .URL}}">
{{- else}}<svg xmlns="http://www.w3.org/2000/svg" class="icon-tabler" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z"/></svg>{{end}}
<span class="name">{{html .Name}}</span>{{if not .IsDir}}<span class="size">{{.HumanSize}}</span>{{end}}
</a></div>
{{- end}}
</div>
{{- else}}
<div class="file-header">
<span class="col icon"></span>
<span class="col name"><a href="?sort=name&order={{if eq .Sort "name"}}{{if eq .Order "asc"}}desc{{else}}asc{{end}}{{else}}asc{{end}}">Name</a></span>
<span class="col modified"><a href="?sort=time&order={{if eq .Sort "time"}}{{if eq .Order "asc"}}desc{{else}}asc{{end}}{{else}}desc{{end}}">Modified</a></span>
<span class="col size"><a href="?sort=size&order={{if eq .Sort "size"}}{{if eq .Order "asc"}}desc{{else}}asc{{end}}{{else}}desc{{end}}">Size</a></span>
</div>
<ul class="file-list">
{{- if gt (len .Breadcrumbs) 1}}
{{- $p := index .Breadcrumbs (sub (len .Breadcrumbs) 2) -}}
<li class="file-row file-row-parent"><a href="../" class="file-link" title="Parent directory">
<span class="col icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg></span>
<span class="col name" style="color:var(--text-secondary)">{{if eq $p.Text "/"}}~{{else}}{{html $p.Text}}{{end}}</span>
<span class="col modified"></span>
<span class="col size"></span>
</a></li>
{{- end}}
{{- range .Items}}
<li class="file-row"><a href="{{html .URL}}" class="file-link">
<span class="col icon">
{{- if .IsDir}}<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>
{{- else if .HasExt ".jpg" ".jpeg" ".png" ".gif" ".webp" ".svg" ".avif"}}<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M15 8h.01"/><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z"/><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"/><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"/></svg>
{{- else if .HasExt ".mp4" ".mov" ".mkv" ".avi" ".webm"}}<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"/><path d="M8 4l0 16"/><path d="M16 4l0 16"/><path d="M4 8l4 0"/><path d="M4 16l4 0"/><path d="M4 12l16 0"/><path d="M16 8l4 0"/><path d="M16 16l4 0"/></svg>
{{- else if .HasExt ".mp3" ".flac" ".wav" ".aac" ".ogg"}}<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M16 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M9 17l0 -13l10 0l0 13"/><path d="M9 8l10 0"/></svg>
{{- else if .HasExt ".pdf"}}<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4"/><path d="M5 18h1.5a1.5 1.5 0 0 0 0 -3h-1.5v6"/><path d="M17 18h2"/><path d="M20 15h-3v6"/><path d="M11 15v6h1a2 2 0 0 0 2 -2v-2a2 2 0 0 0 -2 -2h-1z"/></svg>
{{- else if .HasExt ".zip" ".gz" ".tar" ".7z" ".rar"}}<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 20.735a2 2 0 0 1 -1 -1.735v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2h-1"/><path d="M11 17a2 2 0 0 1 2 2v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a2 2 0 0 1 2 -2z"/></svg>
{{- else if .HasExt ".py" ".js" ".ts" ".go" ".rs" ".c" ".cpp" ".sh"}}<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z"/><path d="M10 13l-1 2l1 2"/><path d="M14 13l1 2l-1 2"/></svg>
{{- else if .HasExt ".md" ".txt"}}<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z"/><path d="M9 9l1 0"/><path d="M9 13l6 0"/><path d="M9 17l6 0"/></svg>
{{- else if .HasExt ".iso" ".img"}}<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/></svg>
{{- else if .HasExt ".exe" ".apk" ".msi" ".deb"}}<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5"/><path d="M12 12l8 -4.5"/><path d="M12 12l0 9"/><path d="M12 12l-8 -4.5"/><path d="M16 5.25l-8 4.5"/></svg>
{{- else}}<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z"/></svg>{{end}}
</span>
<span class="col name">{{html .Name}}</span>
<span class="col modified">{{.HumanModTime "2006-01-02 15:04"}}</span>
<span class="col size">{{if not .IsDir}}{{.HumanSize}}{{end}}</span>
</a></li>
{{- end}}
</ul>
{{- end}}
{{- else}}<div class="empty-msg">This directory is empty.</div>{{- end}}
</div>
</div>
</div>
<div class="sidebar-overlay"></div>
<div class="modal-backdrop" id="preview-modal" style="display:none">
<div class="modal-container">
<div class="modal-header">
<span class="modal-title"></span>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body"></div>
</div>
</div>
<script {{ $nonceAttribute }}>
(function(){
var tree=document.getElementById('tree'), INDENT=14;
function ico(open){
return '<svg width="14" height="14" viewBox="0 0 24 24" fill="'+(open?'currentColor':'none')+'" stroke="currentColor" stroke-width="1.5">'
+(open?'<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="M2 10h20" stroke-width="1.5"/>'
:'<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>')
+'</svg>';
}
function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML;}
function hrefOf(p){return '/'+p.replace(/^\//,'').split('/').map(encodeURIComponent).join('/');}
function cpath(){var p=decodeURIComponent(window.location.pathname).replace(/\/$/,'');return p||'/';}
function mkNode(name,path,depth){
var node=document.createElement('div');
node.className='tree-node';
node.setAttribute('data-path',path);
node.setAttribute('data-depth',depth);
var row=document.createElement('div');
row.className='tree-row';
row.style.paddingLeft=(10+depth*INDENT)+'px';
row.innerHTML='<span class="tree-arrow">▶</span><span class="tree-icon">'+ico(false)+'</span><span class="tree-name">'+esc(name||'Home')+'</span>';
node.appendChild(row);
var children=document.createElement('div');
children.className='tree-children';
node.appendChild(children);
row.addEventListener('click',function(e){
if(e.target.closest('.tree-arrow')){toggle(node);e.stopPropagation();}
else window.location.href=hrefOf(path);
});
return node;
}
function toggle(node){
var children=node.querySelector('.tree-children');
var arrow=node.querySelector('.tree-arrow');
if(arrow.classList.contains('hidden'))return;
if(children.classList.contains('expanded')){
children.classList.remove('expanded');
arrow.classList.remove('expanded');
node.querySelector('.tree-icon').innerHTML=ico(false);
}else{
load(node).then(function(count){
if(count>0){
children.classList.add('expanded');
arrow.classList.add('expanded');
node.querySelector('.tree-icon').innerHTML=ico(true);
}else{
arrow.classList.add('hidden');
}
});
}
}
function load(node){
return new Promise(function(resolve){
var path=node.getAttribute('data-path');
var children=node.querySelector('.tree-children');
if(children.children.length>0){resolve();return;}
children.innerHTML='<div class="tree-loading">Loading...</div>';
fetch(hrefOf(path),{headers:{'Accept':'application/json'}})
.then(function(r){return r.json();})
.then(function(items){
children.innerHTML='';
var dirs=(items||[]).filter(function(i){return i.is_dir;});
var depth=parseInt(node.getAttribute('data-depth'))+1;
dirs.forEach(function(d){
var nm=d.name.replace(/\/$/,'');
var cp=(path==='/'?'/'+nm:path+'/'+nm);
var cn=mkNode(nm,cp,depth);
children.appendChild(cn);
});
resolve(dirs.length);
})
.catch(function(){children.innerHTML='';resolve(0);});
});
}
function expandTo(target){
if(target==='/'||target===''){highlight('/');return;}
var parts=target.split('/').filter(Boolean);
function walk(node,idx){
if(idx>=parts.length){
highlight(node.getAttribute('data-path'));
var nc=node.querySelector('.tree-children');
if(!nc.classList.contains('expanded')){
load(node).then(function(count){
if(count>0){
nc.classList.add('expanded');
node.querySelector('.tree-arrow').classList.add('expanded');
node.querySelector('.tree-icon').innerHTML=ico(true);
}else{
node.querySelector('.tree-arrow').classList.add('hidden');
}
});
}
return;
}
var children=node.querySelector('.tree-children');
var tgt='/'+parts.slice(0,idx+1).join('/');
load(node).then(function(count){
if(count>0&&!children.classList.contains('expanded')){
children.classList.add('expanded');
node.querySelector('.tree-arrow').classList.add('expanded');
node.querySelector('.tree-icon').innerHTML=ico(true);
}else if(count===0){
node.querySelector('.tree-arrow').classList.add('hidden');
}
var child=null,all=children.querySelectorAll('.tree-node');
for(var i=0;i<all.length;i++){if(all[i].getAttribute('data-path')===tgt){child=all[i];break;}}
if(child){
walk(child,idx+1);
}else{
highlight(node.getAttribute('data-path'));
}
});
}
walk(tree.querySelector('.tree-node[data-path="/"]'),0);
}
function highlight(path){
document.querySelectorAll('.tree-row.active').forEach(function(r){r.classList.remove('active');});
var n=null,all=tree.querySelectorAll('.tree-node');
for(var i=0;i<all.length;i++){if(all[i].getAttribute('data-path')===path){n=all[i];break;}}
if(n)n.querySelector('.tree-row').classList.add('active');
}
// Sidebar toggle
var sidebar=document.querySelector('.sidebar');
var overlay=document.querySelector('.sidebar-overlay');
document.querySelector('.sidebar-toggle').addEventListener('click',function(){
if(window.innerWidth<=768){
if(sidebar.classList.contains('open')){
sidebar.classList.remove('open');
overlay.classList.remove('visible');
}else{
sidebar.classList.remove('collapsed');
sidebar.classList.add('open');
overlay.classList.add('visible');
}
}else{
sidebar.classList.remove('open');
overlay.classList.remove('visible');
sidebar.classList.toggle('collapsed');
}
});
overlay.addEventListener('click',function(){
sidebar.classList.remove('open');
overlay.classList.remove('visible');
});
// Init
var host=window.location.hostname;
document.title=host+(document.title?' — '+document.title:'');
var br=document.getElementById('breadcrumb-root');
if(br)br.textContent=host;
var pr=document.querySelector('.file-row-parent .col.name');
if(pr&&pr.textContent.trim()==='~')pr.textContent=host;
var root=mkNode(host,'/',0);
tree.innerHTML='';
tree.appendChild(root);
load(root).then(function(){
root.querySelector('.tree-children').classList.add('expanded');
root.querySelector('.tree-arrow').classList.add('expanded');
root.querySelector('.tree-icon').innerHTML=ico(true);
expandTo(cpath());
});
// File preview dialog
function getPreviewType(name) {
var n = name.toLowerCase();
if (/\.(jpg|jpeg|png|gif|webp|svg|avif|bmp|ico)$/i.test(n)) return 'image';
if (/\.(mp4|mov|mkv|avi|webm|flv)$/i.test(n)) return 'video';
if (/\.(mp3|flac|wav|aac|ogg|wma|m4a|opus)$/i.test(n)) return 'audio';
if (/\.pdf$/i.test(n)) return 'pdf';
if (/\.(txt|md|py|js|ts|go|rs|c|cpp|h|hpp|json|xml|html|css|sh|yaml|yml|toml|ini|cfg|conf|log|java|rb|php|sql|r|swift|kt|lua|bat|ps1|makefile|dockerfile|env)$/i.test(n)) return 'text';
return null;
}
function openPreview(url, name) {
var m = document.getElementById('preview-modal');
var body = m.querySelector('.modal-body');
var type = getPreviewType(name);
m.querySelector('.modal-title').textContent = decodeURIComponent(name);
body.innerHTML = '<div class="modal-loading">Loading...</div>';
m.style.display = 'flex';
document.body.style.overflow = 'hidden';
if (type === 'image') {
var img = new Image(); img.className = 'modal-img'; img.src = url;
img.onload = function(){body.innerHTML='';body.appendChild(img);};
img.onerror = function(){body.innerHTML='<div class="modal-info"><p>Failed to load image</p></div>';};
} else if (type === 'video') {
var v = document.createElement('video'); v.className = 'modal-video';
v.controls = true; v.autoplay = true; v.src = url;
body.innerHTML = ''; body.appendChild(v);
} else if (type === 'audio') {
var a = document.createElement('audio'); a.className = 'modal-audio';
a.controls = true; a.autoplay = true; a.src = url;
body.innerHTML = ''; body.appendChild(a);
} else if (type === 'pdf') {
body.innerHTML='<iframe class="modal-pdf" src="'+url+'"></iframe>';
} else if (type === 'text') {
fetch(url).then(function(r){if(!r.ok)throw r.status;return r.text();})
.then(function(t){
if(t.length>1048576)t=t.substring(0,1048576)+'\n\n... [truncated at 1MB]';
body.innerHTML='<pre class="modal-text"></pre>';
body.firstChild.textContent=t;
})
.catch(function(){body.innerHTML='<div class="modal-info"><p>Failed to load file</p></div>';});
}
}
function closePreview() {
var m = document.getElementById('preview-modal');
var v = m.querySelector('video'); if(v)v.pause();
var a = m.querySelector('audio'); if(a)a.pause();
m.style.display = 'none'; document.body.style.overflow = '';
}
document.addEventListener('click', function(e) {
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
var a = e.target.closest('a[href]');
if (!a) {
var entry = e.target.closest('.grid .entry');
if (entry) a = entry.querySelector('a[href]');
}
if (!a) return;
var href = a.getAttribute('href');
if (!href || href === '../' || href.startsWith('?')) return;
if (a.closest('.file-header')) return;
if (href.endsWith('/')) return;
var type = getPreviewType(href);
if (!type) return;
e.preventDefault();
openPreview(href, href.split('/').pop());
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && document.getElementById('preview-modal').style.display === 'flex') closePreview();
});
document.getElementById('preview-modal').addEventListener('click', function(e) {
if (e.target === this || e.target.closest('.modal-close')) closePreview();
});
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment