2026-03-13 00:18:14 +00:00
|
|
|
|
{% extends "base.html" %}
|
|
|
|
|
|
|
|
|
|
|
|
{% block title %}Search — Ontoref{% endblock title %}
|
|
|
|
|
|
{% block nav_search %}active{% endblock nav_search %}
|
feat: config surface, NCL contracts, override-layer mutation, on+re update
Config surface — per-project config introspection, coherence verification, and
audited mutation without destroying NCL structure (ADR-008):
- crates/ontoref-daemon/src/config.rs — typed DaemonNclConfig (parse-at-boundary
pattern); all section structs derive ConfigFields + config_section(id, ncl_file)
emitting inventory::submit!(ConfigFieldsEntry{...}) at link time
- crates/ontoref-derive/src/lib.rs — #[derive(ConfigFields)] proc-macro; serde
rename support; serde_rename_of() helper extracted to fix excessive_nesting
- crates/ontoref-daemon/src/main.rs — 3-tuple bootstrap block (nickel_import_path,
loaded_ncl_config: Option<DaemonNclConfig>, stdin_raw); apply_ui_config takes
&UiConfig; NATS call site typed; resolve_asset_dir cfg(feature = "ui")
- crates/ontoref-daemon/src/api.rs — config GET/PUT endpoints, quickref, coherence,
cross-project comparison; index_section_fields() extracted (excessive_nesting)
- crates/ontoref-daemon/src/config_coherence.rs — multi-consumer coherence;
merge_meta_into_section() extracted; and() replaces unnecessary and_then
NCL contracts for ontoref's own config:
- .ontoref/contracts.ncl — LogConfig (LogLevel, LogRotation, PositiveInt) and
DaemonConfig (Port, optional overrides); std.contract.from_validator throughout
- .ontoref/config.ncl — log | C.LogConfig applied
- .ontology/manifest.ncl — contracts_path, log/daemon contract refs, daemon section
with DaemonRuntimeConfig consumer and 7 declared fields
Protocol:
- adrs/adr-008-ncl-first-config-validation-and-override-layer.ncl — NCL contracts
as single validation gate; Rust structs are contract-trusted; override-layer
mutation writes {section}.overrides.ncl + _overrides_meta, never touches source
on+re update:
- .ontology/core.ncl — config-surface node (28 practices); adr-lifecycle extended
to adr-007 + adr-008; 6 new edges (ManifestsIn daemon, DependsOn ontology-crate,
Complements api-catalog-surface/dag-formalized/self-describing/adopt-ontoref)
- .ontology/state.ncl — protocol-maturity blocker and self-description-coverage
catalyst updated for session 2026-03-26
- README.md / CHANGELOG.md updated
2026-03-26 20:20:22 +00:00
|
|
|
|
{% block nav_group_knowledge %}active{% endblock nav_group_knowledge %}
|
2026-03-13 00:18:14 +00:00
|
|
|
|
|
|
|
|
|
|
{% block content %}
|
|
|
|
|
|
<!-- Hidden context for JS -->
|
|
|
|
|
|
<input type="hidden" id="search-slug" value="{% if slug %}{{ slug }}{% endif %}">
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Page header: tab switcher -->
|
|
|
|
|
|
<div class="mb-4 flex items-center gap-1.5">
|
|
|
|
|
|
<button id="tab-search"
|
|
|
|
|
|
class="btn btn-sm btn-primary gap-1.5"
|
|
|
|
|
|
title="Search ontology nodes, ADRs and modes">
|
|
|
|
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
|
|
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<span class="nav-label">Search</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button id="tab-bookmarks"
|
|
|
|
|
|
class="btn btn-sm btn-ghost gap-1.5"
|
|
|
|
|
|
title="Saved bookmarks">
|
|
|
|
|
|
<svg class="nav-icon w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
|
|
|
|
d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<span class="nav-label">Bookmarks</span>
|
|
|
|
|
|
<span id="bm-count" class="badge badge-xs badge-ghost hidden"></span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div class="flex-1"></div>
|
|
|
|
|
|
<button id="btn-reset-search" class="btn btn-xs btn-ghost">Clear</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex gap-0 h-[calc(100vh-196px)] min-h-[400px]">
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Left: tab content (search or bookmarks) -->
|
|
|
|
|
|
<div class="flex flex-col bg-base-200 rounded-l-lg" style="flex:0 0 320px;min-width:200px">
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ── Search pane ── -->
|
|
|
|
|
|
<div id="search-pane" class="flex flex-col flex-1 overflow-hidden">
|
|
|
|
|
|
<div class="p-3 border-b border-base-content/10">
|
|
|
|
|
|
<div class="relative">
|
|
|
|
|
|
<input id="search-input" type="search" placeholder="Search nodes, ADRs, modes…"
|
|
|
|
|
|
class="input input-bordered input-sm w-full pr-8 font-mono"
|
|
|
|
|
|
autocomplete="off" autocorrect="off" spellcheck="false">
|
|
|
|
|
|
<svg class="absolute right-2.5 top-2 w-4 h-4 text-base-content/30 pointer-events-none"
|
|
|
|
|
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
|
|
|
|
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div id="results-count" class="px-3 py-1 text-xs text-base-content/40 border-b border-base-content/10 hidden"></div>
|
|
|
|
|
|
<ul id="results-list" class="flex-1 overflow-y-auto divide-y divide-base-content/10 text-sm"></ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- ── Bookmarks pane ── -->
|
|
|
|
|
|
<div id="bookmarks-pane" class="flex flex-col flex-1 overflow-hidden hidden">
|
|
|
|
|
|
<div class="flex items-center justify-between px-3 py-2 border-b border-base-content/10">
|
|
|
|
|
|
<span class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
|
|
|
|
|
Saved bookmarks
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button id="btn-clear-bookmarks"
|
|
|
|
|
|
class="btn btn-ghost btn-xs text-base-content/30 hover:text-error">
|
|
|
|
|
|
clear all
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ul id="bookmarks-list" class="flex-1 overflow-y-auto divide-y divide-base-content/10 text-sm"></ul>
|
|
|
|
|
|
<div id="bookmarks-empty" class="flex-1 flex items-center justify-center text-base-content/25 text-xs hidden">
|
|
|
|
|
|
No bookmarks yet — star a result to save it
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Resize handle -->
|
|
|
|
|
|
<div id="search-resize" class="w-1.5 bg-base-300 hover:bg-primary/40 cursor-col-resize transition-colors flex-shrink-0"></div>
|
|
|
|
|
|
|
2026-03-29 08:32:50 +01:00
|
|
|
|
<!-- Right: detail panel with nav bar -->
|
|
|
|
|
|
<div id="detail-panel" class="flex-1 bg-base-200 rounded-r-lg overflow-hidden flex flex-col hidden min-w-[200px]">
|
|
|
|
|
|
<div class="flex items-center gap-1 px-3 py-2 border-b border-base-content/10 flex-shrink-0">
|
|
|
|
|
|
<button id="dp-back" class="btn btn-xs btn-ghost px-2" title="Back" disabled>‹</button>
|
|
|
|
|
|
<button id="dp-forward" class="btn btn-xs btn-ghost px-2" title="Forward" disabled>›</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div id="dp-content" class="flex-1 overflow-y-auto p-5">
|
|
|
|
|
|
<!-- filled by JS -->
|
|
|
|
|
|
</div>
|
2026-03-13 00:18:14 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Empty right side when nothing selected -->
|
|
|
|
|
|
<div id="detail-empty" class="flex-1 bg-base-200 rounded-r-lg flex items-center justify-center text-base-content/25 text-sm">
|
|
|
|
|
|
<div class="text-center">
|
|
|
|
|
|
<svg class="w-10 h-10 mx-auto mb-3 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
|
|
|
|
|
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
Type to search, click a result to view
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{% endblock content %}
|
|
|
|
|
|
|
|
|
|
|
|
{% block scripts %}
|
|
|
|
|
|
<script>
|
|
|
|
|
|
const slugInput = document.getElementById('search-slug');
|
|
|
|
|
|
const input = document.getElementById('search-input');
|
|
|
|
|
|
const resultsList = document.getElementById('results-list');
|
|
|
|
|
|
const resultsCount = document.getElementById('results-count');
|
|
|
|
|
|
const detail = document.getElementById('detail-panel');
|
2026-03-29 08:32:50 +01:00
|
|
|
|
const dpContent = document.getElementById('dp-content');
|
2026-03-13 00:18:14 +00:00
|
|
|
|
const detailEmpty = document.getElementById('detail-empty');
|
|
|
|
|
|
const resetBtn = document.getElementById('btn-reset-search');
|
|
|
|
|
|
const resizeHandle = document.getElementById('search-resize');
|
2026-03-29 08:32:50 +01:00
|
|
|
|
const dpBack = document.getElementById('dp-back');
|
|
|
|
|
|
const dpForward = document.getElementById('dp-forward');
|
2026-03-13 00:18:14 +00:00
|
|
|
|
|
|
|
|
|
|
const LEFT_PANEL = document.querySelector('#search-pane').closest('div.flex-col');
|
|
|
|
|
|
const CONTAINER = LEFT_PANEL.parentElement;
|
|
|
|
|
|
|
2026-03-29 08:32:50 +01:00
|
|
|
|
// ── Panel navigation (back/forward) ────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const dpNav = {
|
|
|
|
|
|
stack: [],
|
|
|
|
|
|
cursor: -1,
|
|
|
|
|
|
push(entry) {
|
|
|
|
|
|
this.stack = this.stack.slice(0, this.cursor + 1);
|
|
|
|
|
|
this.stack.push(entry);
|
|
|
|
|
|
this.cursor = this.stack.length - 1;
|
|
|
|
|
|
this._sync();
|
|
|
|
|
|
},
|
|
|
|
|
|
back() {
|
|
|
|
|
|
if (this.cursor <= 0) return;
|
|
|
|
|
|
this.cursor--;
|
|
|
|
|
|
this._replay();
|
|
|
|
|
|
this._sync();
|
|
|
|
|
|
},
|
|
|
|
|
|
forward() {
|
|
|
|
|
|
if (this.cursor >= this.stack.length - 1) return;
|
|
|
|
|
|
this.cursor++;
|
|
|
|
|
|
this._replay();
|
|
|
|
|
|
this._sync();
|
|
|
|
|
|
},
|
|
|
|
|
|
reset() { this.stack = []; this.cursor = -1; this._sync(); },
|
|
|
|
|
|
_replay() {
|
|
|
|
|
|
const e = this.stack[this.cursor];
|
|
|
|
|
|
if (e.type === 'result') _showResultContent(e.r, false);
|
|
|
|
|
|
else if (e.type === 'file') srcOpen(e.path);
|
|
|
|
|
|
else if (e.type === 'adr') openAdrInPanel(e.id, e.title, false);
|
|
|
|
|
|
},
|
|
|
|
|
|
_sync() {
|
|
|
|
|
|
dpBack.disabled = this.cursor <= 0;
|
|
|
|
|
|
dpForward.disabled = this.cursor >= this.stack.length - 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
dpBack.addEventListener('click', () => dpNav.back());
|
|
|
|
|
|
dpForward.addEventListener('click', () => dpNav.forward());
|
|
|
|
|
|
|
|
|
|
|
|
function showPanel() {
|
|
|
|
|
|
detail.classList.remove('hidden');
|
|
|
|
|
|
detailEmpty.classList.add('hidden');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hidePanel() {
|
|
|
|
|
|
detail.classList.add('hidden');
|
|
|
|
|
|
detailEmpty.classList.remove('hidden');
|
|
|
|
|
|
dpNav.reset();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── ADR inline loader ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
async function openAdrInPanel(id, title = '', push = true) {
|
|
|
|
|
|
showPanel();
|
|
|
|
|
|
dpContent.innerHTML = '<span class="loading loading-spinner loading-sm"></span>';
|
|
|
|
|
|
const params = new URLSearchParams({});
|
|
|
|
|
|
if (SLUG) params.set('slug', SLUG);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/api/adr/${encodeURIComponent(id)}?${params}`);
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (data.error) {
|
|
|
|
|
|
dpContent.innerHTML = `<p class="text-error text-sm">${esc(data.error)}</p>`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const entries = Object.entries(data)
|
|
|
|
|
|
.filter(([k, v]) => k !== 'id' && v !== '' && v !== null && v !== undefined)
|
|
|
|
|
|
.map(([k, v]) => {
|
|
|
|
|
|
const label = k.replace(/_/g, ' ');
|
|
|
|
|
|
let val;
|
|
|
|
|
|
if (Array.isArray(v)) {
|
|
|
|
|
|
if (v.length === 0) return null;
|
|
|
|
|
|
val = `<ul class="list-disc pl-4 space-y-0.5 text-base-content/70 text-xs">${
|
|
|
|
|
|
v.map(item => typeof item === 'object'
|
|
|
|
|
|
? `<li><pre class="text-xs whitespace-pre-wrap bg-base-300 p-1.5 rounded">${esc(JSON.stringify(item, null, 2))}</pre></li>`
|
|
|
|
|
|
: `<li>${esc(String(item))}</li>`
|
|
|
|
|
|
).join('')}</ul>`;
|
|
|
|
|
|
} else if (typeof v === 'object') {
|
|
|
|
|
|
val = `<pre class="text-xs whitespace-pre-wrap bg-base-300 p-2 rounded">${esc(JSON.stringify(v, null, 2))}</pre>`;
|
|
|
|
|
|
} else if (typeof v === 'boolean') {
|
|
|
|
|
|
val = `<span class="badge badge-xs ${v ? 'badge-success' : 'badge-ghost'}">${v}</span>`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
val = `<span class="text-base-content/80 text-xs">${esc(String(v))}</span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `<div class="mb-3"><p class="text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-0.5">${esc(label)}</p>${val}</div>`;
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join('');
|
|
|
|
|
|
dpContent.innerHTML = entries || '<p class="text-base-content/50 text-sm">No details.</p>';
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
dpContent.innerHTML = `<p class="text-error text-sm">Failed: ${esc(String(err))}</p>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (push) dpNav.push({ type: 'adr', id, title });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Click delegation inside detail panel ─────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('detail-panel').addEventListener('click', e => {
|
|
|
|
|
|
const fileBtn = e.target.closest('.s-file-link');
|
|
|
|
|
|
if (fileBtn) { srcOpen(fileBtn.dataset.path); return; }
|
|
|
|
|
|
|
|
|
|
|
|
const adrBtn = e.target.closest('.s-adr-link');
|
|
|
|
|
|
if (adrBtn) { openAdrInPanel(adrBtn.dataset.adr, adrBtn.dataset.adr); }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const BASE_URL = "{{ base_url }}";
|
|
|
|
|
|
const CARD_REPO = "{{ card_repo | default(value='') | safe }}";
|
|
|
|
|
|
const CARD_DOCS = "{{ card_docs | default(value='') | safe }}";
|
|
|
|
|
|
|
|
|
|
|
|
function srcOpen(path) {
|
|
|
|
|
|
if (!path) return;
|
|
|
|
|
|
const ext = path.split('.').pop().toLowerCase();
|
|
|
|
|
|
if (ext === 'rs' && CARD_DOCS) {
|
|
|
|
|
|
window.open(CARD_DOCS.replace(/\/$/, ''), '_blank', 'noopener');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (CARD_REPO) {
|
|
|
|
|
|
window.open(`${CARD_REPO.replace(/\/$/, '')}/src/branch/main/${path}`, '_blank', 'noopener');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-16 01:48:17 +00:00
|
|
|
|
|
|
|
|
|
|
let results = [];
|
2026-03-13 00:18:14 +00:00
|
|
|
|
let searchTimer = null;
|
|
|
|
|
|
let selectedItem = null;
|
|
|
|
|
|
|
|
|
|
|
|
// ── Tab switching ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-03-16 01:48:17 +00:00
|
|
|
|
const tabSearch = document.getElementById('tab-search');
|
|
|
|
|
|
const tabBm = document.getElementById('tab-bookmarks');
|
|
|
|
|
|
const searchPane = document.getElementById('search-pane');
|
2026-03-13 00:18:14 +00:00
|
|
|
|
const bookmarksPane = document.getElementById('bookmarks-pane');
|
2026-03-16 01:48:17 +00:00
|
|
|
|
const TAB_KEY = 'ontoref-search-tab';
|
2026-03-13 00:18:14 +00:00
|
|
|
|
|
|
|
|
|
|
function setTab(tab) {
|
|
|
|
|
|
const isBm = tab === 'bookmarks';
|
|
|
|
|
|
searchPane.classList.toggle('hidden', isBm);
|
|
|
|
|
|
bookmarksPane.classList.toggle('hidden', !isBm);
|
|
|
|
|
|
tabSearch.className = isBm ? 'btn btn-sm btn-ghost gap-1.5' : 'btn btn-sm btn-primary gap-1.5';
|
|
|
|
|
|
tabBm.className = isBm ? 'btn btn-sm btn-primary gap-1.5' : 'btn btn-sm btn-ghost gap-1.5';
|
|
|
|
|
|
resetBtn.classList.toggle('hidden', isBm);
|
|
|
|
|
|
try { localStorage.setItem(TAB_KEY, tab); } catch (_) {}
|
|
|
|
|
|
if (isBm) renderBookmarks();
|
2026-03-16 01:48:17 +00:00
|
|
|
|
else input.focus();
|
2026-03-13 00:18:14 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tabSearch.addEventListener('click', () => setTab('search'));
|
|
|
|
|
|
tabBm.addEventListener('click', () => setTab('bookmarks'));
|
|
|
|
|
|
|
2026-03-16 01:48:17 +00:00
|
|
|
|
// ── Bookmarks — server-backed ──────────────────────────────────────────────
|
|
|
|
|
|
//
|
|
|
|
|
|
// `bookmarks` is a Map<node_id, entry> kept in memory.
|
|
|
|
|
|
// Initialised from server-hydrated data; mutations go to HTTP endpoints.
|
|
|
|
|
|
// The NCL sb-NNN id is stored in entry.id so deletes don't need a lookup.
|
2026-03-13 00:18:14 +00:00
|
|
|
|
|
|
|
|
|
|
const bmList = document.getElementById('bookmarks-list');
|
|
|
|
|
|
const bmCount = document.getElementById('bm-count');
|
|
|
|
|
|
const bmEmpty = document.getElementById('bookmarks-empty');
|
|
|
|
|
|
const bmClearBtn = document.getElementById('btn-clear-bookmarks');
|
2026-03-16 01:48:17 +00:00
|
|
|
|
const SLUG = slugInput.value || null;
|
2026-03-13 00:18:14 +00:00
|
|
|
|
|
2026-03-16 01:48:17 +00:00
|
|
|
|
// Hydrate from server — array injected by Tera at render time.
|
|
|
|
|
|
const SERVER_BOOKMARKS = {{ server_bookmarks | json_encode | safe }};
|
|
|
|
|
|
const bookmarks = new Map(); // node_id → { id, node_id, kind, title, level, term, ... }
|
|
|
|
|
|
for (const b of SERVER_BOOKMARKS) {
|
|
|
|
|
|
bookmarks.set(b.node_id, b);
|
2026-03-13 00:18:14 +00:00
|
|
|
|
}
|
2026-03-16 01:48:17 +00:00
|
|
|
|
|
|
|
|
|
|
function isBookmarked(r) { return bookmarks.has(r.id); }
|
|
|
|
|
|
|
|
|
|
|
|
async function toggleBookmark(r) {
|
|
|
|
|
|
if (isBookmarked(r)) {
|
|
|
|
|
|
const entry = bookmarks.get(r.id);
|
|
|
|
|
|
const url = `${BASE_URL}/search/bookmark/delete`;
|
|
|
|
|
|
const body = { id: entry.id };
|
|
|
|
|
|
if (SLUG) body.slug = SLUG;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(url, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (res.ok) bookmarks.delete(r.id);
|
|
|
|
|
|
} catch (_) {}
|
2026-03-13 00:18:14 +00:00
|
|
|
|
} else {
|
2026-03-16 01:48:17 +00:00
|
|
|
|
const url = `${BASE_URL}/search/bookmark/add`;
|
|
|
|
|
|
const body = {
|
|
|
|
|
|
node_id: r.id,
|
|
|
|
|
|
kind: r.kind || 'node',
|
|
|
|
|
|
title: r.title || r.id,
|
|
|
|
|
|
level: r.level || '',
|
|
|
|
|
|
term: input.value.trim(),
|
|
|
|
|
|
actor: 'human',
|
|
|
|
|
|
};
|
|
|
|
|
|
if (SLUG) body.slug = SLUG;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(url, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
bookmarks.set(r.id, {
|
|
|
|
|
|
id: data.id, // sb-NNN — needed for delete
|
|
|
|
|
|
node_id: r.id,
|
|
|
|
|
|
kind: r.kind || 'node',
|
|
|
|
|
|
title: r.title || r.id,
|
|
|
|
|
|
level: r.level || '',
|
|
|
|
|
|
term: input.value.trim(),
|
|
|
|
|
|
created_at: data.created_at || '',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {}
|
2026-03-13 00:18:14 +00:00
|
|
|
|
}
|
|
|
|
|
|
renderBookmarks();
|
|
|
|
|
|
renderResults();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderBookmarks() {
|
2026-03-16 01:48:17 +00:00
|
|
|
|
const bms = [...bookmarks.values()];
|
|
|
|
|
|
|
2026-03-13 00:18:14 +00:00
|
|
|
|
if (bms.length > 0) {
|
|
|
|
|
|
bmCount.textContent = bms.length;
|
|
|
|
|
|
bmCount.classList.remove('hidden');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
bmCount.classList.add('hidden');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (bms.length === 0) {
|
|
|
|
|
|
bmList.innerHTML = '';
|
|
|
|
|
|
if (bmEmpty) bmEmpty.classList.remove('hidden');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (bmEmpty) bmEmpty.classList.add('hidden');
|
|
|
|
|
|
|
|
|
|
|
|
bmList.innerHTML = bms.map(b => `
|
2026-03-16 01:48:17 +00:00
|
|
|
|
<li class="bm-item cursor-pointer hover:bg-base-300 transition-colors" data-nid="${esc(b.node_id)}">
|
2026-03-13 00:18:14 +00:00
|
|
|
|
<div class="px-3 py-2 flex items-center gap-2">
|
2026-03-16 01:48:17 +00:00
|
|
|
|
<span class="badge badge-xs ${kindCls(b.kind)} flex-shrink-0">${esc(b.kind)}</span>
|
2026-03-13 00:18:14 +00:00
|
|
|
|
<span class="text-xs font-medium truncate flex-1">${esc(b.title)}</span>
|
2026-03-29 08:32:50 +01:00
|
|
|
|
<button class="btn-unbm btn btn-ghost btn-xs flex-shrink-0 opacity-40 hover:opacity-100 hover:text-error"
|
2026-03-16 01:48:17 +00:00
|
|
|
|
data-nid="${esc(b.node_id)}" title="Remove">
|
2026-03-13 00:18:14 +00:00
|
|
|
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
`).join('');
|
|
|
|
|
|
|
|
|
|
|
|
bmList.querySelectorAll('.bm-item').forEach(el => {
|
|
|
|
|
|
el.addEventListener('click', e => {
|
|
|
|
|
|
if (e.target.closest('.btn-unbm')) return;
|
2026-03-16 01:48:17 +00:00
|
|
|
|
const bm = bookmarks.get(el.dataset.nid);
|
2026-03-13 00:18:14 +00:00
|
|
|
|
if (!bm) return;
|
|
|
|
|
|
if (selectedItem) selectedItem.classList.remove('bg-base-200');
|
|
|
|
|
|
selectedItem = null;
|
|
|
|
|
|
showDetailBm(bm);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
bmList.querySelectorAll('.btn-unbm').forEach(el => {
|
2026-03-16 01:48:17 +00:00
|
|
|
|
el.addEventListener('click', async e => {
|
2026-03-13 00:18:14 +00:00
|
|
|
|
e.stopPropagation();
|
2026-03-16 01:48:17 +00:00
|
|
|
|
const nid = el.dataset.nid;
|
|
|
|
|
|
const bm = bookmarks.get(nid);
|
|
|
|
|
|
if (!bm) return;
|
|
|
|
|
|
const url = `${BASE_URL}/search/bookmark/delete`;
|
|
|
|
|
|
const body = { id: bm.id };
|
|
|
|
|
|
if (SLUG) body.slug = SLUG;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(url, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (res.ok) bookmarks.delete(nid);
|
|
|
|
|
|
} catch (_) {}
|
2026-03-13 00:18:14 +00:00
|
|
|
|
renderBookmarks();
|
|
|
|
|
|
renderResults();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showDetailBm(bm) {
|
2026-03-29 08:32:50 +01:00
|
|
|
|
showPanel();
|
|
|
|
|
|
dpContent.innerHTML = `
|
2026-03-13 00:18:14 +00:00
|
|
|
|
<div class="flex items-start justify-between mb-3">
|
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
|
<h2 class="font-bold text-base leading-tight">${esc(bm.title)}</h2>
|
|
|
|
|
|
<div class="flex flex-wrap gap-1 mt-1.5">
|
2026-03-16 01:48:17 +00:00
|
|
|
|
<span class="badge badge-xs ${kindCls(bm.kind)}">${esc(bm.kind)}</span>
|
2026-03-13 00:18:14 +00:00
|
|
|
|
${bm.level ? `<span class="badge badge-xs badge-ghost">${esc(bm.level)}</span>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-29 08:32:50 +01:00
|
|
|
|
<button id="bm-detail-star" class="btn btn-ghost btn-xs text-warning" title="Remove bookmark">
|
2026-03-13 00:18:14 +00:00
|
|
|
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
|
<path d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-03-16 01:48:17 +00:00
|
|
|
|
${bm.term ? `<p class="text-xs text-base-content/35 mb-2">Search term: <span class="font-mono">${esc(bm.term)}</span></p>` : ''}
|
|
|
|
|
|
<p class="text-xs font-mono text-base-content/30">id: ${esc(bm.node_id)}</p>
|
|
|
|
|
|
${bm.created_at ? `<p class="text-xs text-base-content/25 mt-1">${esc(bm.created_at)}</p>` : ''}
|
2026-03-13 00:18:14 +00:00
|
|
|
|
`;
|
2026-03-16 01:48:17 +00:00
|
|
|
|
document.getElementById('bm-detail-star').addEventListener('click', async () => {
|
|
|
|
|
|
await toggleBookmark({ id: bm.node_id, kind: bm.kind, title: bm.title, level: bm.level });
|
2026-03-29 08:32:50 +01:00
|
|
|
|
hidePanel();
|
2026-03-16 01:48:17 +00:00
|
|
|
|
});
|
2026-03-13 00:18:14 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 01:48:17 +00:00
|
|
|
|
bmClearBtn.addEventListener('click', async () => {
|
|
|
|
|
|
const ids = [...bookmarks.values()].map(b => b.id);
|
|
|
|
|
|
const url = `${BASE_URL}/search/bookmark/delete`;
|
|
|
|
|
|
await Promise.all(ids.map(id => {
|
|
|
|
|
|
const body = { id };
|
|
|
|
|
|
if (SLUG) body.slug = SLUG;
|
|
|
|
|
|
return fetch(url, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
|
}));
|
|
|
|
|
|
bookmarks.clear();
|
2026-03-13 00:18:14 +00:00
|
|
|
|
renderBookmarks();
|
|
|
|
|
|
renderResults();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-16 01:48:17 +00:00
|
|
|
|
// ── Query persistence (session only — not bookmark data) ───────────────────
|
2026-03-13 00:18:14 +00:00
|
|
|
|
|
2026-03-16 01:48:17 +00:00
|
|
|
|
const STORAGE_KEY = 'ontoref-search:' + (SLUG || '__single__');
|
2026-03-13 00:18:14 +00:00
|
|
|
|
function saveQuery(q) { try { sessionStorage.setItem(STORAGE_KEY, q); } catch (_) {} }
|
|
|
|
|
|
function loadQuery() { try { return sessionStorage.getItem(STORAGE_KEY) || ''; } catch (_) { return ''; } }
|
|
|
|
|
|
|
|
|
|
|
|
// ── Search ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
input.addEventListener('input', () => {
|
|
|
|
|
|
saveQuery(input.value);
|
|
|
|
|
|
clearTimeout(searchTimer);
|
|
|
|
|
|
searchTimer = setTimeout(doSearch, 280);
|
|
|
|
|
|
});
|
|
|
|
|
|
input.addEventListener('keydown', e => { if (e.key === 'Escape') { reset(); e.preventDefault(); } });
|
|
|
|
|
|
|
|
|
|
|
|
async function doSearch() {
|
|
|
|
|
|
const q = input.value.trim();
|
|
|
|
|
|
if (!q) { clearResults(); return; }
|
|
|
|
|
|
const slug = slugInput.value;
|
|
|
|
|
|
const url = `/search?q=${encodeURIComponent(q)}${slug ? `&slug=${encodeURIComponent(slug)}` : ''}`;
|
|
|
|
|
|
let data;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
|
|
data = await res.json();
|
2026-03-16 01:48:17 +00:00
|
|
|
|
} catch (_) {
|
2026-03-13 00:18:14 +00:00
|
|
|
|
resultsCount.textContent = 'Search error';
|
|
|
|
|
|
resultsCount.classList.remove('hidden');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
results = data.results || [];
|
|
|
|
|
|
renderResults();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderResults() {
|
|
|
|
|
|
if (results.length === 0) {
|
|
|
|
|
|
resultsList.innerHTML = '<li class="px-3 py-8 text-center text-xs text-base-content/40">No results</li>';
|
|
|
|
|
|
resultsCount.textContent = '0 results';
|
|
|
|
|
|
resultsCount.classList.remove('hidden');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
resultsCount.textContent = `${results.length} result${results.length === 1 ? '' : 's'}`;
|
|
|
|
|
|
resultsCount.classList.remove('hidden');
|
|
|
|
|
|
|
|
|
|
|
|
resultsList.innerHTML = results.map((r, i) => {
|
|
|
|
|
|
const starred = isBookmarked(r);
|
|
|
|
|
|
return `
|
|
|
|
|
|
<li class="result-item cursor-pointer hover:bg-base-300 transition-colors" data-idx="${i}">
|
|
|
|
|
|
<div class="px-3 py-2.5 flex items-start gap-1">
|
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
|
<div class="flex items-center gap-1 mb-1">
|
|
|
|
|
|
<span class="badge badge-xs ${kindCls(r.kind)}">${r.kind}</span>
|
|
|
|
|
|
${r.level ? `<span class="badge badge-xs badge-ghost">${esc(r.level)}</span>` : ''}
|
|
|
|
|
|
${r.pole ? `<span class="badge badge-xs" style="background:${poleColor(r.pole)};color:#111;border:none">${esc(r.pole)}</span>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="text-sm font-medium leading-tight truncate">${esc(r.title)}</div>
|
|
|
|
|
|
<div class="text-xs text-base-content/50 leading-tight truncate mt-0.5">${esc(r.description)}</div>
|
|
|
|
|
|
</div>
|
2026-03-29 08:32:50 +01:00
|
|
|
|
<div class="flex items-center gap-0.5 flex-shrink-0 mt-0.5">
|
|
|
|
|
|
${r.kind === 'node' ? `<a href="${graphUrl(r.id)}" class="btn btn-ghost btn-xs text-base-content/20 hover:text-primary" title="View in graph">
|
|
|
|
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm12-3c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2z"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</a>` : ''}
|
|
|
|
|
|
<button class="btn-star btn btn-ghost btn-xs ${starred ? 'text-warning' : 'text-base-content/20 hover:text-warning'}"
|
|
|
|
|
|
data-idx="${i}" title="${starred ? 'Remove bookmark' : 'Bookmark'}">
|
|
|
|
|
|
<svg class="w-3.5 h-3.5" fill="${starred ? 'currentColor' : 'none'}" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-03-13 00:18:14 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</li>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('.result-item').forEach(el => {
|
|
|
|
|
|
el.addEventListener('click', e => {
|
|
|
|
|
|
if (e.target.closest('.btn-star')) return;
|
|
|
|
|
|
if (selectedItem) selectedItem.classList.remove('bg-base-200');
|
|
|
|
|
|
el.classList.add('bg-base-200');
|
|
|
|
|
|
selectedItem = el;
|
|
|
|
|
|
showDetail(parseInt(el.dataset.idx));
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
document.querySelectorAll('.btn-star').forEach(el => {
|
2026-03-16 01:48:17 +00:00
|
|
|
|
el.addEventListener('click', async e => {
|
2026-03-13 00:18:14 +00:00
|
|
|
|
e.stopPropagation();
|
2026-03-16 01:48:17 +00:00
|
|
|
|
await toggleBookmark(results[parseInt(el.dataset.idx)]);
|
2026-03-13 00:18:14 +00:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 01:48:17 +00:00
|
|
|
|
async function copyResultToClipboard(r, btn) {
|
|
|
|
|
|
const lines = [
|
|
|
|
|
|
`# ${r.title} [${r.kind}${r.level ? ' · ' + r.level : ''}]`,
|
|
|
|
|
|
r.description ? '' : null,
|
|
|
|
|
|
r.description || null,
|
|
|
|
|
|
r.path ? `\nPath: ${r.path}` : null,
|
|
|
|
|
|
r.id ? `ID: ${r.id}` : null,
|
|
|
|
|
|
].filter(l => l !== null).join('\n');
|
|
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(lines);
|
|
|
|
|
|
const orig = btn.innerHTML;
|
|
|
|
|
|
btn.innerHTML = `<svg class="w-4 h-4 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>`;
|
|
|
|
|
|
setTimeout(() => { btn.innerHTML = orig; }, 1400);
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-29 08:32:50 +01:00
|
|
|
|
function _showResultContent(r, push) {
|
2026-03-13 00:18:14 +00:00
|
|
|
|
const starred = isBookmarked(r);
|
2026-03-29 08:32:50 +01:00
|
|
|
|
showPanel();
|
|
|
|
|
|
const canView = r.path && /\.(ncl|toml|rs|nu|md|json|html|yaml|yml)$/.test(r.path);
|
|
|
|
|
|
dpContent.innerHTML = `
|
2026-03-13 00:18:14 +00:00
|
|
|
|
<div class="flex items-start justify-between mb-3">
|
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
|
<h2 class="font-bold text-base leading-tight">${esc(r.title)}</h2>
|
|
|
|
|
|
<div class="flex flex-wrap gap-1 mt-1.5">
|
|
|
|
|
|
<span class="badge badge-xs ${kindCls(r.kind)}">${r.kind}</span>
|
|
|
|
|
|
${r.level ? `<span class="badge badge-xs badge-ghost">${esc(r.level)}</span>` : ''}
|
|
|
|
|
|
${r.pole ? `<span class="badge badge-xs" style="background:${poleColor(r.pole)};color:#111;border:none">${esc(r.pole)}</span>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-16 01:48:17 +00:00
|
|
|
|
<div class="flex items-center gap-1 flex-shrink-0 mt-0.5">
|
2026-03-29 08:32:50 +01:00
|
|
|
|
${r.kind === 'node' ? `<a href="${graphUrl(r.id)}" class="btn btn-ghost btn-xs text-base-content/25 hover:text-primary" title="View in graph">
|
|
|
|
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm12-3c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2z"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</a>` : ''}
|
|
|
|
|
|
<button id="detail-copy" class="btn btn-ghost btn-xs text-base-content/25 hover:text-base-content" title="Copy to clipboard">
|
2026-03-16 01:48:17 +00:00
|
|
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
|
|
|
|
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
2026-03-29 08:32:50 +01:00
|
|
|
|
<button id="detail-star" class="btn btn-ghost btn-xs ${starred ? 'text-warning' : 'text-base-content/25 hover:text-warning'}"
|
2026-03-16 01:48:17 +00:00
|
|
|
|
title="${starred ? 'Remove bookmark' : 'Bookmark this'}">
|
|
|
|
|
|
<svg class="w-4 h-4" fill="${starred ? 'currentColor' : 'none'}" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-03-13 00:18:14 +00:00
|
|
|
|
</div>
|
2026-03-29 08:32:50 +01:00
|
|
|
|
${r.path ? `<p class="text-xs font-mono text-base-content/30 mb-4 break-all">${
|
|
|
|
|
|
canView
|
|
|
|
|
|
? `<button class="s-file-link text-primary hover:underline underline-offset-2 bg-transparent border-none p-0 cursor-pointer text-left" data-path="${esc(r.path)}">${esc(r.path)}</button>`
|
|
|
|
|
|
: esc(r.path)
|
|
|
|
|
|
}</p>` : ''}
|
2026-03-13 00:18:14 +00:00
|
|
|
|
<div class="space-y-1 text-sm">${r.detail_html}</div>
|
|
|
|
|
|
`;
|
2026-03-16 01:48:17 +00:00
|
|
|
|
document.getElementById('detail-copy').addEventListener('click', async e => {
|
|
|
|
|
|
await copyResultToClipboard(r, e.currentTarget);
|
|
|
|
|
|
});
|
|
|
|
|
|
document.getElementById('detail-star').addEventListener('click', async () => {
|
|
|
|
|
|
await toggleBookmark(r);
|
2026-03-29 08:32:50 +01:00
|
|
|
|
_showResultContent(r, false);
|
2026-03-13 00:18:14 +00:00
|
|
|
|
});
|
2026-03-29 08:32:50 +01:00
|
|
|
|
if (push) dpNav.push({ type: 'result', r });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showDetail(idx) {
|
|
|
|
|
|
const r = results[idx];
|
|
|
|
|
|
if (!r) return;
|
|
|
|
|
|
_showResultContent(r, true);
|
2026-03-13 00:18:14 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearResults() {
|
|
|
|
|
|
results = [];
|
|
|
|
|
|
resultsList.innerHTML = '';
|
|
|
|
|
|
resultsCount.classList.add('hidden');
|
2026-03-29 08:32:50 +01:00
|
|
|
|
hidePanel();
|
2026-03-13 00:18:14 +00:00
|
|
|
|
selectedItem = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
function reset() { input.value = ''; saveQuery(''); clearResults(); input.focus(); }
|
|
|
|
|
|
resetBtn.addEventListener('click', reset);
|
|
|
|
|
|
|
|
|
|
|
|
// ── Resize handle ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
let resizing = false;
|
|
|
|
|
|
resizeHandle.addEventListener('mousedown', e => {
|
|
|
|
|
|
resizing = true;
|
|
|
|
|
|
document.body.style.cursor = 'col-resize';
|
|
|
|
|
|
document.body.style.userSelect = 'none';
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
});
|
|
|
|
|
|
document.addEventListener('mousemove', e => {
|
|
|
|
|
|
if (!resizing) return;
|
|
|
|
|
|
const rect = CONTAINER.getBoundingClientRect();
|
|
|
|
|
|
const newW = Math.max(160, Math.min(e.clientX - rect.left, rect.width * 0.6));
|
|
|
|
|
|
LEFT_PANEL.style.flex = `0 0 ${newW}px`;
|
|
|
|
|
|
});
|
|
|
|
|
|
document.addEventListener('mouseup', () => {
|
|
|
|
|
|
if (!resizing) return;
|
|
|
|
|
|
resizing = false;
|
|
|
|
|
|
document.body.style.cursor = '';
|
|
|
|
|
|
document.body.style.userSelect = '';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-03-16 01:48:17 +00:00
|
|
|
|
function kindCls(kind) { return { node: 'badge-primary', adr: 'badge-secondary', mode: 'badge-accent' }[kind] || 'badge-neutral'; }
|
2026-03-29 08:32:50 +01:00
|
|
|
|
function poleColor(p) { return { Yang: '#f59e0b', Yin: '#3b82f6', Spiral: '#8b5cf6', State: '#ec4899', Config: '#14b8a6', Env: '#f97316' }[p] || '#6b7280'; }
|
|
|
|
|
|
function graphUrl(id) {
|
|
|
|
|
|
const params = new URLSearchParams({ node: id });
|
|
|
|
|
|
if (SLUG) params.set('slug', SLUG);
|
|
|
|
|
|
return `/graph?${params}`;
|
|
|
|
|
|
}
|
2026-03-16 01:48:17 +00:00
|
|
|
|
function esc(s) { return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
2026-03-13 00:18:14 +00:00
|
|
|
|
|
|
|
|
|
|
// ── Init ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
renderBookmarks();
|
|
|
|
|
|
const savedTab = (() => { try { return localStorage.getItem(TAB_KEY); } catch (_) { return null; } })();
|
|
|
|
|
|
setTab(savedTab === 'bookmarks' ? 'bookmarks' : 'search');
|
|
|
|
|
|
|
|
|
|
|
|
const savedQuery = loadQuery();
|
|
|
|
|
|
if (savedQuery) { input.value = savedQuery; doSearch(); }
|
|
|
|
|
|
</script>
|
|
|
|
|
|
{% endblock scripts %}
|