From e07b9e6a88ff0aad0aa5dae2f32f648ad5f0c0a8 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 5 Apr 2025 13:54:16 +0200 Subject: [PATCH] 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 --- .../boards/[name]/settings/_background.tsx | 82 ++++++++++++++++++- .../boards/[name]/settings/_general.tsx | 57 +++---------- .../src/icon-picker/icon-picker.tsx | 9 +- packages/translation/src/lang/en.json | 7 +- 4 files changed, 105 insertions(+), 50 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx index 9f790cf7c..0a5786655 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx @@ -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) => { - = 2 && ( + + +
+ +
+
+ + + +
+ ) + } + // 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 ( + + + + {current.name} + + {option.value} + + + + ); + }} + 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")} />
@@ -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 ; + } + + // eslint-disable-next-line @next/next/no-img-element + return preview image; +}; + type BackgroundImageKey = "backgroundImageAttachment" | "backgroundImageSize" | "backgroundImageRepeat"; type inferOptions = TranslationObject["board"]["field"][TKey]["option"]; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx index efa640f93..389bff5ed 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx @@ -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) => { } + rightSection={metaTitleStatus.isPending && } {...form.getInputProps("metaTitle")} /> - } - {...form.getInputProps("logoImageUrl")} + withAsterisk={false} /> - } - {...form.getInputProps("faviconImageUrl")} + withAsterisk={false} />
@@ -125,40 +125,16 @@ export const GeneralSettingsContent = ({ board }: Props) => { ); }; -const PendingOrInvalidIndicator = ({ isPending, isInvalid }: { isPending: boolean; isInvalid?: boolean }) => { - const t = useI18n(); - - if (isInvalid) { - return ( - - - - ); - } - - if (isPending) { - return ; - } - - 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); }; diff --git a/packages/forms-collection/src/icon-picker/icon-picker.tsx b/packages/forms-collection/src/icon-picker/icon-picker.tsx index a571abbe2..aa18248d7 100644 --- a/packages/forms-collection/src/icon-picker/icon-picker.tsx +++ b/packages/forms-collection/src/icon-picker/icon-picker.tsx @@ -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") && (