diff --git a/index.html b/index.html index 7b129e0..37a5d21 100644 --- a/index.html +++ b/index.html @@ -17,7 +17,7 @@ @media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } } - + diff --git a/package.json b/package.json index 0388144..e62a6af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amerc-site", - "version": "0.92.0-apipeek", + "version": "0.93.0-docsearch", "private": true, "type": "module", "scripts": { diff --git a/src/DocsApp.jsx b/src/DocsApp.jsx index 62d3796..8fa74d8 100644 --- a/src/DocsApp.jsx +++ b/src/DocsApp.jsx @@ -25,6 +25,16 @@ function highlightCode(code) { // URL-safe slug from a doc title, for shareable #/ doc links. const slugify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'doc'; +// A context snippet around a full-text match, markdown stripped, query ed. +function makeSnippet(body, idx, query) { + const start = Math.max(0, idx - 48); + const end = Math.min(body.length, idx + query.length + 64); + const raw = body.slice(start, end).replace(/[#>*`~_[\]]/g, '').replace(/\s+/g, ' ').trim(); + const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); + const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'ig'); + return (start > 0 ? '…' : '') + esc(raw).replace(re, '$1') + (end < body.length ? '…' : ''); +} + function DocsWorkspace({ canEdit }) { const [docs, setDocs] = useState([]); const [sel, setSel] = useState(null); @@ -34,8 +44,10 @@ function DocsWorkspace({ canEdit }) { const [err, setErr] = useState(''); const [q, setQ] = useState(''); const [navOpen, setNavOpen] = useState(false); + const [results, setResults] = useState(null); const renderRef = useRef(null); const pendingSection = useRef(null); + const bodiesRef = useRef(null); const loadList = useCallback(async () => { try { @@ -121,6 +133,30 @@ function DocsWorkspace({ canEdit }) { const el = renderRef.current?.querySelector(`#${CSS.escape(s)}`); if (el) el.scrollIntoView({ block: 'start' }); })); }, [docHtml]); + // full-text search: fetch all doc bodies once, then match title + body, with snippets + useEffect(() => { bodiesRef.current = null; }, [docs]); + useEffect(() => { + const query = q.trim(); + if (!query) { setResults(null); return; } + let live = true; + (async () => { + if (!bodiesRef.current) { + const all = await Promise.all(docs.map((d) => api(`/docs/${d.id}`).then((r) => [d.id, r.doc.body || '']).catch(() => [d.id, '']))); + if (!live) return; + bodiesRef.current = Object.fromEntries(all); + } + const ql = query.toLowerCase(); + const res = []; + for (const d of docs) { + const body = bodiesRef.current[d.id] || ''; + const inMeta = (d.title + ' ' + (d.folder || '')).toLowerCase().includes(ql); + const idx = body.toLowerCase().indexOf(ql); + if (inMeta || idx >= 0) res.push({ ...d, snippet: idx >= 0 ? makeSnippet(body, idx, query) : '' }); + } + if (live) setResults(res); + })(); + return () => { live = false; }; + }, [q, docs]); const create = async () => { const title = window.prompt('Document title', 'New document'); if (!title) return; @@ -129,9 +165,8 @@ function DocsWorkspace({ canEdit }) { const save = async () => { try { await api(`/docs/${sel}`, { method: 'PATCH', body: draft }); await loadList(); setDoc({ ...doc, ...draft }); setEditing(false); } catch (e) { setErr(e.message); } }; const del = async () => { if (!window.confirm('Delete document?')) return; try { await api(`/docs/${sel}`, { method: 'DELETE' }); setSel(null); loadList(); } catch (e) { setErr(e.message); } }; - const filtered = docs.filter((d) => (d.title + ' ' + (d.folder || '')).toLowerCase().includes(q.toLowerCase())); const folders = {}; - filtered.forEach((d) => { (folders[d.folder || ''] = folders[d.folder || ''] || []).push(d); }); + docs.forEach((d) => { (folders[d.folder || ''] = folders[d.folder || ''] || []).push(d); }); return (
@@ -145,17 +180,30 @@ function DocsWorkspace({ canEdit }) { setQ(e.target.value)} /> {canEdit && }
-
- {Object.keys(folders).sort().map((f) => ( -
-
{f || '📁 root'}
- {folders[f].map((d) => ( - - ))} -
- ))} - {!filtered.length &&

No documents yet. Create one — humans and agents (via API key) can both read & write here.

} -
+ {q.trim() ? ( +
+ {results === null &&

Searching…

} + {results !== null && !results.length &&

No matches for “{q.trim()}”.

} + {results !== null && results.map((d) => ( + + ))} +
+ ) : ( +
+ {Object.keys(folders).sort().map((f) => ( +
+
{f || '📁 root'}
+ {folders[f].map((d) => ( + + ))} +
+ ))} + {!docs.length &&

No documents yet. Create one — humans and agents (via API key) can both read & write here.

} +
+ )}
diff --git a/src/Scene2D.jsx b/src/Scene2D.jsx index 28b17fa..c81b4f7 100644 --- a/src/Scene2D.jsx +++ b/src/Scene2D.jsx @@ -10,7 +10,7 @@ import { api } from './api.js'; const Menu = ({ size = 20 }) => (); const X = ({ size = 20 }) => (); -const RELEASE = '0.92.0-apipeek'; +const RELEASE = '0.93.0-docsearch'; const NAV = [ { id: 'home', label: 'Tavern' }, @@ -264,9 +264,9 @@ function ApiPeek() { const cur = PEEK.find((x) => x.id === ep); return (
-
+
{PEEK.map((e) => ( - + ))}
diff --git a/src/styles.css b/src/styles.css index ec0ce62..ef22f37 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1538,3 +1538,12 @@ button { .docs-anchor { opacity: 0; margin-left: 9px; padding: 0 2px; background: none; border: none; color: #46c8e0; cursor: pointer; font-size: 0.8em; font-weight: 600; text-decoration: none; vertical-align: middle; transition: opacity 0.12s; } .docs-render h2:hover .docs-anchor, .docs-render h3:hover .docs-anchor, .docs-anchor:focus-visible { opacity: 0.85; } .docs-anchor:hover { opacity: 1; } + +/* docs full-text search results (0.93) */ +.docs-results { padding: 8px; display: flex; flex-direction: column; gap: 2px; } +.docs-result { display: flex; flex-direction: column; gap: 3px; text-align: left; background: none; border: none; padding: 8px 10px; border-radius: 8px; cursor: pointer; } +.docs-result:hover { background: #131c2b; } +.docs-result.on { background: #1a2740; } +.docs-result-title { color: #eaf2ff; font-size: 13.5px; font-weight: 600; } +.docs-result-snip { color: #8595ab; font-size: 12px; line-height: 1.45; } +.docs-result-snip mark { background: rgba(39, 216, 255, 0.22); color: #d6f0f8; border-radius: 2px; padding: 0 1px; }