added stickers and emojies list
This commit is contained in:
parent
24276d1379
commit
43894f2fc9
26 changed files with 1118 additions and 14 deletions
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
86
cmd/telesrv-admin/web/src/pages/StickerSetPreviewModal.tsx
Normal file
86
cmd/telesrv-admin/web/src/pages/StickerSetPreviewModal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
223
cmd/telesrv-admin/web/src/pages/StickerSetsPage.tsx
Normal file
223
cmd/telesrv-admin/web/src/pages/StickerSetsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue