"use client"; import { useCallback, useMemo, useState } from "react"; import { Button, Divider, Group, Stack, Text, Title, Tooltip } from "@mantine/core"; import { IconArchive, 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, 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 { Providers } from "./releases-providers"; import type { 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 [expandedRepository, setExpandedRepository] = useState({ providerKey: "", identifier: "" }); const hasIconColor = useMemo(() => board.iconColor !== null, [board.iconColor]); const relativeDateOptions = useMemo( () => ({ newReleaseWithin: formatRelativeDate(options.newReleaseWithin), staleReleaseWithin: formatRelativeDate(options.staleReleaseWithin), }), [options.newReleaseWithin, options.staleReleaseWithin], ); const batchedRepositories = useMemo(() => splitToChunksWithNItems(options.repositories, 5), [options.repositories]); const [results] = clientApi.useSuspenseQueries((t) => batchedRepositories.flatMap((chunk) => t.widget.releases.getLatest({ repositories: chunk.map((repository) => ({ providerKey: repository.providerKey, identifier: repository.identifier, versionFilter: repository.versionFilter, })), }), ), ); const repositories = useMemo(() => { return results .flat() .map(({ data }) => { if (data === undefined) return undefined; const repository = options.repositories.find( (repository) => repository.providerKey === data.providerKey && repository.identifier === data.identifier, ); if (repository === undefined) return undefined; return { ...data, iconUrl: repository.iconUrl, isNewRelease: relativeDateOptions.newReleaseWithin !== "" && data.latestReleaseAt ? isDateWithin(data.latestReleaseAt, relativeDateOptions.newReleaseWithin) : false, isStaleRelease: relativeDateOptions.staleReleaseWithin !== "" && data.latestReleaseAt ? !isDateWithin(data.latestReleaseAt, relativeDateOptions.staleReleaseWithin) : false, }; }) .filter( (repository) => repository !== undefined && (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[]; }, [ results, options.repositories, options.showOnlyHighlighted, relativeDateOptions.newReleaseWithin, relativeDateOptions.staleReleaseWithin, ]); const toggleExpandedRepository = useCallback( (repository: ReleasesRepositoryResponse) => { if ( expandedRepository.providerKey === repository.providerKey && expandedRepository.identifier === repository.identifier ) { setExpandedRepository({ providerKey: "", identifier: "" }); } else { setExpandedRepository({ providerKey: repository.providerKey, identifier: repository.identifier }); } }, [expandedRepository], ); return ( {repositories.map((repository: ReleasesRepositoryResponse) => { const isActive = expandedRepository.providerKey === repository.providerKey && expandedRepository.identifier === repository.identifier; const hasError = repository.error !== undefined; return ( toggleExpandedRepository(repository)} > {repository.identifier} {hasError ? t("error.label") : (repository.latestRelease ?? t("not-found"))} {repository.latestReleaseAt && !hasError && formatter.relativeTime(repository.latestReleaseAt, { now, style: "narrow", })} {!hasError ? ( (repository.isNewRelease || repository.isStaleRelease) && ( ) ) : ( )} {options.showDetails && ( )} {isActive && } ); })} ); } interface DetailsDisplayProps { repository: ReleasesRepositoryResponse; toggleExpandedRepository: (repository: ReleasesRepositoryResponse) => void; } const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplayProps) => { const t = useScopedI18n("widget.releases"); const formatter = useFormatter(); return ( <> toggleExpandedRepository(repository)} /> toggleExpandedRepository(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; } const ExpandedDisplay = ({ repository, hasIconColor }: ExtendedDisplayProps) => { const t = useScopedI18n("widget.releases"); const now = useNow(); const formatter = useFormatter(); return ( <> {Providers[repository.providerKey]?.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.options.${repository.error.code}` as never) : repository.error.message} )} {repository.releaseDescription && ( <> {t("releaseDescription")} {repository.releaseDescription} )} ); };