merge: origin/dev (widget-only integration refactor)
Resolve conflicts keeping our simplified request handlers (with TTL cache) and adopting dev's new release-provider + timetable direct implementations. Remove per-query staleTime from timetable widget.
This commit is contained in:
91 files changed
+2306
-2545
No files matched your search
@@ -8,6 +8,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
|
||||
property: string;
|
||||
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
|
||||
initialOptions: Record<string, unknown>;
|
||||
itemId?: string;
|
||||
}
|
||||
|
||||
type UseWidgetInputTranslationReturnType = (key: "label" | "description") => string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
IconCopy,
|
||||
IconCopyCheckFilled,
|
||||
IconEdit,
|
||||
IconKey,
|
||||
IconPackageImport,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
@@ -38,16 +40,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,13 +63,10 @@ interface FormValidation {
|
||||
errors: FormErrors;
|
||||
}
|
||||
|
||||
interface Integration extends IntegrationSelectOption {
|
||||
iconUrl: string;
|
||||
}
|
||||
|
||||
export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
property,
|
||||
kind,
|
||||
itemId,
|
||||
}: CommonWidgetInputProps<"multiReleasesRepositories">) => {
|
||||
const t = useWidgetInputTranslation(kind, property);
|
||||
const tRepository = useScopedI18n("widget.releases.option.repositories");
|
||||
@@ -78,36 +81,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,
|
||||
},
|
||||
);
|
||||
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]) => {
|
||||
@@ -150,7 +136,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
onRepositorySave: (saved) => onRepositorySave(saved, index),
|
||||
onRepositoryCancel: () => onRepositoryRemove(index),
|
||||
versionFilterPrecisionOptions,
|
||||
integrations,
|
||||
providerOptions,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -181,7 +167,6 @@ export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
onClick={() =>
|
||||
openImportModal({
|
||||
repositories,
|
||||
integrations,
|
||||
versionFilterPrecisionOptions,
|
||||
onConfirm: (selectedRepositories) => {
|
||||
if (!selectedRepositories.length) return;
|
||||
@@ -208,22 +193,22 @@ 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",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text c="dimmed" fw={100} size="xs">
|
||||
{integration?.name ?? ""}
|
||||
<Text c="dimmed" size="xs">
|
||||
{providerName}
|
||||
</Text>
|
||||
|
||||
<Group justify="space-between" align="center" style={{ flex: 1 }} gap={5}>
|
||||
@@ -240,7 +225,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
repository,
|
||||
onRepositorySave: (saved) => onRepositorySave(saved, index),
|
||||
versionFilterPrecisionOptions,
|
||||
integrations,
|
||||
providerOptions,
|
||||
})
|
||||
}
|
||||
variant="light"
|
||||
@@ -267,10 +252,125 @@ export const WidgetMultiReleasesRepositoriesInput = ({
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
{itemId && <ProviderTokensSection itemId={itemId} repositories={repositories} />}
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
const providersWithAuth: ReleaseProviderKind[] = [
|
||||
"github",
|
||||
"gitHubContainerRegistry",
|
||||
"dockerHub",
|
||||
"gitlab",
|
||||
"npm",
|
||||
"codeberg",
|
||||
];
|
||||
|
||||
const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repositories: ReleasesRepository[] }) => {
|
||||
const tRepository = useScopedI18n("widget.releases.option.repositories");
|
||||
const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId });
|
||||
const setSecret = clientApi.widget.secrets.setSecret.useMutation({ onSuccess: () => refetch() });
|
||||
const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({ onSuccess: () => refetch() });
|
||||
|
||||
const authProviders = useMemo(() => {
|
||||
const usedProviders = repositories.map((r) => r.provider).filter((p): p is ReleaseProviderKind => p !== undefined);
|
||||
const configuredProviders = configuredKinds.filter((k): k is ReleaseProviderKind =>
|
||||
providersWithAuth.includes(k as ReleaseProviderKind),
|
||||
);
|
||||
return [...new Set([...usedProviders, ...configuredProviders])].filter((p) => providersWithAuth.includes(p));
|
||||
}, [repositories, configuredKinds]);
|
||||
|
||||
if (authProviders.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider my="sm" label={tRepository("tokens.label")} labelPosition="center" />
|
||||
<Stack gap="xs">
|
||||
{authProviders.map((provider) => (
|
||||
<ProviderTokenInput
|
||||
key={provider}
|
||||
provider={provider}
|
||||
hasToken={configuredKinds.includes(provider)}
|
||||
onSave={async (value) => {
|
||||
await setSecret.mutateAsync({ itemId, kind: provider, value });
|
||||
}}
|
||||
onDelete={async () => {
|
||||
await deleteSecret.mutateAsync({ itemId, kind: provider });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ProviderTokenInput = ({
|
||||
provider,
|
||||
hasToken,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
provider: ReleaseProviderKind;
|
||||
hasToken: boolean;
|
||||
onSave: (value: string) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
}) => {
|
||||
const tRepository = useScopedI18n("widget.releases.option.repositories");
|
||||
const [value, setValue] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!value.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(value.trim());
|
||||
setValue("");
|
||||
setEditing(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onDelete();
|
||||
setValue("");
|
||||
setEditing(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group gap="xs" align="end">
|
||||
<PasswordInput
|
||||
label={`${getReleaseProviderName(provider)} ${tRepository("tokens.token")}`}
|
||||
placeholder={hasToken ? tRepository("tokens.configured") : tRepository("tokens.notConfigured")}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.currentTarget.value);
|
||||
setEditing(true);
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
size="xs"
|
||||
leftSection={<IconKey size={14} />}
|
||||
/>
|
||||
{editing && value.trim() && (
|
||||
<Button size="xs" onClick={handleSave} loading={saving}>
|
||||
{tRepository("tokens.save")}
|
||||
</Button>
|
||||
)}
|
||||
{hasToken && !editing && (
|
||||
<ActionIcon variant="light" color="red" size="sm" onClick={handleDelete} loading={saving}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const formatVersionFilterRegex = (versionFilter: ReleasesVersionFilter | undefined) => {
|
||||
if (!versionFilter) return undefined;
|
||||
|
||||
@@ -292,7 +392,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 }) => {
|
||||
@@ -300,11 +400,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);
|
||||
@@ -359,15 +454,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>
|
||||
@@ -395,6 +489,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")}
|
||||
@@ -511,7 +615,6 @@ interface ReleasesRepositoryImport extends ReleasesRepository {
|
||||
interface ImportRepositorySelectProps {
|
||||
repository: ReleasesRepositoryImport;
|
||||
checked: boolean;
|
||||
integration?: Integration;
|
||||
versionFilterPrecisionOptions: string[];
|
||||
disabled: boolean;
|
||||
onImageSelectionChanged?: (isSelected: boolean) => void;
|
||||
@@ -520,12 +623,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">
|
||||
@@ -568,12 +671,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",
|
||||
@@ -584,7 +687,7 @@ const ImportRepositorySelect = ({
|
||||
)}
|
||||
|
||||
<Text ff="monospace" c="dimmed" size="sm">
|
||||
{integration?.name ?? tRepository("noProvider.label")}
|
||||
{provider ? getReleaseProviderName(provider) : tRepository("noProvider.label")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
@@ -594,7 +697,6 @@ const ImportRepositorySelect = ({
|
||||
|
||||
interface RepositoryImportProps {
|
||||
repositories: ReleasesRepository[];
|
||||
integrations: Record<string, Integration>;
|
||||
versionFilterPrecisionOptions: string[];
|
||||
onConfirm: (selectedRepositories: ReleasesRepositoryImport[]) => void;
|
||||
isAdmin: boolean;
|
||||
@@ -621,28 +723,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(() => {
|
||||
@@ -718,27 +817,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>
|
||||
@@ -752,22 +844,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>
|
||||
@@ -794,8 +879,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",
|
||||
|
||||
@@ -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,15 @@ export default function ImmichAlbumCarouselWidget({
|
||||
{ enabled: Boolean(options.albumId) },
|
||||
);
|
||||
|
||||
const photoAssets = useMemo(() => {
|
||||
const assets = album?.assets.filter((asset) => asset.type === "IMAGE") ?? [];
|
||||
return options.randomizePhotos ? shuffle(assets) : assets;
|
||||
}, [album?.assets, options.randomizePhotos]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPhotoIndex(0);
|
||||
}, [photoAssets]);
|
||||
|
||||
if (!options.albumId) {
|
||||
return <NoAlbumSelected />;
|
||||
}
|
||||
@@ -36,8 +45,6 @@ export default function ImmichAlbumCarouselWidget({
|
||||
return <NoPhotosInAlbum />;
|
||||
}
|
||||
|
||||
const photoAssets = album.assets.filter((asset) => asset.type === "IMAGE");
|
||||
|
||||
if (photoAssets.length === 0) {
|
||||
return <NoPhotosInAlbum />;
|
||||
}
|
||||
@@ -53,6 +60,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"));
|
||||
@@ -38,6 +38,7 @@ interface ModalProps<TSort extends WidgetKind> {
|
||||
integrationData: IntegrationSelectOption[];
|
||||
integrationSupport: boolean;
|
||||
settings: SettingsContextProps;
|
||||
itemId?: string;
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
@@ -146,6 +147,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
property={key}
|
||||
options={value as never}
|
||||
initialOptions={innerProps.value.options}
|
||||
itemId={innerProps.itemId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -55,6 +55,7 @@ interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
|
||||
useOptions: (
|
||||
query: string,
|
||||
integrationIds: string[],
|
||||
options: Record<string, unknown>,
|
||||
) => {
|
||||
isPending: boolean;
|
||||
options: DynamicSelectOption[];
|
||||
|
||||
@@ -20,8 +20,9 @@ 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 { createId, isDateWithin, isNullOrWhitespace, splitToChunksWithNItems } from "@homarr/common";
|
||||
import type { ReleaseProviderKind } from "@homarr/definitions";
|
||||
import { getReleaseProviderIconUrl, getReleaseProviderName } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
|
||||
@@ -35,7 +36,7 @@ const formatRelativeDate = (value: string): string => {
|
||||
return isMonths ? value.toUpperCase() : isOtherUnits ? value.toLowerCase() : value;
|
||||
};
|
||||
|
||||
export default function ReleasesWidget({ options }: WidgetComponentProps<"releases">) {
|
||||
export default function ReleasesWidget({ options, itemId }: WidgetComponentProps<"releases">) {
|
||||
const t = useScopedI18n("widget.releases");
|
||||
const now = useNow();
|
||||
const formatter = useFormatter();
|
||||
@@ -55,11 +56,16 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
|
||||
[options.newReleaseWithin, options.staleReleaseWithin],
|
||||
);
|
||||
|
||||
// Group repositories by integration
|
||||
const normalizedRepositories = useMemo(
|
||||
() => options.repositories.map((repo) => (repo.id ? repo : { ...repo, id: createId() })),
|
||||
[options.repositories],
|
||||
);
|
||||
|
||||
// Group repositories by provider
|
||||
const groupedRepositories = useMemo(() => {
|
||||
return options.repositories.reduce(
|
||||
return normalizedRepositories.reduce(
|
||||
(acc, repo) => {
|
||||
const key = repo.providerIntegrationId;
|
||||
const key = repo.provider;
|
||||
if (!key) return acc;
|
||||
|
||||
acc[key] ??= [];
|
||||
@@ -67,28 +73,30 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ReleasesRepository[]>,
|
||||
{} as Partial<Record<ReleaseProviderKind, ReleasesRepository[]>>,
|
||||
);
|
||||
}, [options.repositories]);
|
||||
}, [normalizedRepositories]);
|
||||
|
||||
// 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({
|
||||
itemId,
|
||||
repositories: repositories.map((repository) => ({
|
||||
id: repository.id,
|
||||
provider,
|
||||
identifier: repository.identifier,
|
||||
versionFilter: repository.versionFilter,
|
||||
providerUrl: repository.providerUrl,
|
||||
})),
|
||||
}),
|
||||
),
|
||||
@@ -100,15 +108,15 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
|
||||
);
|
||||
|
||||
const repositories = useMemo(() => {
|
||||
const formattedResults = options.repositories
|
||||
const formattedResults = normalizedRepositories
|
||||
.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 +124,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,
|
||||
@@ -139,7 +147,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
|
||||
return formattedResults;
|
||||
}, [
|
||||
results,
|
||||
options.repositories,
|
||||
normalizedRepositories,
|
||||
options.showOnlyHighlighted,
|
||||
options.topReleases,
|
||||
relativeDateOptions.newReleaseWithin,
|
||||
@@ -173,7 +181,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 +195,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",
|
||||
@@ -529,28 +537,23 @@ const ExpandedDisplay = ({
|
||||
p={10}
|
||||
>
|
||||
<Group className="releases-repository-expanded-header" justify="space-between" align="center" gap="xs">
|
||||
<Text className="releases-repository-expanded-header-identifier" size="xs" c="iconColor" ff="monospace">
|
||||
<Text className="releases-repository-expanded-header-identifier" size="xs" c="dimmed" ff="monospace">
|
||||
{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",
|
||||
aspectRatio: "1/1",
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
className="releases-repository-expanded-header-provider-name"
|
||||
size="xs"
|
||||
c="iconColor"
|
||||
ff="monospace"
|
||||
>
|
||||
{repository.integration.name}
|
||||
<Text className="releases-repository-expanded-header-provider-name" size="xs" c="dimmed" ff="monospace">
|
||||
{repository.providerMetadata.name}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
@@ -614,7 +617,7 @@ const ExpandedDisplay = ({
|
||||
{repository.error && (
|
||||
<>
|
||||
<Divider className="releases-repository-expanded-error-divider" mx="30%" />
|
||||
<Title className="releases-repository-expanded-error-title" order={4} ta="center">
|
||||
<Title className="releases-repository-expanded-error-title" order={4} ta="center" c="red">
|
||||
{t("error.label")}
|
||||
</Title>
|
||||
<Text
|
||||
@@ -624,7 +627,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,9 @@
|
||||
import { IconRocket } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { releaseProviderKinds } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
@@ -39,10 +42,20 @@ export const { definition, componentLoader } = createWidgetDefinition("releases"
|
||||
validate: z.number().min(0),
|
||||
}),
|
||||
repositories: factory.multiReleasesRepositories({
|
||||
defaultValue: [],
|
||||
defaultValue: [
|
||||
{
|
||||
id: createId(),
|
||||
provider: "github" as const,
|
||||
identifier: "homarr-labs/homarr",
|
||||
name: "Homarr",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/homarr.png",
|
||||
versionFilter: { prefix: "v", precision: 3 },
|
||||
},
|
||||
],
|
||||
validate: z.array(
|
||||
z.object({
|
||||
providerIntegrationId: z.string().optional(),
|
||||
id: z.string().default(() => createId()),
|
||||
provider: z.enum(releaseProviderKinds).optional(),
|
||||
identifier: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
versionFilter: z
|
||||
@@ -53,6 +66,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,27 +7,24 @@ 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,20 +8,18 @@ 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) {
|
||||
const { data: stations, isPending } = clientApi.widget.timetable.searchStations.useQuery(
|
||||
{
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
query,
|
||||
},
|
||||
{
|
||||
enabled: integrationIds.length > 0,
|
||||
},
|
||||
);
|
||||
useOptions(query, _integrationIds, options) {
|
||||
const { data: stations, isPending } = clientApi.widget.timetable.searchStations.useQuery({
|
||||
baseUrl: typeof options.baseUrl === "string" ? options.baseUrl : "https://search.ch",
|
||||
query,
|
||||
});
|
||||
|
||||
return {
|
||||
isPending,
|
||||
|
||||
Reference in new issue
Block a user