tags
- const matches = [...html.matchAll(/href="([^"]+)"/g)];
- const imageExts = /\.(png|jpg|jpeg|gif|svg|webp)$/i;
-
- this._imageListCache = matches
- .map(m => {
- // Normalise: strip any leading path so we keep just the filename
- // e.g. "/local/images/shopping_list_manager/milk.png" โ "milk.png"
- let h = m[1];
- const lastSlash = h.lastIndexOf('/');
- if (lastSlash !== -1) h = h.substring(lastSlash + 1);
- return h;
- })
- .filter(h => imageExts.test(h));
-
- console.log('[ShoppingList] Parsed image list (' + this._imageListCache.length + ' files):', this._imageListCache);
- } catch (e) {
- console.warn('[ShoppingList] Could not fetch image list:', e, 'โ falling back to direct-guess mode');
- this._imageListCache = [];
- }
- return this._imageListCache;
- }
-
- /**
- * Score a single filename against the product name.
- * Returns 0-100. Higher = better match.
- *
- * Strategy (order of priority):
- * 100 โ exact stem match "Milk" vs "milk.png"
- * 80 โ stem starts-with product "Milk" vs "milk_whole.png"
- * 70 โ product starts-with stem "Whole milk" vs "milk.png" (rare but possible)
- * 50 โ product contains stem "Semi skimmed milk" vs "milk.png"
- * 40 โ stem contains product "milk_chocolate" vs "milk" โ product is a substring
- * 20 โ any shared token (word) "organic_milk" vs "milk_2litre"
- * 0 โ no overlap
- */
- _scoreImage(productName, filename) {
- // Strip extension and path, normalise
- const stem = filename.replace(/\.[^.]+$/, '').toLowerCase().replace(/[-]/g, '_');
- const prod = productName.toLowerCase().trim().replace(/[\s-]+/g, '_');
-
- if (stem === prod) return 100;
- if (stem.startsWith(prod)) return 80;
- if (prod.startsWith(stem)) return 70;
- if (prod.includes(stem)) return 50;
- if (stem.includes(prod)) return 40;
-
- // Token-level overlap
- const stemTokens = new Set(stem.split('_').filter(Boolean));
- const prodTokens = new Set(prod.split('_').filter(Boolean));
- for (const t of prodTokens) {
- if (stemTokens.has(t)) return 20;
- }
- return 0;
- }
-
- /**
- * Find the best-matching local image for a product name.
- * Fetches the directory listing once, then scores every file.
- * Results are cached per product name for the session.
- */
- /**
- * Try to load a single URL; resolves true/false within 2 s.
- */
- _testImageUrl(url) {
- return new Promise((resolve) => {
- const img = new Image();
- img.onload = () => resolve(true);
- img.onerror = () => resolve(false);
- img.src = url;
- setTimeout(() => resolve(false), 2000);
- });
- }
-
- /**
- * Direct-guess fallback: try the product name (and common
- * singular/plural tweaks) with every extension directly.
- * Returns the first URL that loads, or null.
- */
- async _guessImageDirect(productName) {
- const dir = '/local/images/shopping_list_manager/';
- const base = productName.toLowerCase().trim().replace(/[\s-]+/g, '_');
- const exts = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'];
-
- // Build candidate stems: exact, +s, -s, -es, -iesโy
- const stems = [base];
- if (!base.endsWith('s')) stems.push(base + 's'); // carrot โ carrots
- if (base.endsWith('s')) stems.push(base.slice(0,-1)); // carrots โ carrot
- if (base.endsWith('es')) stems.push(base.slice(0,-2)); // tomatoes โ tomat (covered by -s too)
- if (base.endsWith('ies')) stems.push(base.slice(0,-3) + 'y'); // berries โ berry
-
- for (const stem of stems) {
- for (const ext of exts) {
- const url = `${dir}${stem}.${ext}`;
- if (await this._testImageUrl(url)) {
- console.log('[ShoppingList] Direct-guess hit:', url);
- return url;
- }
- }
- }
- return null;
- }
-
- async _findLocalImage(productName) {
- const cacheKey = productName.toLowerCase().trim();
- if (this._localImageCache[cacheKey] !== undefined) {
- return this._localImageCache[cacheKey];
- }
-
- const dir = '/local/images/shopping_list_manager/';
- const files = await this._fetchImageList();
-
- let bestScore = 0;
- let bestFile = null;
-
- // --- Pass 1: score against the directory listing (if we got one) ---
- if (files.length > 0) {
- for (const file of files) {
- const score = this._scoreImage(productName, file);
- if (score > bestScore) {
- bestScore = score;
- bestFile = file;
- if (score === 100) break;
- }
- }
- console.log('[ShoppingList] Best listing match for "' + productName + '":', bestFile, 'score=' + bestScore);
- }
-
- // Accept listing match if score >= 20
- if (bestScore >= 20 && bestFile) {
- const result = `${dir}${bestFile}`;
- this._localImageCache[cacheKey] = result;
- return result;
- }
-
- // --- Pass 2: direct HTTP guess (works even when listing is empty/404) ---
- console.log('[ShoppingList] Listing empty or no match โ trying direct guess for "' + productName + '"');
- const guessed = await this._guessImageDirect(productName);
- this._localImageCache[cacheKey] = guessed;
- return guessed;
- }
-
- /**
- * Search for appropriate emoji based on product name
- */
- _searchEmoji(productName) {
- const name = productName.toLowerCase().trim();
-
- // Common food and grocery emojis mapped to keywords
- const emojiMap = {
- // Fruits
- 'apple': '๐', 'banana': '๐', 'orange': '๐', 'lemon': '๐', 'lime': '๐',
- 'watermelon': '๐', 'grapes': '๐', 'grape': '๐', 'strawberry': '๐', 'strawberries': '๐',
- 'melon': '๐', 'cherry': '๐', 'cherries': '๐', 'peach': '๐', 'pear': '๐',
- 'pineapple': '๐', 'kiwi': '๐ฅ', 'avocado': '๐ฅ', 'mango': '๐ฅญ', 'coconut': '๐ฅฅ',
-
- // Vegetables
- 'tomato': '๐
', 'tomatoes': '๐
', 'cucumber': '๐ฅ', 'carrot': '๐ฅ', 'carrots': '๐ฅ',
- 'potato': '๐ฅ', 'potatoes': '๐ฅ', 'corn': '๐ฝ', 'pepper': '๐ซ', 'peppers': '๐ซ',
- 'broccoli': '๐ฅฆ', 'lettuce': '๐ฅฌ', 'salad': '๐ฅ', 'onion': '๐ง
', 'onions': '๐ง
',
- 'garlic': '๐ง', 'mushroom': '๐', 'mushrooms': '๐', 'eggplant': '๐', 'aubergine': '๐',
-
- // Meat & Protein
- 'meat': '๐ฅฉ', 'beef': '๐ฅฉ', 'steak': '๐ฅฉ', 'chicken': '๐', 'poultry': '๐',
- 'bacon': '๐ฅ', 'pork': '๐ฅ', 'ham': '๐', 'sausage': '๐ญ', 'egg': '๐ฅ', 'eggs': '๐ฅ',
- 'fish': '๐', 'salmon': '๐', 'tuna': '๐', 'shrimp': '๐ฆ', 'prawn': '๐ฆ',
-
- // Dairy
- 'milk': '๐ฅ', 'cheese': '๐ง', 'butter': '๐ง', 'yogurt': '๐ฅ', 'yoghurt': '๐ฅ',
- 'cream': '๐ฅ', 'ice cream': '๐ฆ', 'icecream': '๐ฆ',
-
- // Bakery
- 'bread': '๐', 'baguette': '๐ฅ', 'croissant': '๐ฅ', 'bagel': '๐ฅฏ', 'pretzel': '๐ฅจ',
- 'roll': '๐ฅ', 'rolls': '๐ฅ', 'bun': '๐', 'buns': '๐', 'cake': '๐', 'cookie': '๐ช',
- 'cookies': '๐ช', 'donut': '๐ฉ', 'doughnut': '๐ฉ', 'pie': '๐ฅง', 'muffin': '๐ง',
-
- // Pantry
- 'pasta': '๐', 'spaghetti': '๐', 'rice': '๐', 'noodles': '๐', 'ramen': '๐',
- 'pizza': '๐', 'burger': '๐', 'sandwich': '๐ฅช', 'taco': '๐ฎ', 'burrito': '๐ฏ',
- 'soup': '๐ฒ', 'stew': '๐ฒ', 'can': '๐ฅซ', 'canned': '๐ฅซ', 'jar': '๐ซ',
-
- // Snacks & Sweets
- 'chocolate': '๐ซ', 'candy': '๐ฌ', 'lollipop': '๐ญ', 'chips': '๐', 'crisps': '๐',
- 'popcorn': '๐ฟ', 'nuts': '๐ฅ', 'peanut': '๐ฅ', 'honey': '๐ฏ',
-
- // Drinks
- 'coffee': 'โ', 'tea': '๐ต', 'beer': '๐บ', 'wine': '๐ท', 'champagne': '๐พ',
- 'juice': '๐ง', 'soda': '๐ฅค', 'water': '๐ง', 'bottle': '๐พ',
-
- // Condiments
- 'salt': '๐ง', 'pepper': '๐ซ', 'oil': '๐ซ', 'vinegar': '๐ซ', 'sauce': '๐ฅซ',
- 'ketchup': '๐
', 'mustard': '๐ญ', 'mayo': '๐ฅ', 'mayonnaise': '๐ฅ',
-
- // Household
- 'toilet': '๐งป', 'paper': '๐งป', 'soap': '๐งผ', 'shampoo': '๐งด', 'detergent': '๐งด',
- 'cleaning': '๐งน', 'bleach': '๐งด', 'sponge': '๐งฝ', 'trash': '๐๏ธ', 'bin': '๐๏ธ',
-
- // Baby
- 'baby': '๐ถ', 'diaper': '๐ผ', 'nappy': '๐ผ', 'formula': '๐ผ', 'wipes': '๐งป',
-
- // Pet
- 'dog': '๐', 'cat': '๐', 'pet': '๐พ', 'food': '๐พ'
- };
-
- // Try exact match first
- if (emojiMap[name]) {
- return emojiMap[name];
- }
-
- // Try partial matches (check if any keyword is in the product name)
- for (const [keyword, emoji] of Object.entries(emojiMap)) {
- if (name.includes(keyword)) {
- return emoji;
- }
- }
-
- // Default fallback
- return '๐';
- }
-
- /**
- * Add a new product - opens the unified dialog in "new" mode
- */
- async _addNewProduct(name) {
- const key = this._generateKey(name);
-
- if (this._products[key]) {
- alert('Product already exists');
- return;
- }
-
- // Auto-search: try local image first, then fall back to emoji
- const localImage = await this._findLocalImage(name);
- const autoImage = localImage || this._searchEmoji(name);
-
- // Open the same dialog used for editing, but in "new" mode
- const result = await this._showEditDialog(null, { name, image: autoImage });
-
- // Always clear search after dialog closes (cancel or save)
- this._searchQuery = '';
- const _sb = this.shadowRoot.querySelector('.search-bar');
- if (_sb) _sb.value = '';
- const _sc = this.shadowRoot.querySelector('.search-clear');
- if (_sc) _sc.style.display = 'none';
- const _addBtn = this.shadowRoot.querySelector('.settings-btn');
- if (_addBtn) { _addBtn.textContent = 'โ๏ธ'; _addBtn.title = 'Settings'; }
- this._updateContent();
-
- if (!result || result.action !== 'save') return;
-
- try {
- await this._hass.connection.sendMessagePromise({
- type: 'shopping_list_manager/add_product',
- key: key,
- name: result.name,
- category: result.category,
- unit: 'pcs',
- image: result.image || ''
- });
-
- await this._hass.connection.sendMessagePromise({
- type: 'shopping_list_manager/set_qty',
- key: key,
- qty: 1
- });
-
- await this._loadData();
- this._hapticFeedback();
- } catch (error) {
- console.error('Failed to add product:', error);
- alert('Failed to add product: ' + error.message);
- }
- }
-
- /**
- * Show product edit dialog
- */
- async _showEditDialog(productKey, newProductDefaults = null) {
- // "new" mode when productKey is null and defaults are passed
- const isNew = !productKey && newProductDefaults;
- const product = isNew
- ? { name: newProductDefaults.name || '', image: newProductDefaults.image || '', category: 'other' }
- : this._products[productKey];
- if (!product) return;
-
- return new Promise((resolve) => {
- const modal = document.createElement('div');
- modal.style.cssText = `
- position: fixed;
- top: 0;
- left: 0;
- width: 100vw;
- height: 100vh;
- background: rgba(0,0,0,0.5);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 100000;
- isolation: isolate;
- `;
-
- const dialog = document.createElement('div');
- dialog.style.cssText = `
- background: var(--card-background-color);
- border-radius: 12px;
- padding: 24px;
- max-width: 400px;
- width: 90%;
- `;
-
- const currentCategory = CATEGORY_MAP[product.category] || CATEGORY_MAP['other'];
-
- dialog.innerHTML = `
-
- ${isNew ? 'New Product' : 'Edit Product'}
-
-
-
-
-
-
-
-
-
-
-
- Enter URL (http://...) or emoji. Leave empty for ๐
-
-
-
-
-
-
-
-
-
- ${isNew ? '' : ``}
-
-
-
-
-
- `;
-
- modal.appendChild(dialog);
- document.body.appendChild(modal);
-
- // Save button
- dialog.querySelector('.save-btn').addEventListener('click', () => {
- const newName = dialog.querySelector('.edit-name').value.trim();
- const newImage = dialog.querySelector('.edit-image').value.trim();
- const newCategory = dialog.querySelector('.edit-category').value;
- if (!newName) {
- alert('Product name cannot be empty');
- return;
- }
- document.body.removeChild(modal);
- resolve({ action: 'save', name: newName, category: newCategory, image: newImage });
- });
-
- // Delete button (only present in edit mode)
- const deleteBtn = dialog.querySelector('.delete-btn');
- if (deleteBtn) {
- deleteBtn.addEventListener('click', () => {
- if (confirm(`Delete "${product.name}"?`)) {
- document.body.removeChild(modal);
- resolve({ action: 'delete' });
- }
- });
- }
-
- // Cancel button
- dialog.querySelector('.cancel-btn').addEventListener('click', () => {
- document.body.removeChild(modal);
- resolve(null);
- });
-
- // Backdrop click
- modal.addEventListener('click', (e) => {
- if (e.target === modal) {
- document.body.removeChild(modal);
- resolve(null);
- }
- });
- });
- }
-
- /**
- * Edit a product
- */
- async _editProduct(productKey) {
- const result = await this._showEditDialog(productKey);
- if (!result) return;
-
- try {
- if (result.action === 'delete') {
- // Delete product
- await this._hass.connection.sendMessagePromise({
- type: 'shopping_list_manager/delete_product',
- key: productKey
- });
-
- // Reload data
- await this._loadData();
- this._hapticFeedback();
- } else if (result.action === 'save') {
- // Update product
- await this._hass.connection.sendMessagePromise({
- type: 'shopping_list_manager/add_product',
- key: productKey,
- name: result.name,
- category: result.category,
- unit: 'pcs',
- image: result.image || ''
- });
-
- // Reload data
- await this._loadData();
- this._hapticFeedback();
- }
- } catch (error) {
- console.error('Failed to edit product:', error);
- alert('Failed to edit product: ' + error.message);
- }
- }
-
- /**
- * Set quantity for a product
- */
- async _setQuantity(productKey, qty) {
- const currentQty = this._activeList[productKey]?.qty || 0;
-
- // Optimistic update
- if (qty === 0) {
- delete this._activeList[productKey];
- } else {
- this._activeList[productKey] = { qty };
- }
- this._render();
- this._hapticFeedback();
-
- try {
- await this._hass.connection.sendMessagePromise({
- type: 'shopping_list_manager/set_qty',
- key: productKey,
- qty: qty
- });
- } catch (error) {
- console.error('Failed to set quantity:', error);
- // Revert on error
- if (currentQty === 0) {
- delete this._activeList[productKey];
- } else {
- this._activeList[productKey] = { qty: currentQty };
- }
- this._render();
- alert('Failed to update quantity');
- }
- }
-
- /**
- * Toggle product on/off list
- */
- async _toggleProduct(productKey) {
- const currentQty = this._activeList[productKey]?.qty || 0;
- const newQty = currentQty > 0 ? 0 : 1;
- await this._setQuantity(productKey, newQty);
- }
-
- /**
- * Increment quantity
- */
- async _incrementProduct(productKey) {
- const currentQty = this._activeList[productKey]?.qty || 0;
- await this._setQuantity(productKey, currentQty + 1);
- }
-
- /**
- * Decrement quantity
- */
- async _decrementProduct(productKey) {
- const currentQty = this._activeList[productKey]?.qty || 0;
- if (currentQty > 0) {
- await this._setQuantity(productKey, currentQty - 1);
- }
- }
-
- /**
- * Generate product key from name
- */
- _generateKey(name) {
- return name.toLowerCase()
- .replace(/[^a-z0-9]+/g, '_')
- .replace(/^_+|_+$/g, '');
- }
-
- /**
- * Haptic feedback based on settings
- */
- _hapticFeedback(intensity = 'medium') {
- if (this._settings.haptics === 'off') return;
-
- if ('vibrate' in navigator) {
- const patterns = {
- low: 5,
- medium: 10,
- high: 20
- };
-
- const duration = patterns[this._settings.haptics] || patterns.medium;
- navigator.vibrate(duration);
- }
- }
-
- /**
- * Show settings modal
- */
- _showSettings() {
- return new Promise((resolve) => {
- const modal = document.createElement('div');
- modal.style.cssText = `
- position: fixed;
- top: 0;
- left: 0;
- width: 100vw;
- height: 100vh;
- background: rgba(0,0,0,0.5);
- display: flex;
- align-items: flex-end;
- justify-content: center;
- z-index: 100000;
- isolation: isolate;
- `;
-
- const dialog = document.createElement('div');
- dialog.style.cssText = `
- background: var(--card-background-color);
- border-radius: 16px 16px 0 0;
- padding: 12px 24px 32px 24px;
- width: 100%;
- max-width: 480px;
- max-height: 70vh;
- overflow-y: auto;
- position: relative;
- `;
-
- dialog.innerHTML = `
-
-
-
-
Settings
-
-
-
-
-
-
-
- ${['off', 'low', 'medium', 'high'].map(level => `
-
- `).join('')}
-
-
-
-
-
-
-
- ${['auto', '2', '3', '4', '5'].map(count => `
-
- `).join('')}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Hide Completed
-
Hide products with quantity 0
-
-
-
-
-
-
-
-
Compact Headers
-
Show category names only (no emoji)
-
-
-
-
-
- `;
-
- modal.appendChild(dialog);
- document.body.appendChild(modal);
-
- // Haptics buttons
- dialog.querySelectorAll('.haptic-btn').forEach(btn => {
- btn.addEventListener('click', () => {
- this._settings.haptics = btn.dataset.level;
- this._saveSettings();
-
- // Update visual selection
- dialog.querySelectorAll('.haptic-btn').forEach(b => {
- b.style.borderColor = 'var(--divider-color)';
- b.style.background = 'var(--card-background-color)';
- });
- btn.style.borderColor = 'var(--primary-color)';
- btn.style.background = 'var(--primary-color)';
-
- // Test vibration
- this._hapticFeedback();
- });
- });
-
- // Products per row buttons
- dialog.querySelectorAll('.perrow-btn').forEach(btn => {
- btn.addEventListener('click', () => {
- this._settings.productsPerRow = btn.dataset.count;
- this._saveSettings();
- this._updateContent(); // apply grid change immediately
-
- // Update visual selection
- dialog.querySelectorAll('.perrow-btn').forEach(b => {
- b.style.borderColor = 'var(--divider-color)';
- b.style.background = 'var(--card-background-color)';
- });
- btn.style.borderColor = 'var(--primary-color)';
- btn.style.background = 'var(--primary-color)';
-
- this._hapticFeedback();
- });
- });
-
- // Hide completed toggle
- const hideCompletedToggle = dialog.querySelector('.hide-completed-toggle');
- if (hideCompletedToggle) {
- hideCompletedToggle.addEventListener('change', (e) => {
- this._settings.hideCompleted = e.target.checked;
- this._saveSettings();
-
- // Update toggle visual state
- const toggle = e.target.parentElement;
- const slider = toggle.querySelectorAll('span')[0];
- const knob = toggle.querySelectorAll('span')[1];
- slider.style.backgroundColor = e.target.checked ? 'var(--primary-color)' : 'var(--divider-color)';
- knob.style.left = e.target.checked ? '23px' : '3px';
-
- this._updateContent();
- this._hapticFeedback();
- });
- }
-
- // Compact headers toggle
- const compactHeadersToggle = dialog.querySelector('.compact-headers-toggle');
- if (compactHeadersToggle) {
- compactHeadersToggle.addEventListener('change', (e) => {
- this._settings.compactHeaders = e.target.checked;
- this._saveSettings();
-
- // Update toggle visual state
- const toggle = e.target.parentElement;
- const slider = toggle.querySelectorAll('span')[0];
- const knob = toggle.querySelectorAll('span')[1];
- slider.style.backgroundColor = e.target.checked ? 'var(--primary-color)' : 'var(--divider-color)';
- knob.style.left = e.target.checked ? '23px' : '3px';
-
- this._updateContent();
- this._hapticFeedback();
- });
- }
-
- // Layout buttons
- dialog.querySelectorAll('.layout-btn').forEach(btn => {
- btn.addEventListener('click', () => {
- this._settings.layout = btn.dataset.layout;
- this._saveSettings();
- this._updateContent(); // switch gridโlist immediately
-
- // Update visual selection
- dialog.querySelectorAll('.layout-btn').forEach(b => {
- b.style.borderColor = 'var(--divider-color)';
- b.style.background = 'var(--card-background-color)';
- });
- btn.style.borderColor = 'var(--primary-color)';
- btn.style.background = 'var(--primary-color)';
-
- this._hapticFeedback();
- });
- });
-
- // Close button (Done)
- dialog.querySelector('.close-btn').addEventListener('click', () => {
- document.body.removeChild(modal);
- this._updateContent();
- resolve();
- });
-
- // โ top-right close button
- dialog.querySelector('.close-x-btn').addEventListener('click', () => {
- document.body.removeChild(modal);
- this._updateContent();
- resolve();
- });
-
- // Backdrop click
- modal.addEventListener('click', (e) => {
- if (e.target === modal) {
- document.body.removeChild(modal);
- this._updateContent();
- resolve();
- }
- });
- });
- }
-
- /**
- * Fuzzy search - matches even with typos or partial matches
- */
- _fuzzyMatch(text, query) {
- text = text.toLowerCase();
- query = query.toLowerCase();
-
- // Exact match or substring
- if (text.includes(query)) {
- return true;
- }
-
- // Fuzzy match - allows for missing characters
- let queryIndex = 0;
- for (let i = 0; i < text.length && queryIndex < query.length; i++) {
- if (text[i] === query[queryIndex]) {
- queryIndex++;
- }
- }
- return queryIndex === query.length;
- }
-
- /**
- * Filter products by search and split into active/inactive
- */
- _getFilteredProducts() {
- let products = Object.values(this._products);
-
- // Filter by search query with fuzzy matching
- if (this._searchQuery) {
- const query = this._searchQuery.toLowerCase();
- products = products.filter(product =>
- this._fuzzyMatch(product.name, query)
- );
- }
-
- // Filter by selected category
- if (this._selectedCategory) {
- products = products.filter(product =>
- product.category === this._selectedCategory
- );
- }
-
- // Sort products
- if (this._sortBy === 'category') {
- // Sort by category order, then by name
- products.sort((a, b) => {
- const catA = CATEGORY_MAP[a.category] || CATEGORY_MAP['other'];
- const catB = CATEGORY_MAP[b.category] || CATEGORY_MAP['other'];
-
- if (catA.order !== catB.order) {
- return catA.order - catB.order;
- }
- return a.name.localeCompare(b.name);
- });
- } else {
- // Alphabetical sort
- products.sort((a, b) => a.name.localeCompare(b.name));
- }
-
- return products;
- }
-
- /**
- * Get active products (in shopping list)
- */
- _getActiveProducts() {
- const filtered = this._getFilteredProducts();
- return filtered.filter(product => {
- const qty = this._activeList[product.key]?.qty || 0;
- return qty > 0;
- });
- }
-
- /**
- * Get inactive products (not in shopping list)
- */
- _getInactiveProducts() {
- const filtered = this._getFilteredProducts();
- return filtered.filter(product => {
- const qty = this._activeList[product.key]?.qty || 0;
- return qty === 0;
- });
- }
-
- /**
- * Check if should show "Add New" button
- */
- _shouldShowAddNew() {
- if (!this._searchQuery || this._searchQuery.length < 2) {
- return false;
- }
-
- const query = this._searchQuery.toLowerCase();
- const exactMatch = Object.values(this._products).some(
- product => product.name.toLowerCase() === query
- );
-
- return !exactMatch;
- }
-
- /**
- * Render the card
- */
- _render() {
- // Only do initial render if the structure doesn't exist
- if (!this.shadowRoot.querySelector('.card-content')) {
- this._initialRender();
- return;
- }
-
- // Otherwise just update the content area
- this._updateContent();
- }
-
- /**
- * Initial render - creates the persistent structure
- */
- _initialRender() {
- this.shadowRoot.innerHTML = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
-
- this._attachPersistentListeners();
- this._updateContent();
- }
-
- /**
- * Update only the content area (not search bar)
- */
- _updateContent() {
- const contentArea = this.shadowRoot.querySelector('.content-area');
- if (!contentArea) return;
-
- if (this._isLoading) {
- contentArea.innerHTML = 'Loading...
';
- return;
- }
-
- const activeProducts = this._getActiveProducts();
- const inactiveProducts = this._getInactiveProducts();
- let html = '';
-
-
-
- // Show active products first
- if (activeProducts.length > 0) {
- if (this._sortBy === 'category') {
- html += this._renderProductsByCategory(activeProducts, false);
- } else {
- const containerClass = this._settings.layout === 'list' ? 'product-list' : 'product-grid';
- html += ``;
- html += activeProducts.map(product => this._renderProductTile(product)).join('');
- html += '
';
- }
- }
-
- // Show inactive products in "Recently Used" section (unless hidden)
- if (inactiveProducts.length > 0 && !this._searchQuery && !this._settings.hideCompleted) {
- html += `
-
-
- Recently Used
-
- ${this._sortBy === 'category' ?
- this._renderProductsByCategory(inactiveProducts, true) :
- `
- ${inactiveProducts.map(product => this._renderProductTile(product)).join('')}
-
`
- }
-
- `;
- }
-
- // Empty state
- if (activeProducts.length === 0 && inactiveProducts.length === 0) {
- html += `
-
- ${this._searchQuery ? 'No products found' : 'No products yet. Search to add your first product!'}
-
- `;
- }
-
- contentArea.innerHTML = html;
-
- // Stamp live grid columns onto every .product-grid so the setting
- // takes effect immediately without needing a full re-render.
- const gridCols = this._settings.productsPerRow === 'auto'
- ? 'repeat(auto-fill, minmax(120px, 1fr))'
- : `repeat(${this._settings.productsPerRow}, 1fr)`;
- contentArea.querySelectorAll('.product-grid').forEach(g => {
- g.style.gridTemplateColumns = gridCols;
- });
-
- this._attachContentListeners();
- }
-
- /**
- * Render products by category (helper for active/inactive)
- */
- _renderProductsByCategory(products, isRecentlyUsed) {
- const containerClass = this._settings.layout === 'list' ? 'product-list' : 'product-grid';
- const productsByCategory = {};
-
- products.forEach(product => {
- const categoryId = product.category || 'other';
- if (!productsByCategory[categoryId]) {
- productsByCategory[categoryId] = [];
- }
- productsByCategory[categoryId].push(product);
- });
-
- return CATEGORIES
- .filter(cat => productsByCategory[cat.id] && productsByCategory[cat.id].length > 0)
- .map(cat => {
- const categoryProducts = productsByCategory[cat.id];
- const headerHtml = this._settings.compactHeaders
- ? ``
- : ``;
- return `
-
- ${headerHtml}
-
- ${categoryProducts.map(product => this._renderProductTile(product)).join('')}
-
-
- `;
- }).join('');
- }
-
- /**
- * Attach listeners that persist (search bar, sort buttons)
- */
- _attachPersistentListeners() {
- const searchBar = this.shadowRoot.querySelector('.search-bar');
- const searchClear = this.shadowRoot.querySelector('.search-clear');
- const settingsBtn = this.shadowRoot.querySelector('.settings-btn');
-
- if (searchBar) {
- searchBar.addEventListener('input', (e) => {
- this._searchQuery = e.target.value;
- // โ clear button: show/hide based on text
- if (searchClear) {
- searchClear.style.display = this._searchQuery.length > 0 ? 'block' : 'none';
- }
- // Settings/Add button: swap โ๏ธ โ โ based on whether "add new" applies
- if (settingsBtn) {
- if (this._searchQuery.length > 0 && this._shouldShowAddNew()) {
- settingsBtn.textContent = 'โ';
- settingsBtn.title = 'Add new product';
- } else {
- settingsBtn.textContent = 'โ๏ธ';
- settingsBtn.title = 'Settings';
- }
- }
- this._updateContent();
- });
- }
-
- // โ clear search
- if (searchClear) {
- searchClear.addEventListener('click', () => {
- this._searchQuery = '';
- searchBar.value = '';
- searchClear.style.display = 'none';
- if (settingsBtn) { settingsBtn.textContent = 'โ๏ธ'; settingsBtn.title = 'Settings'; }
- searchBar.focus();
- this._updateContent();
- this._hapticFeedback();
- });
- }
-
- // Settings / Add button (toggles based on search state)
- if (settingsBtn) {
- settingsBtn.addEventListener('click', () => {
- if (settingsBtn.textContent === 'โ') {
- this._addNewProduct(this._searchQuery);
- } else {
- this._showSettings();
- }
- this._hapticFeedback();
- });
- }
-
- const sortButtons = this.shadowRoot.querySelectorAll('.control-btn[data-sort]');
- sortButtons.forEach(btn => {
- btn.addEventListener('click', () => {
- this._sortBy = btn.dataset.sort;
- // Update button states
- sortButtons.forEach(b => b.classList.remove('active'));
- btn.classList.add('active');
- this._updateContent();
- this._hapticFeedback();
- });
- });
- }
-
- /**
- * Attach listeners for content area (products, buttons)
- */
- _attachContentListeners() {
- // Product tiles - add long-press for edit
- const tiles = this.shadowRoot.querySelectorAll('.product-tile');
- tiles.forEach(tile => {
- const key = tile.dataset.key;
- let longPressTimer = null;
- let longPressTriggered = false;
-
- // Touch events for mobile long-press
- tile.addEventListener('touchstart', (e) => {
- longPressTriggered = false;
- longPressTimer = setTimeout(() => {
- longPressTriggered = true;
- this._hapticFeedback();
- this._editProduct(key);
- }, 500); // 500ms long press
- }, { passive: true });
-
- tile.addEventListener('touchend', () => {
- if (longPressTimer) {
- clearTimeout(longPressTimer);
- }
- });
-
- tile.addEventListener('touchmove', () => {
- if (longPressTimer) {
- clearTimeout(longPressTimer);
- }
- }, { passive: true });
-
- // Mouse events for desktop
- tile.addEventListener('mousedown', (e) => {
- longPressTriggered = false;
- longPressTimer = setTimeout(() => {
- longPressTriggered = true;
- this._editProduct(key);
- }, 500);
- });
-
- tile.addEventListener('mouseup', () => {
- if (longPressTimer) {
- clearTimeout(longPressTimer);
- }
- });
-
- tile.addEventListener('mouseleave', () => {
- if (longPressTimer) {
- clearTimeout(longPressTimer);
- }
- });
-
- // Right-click for edit
- tile.addEventListener('contextmenu', (e) => {
- e.preventDefault();
- this._editProduct(key);
- });
-
- // Regular click
- tile.addEventListener('click', (e) => {
- // Don't toggle if it was a long press or clicking buttons
- if (!longPressTriggered && !e.target.closest('.qty-button')) {
- // Check if click is in bottom 30px area (where buttons would be)
- const rect = tile.getBoundingClientRect();
- const clickY = e.clientY - rect.top;
- const isBottomArea = clickY > (rect.height - 35);
-
- // Only toggle if not clicking in button area
- if (!isBottomArea || !isActive) {
- this._toggleProduct(key);
- }
- }
- });
- });
-
- // Plus buttons
- const plusButtons = this.shadowRoot.querySelectorAll('.plus-btn');
- plusButtons.forEach(btn => {
- btn.addEventListener('click', (e) => {
- e.stopPropagation();
- this._incrementProduct(btn.dataset.key);
- });
- });
-
- // Minus buttons
- const minusButtons = this.shadowRoot.querySelectorAll('.minus-btn');
- minusButtons.forEach(btn => {
- btn.addEventListener('click', (e) => {
- e.stopPropagation();
- this._decrementProduct(btn.dataset.key);
- });
- });
- }
-
- /**
- * Render a single product tile
- */
- _renderProductTile(product) {
- const qty = this._activeList[product.key]?.qty || 0;
- const isActive = qty > 0;
- const hasImage = product.image && product.image.trim().length > 0;
-
- // Determine if image is URL or emoji
- const isUrl = hasImage && (product.image.startsWith('http') || product.image.startsWith('/'));
- const displayImage = hasImage ? product.image : '๐';
-
- return `
-
-
${product.name}
-
-
- ${isUrl ?
- `

-
๐
` :
- `
${displayImage}
`
- }
-
-
- ${isActive ? `
-
-
-
-
- ` : `
-
Tap to add
- `}
-
- `;
- }
-
- disconnectedCallback() {
- if (this._pollInterval) {
- clearInterval(this._pollInterval);
- }
- if (this._visibilityHandler) {
- document.removeEventListener('visibilitychange', this._visibilityHandler);
- }
- }
-
- // Temporarily disabled GUI editor - use YAML configuration
- // static getConfigElement() {
- // return document.createElement('shopping-list-card-editor');
- // }
-
- static getStubConfig() {
- return { title: 'Shopping List' };
- }
-
- getCardSize() {
- return 3;
- }
-}
-
-// โโ GUI Config Editor โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-// This is the standard HA card-editor pattern. HA renders this
-// element inside its "configure card" panel automatically when the
-// card exposes a static getConfigElement() method.
-class ShoppingListCardEditor extends HTMLElement {
- setConfig(config) {
- console.log('[Editor] setConfig called, _rendered:', this._rendered, 'config:', config);
- this._config = { ...(config || {}) }; // shallow-copy: HA 2026+ freezes the original
- if (!this._rendered) {
- console.log('[Editor] First render, calling _render()');
- this._render(); // only render once, then STOP
- } else {
- console.log('[Editor] Already rendered, ignoring setConfig');
- }
- // After first render, ignore setConfig calls - the updateConfig closure
- // already handles live updates by mutating this._config directly
- }
-
- set hass(hass) {
- console.log('[Editor] hass setter called, _rendered:', this._rendered);
- this._hass = hass;
- if (!this._rendered) this._render();
- }
-
- _render() {
- if (!this._config) return; // nothing to render yet
-
- // Attach shadow DOM once, reuse on subsequent renders
- if (!this.shadowRoot) {
- this.attachShadow({ mode: 'open' });
- }
-
- const title = this._config.title || '';
- const productsPerRow = String(this._config.products_per_row || '3');
- const layout = this._config.layout || 'grid';
- const haptics = this._config.haptics || 'medium';
- const hideCompleted = !!this._config.hide_completed;
- const compactHeaders = !!this._config.compact_headers;
-
- this.shadowRoot.innerHTML = `
-
-
-
-
-
-
-
-
-
-
- Auto
- 2
- 3
- 4
- 5
-
-
-
-
- Grid
- List
-
-
-
-
-
-
- Off
- Low
- Medium
- High
-
-
-
-
-
-
-
-
- Hide completed items
- Hides the "Recently Used" section showing products not currently on the list.
-
-
-
-
-
-
-
- Compact headers
- Show category names only (no emoji)
-
-
-
-
-
-
- ${this._showCodeEditor ? `${this._toYaml()}
` : ''}
- `;
-
- // โโ Set values imperatively (reliable with shadow DOM + HA components) โโ
- const titleEl = this.shadowRoot.querySelector('#ed-title');
- const perrowEl = this.shadowRoot.querySelector('#ed-perrow');
- const layoutEl = this.shadowRoot.querySelector('#ed-layout');
- const hapticsEl = this.shadowRoot.querySelector('#ed-haptics');
- const hideCompletedEl = this.shadowRoot.querySelector('#ed-hide-completed');
- const hideHeadersEl = this.shadowRoot.querySelector('#ed-hide-headers');
-
- titleEl.value = title;
- perrowEl.value = productsPerRow;
- layoutEl.value = layout;
- hapticsEl.value = haptics;
- hideCompletedEl.checked = hideCompleted;
- hideHeadersEl.checked = compactHeaders;
-
- // โโ Single updateConfig closure โ mirrors your working pattern โโ
- const updateConfig = () => {
- console.log('[Editor] updateConfig called');
- this._config = {
- ...this._config,
- title: titleEl.value || 'Shopping List',
- products_per_row: perrowEl.value,
- layout: layoutEl.value,
- haptics: hapticsEl.value,
- hide_completed: hideCompletedEl.checked,
- compact_headers: hideHeadersEl.checked
- };
- console.log('[Editor] Dispatching config-changed:', this._config);
- this.dispatchEvent(new CustomEvent('config-changed', {
- detail: { config: this._config },
- bubbles: true,
- composed: true
- }));
- };
-
- titleEl.addEventListener('input', updateConfig);
- perrowEl.addEventListener('value-changed', updateConfig);
- layoutEl.addEventListener('value-changed', updateConfig);
- hapticsEl.addEventListener('value-changed', updateConfig);
- hideCompletedEl.addEventListener('checked-changed', updateConfig);
- hideHeadersEl.addEventListener('checked-changed', updateConfig);
-
- this.shadowRoot.querySelector('#ed-code-toggle').addEventListener('click', () => {
- this._showCodeEditor = !this._showCodeEditor;
- this._render();
- });
-
- this._rendered = true;
- }
-
- _toYaml() {
- const lines = ['type: custom:shopping-list-card'];
- if (this._config.title) lines.push('title: ' + this._config.title);
- if (this._config.products_per_row) lines.push('products_per_row: ' + this._config.products_per_row);
- if (this._config.layout) lines.push('layout: ' + this._config.layout);
- if (this._config.haptics) lines.push('haptics: ' + this._config.haptics);
- if (this._config.hide_completed) lines.push('hide_completed: true');
- if (this._config.compact_headers) lines.push('compact_headers: true');
- return lines.join('\n');
- }
-}
-
-customElements.define('shopping-list-card-editor', ShoppingListCardEditor);
-customElements.define('shopping-list-card', ShoppingListCard);
-
-window.customCards = window.customCards || [];
-window.customCards.push({
- type: 'shopping-list-card',
- name: 'Shopping List Card',
- description: 'A shopping list card with search, categories, and product images.',
- preview: false
-});