added stickers and emojies list

This commit is contained in:
onysd 2026-07-22 12:18:27 +03:00
parent 24276d1379
commit 43894f2fc9
26 changed files with 1118 additions and 14 deletions

View file

@ -10,7 +10,8 @@ import type {
MessageListResponse,
OfficialStarGiftListResponse,
StarGiftCollectiblePreview,
StarGiftListResponse
StarGiftListResponse,
StickerSetListResponse
} from "./types";
export class APIError extends Error {
@ -67,6 +68,9 @@ export const api = {
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
},
gifts: () => request<StarGiftListResponse>("/api/gifts"),
stickerSets: (kind: string) => request<StickerSetListResponse>(`/api/stickers?kind=${encodeURIComponent(kind)}`),
stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`),
stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`,
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),
giftAnimation: (id: string) => request<Record<string, unknown>>(`/api/gifts/${encodeURIComponent(id)}/animation`),

View file

@ -8,7 +8,9 @@ import {
Shield,
ShieldCheck,
Users,
Gift
Gift,
Sticker,
Smile
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
@ -76,6 +78,8 @@ export function Shell({
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{t("layout.accounts")}</NavLink>
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink>
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{t("layout.stickers")}</NavLink>
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{t("layout.emoji")}</NavLink>
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button
className="nav-section-toggle"

View file

@ -0,0 +1,71 @@
import lottie from "lottie-web/build/player/lottie_light_canvas";
import { useEffect, useRef, useState } from "react";
import { api, errorMessage } from "../api";
// One preview cell in a sticker/emoji set's preview grid. Mounted only while
// its modal is open, and only for the one set being viewed — not on the list
// page — so this never repeats the "100 animations on one page" lag.
//
// Documents come back either as Lottie JSON (TGS-animated) or as a raw raster
// image (static webp/png/etc, e.g. the "GestosLol" pack) — branch on the
// response's real Content-Type rather than assuming every document animates.
export function StickerDocumentPreview({ documentID, className = "", showError = true }: { documentID: string; className?: string; showError?: boolean }) {
const host = useRef<HTMLDivElement>(null);
const animation = useRef<ReturnType<typeof lottie.loadAnimation> | null>(null);
const [error, setError] = useState("");
const [imageURL, setImageURL] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let objectURL: string | null = null;
setError("");
setImageURL(null);
fetch(api.stickerDocumentAnimationURL(documentID), { credentials: "same-origin" })
.then(async (response) => {
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.error || response.statusText);
}
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("json")) {
const data = await response.json();
if (cancelled || !host.current) return;
animation.current?.destroy();
animation.current = lottie.loadAnimation({
container: host.current,
renderer: "canvas",
loop: true,
autoplay: true,
animationData: data
});
return;
}
const blob = await response.blob();
if (cancelled) return;
objectURL = URL.createObjectURL(blob);
setImageURL(objectURL);
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err));
});
return () => {
cancelled = true;
animation.current?.destroy();
animation.current = null;
if (objectURL) URL.revokeObjectURL(objectURL);
};
}, [documentID]);
return (
<div className={`sticker-doc-cell ${className}`.trim()}>
{imageURL ? (
<img className="sticker-doc-image" src={imageURL} alt="" />
) : (
<div className="sticker-doc-canvas" ref={host} />
)}
{error && showError && <span className="sticker-doc-error">{error}</span>}
</div>
);
}

View file

@ -59,6 +59,10 @@ const translations: Record<string, string> = {
"route.messagesSubtitle": "Console / Messages",
"route.gifts": "Star Gifts",
"route.giftsSubtitle": "Console / Star Gifts",
"route.stickers": "Stickers",
"route.stickersSubtitle": "Console / Stickers",
"route.emoji": "Emoji",
"route.emojiSubtitle": "Console / Emoji",
"layout.navigation": "Navigation",
"layout.primaryNav": "Primary navigation",
"layout.dashboard": "Overview",
@ -66,6 +70,8 @@ const translations: Record<string, string> = {
"layout.channels": "Supergroups / Channels",
"layout.messages": "Messages",
"layout.gifts": "Star Gifts",
"layout.stickers": "Stickers",
"layout.emoji": "Emoji",
"layout.privateMessages": "Private",
"layout.groupMessages": "Groups",
"layout.runtime": "Runtime",
@ -315,6 +321,30 @@ const translations: Record<string, string> = {
"gifts.validationReady": "Validation passed",
"gifts.validationHint": "Review the normalized metadata, then confirm the import.",
"gifts.confirmState": "Apply the validated state change to gift #{id}?",
"stickers.pageTitle": "Stickers",
"stickers.eyebrow": "Sticker packs — system packs (dice, animated emoji, gifts) aren't shown here, they're not hand-edited",
"stickers.emojiPageTitle": "Emoji",
"stickers.emojiEyebrow": "Custom-emoji packs — system packs aren't shown here, they're not hand-edited",
"stickers.total": "Total sets",
"stickers.searchPlaceholder": "Search set ID, short name or title",
"stickers.listSummary": "Showing {shown} of {total}",
"stickers.logo": "Logo",
"stickers.id": "ID",
"stickers.shortName": "Short name",
"stickers.title": "Title",
"stickers.count": "Documents",
"stickers.official": "Official",
"stickers.archived": "Archived",
"stickers.sortOrder": "Sort order",
"stickers.createdAt": "Created",
"stickers.archive": "Archive",
"stickers.unarchive": "Unarchive",
"stickers.saveOrder": "Save",
"stickers.saveTitle": "Save",
"stickers.delete": "Delete",
"stickers.view": "View",
"stickers.previewEyebrow": "Set contents",
"stickers.previewEmpty": "This set has no documents.",
"collectibles.manage": "Attribute pool",
"collectibles.title": "Collectible pool · Gift #{id}",
"collectibles.eyebrow": "Unique gift attributes",

View file

@ -9,6 +9,7 @@ import { GroupMessagesPage } from "./GroupMessagesPage";
import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
import { GiftsPage } from "./GiftsPage";
import { StickerSetsPage } from "./StickerSetsPage";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
@ -28,6 +29,12 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/gifts") {
return <GiftsPage />;
}
if (route.path === "/stickers") {
return <StickerSetsPage kind="stickers" />;
}
if (route.path === "/emoji") {
return <StickerSetsPage kind="emoji" />;
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return (
<MessageDetailPage

View file

@ -0,0 +1,86 @@
import { ChevronLeft, ChevronRight, Loader2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
import { Alert } from "../components/ui";
import { useI18n } from "../i18n";
import type { StickerSetRow } from "../types";
// Cells per modal page. Each page fully replaces the previous one (rather
// than appending to a growing "load more" list) — accumulating batches made
// the Lottie canvases overlap visually, and a fresh page also means each
// batch's animations are properly unmounted before the next one mounts.
const PAGE_SIZE = 24;
export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; onClose: () => void }) {
const { t } = useI18n();
const [documentIDs, setDocumentIDs] = useState<string[] | null>(null);
const [error, setError] = useState("");
const [page, setPage] = useState(1);
useEffect(() => {
let cancelled = false;
setDocumentIDs(null);
setError("");
setPage(1);
api.stickerSetDocuments(set.ID).then((result) => {
if (cancelled) return;
setDocumentIDs(result.document_ids ?? []);
}).catch((err) => {
if (!cancelled) setError(errorMessage(err));
});
return () => { cancelled = true; };
}, [set.ID]);
const total = documentIDs?.length ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pageStart = (currentPage - 1) * PAGE_SIZE;
const pageItems = documentIDs?.slice(pageStart, pageStart + PAGE_SIZE) ?? [];
const rangeStart = pageItems.length === 0 ? 0 : pageStart + 1;
const rangeEnd = rangeStart === 0 ? 0 : rangeStart + pageItems.length - 1;
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal sticker-preview-modal" role="dialog" aria-modal="true" aria-label={set.Title || `#${set.ID}`}>
<div className="modal-head">
<div>
<div className="eyebrow">{t("stickers.previewEyebrow")}</div>
<h2>{set.Title || `#${set.ID}`}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={t("action.close")}><X size={15} /></button>
</div>
<div className="command-body">
{error && <Alert>{error}</Alert>}
{!error && documentIDs === null && (
<div className="loading-line"><Loader2 className="spin" size={18} /> {t("common.loading")}</div>
)}
{documentIDs !== null && total === 0 && !error && (
<div className="empty-panel">{t("stickers.previewEmpty")}</div>
)}
{pageItems.length > 0 && (
<div className="sticker-doc-grid" key={currentPage}>
{pageItems.map((documentID) => <StickerDocumentPreview key={documentID} documentID={documentID} />)}
</div>
)}
{total > PAGE_SIZE && (
<div className="gift-pager">
<span className="gift-pager-range">{t("gifts.pageRange", { start: rangeStart, end: rangeEnd, total })}</span>
<div className="gift-pager-controls">
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
<ChevronLeft size={14} /> {t("gifts.pagePrev")}
</button>
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span>
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
{t("gifts.pageNext")} <ChevronRight size={14} />
</button>
</div>
</div>
)}
</div>
</section>
</div>,
document.body
);
}

View file

@ -0,0 +1,223 @@
import { Eye, ChevronLeft, ChevronRight, ImageOff, RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import type { StickerSetRow } from "../types";
import { StickerSetPreviewModal } from "./StickerSetPreviewModal";
type StickerPageSize = 10 | 20 | 50 | 100 | "all";
// Shared list/manage view for one non-system sticker-set kind ("stickers" or
// "emoji") — system packs (dice, animated emoji, premium/TON gifts, etc.) are
// filtered out server-side and never reach this page; they aren't meant to be
// hand-edited.
export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
const { t } = useI18n();
const [sets, setSets] = useState<StickerSetRow[]>([]);
const [query, setQuery] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [pageSize, setPageSize] = useState<StickerPageSize>(10);
const [page, setPage] = useState(1);
const [orderDrafts, setOrderDrafts] = useState<Record<string, string>>({});
const [titleDrafts, setTitleDrafts] = useState<Record<string, string>>({});
const [previewSet, setPreviewSet] = useState<StickerSetRow | null>(null);
const pageTitleKey = kind === "emoji" ? "stickers.emojiPageTitle" : "stickers.pageTitle";
const eyebrowKey = kind === "emoji" ? "stickers.emojiEyebrow" : "stickers.eyebrow";
async function load() {
setBusy(true);
setError("");
try {
setSets((await api.stickerSets(kind)).rows ?? []);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => { void load(); }, [kind]);
const visible = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return sets;
return sets.filter((set) =>
String(set.ID).includes(normalized) ||
set.ShortName.toLowerCase().includes(normalized) ||
set.Title.toLowerCase().includes(normalized)
);
}, [sets, query]);
useEffect(() => { setPage(1); }, [query, pageSize, kind]);
const totalPages = pageSize === "all" ? 1 : Math.max(1, Math.ceil(visible.length / pageSize));
const currentPage = Math.min(page, totalPages);
const paged = useMemo(() => {
if (pageSize === "all") return visible;
const start = (currentPage - 1) * pageSize;
return visible.slice(start, start + pageSize);
}, [visible, currentPage, pageSize]);
const rangeStart = paged.length === 0 ? 0 : pageSize === "all" ? 1 : (currentPage - 1) * pageSize + 1;
const rangeEnd = rangeStart === 0 ? 0 : rangeStart + paged.length - 1;
const counts = useMemo(() => ({
total: sets.length,
official: sets.filter((set) => set.Official).length,
archived: sets.filter((set) => set.Archived).length
}), [sets]);
return (
<PageFrame
title={t(pageTitleKey)}
eyebrow={t(eyebrowKey)}
actions={
<button className="btn" type="button" onClick={() => load()} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("stickers.total")} value={String(counts.total)} />
<Metric label={t("stickers.official")} value={String(counts.official)} tone="good" />
<Metric label={t("stickers.archived")} value={String(counts.archived)} tone={counts.archived > 0 ? "warn" : "neutral"} />
</div>
<QueryPanel>
<div className="toolbar">
<label className="searchbox">
<Search size={15} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("stickers.searchPlaceholder")} />
</label>
<label className="gift-page-size">
<span>{t("gifts.perPage")}</span>
<select
value={String(pageSize)}
onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as StickerPageSize))}
>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="all">{t("gifts.perPageAll")}</option>
</select>
</label>
<span className="gift-list-summary">{t("stickers.listSummary", { shown: visible.length, total: sets.length })}</span>
</div>
</QueryPanel>
<div className="table-wrap gift-table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("stickers.logo")}</th>
<th>{t("stickers.id")}</th>
<th>{t("stickers.shortName")}</th>
<th>{t("stickers.title")}</th>
<th>{t("stickers.count")}</th>
<th>{t("stickers.official")}</th>
<th>{t("common.status")}</th>
<th>{t("stickers.sortOrder")}</th>
<th>{t("common.actions")}</th>
</tr>
</thead>
<tbody>
{paged.map((set) => (
<tr className={set.Archived ? "gift-row-disabled" : ""} key={set.ID}>
<td>
{set.CoverDocumentID ? (
<StickerDocumentPreview documentID={set.CoverDocumentID} className="list-thumb" showError={false} />
) : (
<div className="sticker-list-thumb-empty"><ImageOff size={14} /></div>
)}
</td>
<td className="mono">{set.ID}</td>
<td className="mono">{set.ShortName || <span className="muted-cell">{t("common.none")}</span>}</td>
<td>
<div className="sort-order-editor">
<input
className="small-input title-input"
value={titleDrafts[set.ID] ?? set.Title}
onChange={(event) => setTitleDrafts((prev) => ({ ...prev, [set.ID]: event.target.value }))}
/>
<ActionButton
compact
tone="neutral"
label={t("stickers.saveTitle")}
path="/api/actions/rename-sticker-set"
payload={() => ({ set_id: set.ID, title: (titleDrafts[set.ID] ?? set.Title).trim() })}
onDone={() => void load()}
/>
</div>
</td>
<td>{set.Count}</td>
<td>{set.Official ? <Badge tone="good">{t("common.yes")}</Badge> : <Badge>{t("common.no")}</Badge>}</td>
<td>{set.Archived ? <Badge tone="danger">{t("stickers.archived")}</Badge> : <Badge tone="good">{t("common.enabled")}</Badge>}</td>
<td>
<div className="sort-order-editor">
<input
type="number"
className="small-input"
value={orderDrafts[set.ID] ?? String(set.SortOrder)}
onChange={(event) => setOrderDrafts((prev) => ({ ...prev, [set.ID]: event.target.value }))}
/>
<ActionButton
compact
tone="neutral"
label={t("stickers.saveOrder")}
path="/api/actions/set-sticker-set-sort-order"
payload={() => ({ set_id: set.ID, sort_order: Number(orderDrafts[set.ID] ?? set.SortOrder) })}
onDone={() => void load()}
/>
</div>
</td>
<td>
<div className="gift-table-actions">
<button className="btn compact-btn" type="button" onClick={() => setPreviewSet(set)}>
<Eye size={13} /> {t("stickers.view")}
</button>
<ActionButton
compact
tone="neutral"
label={set.Archived ? t("stickers.unarchive") : t("stickers.archive")}
path="/api/actions/set-sticker-set-archived"
payload={() => ({ set_id: set.ID, archived: !set.Archived })}
onDone={() => void load()}
/>
<ActionButton
compact
tone="danger"
label={t("stickers.delete")}
path="/api/actions/delete-sticker-set"
payload={() => ({ set_id: set.ID })}
onDone={() => void load()}
/>
</div>
</td>
</tr>
))}
{paged.length === 0 && <EmptyRow colSpan={9} />}
</tbody>
</table>
</div>
{pageSize !== "all" && visible.length > 0 && (
<div className="gift-pager">
<span className="gift-pager-range">{t("gifts.pageRange", { start: rangeStart, end: rangeEnd, total: visible.length })}</span>
<div className="gift-pager-controls">
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
<ChevronLeft size={14} /> {t("gifts.pagePrev")}
</button>
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span>
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
{t("gifts.pageNext")} <ChevronRight size={14} />
</button>
</div>
</div>
)}
{previewSet && <StickerSetPreviewModal set={previewSet} onClose={() => setPreviewSet(null)} />}
</PageFrame>
);
}

View file

@ -21,6 +21,8 @@ export function routeTitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/channels")) return t("route.channels");
if (pathname.startsWith("/messages")) return t("route.messages");
if (pathname.startsWith("/gifts")) return t("route.gifts");
if (pathname.startsWith("/stickers")) return t("route.stickers");
if (pathname.startsWith("/emoji")) return t("route.emoji");
return t("route.dashboard");
}
@ -29,5 +31,7 @@ export function routeSubtitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
if (pathname.startsWith("/messages")) return t("route.messagesSubtitle");
if (pathname.startsWith("/gifts")) return t("route.giftsSubtitle");
if (pathname.startsWith("/stickers")) return t("route.stickersSubtitle");
if (pathname.startsWith("/emoji")) return t("route.emojiSubtitle");
return t("route.dashboardSubtitle");
}

View file

@ -347,6 +347,19 @@ textarea:focus {
width: 88px;
}
.sort-order-editor {
display: flex;
align-items: center;
gap: 6px;
}
.sort-order-editor .small-input {
width: 64px;
height: 32px;
}
.sort-order-editor .title-input {
width: 160px;
}
.field-inline {
display: inline-flex;
align-items: center;

View file

@ -401,6 +401,32 @@
.gift-validation-head span { color: #99cfc4; font-size: 10px; }
.gift-validation pre { max-height: 180px; overflow: auto; margin: 0; padding: 11px 12px; color: #d5fff5; font-size: 11px; }
.sticker-preview-modal { width: min(760px, 100%); }
.sticker-doc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
gap: 8px;
max-height: 420px;
overflow: auto;
padding: 2px;
}
.sticker-doc-cell {
position: relative;
display: grid;
place-items: center;
aspect-ratio: 1;
overflow: hidden;
background: var(--panel-strong);
border: 1px solid var(--line);
border-radius: 10px;
}
.sticker-doc-canvas { width: 100%; height: 100%; }
.sticker-doc-canvas canvas { width: 100% !important; height: 100% !important; }
.sticker-doc-image { width: 100%; height: 100%; object-fit: contain; }
.sticker-doc-cell.list-thumb { width: 40px; flex: 0 0 40px; }
.sticker-list-thumb-empty { display: grid; place-items: center; width: 40px; height: 40px; background: var(--panel-strong); border: 1px solid var(--line); border-radius: 9px; color: var(--muted); }
.sticker-doc-error { position: absolute; inset: 0; display: grid; place-items: center; padding: 4px; color: var(--danger); font-size: 9px; text-align: center; }
.gift-animation-shell { position: relative; display: grid; min-height: 210px; place-items: center; background: radial-gradient(circle, #f9f3ff, #eaf2fe); }
.gift-animation { width: 200px; height: 200px; }
.gift-animation canvas { width: 100% !important; height: 100% !important; }

View file

@ -266,6 +266,27 @@ export type CommandResult = {
error?: string;
};
export type StickerSetRow = {
// String, not number: these are 18-19 digit snowflake ids, past JS's 2^53
// safe-integer limit — see GiftID on StarGiftRow for the same convention.
ID: string;
ShortName: string;
Title: string;
Count: number;
Kind: string;
SystemKey: string;
Official: boolean;
Archived: boolean;
Installed: boolean;
SortOrder: number;
CreatedAt: string;
// Id of the set's first document, for a small list thumbnail — empty when
// the set has no documents. Same string-not-number reasoning as ID.
CoverDocumentID: string;
};
export type StickerSetListResponse = { rows: StickerSetRow[] };
export type AccountListResponse = {
query: string;
limit: number;