Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db17f94627 | |||
| 9ed6475775 |
+3
-1
@@ -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).
|
||||||
|
|||||||
+3
-2
@@ -13,13 +13,13 @@
|
|||||||
<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>
|
||||||
<header>
|
<header>
|
||||||
<nav class="breadcrumb row">
|
<nav class="breadcrumb row">
|
||||||
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
|
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
|
||||||
{{if .ParentURL}}<a class="nav-up" href="{{.ParentURL}}" tabindex="-1" title="Up" aria-label="Up">Up <svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square"><path d="M8 13V3M3 8l5-5 5 5"/></svg></a>{{end}}
|
|
||||||
</nav>
|
</nav>
|
||||||
{{if not .EditMode}}
|
{{if not .EditMode}}
|
||||||
<form class="search-form" action="/" method="get">
|
<form class="search-form" action="/" method="get">
|
||||||
@@ -29,6 +29,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>
|
||||||
|
|||||||
+87
-21
@@ -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%;
|
||||||
@@ -112,7 +115,7 @@ header {
|
|||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-bottom: var(--border-dashed);
|
border-bottom: var(--border-dashed);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr minmax(0, auto) 1fr;
|
grid-template-columns: 1fr minmax(0, 60rem) 1fr;
|
||||||
grid-template-areas: "crumbs search actions";
|
grid-template-areas: "crumbs search actions";
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
@@ -128,13 +131,6 @@ footer {
|
|||||||
.breadcrumb { grid-area: crumbs; gap: var(--space-1); min-width: 0; }
|
.breadcrumb { grid-area: crumbs; gap: var(--space-1); min-width: 0; }
|
||||||
.header-actions { grid-area: actions; justify-content: flex-end; flex-wrap: wrap; }
|
.header-actions { grid-area: actions; justify-content: flex-end; flex-wrap: wrap; }
|
||||||
.logo { width: 1.1em; height: 1.1em; vertical-align: center; }
|
.logo { width: 1.1em; height: 1.1em; vertical-align: center; }
|
||||||
.nav-up {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--secondary);
|
|
||||||
padding: 0 var(--space-1);
|
|
||||||
}
|
|
||||||
.nav-up:hover { color: var(--primary-hover); }
|
|
||||||
|
|
||||||
/* === Panel ===
|
/* === Panel ===
|
||||||
Bordered container recipe shared by listings, sidebar widgets, the tree
|
Bordered container recipe shared by listings, sidebar widgets, the tree
|
||||||
@@ -406,8 +402,8 @@ body.edit-mode .page-wrap { grid-template-columns: minmax(0, 1fr); max-width: no
|
|||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
position: relative;
|
position: relative;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
width: 24rem;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 40rem;
|
||||||
}
|
}
|
||||||
.search-input { font-size: 0.9rem; }
|
.search-input { font-size: 0.9rem; }
|
||||||
.search-card {
|
.search-card {
|
||||||
@@ -513,12 +509,23 @@ button.fab { display: none; }
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
|
/* Mirror the left tree rail: a dashed separator to main, no panel outlines
|
||||||
|
on the widgets within (see .panel-sidebar). */
|
||||||
|
padding-left: var(--space-2);
|
||||||
|
border-left: var(--border-dashed);
|
||||||
}
|
}
|
||||||
aside.sidebar:empty { display: none; }
|
aside.sidebar:empty { display: none; }
|
||||||
|
|
||||||
/* Density modifier for panels in the sidebar (smaller font, tighter padding).
|
/* Density modifier for panels in the sidebar (smaller font, tighter padding).
|
||||||
The .panel class applied alongside provides the border + background. */
|
Drops the .panel outline so the rail reads as a clean column separated from
|
||||||
.panel-sidebar { padding: var(--space-2) var(--space-3); font-size: var(--font-sm); }
|
main by the .sidebar dashed border, matching the left tree rail. The mobile
|
||||||
|
drawer re-adds a full border + bg (see responsive block) so it stays legible
|
||||||
|
floating over content. */
|
||||||
|
.panel-sidebar {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
.actions { display: flex; flex-direction: column; gap: 0.15rem; }
|
.actions { display: flex; flex-direction: column; gap: 0.15rem; }
|
||||||
|
|
||||||
/* === Table of contents (floating variant) ===
|
/* === Table of contents (floating variant) ===
|
||||||
@@ -602,17 +609,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; }
|
||||||
@@ -695,8 +729,8 @@ aside.sidebar:empty { display: none; }
|
|||||||
/* === Responsive === */
|
/* === Responsive === */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.page-wrap { grid-template-columns: 1fr; }
|
.page-wrap { grid-template-columns: 1fr; }
|
||||||
/* Single-row mobile header: logo + Up icon are compact so search can
|
/* Single-row mobile header: the compact logo sits left so search can take
|
||||||
take the middle flex column, with actions on the right. */
|
the middle flex column, with actions on the right. */
|
||||||
header { grid-template-columns: auto 1fr auto; }
|
header { grid-template-columns: auto 1fr auto; }
|
||||||
/* Mobile view mode: header scrolls away with the page (out of scope to
|
/* Mobile view mode: header scrolls away with the page (out of scope to
|
||||||
stick it). It re-sticks only in edit mode so SAVE/CANCEL stay reachable
|
stick it). It re-sticks only in edit mode so SAVE/CANCEL stay reachable
|
||||||
@@ -705,7 +739,9 @@ aside.sidebar:empty { display: none; }
|
|||||||
body.edit-mode header { position: sticky; top: 0; }
|
body.edit-mode header { position: sticky; top: 0; }
|
||||||
.search-form { width: 100%; max-width: none; justify-self: stretch; }
|
.search-form { width: 100%; max-width: none; justify-self: stretch; }
|
||||||
/* Sidebar on mobile is a floating overlay toggled by the FAB. The aside
|
/* Sidebar on mobile is a floating overlay toggled by the FAB. The aside
|
||||||
itself is the scroll container; children render at natural height. */
|
itself is the scroll container; children render at natural height. The
|
||||||
|
full border + bg replace the per-panel outlines dropped on desktop so the
|
||||||
|
drawer stays legible floating over content. */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 5rem;
|
bottom: 5rem;
|
||||||
@@ -718,14 +754,44 @@ aside.sidebar:empty { display: none; }
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: none;
|
display: none;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: var(--border);
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
.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) {
|
||||||
header, footer { padding: var(--space-2) var(--space-3); }
|
header, footer { padding: var(--space-2) var(--space-3); }
|
||||||
main { padding: var(--space-4) var(--space-3); }
|
main { padding: var(--space-4) var(--space-3); }
|
||||||
|
.app-name {display: none;}
|
||||||
.editor-cm { min-height: 50vh; }
|
.editor-cm { min-height: 50vh; }
|
||||||
.sidebar { width: calc(100% - 1.5rem); }
|
.sidebar { width: calc(100% - 1.5rem); }
|
||||||
/* Editing on mobile is full-bleed: drop the page/main inset so the toolbar
|
/* Editing on mobile is full-bleed: drop the page/main inset so the toolbar
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -318,13 +318,8 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
rawContent = "# " + pageTitle(urlPath) + "\n\n"
|
rawContent = "# " + pageTitle(urlPath) + "\n\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
parent := ""
|
|
||||||
if urlPath != "/" {
|
|
||||||
parent = parentURL(urlPath)
|
|
||||||
}
|
|
||||||
data := pageData{
|
data := pageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
ParentURL: parent,
|
|
||||||
CanEdit: true,
|
CanEdit: true,
|
||||||
EditMode: editMode,
|
EditMode: editMode,
|
||||||
IsRoot: urlPath == "/",
|
IsRoot: urlPath == "/",
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ type entry struct {
|
|||||||
|
|
||||||
type pageData struct {
|
type pageData struct {
|
||||||
Title string
|
Title string
|
||||||
ParentURL string
|
|
||||||
CanEdit bool
|
CanEdit bool
|
||||||
EditMode bool
|
EditMode bool
|
||||||
IsRoot bool
|
IsRoot bool
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ type searchResult struct {
|
|||||||
|
|
||||||
type searchPageData struct {
|
type searchPageData struct {
|
||||||
Title string
|
Title string
|
||||||
ParentURL string
|
|
||||||
EditMode bool
|
EditMode bool
|
||||||
Query string
|
Query string
|
||||||
Results []searchResult
|
Results []searchResult
|
||||||
@@ -66,7 +65,6 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
data := searchPageData{
|
data := searchPageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
ParentURL: "/",
|
|
||||||
Query: query,
|
Query: query,
|
||||||
Results: results,
|
Results: results,
|
||||||
IndexBuiltAt: builtAt,
|
IndexBuiltAt: builtAt,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user