Files
rclone/lib/http/templates/index.html
T
Nick Craig-Wood 72a6d36cb2 serve http,webdav: redesign the directory listing page
The listing now shows the folder path with clickable breadcrumbs
above a card containing the entries:

- A summary of the directories, files and total size
- The directory and file counts are toggles
- The search box sits beside the Name heading (focussable with /)
- Sizes right aligned, empty columns are gone and the icons are simpler.
- The colours are CSS variables

The template data is unchanged so custom templates still work, and
the zip download links and ?sort= parameters work as before.
2026-09-29 18:21:23 +01:00

356 lines
14 KiB
HTML

<!--
// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
Modifications: Adapted to rclone markup -->
<!DOCTYPE html>
<html>
<head>
<title>{{html .Name}}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="google" content="notranslate">
<style>
:root {
--page: #f3f4f6;
--card: #ffffff;
--text: #1f2328;
--muted: #6b7280;
--faint: #9ca3af;
--rule: #e5e7eb;
--rule-soft: #eef0f3;
--hover: #fbfbe6;
--link: #006ed3;
--link-hover: #319cff;
--folder: #f4b400;
--file: #8b95a5;
--input: #ffffff;
--input-border: #cfd4dc;
--shadow: 0 1px 2px rgba(16, 24, 40, .06), 0 1px 3px rgba(16, 24, 40, .1);
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--page: #111114;
--card: #18181b;
--text: #f4f4f5;
--muted: #a1a1aa;
--faint: #71717a;
--rule: #3f3f46;
--rule-soft: #27272a;
--hover: #27272a;
--link: #99c7fb;
--link-hover: #cce3fd;
--file: #a1a1aa;
--input: #27272a;
--input-border: #52525b;
--shadow: 0 1px 2px rgba(0, 0, 0, .5);
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
[hidden] { display: none !important; }
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
line-height: 1.4;
color: var(--text);
background: var(--page);
}
a { color: var(--link); text-decoration: none; }
a:hover { color: var(--link-hover); }
a:focus-visible, button:focus-visible, input:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; }
svg { fill: currentColor; }
/* Header: folder path */
header {
background: var(--card);
border-bottom: 1px solid var(--rule);
padding: 14px 5% 12px;
}
.eyebrow {
font-size: 11px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
}
h1 {
font-size: 20px;
font-weight: 400;
color: var(--faint);
white-space: nowrap;
overflow-x: auto;
margin-top: 4px;
}
h1 a { color: var(--text); margin: 0 3px; }
h1 a:first-child { margin-left: 0; }
h1 a:hover { text-decoration: underline; }
.zip-root { margin-left: 8px; vertical-align: middle; }
.zip-root svg { width: 1.3em; height: 1.3em; }
/* The listing card */
main { padding: 24px 5% 40px; }
.card {
background: var(--card);
border: 1px solid var(--rule);
border-radius: 8px;
box-shadow: var(--shadow);
overflow: hidden;
}
#summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
padding: 10px 16px;
border-bottom: 1px solid var(--rule);
color: var(--muted);
}
#summary b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
.toggle {
font: inherit;
color: var(--text);
background: var(--rule-soft);
border: 1px solid var(--rule);
border-radius: 999px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 12px 3px 9px;
}
.toggle svg { width: 1.15em; height: 1.15em; }
#toggle-dirs svg { color: var(--folder); }
#toggle-files svg { color: var(--file); }
.toggle:hover { border-color: var(--faint); }
.toggle[aria-pressed="false"] { background: none; color: var(--faint); text-decoration: line-through; }
.toggle[aria-pressed="false"] b { color: var(--faint); font-weight: 400; }
.toggle[aria-pressed="false"] svg { opacity: .35; }
.total { margin-left: auto; }
.listing { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 9px 12px; white-space: nowrap; }
th {
font-size: 13px;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--text);
border-bottom: 1px solid var(--rule);
}
th a { color: inherit; }
th[aria-sort="ascending"] a::after { content: " \25B4"; }
th[aria-sort="descending"] a::after { content: " \25BE"; }
th.name { display: flex; align-items: center; gap: 14px; }
.search {
position: relative;
font-weight: 400;
letter-spacing: 0;
text-transform: none;
}
.search svg {
position: absolute;
left: 8px;
top: 50%;
width: 13px;
height: 13px;
margin-top: -6.5px;
color: var(--faint);
pointer-events: none;
}
#filter {
font: inherit;
font-size: 13px;
width: 220px;
max-width: 100%;
padding: 5px 8px 5px 26px;
border: 1px solid var(--input-border);
border-radius: 6px;
background: var(--input);
color: var(--text);
}
#filter::placeholder { color: var(--faint); }
th.size, td.size { text-align: right; font-variant-numeric: tabular-nums; }
tbody tr { border-bottom: 1px solid var(--rule-soft); }
tbody tr:last-child { border-bottom: 0; }
tbody tr:hover { background: var(--hover); }
td.name { width: 100%; white-space: normal; word-break: break-all; }
td.name svg { width: 1.15em; height: 1.15em; vertical-align: -.2em; margin-right: 8px; }
tr.dir td.name svg { color: var(--folder); }
tr:not(.dir) td.name svg { color: var(--file); }
td.time { font-variant-numeric: tabular-nums; }
td.name .zip { opacity: 0; margin: 0 0 0 6px; transition: opacity 0.15s ease-in-out; }
tr.file:hover td.name .zip { opacity: 1; }
tr.empty td { text-align: center; color: var(--muted); padding: 28px 12px; }
.goup a { color: var(--muted); }
.goup a:hover { color: var(--link-hover); }
@media (prefers-reduced-motion: reduce) {
td.name .zip { transition: none; }
}
@media (max-width: 640px) {
header, main { padding-left: 16px; padding-right: 16px; }
main { padding-top: 16px; }
th, td { padding: 8px 6px; }
th.name { gap: 10px; }
#filter { width: 130px; }
.total { margin-left: 0; }
}
</style>
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="folder" viewBox="0 0 24 24"><path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/></symbol>
<symbol id="file" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/></symbol>
<symbol id="search" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" d="M10.5 4a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13zm5 11.5L20 20"/></symbol>
<symbol id="zip-folder" viewBox="0 -960 960 960"><path d="M640-480v-80h80v80h-80Zm0 80h-80v-80h80v80Zm0 80v-80h80v80h-80ZM447-640l-80-80H160v480h400v-80h80v80h160v-400H640v80h-80v-80H447ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80v-480 480Z"/></symbol>
</defs>
</svg>
<header>
<div class="eyebrow">Folder path</div>
<h1>
{{- range $i, $crumb := .Breadcrumb}}<a href="{{html $crumb.Link}}">{{html $crumb.Text}}</a>{{if ne $i 0}}/{{end}}{{end}}
{{- if not .DisableZip}}
<a class="zip-root" href="{{html .ZipURL}}" title="Download root as .zip"><svg><use href="#zip-folder"></use></svg></a>
{{- end}}
</h1>
</header>
<main>
<div class="card">
<div id="summary">
<button class="toggle" id="toggle-dirs" aria-pressed="true" title="Show or hide directories"><svg><use href="#folder"></use></svg><b id="ndirs">{{.NumDirs}}</b> directories</button>
<button class="toggle" id="toggle-files" aria-pressed="true" title="Show or hide files"><svg><use href="#file"></use></svg><b id="nfiles">{{.NumFiles}}</b> files</button>
<span class="total"><b><size>{{.TotalSize}}</size></b> total</span>
</div>
<div class="listing">
<table aria-describedby="summary">
<thead>
<tr>
<th class="name" data-key="name">
<a href="#" onclick="return sortBy(this.parentNode)">Name</a>
<span class="search"><svg><use href="#search"></use></svg><input type="search" id="filter" placeholder="Search" aria-label="Search this directory" autocomplete="off" oninput="filter()"></span>
</th>
<th class="size" data-key="size"><a href="#" onclick="return sortBy(this.parentNode)">Size</a></th>
<th class="time" data-key="time"><a href="#" onclick="return sortBy(this.parentNode)">Modified</a></th>
</tr>
</thead>
<tbody>
{{- if not (and .Static .IsRoot)}}
<tr class="goup"><td class="name"><a href="{{html .UpLink}}">Go up</a></td><td class="size">&mdash;</td><td class="time">&mdash;</td></tr>
{{- end}}
{{- range .Entries}}
<tr class="file{{if .IsDir}} dir{{end}}" data-size="{{.Size}}" data-time="{{if .ModTime | afterEpoch}}{{.ModTime}}{{end}}">
<td class="name">
{{- if .IsDir}}<svg><use href="#folder"></use></svg>{{else}}<svg><use href="#file"></use></svg>{{end -}}
<a href="{{html .URL}}">{{html .Leaf}}</a>
{{- if and .IsDir (not $.DisableZip)}}
<a class="zip" href="{{html .ZipURL}}" title="Download folder as .zip"><svg><use href="#zip-folder"></use></svg></a>
{{- end}}
</td>
<td class="size">{{if .IsDir}}&mdash;{{else}}<size>{{.Size}}</size>{{end}}</td>
<td class="time">{{if .ModTime | afterEpoch}}<time datetime="{{.ModTime}}">{{.ModTime}}</time>{{else}}&mdash;{{end}}</td>
</tr>
{{- end}}
<tr class="empty" hidden><td colspan="3"></td></tr>
</tbody>
</table>
</div>
</div>
</main>
<script>
var filterEl = document.getElementById('filter');
var toggles = {dirs: document.getElementById('toggle-dirs'), files: document.getElementById('toggle-files')};
var rows = function() { return Array.prototype.slice.call(document.querySelectorAll('tr.file')); };
// Apply the search text and the directory and file toggles to the rows
function filter() {
var q = filterEl.value.trim().toLowerCase();
var shown = {dirs: 0, files: 0}, total = {dirs: 0, files: 0};
rows().forEach(function(tr) {
var kind = tr.classList.contains('dir') ? 'dirs' : 'files';
var show = toggles[kind].getAttribute('aria-pressed') == 'true' && (!q || tr.querySelector('.name').textContent.toLowerCase().indexOf(q) != -1);
tr.hidden = !show;
total[kind]++;
if (show) shown[kind]++;
});
['dirs', 'files'].forEach(function(kind) {
document.getElementById('n' + kind).textContent = shown[kind] == total[kind] ? total[kind] : shown[kind] + ' of ' + total[kind];
});
var empty = document.querySelector('tr.empty');
empty.hidden = shown.dirs + shown.files > 0;
empty.firstChild.textContent = total.dirs + total.files ? 'No matching entries' : 'Empty directory';
}
function toggle(kind) {
toggles[kind].setAttribute('aria-pressed', toggles[kind].getAttribute('aria-pressed') == 'true' ? 'false' : 'true');
filter();
}
toggles.dirs.onclick = function() { toggle('dirs'); };
toggles.files.onclick = function() { toggle('files'); };
// Sort by the column of th, toggling the direction on repeated clicks
function sortBy(th) {
var key = th.getAttribute('data-key');
var asc = th.getAttribute('aria-sort') != 'ascending';
document.querySelectorAll('th[aria-sort]').forEach(function(el) { el.removeAttribute('aria-sort'); });
th.setAttribute('aria-sort', asc ? 'ascending' : 'descending');
var value = function(tr) {
if (key == 'name') return tr.querySelector('.name').textContent.trim();
if (key == 'size') return Number(tr.getAttribute('data-size'));
return tr.getAttribute('data-time');
};
var sorted = rows().sort(function(a, b) {
// Directories first when sorting by name
var byDir = key == 'name' ? b.classList.contains('dir') - a.classList.contains('dir') : 0;
var va = value(a), vb = value(b);
var cmp = typeof va == 'string' ? va.localeCompare(vb, undefined, {numeric: true, sensitivity: 'base'}) : va - vb;
return byDir || (asc ? cmp : -cmp);
});
var empty = document.querySelector('tr.empty');
sorted.forEach(function(tr) { empty.parentNode.insertBefore(tr, empty); });
return false;
}
// Show how the server sorted the listing from the ?sort= and ?order= parameters
var getUrlParameter = function(name) {
var params = window.location.search.substring(1).split('&');
for (var i = 0; i < params.length; i++) {
var param = params[i].split('=');
if (param[0] === name) return param[1] === undefined ? true : decodeURIComponent(param[1]);
}
};
var sorted = document.querySelector('th[data-key="' + (getUrlParameter('sort') || 'name').replace('dirfirst', '') + '"]');
if (sorted) sorted.setAttribute('aria-sort', getUrlParameter('order') == 'desc' ? 'descending' : 'ascending');
// Press / to search
document.addEventListener('keydown', function(e) {
if (e.key == '/' && document.activeElement != filterEl) { filterEl.focus(); e.preventDefault(); }
});
function localizeDatetime(e) {
var d = new Date(e.getAttribute('datetime'));
if (isNaN(d)) {
d = new Date(e.textContent);
if (isNaN(d)) return;
}
e.textContent = d.toLocaleString([], {day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit"});
}
Array.prototype.slice.call(document.getElementsByTagName("time")).forEach(localizeDatetime);
function readableFileSize(size) {
var units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
var i = 0;
while (size >= 1024) { size /= 1024; ++i; }
return (i ? size.toFixed(1) : size) + ' ' + units[i];
}
Array.prototype.slice.call(document.getElementsByTagName("size")).forEach(function(el) { el.textContent = readableFileSize(Number(el.textContent)); });
filter();
</script>
</body>
</html>