Improve serach function with quick suggestions
This commit is contained in:
+57
-29
@@ -64,43 +64,71 @@
|
||||
if (result) applyResult(result);
|
||||
}
|
||||
|
||||
function promptDisplayText(initial, onDone) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'modal-input';
|
||||
input.placeholder = 'Display text (optional)';
|
||||
if (initial) input.value = initial;
|
||||
var handle = openModal({
|
||||
title: 'Insert link — display text?',
|
||||
body: input,
|
||||
confirm: {
|
||||
label: 'INSERT',
|
||||
onConfirm: function () {
|
||||
handle.close();
|
||||
onDone(input.value.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
// isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
|
||||
// path, no empty/dot segments. Used to gate the INSERT confirm button.
|
||||
function isValidWikiTarget(p) {
|
||||
if (!p || p[0] !== '/') return false;
|
||||
var trimmed = p.replace(/^\/+|\/+$/g, '');
|
||||
if (trimmed === '') return true;
|
||||
var segs = trimmed.split('/');
|
||||
for (var i = 0; i < segs.length; i++) {
|
||||
if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function insertWikilink() {
|
||||
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',
|
||||
mode: 'any',
|
||||
initialPath: '/',
|
||||
confirmLabel: 'NEXT',
|
||||
onSelect: function (path, kind) {
|
||||
if (kind === 'folder') {
|
||||
promptDisplayText(sel, function (display) {
|
||||
insertAtCursor(display ? '[[' + path + '::' + display + ']]' : '[[' + path + ']]');
|
||||
});
|
||||
} else {
|
||||
var name = path.split('/').pop();
|
||||
insertAtCursor('[' + (sel || name) + '](' + path + ')');
|
||||
body: container,
|
||||
confirm: {
|
||||
label: 'INSERT',
|
||||
initiallyDisabled: true,
|
||||
onConfirm: function () {
|
||||
var target = targetInput.value.trim();
|
||||
if (!isValidWikiTarget(target)) return;
|
||||
var display = displayInput.value.trim();
|
||||
handle.close();
|
||||
insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 ---
|
||||
|
||||
@@ -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" />
|
||||
<script src="/_/modal.js"></script>
|
||||
<script src="/_/global-shortcuts.js"></script>
|
||||
<script src="/_/search-suggest.js" defer></script>
|
||||
<script src="/_/tree-picker.js"></script>
|
||||
<script src="/_/companion.js" defer></script>
|
||||
{{block "headScripts" .}}{{end}}
|
||||
@@ -17,17 +18,15 @@
|
||||
<body>
|
||||
<header>
|
||||
<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>
|
||||
{{range .Crumbs}}
|
||||
<span class="sep">/</span><a href="{{.URL}}" tabindex="-1">{{.Name}}</a>
|
||||
{{end}}
|
||||
<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>
|
||||
{{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>
|
||||
{{if not .EditMode}}
|
||||
<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" />
|
||||
</form>
|
||||
{{end}}
|
||||
{{block "headerActions" .}}{{end}}
|
||||
<div class="header-actions">{{block "headerActions" .}}{{end}}</div>
|
||||
</header>
|
||||
<div class="page-wrap">
|
||||
<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);
|
||||
}
|
||||
|
||||
/* === 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 {
|
||||
padding: 0.75rem 1rem;
|
||||
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;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
grid-area: crumbs;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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: 1;
|
||||
}
|
||||
|
||||
.logo {
|
||||
@@ -96,8 +110,14 @@ header {
|
||||
vertical-align: center;
|
||||
}
|
||||
|
||||
.sep {
|
||||
.nav-up {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--secondary);
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.nav-up:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -429,8 +449,13 @@ textarea {
|
||||
|
||||
/* === Search === */
|
||||
.search-form {
|
||||
grid-area: search;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
position: relative;
|
||||
justify-self: center;
|
||||
width: 24rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
.search-input {
|
||||
background: var(--bg-panel);
|
||||
@@ -440,13 +465,73 @@ textarea {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
min-width: 0;
|
||||
width: 12rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.search-input:focus {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -880,6 +965,16 @@ aside.sidebar:empty {
|
||||
.page-wrap {
|
||||
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
|
||||
itself is the scroll container; its children render at natural height. */
|
||||
.sidebar {
|
||||
|
||||
@@ -98,6 +98,7 @@ func main() {
|
||||
}))
|
||||
http.HandleFunc("/_logout", h.handleLogout)
|
||||
http.HandleFunc("/_reindex", h.handleReindex)
|
||||
http.HandleFunc("/_search", h.handleSearchSuggest)
|
||||
http.HandleFunc("/quickadd", h.handleQuickAdd)
|
||||
http.Handle("/", h)
|
||||
|
||||
@@ -284,9 +285,13 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
rawContent = "# " + heading + "\n\n"
|
||||
}
|
||||
|
||||
parent := ""
|
||||
if urlPath != "/" {
|
||||
parent = parentURL(urlPath)
|
||||
}
|
||||
data := pageData{
|
||||
Title: title,
|
||||
Crumbs: buildCrumbs(urlPath),
|
||||
ParentURL: parent,
|
||||
CanEdit: true,
|
||||
EditMode: editMode,
|
||||
IsRoot: urlPath == "/",
|
||||
|
||||
@@ -28,7 +28,6 @@ func initMarkdown(root string) {
|
||||
)
|
||||
}
|
||||
|
||||
type crumb struct{ Name, URL string }
|
||||
type entry struct {
|
||||
Icon template.HTML
|
||||
Name, URL, Meta string
|
||||
@@ -36,7 +35,7 @@ type entry struct {
|
||||
|
||||
type pageData struct {
|
||||
Title string
|
||||
Crumbs []crumb
|
||||
ParentURL string
|
||||
CanEdit bool
|
||||
EditMode bool
|
||||
IsRoot bool
|
||||
@@ -56,6 +55,7 @@ type pageSettings struct {
|
||||
}
|
||||
|
||||
var (
|
||||
iconUp = readIcon("up")
|
||||
iconFolder = readIcon("folder")
|
||||
iconDoc = readIcon("doc")
|
||||
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 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 {
|
||||
@@ -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 {
|
||||
if urlPath == "/" {
|
||||
return "Datascape"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -21,7 +22,7 @@ type searchResult struct {
|
||||
|
||||
type searchPageData struct {
|
||||
Title string
|
||||
Crumbs []crumb
|
||||
ParentURL string
|
||||
EditMode bool
|
||||
Query string
|
||||
Results []searchResult
|
||||
@@ -65,7 +66,7 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
data := searchPageData{
|
||||
Title: title,
|
||||
Crumbs: []crumb{{Name: "search", URL: "/?q=" + query}},
|
||||
ParentURL: "/",
|
||||
Query: query,
|
||||
Results: results,
|
||||
IndexBuiltAt: builtAt,
|
||||
@@ -159,6 +160,47 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
|
||||
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.
|
||||
// The frontend reloads the page on success. Serialized via buildMu so a
|
||||
// double-click waits rather than running two walks in parallel.
|
||||
|
||||
Reference in New Issue
Block a user