slack status without the slack status.zzstoatzz.io/
quickslice

fix: Address PR review feedback and fix custom picker bug

- Prevent modal from closing when clicking Custom tab
- Add event listener cleanup to prevent memory leaks
- Add CSS fallback for inset property for browser compatibility
- Stop event propagation on emoji picker to prevent accidental closes

Changed files
+231 -141
templates
+231 -141
templates/status.html
··· 209 209 </div> 210 210 <div class="emoji-picker-body"> 211 211 <div class="emoji-categories" id="emoji-categories"> 212 - <button class="category-btn active" data-category="frequent">Frequent</button> 213 - <button class="category-btn" data-category="custom">Custom</button> 214 - <button class="category-btn" data-category="people">People</button> 215 - <button class="category-btn" data-category="nature">Nature</button> 216 - <button class="category-btn" data-category="food">Food</button> 217 - <button class="category-btn" data-category="activity">Activity</button> 218 - <button class="category-btn" data-category="travel">Travel</button> 219 - <button class="category-btn" data-category="objects">Objects</button> 220 - <button class="category-btn" data-category="symbols">Symbols</button> 221 - <button class="category-btn" data-category="flags">Flags</button> 212 + <button type="button" class="category-btn active" data-category="frequent">Frequent</button> 213 + <button type="button" class="category-btn" data-category="custom">Custom</button> 214 + <button type="button" class="category-btn" data-category="people">People</button> 215 + <button type="button" class="category-btn" data-category="nature">Nature</button> 216 + <button type="button" class="category-btn" data-category="food">Food</button> 217 + <button type="button" class="category-btn" data-category="activity">Activity</button> 218 + <button type="button" class="category-btn" data-category="travel">Travel</button> 219 + <button type="button" class="category-btn" data-category="objects">Objects</button> 220 + <button type="button" class="category-btn" data-category="symbols">Symbols</button> 221 + <button type="button" class="category-btn" data-category="flags">Flags</button> 222 222 </div> 223 223 <div class="emoji-grid" id="emoji-grid"> 224 224 <!-- Will be populated by JavaScript --> ··· 624 624 .handle-link::before { 625 625 content: ''; 626 626 position: absolute; 627 + top: -2px; 628 + right: -2px; 629 + bottom: -2px; 630 + left: -2px; 627 631 inset: -2px; 628 632 border-radius: var(--radius-sm); 629 633 background: linear-gradient(45deg, var(--accent), transparent); ··· 1072 1076 right: 0; 1073 1077 bottom: 0; 1074 1078 left: 0; 1075 - inset: 0; 1079 + inset: 0; /* Modern browsers */ 1076 1080 background: rgba(6, 6, 8, 0.75); 1077 1081 backdrop-filter: blur(6px); 1078 1082 display: flex; ··· 1258 1262 width: 75%; 1259 1263 height: 75%; 1260 1264 object-fit: contain; 1265 + } 1266 + 1267 + .emoji-grid-placeholder, 1268 + .emoji-loading-message { 1269 + text-align: center; 1270 + color: var(--text-tertiary); 1271 + padding: 2rem 1rem; 1272 + font-size: 0.95rem; 1261 1273 } 1262 1274 1263 1275 .emoji-picker-overlay::before { ··· 1622 1634 .webhook-modal.hidden { display: none; } 1623 1635 .webhook-modal { 1624 1636 position: fixed; 1625 - inset: 0; 1637 + top: 0; 1638 + right: 0; 1639 + bottom: 0; 1640 + left: 0; 1641 + inset: 0; /* Modern browsers */ 1626 1642 background: rgba(0,0,0,0.6); 1627 1643 z-index: 1000; 1628 1644 display: flex; ··· 2108 2124 closeEmojiPicker(); 2109 2125 } 2110 2126 }; 2127 + 2128 + // Clean up event listener on page unload 2129 + window.addEventListener('beforeunload', () => { 2130 + if (keydownBound) { 2131 + document.removeEventListener('keydown', handleKeydown); 2132 + keydownBound = false; 2133 + } 2134 + }); 2111 2135 2112 2136 const openEmojiPicker = () => { 2113 2137 if (!emojiPickerOverlay || !emojiPicker) return; ··· 2187 2211 2188 2212 if (emojiPickerOverlay) { 2189 2213 emojiPickerOverlay.addEventListener('click', (e) => { 2214 + // Only close if clicking the overlay itself, not the picker content 2190 2215 if (e.target === emojiPickerOverlay) { 2191 2216 closeEmojiPicker(); 2192 2217 } 2193 2218 }); 2194 2219 } 2195 2220 2196 - // Store custom emojis 2221 + // Prevent clicks inside the picker from bubbling to the overlay 2222 + if (emojiPicker) { 2223 + emojiPicker.addEventListener('click', (e) => { 2224 + e.stopPropagation(); 2225 + }); 2226 + } 2227 + 2228 + // Emoji data caches 2197 2229 let customEmojis = []; 2198 - 2199 - // Load custom emojis from API 2200 - const loadCustomEmojis = async () => { 2201 - try { 2202 - const response = await fetch('/api/custom-emojis'); 2203 - customEmojis = await response.json(); 2204 - } catch (e) { 2205 - console.error('Failed to load custom emojis:', e); 2230 + let customEmojiMap = new Map(); 2231 + let customEmojiFetchPromise = null; 2232 + let searchDebounce; 2233 + const SEARCH_DEBOUNCE_MS = 120; 2234 + 2235 + const showGridMessage = (message, className = 'emoji-grid-placeholder') => { 2236 + if (!emojiGrid) return; 2237 + const placeholder = document.createElement('div'); 2238 + placeholder.className = className; 2239 + placeholder.textContent = message; 2240 + emojiGrid.replaceChildren(placeholder); 2241 + }; 2242 + 2243 + const createCustomEmojiButton = (emoji) => { 2244 + const button = document.createElement('button'); 2245 + button.type = 'button'; 2246 + button.className = 'emoji-option custom-emoji'; 2247 + button.dataset.emoji = `custom:${emoji.name}`; 2248 + button.dataset.name = emoji.name; 2249 + 2250 + const img = document.createElement('img'); 2251 + img.src = `/emojis/${emoji.filename}`; 2252 + img.alt = emoji.name; 2253 + img.title = emoji.name; 2254 + button.appendChild(img); 2255 + return button; 2256 + }; 2257 + 2258 + const createStandardEmojiButton = (emoji, title = '') => { 2259 + const button = document.createElement('button'); 2260 + button.type = 'button'; 2261 + button.className = 'emoji-option'; 2262 + button.dataset.emoji = emoji; 2263 + if (title) { 2264 + button.dataset.name = title; 2265 + button.title = title; 2206 2266 } 2267 + button.textContent = emoji; 2268 + return button; 2269 + }; 2270 + 2271 + const renderEmojiButtons = (buttons) => { 2272 + if (!emojiGrid) return; 2273 + const fragment = document.createDocumentFragment(); 2274 + buttons.forEach(btn => fragment.appendChild(btn)); 2275 + emojiGrid.replaceChildren(fragment); 2276 + }; 2277 + 2278 + const fetchCustomEmojis = async () => { 2279 + const response = await fetch('/api/custom-emojis'); 2280 + const data = await response.json(); 2281 + if (!Array.isArray(data)) { 2282 + throw new Error('Invalid custom emoji payload'); 2283 + } 2284 + customEmojis = data; 2285 + customEmojiMap = new Map(data.map(emoji => [emoji.name, emoji])); 2207 2286 return customEmojis; 2208 2287 }; 2209 - 2210 - // Load custom emojis on page load 2211 - loadCustomEmojis(); 2212 2288 2213 - // Refresh custom emojis when an admin uploads a new one 2214 - document.addEventListener('custom-emoji-uploaded', async (e) => { 2215 - await loadCustomEmojis(); 2216 - // If picker visible, refresh current category 2217 - if (isEmojiPickerOpen()) { 2218 - const active = document.querySelector('.emoji-categories .category-btn.active'); 2219 - const cat = active ? active.getAttribute('data-category') : 'frequent'; 2220 - loadEmojiCategory(cat || 'frequent'); 2289 + const ensureCustomEmojis = async ({ showLoading = false, showErrors = true } = {}) => { 2290 + if (customEmojis.length) return customEmojis; 2291 + if (showLoading) { 2292 + showGridMessage('loading custom emojis…', 'emoji-loading-message'); 2293 + } 2294 + if (!customEmojiFetchPromise) { 2295 + customEmojiFetchPromise = fetchCustomEmojis() 2296 + .catch(err => { 2297 + console.error('Failed to load custom emojis:', err); 2298 + customEmojis = []; 2299 + customEmojiMap = new Map(); 2300 + if (showErrors) { 2301 + showGridMessage('failed to load custom emojis'); 2302 + } 2303 + throw err; 2304 + }) 2305 + .finally(() => { 2306 + customEmojiFetchPromise = null; 2307 + }); 2308 + } 2309 + try { 2310 + await customEmojiFetchPromise; 2311 + } catch (_) { 2312 + return []; 2221 2313 } 2222 - }); 2223 - 2224 - // Load emoji category 2314 + return customEmojis; 2315 + }; 2316 + 2317 + // Quietly preload custom emojis so the custom tab feels instant 2318 + ensureCustomEmojis({ showLoading: false, showErrors: false }).catch(() => {}); 2319 + 2320 + const updateActiveCategory = (category) => { 2321 + document.querySelectorAll('.category-btn').forEach(btn => { 2322 + btn.classList.toggle('active', btn.getAttribute('data-category') === category); 2323 + }); 2324 + }; 2325 + 2225 2326 const loadEmojiCategory = async (category) => { 2327 + if (!emojiGrid) return; 2328 + 2329 + const buttons = []; 2330 + const emojis = emojiData[category] || []; 2331 + 2226 2332 if (category === 'frequent') { 2227 - // For frequent tab, we need to handle both regular and custom emojis 2228 - const emojis = emojiData[category] || []; 2229 - let html = ''; 2230 - 2333 + if (emojis.some(emoji => emoji.startsWith('custom:'))) { 2334 + await ensureCustomEmojis({ showLoading: false, showErrors: false }); 2335 + } 2231 2336 for (const emoji of emojis) { 2232 2337 if (emoji.startsWith('custom:')) { 2233 - // This is a custom emoji 2234 - const emojiName = emoji.replace('custom:', ''); 2235 - // Find the custom emoji data 2236 - const customEmoji = customEmojis.find(e => e.name === emojiName); 2237 - if (customEmoji) { 2238 - html += `<button type="button" class="emoji-option custom-emoji" data-emoji="${emoji}" data-name="${emojiName}"> 2239 - <img src="/emojis/${customEmoji.filename}" alt="${emojiName}" title="${emojiName}"> 2240 - </button>`; 2338 + const name = emoji.replace('custom:', ''); 2339 + const custom = customEmojiMap.get(name); 2340 + if (custom) { 2341 + buttons.push(createCustomEmojiButton(custom)); 2241 2342 } 2242 2343 } else { 2243 - // Regular Unicode emoji 2244 - html += `<button type="button" class="emoji-option" data-emoji="${emoji}">${emoji}</button>`; 2344 + const slug = (window.__emojiSlugs && window.__emojiSlugs[emoji]) || ''; 2345 + buttons.push(createStandardEmojiButton(emoji, slug)); 2245 2346 } 2246 2347 } 2247 - 2248 - emojiGrid.innerHTML = html; 2249 2348 } else if (category === 'custom') { 2250 - // Load custom image emojis 2251 - if (customEmojis.length === 0) { 2252 - await loadCustomEmojis(); 2349 + const data = await ensureCustomEmojis({ showLoading: true }); 2350 + if (!data.length) { 2351 + showGridMessage('no custom emojis yet'); 2352 + updateActiveCategory(category); 2353 + return; 2253 2354 } 2254 - 2255 - emojiGrid.innerHTML = customEmojis.map(emoji => 2256 - `<button type="button" class="emoji-option custom-emoji" data-emoji="custom:${emoji.name}" data-name="${emoji.name}"> 2257 - <img src="/emojis/${emoji.filename}" alt="${emoji.name}" title="${emoji.name}"> 2258 - </button>` 2259 - ).join(''); 2355 + for (const emoji of data) { 2356 + buttons.push(createCustomEmojiButton(emoji)); 2357 + } 2260 2358 } else { 2261 - // Load standard Unicode emojis 2262 - const emojis = emojiData[category] || []; 2263 - emojiGrid.innerHTML = emojis.map(emoji => { 2359 + for (const emoji of emojis) { 2264 2360 const slug = (window.__emojiSlugs && window.__emojiSlugs[emoji]) || ''; 2265 - return `<button type="button" class="emoji-option" data-emoji="${emoji}" data-name="${slug}" title="${slug}">${emoji}</button>`; 2266 - }).join(''); 2361 + buttons.push(createStandardEmojiButton(emoji, slug)); 2362 + } 2363 + } 2364 + 2365 + if (buttons.length) { 2366 + renderEmojiButtons(buttons); 2367 + } else if (category === 'custom') { 2368 + showGridMessage('no custom emojis yet'); 2369 + } else { 2370 + showGridMessage('no emojis in this category'); 2267 2371 } 2268 - 2269 - // Update active category 2270 - document.querySelectorAll('.category-btn').forEach(btn => { 2271 - btn.classList.toggle('active', btn.getAttribute('data-category') === category); 2272 - }); 2372 + 2373 + updateActiveCategory(category); 2273 2374 }; 2274 - 2375 + 2275 2376 const applyEmojiSelection = (button) => { 2276 2377 if (!button || !selectedEmoji || !statusInput) return; 2277 2378 const emojiValue = button.getAttribute('data-emoji'); 2278 2379 if (!emojiValue) return; 2279 2380 2381 + selectedEmoji.innerHTML = ''; 2280 2382 if (button.classList.contains('custom-emoji')) { 2281 2383 const img = button.querySelector('img'); 2282 2384 if (img) { 2283 - selectedEmoji.innerHTML = ''; 2284 2385 const previewImg = document.createElement('img'); 2285 2386 previewImg.src = img.src; 2286 2387 previewImg.alt = img.alt; ··· 2295 2396 } 2296 2397 2297 2398 statusInput.value = emojiValue; 2298 - if (emojiSearch) emojiSearch.value = ''; 2299 - if (emojiCategories) emojiCategories.classList.remove('hidden'); 2399 + if (emojiSearch) { 2400 + emojiSearch.value = ''; 2401 + } 2402 + if (emojiCategories) { 2403 + emojiCategories.classList.remove('hidden'); 2404 + } 2300 2405 closeEmojiPicker(); 2301 2406 checkForChanges(); 2302 2407 }; ··· 2312 2417 2313 2418 // Category buttons 2314 2419 document.querySelectorAll('.category-btn').forEach(btn => { 2315 - btn.addEventListener('click', () => { 2316 - loadEmojiCategory(btn.getAttribute('data-category')); 2420 + btn.addEventListener('click', async (event) => { 2421 + event.preventDefault(); 2422 + event.stopPropagation(); // Prevent event bubbling that might close the modal 2423 + const category = btn.getAttribute('data-category'); 2424 + await loadEmojiCategory(category); 2317 2425 }); 2318 2426 }); 2319 2427 ··· 2437 2545 }); 2438 2546 2439 2547 // Search emoji function 2440 - const searchEmojis = (query) => { 2548 + const searchEmojis = async (rawQuery) => { 2549 + if (!emojiGrid) return; 2550 + const query = (rawQuery || '').trim(); 2551 + 2441 2552 if (!query) { 2442 2553 if (emojiCategories) emojiCategories.classList.remove('hidden'); 2443 - loadEmojiCategory('frequent'); 2554 + await loadEmojiCategory('frequent'); 2444 2555 return; 2445 2556 } 2446 - 2447 - // Hide categories when searching 2557 + 2448 2558 if (emojiCategories) emojiCategories.classList.add('hidden'); 2449 - 2559 + 2450 2560 const lowerQuery = query.toLowerCase(); 2451 - const results = []; 2452 - const customResults = []; 2453 - 2454 - // Search through emoji keywords 2455 - for (const [emoji, keywords] of Object.entries(emojiKeywords)) { 2456 - // Check if emoji itself matches 2457 - if (emoji.includes(query)) { 2458 - results.push(emoji); 2459 - continue; 2561 + const buttons = []; 2562 + const seen = new Set(); 2563 + 2564 + await ensureCustomEmojis({ showLoading: false, showErrors: false }); 2565 + 2566 + for (const emoji of customEmojis) { 2567 + if (emoji.name.toLowerCase().includes(lowerQuery)) { 2568 + buttons.push(createCustomEmojiButton(emoji)); 2569 + seen.add(`custom:${emoji.name}`); 2570 + if (buttons.length >= 80) break; 2460 2571 } 2461 - 2462 - // Check keywords 2463 - for (const keyword of keywords) { 2464 - if (keyword.includes(lowerQuery)) { 2465 - results.push(emoji); 2466 - break; 2572 + } 2573 + 2574 + if (buttons.length < 80) { 2575 + for (const [emoji, keywords] of Object.entries(emojiKeywords)) { 2576 + if (seen.has(emoji)) continue; 2577 + if (emoji.includes(query) || keywords.some(keyword => keyword.includes(lowerQuery))) { 2578 + buttons.push(createStandardEmojiButton(emoji)); 2579 + seen.add(emoji); 2580 + if (buttons.length >= 80) break; 2467 2581 } 2468 2582 } 2469 2583 } 2470 - 2471 - // Also add any emojis from all categories that aren't in keywords 2472 - const allCategoryEmojis = Object.values(emojiData).flat(); 2473 - for (const emoji of allCategoryEmojis) { 2474 - if (!emojiKeywords[emoji] && !results.includes(emoji)) { 2475 - // For emojis without keywords, just do a basic include check 2476 - if (emoji.includes(query)) { 2477 - results.push(emoji); 2584 + 2585 + if (buttons.length < 80) { 2586 + outer: for (const categoryEmojis of Object.values(emojiData)) { 2587 + for (const emoji of categoryEmojis) { 2588 + if (seen.has(emoji)) continue; 2589 + if (emoji.includes(query)) { 2590 + buttons.push(createStandardEmojiButton(emoji)); 2591 + seen.add(emoji); 2592 + if (buttons.length >= 80) break outer; 2593 + } 2478 2594 } 2479 2595 } 2480 2596 } 2481 - 2482 - // Search through custom emojis by name 2483 - for (const customEmoji of customEmojis) { 2484 - if (customEmoji.name.toLowerCase().includes(lowerQuery)) { 2485 - customResults.push(customEmoji); 2486 - } 2487 - } 2488 - 2489 - // Remove duplicates and limit results 2490 - const uniqueResults = [...new Set(results)].slice(0, 50); 2491 - const uniqueCustomResults = customResults.slice(0, 50); 2492 - 2493 - // Display results - combine regular and custom emojis 2494 - let resultsHtml = ''; 2495 - 2496 - // Add custom emoji results first 2497 - if (uniqueCustomResults.length > 0) { 2498 - resultsHtml += uniqueCustomResults.map(emoji => 2499 - `<button type="button" class="emoji-option custom-emoji" data-emoji="custom:${emoji.name}" data-name="${emoji.name}"> 2500 - <img src="/emojis/${emoji.filename}" alt="${emoji.name}" title="${emoji.name}"> 2501 - </button>` 2502 - ).join(''); 2503 - } 2504 - 2505 - // Add regular emoji results 2506 - if (uniqueResults.length > 0) { 2507 - resultsHtml += uniqueResults.map(emoji => 2508 - `<button type="button" class="emoji-option" data-emoji="${emoji}">${emoji}</button>` 2509 - ).join(''); 2510 - } 2511 - 2512 - if (resultsHtml) { 2513 - emojiGrid.innerHTML = resultsHtml; 2597 + 2598 + if (buttons.length) { 2599 + renderEmojiButtons(buttons); 2514 2600 } else { 2515 - emojiGrid.innerHTML = '<div style="text-align: center; color: var(--text-tertiary); padding: 2rem;">No emojis found</div>'; 2601 + showGridMessage('No emojis found'); 2516 2602 } 2517 2603 }; 2518 - 2604 + 2519 2605 // Emoji search input handler 2520 2606 if (emojiSearch) { 2521 2607 emojiSearch.addEventListener('input', (e) => { 2522 - searchEmojis(e.target.value); 2608 + const value = e.target.value; 2609 + if (searchDebounce) clearTimeout(searchDebounce); 2610 + searchDebounce = setTimeout(() => { 2611 + searchEmojis(value); 2612 + }, SEARCH_DEBOUNCE_MS); 2523 2613 }); 2524 - 2614 + 2525 2615 // Focus search when picker opens 2526 2616 emojiSearch.addEventListener('focus', () => { 2527 2617 if (!emojiSearch.value) {