Add skin tone support for discover grid and emoji detail

This commit is contained in:
Dwindi Ramadhana
2026-02-18 22:54:48 +07:00
parent 6c4d5b3ca7
commit 0d239b1967
3 changed files with 128 additions and 19 deletions

View File

@@ -536,12 +536,10 @@ class EmojiApiController extends Controller
if ($supportsTone && $emoji !== '') {
$base = preg_replace('/\x{1F3FB}|\x{1F3FC}|\x{1F3FD}|\x{1F3FE}|\x{1F3FF}/u', '', $emoji) ?? $emoji;
$out['emoji_base'] = $base;
if ($tier === self::TIER_PRO) {
$out['variants'] = array_map(
fn (string $tone): string => $base.$tone,
["\u{1F3FB}", "\u{1F3FC}", "\u{1F3FD}", "\u{1F3FE}", "\u{1F3FF}"]
);
}
$out['variants'] = array_map(
fn (string $tone): string => $base.$tone,
["\u{1F3FB}", "\u{1F3FC}", "\u{1F3FD}", "\u{1F3FE}", "\u{1F3FF}"]
);
}
if ($tier === self::TIER_PRO) {

View File

@@ -12,6 +12,20 @@
$description = $emoji['description'] ?? '';
$unified = $emoji['unified'] ?? '';
$shortcode = $emoji['shortcodes'][0] ?? '';
$supportsTone = (bool) ($emoji['supports_skin_tone'] ?? false);
$emojiBase = $symbol;
if ($supportsTone && $symbol !== '') {
$emojiBase = preg_replace('/\x{1F3FB}|\x{1F3FC}|\x{1F3FD}|\x{1F3FE}|\x{1F3FF}/u', '', $symbol) ?: $symbol;
}
$toneVariants = $supportsTone
? [
'light' => $emojiBase."\u{1F3FB}",
'medium-light' => $emojiBase."\u{1F3FC}",
'medium' => $emojiBase."\u{1F3FD}",
'medium-dark' => $emojiBase."\u{1F3FE}",
'dark' => $emojiBase."\u{1F3FF}",
]
: [];
$user = auth()->user();
$userTier = $userTier ?? $user?->tier;
$isPersonal = $userTier === 'personal';
@@ -93,9 +107,9 @@
<div class="lg:col-span-5 flex flex-col gap-6">
<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="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">
<button onclick="copyToClipboard('{{ $symbol }}')" 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 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">
<i data-lucide="copy" class="w-5 h-5 force-white"></i>
</button>
</div>
@@ -139,12 +153,26 @@
</div>
<div class="flex gap-4">
<button onclick="copyToClipboard('{{ $symbol }}')" 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)]">
<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)
<div class="glass-card rounded-xl p-3">
<div class="text-xs font-mono text-gray-500 mb-2">Skin tone</div>
<div class="flex flex-wrap gap-2">
<button type="button" data-tone="off" class="tone-chip px-3 py-1.5 rounded-lg border border-white/10 text-sm text-gray-200 bg-white/5 hover:bg-white/10">Default</button>
<button type="button" data-tone="light" class="tone-chip px-3 py-1.5 rounded-lg border border-white/10 text-sm text-gray-200 bg-white/5 hover:bg-white/10">🏻</button>
<button type="button" data-tone="medium-light" class="tone-chip px-3 py-1.5 rounded-lg border border-white/10 text-sm text-gray-200 bg-white/5 hover:bg-white/10">🏼</button>
<button type="button" data-tone="medium" class="tone-chip px-3 py-1.5 rounded-lg border border-white/10 text-sm text-gray-200 bg-white/5 hover:bg-white/10">🏽</button>
<button type="button" data-tone="medium-dark" class="tone-chip px-3 py-1.5 rounded-lg border border-white/10 text-sm text-gray-200 bg-white/5 hover:bg-white/10">🏾</button>
<button type="button" data-tone="dark" class="tone-chip px-3 py-1.5 rounded-lg border border-white/10 text-sm text-gray-200 bg-white/5 hover:bg-white/10">🏿</button>
</div>
</div>
@endif
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@if($shortcode !== '')
<button onclick="copyToClipboard('{{ $shortcode }}')" class="glass-card p-4 rounded-xl group text-left">
@@ -300,6 +328,35 @@
@push('scripts')
<script>
const RECENT_KEY = 'dewemoji_recent';
const TONE_STORAGE_KEY = 'dewemoji_skin_tone';
const SYMBOL_DEFAULT = @json($symbol);
const TONE_VARIANTS = @json($toneVariants);
let currentDisplayEmoji = SYMBOL_DEFAULT;
function getStoredTone() {
return localStorage.getItem(TONE_STORAGE_KEY) || 'off';
}
function setStoredTone(value) {
localStorage.setItem(TONE_STORAGE_KEY, value || 'off');
}
function emojiByTone(tone) {
if (!tone || tone === 'off') return SYMBOL_DEFAULT;
return TONE_VARIANTS[tone] || SYMBOL_DEFAULT;
}
function applyTone(tone) {
currentDisplayEmoji = emojiByTone(tone);
const hero = document.getElementById('emoji-hero-symbol');
if (hero) hero.textContent = currentDisplayEmoji;
document.querySelectorAll('.tone-chip').forEach((chip) => {
const active = chip.dataset.tone === tone;
chip.classList.toggle('bg-brand-ocean/20', active);
chip.classList.toggle('border-brand-ocean/50', active);
chip.classList.toggle('text-brand-oceanSoft', active);
});
}
function loadRecent() {
try {
@@ -319,6 +376,10 @@ function addRecent(emoji) {
saveRecent(curr);
}
function copyCurrentEmoji() {
copyToClipboard(currentDisplayEmoji);
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
addRecent(text);
@@ -334,12 +395,24 @@ function copyToClipboard(text) {
document.addEventListener('keydown', (e) => {
if (e.key === 'c' && !e.metaKey && !e.ctrlKey && document.activeElement.tagName !== 'INPUT') {
copyToClipboard(@json($symbol));
copyCurrentEmoji();
}
});
(() => {
const initialTone = getStoredTone();
applyTone(initialTone);
document.querySelectorAll('.tone-chip').forEach((chip) => {
chip.addEventListener('click', () => {
const tone = chip.dataset.tone || 'off';
setStoredTone(tone);
applyTone(tone);
});
});
})();
// Treat opening the single-emoji page as a "recently viewed emoji" event.
addRecent(@json($symbol));
addRecent(currentDisplayEmoji);
(() => {
const canManageKeywords = @json($canManageKeywords);

View File

@@ -96,6 +96,14 @@
<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>
<option value="">All Subcategories</option>
</select>
<select id="skin-tone" 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">
<option value="off">Default tone</option>
<option value="light">Light</option>
<option value="medium-light">Medium light</option>
<option value="medium">Medium</option>
<option value="medium-dark">Medium dark</option>
<option value="dark">Dark</option>
</select>
<button id="theme-toggle" class="w-10 h-10 rounded-full theme-surface border border-white/10 shadow-lg flex items-center justify-center text-gray-300 hover:text-white transition-colors">
<span class="sr-only">Toggle theme</span>
<i data-lucide="moon" class="w-4 h-4" data-theme-icon="dark"></i>
@@ -226,6 +234,7 @@
const qEl = document.getElementById('q');
const catEl = document.getElementById('category');
const subEl = document.getElementById('subcategory');
const toneEl = document.getElementById('skin-tone');
const grid = document.getElementById('grid');
const count = document.getElementById('count');
const more = document.getElementById('more');
@@ -250,6 +259,14 @@
const keywordEditSlug = document.getElementById('keyword-edit-slug');
const keywordEditText = document.getElementById('keyword-edit-text');
const keywordEditLang = document.getElementById('keyword-edit-lang');
const toneStorageKey = 'dewemoji_skin_tone';
const toneIndexMap = {
light: 0,
'medium-light': 1,
medium: 2,
'medium-dark': 3,
dark: 4,
};
if (initialQuery) qEl.value = initialQuery;
@@ -310,6 +327,20 @@
return String(s || '').replace(/[&<>"']/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
function selectedTone() {
return String(toneEl?.value || 'off');
}
function emojiWithTone(item) {
const tone = selectedTone();
if (tone === 'off') return item.emoji;
if (!item?.supports_skin_tone) return item.emoji;
const variants = Array.isArray(item?.variants) ? item.variants : [];
const idx = toneIndexMap[tone];
if (typeof idx === 'number' && variants[idx]) return variants[idx];
return item.emoji_base || item.emoji;
}
function applyGridDensity(level) {
const sizes = [
{ min: 90, emoji: '2.2rem' },
@@ -484,12 +515,12 @@
}
items.forEach((item) => {
const isPrivate = item.source === 'private';
const renderedEmoji = emojiWithTone(item);
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 = `
<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(item.emoji)}</span>
<span class="leading-none" style="font-size:var(--emoji-size)">${esc(renderedEmoji)}</span>
</a>
<div class="emoji-card-bar absolute bottom-0 left-0 right-0 border-t border-white/10 bg-black/20 px-2 py-1.5 flex items-start gap-1">
<span class="emoji-name-clamp text-[10px] text-gray-300 text-left flex-1">${esc(item.name)}</span>
@@ -501,17 +532,17 @@
copyBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
navigator.clipboard.writeText(item.emoji).then(() => {
showToast('Copied ' + item.emoji);
addRecent(item.emoji);
navigator.clipboard.writeText(renderedEmoji).then(() => {
showToast('Copied ' + renderedEmoji);
addRecent(renderedEmoji);
});
});
}
card.addEventListener('contextmenu', (e) => {
e.preventDefault();
navigator.clipboard.writeText(item.emoji).then(() => {
showToast('Copied ' + item.emoji);
addRecent(item.emoji);
navigator.clipboard.writeText(renderedEmoji).then(() => {
showToast('Copied ' + renderedEmoji);
addRecent(renderedEmoji);
});
});
grid.appendChild(card);
@@ -537,6 +568,10 @@
});
subEl.addEventListener('change', () => fetchEmojis(true));
toneEl?.addEventListener('change', () => {
localStorage.setItem(toneStorageKey, selectedTone());
fetchEmojis(true);
});
more.addEventListener('click', async () => {
state.page += 1;
@@ -609,6 +644,9 @@
}
(async () => {
if (toneEl) {
toneEl.value = localStorage.getItem(toneStorageKey) || 'off';
}
await loadCategories();
if (initialCategory && state.categories[initialCategory]) {
catEl.value = initialCategory;