"use client"; import type { ReactNode } from "react"; import { useEffect, useState } from "react"; import { Badge, Box, Center, Group, Image, Paper, RingProgress, ScrollArea, SimpleGrid, Stack, Tabs, Text, ThemeIcon, Tooltip, } from "@mantine/core"; import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react"; import { getQueryKey } from "@trpc/react-query"; 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 { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getUsableWidgetQueryData } from "../common/query-state"; import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; import { useWidgetRuntimeQueries } from "../runtime-hooks"; import { NoIntegrationDataError } from "../errors/no-data-integration"; import classes from "./component.module.css"; import type { MediaMissingTab } from "./tabs"; import { resolveMediaMissingTab } from "./tabs"; export default function MediaMissingWidget({ integrationIds, options, width, height, displayMode, widgetRuntimeRef, }: WidgetComponentProps<"mediaMissing">) { const t = useScopedI18n("widget.mediaMissing"); const isAdvanced = displayMode === "advanced"; const showMissing = isAdvanced || options.showMissing; const showQueued = isAdvanced || options.showQueued; const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize); const input = { integrationIds, pageSize }; useWidgetRuntimeQueries(widgetRuntimeRef, [getQueryKey(clientApi.widget.mediaOrganizer.getData, input, "query")]); const mediaQuery = clientApi.widget.mediaOrganizer.getData.useQuery(input, { staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false, }); const data = getUsableWidgetQueryData(mediaQuery); const [selectedTab, setSelectedTab] = useState(showMissing ? "missing" : "queued"); const activeTab = resolveMediaMissingTab(selectedTab, showMissing, showQueued); useEffect(() => { if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab); }, [activeTab, selectedTab]); if (!data) return ; if (data.length === 0) throw new NoIntegrationDataError(); if (!showMissing && !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 failedIntegrations = data.filter((entry): entry is typeof entry & { error: string } => Boolean(entry.error)); const enabledPanelCount = Number(showMissing) + Number(showQueued); const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width; const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160; const isShort = !isAdvanced && height > 0 && height < 180; const targetCardWidth = isShort ? 130 : 200; const columns = panelWidth > 0 ? Math.max(1, Math.min(Math.floor(panelWidth / targetCardWidth), 4)) : 1; const density: Density = isThin ? "thin" : panelWidth > 0 && panelWidth / 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 }) => ( ))} )} ); const partialFailures = failedIntegrations.length > 0 && ( {failedIntegrations.map((entry) => ( {entry.integrationName} ))} ); const queryFailure = ; if (isAdvanced) { return ( {queryFailure} {partialFailures} {showMissing && ( {tabLabel(t("tab.missing"), missing.length, missingCount)} {renderPanel(missing, t("empty.missing"))} )} {showQueued && ( {tabLabel(t("tab.queued"), queued.length, queuedCount)} {renderPanel(queued, t("empty.queued"))} )} ); } return ( { if (value === "missing" || value === "queued") setSelectedTab(value); }} h="100%" style={{ display: "flex", flexDirection: "column" }} > {queryFailure} {partialFailures} {showMissing && ( }> {tabLabel(t("tab.missing"), missing.length, missingCount)} )} {showQueued && ( }> {tabLabel(t("tab.queued"), queued.length, queuedCount)} )} {showMissing && ( {renderPanel(missing, t("empty.missing"))} )} {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; }) => { const href = getSafeApplicationUrl(item.link); return (
{children}
); }; const MediaCard = ({ item, density, showQueueDetails, }: { item: MissingMediaItem | QueuedMediaItem; density: Density; showQueueDetails: boolean; }) => { const isQueued = "percentComplete" in item; return ( {primaryTitle(item)} {density === "comfortable" && ( {item.type === "episode" ? item.title : item.year} )} {isQueued && showQueueDetails && ( {[item.status, item.timeLeft].filter(Boolean).join(" ยท ")} )} {isQueued && } ); };