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}}
+
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.