diff --git a/index.html b/index.html
index 2c823ad..3152867 100644
--- a/index.html
+++ b/index.html
@@ -1,5 +1,6 @@
+
-
📦 Category Tree
+
@@ -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);
+ });
+ }
-
+
+