Jesús Pérez 75892a8eea
Some checks failed
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
feat: browser-style panel nav, repo file routing, migration 0007
graph, search, api_catalog pages: back/forward history stack (PanelNav/dpNav).
  File artifact paths open in external tabs via card.repo (Gitea source URL) or
  card.docs (cargo docs for .rs) — openFile/openFileInPanel removed from all pages.
  Tera | safe required for URL values inside <script> blocks (auto-escape of slashes).

  card.ncl: repo field added.
  insert_brand_ctx: injects card_repo/card_docs into Tera context.
  #[onto_api] proc-macro: source_file = file!() emitted; ApiRouteEntry.source_file
  populated in primary catalog handler.

  migration 0007-card-repo-field: check card.ncl for repo field; skip if absent.
2026-03-29 08:32:50 +01:00

667 lines
28 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Search — Ontoref{% endblock title %}
{% block nav_search %}active{% endblock nav_search %}
{% block nav_group_knowledge %}active{% endblock nav_group_knowledge %}
{% 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>
<!-- 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>
</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');
const dpContent = document.getElementById('dp-content');
const detailEmpty = document.getElementById('detail-empty');
const resetBtn = document.getElementById('btn-reset-search');
const resizeHandle = document.getElementById('search-resize');
const dpBack = document.getElementById('dp-back');
const dpForward = document.getElementById('dp-forward');
const LEFT_PANEL = document.querySelector('#search-pane').closest('div.flex-col');
const CONTAINER = LEFT_PANEL.parentElement;
// ── 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;
}
}
let results = [];
let searchTimer = null;
let selectedItem = null;
// ── Tab switching ──────────────────────────────────────────────────────────
const tabSearch = document.getElementById('tab-search');
const tabBm = document.getElementById('tab-bookmarks');
const searchPane = document.getElementById('search-pane');
const bookmarksPane = document.getElementById('bookmarks-pane');
const TAB_KEY = 'ontoref-search-tab';
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();
else input.focus();
}
tabSearch.addEventListener('click', () => setTab('search'));
tabBm.addEventListener('click', () => setTab('bookmarks'));
// ── 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.
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');
const SLUG = slugInput.value || null;
// 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);
}
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 (_) {}
} else {
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 (_) {}
}
renderBookmarks();
renderResults();
}
function renderBookmarks() {
const bms = [...bookmarks.values()];
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 => `
<li class="bm-item cursor-pointer hover:bg-base-300 transition-colors" data-nid="${esc(b.node_id)}">
<div class="px-3 py-2 flex items-center gap-2">
<span class="badge badge-xs ${kindCls(b.kind)} flex-shrink-0">${esc(b.kind)}</span>
<span class="text-xs font-medium truncate flex-1">${esc(b.title)}</span>
<button class="btn-unbm btn btn-ghost btn-xs flex-shrink-0 opacity-40 hover:opacity-100 hover:text-error"
data-nid="${esc(b.node_id)}" title="Remove">
<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;
const bm = bookmarks.get(el.dataset.nid);
if (!bm) return;
if (selectedItem) selectedItem.classList.remove('bg-base-200');
selectedItem = null;
showDetailBm(bm);
});
});
bmList.querySelectorAll('.btn-unbm').forEach(el => {
el.addEventListener('click', async e => {
e.stopPropagation();
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 (_) {}
renderBookmarks();
renderResults();
});
});
}
function showDetailBm(bm) {
showPanel();
dpContent.innerHTML = `
<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">
<span class="badge badge-xs ${kindCls(bm.kind)}">${esc(bm.kind)}</span>
${bm.level ? `<span class="badge badge-xs badge-ghost">${esc(bm.level)}</span>` : ''}
</div>
</div>
<button id="bm-detail-star" class="btn btn-ghost btn-xs text-warning" title="Remove bookmark">
<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>
${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>` : ''}
`;
document.getElementById('bm-detail-star').addEventListener('click', async () => {
await toggleBookmark({ id: bm.node_id, kind: bm.kind, title: bm.title, level: bm.level });
hidePanel();
});
}
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();
renderBookmarks();
renderResults();
});
// ── Query persistence (session only — not bookmark data) ───────────────────
const STORAGE_KEY = 'ontoref-search:' + (SLUG || '__single__');
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();
} catch (_) {
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>
<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>
</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 => {
el.addEventListener('click', async e => {
e.stopPropagation();
await toggleBookmark(results[parseInt(el.dataset.idx)]);
});
});
}
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 (_) {}
}
function _showResultContent(r, push) {
const starred = isBookmarked(r);
showPanel();
const canView = r.path && /\.(ncl|toml|rs|nu|md|json|html|yaml|yml)$/.test(r.path);
dpContent.innerHTML = `
<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>
<div class="flex items-center gap-1 flex-shrink-0 mt-0.5">
${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">
<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>
<button id="detail-star" class="btn btn-ghost btn-xs ${starred ? 'text-warning' : 'text-base-content/25 hover:text-warning'}"
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>
</div>
${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>` : ''}
<div class="space-y-1 text-sm">${r.detail_html}</div>
`;
document.getElementById('detail-copy').addEventListener('click', async e => {
await copyResultToClipboard(r, e.currentTarget);
});
document.getElementById('detail-star').addEventListener('click', async () => {
await toggleBookmark(r);
_showResultContent(r, false);
});
if (push) dpNav.push({ type: 'result', r });
}
function showDetail(idx) {
const r = results[idx];
if (!r) return;
_showResultContent(r, true);
}
function clearResults() {
results = [];
resultsList.innerHTML = '';
resultsCount.classList.add('hidden');
hidePanel();
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 ────────────────────────────────────────────────────────────────
function kindCls(kind) { return { node: 'badge-primary', adr: 'badge-secondary', mode: 'badge-accent' }[kind] || 'badge-neutral'; }
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}`;
}
function esc(s) { return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ── 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 %}