telegram stickers v1
This commit is contained in:
53
migrations/add_sticker_packs.sql
Normal file
53
migrations/add_sticker_packs.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- Migration: Add sticker_packs table and link custom_emojis to packs
|
||||
-- Run this against the f0ckm database (idempotent — safe to run multiple times)
|
||||
|
||||
-- Create sticker_packs table
|
||||
CREATE TABLE IF NOT EXISTS public.sticker_packs (
|
||||
id serial PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
tg_name text UNIQUE,
|
||||
tg_title text,
|
||||
thumb_url text,
|
||||
sticker_count integer DEFAULT 0,
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.sticker_packs OWNER TO f0ckm;
|
||||
|
||||
-- Add pack_id column to custom_emojis (nullable: NULL = standalone emoji, no pack)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'custom_emojis'
|
||||
AND column_name = 'pack_id'
|
||||
) THEN
|
||||
ALTER TABLE public.custom_emojis
|
||||
ADD COLUMN pack_id integer REFERENCES public.sticker_packs(id) ON DELETE SET NULL;
|
||||
RAISE NOTICE 'pack_id column added to custom_emojis';
|
||||
ELSE
|
||||
RAISE NOTICE 'pack_id column already exists on custom_emojis — skipping';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Add thumb_url column to sticker_packs if missing
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'sticker_packs'
|
||||
AND column_name = 'thumb_url'
|
||||
) THEN
|
||||
ALTER TABLE public.sticker_packs ADD COLUMN thumb_url text;
|
||||
RAISE NOTICE 'thumb_url column added to sticker_packs';
|
||||
ELSE
|
||||
RAISE NOTICE 'thumb_url already exists on sticker_packs — skipping';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Index for fast pack lookups
|
||||
CREATE INDEX IF NOT EXISTS custom_emojis_pack_id_idx ON public.custom_emojis (pack_id);
|
||||
@@ -5638,6 +5638,15 @@ body[type='login'] {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* Tabbed mode — switched to column layout by JS when packs are present */
|
||||
.emoji-picker.has-tabs {
|
||||
max-height: 200px;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.emoji-picker img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
@@ -5653,6 +5662,95 @@ body[type='login'] {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* ── Tabbed picker ── */
|
||||
.emoji-picker-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex-shrink: 0;
|
||||
background: rgba(0,0,0,0.25);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.07);
|
||||
scrollbar-width: none;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.emoji-picker-tabs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.ep-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
opacity: 0.55;
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.ep-tab:hover { opacity: 1; background: rgba(255,255,255,0.07); }
|
||||
|
||||
.ep-tab.active {
|
||||
opacity: 1;
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-bottom: 2px solid var(--accent, #fff);
|
||||
}
|
||||
|
||||
.ep-tab img,
|
||||
.ep-tab video {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.emoji-picker-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.2) transparent;
|
||||
}
|
||||
|
||||
.emoji-picker-grid img,
|
||||
.emoji-picker-grid video {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.emoji-picker-grid img:hover,
|
||||
.emoji-picker-grid video:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* video emojis in comment content */
|
||||
.comment-content video.emoji,
|
||||
video.emoji {
|
||||
height: 200px;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* video in emoji autocomplete dropdown */
|
||||
.emoji-ac-item video {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
/* User Mentions */
|
||||
.mention {
|
||||
text-decoration: none !important;
|
||||
@@ -13902,7 +14000,8 @@ body.layout-modern .xd-score-wrapper {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.emoji-card .emoji-preview {
|
||||
.emoji-card .emoji-preview,
|
||||
.emoji-card video.emoji-preview {
|
||||
height: 80px;
|
||||
max-width: 90px;
|
||||
object-fit: contain;
|
||||
|
||||
@@ -263,7 +263,9 @@ class CommentSystem {
|
||||
this.customEmojis[e.name] = e.url;
|
||||
});
|
||||
CommentSystem.emojiCache = this.customEmojis;
|
||||
_f0ckDebug('Loaded Emojis:', this.customEmojis);
|
||||
// Store pack structure for tabbed picker
|
||||
CommentSystem.emojiPacks = data.packs || [];
|
||||
_f0ckDebug('Loaded Emojis:', this.customEmojis, 'Packs:', CommentSystem.emojiPacks);
|
||||
|
||||
if (this.container && this.lastData) {
|
||||
const state = this.saveState();
|
||||
@@ -304,7 +306,11 @@ class CommentSystem {
|
||||
renderEmoji(match, name) {
|
||||
// _f0ckDebug('Rendering Emoji:', name, this.customEmojis ? this.customEmojis[name] : 'No list');
|
||||
if (this.customEmojis && this.customEmojis[name]) {
|
||||
return `<img src="${this.customEmojis[name]}" style="height:60px;vertical-align:middle;" alt="${name}">`;
|
||||
const url = this.customEmojis[name];
|
||||
if (url.endsWith('.webm')) {
|
||||
return `<video src="${url}" style="height:60px;vertical-align:middle;" autoplay loop muted playsinline title=":${name}:"></video>`;
|
||||
}
|
||||
return `<img src="${url}" style="height:60px;vertical-align:middle;" alt=":${name}:">`;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
@@ -559,6 +565,7 @@ class CommentSystem {
|
||||
contentEl.dataset.raw = this.escapeHtml(fullContent);
|
||||
contentEl.innerHTML = this.renderCommentContent(fullContent, commentId);
|
||||
CommentSystem.autoplayConvertedGifs(contentEl);
|
||||
CommentSystem.playEmojiVideos(contentEl);
|
||||
} catch (e) {
|
||||
_f0ckDebug('[CommentSystem] _patchLiveCommentContent failed:', e);
|
||||
}
|
||||
@@ -583,6 +590,7 @@ class CommentSystem {
|
||||
if (contentEl) {
|
||||
contentEl.innerHTML = this.renderCommentContent(data.content, data.comment_id);
|
||||
CommentSystem.autoplayConvertedGifs(contentEl);
|
||||
CommentSystem.playEmojiVideos(contentEl);
|
||||
|
||||
// Flash effect to draw attention
|
||||
el.classList.remove('new-item-fade');
|
||||
@@ -1296,6 +1304,7 @@ class CommentSystem {
|
||||
}
|
||||
this.syncSubscribeButton(isSubscribed);
|
||||
CommentSystem.autoplayConvertedGifs(this.container);
|
||||
CommentSystem.playEmojiVideos(this.container);
|
||||
|
||||
// Attach media load listeners to re-stabilize scroll if a hash is active.
|
||||
// Only during the initial anchor scroll — never on subsequent renders (tab re-focus,
|
||||
@@ -1415,6 +1424,7 @@ class CommentSystem {
|
||||
_f0ckDebug(`[CommentSystem] Reconcile: Updating content for #c${id}`);
|
||||
contentEl.innerHTML = this.renderCommentContent(incoming.content, incoming.id);
|
||||
CommentSystem.autoplayConvertedGifs(contentEl);
|
||||
CommentSystem.playEmojiVideos(contentEl);
|
||||
contentEl.dataset.raw = incoming.content;
|
||||
}
|
||||
|
||||
@@ -1929,6 +1939,15 @@ class CommentSystem {
|
||||
});
|
||||
}
|
||||
|
||||
static playEmojiVideos(container) {
|
||||
if (!container) return;
|
||||
container.querySelectorAll('video.emoji').forEach(v => {
|
||||
v.play().catch(() => {
|
||||
v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
buildBacklinkMap(comments) {
|
||||
this.backlinkMap = {};
|
||||
const process = (c) => {
|
||||
@@ -1959,7 +1978,11 @@ class CommentSystem {
|
||||
|
||||
renderEmoji(match, name) {
|
||||
if (this.customEmojis && this.customEmojis[name]) {
|
||||
return `<img src="${this.customEmojis[name]}" class="emoji" alt="${match}" title="${match}">`;
|
||||
const url = this.customEmojis[name];
|
||||
if (url.endsWith('.webm')) {
|
||||
return `<video src="${url}" class="emoji" autoplay loop muted playsinline title=":${name}:"></video>`;
|
||||
}
|
||||
return `<img src="${url}" class="emoji" alt="${match}" title="${match}">`;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
@@ -3383,6 +3406,7 @@ class CommentSystem {
|
||||
const newEl = tmp.firstElementChild;
|
||||
if (newEl) {
|
||||
parent.replaceChild(newEl, existingReply);
|
||||
CommentSystem.playEmojiVideos(newEl);
|
||||
requestAnimationFrame(() => {
|
||||
newEl.classList.add('comment-entering', 'new-item-fade');
|
||||
newEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
@@ -3404,6 +3428,7 @@ class CommentSystem {
|
||||
if (commentEl) {
|
||||
repliesEl.appendChild(commentEl);
|
||||
this._ensureTruncationButton(commentEl, newComment.content);
|
||||
CommentSystem.playEmojiVideos(commentEl);
|
||||
requestAnimationFrame(() => {
|
||||
commentEl.classList.add('comment-entering', 'new-item-fade');
|
||||
commentEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
@@ -3421,6 +3446,7 @@ class CommentSystem {
|
||||
const newEl = tmp.firstElementChild;
|
||||
if (newEl) {
|
||||
parent.replaceChild(newEl, existingTop);
|
||||
CommentSystem.playEmojiVideos(newEl);
|
||||
requestAnimationFrame(() => {
|
||||
newEl.classList.add('comment-entering', 'new-item-fade');
|
||||
newEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
@@ -3442,6 +3468,7 @@ class CommentSystem {
|
||||
list.appendChild(commentEl);
|
||||
}
|
||||
this._ensureTruncationButton(commentEl, newComment.content);
|
||||
CommentSystem.playEmojiVideos(commentEl);
|
||||
requestAnimationFrame(() => {
|
||||
commentEl.classList.add('comment-entering', 'new-item-fade');
|
||||
commentEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
@@ -3586,6 +3613,7 @@ class CommentSystem {
|
||||
const cs = window.commentSystem;
|
||||
if (!cs) return;
|
||||
CommentSystem.emojiCache = null;
|
||||
CommentSystem.emojiPacks = null;
|
||||
CommentSystem.loadingEmojis = false;
|
||||
cs.loadEmojis(true); // force=true: bypass page-scan, admin just changed emojis
|
||||
});
|
||||
@@ -3797,13 +3825,24 @@ class CommentSystem {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'emoji-ac-item';
|
||||
item.dataset.idx = idx;
|
||||
const img = document.createElement('img');
|
||||
img.src = this.customEmojis[name];
|
||||
img.alt = name;
|
||||
img.loading = 'lazy';
|
||||
const url = this.customEmojis[name];
|
||||
let preview;
|
||||
if (url && url.endsWith('.webm')) {
|
||||
preview = document.createElement('video');
|
||||
preview.src = url;
|
||||
preview.autoplay = true;
|
||||
preview.loop = true;
|
||||
preview.muted = true;
|
||||
preview.playsInline = true;
|
||||
} else {
|
||||
preview = document.createElement('img');
|
||||
preview.src = url;
|
||||
preview.alt = name;
|
||||
preview.loading = 'lazy';
|
||||
}
|
||||
const label = document.createElement('span');
|
||||
label.textContent = `:${name}:`;
|
||||
item.appendChild(img);
|
||||
item.appendChild(preview);
|
||||
item.appendChild(label);
|
||||
item.addEventListener('mousedown', ev => {
|
||||
ev.preventDefault(); // don't blur textarea
|
||||
@@ -3978,35 +4017,120 @@ class CommentSystem {
|
||||
const buildPickerContent = () => {
|
||||
if (!picker) return;
|
||||
picker.innerHTML = '';
|
||||
if (this.customEmojis && Object.keys(this.customEmojis).length > 0) {
|
||||
Object.keys(this.customEmojis).forEach(name => {
|
||||
const url = this.customEmojis[name];
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
img.title = `:${name}:`;
|
||||
img.loading = 'lazy'; // Use native lazy loading
|
||||
|
||||
// Add error handling for failed loads
|
||||
img.onerror = () => {
|
||||
console.warn(`Failed to load emoji: ${name}`);
|
||||
img.style.display = 'none';
|
||||
const packs = CommentSystem.emojiPacks || [];
|
||||
const hasPacks = packs.length > 0;
|
||||
|
||||
if (!this.customEmojis || Object.keys(this.customEmojis).length === 0) {
|
||||
picker.innerHTML = '<div style="padding:8px 10px;color:rgba(255,255,255,0.5);font-size:0.8em;">No emojis found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper: create img or video element for a given emoji URL
|
||||
const makeEmojiEl = (url, name) => {
|
||||
let el;
|
||||
if (url && url.endsWith('.webm')) {
|
||||
el = document.createElement('video');
|
||||
el.src = url;
|
||||
el.autoplay = true;
|
||||
el.loop = true;
|
||||
el.muted = true;
|
||||
el.playsInline = true;
|
||||
} else {
|
||||
el = document.createElement('img');
|
||||
el.src = url;
|
||||
el.loading = 'lazy';
|
||||
}
|
||||
el.title = `:${name}:`;
|
||||
el.onerror = () => { el.style.display = 'none'; };
|
||||
return el;
|
||||
};
|
||||
|
||||
img.onclick = (ev) => {
|
||||
if (!hasPacks) {
|
||||
// Flat list — old behaviour for sites without packs
|
||||
picker.classList.remove('has-tabs');
|
||||
Object.keys(this.customEmojis).forEach(name => {
|
||||
const url = this.customEmojis[name];
|
||||
const el = makeEmojiEl(url, name);
|
||||
el.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
const pos = textarea.selectionStart ?? textarea.value.length;
|
||||
const val = textarea.value;
|
||||
textarea.value = val.slice(0, pos) + `:${name}:` + val.slice(pos);
|
||||
textarea.focus();
|
||||
// Move cursor after the inserted emoji
|
||||
const newPos = pos + name.length + 2;
|
||||
textarea.setSelectionRange(newPos, newPos);
|
||||
};
|
||||
picker.appendChild(img);
|
||||
picker.appendChild(el);
|
||||
});
|
||||
} else {
|
||||
picker.innerHTML = '<div style="padding:5px;color:white;font-size:0.8em;">No emojis found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Tabbed picker with sticker packs ──
|
||||
picker.classList.add('has-tabs');
|
||||
|
||||
// Tab bar
|
||||
const tabBar = document.createElement('div');
|
||||
tabBar.className = 'emoji-picker-tabs';
|
||||
|
||||
// Emoji grid area
|
||||
const gridArea = document.createElement('div');
|
||||
gridArea.className = 'emoji-picker-grid';
|
||||
|
||||
let activeTabId = null;
|
||||
|
||||
const showTab = (packId) => {
|
||||
activeTabId = packId;
|
||||
tabBar.querySelectorAll('.ep-tab').forEach(t => t.classList.toggle('active', t.dataset.packId === String(packId ?? 'null')));
|
||||
|
||||
gridArea.innerHTML = '';
|
||||
const pack = packs.find(p => String(p.id ?? null) === String(packId ?? null)) || packs[0];
|
||||
if (!pack) return;
|
||||
|
||||
pack.emojis.forEach(({ name, url }) => {
|
||||
const el = makeEmojiEl(url, name);
|
||||
el.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
const pos = textarea.selectionStart ?? textarea.value.length;
|
||||
const val = textarea.value;
|
||||
textarea.value = val.slice(0, pos) + `:${name}:` + val.slice(pos);
|
||||
textarea.focus();
|
||||
const newPos = pos + name.length + 2;
|
||||
textarea.setSelectionRange(newPos, newPos);
|
||||
};
|
||||
gridArea.appendChild(el);
|
||||
});
|
||||
};
|
||||
|
||||
// Build tab buttons
|
||||
packs.forEach((pack, i) => {
|
||||
const tab = document.createElement('button');
|
||||
tab.className = 'ep-tab';
|
||||
tab.dataset.packId = String(pack.id ?? null);
|
||||
tab.title = pack.name || 'Emojis';
|
||||
tab.type = 'button';
|
||||
// Tab icon — always use a static img (thumb_url from import, or first non-video emoji)
|
||||
if (pack.emojis && pack.emojis.length > 0) {
|
||||
const iconUrl = pack.thumb_url
|
||||
|| (pack.emojis.find(e => !e.url.endsWith('.webm')) || pack.emojis[0]).url;
|
||||
const icon = document.createElement('img');
|
||||
icon.src = iconUrl;
|
||||
icon.alt = pack.name;
|
||||
icon.loading = 'lazy';
|
||||
tab.appendChild(icon);
|
||||
} else {
|
||||
tab.textContent = '☺';
|
||||
}
|
||||
tab.addEventListener('mousedown', e => e.preventDefault());
|
||||
tab.addEventListener('click', () => showTab(pack.id ?? null));
|
||||
tabBar.appendChild(tab);
|
||||
});
|
||||
|
||||
picker.appendChild(tabBar);
|
||||
picker.appendChild(gridArea);
|
||||
|
||||
// Show first pack by default
|
||||
showTab(packs[0]?.id ?? null);
|
||||
};
|
||||
|
||||
trigger.addEventListener('click', (e) => {
|
||||
|
||||
@@ -23,14 +23,169 @@ export default (router, tpl) => {
|
||||
});
|
||||
});
|
||||
|
||||
// List all emojis (Public)
|
||||
// List all emojis — returns both a flat list and a grouped-by-pack structure
|
||||
router.get('/api/v2/emojis', async (req, res) => {
|
||||
try {
|
||||
const emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY id DESC`;
|
||||
// Try the full pack-aware query first; fall back if pack_id column doesn't exist yet
|
||||
let emojis;
|
||||
let hasPacks = true;
|
||||
try {
|
||||
emojis = await db`
|
||||
SELECT e.id, e.name, e.url, e.pack_id,
|
||||
p.name as pack_name, p.tg_name, p.tg_title, p.thumb_url as pack_thumb_url
|
||||
FROM custom_emojis e
|
||||
LEFT JOIN sticker_packs p ON p.id = e.pack_id
|
||||
ORDER BY e.pack_id NULLS FIRST, e.id ASC
|
||||
`;
|
||||
} catch (colErr) {
|
||||
// column pack_id or thumb_url may not exist — try without thumb_url, then flat
|
||||
try {
|
||||
emojis = await db`
|
||||
SELECT e.id, e.name, e.url, e.pack_id,
|
||||
p.name as pack_name, p.tg_name, p.tg_title, NULL as pack_thumb_url
|
||||
FROM custom_emojis e
|
||||
LEFT JOIN sticker_packs p ON p.id = e.pack_id
|
||||
ORDER BY e.pack_id NULLS FIRST, e.id ASC
|
||||
`;
|
||||
} catch (_) {
|
||||
// pack_id column missing entirely — serve flat list
|
||||
console.warn('[EMOJIS] pack_id column missing, serving flat list');
|
||||
hasPacks = false;
|
||||
emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY id ASC`;
|
||||
}
|
||||
}
|
||||
|
||||
// Flat list (backwards compat)
|
||||
const flat = emojis.map(e => ({ id: e.id, name: e.name, url: e.url, pack_id: e.pack_id ?? null }));
|
||||
|
||||
if (!hasPacks) {
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, emojis })
|
||||
body: JSON.stringify({ success: true, emojis: flat, packs: [] })
|
||||
});
|
||||
}
|
||||
|
||||
// Grouped by pack
|
||||
const packsMap = new Map();
|
||||
// "No pack" group for standalone emojis
|
||||
packsMap.set(null, { id: null, name: 'Custom Emojis', tg_name: null, emojis: [] });
|
||||
|
||||
for (const e of emojis) {
|
||||
if (e.pack_id && !packsMap.has(e.pack_id)) {
|
||||
packsMap.set(e.pack_id, {
|
||||
id: e.pack_id,
|
||||
name: e.pack_name || e.tg_title || `Pack #${e.pack_id}`,
|
||||
tg_name: e.tg_name,
|
||||
thumb_url: e.pack_thumb_url || null,
|
||||
emojis: []
|
||||
});
|
||||
}
|
||||
const packKey = e.pack_id ?? null;
|
||||
packsMap.get(packKey).emojis.push({ id: e.id, name: e.name, url: e.url });
|
||||
}
|
||||
|
||||
// Filter out empty groups
|
||||
const packs = [...packsMap.values()].filter(p => p.emojis.length > 0);
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, emojis: flat, packs })
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
console.error(e);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
||||
}
|
||||
});
|
||||
|
||||
// List sticker packs (Admin)
|
||||
router.get('/api/v2/admin/sticker-packs', async (req, res) => {
|
||||
if (!req.session || !req.session.admin) {
|
||||
return res.reply({ code: 403, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||
}
|
||||
try {
|
||||
let packs;
|
||||
try {
|
||||
packs = await db`
|
||||
SELECT sp.id, sp.name, sp.tg_name, sp.tg_title, sp.thumb_url, sp.sticker_count, sp.created_at,
|
||||
count(e.id)::int as actual_count
|
||||
FROM sticker_packs sp
|
||||
LEFT JOIN custom_emojis e ON e.pack_id = sp.id
|
||||
GROUP BY sp.id
|
||||
ORDER BY sp.created_at DESC
|
||||
`;
|
||||
} catch (_) {
|
||||
// thumb_url column may not exist yet — fall back without it
|
||||
packs = await db`
|
||||
SELECT sp.id, sp.name, sp.tg_name, sp.tg_title, NULL as thumb_url, sp.sticker_count, sp.created_at,
|
||||
count(e.id)::int as actual_count
|
||||
FROM sticker_packs sp
|
||||
LEFT JOIN custom_emojis e ON e.pack_id = sp.id
|
||||
GROUP BY sp.id
|
||||
ORDER BY sp.created_at DESC
|
||||
`;
|
||||
}
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, packs })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Delete a sticker pack and all its emojis (Admin)
|
||||
router.delete(/\/api\/v2\/admin\/sticker-packs\/(?<id>\d+)/, async (req, res) => {
|
||||
if (!req.session || !req.session.admin) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||
}
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
|
||||
}
|
||||
const id = req.params.id;
|
||||
try {
|
||||
// Fetch emoji file URLs before deletion for filesystem cleanup
|
||||
const emojiFiles = await db`SELECT url FROM custom_emojis WHERE pack_id = ${id}`;
|
||||
await db`DELETE FROM custom_emojis WHERE pack_id = ${id}`;
|
||||
await db`DELETE FROM sticker_packs WHERE id = ${id}`;
|
||||
|
||||
// Clean up local files
|
||||
for (const e of emojiFiles) {
|
||||
if (e.url && e.url.startsWith('/s/emojis/')) {
|
||||
const filename = path.basename(e.url);
|
||||
await fs.unlink(path.join(cfg.paths.emojis, filename)).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
await db`NOTIFY emojis_updated, '{}'`;
|
||||
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
||||
}
|
||||
});
|
||||
|
||||
// Rename a sticker pack (Admin)
|
||||
router.post(/\/api\/v2\/admin\/sticker-packs\/(?<id>\d+)\/rename/, async (req, res) => {
|
||||
if (!req.session || !req.session.admin) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||
}
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
|
||||
}
|
||||
const id = req.params.id;
|
||||
const newName = (req.body?.name || req.post?.name || '').trim();
|
||||
if (!newName) {
|
||||
return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'name is required' }) });
|
||||
}
|
||||
try {
|
||||
await db`UPDATE sticker_packs SET name = ${newName} WHERE id = ${id}`;
|
||||
await db`NOTIFY emojis_updated, '{}'`;
|
||||
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
||||
@@ -132,5 +287,7 @@ export default (router, tpl) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Edit emoji (Admin only) — handled by bypass middleware in index.mjs
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { handleAvatarUpload, handleAvatarDelete } from "./avatar_handler.mjs";
|
||||
import { handleRethumbUpload } from "./rethumb_handler.mjs";
|
||||
import { handleMemeUpload, handleMemeEdit } from "./meme_upload_handler.mjs";
|
||||
import { handleEmojiUpload, handleEmojiEdit } from "./emoji_upload_handler.mjs";
|
||||
import { handleImportTelegramPack } from "./sticker_pack_handler.mjs";
|
||||
import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs";
|
||||
import { handleMetaExtract } from "./meta_extract_handler.mjs";
|
||||
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
||||
@@ -784,7 +785,7 @@ process.on('uncaughtException', err => {
|
||||
// because the session middleware will have completed by the time router callbacks execute.
|
||||
app.use(async (req, res) => {
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
|
||||
if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload'].includes(req.url.pathname)) return;
|
||||
if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import'].includes(req.url.pathname)) return;
|
||||
// DM attachment upload validates CSRF internally
|
||||
if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return;
|
||||
// Hall manager routes are handled by bypass middleware with their own session auth
|
||||
@@ -874,6 +875,14 @@ process.on('uncaughtException', err => {
|
||||
}
|
||||
});
|
||||
|
||||
// Bypass middleware for Telegram sticker pack import
|
||||
app.use(async (req, res) => {
|
||||
if (req.method === 'POST' && req.url.pathname === '/api/v2/admin/sticker-packs/import') {
|
||||
await handleImportTelegramPack(req, res);
|
||||
req.url.pathname = '/handled_sticker_pack_import_bypass';
|
||||
}
|
||||
});
|
||||
|
||||
// Bypass middleware for hall image uploads (multipart — needs raw body)
|
||||
app.use(async (req, res) => {
|
||||
if (cfg.websrv.halls_enabled === false) return;
|
||||
|
||||
279
src/sticker_pack_handler.mjs
Normal file
279
src/sticker_pack_handler.mjs
Normal file
@@ -0,0 +1,279 @@
|
||||
import { promises as fs } from "fs";
|
||||
import db from "./inc/sql.mjs";
|
||||
import lib from "./inc/lib.mjs";
|
||||
import cfg from "./inc/config.mjs";
|
||||
import { collectBody } from "./inc/multipart.mjs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { execFile as _execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import crypto from "crypto";
|
||||
import fetch from "flumm-fetch"; // used for JSON API calls only
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
|
||||
const execFile = promisify(_execFile);
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MAGICK_POLICY_PATH = path.resolve(__dirname, '../config/magick-policy');
|
||||
const magickEnv = { ...process.env, MAGICK_CONFIGURE_PATH: MAGICK_POLICY_PATH };
|
||||
|
||||
const sendJson = (res, data, code = 200) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
|
||||
const requireAdmin = async (req, res) => {
|
||||
let user = [];
|
||||
if (req.cookies && req.cookies.session) {
|
||||
user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token
|
||||
from "user_sessions"
|
||||
left join "user" on "user".id = "user_sessions".user_id
|
||||
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||
limit 1
|
||||
`;
|
||||
}
|
||||
if (user.length === 0 || !user[0].admin) {
|
||||
sendJson(res, { success: false, message: 'Unauthorized' }, 403);
|
||||
return null;
|
||||
}
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (user[0].csrf_token && (!csrfToken || csrfToken !== user[0].csrf_token)) {
|
||||
sendJson(res, { success: false, message: 'Invalid CSRF token' }, 403);
|
||||
return null;
|
||||
}
|
||||
return user[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Download a file from a URL and return it as a raw Buffer.
|
||||
* Uses Node's built-in https/http directly — flumm-fetch's .buffer() method
|
||||
* calls setEncoding('utf8') which corrupts binary image data.
|
||||
*/
|
||||
const downloadBuffer = (url) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith('https://') ? https : http;
|
||||
mod.get(url, (res) => {
|
||||
if (res.statusCode >= 400) {
|
||||
res.resume(); // drain the socket
|
||||
return reject(new Error('HTTP ' + res.statusCode + ' fetching ' + url));
|
||||
}
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
res.on('error', reject);
|
||||
}).on('error', reject);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a buffer to WebP and write to the emojis dir.
|
||||
* Returns the relative URL path.
|
||||
*/
|
||||
const saveAsWebP = async (buffer, originalExt) => {
|
||||
const randSuffix = crypto.randomBytes(24).toString('hex');
|
||||
const webpFilename = randSuffix + '.webp';
|
||||
const webpPath = path.join(cfg.paths.emojis, webpFilename);
|
||||
|
||||
if (originalExt === 'webp') {
|
||||
await fs.writeFile(webpPath, buffer);
|
||||
} else {
|
||||
const tmpFilename = randSuffix + '_tmp.' + originalExt;
|
||||
const tmpPath = path.join(cfg.paths.emojis, tmpFilename);
|
||||
await fs.writeFile(tmpPath, buffer);
|
||||
try {
|
||||
await execFile('magick', [tmpPath, '-coalesce', '-quality', '80', webpPath], { env: magickEnv });
|
||||
} finally {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const stat = await fs.stat(webpPath);
|
||||
if (!stat || stat.size === 0) throw new Error('WebP conversion produced empty file');
|
||||
|
||||
return '/s/emojis/' + webpFilename;
|
||||
};
|
||||
|
||||
/**
|
||||
* Save a raw buffer directly to the emojis dir with the given extension (no re-encoding).
|
||||
* Used for video stickers (.webm) which browsers can play natively.
|
||||
*/
|
||||
const saveRaw = async (buffer, ext) => {
|
||||
const randSuffix = crypto.randomBytes(24).toString('hex');
|
||||
const filename = randSuffix + '.' + ext;
|
||||
const filePath = path.join(cfg.paths.emojis, filename);
|
||||
await fs.writeFile(filePath, buffer);
|
||||
const stat = await fs.stat(filePath);
|
||||
if (!stat || stat.size === 0) throw new Error('Saved file is empty');
|
||||
return '/s/emojis/' + filename;
|
||||
};
|
||||
|
||||
/**
|
||||
* Import a Telegram sticker pack given its name (short name from t.me/addstickers/<name>).
|
||||
* Creates a sticker_pack record and downloads all stickers as custom_emojis.
|
||||
*/
|
||||
export const handleImportTelegramPack = async (req, res) => {
|
||||
const user = await requireAdmin(req, res);
|
||||
if (!user) return;
|
||||
|
||||
let body = {};
|
||||
try {
|
||||
const raw = await collectBody(req);
|
||||
body = JSON.parse(raw.toString());
|
||||
} catch (e) {
|
||||
return sendJson(res, { success: false, message: 'Invalid JSON body' }, 400);
|
||||
}
|
||||
|
||||
const { pack_name, display_name } = body;
|
||||
if (!pack_name || typeof pack_name !== 'string') {
|
||||
return sendJson(res, { success: false, message: 'pack_name is required' }, 400);
|
||||
}
|
||||
|
||||
// Get Telegram bot token
|
||||
const tgClient = cfg.clients.find(c => c.type === 'tg');
|
||||
if (!tgClient || !tgClient.token) {
|
||||
return sendJson(res, { success: false, message: 'Telegram bot not configured' }, 500);
|
||||
}
|
||||
const botToken = tgClient.token;
|
||||
|
||||
const tgName = pack_name.trim().replace(/^https?:\/\/t\.me\/addstickers\//i, '');
|
||||
if (!tgName) {
|
||||
return sendJson(res, { success: false, message: 'Could not extract pack name from URL' }, 400);
|
||||
}
|
||||
|
||||
console.log(`[STICKER PACK] Importing Telegram pack: ${tgName}`);
|
||||
|
||||
// Check if pack already imported
|
||||
const existing = await db`SELECT id FROM sticker_packs WHERE tg_name = ${tgName} LIMIT 1`;
|
||||
if (existing.length > 0) {
|
||||
return sendJson(res, { success: false, message: `Pack "${tgName}" was already imported (ID ${existing[0].id})` }, 409);
|
||||
}
|
||||
|
||||
// Call Telegram API
|
||||
let stickerSet;
|
||||
try {
|
||||
const apiRes = await fetch(`https://api.telegram.org/bot${botToken}/getStickerSet?name=${encodeURIComponent(tgName)}`);
|
||||
const apiData = await apiRes.json();
|
||||
if (!apiData.ok) {
|
||||
return sendJson(res, { success: false, message: `Telegram API error: ${apiData.description || 'unknown'}` }, 400);
|
||||
}
|
||||
stickerSet = apiData.result;
|
||||
} catch (e) {
|
||||
console.error('[STICKER PACK] Telegram API fetch failed:', e);
|
||||
return sendJson(res, { success: false, message: `Failed to contact Telegram API: ${e.message}` }, 500);
|
||||
}
|
||||
|
||||
const packTitle = display_name?.trim() || stickerSet.title || tgName;
|
||||
const stickers = stickerSet.stickers || [];
|
||||
|
||||
console.log(`[STICKER PACK] Found ${stickers.length} stickers in "${stickerSet.title}"`);
|
||||
|
||||
// Create pack record
|
||||
const [newPack] = await db`
|
||||
INSERT INTO sticker_packs (name, tg_name, tg_title, sticker_count)
|
||||
VALUES (${packTitle}, ${tgName}, ${stickerSet.title}, ${stickers.length})
|
||||
RETURNING id, name, tg_name, tg_title
|
||||
`;
|
||||
|
||||
// Generate a static WebP thumbnail for the pack from the first sticker's Telegram thumbnail.
|
||||
// Telegram always provides a static webp thumbnail for every sticker regardless of type.
|
||||
let packThumbUrl = null;
|
||||
const firstThumb = stickers[0]?.thumbnail;
|
||||
if (firstThumb && firstThumb.file_id) {
|
||||
try {
|
||||
const tRes = await fetch('https://api.telegram.org/bot' + botToken + '/getFile?file_id=' + encodeURIComponent(firstThumb.file_id));
|
||||
const tData = await tRes.json();
|
||||
if (tData.ok && tData.result && tData.result.file_path) {
|
||||
const tBuf = await downloadBuffer('https://api.telegram.org/file/bot' + botToken + '/' + tData.result.file_path);
|
||||
const tExt = (tData.result.file_path.match(/\.([a-z0-9]+)$/i) || ['', 'webp'])[1].toLowerCase();
|
||||
packThumbUrl = await saveAsWebP(tBuf, tExt);
|
||||
await db`UPDATE sticker_packs SET thumb_url = ${packThumbUrl} WHERE id = ${newPack.id}`;
|
||||
console.log('[STICKER PACK] Pack thumbnail saved: ' + packThumbUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[STICKER PACK] Could not save pack thumbnail:', e.message);
|
||||
}
|
||||
}
|
||||
let imported = 0;
|
||||
let failed = 0;
|
||||
const errors = [];
|
||||
|
||||
for (let i = 0; i < stickers.length; i++) {
|
||||
const sticker = stickers[i];
|
||||
try {
|
||||
// Skip only animated TGS stickers (Lottie format — cannot be displayed as-is)
|
||||
const isAnimated = sticker.is_animated || false;
|
||||
const isVideo = sticker.is_video || false;
|
||||
if (isAnimated) {
|
||||
console.log('[STICKER PACK] Skipping animated (TGS) sticker ' + (i + 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileId = sticker.file_id;
|
||||
|
||||
// Resolve the download URL via getFile
|
||||
const fileRes = await fetch('https://api.telegram.org/bot' + botToken + '/getFile?file_id=' + encodeURIComponent(fileId));
|
||||
const fileData = await fileRes.json();
|
||||
if (!fileData.ok || !fileData.result || !fileData.result.file_path) {
|
||||
throw new Error('getFile failed: ' + (fileData.description || 'no file_path'));
|
||||
}
|
||||
|
||||
const filePath = fileData.result.file_path;
|
||||
const extMatch = filePath.match(/\.([a-z0-9]+)$/i);
|
||||
const ext = extMatch ? extMatch[1].toLowerCase() : 'webp';
|
||||
|
||||
const downloadUrl = 'https://api.telegram.org/file/bot' + botToken + '/' + filePath;
|
||||
const buffer = await downloadBuffer(downloadUrl);
|
||||
|
||||
// Generate emoji name from pack name + index
|
||||
const safeName = tgName.replace(/[^a-z0-9_]/gi, '_').toLowerCase();
|
||||
const emojiName = safeName + '_' + String(i + 1).padStart(3, '0');
|
||||
|
||||
// Check for name conflict
|
||||
const nameConflict = await db`SELECT id FROM custom_emojis WHERE name = ${emojiName} LIMIT 1`;
|
||||
const finalName = nameConflict.length > 0 ? emojiName + '_' + crypto.randomBytes(3).toString('hex') : emojiName;
|
||||
|
||||
// Save: video stickers (.webm) stored as-is; static stickers converted to WebP
|
||||
let savedUrl;
|
||||
if (isVideo) {
|
||||
savedUrl = await saveRaw(buffer, ext); // keeps original webm, no re-encoding
|
||||
console.log('[STICKER PACK] Saved video sticker ' + (i + 1) + ' as ' + ext);
|
||||
} else {
|
||||
savedUrl = await saveAsWebP(buffer, ext);
|
||||
}
|
||||
|
||||
await db`
|
||||
INSERT INTO custom_emojis (name, url, pack_id)
|
||||
VALUES (${finalName}, ${savedUrl}, ${newPack.id})
|
||||
`;
|
||||
|
||||
imported++;
|
||||
console.log('[STICKER PACK] Imported sticker ' + (i + 1) + '/' + stickers.length + ': ' + finalName + ' (' + (isVideo ? 'video' : 'static') + ')');
|
||||
|
||||
// Small delay to avoid Telegram rate limits
|
||||
if (i < stickers.length - 1) await new Promise(function(resolve) { setTimeout(resolve, 50); });
|
||||
|
||||
} catch (e) {
|
||||
console.error('[STICKER PACK] Failed sticker ' + (i + 1) + ':', e.message);
|
||||
errors.push('Sticker ' + (i + 1) + ': ' + e.message);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update pack with actual imported count
|
||||
await db`UPDATE sticker_packs SET sticker_count = ${imported} WHERE id = ${newPack.id}`;
|
||||
|
||||
// Notify clients that emojis changed
|
||||
await db`NOTIFY emojis_updated, '{}'`;
|
||||
|
||||
console.log(`[STICKER PACK] Done. Imported: ${imported}, Failed: ${failed}`);
|
||||
|
||||
return sendJson(res, {
|
||||
success: true,
|
||||
pack: { ...newPack, sticker_count: imported },
|
||||
imported,
|
||||
failed,
|
||||
errors
|
||||
});
|
||||
};
|
||||
@@ -2,54 +2,85 @@
|
||||
<div class="pagewrapper">
|
||||
<div id="main" class="admin-container">
|
||||
<div class="container">
|
||||
<h2>Custom Emojis</h2>
|
||||
<h2>Custom Emojis & Sticker Packs</h2>
|
||||
|
||||
<!-- Telegram Pack Import -->
|
||||
<div class="admin-form-container sp-import-card"
|
||||
style="margin-bottom: 28px; text-align: left; background: var(--dropdown-bg); padding: 20px; border: 1px solid var(--nav-border-color); border-radius: 10px;">
|
||||
<h4 style="margin: 0 0 6px; font-size: 1rem; display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fa-brands fa-telegram" style="color: #26a5e4;"></i>
|
||||
Import Telegram Sticker Pack
|
||||
</h4>
|
||||
<p style="font-size: 0.82em; color: rgba(255,255,255,0.5); margin: 0 0 14px;">
|
||||
Enter a Telegram sticker pack URL (e.g. <code>https://t.me/addstickers/PackName</code>) or just the short name.
|
||||
All stickers will be downloaded and added as a new sticker pack.
|
||||
</p>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end;">
|
||||
<div style="flex: 1; min-width: 200px;">
|
||||
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">Pack URL or Name</label>
|
||||
<input type="text" id="sp-pack-url" placeholder="https://t.me/addstickers/PackName"
|
||||
style="width: 100%; background: var(--bg); border: 1px solid var(--black); padding: 7px 10px; color: var(--white); border-radius: 5px; box-sizing: border-box;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">Display Name (optional)</label>
|
||||
<input type="text" id="sp-display-name" placeholder="My Pack Name"
|
||||
style="background: var(--bg); border: 1px solid var(--black); padding: 7px 10px; color: var(--white); border-radius: 5px;">
|
||||
</div>
|
||||
<button id="sp-import-btn" class="btn-upload"
|
||||
style="width: auto; padding: 8px 20px; border: 1px solid var(--nav-border-color); background: #26a5e4; color: #fff; cursor: pointer; border-radius: 5px; font-weight: 700; white-space: nowrap;">
|
||||
<i class="fa-solid fa-download"></i> Import Pack
|
||||
</button>
|
||||
</div>
|
||||
<div id="sp-import-status" style="margin-top: 12px; font-size: 0.85em; display: none; padding: 10px 14px; border-radius: 7px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Sticker Packs -->
|
||||
<div id="sp-packs-section" style="margin-bottom: 28px;">
|
||||
<h3 style="margin: 0 0 12px; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.6;">Imported Sticker Packs</h3>
|
||||
<div id="sp-packs-list" style="display: flex; flex-direction: column; gap: 10px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Standalone Emojis -->
|
||||
<div class="admin-form-container"
|
||||
style="margin-bottom: 20px; text-align: left; background: var(--dropdown-bg); padding: 15px; border: 1px solid var(--nav-border-color);">
|
||||
<h4>Add New Emoji</h4>
|
||||
style="margin-bottom: 20px; text-align: left; background: var(--dropdown-bg); padding: 15px; border: 1px solid var(--nav-border-color); border-radius: 8px;">
|
||||
<h4 style="margin: 0 0 12px;">Add Standalone Emoji</h4>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end;">
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">Name</label>
|
||||
<input type="text" id="emoji-name" placeholder="" style="background: var(--bg); border: 1px solid var(--black); padding: 5px; color: var(--white);">
|
||||
<input type="text" id="emoji-name" placeholder=""
|
||||
style="background: var(--bg); border: 1px solid var(--black); padding: 5px; color: var(--white);">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">Image File</label>
|
||||
<input type="file" id="emoji-file" style="background: var(--bg); border: 1px solid var(--black); padding: 4px; color: var(--white);">
|
||||
<input type="file" id="emoji-file"
|
||||
style="background: var(--bg); border: 1px solid var(--black); padding: 4px; color: var(--white);">
|
||||
</div>
|
||||
<button id="add-emoji" class="btn-upload" style="width: auto; padding: 7px 20px; border: 1px solid var(--nav-border-color); background: var(--bg); color: var(--white); cursor: pointer;">Add</button>
|
||||
<button id="add-emoji" class="btn-upload"
|
||||
style="width: auto; padding: 7px 20px; border: 1px solid var(--nav-border-color); background: var(--bg); color: var(--white); cursor: pointer;">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div id="emoji-list" class="emoji-grid">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div id="emoji-list" class="emoji-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Emoji Modal -->
|
||||
<div id="edit-emoji-modal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 460px; background: var(--dropdown-bg, #222); border: 1px solid var(--nav-border-color, #444); border-radius: 8px; padding: 20px;">
|
||||
<h3 style="margin-top: 0; margin-bottom: 15px; border-bottom: 1px solid var(--nav-border-color, rgba(255,255,255,0.1)); padding-bottom: 10px; color: var(--white);">Edit Emoji</h3>
|
||||
|
||||
<input type="hidden" id="edit-emoji-id">
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 12px; text-align: left;">
|
||||
<div style="text-align: center;">
|
||||
<img id="edit-emoji-preview" src="" alt="" style="height: 64px; width: 64px; object-fit: contain; border-radius: 4px; background: rgba(0,0,0,0.3);">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.85em; margin-bottom: 4px; color: rgba(255,255,255,0.7);">Name (lowercase a-z, 0-9, _, - only)</label>
|
||||
<input type="text" id="edit-emoji-name" style="width: 100%; background: var(--bg, #111); border: 1px solid var(--black, #000); padding: 8px; color: var(--white); border-radius: 4px; box-sizing: border-box;">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.85em; margin-bottom: 4px; color: rgba(255,255,255,0.7);">Replace Image — Upload New File</label>
|
||||
<label style="display: block; font-size: 0.85em; margin-bottom: 4px; color: rgba(255,255,255,0.7);">Replace Image — Upload New File</label>
|
||||
<input type="file" id="edit-emoji-file" accept="image/*" style="width: 100%; background: var(--bg, #111); border: 1px solid var(--black, #000); padding: 8px; color: var(--white); border-radius: 4px; box-sizing: border-box;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions" style="margin-top: 20px; display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button onclick="window.emojiAdmin.closeEditModal()" class="btn-cancel" style="padding: 8px 16px; background: rgba(255,255,255,0.1); color: var(--white); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; cursor: pointer;">Cancel</button>
|
||||
<button onclick="window.emojiAdmin.saveEmoji()" class="btn-save" style="padding: 8px 16px; background: #28a745; color: white; border: none; border-radius: 4px; cursor: pointer;">Save Changes</button>
|
||||
@@ -60,168 +91,313 @@
|
||||
<script>
|
||||
(() => {
|
||||
var i18n = window.f0ckI18n || {};
|
||||
const esc = (s) => (s || '').toString().replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
var csrf = '{{ csrf_token }}';
|
||||
|
||||
const loadEmojis = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v2/emojis');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
window.emojiAdmin.emojis = data.emojis;
|
||||
const grid = document.getElementById('emoji-list');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = data.emojis.map(e =>
|
||||
'<div class="emoji-card">' +
|
||||
'<button class="emoji-delete" onclick="window.emojiAdmin.deleteEmoji(' + e.id + ')" title="Delete">✕</button>' +
|
||||
'<img class="emoji-preview" src="' + e.url + '" alt=":' + esc(e.name) + ':">' +
|
||||
'<span class="emoji-label">:' + esc(e.name) + ':</span>' +
|
||||
'<button onclick="window.emojiAdmin.openEditModal(' + e.id + ')" style="margin-top: 6px; width: 100%; padding: 4px 0; font-size: 0.75em; background: #28a745; color: white; border: none; border-radius: 3px; cursor: pointer;">Edit</button>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
function esc(s) {
|
||||
return (s || '').toString()
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
} catch (err) { console.error('[EMOJI_ADMIN] Load Error:', err); }
|
||||
};
|
||||
|
||||
const addEmoji = async (e) => {
|
||||
/* Returns an HTML string for a sticker preview — <video> for .webm, <img> otherwise */
|
||||
function stickerEl(url, name, extraAttrs) {
|
||||
var a = extraAttrs || '';
|
||||
if (url && url.slice(-5) === '.webm') {
|
||||
return '<video class="emoji-preview" src="' + url + '" autoplay loop muted playsinline title=":' + esc(name) + ':"' + a + '></video>';
|
||||
}
|
||||
return '<img class="emoji-preview" src="' + url + '" alt=":' + esc(name) + ':"' + a + '>';
|
||||
}
|
||||
|
||||
/* ── DATA LOAD ── */
|
||||
function loadAll() {
|
||||
Promise.all([
|
||||
fetch('/api/v2/emojis'),
|
||||
fetch('/api/v2/admin/sticker-packs', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
]).then(function(responses) {
|
||||
return Promise.all([responses[0].json(), responses[1].json()]);
|
||||
}).then(function(results) {
|
||||
var emojisData = results[0];
|
||||
var packsData = results[1];
|
||||
window.emojiAdmin.emojis = emojisData.emojis || [];
|
||||
window.emojiAdmin.packs = packsData.packs || [];
|
||||
renderStandaloneEmojis(window.emojiAdmin.emojis);
|
||||
renderPacksList(window.emojiAdmin.packs);
|
||||
}).catch(function(err) {
|
||||
console.error('[EMOJI_ADMIN] Load Error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── STANDALONE EMOJIS ── */
|
||||
function renderStandaloneEmojis(emojis) {
|
||||
var grid = document.getElementById('emoji-list');
|
||||
if (!grid) return;
|
||||
var standalone = emojis.filter(function(e) { return !e.pack_id; });
|
||||
if (standalone.length === 0) {
|
||||
grid.innerHTML = '<p style="opacity:0.5;font-size:0.85em;">No standalone emojis yet.</p>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = standalone.map(function(e) {
|
||||
return '<div class="emoji-card">' +
|
||||
'<button class="emoji-delete" onclick="window.emojiAdmin.deleteEmoji(' + e.id + ')" title="Delete">x</button>' +
|
||||
stickerEl(e.url, e.name) +
|
||||
'<span class="emoji-label">:' + esc(e.name) + ':</span>' +
|
||||
'<button onclick="window.emojiAdmin.openEditModal(' + e.id + ')" style="margin-top:6px;width:100%;padding:4px 0;font-size:0.75em;background:#28a745;color:white;border:none;border-radius:3px;cursor:pointer;">Edit</button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ── STICKER PACKS LIST ── */
|
||||
function renderPacksList(packs) {
|
||||
var container = document.getElementById('sp-packs-list');
|
||||
if (!container) return;
|
||||
if (packs.length === 0) {
|
||||
container.innerHTML = '<p style="opacity:0.5;font-size:0.85em;">No sticker packs imported yet.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = packs.map(function(p) {
|
||||
var tgLink = p.tg_name
|
||||
? '<a href="https://t.me/addstickers/' + esc(p.tg_name) + '" target="_blank" rel="noopener" style="font-size:0.72em;color:#26a5e4;text-decoration:none;opacity:0.8;">@' + esc(p.tg_name) + ' ↗</a>'
|
||||
: '';
|
||||
var count = (p.actual_count !== undefined && p.actual_count !== null) ? p.actual_count : (p.sticker_count || 0);
|
||||
return '<div class="sp-pack-row" data-pack-id="' + p.id + '" style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:10px;padding:14px 16px;">' +
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;">' +
|
||||
'<div style="flex:1;min-width:0;">' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">' +
|
||||
'<span class="sp-pack-name" style="font-size:0.95rem;font-weight:700;">' + esc(p.name) + '</span>' +
|
||||
tgLink +
|
||||
'</div>' +
|
||||
'<div style="font-size:0.76em;opacity:0.5;margin-top:2px;">' + count + ' sticker(s)</div>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:8px;flex-shrink:0;">' +
|
||||
'<button onclick="window.emojiAdmin.renamePack(' + p.id + ')" style="padding:5px 12px;border-radius:5px;border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.07);color:#fff;cursor:pointer;font-size:0.8em;">Rename</button>' +
|
||||
'<button onclick="window.emojiAdmin.togglePackEmojis(' + p.id + ')" style="padding:5px 12px;border-radius:5px;border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.07);color:#fff;cursor:pointer;font-size:0.8em;">View</button>' +
|
||||
'<button onclick="window.emojiAdmin.deletePack(' + p.id + ')" style="padding:5px 12px;border-radius:5px;border:1px solid rgba(180,40,40,0.5);background:rgba(180,40,40,0.15);color:#e55;cursor:pointer;font-size:0.8em;">Delete</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="sp-emojis-' + p.id + '" style="display:none;margin-top:12px;"></div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function togglePackEmojis(packId) {
|
||||
var container = document.getElementById('sp-emojis-' + packId);
|
||||
if (!container) return;
|
||||
if (container.style.display !== 'none') { container.style.display = 'none'; return; }
|
||||
container.style.display = 'block';
|
||||
if (container.dataset.loaded) return;
|
||||
|
||||
var packEmojis = (window.emojiAdmin.emojis || []).filter(function(e) { return e.pack_id === packId; });
|
||||
if (packEmojis.length === 0) {
|
||||
container.innerHTML = '<p style="opacity:0.5;font-size:0.82em;padding:8px 0;">No stickers.</p>';
|
||||
} else {
|
||||
container.innerHTML = '<div class="emoji-grid" style="max-height:320px;overflow-y:auto;">' +
|
||||
packEmojis.map(function(e) {
|
||||
return '<div class="emoji-card" style="position:relative;">' +
|
||||
'<button class="emoji-delete" onclick="window.emojiAdmin.deleteEmoji(' + e.id + ')" title="Delete">x</button>' +
|
||||
stickerEl(e.url, e.name, ' loading="lazy"') +
|
||||
'<span class="emoji-label">:' + esc(e.name) + ':</span>' +
|
||||
'</div>';
|
||||
}).join('') +
|
||||
'</div>';
|
||||
}
|
||||
container.dataset.loaded = '1';
|
||||
}
|
||||
|
||||
/* ── STANDALONE EMOJI ACTIONS ── */
|
||||
function addEmoji(e) {
|
||||
if (e) e.preventDefault();
|
||||
const name = document.getElementById('emoji-name').value;
|
||||
const fileInput = document.getElementById('emoji-file');
|
||||
|
||||
var name = document.getElementById('emoji-name').value;
|
||||
var fileInput = document.getElementById('emoji-file');
|
||||
if (!name || !fileInput.files[0]) return alert('Fill Name and select a File');
|
||||
|
||||
const btn = document.getElementById('add-emoji');
|
||||
const oldText = btn.textContent;
|
||||
var btn = document.getElementById('add-emoji');
|
||||
var oldText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = i18n.uploading || 'Uploading...';
|
||||
|
||||
const formData = new FormData();
|
||||
var formData = new FormData();
|
||||
formData.append('name', name);
|
||||
if (fileInput.files[0]) {
|
||||
formData.append('file', fileInput.files[0]);
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = { 'X-Requested-With': 'XMLHttpRequest' };
|
||||
const csrf = '{{ csrf_token }}';
|
||||
var headers = { 'X-Requested-With': 'XMLHttpRequest' };
|
||||
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||
|
||||
const res = await fetch('/api/v2/admin/emojis', {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
fetch('/api/v2/admin/emojis', { method: 'POST', headers: headers, body: formData })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
document.getElementById('emoji-name').value = '';
|
||||
document.getElementById('emoji-file').value = '';
|
||||
loadEmojis();
|
||||
loadAll();
|
||||
} else {
|
||||
alert('Failed: ' + (data.message || data.msg || 'Unknown error'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[EMOJI_ADMIN] Add Error:', e);
|
||||
alert('Error: ' + e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = oldText;
|
||||
})
|
||||
.catch(function(err) { alert('Error: ' + err.message); })
|
||||
.finally(function() { btn.disabled = false; btn.textContent = oldText; });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEmoji = async (id) => {
|
||||
function deleteEmoji(id) {
|
||||
if (!confirm('Delete this emoji?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/v2/admin/emojis/' + id + '/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': '{{ csrf_token }}' }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadEmojis();
|
||||
} else {
|
||||
alert('Delete failed');
|
||||
fetch('/api/v2/admin/emojis/' + id + '/delete', { method: 'POST', headers: { 'X-CSRF-Token': csrf } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) { if (data.success) loadAll(); else alert('Delete failed'); })
|
||||
.catch(function(err) { console.error(err); });
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const openEditModal = (id) => {
|
||||
const emoji = (window.emojiAdmin.emojis || []).find(e => e.id === id);
|
||||
function openEditModal(id) {
|
||||
var emoji = (window.emojiAdmin.emojis || []).find(function(e) { return e.id === id; });
|
||||
if (!emoji) return;
|
||||
|
||||
document.getElementById('edit-emoji-id').value = emoji.id;
|
||||
document.getElementById('edit-emoji-name').value = emoji.name;
|
||||
document.getElementById('edit-emoji-file').value = '';
|
||||
|
||||
const preview = document.getElementById('edit-emoji-preview');
|
||||
var preview = document.getElementById('edit-emoji-preview');
|
||||
preview.src = emoji.url;
|
||||
preview.alt = ':' + emoji.name + ':';
|
||||
|
||||
const modal = document.getElementById('edit-emoji-modal');
|
||||
var modal = document.getElementById('edit-emoji-modal');
|
||||
if (modal) modal.style.display = 'flex';
|
||||
};
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
const modal = document.getElementById('edit-emoji-modal');
|
||||
function closeEditModal() {
|
||||
var modal = document.getElementById('edit-emoji-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
document.getElementById('edit-emoji-file').value = '';
|
||||
};
|
||||
}
|
||||
|
||||
const saveEmoji = async () => {
|
||||
const id = document.getElementById('edit-emoji-id').value;
|
||||
const name = document.getElementById('edit-emoji-name').value.trim().toLowerCase();
|
||||
const fileInput = document.getElementById('edit-emoji-file');
|
||||
function saveEmoji() {
|
||||
var id = document.getElementById('edit-emoji-id').value;
|
||||
var name = document.getElementById('edit-emoji-name').value.trim().toLowerCase();
|
||||
var fileInput = document.getElementById('edit-emoji-file');
|
||||
|
||||
if (!name) return alert('Emoji name is required');
|
||||
if (!/^[a-z0-9_-]+$/.test(name)) return alert('Invalid name. Use lowercase a-z, 0-9, _, - only.');
|
||||
|
||||
const btn = document.querySelector('#edit-emoji-modal .btn-save');
|
||||
const oldText = btn.textContent;
|
||||
var btn = document.querySelector('#edit-emoji-modal .btn-save');
|
||||
var oldText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Saving...';
|
||||
|
||||
const formData = new FormData();
|
||||
var formData = new FormData();
|
||||
formData.append('name', name);
|
||||
if (fileInput.files[0]) {
|
||||
formData.append('file', fileInput.files[0]);
|
||||
}
|
||||
if (fileInput.files[0]) formData.append('file', fileInput.files[0]);
|
||||
|
||||
try {
|
||||
const headers = { 'X-Requested-With': 'XMLHttpRequest' };
|
||||
const csrf = '{{ csrf_token }}';
|
||||
var headers = { 'X-Requested-With': 'XMLHttpRequest' };
|
||||
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||
|
||||
const res = await fetch('/api/v2/admin/emojis/' + id + '/edit', {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: formData
|
||||
});
|
||||
fetch('/api/v2/admin/emojis/' + id + '/edit', { method: 'POST', headers: headers, body: formData })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) { closeEditModal(); loadAll(); }
|
||||
else alert('Save failed: ' + (data.message || data.msg || 'Unknown error'));
|
||||
})
|
||||
.catch(function(err) { alert('Save failed: ' + err.message); })
|
||||
.finally(function() { btn.disabled = false; btn.textContent = oldText; });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
/* ── STICKER PACK ACTIONS ── */
|
||||
function importPack() {
|
||||
var packUrl = (document.getElementById('sp-pack-url').value || '').trim();
|
||||
var displayName = (document.getElementById('sp-display-name').value || '').trim();
|
||||
var statusEl = document.getElementById('sp-import-status');
|
||||
var btn = document.getElementById('sp-import-btn');
|
||||
|
||||
if (!packUrl) return alert('Please enter a sticker pack URL or name.');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Importing...';
|
||||
statusEl.style.display = 'none';
|
||||
|
||||
fetch('/api/v2/admin/sticker-packs/import', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify({ pack_name: packUrl, display_name: displayName })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
statusEl.style.display = 'block';
|
||||
if (data.success) {
|
||||
closeEditModal();
|
||||
loadEmojis();
|
||||
statusEl.style.background = 'rgba(40,167,69,0.15)';
|
||||
statusEl.style.border = '1px solid rgba(40,167,69,0.4)';
|
||||
statusEl.style.color = '#4caf50';
|
||||
var packName = (data.pack && data.pack.name) ? data.pack.name : packUrl;
|
||||
statusEl.innerHTML = 'Imported "' + esc(packName) + '" — ' + (data.imported || 0) + ' sticker(s) added' + (data.failed ? ', ' + data.failed + ' failed' : '') + '.';
|
||||
document.getElementById('sp-pack-url').value = '';
|
||||
document.getElementById('sp-display-name').value = '';
|
||||
loadAll();
|
||||
} else {
|
||||
alert('Save failed: ' + (data.message || data.msg || 'Unknown error'));
|
||||
statusEl.style.background = 'rgba(180,40,40,0.15)';
|
||||
statusEl.style.border = '1px solid rgba(180,40,40,0.4)';
|
||||
statusEl.style.color = '#e55';
|
||||
statusEl.textContent = data.message || 'Import failed';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[EMOJI_ADMIN] Edit Error:', e);
|
||||
alert('Save failed: ' + e.message);
|
||||
} finally {
|
||||
})
|
||||
.catch(function(err) {
|
||||
statusEl.style.display = 'block';
|
||||
statusEl.style.background = 'rgba(180,40,40,0.15)';
|
||||
statusEl.style.border = '1px solid rgba(180,40,40,0.4)';
|
||||
statusEl.style.color = '#e55';
|
||||
statusEl.textContent = 'Error: ' + err.message;
|
||||
})
|
||||
.finally(function() {
|
||||
btn.disabled = false;
|
||||
btn.textContent = oldText;
|
||||
btn.innerHTML = '<i class="fa-solid fa-download"></i> Import Pack';
|
||||
});
|
||||
}
|
||||
|
||||
function deletePack(id) {
|
||||
if (!confirm('Delete this sticker pack AND all its stickers? This cannot be undone.')) return;
|
||||
fetch('/api/v2/admin/sticker-packs/' + id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrf, 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) { if (data.success) loadAll(); else alert('Delete failed: ' + (data.message || 'Unknown error')); })
|
||||
.catch(function(err) { alert('Error: ' + err.message); });
|
||||
}
|
||||
|
||||
function renamePack(id) {
|
||||
var pack = (window.emojiAdmin.packs || []).find(function(p) { return p.id === id; });
|
||||
var currentName = pack ? pack.name : '';
|
||||
var newName = prompt('New pack name:', currentName);
|
||||
if (!newName || newName.trim() === currentName) return;
|
||||
fetch('/api/v2/admin/sticker-packs/' + id + '/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: JSON.stringify({ name: newName.trim() })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) { if (data.success) loadAll(); else alert('Rename failed: ' + (data.message || 'Unknown error')); })
|
||||
.catch(function(err) { alert('Error: ' + err.message); });
|
||||
}
|
||||
};
|
||||
|
||||
// Global scope for onclick handlers
|
||||
window.emojiAdmin = { deleteEmoji, openEditModal, closeEditModal, saveEmoji, emojis: [] };
|
||||
window.emojiAdmin = {
|
||||
deleteEmoji: deleteEmoji,
|
||||
openEditModal: openEditModal,
|
||||
closeEditModal: closeEditModal,
|
||||
saveEmoji: saveEmoji,
|
||||
importPack: importPack,
|
||||
deletePack: deletePack,
|
||||
renamePack: renamePack,
|
||||
togglePackEmojis: togglePackEmojis,
|
||||
emojis: [],
|
||||
packs: []
|
||||
};
|
||||
|
||||
const btnAddEmoji = document.getElementById('add-emoji');
|
||||
if (btnAddEmoji) btnAddEmoji.addEventListener('click', addEmoji);
|
||||
document.getElementById('add-emoji').addEventListener('click', addEmoji);
|
||||
document.getElementById('sp-import-btn').addEventListener('click', importPack);
|
||||
|
||||
// Live Update Listener (SSE dispatched via f0ckm.js)
|
||||
document.addEventListener('f0ck:emojis_updated', loadEmojis);
|
||||
// Reload when SSE emits emojis_updated
|
||||
document.addEventListener('f0ck:emojis_updated', loadAll);
|
||||
|
||||
loadEmojis();
|
||||
loadAll();
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user