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

Merge pull request #61 from zzstoatzz/feature/immersive-emoji-picker

Immersive emoji picker modal

authored by nate nowack and committed by GitHub 0a476e92 cfd09b95

Changed files
+554 -293
templates
+554 -293
templates/status.html
··· 190 190 </form> 191 191 </div> 192 192 193 - <!-- Emoji Picker (hidden by default) --> 194 - <div class="emoji-picker" id="emoji-picker" style="display: none;"> 195 - <div class="emoji-search-container"> 196 - <input type="text" 197 - id="emoji-search" 198 - class="emoji-search" 199 - placeholder="Search emojis..." 200 - autocomplete="off"> 201 - </div> 202 - <div class="emoji-categories" id="emoji-categories"> 203 - <button class="category-btn active" data-category="frequent">Frequent</button> 204 - <button class="category-btn" data-category="custom">Custom</button> 205 - <button class="category-btn" data-category="people">People</button> 206 - <button class="category-btn" data-category="nature">Nature</button> 207 - <button class="category-btn" data-category="food">Food</button> 208 - <button class="category-btn" data-category="activity">Activity</button> 209 - <button class="category-btn" data-category="travel">Travel</button> 210 - <button class="category-btn" data-category="objects">Objects</button> 211 - <button class="category-btn" data-category="symbols">Symbols</button> 212 - <button class="category-btn" data-category="flags">Flags</button> 213 - </div> 214 - <div class="emoji-grid" id="emoji-grid"> 215 - <!-- Will be populated by JavaScript --> 193 + <!-- Emoji Picker Modal --> 194 + <div class="emoji-picker-overlay hidden" id="emoji-picker-overlay" aria-hidden="true"> 195 + <div class="emoji-picker" id="emoji-picker" role="dialog" aria-modal="true" aria-labelledby="emoji-picker-title"> 196 + <div class="emoji-picker-header"> 197 + <div> 198 + <h2 id="emoji-picker-title">pick an emoji</h2> 199 + <p class="emoji-picker-subtitle">custom and unicode options side by side</p> 200 + </div> 201 + <button type="button" class="emoji-picker-close" id="emoji-picker-close" aria-label="close emoji picker">✕</button> 202 + </div> 203 + <div class="emoji-search-container"> 204 + <input type="text" 205 + id="emoji-search" 206 + class="emoji-search" 207 + placeholder="Search emojis..." 208 + autocomplete="off"> 209 + </div> 210 + <div class="emoji-picker-body"> 211 + <div class="emoji-categories" id="emoji-categories"> 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 + </div> 223 + <div class="emoji-grid" id="emoji-grid"> 224 + <!-- Will be populated by JavaScript --> 225 + </div> 226 + </div> 216 227 </div> 217 228 </div> 218 229 ··· 457 468 font-family: var(--font-family) !important; 458 469 } 459 470 471 + body.modal-open { 472 + overflow: hidden; 473 + position: fixed; 474 + width: 100%; 475 + } 476 + 460 477 :root { 461 478 --bg-primary: #ffffff; 462 479 --bg-secondary: #f8f9fa; ··· 607 624 .handle-link::before { 608 625 content: ''; 609 626 position: absolute; 627 + top: -2px; 628 + right: -2px; 629 + bottom: -2px; 630 + left: -2px; 610 631 inset: -2px; 611 632 border-radius: var(--radius-sm); 612 633 background: linear-gradient(45deg, var(--accent), transparent); ··· 1049 1070 } 1050 1071 1051 1072 /* Emoji Picker */ 1073 + .emoji-picker-overlay { 1074 + position: fixed; 1075 + top: 0; 1076 + right: 0; 1077 + bottom: 0; 1078 + left: 0; 1079 + inset: 0; /* Modern browsers */ 1080 + background: rgba(6, 6, 8, 0.75); 1081 + backdrop-filter: blur(6px); 1082 + display: flex; 1083 + align-items: center; 1084 + justify-content: center; 1085 + padding: clamp(1rem, 6vw, 2.5rem); 1086 + z-index: 1400; 1087 + } 1088 + 1089 + .emoji-picker-overlay.hidden { 1090 + display: none; 1091 + } 1092 + 1052 1093 .emoji-picker { 1053 - position: absolute; 1094 + position: relative; 1095 + z-index: 1; 1096 + width: min(960px, 94vw); 1097 + height: min(90vh, 820px); 1054 1098 background: var(--bg-secondary); 1055 1099 border: 1px solid var(--border-color); 1056 - border-radius: var(--radius); 1057 - padding: 1rem; 1058 - box-shadow: var(--shadow-md); 1059 - width: 320px; 1060 - max-height: 400px; 1061 - overflow-y: auto; 1062 - z-index: 1000; 1100 + border-radius: clamp(var(--radius), 2vw, 24px); 1101 + box-shadow: 0 32px 80px rgba(0, 0, 0, 0.45); 1102 + display: flex; 1103 + flex-direction: column; 1104 + gap: 1.25rem; 1105 + padding: clamp(1.25rem, 5vw, 2.5rem); 1106 + overflow: hidden; 1107 + } 1108 + 1109 + 1110 + .emoji-picker-header { 1111 + display: flex; 1112 + align-items: flex-start; 1113 + justify-content: space-between; 1114 + gap: 1rem; 1115 + } 1116 + 1117 + .emoji-picker-header h2 { 1118 + margin: 0; 1119 + font-size: 1.5rem; 1120 + color: var(--text-primary); 1121 + } 1122 + 1123 + .emoji-picker-subtitle { 1124 + margin: 0.25rem 0 0; 1125 + font-size: 0.875rem; 1126 + color: var(--text-tertiary); 1127 + } 1128 + 1129 + .emoji-picker-close { 1130 + border: 1px solid var(--border-color); 1131 + background: var(--bg-tertiary); 1132 + color: var(--text-secondary); 1133 + border-radius: 999px; 1134 + width: 2.25rem; 1135 + height: 2.25rem; 1136 + display: flex; 1137 + align-items: center; 1138 + justify-content: center; 1139 + cursor: pointer; 1140 + transition: all 0.2s ease; 1141 + } 1142 + 1143 + .emoji-picker-close:hover, 1144 + .emoji-picker-close:focus-visible { 1145 + color: white; 1146 + background: var(--accent); 1147 + border-color: transparent; 1148 + outline: none; 1063 1149 } 1064 1150 1065 1151 .emoji-search-container { 1066 - margin-bottom: 1rem; 1152 + margin: 0; 1067 1153 } 1068 1154 1069 1155 .emoji-search { 1070 1156 width: 100%; 1071 - padding: 0.5rem 0.75rem; 1157 + padding: 0.75rem 1rem; 1072 1158 background: var(--bg-primary); 1073 1159 border: 1px solid var(--border-color); 1074 - border-radius: var(--radius-sm); 1160 + border-radius: var(--radius); 1075 1161 color: var(--text-primary); 1076 - font-size: 0.875rem; 1162 + font-size: 0.95rem; 1077 1163 outline: none; 1078 - transition: border-color 0.2s; 1164 + transition: border-color 0.2s ease, box-shadow 0.2s ease; 1079 1165 } 1080 1166 1081 1167 .emoji-search:focus { 1082 1168 border-color: var(--accent); 1083 - box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.1); 1169 + box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.12); 1084 1170 } 1085 1171 1086 1172 .emoji-search::placeholder { 1087 1173 color: var(--text-tertiary); 1088 1174 } 1089 1175 1176 + .emoji-picker-body { 1177 + flex: 1; 1178 + display: flex; 1179 + flex-direction: column; 1180 + gap: 0.75rem; 1181 + min-height: 0; 1182 + } 1183 + 1090 1184 .emoji-categories { 1091 1185 display: flex; 1092 - gap: 0.25rem; 1093 - margin-bottom: 1rem; 1186 + gap: 0.75rem; 1094 1187 overflow-x: auto; 1095 1188 padding-bottom: 0.5rem; 1189 + border-bottom: 1px solid rgba(255, 255, 255, 0.06); 1190 + scrollbar-width: none; 1096 1191 } 1097 1192 1098 1193 .emoji-categories.hidden { 1099 1194 display: none; 1100 1195 } 1101 1196 1197 + .emoji-categories::-webkit-scrollbar { 1198 + display: none; 1199 + } 1200 + 1102 1201 .category-btn { 1103 1202 background: transparent; 1104 - border: none; 1203 + border: 1px solid transparent; 1105 1204 color: var(--text-secondary); 1106 - font-size: 0.75rem; 1107 - padding: 0.25rem 0.5rem; 1205 + font-size: 0.8rem; 1206 + padding: 0.4rem 0.75rem; 1108 1207 cursor: pointer; 1109 1208 white-space: nowrap; 1110 - border-radius: var(--radius-sm); 1111 - transition: all 0.2s; 1209 + border-radius: 999px; 1210 + transition: all 0.2s ease; 1112 1211 } 1113 1212 1114 - .category-btn:hover { 1115 - background: var(--bg-tertiary); 1213 + .category-btn:hover, 1214 + .category-btn:focus-visible { 1215 + border-color: var(--accent); 1216 + color: var(--accent); 1217 + outline: none; 1116 1218 } 1117 1219 1118 1220 .category-btn.active { 1119 1221 background: var(--accent); 1120 1222 color: white; 1223 + border-color: transparent; 1121 1224 } 1122 1225 1123 1226 .emoji-grid { 1227 + flex: 1; 1124 1228 display: grid; 1125 - grid-template-columns: repeat(auto-fill, 2rem); 1126 - gap: 0.25rem; 1127 - justify-content: start; 1229 + grid-template-columns: repeat(auto-fill, minmax(min(84px, 18vw), 1fr)); 1230 + gap: 0.75rem; 1231 + padding-right: 0.25rem; 1232 + overflow-y: auto; 1233 + scrollbar-width: none; 1234 + } 1235 + 1236 + .emoji-grid::-webkit-scrollbar { 1237 + display: none; 1128 1238 } 1129 1239 1130 1240 .emoji-option { 1131 1241 background: transparent; 1132 1242 border: none; 1133 - font-size: 1.5rem; 1134 - padding: 0.25rem; 1243 + font-size: 2.4rem; 1135 1244 cursor: pointer; 1136 - border-radius: var(--radius-sm); 1137 - transition: background 0.2s; 1245 + transition: transform 0.15s ease; 1138 1246 display: flex; 1139 1247 align-items: center; 1140 1248 justify-content: center; 1141 - width: 2rem; 1142 - height: 2rem; 1249 + width: 100%; 1250 + aspect-ratio: 1; 1143 1251 } 1144 1252 1145 1253 .emoji-option:hover { 1146 - background: var(--bg-tertiary); 1254 + transform: translateY(-3px) scale(1.05); 1147 1255 } 1148 1256 1149 - /* Custom emoji styles */ 1150 - .emoji-option.custom-emoji { 1151 - padding: 0.125rem; 1257 + .emoji-option:focus-visible { 1258 + outline: 2px solid var(--accent); 1259 + outline-offset: 6px; 1260 + border-radius: 16px; 1152 1261 } 1153 1262 1154 1263 .emoji-option.custom-emoji img { 1155 - width: 1.75rem; 1156 - height: 1.75rem; 1264 + width: 75%; 1265 + height: 75%; 1157 1266 object-fit: contain; 1267 + } 1268 + 1269 + .emoji-grid-placeholder, 1270 + .emoji-loading-message { 1271 + text-align: center; 1272 + color: var(--text-tertiary); 1273 + padding: 2rem 1rem; 1274 + font-size: 0.95rem; 1275 + } 1276 + 1277 + .emoji-picker-overlay::before { 1278 + content: ''; 1279 + position: fixed; 1280 + top: 0; 1281 + right: 0; 1282 + bottom: 0; 1283 + left: 0; 1284 + } 1285 + 1286 + @media (max-width: 640px) { 1287 + .emoji-picker-overlay { 1288 + padding: 0; 1289 + align-items: stretch; 1290 + } 1291 + 1292 + .emoji-picker { 1293 + width: 100%; 1294 + height: 100%; 1295 + max-height: none; 1296 + border-radius: 0; 1297 + padding: 1.5rem 1rem; 1298 + gap: 1rem; 1299 + } 1300 + 1301 + .emoji-grid { 1302 + grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); 1303 + gap: 0.5rem; 1304 + } 1305 + } 1306 + 1307 + @media (prefers-reduced-motion: reduce) { 1308 + .emoji-picker-overlay { 1309 + backdrop-filter: none; 1310 + background: rgba(6, 6, 8, 0.85); 1311 + } 1312 + 1313 + .emoji-picker, 1314 + .emoji-option { 1315 + transition: none; 1316 + } 1158 1317 } 1159 1318 1160 1319 /* Clear Picker */ ··· 1477 1636 .webhook-modal.hidden { display: none; } 1478 1637 .webhook-modal { 1479 1638 position: fixed; 1480 - inset: 0; 1639 + top: 0; 1640 + right: 0; 1641 + bottom: 0; 1642 + left: 0; 1643 + inset: 0; /* Modern browsers */ 1481 1644 background: rgba(0,0,0,0.6); 1482 1645 z-index: 1000; 1483 1646 display: flex; ··· 1937 2100 1938 2101 // Emoji picker 1939 2102 const emojiTrigger = document.getElementById('emoji-trigger'); 2103 + const emojiPickerOverlay = document.getElementById('emoji-picker-overlay'); 1940 2104 const emojiPicker = document.getElementById('emoji-picker'); 2105 + const emojiPickerClose = document.getElementById('emoji-picker-close'); 1941 2106 const emojiGrid = document.getElementById('emoji-grid'); 1942 2107 const selectedEmoji = document.getElementById('selected-emoji'); 1943 2108 const statusInput = document.getElementById('status-input'); 1944 - 2109 + const emojiSearch = document.getElementById('emoji-search'); 2110 + const emojiCategories = document.getElementById('emoji-categories'); 2111 + let lastFocusBeforeEmojiPicker = null; 2112 + let scrollPosition = 0; 2113 + let isScrollLocked = false; 2114 + let keydownBound = false; 2115 + 1945 2116 // Clear time picker 1946 2117 const clearAfterBtn = document.getElementById('clear-after-btn'); 1947 2118 const clearPicker = document.getElementById('clear-picker'); 1948 2119 const clearText = document.getElementById('clear-text'); 1949 2120 const expiresSelect = document.getElementById('expires_in'); 2121 + 2122 + const isEmojiPickerOpen = () => emojiPickerOverlay && !emojiPickerOverlay.classList.contains('hidden'); 2123 + 2124 + const handleKeydown = (e) => { 2125 + if (e.key === 'Escape' && isEmojiPickerOpen()) { 2126 + closeEmojiPicker(); 2127 + } 2128 + }; 1950 2129 2130 + // Clean up event listener on page unload 2131 + window.addEventListener('beforeunload', () => { 2132 + if (keydownBound) { 2133 + document.removeEventListener('keydown', handleKeydown); 2134 + keydownBound = false; 2135 + } 2136 + }); 2137 + 2138 + const openEmojiPicker = () => { 2139 + if (!emojiPickerOverlay || !emojiPicker) return; 2140 + 2141 + lastFocusBeforeEmojiPicker = document.activeElement; 2142 + scrollPosition = window.scrollY || document.documentElement.scrollTop || 0; 2143 + 2144 + emojiPickerOverlay.classList.remove('hidden'); 2145 + emojiPickerOverlay.setAttribute('aria-hidden', 'false'); 2146 + document.body.classList.add('modal-open'); 2147 + document.body.style.top = `-${scrollPosition}px`; 2148 + isScrollLocked = true; 2149 + if (clearPicker) clearPicker.style.display = 'none'; 2150 + 2151 + if (emojiSearch) { 2152 + emojiSearch.value = ''; 2153 + if (emojiCategories) emojiCategories.classList.remove('hidden'); 2154 + requestAnimationFrame(() => { 2155 + try { emojiSearch.focus(); } catch (_) {} 2156 + }); 2157 + } 2158 + 2159 + ensureCustomEmojis({ showLoading: false, showErrors: false }).then(() => { 2160 + loadEmojiCategory('frequent'); 2161 + }); 2162 + 2163 + if (!keydownBound) { 2164 + document.addEventListener('keydown', handleKeydown); 2165 + keydownBound = true; 2166 + } 2167 + }; 2168 + 2169 + const closeEmojiPicker = () => { 2170 + if (!emojiPickerOverlay) return; 2171 + emojiPickerOverlay.classList.add('hidden'); 2172 + emojiPickerOverlay.setAttribute('aria-hidden', 'true'); 2173 + document.body.classList.remove('modal-open'); 2174 + document.body.style.top = ''; 2175 + if (isScrollLocked) { 2176 + window.scrollTo(0, scrollPosition); 2177 + } 2178 + scrollPosition = 0; 2179 + isScrollLocked = false; 2180 + if (lastFocusBeforeEmojiPicker && typeof lastFocusBeforeEmojiPicker.focus === 'function') { 2181 + const focusTarget = lastFocusBeforeEmojiPicker; 2182 + requestAnimationFrame(() => { 2183 + try { focusTarget.focus(); } catch (_) {} 2184 + }); 2185 + } 2186 + lastFocusBeforeEmojiPicker = null; 2187 + 2188 + if (keydownBound) { 2189 + document.removeEventListener('keydown', handleKeydown); 2190 + keydownBound = false; 2191 + } 2192 + }; 2193 + 1951 2194 // Show emoji picker 1952 - if (emojiTrigger && emojiPicker) { 2195 + if (emojiTrigger && emojiPickerOverlay) { 1953 2196 emojiTrigger.addEventListener('click', (e) => { 2197 + e.preventDefault(); 1954 2198 e.stopPropagation(); 1955 - const wasHidden = emojiPicker.style.display === 'none'; 1956 - emojiPicker.style.display = wasHidden ? 'block' : 'none'; 1957 - clearPicker.style.display = 'none'; 1958 - 1959 - if (wasHidden) { 1960 - // Position picker 1961 - const rect = emojiTrigger.getBoundingClientRect(); 1962 - emojiPicker.style.top = rect.bottom + 'px'; 1963 - emojiPicker.style.left = rect.left + 'px'; 1964 - 1965 - // Clear search and show categories 1966 - const emojiSearch = document.getElementById('emoji-search'); 1967 - if (emojiSearch) { 1968 - emojiSearch.value = ''; 1969 - document.getElementById('emoji-categories').classList.remove('hidden'); 1970 - } 1971 - 1972 - // Load frequent emojis by default (wait for custom emojis to be loaded first) 1973 - loadCustomEmojis().then(() => { 1974 - loadEmojiCategory('frequent'); 1975 - }); 1976 - 1977 - // Focus search for easy typing 1978 - setTimeout(() => { 1979 - if (emojiSearch) emojiSearch.focus(); 1980 - }, 50); 2199 + if (isEmojiPickerOpen()) { 2200 + closeEmojiPicker(); 2201 + } else { 2202 + openEmojiPicker(); 2203 + } 2204 + }); 2205 + } 2206 + 2207 + if (emojiPickerClose) { 2208 + emojiPickerClose.addEventListener('click', (e) => { 2209 + e.preventDefault(); 2210 + closeEmojiPicker(); 2211 + }); 2212 + } 2213 + 2214 + if (emojiPickerOverlay) { 2215 + emojiPickerOverlay.addEventListener('click', (e) => { 2216 + // Only close if clicking the overlay itself, not the picker content 2217 + if (e.target === emojiPickerOverlay) { 2218 + closeEmojiPicker(); 1981 2219 } 1982 2220 }); 1983 2221 } 1984 2222 1985 - // Store custom emojis 2223 + // Emoji data caches 1986 2224 let customEmojis = []; 1987 - 1988 - // Load custom emojis from API 1989 - const loadCustomEmojis = async () => { 1990 - try { 1991 - const response = await fetch('/api/custom-emojis'); 1992 - customEmojis = await response.json(); 1993 - } catch (e) { 1994 - console.error('Failed to load custom emojis:', e); 2225 + let customEmojiMap = new Map(); 2226 + let customEmojiFetchPromise = null; 2227 + let searchDebounce; 2228 + const SEARCH_DEBOUNCE_MS = 120; 2229 + 2230 + const showGridMessage = (message, className = 'emoji-grid-placeholder') => { 2231 + if (!emojiGrid) return; 2232 + const placeholder = document.createElement('div'); 2233 + placeholder.className = className; 2234 + placeholder.textContent = message; 2235 + emojiGrid.replaceChildren(placeholder); 2236 + }; 2237 + 2238 + const createCustomEmojiButton = (emoji) => { 2239 + const button = document.createElement('button'); 2240 + button.type = 'button'; 2241 + button.className = 'emoji-option custom-emoji'; 2242 + button.dataset.emoji = `custom:${emoji.name}`; 2243 + button.dataset.name = emoji.name; 2244 + 2245 + const img = document.createElement('img'); 2246 + img.src = `/emojis/${emoji.filename}`; 2247 + img.alt = emoji.name; 2248 + img.title = emoji.name; 2249 + button.appendChild(img); 2250 + return button; 2251 + }; 2252 + 2253 + const createStandardEmojiButton = (emoji, title = '') => { 2254 + const button = document.createElement('button'); 2255 + button.type = 'button'; 2256 + button.className = 'emoji-option'; 2257 + button.dataset.emoji = emoji; 2258 + if (title) { 2259 + button.dataset.name = title; 2260 + button.title = title; 1995 2261 } 2262 + button.textContent = emoji; 2263 + return button; 2264 + }; 2265 + 2266 + const renderEmojiButtons = (buttons) => { 2267 + if (!emojiGrid) return; 2268 + const fragment = document.createDocumentFragment(); 2269 + buttons.forEach(btn => fragment.appendChild(btn)); 2270 + emojiGrid.replaceChildren(fragment); 2271 + }; 2272 + 2273 + const fetchCustomEmojis = async () => { 2274 + const response = await fetch('/api/custom-emojis'); 2275 + const data = await response.json(); 2276 + if (!Array.isArray(data)) { 2277 + throw new Error('Invalid custom emoji payload'); 2278 + } 2279 + customEmojis = data; 2280 + customEmojiMap = new Map(data.map(emoji => [emoji.name, emoji])); 1996 2281 return customEmojis; 1997 2282 }; 1998 - 1999 - // Load custom emojis on page load 2000 - loadCustomEmojis(); 2001 2283 2002 - // Refresh custom emojis when an admin uploads a new one 2003 - document.addEventListener('custom-emoji-uploaded', async (e) => { 2004 - await loadCustomEmojis(); 2005 - // If picker visible, refresh current category 2006 - if (emojiPicker && emojiPicker.style.display !== 'none') { 2007 - const active = document.querySelector('.emoji-categories .category-btn.active'); 2008 - const cat = active ? active.getAttribute('data-category') : 'frequent'; 2009 - loadEmojiCategory(cat || 'frequent'); 2284 + const ensureCustomEmojis = async ({ showLoading = false, showErrors = true } = {}) => { 2285 + if (customEmojis.length) return customEmojis; 2286 + if (showLoading) { 2287 + showGridMessage('loading custom emojis…', 'emoji-loading-message'); 2288 + } 2289 + if (!customEmojiFetchPromise) { 2290 + customEmojiFetchPromise = fetchCustomEmojis() 2291 + .catch(err => { 2292 + console.error('Failed to load custom emojis:', err); 2293 + customEmojis = []; 2294 + customEmojiMap = new Map(); 2295 + if (showErrors) { 2296 + showGridMessage('failed to load custom emojis'); 2297 + } 2298 + throw err; 2299 + }) 2300 + .finally(() => { 2301 + customEmojiFetchPromise = null; 2302 + }); 2010 2303 } 2011 - }); 2012 - 2013 - // Load emoji category 2304 + try { 2305 + await customEmojiFetchPromise; 2306 + } catch (_) { 2307 + return []; 2308 + } 2309 + return customEmojis; 2310 + }; 2311 + 2312 + // Quietly preload custom emojis so the custom tab feels instant 2313 + ensureCustomEmojis({ showLoading: false, showErrors: false }).catch(() => {}); 2314 + 2315 + const updateActiveCategory = (category) => { 2316 + document.querySelectorAll('.category-btn').forEach(btn => { 2317 + btn.classList.toggle('active', btn.getAttribute('data-category') === category); 2318 + }); 2319 + }; 2320 + 2014 2321 const loadEmojiCategory = async (category) => { 2322 + if (!emojiGrid) return; 2323 + 2324 + const buttons = []; 2325 + const emojis = emojiData[category] || []; 2326 + 2015 2327 if (category === 'frequent') { 2016 - // For frequent tab, we need to handle both regular and custom emojis 2017 - const emojis = emojiData[category] || []; 2018 - let html = ''; 2019 - 2328 + if (emojis.some(emoji => emoji.startsWith('custom:'))) { 2329 + await ensureCustomEmojis({ showLoading: false, showErrors: false }); 2330 + } 2020 2331 for (const emoji of emojis) { 2021 2332 if (emoji.startsWith('custom:')) { 2022 - // This is a custom emoji 2023 - const emojiName = emoji.replace('custom:', ''); 2024 - // Find the custom emoji data 2025 - const customEmoji = customEmojis.find(e => e.name === emojiName); 2026 - if (customEmoji) { 2027 - html += `<button type="button" class="emoji-option custom-emoji" data-emoji="${emoji}" data-name="${emojiName}"> 2028 - <img src="/emojis/${customEmoji.filename}" alt="${emojiName}" title="${emojiName}"> 2029 - </button>`; 2333 + const name = emoji.replace('custom:', ''); 2334 + const custom = customEmojiMap.get(name); 2335 + if (custom) { 2336 + buttons.push(createCustomEmojiButton(custom)); 2030 2337 } 2031 2338 } else { 2032 - // Regular Unicode emoji 2033 - html += `<button type="button" class="emoji-option" data-emoji="${emoji}">${emoji}</button>`; 2339 + const slug = (window.__emojiSlugs && window.__emojiSlugs[emoji]) || ''; 2340 + buttons.push(createStandardEmojiButton(emoji, slug)); 2034 2341 } 2035 2342 } 2036 - 2037 - emojiGrid.innerHTML = html; 2038 - 2039 - // Add click handlers for both types 2040 - emojiGrid.querySelectorAll('.emoji-option').forEach(btn => { 2041 - btn.addEventListener('click', (e) => { 2042 - e.preventDefault(); 2043 - const emoji = btn.getAttribute('data-emoji'); 2044 - 2045 - if (emoji.startsWith('custom:')) { 2046 - const img = btn.querySelector('img'); 2047 - selectedEmoji.innerHTML = `<img src="${img.src}" alt="${img.alt}" style="width: 100%; height: 100%; object-fit: contain;">`; 2048 - } else { 2049 - selectedEmoji.textContent = emoji; 2050 - } 2051 - 2052 - statusInput.value = emoji; 2053 - emojiPicker.style.display = 'none'; 2054 - checkForChanges(); 2055 - }); 2056 - }); 2057 2343 } else if (category === 'custom') { 2058 - // Load custom image emojis 2059 - if (customEmojis.length === 0) { 2060 - await loadCustomEmojis(); 2344 + const data = await ensureCustomEmojis({ showLoading: true }); 2345 + if (!data.length) { 2346 + showGridMessage('no custom emojis yet'); 2347 + updateActiveCategory(category); 2348 + return; 2061 2349 } 2062 - 2063 - emojiGrid.innerHTML = customEmojis.map(emoji => 2064 - `<button type="button" class="emoji-option custom-emoji" data-emoji="custom:${emoji.name}" data-name="${emoji.name}"> 2065 - <img src="/emojis/${emoji.filename}" alt="${emoji.name}" title="${emoji.name}"> 2066 - </button>` 2067 - ).join(''); 2068 - 2069 - // Add click handlers for custom emojis 2070 - emojiGrid.querySelectorAll('.custom-emoji').forEach(btn => { 2071 - btn.addEventListener('click', (e) => { 2072 - e.preventDefault(); 2073 - const emojiName = btn.getAttribute('data-name'); 2074 - const emojiValue = btn.getAttribute('data-emoji'); 2075 - const img = btn.querySelector('img'); 2076 - 2077 - // Display the image in the selected emoji area 2078 - selectedEmoji.innerHTML = `<img src="${img.src}" alt="${img.alt}" style="width: 100%; height: 100%; object-fit: contain;">`; 2079 - statusInput.value = emojiValue; 2080 - emojiPicker.style.display = 'none'; 2081 - checkForChanges(); 2082 - }); 2083 - }); 2350 + for (const emoji of data) { 2351 + buttons.push(createCustomEmojiButton(emoji)); 2352 + } 2084 2353 } else { 2085 - // Load standard Unicode emojis 2086 - const emojis = emojiData[category] || []; 2087 - emojiGrid.innerHTML = emojis.map(emoji => { 2354 + for (const emoji of emojis) { 2088 2355 const slug = (window.__emojiSlugs && window.__emojiSlugs[emoji]) || ''; 2089 - return `<button type="button" class="emoji-option" data-emoji="${emoji}" data-name="${slug}" title="${slug}">${emoji}</button>`; 2090 - }).join(''); 2091 - 2092 - // Add click handlers for standard emojis 2093 - emojiGrid.querySelectorAll('.emoji-option').forEach(btn => { 2094 - btn.addEventListener('click', (e) => { 2095 - const emoji = e.target.getAttribute('data-emoji'); 2096 - selectedEmoji.textContent = emoji; 2097 - statusInput.value = emoji; 2098 - emojiPicker.style.display = 'none'; 2099 - checkForChanges(); 2100 - }); 2101 - }); 2356 + buttons.push(createStandardEmojiButton(emoji, slug)); 2357 + } 2358 + } 2359 + 2360 + if (buttons.length) { 2361 + renderEmojiButtons(buttons); 2362 + } else if (category === 'custom') { 2363 + showGridMessage('no custom emojis yet'); 2364 + } else { 2365 + showGridMessage('no emojis in this category'); 2366 + } 2367 + 2368 + updateActiveCategory(category); 2369 + }; 2370 + 2371 + const applyEmojiSelection = (button) => { 2372 + if (!button || !selectedEmoji || !statusInput) return; 2373 + const emojiValue = button.getAttribute('data-emoji'); 2374 + if (!emojiValue) return; 2375 + 2376 + selectedEmoji.innerHTML = ''; 2377 + if (button.classList.contains('custom-emoji')) { 2378 + const img = button.querySelector('img'); 2379 + if (img) { 2380 + const previewImg = document.createElement('img'); 2381 + previewImg.src = img.src; 2382 + previewImg.alt = img.alt; 2383 + previewImg.title = img.title || img.alt || ''; 2384 + previewImg.style.width = '100%'; 2385 + previewImg.style.height = '100%'; 2386 + previewImg.style.objectFit = 'contain'; 2387 + selectedEmoji.appendChild(previewImg); 2388 + } 2389 + } else { 2390 + selectedEmoji.textContent = emojiValue; 2102 2391 } 2103 - 2104 - // Update active category 2105 - document.querySelectorAll('.category-btn').forEach(btn => { 2106 - btn.classList.toggle('active', btn.getAttribute('data-category') === category); 2392 + 2393 + statusInput.value = emojiValue; 2394 + if (emojiSearch) { 2395 + emojiSearch.value = ''; 2396 + } 2397 + if (emojiCategories) { 2398 + emojiCategories.classList.remove('hidden'); 2399 + } 2400 + closeEmojiPicker(); 2401 + checkForChanges(); 2402 + }; 2403 + 2404 + if (emojiGrid) { 2405 + emojiGrid.addEventListener('click', (e) => { 2406 + const button = e.target.closest('.emoji-option'); 2407 + if (!button || !emojiGrid.contains(button)) return; 2408 + e.preventDefault(); 2409 + applyEmojiSelection(button); 2107 2410 }); 2108 - }; 2109 - 2411 + } 2412 + 2110 2413 // Category buttons 2111 2414 document.querySelectorAll('.category-btn').forEach(btn => { 2112 - btn.addEventListener('click', () => { 2113 - loadEmojiCategory(btn.getAttribute('data-category')); 2415 + btn.addEventListener('click', async (event) => { 2416 + event.preventDefault(); 2417 + event.stopPropagation(); // Prevent event bubbling that might close the modal 2418 + const category = btn.getAttribute('data-category'); 2419 + await loadEmojiCategory(category); 2114 2420 }); 2115 2421 }); 2116 2422 ··· 2119 2425 clearAfterBtn.addEventListener('click', (e) => { 2120 2426 e.stopPropagation(); 2121 2427 clearPicker.style.display = clearPicker.style.display === 'none' ? 'block' : 'none'; 2122 - emojiPicker.style.display = 'none'; 2428 + closeEmojiPicker(); 2123 2429 2124 2430 // Position picker 2125 2431 const rect = clearAfterBtn.getBoundingClientRect(); ··· 2228 2534 2229 2535 // Close pickers on outside click 2230 2536 document.addEventListener('click', (e) => { 2231 - if (!emojiPicker.contains(e.target) && e.target !== emojiTrigger) { 2232 - emojiPicker.style.display = 'none'; 2233 - } 2234 - if (!clearPicker.contains(e.target) && e.target !== clearAfterBtn) { 2537 + if (clearPicker && !clearPicker.contains(e.target) && e.target !== clearAfterBtn) { 2235 2538 clearPicker.style.display = 'none'; 2236 2539 } 2237 2540 }); 2238 2541 2239 2542 // Search emoji function 2240 - const searchEmojis = (query) => { 2543 + const searchEmojis = async (rawQuery) => { 2544 + if (!emojiGrid) return; 2545 + const query = (rawQuery || '').trim(); 2546 + 2241 2547 if (!query) { 2242 - document.getElementById('emoji-categories').classList.remove('hidden'); 2243 - loadEmojiCategory('frequent'); 2548 + if (emojiCategories) emojiCategories.classList.remove('hidden'); 2549 + await loadEmojiCategory('frequent'); 2244 2550 return; 2245 2551 } 2246 - 2247 - // Hide categories when searching 2248 - document.getElementById('emoji-categories').classList.add('hidden'); 2249 - 2552 + 2553 + if (emojiCategories) emojiCategories.classList.add('hidden'); 2554 + 2250 2555 const lowerQuery = query.toLowerCase(); 2251 - const results = []; 2252 - const customResults = []; 2253 - 2254 - // Search through emoji keywords 2255 - for (const [emoji, keywords] of Object.entries(emojiKeywords)) { 2256 - // Check if emoji itself matches 2257 - if (emoji.includes(query)) { 2258 - results.push(emoji); 2259 - continue; 2556 + const buttons = []; 2557 + const seen = new Set(); 2558 + 2559 + await ensureCustomEmojis({ showLoading: false, showErrors: false }); 2560 + 2561 + for (const emoji of customEmojis) { 2562 + if (emoji.name.toLowerCase().includes(lowerQuery)) { 2563 + buttons.push(createCustomEmojiButton(emoji)); 2564 + seen.add(`custom:${emoji.name}`); 2565 + if (buttons.length >= 80) break; 2260 2566 } 2261 - 2262 - // Check keywords 2263 - for (const keyword of keywords) { 2264 - if (keyword.includes(lowerQuery)) { 2265 - results.push(emoji); 2266 - break; 2567 + } 2568 + 2569 + if (buttons.length < 80) { 2570 + for (const [emoji, keywords] of Object.entries(emojiKeywords)) { 2571 + if (seen.has(emoji)) continue; 2572 + if (emoji.includes(query) || keywords.some(keyword => keyword.includes(lowerQuery))) { 2573 + buttons.push(createStandardEmojiButton(emoji)); 2574 + seen.add(emoji); 2575 + if (buttons.length >= 80) break; 2267 2576 } 2268 2577 } 2269 2578 } 2270 - 2271 - // Also add any emojis from all categories that aren't in keywords 2272 - const allCategoryEmojis = Object.values(emojiData).flat(); 2273 - for (const emoji of allCategoryEmojis) { 2274 - if (!emojiKeywords[emoji] && !results.includes(emoji)) { 2275 - // For emojis without keywords, just do a basic include check 2276 - if (emoji.includes(query)) { 2277 - results.push(emoji); 2579 + 2580 + if (buttons.length < 80) { 2581 + outer: for (const categoryEmojis of Object.values(emojiData)) { 2582 + for (const emoji of categoryEmojis) { 2583 + if (seen.has(emoji)) continue; 2584 + if (emoji.includes(query)) { 2585 + buttons.push(createStandardEmojiButton(emoji)); 2586 + seen.add(emoji); 2587 + if (buttons.length >= 80) break outer; 2588 + } 2278 2589 } 2279 2590 } 2280 2591 } 2281 - 2282 - // Search through custom emojis by name 2283 - for (const customEmoji of customEmojis) { 2284 - if (customEmoji.name.toLowerCase().includes(lowerQuery)) { 2285 - customResults.push(customEmoji); 2286 - } 2287 - } 2288 - 2289 - // Remove duplicates and limit results 2290 - const uniqueResults = [...new Set(results)].slice(0, 50); 2291 - const uniqueCustomResults = customResults.slice(0, 50); 2292 - 2293 - // Display results - combine regular and custom emojis 2294 - let resultsHtml = ''; 2295 - 2296 - // Add custom emoji results first 2297 - if (uniqueCustomResults.length > 0) { 2298 - resultsHtml += uniqueCustomResults.map(emoji => 2299 - `<button type="button" class="emoji-option custom-emoji" data-emoji="custom:${emoji.name}" data-name="${emoji.name}"> 2300 - <img src="/emojis/${emoji.filename}" alt="${emoji.name}" title="${emoji.name}"> 2301 - </button>` 2302 - ).join(''); 2303 - } 2304 - 2305 - // Add regular emoji results 2306 - if (uniqueResults.length > 0) { 2307 - resultsHtml += uniqueResults.map(emoji => 2308 - `<button type="button" class="emoji-option" data-emoji="${emoji}">${emoji}</button>` 2309 - ).join(''); 2310 - } 2311 - 2312 - if (resultsHtml) { 2313 - emojiGrid.innerHTML = resultsHtml; 2592 + 2593 + if (buttons.length) { 2594 + renderEmojiButtons(buttons); 2314 2595 } else { 2315 - emojiGrid.innerHTML = '<div style="text-align: center; color: var(--text-tertiary); padding: 2rem;">No emojis found</div>'; 2596 + showGridMessage('No emojis found'); 2316 2597 } 2317 - 2318 - // Add click handlers for both regular and custom emojis 2319 - emojiGrid.querySelectorAll('.emoji-option').forEach(btn => { 2320 - btn.addEventListener('click', (e) => { 2321 - e.preventDefault(); 2322 - const emojiValue = btn.getAttribute('data-emoji'); 2323 - 2324 - if (btn.classList.contains('custom-emoji')) { 2325 - // Handle custom emoji 2326 - const img = btn.querySelector('img'); 2327 - selectedEmoji.innerHTML = `<img src="${img.src}" alt="${img.alt}" style="width: 100%; height: 100%; object-fit: contain;">`; 2328 - } else { 2329 - // Handle regular emoji 2330 - selectedEmoji.textContent = emojiValue; 2331 - } 2332 - 2333 - statusInput.value = emojiValue; 2334 - emojiPicker.style.display = 'none'; 2335 - checkForChanges(); 2336 - // Clear search when emoji is selected 2337 - document.getElementById('emoji-search').value = ''; 2338 - }); 2339 - }); 2340 2598 }; 2341 - 2599 + 2342 2600 // Emoji search input handler 2343 - const emojiSearch = document.getElementById('emoji-search'); 2344 2601 if (emojiSearch) { 2345 2602 emojiSearch.addEventListener('input', (e) => { 2346 - searchEmojis(e.target.value); 2603 + const value = e.target.value; 2604 + if (searchDebounce) clearTimeout(searchDebounce); 2605 + searchDebounce = setTimeout(() => { 2606 + searchEmojis(value); 2607 + }, SEARCH_DEBOUNCE_MS); 2347 2608 }); 2348 - 2609 + 2349 2610 // Focus search when picker opens 2350 2611 emojiSearch.addEventListener('focus', () => { 2351 2612 if (!emojiSearch.value) {