Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/api/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
Stats,
CoverListResponse,
CoversStats,
MosaicResponse,
SocialEntityType,
} from "./types"
import { queryClient } from "./client"
Expand Down Expand Up @@ -108,6 +109,15 @@ export function useCovers(sort: string, search: string) {
})
}

export function useMosaic(enabled: boolean = true) {
return useQuery<MosaicResponse>({
queryKey: ["covers-mosaic"],
queryFn: () => fetchJSON("/api/covers/mosaic"),
staleTime: 5 * 60 * 1000,
enabled,
})
}

export function useCoversStats(opts: { enabled?: boolean } = {}) {
return useQuery<CoversStats>({
queryKey: ["covers-stats"],
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
82 changes: 60 additions & 22 deletions frontend/src/components/covers/CoversFilterBar.tsx
Original file line number Diff line number Diff line change
@@ -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 =
Expand All @@ -15,46 +20,79 @@ const itemClass =
export function CoversFilterBar({
sort,
search,
viewMode,
onSortChange,
onSearchChange,
onViewModeChange,
}: CoversFilterBarProps) {
const isMosaic = viewMode === "mosaic"
return (
<nav className="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto max-w-5xl px-4 py-2.5 sm:px-6 sm:py-3 lg:px-8 xl:max-w-6xl xl:px-12 2xl:max-w-7xl 2xl:px-16 3xl:max-w-[100rem]">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="relative w-full sm:w-48">
<div
className={cn(
"relative w-full sm:w-48 transition-opacity",
isMosaic && "pointer-events-none opacity-0 sm:opacity-40",
)}
aria-hidden={isMosaic}
>
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="Rechercher..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="h-8 pl-8 text-sm"
disabled={isMosaic}
/>
</div>
<div className="flex items-center gap-2">
<span className="shrink-0 text-xs font-medium uppercase tracking-wider text-muted-foreground">
Tri
</span>
<ToggleGroup
type="single"
value={sort}
onValueChange={(v) => {
if (v) onSortChange(v)
}}
spacing={1}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => onViewModeChange(isMosaic ? "grid" : "mosaic")}
data-state={isMosaic ? "on" : "off"}
className={cn(
"shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
isMosaic
? "border-primary bg-primary text-primary-foreground"
: "border-border text-foreground hover:bg-muted",
)}
aria-pressed={isMosaic}
>
Manificat Discorded
</button>
<div
className={cn(
"flex items-center gap-2 transition-opacity",
isMosaic && "pointer-events-none opacity-40",
)}
aria-hidden={isMosaic}
>
<ToggleGroupItem value="date" size="sm" className={itemClass}>
Récents
</ToggleGroupItem>
<ToggleGroupItem
value="reactions"
size="sm"
className={itemClass}
<span className="shrink-0 text-xs font-medium uppercase tracking-wider text-muted-foreground">
Tri
</span>
<ToggleGroup
type="single"
value={sort}
onValueChange={(v) => {
if (v) onSortChange(v)
}}
spacing={1}
disabled={isMosaic}
>
Populaires
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroupItem value="date" size="sm" className={itemClass}>
Récents
</ToggleGroupItem>
<ToggleGroupItem
value="reactions"
size="sm"
className={itemClass}
>
Populaires
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
</div>
</div>
Expand Down
Loading