From 48815b95efd0caaead53dbc492af66114e1d6b81 Mon Sep 17 00:00:00 2001 From: andronedev Date: Tue, 26 May 2026 00:31:40 +0200 Subject: [PATCH 1/2] feat: add mosaic frame --- cmd/server/main.go | 21 +- frontend/src/api/hooks.ts | 10 + frontend/src/api/types.ts | 26 ++ .../src/components/covers/CoversFilterBar.tsx | 82 +++-- .../src/components/covers/CoversMozaic.tsx | 150 +++++++++ frontend/src/components/covers/CoversPage.tsx | 37 +- frontend/src/data/changelog.ts | 11 + go.mod | 2 + go.sum | 2 + internal/api/handler.go | 6 +- internal/api/mosaic.go | 18 + internal/db/cover.go | 38 +++ internal/ingest/covers.go | 6 + internal/mosaic/assets/frame.png | Bin 0 -> 1468211 bytes internal/mosaic/mosaic.go | 318 ++++++++++++++++++ internal/mosaic/mosaic_test.go | 111 ++++++ 16 files changed, 811 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/covers/CoversMozaic.tsx create mode 100644 internal/api/mosaic.go create mode 100644 internal/mosaic/assets/frame.png create mode 100644 internal/mosaic/mosaic.go create mode 100644 internal/mosaic/mosaic_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 0290471..c886c57 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -21,6 +21,7 @@ import ( "github.com/andronedev/nqntclub/internal/db" "github.com/andronedev/nqntclub/internal/ingest" "github.com/andronedev/nqntclub/internal/middleware" + "github.com/andronedev/nqntclub/internal/mosaic" "github.com/andronedev/nqntclub/internal/source" "github.com/andronedev/nqntclub/internal/umamiproxy" "github.com/andronedev/nqntclub/internal/web" @@ -74,6 +75,19 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() + mosaicGen, err := mosaic.New(database, imagesDir) + if err != nil { + slog.Error("failed to init mosaic generator", "err", err) + os.Exit(1) + } + go func() { + if err := mosaicGen.Regenerate(ctx); err != nil { + slog.Warn("initial mosaic generation failed", "err", err) + } else { + slog.Info("initial mosaic generated") + } + }() + mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { @@ -114,6 +128,11 @@ func main() { } coversSrc := source.NewDiscord(token, selfbot, coverChannelIDs) coversIngester := ingest.NewCovers(coversSrc, database, imagesDir) + coversIngester.OnBatchComplete = func(ctx context.Context) { + if err := mosaicGen.Regenerate(ctx); err != nil { + slog.Warn("post-ingest mosaic regeneration failed", "err", err) + } + } prodsChannelIDs := make([]string, 0, len(prodsChannels)) for _, ch := range prodsChannels { @@ -207,7 +226,7 @@ func main() { for i, ch := range prodsChannels { apiProdsChannels[i] = api.Channel{ID: ch.ID, Name: ch.Name} } - apiHandler := api.NewAPIHandler(database, apiChannels, database.Prods(), apiProdsChannels, auth, adminSecret, deriveViewSalt(sessionSecret)) + apiHandler := api.NewAPIHandler(database, apiChannels, database.Prods(), apiProdsChannels, auth, adminSecret, deriveViewSalt(sessionSecret), mosaicGen) if adminSecret == "" { slog.Info("admin endpoints disabled (set ADMIN_SECRET to enable)") } else { diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 7fc6b2d..dab349b 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -6,6 +6,7 @@ import type { Stats, CoverListResponse, CoversStats, + MosaicResponse, SocialEntityType, } from "./types" import { queryClient } from "./client" @@ -108,6 +109,15 @@ export function useCovers(sort: string, search: string) { }) } +export function useMosaic(enabled: boolean = true) { + return useQuery({ + queryKey: ["covers-mosaic"], + queryFn: () => fetchJSON("/api/covers/mosaic"), + staleTime: 5 * 60 * 1000, + enabled, + }) +} + export function useCoversStats(opts: { enabled?: boolean } = {}) { return useQuery({ queryKey: ["covers-stats"], diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index d935b19..e01fb77 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -92,3 +92,29 @@ export interface CoverListResponse { export interface CoversStats { total: number } + +export interface MosaicCell { + cover_id: string + x: number + y: number + w: number + h: number +} + +export interface MosaicRect { + x: number + y: number + w: number + h: number +} + +export interface MosaicResponse { + version: string + url: string + width: number + height: number + frame: MosaicRect + cells: MosaicCell[] + cover_ids: string[] + generated_at: string +} diff --git a/frontend/src/components/covers/CoversFilterBar.tsx b/frontend/src/components/covers/CoversFilterBar.tsx index adf7fa0..5573596 100644 --- a/frontend/src/components/covers/CoversFilterBar.tsx +++ b/frontend/src/components/covers/CoversFilterBar.tsx @@ -1,12 +1,17 @@ import { Search } from "lucide-react" import { Input } from "@/components/ui/input" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { cn } from "@/lib/utils" + +export type CoversViewMode = "grid" | "mosaic" interface CoversFilterBarProps { sort: string search: string + viewMode: CoversViewMode onSortChange: (sort: string) => void onSearchChange: (search: string) => void + onViewModeChange: (mode: CoversViewMode) => void } const itemClass = @@ -15,14 +20,23 @@ const itemClass = export function CoversFilterBar({ sort, search, + viewMode, onSortChange, onSearchChange, + onViewModeChange, }: CoversFilterBarProps) { + const isMosaic = viewMode === "mosaic" return (