added full gifts support
This commit is contained in:
parent
ea5b86de8f
commit
354128c106
35 changed files with 1502 additions and 103 deletions
|
|
@ -282,6 +282,38 @@ ORDER BY set_kind, sort_order, id`, kind)
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GifCatalogRow is one entry of the admin-curated GIF catalog @gif serves for
|
||||
// the client's GIF picker.
|
||||
type GifCatalogRow struct {
|
||||
ID int64 `json:"ID,string"`
|
||||
Title string
|
||||
DocumentID int64 `json:"DocumentID,string"`
|
||||
Enabled bool
|
||||
SortOrder int
|
||||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *readStore) ListGifCatalog(ctx context.Context) ([]GifCatalogRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, title, document_id, enabled, sort_order, created_by, created_at
|
||||
FROM gif_catalog
|
||||
ORDER BY sort_order, id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list gif catalog: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]GifCatalogRow, 0)
|
||||
for rows.Next() {
|
||||
var item GifCatalogRow
|
||||
if err := rows.Scan(&item.ID, &item.Title, &item.DocumentID, &item.Enabled, &item.SortOrder, &item.CreatedBy, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// StickerSetDocumentIDs returns document ids as strings, not int64 — a plain
|
||||
// JSON number array would let the browser silently round these snowflake ids
|
||||
// past 2^53 (see StickerSetRow.ID for the same issue on the set id itself).
|
||||
|
|
|
|||
|
|
@ -119,6 +119,11 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/create-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleCreateStickerSetAPI)))
|
||||
mux.Handle("POST /api/actions/add-sticker-to-set", s.requireAuthAPI(http.HandlerFunc(s.handleAddStickerToSetAPI)))
|
||||
mux.Handle("POST /api/actions/remove-sticker-from-set", s.requireAuthAPI(http.HandlerFunc(s.handleRemoveStickerFromSetAPI)))
|
||||
mux.Handle("GET /api/gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleGifCatalogAPI)))
|
||||
mux.Handle("POST /api/actions/create-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleCreateGifCatalogEntryAPI)))
|
||||
mux.Handle("POST /api/actions/set-gif-catalog-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogEnabledAPI)))
|
||||
mux.Handle("POST /api/actions/set-gif-catalog-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogSortOrderAPI)))
|
||||
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI)))
|
||||
mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
|
||||
|
|
@ -1816,6 +1821,124 @@ func (s *server) handleRemoveStickerFromSetAPI(w http.ResponseWriter, r *http.Re
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
func (s *server) handleGifCatalogAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
rows, err := s.read.ListGifCatalog(r.Context())
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
||||
}
|
||||
|
||||
type createGifCatalogEntryAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (s *server) handleCreateGifCatalogEntryAPI(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 21<<20)
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var body createGifCatalogEntryAPIRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "gif file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, (20<<20)+1))
|
||||
if err != nil || len(data) == 0 || len(data) > 20<<20 {
|
||||
writeAPIError(w, http.StatusBadRequest, "gif file is empty or too large")
|
||||
return
|
||||
}
|
||||
req := admin.CreateGifCatalogEntryRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "create-gif-catalog-entry"),
|
||||
Title: body.Title, FileName: header.Filename,
|
||||
}
|
||||
result, err := s.callAdminMultipart(r.Context(), "/v1/gif-catalog/create", req, header.Filename, data)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setGifCatalogEnabledAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
ID int64 `json:"id,string"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetGifCatalogEnabledAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setGifCatalogEnabledAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetGifCatalogEnabledRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-gif-catalog-enabled"),
|
||||
ID: body.ID, Enabled: body.Enabled,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/gif-catalog/set-enabled", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setGifCatalogSortOrderAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
ID int64 `json:"id,string"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetGifCatalogSortOrderAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setGifCatalogSortOrderAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetGifCatalogSortOrderRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-gif-catalog-sort-order"),
|
||||
ID: body.ID, SortOrder: body.SortOrder,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/gif-catalog/set-sort-order", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type deleteGifCatalogEntryAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
ID int64 `json:"id,string"`
|
||||
}
|
||||
|
||||
func (s *server) handleDeleteGifCatalogEntryAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body deleteGifCatalogEntryAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.DeleteGifCatalogEntryRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-gif-catalog-entry"),
|
||||
ID: body.ID,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/gif-catalog/delete", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
func (s *server) handleStickerSetsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
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
|
|
@ -23,8 +23,8 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-BKuHz-WL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C7ZZ0RF2.css">
|
||||
<script type="module" crossorigin src="/assets/index-DQxoDUCy.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CqUW4fne.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -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