feat(ui): searchable language picker and light-mode keyword modals

This commit is contained in:
Dwindi Ramadhana
2026-02-15 11:11:44 +07:00
parent 87aa842e0b
commit a7a854886d
5 changed files with 186 additions and 24 deletions

View File

@@ -11,12 +11,23 @@
$hasSub = $subscription !== null;
$orders = $orders ?? collect();
$payments = $payments ?? collect();
$formatPlan = function (?string $code): string {
$value = (string) ($code ?? '');
return match ($value) {
'personal_monthly' => 'Personal Monthly',
'personal_annual' => 'Personal Annual',
'personal_lifetime' => 'Personal Lifetime',
'free' => 'Free',
'' => 'Free',
default => \Illuminate\Support\Str::of($value)->replace('_', ' ')->title(),
};
};
@endphp
<div class="grid gap-6 lg:grid-cols-3">
<div class="rounded-3xl glass-card p-6">
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Plan</div>
<div class="mt-3 text-3xl font-semibold text-white">{{ $hasSub ? ucfirst($subscription->plan ?? 'Personal') : 'Free' }}</div>
<div class="mt-3 text-3xl font-semibold text-white">{{ $hasSub ? $formatPlan($subscription->plan ?? 'Personal') : 'Free' }}</div>
<div class="mt-2 text-sm text-gray-400">{{ $hasSub ? ucfirst($subscription->status ?? 'active') : 'No subscription' }}</div>
</div>
<div class="rounded-3xl glass-card p-6">
@@ -93,7 +104,7 @@
@endphp
<tr>
<td class="px-4 py-3">{{ $payment->provider ?? '—' }}</td>
<td class="px-4 py-3">{{ $payment->plan_code ?? '—' }}</td>
<td class="px-4 py-3">{{ $formatPlan($payment->plan_code) }}</td>
<td class="px-4 py-3">{{ $payment->currency ?? 'USD' }} {{ number_format((float) ($payment->amount ?? 0), 2) }}</td>
<td class="px-4 py-3">
<span class="rounded-full {{ $pill['bg'] }} px-3 py-1 text-xs font-semibold {{ $pill['text'] }}">{{ $status }}</span>

View File

@@ -133,10 +133,10 @@
<div id="keyword-modal" class="hidden fixed inset-0 z-50 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 p-6">
<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-white" id="keyword-modal-title">Add keyword</h3>
<button id="keyword-modal-close" class="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-gray-200">
<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>
@@ -144,20 +144,22 @@
@csrf
<input type="hidden" name="_method" id="keyword-form-method" value="POST">
<div>
<label class="text-xs uppercase tracking-[0.2em] text-gray-400">Emoji Slug</label>
<input type="text" name="emoji_slug" id="keyword-emoji" class="mt-2 w-full rounded-xl bg-black/40 border border-white/10 px-4 py-3 text-sm text-gray-200 focus:outline-none focus:border-brand-ocean" placeholder="sparkles" required>
<label class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Emoji Slug</label>
<input type="text" name="emoji_slug" id="keyword-emoji" 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="sparkles" required>
</div>
<div>
<label class="text-xs uppercase tracking-[0.2em] text-gray-400">Keyword</label>
<input type="text" name="keyword" id="keyword-text" class="mt-2 w-full rounded-xl bg-black/40 border border-white/10 px-4 py-3 text-sm text-gray-200 focus:outline-none focus:border-brand-ocean" placeholder="magic" required>
<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-gray-400">Language</label>
<input type="text" name="lang" id="keyword-lang" class="mt-2 w-full rounded-xl bg-black/40 border border-white/10 px-4 py-3 text-sm text-gray-200 focus:outline-none focus:border-brand-ocean" placeholder="en">
<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">
<input type="text" id="keyword-lang-display" list="keyword-language-options" 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="Search language (e.g. English)">
@include('partials.language-datalist', ['id' => 'keyword-language-options'])
</div>
<div class="flex items-center justify-end gap-2">
<button type="button" id="keyword-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 font-semibold px-5 py-2 text-sm">Save keyword</button>
<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>
@@ -179,11 +181,44 @@
const emojiEl = document.getElementById('keyword-emoji');
const textEl = document.getElementById('keyword-text');
const langEl = document.getElementById('keyword-lang');
const langDisplayEl = document.getElementById('keyword-lang-display');
const langDataList = document.getElementById('keyword-language-options');
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.dewemojiPopulateLanguageOptions?.(langDataList);
const langDisplayToCode = new Map();
const langCodeToDisplay = new Map();
langDataList?.querySelectorAll('option').forEach((option) => {
const code = (option.dataset.code || '').trim();
const display = (option.value || '').trim();
if (!code || !display) return;
langDisplayToCode.set(display.toLowerCase(), code);
langCodeToDisplay.set(code.toLowerCase(), display);
});
const normalizeLang = (value) => {
const v = (value || '').trim();
if (!v) return 'und';
const byDisplay = langDisplayToCode.get(v.toLowerCase());
if (byDisplay) return byDisplay;
const bracket = v.match(/\(([A-Za-z]{2,3}(?:-[A-Za-z]{2})?)\)\s*$/);
if (bracket?.[1]) return bracket[1];
if (/^[A-Za-z]{2,3}(?:-[A-Za-z]{2})?$/.test(v)) return v;
return 'und';
};
const displayLang = (code) => {
const c = (code || '').trim().toLowerCase();
if (!c) return '';
return langCodeToDisplay.get(c) || code;
};
const openModal = (mode, data = {}) => {
if (mode === 'add' && limitReached) return;
modal.classList.remove('hidden');
@@ -193,7 +228,8 @@
methodEl.value = mode === 'edit' ? 'PUT' : 'POST';
emojiEl.value = data.emoji || '';
textEl.value = data.keyword || '';
langEl.value = data.lang || '';
langEl.value = (data.lang || 'und').trim() || 'und';
langDisplayEl.value = displayLang(data.lang || 'und');
emojiEl.focus();
};
@@ -209,6 +245,14 @@
if (e.target === modal) closeModal();
});
langDisplayEl?.addEventListener('input', () => {
langEl.value = normalizeLang(langDisplayEl.value);
});
form?.addEventListener('submit', () => {
langEl.value = normalizeLang(langDisplayEl?.value || '');
});
document.querySelectorAll('.edit-btn').forEach((btn) => {
btn.addEventListener('click', () => {
openModal('edit', {

View File

@@ -0,0 +1,17 @@
@php
$listId = $id ?? 'language-options';
// Fallbacks only. Full list is generated in JS via Intl APIs.
$languages = [
['code' => 'und', 'name' => 'Undetermined'],
['code' => 'en', 'name' => 'English'],
['code' => 'id', 'name' => 'Indonesian'],
['code' => 'es', 'name' => 'Spanish'],
['code' => 'fr', 'name' => 'French'],
['code' => 'de', 'name' => 'German'],
];
@endphp
<datalist id="{{ $listId }}" data-language-options="1">
@foreach ($languages as $language)
<option value="{{ $language['name'] }} ({{ $language['code'] }})" data-code="{{ $language['code'] }}"></option>
@endforeach
</datalist>

View File

@@ -258,25 +258,26 @@
<div id="user-keyword-modal" class="hidden fixed inset-0 z-50 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 p-6">
<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-white">Add keyword</h3>
<button id="user-keyword-close" class="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-gray-200">
<h3 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-gray-400">Keyword</label>
<input type="text" name="keyword" id="user-keyword-input" class="mt-2 w-full rounded-xl bg-black/40 border border-white/10 px-4 py-3 text-sm text-gray-200 focus:outline-none focus:border-brand-ocean" placeholder="magic" required>
<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-gray-400">Language</label>
<input type="text" name="lang" id="user-keyword-lang" class="mt-2 w-full rounded-xl bg-black/40 border border-white/10 px-4 py-3 text-sm text-gray-200 focus:outline-none focus:border-brand-ocean" placeholder="en">
<label class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Language</label>
<input type="text" id="user-keyword-lang" list="user-keyword-language-options" 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="Search language (e.g. English)">
@include('partials.language-datalist', ['id' => 'user-keyword-language-options'])
</div>
<div class="flex items-center justify-end gap-2">
<button type="button" id="user-keyword-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 font-semibold px-5 py-2 text-sm">Save keyword</button>
<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 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>
@@ -337,9 +338,33 @@ addRecent(@json($symbol));
const form = document.getElementById('user-keyword-form');
const keywordInput = document.getElementById('user-keyword-input');
const langInput = document.getElementById('user-keyword-lang');
const langDataList = document.getElementById('user-keyword-language-options');
const list = document.getElementById('user-keyword-list');
const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
window.dewemojiPopulateLanguageOptions?.(langDataList);
const langDisplayToCode = new Map();
langDataList?.querySelectorAll('option').forEach((option) => {
const code = (option.dataset.code || '').trim();
const display = (option.value || '').trim();
if (!code || !display) return;
langDisplayToCode.set(display.toLowerCase(), code);
});
const normalizeLang = (value) => {
const v = (value || '').trim();
if (!v) return 'und';
const byDisplay = langDisplayToCode.get(v.toLowerCase());
if (byDisplay) return byDisplay;
const bracket = v.match(/\(([A-Za-z]{2,3}(?:-[A-Za-z]{2})?)\)\s*$/);
if (bracket?.[1]) return bracket[1];
if (/^[A-Za-z]{2,3}(?:-[A-Za-z]{2})?$/.test(v)) return v;
return 'und';
};
const openModal = () => {
modal.classList.remove('hidden');
modal.classList.add('flex');
@@ -365,7 +390,7 @@ addRecent(@json($symbol));
const payload = {
emoji_slug: @json($slug),
keyword: keywordInput.value.trim(),
lang: langInput.value.trim() || 'und',
lang: normalizeLang(langInput.value),
};
if (!payload.keyword) return;
const res = await fetch('{{ route('dashboard.keywords.store') }}', {

View File

@@ -604,6 +604,71 @@
});
})();
</script>
<script>
(() => {
const normalizeCode = (raw) => {
const input = String(raw || '').trim().replace('_', '-');
if (!input) return null;
const parts = input.split('-');
if (parts.length === 1) {
const code = parts[0].toLowerCase();
return /^[a-z]{2,3}$/.test(code) ? code : null;
}
const base = parts[0].toLowerCase();
const region = parts[1].toUpperCase();
const code = `${base}-${region}`;
return /^[a-z]{2,3}-[A-Z]{2}$/.test(code) ? code : null;
};
window.dewemojiPopulateLanguageOptions = (target) => {
const list = typeof target === 'string' ? document.getElementById(target) : target;
if (!list || list.dataset.generated === '1') return;
const codeToName = new Map();
list.querySelectorAll('option[data-code]').forEach((option) => {
const code = normalizeCode(option.dataset.code);
if (!code) return;
const fallbackName = String(option.value || '').replace(/\s+\([^)]+\)\s*$/, '').trim() || code;
codeToName.set(code, fallbackName);
});
if (!codeToName.has('und')) codeToName.set('und', 'Undetermined');
if (typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function') {
const display = typeof Intl.DisplayNames === 'function'
? new Intl.DisplayNames(['en'], { type: 'language' })
: null;
Intl.supportedValuesOf('language').forEach((rawCode) => {
const code = normalizeCode(rawCode);
if (!code) return;
const name = display?.of(code) || display?.of(code.split('-')[0]) || code;
codeToName.set(code, name);
});
}
const entries = Array.from(codeToName.entries()).sort((a, b) => {
if (a[0] === 'und') return -1;
if (b[0] === 'und') return 1;
return a[1].localeCompare(b[1]);
});
list.innerHTML = '';
entries.forEach(([code, name]) => {
const option = document.createElement('option');
option.value = `${name} (${code})`;
option.dataset.code = code;
list.appendChild(option);
});
list.dataset.generated = '1';
};
document.querySelectorAll('datalist[data-language-options="1"]').forEach((list) => {
window.dewemojiPopulateLanguageOptions(list);
});
})();
</script>
@stack('scripts')
</body>
</html>