"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 { getIconUrl } from "@homarr/definitions"; 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 queryResults = clientApi.useQueries((t) => batchedRepositories.flatMap(({ integrationId, repositories }) => t.widget.releases.getLatest({ integrationId, repositories: repositories.map((repository) => ({ id: repository.id, identifier: repository.identifier, versionFilter: repository.versionFilter, })), }), ), ); const results = useMemo( () => queryResults.map((q) => q.data).filter((d): d is NonNullable => d != null), [queryResults], ); const repositories = useMemo(() => { const formattedResults = options.repositories .map((repository) => { if (!repository.providerIntegrationId) return { ...repository, error: { code: "noProviderSeleceted" } }; const repositoryResult = results.flat().find(({ id }) => id === repository.id); if (!repositoryResult) return { ...repository, error: { code: "noProviderResponse" } }; if (!repositoryResult.success) return { ...repository, error: repositoryResult.error }; const { data: release, integration } = repositoryResult; const isReleaseWithin = (relativeDate: string) => Boolean(relativeDate) && isDateWithin(release.latestReleaseAt, relativeDate); return { ...repository, ...release, integration: { name: integration.name, iconUrl: getIconUrl(integration.kind) }, isNewRelease: isReleaseWithin(relativeDateOptions.newReleaseWithin), isStaleRelease: !isReleaseWithin(relativeDateOptions.staleReleaseWithin), viewed: releasesViewedList[repository.id] === release.latestRelease, }; }) .filter( (repository) => "error" in repository || !options.showOnlyHighlighted || repository.isNewRelease || repository.isStaleRelease, ) .toSorted((repoA, repoB) => { if ("error" in repoA) return -1; if ("error" in repoB) 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} ); };