fixed stickers and emojies creation packs

This commit is contained in:
onysd 2026-07-22 14:01:02 +03:00
parent 43894f2fc9
commit 139967399d
16 changed files with 771 additions and 22 deletions

View file

@ -71,6 +71,8 @@ export const api = {
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`,
createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }),
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
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

@ -345,6 +345,19 @@ const translations: Record<string, string> = {
"stickers.view": "View",
"stickers.previewEyebrow": "Set contents",
"stickers.previewEmpty": "This set has no documents.",
"stickers.create": "Create {noun} pack",
"stickers.createTitle": "Create a new {noun} pack",
"stickers.createEyebrow": "New set",
"stickers.createFieldsRequired": "Title, short name, emoji and a first {noun} file are required.",
"stickers.shortNamePlaceholder": "lowercase_short_name",
"stickers.firstSticker": "First {noun}",
"stickers.filePrompt": "Choose a TGS, Lottie JSON, or WebP file",
"stickers.emoji": "Emoji",
"stickers.emojiPlaceholder": "e.g. 😀",
"stickers.emojiRequired": "An emoji is required.",
"stickers.addSticker": "Add {noun}",
"stickers.fileRequired": "Choose a {noun} file first",
"stickers.removeSticker": "Remove {noun}",
"collectibles.manage": "Attribute pool",
"collectibles.title": "Collectible pool · Gift #{id}",
"collectibles.eyebrow": "Unique gift attributes",

View file

@ -0,0 +1,86 @@
import { Loader2, Upload, X } from "lucide-react";
import { useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { Alert } from "../components/ui";
import { useI18n } from "../i18n";
// Real Telegram sticker/emoji packs are created with at least one item, so
// this form collects the title/short name plus a single starting file —
// exactly like CreateStickerSet's domain-level requirement. More stickers
// get added afterward from the pack's own preview modal.
export function CreateStickerSetModal({ kind, onClose, onCreated }: { kind: "stickers" | "emoji"; onClose: () => void; onCreated: () => void }) {
const { t } = useI18n();
const noun = kind === "emoji" ? "emoji" : "sticker";
const [title, setTitle] = useState("");
const [shortName, setShortName] = useState("");
const [emoji, setEmoji] = useState("");
const [file, setFile] = useState<File | null>(null);
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submit() {
if (!title.trim() || !shortName.trim() || !emoji.trim() || !file) {
setError(t("stickers.createFieldsRequired", { noun }));
return;
}
if (!reason.trim()) {
setError(t("action.reasonRequired"));
return;
}
setBusy(true);
setError("");
try {
const form = new FormData();
form.set("metadata", JSON.stringify({
command_id: "", reason: reason.trim(), confirm: true,
title: title.trim(), short_name: shortName.trim().toLowerCase(), kind, emoji: emoji.trim()
}));
form.set("file", file, file.name);
await api.createStickerSet(form);
onCreated();
onClose();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={t("stickers.createTitle", { noun })}>
<div className="modal-head">
<div>
<div className="eyebrow">{t("stickers.createEyebrow")}</div>
<h2>{t("stickers.createTitle", { noun })}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
</div>
<div className="command-body">
<div className="gift-fields-grid">
<label><span>{t("stickers.title")}</span><input value={title} maxLength={64} onChange={(event) => setTitle(event.target.value)} /></label>
<label><span>{t("stickers.shortName")}</span><input value={shortName} maxLength={32} onChange={(event) => setShortName(event.target.value)} placeholder={t("stickers.shortNamePlaceholder")} /></label>
<label><span>{t("stickers.emoji")}</span><input value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={t("stickers.emojiPlaceholder")} /></label>
</div>
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept=".tgs,.json,.webp,application/json,application/x-tgsticker,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
<span className="gift-file-copy"><span className="gift-field-label">{t("stickers.firstSticker", { noun })}</span><strong>{file ? file.name : t("stickers.filePrompt")}</strong></span>
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span>
</label>
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={reason} placeholder={t("gifts.reasonPlaceholder")} onChange={(event) => setReason(event.target.value)} /></label>
{error && <Alert>{error}</Alert>}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose} disabled={busy}>{t("common.close")}</button>
<button className="btn primary" type="button" onClick={submit} disabled={busy}>
{busy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
{t("stickers.create", { noun })}
</button>
</div>
</section>
</div>,
document.body
);
}

View file

@ -1,7 +1,8 @@
import { ChevronLeft, ChevronRight, Loader2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { ChevronLeft, ChevronRight, Loader2, Plus, Trash2, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
import { Alert } from "../components/ui";
import { useI18n } from "../i18n";
@ -15,15 +16,14 @@ const PAGE_SIZE = 24;
export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; onClose: () => void }) {
const { t } = useI18n();
const noun = set.Kind === "emoji" ? "emoji" : "sticker";
const [documentIDs, setDocumentIDs] = useState<string[] | null>(null);
const [error, setError] = useState("");
const [page, setPage] = useState(1);
useEffect(() => {
const load = useCallback(() => {
let cancelled = false;
setDocumentIDs(null);
setError("");
setPage(1);
api.stickerSetDocuments(set.ID).then((result) => {
if (cancelled) return;
setDocumentIDs(result.document_ids ?? []);
@ -33,6 +33,12 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
return () => { cancelled = true; };
}, [set.ID]);
useEffect(() => {
setDocumentIDs(null);
setPage(1);
return load();
}, [load]);
const total = documentIDs?.length ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
@ -52,6 +58,7 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
<button className="icon-btn" type="button" onClick={onClose} aria-label={t("action.close")}><X size={15} /></button>
</div>
<div className="command-body">
<AddStickerForm setID={set.ID} noun={noun} onAdded={load} />
{error && <Alert>{error}</Alert>}
{!error && documentIDs === null && (
<div className="loading-line"><Loader2 className="spin" size={18} /> {t("common.loading")}</div>
@ -61,7 +68,20 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
)}
{pageItems.length > 0 && (
<div className="sticker-doc-grid" key={currentPage}>
{pageItems.map((documentID) => <StickerDocumentPreview key={documentID} documentID={documentID} />)}
{pageItems.map((documentID) => (
<div className="sticker-doc-grid-cell" key={documentID}>
<StickerDocumentPreview documentID={documentID} />
<ActionButton
compact
tone="danger"
label={t("stickers.removeSticker", { noun })}
icon={<Trash2 size={12} />}
path="/api/actions/remove-sticker-from-set"
payload={() => ({ set_id: set.ID, document_id: documentID })}
onDone={load}
/>
</div>
))}
</div>
)}
{total > PAGE_SIZE && (
@ -84,3 +104,62 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
document.body
);
}
// Inline upload form embedded in the preview modal — file + emoji + a
// mandatory audit reason, single confirmed call (no separate dry-run preview
// step): unlike destructive actions, materializing one sticker document is
// low-risk and reversible via the per-cell Remove button.
function AddStickerForm({ setID, noun, onAdded }: { setID: string; noun: string; onAdded: () => void }) {
const { t } = useI18n();
const [file, setFile] = useState<File | null>(null);
const [emoji, setEmoji] = useState("");
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submit() {
if (!file) {
setError(t("stickers.fileRequired", { noun }));
return;
}
if (!emoji.trim()) {
setError(t("stickers.emojiRequired"));
return;
}
if (!reason.trim()) {
setError(t("action.reasonRequired"));
return;
}
setBusy(true);
setError("");
try {
const form = new FormData();
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, set_id: setID, emoji: emoji.trim() }));
form.set("file", file, file.name);
await api.addStickerToSet(form);
setFile(null);
setEmoji("");
setReason("");
onAdded();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="sticker-add-form">
<label className={`gift-file-picker compact ${file ? "has-file" : ""}`}>
<input type="file" accept=".tgs,.json,.webp,application/json,application/x-tgsticker,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
<span className="gift-file-copy"><strong>{file ? file.name : t("stickers.filePrompt")}</strong></span>
</label>
<input className="small-input" value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={t("stickers.emojiPlaceholder")} />
<input className="small-input" value={reason} onChange={(event) => setReason(event.target.value)} placeholder={t("action.reasonPlaceholder")} />
<button className="btn primary compact-btn" type="button" onClick={submit} disabled={busy}>
{busy ? <Loader2 className="spin" size={14} /> : <Plus size={14} />} {t("stickers.addSticker", { noun })}
</button>
{error && <span className="sticker-add-form-error">{error}</span>}
</div>
);
}

View file

@ -1,4 +1,4 @@
import { Eye, ChevronLeft, ChevronRight, ImageOff, RefreshCw, Search } from "lucide-react";
import { Eye, ChevronLeft, ChevronRight, ImageOff, Plus, RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
@ -6,6 +6,7 @@ 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 { CreateStickerSetModal } from "./CreateStickerSetModal";
import { StickerSetPreviewModal } from "./StickerSetPreviewModal";
type StickerPageSize = 10 | 20 | 50 | 100 | "all";
@ -25,9 +26,11 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
const [orderDrafts, setOrderDrafts] = useState<Record<string, string>>({});
const [titleDrafts, setTitleDrafts] = useState<Record<string, string>>({});
const [previewSet, setPreviewSet] = useState<StickerSetRow | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const pageTitleKey = kind === "emoji" ? "stickers.emojiPageTitle" : "stickers.pageTitle";
const eyebrowKey = kind === "emoji" ? "stickers.emojiEyebrow" : "stickers.eyebrow";
const noun = kind === "emoji" ? "emoji" : "sticker";
async function load() {
setBusy(true);
@ -76,9 +79,14 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
title={t(pageTitleKey)}
eyebrow={t(eyebrowKey)}
actions={
<button className="btn" type="button" onClick={() => load()} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")}
</button>
<>
<button className="btn" type="button" onClick={() => load()} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")}
</button>
<button className="btn primary" type="button" onClick={() => setCreateOpen(true)}>
<Plus size={15} /> {t("stickers.create", { noun })}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
@ -218,6 +226,7 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
</div>
)}
{previewSet && <StickerSetPreviewModal set={previewSet} onClose={() => setPreviewSet(null)} />}
{createOpen && <CreateStickerSetModal kind={kind} onClose={() => setCreateOpen(false)} onCreated={() => void load()} />}
</PageFrame>
);
}

View file

@ -350,6 +350,7 @@
.gift-file-picker:hover,
.gift-file-picker.has-file { background: #f5f9ff; border-color: var(--brand); box-shadow: 0 0 0 2px rgba(37, 99, 235, .05); }
.gift-file-picker.compact { grid-template-columns: minmax(0, 1fr); min-height: 44px; padding: 8px 12px; }
.gift-file-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.gift-file-icon { width: 40px; height: 40px; border-radius: 9px; }
.gift-file-copy { display: grid; min-width: 0; gap: 2px; }
@ -425,6 +426,12 @@
.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-grid-cell { display: grid; gap: 4px; }
.sticker-doc-grid-cell .btn { width: 100%; justify-content: center; }
.sticker-add-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 14px; padding: 10px; background: var(--panel-strong); border: 1px solid var(--line); border-radius: 10px; }
.sticker-add-form .gift-file-picker.compact { flex: 1 1 220px; min-width: 180px; }
.sticker-add-form .small-input { flex: 0 1 140px; }
.sticker-add-form-error { flex-basis: 100%; color: var(--danger); font-size: 12px; }
.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); }