From a25d5434ac6f226733c21f8e1ed03c31b7781935 Mon Sep 17 00:00:00 2001 From: luxick Date: Mon, 18 May 2026 15:10:34 +0200 Subject: [PATCH] Improve serach function with quick suggestions --- assets/editor/main.js | 86 +++++++---- assets/icons/up.svg | 4 + assets/layout.html | 9 +- assets/search-suggest.js | 313 +++++++++++++++++++++++++++++++++++++++ assets/style.css | 107 ++++++++++++- main.go | 7 +- render.go | 34 ++--- search.go | 46 +++++- 8 files changed, 545 insertions(+), 61 deletions(-) create mode 100644 assets/icons/up.svg create mode 100644 assets/search-suggest.js diff --git a/assets/editor/main.js b/assets/editor/main.js index 91b17ca..4a8dacc 100644 --- a/assets/editor/main.js +++ b/assets/editor/main.js @@ -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 --- diff --git a/assets/icons/up.svg b/assets/icons/up.svg new file mode 100644 index 0000000..f96c927 --- /dev/null +++ b/assets/icons/up.svg @@ -0,0 +1,4 @@ + + + diff --git a/assets/layout.html b/assets/layout.html index df1d4c6..5a3a8c2 100644 --- a/assets/layout.html +++ b/assets/layout.html @@ -10,6 +10,7 @@ + {{block "headScripts" .}}{{end}} @@ -17,17 +18,15 @@
{{if not .EditMode}}
{{end}} - {{block "headerActions" .}}{{end}} +
{{block "headerActions" .}}{{end}}
diff --git a/assets/search-suggest.js b/assets/search-suggest.js new file mode 100644 index 0000000..e87acb4 --- /dev/null +++ b/assets/search-suggest.js @@ -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, '"'); + } + + 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 += '' + escapeHTML(name.slice(sp[0], sp[1])) + ''; + 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; }, + }); + }); +})(); diff --git a/assets/style.css b/assets/style.css index 4096f3c..58d7a88 100644 --- a/assets/style.css +++ b/assets/style.css @@ -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 { diff --git a/main.go b/main.go index c219d61..e8ddaee 100644 --- a/main.go +++ b/main.go @@ -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 == "/", diff --git a/render.go b/render.go index 1901b02..730b207 100644 --- a/render.go +++ b/render.go @@ -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" diff --git a/search.go b/search.go index 49b7d5c..e4d58d0 100644 --- a/search.go +++ b/search.go @@ -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.