added full gifts support
This commit is contained in:
parent
ea5b86de8f
commit
354128c106
35 changed files with 1502 additions and 103 deletions
|
|
@ -31,6 +31,7 @@ import type {
|
|||
ModerationCaseRow,
|
||||
ModerationReport,
|
||||
StickerSetListResponse,
|
||||
GifCatalogListResponse,
|
||||
VerificationApplicationDetail,
|
||||
VerificationApplicationListResponse,
|
||||
VerificationCountsResponse
|
||||
|
|
@ -228,6 +229,8 @@ export const api = {
|
|||
setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }),
|
||||
setChannelAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-channel-avatar", { method: "POST", body: form }),
|
||||
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
|
||||
gifCatalog: () => request<GifCatalogListResponse>("/api/gif-catalog"),
|
||||
createGifCatalogEntry: (form: FormData) => request<CommandResult>("/api/actions/create-gif-catalog-entry", { method: "POST", body: form }),
|
||||
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
Bot,
|
||||
ChevronDown,
|
||||
Database,
|
||||
Film,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
|
|
@ -101,6 +102,7 @@ export function Shell({
|
|||
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
|
||||
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
|
||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
|
||||
<NavLink icon={<Film size={16} />} href="/gif-catalog" route={route} navigate={navigate}>{"GIFs"}</NavLink>
|
||||
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
|
||||
<button
|
||||
className="nav-section-toggle"
|
||||
|
|
|
|||
271
cmd/telesrv-admin/web/src/pages/GifCatalogPage.tsx
Normal file
271
cmd/telesrv-admin/web/src/pages/GifCatalogPage.tsx
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { ChevronLeft, ChevronRight, Loader2, Plus, RefreshCw, Search, Upload, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import type { GifCatalogRow } from "../types";
|
||||
|
||||
type GifPageSize = 10 | 20 | 50 | 100 | "all";
|
||||
|
||||
// Manage view for the admin-curated GIF catalog the built-in @gif inline bot
|
||||
// serves for the client's GIF picker trending/search panel.
|
||||
export function GifCatalogPage() {
|
||||
const [rows, setRows] = useState<GifCatalogRow[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [pageSize, setPageSize] = useState<GifPageSize>(10);
|
||||
const [page, setPage] = useState(1);
|
||||
const [orderDrafts, setOrderDrafts] = useState<Record<string, string>>({});
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setRows((await api.gifCatalog()).rows ?? []);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return rows;
|
||||
return rows.filter((row) =>
|
||||
row.ID.includes(normalized) || row.Title.toLowerCase().includes(normalized)
|
||||
);
|
||||
}, [rows, query]);
|
||||
|
||||
useEffect(() => { setPage(1); }, [query, pageSize]);
|
||||
|
||||
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: rows.length,
|
||||
enabled: rows.filter((row) => row.Enabled).length
|
||||
}), [rows]);
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={"GIFs"}
|
||||
eyebrow={"Curated GIFs served by @gif in the client's GIF picker (trending + search)"}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn" type="button" onClick={() => load()} disabled={busy}>
|
||||
<RefreshCw size={15} /> {"Refresh"}
|
||||
</button>
|
||||
<button className="btn primary" type="button" onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={15} /> {"Add GIF"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={"Total GIFs"} value={String(counts.total)} />
|
||||
<Metric label={"Enabled"} value={String(counts.enabled)} tone="good" />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
<div className="toolbar">
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={"Search ID or title"} />
|
||||
</label>
|
||||
<label className="gift-page-size">
|
||||
<span>{"Per page"}</span>
|
||||
<select
|
||||
value={String(pageSize)}
|
||||
onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as GifPageSize))}
|
||||
>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="all">{"All"}</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="gift-list-summary">{`Showing ${visible.length} of ${rows.length}`}</span>
|
||||
</div>
|
||||
</QueryPanel>
|
||||
<div className="table-wrap gift-table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"ID"}</th>
|
||||
<th>{"Title"}</th>
|
||||
<th>{"Document ID"}</th>
|
||||
<th>{"Added by"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Sort order"}</th>
|
||||
<th>{"Actions"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((row) => (
|
||||
<tr className={row.Enabled ? "" : "gift-row-disabled"} key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td>{row.Title || <span className="muted-cell">{"Untitled"}</span>}</td>
|
||||
<td className="mono">{row.DocumentID}</td>
|
||||
<td>{row.CreatedBy || <span className="muted-cell">{"—"}</span>}</td>
|
||||
<td>{row.Enabled ? <Badge tone="good">{"Enabled"}</Badge> : <Badge tone="danger">{"Disabled"}</Badge>}</td>
|
||||
<td>
|
||||
<div className="sort-order-editor">
|
||||
<input
|
||||
type="number"
|
||||
className="small-input"
|
||||
value={orderDrafts[row.ID] ?? String(row.SortOrder)}
|
||||
onChange={(event) => setOrderDrafts((prev) => ({ ...prev, [row.ID]: event.target.value }))}
|
||||
/>
|
||||
<ActionButton
|
||||
compact
|
||||
tone="neutral"
|
||||
label={"Save"}
|
||||
path="/api/actions/set-gif-catalog-sort-order"
|
||||
payload={() => ({ id: row.ID, sort_order: Number(orderDrafts[row.ID] ?? row.SortOrder) })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="gift-table-actions">
|
||||
<ActionButton
|
||||
compact
|
||||
tone="neutral"
|
||||
label={row.Enabled ? "Disable" : "Enable"}
|
||||
path="/api/actions/set-gif-catalog-enabled"
|
||||
payload={() => ({ id: row.ID, enabled: !row.Enabled })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
<ActionButton
|
||||
compact
|
||||
tone="danger"
|
||||
label={"Delete"}
|
||||
path="/api/actions/delete-gif-catalog-entry"
|
||||
payload={() => ({ id: row.ID })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{paged.length === 0 && <EmptyRow colSpan={7} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pageSize !== "all" && visible.length > 0 && (
|
||||
<div className="gift-pager">
|
||||
<span className="gift-pager-range">{`Showing ${rangeStart}-${rangeEnd} of ${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} /> {"Previous"}
|
||||
</button>
|
||||
<span className="gift-pager-page">{`Page ${currentPage} of ${totalPages}`}</span>
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
|
||||
{"Next"} <ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{createOpen && <AddGifModal onClose={() => setCreateOpen(false)} onCreated={() => void load()} />}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function AddGifModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewURL, setPreviewURL] = useState<string | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function pickFile(picked: File | null) {
|
||||
setFile(picked);
|
||||
setPreviewURL((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return picked ? URL.createObjectURL(picked) : null;
|
||||
});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!title.trim() || !file) {
|
||||
setError("Title and a GIF/MP4 file are required.");
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
setError("Please enter an operation reason");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, title: title.trim() }));
|
||||
form.set("file", file, file.name);
|
||||
await api.createGifCatalogEntry(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={"Add a GIF"}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{"New catalog entry"}</div>
|
||||
<h2>{"Add a GIF"}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<div className="gift-fields-grid">
|
||||
<label><span>{"Title"}</span><input value={title} maxLength={128} onChange={(event) => setTitle(event.target.value)} /></label>
|
||||
</div>
|
||||
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
|
||||
<input type="file" accept=".gif,.mp4,image/gif,video/mp4" onChange={(event) => pickFile(event.target.files?.[0] ?? null)} />
|
||||
<span className="gift-file-copy"><span className="gift-field-label">{"File"}</span><strong>{file ? file.name : "Choose a GIF or MP4 file"}</strong></span>
|
||||
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
|
||||
</label>
|
||||
{previewURL && (
|
||||
<div className="gif-catalog-preview">
|
||||
{file?.type === "video/mp4" ? (
|
||||
<video src={previewURL} autoPlay loop muted playsInline />
|
||||
) : (
|
||||
<img src={previewURL} alt="" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this GIF is being added"} 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}>{"Close"}</button>
|
||||
<button className="btn primary" type="button" onClick={submit} disabled={busy}>
|
||||
{busy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
|
||||
{"Add GIF"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { GroupMessagesPage } from "./GroupMessagesPage";
|
|||
import { MessageDetailPage } from "./MessageDetailPage";
|
||||
import { MessagesPage } from "./MessagesPage";
|
||||
import { StickerSetsPage } from "./StickerSetsPage";
|
||||
import { GifCatalogPage } from "./GifCatalogPage";
|
||||
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
|
||||
import { ModerationCasesPage } from "./ModerationCasesPage";
|
||||
import { StoragePage } from "./StoragePage";
|
||||
|
|
@ -120,6 +121,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (route.path === "/stickers") {
|
||||
return <StickerSetsPage kind="stickers" />;
|
||||
}
|
||||
if (route.path === "/gif-catalog") {
|
||||
return <GifCatalogPage />;
|
||||
}
|
||||
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
|
||||
return (
|
||||
<MessageDetailPage
|
||||
|
|
|
|||
|
|
@ -30,5 +30,6 @@ export function routeTitle(pathname: string): string {
|
|||
if (pathname.startsWith("/emoji")) return "Emoji";
|
||||
if (pathname.startsWith("/messages")) return "Message Audit";
|
||||
if (pathname.startsWith("/stickers")) return "Stickers";
|
||||
if (pathname.startsWith("/gif-catalog")) return "GIFs";
|
||||
return "Operations Console";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -516,6 +516,10 @@
|
|||
.gift-file-copy small { color: var(--muted); font-size: 11px; font-weight: 500; }
|
||||
.gift-file-action { padding: 7px 10px; color: var(--brand); background: var(--brand-tint); border: 1px solid var(--brand-tint-border); border-radius: var(--radius-sm); font-size: 11px; font-weight: 800; }
|
||||
|
||||
.gif-catalog-preview { display: grid; place-items: center; max-height: 220px; overflow: hidden; background: var(--panel-subtle); border: 1px solid var(--line); border-radius: var(--radius-sm); }
|
||||
.gif-catalog-preview img,
|
||||
.gif-catalog-preview video { max-width: 100%; max-height: 220px; object-fit: contain; }
|
||||
|
||||
.gift-fields-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1.5fr) repeat(3, minmax(120px, 1fr));
|
||||
|
|
|
|||
|
|
@ -636,6 +636,20 @@ export type StickerSetRow = {
|
|||
|
||||
export type StickerSetListResponse = { rows: StickerSetRow[] };
|
||||
|
||||
export type GifCatalogRow = {
|
||||
// String, not number: 18-19 digit snowflake ids, past JS's 2^53
|
||||
// safe-integer limit.
|
||||
ID: string;
|
||||
Title: string;
|
||||
DocumentID: string;
|
||||
Enabled: boolean;
|
||||
SortOrder: number;
|
||||
CreatedBy: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type GifCatalogListResponse = { rows: GifCatalogRow[] };
|
||||
|
||||
export type AccountListResponse = {
|
||||
query: string;
|
||||
limit: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue