- Fixed white background in code blocks by adding custom CSS - Code blocks now use dark background (#1a1b26 for pre, #16161e for code) - Inline code matches theme colors - Added copy buttons to all code blocks - Button appears on hover (top-right corner) - Shows 'Copy' → 'Copied!' feedback - Uses Clipboard API for copying code - Copy buttons styled to match documentation theme This improves the dark mode documentation experience with better code block visibility and usability.
722 lines
25 KiB
Templ
722 lines
25 KiB
Templ
package templates
|
||
|
||
import (
|
||
"fmt"
|
||
)
|
||
|
||
templ DocsLayout(nav Navigation, doc Document, user User) {
|
||
<!DOCTYPE html>
|
||
<html lang="en" class="dark">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{ doc.Title } - Bookhoard Documentation</title>
|
||
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/lunr@2.3.9/lunr.min.js"></script>
|
||
<style>
|
||
/* Fix code block backgrounds for dark theme */
|
||
.prose pre {
|
||
background-color: #1a1b26 !important;
|
||
}
|
||
.prose code {
|
||
background-color: #16161e !important;
|
||
color: #a9b1d6 !important;
|
||
}
|
||
.prose pre code {
|
||
background-color: transparent !important;
|
||
}
|
||
</style>
|
||
<script src="https://cdn.jsdelivr.net/npm/lunr-flex@1.0.5/lunr.flex.min.js"></script>
|
||
<script>
|
||
tailwind.config = {
|
||
darkMode: 'class',
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
background: {
|
||
primary: '#1a1b26',
|
||
secondary: '#24283b',
|
||
},
|
||
text: {
|
||
primary: '#c0caf5',
|
||
secondary: '#9aa5ce',
|
||
},
|
||
border: '#414868',
|
||
accent: '#7aa2f7',
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
</head>
|
||
<body class="bg-background-primary text-text-primary font-sans antialiased">
|
||
<!-- Mobile Menu Button -->
|
||
<button
|
||
class="lg:hidden fixed top-4 left-4 z-50 bg-background-secondary border border-border rounded p-2 text-text-primary hover:bg-background-secondary/80"
|
||
onclick="toggleSidebar()"
|
||
aria-label="Toggle menu"
|
||
>
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- Search Results Overlay -->
|
||
<div id="search-results" class="hidden fixed inset-0 bg-black/95 z-50 overflow-y-auto p-8 transition-opacity duration-200 ease-in-out"></div>
|
||
|
||
<!-- Sidebar -->
|
||
<div class="sidebar fixed left-0 top-0 bottom-0 w-72 bg-background-secondary border-r border-border overflow-y-auto z-40 lg:translate-x-0 transition-transform duration-300 ease-in-out">
|
||
<!-- Search -->
|
||
<div class="p-4 border-b border-border">
|
||
<input
|
||
type="text"
|
||
class="search-input w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent text-sm"
|
||
placeholder="Search documentation..."
|
||
id="docs-search"
|
||
oninput="searchDocs(this.value)"
|
||
>
|
||
</div>
|
||
|
||
<!-- Navigation Sections -->
|
||
for _, section := range nav.Sections {
|
||
<div class="nav-section mb-6">
|
||
<div
|
||
class="nav-section-title font-semibold text-xs uppercase tracking-wider text-text-secondary px-4 py-3 cursor-pointer select-none flex items-center justify-between"
|
||
onclick="toggleSection(this)"
|
||
>
|
||
<span>{ section.Title }</span>
|
||
<svg class="w-4 h-4 transform transition-transform section-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||
</svg>
|
||
</div>
|
||
if section.Collapsed {
|
||
<div class="nav-items hidden" data-collapsed="true">
|
||
for _, item := range section.Items {
|
||
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline">
|
||
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
||
</a>
|
||
}
|
||
</div>
|
||
} else {
|
||
<div class="nav-items" data-collapsed="false">
|
||
for _, item := range section.Items {
|
||
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline">
|
||
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
||
</a>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<!-- Main Content -->
|
||
<main class="main-content lg:ml-72 p-8 max-w-4xl mx-auto">
|
||
<!-- Breadcrumb -->
|
||
if len(doc.Breadcrumb) > 0 {
|
||
<nav class="breadcrumb flex gap-2 text-sm text-text-secondary mb-8" aria-label="Breadcrumb">
|
||
for i, crumb := range doc.Breadcrumb {
|
||
if i > 0 {
|
||
<span class="text-text-secondary/50">›</span>
|
||
}
|
||
<a href={ crumb.URL } class="text-accent hover:underline no-underline">{ crumb.Title }</a>
|
||
}
|
||
</nav>
|
||
}
|
||
|
||
<!-- Title -->
|
||
<h1 class="text-4xl font-bold mb-8 text-text-primary">{ doc.Title }</h1>
|
||
|
||
<!-- Table of Contents -->
|
||
if len(doc.TOC) > 0 {
|
||
<div class="toc bg-background-secondary p-4 rounded-lg mb-8 border border-border">
|
||
<strong class="block mb-2 text-text-primary font-semibold">On this page</strong>
|
||
for _, item := range doc.TOC {
|
||
<a
|
||
href={ "#" + item.Anchor }
|
||
class="toc-item block py-1 text-text-secondary hover:text-accent text-sm no-underline"
|
||
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) }
|
||
>
|
||
{ item.Title }
|
||
</a>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
<!-- Content -->
|
||
<div class="prose prose-invert max-w-none">
|
||
@UnsafeHTML(doc.Content).ToComponent()
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
// Toggle sidebar section
|
||
function toggleSection(el) {
|
||
const items = el.nextElementSibling;
|
||
const arrow = el.querySelector('.section-arrow');
|
||
const isCollapsed = items.getAttribute('data-collapsed') === 'true';
|
||
|
||
if (isCollapsed) {
|
||
items.classList.remove('hidden');
|
||
items.setAttribute('data-collapsed', 'false');
|
||
arrow.style.transform = 'rotate(0deg)';
|
||
} else {
|
||
items.classList.add('hidden');
|
||
items.setAttribute('data-collapsed', 'true');
|
||
arrow.style.transform = 'rotate(-90deg)';
|
||
}
|
||
}
|
||
|
||
// Toggle sidebar on mobile
|
||
function toggleSidebar() {
|
||
const sidebar = document.querySelector('.sidebar');
|
||
sidebar.classList.toggle('open');
|
||
}
|
||
|
||
// Close sidebar when clicking main content on mobile
|
||
document.querySelector('.main-content')?.addEventListener('click', () => {
|
||
const sidebar = document.querySelector('.sidebar');
|
||
if (window.innerWidth < 1024) {
|
||
sidebar.classList.remove('open');
|
||
}
|
||
});
|
||
|
||
// Highlight current page in nav
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const currentPath = window.location.pathname;
|
||
document.querySelectorAll('.nav-item').forEach(item => {
|
||
if (item.getAttribute('href') === currentPath) {
|
||
item.classList.add('text-accent', 'bg-accent/10', 'border-r-2', 'border-accent');
|
||
}
|
||
});
|
||
|
||
// Initialize syntax highlighting
|
||
if (typeof hljs !== 'undefined') {
|
||
hljs.highlightAll();
|
||
|
||
// Add copy buttons to all code blocks
|
||
document.querySelectorAll('pre code').forEach((block) => {
|
||
const pre = block.parentElement;
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'relative group';
|
||
pre.parentNode.insertBefore(wrapper, pre);
|
||
wrapper.appendChild(pre);
|
||
|
||
const button = document.createElement('button');
|
||
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent';
|
||
button.textContent = 'Copy';
|
||
button.onclick = async () => {
|
||
await navigator.clipboard.writeText(block.textContent);
|
||
button.textContent = 'Copied!';
|
||
setTimeout(() => {
|
||
button.textContent = 'Copy';
|
||
}, 2000);
|
||
};
|
||
wrapper.appendChild(button);
|
||
});
|
||
}
|
||
|
||
// Add copy buttons to all code blocks
|
||
document.querySelectorAll('pre code').forEach((block) => {
|
||
const pre = block.parentElement;
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'relative group';
|
||
pre.parentNode.insertBefore(wrapper, pre);
|
||
wrapper.appendChild(pre);
|
||
|
||
const button = document.createElement('button');
|
||
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent';
|
||
button.textContent = 'Copy';
|
||
button.onclick = async () => {
|
||
await navigator.clipboard.writeText(block.textContent);
|
||
button.textContent = 'Copied!';
|
||
setTimeout(() => {
|
||
button.textContent = 'Copy';
|
||
}, 2000);
|
||
};
|
||
wrapper.appendChild(button);
|
||
});
|
||
});
|
||
|
||
// Add copy buttons to all code blocks
|
||
document.querySelectorAll('pre code').forEach((block) => {
|
||
const pre = block.parentElement;
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'relative group';
|
||
pre.parentNode.insertBefore(wrapper, pre);
|
||
wrapper.appendChild(pre);
|
||
|
||
const button = document.createElement('button');
|
||
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent';
|
||
button.textContent = 'Copy';
|
||
button.onclick = async () => {
|
||
await navigator.clipboard.writeText(block.textContent);
|
||
button.textContent = 'Copied!';
|
||
setTimeout(() => {
|
||
button.textContent = 'Copy';
|
||
}, 2000);
|
||
};
|
||
wrapper.appendChild(button);
|
||
});
|
||
|
||
// Search state
|
||
let idx;
|
||
let searchDocs = [];
|
||
|
||
// Load index on page load
|
||
fetch('/docs/search-index.json')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
idx = lunr(function() {
|
||
this.use(lunr.flex);
|
||
this.ref('id');
|
||
this.field('title', {boost: 10});
|
||
this.field('content', {boost: 1});
|
||
data.forEach(doc => this.add(doc));
|
||
});
|
||
searchDocs = data;
|
||
console.log('Search index loaded:', data.length, 'documents');
|
||
})
|
||
.catch(err => console.error('Failed to load search index:', err));
|
||
|
||
// Search input with debounce
|
||
const searchInput = document.getElementById('docs-search');
|
||
const searchResultsDiv = document.getElementById('search-results');
|
||
|
||
let debounceTimer;
|
||
|
||
searchInput.addEventListener('input', (e) => {
|
||
clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(() => {
|
||
const query = e.target.value.trim();
|
||
|
||
if (query.length < 2) {
|
||
searchResultsDiv.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
// Search with fuzzy matching
|
||
const results = idx.search(query, {
|
||
fields: {title: {boost: 10}, content: 1},
|
||
expand: true
|
||
});
|
||
|
||
// Render results
|
||
renderSearchResults(results, query);
|
||
}, 150);
|
||
});
|
||
|
||
// Render search results
|
||
function renderSearchResults(results, query) {
|
||
if (results.length === 0) {
|
||
searchResultsDiv.innerHTML = '<div class="text-center py-8 text-text-secondary">No results found</div>';
|
||
searchResultsDiv.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
searchResultsDiv.innerHTML = results.map(r => {
|
||
const doc = searchDocs.find(d => d.id === r.ref);
|
||
if (!doc) return '';
|
||
|
||
const snippet = getSnippet(doc.content, query);
|
||
|
||
return `
|
||
<a href="${doc.url}" class="block p-4 border-b border-border hover:bg-background-secondary text-text-primary no-underline">
|
||
<div class="font-semibold mb-2">${doc.title}</div>
|
||
<div class="text-sm text-text-secondary">${snippet}...</div>
|
||
</a>
|
||
`;
|
||
}).join('');
|
||
|
||
searchResultsDiv.classList.remove('hidden');
|
||
}
|
||
|
||
function getSnippet(content, query) {
|
||
const words = query.toLowerCase().split(/\s+/);
|
||
const contentLower = content.toLowerCase();
|
||
|
||
for (const word of words) {
|
||
const idx = contentLower.indexOf(word);
|
||
if (idx !== -1) {
|
||
const start = Math.max(0, idx - 50);
|
||
const end = Math.min(content.length, idx + 100);
|
||
return '...' + content.substring(start, end) + '...';
|
||
}
|
||
}
|
||
return content.substring(0, 150) + '...';
|
||
}
|
||
|
||
// Close search results when clicking outside
|
||
document.addEventListener('click', (e) => {
|
||
if (!searchResultsDiv.contains(e.target) && e.target !== searchInput) {
|
||
searchResultsDiv.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// Close search on Escape key
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
searchResultsDiv.classList.add('hidden');
|
||
searchInput.blur();
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
}
|
||
|
||
templ DocsLayoutWithExplorer(nav Navigation, doc Document, user User, explorer APIExplorerData) {
|
||
<!DOCTYPE html>
|
||
<html lang="en" class="dark">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{ doc.Title } - Bookhoard Documentation</title>
|
||
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/lunr@2.3.9/lunr.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/lunr-flex@1.0.5/lunr.flex.min.js"></script>
|
||
<script>
|
||
tailwind.config = {
|
||
darkMode: 'class',
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
background: {
|
||
primary: '#1a1b26',
|
||
secondary: '#24283b',
|
||
},
|
||
text: {
|
||
primary: '#c0caf5',
|
||
secondary: '#9aa5ce',
|
||
},
|
||
border: '#414868',
|
||
accent: '#7aa2f7',
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
</head>
|
||
<body class="bg-background-primary text-text-primary font-sans antialiased">
|
||
<!-- Mobile Menu Button -->
|
||
<button
|
||
class="lg:hidden fixed top-4 left-4 z-50 bg-background-secondary border border-border rounded p-2 text-text-primary hover:bg-background-secondary/80"
|
||
onclick="toggleSidebar()"
|
||
aria-label="Toggle menu"
|
||
>
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- Search Results Overlay -->
|
||
<div id="search-results" class="hidden fixed inset-0 bg-black/95 z-50 overflow-y-auto p-4 lg:p-8 transition-opacity duration-200 ease-in-out"></div>
|
||
|
||
<!-- Sidebar -->
|
||
<div class="sidebar fixed left-0 top-0 bottom-0 w-72 bg-background-secondary border-r border-border overflow-y-auto z-40 lg:translate-x-0 transition-transform duration-300 ease-in-out">
|
||
<!-- Search -->
|
||
<div class="p-4 border-b border-border">
|
||
<input
|
||
type="text"
|
||
class="search-input w-full px-3 py-2 rounded bg-background-primary text-text-primary border border-border focus:outline-none focus:ring-2 focus:ring-accent text-sm"
|
||
placeholder="Search documentation..."
|
||
id="docs-search"
|
||
oninput="searchDocs(this.value)"
|
||
>
|
||
</div>
|
||
|
||
<!-- Navigation Sections -->
|
||
for _, section := range nav.Sections {
|
||
<div class="nav-section mb-6">
|
||
<div
|
||
class="nav-section-title font-semibold text-xs uppercase tracking-wider text-text-secondary px-4 py-3 cursor-pointer select-none flex items-center justify-between"
|
||
onclick="toggleSection(this)"
|
||
>
|
||
<span>{ section.Title }</span>
|
||
<svg class="w-4 h-4 transform transition-transform section-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||
</svg>
|
||
</div>
|
||
if section.Collapsed {
|
||
<div class="nav-items hidden" data-collapsed="true">
|
||
for _, item := range section.Items {
|
||
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline">
|
||
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
||
</a>
|
||
}
|
||
</div>
|
||
} else {
|
||
<div class="nav-items" data-collapsed="false">
|
||
for _, item := range section.Items {
|
||
<a href={ item.URL } class="nav-item block py-2 px-4 pl-8 text-text-secondary hover:text-accent text-sm transition-colors no-underline">
|
||
<span class="mr-2">{ item.Icon }</span>{ item.Title }
|
||
</a>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<!-- Main Content -->
|
||
<main class="main-content lg:ml-72 p-4 lg:p-8 max-w-4xl mx-auto">
|
||
<!-- Breadcrumb -->
|
||
if len(doc.Breadcrumb) > 0 {
|
||
<nav class="breadcrumb flex flex-wrap gap-2 text-sm text-text-secondary mb-8" aria-label="Breadcrumb">
|
||
for i, crumb := range doc.Breadcrumb {
|
||
if i > 0 {
|
||
<span class="text-text-secondary/50">›</span>
|
||
}
|
||
<a href={ crumb.URL } class="text-accent hover:underline no-underline">{ crumb.Title }</a>
|
||
}
|
||
</nav>
|
||
}
|
||
|
||
<!-- Title -->
|
||
<h1 class="text-3xl lg:text-4xl font-bold mb-6 lg:mb-8 text-text-primary">{ doc.Title }</h1>
|
||
|
||
<!-- Table of Contents -->
|
||
if len(doc.TOC) > 0 {
|
||
<div class="toc bg-background-secondary p-4 rounded-lg mb-6 lg:mb-8 border border-border lg:static">
|
||
<strong class="block mb-2 text-text-primary font-semibold">On this page</strong>
|
||
for _, item := range doc.TOC {
|
||
<a
|
||
href={ "#" + item.Anchor }
|
||
class="toc-item block py-1 text-text-secondary hover:text-accent text-sm no-underline"
|
||
style={ "margin-left: " + fmt.Sprintf("%drem", item.Level) }
|
||
>
|
||
{ item.Title }
|
||
</a>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
<!-- Content -->
|
||
<div class="prose prose-invert prose-sm lg:prose-base max-w-none">
|
||
@UnsafeHTML(doc.Content).ToComponent()
|
||
</div>
|
||
|
||
<!-- API Explorer -->
|
||
@APIExplorer(explorer)
|
||
</main>
|
||
|
||
<script>
|
||
// Toggle sidebar section
|
||
function toggleSection(el) {
|
||
const items = el.nextElementSibling;
|
||
const arrow = el.querySelector('.section-arrow');
|
||
const isCollapsed = items.getAttribute('data-collapsed') === 'true';
|
||
|
||
if (isCollapsed) {
|
||
items.classList.remove('hidden');
|
||
items.setAttribute('data-collapsed', 'false');
|
||
arrow.style.transform = 'rotate(0deg)';
|
||
} else {
|
||
items.classList.add('hidden');
|
||
items.setAttribute('data-collapsed', 'true');
|
||
arrow.style.transform = 'rotate(-90deg)';
|
||
}
|
||
}
|
||
|
||
// Toggle sidebar on mobile
|
||
function toggleSidebar() {
|
||
const sidebar = document.querySelector('.sidebar');
|
||
sidebar.classList.toggle('open');
|
||
}
|
||
|
||
// Close sidebar when clicking main content on mobile
|
||
document.querySelector('.main-content')?.addEventListener('click', () => {
|
||
const sidebar = document.querySelector('.sidebar');
|
||
if (window.innerWidth < 1024) {
|
||
sidebar.classList.remove('open');
|
||
}
|
||
});
|
||
|
||
// Highlight current page in nav
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const currentPath = window.location.pathname;
|
||
document.querySelectorAll('.nav-item').forEach(item => {
|
||
if (item.getAttribute('href') === currentPath) {
|
||
item.classList.add('text-accent', 'bg-accent/10', 'border-r-2', 'border-accent');
|
||
}
|
||
});
|
||
|
||
// Initialize syntax highlighting
|
||
if (typeof hljs !== 'undefined') {
|
||
hljs.highlightAll();
|
||
|
||
// Add copy buttons to all code blocks
|
||
document.querySelectorAll('pre code').forEach((block) => {
|
||
const pre = block.parentElement;
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'relative group';
|
||
pre.parentNode.insertBefore(wrapper, pre);
|
||
wrapper.appendChild(pre);
|
||
|
||
const button = document.createElement('button');
|
||
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent';
|
||
button.textContent = 'Copy';
|
||
button.onclick = async () => {
|
||
await navigator.clipboard.writeText(block.textContent);
|
||
button.textContent = 'Copied!';
|
||
setTimeout(() => {
|
||
button.textContent = 'Copy';
|
||
}, 2000);
|
||
};
|
||
wrapper.appendChild(button);
|
||
});
|
||
}
|
||
|
||
// Add copy buttons to all code blocks
|
||
document.querySelectorAll('pre code').forEach((block) => {
|
||
const pre = block.parentElement;
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'relative group';
|
||
pre.parentNode.insertBefore(wrapper, pre);
|
||
wrapper.appendChild(pre);
|
||
|
||
const button = document.createElement('button');
|
||
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent';
|
||
button.textContent = 'Copy';
|
||
button.onclick = async () => {
|
||
await navigator.clipboard.writeText(block.textContent);
|
||
button.textContent = 'Copied!';
|
||
setTimeout(() => {
|
||
button.textContent = 'Copy';
|
||
}, 2000);
|
||
};
|
||
wrapper.appendChild(button);
|
||
});
|
||
});
|
||
|
||
// Add copy buttons to all code blocks
|
||
document.querySelectorAll('pre code').forEach((block) => {
|
||
const pre = block.parentElement;
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'relative group';
|
||
pre.parentNode.insertBefore(wrapper, pre);
|
||
wrapper.appendChild(pre);
|
||
|
||
const button = document.createElement('button');
|
||
button.className = 'absolute top-2 right-2 bg-background-secondary text-text-secondary px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity border border-border hover:text-accent';
|
||
button.textContent = 'Copy';
|
||
button.onclick = async () => {
|
||
await navigator.clipboard.writeText(block.textContent);
|
||
button.textContent = 'Copied!';
|
||
setTimeout(() => {
|
||
button.textContent = 'Copy';
|
||
}, 2000);
|
||
};
|
||
wrapper.appendChild(button);
|
||
});
|
||
|
||
// Search state
|
||
let idx;
|
||
let searchDocs = [];
|
||
|
||
// Load index on page load
|
||
fetch('/docs/search-index.json')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
idx = lunr(function() {
|
||
this.use(lunr.flex);
|
||
this.ref('id');
|
||
this.field('title', {boost: 10});
|
||
this.field('content', {boost: 1});
|
||
data.forEach(doc => this.add(doc));
|
||
});
|
||
searchDocs = data;
|
||
console.log('Search index loaded:', data.length, 'documents');
|
||
})
|
||
.catch(err => console.error('Failed to load search index:', err));
|
||
|
||
// Search input with debounce
|
||
const searchInput = document.getElementById('docs-search');
|
||
const searchResultsDiv = document.getElementById('search-results');
|
||
|
||
let debounceTimer;
|
||
|
||
searchInput.addEventListener('input', (e) => {
|
||
clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(() => {
|
||
const query = e.target.value.trim();
|
||
|
||
if (query.length < 2) {
|
||
searchResultsDiv.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
// Search with fuzzy matching
|
||
const results = idx.search(query, {
|
||
fields: {title: {boost: 10}, content: 1},
|
||
expand: true
|
||
});
|
||
|
||
// Render results
|
||
renderSearchResults(results, query);
|
||
}, 150);
|
||
});
|
||
|
||
// Render search results
|
||
function renderSearchResults(results, query) {
|
||
if (results.length === 0) {
|
||
searchResultsDiv.innerHTML = '<div class="text-center py-8 text-text-secondary">No results found</div>';
|
||
searchResultsDiv.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
searchResultsDiv.innerHTML = results.map(r => {
|
||
const doc = searchDocs.find(d => d.id === r.ref);
|
||
if (!doc) return '';
|
||
|
||
const snippet = getSnippet(doc.content, query);
|
||
|
||
return `
|
||
<a href="${doc.url}" class="block p-4 border-b border-border hover:bg-background-secondary text-text-primary no-underline">
|
||
<div class="font-semibold mb-2">${doc.title}</div>
|
||
<div class="text-sm text-text-secondary">${snippet}...</div>
|
||
</a>
|
||
`;
|
||
}).join('');
|
||
|
||
searchResultsDiv.classList.remove('hidden');
|
||
}
|
||
|
||
function getSnippet(content, query) {
|
||
const words = query.toLowerCase().split(/\s+/);
|
||
const contentLower = content.toLowerCase();
|
||
|
||
for (const word of words) {
|
||
const idx = contentLower.indexOf(word);
|
||
if (idx !== -1) {
|
||
const start = Math.max(0, idx - 50);
|
||
const end = Math.min(content.length, idx + 100);
|
||
return '...' + content.substring(start, end) + '...';
|
||
}
|
||
}
|
||
return content.substring(0, 150) + '...';
|
||
}
|
||
|
||
// Close search results when clicking outside
|
||
document.addEventListener('click', (e) => {
|
||
if (!searchResultsDiv.contains(e.target) && e.target !== searchInput) {
|
||
searchResultsDiv.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// Close search on Escape key
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
searchResultsDiv.classList.add('hidden');
|
||
searchInput.blur();
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
}
|