diff --git a/index.html b/index.html index 2c823ad..3152867 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,6 @@ + @@ -34,6 +35,8 @@ text-align: center; } + + .tree { list-style: none; } @@ -50,6 +53,9 @@ border-radius: 8px; border-left: 4px solid #667eea; transition: all 0.3s ease; + } + + .category-header.has-children { cursor: pointer; } @@ -117,6 +123,7 @@ border-radius: 8px; text-align: center; } + .category-products { margin-top: 0.5rem; padding-left: 4rem; @@ -143,11 +150,40 @@ .product-item:last-child { border-bottom: none; } + + .product-item.more { + cursor: pointer; + color: #667eea; + transition: color 0.2s; + } + + .product-item.more:hover { + color: #764ba2; + text-decoration: underline; + } + + #filter-input { + width: 100%; + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 1rem; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); + transition: border-color 0.3s ease, box-shadow 0.3s ease; + } + + #filter-input:focus { + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2); + outline: none; + } +
-

📦 Category Tree

+
Loading categories...
@@ -159,6 +195,18 @@ transports: ['websocket'] }); + // State management + const state = { + categories: [], + filter: '', + // We store expansion state directly on category objects + }; + + // DOM Elements + const container = document.getElementById('tree-container'); + const filterInput = document.getElementById('filter-input'); + + // Socket Events socket.on('connect', () => { console.log('🔌 Connected to server via WebSocket'); }); @@ -170,55 +218,230 @@ socket.on('categoryProductsUpdated', ({ id }) => { console.log(`🔄 Products for category ${id} updated, reloading...`); - // Find the specific category element and reload its products - // Since we don't have easy access to instances, we could reload the whole tree - // or dispatch a custom event. For simplicity, we'll reload the tree for now - // but ideally we'd target the specific DOM element. - - // Better approach: emit event that specific components can listen to - document.dispatchEvent(new CustomEvent('productsUpdated', { detail: { id } })); + updateCategoryProducts(id); }); + // 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); + } + + render(); + }); + + function resetExpansion(nodes) { + nodes.forEach(node => { + node.isChildrenExpanded = false; + node.isExpanded = false; + if (node.children) resetExpansion(node.children); + }); + } + + 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(); + async function loadCategories() { try { const response = await fetch('/api/categories'); if (!response.ok) throw new Error('Failed to load categories'); - const categories = await response.json(); - renderTree(categories); + state.categories = await response.json(); + + // Initialize and fetch products for all categories + initCategories(state.categories); + + render(); } catch (err) { - document.getElementById('tree-container').innerHTML = - `
❌ ${err.message}
`; + container.innerHTML = `
❌ ${err.message}
`; } } - function renderTree(categories) { - const container = document.getElementById('tree-container'); + function initCategories(nodes) { + nodes.forEach(node => { + // Initialize state properties + node.products = null; // null means loading + node.isExpanded = false; + node.isChildrenExpanded = false; // New state for subcategory expansion + + // Fetch products + fetchProducts(node); + + if (node.children) { + initCategories(node.children); + } + }); + } + + function fetchProducts(node) { + fetch(`/api/categories/${node.kKategorie}/products`) + .then(res => res.ok ? res.json() : []) + .then(products => { + node.products = products; + render(); // Re-render on data arrival + }) + .catch(() => { + node.products = []; + render(); + }); + } + + function updateCategoryProducts(id) { + const findAndReload = (nodes) => { + for (const node of nodes) { + if (node.kKategorie === id) { + node.products = null; // Set to loading + render(); + fetchProducts(node); + return true; + } + if (node.children && findAndReload(node.children)) return true; + } + return false; + }; + findAndReload(state.categories); + } + + // Filtering Logic + 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)); + }) + : []; + + // Filter children + const matchingChildren = node.children ? filterTree(node.children, query) : []; + + // 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 + }; + } + return null; + }).filter(n => n !== null); + } + + // Rendering + function render() { + const filteredCategories = filterTree(state.categories, state.filter); + container.innerHTML = ''; + + if (state.filter && filteredCategories.length === 0) { + container.innerHTML = '
No matching articles found.
'; + return; + } + const ul = document.createElement('ul'); ul.className = 'tree'; - categories.forEach(cat => { + filteredCategories.forEach(cat => { ul.appendChild(renderCategory(cat)); }); container.appendChild(ul); } function renderCategory(category) { + // category might be a filtered copy or original. + // If it's a copy, it has _original pointing to the real state node. + const realNode = category._original || category; + const li = document.createElement('li'); - + const div = document.createElement('div'); div.className = 'category'; - + const header = document.createElement('div'); header.className = 'category-header'; - + // Toggle for children if (category.children && category.children.length > 0) { + header.classList.add('has-children'); const toggle = document.createElement('span'); toggle.className = 'toggle'; - toggle.textContent = '▼'; + 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 = '▼'; + } header.appendChild(toggle); + + header.onclick = (e) => { + e.stopPropagation(); + realNode.isChildrenExpanded = !realNode.isChildrenExpanded; + render(); + }; } - + // Image if (category.kBild) { const img = document.createElement('img'); @@ -228,120 +451,118 @@ img.onerror = () => img.style.display = 'none'; header.appendChild(img); } - + // Info const info = document.createElement('div'); info.className = 'category-info'; - + const name = document.createElement('div'); name.className = 'category-name'; name.textContent = category.cName; info.appendChild(name); - + const count = document.createElement('div'); count.className = 'category-count'; - count.textContent = `${category.articleCount} articles`; + // Use original counts or filtered counts? + // "remove all articles ... with no match" + // If filtered, we should probably show the count of MATCHING articles? + // User didn't specify, but it makes sense to show matching count if filtered. + // But articleCount comes from API. + // We can calculate it from products array if loaded. + const productCount = category.products ? category.products.length : category.articleCount; + let countText = `${productCount} articles`; + + // Always show subcategory count if children exist (User requirement) + // Use filtered children length + if (category.children && category.children.length > 0) { + countText += `, ${category.children.length} subcategories`; + } + count.textContent = countText; info.appendChild(count); - + header.appendChild(info); div.appendChild(header); // Products const productsDiv = document.createElement('div'); productsDiv.className = 'category-products'; - productsDiv.innerHTML = 'Loading products...'; - div.appendChild(productsDiv); - // Load products - const loadProducts = () => { + if (category.products === null) { productsDiv.innerHTML = 'Loading products...'; productsDiv.style.display = 'block'; - - fetch(`/api/categories/${category.kKategorie}/products`) - .then(res => res.ok ? res.json() : []) - .then(products => { - productsDiv.innerHTML = ''; - if (products.length === 0) { - productsDiv.style.display = 'none'; - return; - } - - const ul = document.createElement('ul'); - ul.style.listStyle = 'none'; - - products.slice(0, 3).forEach(p => { - const li = document.createElement('li'); - li.className = 'product-item'; - - // Image - 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); - } + } else if (category.products.length > 0) { + productsDiv.style.display = 'block'; + const ul = document.createElement('ul'); + ul.style.listStyle = 'none'; - const span = document.createElement('span'); - span.textContent = p.cName; - li.appendChild(span); + const limit = realNode.isExpanded ? category.products.length : 3; - ul.appendChild(li); - }); + category.products.slice(0, limit).forEach(p => { + const li = document.createElement('li'); + li.className = 'product-item'; - if (products.length > 3) { - const more = document.createElement('li'); - more.className = 'product-item'; - more.style.fontStyle = 'italic'; - more.textContent = `...and ${products.length - 3} more`; - ul.appendChild(more); - } + 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); + } - productsDiv.appendChild(ul); - }) - .catch(() => { - productsDiv.style.display = 'none'; - }); - }; + const span = document.createElement('span'); + span.textContent = p.cName; + li.appendChild(span); + ul.appendChild(li); + }); - loadProducts(); - - // Listen for updates - const updateHandler = (e) => { - if (e.detail.id === category.kKategorie) { - console.log(`✨ refreshing products for category ${category.kKategorie}`); - loadProducts(); + 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); } - }; - document.addEventListener('productsUpdated', updateHandler); - + + productsDiv.appendChild(ul); + } else { + productsDiv.style.display = 'none'; + } + + div.appendChild(productsDiv); + // Children if (category.children && category.children.length > 0) { const childrenUl = document.createElement('ul'); childrenUl.className = 'children'; + if (!realNode.isChildrenExpanded) { + childrenUl.classList.add('hidden'); + } + category.children.forEach(child => { childrenUl.appendChild(renderCategory(child)); }); div.appendChild(childrenUl); - - // Toggle functionality - div.addEventListener('click', (e) => { - if (e.target.closest('.children')) return; - e.stopPropagation(); - childrenUl.classList.toggle('hidden'); - const toggle = div.querySelector('.toggle'); - if (toggle) { - toggle.textContent = childrenUl.classList.contains('hidden') ? '▶' : '▼'; - } - }); } - + li.appendChild(div); return li; } - loadCategories(); + function collapseAllProducts(nodes) { + nodes.forEach(node => { + node.isExpanded = false; + if (node.children) collapseAllProducts(node.children); + }); + } - + + \ No newline at end of file diff --git a/src/server/routes/images.js b/src/server/routes/images.js index 4d3c4f7..d0aed12 100644 --- a/src/server/routes/images.js +++ b/src/server/routes/images.js @@ -30,7 +30,9 @@ export function registerImages(app, cacheDir) { } }, (err) => { if (err) { - console.error(`❌ Error serving image ${resolvedPath}:`, err); + if (err.code !== 'ENOENT') { + console.error(`❌ Error serving image ${resolvedPath}:`, err); + } if (!res.headersSent) { res.status(404).send('Image not found'); } diff --git a/src/syncers/category-products-syncer.js b/src/syncers/category-products-syncer.js index 883f85e..c9e64a1 100644 --- a/src/syncers/category-products-syncer.js +++ b/src/syncers/category-products-syncer.js @@ -113,10 +113,17 @@ class CategoryProductsSyncer extends EventEmitter { JOIN tArtikelBeschreibung ab ON ka.kArtikel = ab.kArtikel JOIN tArtikel a ON ka.kArtikel = a.kArtikel WHERE ab.kSprache = ${process.env.JTL_SPRACHE_ID} + AND a.cAktiv = 'Y' AND ab.kPlattform = ${process.env.JTL_PLATTFORM_ID} AND ab.kShop = ${process.env.JTL_SHOP_ID} AND ka.kKategorie IN (${list}) - ORDER BY (CASE WHEN a.bRowversion > ab.bRowversion THEN a.bRowversion ELSE ab.bRowversion END) DESC + ORDER BY ( + CASE + WHEN a.bRowversion >= ab.bRowversion AND a.bRowversion >= ka.bRowversion THEN a.bRowversion + WHEN ab.bRowversion >= a.bRowversion AND ab.bRowversion >= ka.bRowversion THEN ab.bRowversion + ELSE ka.bRowversion + END + ) DESC `); // Collect all kArtikel IDs to fetch images diff --git a/src/syncers/category-syncer.js b/src/syncers/category-syncer.js index e0cdf8d..9153a56 100644 --- a/src/syncers/category-syncer.js +++ b/src/syncers/category-syncer.js @@ -88,6 +88,7 @@ class CategorySyncer extends EventEmitter { const categoriesResult = await pool.request().query(` SELECT kKategorie, kOberKategorie, nSort FROM tkategorie + ORDER BY nSort, kKategorie `); // Fetch names @@ -201,8 +202,19 @@ class CategorySyncer extends EventEmitter { const rootNodes = []; + // Parse excluded IDs + const excludedIds = new Set( + (process.env.EXCLUDE_CATEGORY_IDS || '') + .split(',') + .map(id => parseInt(id.trim())) + .filter(id => !isNaN(id)) + ); + // Build hierarchy categories.forEach(cat => { + // Skip if excluded + if (excludedIds.has(cat.kKategorie)) return; + const node = categoryMap.get(cat.kKategorie); if (cat.kOberKategorie === 0) { rootNodes.push(node); @@ -221,18 +233,22 @@ class CategorySyncer extends EventEmitter { let resultNodes = rootNodes; if (rootId && applyRootFilter) { - const specificRoot = categoryMap.get(rootId); - // Return the children of the specified root, not the root itself - resultNodes = specificRoot ? specificRoot.children : []; + if (excludedIds.has(rootId)) { + resultNodes = []; + } else { + const specificRoot = categoryMap.get(rootId); + // Return the children of the specified root, not the root itself + resultNodes = specificRoot ? specificRoot.children : []; + } } // Sort children and remove nSort for (const node of categoryMap.values()) { - node.children.sort((a, b) => a.nSort - b.nSort); + node.children.sort((a, b) => a.nSort - b.nSort || a.kKategorie - b.kKategorie); } // Sort root nodes if returning multiple - resultNodes.sort((a, b) => a.nSort - b.nSort); + resultNodes.sort((a, b) => a.nSort - b.nSort || a.kKategorie - b.kKategorie); // Remove nSort property from all nodes for (const node of categoryMap.values()) {