telegram stickers v1

This commit is contained in:
2026-07-15 22:56:26 +02:00
parent afc776d948
commit a7c811025e
7 changed files with 1065 additions and 168 deletions

View 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);

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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;
};

View File

@@ -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;

View 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
});
};

View File

@@ -2,54 +2,85 @@
<div class="pagewrapper">
<div id="main" class="admin-container">
<div class="container">
<h2>Custom Emojis</h2>
<h2>Custom Emojis &amp; 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 &mdash; 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
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');
function esc(s) {
return (s || '').toString()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/* 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;
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) + ':">' +
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('');
'</div>';
}).join('');
}
} catch (err) { console.error('[EMOJI_ADMIN] Load Error:', err); }
};
const addEmoji = async (e) => {
/* ── 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) + ' &nearr;</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) + '" &mdash; ' + (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>