From 9ed6475775e69c016d60ce64df5e79088dc646b6 Mon Sep 17 00:00:00 2001 From: luxick Date: Tue, 16 Jun 2026 15:03:18 +0200 Subject: [PATCH] Folder Sidebar --- assets/companion.js | 4 +- assets/layout.html | 2 + assets/style.css | 68 +++++++++++-- assets/tree-picker.js | 9 -- assets/tree-sidebar.js | 215 +++++++++++++++++++++++++++++++++++++++++ tree.go | 48 +++++++++ 6 files changed, 330 insertions(+), 16 deletions(-) create mode 100644 assets/tree-sidebar.js diff --git a/assets/companion.js b/assets/companion.js index e2122a2..0bf29d3 100644 --- a/assets/companion.js +++ b/assets/companion.js @@ -118,7 +118,9 @@ if (!e.target.closest) return; // Match both listing styles: table rows expose the file link inside // 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; var item = anchor.closest('.list-item'); // Only intercept the primary file link, and only for files (not folders). diff --git a/assets/layout.html b/assets/layout.html index 36b6109..62c0885 100644 --- a/assets/layout.html +++ b/assets/layout.html @@ -13,6 +13,7 @@ + {{if not .EditMode}}{{end}} {{block "headScripts" .}}{{end}} @@ -29,6 +30,7 @@
{{block "headerActions" .}}{{end}}
+ {{if not .EditMode}}{{end}}
{{block "content" .}}{{end}}
diff --git a/assets/style.css b/assets/style.css index 140ca82..1b33b4b 100644 --- a/assets/style.css +++ b/assets/style.css @@ -41,6 +41,9 @@ sidebar/TOC top offsets and anchor scroll-padding. Hardcoded (the desktop header is a single-row grid); revisit if the header wraps. */ --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. @@ -89,9 +92,9 @@ hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; } will silently break. */ .page-wrap { display: grid; - grid-template-columns: minmax(0, 1fr) 14rem; + grid-template-columns: var(--tree-width) minmax(0, 1fr) 14rem; gap: var(--space-5); - max-width: 1280px; + max-width: 1440px; margin: 0 auto; padding: 0 var(--space-4); 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: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.is-leaf { visibility: hidden; } -.tree-marker { width: var(--space-4); color: var(--text-muted); } -.tree-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.tree-children { padding-left: var(--space-5); } +.tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tree-children { padding-left: var(--space-2); } .tree-selected-path { font-size: var(--font-sm); padding: var(--space-1) 0; 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 { margin: var(--space-3) 0; } @@ -721,6 +751,32 @@ aside.sidebar:empty { display: none; } } .sidebar.is-open { display: 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 +
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) { diff --git a/assets/tree-picker.js b/assets/tree-picker.js index 41e6f89..d32b53e 100644 --- a/assets/tree-picker.js +++ b/assets/tree-picker.js @@ -85,11 +85,6 @@ } 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'); label.className = 'tree-name'; label.textContent = name; @@ -199,10 +194,6 @@ var rootChev = document.createElement('span'); rootChev.className = 'tree-chevron is-leaf'; 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.textContent = '/'; rootRow.appendChild(rootLabel); diff --git a/assets/tree-sidebar.js b/assets/tree-sidebar.js new file mode 100644 index 0000000..2bccd76 --- /dev/null +++ b/assets/tree-sidebar.js @@ -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 = ''; + 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(); +})(); diff --git a/tree.go b/tree.go index 6142dcb..3226522 100644 --- a/tree.go +++ b/tree.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "os" + "path/filepath" "sort" "strings" ) @@ -11,6 +12,9 @@ import ( type treeEntry struct { Name string `json:"name"` 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 { @@ -42,6 +46,14 @@ func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fs 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} w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = 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 }) 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 + } +}