refactor widget-only integrations
This commit is contained in:
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"));
|
||||
@@ -54,6 +54,7 @@ interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
|
||||
useOptions: (
|
||||
query: string,
|
||||
integrationIds: string[],
|
||||
options: Record<string, unknown>,
|
||||
) => {
|
||||
isPending: boolean;
|
||||
options: DynamicSelectOption[];
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user