refactor widget-only integrations

This commit is contained in:
Thomas Camlong committed 2026-06-27 10:57:12 +02:00
1 parent f3608b26a0
commit fb348044fb
122 files changed
+2259 -3016

No files matched your search

@@ -24,7 +24,11 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg
const inputProps = form.getInputProps(`options.${property}`);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const { isPending, options: selectOptions } = options.useOptions(debouncedSearch, form.values.integrationIds);
const { isPending, options: selectOptions } = options.useOptions(
debouncedSearch,
form.values.integrationIds,
form.values.options,
);
const currentOption = inputProps.value as DynamicSelectOption | null;
const onChange = inputProps.onChange as (value: DynamicSelectOption | null) => void;
@@ -38,16 +38,20 @@ import { escapeForRegEx } from "@tiptap/react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import type { ReleaseProviderKind } from "@homarr/definitions";
import {
getReleaseProviderDefaultUrl,
getReleaseProviderIconUrl,
getReleaseProviderName,
normalizeReleaseProviderIdentifier,
releaseProviderKinds,
} from "@homarr/definitions";
import { findBestIconMatch, IconPicker } from "@homarr/forms-collection";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { MaskedImage } from "@homarr/ui";
import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import type { IntegrationSelectOption } from "../widget-integration-select";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
@@ -57,10 +61,6 @@ interface FormValidation {
errors: FormErrors;
}
interface Integration extends IntegrationSelectOption {
iconUrl: string;
}
export const WidgetMultiReleasesRepositoriesInput = ({
property,
kind,
@@ -78,38 +78,19 @@ export const WidgetMultiReleasesRepositoriesInput = ({
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const integrationsApi = clientApi.integration.allOfGivenCategory.useQuery(
{
category: "releasesProvider",
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const integrations = useMemo(
() =>
integrationsApi.data?.reduce<Record<string, Integration>>((acc, integration) => {
acc[integration.id] = {
id: integration.id,
name: integration.name,
url: integration.url,
kind: integration.kind,
iconUrl: getIconUrl(integration.kind),
};
return acc;
}, {}) ?? {},
[integrationsApi],
const providerOptions = useMemo(
() => releaseProviderKinds.map((provider) => ({ value: provider, label: getReleaseProviderName(provider) })),
[],
);
const onRepositorySave = useCallback(
(repository: ReleasesRepository, index: number): FormValidation => {
form.setFieldValue(`options.${property}.${index}.providerIntegrationId`, repository.providerIntegrationId);
form.setFieldValue(`options.${property}.${index}.provider`, repository.provider);
form.setFieldValue(`options.${property}.${index}.identifier`, repository.identifier);
form.setFieldValue(`options.${property}.${index}.name`, repository.name);
form.setFieldValue(`options.${property}.${index}.versionFilter`, repository.versionFilter);
form.setFieldValue(`options.${property}.${index}.iconUrl`, repository.iconUrl);
form.setFieldValue(`options.${property}.${index}.providerUrl`, repository.providerUrl);
const formValidation = form.validate();
const fieldErrors: FormErrors = Object.entries(formValidation.errors).reduce((acc, [key, value]) => {
@@ -152,7 +133,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
onRepositorySave: (saved) => onRepositorySave(saved, index),
onRepositoryCancel: () => onRepositoryRemove(index),
versionFilterPrecisionOptions,
integrations,
providerOptions,
});
};
@@ -183,7 +164,6 @@ export const WidgetMultiReleasesRepositoriesInput = ({
onClick={() =>
openImportModal({
repositories,
integrations,
versionFilterPrecisionOptions,
onConfirm: (selectedRepositories) => {
if (!selectedRepositories.length) return;
@@ -210,14 +190,14 @@ export const WidgetMultiReleasesRepositoriesInput = ({
<Divider my="sm" />
{repositories.map((repository, index) => {
const integration = repository.providerIntegrationId
? integrations[repository.providerIntegrationId]
: undefined;
const providerName = repository.provider ? getReleaseProviderName(repository.provider) : "";
return (
<Stack key={repository.id} gap={5}>
<Group align="center" gap="xs">
<Image
src={repository.iconUrl ?? integration?.iconUrl ?? null}
src={
repository.iconUrl ?? (repository.provider ? getReleaseProviderIconUrl(repository.provider) : null)
}
style={{
height: "1.2em",
width: "1.2em",
@@ -225,7 +205,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
/>
<Text c="dimmed" fw={100} size="xs">
{integration?.name ?? ""}
{providerName}
</Text>
<Group justify="space-between" align="center" style={{ flex: 1 }} gap={5}>
@@ -242,7 +222,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
repository,
onRepositorySave: (saved) => onRepositorySave(saved, index),
versionFilterPrecisionOptions,
integrations,
providerOptions,
})
}
variant="light"
@@ -294,7 +274,7 @@ interface RepositoryEditProps {
onRepositorySave: (repository: ReleasesRepository) => FormValidation;
onRepositoryCancel?: () => void;
versionFilterPrecisionOptions: string[];
integrations: Record<string, Integration>;
providerOptions: { value: ReleaseProviderKind; label: string }[];
}
const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, actions }) => {
@@ -302,11 +282,6 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
const [loading, setLoading] = useState(false);
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
const [formErrors, setFormErrors] = useState<FormErrors>({});
const integrationSelectOptions: IntegrationSelectOption[] = useMemo(
() => Object.values(innerProps.integrations),
[innerProps.integrations],
);
// Allows user to not select an icon by removing the url from the input,
// will only try and get an icon if the name or identifier changes
const [autoSetIcon, setAutoSetIcon] = useState(false);
@@ -361,15 +336,14 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
<Stack>
<Group align="start" wrap="nowrap" grow preventGrowOverflow={false}>
<div style={{ flex: 0.3 }}>
<WidgetIntegrationSelect
canSelectMultiple={false}
<Select
withAsterisk
label={tRepository("provider.label")}
data={integrationSelectOptions}
value={tempRepository.providerIntegrationId ? [tempRepository.providerIntegrationId] : []}
error={formErrors[`${innerProps.fieldPath}.providerIntegrationId`] as string}
data={innerProps.providerOptions}
value={tempRepository.provider ?? null}
error={formErrors[`${innerProps.fieldPath}.provider`] as string}
onChange={(value) => {
handleChange({ providerIntegrationId: value.length > 0 ? value.pop() : undefined });
handleChange({ provider: value ? (value as ReleaseProviderKind) : undefined });
}}
/>
</div>
@@ -397,6 +371,16 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
/>
</Group>
<TextInput
label={tRepository("providerUrl.label")}
placeholder={tempRepository.provider ? getReleaseProviderDefaultUrl(tempRepository.provider) : undefined}
value={tempRepository.providerUrl ?? ""}
onChange={(event) => {
handleChange({ providerUrl: event.currentTarget.value || undefined });
}}
error={formErrors[`${innerProps.fieldPath}.providerUrl`]}
/>
<Group align="center" wrap="nowrap" grow preventGrowOverflow={false}>
<TextInput
label={tRepository("name.label")}
@@ -513,7 +497,6 @@ interface ReleasesRepositoryImport extends ReleasesRepository {
interface ImportRepositorySelectProps {
repository: ReleasesRepositoryImport;
checked: boolean;
integration?: Integration;
versionFilterPrecisionOptions: string[];
disabled: boolean;
onImageSelectionChanged?: (isSelected: boolean) => void;
@@ -522,12 +505,12 @@ interface ImportRepositorySelectProps {
const ImportRepositorySelect = ({
repository,
checked,
integration,
versionFilterPrecisionOptions,
disabled = false,
onImageSelectionChanged = undefined,
}: ImportRepositorySelectProps) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const provider = repository.provider;
return (
<Group gap="xl" justify="space-between">
@@ -570,12 +553,12 @@ const ImportRepositorySelect = ({
)}
</Group>
<Tooltip label={tRepository("noProvider.tooltip")} disabled={integration !== undefined} withArrow>
<Tooltip label={tRepository("noProvider.tooltip")} disabled={provider !== undefined} withArrow>
<Group>
{integration ? (
{provider ? (
<MaskedImage
color="dimmed"
imageUrl={integration.iconUrl}
imageUrl={getReleaseProviderIconUrl(provider)}
style={{
height: "1em",
width: "1em",
@@ -586,7 +569,7 @@ const ImportRepositorySelect = ({
)}
<Text ff="monospace" c="dimmed" size="sm">
{integration?.name ?? tRepository("noProvider.label")}
{provider ? getReleaseProviderName(provider) : tRepository("noProvider.label")}
</Text>
</Group>
</Tooltip>
@@ -596,7 +579,6 @@ const ImportRepositorySelect = ({
interface RepositoryImportProps {
repositories: ReleasesRepository[];
integrations: Record<string, Integration>;
versionFilterPrecisionOptions: string[];
onConfirm: (selectedRepositories: ReleasesRepositoryImport[]) => void;
isAdmin: boolean;
@@ -625,28 +607,25 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
if (!identifier) return acc;
const providerKind = containerImageToProviderKind[source] ?? "dockerHub";
const integrationId = Object.values(innerProps.integrations).find(
(integration) => integration.kind === providerKind,
)?.id;
const provider = containerImageToProviderKind[source] ?? "dockerHub";
const normalizedIdentifier = normalizeReleaseProviderIdentifier(provider, identifier);
if (acc.some((item) => item.providerIntegrationId === integrationId && item.identifier === identifier))
return acc;
if (acc.some((item) => item.provider === provider && item.identifier === normalizedIdentifier)) return acc;
acc.push({
id: createId(),
providerIntegrationId: integrationId,
identifier,
provider,
identifier: normalizedIdentifier,
iconUrl: container.iconUrl ?? undefined,
name: formatIdentifierName(identifier),
name: formatIdentifierName(normalizedIdentifier),
versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined,
alreadyImported: innerProps.repositories.some(
(item) => item.providerIntegrationId === integrationId && item.identifier === identifier,
(item) => item.provider === provider && item.identifier === normalizedIdentifier,
),
});
return acc;
}, []) ?? [],
[docker.data, innerProps.repositories, innerProps.integrations],
[docker.data, innerProps.repositories],
);
const handleConfirm = useCallback(() => {
@@ -722,27 +701,20 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
{importRepositories
.filter((repository) => !repository.alreadyImported)
.map((repository) => {
const integration = repository.providerIntegrationId
? innerProps.integrations[repository.providerIntegrationId]
: undefined;
return (
<ImportRepositorySelect
key={repository.id}
repository={repository}
checked={selectedImages.includes(repository)}
integration={integration}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
disabled={false}
onImageSelectionChanged={(isSelected) =>
isSelected
? setSelectedImages([...selectedImages, repository])
: setSelectedImages(selectedImages.filter((img) => img !== repository))
}
/>
);
})}
.map((repository) => (
<ImportRepositorySelect
key={repository.id}
repository={repository}
checked={selectedImages.includes(repository)}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
disabled={false}
onImageSelectionChanged={(isSelected) =>
isSelected
? setSelectedImages([...selectedImages, repository])
: setSelectedImages(selectedImages.filter((img) => img !== repository))
}
/>
))}
</Stack>
)}
</Accordion.Panel>
@@ -756,22 +728,15 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
<Stack justify="center" gap="xs">
{importRepositories
.filter((repository) => repository.alreadyImported)
.map((repository) => {
const integration = repository.providerIntegrationId
? innerProps.integrations[repository.providerIntegrationId]
: undefined;
return (
<ImportRepositorySelect
key={repository.id}
repository={repository}
integration={integration}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
checked
disabled
/>
);
})}
.map((repository) => (
<ImportRepositorySelect
key={repository.id}
repository={repository}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
checked
disabled
/>
))}
</Stack>
)}
</Accordion.Panel>
@@ -798,8 +763,8 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
size: "xl",
});
const containerImageToProviderKind: Record<string, IntegrationKind> = {
"ghcr.io": "github",
const containerImageToProviderKind: Record<string, ReleaseProviderKind> = {
"ghcr.io": "gitHubContainerRegistry",
"docker.io": "dockerHub",
"lscr.io": "linuxServerIO",
"quay.io": "quay",
@@ -4,41 +4,33 @@ import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/co
import { clientApi } from "@homarr/api/client";
import { humanFileSize } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { WidgetComponentProps } from "../definition";
export default function ArchiveTeamWarriorWidget({
integrationIds,
options,
}: WidgetComponentProps<"archiveTeamWarrior">) {
const integrationId = integrationIds[0];
const archiveTeamWarriorIconUrl = "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png";
if (!integrationId) {
return null;
}
return <ArchiveTeamWarriorWidgetContent integrationId={integrationId} options={options} />;
export default function ArchiveTeamWarriorWidget({ options }: WidgetComponentProps<"archiveTeamWarrior">) {
return <ArchiveTeamWarriorWidgetContent url={options.url} options={options} />;
}
const ArchiveTeamWarriorWidgetContent = ({
integrationId,
url,
options,
}: {
integrationId: string;
url: string;
options: WidgetComponentProps<"archiveTeamWarrior">["options"];
}) => {
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({
integrationId,
url,
});
const utils = clientApi.useUtils();
clientApi.widget.archiveTeamWarrior.subscribeStatus.useSubscription(
{ integrationId },
{ url },
{
onData: (next) => {
utils.widget.archiveTeamWarrior.getStatus.setData({ integrationId }, next);
utils.widget.archiveTeamWarrior.getStatus.setData({ url }, next);
},
},
);
@@ -50,7 +42,7 @@ const ArchiveTeamWarriorWidgetContent = ({
<Stack p="xs" gap="xs" h="100%">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap" miw={0}>
<Avatar size="sm" radius="md" src={getIconUrl("archiveTeamWarrior")} />
<Avatar size="sm" radius="md" src={archiveTeamWarriorIconUrl} />
<Text size="sm" c="dimmed" lineClamp={1}>
{projectName}
</Text>
@@ -1,4 +1,5 @@
import { IconArchive } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -7,10 +8,13 @@ export const { definition, componentLoader } = createWidgetDefinition("archiveTe
icon: IconArchive,
createOptions() {
return optionsBuilder.from((factory) => ({
url: factory.text({
defaultValue: "http://localhost:8001",
validate: z.string().url(),
}),
showBroadcastMessage: factory.switch({
defaultValue: true,
}),
}));
},
supportedIntegrations: ["archiveTeamWarrior"],
}).withDynamicImport(() => import("./component"));
@@ -1,7 +1,7 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Box, Center, Group, Image, Stack, Text } from "@mantine/core";
import { IconAlertCircle, IconCalendar } from "@tabler/icons-react";
@@ -26,6 +26,11 @@ export default function ImmichAlbumCarouselWidget({
{ enabled: Boolean(options.albumId), staleTime: 15 * 60 * 1000 },
);
const photoAssets = useMemo(() => {
const assets = album?.assets.filter((asset) => asset.type === "IMAGE") ?? [];
return options.randomizePhotos ? shuffle(assets) : assets;
}, [album?.assets, options.randomizePhotos]);
if (!options.albumId) {
return <NoAlbumSelected />;
}
@@ -36,8 +41,6 @@ export default function ImmichAlbumCarouselWidget({
return <NoPhotosInAlbum />;
}
const photoAssets = album.assets.filter((asset) => asset.type === "IMAGE");
if (photoAssets.length === 0) {
return <NoPhotosInAlbum />;
}
@@ -53,6 +56,19 @@ export default function ImmichAlbumCarouselWidget({
);
}
function shuffle<T>(items: T[]) {
const shuffled = [...items];
for (let index = shuffled.length - 1; index > 0; index--) {
const randomIndex = Math.floor(Math.random() * (index + 1));
const current = shuffled[index];
const random = shuffled[randomIndex];
if (current === undefined || random === undefined) continue;
shuffled[index] = random;
shuffled[randomIndex] = current;
}
return shuffled;
}
interface CarouselProps {
assets: {
id: string;
@@ -1,6 +1,8 @@
import { IconPhoto } from "@tabler/icons-react";
import z from "zod";
import { clientApi } from "@homarr/api/client";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -10,9 +12,20 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
albumId: factory.text({
defaultValue: "",
albumId: factory.integrationSelect({
withDescription: true,
clearable: true,
useOptions: (integrationIds: string[]) => {
const {
data = [],
isPending,
isError,
} = clientApi.widget.immich.getAlbums.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
);
return { data: data.map((album) => ({ value: album.id, label: album.albumName })), isPending, isError };
},
}),
rotationIntervalSeconds: factory.number({
defaultValue: 5,
@@ -23,6 +36,10 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
defaultValue: false,
withDescription: true,
}),
randomizePhotos: factory.switch({
defaultValue: false,
withDescription: true,
}),
}));
},
}).withDynamicImport(() => import("./component"));
+1
View File
@@ -54,6 +54,7 @@ interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
useOptions: (
query: string,
integrationIds: string[],
options: Record<string, unknown>,
) => {
isPending: boolean;
options: DynamicSelectOption[];
+22 -19
View File
@@ -21,7 +21,8 @@ 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 type { ReleaseProviderKind } from "@homarr/definitions";
import { getReleaseProviderIconUrl, getReleaseProviderName } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
@@ -55,11 +56,11 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
[options.newReleaseWithin, options.staleReleaseWithin],
);
// Group repositories by integration
// Group repositories by provider
const groupedRepositories = useMemo(() => {
return options.repositories.reduce(
(acc, repo) => {
const key = repo.providerIntegrationId;
const key = repo.provider;
if (!key) return acc;
acc[key] ??= [];
@@ -67,28 +68,29 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
return acc;
},
{} as Record<string, ReleasesRepository[]>,
{} as Partial<Record<ReleaseProviderKind, ReleasesRepository[]>>,
);
}, [options.repositories]);
// For each group, split into chunks of 5
const batchedRepositories = useMemo(() => {
return Object.entries(groupedRepositories).flatMap(([integrationId, group]) =>
return Object.entries(groupedRepositories).flatMap(([provider, group]) =>
splitToChunksWithNItems(group, 5).map((chunk) => ({
integrationId,
provider: provider as ReleaseProviderKind,
repositories: chunk,
})),
);
}, [groupedRepositories]);
const queryResults = clientApi.useQueries((t) =>
batchedRepositories.flatMap(({ integrationId, repositories }) =>
t.widget.releases.getLatest({
integrationId,
const queryResults = clientApi.useQueries((trpc) =>
batchedRepositories.flatMap(({ provider, repositories }) =>
trpc.widget.releases.getLatest({
repositories: repositories.map((repository) => ({
id: repository.id,
provider,
identifier: repository.identifier,
versionFilter: repository.versionFilter,
providerUrl: repository.providerUrl,
})),
}),
),
@@ -102,13 +104,13 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
const repositories = useMemo(() => {
const formattedResults = options.repositories
.map((repository) => {
if (!repository.providerIntegrationId) return { ...repository, error: { code: "noProviderSeleceted" } };
if (!repository.provider) return { ...repository, error: { code: "noProviderSelected" } };
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 { data: release, provider } = repositoryResult;
const isReleaseWithin = (relativeDate: string) =>
Boolean(relativeDate) && isDateWithin(release.latestReleaseAt, relativeDate);
@@ -116,7 +118,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
return {
...repository,
...release,
integration: { name: integration.name, iconUrl: getIconUrl(integration.kind) },
providerMetadata: { name: getReleaseProviderName(provider), iconUrl: getReleaseProviderIconUrl(provider) },
isNewRelease: isReleaseWithin(relativeDateOptions.newReleaseWithin),
isStaleRelease: !isReleaseWithin(relativeDateOptions.staleReleaseWithin),
viewed: releasesViewedList[repository.id] === release.latestRelease,
@@ -173,7 +175,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
className={combineClasses(
"releases-repository",
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
`releases-repository-${repository.integration?.name ?? "error"}-${repository.name || repository.identifier.replace(/[^a-zA-Z0-9]/g, "_")}`,
`releases-repository-${repository.providerMetadata?.name ?? "error"}-${repository.name || repository.identifier.replace(/[^a-zA-Z0-9]/g, "_")}`,
classes.releasesRepository,
)}
gap={0}
@@ -187,7 +189,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
>
<MaskedOrNormalImage
className="releases-repository-header-icon"
imageUrl={repository.iconUrl ?? repository.integration?.iconUrl}
imageUrl={repository.iconUrl ?? repository.providerMetadata?.iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
@@ -533,11 +535,11 @@ const ExpandedDisplay = ({
{repository.identifier}
</Text>
{repository.integration && (
{repository.providerMetadata && (
<Group className="releases-repository-expanded-header-provider-wrapper" gap={5} align="center">
<MaskedOrNormalImage
className="releases-repository-expanded-header-provider-icon"
imageUrl={repository.integration.iconUrl}
imageUrl={repository.providerMetadata.iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
@@ -550,7 +552,7 @@ const ExpandedDisplay = ({
c="iconColor"
ff="monospace"
>
{repository.integration.name}
{repository.providerMetadata.name}
</Text>
</Group>
)}
@@ -624,7 +626,8 @@ const ExpandedDisplay = ({
c="red"
style={{ whiteSpace: "pre-wrap" }}
>
{repository.error.code ? t(`error.messages.${repository.error.code}` as never) : repository.error.message}
{repository.error.message ??
(repository.error.code ? t(`error.messages.${repository.error.code}` as never) : null)}
</Text>
</>
)}
+4 -1
View File
@@ -1,6 +1,8 @@
import { IconRocket } from "@tabler/icons-react";
import { z } from "zod/v4";
import { releaseProviderKinds } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -42,7 +44,7 @@ export const { definition, componentLoader } = createWidgetDefinition("releases"
defaultValue: [],
validate: z.array(
z.object({
providerIntegrationId: z.string().optional(),
provider: z.enum(releaseProviderKinds).optional(),
identifier: z.string().min(1),
name: z.string().optional(),
versionFilter: z
@@ -53,6 +55,7 @@ export const { definition, componentLoader } = createWidgetDefinition("releases"
})
.optional(),
iconUrl: z.string().optional(),
providerUrl: z.string().url().optional(),
}),
),
}),
@@ -1,3 +1,5 @@
import type { ReleaseProviderKind } from "@homarr/definitions";
export interface ReleasesVersionFilter {
prefix?: string;
precision: number;
@@ -6,11 +8,12 @@ export interface ReleasesVersionFilter {
export interface ReleasesRepository {
id: string;
providerIntegrationId?: string;
provider?: ReleaseProviderKind;
identifier: string;
name?: string;
versionFilter?: ReleasesVersionFilter;
iconUrl?: string;
providerUrl?: string;
}
export interface ReleasesRepositoryResponse extends ReleasesRepository {
@@ -32,7 +35,7 @@ export interface ReleasesRepositoryResponse extends ReleasesRepository {
forksCount?: number;
openIssues?: number;
integration?: {
providerMetadata?: {
name: string;
iconUrl?: string;
};
+5 -8
View File
@@ -7,28 +7,25 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { DynamicSelectOption } from "../_inputs/widget-dynamic-select-input";
import type { WidgetComponentProps } from "../definition";
export default function TimetableWidget({ options, integrationIds }: WidgetComponentProps<"timetable">) {
// It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const integrationId = integrationIds[0]!;
export default function TimetableWidget({ options }: WidgetComponentProps<"timetable">) {
const t = useScopedI18n("widget.timetable");
if (!options.station) {
return <Center h="100%">{t("noStation")}</Center>;
}
return <TimetableWidgetInner station={options.station} integrationId={integrationId} />;
return <TimetableWidgetInner station={options.station} baseUrl={options.baseUrl} />;
}
interface TimetableWidgetInnerProps {
station: DynamicSelectOption;
integrationId: string;
baseUrl: string;
}
const TimetableWidgetInner = ({ station, integrationId }: TimetableWidgetInnerProps) => {
const TimetableWidgetInner = ({ station, baseUrl }: TimetableWidgetInnerProps) => {
const { data: timetable } = clientApi.widget.timetable.getTimetable.useQuery(
{
integrationId,
baseUrl,
stationId: station.value,
limit: 10,
},
+8 -4
View File
@@ -1,4 +1,5 @@
import { IconBusStop } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
@@ -7,18 +8,21 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("timetable", {
icon: IconBusStop,
supportedIntegrations: ["searchCh"],
createOptions() {
return optionsBuilder.from((factory) => ({
baseUrl: factory.text({
defaultValue: "https://search.ch",
validate: z.string().url(),
}),
station: factory.dynamicSelect({
useOptions(query, integrationIds) {
useOptions(query, _integrationIds, options) {
const { data: stations, isPending } = clientApi.widget.timetable.searchStations.useQuery(
{
integrationId: integrationIds[0] ?? "",
baseUrl: typeof options.baseUrl === "string" ? options.baseUrl : "https://search.ch",
query,
},
{
enabled: integrationIds.length > 0,
enabled: query.length > 0,
},
);