Folder Sidebar

This commit is contained in:
2026-06-16 15:03:18 +02:00
parent 7fe0013a5c
commit 9ed6475775
6 changed files with 330 additions and 16 deletions
+3 -1
View File
@@ -118,7 +118,9 @@
if (!e.target.closest) return; if (!e.target.closest) return;
// Match both listing styles: table rows expose the file link inside // Match both listing styles: table rows expose the file link inside
// a .list-item row; thumbnail tiles are bare a.thumb-tile anchors. // a .list-item row; thumbnail tiles are bare a.thumb-tile anchors.
var anchor = e.target.closest('.list-item a, a.thumb-tile'); // The left tree rail's file anchors (a.tree-file) join in too;
// folder anchors there end in "/" and fall through to navigation.
var anchor = e.target.closest('.list-item a, a.thumb-tile, aside.tree-sidebar a.tree-file');
if (!anchor) return; if (!anchor) return;
var item = anchor.closest('.list-item'); var item = anchor.closest('.list-item');
// Only intercept the primary file link, and only for files (not folders). // Only intercept the primary file link, and only for files (not folders).
+2
View File
@@ -13,6 +13,7 @@
<script src="/_/search-suggest.js" defer></script> <script src="/_/search-suggest.js" defer></script>
<script src="/_/tree-picker.js"></script> <script src="/_/tree-picker.js"></script>
<script src="/_/companion.js" defer></script> <script src="/_/companion.js" defer></script>
{{if not .EditMode}}<script src="/_/tree-sidebar.js" defer></script>{{end}}
{{block "headScripts" .}}{{end}} {{block "headScripts" .}}{{end}}
</head> </head>
<body> <body>
@@ -29,6 +30,7 @@
<div class="header-actions row">{{block "headerActions" .}}{{end}}</div> <div class="header-actions row">{{block "headerActions" .}}{{end}}</div>
</header> </header>
<div class="page-wrap"> <div class="page-wrap">
{{if not .EditMode}}<aside class="tree-sidebar"></aside>{{end}}
<main> <main>
{{block "content" .}}{{end}} {{block "content" .}}{{end}}
</main> </main>
+62 -6
View File
@@ -41,6 +41,9 @@
sidebar/TOC top offsets and anchor scroll-padding. Hardcoded (the sidebar/TOC top offsets and anchor scroll-padding. Hardcoded (the
desktop header is a single-row grid); revisit if the header wraps. */ desktop header is a single-row grid); revisit if the header wraps. */
--header-h: 3.25rem; --header-h: 3.25rem;
/* Width of the persistent left folder-tree rail (desktop). */
--tree-width: 15rem;
} }
/* Anchor / TOC jumps land below the sticky header instead of behind it. /* Anchor / TOC jumps land below the sticky header instead of behind it.
@@ -89,9 +92,9 @@ hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
will silently break. */ will silently break. */
.page-wrap { .page-wrap {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 14rem; grid-template-columns: var(--tree-width) minmax(0, 1fr) 14rem;
gap: var(--space-5); gap: var(--space-5);
max-width: 1280px; max-width: 1440px;
margin: 0 auto; margin: 0 auto;
padding: 0 var(--space-4); padding: 0 var(--space-4);
width: 100%; width: 100%;
@@ -602,17 +605,44 @@ aside.sidebar:empty { display: none; }
} }
.tree-row.is-disabled { color: var(--text-muted); cursor: default; } .tree-row.is-disabled { color: var(--text-muted); cursor: default; }
.tree-row.is-disabled:hover { background: none; } .tree-row.is-disabled:hover { background: none; }
.tree-chevron, .tree-marker { text-align: center; flex-shrink: 0; } .tree-chevron { text-align: center; flex-shrink: 0; }
.tree-chevron { width: 1.25rem; color: var(--secondary); } .tree-chevron { width: 1.25rem; color: var(--secondary); }
.tree-chevron.is-leaf { visibility: hidden; } .tree-chevron.is-leaf { visibility: hidden; }
.tree-marker { width: var(--space-4); color: var(--text-muted); } .tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tree-children { padding-left: var(--space-2); }
.tree-children { padding-left: var(--space-5); }
.tree-selected-path { .tree-selected-path {
font-size: var(--font-sm); font-size: var(--font-sm);
padding: var(--space-1) 0; padding: var(--space-1) 0;
word-break: break-all; word-break: break-all;
} }
/* Current-page row in the navigation rail. Mirrors .tree-row.is-selected so the
two tree surfaces stay visually consistent. */
.tree-row.is-active {
background: var(--bg-panel-hover);
border-left: 3px solid var(--primary);
padding-left: calc(var(--space-2) - 3px);
}
.tree-row.is-active > .tree-name { color: var(--primary-hover); }
/* === Tree sidebar (persistent left navigation rail) ===
Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules.
Desktop: a sticky, scrollable rail parked below the header (mirrors .sidebar
offsets). Mobile: an overlay drawer toggled by .fab-tree (see responsive). */
.tree-sidebar {
position: sticky;
top: calc(var(--header-h) + var(--space-2));
align-self: start;
max-height: calc(100vh - var(--header-h) - var(--space-4));
overflow-y: auto;
padding-top: 1rem;
padding-right: var(--space-2);
border-right: var(--border-dashed);
font-size: var(--font-sm);
/* Rows are click targets (navigate / toggle), not prose — suppress the
text-selection highlight that double/drag clicks would otherwise leave. */
user-select: none;
}
aside.tree-sidebar:empty { display: none; }
/* === Movie info box === */ /* === Movie info box === */
.movie-info { margin: var(--space-3) 0; } .movie-info { margin: var(--space-3) 0; }
@@ -721,6 +751,32 @@ aside.sidebar:empty { display: none; }
} }
.sidebar.is-open { display: flex; } .sidebar.is-open { display: flex; }
button.fab { display: inline-flex; } button.fab { display: inline-flex; }
/* Tree rail becomes a left-anchored overlay drawer toggled by .fab-tree.
Off-grid (position: fixed) so it never steals horizontal space from
<main> on narrow viewports. */
.tree-sidebar {
position: fixed;
top: auto;
bottom: 5rem;
left: var(--space-4);
right: auto;
width: calc(100% - 2rem);
max-width: 20rem;
max-height: calc(100vh - 8rem);
padding: var(--space-2);
border: var(--border);
background: var(--bg);
display: none;
z-index: 60;
}
.tree-sidebar.is-open { display: block; }
/* Smaller FAB stacked just above the menu FAB (3rem tall at --space-4). */
.fab-tree {
bottom: calc(var(--space-4) + 3rem + var(--space-2));
width: 2.5rem;
height: 2.5rem;
font-size: 1.1rem;
}
} }
@media (max-width: 600px) { @media (max-width: 600px) {
-9
View File
@@ -85,11 +85,6 @@
} }
row.appendChild(chevron); row.appendChild(chevron);
var marker = document.createElement('span');
marker.className = 'tree-marker';
marker.textContent = kind === 'folder' ? '' : '\u00b7';
row.appendChild(marker);
var label = document.createElement('span'); var label = document.createElement('span');
label.className = 'tree-name'; label.className = 'tree-name';
label.textContent = name; label.textContent = name;
@@ -199,10 +194,6 @@
var rootChev = document.createElement('span'); var rootChev = document.createElement('span');
rootChev.className = 'tree-chevron is-leaf'; rootChev.className = 'tree-chevron is-leaf';
rootRow.appendChild(rootChev); rootRow.appendChild(rootChev);
var rootMarker = document.createElement('span');
rootMarker.className = 'tree-marker';
rootRow.appendChild(rootMarker);
var rootLabel = document.createElement('span');
rootLabel.className = 'tree-name'; rootLabel.className = 'tree-name';
rootLabel.textContent = '/'; rootLabel.textContent = '/';
rootRow.appendChild(rootLabel); rootRow.appendChild(rootLabel);
+215
View File
@@ -0,0 +1,215 @@
// Persistent left navigation rail. Renders the wiki folder/file tree, expanded
// to the current page's ancestor chain, and lets the user navigate by clicking
// folders (links) or open files locally via the companion (a.tree-file, wired
// in companion.js). Shares the .tree-* CSS and the ?tree endpoint with
// tree-picker.js but does not reuse its row builder — that one is modal-select
// behavior, this one is navigation behavior. Hidden in edit mode (the server
// omits the container).
(function () {
var container = document.querySelector('aside.tree-sidebar');
if (!container) return;
function joinPath(parent, name) {
if (parent === '/' || parent === '') return '/' + name;
return parent.replace(/\/+$/, '') + '/' + name;
}
function encodeSegments(p) {
return p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
}
function folderHref(p) {
if (p === '/' || p === '') return '/';
return '/' + encodeSegments(p) + '/';
}
function fileHref(p) {
return '/' + encodeSegments(p);
}
function fetchFolder(path, expandTo) {
var url = folderHref(path) + '?tree=1';
if (expandTo) url += '&expandTo=' + encodeURIComponent(expandTo);
return fetch(url, { credentials: 'same-origin' }).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
// Current page as a canonical, DECODED wiki path ("/" for root, no trailing
// slash otherwise). pathname is percent-encoded; the tree's entry names (and
// thus fullPath) are decoded, so we must decode here too or paths containing
// spaces / non-ASCII never match a row and the chain never expands.
var activePath = (function () {
var p = window.location.pathname.replace(/\/+$/, '');
if (p === '') return '/';
try { return decodeURIComponent(p); } catch (e) { return p; }
})();
var activeRow = null;
function disabledRow(text) {
var row = document.createElement('div');
row.className = 'tree-row is-disabled';
row.textContent = text;
return row;
}
// renderInto fills containerEl with rows for `entries` (children of
// parentPath). Folders carrying a `children` array (the pre-expanded
// ancestor chain from ?expandTo) are opened immediately and recurse.
function renderInto(containerEl, parentPath, entries) {
if (!entries || entries.length === 0) {
containerEl.appendChild(disabledRow('(empty)'));
return;
}
entries.forEach(function (entry) {
var built = buildRow(parentPath, entry);
containerEl.appendChild(built.row);
if (built.preExpand) built.open();
});
}
function buildRow(parentPath, entry) {
var fullPath = joinPath(parentPath, entry.name);
var isFolder = entry.kind === 'folder';
var row = document.createElement('div');
row.className = 'tree-row';
var chevron = document.createElement('span');
chevron.className = 'tree-chevron';
if (isFolder) chevron.textContent = '▸'; // ▸
else chevron.classList.add('is-leaf');
row.appendChild(chevron);
var link = document.createElement('a');
link.className = 'tree-name ' + (isFolder ? 'tree-folder' : 'tree-file');
link.textContent = entry.name;
link.href = isFolder ? folderHref(fullPath) : fileHref(fullPath);
row.appendChild(link);
if (isFolder && fullPath === activePath) {
row.classList.add('is-active');
activeRow = row;
}
var preChildren = (isFolder && entry.children) ? entry.children : null;
var childrenEl = null;
var loaded = false;
var isOpen = false;
function ensureChildrenEl() {
if (!childrenEl) {
childrenEl = document.createElement('div');
childrenEl.className = 'tree-children';
}
return childrenEl;
}
function loadChildren() {
var el = ensureChildrenEl();
el.textContent = '';
el.appendChild(disabledRow('…'));
fetchFolder(fullPath).then(function (resp) {
el.textContent = '';
renderInto(el, fullPath, resp.entries);
loaded = true;
}).catch(function () {
el.textContent = '';
var err = document.createElement('div');
err.className = 'tree-row';
err.textContent = '(failed — tap to retry)';
err.addEventListener('click', function (e) {
e.stopPropagation();
loadChildren();
});
el.appendChild(err);
});
}
function open() {
if (!isFolder || isOpen) return;
var el = ensureChildrenEl();
row.parentNode.insertBefore(el, row.nextSibling);
chevron.textContent = '▾'; // ▾
isOpen = true;
if (!loaded) {
if (preChildren) {
renderInto(el, fullPath, preChildren);
loaded = true;
} else {
loadChildren();
}
}
}
function close() {
if (!isFolder || !isOpen) return;
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl);
}
chevron.textContent = '▸';
isOpen = false;
}
// Clicking the name link navigates (folder) or opens the file; clicking
// anywhere else on the row toggles expansion for folders.
row.addEventListener('click', function (e) {
if (e.target.closest('a.tree-name')) return;
if (!isFolder) return;
if (isOpen) close(); else open();
});
return { row: row, open: open, preExpand: !!preChildren };
}
function render() {
container.textContent = '';
var listing = document.createElement('div');
container.appendChild(listing);
listing.appendChild(disabledRow('…'));
fetchFolder('/', activePath === '/' ? '' : activePath).then(function (resp) {
listing.textContent = '';
renderInto(listing, '/', resp.entries);
if (activeRow) {
try { activeRow.scrollIntoView({ block: 'nearest' }); } catch (e) {}
}
}).catch(function () {
listing.textContent = '';
var err = document.createElement('div');
err.className = 'tree-row';
err.textContent = '(failed — tap to retry)';
err.addEventListener('click', function () { render(); });
listing.appendChild(err);
});
}
// Mobile: a smaller FAB stacked above the menu FAB toggles the rail as an
// overlay drawer; selecting any entry closes it (mirrors sidebar-fab.js).
function setupFab() {
var fab = document.createElement('button');
fab.type = 'button';
fab.className = 'btn btn-fab fab fab-tree';
fab.title = 'Folder tree';
fab.setAttribute('aria-label', 'Folder tree');
fab.setAttribute('aria-expanded', 'false');
fab.innerHTML = '<svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter"><path d="M1 6h14v8H1zm0 0V4h5l1 2"/></svg>';
fab.addEventListener('click', function () {
var open = container.classList.toggle('is-open');
fab.setAttribute('aria-expanded', open ? 'true' : 'false');
});
container.addEventListener('click', function (e) {
if (e.target.closest('a')) {
container.classList.remove('is-open');
fab.setAttribute('aria-expanded', 'false');
}
});
document.body.appendChild(fab);
}
render();
setupFab();
})();
+48
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"os" "os"
"path/filepath"
"sort" "sort"
"strings" "strings"
) )
@@ -11,6 +12,9 @@ import (
type treeEntry struct { type treeEntry struct {
Name string `json:"name"` Name string `json:"name"`
Kind string `json:"kind"` Kind string `json:"kind"`
// Children is populated only along the expandTo chain (see handleTree);
// omitted otherwise so the flat picker listing keeps its original shape.
Children []treeEntry `json:"children,omitempty"`
} }
type treeResponse struct { type treeResponse struct {
@@ -42,6 +46,14 @@ func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fs
return return
} }
// expandTo asks for a nested listing: each folder along the ancestor chain
// carries its own children, recursively, down to the target. Used by the
// tree sidebar to render the current page's chain in one request. The flat
// picker omits expandTo and is unaffected.
if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" {
expandTreeChain(fsPath, entries, treePathSegments(expandTo))
}
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries} resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(resp) _ = json.NewEncoder(w).Encode(resp)
@@ -83,3 +95,39 @@ func listTreeEntries(fsPath string) ([]treeEntry, error) {
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name }) sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
return append(folders, files...), nil return append(folders, files...), nil
} }
// treePathSegments splits a wiki path into its non-empty segments.
func treePathSegments(p string) []string {
var segs []string
for _, s := range strings.Split(p, "/") {
if s != "" {
segs = append(segs, s)
}
}
return segs
}
// expandTreeChain walks segs, matching each against a folder in entries by
// name, loading that folder's children in place, and recursing. The walk stops
// at the deepest matching segment, so a stale or deleted path simply expands as
// far as it still exists. Segments only ever match real directory names from
// listTreeEntries (no "." or ".." entries), so this cannot traverse outside the
// listed tree.
func expandTreeChain(fsPath string, entries []treeEntry, segs []string) {
if len(segs) == 0 {
return
}
for i := range entries {
if entries[i].Kind != "folder" || entries[i].Name != segs[0] {
continue
}
childFs := filepath.Join(fsPath, segs[0])
kids, err := listTreeEntries(childFs)
if err != nil {
return
}
entries[i].Children = kids
expandTreeChain(childFs, entries[i].Children, segs[1:])
return
}
}