added gifs preview
This commit is contained in:
parent
ff1d5ae361
commit
15f2d6e925
11 changed files with 141 additions and 8 deletions
|
|
@ -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/{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/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-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetSortOrderAPI)))
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
|
|
|
|||
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-DQxoDUCy.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CqUW4fne.css">
|
||||
<script type="module" crossorigin src="/assets/index-LWf3JhYx.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ export const api = {
|
|||
stickerSets: (kind: string) => request<StickerSetListResponse>(`/api/stickers?kind=${encodeURIComponent(kind)}`),
|
||||
stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`),
|
||||
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 }),
|
||||
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 }),
|
||||
|
|
|
|||
|
|
@ -115,7 +115,14 @@ export function Dashboard({ navigate }: { navigate: Navigate }) {
|
|||
href="/emoji"
|
||||
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
|
||||
icon={<Database />}
|
||||
label="Media storage used"
|
||||
|
|
|
|||
|
|
@ -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 { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
|
|
@ -8,6 +8,33 @@ import type { GifCatalogRow } from "../types";
|
|||
|
||||
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
|
||||
// serves for the client's GIF picker trending/search panel.
|
||||
export function GifCatalogPage() {
|
||||
|
|
@ -105,6 +132,7 @@ export function GifCatalogPage() {
|
|||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Preview"}</th>
|
||||
<th>{"ID"}</th>
|
||||
<th>{"Title"}</th>
|
||||
<th>{"Document ID"}</th>
|
||||
|
|
@ -117,6 +145,7 @@ export function GifCatalogPage() {
|
|||
<tbody>
|
||||
{paged.map((row) => (
|
||||
<tr className={row.Enabled ? "" : "gift-row-disabled"} key={row.ID}>
|
||||
<td><GifPreviewThumb documentID={row.DocumentID} /></td>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td>{row.Title || <span className="muted-cell">{"Untitled"}</span>}</td>
|
||||
<td className="mono">{row.DocumentID}</td>
|
||||
|
|
@ -162,7 +191,7 @@ export function GifCatalogPage() {
|
|||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{paged.length === 0 && <EmptyRow colSpan={7} />}
|
||||
{paged.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -587,6 +587,7 @@
|
|||
.sticker-doc-image { width: 100%; height: 100%; object-fit: contain; }
|
||||
.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); }
|
||||
.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 .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; }
|
||||
|
|
|
|||
|
|
@ -2904,6 +2904,32 @@ func (s *Service) StickerDocumentAnimation(ctx context.Context, documentID int64
|
|||
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 {
|
||||
switch value {
|
||||
case "image/webp", "image/png", "image/jpeg", "image/gif":
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ type Service interface {
|
|||
AddStickerToSet(ctx context.Context, req admin.AddStickerToSetRequest) (admin.CommandResult, error)
|
||||
RemoveStickerFromSet(ctx context.Context, req admin.RemoveStickerFromSetRequest) (admin.CommandResult, 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)
|
||||
SetGifCatalogEnabled(ctx context.Context, req admin.SetGifCatalogEnabledRequest) (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/delete", s.authenticated(s.handleDeleteGifCatalogEntry))
|
||||
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/moderation/cases", s.authenticated(s.handleModerationCases))
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
documentID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || documentID <= 0 {
|
||||
|
|
|
|||
|
|
@ -444,6 +444,10 @@ func (fakeService) StickerDocumentAnimation(context.Context, int64) ([]byte, str
|
|||
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) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue