"use client"; import type { ReactNode } from "react"; import { Badge, Box, Center, Group, Image, Paper, RingProgress, ScrollArea, SimpleGrid, Stack, Tabs, Text, ThemeIcon } from "@mantine/core"; import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { NoIntegrationDataError } from "../errors/no-data-integration"; import classes from "./component.module.css"; export default function MediaMissingWidget({ integrationIds, options, width, height }: WidgetComponentProps<"mediaMissing">) { const t = useScopedI18n("widget.mediaMissing"); const pageSize = Number(options.pageSize); const { data } = clientApi.widget.mediaOrganizer.getData.useQuery( { integrationIds, pageSize }, { staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false }, ); if (!data) return ; if (data.length === 0) throw new NoIntegrationDataError(); if (!options.showMissing && !options.showQueued) return (
{t("empty.noTabsEnabled")}
); const missing = data.flatMap((entry) => entry.missing.map((item) => ({ item, integrationId: entry.integrationId })), ); const queued = data.flatMap((entry) => entry.queued.map((item) => ({ item, integrationId: entry.integrationId })), ); const missingCount = data.reduce((sum, entry) => sum + entry.missingCount, 0); const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0); const isThin = width > 0 && width < 160; const isShort = height > 0 && height < 180; const targetCardWidth = isShort ? 130 : 200; const columns = width > 0 ? Math.max(1, Math.min(Math.floor(width / targetCardWidth), 4)) : 1; const density: Density = isThin ? "thin" : width > 0 && width / columns < 180 ? "compact" : "comfortable"; const tabLabel = (label: string, shown: number, total: number) => isThin ? total : `${label} (${shown}/${total})`; const renderPanel = (entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[], emptyLabel: string) => ( {entries.length === 0 ? ( {emptyLabel} ) : ( {entries.map(({ item, integrationId }) => ( ))} )} ); return ( {options.showMissing && ( }> {tabLabel(t("tab.missing"), missing.length, missingCount)} )} {options.showQueued && ( }> {tabLabel(t("tab.queued"), queued.length, queuedCount)} )} {options.showMissing && ( {renderPanel(missing, t("empty.missing"))} )} {options.showQueued && ( {renderPanel(queued, t("empty.queued"))} )} ); } type Density = "thin" | "compact" | "comfortable"; const CARD_HEIGHT: Record = { thin: 52, compact: 56, comfortable: 68 }; const posterSizes: Record = { thin: 34, compact: 40, comfortable: 52 }; const Poster = ({ src, type, density }: { src?: string | null; type: "movie" | "episode"; density: Density }) => { const size = posterSizes[density]; const w = Math.round(size * 0.68); if (src) { return ; } return ( {type === "movie" ? : } ); }; const episodeCode = (item: MissingMediaItem | QueuedMediaItem) => item.type === "episode" && item.seasonNumber !== undefined && item.episodeNumber !== undefined ? `S${String(item.seasonNumber).padStart(2, "0")}E${String(item.episodeNumber).padStart(2, "0")}` : null; const TypeBadge = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem; density: Density }) => { const t = useScopedI18n("widget.mediaMissing"); const color = item.type === "movie" ? "yellow" : "blue"; const code = episodeCode(item); if (density !== "comfortable") { return ( {code ?? t(`type.${item.type}`)} ); } return ( {t(`type.${item.type}`)} {code && ( {code} )} ); }; const primaryTitle = (item: MissingMediaItem | QueuedMediaItem) => item.type === "episode" ? (item.seriesTitle ?? item.title) : item.title; const progressColor = (percent: number) => (percent >= 90 ? "green" : percent >= 40 ? "cyan" : "orange"); const ringSizes: Record = { thin: { size: 30, thickness: 3, fz: "8px" }, compact: { size: 34, thickness: 3, fz: "9px" }, comfortable: { size: 40, thickness: 4, fz: "10px" }, }; const ProgressRing = ({ percent, density }: { percent: number; density: Density }) => { const color = progressColor(percent); const ring = ringSizes[density]; return ( {percent} } /> ); }; const CardShell = ({ item, density, children }: { item: MissingMediaItem | QueuedMediaItem; density: Density; children: ReactNode }) => ( {item.imageUrl && ( )}
{children}
); const MediaCard = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem; density: Density }) => { const isQueued = "percentComplete" in item; return ( {primaryTitle(item)} {density === "comfortable" && ( {item.type === "episode" ? item.title : item.year} )} {isQueued && } ); };