Add mobile favorites UX and Android deep link support
This commit is contained in:
@@ -112,3 +112,13 @@ DEWEMOJI_EXTENSION_VERIFY_ENABLED=true
|
|||||||
DEWEMOJI_GOOGLE_PROJECT_ID=
|
DEWEMOJI_GOOGLE_PROJECT_ID=
|
||||||
DEWEMOJI_GOOGLE_SERVER_KEY=
|
DEWEMOJI_GOOGLE_SERVER_KEY=
|
||||||
DEWEMOJI_EXTENSION_IDS=
|
DEWEMOJI_EXTENSION_IDS=
|
||||||
|
|
||||||
|
DEWEMOJI_APK_RELEASE_ENABLED=false
|
||||||
|
DEWEMOJI_APK_PUBLIC_BASE_URL=https://dewemoji.com/downloads
|
||||||
|
DEWEMOJI_R2_PUBLIC_BASE_URL=
|
||||||
|
DEWEMOJI_R2_APK_LATEST_KEY=apk/dewemoji-latest.apk
|
||||||
|
DEWEMOJI_R2_APK_VERSION_KEY=apk/version.json
|
||||||
|
DEWEMOJI_APK_APP_ID=com.dewemoji.app
|
||||||
|
DEWEMOJI_APK_CHANNEL=stable
|
||||||
|
DEWEMOJI_APK_MIN_SUPPORTED_VERSION_CODE=1
|
||||||
|
DEWEMOJI_APK_ASSETLINKS_SHA256=
|
||||||
|
|||||||
@@ -110,9 +110,9 @@ class EmojiApiController extends Controller
|
|||||||
$page = max((int) $request->query('page', 1), 1);
|
$page = max((int) $request->query('page', 1), 1);
|
||||||
|
|
||||||
$defaultLimit = max((int) config('dewemoji.pagination.default_limit', 20), 1);
|
$defaultLimit = max((int) config('dewemoji.pagination.default_limit', 20), 1);
|
||||||
$maxLimit = $tier === self::TIER_PRO
|
// Search result pagination is now feature-parity for all users.
|
||||||
? max((int) config('dewemoji.pagination.pro_max_limit', 50), 1)
|
// Keyword/glossary limits are enforced elsewhere (user_keywords quota logic).
|
||||||
: max((int) config('dewemoji.pagination.free_max_limit', 20), 1);
|
$maxLimit = max((int) config('dewemoji.pagination.max_limit', 50), 1);
|
||||||
$limit = min(max((int) $request->query('limit', $defaultLimit), 1), $maxLimit);
|
$limit = min(max((int) $request->query('limit', $defaultLimit), 1), $maxLimit);
|
||||||
|
|
||||||
$filtered = $this->filterItems($items, $q, $category, $subSlug);
|
$filtered = $this->filterItems($items, $q, $category, $subSlug);
|
||||||
|
|||||||
@@ -301,6 +301,43 @@ class SiteController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function assetLinks(): JsonResponse
|
||||||
|
{
|
||||||
|
$appId = trim((string) config('dewemoji.apk_release.app_id', ''));
|
||||||
|
$rawFingerprints = (array) config('dewemoji.apk_release.assetlinks.fingerprints', []);
|
||||||
|
$fingerprints = [];
|
||||||
|
foreach ($rawFingerprints as $fingerprint) {
|
||||||
|
$normalized = $this->normalizeApkCertFingerprint((string) $fingerprint);
|
||||||
|
if ($normalized !== '') {
|
||||||
|
$fingerprints[] = $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$fingerprints = array_values(array_unique($fingerprints));
|
||||||
|
|
||||||
|
if ($appId === '' || $fingerprints === []) {
|
||||||
|
return response()->json([], 200, [
|
||||||
|
'Cache-Control' => 'no-store, no-cache, must-revalidate',
|
||||||
|
'Pragma' => 'no-cache',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
[
|
||||||
|
'relation' => [
|
||||||
|
'delegate_permission/common.handle_all_urls',
|
||||||
|
],
|
||||||
|
'target' => [
|
||||||
|
'namespace' => 'android_app',
|
||||||
|
'package_name' => $appId,
|
||||||
|
'sha256_cert_fingerprints' => $fingerprints,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 200, [
|
||||||
|
'Cache-Control' => 'no-store, no-cache, must-revalidate',
|
||||||
|
'Pragma' => 'no-cache',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function privacy(): View
|
public function privacy(): View
|
||||||
{
|
{
|
||||||
return view('site.privacy');
|
return view('site.privacy');
|
||||||
@@ -514,6 +551,24 @@ class SiteController extends Controller
|
|||||||
return rtrim($base, '/').'/'.ltrim($objectKey, '/');
|
return rtrim($base, '/').'/'.ltrim($objectKey, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeApkCertFingerprint(string $value): string
|
||||||
|
{
|
||||||
|
$clean = strtoupper(trim($value));
|
||||||
|
if ($clean === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^[0-9A-F]{64}$/', $clean) === 1) {
|
||||||
|
return implode(':', str_split($clean, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^[0-9A-F]{2}(?::[0-9A-F]{2}){31}$/', $clean) === 1) {
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $emoji
|
* @param array<string,mixed> $emoji
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -135,5 +135,8 @@ return [
|
|||||||
'latest_apk' => (string) env('DEWEMOJI_R2_APK_LATEST_KEY', 'apk/dewemoji-latest.apk'),
|
'latest_apk' => (string) env('DEWEMOJI_R2_APK_LATEST_KEY', 'apk/dewemoji-latest.apk'),
|
||||||
'version_json' => (string) env('DEWEMOJI_R2_APK_VERSION_KEY', 'apk/version.json'),
|
'version_json' => (string) env('DEWEMOJI_R2_APK_VERSION_KEY', 'apk/version.json'),
|
||||||
],
|
],
|
||||||
|
'assetlinks' => [
|
||||||
|
'fingerprints' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_APK_ASSETLINKS_SHA256', ''))))),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -111,8 +111,12 @@
|
|||||||
<div class="glass-card rounded-[32px] aspect-square flex items-center justify-center relative overflow-hidden group">
|
<div class="glass-card rounded-[32px] aspect-square flex items-center justify-center relative overflow-hidden group">
|
||||||
<div class="absolute w-64 h-64 bg-yellow-500/20 rounded-full blur-3xl group-hover:bg-yellow-500/30 transition-colors duration-500"></div>
|
<div class="absolute w-64 h-64 bg-yellow-500/20 rounded-full blur-3xl group-hover:bg-yellow-500/30 transition-colors duration-500"></div>
|
||||||
<div id="emoji-hero-symbol" class="text-[140px] md:text-[180px] leading-none select-none relative z-10 animate-float drop-shadow-2xl">{{ $symbol }}</div>
|
<div id="emoji-hero-symbol" class="text-[140px] md:text-[180px] leading-none select-none relative z-10 animate-float drop-shadow-2xl">{{ $symbol }}</div>
|
||||||
<div class="absolute bottom-6 flex gap-3 opacity-0 group-hover:opacity-100 transition-all transform translate-y-2 group-hover:translate-y-0">
|
<div class="absolute bottom-6 flex gap-3 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all transform translate-y-0 sm:translate-y-2 sm:group-hover:translate-y-0">
|
||||||
<button onclick="copyCurrentEmoji()" class="bg-black/50 backdrop-blur text-white force-white p-3 rounded-full hover:bg-brand-sun hover:text-black transition-colors border border-white/10" title="Copy">
|
<button id="favorite-toggle-btn" type="button" class="w-12 h-12 bg-black/50 backdrop-blur text-white force-white rounded-full hover:bg-yellow-400/20 transition-colors border border-white/10 inline-flex items-center justify-center shrink-0" title="Add Favorite" aria-label="Add Favorite">
|
||||||
|
<span id="favorite-toggle-icon" class="text-yellow-300 text-lg leading-none">☆</span>
|
||||||
|
<span id="favorite-toggle-label" class="sr-only">Add Favorite</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="copyCurrentEmoji()" class="w-12 h-12 bg-black/50 backdrop-blur text-white force-white rounded-full hover:bg-brand-sun hover:text-black transition-colors border border-white/10 inline-flex items-center justify-center shrink-0" title="Copy">
|
||||||
<i data-lucide="copy" class="w-5 h-5 force-white"></i>
|
<i data-lucide="copy" class="w-5 h-5 force-white"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,18 +154,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
<span class="bg-yellow-500/10 text-yellow-400 border border-yellow-500/20 px-3 py-1 rounded-full text-xs font-bold uppercase">{{ $subcategory }}</span>
|
<span class="bg-yellow-500/10 text-yellow-400 border border-yellow-500/20 px-3 py-1 rounded-full text-xs font-bold uppercase">{{ $subcategory }}</span>
|
||||||
|
<span id="favorite-pill" class="hidden bg-yellow-500/10 text-yellow-300 border border-yellow-500/30 px-3 py-1 rounded-full text-xs font-bold uppercase">Favorite</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="font-display text-5xl font-bold mb-4">{{ $name }}</h1>
|
<h1 class="font-display text-5xl font-bold mb-4">{{ $name }}</h1>
|
||||||
<p class="text-gray-400 text-lg leading-relaxed">{{ $description }}</p>
|
<p class="text-gray-400 text-lg leading-relaxed">{{ $description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<button id="copy-current-emoji-btn" onclick="copyCurrentEmoji()" class="flex-1 bg-brand-ocean hover:bg-brand-oceanSoft text-white force-white font-bold h-14 rounded-xl flex items-center justify-center gap-3 text-lg transition-all shadow-[0_0_20px_rgba(32,83,255,0.35)] hover:shadow-[0_0_30px_rgba(32,83,255,0.55)]">
|
|
||||||
<i data-lucide="copy" class="w-5 h-5 force-white"></i>
|
|
||||||
Copy Emoji
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if($supportsTone)
|
@if($supportsTone)
|
||||||
<div class="glass-card rounded-xl p-3">
|
<div class="glass-card rounded-xl p-3">
|
||||||
<div class="text-xs font-mono text-gray-500 mb-2">Skin tone</div>
|
<div class="text-xs font-mono text-gray-500 mb-2">Skin tone</div>
|
||||||
@@ -287,7 +285,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div id="toast" class="fixed bottom-10 left-1/2 -translate-x-1/2 translate-y-24 opacity-0 transition-all duration-300 z-50 pointer-events-none">
|
<div id="toast" class="fixed left-1/2 -translate-x-1/2 translate-y-24 opacity-0 transition-all duration-300 z-50 pointer-events-none" style="bottom: calc(env(safe-area-inset-bottom, 0px) + 6rem);">
|
||||||
<div class="bg-brand-ocean text-white px-6 py-2 rounded-full font-bold shadow-[0_0_20px_rgba(32,83,255,0.45)] flex items-center gap-2">
|
<div class="bg-brand-ocean text-white px-6 py-2 rounded-full font-bold shadow-[0_0_20px_rgba(32,83,255,0.45)] flex items-center gap-2">
|
||||||
<i data-lucide="check" class="w-4 h-4"></i>
|
<i data-lucide="check" class="w-4 h-4"></i>
|
||||||
<span id="toast-msg">Copied!</span>
|
<span id="toast-msg">Copied!</span>
|
||||||
@@ -331,8 +329,15 @@
|
|||||||
@push('scripts')
|
@push('scripts')
|
||||||
<script>
|
<script>
|
||||||
const RECENT_KEY = 'dewemoji_recent';
|
const RECENT_KEY = 'dewemoji_recent';
|
||||||
|
const FAVORITES_KEY = 'dewemoji_favorites';
|
||||||
const TONE_STORAGE_KEY = 'dewemoji_skin_tone';
|
const TONE_STORAGE_KEY = 'dewemoji_skin_tone';
|
||||||
const SYMBOL_DEFAULT = @json($symbol);
|
const SYMBOL_DEFAULT = @json($symbol);
|
||||||
|
const DETAIL_SLUG = @json($slug);
|
||||||
|
const DETAIL_NAME = @json($name);
|
||||||
|
const DETAIL_CATEGORY = @json($category);
|
||||||
|
const DETAIL_SUBCATEGORY = @json($subcategory);
|
||||||
|
const DETAIL_SUPPORTS_TONE = @json($supportsTone);
|
||||||
|
const DETAIL_VARIANTS_LIST = @json(array_values($toneVariants));
|
||||||
const TONE_VARIANTS = @json($toneVariants);
|
const TONE_VARIANTS = @json($toneVariants);
|
||||||
let currentDisplayEmoji = SYMBOL_DEFAULT;
|
let currentDisplayEmoji = SYMBOL_DEFAULT;
|
||||||
|
|
||||||
@@ -363,22 +368,124 @@ function applyTone(tone) {
|
|||||||
|
|
||||||
function loadRecent() {
|
function loadRecent() {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
|
const parsed = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
|
||||||
|
return Array.isArray(parsed) ? parsed.filter(isRecentEmojiToken) : [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
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) {
|
function saveRecent(items) {
|
||||||
localStorage.setItem(RECENT_KEY, JSON.stringify(items.slice(0, 8)));
|
const clean = items.filter(isRecentEmojiToken);
|
||||||
|
localStorage.setItem(RECENT_KEY, JSON.stringify(clean.slice(0, 8)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRecent(emoji) {
|
function addRecent(emoji) {
|
||||||
|
if (!isRecentEmojiToken(emoji)) return;
|
||||||
const curr = loadRecent().filter((e) => e !== emoji);
|
const curr = loadRecent().filter((e) => e !== emoji);
|
||||||
curr.unshift(emoji);
|
curr.unshift(emoji);
|
||||||
saveRecent(curr);
|
saveRecent(curr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadFavorites() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(FAVORITES_KEY) || '[]');
|
||||||
|
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(FAVORITES_KEY, JSON.stringify(items.slice(0, 24)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentFavorite() {
|
||||||
|
return loadFavorites().some((item) => item.slug === DETAIL_SLUG);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFavoriteButton() {
|
||||||
|
const btn = document.getElementById('favorite-toggle-btn');
|
||||||
|
const icon = document.getElementById('favorite-toggle-icon');
|
||||||
|
const label = document.getElementById('favorite-toggle-label');
|
||||||
|
const pill = document.getElementById('favorite-pill');
|
||||||
|
if (!btn || !icon || !label) return;
|
||||||
|
const active = isCurrentFavorite();
|
||||||
|
icon.textContent = active ? '★' : '☆';
|
||||||
|
icon.classList.toggle('text-yellow-300', true);
|
||||||
|
icon.classList.toggle('text-gray-300', !active);
|
||||||
|
label.textContent = active ? 'Favorited' : 'Add Favorite';
|
||||||
|
btn.title = active ? 'Remove Favorite' : 'Add Favorite';
|
||||||
|
btn.setAttribute('aria-label', active ? 'Remove Favorite' : 'Add Favorite');
|
||||||
|
btn.classList.toggle('border-yellow-400/30', active);
|
||||||
|
btn.classList.toggle('bg-yellow-400/10', active);
|
||||||
|
if (pill) {
|
||||||
|
pill.classList.toggle('hidden', !active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCurrentFavorite() {
|
||||||
|
const current = loadFavorites();
|
||||||
|
const remaining = current.filter((item) => item.slug !== DETAIL_SLUG);
|
||||||
|
const isRemoving = remaining.length !== current.length;
|
||||||
|
if (isRemoving) {
|
||||||
|
saveFavorites(remaining);
|
||||||
|
renderFavoriteButton();
|
||||||
|
if (typeof showToast === 'function') showToast('Removed from favorites');
|
||||||
|
else showDetailToast('Removed from favorites');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
remaining.unshift({
|
||||||
|
slug: DETAIL_SLUG,
|
||||||
|
emoji: currentDisplayEmoji,
|
||||||
|
name: DETAIL_NAME,
|
||||||
|
category: DETAIL_CATEGORY,
|
||||||
|
subcategory: DETAIL_SUBCATEGORY,
|
||||||
|
supports_skin_tone: Boolean(DETAIL_SUPPORTS_TONE),
|
||||||
|
variants: Array.isArray(DETAIL_VARIANTS_LIST) ? DETAIL_VARIANTS_LIST : [],
|
||||||
|
});
|
||||||
|
saveFavorites(remaining);
|
||||||
|
renderFavoriteButton();
|
||||||
|
if (typeof showToast === 'function') showToast('Added to favorites');
|
||||||
|
else showDetailToast('Added to favorites');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetailToast(message) {
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
const msg = document.getElementById('toast-msg');
|
||||||
|
if (!toast || !msg) return;
|
||||||
|
msg.innerText = message;
|
||||||
|
toast.classList.remove('translate-y-24', 'opacity-0');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('translate-y-24', 'opacity-0');
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
function copyCurrentEmoji() {
|
function copyCurrentEmoji() {
|
||||||
copyToClipboard(currentDisplayEmoji);
|
copyToClipboard(currentDisplayEmoji);
|
||||||
}
|
}
|
||||||
@@ -386,13 +493,7 @@ function copyCurrentEmoji() {
|
|||||||
function copyToClipboard(text) {
|
function copyToClipboard(text) {
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
addRecent(text);
|
addRecent(text);
|
||||||
const toast = document.getElementById('toast');
|
showDetailToast(`Copied ${text}`);
|
||||||
const msg = document.getElementById('toast-msg');
|
|
||||||
msg.innerText = `Copied ${text}`;
|
|
||||||
toast.classList.remove('translate-y-24', 'opacity-0');
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.classList.add('translate-y-24', 'opacity-0');
|
|
||||||
}, 1500);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +515,9 @@ document.addEventListener('keydown', (e) => {
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
document.getElementById('favorite-toggle-btn')?.addEventListener('click', toggleCurrentFavorite);
|
||||||
|
renderFavoriteButton();
|
||||||
|
|
||||||
// Treat opening the single-emoji page as a "recently viewed emoji" event.
|
// Treat opening the single-emoji page as a "recently viewed emoji" event.
|
||||||
addRecent(currentDisplayEmoji);
|
addRecent(currentDisplayEmoji);
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,13 @@
|
|||||||
</div>
|
</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">
|
<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">
|
<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">
|
<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>
|
<span id="tone-dot-mobile" class="w-3 h-3 rounded-full bg-white/80 border border-white/30"></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -112,12 +119,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 shrink-0">
|
<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>
|
<option value="">All Categories</option>
|
||||||
</select>
|
</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>
|
<option value="">All Subcategories</option>
|
||||||
</select>
|
</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">
|
<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>
|
<span id="tone-dot-desktop" class="w-4 h-4 rounded-full bg-white/80 border border-white/30"></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -173,7 +183,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span id="dataset-count" class="text-[10px] text-gray-500 font-mono">0 items</span>
|
<span id="dataset-count" class="text-[10px] text-gray-500 font-mono">0 items</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="mt-4">
|
||||||
<h4 class="font-bold text-sm">Recent</h4>
|
<h4 class="font-bold text-sm">Recent</h4>
|
||||||
<div id="recent-list" class="flex gap-2 mt-2"></div>
|
<div id="recent-list" class="flex gap-2 mt-2"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,7 +219,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</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">
|
<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>
|
<i data-lucide="check" class="w-4 h-4"></i>
|
||||||
<span id="toast-msg">Copied!</span>
|
<span id="toast-msg">Copied!</span>
|
||||||
@@ -243,6 +253,30 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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
|
@endsection
|
||||||
|
|
||||||
@push('scripts')
|
@push('scripts')
|
||||||
@@ -259,6 +293,16 @@
|
|||||||
const qEl = document.getElementById('q');
|
const qEl = document.getElementById('q');
|
||||||
const catEl = document.getElementById('category');
|
const catEl = document.getElementById('category');
|
||||||
const subEl = document.getElementById('subcategory');
|
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 toneDesktopBtn = document.getElementById('tone-toggle');
|
||||||
const toneMobileBtn = document.getElementById('tone-toggle-mobile');
|
const toneMobileBtn = document.getElementById('tone-toggle-mobile');
|
||||||
const toneDotDesktop = document.getElementById('tone-dot-desktop');
|
const toneDotDesktop = document.getElementById('tone-dot-desktop');
|
||||||
@@ -274,6 +318,8 @@
|
|||||||
const datasetCount = document.getElementById('dataset-count');
|
const datasetCount = document.getElementById('dataset-count');
|
||||||
const trendingList = document.getElementById('trending-list');
|
const trendingList = document.getElementById('trending-list');
|
||||||
const recentList = document.getElementById('recent-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 heroCards = document.getElementById('hero-cards');
|
||||||
const heroMain = document.getElementById('hero-main');
|
const heroMain = document.getElementById('hero-main');
|
||||||
const heroOptional1 = document.getElementById('hero-optional-1');
|
const heroOptional1 = document.getElementById('hero-optional-1');
|
||||||
@@ -286,6 +332,9 @@
|
|||||||
const labelsToggleEl = document.getElementById('labels-toggle');
|
const labelsToggleEl = document.getElementById('labels-toggle');
|
||||||
const densityStorageKey = 'dewemoji_grid_density';
|
const densityStorageKey = 'dewemoji_grid_density';
|
||||||
const labelsStorageKey = 'dewemoji_grid_show_labels';
|
const labelsStorageKey = 'dewemoji_grid_show_labels';
|
||||||
|
const recentStorageKey = 'dewemoji_recent';
|
||||||
|
const favoritesStorageKey = 'dewemoji_favorites';
|
||||||
|
const favoritesOnlyStorageKey = 'dewemoji_favorites_only';
|
||||||
let isFetching = false;
|
let isFetching = false;
|
||||||
let autoLoadObserver = null;
|
let autoLoadObserver = null;
|
||||||
const AUTOLOAD_THRESHOLD_PX = 420;
|
const AUTOLOAD_THRESHOLD_PX = 420;
|
||||||
@@ -345,6 +394,50 @@
|
|||||||
if (labelsToggleEl) labelsToggleEl.textContent = `Labels: ${enabled ? 'On' : 'Off'}`;
|
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;
|
if (initialQuery) qEl.value = initialQuery;
|
||||||
|
|
||||||
function slugify(text) {
|
function slugify(text) {
|
||||||
@@ -446,7 +539,7 @@
|
|||||||
|
|
||||||
|
|
||||||
function hasActiveFilters() {
|
function hasActiveFilters() {
|
||||||
return qEl.value.trim() !== '' || catEl.value !== '' || subEl.value !== '';
|
return isFavoritesOnlyEnabled() || qEl.value.trim() !== '' || catEl.value !== '' || subEl.value !== '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHeroMode() {
|
function updateHeroMode() {
|
||||||
@@ -461,24 +554,50 @@
|
|||||||
const res = await fetch('/v1/categories');
|
const res = await fetch('/v1/categories');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
state.categories = data || {};
|
state.categories = data || {};
|
||||||
Object.keys(state.categories).forEach((name) => {
|
const fillCategorySelect = (selectEl) => {
|
||||||
const opt = document.createElement('option');
|
if (!selectEl) return;
|
||||||
opt.value = name;
|
selectEl.innerHTML = '<option value="">All Categories</option>';
|
||||||
opt.textContent = name;
|
Object.keys(state.categories).forEach((name) => {
|
||||||
catEl.appendChild(opt);
|
const opt = document.createElement('option');
|
||||||
});
|
opt.value = name;
|
||||||
|
opt.textContent = name;
|
||||||
|
selectEl.appendChild(opt);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
fillCategorySelect(catEl);
|
||||||
|
fillCategorySelect(catMobileEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSubcategories() {
|
function fillSubcategories(selectEl, categoryValue, selectedValue = '') {
|
||||||
const subs = state.categories[catEl.value] || [];
|
if (!selectEl) return;
|
||||||
subEl.innerHTML = '<option value="">All subcategories</option>';
|
const subs = state.categories[categoryValue] || [];
|
||||||
|
selectEl.innerHTML = '<option value="">All Subcategories</option>';
|
||||||
subs.forEach((s) => {
|
subs.forEach((s) => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = s;
|
opt.value = s;
|
||||||
opt.textContent = 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) {
|
function showToast(message) {
|
||||||
@@ -491,17 +610,32 @@
|
|||||||
|
|
||||||
function loadRecent() {
|
function loadRecent() {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem('dewemoji_recent') || '[]');
|
const parsed = JSON.parse(localStorage.getItem(recentStorageKey) || '[]');
|
||||||
|
return Array.isArray(parsed) ? parsed.filter(isRecentEmojiToken) : [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
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) {
|
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) {
|
function addRecent(emoji) {
|
||||||
|
if (!isRecentEmojiToken(emoji)) return;
|
||||||
const curr = loadRecent().filter((e) => e !== emoji);
|
const curr = loadRecent().filter((e) => e !== emoji);
|
||||||
curr.unshift(emoji);
|
curr.unshift(emoji);
|
||||||
saveRecent(curr);
|
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) {
|
function renderTrendingFromItems(items) {
|
||||||
const score = new Map();
|
const score = new Map();
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
@@ -574,6 +815,23 @@
|
|||||||
|
|
||||||
updateHeroMode();
|
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) });
|
const params = new URLSearchParams({ page: String(state.page), limit: String(state.limit) });
|
||||||
if (qEl.value.trim()) params.set('q', qEl.value.trim());
|
if (qEl.value.trim()) params.set('q', qEl.value.trim());
|
||||||
if (catEl.value) params.set('category', catEl.value);
|
if (catEl.value) params.set('category', catEl.value);
|
||||||
@@ -619,10 +877,12 @@
|
|||||||
const showLabels = isLabelsEnabled();
|
const showLabels = isLabelsEnabled();
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const renderedEmoji = emojiWithTone(item);
|
const renderedEmoji = emojiWithTone(item);
|
||||||
|
const favoriteActive = isFavoriteSlug(item.slug);
|
||||||
const card = document.createElement('div');
|
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.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
|
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">
|
<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>
|
<span class="leading-none" style="font-size:var(--emoji-size)">${esc(renderedEmoji)}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -632,6 +892,7 @@
|
|||||||
</div>
|
</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">
|
<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>
|
<span class="leading-none" style="font-size:var(--emoji-size)">${esc(renderedEmoji)}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -661,7 +922,7 @@
|
|||||||
|
|
||||||
function updateStats() {
|
function updateStats() {
|
||||||
count.textContent = `${state.items.length} / ${state.total}`;
|
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`;
|
datasetCount.textContent = `${state.total} matches`;
|
||||||
more.classList.toggle('hidden', state.items.length >= state.total);
|
more.classList.toggle('hidden', state.items.length >= state.total);
|
||||||
}
|
}
|
||||||
@@ -678,6 +939,24 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
subEl.addEventListener('change', () => fetchEmojis(true));
|
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 onToneChange = (nextTone = null) => {
|
||||||
const tone = nextTone || selectedTone();
|
const tone = nextTone || selectedTone();
|
||||||
setToneControlValue(tone);
|
setToneControlValue(tone);
|
||||||
@@ -783,9 +1062,44 @@
|
|||||||
applyLabelsToggleUI();
|
applyLabelsToggleUI();
|
||||||
fetchEmojis(true);
|
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 () => {
|
(async () => {
|
||||||
applyLabelsToggleUI();
|
applyLabelsToggleUI();
|
||||||
|
applyFavoritesOnlyUI();
|
||||||
setToneControlValue(localStorage.getItem(toneStorageKey) || 'off');
|
setToneControlValue(localStorage.getItem(toneStorageKey) || 'off');
|
||||||
await loadCategories();
|
await loadCategories();
|
||||||
if (initialCategory && state.categories[initialCategory]) {
|
if (initialCategory && state.categories[initialCategory]) {
|
||||||
@@ -799,6 +1113,7 @@
|
|||||||
await fetchEmojis(true);
|
await fetchEmojis(true);
|
||||||
renderTrendingFromItems(state.items);
|
renderTrendingFromItems(state.items);
|
||||||
renderRecent();
|
renderRecent();
|
||||||
|
renderFavorites();
|
||||||
updateHeroMode();
|
updateHeroMode();
|
||||||
if (catalogScrollEl) {
|
if (catalogScrollEl) {
|
||||||
catalogScrollEl.addEventListener('scroll', () => {
|
catalogScrollEl.addEventListener('scroll', () => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Route::post('/pricing/currency', [SiteController::class, 'setPricingCurrency'])-
|
|||||||
Route::get('/download', [SiteController::class, 'download'])->name('download');
|
Route::get('/download', [SiteController::class, 'download'])->name('download');
|
||||||
Route::get('/downloads/version.json', [SiteController::class, 'downloadVersionJson'])->name('downloads.version');
|
Route::get('/downloads/version.json', [SiteController::class, 'downloadVersionJson'])->name('downloads.version');
|
||||||
Route::get('/downloads/dewemoji-latest.apk', [SiteController::class, 'downloadLatestApk'])->name('downloads.latest-apk');
|
Route::get('/downloads/dewemoji-latest.apk', [SiteController::class, 'downloadLatestApk'])->name('downloads.latest-apk');
|
||||||
|
Route::get('/.well-known/assetlinks.json', [SiteController::class, 'assetLinks'])->name('assetlinks');
|
||||||
Route::get('/support', [SiteController::class, 'support'])->name('support');
|
Route::get('/support', [SiteController::class, 'support'])->name('support');
|
||||||
Route::get('/privacy', [SiteController::class, 'privacy'])->name('privacy');
|
Route::get('/privacy', [SiteController::class, 'privacy'])->name('privacy');
|
||||||
Route::get('/terms', [SiteController::class, 'terms'])->name('terms');
|
Route::get('/terms', [SiteController::class, 'terms'])->name('terms');
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ class SitePagesTest extends TestCase
|
|||||||
config()->set('dewemoji.apk_release.r2_public_base_url', 'https://downloads.example.com');
|
config()->set('dewemoji.apk_release.r2_public_base_url', 'https://downloads.example.com');
|
||||||
config()->set('dewemoji.apk_release.r2_keys.latest_apk', 'apk/dewemoji-latest.apk');
|
config()->set('dewemoji.apk_release.r2_keys.latest_apk', 'apk/dewemoji-latest.apk');
|
||||||
config()->set('dewemoji.apk_release.r2_keys.version_json', 'apk/version.json');
|
config()->set('dewemoji.apk_release.r2_keys.version_json', 'apk/version.json');
|
||||||
|
config()->set('dewemoji.apk_release.assetlinks.fingerprints', [
|
||||||
|
'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_core_pages_are_available(): void
|
public function test_core_pages_are_available(): void
|
||||||
@@ -54,4 +57,19 @@ class SitePagesTest extends TestCase
|
|||||||
->assertStatus(302)
|
->assertStatus(302)
|
||||||
->assertRedirect('https://downloads.example.com/apk/dewemoji-latest.apk');
|
->assertRedirect('https://downloads.example.com/apk/dewemoji-latest.apk');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_assetlinks_endpoint_is_available(): void
|
||||||
|
{
|
||||||
|
$this->get('/.well-known/assetlinks.json')
|
||||||
|
->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
[
|
||||||
|
'relation' => ['delegate_permission/common.handle_all_urls'],
|
||||||
|
'target' => [
|
||||||
|
'namespace' => 'android_app',
|
||||||
|
'package_name' => 'com.dewemoji.app',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,20 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="dewemoji.com" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="www.dewemoji.com" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.net.Uri;
|
|||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
import android.webkit.WebView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.core.view.WindowCompat;
|
import androidx.core.view.WindowCompat;
|
||||||
@@ -47,6 +48,38 @@ public class MainActivity extends BridgeActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBackPressed() {
|
||||||
|
WebView webView = getBridge() != null ? getBridge().getWebView() : null;
|
||||||
|
if (webView == null) {
|
||||||
|
super.onBackPressed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
webView.evaluateJavascript(
|
||||||
|
"(function(){try{return (window.dewemojiHandleAndroidBack && window.dewemojiHandleAndroidBack()) ? '1' : '0';}catch(e){return '0';}})();",
|
||||||
|
value -> {
|
||||||
|
boolean handled = value != null && value.contains("1");
|
||||||
|
if (handled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (webView.canGoBack()) {
|
||||||
|
webView.goBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MainActivity.super.onBackPressed();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
if (webView.canGoBack()) {
|
||||||
|
webView.goBack();
|
||||||
|
} else {
|
||||||
|
super.onBackPressed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void hideSystemBars() {
|
private void hideSystemBars() {
|
||||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||||
WindowInsetsControllerCompat controller =
|
WindowInsetsControllerCompat controller =
|
||||||
|
|||||||
Reference in New Issue
Block a user