Add mobile favorites UX and Android deep link support

This commit is contained in:
Dwindi Ramadhana
2026-02-22 23:32:48 +07:00
parent d5d925d534
commit 32e9282349
10 changed files with 594 additions and 41 deletions

View File

@@ -100,6 +100,13 @@
</div>
<input id="q" type="text" placeholder="Search emojis by keyword, mood, meaning..." class="w-full bg-transparent text-white placeholder-gray-500 focus:outline-none font-medium h-full text-sm sm:text-base">
<div class="flex md:hidden items-center gap-2 pr-2">
<button id="mobile-filters-open" type="button" class="h-8 rounded-lg bg-white/5 border border-white/10 px-2 text-xs text-gray-300 hover:bg-white/10 transition-colors inline-flex items-center gap-1.5 shrink-0">
<i data-lucide="sliders-horizontal" class="w-3.5 h-3.5"></i>
<span>Filter</span>
</button>
<button id="favorites-only-toggle-mobile" type="button" class="w-8 h-8 rounded-full theme-surface border border-white/10 flex items-center justify-center text-gray-300 hover:text-white transition-colors shrink-0" title="Show favorites only">
<span class="text-sm leading-none"></span>
</button>
<button id="tone-toggle-mobile" class="w-8 h-8 rounded-full theme-surface border border-white/10 flex items-center justify-center text-gray-300 hover:text-white transition-colors shrink-0" title="Tone: Default">
<span id="tone-dot-mobile" class="w-3 h-3 rounded-full bg-white/80 border border-white/30"></span>
</button>
@@ -112,12 +119,15 @@
</div>
<div class="flex items-center gap-3 shrink-0">
<select id="category" class="bg-[#151518] border border-white/10 rounded-xl px-4 text-sm text-gray-300 focus:outline-none focus:border-brand-ocean hover:bg-white/5 transition-colors h-11 cursor-pointer appearance-none theme-surface">
<select id="category" class="hidden md:block bg-[#151518] border border-white/10 rounded-xl px-4 text-sm text-gray-300 focus:outline-none focus:border-brand-ocean hover:bg-white/5 transition-colors h-11 cursor-pointer appearance-none theme-surface">
<option value="">All Categories</option>
</select>
<select id="subcategory" class="bg-[#151518] border border-white/10 rounded-xl px-4 text-sm text-gray-300 focus:outline-none focus:border-brand-ocean hover:bg-white/5 transition-colors h-11 cursor-pointer appearance-none theme-surface" disabled>
<select id="subcategory" class="hidden md:block bg-[#151518] border border-white/10 rounded-xl px-4 text-sm text-gray-300 focus:outline-none focus:border-brand-ocean hover:bg-white/5 transition-colors h-11 cursor-pointer appearance-none theme-surface" disabled>
<option value="">All Subcategories</option>
</select>
<button id="favorites-only-toggle" type="button" class="hidden md:flex w-10 h-10 rounded-full theme-surface border border-white/10 shadow-lg items-center justify-center text-gray-300 hover:text-white transition-colors" title="Show favorites only">
<span class="text-sm leading-none"></span>
</button>
<button id="tone-toggle" class="hidden md:flex w-10 h-10 rounded-full theme-surface border border-white/10 shadow-lg items-center justify-center text-gray-300 hover:text-white transition-colors" title="Tone: Default">
<span id="tone-dot-desktop" class="w-4 h-4 rounded-full bg-white/80 border border-white/30"></span>
</button>
@@ -173,7 +183,7 @@
</div>
<span id="dataset-count" class="text-[10px] text-gray-500 font-mono">0 items</span>
</div>
<div>
<div class="mt-4">
<h4 class="font-bold text-sm">Recent</h4>
<div id="recent-list" class="flex gap-2 mt-2"></div>
</div>
@@ -209,7 +219,7 @@
</main>
</div>
<div id="toast" class="fixed bottom-8 left-1/2 -translate-x-1/2 translate-y-24 opacity-0 transition-all duration-300 z-50">
<div id="toast" class="fixed left-1/2 -translate-x-1/2 translate-y-24 opacity-0 transition-all duration-300 z-50" style="bottom: calc(env(safe-area-inset-bottom, 0px) + 6rem);">
<div class="bg-brand-ocean text-white px-4 py-2 rounded-full font-bold shadow-[0_0_20px_rgba(32,83,255,0.45)] flex items-center gap-2 text-sm">
<i data-lucide="check" class="w-4 h-4"></i>
<span id="toast-msg">Copied!</span>
@@ -243,6 +253,30 @@
</form>
</div>
</div>
<div id="mobile-filters-sheet" class="hidden md:hidden fixed inset-0 z-50 items-end">
<div id="mobile-filters-backdrop" class="absolute inset-0 bg-black/70 backdrop-blur-sm"></div>
<div class="relative z-10 w-full rounded-t-3xl glass-card theme-surface p-5 pb-6 border-t border-white/10" style="padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 6rem);">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-gray-100">Filters</h3>
<button id="mobile-filters-close" type="button" class="w-9 h-9 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-gray-200">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<div class="grid gap-3">
<select id="category-mobile" class="bg-[#151518] border border-white/10 rounded-xl px-4 text-sm text-gray-300 focus:outline-none focus:border-brand-ocean h-12 cursor-pointer appearance-none theme-surface">
<option value="">All Categories</option>
</select>
<select id="subcategory-mobile" class="bg-[#151518] border border-white/10 rounded-xl px-4 text-sm text-gray-300 focus:outline-none focus:border-brand-ocean h-12 cursor-pointer appearance-none theme-surface" disabled>
<option value="">All Subcategories</option>
</select>
<div class="grid grid-cols-2 gap-3 pt-1">
<button id="mobile-filters-reset" type="button" class="h-11 rounded-xl border border-white/10 text-sm text-gray-200 hover:bg-white/5">Reset</button>
<button id="mobile-filters-apply" type="button" class="h-11 rounded-xl bg-brand-ocean text-white force-white text-sm font-semibold">Apply</button>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
@@ -259,6 +293,16 @@
const qEl = document.getElementById('q');
const catEl = document.getElementById('category');
const subEl = document.getElementById('subcategory');
const mobileFiltersOpenEl = document.getElementById('mobile-filters-open');
const mobileFiltersSheetEl = document.getElementById('mobile-filters-sheet');
const mobileFiltersBackdropEl = document.getElementById('mobile-filters-backdrop');
const mobileFiltersCloseEl = document.getElementById('mobile-filters-close');
const mobileFiltersApplyEl = document.getElementById('mobile-filters-apply');
const mobileFiltersResetEl = document.getElementById('mobile-filters-reset');
const catMobileEl = document.getElementById('category-mobile');
const subMobileEl = document.getElementById('subcategory-mobile');
const favoritesOnlyDesktopBtn = document.getElementById('favorites-only-toggle');
const favoritesOnlyMobileBtn = document.getElementById('favorites-only-toggle-mobile');
const toneDesktopBtn = document.getElementById('tone-toggle');
const toneMobileBtn = document.getElementById('tone-toggle-mobile');
const toneDotDesktop = document.getElementById('tone-dot-desktop');
@@ -274,6 +318,8 @@
const datasetCount = document.getElementById('dataset-count');
const trendingList = document.getElementById('trending-list');
const recentList = document.getElementById('recent-list');
const favoritesList = document.getElementById('favorites-list');
const favoritesClearEl = document.getElementById('favorites-clear');
const heroCards = document.getElementById('hero-cards');
const heroMain = document.getElementById('hero-main');
const heroOptional1 = document.getElementById('hero-optional-1');
@@ -286,6 +332,9 @@
const labelsToggleEl = document.getElementById('labels-toggle');
const densityStorageKey = 'dewemoji_grid_density';
const labelsStorageKey = 'dewemoji_grid_show_labels';
const recentStorageKey = 'dewemoji_recent';
const favoritesStorageKey = 'dewemoji_favorites';
const favoritesOnlyStorageKey = 'dewemoji_favorites_only';
let isFetching = false;
let autoLoadObserver = null;
const AUTOLOAD_THRESHOLD_PX = 420;
@@ -345,6 +394,50 @@
if (labelsToggleEl) labelsToggleEl.textContent = `Labels: ${enabled ? 'On' : 'Off'}`;
}
function isFavoritesOnlyEnabled() {
return localStorage.getItem(favoritesOnlyStorageKey) === '1';
}
function setFavoritesOnlyEnabled(enabled) {
localStorage.setItem(favoritesOnlyStorageKey, enabled ? '1' : '0');
applyFavoritesOnlyUI();
}
function applyFavoritesOnlyUI() {
const active = isFavoritesOnlyEnabled();
[favoritesOnlyDesktopBtn, favoritesOnlyMobileBtn].forEach((btn) => {
if (!btn) return;
btn.classList.toggle('text-yellow-300', active);
btn.classList.toggle('border-yellow-400/30', active);
btn.classList.toggle('bg-yellow-400/10', active);
btn.classList.toggle('text-gray-300', !active);
btn.title = active ? 'Showing favorites only' : 'Show favorites only';
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
const icon = btn.querySelector('span');
if (icon) icon.textContent = active ? '★' : '☆';
});
}
function isMobileFiltersOpen() {
return mobileFiltersSheetEl?.classList.contains('flex');
}
function openMobileFilters() {
if (!mobileFiltersSheetEl) return;
syncMobileFiltersFromDesktop();
mobileFiltersSheetEl.classList.remove('hidden');
mobileFiltersSheetEl.classList.add('flex');
setTimeout(() => lucide.createIcons(), 0);
}
function closeMobileFilters() {
if (!mobileFiltersSheetEl) return false;
if (!isMobileFiltersOpen()) return false;
mobileFiltersSheetEl.classList.add('hidden');
mobileFiltersSheetEl.classList.remove('flex');
return true;
}
if (initialQuery) qEl.value = initialQuery;
function slugify(text) {
@@ -446,7 +539,7 @@
function hasActiveFilters() {
return qEl.value.trim() !== '' || catEl.value !== '' || subEl.value !== '';
return isFavoritesOnlyEnabled() || qEl.value.trim() !== '' || catEl.value !== '' || subEl.value !== '';
}
function updateHeroMode() {
@@ -461,24 +554,50 @@
const res = await fetch('/v1/categories');
const data = await res.json();
state.categories = data || {};
Object.keys(state.categories).forEach((name) => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
catEl.appendChild(opt);
});
const fillCategorySelect = (selectEl) => {
if (!selectEl) return;
selectEl.innerHTML = '<option value="">All Categories</option>';
Object.keys(state.categories).forEach((name) => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
selectEl.appendChild(opt);
});
};
fillCategorySelect(catEl);
fillCategorySelect(catMobileEl);
}
function renderSubcategories() {
const subs = state.categories[catEl.value] || [];
subEl.innerHTML = '<option value="">All subcategories</option>';
function fillSubcategories(selectEl, categoryValue, selectedValue = '') {
if (!selectEl) return;
const subs = state.categories[categoryValue] || [];
selectEl.innerHTML = '<option value="">All Subcategories</option>';
subs.forEach((s) => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
subEl.appendChild(opt);
selectEl.appendChild(opt);
});
subEl.disabled = subs.length === 0;
const hasSelected = Array.from(selectEl.options).some((opt) => opt.value === selectedValue);
selectEl.value = hasSelected ? selectedValue : '';
selectEl.disabled = subs.length === 0;
}
function renderSubcategories() {
fillSubcategories(subEl, catEl.value, subEl.value);
fillSubcategories(subMobileEl, catEl.value, subEl.value);
}
function syncMobileFiltersFromDesktop() {
if (!catMobileEl || !subMobileEl) return;
catMobileEl.value = catEl.value;
fillSubcategories(subMobileEl, catMobileEl.value, subEl.value);
}
function syncDesktopFiltersFromMobile() {
if (!catMobileEl || !subMobileEl) return;
catEl.value = catMobileEl.value;
fillSubcategories(subEl, catEl.value, subMobileEl.value);
}
function showToast(message) {
@@ -491,17 +610,32 @@
function loadRecent() {
try {
return JSON.parse(localStorage.getItem('dewemoji_recent') || '[]');
const parsed = JSON.parse(localStorage.getItem(recentStorageKey) || '[]');
return Array.isArray(parsed) ? parsed.filter(isRecentEmojiToken) : [];
} catch {
return [];
}
}
function isRecentEmojiToken(value) {
const s = String(value || '').trim();
if (!s) return false;
if (s.length > 24) return false;
if (/[:;&<#\\]/.test(s)) return false;
try {
return /\p{Extended_Pictographic}/u.test(s);
} catch {
return /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(s);
}
}
function saveRecent(items) {
localStorage.setItem('dewemoji_recent', JSON.stringify(items.slice(0, 8)));
const clean = items.filter(isRecentEmojiToken);
localStorage.setItem(recentStorageKey, JSON.stringify(clean.slice(0, 8)));
}
function addRecent(emoji) {
if (!isRecentEmojiToken(emoji)) return;
const curr = loadRecent().filter((e) => e !== emoji);
curr.unshift(emoji);
saveRecent(curr);
@@ -522,6 +656,113 @@
});
}
function loadFavorites() {
try {
const parsed = JSON.parse(localStorage.getItem(favoritesStorageKey) || '[]');
if (!Array.isArray(parsed)) return [];
return parsed
.filter((item) => item && typeof item === 'object')
.map((item) => ({
slug: String(item.slug || '').trim(),
emoji: String(item.emoji || '').trim(),
name: String(item.name || '').trim(),
category: String(item.category || '').trim(),
subcategory: String(item.subcategory || '').trim(),
supports_skin_tone: Boolean(item.supports_skin_tone),
variants: Array.isArray(item.variants) ? item.variants : [],
}))
.filter((item) => item.slug !== '' && isRecentEmojiToken(item.emoji));
} catch {
return [];
}
}
function saveFavorites(items) {
localStorage.setItem(favoritesStorageKey, JSON.stringify(items.slice(0, 24)));
}
function isFavoriteSlug(slug) {
const key = String(slug || '');
return loadFavorites().some((item) => item.slug === key);
}
function toggleFavorite(item) {
const slug = String(item?.slug || '');
if (!slug) return false;
const current = loadFavorites();
const all = current.filter((row) => row.slug !== slug);
const exists = all.length !== current.length;
if (!exists) {
all.unshift({
slug,
emoji: emojiWithTone(item),
name: String(item?.name || ''),
category: String(item?.category || ''),
subcategory: String(item?.subcategory || ''),
supports_skin_tone: Boolean(item?.supports_skin_tone),
variants: Array.isArray(item?.variants) ? item.variants : [],
});
}
saveFavorites(all);
renderFavorites();
return !exists;
}
function renderFavorites() {
if (!favoritesList) return;
const favorites = loadFavorites();
favoritesList.innerHTML = '';
if (favorites.length === 0) {
const empty = document.createElement('span');
empty.className = 'text-xs text-gray-500';
empty.textContent = 'No favorites yet';
favoritesList.appendChild(empty);
return;
}
favorites.slice(0, 8).forEach((fav) => {
const a = document.createElement('a');
a.href = `/emoji/${encodeURIComponent(fav.slug)}`;
a.className = 'w-8 h-8 rounded bg-white/5 hover:bg-white/10 flex items-center justify-center text-lg';
a.title = fav.name || fav.slug;
a.textContent = fav.emoji;
favoritesList.appendChild(a);
});
}
function favoriteItemsForCatalog() {
const q = qEl.value.trim().toLowerCase();
const cat = String(catEl.value || '').trim().toLowerCase();
const sub = String(subEl.value || '').trim().toLowerCase();
return loadFavorites().filter((item) => {
const name = String(item.name || '').toLowerCase();
const slug = String(item.slug || '').toLowerCase();
const emoji = String(item.emoji || '');
const itemCat = String(item.category || '').toLowerCase();
const itemSub = String(item.subcategory || '').toLowerCase();
if (q && !(name.includes(q) || slug.includes(q) || emoji.includes(q))) {
return false;
}
if (cat && itemCat !== cat) {
return false;
}
if (sub && itemSub !== sub) {
return false;
}
return true;
}).map((item) => ({
emoji: item.emoji,
emoji_base: item.emoji,
name: item.name || item.slug,
slug: item.slug,
category: item.category || '',
subcategory: item.subcategory || '',
supports_skin_tone: Boolean(item.supports_skin_tone),
variants: Array.isArray(item.variants) ? item.variants : [],
}));
}
function renderTrendingFromItems(items) {
const score = new Map();
items.forEach((item) => {
@@ -574,6 +815,23 @@
updateHeroMode();
if (isFavoritesOnlyEnabled()) {
try {
const allFavorites = favoriteItemsForCatalog();
state.total = allFavorites.length;
const start = (state.page - 1) * state.limit;
const incoming = allFavorites.slice(start, start + state.limit);
if (reset) state.items = [];
incoming.forEach((item) => state.items.push(item));
renderGrid(incoming, reset);
updateStats();
syncUrl();
return;
} finally {
isFetching = false;
}
}
const params = new URLSearchParams({ page: String(state.page), limit: String(state.limit) });
if (qEl.value.trim()) params.set('q', qEl.value.trim());
if (catEl.value) params.set('category', catEl.value);
@@ -619,10 +877,12 @@
const showLabels = isLabelsEnabled();
items.forEach((item) => {
const renderedEmoji = emojiWithTone(item);
const favoriteActive = isFavoriteSlug(item.slug);
const card = document.createElement('div');
card.className = 'emoji-card relative aspect-square rounded-lg bg-white/5 hover:bg-white/10 transition-transform hover:scale-[1.02] border border-transparent hover:border-white/20 overflow-hidden group';
card.innerHTML = showLabels
? `
${favoriteActive ? '<span class="absolute top-1.5 left-1.5 z-10 text-yellow-300 text-sm leading-none select-none pointer-events-none" title="Favorited">★</span>' : ''}
<a href="/emoji/${encodeURIComponent(item.slug)}" class="absolute inset-0 flex items-center justify-center pb-10">
<span class="leading-none" style="font-size:var(--emoji-size)">${esc(renderedEmoji)}</span>
</a>
@@ -632,6 +892,7 @@
</div>
`
: `
${favoriteActive ? '<span class="absolute top-1.5 left-1.5 z-10 text-yellow-300 text-sm leading-none select-none pointer-events-none" title="Favorited">★</span>' : ''}
<a href="/emoji/${encodeURIComponent(item.slug)}" class="absolute inset-0 flex items-center justify-center">
<span class="leading-none" style="font-size:var(--emoji-size)">${esc(renderedEmoji)}</span>
</a>
@@ -661,7 +922,7 @@
function updateStats() {
count.textContent = `${state.items.length} / ${state.total}`;
resultCount.textContent = `Showing ${state.items.length}`;
resultCount.textContent = `Showing ${state.items.length}${isFavoritesOnlyEnabled() ? ' • Favorites' : ''}`;
datasetCount.textContent = `${state.total} matches`;
more.classList.toggle('hidden', state.items.length >= state.total);
}
@@ -678,6 +939,24 @@
});
subEl.addEventListener('change', () => fetchEmojis(true));
catMobileEl?.addEventListener('change', () => {
fillSubcategories(subMobileEl, catMobileEl.value, '');
});
subMobileEl?.addEventListener('change', () => {
// no-op; applied explicitly via button for better mobile UX
});
mobileFiltersOpenEl?.addEventListener('click', openMobileFilters);
mobileFiltersCloseEl?.addEventListener('click', closeMobileFilters);
mobileFiltersBackdropEl?.addEventListener('click', closeMobileFilters);
mobileFiltersResetEl?.addEventListener('click', () => {
if (catMobileEl) catMobileEl.value = '';
fillSubcategories(subMobileEl, '', '');
});
mobileFiltersApplyEl?.addEventListener('click', async () => {
syncDesktopFiltersFromMobile();
closeMobileFilters();
await fetchEmojis(true);
});
const onToneChange = (nextTone = null) => {
const tone = nextTone || selectedTone();
setToneControlValue(tone);
@@ -783,9 +1062,44 @@
applyLabelsToggleUI();
fetchEmojis(true);
});
const onFavoritesOnlyToggle = async () => {
const next = !isFavoritesOnlyEnabled();
if (next && loadFavorites().length === 0) {
showToast('No favorites yet');
return;
}
setFavoritesOnlyEnabled(next);
await fetchEmojis(true);
};
favoritesOnlyDesktopBtn?.addEventListener('click', onFavoritesOnlyToggle);
favoritesOnlyMobileBtn?.addEventListener('click', onFavoritesOnlyToggle);
favoritesClearEl?.addEventListener('click', () => {
saveFavorites([]);
renderFavorites();
if (isFavoritesOnlyEnabled()) {
setFavoritesOnlyEnabled(false);
fetchEmojis(true);
}
showToast('Favorites cleared');
});
window.dewemojiHandleAndroidBack = () => {
if (closeMobileFilters()) return true;
if (keywordEditModal?.classList.contains('flex')) {
closeKeywordEdit();
return true;
}
return false;
};
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (closeMobileFilters()) return;
}
});
(async () => {
applyLabelsToggleUI();
applyFavoritesOnlyUI();
setToneControlValue(localStorage.getItem(toneStorageKey) || 'off');
await loadCategories();
if (initialCategory && state.categories[initialCategory]) {
@@ -799,6 +1113,7 @@
await fetchEmojis(true);
renderTrendingFromItems(state.items);
renderRecent();
renderFavorites();
updateHeroMode();
if (catalogScrollEl) {
catalogScrollEl.addEventListener('scroll', () => {