Last active
July 10, 2026 16:16
-
-
Save arnesacnussem/8512f39f228aed79a0718e7b0913c7bf to your computer and use it in GitHub Desktop.
Caddy file_server custom browse template with sidebar tree
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| {{ $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 · {{.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">×</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