"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, } 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 { 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 { ReleasesRepository } from "./releases-repository"; function isDateWithin(date: Date, relativeDate: string): boolean { const amount = parseInt(relativeDate.slice(0, -1), 10); const unit = relativeDate.slice(-1); const startTime = new Date().getTime(); const endTime = new Date(date).getTime(); const diffTime = Math.abs(endTime - startTime); const diffHours = Math.ceil(diffTime / (1000 * 60 * 60)); switch (unit) { case "h": return diffHours < amount; case "d": return diffHours / 24 < amount; case "w": return diffHours / (24 * 7) < amount; case "m": return diffHours / (24 * 30) < amount; case "y": return diffHours / (24 * 365) < amount; default: throw new Error("Invalid unit"); } } 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(""); const hasIconColor = useMemo(() => board.iconColor !== null, [board.iconColor]); const [results] = clientApi.widget.releases.getLatest.useSuspenseQuery( { repositories: options.repositories.map((repository) => ({ providerKey: repository.providerKey, identifier: repository.identifier, versionFilter: repository.versionFilter, })), }, { refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, }, ); const repositories = useMemo(() => { return results .map(({ data }) => { if (data === undefined) return undefined; const repository = options.repositories.find( (repository: ReleasesRepository) => repository.providerKey === data.providerKey && repository.identifier === data.identifier, ); if (repository === undefined) return undefined; return { ...repository, ...data, isNewRelease: options.newReleaseWithin !== "" ? isDateWithin(data.latestReleaseAt, options.newReleaseWithin) : false, isStaleRelease: options.staleReleaseWithin !== "" ? !isDateWithin(data.latestReleaseAt, options.staleReleaseWithin) : false, }; }) .filter( (repository) => repository !== 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 ReleasesRepository[]; }, [ results, options.repositories, options.showOnlyHighlighted, options.newReleaseWithin, options.staleReleaseWithin, ]); const toggleExpandedRepository = useCallback( (identifier: string) => { setExpandedRepository(expandedRepository === identifier ? "" : identifier); }, [expandedRepository], ); return ( {repositories.map((repository: ReleasesRepository) => { const isActive = expandedRepository === repository.identifier; return ( toggleExpandedRepository(repository.identifier)} > {repository.identifier} {repository.latestRelease ?? t("not-found")} {repository.latestReleaseAt && formatter.relativeTime(repository.latestReleaseAt, { now, style: "narrow", })} {(repository.isNewRelease || repository.isStaleRelease) && ( )} {options.showDetails && ( )} {isActive && } ); })} ); } interface DetailsDisplayProps { repository: ReleasesRepository; toggleExpandedRepository: (identifier: string) => void; } const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplayProps) => { const t = useScopedI18n("widget.releases"); const formatter = useFormatter(); return ( <> toggleExpandedRepository(repository.identifier)} /> toggleExpandedRepository(repository.identifier)} > {repository.starsCount === 0 ? "-" : formatter.number(repository.starsCount ?? 0, { notation: "compact", maximumFractionDigits: 1, })} {repository.forksCount === 0 ? "-" : formatter.number(repository.forksCount ?? 0, { notation: "compact", maximumFractionDigits: 1, })} {repository.openIssues === 0 ? "-" : formatter.number(repository.openIssues ?? 0, { notation: "compact", maximumFractionDigits: 1, })} ); }; interface ExtendedDisplayProps { repository: ReleasesRepository; 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.releaseDescription && ( <> {t("releaseDescription")} {repository.releaseDescription} )} ); };