added stickers and emojies list
This commit is contained in:
parent
24276d1379
commit
43894f2fc9
26 changed files with 1118 additions and 14 deletions
9
cmd/telesrv-admin/web/dist/assets/index-B4-Dmklu.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-B4-Dmklu.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
cmd/telesrv-admin/web/dist/assets/index-CnQEc5UV.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-CnQEc5UV.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -5,8 +5,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<title>OwpenGram Admin</title>
|
||||
<script type="module" crossorigin src="/assets/index-CGbIqVNE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BAJpg0mp.css">
|
||||
<script type="module" crossorigin src="/assets/index-B4-Dmklu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CnQEc5UV.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -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