662 lines
34 KiB
PHP
662 lines
34 KiB
PHP
@extends('site.layout')
|
|
|
|
@section('title', ($emoji['name'] ?? 'Emoji').' - Dewemoji')
|
|
@section('meta_description', ($emoji['description'] ?? 'Emoji detail').' Discover meaning, keywords, and copy-ready formats on Dewemoji.')
|
|
|
|
@php
|
|
$name = $emoji['name'] ?? '';
|
|
$category = $emoji['category'] ?? '';
|
|
$subcategory = $emoji['subcategory'] ?? '';
|
|
$symbol = $emoji['emoji'] ?? '';
|
|
$slug = $emoji['slug'] ?? '';
|
|
$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';
|
|
$userKeywords = $userKeywords ?? collect();
|
|
$activeKeywordCount = (int) ($activeKeywordCount ?? $userKeywords->where('is_active', true)->count());
|
|
$htmlHex = '';
|
|
$cssCode = '';
|
|
if (!empty($emoji['codepoints'][0])) {
|
|
$hex = strtoupper($emoji['codepoints'][0]);
|
|
$htmlHex = '&#x'.$hex.';';
|
|
$cssCode = '\\'.$hex;
|
|
}
|
|
$related = $relatedDetails ?? [];
|
|
$keywords = array_slice($emoji['keywords_en'] ?? [], 0, 16);
|
|
@endphp
|
|
|
|
@push('jsonld')
|
|
<script type="application/ld+json">
|
|
{
|
|
"@@context": "https://schema.org",
|
|
"@@graph": [
|
|
{
|
|
"@@type": "CreativeWork",
|
|
"name": @json($name),
|
|
"description": @json($description),
|
|
"url": @json(url('/emoji/'.$slug))
|
|
},
|
|
{
|
|
"@@type": "BreadcrumbList",
|
|
"itemListElement": [
|
|
{"@@type":"ListItem","position":1,"name":"Home","item":@json(url('/'))},
|
|
{"@@type":"ListItem","position":2,"name":"Emoji","item":@json(url('/browse'))},
|
|
{"@@type":"ListItem","position":3,"name":@json($name),"item":@json(url('/emoji/'.$slug))}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
</script>
|
|
@endpush
|
|
|
|
@section('content')
|
|
<div class="flex h-screen">
|
|
<aside class="hidden lg:flex w-20 lg:w-64 h-full glass-panel flex-col justify-between p-4 z-20">
|
|
<div>
|
|
<div class="flex items-center gap-3 px-2 mb-10 mt-2">
|
|
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-white to-gray-300 flex items-center justify-center shadow-lg shadow-white/20">
|
|
<img src="/assets/logo/logo-mark.svg" alt="Dewemoji logo" class="w-7 h-7 object-contain" />
|
|
</div>
|
|
<h1 class="font-display font-bold text-lg tracking-tight hidden lg:block">Dewemoji</h1>
|
|
</div>
|
|
<nav class="space-y-2">
|
|
<a href="{{ route('home') }}" class="flex items-center gap-4 px-3 py-3 rounded-xl text-gray-300 hover:text-white hover:bg-white/5 transition-all group">
|
|
<i data-lucide="arrow-left" class="w-5 h-5 text-brand-sun"></i>
|
|
<span class="text-sm font-medium hidden lg:block text-brand-sun">Back</span>
|
|
</a>
|
|
</nav>
|
|
</div>
|
|
</aside>
|
|
|
|
<main class="flex-1 h-full overflow-y-auto relative p-4 pt-0 sm:p-6 lg:p-10 pb-24 lg:pb-10 flex flex-col">
|
|
<div class="sticky top-0 z-40 -mx-4 px-4 py-3 mb-6 bg-[var(--app-bg)]/90 backdrop-blur border-b border-white/10 sm:static sm:mx-0 sm:px-0 sm:py-0 sm:mb-8 sm:border-0">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2 text-xs sm:text-sm text-gray-500 font-mono">
|
|
<a href="{{ route('home') }}" class="hover:text-white transition-colors">Home</a>
|
|
<i data-lucide="chevron-right" class="w-3 h-3"></i>
|
|
<span class="hidden sm:inline-flex hover:text-white">{{ $category }}</span>
|
|
<i data-lucide="chevron-right" class="w-3 h-3 hidden sm:inline-flex"></i>
|
|
<span class="text-brand-sun">{{ $name }}</span>
|
|
</div>
|
|
<button id="theme-toggle" class="w-9 h-9 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>
|
|
<i data-lucide="sun" class="w-4 h-4 hidden" data-theme-icon="light"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 max-w-6xl mx-auto w-full">
|
|
<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 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="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>
|
|
</div>
|
|
|
|
@if(count($related) > 0)
|
|
<div class="glass-card rounded-2xl p-5">
|
|
<h3 class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4">Related</h3>
|
|
<div class="flex gap-2 overflow-x-auto pb-2">
|
|
@foreach($related as $item)
|
|
<div class="relative group">
|
|
@if(!empty($item['slug']))
|
|
<a
|
|
href="{{ route('emoji-detail', ['slug' => $item['slug']]) }}"
|
|
class="w-12 h-12 rounded-lg bg-white/5 hover:bg-white/10 flex items-center justify-center text-2xl transition-colors"
|
|
title="{{ $item['name'] }}"
|
|
>{{ $item['emoji'] }}</a>
|
|
@else
|
|
<div class="w-12 h-12 rounded-lg bg-white/5 flex items-center justify-center text-2xl">{{ $item['emoji'] }}</div>
|
|
@endif
|
|
<button
|
|
type="button"
|
|
onclick="copyToClipboard('{{ $item['emoji'] }}'); event.preventDefault(); event.stopPropagation();"
|
|
class="absolute -bottom-1 -right-1 w-5 h-5 rounded bg-black/70 border border-white/10 text-[10px] text-white force-white hover:bg-brand-ocean/40"
|
|
title="Copy {{ $item['name'] }}"
|
|
>⧉</button>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="lg:col-span-7 flex flex-col gap-6">
|
|
<div>
|
|
<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>
|
|
</div>
|
|
<h1 class="font-display text-5xl font-bold mb-4">{{ $name }}</h1>
|
|
<p class="text-gray-400 text-lg leading-relaxed">{{ $description }}</p>
|
|
</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)
|
|
<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">
|
|
<div class="flex justify-between items-start mb-1">
|
|
<span class="text-xs font-mono text-gray-500">Shortcode</span>
|
|
<i data-lucide="copy" class="w-3 h-3 text-gray-600 group-hover:text-brand-oceanSoft transition-colors"></i>
|
|
</div>
|
|
<div class="font-mono text-brand-sun group-hover:text-brand-sunSoft font-medium truncate">{{ $shortcode }}</div>
|
|
</button>
|
|
@endif
|
|
|
|
@if($unified !== '')
|
|
<button onclick="copyToClipboard('{{ $unified }}')" class="glass-card p-4 rounded-xl group text-left">
|
|
<div class="flex justify-between items-start mb-1">
|
|
<span class="text-xs font-mono text-gray-500">Unicode</span>
|
|
<i data-lucide="copy" class="w-3 h-3 text-gray-600 group-hover:text-brand-oceanSoft transition-colors"></i>
|
|
</div>
|
|
<div class="font-mono text-gray-300 group-hover:text-white font-medium">{{ $unified }}</div>
|
|
</button>
|
|
@endif
|
|
|
|
@if($htmlHex !== '')
|
|
<button onclick="copyToClipboard('{{ $htmlHex }}')" class="glass-card p-4 rounded-xl group text-left">
|
|
<div class="flex justify-between items-start mb-1">
|
|
<span class="text-xs font-mono text-gray-500">HTML Entity</span>
|
|
<i data-lucide="copy" class="w-3 h-3 text-gray-600 group-hover:text-brand-oceanSoft transition-colors"></i>
|
|
</div>
|
|
<div class="font-mono text-gray-400 group-hover:text-white font-medium truncate">{{ $htmlHex }}</div>
|
|
</button>
|
|
@endif
|
|
|
|
@if($cssCode !== '')
|
|
<button onclick="copyToClipboard('{{ $cssCode }}')" class="glass-card p-4 rounded-xl group text-left">
|
|
<div class="flex justify-between items-start mb-1">
|
|
<span class="text-xs font-mono text-gray-500">CSS Content</span>
|
|
<i data-lucide="copy" class="w-3 h-3 text-gray-600 group-hover:text-brand-oceanSoft transition-colors"></i>
|
|
</div>
|
|
<div class="font-mono text-gray-400 group-hover:text-white font-medium">{{ $cssCode }}</div>
|
|
</button>
|
|
@endif
|
|
</div>
|
|
|
|
@if(count($keywords) > 0)
|
|
<div class="mt-2">
|
|
<div class="flex items-center gap-2 mb-4">
|
|
<i data-lucide="hash" class="w-4 h-4 text-brand-sun"></i>
|
|
<h3 class="text-sm font-bold text-gray-200 uppercase tracking-wide">Semantic Tags</h3>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
@foreach($keywords as $tag)
|
|
<a href="{{ route('home') }}?q={{ urlencode($tag) }}" class="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-brand-ocean/10 border border-white/5 hover:border-brand-ocean/30 text-sm text-gray-300 hover:text-brand-oceanSoft transition-all">{{ $tag }}</a>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<div class="mt-6 glass-card rounded-2xl p-5">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<i data-lucide="sparkles" class="w-4 h-4 text-brand-ocean"></i>
|
|
<h3 class="text-sm font-bold text-gray-200 uppercase tracking-wide">Your Keywords</h3>
|
|
</div>
|
|
@if ($canManageKeywords)
|
|
<button id="user-keyword-add" class="rounded-full bg-brand-ocean text-white force-white text-xs font-semibold px-3 py-1.5 {{ $limitReached ? 'opacity-60 cursor-not-allowed' : '' }}" {{ $limitReached ? 'disabled' : '' }}>Add keyword</button>
|
|
@endif
|
|
</div>
|
|
|
|
@if ($canManageKeywords)
|
|
@if (!is_null($keywordLimit))
|
|
<div class="mt-3 text-xs text-gray-400">
|
|
Free active limit: {{ $activeKeywordCount }} / {{ $keywordLimit }} keywords.
|
|
</div>
|
|
@endif
|
|
<div id="user-keyword-list" class="mt-4 flex flex-wrap gap-2">
|
|
@forelse ($userKeywords as $keyword)
|
|
@php($isKeywordActive = (bool) ($keyword->is_active ?? true))
|
|
<span
|
|
class="user-keyword-pill inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm {{ $isKeywordActive ? 'bg-white/5 border-white/10 text-gray-200' : 'bg-slate-200/10 border-slate-200/20 text-gray-300' }}"
|
|
data-id="{{ $keyword->id }}"
|
|
data-keyword="{{ $keyword->keyword }}"
|
|
data-lang="{{ $keyword->lang ?? 'und' }}"
|
|
data-active="{{ $isKeywordActive ? '1' : '0' }}"
|
|
>
|
|
<span>{{ $keyword->keyword }}</span>
|
|
<span class="text-[10px] uppercase tracking-[0.2em] text-gray-500">{{ $keyword->lang ?? 'und' }}</span>
|
|
@unless($isKeywordActive)
|
|
<span class="text-[10px] uppercase tracking-[0.2em] text-amber-400">inactive</span>
|
|
@endunless
|
|
<button type="button" class="user-keyword-edit rounded-md border border-white/10 px-1.5 py-0.5 text-[10px] text-gray-300 hover:bg-white/10">Edit</button>
|
|
<button type="button" class="user-keyword-delete rounded-md border border-white/10 px-1.5 py-0.5 text-[10px] text-gray-300 hover:bg-red-500/20">Delete</button>
|
|
</span>
|
|
@empty
|
|
<span id="user-keyword-empty" class="text-sm text-gray-400">No private keywords yet. Add one to personalize search.</span>
|
|
@endforelse
|
|
</div>
|
|
@else
|
|
<div class="mt-4 rounded-xl border border-white/10 bg-white/5 p-3 text-sm text-gray-300">
|
|
Sign up to personalize keywords and sync across devices.
|
|
</div>
|
|
<a href="{{ route('register') }}" class="mt-3 inline-flex items-center justify-center rounded-full bg-brand-sun text-black font-semibold px-4 py-2 text-sm">Sign up free</a>
|
|
@endif
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<footer class="mt-auto pt-10 text-center text-gray-600 text-xs">Dewemoji Native • Press 'C' to copy</footer>
|
|
</main>
|
|
</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 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>
|
|
<span id="toast-msg">Copied!</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="user-keyword-modal" class="hidden fixed inset-0 z-[90] items-center justify-center">
|
|
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm"></div>
|
|
<div class="relative z-10 w-full max-w-lg rounded-3xl glass-card theme-surface p-6">
|
|
<div class="flex items-center justify-between">
|
|
<h3 id="user-keyword-title" class="text-lg font-semibold text-slate-900 dark:text-white">Add keyword</h3>
|
|
<button id="user-keyword-close" class="w-8 h-8 rounded-full bg-slate-100 hover:bg-slate-200 dark:bg-white/10 dark:hover:bg-white/20 flex items-center justify-center text-slate-600 dark:text-gray-200">
|
|
<i data-lucide="x" class="w-4 h-4"></i>
|
|
</button>
|
|
</div>
|
|
<form id="user-keyword-form" class="mt-4 grid gap-4">
|
|
<div>
|
|
<label class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Keyword</label>
|
|
<input type="text" name="keyword" id="user-keyword-input" class="mt-2 w-full rounded-xl bg-white border border-slate-300 px-4 py-3 text-sm text-slate-900 placeholder-slate-400 focus:outline-none focus:border-brand-ocean dark:bg-black/40 dark:border-white/10 dark:text-gray-200 dark:placeholder-gray-500" placeholder="magic" required>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Language</label>
|
|
<input type="hidden" id="user-keyword-lang" value="und">
|
|
<div class="relative mt-2">
|
|
<input type="text" id="user-keyword-lang-combo" autocomplete="off" class="w-full rounded-xl bg-white border border-slate-300 px-4 py-3 text-sm text-slate-900 placeholder-slate-400 focus:outline-none focus:border-brand-ocean dark:bg-black/40 dark:border-white/10 dark:text-gray-200 dark:placeholder-gray-500" placeholder="Search language (e.g. Indonesian)">
|
|
<div id="user-keyword-lang-menu" class="hidden absolute left-0 right-0 mt-2 max-h-56 overflow-y-auto rounded-xl border border-slate-200 bg-white shadow-xl z-[100] dark:border-white/10 dark:bg-[#0b0b0f]"></div>
|
|
</div>
|
|
<select id="user-keyword-lang-source" class="hidden">
|
|
@include('partials.language-datalist')
|
|
</select>
|
|
</div>
|
|
<div class="flex items-center justify-end gap-2">
|
|
<button type="button" id="user-keyword-cancel" class="rounded-full border border-slate-300 px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 dark:border-white/10 dark:text-gray-200 dark:hover:bg-white/5">Cancel</button>
|
|
<button id="user-keyword-submit" type="submit" class="rounded-full bg-brand-ocean text-white force-white font-semibold px-5 py-2 text-sm">Save keyword</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
@endsection
|
|
|
|
@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 {
|
|
return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveRecent(items) {
|
|
localStorage.setItem(RECENT_KEY, JSON.stringify(items.slice(0, 8)));
|
|
}
|
|
|
|
function addRecent(emoji) {
|
|
const curr = loadRecent().filter((e) => e !== emoji);
|
|
curr.unshift(emoji);
|
|
saveRecent(curr);
|
|
}
|
|
|
|
function copyCurrentEmoji() {
|
|
copyToClipboard(currentDisplayEmoji);
|
|
}
|
|
|
|
function copyToClipboard(text) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
addRecent(text);
|
|
const toast = document.getElementById('toast');
|
|
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);
|
|
});
|
|
}
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'c' && !e.metaKey && !e.ctrlKey && document.activeElement.tagName !== 'INPUT') {
|
|
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(currentDisplayEmoji);
|
|
|
|
(() => {
|
|
const canManageKeywords = @json($canManageKeywords);
|
|
const limitReached = @json($limitReached);
|
|
if (!canManageKeywords) return;
|
|
const modal = document.getElementById('user-keyword-modal');
|
|
const openBtn = document.getElementById('user-keyword-add');
|
|
const closeBtn = document.getElementById('user-keyword-close');
|
|
const cancelBtn = document.getElementById('user-keyword-cancel');
|
|
const form = document.getElementById('user-keyword-form');
|
|
const modalTitle = document.getElementById('user-keyword-title');
|
|
const submitBtn = document.getElementById('user-keyword-submit');
|
|
const keywordInput = document.getElementById('user-keyword-input');
|
|
const langInput = document.getElementById('user-keyword-lang');
|
|
const langComboInput = document.getElementById('user-keyword-lang-combo');
|
|
const langMenuEl = document.getElementById('user-keyword-lang-menu');
|
|
const langSourceEl = document.getElementById('user-keyword-lang-source');
|
|
const list = document.getElementById('user-keyword-list');
|
|
const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
const updateUrlTemplate = @json(route('dashboard.keywords.update', ['keyword' => '__ID__']));
|
|
const deleteUrlTemplate = @json(route('dashboard.keywords.delete', ['keyword' => '__ID__']));
|
|
let editingKeywordId = null;
|
|
const escHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
|
|
|
window.dewemojiPopulateLanguageSelect?.(langSourceEl);
|
|
const canonicalizeLanguageCode = (raw) => window.dewemojiCanonicalLanguageCode?.(raw) || String(raw || '').trim().toLowerCase();
|
|
|
|
const DEFAULT_LANGUAGE_CODE = 'en';
|
|
const UNDETERMINED_LANGUAGE_CODE = 'und';
|
|
|
|
const languageItems = Array.from(langSourceEl?.options || []).map((option) => ({
|
|
code: option.value,
|
|
label: option.text,
|
|
}));
|
|
const labelByCode = new Map(languageItems.map((item) => [item.code.toLowerCase(), item.label]));
|
|
const itemByCode = new Map(languageItems.map((item) => [item.code.toLowerCase(), item]));
|
|
|
|
const renderLanguageMenu = (query = '') => {
|
|
if (!langMenuEl) return;
|
|
const q = query.trim().toLowerCase();
|
|
const filtered = languageItems.filter((item) => item.label.toLowerCase().includes(q));
|
|
langMenuEl.innerHTML = filtered.map((item) => {
|
|
const selected = langInput.value === item.code;
|
|
return `<button type="button" data-code="${item.code}" class="w-full text-left px-3 py-2 text-sm ${selected ? 'bg-brand-ocean/10 text-brand-ocean dark:text-brand-oceanSoft' : 'text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10'}">${item.label}</button>`;
|
|
}).join('') || '<div class="px-3 py-2 text-sm text-slate-500 dark:text-gray-400">No matches</div>';
|
|
};
|
|
|
|
const openLanguageMenu = () => {
|
|
renderLanguageMenu(langComboInput.value);
|
|
langMenuEl.classList.remove('hidden');
|
|
};
|
|
|
|
const closeLanguageMenu = () => {
|
|
langMenuEl.classList.add('hidden');
|
|
};
|
|
|
|
const setLanguageByCode = (code, { silentInput = false } = {}) => {
|
|
const value = canonicalizeLanguageCode(code || UNDETERMINED_LANGUAGE_CODE) || UNDETERMINED_LANGUAGE_CODE;
|
|
langInput.value = value;
|
|
if (silentInput || value.toLowerCase() === UNDETERMINED_LANGUAGE_CODE) {
|
|
langComboInput.value = '';
|
|
return;
|
|
}
|
|
langComboInput.value = labelByCode.get(value.toLowerCase()) || value;
|
|
};
|
|
|
|
const resolveLanguageCode = (raw) => {
|
|
const value = (raw || '').trim();
|
|
if (!value) return UNDETERMINED_LANGUAGE_CODE;
|
|
const byCode = itemByCode.get(canonicalizeLanguageCode(value));
|
|
if (byCode) return byCode.code;
|
|
const byLabel = languageItems.find((item) => item.label.toLowerCase() === value.toLowerCase());
|
|
if (byLabel) return canonicalizeLanguageCode(byLabel.code);
|
|
return UNDETERMINED_LANGUAGE_CODE;
|
|
};
|
|
|
|
const setModalMode = (mode = 'add') => {
|
|
if (mode === 'edit') {
|
|
modalTitle.textContent = 'Edit keyword';
|
|
submitBtn.textContent = 'Save changes';
|
|
return;
|
|
}
|
|
modalTitle.textContent = 'Add keyword';
|
|
submitBtn.textContent = 'Save keyword';
|
|
};
|
|
|
|
const buildKeywordPill = (item) => {
|
|
const pill = document.createElement('span');
|
|
const isActive = Boolean(item?.is_active ?? true);
|
|
const lang = String(item?.lang || 'und');
|
|
pill.className = `user-keyword-pill inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm ${isActive ? 'bg-white/5 border-white/10 text-gray-200' : 'bg-slate-200/10 border-slate-200/20 text-gray-300'}`;
|
|
pill.dataset.id = String(item?.id || '');
|
|
pill.dataset.keyword = String(item?.keyword || '');
|
|
pill.dataset.lang = lang;
|
|
pill.dataset.active = isActive ? '1' : '0';
|
|
pill.innerHTML = `
|
|
<span>${escHtml(String(item?.keyword || ''))}</span>
|
|
<span class="text-[10px] uppercase tracking-[0.2em] text-gray-500">${escHtml(lang)}</span>
|
|
${isActive ? '' : '<span class="text-[10px] uppercase tracking-[0.2em] text-amber-400">inactive</span>'}
|
|
<button type="button" class="user-keyword-edit rounded-md border border-white/10 px-1.5 py-0.5 text-[10px] text-gray-300 hover:bg-white/10">Edit</button>
|
|
<button type="button" class="user-keyword-delete rounded-md border border-white/10 px-1.5 py-0.5 text-[10px] text-gray-300 hover:bg-red-500/20">Delete</button>
|
|
`;
|
|
return pill;
|
|
};
|
|
|
|
const openModal = (mode = 'add', item = null) => {
|
|
if (mode === 'add' && limitReached) {
|
|
showToast('Free plan keyword limit reached');
|
|
return;
|
|
}
|
|
editingKeywordId = mode === 'edit' ? item?.id || null : null;
|
|
setModalMode(mode);
|
|
modal.classList.remove('hidden');
|
|
modal.classList.add('flex');
|
|
keywordInput.value = mode === 'edit' ? (item?.keyword || '') : '';
|
|
setLanguageByCode(mode === 'edit' ? (item?.lang || UNDETERMINED_LANGUAGE_CODE) : DEFAULT_LANGUAGE_CODE, {
|
|
silentInput: mode === 'edit' && String(item?.lang || '').toLowerCase() === UNDETERMINED_LANGUAGE_CODE,
|
|
});
|
|
closeLanguageMenu();
|
|
keywordInput.focus();
|
|
};
|
|
|
|
const closeModal = () => {
|
|
editingKeywordId = null;
|
|
setModalMode('add');
|
|
modal.classList.add('hidden');
|
|
modal.classList.remove('flex');
|
|
};
|
|
|
|
openBtn?.addEventListener('click', () => openModal('add'));
|
|
closeBtn?.addEventListener('click', closeModal);
|
|
cancelBtn?.addEventListener('click', closeModal);
|
|
modal?.addEventListener('click', (e) => {
|
|
if (e.target === modal) closeModal();
|
|
});
|
|
langComboInput?.addEventListener('focus', openLanguageMenu);
|
|
langComboInput?.addEventListener('input', openLanguageMenu);
|
|
langComboInput?.addEventListener('blur', () => {
|
|
const nextCode = resolveLanguageCode(langComboInput.value);
|
|
setLanguageByCode(nextCode, { silentInput: nextCode.toLowerCase() === UNDETERMINED_LANGUAGE_CODE });
|
|
setTimeout(closeLanguageMenu, 120);
|
|
});
|
|
langMenuEl?.addEventListener('mousedown', (event) => {
|
|
const button = event.target.closest('button[data-code]');
|
|
if (!button) return;
|
|
event.preventDefault();
|
|
setLanguageByCode(button.dataset.code);
|
|
closeLanguageMenu();
|
|
});
|
|
|
|
form?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const payload = {
|
|
emoji_slug: @json($slug),
|
|
keyword: keywordInput.value.trim(),
|
|
lang: langInput.value || 'und',
|
|
};
|
|
if (!payload.keyword) return;
|
|
const url = editingKeywordId
|
|
? updateUrlTemplate.replace('__ID__', String(editingKeywordId))
|
|
: '{{ route('dashboard.keywords.store') }}';
|
|
const method = editingKeywordId ? 'PUT' : 'POST';
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await res.json().catch(() => null);
|
|
if (!res.ok || !data?.ok) {
|
|
showToast('Could not save keyword');
|
|
return;
|
|
}
|
|
const item = data?.item || payload;
|
|
const wasEditing = Boolean(editingKeywordId);
|
|
const badge = buildKeywordPill(item);
|
|
if (list) {
|
|
const empty = list.querySelector('#user-keyword-empty');
|
|
if (empty) empty.remove();
|
|
if (wasEditing) {
|
|
const current = list.querySelector(`.user-keyword-pill[data-id="${editingKeywordId}"]`);
|
|
if (current) current.replaceWith(badge);
|
|
else list.prepend(badge);
|
|
} else {
|
|
list.prepend(badge);
|
|
}
|
|
}
|
|
closeModal();
|
|
showToast(wasEditing ? 'Keyword updated' : 'Keyword added');
|
|
});
|
|
|
|
list?.addEventListener('click', async (event) => {
|
|
const editBtn = event.target.closest('.user-keyword-edit');
|
|
const deleteBtn = event.target.closest('.user-keyword-delete');
|
|
const pill = event.target.closest('.user-keyword-pill');
|
|
if (!pill) return;
|
|
const id = pill.dataset.id;
|
|
if (!id) return;
|
|
|
|
if (editBtn) {
|
|
openModal('edit', {
|
|
id,
|
|
keyword: pill.dataset.keyword || '',
|
|
lang: pill.dataset.lang || 'und',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!deleteBtn) return;
|
|
const ok = window.dewemojiConfirm
|
|
? await window.dewemojiConfirm('Delete this keyword?', {
|
|
title: 'Delete keyword',
|
|
okText: 'Delete',
|
|
})
|
|
: true;
|
|
if (!ok) return;
|
|
|
|
const res = await fetch(deleteUrlTemplate.replace('__ID__', id), {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
|
|
},
|
|
});
|
|
const data = await res.json().catch(() => null);
|
|
if (!res.ok || !data?.ok) {
|
|
showToast('Could not delete keyword');
|
|
return;
|
|
}
|
|
pill.remove();
|
|
if (!list.querySelector('.user-keyword-pill')) {
|
|
const empty = document.createElement('span');
|
|
empty.id = 'user-keyword-empty';
|
|
empty.className = 'text-sm text-gray-400';
|
|
empty.textContent = 'No private keywords yet. Add one to personalize search.';
|
|
list.appendChild(empty);
|
|
}
|
|
showToast('Keyword deleted');
|
|
});
|
|
})();
|
|
|
|
</script>
|
|
@endpush
|