categorisation for gifs

This commit is contained in:
onysd 2026-08-24 23:09:08 +03:00
parent 5e18bd3830
commit d0669bdc5e
15 changed files with 468 additions and 49 deletions

View file

@ -290,13 +290,14 @@ type GifCatalogRow struct {
DocumentID int64 `json:"DocumentID,string"`
Enabled bool
SortOrder int
Category string
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
SELECT id, title, document_id, enabled, sort_order, category, created_by, created_at
FROM gif_catalog
ORDER BY sort_order, id`)
if err != nil {
@ -306,7 +307,7 @@ ORDER BY sort_order, id`)
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 {
if err := rows.Scan(&item.ID, &item.Title, &item.DocumentID, &item.Enabled, &item.SortOrder, &item.Category, &item.CreatedBy, &item.CreatedAt); err != nil {
return nil, err
}
out = append(out, item)

View file

@ -124,6 +124,8 @@ func (s *server) routes() http.Handler {
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/set-gif-catalog-category", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogCategoryAPI)))
mux.Handle("POST /api/actions/auto-categorize-gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleAutoCategorizeGifCatalogAPI)))
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)))
@ -1920,6 +1922,45 @@ func (s *server) handleSetGifCatalogSortOrderAPI(w http.ResponseWriter, r *http.
writeCommandResultAPI(w, result, err)
}
type setGifCatalogCategoryAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ID int64 `json:"id,string"`
Category string `json:"category"`
}
func (s *server) handleSetGifCatalogCategoryAPI(w http.ResponseWriter, r *http.Request) {
var body setGifCatalogCategoryAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.SetGifCatalogCategoryRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-gif-catalog-category"),
ID: body.ID, Category: body.Category,
}
result, err := s.callAdminAPI(r.Context(), "/v1/gif-catalog/set-category", req)
writeCommandResultAPI(w, result, err)
}
type autoCategorizeGifCatalogAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
}
func (s *server) handleAutoCategorizeGifCatalogAPI(w http.ResponseWriter, r *http.Request) {
var body autoCategorizeGifCatalogAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.AutoCategorizeGifCatalogRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "auto-categorize-gif-catalog"),
}
result, err := s.callAdminAPI(r.Context(), "/v1/gif-catalog/auto-categorize", req)
writeCommandResultAPI(w, result, err)
}
type deleteGifCatalogEntryAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`

View file

@ -4,7 +4,7 @@ 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";
import { GIF_CATALOG_CATEGORIES, type GifCatalogRow } from "../types";
type GifPageSize = 10 | 20 | 50 | 100 | "all";
@ -45,6 +45,7 @@ export function GifCatalogPage() {
const [pageSize, setPageSize] = useState<GifPageSize>(10);
const [page, setPage] = useState(1);
const [orderDrafts, setOrderDrafts] = useState<Record<string, string>>({});
const [categoryDrafts, setCategoryDrafts] = useState<Record<string, string>>({});
const [createOpen, setCreateOpen] = useState(false);
async function load() {
@ -83,7 +84,8 @@ export function GifCatalogPage() {
const counts = useMemo(() => ({
total: rows.length,
enabled: rows.filter((row) => row.Enabled).length
enabled: rows.filter((row) => row.Enabled).length,
uncategorized: rows.filter((row) => !row.Category).length
}), [rows]);
return (
@ -95,6 +97,13 @@ export function GifCatalogPage() {
<button className="btn" type="button" onClick={() => load()} disabled={busy}>
<RefreshCw size={15} /> {"Refresh"}
</button>
<ActionButton
tone="neutral"
label={"Auto-categorize"}
path="/api/actions/auto-categorize-gif-catalog"
payload={() => ({})}
onDone={() => void load()}
/>
<button className="btn primary" type="button" onClick={() => setCreateOpen(true)}>
<Plus size={15} /> {"Add GIF"}
</button>
@ -105,6 +114,7 @@ export function GifCatalogPage() {
<div className="metric-row">
<Metric label={"Total GIFs"} value={String(counts.total)} />
<Metric label={"Enabled"} value={String(counts.enabled)} tone="good" />
<Metric label={"Uncategorized"} value={String(counts.uncategorized)} tone={counts.uncategorized > 0 ? "warn" : undefined} />
</div>
<QueryPanel>
<div className="toolbar">
@ -138,6 +148,7 @@ export function GifCatalogPage() {
<th>{"Document ID"}</th>
<th>{"Added by"}</th>
<th>{"Status"}</th>
<th>{"Category"}</th>
<th>{"Sort order"}</th>
<th>{"Actions"}</th>
</tr>
@ -151,6 +162,28 @@ export function GifCatalogPage() {
<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">
<select
className="small-input"
value={categoryDrafts[row.ID] ?? row.Category}
onChange={(event) => setCategoryDrafts((prev) => ({ ...prev, [row.ID]: event.target.value }))}
>
<option value="">{"Uncategorized"}</option>
{GIF_CATALOG_CATEGORIES.map((category) => (
<option key={category} value={category}>{category}</option>
))}
</select>
<ActionButton
compact
tone="neutral"
label={"Save"}
path="/api/actions/set-gif-catalog-category"
payload={() => ({ id: row.ID, category: categoryDrafts[row.ID] ?? row.Category })}
onDone={() => void load()}
/>
</div>
</td>
<td>
<div className="sort-order-editor">
<input
@ -191,7 +224,7 @@ export function GifCatalogPage() {
</td>
</tr>
))}
{paged.length === 0 && <EmptyRow colSpan={8} />}
{paged.length === 0 && <EmptyRow colSpan={9} />}
</tbody>
</table>
</div>

View file

@ -636,6 +636,14 @@ export type StickerSetRow = {
export type StickerSetListResponse = { rows: StickerSetRow[] };
// GifCatalogCategories mirrors domain.GifCatalogCategories -- the titles
// internal/seed/catalog/emoji_groups.json uses for the GIF picker's category
// icons. "" (rendered as "Uncategorized") is always a valid value too.
export const GIF_CATALOG_CATEGORIES = [
"Love", "Approval", "Disapproval", "Cheers", "Laughter",
"Astonishment", "Sadness", "Anger", "Neutral", "Doubt", "Silly",
];
export type GifCatalogRow = {
// String, not number: 18-19 digit snowflake ids, past JS's 2^53
// safe-integer limit.
@ -644,6 +652,7 @@ export type GifCatalogRow = {
DocumentID: string;
Enabled: boolean;
SortOrder: number;
Category: string;
CreatedBy: string;
CreatedAt: string;
};