added gifs preview

This commit is contained in:
onysd 2026-08-07 10:51:43 +03:00
parent ff1d5ae361
commit 15f2d6e925
11 changed files with 141 additions and 8 deletions

View file

@ -112,6 +112,7 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/stickers", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetsAPI))) mux.Handle("GET /api/stickers", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetsAPI)))
mux.Handle("GET /api/stickers/{id}/documents", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetDocumentsAPI))) mux.Handle("GET /api/stickers/{id}/documents", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetDocumentsAPI)))
mux.Handle("GET /api/stickers/documents/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStickerDocumentAnimationAPI))) mux.Handle("GET /api/stickers/documents/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStickerDocumentAnimationAPI)))
mux.Handle("GET /api/gif-catalog/documents/{id}/preview", s.requireAuthAPI(http.HandlerFunc(s.handleGifCatalogDocumentPreviewAPI)))
mux.Handle("POST /api/actions/set-sticker-set-archived", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetArchivedAPI))) mux.Handle("POST /api/actions/set-sticker-set-archived", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetArchivedAPI)))
mux.Handle("POST /api/actions/set-sticker-set-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetSortOrderAPI))) mux.Handle("POST /api/actions/set-sticker-set-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetSortOrderAPI)))
mux.Handle("POST /api/actions/rename-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleRenameStickerSetAPI))) mux.Handle("POST /api/actions/rename-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleRenameStickerSetAPI)))
@ -2014,6 +2015,47 @@ func (s *server) handleStickerDocumentAnimationAPI(w http.ResponseWriter, r *htt
_, _ = w.Write(raw) _, _ = w.Write(raw)
} }
// handleGifCatalogDocumentPreviewAPI proxies one gif_catalog document's raw
// MP4 bytes from the real telesrv admin API, for the panel list page's
// preview cell.
func (s *server) handleGifCatalogDocumentPreviewAPI(w http.ResponseWriter, r *http.Request) {
documentID, err := parseInt64(r.PathValue("id"))
if err != nil || documentID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid document id")
return
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
fmt.Sprintf("%s/v1/gif-catalog/documents/%d/preview", s.cfg.AdminAPIURL, documentID), nil)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeAPIError(w, http.StatusBadGateway, err.Error())
return
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, (20<<20)+1))
if err != nil || len(raw) > 20<<20 {
writeAPIError(w, http.StatusBadGateway, "invalid preview response")
return
}
if resp.StatusCode != http.StatusOK {
writeAPIError(w, resp.StatusCode, string(raw))
return
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "video/mp4"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "private, max-age=300")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
type setStickerSetArchivedAPIRequest struct { type setStickerSetArchivedAPIRequest struct {
CommandID string `json:"command_id"` CommandID string `json:"command_id"`
Reason string `json:"reason"` Reason string `json:"reason"`

View file

@ -23,8 +23,8 @@
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-DQxoDUCy.js"></script> <script type="module" crossorigin src="/assets/index-LWf3JhYx.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CqUW4fne.css"> <link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -225,6 +225,7 @@ export const api = {
stickerSets: (kind: string) => request<StickerSetListResponse>(`/api/stickers?kind=${encodeURIComponent(kind)}`), stickerSets: (kind: string) => request<StickerSetListResponse>(`/api/stickers?kind=${encodeURIComponent(kind)}`),
stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`), stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`),
stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`, stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`,
gifCatalogDocumentPreviewURL: (documentID: string) => `/api/gif-catalog/documents/${encodeURIComponent(documentID)}/preview`,
createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }), createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }),
setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }), 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 }), setChannelAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-channel-avatar", { method: "POST", body: form }),

View file

@ -115,7 +115,14 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
href="/emoji" href="/emoji"
navigate={navigate} navigate={navigate}
/> />
<StatTile icon={<Film />} label="GIFs" value={counts ? formatQuantity(String(counts.Gifs)) : "…"} sub="saved by users" /> <StatTile
icon={<Film />}
label="GIFs"
value={counts ? formatQuantity(String(counts.Gifs)) : "…"}
sub="saved by users"
href="/gif-catalog"
navigate={navigate}
/>
<StatTile <StatTile
icon={<Database />} icon={<Database />}
label="Media storage used" label="Media storage used"

View file

@ -1,4 +1,4 @@
import { ChevronLeft, ChevronRight, Loader2, Plus, RefreshCw, Search, Upload, X } from "lucide-react"; import { ChevronLeft, ChevronRight, ImageOff, Loader2, Plus, RefreshCw, Search, Upload, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
@ -8,6 +8,33 @@ import type { GifCatalogRow } from "../types";
type GifPageSize = 10 | 20 | 50 | 100 | "all"; type GifPageSize = 10 | 20 | 50 | 100 | "all";
// One list-row preview cell. The backend always stores a plain H.264 MP4 (see
// files.Service.AdminUploadGifMaterial), so this is just a small looping
// <video> -- no Lottie/canvas work needed the way StickerDocumentPreview
// needs for TGS. onError falls back to a placeholder rather than a broken
// player if the document is somehow missing.
function GifPreviewThumb({ documentID }: { documentID: string }) {
const [broken, setBroken] = useState(false);
if (broken) {
return (
<div className="sticker-list-thumb-empty">
<ImageOff size={14} />
</div>
);
}
return (
<video
className="gif-catalog-thumb"
src={api.gifCatalogDocumentPreviewURL(documentID)}
muted
loop
autoPlay
playsInline
onError={() => setBroken(true)}
/>
);
}
// Manage view for the admin-curated GIF catalog the built-in @gif inline bot // Manage view for the admin-curated GIF catalog the built-in @gif inline bot
// serves for the client's GIF picker trending/search panel. // serves for the client's GIF picker trending/search panel.
export function GifCatalogPage() { export function GifCatalogPage() {
@ -105,6 +132,7 @@ export function GifCatalogPage() {
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>{"Preview"}</th>
<th>{"ID"}</th> <th>{"ID"}</th>
<th>{"Title"}</th> <th>{"Title"}</th>
<th>{"Document ID"}</th> <th>{"Document ID"}</th>
@ -117,6 +145,7 @@ export function GifCatalogPage() {
<tbody> <tbody>
{paged.map((row) => ( {paged.map((row) => (
<tr className={row.Enabled ? "" : "gift-row-disabled"} key={row.ID}> <tr className={row.Enabled ? "" : "gift-row-disabled"} key={row.ID}>
<td><GifPreviewThumb documentID={row.DocumentID} /></td>
<td className="mono">{row.ID}</td> <td className="mono">{row.ID}</td>
<td>{row.Title || <span className="muted-cell">{"Untitled"}</span>}</td> <td>{row.Title || <span className="muted-cell">{"Untitled"}</span>}</td>
<td className="mono">{row.DocumentID}</td> <td className="mono">{row.DocumentID}</td>
@ -162,7 +191,7 @@ export function GifCatalogPage() {
</td> </td>
</tr> </tr>
))} ))}
{paged.length === 0 && <EmptyRow colSpan={7} />} {paged.length === 0 && <EmptyRow colSpan={8} />}
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -587,6 +587,7 @@
.sticker-doc-image { width: 100%; height: 100%; object-fit: contain; } .sticker-doc-image { width: 100%; height: 100%; object-fit: contain; }
.sticker-doc-cell.list-thumb { width: 40px; flex: 0 0 40px; } .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-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); }
.gif-catalog-thumb { width: 40px; height: 40px; object-fit: cover; background: var(--panel-strong); border: 1px solid var(--line); border-radius: 9px; }
.sticker-doc-grid-cell { display: grid; gap: 4px; } .sticker-doc-grid-cell { display: grid; gap: 4px; }
.sticker-doc-grid-cell .btn { width: 100%; justify-content: center; } .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 { 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; }

View file

@ -2904,6 +2904,32 @@ func (s *Service) StickerDocumentAnimation(ctx context.Context, documentID int64
return data, detected, true, nil return data, detected, true, nil
} }
// GifCatalogDocumentPreview returns a gif_catalog entry's document bytes for
// the admin panel's list-page preview, plus the content type to serve it as.
// Unlike StickerDocumentAnimation there is no gzip/Lottie branch to consider:
// every document AdminUploadGifMaterial creates is already a plain H.264 MP4
// (see gif_admin.go), so this only needs to fetch and sanity-check the blob.
func (s *Service) GifCatalogDocumentPreview(ctx context.Context, documentID int64) ([]byte, string, bool, error) {
if s == nil || s.photos == nil || documentID <= 0 {
return nil, "", false, nil
}
chunk, found, err := s.photos.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("doc:%d", documentID),
Limit: domain.MaxGifCatalogUploadSize + 1,
})
if err != nil {
return nil, "", false, err
}
if !found || chunk.Total <= 0 || chunk.Total > domain.MaxGifCatalogUploadSize || int64(len(chunk.Bytes)) != chunk.Total {
return nil, "", false, nil
}
detected := http.DetectContentType(chunk.Bytes)
if detected != "video/mp4" && !strings.HasPrefix(detected, "video/") {
return nil, "", false, nil
}
return chunk.Bytes, detected, true, nil
}
func isSafeStickerPreviewImageType(value string) bool { func isSafeStickerPreviewImageType(value string) bool {
switch value { switch value {
case "image/webp", "image/png", "image/jpeg", "image/gif": case "image/webp", "image/png", "image/jpeg", "image/gif":

View file

@ -75,6 +75,7 @@ type Service interface {
AddStickerToSet(ctx context.Context, req admin.AddStickerToSetRequest) (admin.CommandResult, error) AddStickerToSet(ctx context.Context, req admin.AddStickerToSetRequest) (admin.CommandResult, error)
RemoveStickerFromSet(ctx context.Context, req admin.RemoveStickerFromSetRequest) (admin.CommandResult, error) RemoveStickerFromSet(ctx context.Context, req admin.RemoveStickerFromSetRequest) (admin.CommandResult, error)
StickerDocumentAnimation(ctx context.Context, documentID int64) ([]byte, string, bool, error) StickerDocumentAnimation(ctx context.Context, documentID int64) ([]byte, string, bool, error)
GifCatalogDocumentPreview(ctx context.Context, documentID int64) ([]byte, string, bool, error)
CreateGifCatalogEntry(ctx context.Context, req admin.CreateGifCatalogEntryRequest) (admin.CommandResult, error) CreateGifCatalogEntry(ctx context.Context, req admin.CreateGifCatalogEntryRequest) (admin.CommandResult, error)
SetGifCatalogEnabled(ctx context.Context, req admin.SetGifCatalogEnabledRequest) (admin.CommandResult, error) SetGifCatalogEnabled(ctx context.Context, req admin.SetGifCatalogEnabledRequest) (admin.CommandResult, error)
SetGifCatalogSortOrder(ctx context.Context, req admin.SetGifCatalogSortOrderRequest) (admin.CommandResult, error) SetGifCatalogSortOrder(ctx context.Context, req admin.SetGifCatalogSortOrderRequest) (admin.CommandResult, error)
@ -212,6 +213,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/gif-catalog/set-sort-order", s.authenticated(s.handleSetGifCatalogSortOrder)) mux.HandleFunc("POST /v1/gif-catalog/set-sort-order", s.authenticated(s.handleSetGifCatalogSortOrder))
mux.HandleFunc("POST /v1/gif-catalog/delete", s.authenticated(s.handleDeleteGifCatalogEntry)) mux.HandleFunc("POST /v1/gif-catalog/delete", s.authenticated(s.handleDeleteGifCatalogEntry))
mux.HandleFunc("GET /v1/stickers/documents/{id}/animation", s.authenticated(s.handleStickerDocumentAnimation)) mux.HandleFunc("GET /v1/stickers/documents/{id}/animation", s.authenticated(s.handleStickerDocumentAnimation))
mux.HandleFunc("GET /v1/gif-catalog/documents/{id}/preview", s.authenticated(s.handleGifCatalogDocumentPreview))
mux.HandleFunc("GET /v1/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation)) mux.HandleFunc("GET /v1/emoji/{id}/animation", s.authenticated(s.handleEmojiAnimation))
mux.HandleFunc("GET /v1/moderation/cases", s.authenticated(s.handleModerationCases)) mux.HandleFunc("GET /v1/moderation/cases", s.authenticated(s.handleModerationCases))
mux.HandleFunc("GET /v1/moderation/cases/{id}", s.authenticated(s.handleModerationCase)) mux.HandleFunc("GET /v1/moderation/cases/{id}", s.authenticated(s.handleModerationCase))
@ -783,6 +785,27 @@ func (s *Server) handleStickerDocumentAnimation(w http.ResponseWriter, r *http.R
_, _ = w.Write(raw) _, _ = w.Write(raw)
} }
func (s *Server) handleGifCatalogDocumentPreview(w http.ResponseWriter, r *http.Request) {
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || documentID <= 0 {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
raw, contentType, found, err := s.svc.GifCatalogDocumentPreview(r.Context(), documentID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !found {
writeError(w, http.StatusNotFound, "gif document not found")
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "private, max-age=300")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
func (s *Server) handleEmojiAnimation(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEmojiAnimation(w http.ResponseWriter, r *http.Request) {
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || documentID <= 0 { if err != nil || documentID <= 0 {

View file

@ -444,6 +444,10 @@ func (fakeService) StickerDocumentAnimation(context.Context, int64) ([]byte, str
return nil, "", false, nil return nil, "", false, nil
} }
func (fakeService) GifCatalogDocumentPreview(context.Context, int64) ([]byte, string, bool, error) {
return nil, "", false, nil
}
func (fakeService) CreateGifCatalogEntry(_ context.Context, req admin.CreateGifCatalogEntryRequest) (admin.CommandResult, error) { func (fakeService) CreateGifCatalogEntry(_ context.Context, req admin.CreateGifCatalogEntryRequest) (admin.CommandResult, error) {
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
} }