Improve serach function with quick suggestions
This commit is contained in:
+57
-29
@@ -64,43 +64,71 @@
|
|||||||
if (result) applyResult(result);
|
if (result) applyResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
function promptDisplayText(initial, onDone) {
|
// isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
|
||||||
var input = document.createElement('input');
|
// path, no empty/dot segments. Used to gate the INSERT confirm button.
|
||||||
input.type = 'text';
|
function isValidWikiTarget(p) {
|
||||||
input.className = 'modal-input';
|
if (!p || p[0] !== '/') return false;
|
||||||
input.placeholder = 'Display text (optional)';
|
var trimmed = p.replace(/^\/+|\/+$/g, '');
|
||||||
if (initial) input.value = initial;
|
if (trimmed === '') return true;
|
||||||
var handle = openModal({
|
var segs = trimmed.split('/');
|
||||||
title: 'Insert link — display text?',
|
for (var i = 0; i < segs.length; i++) {
|
||||||
body: input,
|
if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false;
|
||||||
confirm: {
|
}
|
||||||
label: 'INSERT',
|
return true;
|
||||||
onConfirm: function () {
|
|
||||||
handle.close();
|
|
||||||
onDone(input.value.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertWikilink() {
|
function insertWikilink() {
|
||||||
var sel = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
var sel = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||||
openTreePicker({
|
|
||||||
|
var container = document.createElement('div');
|
||||||
|
|
||||||
|
var targetWrap = document.createElement('div');
|
||||||
|
var targetInput = document.createElement('input');
|
||||||
|
targetInput.type = 'text';
|
||||||
|
targetInput.className = 'modal-input';
|
||||||
|
targetInput.placeholder = 'Page path or search…';
|
||||||
|
targetWrap.appendChild(targetInput);
|
||||||
|
|
||||||
|
var displayInput = document.createElement('input');
|
||||||
|
displayInput.type = 'text';
|
||||||
|
displayInput.className = 'modal-input';
|
||||||
|
displayInput.placeholder = 'Display text (optional)';
|
||||||
|
if (sel) displayInput.value = sel;
|
||||||
|
|
||||||
|
container.appendChild(targetWrap);
|
||||||
|
container.appendChild(displayInput);
|
||||||
|
|
||||||
|
var handle = openModal({
|
||||||
title: 'Insert link',
|
title: 'Insert link',
|
||||||
mode: 'any',
|
body: container,
|
||||||
initialPath: '/',
|
confirm: {
|
||||||
confirmLabel: 'NEXT',
|
label: 'INSERT',
|
||||||
onSelect: function (path, kind) {
|
initiallyDisabled: true,
|
||||||
if (kind === 'folder') {
|
onConfirm: function () {
|
||||||
promptDisplayText(sel, function (display) {
|
var target = targetInput.value.trim();
|
||||||
insertAtCursor(display ? '[[' + path + '::' + display + ']]' : '[[' + path + ']]');
|
if (!isValidWikiTarget(target)) return;
|
||||||
});
|
var display = displayInput.value.trim();
|
||||||
} else {
|
handle.close();
|
||||||
var name = path.split('/').pop();
|
insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]');
|
||||||
insertAtCursor('[' + (sel || name) + '](' + path + ')');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function updateConfirm() {
|
||||||
|
handle.setConfirmDisabled(!isValidWikiTarget(targetInput.value.trim()));
|
||||||
|
}
|
||||||
|
targetInput.addEventListener('input', updateConfirm);
|
||||||
|
|
||||||
|
window.attachSuggestions(targetInput, {
|
||||||
|
showFooter: false,
|
||||||
|
container: targetWrap,
|
||||||
|
onPick: function (r) {
|
||||||
|
targetInput.value = '/' + r.path;
|
||||||
|
updateConfirm();
|
||||||
|
displayInput.focus();
|
||||||
|
displayInput.select();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/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>
|
||||||
|
After Width: | Height: | Size: 233 B |
+4
-5
@@ -10,6 +10,7 @@
|
|||||||
<link rel="stylesheet" href="/_/style.css" />
|
<link rel="stylesheet" href="/_/style.css" />
|
||||||
<script src="/_/modal.js"></script>
|
<script src="/_/modal.js"></script>
|
||||||
<script src="/_/global-shortcuts.js"></script>
|
<script src="/_/global-shortcuts.js"></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>
|
||||||
{{block "headScripts" .}}{{end}}
|
{{block "headScripts" .}}{{end}}
|
||||||
@@ -17,17 +18,15 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<nav class="breadcrumb">
|
<nav class="breadcrumb">
|
||||||
<a href="/" tabindex="-1"><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></a>
|
||||||
{{range .Crumbs}}
|
{{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}}
|
||||||
<span class="sep">/</span><a href="{{.URL}}" tabindex="-1">{{.Name}}</a>
|
|
||||||
{{end}}
|
|
||||||
</nav>
|
</nav>
|
||||||
{{if not .EditMode}}
|
{{if not .EditMode}}
|
||||||
<form class="search-form" action="/" method="get">
|
<form class="search-form" action="/" method="get">
|
||||||
<input class="search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
|
<input class="search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{block "headerActions" .}}{{end}}
|
<div class="header-actions">{{block "headerActions" .}}{{end}}</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="page-wrap">
|
<div class="page-wrap">
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// search-suggest.js — instant typeahead dropdown.
|
||||||
|
//
|
||||||
|
// Exposes window.attachSuggestions(inputEl, opts) used by both the header
|
||||||
|
// search box and the editor's "Insert link" modal. Owns: debounced fetching,
|
||||||
|
// request ordering, DOM creation, keyboard handling, open/close lifecycle.
|
||||||
|
//
|
||||||
|
// opts:
|
||||||
|
// onPick(result) — called when the user selects a row
|
||||||
|
// onShowAll(query) — optional; called when the footer row activates
|
||||||
|
// showFooter (bool) — show the "Show all N matches" footer row
|
||||||
|
// container (Element) — optional parent (defaults to inputEl.parentNode)
|
||||||
|
(function () {
|
||||||
|
var DEBOUNCE_MS = 100;
|
||||||
|
var MIN_QUERY_LEN = 2;
|
||||||
|
|
||||||
|
function escapeHTML(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(s) {
|
||||||
|
return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// highlight bolds the substring spans in `name` that match any of the
|
||||||
|
// query tokens (case-insensitive). Overlapping/adjacent spans merge.
|
||||||
|
// Returns a safe HTML string.
|
||||||
|
function highlight(name, tokens) {
|
||||||
|
if (!tokens.length) return escapeHTML(name);
|
||||||
|
var lower = name.toLowerCase();
|
||||||
|
var spans = [];
|
||||||
|
tokens.forEach(function (t) {
|
||||||
|
if (!t) return;
|
||||||
|
var idx = lower.indexOf(t);
|
||||||
|
if (idx >= 0) spans.push([idx, idx + t.length]);
|
||||||
|
});
|
||||||
|
if (!spans.length) return escapeHTML(name);
|
||||||
|
spans.sort(function (a, b) { return a[0] - b[0]; });
|
||||||
|
var merged = [spans[0].slice()];
|
||||||
|
for (var i = 1; i < spans.length; i++) {
|
||||||
|
var last = merged[merged.length - 1];
|
||||||
|
if (spans[i][0] <= last[1]) {
|
||||||
|
last[1] = Math.max(last[1], spans[i][1]);
|
||||||
|
} else {
|
||||||
|
merged.push(spans[i].slice());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out = '';
|
||||||
|
var cursor = 0;
|
||||||
|
merged.forEach(function (sp) {
|
||||||
|
out += escapeHTML(name.slice(cursor, sp[0]));
|
||||||
|
out += '<strong>' + escapeHTML(name.slice(sp[0], sp[1])) + '</strong>';
|
||||||
|
cursor = sp[1];
|
||||||
|
});
|
||||||
|
out += escapeHTML(name.slice(cursor));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachSuggestions(inputEl, opts) {
|
||||||
|
if (!inputEl) return;
|
||||||
|
opts = opts || {};
|
||||||
|
var host = opts.container || inputEl.parentNode;
|
||||||
|
if (!host) return;
|
||||||
|
host.classList.add('suggest-host');
|
||||||
|
|
||||||
|
var dropdown = document.createElement('div');
|
||||||
|
dropdown.className = 'suggest-dropdown';
|
||||||
|
host.appendChild(dropdown);
|
||||||
|
|
||||||
|
var state = {
|
||||||
|
results: [],
|
||||||
|
total: 0,
|
||||||
|
query: '',
|
||||||
|
activeIdx: -1,
|
||||||
|
open: false,
|
||||||
|
reqSeq: 0,
|
||||||
|
debounceTimer: null,
|
||||||
|
blurTimer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function rowCount() {
|
||||||
|
var n = state.results.length;
|
||||||
|
if (state.results.length === 0 && state.query.length >= MIN_QUERY_LEN) {
|
||||||
|
return 0; // "no matches" row is non-interactive
|
||||||
|
}
|
||||||
|
if (opts.showFooter && state.total > state.results.length) n += 1;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFooterIdx(idx) {
|
||||||
|
return opts.showFooter
|
||||||
|
&& state.total > state.results.length
|
||||||
|
&& idx === state.results.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
dropdown.textContent = '';
|
||||||
|
if (!state.open) {
|
||||||
|
dropdown.classList.remove('is-open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var tokens = tokenize(state.query);
|
||||||
|
if (state.results.length === 0) {
|
||||||
|
var empty = document.createElement('div');
|
||||||
|
empty.className = 'suggest-row is-empty';
|
||||||
|
empty.textContent = 'No matches';
|
||||||
|
dropdown.appendChild(empty);
|
||||||
|
} else {
|
||||||
|
state.results.forEach(function (r, i) {
|
||||||
|
var row = document.createElement('button');
|
||||||
|
row.type = 'button';
|
||||||
|
row.className = 'suggest-row';
|
||||||
|
row.setAttribute('data-idx', String(i));
|
||||||
|
var nameEl = document.createElement('span');
|
||||||
|
nameEl.className = 'suggest-name';
|
||||||
|
nameEl.innerHTML = highlight(r.name, tokens);
|
||||||
|
var pathEl = document.createElement('span');
|
||||||
|
pathEl.className = 'suggest-path';
|
||||||
|
pathEl.textContent = '/' + r.path;
|
||||||
|
row.appendChild(nameEl);
|
||||||
|
row.appendChild(pathEl);
|
||||||
|
if (i === state.activeIdx) row.classList.add('is-active');
|
||||||
|
row.addEventListener('mousedown', function (e) {
|
||||||
|
// mousedown (not click) so the input doesn't blur-close
|
||||||
|
// the dropdown before the pick handler fires.
|
||||||
|
e.preventDefault();
|
||||||
|
pick(i);
|
||||||
|
});
|
||||||
|
dropdown.appendChild(row);
|
||||||
|
});
|
||||||
|
if (opts.showFooter && state.total > state.results.length) {
|
||||||
|
var footer = document.createElement('button');
|
||||||
|
footer.type = 'button';
|
||||||
|
footer.className = 'suggest-row suggest-footer';
|
||||||
|
footer.textContent = 'Show all ' + state.total + ' matches';
|
||||||
|
var footerIdx = state.results.length;
|
||||||
|
if (state.activeIdx === footerIdx) footer.classList.add('is-active');
|
||||||
|
footer.addEventListener('mousedown', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
pickFooter();
|
||||||
|
});
|
||||||
|
dropdown.appendChild(footer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dropdown.classList.add('is-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(idx) {
|
||||||
|
var r = state.results[idx];
|
||||||
|
if (!r) return;
|
||||||
|
close();
|
||||||
|
if (opts.onPick) opts.onPick(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFooter() {
|
||||||
|
close();
|
||||||
|
if (opts.onShowAll) {
|
||||||
|
opts.onShowAll(state.query);
|
||||||
|
} else if (inputEl.form) {
|
||||||
|
inputEl.form.submit();
|
||||||
|
} else {
|
||||||
|
window.location.href = '/?q=' + encodeURIComponent(state.query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
state.open = true;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
state.open = false;
|
||||||
|
state.activeIdx = -1;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchResults(query) {
|
||||||
|
var seq = ++state.reqSeq;
|
||||||
|
fetch('/_search?q=' + encodeURIComponent(query), {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
}).then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
}).then(function (resp) {
|
||||||
|
if (seq !== state.reqSeq) return; // stale
|
||||||
|
state.results = resp.results || [];
|
||||||
|
state.total = resp.total || 0;
|
||||||
|
state.query = resp.query || query;
|
||||||
|
state.activeIdx = -1;
|
||||||
|
open();
|
||||||
|
}).catch(function () {
|
||||||
|
if (seq !== state.reqSeq) return;
|
||||||
|
state.results = [];
|
||||||
|
state.total = 0;
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput() {
|
||||||
|
var q = inputEl.value.trim();
|
||||||
|
state.query = q;
|
||||||
|
if (state.debounceTimer) clearTimeout(state.debounceTimer);
|
||||||
|
if (q.length < MIN_QUERY_LEN) {
|
||||||
|
state.reqSeq++; // invalidate any in-flight response
|
||||||
|
state.results = [];
|
||||||
|
state.total = 0;
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.debounceTimer = setTimeout(function () {
|
||||||
|
fetchResults(q);
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveActive(delta) {
|
||||||
|
var n = rowCount();
|
||||||
|
if (n === 0) return;
|
||||||
|
var next = state.activeIdx + delta;
|
||||||
|
if (next < 0) next = n - 1;
|
||||||
|
if (next >= n) next = 0;
|
||||||
|
state.activeIdx = next;
|
||||||
|
render();
|
||||||
|
// Keep the active row in view.
|
||||||
|
var active = dropdown.querySelector('.suggest-row.is-active');
|
||||||
|
if (active && active.scrollIntoView) {
|
||||||
|
try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateCurrent() {
|
||||||
|
if (state.activeIdx < 0) return false;
|
||||||
|
if (isFooterIdx(state.activeIdx)) {
|
||||||
|
pickFooter();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
pick(state.activeIdx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputEl.addEventListener('input', onInput);
|
||||||
|
inputEl.addEventListener('focus', function () {
|
||||||
|
if (state.blurTimer) {
|
||||||
|
clearTimeout(state.blurTimer);
|
||||||
|
state.blurTimer = null;
|
||||||
|
}
|
||||||
|
if (inputEl.value.trim().length >= MIN_QUERY_LEN
|
||||||
|
&& (state.results.length || state.query)) {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
inputEl.addEventListener('blur', function () {
|
||||||
|
// Delay so click/mousedown on a row still resolves.
|
||||||
|
state.blurTimer = setTimeout(close, 150);
|
||||||
|
});
|
||||||
|
inputEl.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
if (!state.open) return;
|
||||||
|
e.preventDefault();
|
||||||
|
moveActive(1);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
if (!state.open) return;
|
||||||
|
e.preventDefault();
|
||||||
|
moveActive(-1);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
if (!state.open) return;
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
if (state.open && state.activeIdx >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
activateCurrent();
|
||||||
|
}
|
||||||
|
// else: native form submit behaviour (full results page)
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
if (!state.open || rowCount() === 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
moveActive(e.shiftKey ? -1 : 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside the host closes the dropdown.
|
||||||
|
document.addEventListener('mousedown', function (e) {
|
||||||
|
if (!state.open) return;
|
||||||
|
if (host.contains(e.target)) return;
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: close,
|
||||||
|
destroy: function () {
|
||||||
|
if (dropdown.parentNode) dropdown.parentNode.removeChild(dropdown);
|
||||||
|
host.classList.remove('suggest-host');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.attachSuggestions = attachSuggestions;
|
||||||
|
|
||||||
|
// Auto-bind to the header search input. Header search submits the form
|
||||||
|
// for the "show all" action; we route to a navigate-on-pick handler.
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var input = document.querySelector('header .search-input');
|
||||||
|
if (!input) return;
|
||||||
|
attachSuggestions(input, {
|
||||||
|
showFooter: true,
|
||||||
|
onPick: function (r) { window.location.href = r.url; },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
+101
-6
@@ -72,22 +72,36 @@ a:hover {
|
|||||||
color: var(--link-hover);
|
color: var(--link-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Header === */
|
/* === Header ===
|
||||||
|
Three-column grid (breadcrumbs left, search centre, actions right) using
|
||||||
|
named grid-areas so the centre stays reserved even when search is hidden
|
||||||
|
in editor mode. Mobile (≤1100px) collapses to a two-row layout — see the
|
||||||
|
responsive block at the bottom of this file. */
|
||||||
header {
|
header {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px dashed var(--secondary);
|
border-bottom: 1px dashed var(--secondary);
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr minmax(0, auto) 1fr;
|
||||||
|
grid-template-areas: "crumbs search actions";
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb {
|
.breadcrumb {
|
||||||
|
grid-area: crumbs;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
grid-area: actions;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
@@ -96,8 +110,14 @@ header {
|
|||||||
vertical-align: center;
|
vertical-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sep {
|
.nav-up {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
color: var(--secondary);
|
color: var(--secondary);
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
.nav-up:hover {
|
||||||
|
color: var(--primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@@ -429,8 +449,13 @@ textarea {
|
|||||||
|
|
||||||
/* === Search === */
|
/* === Search === */
|
||||||
.search-form {
|
.search-form {
|
||||||
|
grid-area: search;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
position: relative;
|
||||||
|
justify-self: center;
|
||||||
|
width: 24rem;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.search-input {
|
.search-input {
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
@@ -440,13 +465,73 @@ textarea {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 12rem;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.search-input:focus {
|
.search-input:focus {
|
||||||
border-color: var(--primary-hover);
|
border-color: var(--primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Suggestion dropdown (header + editor link picker) ===
|
||||||
|
Anchored to a position:relative host (search-form, or the modal body
|
||||||
|
for the link picker). Visuals mirror .dropdown-menu — dashed border,
|
||||||
|
bg-panel-hover for the active row. */
|
||||||
|
.suggest-host {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.suggest-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px dashed var(--secondary);
|
||||||
|
border-top: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.suggest-dropdown.is-open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.suggest-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.suggest-row + .suggest-row {
|
||||||
|
border-top: 1px solid var(--secondary);
|
||||||
|
}
|
||||||
|
.suggest-row:hover,
|
||||||
|
.suggest-row.is-active {
|
||||||
|
background: var(--bg-panel-hover);
|
||||||
|
}
|
||||||
|
.suggest-row.is-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.suggest-row.is-empty:hover {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
.suggest-name {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.suggest-path {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.suggest-footer {
|
||||||
|
color: var(--link);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
.search-card {
|
.search-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -880,6 +965,16 @@ aside.sidebar:empty {
|
|||||||
.page-wrap {
|
.page-wrap {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
/* Single-row mobile header: logo + Up icon are compact so search
|
||||||
|
can take the middle flex column, with actions on the right. */
|
||||||
|
header {
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
}
|
||||||
|
.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; its children render at natural height. */
|
itself is the scroll container; its children render at natural height. */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ func main() {
|
|||||||
}))
|
}))
|
||||||
http.HandleFunc("/_logout", h.handleLogout)
|
http.HandleFunc("/_logout", h.handleLogout)
|
||||||
http.HandleFunc("/_reindex", h.handleReindex)
|
http.HandleFunc("/_reindex", h.handleReindex)
|
||||||
|
http.HandleFunc("/_search", h.handleSearchSuggest)
|
||||||
http.HandleFunc("/quickadd", h.handleQuickAdd)
|
http.HandleFunc("/quickadd", h.handleQuickAdd)
|
||||||
http.Handle("/", h)
|
http.Handle("/", h)
|
||||||
|
|
||||||
@@ -284,9 +285,13 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
rawContent = "# " + heading + "\n\n"
|
rawContent = "# " + heading + "\n\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parent := ""
|
||||||
|
if urlPath != "/" {
|
||||||
|
parent = parentURL(urlPath)
|
||||||
|
}
|
||||||
data := pageData{
|
data := pageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
Crumbs: buildCrumbs(urlPath),
|
ParentURL: parent,
|
||||||
CanEdit: true,
|
CanEdit: true,
|
||||||
EditMode: editMode,
|
EditMode: editMode,
|
||||||
IsRoot: urlPath == "/",
|
IsRoot: urlPath == "/",
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ func initMarkdown(root string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type crumb struct{ Name, URL string }
|
|
||||||
type entry struct {
|
type entry struct {
|
||||||
Icon template.HTML
|
Icon template.HTML
|
||||||
Name, URL, Meta string
|
Name, URL, Meta string
|
||||||
@@ -36,7 +35,7 @@ type entry struct {
|
|||||||
|
|
||||||
type pageData struct {
|
type pageData struct {
|
||||||
Title string
|
Title string
|
||||||
Crumbs []crumb
|
ParentURL string
|
||||||
CanEdit bool
|
CanEdit bool
|
||||||
EditMode bool
|
EditMode bool
|
||||||
IsRoot bool
|
IsRoot bool
|
||||||
@@ -56,6 +55,7 @@ type pageSettings struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
iconUp = readIcon("up")
|
||||||
iconFolder = readIcon("folder")
|
iconFolder = readIcon("folder")
|
||||||
iconDoc = readIcon("doc")
|
iconDoc = readIcon("doc")
|
||||||
iconImage = readIcon("image")
|
iconImage = readIcon("image")
|
||||||
@@ -155,7 +155,20 @@ func listEntries(fsPath, urlPath string) []entry {
|
|||||||
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
|
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
|
||||||
})
|
})
|
||||||
|
|
||||||
return append(folders, files...)
|
// `..` row mirrors the header Up button so the listing itself is
|
||||||
|
// navigable without reaching for the header on mobile. Prepended after
|
||||||
|
// sort so it always sits at the top regardless of folder names.
|
||||||
|
var out []entry
|
||||||
|
if urlPath != "/" {
|
||||||
|
out = append(out, entry{
|
||||||
|
Icon: iconUp,
|
||||||
|
Name: "..",
|
||||||
|
URL: parentURL(urlPath),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out = append(out, folders...)
|
||||||
|
out = append(out, files...)
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func readIcon(name string) template.HTML {
|
func readIcon(name string) template.HTML {
|
||||||
@@ -192,21 +205,6 @@ func formatSize(b int64) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildCrumbs(urlPath string) []crumb {
|
|
||||||
if urlPath == "/" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
|
||||||
crumbs := make([]crumb, len(parts))
|
|
||||||
for i, p := range parts {
|
|
||||||
crumbs[i] = crumb{
|
|
||||||
Name: p,
|
|
||||||
URL: "/" + strings.Join(parts[:i+1], "/") + "/",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return crumbs
|
|
||||||
}
|
|
||||||
|
|
||||||
func pageTitle(urlPath string) string {
|
func pageTitle(urlPath string) string {
|
||||||
if urlPath == "/" {
|
if urlPath == "/" {
|
||||||
return "Datascape"
|
return "Datascape"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -21,7 +22,7 @@ type searchResult struct {
|
|||||||
|
|
||||||
type searchPageData struct {
|
type searchPageData struct {
|
||||||
Title string
|
Title string
|
||||||
Crumbs []crumb
|
ParentURL string
|
||||||
EditMode bool
|
EditMode bool
|
||||||
Query string
|
Query string
|
||||||
Results []searchResult
|
Results []searchResult
|
||||||
@@ -65,7 +66,7 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
data := searchPageData{
|
data := searchPageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
Crumbs: []crumb{{Name: "search", URL: "/?q=" + query}},
|
ParentURL: "/",
|
||||||
Query: query,
|
Query: query,
|
||||||
Results: results,
|
Results: results,
|
||||||
IndexBuiltAt: builtAt,
|
IndexBuiltAt: builtAt,
|
||||||
@@ -159,6 +160,47 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
|
|||||||
return score
|
return score
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleSearchSuggest serves the JSON typeahead for the header dropdown and
|
||||||
|
// the editor's link picker. Caps results at 5; reports total so the UI can
|
||||||
|
// surface a "show all" footer when more matches exist. Empty/whitespace query
|
||||||
|
// is a no-op (200 with empty results), not a 400 — every keystroke fires this.
|
||||||
|
func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.checkAuth(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
type suggestResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
type suggestResp struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Results []suggestResult `json:"results"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
resp := suggestResp{Query: query, Results: []suggestResult{}}
|
||||||
|
if query != "" {
|
||||||
|
all, _ := searchWiki(query)
|
||||||
|
resp.Total = len(all)
|
||||||
|
limit := 5
|
||||||
|
if len(all) < limit {
|
||||||
|
limit = len(all)
|
||||||
|
}
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
resp.Results = append(resp.Results, suggestResult{
|
||||||
|
Name: all[i].Name,
|
||||||
|
Path: all[i].Path,
|
||||||
|
URL: all[i].URL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||||
|
log.Printf("search suggest encode error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleReindex rebuilds the folder index synchronously and returns 204.
|
// handleReindex rebuilds the folder index synchronously and returns 204.
|
||||||
// The frontend reloads the page on success. Serialized via buildMu so a
|
// The frontend reloads the page on success. Serialized via buildMu so a
|
||||||
// double-click waits rather than running two walks in parallel.
|
// double-click waits rather than running two walks in parallel.
|
||||||
|
|||||||
Reference in New Issue
Block a user