From a7d8668d4bc0c997272f05b80fe7f1ba6731ccc8 Mon Sep 17 00:00:00 2001 From: Jenni Oishee Date: Sun, 28 Sep 2025 23:13:56 -0500 Subject: [PATCH 1/2] Submitting --- boneset-api/server.js | 265 +++++++++++++++++++++++++----------------- 1 file changed, 156 insertions(+), 109 deletions(-) diff --git a/boneset-api/server.js b/boneset-api/server.js index e060ce8..adf3166 100644 --- a/boneset-api/server.js +++ b/boneset-api/server.js @@ -103,7 +103,6 @@ const PORT = process.env.PORT || 8000; app.use(cors()); -// ---- 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/`; @@ -113,10 +112,10 @@ 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, + windowMs: 60 * 1000, // 1 minute + max: 60, // 60 requests / min / IP + standardHeaders: true, + legacyHeaders: false, }); // ---- Only allow bonesets we ship locally right now ---- @@ -124,143 +123,191 @@ const ALLOWED_BONESETS = new Set(["bony_pelvis"]); // ---- 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; - } + 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; + } } // 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; + 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; } -// Tiny HTML escape (double-quotes everywhere for ESLint) function escapeHtml(str = "") { - return String(str).replace(/[&<>"']/g, (c) => ({ - "&": "&", - "<": "<", - ">": ">", - "\"": """, - "'": "'", - })[c]); + return String(str).replace(/[&<>"']/g, (c) => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", + })[c]); } // 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; + if (cachedBoneset) return cachedBoneset; + const file = safeDataPath("final_bony_pelvis.json"); + const raw = await fs.readFile(file, "utf8"); + cachedBoneset = JSON.parse(raw); + return cachedBoneset; } 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; + 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; + } } - } - return null; + return null; } -// ---- Routes ---- app.get("/", (_req, res) => { - res.json({ message: "Welcome to the Boneset API (GitHub-Integrated)" }); + res.json({ message: "Welcome to the Boneset API (GitHub-Integrated)" }); }); -// 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" }); - res.json({ bonesets, bones, subbones }); - } catch (error) { - console.error("Error fetching combined data:", error.message); - res.status(500).json({ error: "Internal Server Error" }); - } -}); + const bonesets = [{ id: bonesetData.id, name: bonesetData.name }]; + const bones = []; + const subbones = []; -// Serve description from the local merged JSON (no SSRF) -app.get("/api/description", bonesetLimiter, async (req, res) => { - const boneId = String(req.query.boneId || ""); + 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 }); + }); + } + } - // Basic allowlist-style validation - if (!/^[a-z0-9_]+$/.test(boneId)) { - return res.type("text/html").send(""); - } + res.json({ bonesets, bones, subbones }); + } catch (error) { + console.error("Error fetching combined data:", error.message); + res.status(500).json({ error: "Internal Server Error" }); + } +}); - 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 = `
  • ${descriptionData.name}
  • `; + descriptionData.description.forEach(point => { + html += `
  • ${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; - - if (!ALLOWED_BONESETS.has(bonesetId)) { - return res.status(404).json({ error: `Boneset '${bonesetId}' not found` }); - } - - 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` }); +app.get("/api/search", async (req, res) => { + const query = req.query.q; + + if (!query || query.trim() === "") { + return res.send("
  • Enter a search term
  • "); + } + + const searchTerm = query.toLowerCase().trim(); + + try { + const bonesetData = await fetchJSON(BONESET_JSON_URL); + if (!bonesetData) { + return res.send("
  • No data available
  • "); + } + + const results = []; + + // Search boneset name + if (bonesetData.name && bonesetData.name.toLowerCase().includes(searchTerm)) { + results.push({ + type: "boneset", + id: bonesetData.id, + name: bonesetData.name + }); + } + + // Search through bones + if (bonesetData.bones) { + for (const boneId of bonesetData.bones) { + const boneData = await fetchJSON(`${BONES_DIR_URL}${boneId}.json`); + + if (boneData) { + // Search bone name + if (boneData.name && boneData.name.toLowerCase().includes(searchTerm)) { + results.push({ + type: "bone", + id: boneData.id, + name: boneData.name + }); + } + + // Search sub-bones + if (boneData.subBones) { + for (const subBoneId of boneData.subBones) { + const subBoneName = subBoneId.replace(/_/g, " "); + if (subBoneName.toLowerCase().includes(searchTerm)) { + results.push({ + type: "subbone", + id: subBoneId, + name: subBoneName, + parentBone: boneData.id + }); + } + } + } + } + } + } + + // Format results as HTML + if (results.length === 0) { + return res.send("
  • No results found
  • "); + } + + let html = ""; + results.forEach(result => { + const escapedName = result.name.replace(/"/g, """).replace(/'/g, "'"); + html += `
  • ${escapedName} (${result.type})
  • `; + }); + + 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" }); - } }); +// Only one app.listen() at the very end 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 From afcd3a1fbc03853ff79b019a7649ca046ddd169f Mon Sep 17 00:00:00 2001 From: Jenni Oishee Date: Sun, 5 Oct 2025 22:25:26 -0500 Subject: [PATCH 2/2] Submitting --- boneset-api/server.js | 350 +++++++++++++++++------------------------ templates/boneset.html | 28 ++-- templates/js/main.js | 36 +++-- templates/js/search.js | 186 ++++++++++++++++++++++ templates/style.css | 101 +++++++++++- 5 files changed, 465 insertions(+), 236 deletions(-) create mode 100644 templates/js/search.js diff --git a/boneset-api/server.js b/boneset-api/server.js index adf3166..d0505ad 100644 --- a/boneset-api/server.js +++ b/boneset-api/server.js @@ -1,127 +1,42 @@ -//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()); 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 +// 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; + +// HTML escaping helper +function escapeHtml(str = "") { + return String(str).replace(/[&<>"']/g, (c) => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", + })[c]); +} -// ---- Helpers ---- +// GitHub JSON fetcher async function fetchJSON(url) { try { const response = await axios.get(url, { timeout: 10_000 }); @@ -132,66 +47,112 @@ async function fetchJSON(url) { } } -// 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; -} +// 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; + } -function escapeHtml(str = "") { - return String(str).replace(/[&<>"']/g, (c) => ({ - "&": "&", - "<": "<", - ">": ">", - "\"": """, - "'": "'", - })[c]); -} + const searchData = []; -// 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; -} + // Add boneset to search data + searchData.push({ + id: bonesetData.id, + name: bonesetData.name, + type: "boneset", + boneset: bonesetData.id, + bone: null, + subbone: null + }); -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; + // 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); } - return null; } +// 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 }); + } + } + + // 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 app.get("/", (_req, res) => { - res.json({ message: "Welcome to the Boneset API (GitHub-Integrated)" }); + res.json({ message: "Welcome to the Boneset API" }); }); 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" }); + 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); + 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) => { @@ -212,102 +173,75 @@ app.get("/api/description/", async (req, res) => { if (!boneId) { return res.send(" "); } + 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}
  • `; + let html = `
  • ${escapeHtml(descriptionData.name)}
  • `; descriptionData.description.forEach(point => { - html += `
  • ${point}
  • `; + html += `
  • ${escapeHtml(point)}
  • `; }); res.send(html); - } catch (error) { res.send("
  • Description not available.
  • "); } }); -app.get("/api/search", async (req, res) => { +// Search endpoint +app.get("/api/search", searchLimiter, (req, res) => { const query = req.query.q; + + console.log("Search request received for:", query); - if (!query || query.trim() === "") { - return res.send("
  • Enter a search term
  • "); + // Handle empty or too short queries + if (!query || query.trim().length < 2) { + return res.send("
  • Enter at least 2 characters to search
  • "); } - const searchTerm = query.toLowerCase().trim(); - + const searchTerm = query.trim(); + try { - const bonesetData = await fetchJSON(BONESET_JSON_URL); - if (!bonesetData) { - return res.send("
  • No data available
  • "); - } - - const results = []; - - // Search boneset name - if (bonesetData.name && bonesetData.name.toLowerCase().includes(searchTerm)) { - results.push({ - type: "boneset", - id: bonesetData.id, - name: bonesetData.name - }); + if (!searchCache) { + return res.send("
  • Search not available - cache not initialized
  • "); } - // Search through bones - if (bonesetData.bones) { - for (const boneId of bonesetData.bones) { - const boneData = await fetchJSON(`${BONES_DIR_URL}${boneId}.json`); + const results = searchItems(searchTerm, 20); + console.log(`Found ${results.length} results for "${searchTerm}"`); - if (boneData) { - // Search bone name - if (boneData.name && boneData.name.toLowerCase().includes(searchTerm)) { - results.push({ - type: "bone", - id: boneData.id, - name: boneData.name - }); - } - - // Search sub-bones - if (boneData.subBones) { - for (const subBoneId of boneData.subBones) { - const subBoneName = subBoneId.replace(/_/g, " "); - if (subBoneName.toLowerCase().includes(searchTerm)) { - results.push({ - type: "subbone", - id: subBoneId, - name: subBoneName, - parentBone: boneData.id - }); - } - } - } - } - } - } - - // Format results as HTML if (results.length === 0) { - return res.send("
  • No results found
  • "); + return res.send("
  • No results found
  • "); } let html = ""; - results.forEach(result => { - const escapedName = result.name.replace(/"/g, """).replace(/'/g, "'"); - html += `
  • ${escapedName} (${result.type})
  • `; - }); + 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
  • "); + res.status(500).send("
  • Search error occurred
  • "); } }); -// Only one app.listen() at the very end +// Initialize search cache on startup +initializeSearchCache(); + app.listen(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;