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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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-B4-Dmklu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CnQEc5UV.css">
<script type="module" crossorigin src="/assets/index-CT_6lfHd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7AAg19g.css">
</head>
<body>
<div id="root"></div>

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