"use client"; import { useCallback, useMemo, useState } from "react"; import { Button, Divider, Group, Stack, Text, Title, Tooltip } from "@mantine/core"; import { useLocalStorage } from "@mantine/hooks"; import { IconArchive, IconCheck, IconCircleDot, IconCircleFilled, IconExternalLink, IconGitFork, IconProgressCheck, IconStar, IconTriangleFilled, } from "@tabler/icons-react"; import combineClasses from "clsx"; import { useFormatter, useNow } from "next-intl"; import ReactMarkdown from "react-markdown"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { isDateWithin, isNullOrWhitespace, splitToChunksWithNItems } from "@homarr/common"; import { useScopedI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.scss"; import type { ReleasesRepository, ReleasesRepositoryResponse } from "./releases-repository"; const formatRelativeDate = (value: string): string => { const isMonths = /\d+m/g.test(value); const isOtherUnits = /\d+[HDWY]/g.test(value); return isMonths ? value.toUpperCase() : isOtherUnits ? value.toLowerCase() : value; }; export default function ReleasesWidget({ options }: WidgetComponentProps<"releases">) { const t = useScopedI18n("widget.releases"); const now = useNow(); const formatter = useFormatter(); const board = useRequiredBoard(); const [expandedRepositoryId, setExpandedRepositoryId] = useState(null); const hasIconColor = useMemo(() => board.iconColor !== null, [board.iconColor]); const [releasesViewedList, setReleasesViewedList] = useLocalStorage>({ key: "releases-viewed-versions", defaultValue: {}, }); const relativeDateOptions = useMemo( () => ({ newReleaseWithin: formatRelativeDate(options.newReleaseWithin), staleReleaseWithin: formatRelativeDate(options.staleReleaseWithin), }), [options.newReleaseWithin, options.staleReleaseWithin], ); // Group repositories by integration const groupedRepositories = useMemo(() => { return options.repositories.reduce( (acc, repo) => { const key = repo.providerIntegrationId; if (!key) return acc; acc[key] ??= []; acc[key].push(repo); return acc; }, {} as Record, ); }, [options.repositories]); // For each group, split into chunks of 5 const batchedRepositories = useMemo(() => { return Object.entries(groupedRepositories).flatMap(([integrationId, group]) => splitToChunksWithNItems(group, 5).map((chunk) => ({ integrationId, repositories: chunk, })), ); }, [groupedRepositories]); const [results] = clientApi.useSuspenseQueries((t) => batchedRepositories.flatMap(({ integrationId, repositories }) => t.widget.releases.getLatest({ integrationId, repositories: repositories.map((repository) => ({ id: repository.id, identifier: repository.identifier, versionFilter: repository.versionFilter, })), }), ), ); const repositories = useMemo(() => { const formattedResults = options.repositories .map((repository) => { if (repository.providerIntegrationId === undefined) { return { ...repository, isNewRelease: false, isStaleRelease: false, latestReleaseAt: undefined, error: { code: "noProviderSeleceted", }, }; } const response = results.flat().find(({ data }) => data.id === repository.id)?.data; if (response === undefined) return { ...repository, isNewRelease: false, isStaleRelease: false, latestReleaseAt: undefined, error: { code: "noProviderResponse", }, }; return { ...repository, ...response, isNewRelease: relativeDateOptions.newReleaseWithin !== "" && response.latestReleaseAt ? isDateWithin(response.latestReleaseAt, relativeDateOptions.newReleaseWithin) : false, isStaleRelease: relativeDateOptions.staleReleaseWithin !== "" && response.latestReleaseAt ? !isDateWithin(response.latestReleaseAt, relativeDateOptions.staleReleaseWithin) : false, viewed: releasesViewedList[repository.id] === response.latestRelease, }; }) .filter( (repository) => repository.error !== undefined || !options.showOnlyHighlighted || repository.isNewRelease || repository.isStaleRelease, ) .sort((repoA, repoB) => { if (repoA.latestReleaseAt === undefined) return -1; if (repoB.latestReleaseAt === undefined) return 1; return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1; }) as ReleasesRepositoryResponse[]; if (typeof options.topReleases !== "string" && options.topReleases > 0) { return formattedResults.slice(0, options.topReleases); } return formattedResults; }, [ results, options.repositories, options.showOnlyHighlighted, options.topReleases, relativeDateOptions.newReleaseWithin, relativeDateOptions.staleReleaseWithin, releasesViewedList, ]); const toggleExpandedDisplay = useCallback( (repository: ReleasesRepositoryResponse) => setExpandedRepositoryId(expandedRepositoryId === repository.id ? null : repository.id), [expandedRepositoryId], ); const markReleaseViewed = useCallback( (repository: ReleasesRepositoryResponse) => { repository.viewed = true; setReleasesViewedList((prev) => ({ ...prev, [repository.id]: repository.latestRelease ?? "" })); }, [setReleasesViewedList], ); return ( {repositories.map((repository: ReleasesRepositoryResponse) => { const isActive = expandedRepositoryId === repository.id; const hasError = repository.error !== undefined; return ( toggleExpandedDisplay(repository)} > {!options.showOnlyIcon && ( {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} {repository.name || repository.identifier} )} {hasError ? t("error.label") : (repository.latestRelease ?? t("not-found"))} {repository.latestReleaseAt && !hasError && formatter.relativeTime(repository.latestReleaseAt, { now, style: "narrow", })} {hasError ? ( ) : repository.viewed ? ( ) : ( (repository.isNewRelease || repository.isStaleRelease) && ( ) )} {options.showDetails && ( )} {isActive && ( )} ); })} ); } interface DetailsDisplayProps { repository: ReleasesRepositoryResponse; toggleExpandedDisplay: (repository: ReleasesRepositoryResponse) => void; } const DetailsDisplay = ({ repository, toggleExpandedDisplay }: DetailsDisplayProps) => { const t = useScopedI18n("widget.releases"); const formatter = useFormatter(); return ( <> toggleExpandedDisplay(repository)} /> toggleExpandedDisplay(repository)} > {!repository.starsCount ? "-" : formatter.number(repository.starsCount, { notation: "compact", maximumFractionDigits: 1, })} {!repository.forksCount ? "-" : formatter.number(repository.forksCount, { notation: "compact", maximumFractionDigits: 1, })} {!repository.openIssues ? "-" : formatter.number(repository.openIssues, { notation: "compact", maximumFractionDigits: 1, })} ); }; interface ExtendedDisplayProps { repository: ReleasesRepositoryResponse; hasIconColor: boolean; markReleaseViewed: (repository: ReleasesRepositoryResponse) => void; toggleExpandedDisplay: (repository: ReleasesRepositoryResponse) => void; } const ExpandedDisplay = ({ repository, hasIconColor, markReleaseViewed, toggleExpandedDisplay, }: ExtendedDisplayProps) => { const t = useScopedI18n("widget.releases"); const now = useNow(); const formatter = useFormatter(); return ( <> {repository.identifier} {repository.integration && ( {repository.integration.name} )} {repository.createdAt && ( {`${t("created")} | `} {formatter.relativeTime(repository.createdAt, { now, style: "narrow", })} )} {(repository.releaseUrl ?? repository.projectUrl) && ( )} {repository.error && ( <> {t("error.label")} {repository.error.code ? t(`error.messages.${repository.error.code}` as never) : repository.error.message} )} {repository.releaseDescription ? ( ) : ( )} ); }; interface DescriptionProps { title: string; description: string | null; } const Description = ({ title, description }: DescriptionProps) => { if (isNullOrWhitespace(description)) return null; return ( <> {title} {description} ); };