533 lines
27 KiB
PHP
533 lines
27 KiB
PHP
@extends('dashboard.app')
|
|
|
|
@section('title', 'My Keywords')
|
|
@section('page_title', 'My Keywords')
|
|
@section('page_subtitle', 'Manage your private emoji keywords and language tags.')
|
|
|
|
@section('dashboard_content')
|
|
@php
|
|
$user = $user ?? auth()->user();
|
|
$isPersonal = $user && (string) $user->tier === 'personal';
|
|
$freeLimit = $freeLimit ?? null;
|
|
$activeCount = (int) ($activeCount ?? $items->where('is_active', true)->count());
|
|
$limitReached = $freeLimit !== null && $activeCount >= $freeLimit;
|
|
$emojiLookup = $emojiLookup ?? [];
|
|
@endphp
|
|
|
|
@if (session('status'))
|
|
<div class="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-200">
|
|
{{ session('status') }}
|
|
</div>
|
|
@endif
|
|
@if ($errors->any())
|
|
<div class="mb-6 rounded-2xl border border-amber-300/40 bg-amber-400/10 px-4 py-3 text-sm text-amber-200">
|
|
{{ $errors->first() }}
|
|
</div>
|
|
@endif
|
|
|
|
<div class="rounded-3xl glass-card p-6">
|
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
|
<div>
|
|
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Keyword library</div>
|
|
<div class="mt-2 text-xl font-semibold text-white">{{ $isPersonal ? 'Ready to personalize' : 'Free plan keywords' }}</div>
|
|
<p class="mt-2 text-sm text-gray-400">Add keywords to emojis to improve your personal search results.</p>
|
|
@if (!$isPersonal && $freeLimit)
|
|
<p class="mt-1 text-xs text-gray-500">Free active limit: {{ $activeCount }} / {{ $freeLimit }} keywords. Inactive keywords are stored but not used in search.</p>
|
|
@endif
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<button id="add-keyword-btn" class="rounded-full bg-brand-ocean text-white force-white font-semibold px-4 py-2 text-sm {{ $limitReached ? 'opacity-50 cursor-not-allowed' : '' }}" {{ $limitReached ? 'disabled' : '' }}>
|
|
+ Add Keyword
|
|
</button>
|
|
<button id="import-btn" class="rounded-full border border-white/10 px-4 py-2 text-sm text-gray-200 hover:bg-white/5 {{ $limitReached ? 'opacity-50 cursor-not-allowed' : '' }}" {{ $limitReached ? 'disabled' : '' }}>
|
|
Import JSON
|
|
</button>
|
|
<a href="{{ route('dashboard.keywords.export') }}" class="rounded-full border border-white/10 px-4 py-2 text-sm text-gray-200 hover:bg-white/5">
|
|
Export JSON
|
|
</a>
|
|
<div class="relative">
|
|
<input id="keyword-search" type="text" placeholder="Search keywords..." class="rounded-full bg-white/5 border border-white/10 px-4 py-2 text-sm text-gray-200 focus:outline-none focus:border-brand-ocean">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@if (!$isPersonal && $freeLimit)
|
|
<div class="mt-6 rounded-2xl border border-brand-sun/30 bg-brand-sun/10 p-4 text-sm text-brand-sun">
|
|
Free plan allows up to {{ $freeLimit }} active keywords. You can keep extras as inactive and reactivate after upgrading.
|
|
</div>
|
|
@endif
|
|
<div class="mt-4 rounded-2xl border border-white/10 bg-white/5 p-4 text-xs text-gray-400">
|
|
Search behavior: only <strong class="text-gray-200">Active</strong> private keywords are used in emoji matching. Inactive keywords stay saved in your account but are ignored by search and API results.
|
|
</div>
|
|
|
|
<div id="import-panel" class="mt-6 hidden rounded-2xl border border-white/10 bg-white/5 p-5">
|
|
<form method="POST" action="{{ route('dashboard.keywords.import') }}" enctype="multipart/form-data" class="grid gap-3 md:grid-cols-2">
|
|
@csrf
|
|
<div class="space-y-2">
|
|
<label class="text-xs uppercase tracking-[0.2em] text-gray-400">JSON file</label>
|
|
<input type="file" name="file" accept="application/json" class="block w-full text-sm text-gray-200 file:mr-4 file:rounded-full file:border-0 file:bg-brand-ocean file:px-4 file:py-2 file:text-sm file:font-semibold file:text-white hover:file:bg-brand-oceanSoft" {{ $limitReached ? 'disabled' : '' }}>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<label class="text-xs uppercase tracking-[0.2em] text-gray-400">Or paste JSON</label>
|
|
<textarea name="payload" rows="4" class="w-full rounded-2xl bg-black/30 border border-white/10 px-4 py-3 text-sm text-gray-200 focus:outline-none focus:border-brand-ocean" placeholder='[{"emoji_slug":"sparkles","keyword":"magic","lang":"en"}]'></textarea>
|
|
</div>
|
|
<div class="md:col-span-2 flex items-center justify-end gap-2">
|
|
<button type="button" id="import-cancel" class="rounded-full border border-white/10 px-4 py-2 text-sm text-gray-200 hover:bg-white/5">Cancel</button>
|
|
<button type="submit" class="rounded-full bg-brand-ocean text-white force-white font-semibold px-5 py-2 text-sm" {{ $limitReached ? 'disabled' : '' }}>Import</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="mt-6 overflow-hidden rounded-2xl border border-white/10">
|
|
<table class="min-w-full text-sm text-gray-300">
|
|
<thead class="bg-white/5 text-xs uppercase tracking-[0.2em] text-gray-400">
|
|
<tr>
|
|
<th class="px-4 py-3 text-left">Emoji</th>
|
|
<th class="px-4 py-3 text-left">Keyword</th>
|
|
<th class="px-4 py-3 text-left">Language</th>
|
|
<th class="px-4 py-3 text-left">Status</th>
|
|
<th class="px-4 py-3 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="keyword-table">
|
|
@forelse ($items as $item)
|
|
<tr class="border-t border-white/5 keyword-row" data-keyword="{{ strtolower($item->keyword) }}" data-slug="{{ strtolower($item->emoji_slug) }}">
|
|
@php
|
|
$lookup = $emojiLookup[$item->emoji_slug] ?? null;
|
|
@endphp
|
|
<td class="px-4 py-3">
|
|
<div class="flex items-center gap-3">
|
|
<span class="text-xl">{{ $lookup['emoji'] ?? '⬚' }}</span>
|
|
<div>
|
|
<div class="text-sm text-white">{{ $lookup['name'] ?? $item->emoji_slug }}</div>
|
|
<div class="text-xs text-gray-500">{{ $item->emoji_slug }}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-3 font-semibold text-white">{{ $item->keyword }}</td>
|
|
<td class="px-4 py-3 text-xs uppercase tracking-[0.15em] text-gray-400">{{ $item->lang ?? 'und' }}</td>
|
|
<td class="px-4 py-3">
|
|
@php
|
|
$isActive = (bool) ($item->is_active ?? true);
|
|
$canActivate = $isActive || $isPersonal || !$limitReached;
|
|
@endphp
|
|
<span class="rounded-full px-3 py-1 text-xs font-semibold {{ $isActive ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200' : 'bg-slate-100 text-slate-700 dark:bg-slate-500/20 dark:text-slate-200' }}">
|
|
{{ $isActive ? 'Active' : 'Inactive' }}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-right">
|
|
<button
|
|
type="button"
|
|
class="edit-btn rounded-full border border-white/10 px-3 py-1 text-xs text-gray-200 hover:bg-white/10"
|
|
data-id="{{ $item->id }}"
|
|
data-emoji="{{ $item->emoji_slug }}"
|
|
data-keyword="{{ $item->keyword }}"
|
|
data-lang="{{ $item->lang }}"
|
|
>
|
|
Edit
|
|
</button>
|
|
<form method="POST" action="{{ route('dashboard.keywords.toggle_active', $item->id) }}" class="inline">
|
|
@csrf
|
|
@method('PUT')
|
|
<input type="hidden" name="is_active" value="{{ $isActive ? '0' : '1' }}">
|
|
<button
|
|
type="submit"
|
|
class="rounded-full border border-white/10 px-3 py-1 text-xs text-gray-200 hover:bg-white/10 {{ (!$canActivate && !$isActive) ? 'opacity-50 cursor-not-allowed' : '' }}"
|
|
{{ (!$canActivate && !$isActive) ? 'disabled' : '' }}
|
|
>
|
|
{{ $isActive ? 'Deactivate' : 'Activate' }}
|
|
</button>
|
|
</form>
|
|
<form method="POST" action="{{ route('dashboard.keywords.delete', $item->id) }}" class="inline">
|
|
@csrf
|
|
@method('DELETE')
|
|
<button type="submit" class="rounded-full border border-white/10 px-3 py-1 text-xs text-gray-200 hover:bg-white/10">
|
|
Delete
|
|
</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
@empty
|
|
<tr>
|
|
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500">No keywords yet.</td>
|
|
</tr>
|
|
@endforelse
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="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 class="text-lg font-semibold text-slate-900 dark:text-white" id="keyword-modal-title">Add keyword</h3>
|
|
<button id="keyword-modal-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="keyword-form" method="POST" action="{{ route('dashboard.keywords.store') }}" class="mt-4 grid gap-4">
|
|
@csrf
|
|
<input type="hidden" name="_method" id="keyword-form-method" value="POST">
|
|
<div>
|
|
<label class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Emoji</label>
|
|
<input type="hidden" name="emoji_slug" id="keyword-emoji" required>
|
|
<div class="relative mt-2">
|
|
<input type="text" id="keyword-emoji-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 emoji (e.g. sparkles)" required>
|
|
<div id="keyword-emoji-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>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Keyword</label>
|
|
<input type="text" name="keyword" id="keyword-text" 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" name="lang" id="keyword-lang" value="und">
|
|
<div class="relative mt-2">
|
|
<input type="text" id="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="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="keyword-lang-source" class="hidden">
|
|
@include('partials.language-datalist')
|
|
</select>
|
|
</div>
|
|
<div class="flex items-center justify-end gap-2">
|
|
<button type="button" id="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 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 isPersonal = @json($isPersonal);
|
|
const limitReached = @json($limitReached);
|
|
const modal = document.getElementById('keyword-modal');
|
|
const modalTitle = document.getElementById('keyword-modal-title');
|
|
const openBtn = document.getElementById('add-keyword-btn');
|
|
const closeBtn = document.getElementById('keyword-modal-close');
|
|
const cancelBtn = document.getElementById('keyword-cancel');
|
|
const form = document.getElementById('keyword-form');
|
|
const methodEl = document.getElementById('keyword-form-method');
|
|
const emojiEl = document.getElementById('keyword-emoji');
|
|
const emojiComboEl = document.getElementById('keyword-emoji-combo');
|
|
const emojiMenuEl = document.getElementById('keyword-emoji-menu');
|
|
const textEl = document.getElementById('keyword-text');
|
|
const langEl = document.getElementById('keyword-lang');
|
|
const langComboEl = document.getElementById('keyword-lang-combo');
|
|
const langMenuEl = document.getElementById('keyword-lang-menu');
|
|
const langSourceEl = document.getElementById('keyword-lang-source');
|
|
const searchEl = document.getElementById('keyword-search');
|
|
const importBtn = document.getElementById('import-btn');
|
|
const importPanel = document.getElementById('import-panel');
|
|
const importCancel = document.getElementById('import-cancel');
|
|
|
|
window.dewemojiPopulateLanguageSelect?.(langSourceEl);
|
|
const canonicalizeLanguageCode = (raw) => window.dewemojiCanonicalLanguageCode?.(raw) || String(raw || '').trim().toLowerCase();
|
|
const emojiLookup = @json($emojiLookup);
|
|
const emojiSearchUrl = @json(route('dashboard.keywords.search'));
|
|
const publicEmojiSearchUrl = @json(url('/v1/emojis'));
|
|
|
|
const DEFAULT_LANGUAGE_CODE = 'en';
|
|
const UNDETERMINED_LANGUAGE_CODE = 'und';
|
|
const emojiItems = Object.entries(emojiLookup || {}).map(([slug, item]) => {
|
|
const name = String(item?.name || slug);
|
|
const glyph = String(item?.emoji || '');
|
|
const label = `${glyph ? `${glyph} ` : ''}${name}`;
|
|
return {
|
|
slug,
|
|
name,
|
|
glyph,
|
|
label,
|
|
search: `${slug} ${name} ${glyph}`.toLowerCase(),
|
|
};
|
|
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
const emojiBySlug = new Map(emojiItems.map((item) => [item.slug.toLowerCase(), item]));
|
|
let remoteEmojiItems = [];
|
|
let remoteEmojiSearched = false;
|
|
let emojiQueryToken = 0;
|
|
let emojiSearchTimer = null;
|
|
let emojiSearchAbort = null;
|
|
|
|
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 = langEl.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(langComboEl.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;
|
|
langEl.value = value;
|
|
if (silentInput || value.toLowerCase() === UNDETERMINED_LANGUAGE_CODE) {
|
|
langComboEl.value = '';
|
|
return;
|
|
}
|
|
langComboEl.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 renderEmojiMenu = (query = '') => {
|
|
if (!emojiMenuEl) return;
|
|
const q = query.trim().toLowerCase();
|
|
const useRemote = q.length >= 2 && remoteEmojiSearched;
|
|
const filtered = useRemote
|
|
? remoteEmojiItems.slice(0, 80)
|
|
: emojiItems.filter((item) => !q || item.search.includes(q)).slice(0, 80);
|
|
emojiMenuEl.innerHTML = filtered.map((item) => {
|
|
const selected = emojiEl.value === item.slug;
|
|
const glyph = item.glyph || ' ';
|
|
return `<button type="button" data-slug="${item.slug}" 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'}"><span class="inline-flex items-center gap-2"><span class="inline-flex w-6 shrink-0 justify-center">${glyph}</span><span class="truncate">${item.name}</span></span></button>`;
|
|
}).join('') || '<div class="px-3 py-2 text-sm text-slate-500 dark:text-gray-400">No matches</div>';
|
|
};
|
|
|
|
const mapApiItemsToEmojiItems = (items) => (items || []).map((item) => {
|
|
const slug = String(item?.slug || '').trim();
|
|
if (!slug) return null;
|
|
const existing = emojiBySlug.get(slug.toLowerCase());
|
|
const name = String(item?.name || existing?.name || slug);
|
|
const glyph = String(item?.emoji || existing?.glyph || '');
|
|
return {
|
|
slug,
|
|
name,
|
|
glyph,
|
|
label: `${glyph ? `${glyph} ` : ''}${name}`,
|
|
search: `${slug} ${name} ${glyph}`.toLowerCase(),
|
|
};
|
|
}).filter(Boolean);
|
|
|
|
const fetchEmojiMenuItems = async (query) => {
|
|
const q = String(query || '').trim();
|
|
if (q.length < 2) {
|
|
remoteEmojiItems = [];
|
|
remoteEmojiSearched = false;
|
|
renderEmojiMenu(q);
|
|
return;
|
|
}
|
|
|
|
const token = ++emojiQueryToken;
|
|
if (emojiSearchAbort) {
|
|
emojiSearchAbort.abort();
|
|
}
|
|
emojiSearchAbort = new AbortController();
|
|
|
|
const base = emojiSearchUrl;
|
|
const url = `${base}?${new URLSearchParams({ q, limit: '30', page: '1' }).toString()}`;
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
headers: { 'Accept': 'application/json' },
|
|
signal: emojiSearchAbort.signal,
|
|
});
|
|
const data = await res.json().catch(() => null);
|
|
if (token !== emojiQueryToken) return;
|
|
const items = Array.isArray(data?.items) ? data.items : [];
|
|
remoteEmojiItems = mapApiItemsToEmojiItems(items);
|
|
remoteEmojiSearched = true;
|
|
renderEmojiMenu(q);
|
|
} catch (error) {
|
|
if (error?.name === 'AbortError') return;
|
|
if (token !== emojiQueryToken) return;
|
|
remoteEmojiItems = [];
|
|
remoteEmojiSearched = true;
|
|
renderEmojiMenu(q);
|
|
}
|
|
};
|
|
|
|
const scheduleEmojiFetch = (query) => {
|
|
if (emojiSearchTimer) {
|
|
clearTimeout(emojiSearchTimer);
|
|
}
|
|
emojiSearchTimer = setTimeout(() => {
|
|
fetchEmojiMenuItems(query);
|
|
}, 180);
|
|
};
|
|
|
|
const openEmojiMenu = () => {
|
|
renderEmojiMenu(emojiComboEl.value);
|
|
emojiMenuEl?.classList.remove('hidden');
|
|
scheduleEmojiFetch(emojiComboEl.value);
|
|
};
|
|
|
|
const closeEmojiMenu = () => {
|
|
emojiMenuEl?.classList.add('hidden');
|
|
};
|
|
|
|
const setEmojiBySlug = (slug, { keepRaw = false } = {}) => {
|
|
const normalized = String(slug || '').trim().toLowerCase();
|
|
if (!normalized) {
|
|
emojiEl.value = '';
|
|
emojiComboEl.value = '';
|
|
return;
|
|
}
|
|
const item = emojiBySlug.get(normalized);
|
|
if (!item) {
|
|
emojiEl.value = normalized;
|
|
emojiComboEl.value = keepRaw ? String(slug || '') : normalized;
|
|
return;
|
|
}
|
|
emojiEl.value = item.slug;
|
|
emojiComboEl.value = item.label;
|
|
};
|
|
|
|
const resolveEmojiSlug = (raw) => {
|
|
const value = String(raw || '').trim().toLowerCase();
|
|
if (!value) return '';
|
|
const exact = emojiBySlug.get(value);
|
|
if (exact) return exact.slug;
|
|
const exactLabel = emojiItems.find((item) => item.label.toLowerCase() === value);
|
|
if (exactLabel) return exactLabel.slug;
|
|
const exactName = emojiItems.find((item) => item.name.toLowerCase() === value);
|
|
if (exactName) return exactName.slug;
|
|
const starts = emojiItems.filter((item) => item.slug.toLowerCase().startsWith(value) || item.name.toLowerCase().startsWith(value));
|
|
if (starts.length === 1) return starts[0].slug;
|
|
return '';
|
|
};
|
|
|
|
const openModal = (mode, data = {}) => {
|
|
if (mode === 'add' && limitReached) return;
|
|
modal.classList.remove('hidden');
|
|
modal.classList.add('flex');
|
|
modalTitle.textContent = mode === 'edit' ? 'Edit keyword' : 'Add keyword';
|
|
form.action = mode === 'edit' ? `/dashboard/keywords/${data.id}` : '{{ route('dashboard.keywords.store') }}';
|
|
methodEl.value = mode === 'edit' ? 'PUT' : 'POST';
|
|
setEmojiBySlug(data.emoji || '');
|
|
textEl.value = data.keyword || '';
|
|
const initialLanguage = mode === 'edit'
|
|
? (data.lang || UNDETERMINED_LANGUAGE_CODE)
|
|
: DEFAULT_LANGUAGE_CODE;
|
|
setLanguageByCode(initialLanguage, { silentInput: initialLanguage.toLowerCase() === UNDETERMINED_LANGUAGE_CODE });
|
|
closeLanguageMenu();
|
|
emojiComboEl.focus();
|
|
};
|
|
|
|
const closeModal = () => {
|
|
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();
|
|
});
|
|
|
|
emojiComboEl?.addEventListener('focus', openEmojiMenu);
|
|
emojiComboEl?.addEventListener('input', () => {
|
|
openEmojiMenu();
|
|
scheduleEmojiFetch(emojiComboEl.value);
|
|
});
|
|
emojiComboEl?.addEventListener('blur', () => {
|
|
const nextSlug = resolveEmojiSlug(emojiComboEl.value);
|
|
setEmojiBySlug(nextSlug);
|
|
setTimeout(closeEmojiMenu, 120);
|
|
});
|
|
emojiMenuEl?.addEventListener('mousedown', (event) => {
|
|
const button = event.target.closest('button[data-slug]');
|
|
if (!button) return;
|
|
event.preventDefault();
|
|
setEmojiBySlug(button.dataset.slug);
|
|
closeEmojiMenu();
|
|
});
|
|
|
|
langComboEl?.addEventListener('focus', openLanguageMenu);
|
|
langComboEl?.addEventListener('input', openLanguageMenu);
|
|
langComboEl?.addEventListener('blur', () => {
|
|
const nextCode = resolveLanguageCode(langComboEl.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();
|
|
});
|
|
|
|
document.querySelectorAll('.edit-btn').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
openModal('edit', {
|
|
id: btn.dataset.id,
|
|
emoji: btn.dataset.emoji,
|
|
keyword: btn.dataset.keyword,
|
|
lang: btn.dataset.lang,
|
|
});
|
|
});
|
|
});
|
|
|
|
form?.addEventListener('submit', (event) => {
|
|
const slug = resolveEmojiSlug(emojiComboEl?.value || emojiEl?.value || '');
|
|
if (!slug) {
|
|
event.preventDefault();
|
|
alert('Please select a valid emoji from the dropdown.');
|
|
emojiComboEl?.focus();
|
|
openEmojiMenu();
|
|
return;
|
|
}
|
|
setEmojiBySlug(slug);
|
|
});
|
|
|
|
if (searchEl) {
|
|
searchEl.addEventListener('input', () => {
|
|
const value = searchEl.value.trim().toLowerCase();
|
|
document.querySelectorAll('.keyword-row').forEach((row) => {
|
|
const match = row.dataset.keyword?.includes(value) || row.dataset.slug?.includes(value);
|
|
row.classList.toggle('hidden', value && !match);
|
|
});
|
|
});
|
|
}
|
|
|
|
importBtn?.addEventListener('click', () => {
|
|
if (!isPersonal) return;
|
|
importPanel.classList.remove('hidden');
|
|
});
|
|
importCancel?.addEventListener('click', () => {
|
|
importPanel.classList.add('hidden');
|
|
});
|
|
|
|
if (window.location.hash === '#add') {
|
|
openModal('add');
|
|
}
|
|
window.addEventListener('hashchange', () => {
|
|
if (window.location.hash === '#add') {
|
|
openModal('add');
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
@endpush
|