+231
-141
templates/status.html
+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) {