diff --git a/index.html b/index.html index 3152867..22328ef 100644 --- a/index.html +++ b/index.html @@ -183,7 +183,7 @@
- +
Loading categories...
@@ -221,23 +221,89 @@ updateCategoryProducts(id); }); + // Debounce function + function debounce(func, wait) { + let timeout; + return function (...args) { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(this, args), wait); + }; + } + + const debouncedSearch = debounce((value) => { + socket.emit('search', value); + }, 300); + // Event Listeners filterInput.addEventListener('input', (e) => { const value = e.target.value; state.filter = value; if (value.trim()) { - // Reset expansion first - resetExpansion(state.categories); - - // Get filtered tree to find best match - const filtered = filterTree(state.categories, value); - autoExpandBestMatch(filtered); + debouncedSearch(value); + } else { + // Clear matches + resetMatches(state.categories); + render(); } + }); + + socket.on('searchResults', ({ query, matches }) => { + if (query !== state.filter) return; + + const matchSet = new Set(matches); + + // Reset expansion and matches + resetExpansion(state.categories); + + // Mark matches and expand + markMatches(state.categories, matchSet); render(); }); + function resetMatches(nodes) { + nodes.forEach(node => { + node._hasMatch = false; + if (node.children) resetMatches(node.children); + }); + } + + function markMatches(nodes, matchSet) { + let anyMatch = false; + nodes.forEach(node => { + const isDirectMatch = matchSet.has(node.kKategorie); + let childMatch = false; + + if (node.children) { + childMatch = markMatches(node.children, matchSet); + } + + if (isDirectMatch || childMatch) { + node._hasMatch = true; + node._isDirectMatch = isDirectMatch; // Track if this node itself matches + anyMatch = true; + + // Expand if children have matches + if (childMatch) { + node.isChildrenExpanded = true; + } + + // Expand if direct match (to show products) + if (isDirectMatch) { + node.isChildrenExpanded = true; + node.isExpanded = true; + // Trigger load if needed + if (node.products === null) fetchProducts(node); + } + } else { + node._hasMatch = false; + node._isDirectMatch = false; + } + }); + return anyMatch; + } + function resetExpansion(nodes) { nodes.forEach(node => { node.isChildrenExpanded = false; @@ -246,42 +312,7 @@ }); } - function autoExpandBestMatch(nodes) { - let maxCount = -1; - let bestNode = null; - let bestPath = []; - const traverse = (currentNodes, path) => { - currentNodes.forEach(node => { - // Count direct matches - const count = node.products ? node.products.length : 0; - - if (count > maxCount) { - maxCount = count; - bestNode = node; - bestPath = [...path]; - } - - if (node.children) { - traverse(node.children, [...path, node]); - } - }); - }; - - traverse(nodes, []); - - if (bestNode) { - // Expand ancestors - bestPath.forEach(n => { - const real = n._original || n; - real.isChildrenExpanded = true; - }); - // Expand the node itself - const realBest = bestNode._original || bestNode; - realBest.isChildrenExpanded = true; - realBest.isExpanded = true; - } - } // Initial Load loadCategories(); @@ -309,7 +340,7 @@ node.isChildrenExpanded = false; // New state for subcategory expansion // Fetch products - fetchProducts(node); + // fetchProducts(node); // Lazy load instead if (node.children) { initCategories(node.children); @@ -350,37 +381,26 @@ function filterTree(nodes, query) { if (!query.trim()) return nodes; // Return original structure if no filter - const words = query.toLowerCase().split(/\s+/).filter(w => w); - return nodes.map(node => { - // Filter products - // If products are loading (null), we treat as no match for now - const matchingProducts = node.products - ? node.products.filter(p => { - const name = p.cName.toLowerCase(); - return words.every(w => name.includes(w)); - }) - : []; + // Only keep if marked as having a match + if (node._hasMatch) { + // Filter children + const matchingChildren = node.children ? filterTree(node.children, query) : []; - // Filter children - const matchingChildren = node.children ? filterTree(node.children, query) : []; + // Filter products if loaded + let matchingProducts = node.products; + if (matchingProducts) { + const words = query.toLowerCase().split(/\s+/).filter(w => w); + matchingProducts = matchingProducts.filter(p => { + const name = p.cName.toLowerCase(); + return words.every(w => name.includes(w)); + }); + } - // Keep node if it has matching products OR matching children - if (matchingProducts.length > 0 || matchingChildren.length > 0) { - // Return a shallow copy with filtered data - // We preserve the original reference for updates, but render the copy return { ...node, products: matchingProducts, children: matchingChildren, - // Preserve expansion state from original node? - // Actually we are copying properties, so isExpanded is copied. - // But if we modify isExpanded on the copy, it won't affect original. - // We need to handle interactions carefully. - // For this implementation, we'll let the render function modify the ORIGINAL node - // by looking it up or passing it through. - // Simpler: The filtered tree is just for rendering. - // Interaction handlers should target the ID and update the global state. _original: node }; } @@ -420,24 +440,26 @@ const header = document.createElement('div'); header.className = 'category-header'; - // Toggle for children - if (category.children && category.children.length > 0) { + // Toggle for children or products + const hasChildren = category.children && category.children.length > 0; + const hasProducts = category.articleCount > 0 || (category.products && category.products.length > 0); + + if (hasChildren || hasProducts) { header.classList.add('has-children'); const toggle = document.createElement('span'); toggle.className = 'toggle'; - toggle.textContent = '▶'; // We don't have collapse state for categories in requirements, only products - // Wait, the original code had collapse for subcategories! - // "childrenUl.classList.toggle('hidden')" - // I should preserve this. - // Let's add isChildrenExpanded to state. - if (realNode.isChildrenExpanded) { - toggle.textContent = '▼'; - } + toggle.textContent = realNode.isChildrenExpanded ? '▼' : '▶'; header.appendChild(toggle); header.onclick = (e) => { e.stopPropagation(); realNode.isChildrenExpanded = !realNode.isChildrenExpanded; + + // Lazy load products if expanding and not yet loaded + if (realNode.isChildrenExpanded && realNode.products === null) { + fetchProducts(realNode); + } + render(); }; } @@ -448,6 +470,7 @@ img.className = 'category-image'; img.src = `/img/cat/${category.kBild}.avif`; img.alt = category.cName; + img.loading = 'lazy'; img.onerror = () => img.style.display = 'none'; header.appendChild(img); } @@ -487,51 +510,61 @@ const productsDiv = document.createElement('div'); productsDiv.className = 'category-products'; - if (category.products === null) { - productsDiv.innerHTML = 'Loading products...'; - productsDiv.style.display = 'block'; - } else if (category.products.length > 0) { - productsDiv.style.display = 'block'; - const ul = document.createElement('ul'); - ul.style.listStyle = 'none'; + // Only show products if: + // 1. Not filtering (show all), OR + // 2. This category is a direct match (not just expanded due to child matches) + const shouldShowProducts = !state.filter || realNode._isDirectMatch; - const limit = realNode.isExpanded ? category.products.length : 3; + if (realNode.isChildrenExpanded && shouldShowProducts) { + if (category.products === null) { + productsDiv.innerHTML = 'Loading products...'; + productsDiv.style.display = 'block'; + } else if (category.products.length > 0) { + productsDiv.style.display = 'block'; + const ul = document.createElement('ul'); + ul.style.listStyle = 'none'; - category.products.slice(0, limit).forEach(p => { - const li = document.createElement('li'); - li.className = 'product-item'; + const limit = realNode.isExpanded ? category.products.length : 3; - if (p.images && p.images.length > 0) { - const img = document.createElement('img'); - img.className = 'product-image'; - img.src = `/img/prod/${p.images[0]}.avif`; - img.alt = p.cName; - img.onerror = () => img.style.display = 'none'; - li.appendChild(img); + category.products.slice(0, limit).forEach(p => { + const li = document.createElement('li'); + li.className = 'product-item'; + + if (p.images && p.images.length > 0) { + const img = document.createElement('img'); + img.className = 'product-image'; + img.src = `/img/prod/${p.images[0]}.avif`; + img.alt = p.cName; + img.loading = 'lazy'; + img.onerror = () => img.style.display = 'none'; + li.appendChild(img); + } + + const span = document.createElement('span'); + span.textContent = p.cName; + li.appendChild(span); + ul.appendChild(li); + }); + + if (!realNode.isExpanded && category.products.length > 3) { + const more = document.createElement('li'); + more.className = 'product-item more'; + more.style.fontStyle = 'italic'; + more.textContent = `...and ${category.products.length - 3} more`; + more.onclick = (e) => { + e.stopPropagation(); + // Collapse others + collapseAllProducts(state.categories); + realNode.isExpanded = true; + render(); + }; + ul.appendChild(more); } - const span = document.createElement('span'); - span.textContent = p.cName; - li.appendChild(span); - ul.appendChild(li); - }); - - if (!realNode.isExpanded && category.products.length > 3) { - const more = document.createElement('li'); - more.className = 'product-item more'; - more.style.fontStyle = 'italic'; - more.textContent = `...and ${category.products.length - 3} more`; - more.onclick = (e) => { - e.stopPropagation(); - // Collapse others - collapseAllProducts(state.categories); - realNode.isExpanded = true; - render(); - }; - ul.appendChild(more); + productsDiv.appendChild(ul); + } else { + productsDiv.style.display = 'none'; } - - productsDiv.appendChild(ul); } else { productsDiv.style.display = 'none'; } @@ -539,12 +572,9 @@ div.appendChild(productsDiv); // Children - if (category.children && category.children.length > 0) { + if (category.children && category.children.length > 0 && realNode.isChildrenExpanded) { const childrenUl = document.createElement('ul'); childrenUl.className = 'children'; - if (!realNode.isChildrenExpanded) { - childrenUl.classList.add('hidden'); - } category.children.forEach(child => { childrenUl.appendChild(renderCategory(child)); diff --git a/src/server/server.js b/src/server/server.js index 14db110..e59d9b3 100644 --- a/src/server/server.js +++ b/src/server/server.js @@ -99,7 +99,7 @@ export function startServer(categorySyncer, categoryProductsSyncer) { } // Register socket connection handler - registerConnection(io); + registerConnection(io, CACHE_DIR); // Register routes registerCategories(app, cache); diff --git a/src/server/socket/connection.js b/src/server/socket/connection.js index 6032ca8..986ebe2 100644 --- a/src/server/socket/connection.js +++ b/src/server/socket/connection.js @@ -1,6 +1,20 @@ -export function registerConnection(io) { +import { findMatches } from '../utils/search-helper.js'; + +export function registerConnection(io, cacheDir) { io.on('connection', (socket) => { console.log('🔌 Client connected'); + + socket.on('search', async (query) => { + // console.log(`🔍 Search request: "${query}"`); + try { + const matches = await findMatches(query, cacheDir); + socket.emit('searchResults', { query, matches }); + } catch (err) { + console.error('Search error:', err); + socket.emit('searchResults', { query, matches: [] }); + } + }); + socket.on('disconnect', () => { console.log('🔌 Client disconnected'); }); diff --git a/src/server/utils/search-helper.js b/src/server/utils/search-helper.js new file mode 100644 index 0000000..d292055 --- /dev/null +++ b/src/server/utils/search-helper.js @@ -0,0 +1,73 @@ +import fs from 'fs/promises'; +import path from 'path'; + +export async function findMatches(query, cacheDir) { + if (!query || !query.trim()) return []; + const terms = query.toLowerCase().split(/\s+/).filter(t => t); + + // Load category tree + const treePath = path.join(cacheDir, 'category_tree.json'); + let tree = []; + try { + const treeData = await fs.readFile(treePath, 'utf-8'); + tree = JSON.parse(treeData); + } catch (e) { + console.error("Failed to load category tree for search", e); + return []; + } + + const matchingCategoryIds = new Set(); + + // Helper to check text match + const isMatch = (text) => { + if (!text) return false; + const lower = text.toLowerCase(); + return terms.every(t => lower.includes(t)); + }; + + // Flatten tree to linear list for easier iteration + const queue = [...tree]; + const nodes = []; + while (queue.length > 0) { + const node = queue.shift(); + nodes.push(node); + if (node.children) { + queue.push(...node.children); + } + } + + // Process in chunks to avoid too many open files + const CHUNK_SIZE = 50; + for (let i = 0; i < nodes.length; i += CHUNK_SIZE) { + const chunk = nodes.slice(i, i + CHUNK_SIZE); + await Promise.all(chunk.map(async (node) => { + let nodeMatches = false; + + // Check category name + if (isMatch(node.cName)) { + nodeMatches = true; + } else { + // Check products + try { + const prodPath = path.join(cacheDir, 'products', `category_${node.kKategorie}.json`); + // Check if file exists before reading to avoid throwing too many errors + // Actually readFile throws ENOENT which is fine + const prodData = await fs.readFile(prodPath, 'utf-8'); + const products = JSON.parse(prodData); + + if (products.some(p => isMatch(p.cName))) { + nodeMatches = true; + } + } catch (e) { + // Ignore missing files + } + } + + if (nodeMatches) { + matchingCategoryIds.add(node.kKategorie); + } + })); + } + + return Array.from(matchingCategoryIds); +}