first from cpanel commit
This commit is contained in:
0
__MACOSX/._dewe-api-2
Normal file
0
__MACOSX/._dewe-api-2
Normal file
0
__MACOSX/dewe-api-2/._.htaccess
Normal file
0
__MACOSX/dewe-api-2/._.htaccess
Normal file
0
__MACOSX/dewe-api-2/._api
Normal file
0
__MACOSX/dewe-api-2/._api
Normal file
0
__MACOSX/dewe-api-2/._api-docs.html
Normal file
0
__MACOSX/dewe-api-2/._api-docs.html
Normal file
0
__MACOSX/dewe-api-2/._assets
Normal file
0
__MACOSX/dewe-api-2/._assets
Normal file
0
__MACOSX/dewe-api-2/._data
Normal file
0
__MACOSX/dewe-api-2/._data
Normal file
0
__MACOSX/dewe-api-2/._ext
Normal file
0
__MACOSX/dewe-api-2/._ext
Normal file
0
__MACOSX/dewe-api-2/._helpers
Normal file
0
__MACOSX/dewe-api-2/._helpers
Normal file
0
__MACOSX/dewe-api-2/._index.html
Normal file
0
__MACOSX/dewe-api-2/._index.html
Normal file
0
__MACOSX/dewe-api-2/._package.json
Normal file
0
__MACOSX/dewe-api-2/._package.json
Normal file
0
__MACOSX/dewe-api-2/._public
Normal file
0
__MACOSX/dewe-api-2/._public
Normal file
0
__MACOSX/dewe-api-2/._tools
Normal file
0
__MACOSX/dewe-api-2/._tools
Normal file
0
__MACOSX/dewe-api-2/api/._emoji.php
Normal file
0
__MACOSX/dewe-api-2/api/._emoji.php
Normal file
0
__MACOSX/dewe-api-2/api/._emojis.php
Normal file
0
__MACOSX/dewe-api-2/api/._emojis.php
Normal file
0
__MACOSX/dewe-api-2/api/._index.php
Normal file
0
__MACOSX/dewe-api-2/api/._index.php
Normal file
0
__MACOSX/dewe-api-2/assets/._script.js
Normal file
0
__MACOSX/dewe-api-2/assets/._script.js
Normal file
0
__MACOSX/dewe-api-2/data/._array.json
Normal file
0
__MACOSX/dewe-api-2/data/._array.json
Normal file
0
__MACOSX/dewe-api-2/data/._categories.json
Normal file
0
__MACOSX/dewe-api-2/data/._categories.json
Normal file
0
__MACOSX/dewe-api-2/data/._emojis.json
Normal file
0
__MACOSX/dewe-api-2/data/._emojis.json
Normal file
0
__MACOSX/dewe-api-2/data/._keywords-id.json
Normal file
0
__MACOSX/dewe-api-2/data/._keywords-id.json
Normal file
0
__MACOSX/dewe-api-2/data/._list.json
Normal file
0
__MACOSX/dewe-api-2/data/._list.json
Normal file
0
__MACOSX/dewe-api-2/data/._overrides.json
Normal file
0
__MACOSX/dewe-api-2/data/._overrides.json
Normal file
0
__MACOSX/dewe-api-2/ext/._updates.xml
Normal file
0
__MACOSX/dewe-api-2/ext/._updates.xml
Normal file
0
__MACOSX/dewe-api-2/helpers/._auth.php
Normal file
0
__MACOSX/dewe-api-2/helpers/._auth.php
Normal file
0
__MACOSX/dewe-api-2/helpers/._filters.php
Normal file
0
__MACOSX/dewe-api-2/helpers/._filters.php
Normal file
0
__MACOSX/dewe-api-2/public/._js
Normal file
0
__MACOSX/dewe-api-2/public/._js
Normal file
0
__MACOSX/dewe-api-2/public/js/._api-client.js
Normal file
0
__MACOSX/dewe-api-2/public/js/._api-client.js
Normal file
0
__MACOSX/dewe-api-2/tools/._build-emojis.js
Normal file
0
__MACOSX/dewe-api-2/tools/._build-emojis.js
Normal file
0
api-docs.html
Normal file
0
api-docs.html
Normal file
82
api/emoji.php
Normal file
82
api/emoji.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
// api/emoji.php (route: /api/emoji?slug=... or rewrite to /api/emoji/{slug})
|
||||||
|
require_once __DIR__.'/../helpers/auth.php';
|
||||||
|
require_once __DIR__.'/../helpers/filters.php';
|
||||||
|
|
||||||
|
// ---- Skin tone helpers (Fitzpatrick modifiers) ----
|
||||||
|
if (!function_exists('dw_supports_skin_tone')) {
|
||||||
|
function dw_supports_skin_tone(array $emoji): bool {
|
||||||
|
// Respect explicit flag if present in source data
|
||||||
|
if (isset($emoji['supports_skin_tone'])) {
|
||||||
|
return (bool)$emoji['supports_skin_tone'];
|
||||||
|
}
|
||||||
|
$cat = strtolower(trim($emoji['category'] ?? ''));
|
||||||
|
$sub = strtolower(trim($emoji['subcategory'] ?? ''));
|
||||||
|
$name = strtolower(trim($emoji['name'] ?? ''));
|
||||||
|
// 1) category must be People & Body
|
||||||
|
if ($cat !== 'people & body') return false;
|
||||||
|
// 2) Exclude faces: many face emojis are not tone-modifiable
|
||||||
|
if (preg_match('~\bface\b~', $name) || preg_match('~\bface\b~', $sub)) return false;
|
||||||
|
// 3) Allowlist of tone-capable subcategories (based on Unicode charts)
|
||||||
|
$allowSubs = [
|
||||||
|
'hand-fingers-open','hand-fingers-partial','hand-fingers-closed','hand-single-finger',
|
||||||
|
'hands','hand-prop','handshake','body-parts','person','person-gesture','person-activity','person-resting'
|
||||||
|
];
|
||||||
|
if (in_array($sub, $allowSubs, true) || str_starts_with($sub, 'hand')) return true;
|
||||||
|
// Fallback by keywords mentioning body parts / gestures
|
||||||
|
$keys = [];
|
||||||
|
foreach (['keywords','keywords_en','keywords_id'] as $k) {
|
||||||
|
if (!empty($emoji[$k]) && is_array($emoji[$k])) $keys = array_merge($keys, $emoji[$k]);
|
||||||
|
}
|
||||||
|
$hay = strtolower(' '.implode(' ', $keys).' ');
|
||||||
|
return (bool)preg_match('~\b(hand|hands|wave|clap|thumb|finger|palm|point|ear|nose|leg|foot|person|man|woman|boy|girl|salute)\b~', $hay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!function_exists('dw_skin_tone_modifiers')) {
|
||||||
|
function dw_skin_tone_modifiers(): array {
|
||||||
|
return ["\u{1F3FB}", "\u{1F3FC}", "\u{1F3FD}", "\u{1F3FE}", "\u{1F3FF}"]; // light .. dark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!function_exists('dw_strip_tone')) {
|
||||||
|
function dw_strip_tone(string $emoji): string {
|
||||||
|
return preg_replace('/\x{1F3FB}|\x{1F3FC}|\x{1F3FD}|\x{1F3FE}|\x{1F3FF}/u', '', $emoji);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!function_exists('dw_build_tone_variants')) {
|
||||||
|
function dw_build_tone_variants(string $emojiBase): array {
|
||||||
|
$mods = ["\u{1F3FB}", "\u{1F3FC}", "\u{1F3FD}", "\u{1F3FE}", "\u{1F3FF}"];
|
||||||
|
return array_map(fn($m) => $emojiBase.$m, $mods);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
$tier = detectTier();
|
||||||
|
$slug = trim($_GET['slug'] ?? '');
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents(__DIR__.'/../data/emojis.json'), true);
|
||||||
|
$found = null;
|
||||||
|
foreach ($data['emojis'] as $e) {
|
||||||
|
if (($e['slug'] ?? '') === $slug) { $found = $e; break; }
|
||||||
|
}
|
||||||
|
if (!$found) { http_response_code(404); echo json_encode(['error'=>'not_found']); exit; }
|
||||||
|
|
||||||
|
$out = filterEmoji($found, $tier, false);
|
||||||
|
// Add tone support & modifiers for single emoji response
|
||||||
|
$out['supports_skin_tone'] = dw_supports_skin_tone($found) || dw_supports_skin_tone($out);
|
||||||
|
if (!isset($out['skin_tone_modifiers'])) {
|
||||||
|
$out['skin_tone_modifiers'] = dw_skin_tone_modifiers();
|
||||||
|
}
|
||||||
|
if (!empty($out['supports_skin_tone']) && !empty($out['emoji'])) {
|
||||||
|
$base = dw_strip_tone($out['emoji']);
|
||||||
|
if ($base !== '') {
|
||||||
|
$out['emoji_base'] = $base;
|
||||||
|
$out['variants'] = dw_build_tone_variants($base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$etag = '"'.sha1(json_encode([$slug,$tier,$out])).'"';
|
||||||
|
header('ETag: '.$etag);
|
||||||
|
header('Cache-Control: public, max-age=300');
|
||||||
|
if (($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') === $etag) { http_response_code(304); exit; }
|
||||||
|
|
||||||
|
echo json_encode($out, JSON_UNESCAPED_UNICODE);
|
||||||
231
api/emojis.php
Normal file
231
api/emojis.php
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
// api/emojis.php (route: /api/emojis?q=...&category=...&subcategory=...&page=1&limit=50)
|
||||||
|
require_once __DIR__.'/../helpers/auth.php';
|
||||||
|
require_once __DIR__.'/../helpers/filters.php';
|
||||||
|
|
||||||
|
// ---- Skin tone helpers (Fitzpatrick modifiers) ----
|
||||||
|
if (!function_exists('dw_supports_skin_tone')) {
|
||||||
|
function dw_supports_skin_tone(array $emoji): bool {
|
||||||
|
// Respect explicit flag if present in source data
|
||||||
|
if (isset($emoji['supports_skin_tone'])) {
|
||||||
|
return (bool)$emoji['supports_skin_tone'];
|
||||||
|
}
|
||||||
|
$cat = strtolower(trim($emoji['category'] ?? ''));
|
||||||
|
$sub = strtolower(trim($emoji['subcategory'] ?? ''));
|
||||||
|
$name = strtolower(trim($emoji['name'] ?? ''));
|
||||||
|
// 1) category must be People & Body
|
||||||
|
if ($cat !== 'people & body') return false;
|
||||||
|
// 2) Exclude faces: many face emojis are not tone-modifiable
|
||||||
|
if (preg_match('~\bface\b~', $name) || preg_match('~\bface\b~', $sub)) return false;
|
||||||
|
// 3) Allowlist of tone-capable subcategories (based on Unicode charts)
|
||||||
|
$allowSubs = [
|
||||||
|
'hand-fingers-open','hand-fingers-partial','hand-fingers-closed','hand-single-finger',
|
||||||
|
'hands','hand-prop','handshake','body-parts','person','person-gesture','person-activity','person-resting'
|
||||||
|
];
|
||||||
|
if (in_array($sub, $allowSubs, true) || (function_exists('str_starts_with') ? str_starts_with($sub, 'hand') : strpos($sub, 'hand') === 0)) return true;
|
||||||
|
// Fallback by keywords mentioning body parts / gestures
|
||||||
|
$keys = [];
|
||||||
|
foreach (['keywords','keywords_en','keywords_id'] as $k) {
|
||||||
|
if (!empty($emoji[$k]) && is_array($emoji[$k])) $keys = array_merge($keys, $emoji[$k]);
|
||||||
|
}
|
||||||
|
$hay = strtolower(' '.implode(' ', $keys).' ');
|
||||||
|
return (bool)preg_match('~\b(hand|hands|wave|clap|thumb|finger|palm|point|ear|nose|leg|foot|person|man|woman|boy|girl|salute)\b~', $hay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!function_exists('dw_skin_tone_modifiers')) {
|
||||||
|
function dw_skin_tone_modifiers(): array {
|
||||||
|
return ["\u{1F3FB}", "\u{1F3FC}", "\u{1F3FD}", "\u{1F3FE}", "\u{1F3FF}"]; // light .. dark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!function_exists('dw_strip_tone')) {
|
||||||
|
function dw_strip_tone(string $emoji): string {
|
||||||
|
return preg_replace('/\x{1F3FB}|\x{1F3FC}|\x{1F3FD}|\x{1F3FE}|\x{1F3FF}/u', '', $emoji);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!function_exists('dw_build_tone_variants')) {
|
||||||
|
function dw_build_tone_variants(string $emojiBase): array {
|
||||||
|
$mods = ["\u{1F3FB}", "\u{1F3FC}", "\u{1F3FD}", "\u{1F3FE}", "\u{1F3FF}"];
|
||||||
|
return array_map(fn($m) => $emojiBase.$m, $mods);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
$tier = detectTier();
|
||||||
|
// Allow client hint via header (extension may send X-Dewemoji-Plan: pro|free)
|
||||||
|
$hint = strtolower(trim($_SERVER['HTTP_X_DEWEMOJI_PLAN'] ?? ''));
|
||||||
|
// Optional license verify (server-side spot check)
|
||||||
|
$acct = $_SERVER['HTTP_X_ACCOUNT_ID'] ?? '';
|
||||||
|
$key = $_SERVER['HTTP_X_LICENSE_KEY'] ?? '';
|
||||||
|
if ($acct && $key) {
|
||||||
|
// probabilistic verify, e.g., 1 in 50 requests
|
||||||
|
if (mt_rand(1,50) === 1) {
|
||||||
|
$verify = @file_get_contents('https://emojilicense.dewe.pw/v1/license/verify', false, stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'header' => "Content-Type: application/json\r\n",
|
||||||
|
'content' => json_encode(['key'=>$key,'account_id'=>$acct]),
|
||||||
|
'timeout' => 3,
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
if ($verify) {
|
||||||
|
$v = json_decode($verify,true);
|
||||||
|
if (!empty($v['ok']) && !empty($v['plan'])) {
|
||||||
|
$tier = strtolower($v['plan']);
|
||||||
|
$isPro = ($tier==='pro');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($hint === 'pro' || $hint === 'free') {
|
||||||
|
$tier = $hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isPro = ($tier === 'pro');
|
||||||
|
|
||||||
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||||
|
|
||||||
|
$whitelist = ['emoji.dewe.pw'];
|
||||||
|
|
||||||
|
$isWhitelisted = in_array($host, $whitelist, true)
|
||||||
|
|| in_array(parse_url($origin, PHP_URL_HOST) ?: '', $whitelist, true);
|
||||||
|
|
||||||
|
// --- Free vs Pro gating ---
|
||||||
|
$maxLimit = $isPro ? 50 : 20;
|
||||||
|
$maxPages = $isPro ? 20 : 5;
|
||||||
|
|
||||||
|
if ($isWhitelisted) {
|
||||||
|
$maxLimit = 100;
|
||||||
|
$maxPages = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers for slug <-> label normalization ---
|
||||||
|
$CATEGORY_MAP = [
|
||||||
|
'all' => 'all',
|
||||||
|
'smileys' => 'Smileys & Emotion',
|
||||||
|
'people' => 'People & Body',
|
||||||
|
'animals' => 'Animals & Nature',
|
||||||
|
'food' => 'Food & Drink',
|
||||||
|
'travel' => 'Travel & Places',
|
||||||
|
'activities'=> 'Activities',
|
||||||
|
'objects' => 'Objects',
|
||||||
|
'symbols' => 'Symbols',
|
||||||
|
'flags' => 'Flags',
|
||||||
|
];
|
||||||
|
function dw_slugify($s){
|
||||||
|
$s = strtolower(trim((string)$s));
|
||||||
|
$s = str_replace('&', 'and', $s);
|
||||||
|
$s = preg_replace('/[^a-z0-9]+/','-',$s);
|
||||||
|
return trim($s,'-');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse incoming filters
|
||||||
|
$qParam = trim((string)($_GET['q'] ?? ''));
|
||||||
|
$catParam = trim((string)($_GET['category'] ?? ''));
|
||||||
|
$subParam = trim((string)($_GET['subcategory'] ?? ''));
|
||||||
|
|
||||||
|
// Normalize category: accept either slug ("people") or label ("People & Body")
|
||||||
|
$catLabel = '';
|
||||||
|
if ($catParam !== '' && strtolower($catParam) !== 'all') {
|
||||||
|
$catLabel = $CATEGORY_MAP[strtolower($catParam)] ?? $catParam; // fallback to raw label
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize subcategory to a slug for comparison
|
||||||
|
$subSlug = $subParam !== '' ? dw_slugify($subParam) : '';
|
||||||
|
|
||||||
|
// Expose normalized vars for filter closure
|
||||||
|
$q = $qParam;
|
||||||
|
$cat = $catLabel; // label form (or '')
|
||||||
|
$sub = $subSlug; // slug form (or '')
|
||||||
|
|
||||||
|
// Parse query params with caps
|
||||||
|
$limit = min(max((int)($_GET['limit'] ?? $maxLimit), 1), $maxLimit);
|
||||||
|
$page = max((int)($_GET['page'] ?? 1), 1);
|
||||||
|
if ($page > $maxPages) {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode([
|
||||||
|
'items' => [],
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
'total' => 0,
|
||||||
|
'plan' => $isPro ? 'pro' : 'free',
|
||||||
|
'message' => 'Page limit reached for your plan.'
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
$data = json_decode(file_get_contents(__DIR__.'/../data/emojis.json'), true);
|
||||||
|
$items = $data['emojis'] ?? [];
|
||||||
|
|
||||||
|
// Server-side filtering (case-insensitive)
|
||||||
|
$items = array_values(array_filter($items, function($e) use ($q,$cat,$sub) {
|
||||||
|
// Category match: compare by slug to be robust
|
||||||
|
if ($cat !== '') {
|
||||||
|
$itemCatSlug = dw_slugify($e['category'] ?? '');
|
||||||
|
$wantCatSlug = dw_slugify($cat);
|
||||||
|
if ($itemCatSlug !== $wantCatSlug) return false;
|
||||||
|
}
|
||||||
|
// Subcategory match: normalize item subcategory to slug and compare
|
||||||
|
if ($sub !== '') {
|
||||||
|
$itemSubSlug = dw_slugify($e['subcategory'] ?? '');
|
||||||
|
if ($itemSubSlug !== $sub) return false;
|
||||||
|
}
|
||||||
|
// Text search across common fields
|
||||||
|
if ($q !== '') {
|
||||||
|
$hay = strtolower(join(' ', [
|
||||||
|
$e['emoji'] ?? '',
|
||||||
|
$e['name'] ?? '',
|
||||||
|
$e['slug'] ?? '',
|
||||||
|
$e['category'] ?? '',
|
||||||
|
$e['subcategory'] ?? '',
|
||||||
|
join(' ', $e['keywords_en'] ?? []),
|
||||||
|
join(' ', $e['keywords_id'] ?? []),
|
||||||
|
join(' ', $e['aliases'] ?? []),
|
||||||
|
join(' ', $e['shortcodes'] ?? []),
|
||||||
|
]));
|
||||||
|
foreach (preg_split('/\s+/', strtolower($q)) as $tok) {
|
||||||
|
if ($tok === '') continue;
|
||||||
|
if (strpos($hay, $tok) === false) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
|
||||||
|
$total = count($items);
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
$items = array_slice($items, $offset, $limit);
|
||||||
|
|
||||||
|
// Field filtering + augmentation
|
||||||
|
$items = array_map(function($raw) use ($tier, $isPro) {
|
||||||
|
$e = filterEmoji($raw, $tier, true);
|
||||||
|
// add tone support & modifiers for clients
|
||||||
|
$e['supports_skin_tone'] = dw_supports_skin_tone($raw) || dw_supports_skin_tone($e);
|
||||||
|
if (!isset($e['skin_tone_modifiers'])) {
|
||||||
|
$e['skin_tone_modifiers'] = dw_skin_tone_modifiers();
|
||||||
|
}
|
||||||
|
if ($e['supports_skin_tone'] && !empty($e['emoji'])) {
|
||||||
|
$base = dw_strip_tone($e['emoji']);
|
||||||
|
if ($base !== '') {
|
||||||
|
$e['emoji_base'] = $base;
|
||||||
|
if ($isPro) {
|
||||||
|
$e['variants'] = dw_build_tone_variants($base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $e;
|
||||||
|
}, $items);
|
||||||
|
|
||||||
|
// ETag for caching
|
||||||
|
$etag = '"'.sha1(json_encode([$page,$limit,$q,$cat,$sub,$tier,$total,$items])).'"';
|
||||||
|
header('ETag: '.$etag);
|
||||||
|
header('Cache-Control: public, max-age=300');
|
||||||
|
if (($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') === $etag) { http_response_code(304); exit; }
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'items' => $items,
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
'total' => $total,
|
||||||
|
'plan' => $isPro ? 'pro' : 'free',
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
0
api/error_log
Normal file
0
api/error_log
Normal file
0
api/index.php
Normal file
0
api/index.php
Normal file
848
assets/script.js
Normal file
848
assets/script.js
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// --- DOM Elements ---
|
||||||
|
const emojiGrid = document.getElementById('emoji-grid');
|
||||||
|
// Support multiple search inputs (mobile + desktop); class-based selector (no dot in getElementsByClassName)
|
||||||
|
const searchInputs = Array.from(document.querySelectorAll('.search-input'));
|
||||||
|
// Dark mode toggles & icons (support multiple instances)
|
||||||
|
const themeToggles = Array.from(document.querySelectorAll('.theme-toggle'));
|
||||||
|
const lightIcons = Array.from(document.querySelectorAll('.theme-icon-light'));
|
||||||
|
const darkIcons = Array.from(document.querySelectorAll('.theme-icon-dark'));
|
||||||
|
|
||||||
|
// Check if essential elements exist
|
||||||
|
if (!emojiGrid || searchInputs.length === 0) {
|
||||||
|
console.error('Critical DOM elements missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const modal = document.getElementById('emoji-modal');
|
||||||
|
const modalContent = document.getElementById('modal-content');
|
||||||
|
const modalCloseBtn = document.getElementById('modal-close-btn');
|
||||||
|
const modalEmoji = document.getElementById('modal-emoji');
|
||||||
|
const modalName = document.getElementById('modal-name');
|
||||||
|
const modalCategory = document.getElementById('modal-category');
|
||||||
|
const modalKeywords = document.getElementById('modal-keywords');
|
||||||
|
const modalCopyBtn = document.getElementById('modal-copy-btn');
|
||||||
|
const loadMoreBtn = document.getElementById('load-more-btn');
|
||||||
|
const categoryButtons = document.querySelectorAll('.category-btn');
|
||||||
|
const currentCategoryTitle = document.getElementById('current-category-title');
|
||||||
|
const currentCategoryCount = document.getElementById('current-category-count');
|
||||||
|
const offcanvasToggle = document.getElementById('offcanvas-toggle');
|
||||||
|
const offcanvasOverlay = document.getElementById('offcanvas-overlay');
|
||||||
|
const offcanvasClose = document.getElementById('offcanvas-close');
|
||||||
|
const offcanvasBackdrop = document.getElementById('offcanvas-backdrop');
|
||||||
|
const offcanvasSidebar = document.getElementById('offcanvas-sidebar');
|
||||||
|
const offcanvasNav = document.getElementById('offcanvas-nav');
|
||||||
|
|
||||||
|
// --- Loading state (spinner over grid) ---
|
||||||
|
const gridWrap = emojiGrid.parentElement;
|
||||||
|
let spinnerEl = null;
|
||||||
|
function showSpinner() {
|
||||||
|
if (spinnerEl) return;
|
||||||
|
// Hide Load More while loading to avoid redundancy
|
||||||
|
if (loadMoreBtn) loadMoreBtn.classList.add('hidden');
|
||||||
|
spinnerEl = document.createElement('div');
|
||||||
|
spinnerEl.id = 'grid-spinner';
|
||||||
|
spinnerEl.className = 'w-full flex justify-center py-6';
|
||||||
|
spinnerEl.innerHTML = `
|
||||||
|
<div class="animate-spin inline-block w-8 h-8 border-4 border-current border-t-transparent text-blue-600 rounded-full" role="status" aria-label="loading"></div>
|
||||||
|
`;
|
||||||
|
gridWrap.appendChild(spinnerEl);
|
||||||
|
}
|
||||||
|
function hideSpinner() {
|
||||||
|
if (spinnerEl) { spinnerEl.remove(); spinnerEl = null; }
|
||||||
|
// Recompute Load More visibility after loading completes
|
||||||
|
if (typeof updateLoadMoreButton === 'function') {
|
||||||
|
try { updateLoadMoreButton(); } catch(_) {}
|
||||||
|
} else if (loadMoreBtn) {
|
||||||
|
// Fallback: show button; displayPage() will hide it if needed
|
||||||
|
loadMoreBtn.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Skin tone support (Fitzpatrick modifiers) ---
|
||||||
|
const SKIN_TONES = [
|
||||||
|
{ key: '1f3fb', ch: '\u{1F3FB}', label: 'Light' },
|
||||||
|
{ key: '1f3fc', ch: '\u{1F3FC}', label: 'Medium-Light' },
|
||||||
|
{ key: '1f3fd', ch: '\u{1F3FD}', label: 'Medium' },
|
||||||
|
{ key: '1f3fe', ch: '\u{1F3FE}', label: 'Medium-Dark' },
|
||||||
|
{ key: '1f3ff', ch: '\u{1F3FF}', label: 'Dark' },
|
||||||
|
];
|
||||||
|
// Preferred tone helpers
|
||||||
|
const TONE_SLUGS = ['light','medium-light','medium','medium-dark','dark'];
|
||||||
|
function getPreferredToneIndex(){
|
||||||
|
const slug = localStorage.getItem('preferredSkinTone');
|
||||||
|
const i = TONE_SLUGS.indexOf(slug);
|
||||||
|
return i >= 0 ? i : -1;
|
||||||
|
}
|
||||||
|
function setPreferredToneIndex(i){
|
||||||
|
if (i>=0 && i<SKIN_TONES.length) localStorage.setItem('preferredSkinTone', TONE_SLUGS[i]);
|
||||||
|
}
|
||||||
|
// Append a skin tone modifier to an emoji (browsers ignore invalid combos gracefully)
|
||||||
|
function withSkinTone(emojiChar, modifierChar) {
|
||||||
|
if (!emojiChar) return emojiChar;
|
||||||
|
return `${emojiChar}${modifierChar}`;
|
||||||
|
}
|
||||||
|
// Helper: strip Fitzpatrick skin tone modifiers from emoji string
|
||||||
|
function stripSkinTone(emojiChar) {
|
||||||
|
if (!emojiChar) return emojiChar;
|
||||||
|
// Remove Fitzpatrick modifiers (\u{1F3FB}-\u{1F3FF})
|
||||||
|
return emojiChar.replace(/[\u{1F3FB}-\u{1F3FF}]/gu, '');
|
||||||
|
}
|
||||||
|
function buildSkinTonePicker(onPick) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'absolute z-50 mt-2 p-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg flex gap-1';
|
||||||
|
SKIN_TONES.forEach(t => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'w-6 h-6 rounded-md flex items-center justify-center hover:ring-2 hover:ring-blue-500';
|
||||||
|
b.textContent = t.ch;
|
||||||
|
b.title = t.label;
|
||||||
|
b.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPick(t);
|
||||||
|
});
|
||||||
|
wrap.appendChild(b);
|
||||||
|
});
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Pretty URL helpers (category & subcategory slugs)
|
||||||
|
const CATEGORY_TO_SLUG = {
|
||||||
|
'all': 'all',
|
||||||
|
'Smileys & Emotion': 'smileys',
|
||||||
|
'People & Body': 'people',
|
||||||
|
'Animals & Nature': 'animals',
|
||||||
|
'Food & Drink': 'food',
|
||||||
|
'Travel & Places': 'travel',
|
||||||
|
'Activities': 'activities',
|
||||||
|
'Objects': 'objects',
|
||||||
|
'Symbols': 'symbols',
|
||||||
|
'Flags': 'flags'
|
||||||
|
};
|
||||||
|
const SLUG_TO_CATEGORY = Object.fromEntries(Object.entries(CATEGORY_TO_SLUG).map(([k,v]) => [v,k]));
|
||||||
|
|
||||||
|
// Subcategory is best-effort slug (lowercase, hyphen). Most of your subs already match this.
|
||||||
|
const subcatToSlug = (s='') => s.toLowerCase()
|
||||||
|
.replace(/&/g, 'and')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
// For API use, keep hyphenated slug as canonical
|
||||||
|
const slugToSubcat = (s='') => s; // keep hyphenated for API
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let allEmojis = [];
|
||||||
|
let currentEmojiList = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
const EMOJIS_PER_PAGE = 50;
|
||||||
|
let indonesianKeywords = {};
|
||||||
|
let currentCategory = 'all';
|
||||||
|
let categorizedEmojis = {};
|
||||||
|
|
||||||
|
// --- Search helpers for multi-input support (mobile + desktop) ---
|
||||||
|
function getSearchValue() {
|
||||||
|
const el = searchInputs.find(n => n && typeof n.value === 'string');
|
||||||
|
return (el?.value || '').toLowerCase().trim();
|
||||||
|
}
|
||||||
|
function setSearchValue(val) {
|
||||||
|
searchInputs.forEach(n => { if (n && typeof n.value === 'string') n.value = val; });
|
||||||
|
}
|
||||||
|
function focusFirstSearch() {
|
||||||
|
if (searchInputs[0]) searchInputs[0].focus();
|
||||||
|
updateURLFromFilters();
|
||||||
|
}
|
||||||
|
function wireSearchListeners() {
|
||||||
|
searchInputs.forEach(el => {
|
||||||
|
el.addEventListener('input', (e) => {
|
||||||
|
const v = (e.target.value || '').toLowerCase().trim();
|
||||||
|
// keep other inputs in sync visually
|
||||||
|
searchInputs.forEach(other => { if (other !== e.target) other.value = e.target.value; });
|
||||||
|
currentFilters.q = v;
|
||||||
|
updateURLFromFilters();
|
||||||
|
resetAndLoadFirstPage();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dark Mode Logic ---
|
||||||
|
const applyTheme = (isDark) => {
|
||||||
|
document.documentElement.classList.toggle('dark', isDark);
|
||||||
|
lightIcons.forEach(el => el.classList.toggle('hidden', !isDark));
|
||||||
|
darkIcons.forEach(el => el.classList.toggle('hidden', isDark));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDarkMode = () => {
|
||||||
|
const isDark = !document.documentElement.classList.contains('dark');
|
||||||
|
localStorage.setItem('darkMode', isDark);
|
||||||
|
applyTheme(isDark);
|
||||||
|
};
|
||||||
|
|
||||||
|
themeToggles.forEach(btn => btn.addEventListener('click', toggleDarkMode));
|
||||||
|
|
||||||
|
// Set initial icon state on load
|
||||||
|
const initialIsDark = document.documentElement.classList.contains('dark');
|
||||||
|
applyTheme(initialIsDark);
|
||||||
|
wireSearchListeners();
|
||||||
|
|
||||||
|
// --- Data Fetching & Processing ---
|
||||||
|
// Switch from loading local JSON files to calling the server API.
|
||||||
|
// We keep UI logic intact (pagination buttons etc.), only the data source changes.
|
||||||
|
|
||||||
|
// Server pagination total for current filter
|
||||||
|
let totalAvailable = 0;
|
||||||
|
|
||||||
|
// Current filters used when requesting data from API
|
||||||
|
const currentFilters = {
|
||||||
|
q: '',
|
||||||
|
category: 'all',
|
||||||
|
subcategory: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: build query string and fetch a page from the API
|
||||||
|
async function fetchEmojisFromAPI({ q = '', category = 'all', subcategory = '', page = 1, limit = EMOJIS_PER_PAGE } = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (q) params.set('q', q);
|
||||||
|
|
||||||
|
// normalize category to slug
|
||||||
|
const catSlug = CATEGORY_TO_SLUG[category] || category;
|
||||||
|
if (catSlug && catSlug !== 'all') params.set('category', catSlug);
|
||||||
|
|
||||||
|
if (subcategory) params.set('subcategory', subcategory);
|
||||||
|
params.set('page', String(page));
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
|
||||||
|
console.debug('[API] /api/emojis?' + params.toString());
|
||||||
|
const res = await fetch('/api/emojis?' + params.toString(), { cache: 'no-store' });
|
||||||
|
if (!res.ok) throw new Error('Failed to load emojis from API');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: load first page for current filters (reset list)
|
||||||
|
async function resetAndLoadFirstPage() {
|
||||||
|
try {
|
||||||
|
// Clear grid immediately to reflect new scope
|
||||||
|
emojiGrid.innerHTML = '';
|
||||||
|
showSpinner();
|
||||||
|
const { total, items, plan } = await fetchEmojisFromAPI({
|
||||||
|
q: currentFilters.q,
|
||||||
|
category: currentFilters.category,
|
||||||
|
subcategory: currentFilters.subcategory,
|
||||||
|
page: 1,
|
||||||
|
limit: EMOJIS_PER_PAGE
|
||||||
|
});
|
||||||
|
console.debug('[API] first page loaded:', { total, count: items?.length, plan });
|
||||||
|
|
||||||
|
let usedScope = { ...currentFilters };
|
||||||
|
let data = { total, items };
|
||||||
|
|
||||||
|
if (data.total === 0 && (usedScope.category !== 'all' || usedScope.subcategory)) {
|
||||||
|
const retry = await fetchEmojisFromAPI({
|
||||||
|
q: usedScope.q,
|
||||||
|
category: 'all',
|
||||||
|
subcategory: '',
|
||||||
|
page: 1,
|
||||||
|
limit: EMOJIS_PER_PAGE
|
||||||
|
});
|
||||||
|
if (retry.total > 0) {
|
||||||
|
showHint(`No matches in "${usedScope.category}${usedScope.subcategory ? ' › ' + usedScope.subcategory : ''}". Showing results across All categories.`);
|
||||||
|
data = retry;
|
||||||
|
usedScope.category = 'all';
|
||||||
|
usedScope.subcategory = '';
|
||||||
|
} else {
|
||||||
|
clearHint();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clearHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
totalAvailable = data.total || 0;
|
||||||
|
allEmojis = Array.isArray(data.items) ? data.items.slice() : [];
|
||||||
|
currentEmojiList = allEmojis.slice();
|
||||||
|
currentPage = 1;
|
||||||
|
|
||||||
|
updateCategoryDisplay();
|
||||||
|
renderActiveFilters();
|
||||||
|
displayPage(1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading first page from API:', error);
|
||||||
|
emojiGrid.innerHTML = '<p class="text-red-500 col-span-full text-center">Failed to load emoji data.</p>';
|
||||||
|
} finally {
|
||||||
|
hideSpinner();
|
||||||
|
}
|
||||||
|
reflectScopeInPlaceholders();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: append next page (used by Load More)
|
||||||
|
async function appendNextPage() {
|
||||||
|
const nextPage = currentPage + 1;
|
||||||
|
try {
|
||||||
|
showSpinner();
|
||||||
|
const { items } = await fetchEmojisFromAPI({
|
||||||
|
q: currentFilters.q,
|
||||||
|
category: currentFilters.category,
|
||||||
|
subcategory: currentFilters.subcategory,
|
||||||
|
page: nextPage,
|
||||||
|
limit: EMOJIS_PER_PAGE
|
||||||
|
});
|
||||||
|
// Accumulate and mirror into currentEmojiList for UI rendering
|
||||||
|
allEmojis = allEmojis.concat(items);
|
||||||
|
currentEmojiList = allEmojis.slice();
|
||||||
|
displayPage(nextPage);
|
||||||
|
console.debug('[API] appended page', nextPage, 'count:', items?.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading next page from API:', error);
|
||||||
|
} finally {
|
||||||
|
hideSpinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureHint() {
|
||||||
|
let el = document.getElementById('search-hint');
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.id = 'search-hint';
|
||||||
|
el.className = 'mb-3 text-sm px-3 py-2 rounded-md bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100';
|
||||||
|
emojiGrid.parentElement.insertBefore(el, emojiGrid);
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
function showHint(msg) { const el = ensureHint(); el.textContent = msg; el.classList.remove('hidden'); }
|
||||||
|
function clearHint() { const el = document.getElementById('search-hint'); if (el) el.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a pretty path for a given category/subcategory
|
||||||
|
function buildPrettyPathFor(category = 'all', subcategory = '') {
|
||||||
|
const catSlug = CATEGORY_TO_SLUG[category] || 'all';
|
||||||
|
const segs = [];
|
||||||
|
if (catSlug !== 'all') segs.push(catSlug);
|
||||||
|
if (subcategory) segs.push(subcatToSlug(subcategory));
|
||||||
|
return '/' + segs.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UI Rendering ---
|
||||||
|
function updateLoadMoreButton() {
|
||||||
|
if (currentEmojiList.length < totalAvailable) {
|
||||||
|
loadMoreBtn.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
loadMoreBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayPage(page) {
|
||||||
|
currentPage = page;
|
||||||
|
const start = (page - 1) * EMOJIS_PER_PAGE;
|
||||||
|
const end = start + EMOJIS_PER_PAGE;
|
||||||
|
const emojisToDisplay = currentEmojiList.slice(start, end);
|
||||||
|
|
||||||
|
if (page === 1) {
|
||||||
|
emojiGrid.innerHTML = ''; // Clear the grid only for the first page
|
||||||
|
}
|
||||||
|
|
||||||
|
emojisToDisplay.forEach(emoji => {
|
||||||
|
const emojiCard = document.createElement('div');
|
||||||
|
emojiCard.className = 'relative flex flex-col items-center justify-center p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm cursor-pointer transition-transform duration-200 hover:scale-105 hover:shadow-md';
|
||||||
|
emojiCard.innerHTML = `
|
||||||
|
<div class="text-4xl">${emoji.emoji}</div>
|
||||||
|
<div class="mt-2 text-xs text-center text-gray-600 dark:text-gray-300 font-semibold w-full truncate">${emoji.name}</div>
|
||||||
|
`;
|
||||||
|
// Open modal on card click
|
||||||
|
emojiCard.addEventListener('click', () => openModal(emoji));
|
||||||
|
// If this emoji supports skin tone, add a small trigger to pick a tone
|
||||||
|
if (emoji.supports_skin_tone) {
|
||||||
|
const trigger = document.createElement('button');
|
||||||
|
trigger.type = 'button';
|
||||||
|
trigger.className = 'absolute top-1 right-1 rounded-md px-1 text-xs bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600';
|
||||||
|
trigger.setAttribute('aria-label', 'Choose skin tone');
|
||||||
|
trigger.textContent = '⋯';
|
||||||
|
emojiCard.appendChild(trigger);
|
||||||
|
let pickerEl = null;
|
||||||
|
function closePicker() {
|
||||||
|
if (pickerEl) {
|
||||||
|
pickerEl.remove();
|
||||||
|
pickerEl = null;
|
||||||
|
document.removeEventListener('click', outsideClose, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function outsideClose(e) {
|
||||||
|
if (pickerEl && !pickerEl.contains(e.target) && e.target !== trigger) closePicker();
|
||||||
|
}
|
||||||
|
const openPicker = () => {
|
||||||
|
if (pickerEl) return;
|
||||||
|
pickerEl = buildSkinTonePicker((tone) => {
|
||||||
|
// Always use base for variant
|
||||||
|
const base = emoji.emoji_base || stripSkinTone(emoji.emoji);
|
||||||
|
const variant = withSkinTone(base, tone.ch);
|
||||||
|
// persist preferred tone
|
||||||
|
const idx = SKIN_TONES.findIndex(x => x.key === tone.key);
|
||||||
|
if (idx >= 0) setPreferredToneIndex(idx);
|
||||||
|
openModal({ ...emoji, emoji: variant, emoji_base: base });
|
||||||
|
closePicker();
|
||||||
|
});
|
||||||
|
trigger.after(pickerEl);
|
||||||
|
setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
|
||||||
|
};
|
||||||
|
trigger.addEventListener('click', (e) => { e.stopPropagation(); pickerEl ? closePicker() : openPicker(); });
|
||||||
|
// Optional: long-press support for touch
|
||||||
|
let lp;
|
||||||
|
trigger.addEventListener('touchstart', () => { lp = setTimeout(openPicker, 350); }, {passive: true});
|
||||||
|
trigger.addEventListener('touchend', () => { clearTimeout(lp); }, {passive: true});
|
||||||
|
}
|
||||||
|
emojiGrid.appendChild(emojiCard);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show 'No emojis found' only if the grid is still empty after rendering
|
||||||
|
if (emojiGrid.innerHTML === '') {
|
||||||
|
emojiGrid.innerHTML = '<p class="text-gray-500 dark:text-gray-400 col-span-full text-center">No emojis found.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLoadMoreButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMoreBtn.addEventListener('click', () => {
|
||||||
|
appendNextPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Category Logic ---
|
||||||
|
function initializeCategoryMenu() {
|
||||||
|
// Transform existing desktop sidebar buttons into anchors for proper link behavior
|
||||||
|
const desktopButtons = Array.from(document.querySelectorAll('.category-btn'));
|
||||||
|
desktopButtons.forEach((btn) => {
|
||||||
|
const category = btn.getAttribute('data-category') || 'all';
|
||||||
|
const a = document.createElement('a');
|
||||||
|
// Preserve inner content (icon + label)
|
||||||
|
a.innerHTML = btn.innerHTML;
|
||||||
|
// Build pretty URL and assign SPA-recognized classes/attributes
|
||||||
|
a.href = (typeof buildPrettyPathFor === 'function') ? buildPrettyPathFor(category, '') : '/';
|
||||||
|
a.className = 'category-link scope-link block no-underline w-full text-left px-3 py-2 rounded-lg text-sm font-medium ' +
|
||||||
|
'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ' +
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-blue-500';
|
||||||
|
a.setAttribute('data-category', category);
|
||||||
|
a.setAttribute('data-cat', category);
|
||||||
|
a.setAttribute('data-sub', '');
|
||||||
|
// Replace button with anchor
|
||||||
|
btn.replaceWith(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Offcanvas category menu
|
||||||
|
const categories = [
|
||||||
|
{ key: 'all', name: 'All Emojis', icon: '🌟' },
|
||||||
|
{ key: 'Smileys & Emotion', name: 'Smileys & Emotion', icon: '😀' },
|
||||||
|
{ key: 'People & Body', name: 'People & Body', icon: '👋' },
|
||||||
|
{ key: 'Animals & Nature', name: 'Animals & Nature', icon: '🐶' },
|
||||||
|
{ key: 'Food & Drink', name: 'Food & Drink', icon: '🍎' },
|
||||||
|
{ key: 'Travel & Places', name: 'Travel & Places', icon: '🌍' },
|
||||||
|
{ key: 'Activities', name: 'Activities', icon: '⚽' },
|
||||||
|
{ key: 'Objects', name: 'Objects', icon: '💡' },
|
||||||
|
{ key: 'Symbols', name: 'Symbols', icon: '❤️' },
|
||||||
|
{ key: 'Flags', name: 'Flags', icon: '🏳️' }
|
||||||
|
];
|
||||||
|
|
||||||
|
offcanvasNav.innerHTML = categories.map(cat => {
|
||||||
|
const href = (typeof buildPrettyPathFor === 'function')
|
||||||
|
? buildPrettyPathFor(cat.name, '')
|
||||||
|
: '/'; // fallback safeguard
|
||||||
|
return `
|
||||||
|
<a href="${href}"
|
||||||
|
class="category-link scope-link block no-underline w-full text-left px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
data-category="${cat.key}"
|
||||||
|
data-cat="${cat.name}"
|
||||||
|
data-sub="">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="mr-2">${cat.icon}</span>
|
||||||
|
${cat.name}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Offcanvas menu controls
|
||||||
|
offcanvasToggle.addEventListener('click', openOffcanvas);
|
||||||
|
offcanvasClose.addEventListener('click', closeOffcanvas);
|
||||||
|
offcanvasBackdrop.addEventListener('click', closeOffcanvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize category menu (sidebar + desktop listeners)
|
||||||
|
initializeCategoryMenu();
|
||||||
|
|
||||||
|
// Intercept plain left-clicks on category links for SPA behavior.
|
||||||
|
// Modified clicks (Cmd/Ctrl/middle) and no-JS still work as normal links.
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const a = e.target.closest('a.category-link');
|
||||||
|
if (!a) return;
|
||||||
|
// Only intercept unmodified left clicks
|
||||||
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
const category = a.dataset.category || 'all';
|
||||||
|
setActiveCategory(category);
|
||||||
|
// Top-level category navigation clears subcategory
|
||||||
|
currentFilters.subcategory = '';
|
||||||
|
updateURLFromFilters();
|
||||||
|
// If off-canvas is open, close it
|
||||||
|
try { closeOffcanvas(); } catch(_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const a = e.target.closest('a.scope-link');
|
||||||
|
if (!a) return;
|
||||||
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
currentFilters.category = a.getAttribute('data-cat') || 'all';
|
||||||
|
currentFilters.subcategory = subcatToSlug(a.getAttribute('data-sub') || '');
|
||||||
|
updateURLFromFilters();
|
||||||
|
resetAndLoadFirstPage();
|
||||||
|
try { closeOffcanvas(); } catch(_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setActiveCategory(category) {
|
||||||
|
currentCategory = category;
|
||||||
|
currentFilters.category = category;
|
||||||
|
updateURLFromFilters();
|
||||||
|
|
||||||
|
// Update active state for all category links (desktop + offcanvas)
|
||||||
|
document.querySelectorAll('.category-link').forEach(link => {
|
||||||
|
const isActive = (link.dataset.category === category);
|
||||||
|
link.classList.toggle('active', isActive);
|
||||||
|
if (isActive) {
|
||||||
|
link.classList.add('bg-blue-100', 'dark:bg-blue-900', 'text-blue-700', 'dark:text-blue-300');
|
||||||
|
} else {
|
||||||
|
link.classList.remove('bg-blue-100', 'dark:bg-blue-900', 'text-blue-700', 'dark:text-blue-300');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset to first page and fetch from API for this category (search term preserved)
|
||||||
|
currentFilters.q = getSearchValue();
|
||||||
|
resetAndLoadFirstPage();
|
||||||
|
reflectScopeInPlaceholders();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear search buttons (support multiple: desktop/mobile)
|
||||||
|
const searchClearButtons = Array.from(document.querySelectorAll('#search-clear, .search-clear'));
|
||||||
|
searchClearButtons.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const curr = getSearchValue();
|
||||||
|
if (!curr) { focusFirstSearch(); return; }
|
||||||
|
setSearchValue('');
|
||||||
|
currentFilters.q = '';
|
||||||
|
updateURLFromFilters();
|
||||||
|
resetAndLoadFirstPage();
|
||||||
|
focusFirstSearch();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateCategoryDisplay() {
|
||||||
|
const categoryName = currentCategory === 'all' ? 'All Emojis' : currentCategory;
|
||||||
|
currentCategoryTitle.textContent = categoryName;
|
||||||
|
currentCategoryCount.textContent = `${totalAvailable} emojis`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureFilterUI() {
|
||||||
|
let bar = document.getElementById('active-filter-bar');
|
||||||
|
if (!bar) {
|
||||||
|
bar = document.createElement('div');
|
||||||
|
bar.id = 'active-filter-bar';
|
||||||
|
bar.className = 'mb-3 flex flex-wrap items-center gap-2';
|
||||||
|
// insert above the grid
|
||||||
|
emojiGrid.parentElement.insertBefore(bar, emojiGrid);
|
||||||
|
}
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reflectScopeInPlaceholders() {
|
||||||
|
const prettySub = currentFilters.subcategory ? currentFilters.subcategory.replace(/-/g, ' ') : '';
|
||||||
|
const scope = (currentFilters.category !== 'all' ? currentFilters.category : '')
|
||||||
|
+ (prettySub ? ` › ${prettySub}` : '');
|
||||||
|
const hint = scope ? `Searching in ${scope.toLowerCase()}…` : 'Search for an emoji…';
|
||||||
|
searchInputs.forEach(el => el.placeholder = hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActiveFilters() {
|
||||||
|
const bar = ensureFilterUI();
|
||||||
|
bar.innerHTML = ''; // reset
|
||||||
|
|
||||||
|
const mkPill = (label, onClick, color='bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200') => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = `px-2 py-1 rounded-md text-sm ${color} hover:opacity-90 transition`;
|
||||||
|
b.textContent = label;
|
||||||
|
b.addEventListener('click', onClick);
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasCat = currentFilters.category && currentFilters.category !== 'all';
|
||||||
|
const hasSub = !!currentFilters.subcategory;
|
||||||
|
|
||||||
|
if (hasCat) {
|
||||||
|
bar.appendChild(mkPill(
|
||||||
|
`Category: ${currentFilters.category} ✕`,
|
||||||
|
() => { currentFilters.category = 'all'; updateURLFromFilters(); resetAndLoadFirstPage(); }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (hasSub) {
|
||||||
|
const prettySub = currentFilters.subcategory.replace(/-/g, ' ');
|
||||||
|
bar.appendChild(mkPill(
|
||||||
|
`Subcategory: ${prettySub} ✕`,
|
||||||
|
() => { currentFilters.subcategory = ''; updateURLFromFilters(); resetAndLoadFirstPage(); }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (hasCat || hasSub) {
|
||||||
|
bar.appendChild(mkPill(
|
||||||
|
'Clear all filters',
|
||||||
|
() => { currentFilters.category = 'all'; currentFilters.subcategory = ''; updateURLFromFilters(); resetAndLoadFirstPage(); },
|
||||||
|
'bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOffcanvas() {
|
||||||
|
offcanvasOverlay.classList.remove('hidden');
|
||||||
|
setTimeout(() => {
|
||||||
|
offcanvasSidebar.classList.remove('-translate-x-full');
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOffcanvas() {
|
||||||
|
offcanvasSidebar.classList.add('-translate-x-full');
|
||||||
|
setTimeout(() => {
|
||||||
|
offcanvasOverlay.classList.add('hidden');
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search Logic ---
|
||||||
|
// (handled by wireSearchListeners)
|
||||||
|
|
||||||
|
// --- Modal Logic ---
|
||||||
|
function openModal(emoji) {
|
||||||
|
const baseForModal = emoji.emoji_base || stripSkinTone(emoji.emoji);
|
||||||
|
const prefIdx = getPreferredToneIndex();
|
||||||
|
const initial = (emoji.supports_skin_tone && prefIdx>=0)
|
||||||
|
? withSkinTone(baseForModal, SKIN_TONES[prefIdx].ch)
|
||||||
|
: emoji.emoji;
|
||||||
|
modalEmoji.textContent = initial;
|
||||||
|
// Ensure Copy button always copies the currently shown emoji
|
||||||
|
let currentEmojiForCopy = initial;
|
||||||
|
if (modalCopyBtn) {
|
||||||
|
modalCopyBtn.onclick = () => copyToClipboard(currentEmojiForCopy);
|
||||||
|
}
|
||||||
|
// Tone row placed directly under the big emoji
|
||||||
|
(function renderModalToneRow() {
|
||||||
|
// Prepare base and current
|
||||||
|
const base = emoji.emoji_base || stripSkinTone(emoji.emoji);
|
||||||
|
let current = initial; // what is shown now
|
||||||
|
// Insert tone row after the emoji display
|
||||||
|
let toneRow = document.getElementById('modal-tone-row');
|
||||||
|
if (!toneRow) {
|
||||||
|
toneRow = document.createElement('div');
|
||||||
|
toneRow.id = 'modal-tone-row';
|
||||||
|
toneRow.className = 'mt-2 flex gap-1 flex-wrap justify-center';
|
||||||
|
modalEmoji.insertAdjacentElement('afterend', toneRow);
|
||||||
|
}
|
||||||
|
toneRow.innerHTML = '';
|
||||||
|
if (!emoji.supports_skin_tone) return;
|
||||||
|
SKIN_TONES.forEach((t, i) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'w-7 h-7 rounded-md flex items-center justify-center bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600';
|
||||||
|
b.textContent = t.ch;
|
||||||
|
b.title = t.label;
|
||||||
|
if (typeof prefIdx === 'number' && prefIdx === i) {
|
||||||
|
b.classList.add('ring-2','ring-blue-500');
|
||||||
|
}
|
||||||
|
b.addEventListener('click', () => {
|
||||||
|
const variant = withSkinTone(base, t.ch);
|
||||||
|
current = variant;
|
||||||
|
modalEmoji.textContent = variant;
|
||||||
|
currentEmojiForCopy = variant; // update shared copy target
|
||||||
|
setPreferredToneIndex(i);
|
||||||
|
// update highlight
|
||||||
|
Array.from(toneRow.children).forEach(child => child.classList.remove('ring-2','ring-blue-500'));
|
||||||
|
b.classList.add('ring-2','ring-blue-500');
|
||||||
|
});
|
||||||
|
toneRow.appendChild(b);
|
||||||
|
});
|
||||||
|
// (removed rebinding of modalCopyBtn.onclick here)
|
||||||
|
})();
|
||||||
|
modalName.textContent = emoji.name;
|
||||||
|
modalCategory.textContent = [emoji.category, emoji.subcategory].filter(Boolean).join(' / ');
|
||||||
|
modalKeywords.innerHTML = '';
|
||||||
|
|
||||||
|
const tags = new Set();
|
||||||
|
if (emoji.category) tags.add({ type: 'category', label: emoji.category });
|
||||||
|
if (emoji.subcategory) tags.add({ type: 'subcategory', label: emoji.subcategory });
|
||||||
|
|
||||||
|
if (tags.size > 0) {
|
||||||
|
[...tags].forEach(({ type, label }) => {
|
||||||
|
// Create an anchor element for pill, so Cmd/Ctrl/middle-click works
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = buildPrettyPathFor(
|
||||||
|
type === 'category' ? label : (emoji.category || 'all'),
|
||||||
|
type === 'subcategory' ? label : ''
|
||||||
|
);
|
||||||
|
link.textContent = label;
|
||||||
|
link.className =
|
||||||
|
'scope-link px-2 py-1 bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200 ' +
|
||||||
|
'rounded-md text-sm hover:bg-gray-300 dark:hover:bg-gray-600 ' +
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-blue-500';
|
||||||
|
link.setAttribute('data-cat', type === 'category' ? label : (emoji.category || 'all'));
|
||||||
|
link.setAttribute('data-sub', type === 'subcategory' ? subcatToSlug(label) : '');
|
||||||
|
modalKeywords.appendChild(link);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
modalKeywords.innerHTML =
|
||||||
|
'<p class="text-sm text-gray-500 dark:text-gray-400">No tags available.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy button (handled in tone row logic above)
|
||||||
|
// modalCopyBtn.onclick = () => copyToClipboard(emoji.emoji);
|
||||||
|
|
||||||
|
// Ensure & wire "Open page" button
|
||||||
|
const openBtn = ensureModalButtons();
|
||||||
|
if (openBtn) {
|
||||||
|
// Prefer server-provided slug if present; otherwise build a safe fallback from name
|
||||||
|
const fallbackSlug = (emoji.slug || (emoji.name || 'emoji'))
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
openBtn.onclick = () => {
|
||||||
|
window.location.href = `/emoji/${fallbackSlug}`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
setTimeout(() => {
|
||||||
|
modalContent.classList.remove('scale-95', 'opacity-0');
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureModalButtons() {
|
||||||
|
// We assume modalCopyBtn exists already.
|
||||||
|
// Append a sibling "Open page" button if not present.
|
||||||
|
let openBtn = document.getElementById('modal-open-btn');
|
||||||
|
if (!openBtn && modalCopyBtn && modalCopyBtn.parentElement) {
|
||||||
|
openBtn = document.createElement('button');
|
||||||
|
openBtn.id = 'modal-open-btn';
|
||||||
|
openBtn.type = 'button';
|
||||||
|
openBtn.className =
|
||||||
|
'ml-2 inline-flex items-center px-3 py-1.5 rounded-md ' +
|
||||||
|
'bg-blue-600 text-white hover:bg-blue-700 focus:outline-none ' +
|
||||||
|
'focus:ring-2 focus:ring-blue-500';
|
||||||
|
openBtn.textContent = 'Open page';
|
||||||
|
modalCopyBtn.parentElement.appendChild(openBtn);
|
||||||
|
}
|
||||||
|
return openBtn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modalContent.classList.add('scale-95', 'opacity-0');
|
||||||
|
setTimeout(() => {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
modalCloseBtn.addEventListener('click', closeModal);
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Clipboard Logic ---
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
const originalText = modalCopyBtn.textContent;
|
||||||
|
modalCopyBtn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => { modalCopyBtn.textContent = originalText; }, 2000);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy: ', err);
|
||||||
|
const originalText = modalCopyBtn.textContent;
|
||||||
|
modalCopyBtn.textContent = 'Failed!';
|
||||||
|
setTimeout(() => { modalCopyBtn.textContent = originalText; }, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Deep link: initialize filters from URL ----
|
||||||
|
(function initFiltersFromURL() {
|
||||||
|
// 1) Parse pretty path /Category[/Subcategory] → querystring
|
||||||
|
(function parsePrettyPathIntoQuery(fv) {
|
||||||
|
const path = location.pathname;
|
||||||
|
// ignore known non-app paths
|
||||||
|
if (path === '/' || path.startsWith('/emoji') || path.startsWith('/api') || path.startsWith('/assets') || path.startsWith('/api-docs')) return;
|
||||||
|
|
||||||
|
const parts = path.replace(/^\/+|\/+$/g, '').split('/');
|
||||||
|
if (!parts[0]) return;
|
||||||
|
|
||||||
|
const qs = new URLSearchParams(location.search);
|
||||||
|
|
||||||
|
// slug → display
|
||||||
|
const catSlug = parts[0].toLowerCase();
|
||||||
|
const category = SLUG_TO_CATEGORY[catSlug] || 'all';
|
||||||
|
qs.set('category', category);
|
||||||
|
|
||||||
|
if (parts[1]) {
|
||||||
|
// Keep canonical hyphenated slug for API queries
|
||||||
|
const sub = (parts[1] + '').toLowerCase();
|
||||||
|
qs.set('subcategory', sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
history.replaceState(null, '', `/?${qs.toString()}`);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// 2) Read params
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const qParam = params.get('q') || '';
|
||||||
|
const catParam = params.get('category') || 'all';
|
||||||
|
const subParam = params.get('subcategory') || '';
|
||||||
|
|
||||||
|
// 3) Put q in both search inputs
|
||||||
|
searchInputs.forEach(el => (el.value = qParam));
|
||||||
|
|
||||||
|
// 4) Apply to state, activate buttons, and fetch
|
||||||
|
currentFilters.q = qParam.toLowerCase().trim();
|
||||||
|
currentFilters.category = catParam;
|
||||||
|
currentFilters.subcategory = subcatToSlug(subParam);
|
||||||
|
|
||||||
|
// setActiveCategory also fetches, but we need subcategory respected on first load
|
||||||
|
setActiveCategory(catParam);
|
||||||
|
currentFilters.subcategory = subcatToSlug(subParam);
|
||||||
|
if (subParam) resetAndLoadFirstPage();
|
||||||
|
})();
|
||||||
|
|
||||||
|
function updateURLFromFilters() {
|
||||||
|
const hasQ = !!(currentFilters.q && currentFilters.q.trim());
|
||||||
|
const hasCat = currentFilters.category && currentFilters.category !== 'all';
|
||||||
|
const hasSub = !!currentFilters.subcategory;
|
||||||
|
|
||||||
|
// Prefer pretty URLs when no q and we have category/subcategory
|
||||||
|
if (!hasQ && (hasCat || hasSub)) {
|
||||||
|
const segs = [];
|
||||||
|
// map display → slug
|
||||||
|
const catSlug = CATEGORY_TO_SLUG[currentFilters.category] || 'all';
|
||||||
|
if (hasCat && catSlug !== 'all') segs.push(catSlug);
|
||||||
|
if (hasSub) segs.push(subcatToSlug(currentFilters.subcategory));
|
||||||
|
const pretty = '/' + segs.join('/');
|
||||||
|
history.replaceState(null, '', pretty || '/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise keep query form (use display name for category to keep UI-friendly querystring)
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (hasQ) qs.set('q', currentFilters.q.trim());
|
||||||
|
if (hasCat) qs.set('category', currentFilters.category);
|
||||||
|
if (hasSub) qs.set('subcategory', currentFilters.subcategory);
|
||||||
|
const url = qs.toString() ? `/?${qs.toString()}` : '/';
|
||||||
|
history.replaceState(null, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize footer with dynamic year
|
||||||
|
function initFooter() {
|
||||||
|
const yearElement = document.getElementById('current-year');
|
||||||
|
if (yearElement) {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
yearElement.textContent = currentYear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize footer
|
||||||
|
initFooter();
|
||||||
|
|
||||||
|
});
|
||||||
0
backup-old-site.zip
Normal file
0
backup-old-site.zip
Normal file
0
data/array.json
Normal file
0
data/array.json
Normal file
0
data/categories.json
Normal file
0
data/categories.json
Normal file
0
data/emojis.json
Normal file
0
data/emojis.json
Normal file
0
data/keywords-id.json
Normal file
0
data/keywords-id.json
Normal file
0
data/list.json
Normal file
0
data/list.json
Normal file
0
data/overrides.json
Normal file
0
data/overrides.json
Normal file
48
db.php
Normal file
48
db.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
// db.php
|
||||||
|
$pdo = new PDO('mysql:host=localhost;dbname=dewepw_emoji;charset=utf8mb4','dewepw_emoji_bro','3vgxEHxf2oBirx*D',
|
||||||
|
[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);
|
||||||
|
|
||||||
|
function j($ok, $extra=[]) { echo json_encode(array_merge(['ok'=>$ok], $extra)); exit; }
|
||||||
|
function now() { return date('Y-m-d H:i:s'); }
|
||||||
|
function row($pdo,$sql,$p){ $st=$pdo->prepare($sql); $st->execute($p); return $st->fetch(PDO::FETCH_ASSOC); }
|
||||||
|
|
||||||
|
// activate.php
|
||||||
|
require 'db.php';
|
||||||
|
$in = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$key = trim($in['key'] ?? ''); $dev = trim($in['device_id'] ?? ''); if (!$key || !$dev) j(false,['message'=>'bad request']);
|
||||||
|
|
||||||
|
$lic = row($pdo,'SELECT * FROM licenses WHERE license_key=?',[$key]);
|
||||||
|
if (!$lic || $lic['revoked']) j(false,['message'=>'invalid']);
|
||||||
|
if ($lic['expires_at'] && strtotime($lic['expires_at']) < time()) j(false,['message'=>'expired']);
|
||||||
|
|
||||||
|
$cnt = row($pdo,'SELECT COUNT(*) c FROM license_activations WHERE license_key=?',[$key])['c'];
|
||||||
|
$has = row($pdo,'SELECT 1 FROM license_activations WHERE license_key=? AND device_id=?',[$key,$dev]);
|
||||||
|
|
||||||
|
if (!$has && $cnt >= (int)$lic['max_devices']) j(false,['message'=>'device limit reached']);
|
||||||
|
|
||||||
|
// upsert activation
|
||||||
|
$pdo->prepare('INSERT INTO license_activations(license_key,device_id,activated_at,last_seen_at)
|
||||||
|
VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE last_seen_at=VALUES(last_seen_at)')
|
||||||
|
->execute([$key,$dev,now(),now()]);
|
||||||
|
j(true,['plan'=>$lic['plan'],'expires_at'=>$lic['expires_at']]);
|
||||||
|
|
||||||
|
// deactivate.php
|
||||||
|
require 'db.php';
|
||||||
|
$in = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$key = trim($in['key'] ?? ''); $dev = trim($in['device_id'] ?? ''); if (!$key || !$dev) j(false,['message'=>'bad request']);
|
||||||
|
$pdo->prepare('DELETE FROM license_activations WHERE license_key=? AND device_id=?')->execute([$key,$dev]);
|
||||||
|
j(true);
|
||||||
|
|
||||||
|
// verify.php
|
||||||
|
require 'db.php';
|
||||||
|
$in = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$key = trim($in['key'] ?? ''); $dev = trim($in['device_id'] ?? ''); if (!$key || !$dev) j(false,['ok'=>false]);
|
||||||
|
$lic = row($pdo,'SELECT * FROM licenses WHERE license_key=?',[$key]);
|
||||||
|
if (!$lic || $lic['revoked']) j(false);
|
||||||
|
if ($lic['expires_at'] && strtotime($lic['expires_at']) < time()) j(false);
|
||||||
|
|
||||||
|
$has = row($pdo,'SELECT 1 FROM license_activations WHERE license_key=? AND device_id=?',[$key,$dev]);
|
||||||
|
if (!$has) j(false);
|
||||||
|
$pdo->prepare('UPDATE license_activations SET last_seen_at=? WHERE license_key=? AND device_id=?')->execute([now(),$key,$dev]);
|
||||||
|
j(true,['plan'=>$lic['plan'],'expires_at'=>$lic['expires_at']]);
|
||||||
0
dewe-api-2.zip
Normal file
0
dewe-api-2.zip
Normal file
0
ext/updates.xml
Normal file
0
ext/updates.xml
Normal file
0
helpers/auth.php
Normal file
0
helpers/auth.php
Normal file
0
helpers/filters.php
Normal file
0
helpers/filters.php
Normal file
285
index.html
Normal file
285
index.html
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Dewemoji</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
.sidebar-sticky { position: sticky; top: 95px!important; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
|
||||||
|
if (localStorage.getItem('darkMode') === 'true' || (!('darkMode' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="flex flex-col h-full bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||||
|
|
||||||
|
<header class="sticky top-0 z-40 md:hidden bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="mx-auto py-8 px-4 sm:px-6 lg:px-8 text-center">
|
||||||
|
<!-- Mobile Header Layout -->
|
||||||
|
<div class="flex items-center justify-between mb-4 lg:hidden">
|
||||||
|
<button id="offcanvas-toggle" class="p-2 rounded-full text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 class="text-4xl font-bold tracking-tight flex-1 text-center">Dewemoji</h1>
|
||||||
|
<button class="theme-toggle p-2 rounded-full text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none">
|
||||||
|
<svg class="theme-icon-dark block dark:hidden w-6 h-6" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
|
||||||
|
<svg class="theme-icon-light hidden dark:block w-6 h-6" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 5.05A1 1 0 003.636 6.464l.707.707a1 1 0 001.414-1.414l-.707-.707zM3 11a1 1 0 100-2H2a1 1 0 100 2h1zM13.536 14.95a1 1 0 011.414 0l.707.707a1 1 0 01-1.414 1.414l-.707-.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 max-w-md mx-auto">
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search for an emoji..."
|
||||||
|
class="search-input w-full pl-4 pr-10 py-2 border border-gray-300 rounded-full shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Clear search"
|
||||||
|
class="search-clear absolute inset-y-0 right-0 px-3 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- DESKTOP header (new) -->
|
||||||
|
<header class="hidden md:block sticky top-0 z-40 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="mx-auto py-4 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<!-- Title -->
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight">Dewemoji</h1>
|
||||||
|
|
||||||
|
<!-- Right: search + dark toggle -->
|
||||||
|
<div class="flex items-center gap-3 min-w-[420px]">
|
||||||
|
<!-- Search with clear -->
|
||||||
|
<div class="relative w-full">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search for an emoji…"
|
||||||
|
class="search-input w-full pl-4 pr-10 py-2 rounded-lg bg-gray-100 dark:bg-gray-900 border border-transparent focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-900 outline-none transition"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
id="search-clear"
|
||||||
|
type="button"
|
||||||
|
aria-label="Clear search"
|
||||||
|
class="absolute inset-y-0 right-0 px-3 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dark mode toggle (reuse your existing button/ids if you have them) -->
|
||||||
|
<button class="theme-toggle p-2 rounded-full text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none">
|
||||||
|
<svg class="theme-icon-dark block dark:hidden w-6 h-6" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
|
||||||
|
<svg class="theme-icon-light hidden dark:block w-6 h-6" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 5.05A1 1 0 003.636 6.464l.707.707a1 1 0 001.414-1.414l-.707-.707zM3 11a1 1 0 100-2H2a1 1 0 100 2h1zM13.536 14.95a1 1 0 011.414 0l.707.707a1 1 0 01-1.414 1.414l-.707-.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-grow flex">
|
||||||
|
<!-- Category Sidebar -->
|
||||||
|
<aside class="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 p-4 hidden lg:block">
|
||||||
|
<div class="md:sticky md:top-20 sidebar-sticky">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Menu</h2>
|
||||||
|
<nav id="menu-nav" class="space-y-2 mb-4">
|
||||||
|
<a href="/" class="w-full block text-left px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors active">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="mr-2">🏡</span>
|
||||||
|
Emoji
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a href="/api-docs" class="w-full block text-left px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="mr-2">🔥</span>
|
||||||
|
API Docs
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Categories</h2>
|
||||||
|
<nav id="category-nav" class="space-y-2">
|
||||||
|
<button class="category-btn w-full text-left px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors active" data-category="all">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="mr-2">🌟</span>
|
||||||
|
All Emojis
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button class="category-btn w-full text-left px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" data-category="Smileys & Emotion">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="mr-2">😀</span>
|
||||||
|
Smileys & Emotion
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button class="category-btn w-full text-left px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" data-category="People & Body">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="mr-2">👋</span>
|
||||||
|
People & Body
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button class="category-btn w-full text-left px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" data-category="Animals & Nature">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="mr-2">🐶</span>
|
||||||
|
Animals & Nature
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button class="category-btn w-full text-left px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" data-category="Food & Drink">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="mr-2">🍎</span>
|
||||||
|
Food & Drink
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button class="category-btn w-full text-left px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" data-category="Travel & Places">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="mr-2">🌍</span>
|
||||||
|
Travel & Places
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button class="category-btn w-full text-left px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" data-category="Activities">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="mr-2">⚽</span>
|
||||||
|
Activities
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button class="category-btn w-full text-left px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" data-category="Objects">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="mr-2">💡</span>
|
||||||
|
Objects
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button class="category-btn w-full text-left px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" data-category="Symbols">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="mr-2">❤️</span>
|
||||||
|
Symbols
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button class="category-btn w-full text-left px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" data-category="Flags">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="mr-2">🏳️</span>
|
||||||
|
Flags
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Mobile Offcanvas Overlay -->
|
||||||
|
<div id="offcanvas-overlay" class="lg:hidden fixed inset-0 z-50 hidden">
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-50" id="offcanvas-backdrop"></div>
|
||||||
|
<!-- Offcanvas Sidebar -->
|
||||||
|
<div id="offcanvas-sidebar" class="absolute left-0 top-0 h-full w-64 bg-white dark:bg-gray-800 transform -translate-x-full transition-transform duration-300 ease-in-out">
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Menu</h2>
|
||||||
|
<button id="offcanvas-close" class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 overflow-y-auto h-full pb-20">
|
||||||
|
<!-- Menu (same as desktop sidebar) -->
|
||||||
|
<nav id="offcanvas-menu" class="space-y-2 mb-6">
|
||||||
|
<a href="/" class="w-full block text-left px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="mr-2">🏡</span>
|
||||||
|
Emoji
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a href="/api-docs" class="w-full block text-left px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="mr-2">🔥</span>
|
||||||
|
API Docs
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Categories (JS will populate #offcanvas-nav) -->
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Categories</h3>
|
||||||
|
<nav id="offcanvas-nav" class="space-y-2">
|
||||||
|
<!-- Offcanvas category buttons will be populated by JavaScript -->
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<div class="flex-1 p-4 sm:p-6 lg:p-8">
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 id="current-category-title" class="text-2xl font-bold text-gray-900 dark:text-gray-100">All Emojis</h2>
|
||||||
|
<p id="current-category-count" class="text-sm text-gray-500 dark:text-gray-400 mt-1"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="emoji-grid" class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-4">
|
||||||
|
<!-- Emojis will be loaded here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="load-more-container" class="text-center mt-8">
|
||||||
|
<button id="load-more-btn" class="hidden px-6 py-2 bg-blue-500 text-white font-semibold rounded-full hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 transition">
|
||||||
|
Load More
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 py-6">
|
||||||
|
<p class="text-center text-sm text-gray-500">
|
||||||
|
© <span id="current-year">2025</span> Dewe Toolsites - Dewemoji.
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<div id="emoji-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black bg-opacity-50 transition-opacity duration-300">
|
||||||
|
<div id="modal-content" class="relative w-full max-w-md p-6 mx-4 bg-white rounded-lg shadow-xl transform transition-all duration-300 scale-95 opacity-0 dark:bg-gray-800">
|
||||||
|
<button id="modal-close-btn" class="absolute top-3 right-3 p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<div id="modal-emoji" class="text-8xl mb-4"></div>
|
||||||
|
<h2 id="modal-name" class="text-2xl font-bold text-gray-900 dark:text-gray-100"></h2>
|
||||||
|
<p id="modal-category" class="text-sm text-gray-500 dark:text-gray-400 mt-1"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Tags</h3>
|
||||||
|
<div id="modal-keywords" class="flex flex-wrap gap-2 mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex gap-2">
|
||||||
|
<button id="modal-copy-btn" class="w-full px-4 py-2 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 flex gap-2 justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20.829 12.861c.171-.413.171-.938.171-1.986s0-1.573-.171-1.986a2.25 2.25 0 0 0-1.218-1.218c-.413-.171-.938-.171-1.986-.171H11.1c-1.26 0-1.89 0-2.371.245a2.25 2.25 0 0 0-.984.984C7.5 9.209 7.5 9.839 7.5 11.1v6.525c0 1.048 0 1.573.171 1.986c.229.551.667.99 1.218 1.218c.413.171.938.171 1.986.171s1.573 0 1.986-.171m7.968-7.968a2.25 2.25 0 0 1-1.218 1.218c-.413.171-.938.171-1.986.171s-1.573 0-1.986.171a2.25 2.25 0 0 0-1.218 1.218c-.171.413-.171.938-.171 1.986s0 1.573-.171 1.986a2.25 2.25 0 0 1-1.218 1.218m7.968-7.968a11.68 11.68 0 0 1-7.75 7.9l-.218.068M16.5 7.5v-.9c0-1.26 0-1.89-.245-2.371a2.25 2.25 0 0 0-.983-.984C14.79 3 14.16 3 12.9 3H6.6c-1.26 0-1.89 0-2.371.245a2.25 2.25 0 0 0-.984.984C3 4.709 3 5.339 3 6.6v6.3c0 1.26 0 1.89.245 2.371c.216.424.56.768.984.984c.48.245 1.111.245 2.372.245H7.5"/></svg>
|
||||||
|
Copy Emoji
|
||||||
|
</button>
|
||||||
|
<button id="modal-open-btn" type="button" class="px-4 py-2 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M9.367 2.25H9.4a.75.75 0 0 1 0 1.5c-1.132 0-1.937 0-2.566.052c-.62.05-1.005.147-1.31.302a3.25 3.25 0 0 0-1.42 1.42c-.155.305-.251.69-.302 1.31c-.051.63-.052 1.434-.052 2.566v5.2c0 1.133 0 1.937.052 2.566c.05.62.147 1.005.302 1.31a3.25 3.25 0 0 0 1.42 1.42c.305.155.69.251 1.31.302c.63.051 1.434.052 2.566.052h5.2c1.133 0 1.937 0 2.566-.052c.62-.05 1.005-.147 1.31-.302a3.25 3.25 0 0 0 1.42-1.42c.155-.305.251-.69.302-1.31c.051-.63.052-1.434.052-2.566v-1.1a.75.75 0 0 1 1.5 0v1.133c0 1.092 0 1.958-.057 2.655c-.058.714-.18 1.317-.46 1.869a4.75 4.75 0 0 1-2.076 2.075c-.552.281-1.155.403-1.869.461c-.697.057-1.563.057-2.655.057H9.367c-1.092 0-1.958 0-2.655-.057c-.714-.058-1.317-.18-1.868-.46a4.75 4.75 0 0 1-2.076-2.076c-.281-.552-.403-1.155-.461-1.869c-.057-.697-.057-1.563-.057-2.655V9.367c0-1.092 0-1.958.057-2.655c.058-.714.18-1.317.46-1.868a4.75 4.75 0 0 1 2.077-2.076c.55-.281 1.154-.403 1.868-.461c.697-.057 1.563-.057 2.655-.057M13.5 3a.75.75 0 0 1 .75-.75H21a.75.75 0 0 1 .75.75v6.75a.75.75 0 0 1-1.5 0V4.81l-6.97 6.97a.75.75 0 1 1-1.06-1.06l6.97-6.97h-4.94A.75.75 0 0 1 13.5 3"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/assets/script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
0
public/js/api-client.js
Normal file
0
public/js/api-client.js
Normal file
0
robots.txt
Normal file
0
robots.txt
Normal file
0
sitemap.xml.php
Normal file
0
sitemap.xml.php
Normal file
Reference in New Issue
Block a user