Add skin tone support for discover grid and emoji detail
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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;
|
||||
|
||||
Reference in New Issue
Block a user