owpengram-server/cmd/telesrv-admin/web/src/pages/StickerSetPreviewModal.tsx

165 lines
7.1 KiB
TypeScript

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";
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 noun = set.Kind === "emoji" ? "emoji" : "sticker";
const [documentIDs, setDocumentIDs] = useState<string[] | null>(null);
const [error, setError] = useState("");
const [page, setPage] = useState(1);
const load = useCallback(() => {
let cancelled = false;
setError("");
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]);
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);
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">
<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>
)}
{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) => (
<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 && (
<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
);
}
// 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>
);
}