mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-27 00:40:58 +01:00
feat(boards): add upload buttons for background, favicon and logo (#2770)
* feat(boards): add upload buttons for background, favicon and logo * feat(boards): use media search and show names for background * fix: background preview not working
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Grid, Group, Stack, TextInput } from "@mantine/core";
|
||||
import { Autocomplete, Button, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { IconPhotoOff } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
@@ -18,6 +22,7 @@ interface Props {
|
||||
}
|
||||
export const BackgroundSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
||||
const form = useZodForm(validation.board.savePartialSettings, {
|
||||
initialValues: {
|
||||
@@ -28,6 +33,16 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
|
||||
},
|
||||
});
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl, 200);
|
||||
const medias = clientApi.media.getPaginated.useQuery({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
includeFromAllUsers: true,
|
||||
search: debouncedSearch ?? "",
|
||||
});
|
||||
const images = medias.data?.items.filter((media) => media.contentType.startsWith("image/")) ?? [];
|
||||
const imageMap = new Map(images.map((image) => [`/api/user-medias/${image.id}`, image]));
|
||||
|
||||
const backgroundImageAttachmentData = useBackgroundOptionData(
|
||||
"backgroundImageAttachment",
|
||||
backgroundImageAttachments,
|
||||
@@ -47,8 +62,56 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
|
||||
<Stack>
|
||||
<Grid>
|
||||
<Grid.Col span={12}>
|
||||
<TextInput
|
||||
<Autocomplete
|
||||
leftSection={
|
||||
form.values.backgroundImageUrl &&
|
||||
form.values.backgroundImageUrl.trim().length >= 2 && (
|
||||
<Popover width={300} withArrow>
|
||||
<Popover.Target>
|
||||
<Center h="100%">
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
||||
</Center>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
// We filter it on the server
|
||||
filter={({ options }) => options}
|
||||
label={t("board.field.backgroundImageUrl.label")}
|
||||
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
|
||||
renderOption={({ option }) => {
|
||||
const current = imageMap.get(option.value);
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<Group gap="sm">
|
||||
<ImagePreview src={option.value} w={20} h={20} />
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{current.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{option.value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.your"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId === session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
{
|
||||
group: t("board.field.backgroundImageUrl.group.other"),
|
||||
items: images
|
||||
.filter((media) => media.creatorId !== session?.user.id)
|
||||
.map((media) => `/api/user-medias/${media.id}`),
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps("backgroundImageUrl")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
@@ -85,6 +148,21 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface ImagePreviewProps {
|
||||
src: string;
|
||||
w: string | number;
|
||||
h?: string | number;
|
||||
}
|
||||
|
||||
const ImagePreview = ({ src, w, h }: ImagePreviewProps) => {
|
||||
if (!["/", "http://", "https://"].some((prefix) => src.startsWith(prefix))) {
|
||||
return <IconPhotoOff size={w} />;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img src={src} alt="preview image" style={{ width: w, height: h, objectFit: "contain" }} />;
|
||||
};
|
||||
|
||||
type BackgroundImageKey = "backgroundImageAttachment" | "backgroundImageSize" | "backgroundImageRepeat";
|
||||
|
||||
type inferOptions<TKey extends BackgroundImageKey> = TranslationObject["board"]["field"][TKey]["option"];
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button, Grid, Group, Loader, Stack, TextInput, Tooltip } from "@mantine/core";
|
||||
import { Button, Grid, Group, Loader, Stack, TextInput } from "@mantine/core";
|
||||
import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { IconPicker } from "@homarr/forms-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
@@ -52,9 +52,9 @@ export const GeneralSettingsContent = ({ board }: Props) => {
|
||||
},
|
||||
);
|
||||
|
||||
useLogoPreview(form.values.logoImageUrl);
|
||||
useFaviconPreview(form.values.faviconImageUrl);
|
||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
|
||||
const faviconStatus = useFaviconPreview(form.values.faviconImageUrl);
|
||||
const logoStatus = useLogoPreview(form.values.logoImageUrl);
|
||||
|
||||
// Cleanup for not applied changes of the page title and logo image URL
|
||||
useEffect(() => {
|
||||
@@ -94,24 +94,24 @@ export const GeneralSettingsContent = ({ board }: Props) => {
|
||||
<TextInput
|
||||
label={t("board.field.metaTitle.label")}
|
||||
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
|
||||
rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />}
|
||||
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
|
||||
{...form.getInputProps("metaTitle")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
<IconPicker
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
label={t("board.field.logoImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
rightSection={<PendingOrInvalidIndicator {...logoStatus} />}
|
||||
{...form.getInputProps("logoImageUrl")}
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ xs: 12, md: 6 }}>
|
||||
<TextInput
|
||||
<IconPicker
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
label={t("board.field.faviconImageUrl.label")}
|
||||
placeholder="/logo/logo.png"
|
||||
rightSection={<PendingOrInvalidIndicator {...faviconStatus} />}
|
||||
{...form.getInputProps("faviconImageUrl")}
|
||||
withAsterisk={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
@@ -125,40 +125,16 @@ export const GeneralSettingsContent = ({ board }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const PendingOrInvalidIndicator = ({ isPending, isInvalid }: { isPending: boolean; isInvalid?: boolean }) => {
|
||||
const t = useI18n();
|
||||
|
||||
if (isInvalid) {
|
||||
return (
|
||||
<Tooltip multiline w={220} label={t("board.setting.section.general.unrecognizedLink")}>
|
||||
<IconAlertTriangle size="1rem" color="red" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return <Loader size="xs" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const useLogoPreview = (url: string | null) => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const [logoDebounced] = useDebouncedValue(url ?? "", 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logoDebounced.includes(".") && logoDebounced.length >= 1) return;
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
logoImageUrl: logoDebounced.length >= 1 ? logoDebounced : null,
|
||||
}));
|
||||
}, [logoDebounced, updateBoard]);
|
||||
|
||||
return {
|
||||
isPending: (url ?? "") !== logoDebounced,
|
||||
isInvalid: logoDebounced.length >= 1 && !logoDebounced.includes("."),
|
||||
};
|
||||
};
|
||||
|
||||
const useMetaTitlePreview = (title: string | null) => {
|
||||
@@ -170,16 +146,7 @@ const useMetaTitlePreview = (title: string | null) => {
|
||||
};
|
||||
};
|
||||
|
||||
const validFaviconExtensions = ["ico", "png", "svg", "gif"];
|
||||
const isValidUrl = (url: string) =>
|
||||
url.includes("/") && validFaviconExtensions.some((extension) => url.endsWith(`.${extension}`));
|
||||
|
||||
const useFaviconPreview = (url: string | null) => {
|
||||
const [faviconDebounced] = useDebouncedValue(url ?? "", 500);
|
||||
useFavicon(isValidUrl(faviconDebounced) ? faviconDebounced : "");
|
||||
|
||||
return {
|
||||
isPending: (url ?? "") !== faviconDebounced,
|
||||
isInvalid: faviconDebounced.length >= 1 && !isValidUrl(faviconDebounced),
|
||||
};
|
||||
useFavicon(faviconDebounced);
|
||||
};
|
||||
|
||||
@@ -33,6 +33,9 @@ interface IconPickerProps {
|
||||
error?: string | null;
|
||||
onFocus?: FocusEventHandler;
|
||||
onBlur?: FocusEventHandler;
|
||||
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
withAsterisk?: boolean;
|
||||
}
|
||||
|
||||
@@ -43,6 +46,8 @@ export const IconPicker = ({
|
||||
onFocus,
|
||||
onBlur,
|
||||
withAsterisk = true,
|
||||
label,
|
||||
placeholder,
|
||||
}: IconPickerProps) => {
|
||||
const [value, setValue] = useUncontrolled({
|
||||
value: propsValue,
|
||||
@@ -155,8 +160,8 @@ export const IconPicker = ({
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk={withAsterisk}
|
||||
error={error}
|
||||
label={tCommon("iconPicker.label")}
|
||||
placeholder={tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
|
||||
label={label ?? tCommon("iconPicker.label")}
|
||||
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
|
||||
@@ -2146,7 +2146,12 @@
|
||||
"label": "Favicon image URL"
|
||||
},
|
||||
"backgroundImageUrl": {
|
||||
"label": "Background image URL"
|
||||
"label": "Background image URL",
|
||||
"placeholder": "Start typing to search local images",
|
||||
"group": {
|
||||
"your": "Your images",
|
||||
"other": "Other images"
|
||||
}
|
||||
},
|
||||
"backgroundImageAttachment": {
|
||||
"label": "Background image attachment",
|
||||
|
||||
Reference in New Issue
Block a user