diff --git a/boneset-api/server.js b/boneset-api/server.js index e060ce8..d0505ad 100644 --- a/boneset-api/server.js +++ b/boneset-api/server.js @@ -1,266 +1,247 @@ -//const express = require("express"); -//const axios = require("axios"); -//const cors = require("cors"); -//const path = require('path'); // Added for consistency, though not strictly needed for this version -// -//const app = express(); -//const PORT = process.env.PORT || 8000; -// -//app.use(cors()); -// -//// --- Original GitHub URLs --- -//const GITHUB_REPO = "https://raw.githubusercontent.com/oss-slu/DigitalBonesBox/data/DataPelvis/"; -//const BONESET_JSON_URL = `${GITHUB_REPO}boneset/bony_pelvis.json`; -//const BONES_DIR_URL = `${GITHUB_REPO}bones/`; -// -//// Helper function to fetch JSON from GitHub -//async function fetchJSON(url) { -// try { -// const response = await axios.get(url); -// return response.data; -// } catch (error) { -// console.error(`Failed to fetch ${url}:`, error.message); -// return null; -// } -//} -// -//// Home route (fixes "Cannot GET /" issue) -//app.get("/", (req, res) => { -// res.json({ message: "Welcome to the Boneset API (GitHub-Integrated)" }); -//}); -// -//// --- Original Combined Data Endpoint --- -//// This endpoint still provides the main data for the dropdowns -//app.get("/combined-data", async (req, res) => { -// try { -// const bonesetData = await fetchJSON(BONESET_JSON_URL); -// if (!bonesetData) return res.status(500).json({ error: "Failed to load boneset data" }); -// -// const bonesets = [{ id: bonesetData.id, name: bonesetData.name }]; -// const bones = []; -// const subbones = []; -// -// for (const boneId of bonesetData.bones) { -// const boneJsonUrl = `${BONES_DIR_URL}${boneId}.json`; -// const boneData = await fetchJSON(boneJsonUrl); -// -// if (boneData) { -// bones.push({ id: boneData.id, name: boneData.name, boneset: bonesetData.id }); -// boneData.subBones.forEach(subBoneId => { -// subbones.push({ id: subBoneId, name: subBoneId.replace(/_/g, " "), bone: boneData.id }); -// }); -// } -// } -// -// res.json({ bonesets, bones, subbones }); -// -// } catch (error) { -// console.error("Error fetching combined data:", error.message); -// res.status(500).json({ error: "Internal Server Error" }); -// } -//}); -// -//// --- NEW HTMX ENDPOINT --- -//// This endpoint fetches a description and returns it as an HTML fragment -//app.get("/api/description/", async (req, res) => { // Path changed here -// const { boneId } = req.query; // Changed from req.params to req.query -// if (!boneId) { -// return res.send(''); // Send empty response if no boneId is provided -// } -// const GITHUB_DESC_URL = `https://raw.githubusercontent.com/oss-slu/DigitalBonesBox/data/DataPelvis/descriptions/${boneId}_description.json`; -// -// try { -// const response = await axios.get(GITHUB_DESC_URL); -// const descriptionData = response.data; -// -// let html = `
  • ${descriptionData.name}
  • `; -// descriptionData.description.forEach(point => { -// html += `
  • ${point}
  • `; -// }); -// res.send(html); -// -// } catch (error) { -// res.send('
  • Description not available.
  • '); -// } -//}); - - -// Start server -//app.listen(PORT, () => { -// console.log(`🚀 Server running on http://127.0.0.1:${PORT}`); -//}); - // boneset-api/server.js const express = require("express"); const axios = require("axios"); const cors = require("cors"); -const path = require("path"); -const fs = require("fs/promises"); const rateLimit = require("express-rate-limit"); const app = express(); const PORT = process.env.PORT || 8000; app.use(cors()); +app.use(express.json()); -// ---- Existing GitHub sources used only by /combined-data (unchanged) ---- const GITHUB_REPO = "https://raw.githubusercontent.com/oss-slu/DigitalBonesBox/data/DataPelvis/"; const BONESET_JSON_URL = `${GITHUB_REPO}boneset/bony_pelvis.json`; const BONES_DIR_URL = `${GITHUB_REPO}bones/`; -// ---- Local data directory for merged files ---- -const DATA_DIR = path.join(__dirname, "data"); - -// ---- Simple rate limiter for FS-backed endpoints ---- -const bonesetLimiter = rateLimit({ - windowMs: 60 * 1000, // 1 minute - max: 60, // 60 requests / min / IP - standardHeaders: true, - legacyHeaders: false, +// Rate limiter for search endpoint +const searchLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, }); -// ---- Only allow bonesets we ship locally right now ---- -const ALLOWED_BONESETS = new Set(["bony_pelvis"]); +// Cache for search data +let searchCache = null; -// ---- Helpers ---- -async function fetchJSON(url) { - try { - const response = await axios.get(url, { timeout: 10_000 }); - return response.data; - } catch (error) { - console.error(`Failed to fetch ${url}:`, error.message); - return null; - } +// HTML escaping helper +function escapeHtml(str = "") { + return String(str).replace(/[&<>"']/g, (c) => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", + })[c]); } -// Ensure any resolved path stays inside DATA_DIR -function safeDataPath(fileName) { - const base = path.resolve(DATA_DIR); - const candidate = path.resolve(DATA_DIR, fileName); - if (!candidate.startsWith(base + path.sep)) { - const err = new Error("Invalid path"); - err.code = "EINVAL"; - throw err; - } - return candidate; +// GitHub JSON fetcher +async function fetchJSON(url) { + try { + const response = await axios.get(url, { timeout: 10_000 }); + return response.data; + } catch (error) { + console.error(`Failed to fetch ${url}:`, error.message); + return null; + } } -// Tiny HTML escape (double-quotes everywhere for ESLint) -function escapeHtml(str = "") { - return String(str).replace(/[&<>"']/g, (c) => ({ - "&": "&", - "<": "<", - ">": ">", - "\"": """, - "'": "'", - })[c]); -} +// Initialize search cache at startup +async function initializeSearchCache() { + try { + console.log("Initializing search cache..."); + const bonesetData = await fetchJSON(BONESET_JSON_URL); + if (!bonesetData) { + console.error("Failed to load boneset data for search cache"); + return; + } + + const searchData = []; + + // Add boneset to search data + searchData.push({ + id: bonesetData.id, + name: bonesetData.name, + type: "boneset", + boneset: bonesetData.id, + bone: null, + subbone: null + }); -// Cache the merged boneset for fast description lookups -let cachedBoneset = null; -async function loadBoneset() { - if (cachedBoneset) return cachedBoneset; - const file = safeDataPath("final_bony_pelvis.json"); - const raw = await fs.readFile(file, "utf8"); - cachedBoneset = JSON.parse(raw); - return cachedBoneset; + // Load all bones and sub-bones + for (const boneId of bonesetData.bones || []) { + const boneData = await fetchJSON(`${BONES_DIR_URL}${boneId}.json`); + if (boneData) { + // Add bone to search data + searchData.push({ + id: boneData.id, + name: boneData.name, + type: "bone", + boneset: bonesetData.id, + bone: boneData.id, + subbone: null + }); + + // Add sub-bones to search data + for (const subBoneId of boneData.subBones || []) { + const subBoneName = subBoneId.replace(/_/g, " "); + searchData.push({ + id: subBoneId, + name: subBoneName, + type: "subbone", + boneset: bonesetData.id, + bone: boneData.id, + subbone: subBoneId + }); + } + } + } + + searchCache = searchData; + console.log(`Search cache initialized with ${searchData.length} items`); + } catch (error) { + console.error("Error initializing search cache:", error); + } } -function findNodeById(boneset, id) { - if (!boneset) return null; - for (const bone of boneset.bones || []) { - if (bone.id === id) return bone; - for (const sub of bone.subbones || []) { - if (sub.id === id) return sub; +// Search function with ranking +function searchItems(query, limit = 20) { + if (!searchCache) return []; + + const q = query.toLowerCase().trim(); + const results = []; + + // First pass: prefix matches (higher priority) + for (const item of searchCache) { + if (item.name.toLowerCase().startsWith(q)) { + results.push({ ...item, priority: 1 }); + } + } + + // Second pass: substring matches (lower priority) + for (const item of searchCache) { + if (!item.name.toLowerCase().startsWith(q) && item.name.toLowerCase().includes(q)) { + results.push({ ...item, priority: 2 }); + } } - } - return null; + + // Sort by priority, then by name + results.sort((a, b) => { + if (a.priority !== b.priority) return a.priority - b.priority; + return a.name.localeCompare(b.name); + }); + + return results.slice(0, limit); } -// ---- Routes ---- - +// Routes app.get("/", (_req, res) => { - res.json({ message: "Welcome to the Boneset API (GitHub-Integrated)" }); + res.json({ message: "Welcome to the Boneset API" }); }); -// Unchanged: used by the dropdowns in the current UI app.get("/combined-data", async (_req, res) => { - try { - const bonesetData = await fetchJSON(BONESET_JSON_URL); - if (!bonesetData) return res.status(500).json({ error: "Failed to load boneset data" }); - - const bonesets = [{ id: bonesetData.id, name: bonesetData.name }]; - const bones = []; - const subbones = []; - - for (const boneId of bonesetData.bones) { - const boneJsonUrl = `${BONES_DIR_URL}${boneId}.json`; - const boneData = await fetchJSON(boneJsonUrl); - if (boneData) { - bones.push({ id: boneData.id, name: boneData.name, boneset: bonesetData.id }); - (boneData.subBones || []).forEach((subBoneId) => { - subbones.push({ id: subBoneId, name: subBoneId.replace(/_/g, " "), bone: boneData.id }); - }); - } + try { + const bonesetData = await fetchJSON(BONESET_JSON_URL); + if (!bonesetData) { + return res.status(500).json({ error: "Failed to load boneset data" }); + } + + const bonesets = [{ id: bonesetData.id, name: bonesetData.name }]; + const bones = []; + const subbones = []; + + for (const boneId of bonesetData.bones || []) { + const boneData = await fetchJSON(`${BONES_DIR_URL}${boneId}.json`); + if (boneData) { + bones.push({ id: boneData.id, name: boneData.name, boneset: bonesetData.id }); + (boneData.subBones || []).forEach((subBoneId) => { + subbones.push({ id: subBoneId, name: subBoneId.replace(/_/g, " "), bone: boneData.id }); + }); + } + } + + res.json({ bonesets, bones, subbones }); + } catch (error) { + console.error("Error fetching combined data:", error.message); + res.status(500).json({ error: "Internal Server Error" }); } - - res.json({ bonesets, bones, subbones }); - } catch (error) { - console.error("Error fetching combined data:", error.message); - res.status(500).json({ error: "Internal Server Error" }); - } }); -// Serve description from the local merged JSON (no SSRF) -app.get("/api/description", bonesetLimiter, async (req, res) => { - const boneId = String(req.query.boneId || ""); - - // Basic allowlist-style validation - if (!/^[a-z0-9_]+$/.test(boneId)) { - return res.type("text/html").send(""); - } - - try { - const set = await loadBoneset(); - const node = findNodeById(set, boneId); - if (!node) return res.type("text/html").send(""); +app.get("/api/description/", async (req, res) => { + const { boneId } = req.query; + if (!boneId) { + return res.send(" "); + } + + const GITHUB_DESC_URL = `https://raw.githubusercontent.com/oss-slu/DigitalBonesBox/data/DataPelvis/descriptions/${boneId}_description.json`; - const name = node.name || boneId.replace(/_/g, " "); - const lines = Array.isArray(node.description) ? node.description : []; + try { + const response = await axios.get(GITHUB_DESC_URL); + const descriptionData = response.data; - // HTMX expects an
  • list fragment - let html = `
  • ${escapeHtml(name)}
  • `; - for (const line of lines) { - html += `
  • ${escapeHtml(line)}
  • `; + let html = `
  • ${escapeHtml(descriptionData.name)}
  • `; + descriptionData.description.forEach(point => { + html += `
  • ${escapeHtml(point)}
  • `; + }); + res.send(html); + } catch (error) { + res.send("
  • Description not available.
  • "); } - res.type("text/html").send(html); - } catch (err) { - console.error("description error:", err); - res.type("text/html").send("
  • Description not available.
  • "); - } }); -// Safe path + allowlist + rate limit -app.get("/api/boneset/:bonesetId", bonesetLimiter, async (req, res) => { - const { bonesetId } = req.params; +// Search endpoint +app.get("/api/search", searchLimiter, (req, res) => { + const query = req.query.q; + + console.log("Search request received for:", query); - if (!ALLOWED_BONESETS.has(bonesetId)) { - return res.status(404).json({ error: `Boneset '${bonesetId}' not found` }); - } + // Handle empty or too short queries + if (!query || query.trim().length < 2) { + return res.send("
  • Enter at least 2 characters to search
  • "); + } - try { - const filePath = safeDataPath(`final_${bonesetId}.json`); - const raw = await fs.readFile(filePath, "utf8"); - res.type("application/json").send(raw); - } catch (err) { - if (err.code === "ENOENT") { - return res.status(404).json({ error: `Boneset '${bonesetId}' not found` }); + const searchTerm = query.trim(); + + try { + if (!searchCache) { + return res.send("
  • Search not available - cache not initialized
  • "); + } + + const results = searchItems(searchTerm, 20); + console.log(`Found ${results.length} results for "${searchTerm}"`); + + if (results.length === 0) { + return res.send("
  • No results found
  • "); + } + + let html = ""; + for (const result of results) { + const escapedName = escapeHtml(result.name); + const escapedType = escapeHtml(result.type); + + html += `
  • + ${escapedName} (${escapedType}) +
  • `; + } + + res.send(html); + } catch (error) { + console.error("Search error:", error); + res.status(500).send("
  • Search error occurred
  • "); } - console.error("Error reading boneset file:", err); - res.status(500).json({ error: "Internal Server Error" }); - } }); +// Initialize search cache on startup +initializeSearchCache(); + app.listen(PORT, () => { - console.log(`🚀 Server running on http://127.0.0.1:${PORT}`); -}); + console.log(`🚀 Server running on http://127.0.0.1:${PORT}`); +}); \ No newline at end of file diff --git a/templates/boneset.html b/templates/boneset.html index f02169e..ec0c408 100644 --- a/templates/boneset.html +++ b/templates/boneset.html @@ -7,7 +7,7 @@ Bone Set Viewer - + @@ -28,8 +28,18 @@

    Bone Set Viewer

    - - + + +
    + + +
    + + +
    @@ -61,8 +71,7 @@

    Bone Set Viewer

    Description

    -
      -
    +
      - -
      +
      - - - + + + \ No newline at end of file diff --git a/templates/js/main.js b/templates/js/main.js index 43df81a..e53df25 100644 --- a/templates/js/main.js +++ b/templates/js/main.js @@ -1,9 +1,10 @@ import { fetchCombinedData, fetchMockBoneData } from "./api.js"; import { populateBonesetDropdown, setupDropdownListeners } from "./dropdowns.js"; -import { initializeSidebar } from "./sidebar.js"; +import { initializeSidebar, loadHelpButton } from "./sidebar.js"; import { setupNavigation, setBoneAndSubbones, disableButtons } from "./navigation.js"; import { loadDescription } from "./description.js"; import { displayBoneData, clearViewer } from "./viewer.js"; +import { initializeSearch } from "./search.js"; let combinedData = { bonesets: [], bones: [], subbones: [] }; let mockBoneData = null; @@ -30,26 +31,27 @@ function handleBoneSelection(boneId) { } document.addEventListener("DOMContentLoaded", async () => { - // 1. Sidebar behavior + // 1. Initialize search functionality + initializeSearch(); + + // 2. Sidebar behavior and help button initializeSidebar(); + loadHelpButton(); - // 2. Load mock bone data using centralized API + // 3. Load mock bone data using centralized API mockBoneData = await fetchMockBoneData(); - // 3. Fetch data and populate dropdowns + // 4. Fetch data and populate dropdowns combinedData = await fetchCombinedData(); populateBonesetDropdown(combinedData.bonesets); setupDropdownListeners(combinedData); - // 4. Hook up navigation buttons - const prevButton = document.getElementById("prev-button"); - const nextButton = document.getElementById("next-button"); - const subboneDropdown = document.getElementById("subbone-select"); - const boneDropdown = document.getElementById("bone-select"); - - setupNavigation(prevButton, nextButton, subboneDropdown, loadDescription); + // 5. Setup navigation after everything else + setupNavigation(combinedData); + disableButtons(); - // 5. Update navigation when bone changes + // 6. Update navigation when bone changes + const boneDropdown = document.getElementById("bone-select"); boneDropdown.addEventListener("change", (event) => { const selectedBone = event.target.value; @@ -58,9 +60,9 @@ document.addEventListener("DOMContentLoaded", async () => { .map(sb => sb.id); setBoneAndSubbones(selectedBone, relatedSubbones); - populateSubboneDropdown(subboneDropdown, relatedSubbones); - disableButtons(prevButton, nextButton); - + populateSubboneDropdown(document.getElementById("subbone-select"), relatedSubbones); + disableButtons(); + // Handle bone selection using dedicated function if (selectedBone) { handleBoneSelection(selectedBone); @@ -69,7 +71,7 @@ document.addEventListener("DOMContentLoaded", async () => { } }); - // 6. Auto-select the first boneset + // 7. Auto-select the first boneset const boneset = combinedData.bonesets[0]; if (boneset) { document.getElementById("boneset-select").value = boneset.id; @@ -77,7 +79,7 @@ document.addEventListener("DOMContentLoaded", async () => { document.getElementById("boneset-select").dispatchEvent(event); } - // 7. Initialize display + // 8. Initialize display clearViewer(); }); diff --git a/templates/js/search.js b/templates/js/search.js new file mode 100644 index 0000000..01cc1e5 --- /dev/null +++ b/templates/js/search.js @@ -0,0 +1,186 @@ +let selectedIndex = -1; +let searchTimeout; + +// Handle search result clicks and keyboard navigation +export function initializeSearch() { + const searchBar = document.getElementById("search-bar"); + const searchResultsContainer = document.getElementById("search-results"); + const searchLoading = document.getElementById("search-loading"); + + if (!searchBar || !searchResultsContainer) { + console.error("Search elements not found"); + return; + } + + console.log("Search initialized"); + + // Handle typing in search bar + searchBar.addEventListener("input", (e) => { + clearTimeout(searchTimeout); + const query = e.target.value.trim(); + + if (query.length < 2) { + searchResultsContainer.innerHTML = ""; + searchLoading.style.display = "none"; + return; + } + + searchLoading.style.display = "block"; + + searchTimeout = setTimeout(() => { + performSearch(query); + }, 300); + }); + + // Handle keyboard navigation + searchBar.addEventListener("keydown", (e) => { + const results = searchResultsContainer.querySelectorAll(".search-result"); + + if (e.key === "ArrowDown") { + e.preventDefault(); + selectedIndex = Math.min(selectedIndex + 1, results.length - 1); + updateSelection(results); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + selectedIndex = Math.max(selectedIndex - 1, -1); + updateSelection(results); + } else if (e.key === "Enter") { + e.preventDefault(); + if (selectedIndex >= 0 && results[selectedIndex]) { + selectSearchResult(results[selectedIndex]); + } + } else if (e.key === "Escape") { + clearSearch(); + } + }); + + // Handle clicks outside search to close results + document.addEventListener("click", (e) => { + if (!searchBar.contains(e.target) && !searchResultsContainer.contains(e.target)) { + if (!e.target.closest(".search-result")) { + clearSearchResults(); + } + } + }); +} + +async function performSearch(query) { + const searchResultsContainer = document.getElementById("search-results"); + const searchLoading = document.getElementById("search-loading"); + + try { + console.log("Performing search for:", query); + const response = await fetch(`http://127.0.0.1:8000/api/search?q=${encodeURIComponent(query)}`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const html = await response.text(); + console.log("Search response received"); + + searchResultsContainer.innerHTML = html; + searchLoading.style.display = "none"; + selectedIndex = -1; + + // Attach click handlers to new results + attachClickHandlers(); + + } catch (error) { + console.error("Search error:", error); + searchResultsContainer.innerHTML = "
    • Search failed. Make sure the server is running.
    • "; + searchLoading.style.display = "none"; + } +} + +function attachClickHandlers() { + const results = document.querySelectorAll(".search-result"); + results.forEach(result => { + result.addEventListener("click", (e) => { + e.preventDefault(); + selectSearchResult(result); + }); + }); +} + +function updateSelection(results) { + results.forEach((result, index) => { + if (index === selectedIndex) { + result.classList.add("selected"); + result.scrollIntoView({ block: "nearest" }); + } else { + result.classList.remove("selected"); + } + }); +} + +function selectSearchResult(resultElement) { + const type = resultElement.dataset.type; + const bonesetId = resultElement.dataset.boneset; + const boneId = resultElement.dataset.bone; + const subboneId = resultElement.dataset.subbone; + + console.log("Selected search result:", { type, bonesetId, boneId, subboneId }); + + // Update dropdowns based on search result + updateDropdowns(type, bonesetId, boneId, subboneId); + + // Clear search after selection + clearSearch(); +} + +function updateDropdowns(type, bonesetId, boneId, subboneId) { + const bonesetSelect = document.getElementById("boneset-select"); + const boneSelect = document.getElementById("bone-select"); + const subboneSelect = document.getElementById("subbone-select"); + + // Always set boneset first + if (bonesetId && bonesetSelect) { + bonesetSelect.value = bonesetId; + bonesetSelect.dispatchEvent(new Event("change")); + + // Wait for bone dropdown to populate, then set bone + if (boneId && (type === "bone" || type === "subbone")) { + setTimeout(() => { + if (boneSelect) { + boneSelect.disabled = false; + boneSelect.value = boneId; + boneSelect.dispatchEvent(new Event("change")); + + // Wait for subbone dropdown to populate, then set subbone + if (subboneId && type === "subbone") { + setTimeout(() => { + if (subboneSelect) { + subboneSelect.disabled = false; + subboneSelect.value = subboneId; + subboneSelect.dispatchEvent(new Event("change")); + } + }, 200); + } + } + }, 200); + } + } +} + +function clearSearch() { + const searchBar = document.getElementById("search-bar"); + searchBar.value = ""; + clearSearchResults(); +} + +function clearSearchResults() { + const searchResults = document.getElementById("search-results"); + const searchLoading = document.getElementById("search-loading"); + + if (searchResults) { + searchResults.innerHTML = ""; + } + if (searchLoading) { + searchLoading.style.display = "none"; + } + selectedIndex = -1; +} + +// Initialize when DOM is loaded +document.addEventListener("DOMContentLoaded", initializeSearch); \ No newline at end of file diff --git a/templates/style.css b/templates/style.css index 7862007..730828a 100644 --- a/templates/style.css +++ b/templates/style.css @@ -226,6 +226,105 @@ ul li { margin-bottom: 0; } +/* Search functionality styles */ +.search-container { + position: relative; + margin-bottom: 10px; +} + +#search-bar { + width: 100%; + padding: 12px 16px; + border: 2px solid #ddd; + border-radius: 8px; + font-size: 16px; + transition: border-color 0.2s ease; + box-sizing: border-box; +} + +#search-bar:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); +} + +.search-loading { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + font-size: 12px; + color: #666; + background: white; + padding: 2px 6px; + border-radius: 4px; +} + +.search-results { + max-height: 300px; + overflow-y: auto; + border: 1px solid #ddd; + border-radius: 8px; + background: white; + margin-bottom: 20px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + list-style: none; + padding: 0; + margin: 0 0 20px 0; +} + +.search-results:empty { + display: none; +} + +.search-result { + padding: 12px 16px; + border-bottom: 1px solid #eee; + cursor: pointer; + transition: background-color 0.2s ease; + display: flex; + justify-content: space-between; + align-items: center; +} + +.search-result:last-child { + border-bottom: none; +} + +.search-result:hover, +.search-result:focus { + background-color: #f0f8ff; + outline: none; +} + +.search-result.selected { + background-color: #e6f3ff; +} + +.search-result small { + color: #666; + font-size: 0.85em; + font-weight: normal; +} + +.search-placeholder, +.search-error, +.search-no-results { + padding: 12px 16px; + color: #666; + font-style: italic; + cursor: default; + text-align: center; +} + +.search-error { + color: #d32f2f; +} + +.search-no-results { + color: #666; +} + /* Responsive design for smaller screens */ @media (max-width: 768px) { .viewer-wrapper { @@ -240,7 +339,7 @@ ul li { max-width: 100%; } } -======= + .help-modal { position: fixed; top: 0;