feat: add releases widget (#2497)

Co-authored-by: Andre Silva <asilva01@acuitysso.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com>
This commit is contained in:
Andre Silva
2025-04-25 19:49:32 +01:00
committed by GitHub
parent d97e74047d
commit 3dcee8cb86
19 changed files with 2068 additions and 7 deletions

View File

@@ -2,6 +2,7 @@ import type { WidgetOptionType } from "../options";
import { WidgetAppInput } from "./widget-app-input";
import { WidgetLocationInput } from "./widget-location-input";
import { WidgetMultiTextInput } from "./widget-multi-text-input";
import { WidgetMultiReleasesRepositoriesInput } from "./widget-multiReleasesRepositories-input";
import { WidgetMultiSelectInput } from "./widget-multiselect-input";
import { WidgetNumberInput } from "./widget-number-input";
import { WidgetSelectInput } from "./widget-select-input";
@@ -21,6 +22,7 @@ const mapping = {
switch: WidgetSwitchInput,
app: WidgetAppInput,
sortableItemList: WidgetSortedItemListInput,
multiReleasesRepositories: WidgetMultiReleasesRepositoriesInput,
} satisfies Record<WidgetOptionType, unknown>;
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {

View File

@@ -0,0 +1,326 @@
"use client";
import React, { useCallback, useMemo, useState } from "react";
import { ActionIcon, Button, Divider, Fieldset, Group, Select, Stack, Text, TextInput } from "@mantine/core";
import type { FormErrors } from "@mantine/form";
import { IconEdit, IconTrash, IconTriangleFilled } from "@tabler/icons-react";
import { escapeForRegEx } from "@tiptap/react";
import { IconPicker } from "@homarr/forms-collection";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { Providers } from "../releases/releases-providers";
import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
interface FormValidation {
hasErrors: boolean;
errors: FormErrors;
}
export const WidgetMultiReleasesRepositoriesInput = ({
property,
kind,
}: CommonWidgetInputProps<"multiReleasesRepositories">) => {
const t = useWidgetInputTranslation(kind, property);
const tRepository = useScopedI18n("widget.releases.option.repositories");
const form = useFormContext();
const repositories = form.values.options[property] as ReleasesRepository[];
const { openModal } = useModalAction(ReleaseEditModal);
const versionFilterPrecisionOptions = useMemo(
() => [tRepository("versionFilter.precision.options.none"), "#", "#.#", "#.#.#", "#.#.#.#", "#.#.#.#.#"],
[tRepository],
);
const onRepositorySave = useCallback(
(repository: ReleasesRepository, index: number): FormValidation => {
form.setFieldValue(`options.${property}.${index}.providerKey`, repository.providerKey);
form.setFieldValue(`options.${property}.${index}.identifier`, repository.identifier);
form.setFieldValue(`options.${property}.${index}.versionFilter`, repository.versionFilter);
form.setFieldValue(`options.${property}.${index}.iconUrl`, repository.iconUrl);
const formValidation = form.validate();
const fieldErrors: FormErrors = Object.entries(formValidation.errors).reduce((acc, [key, value]) => {
if (key.startsWith(`options.${property}.${index}.`)) {
acc[key] = value;
}
return acc;
}, {} as FormErrors);
return {
hasErrors: Object.keys(fieldErrors).length > 0,
errors: fieldErrors,
};
},
[form, property],
);
const addNewItem = () => {
const item = {
providerKey: "DockerHub",
identifier: "",
} as ReleasesRepository;
form.setValues((previous) => {
const previousValues = previous.options?.[property] as ReleasesRepository[];
return {
...previous,
options: {
...previous.options,
[property]: [...previousValues, item],
},
};
});
const index = repositories.length;
openModal({
fieldPath: `options.${property}.${index}`,
repository: item,
onRepositorySave: (saved) => onRepositorySave(saved, index),
versionFilterPrecisionOptions,
});
};
const onReleaseRemove = (index: number) => {
form.setValues((previous) => {
const previousValues = previous.options?.[property] as ReleasesRepository[];
return {
...previous,
options: {
...previous.options,
[property]: previousValues.filter((_, i) => i !== index),
},
};
});
};
return (
<Fieldset legend={t("label")}>
<Stack gap="5">
<Button onClick={addNewItem}>{tRepository("addRRepository.label")}</Button>
<Divider my="sm" />
{repositories.map((repository, index) => {
return (
<Stack key={`${repository.providerKey}.${repository.identifier}`} gap={5}>
<Group align="center" gap="xs">
<MaskedOrNormalImage
hasColor={false}
imageUrl={repository.iconUrl ?? Providers[repository.providerKey]?.iconUrl}
style={{
height: "1em",
width: "1em",
}}
/>
<Text c="dimmed" fw={100} size="xs">
{Providers[repository.providerKey]?.name}
</Text>
<Group justify="space-between" align="center" style={{ flex: 1 }} gap={5}>
<Text size="sm" style={{ flex: 1, whiteSpace: "nowrap" }}>
{repository.identifier}
</Text>
<Text c="dimmed" size="xs" ta="end" style={{ flex: 1, whiteSpace: "nowrap" }}>
{formatVersionFilterRegex(repository.versionFilter) ?? ""}
</Text>
</Group>
<Button
onClick={() =>
openModal({
fieldPath: `options.${property}.${index}`,
repository,
onRepositorySave: (saved) => onRepositorySave(saved, index),
versionFilterPrecisionOptions,
})
}
variant="light"
leftSection={<IconEdit size={15} />}
size="xs"
>
{tRepository("edit.label")}
</Button>
<ActionIcon variant="transparent" color="red" onClick={() => onReleaseRemove(index)}>
<IconTrash size={15} />
</ActionIcon>
</Group>
{Object.keys(form.errors).filter((key) => key.startsWith(`options.${property}.${index}.`)).length > 0 && (
<Group align="center" justify="center" gap="xs" bg="red.1">
<IconTriangleFilled size={15} color="var(--mantine-color-red-filled)" />
<Text size="sm" c="red">
{tRepository("invalid")}
</Text>
</Group>
)}
<Divider my="sm" size="xs" mt={5} mb={5} />
</Stack>
);
})}
</Stack>
</Fieldset>
);
};
const formatVersionFilterRegex = (versionFilter: ReleasesVersionFilter | undefined) => {
if (!versionFilter) return undefined;
const escapedPrefix = versionFilter.prefix ? escapeForRegEx(versionFilter.prefix) : "";
const precision = "[0-9]+\\.".repeat(versionFilter.precision).slice(0, -2);
const escapedSuffix = versionFilter.suffix ? escapeForRegEx(versionFilter.suffix) : "";
return `^${escapedPrefix}${precision}${escapedSuffix}$`;
};
interface ReleaseEditProps {
fieldPath: string;
repository: ReleasesRepository;
onRepositorySave: (repository: ReleasesRepository) => FormValidation;
versionFilterPrecisionOptions: string[];
}
const ReleaseEditModal = createModal<ReleaseEditProps>(({ innerProps, actions }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const [loading, setLoading] = useState(false);
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
const [formErrors, setFormErrors] = useState<FormErrors>({});
const handleConfirm = useCallback(() => {
setLoading(true);
const validation = innerProps.onRepositorySave(tempRepository);
setFormErrors(validation.errors);
if (!validation.hasErrors) {
actions.closeModal();
}
setLoading(false);
}, [innerProps, tempRepository, actions]);
const handleChange = useCallback((changedValue: Partial<ReleasesRepository>) => {
setTempRepository((prev) => ({ ...prev, ...changedValue }));
}, []);
return (
<Stack>
<Group align="center">
<Select
withAsterisk
label={tRepository("provider.label")}
data={Object.entries(Providers).map(([key, provider]) => ({
value: key,
label: provider.name,
}))}
value={tempRepository.providerKey}
error={formErrors[`${innerProps.fieldPath}.providerKey`]}
onChange={(value) => {
if (value && Providers[value]) {
handleChange({ providerKey: value });
}
}}
/>
<TextInput
withAsterisk
label={tRepository("identifier.label")}
value={tempRepository.identifier}
onChange={(event) => {
handleChange({ identifier: event.currentTarget.value });
}}
error={formErrors[`${innerProps.fieldPath}.identifier`]}
style={{ flex: 1 }}
/>
</Group>
<Fieldset legend={tRepository("versionFilter.label")}>
<Group justify="stretch" align="center" grow>
<TextInput
label={tRepository("versionFilter.prefix.label")}
value={tempRepository.versionFilter?.prefix ?? ""}
onChange={(event) => {
handleChange({
versionFilter: {
...(tempRepository.versionFilter ?? { precision: 0 }),
prefix: event.currentTarget.value,
},
});
}}
error={formErrors[`${innerProps.fieldPath}.versionFilter.prefix`]}
disabled={!tempRepository.versionFilter}
/>
<Select
label={tRepository("versionFilter.precision.label")}
data={Object.entries(innerProps.versionFilterPrecisionOptions).map(([key, value]) => ({
value: key,
label: value,
}))}
value={tempRepository.versionFilter?.precision.toString() ?? "0"}
onChange={(value) => {
const precision = value ? parseInt(value) : 0;
handleChange({
versionFilter:
isNaN(precision) || precision <= 0
? undefined
: {
...(tempRepository.versionFilter ?? {}),
precision,
},
});
}}
error={formErrors[`${innerProps.fieldPath}.versionFilter.precision`]}
/>
<TextInput
label={tRepository("versionFilter.suffix.label")}
value={tempRepository.versionFilter?.suffix ?? ""}
onChange={(event) => {
handleChange({
versionFilter: {
...(tempRepository.versionFilter ?? { precision: 0 }),
suffix: event.currentTarget.value,
},
});
}}
error={formErrors[`${innerProps.fieldPath}.versionFilter.suffix`]}
disabled={!tempRepository.versionFilter}
/>
</Group>
<Text size="xs" c="dimmed">
{tRepository("versionFilter.regex.label")}:{" "}
{formatVersionFilterRegex(tempRepository.versionFilter) ??
tRepository("versionFilter.precision.options.none")}
</Text>
</Fieldset>
<IconPicker
withAsterisk={false}
value={tempRepository.iconUrl}
onChange={(url) => handleChange({ iconUrl: url })}
error={formErrors[`${innerProps.fieldPath}.iconUrl`] as string}
/>
<Divider my={"sm"} />
<Group justify="flex-end">
<Button variant="default" onClick={actions.closeModal} color="gray.5">
{tRepository("editForm.cancel.label")}
</Button>
<Button data-autofocus onClick={handleConfirm} color="red.9" loading={loading}>
{tRepository("editForm.confirm.label")}
</Button>
</Group>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("widget.releases.option.repositories.editForm.title");
},
size: "xl",
});

View File

@@ -28,6 +28,7 @@ import * as networkControllerStatus from "./network-controller/network-status";
import * as networkControllerSummary from "./network-controller/summary";
import * as notebook from "./notebook";
import type { WidgetOptionDefinition } from "./options";
import * as releases from "./releases";
import * as rssFeed from "./rssFeed";
import * as smartHomeEntityState from "./smart-home/entity-state";
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
@@ -63,6 +64,7 @@ export const widgetImports = {
healthMonitoring,
mediaTranscoding,
minecraftServerStatus,
releases,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;

View File

@@ -7,6 +7,7 @@ import type { ZodType } from "zod";
import type { IntegrationKind } from "@homarr/definitions";
import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input";
import type { ReleasesRepository } from "./releases/releases-repository";
interface CommonInput<TType> {
defaultValue?: TType;
@@ -119,6 +120,13 @@ const optionsFactory = {
values: [] as string[],
validate: input?.validate,
}),
multiReleasesRepositories: (input?: CommonInput<ReleasesRepository[]> & { validate?: ZodType }) => ({
type: "multiReleasesRepositories" as const,
defaultValue: input?.defaultValue ?? [],
withDescription: input?.withDescription ?? false,
values: [] as ReleasesRepository[],
validate: input?.validate,
}),
app: () => ({
type: "app" as const,
defaultValue: "",

View File

@@ -0,0 +1,30 @@
.releasesRepository {
border-left: 2px solid transparent;
&:has(.releasesRepositoryHeader:hover),
&:has(.releasesRepositoryHeader.active),
&:has(.releasesRepositoryDetails:hover) {
border-left-color: var(--mantine-color-secondaryColor-text);
}
}
.releasesRepositoryHeader {
user-select: none;
cursor: pointer;
&:hover,
&.active,
&:has(~ .releasesRepositoryDetails:hover) {
background-color: var(--mantine-color-secondaryColor-light);
}
}
.releasesRepositoryDetails {
background-color: var(--mantine-color-default-hover);
user-select: none;
cursor: pointer;
}
.releasesRepositoryExpanded {
background-color: var(--mantine-color-default-hover);
}

View File

@@ -0,0 +1,366 @@
"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 (
<Stack gap={0}>
{repositories.map((repository: ReleasesRepository) => {
const isActive = expandedRepository === repository.identifier;
return (
<Stack
key={`${repository.providerKey}.${repository.identifier}`}
className={classes.releasesRepository}
gap={0}
>
<Group
className={combineClasses(classes.releasesRepositoryHeader, {
[classes.active ?? ""]: isActive,
})}
p="xs"
wrap="nowrap"
onClick={() => toggleExpandedRepository(repository.identifier)}
>
<MaskedOrNormalImage
imageUrl={repository.iconUrl ?? Providers[repository.providerKey]?.iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
aspectRatio: "1/1",
}}
/>
<Group gap={5} justify="space-between" style={{ flex: 1, minWidth: 0 }} wrap="nowrap">
<Text size="xs">{repository.identifier}</Text>
<Tooltip label={repository.latestRelease ?? t("not-found")}>
<Text size="xs" fw={700} truncate="end" style={{ flexShrink: 1 }}>
{repository.latestRelease ?? t("not-found")}
</Text>
</Tooltip>
</Group>
<Group gap={5} wrap="nowrap">
<Text
size="xs"
c={repository.isNewRelease ? "primaryColor" : repository.isStaleRelease ? "secondaryColor" : "dimmed"}
>
{repository.latestReleaseAt &&
formatter.relativeTime(repository.latestReleaseAt, {
now,
style: "narrow",
})}
</Text>
{(repository.isNewRelease || repository.isStaleRelease) && (
<IconCircleFilled
size={10}
color={
repository.isNewRelease
? "var(--mantine-color-primaryColor-filled)"
: "var(--mantine-color-secondaryColor-filled)"
}
/>
)}
</Group>
</Group>
{options.showDetails && (
<DetailsDisplay repository={repository} toggleExpandedRepository={toggleExpandedRepository} />
)}
{isActive && <ExpandedDisplay repository={repository} hasIconColor={hasIconColor} />}
<Divider />
</Stack>
);
})}
</Stack>
);
}
interface DetailsDisplayProps {
repository: ReleasesRepository;
toggleExpandedRepository: (identifier: string) => void;
}
const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplayProps) => {
const t = useScopedI18n("widget.releases");
const formatter = useFormatter();
return (
<>
<Divider onClick={() => toggleExpandedRepository(repository.identifier)} />
<Group
className={classes.releasesRepositoryDetails}
justify="space-between"
p={5}
onClick={() => toggleExpandedRepository(repository.identifier)}
>
<Group>
<Tooltip label={t("pre-release")}>
<IconProgressCheck
size={13}
color={
repository.isPreRelease ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"
}
/>
</Tooltip>
<Tooltip label={t("archived")}>
<IconArchive
size={13}
color={repository.isArchived ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
/>
</Tooltip>
<Tooltip label={t("forked")}>
<IconGitFork
size={13}
color={repository.isFork ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
/>
</Tooltip>
</Group>
<Group>
<Tooltip label={t("starsCount")}>
<Group gap={5}>
<IconStar
size={12}
color={repository.starsCount === 0 ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
/>
<Text size="xs" c={repository.starsCount === 0 ? "dimmed" : ""}>
{repository.starsCount === 0
? "-"
: formatter.number(repository.starsCount ?? 0, {
notation: "compact",
maximumFractionDigits: 1,
})}
</Text>
</Group>
</Tooltip>
<Tooltip label={t("forksCount")}>
<Group gap={5}>
<IconGitFork
size={12}
color={repository.forksCount === 0 ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
/>
<Text size="xs" c={repository.forksCount === 0 ? "dimmed" : ""}>
{repository.forksCount === 0
? "-"
: formatter.number(repository.forksCount ?? 0, {
notation: "compact",
maximumFractionDigits: 1,
})}
</Text>
</Group>
</Tooltip>
<Tooltip label={t("issuesCount")}>
<Group gap={5}>
<IconCircleDot
size={12}
color={repository.openIssues === 0 ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
/>
<Text size="xs" c={repository.openIssues === 0 ? "dimmed" : ""}>
{repository.openIssues === 0
? "-"
: formatter.number(repository.openIssues ?? 0, {
notation: "compact",
maximumFractionDigits: 1,
})}
</Text>
</Group>
</Tooltip>
</Group>
</Group>
</>
);
};
interface ExtendedDisplayProps {
repository: ReleasesRepository;
hasIconColor: boolean;
}
const ExpandedDisplay = ({ repository, hasIconColor }: ExtendedDisplayProps) => {
const t = useScopedI18n("widget.releases");
const now = useNow();
const formatter = useFormatter();
return (
<>
<Divider mx={5} />
<Stack className={classes.releasesRepositoryExpanded} gap={0} p={10}>
<Group justify="space-between" align="center">
<Group gap={5} align="center">
<MaskedOrNormalImage
imageUrl={Providers[repository.providerKey]?.iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
aspectRatio: "1/1",
}}
/>
<Text size="xs" c="iconColor" ff="monospace">
{Providers[repository.providerKey]?.name}
</Text>
</Group>
{repository.createdAt && (
<Text size="xs" c="dimmed" ff="monospace">
<Text span>{t("created")}</Text>
<Text span> | </Text>
<Text span fw={700}>
{formatter.relativeTime(repository.createdAt, {
now,
style: "narrow",
})}
</Text>
</Text>
)}
</Group>
<Divider my={10} mx="30%" />
<Button
variant="light"
component="a"
href={repository.releaseUrl ?? repository.projectUrl}
target="_blank"
rel="noreferrer"
>
<IconExternalLink />
{repository.releaseUrl ? t("openReleasePage") : t("openProjectPage")}
</Button>
{repository.releaseDescription && (
<>
<Divider my={10} mx="30%" />
<Title order={4} ta="center">
{t("releaseDescription")}
</Title>
<Text component="div" size="xs" ff="monospace">
<ReactMarkdown skipHtml>{repository.releaseDescription}</ReactMarkdown>
</Text>
</>
)}
</Stack>
</>
);
};

View File

@@ -0,0 +1,53 @@
import { IconRocket } from "@tabler/icons-react";
import { z } from "zod";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("releases", {
icon: IconRocket,
createOptions() {
return optionsBuilder.from((factory) => ({
newReleaseWithin: factory.text({
defaultValue: "1w",
withDescription: true,
validate: z
.string()
.regex(/^\d+[hdwmy]$/)
.or(z.literal("")),
}),
staleReleaseWithin: factory.text({
defaultValue: "6m",
withDescription: true,
validate: z
.string()
.regex(/^\d+[hdwmy]$/)
.or(z.literal("")),
}),
showOnlyHighlighted: factory.switch({
withDescription: true,
defaultValue: true,
}),
showDetails: factory.switch({
defaultValue: true,
}),
repositories: factory.multiReleasesRepositories({
defaultValue: [],
validate: z.array(
z.object({
providerKey: z.string().min(1),
identifier: z.string().min(1),
versionFilter: z
.object({
prefix: z.string().optional(),
precision: z.number(),
suffix: z.string().optional(),
})
.optional(),
iconUrl: z.string().url().optional(),
}),
),
}),
}));
},
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,36 @@
export interface ReleasesProvider {
name: string;
iconUrl: string;
}
interface ProvidersProps {
[key: string]: ReleasesProvider;
DockerHub: ReleasesProvider;
Github: ReleasesProvider;
Gitlab: ReleasesProvider;
Npm: ReleasesProvider;
Codeberg: ReleasesProvider;
}
export const Providers: ProvidersProps = {
DockerHub: {
name: "Docker Hub",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/docker.svg",
},
Github: {
name: "Github",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/github-dark.svg",
},
Gitlab: {
name: "Gitlab",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/gitlab.svg",
},
Npm: {
name: "Npm",
iconUrl: "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets//assets/npm.svg",
},
Codeberg: {
name: "Codeberg",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/codeberg.svg",
},
};

View File

@@ -0,0 +1,30 @@
export interface ReleasesVersionFilter {
prefix?: string;
precision: number;
suffix?: string;
}
export interface ReleasesRepository {
providerKey: string;
identifier: string;
versionFilter?: ReleasesVersionFilter;
iconUrl?: string;
latestRelease?: string;
latestReleaseAt?: Date;
isNewRelease: boolean;
isStaleRelease: boolean;
releaseUrl?: string;
releaseDescription?: string;
isPreRelease?: boolean;
projectUrl?: string;
projectDescription?: string;
isFork?: boolean;
isArchived?: boolean;
createdAt?: Date;
starsCount?: number;
forksCount?: number;
openIssues?: number;
}