From bb02163e25e64c13e4f36df21fa90ff33483cac6 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sun, 3 Mar 2024 16:01:32 +0100 Subject: [PATCH] feat: board settings (#137) * refactor: improve user feedback for general board settings section * wip: add board settings for background and colors, move danger zone to own file, refactor code * feat: add shade selector * feat: add slider for opacity * fix: issue with invalid hex values for color preview * refactor: add shared mutation hook for saving partial board settings with invalidate query * fix: add cleanup for not applied changes to logo and page title * feat: add layout settings * feat: add empty custom css section to board settings * refactor: improve layout of board logo on mobile * feat: add theme provider for board colors * refactor: add auto contrast for better contrast of buttons with low primary shade * feat: add background for boards * feat: add opacity for boards * feat: add rename board * feat: add visibility and delete of board settings * fix: issue that wrong data is updated with update board method * refactor: improve danger zone button placement for mobile * fix: board not revalidated when already in boards layout * refactor: improve board color preview * refactor: change save button color to teal, add placeholders for general board settings * chore: update initial migration * refactor: remove unnecessary div * chore: address pull request feedback * fix: ci issues * fix: deepsource issues * chore: address pull request feedback * fix: formatting issue * chore: address pull request feedback --- apps/nextjs/package.json | 3 + .../integrations/_integration-accordion.tsx | 32 --- .../app/[locale]/(main)/integrations/page.tsx | 8 +- .../boards/[name]/@headeractions/page.tsx | 10 +- .../boards/[name]/settings/_background.tsx | 140 +++++++++++++ .../boards/[name]/settings/_colors.tsx | 157 +++++++++++++++ .../boards/[name]/settings/_customCss.tsx | 7 + .../boards/[name]/settings/_danger.tsx | 165 +++++++++++++++ .../boards/[name]/settings/_general.tsx | 113 +++++++++-- .../boards/[name]/settings/_layout.tsx | 54 +++++ .../boards/[name]/settings/_shared.tsx | 13 ++ .../boards/[name]/settings/danger.module.css | 5 + .../[locale]/boards/[name]/settings/page.tsx | 189 +++++++++--------- .../src/app/[locale]/boards/_client.tsx | 25 ++- .../src/app/[locale]/boards/_context.tsx | 33 ++- .../src/app/[locale]/boards/_creator.tsx | 20 +- .../nextjs/src/app/[locale]/boards/_theme.tsx | 50 +++++ apps/nextjs/src/app/[locale]/boards/_types.ts | 1 - apps/nextjs/src/app/[locale]/layout.tsx | 13 +- apps/nextjs/src/app/[locale]/modals.tsx | 2 + .../src/components/active-tab-accordion.tsx | 45 +++++ .../board/modals/board-rename-modal.tsx | 71 +++++++ .../sections/category/category-actions.ts | 10 +- .../src/components/board/sections/content.tsx | 15 +- .../sections/gridstack/init-gridstack.ts | 14 +- .../board/sections/gridstack/use-gridstack.ts | 31 +-- .../components/board/sections/item.module.css | 10 + .../src/components/layout/background.tsx | 62 ++++++ .../src/components/layout/logo/board-logo.tsx | 7 +- .../src/components/layout/logo/logo.tsx | 16 +- apps/nextjs/src/components/layout/shell.tsx | 3 + apps/nextjs/src/styles/gridstack.scss | 41 ---- packages/api/src/router/board.ts | 72 ++++++- packages/api/src/router/test/board.spec.ts | 28 +-- ...ed_wolf.sql => 0000_sloppy_bloodstorm.sql} | 10 +- .../db/migrations/meta/0000_snapshot.json | 42 ++-- packages/db/migrations/meta/_journal.json | 4 +- packages/db/schema/sqlite.ts | 35 +--- packages/definitions/src/_definition.ts | 20 ++ packages/definitions/src/board.ts | 35 ++-- packages/definitions/src/section.ts | 2 +- packages/translation/src/lang/en.ts | 114 ++++++++++- packages/ui/src/components/index.tsx | 2 + .../components/select-with-custom-items.tsx | 101 ++++++++++ .../select-with-description-and-badge.tsx | 49 +++++ .../components/select-with-description.tsx | 35 ++++ packages/validation/src/board.ts | 70 +++++-- packages/validation/src/shared.ts | 17 +- pnpm-lock.yaml | 25 +++ 49 files changed, 1620 insertions(+), 406 deletions(-) delete mode 100644 apps/nextjs/src/app/[locale]/(main)/integrations/_integration-accordion.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_colors.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css create mode 100644 apps/nextjs/src/app/[locale]/boards/_theme.tsx create mode 100644 apps/nextjs/src/components/active-tab-accordion.tsx create mode 100644 apps/nextjs/src/components/board/modals/board-rename-modal.tsx create mode 100644 apps/nextjs/src/components/board/sections/item.module.css create mode 100644 apps/nextjs/src/components/layout/background.tsx rename packages/db/migrations/{0000_true_red_wolf.sql => 0000_sloppy_bloodstorm.sql} (92%) create mode 100644 packages/definitions/src/_definition.ts create mode 100644 packages/ui/src/components/select-with-custom-items.tsx create mode 100644 packages/ui/src/components/select-with-description-and-badge.tsx create mode 100644 packages/ui/src/components/select-with-description.tsx diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index c1e6daca0..51209ddec 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -27,6 +27,7 @@ "@homarr/ui": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0", + "@mantine/colors-generator": "^7.5.3", "@mantine/hooks": "^7.5.3", "@mantine/modals": "^7.5.3", "@mantine/tiptap": "^7.5.3", @@ -41,6 +42,7 @@ "@trpc/next": "next", "@trpc/react-query": "next", "@trpc/server": "next", + "chroma-js": "^2.4.2", "dayjs": "^1.11.10", "jotai": "^2.7.0", "mantine-modal-manager": "^7.5.3", @@ -59,6 +61,7 @@ "@types/node": "^20.11.24", "@types/react": "^18.2.61", "@types/react-dom": "^18.2.19", + "@types/chroma-js": "2.4.4", "dotenv-cli": "^7.3.0", "eslint": "^8.57.0", "prettier": "^3.2.5", diff --git a/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-accordion.tsx b/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-accordion.tsx deleted file mode 100644 index 389bed980..000000000 --- a/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-accordion.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import type { PropsWithChildren } from "react"; -import { useRouter } from "next/navigation"; - -import type { IntegrationKind } from "@homarr/definitions"; -import { Accordion } from "@homarr/ui"; - -type IntegrationGroupAccordionControlProps = PropsWithChildren<{ - activeTab: IntegrationKind | undefined; -}>; - -export const IntegrationGroupAccordion = ({ - children, - activeTab, -}: IntegrationGroupAccordionControlProps) => { - const router = useRouter(); - - return ( - - tab - ? router.replace(`?tab=${tab}`, {}) - : router.replace("/integrations") - } - > - {children} - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/(main)/integrations/page.tsx b/apps/nextjs/src/app/[locale]/(main)/integrations/page.tsx index 2b5549d94..91a7454ec 100644 --- a/apps/nextjs/src/app/[locale]/(main)/integrations/page.tsx +++ b/apps/nextjs/src/app/[locale]/(main)/integrations/page.tsx @@ -2,8 +2,8 @@ import Link from "next/link"; import type { RouterOutputs } from "@homarr/api"; import { objectEntries } from "@homarr/common"; -import { getIntegrationName } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions"; +import { getIntegrationName } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; import { AccordionControl, @@ -33,7 +33,7 @@ import { } from "@homarr/ui"; import { api } from "~/trpc/server"; -import { IntegrationGroupAccordion } from "./_integration-accordion"; +import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; import { IntegrationAvatar } from "./_integration-avatar"; import { DeleteIntegrationActionButton } from "./_integration-buttons"; import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown"; @@ -112,7 +112,7 @@ const IntegrationList = async ({ ); return ( - + {objectEntries(grouppedIntegrations).map(([kind, integrations]) => ( }> @@ -170,6 +170,6 @@ const IntegrationList = async ({ ))} - + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx index ae4d5cfa1..24995d234 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx @@ -22,6 +22,7 @@ import { } from "@homarr/ui"; import { modalEvents } from "~/app/[locale]/modals"; +import { revalidatePathAction } from "~/app/revalidatePathAction"; import { editModeAtom } from "~/components/board/editMode"; import { useCategoryActions } from "~/components/board/sections/category/category-actions"; import { HeaderButton } from "~/components/layout/header/button"; @@ -107,6 +108,7 @@ const AddMenu = () => { const EditModeMenu = () => { const [isEditMode, setEditMode] = useAtom(editModeAtom); const board = useRequiredBoard(); + const utils = clientApi.useUtils(); const t = useScopedI18n("board.action.edit"); const { mutate: saveBoard, isPending } = clientApi.board.save.useMutation({ onSuccess() { @@ -114,6 +116,8 @@ const EditModeMenu = () => { title: t("notification.success.title"), message: t("notification.success.message"), }); + void utils.board.byName.invalidate({ name: board.name }); + void revalidatePathAction(`/boards/${board.name}`); setEditMode(false); }, onError() { @@ -125,11 +129,7 @@ const EditModeMenu = () => { }); const toggle = () => { - if (isEditMode) - return saveBoard({ - boardId: board.id, - ...board, - }); + if (isEditMode) return saveBoard(board); setEditMode(true); }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx new file mode 100644 index 000000000..fa314a4e0 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { + backgroundImageAttachments, + backgroundImageRepeats, + backgroundImageSizes, +} from "@homarr/definitions"; +import { useForm } from "@homarr/form"; +import type { TranslationObject } from "@homarr/translation"; +import { useI18n } from "@homarr/translation/client"; +import type { SelectItemWithDescriptionBadge } from "@homarr/ui"; +import { + Button, + Grid, + Group, + SelectWithDescriptionBadge, + Stack, + TextInput, +} from "@homarr/ui"; + +import type { Board } from "../../_types"; +import { useSavePartialSettingsMutation } from "./_shared"; + +interface Props { + board: Board; +} +export const BackgroundSettingsContent = ({ board }: Props) => { + const t = useI18n(); + const { mutate: savePartialSettings, isPending } = + useSavePartialSettingsMutation(board); + const form = useForm({ + initialValues: { + backgroundImageUrl: board.backgroundImageUrl ?? "", + backgroundImageAttachment: board.backgroundImageAttachment, + backgroundImageRepeat: board.backgroundImageRepeat, + backgroundImageSize: board.backgroundImageSize, + }, + }); + + const backgroundImageAttachmentData = useBackgroundOptionData( + "backgroundImageAttachment", + backgroundImageAttachments, + ); + const backgroundImageSizeData = useBackgroundOptionData( + "backgroundImageSize", + backgroundImageSizes, + ); + const backgroundImageRepeatData = useBackgroundOptionData( + "backgroundImageRepeat", + backgroundImageRepeats, + ); + + return ( +
{ + savePartialSettings({ + id: board.id, + ...values, + }); + })} + > + + + + + + + + + + + + + + + + + + + + +
+ ); +}; + +type BackgroundImageKey = + | "backgroundImageAttachment" + | "backgroundImageSize" + | "backgroundImageRepeat"; + +type inferOptions = + TranslationObject["board"]["field"][TKey]["option"]; + +const useBackgroundOptionData = < + TKey extends BackgroundImageKey, + TOptions extends inferOptions = inferOptions, +>( + key: TKey, + data: { + values: (keyof TOptions)[]; + defaultValue: keyof TOptions; + }, +) => { + const t = useI18n(); + + return data.values.map( + (value) => + ({ + label: t(`board.field.${key}.option.${value as string}.label` as never), + description: t( + `board.field.${key}.option.${value as string}.description` as never, + ), + value: value as string, + badge: + data.defaultValue === value + ? { + color: "blue", + label: t("common.select.badge.recommended"), + } + : undefined, + }) satisfies SelectItemWithDescriptionBadge, + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_colors.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_colors.tsx new file mode 100644 index 000000000..6368a4801 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_colors.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useDisclosure } from "@mantine/hooks"; + +import { useForm } from "@homarr/form"; +import { useI18n } from "@homarr/translation/client"; +import { + Anchor, + Button, + Collapse, + ColorInput, + ColorSwatch, + Grid, + Group, + InputWrapper, + isLightColor, + Slider, + Stack, + Text, + useMantineTheme, +} from "@homarr/ui"; + +import { generateColors } from "../../_theme"; +import type { Board } from "../../_types"; +import { useSavePartialSettingsMutation } from "./_shared"; + +interface Props { + board: Board; +} + +const hexRegex = /^#[0-9a-fA-F]{6}$/; + +const progressPercentageLabel = (value: number) => `${value}%`; + +export const ColorSettingsContent = ({ board }: Props) => { + const form = useForm({ + initialValues: { + primaryColor: board.primaryColor, + secondaryColor: board.secondaryColor, + opacity: board.opacity, + }, + }); + const [showPreview, { toggle }] = useDisclosure(false); + const t = useI18n(); + const theme = useMantineTheme(); + const { mutate: savePartialSettings, isPending } = + useSavePartialSettingsMutation(board); + return ( +
{ + savePartialSettings({ + id: board.id, + ...values, + }); + })} + > + + + + + color[6])} + {...form.getInputProps("primaryColor")} + /> + + + + color[6])} + {...form.getInputProps("secondaryColor")} + /> + + + + {showPreview + ? t("common.preview.hide") + : t("common.preview.show")} + + + + + + + + + + + + + + + + + + + + +
+ ); +}; + +interface ColorsPreviewProps { + previewColor: string; +} + +const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => { + const theme = useMantineTheme(); + + const colors = hexRegex.test(previewColor) + ? generateColors(previewColor) + : generateColors("#000000"); + + return ( + + {colors.map((color, index) => ( + + + + {index} + + + {color} + + + + ))} + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx new file mode 100644 index 000000000..4d9bc7f75 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx @@ -0,0 +1,7 @@ +"use client"; + +// TODO: add some sort of store (maybe directory on GitHub) + +export const CustomCssSettingsContent = () => { + return null; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx new file mode 100644 index 000000000..7b314a9b2 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useCallback } from "react"; +import { useRouter } from "next/navigation"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; +import { Button, Divider, Group, Stack, Text } from "@homarr/ui"; + +import { modalEvents } from "~/app/[locale]/modals"; +import { useRequiredBoard } from "../../_context"; +import classes from "./danger.module.css"; + +export const DangerZoneSettingsContent = () => { + const board = useRequiredBoard(); + const t = useScopedI18n("board.setting"); + const router = useRouter(); + const { mutate: changeVisibility, isPending: isChangeVisibilityPending } = + clientApi.board.changeVisibility.useMutation(); + const { mutate: deleteBoard, isPending: isDeletePending } = + clientApi.board.delete.useMutation(); + const utils = clientApi.useUtils(); + const visibility = board.isPublic ? "public" : "private"; + + const onRenameClick = useCallback( + () => + modalEvents.openManagedModal({ + modal: "boardRenameModal", + title: t("section.dangerZone.action.rename.modal.title"), + innerProps: { + id: board.id, + previousName: board.name, + onSuccess: (name) => { + router.push(`/boards/${name}/settings`); + }, + }, + }), + [board.id, board.name, router, t], + ); + + const onVisibilityClick = useCallback(() => { + modalEvents.openConfirmModal({ + title: t( + `section.dangerZone.action.visibility.confirm.${visibility}.title`, + ), + children: t( + `section.dangerZone.action.visibility.confirm.${visibility}.description`, + ), + confirmProps: { + color: "red.9", + }, + onConfirm: () => { + changeVisibility( + { + id: board.id, + visibility: visibility === "public" ? "private" : "public", + }, + { + onSettled() { + void utils.board.byName.invalidate({ name: board.name }); + void utils.board.default.invalidate(); + }, + }, + ); + }, + }); + }, [ + board.id, + board.name, + changeVisibility, + t, + utils.board.byName, + utils.board.default, + visibility, + ]); + + const onDeleteClick = useCallback(() => { + modalEvents.openConfirmModal({ + title: t("section.dangerZone.action.delete.confirm.title"), + children: t("section.dangerZone.action.delete.confirm.description"), + confirmProps: { + color: "red.9", + }, + onConfirm: () => { + deleteBoard( + { id: board.id }, + { + onSettled: () => { + router.push("/"); + }, + }, + ); + }, + }); + }, [board.id, deleteBoard, router, t]); + + return ( + + + + + + + + + ); +}; + +interface DangerZoneRowProps { + label: string; + description: string; + buttonText: string; + isPending?: boolean; + onClick: () => void; +} + +const DangerZoneRow = ({ + label, + description, + buttonText, + onClick, + isPending, +}: DangerZoneRowProps) => { + return ( + + + + {label} + + {description} + + + + + + ); +}; 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 ae9b65e9d..4b900dcbf 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx @@ -1,19 +1,28 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useDebouncedValue, useDocumentTitle, useFavicon, } from "@mantine/hooks"; -import { clientApi } from "@homarr/api/client"; import { useForm } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; -import { Button, Grid, Group, Stack, TextInput } from "@homarr/ui"; +import { + Button, + Grid, + Group, + IconAlertTriangle, + Loader, + Stack, + TextInput, + Tooltip, +} from "@homarr/ui"; import { useUpdateBoard } from "../../_client"; import type { Board } from "../../_types"; +import { useSavePartialSettingsMutation } from "./_shared"; interface Props { board: Board; @@ -21,15 +30,20 @@ interface Props { export const GeneralSettingsContent = ({ board }: Props) => { const t = useI18n(); + const ref = useRef({ + pageTitle: board.pageTitle, + logoImageUrl: board.logoImageUrl, + }); const { updateBoard } = useUpdateBoard(); - const { mutate: saveGeneralSettings, isPending } = - clientApi.board.saveGeneralSettings.useMutation(); + + const { mutate: savePartialSettings, isPending } = + useSavePartialSettingsMutation(board); const form = useForm({ initialValues: { - pageTitle: board.pageTitle, - logoImageUrl: board.logoImageUrl, - metaTitle: board.metaTitle, - faviconImageUrl: board.faviconImageUrl, + pageTitle: board.pageTitle ?? "", + logoImageUrl: board.logoImageUrl ?? "", + metaTitle: board.metaTitle ?? "", + faviconImageUrl: board.faviconImageUrl ?? "", }, onValuesChange({ pageTitle }) { updateBoard((previous) => ({ @@ -39,15 +53,31 @@ export const GeneralSettingsContent = ({ board }: Props) => { }, }); - useMetaTitlePreview(form.values.metaTitle); - useFaviconPreview(form.values.faviconImageUrl); - useLogoPreview(form.values.logoImageUrl); + 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(() => { + return () => { + updateBoard((previous) => ({ + ...previous, + pageTitle: ref.current.pageTitle, + logoImageUrl: ref.current.logoImageUrl, + })); + }; + }, [updateBoard]); return (
{ - saveGeneralSettings({ - boardId: board.id, + // Save the current values to the ref so that it does not reset if the form is submitted + ref.current = { + pageTitle: values.pageTitle, + logoImageUrl: values.logoImageUrl, + }; + savePartialSettings({ + id: board.id, ...values, }); })} @@ -57,30 +87,37 @@ export const GeneralSettingsContent = ({ board }: Props) => { } {...form.getInputProps("metaTitle")} /> } {...form.getInputProps("logoImageUrl")} /> } {...form.getInputProps("faviconImageUrl")} /> - @@ -89,22 +126,59 @@ 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(".")) return; + if (!logoDebounced.includes(".") && logoDebounced.length >= 1) return; updateBoard((previous) => ({ ...previous, - logoImageUrl: logoDebounced, + logoImageUrl: logoDebounced.length >= 1 ? logoDebounced : null, })); }, [logoDebounced, updateBoard]); + + return { + isPending: (url ?? "") !== logoDebounced, + isInvalid: logoDebounced.length >= 1 && !logoDebounced.includes("."), + }; }; const useMetaTitlePreview = (title: string | null) => { const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200); useDocumentTitle(metaTitleDebounced); + + return { + isPending: (title ?? "") !== metaTitleDebounced, + }; }; const validFaviconExtensions = ["ico", "png", "svg", "gif"]; @@ -115,4 +189,9 @@ const isValidUrl = (url: string) => const useFaviconPreview = (url: string | null) => { const [faviconDebounced] = useDebouncedValue(url ?? "", 500); useFavicon(isValidUrl(faviconDebounced) ? faviconDebounced : ""); + + return { + isPending: (url ?? "") !== faviconDebounced, + isInvalid: faviconDebounced.length >= 1 && !isValidUrl(faviconDebounced), + }; }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx new file mode 100644 index 000000000..6494def03 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useForm } from "@homarr/form"; +import { useI18n } from "@homarr/translation/client"; +import { Button, Grid, Group, Input, Slider, Stack } from "@homarr/ui"; + +import type { Board } from "../../_types"; +import { useSavePartialSettingsMutation } from "./_shared"; + +interface Props { + board: Board; +} +export const LayoutSettingsContent = ({ board }: Props) => { + const t = useI18n(); + const { mutate: savePartialSettings, isPending } = + useSavePartialSettingsMutation(board); + const form = useForm({ + initialValues: { + columnCount: board.columnCount, + }, + }); + + return ( + { + savePartialSettings({ + id: board.id, + ...values, + }); + })} + > + + + + + + + + + + + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx new file mode 100644 index 000000000..f69afcf9b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_shared.tsx @@ -0,0 +1,13 @@ +import { clientApi } from "@homarr/api/client"; + +import type { Board } from "../../_types"; + +export const useSavePartialSettingsMutation = (board: Board) => { + const utils = clientApi.useUtils(); + return clientApi.board.savePartialSettings.useMutation({ + onSettled() { + void utils.board.byName.invalidate({ name: board.name }); + void utils.board.default.invalidate(); + }, + }); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css b/apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css new file mode 100644 index 000000000..39a60b735 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/danger.module.css @@ -0,0 +1,5 @@ +@media (min-width: 36em) { + .dangerZoneGroup { + --group-wrap: nowrap !important; + } +} diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx index 1b156881b..749edfcab 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx @@ -1,17 +1,19 @@ +import type { PropsWithChildren } from "react"; + import { capitalize } from "@homarr/common"; +import type { TranslationObject } from "@homarr/translation"; import { getScopedI18n } from "@homarr/translation/server"; +import type { TablerIconsProps } from "@homarr/ui"; import { - Accordion, AccordionControl, AccordionItem, AccordionPanel, - Button, Container, - Divider, - Group, IconAlertTriangle, IconBrush, + IconFileTypeCss, IconLayout, + IconPhoto, IconSettings, Stack, Text, @@ -19,15 +21,27 @@ import { } from "@homarr/ui"; import { api } from "~/trpc/server"; +import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion"; +import { BackgroundSettingsContent } from "./_background"; +import { ColorSettingsContent } from "./_colors"; +import { CustomCssSettingsContent } from "./_customCss"; +import { DangerZoneSettingsContent } from "./_danger"; import { GeneralSettingsContent } from "./_general"; +import { LayoutSettingsContent } from "./_layout"; interface Props { params: { name: string; }; + searchParams: { + tab?: keyof TranslationObject["board"]["setting"]["section"]; + }; } -export default async function BoardSettingsPage({ params }: Props) { +export default async function BoardSettingsPage({ + params, + searchParams, +}: Props) { const board = await api.board.byName({ name: params.name }); const t = await getScopedI18n("board.setting"); @@ -35,99 +49,82 @@ export default async function BoardSettingsPage({ params }: Props) { {t("title", { boardName: capitalize(board.name) })} - - - }> - - {t("section.general.title")} - - - - - - - - }> - - {t("section.layout.title")} - - - - - - }> - - {t("section.appearance.title")} - - - - - + + + + + + + + + + + + + + + + - }> - - {t("section.dangerZone.title")} - - - - - - - - - {t("section.dangerZone.action.rename.label")} - - - {t("section.dangerZone.action.rename.description")} - - - - - - - - - {t("section.dangerZone.action.visibility.label")} - - - {t( - "section.dangerZone.action.visibility.description.private", - )} - - - - - - - - - {t("section.dangerZone.action.delete.label")} - - - {t("section.dangerZone.action.delete.description")} - - - - - - - - + + + ); } + +type AccordionItemForProps = PropsWithChildren<{ + value: keyof TranslationObject["board"]["setting"]["section"]; + icon: (props: TablerIconsProps) => JSX.Element; + danger?: boolean; + noPadding?: boolean; +}>; + +const AccordionItemFor = async ({ + value, + children, + icon: Icon, + danger, + noPadding, +}: AccordionItemForProps) => { + const t = await getScopedI18n("board.setting.section"); + return ( + + }> + + {t(`${value}.title`)} + + + + {children} + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_client.tsx b/apps/nextjs/src/app/[locale]/boards/_client.tsx index e3b18be59..0a5f17eec 100644 --- a/apps/nextjs/src/app/[locale]/boards/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_client.tsx @@ -8,8 +8,14 @@ import { Box, LoadingOverlay, Stack } from "@homarr/ui"; import { BoardCategorySection } from "~/components/board/sections/category-section"; import { BoardEmptySection } from "~/components/board/sections/empty-section"; +import { BoardBackgroundVideo } from "~/components/layout/background"; import { useIsBoardReady, useRequiredBoard } from "./_context"; -import type { CategorySection, EmptySection } from "./_types"; + +let boardName: string | null = null; + +export const updateBoardName = (name: string | null) => { + boardName = name; +}; type UpdateCallback = ( prev: RouterOutputs["board"]["default"], @@ -20,7 +26,10 @@ export const useUpdateBoard = () => { const updateBoard = useCallback( (updaterWithoutUndefined: UpdateCallback) => { - utils.board.default.setData(undefined, (previous) => + if (!boardName) { + throw new Error("Board name is not set"); + } + utils.board.byName.setData({ name: boardName }, (previous) => previous ? updaterWithoutUndefined(previous) : previous, ); }, @@ -36,21 +45,17 @@ export const ClientBoard = () => { const board = useRequiredBoard(); const isReady = useIsBoardReady(); - const sectionsWithoutSidebars = board.sections - .filter( - (section): section is CategorySection | EmptySection => - section.kind !== "sidebar", - ) - .sort((a, b) => a.position - b.position); + const sortedSections = board.sections.sort((a, b) => a.position - b.position); const ref = useRef(null); return ( + { h="100%" style={{ visibility: isReady ? "visible" : "hidden" }} > - {sectionsWithoutSidebars.map((section) => + {sortedSections.map((section) => section.kind === "empty" ? ( ) => { +}: PropsWithChildren<{ initialBoard: RouterOutputs["board"]["byName"] }>) => { + const pathname = usePathname(); + const utils = clientApi.useUtils(); const [readySections, setReadySections] = useState([]); - const { data } = clientApi.board.default.useQuery(undefined, { - initialData: initialBoard, - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }); + const { data } = clientApi.board.byName.useQuery( + { name: initialBoard.name }, + { + initialData: initialBoard, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + ); + // Update the board name so it can be used within updateBoard method + updateBoardName(initialBoard.name); + + // Invalidate the board when the pathname changes + // This allows to refetch the board when it might have changed - e.g. if someone else added an item + useEffect(() => { + return () => { + setReadySections([]); + void utils.board.byName.invalidate({ name: initialBoard.name }); + }; + }, [pathname, utils, initialBoard.name]); useEffect(() => { setReadySections((previous) => diff --git a/apps/nextjs/src/app/[locale]/boards/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/_creator.tsx index ec2ef4f97..dc31f398c 100644 --- a/apps/nextjs/src/app/[locale]/boards/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_creator.tsx @@ -15,6 +15,8 @@ import "../../../styles/gridstack.scss"; import { GlobalItemServerDataRunner } from "@homarr/widgets"; +import { BoardMantineProvider } from "./_theme"; + type Params = Record; interface Props { @@ -35,14 +37,16 @@ export const createBoardPage = >({ return ( - - } - actions={headeractions} - hasNavigation={false} - /> - {children} - + + + } + actions={headeractions} + hasNavigation={false} + /> + {children} + + ); diff --git a/apps/nextjs/src/app/[locale]/boards/_theme.tsx b/apps/nextjs/src/app/[locale]/boards/_theme.tsx new file mode 100644 index 000000000..870b215c6 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_theme.tsx @@ -0,0 +1,50 @@ +"use client"; + +import type { PropsWithChildren } from "react"; + +import type { MantineColorsTuple } from "@homarr/ui"; +import { createTheme, darken, lighten, MantineProvider } from "@homarr/ui"; + +import { useRequiredBoard } from "./_context"; + +export const BoardMantineProvider = ({ children }: PropsWithChildren) => { + const board = useRequiredBoard(); + + const theme = createTheme({ + colors: { + primaryColor: generateColors(board.primaryColor), + secondaryColor: generateColors(board.secondaryColor), + }, + primaryColor: "primaryColor", + autoContrast: true, + }); + + return {children}; +}; + +export const generateColors = (hex: string) => { + const lightnessForColors = [ + -0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2, + ] as const; + const rgbaColors = lightnessForColors.map((lightness) => { + if (lightness < 0) { + return lighten(hex, -lightness); + } + return darken(hex, lightness); + }); + + return rgbaColors.map((color) => { + return ( + "#" + + color + .split("(")[1]! + .replaceAll(" ", "") + .replace(")", "") + .split(",") + .map((color) => parseInt(color, 10)) + .slice(0, 3) + .map((color) => color.toString(16).padStart(2, "0")) + .join("") + ); + }) as unknown as MantineColorsTuple; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_types.ts b/apps/nextjs/src/app/[locale]/boards/_types.ts index 0e9d0495c..d1f2b92d8 100644 --- a/apps/nextjs/src/app/[locale]/boards/_types.ts +++ b/apps/nextjs/src/app/[locale]/boards/_types.ts @@ -7,7 +7,6 @@ export type Item = Section["items"][number]; export type CategorySection = Extract; export type EmptySection = Extract; -export type SidebarSection = Extract; export type ItemOfKind = Extract< Item, diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index ec25281df..621b4acb1 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -6,11 +6,7 @@ import "@homarr/spotlight/styles.css"; import "@homarr/ui/styles.css"; import { Notifications } from "@homarr/notifications"; -import { - ColorSchemeScript, - MantineProvider, - uiConfiguration, -} from "@homarr/ui"; +import { ColorSchemeScript, createTheme, MantineProvider } from "@homarr/ui"; import { JotaiProvider } from "./_client-providers/jotai"; import { ModalsProvider } from "./_client-providers/modals"; @@ -64,8 +60,11 @@ export default function Layout(props: { (innerProps) => ( ), (innerProps) => , diff --git a/apps/nextjs/src/app/[locale]/modals.tsx b/apps/nextjs/src/app/[locale]/modals.tsx index f3e700b7c..8f64c3b41 100644 --- a/apps/nextjs/src/app/[locale]/modals.tsx +++ b/apps/nextjs/src/app/[locale]/modals.tsx @@ -5,6 +5,7 @@ import { createModalManager } from "mantine-modal-manager"; import { WidgetEditModal } from "@homarr/widgets"; import { ItemSelectModal } from "~/components/board/items/item-select-modal"; +import { BoardRenameModal } from "~/components/board/modals/board-rename-modal"; import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { AddBoardModal } from "~/components/manage/boards/add-board-modal"; @@ -13,4 +14,5 @@ export const [ModalsManager, modalEvents] = createModalManager({ widgetEditModal: WidgetEditModal, itemSelectModal: ItemSelectModal, addBoardModal: AddBoardModal, + boardRenameModal: BoardRenameModal, }); diff --git a/apps/nextjs/src/components/active-tab-accordion.tsx b/apps/nextjs/src/components/active-tab-accordion.tsx new file mode 100644 index 000000000..9e416da06 --- /dev/null +++ b/apps/nextjs/src/components/active-tab-accordion.tsx @@ -0,0 +1,45 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { useCallback } from "react"; +import { usePathname } from "next/navigation"; +import { useShallowEffect } from "@mantine/hooks"; + +import type { AccordionProps } from "@homarr/ui"; +import { Accordion } from "@homarr/ui"; + +type ActiveTabAccordionProps = PropsWithChildren< + Omit, "onChange"> +>; + +// Replace state without fetchign new data +const replace = (newUrl: string) => { + window.history.replaceState( + { ...window.history.state, as: newUrl, url: newUrl }, + "", + newUrl, + ); +}; + +export const ActiveTabAccordion = ({ + children, + ...props +}: ActiveTabAccordionProps) => { + const pathname = usePathname(); + const onChange = useCallback( + (tab: string | null) => (tab ? replace(`?tab=${tab}`) : replace(pathname)), + [pathname], + ); + + useShallowEffect(() => { + if (props.defaultValue) { + replace(`?tab=${props.defaultValue}`); + } + }, [props.defaultValue]); + + return ( + + {children} + + ); +}; diff --git a/apps/nextjs/src/components/board/modals/board-rename-modal.tsx b/apps/nextjs/src/components/board/modals/board-rename-modal.tsx new file mode 100644 index 000000000..4bb594e35 --- /dev/null +++ b/apps/nextjs/src/components/board/modals/board-rename-modal.tsx @@ -0,0 +1,71 @@ +"use client"; + +import type { ManagedModal } from "mantine-modal-manager"; + +import { clientApi } from "@homarr/api/client"; +import { useForm } from "@homarr/form"; +import { useI18n } from "@homarr/translation/client"; +import { Button, Group, Stack, TextInput } from "@homarr/ui"; +import type { validation, z } from "@homarr/validation"; + +interface InnerProps { + id: string; + previousName: string; + onSuccess?: (name: string) => void; +} + +export const BoardRenameModal: ManagedModal = ({ + actions, + innerProps, +}) => { + const utils = clientApi.useUtils(); + const t = useI18n(); + const { mutate, isPending } = clientApi.board.rename.useMutation({ + onSettled() { + void utils.board.byName.invalidate({ name: innerProps.previousName }); + void utils.board.default.invalidate(); + }, + }); + const form = useForm({ + initialValues: { + name: innerProps.previousName, + }, + }); + + const handleSubmit = (values: FormType) => { + mutate( + { + id: innerProps.id, + name: values.name, + }, + { + onSuccess: () => { + actions.closeModal(); + innerProps.onSuccess?.(values.name); + }, + }, + ); + }; + + return ( +
+ + + + + + + +
+ ); +}; + +type FormType = Omit, "id">; diff --git a/apps/nextjs/src/components/board/sections/category/category-actions.ts b/apps/nextjs/src/components/board/sections/category/category-actions.ts index 16c1b6714..bef680493 100644 --- a/apps/nextjs/src/components/board/sections/category/category-actions.ts +++ b/apps/nextjs/src/components/board/sections/category/category-actions.ts @@ -39,8 +39,6 @@ export const useCategoryActions = () => { updateBoard((previous) => ({ ...previous, sections: [ - // Ignore sidebar sections - ...previous.sections.filter((section) => section.kind === "sidebar"), // Place sections before the new category ...previous.sections.filter( (section) => @@ -235,12 +233,7 @@ export const useCategoryActions = () => { ...previous, sections: [ ...previous.sections.filter( - (section) => section.kind === "sidebar", - ), - ...previous.sections.filter( - (section) => - (section.kind === "category" || section.kind === "empty") && - section.position < currentCategory.position - 1, + (section) => section.position < currentCategory.position - 1, ), { ...aboveWrapper, @@ -253,7 +246,6 @@ export const useCategoryActions = () => { ...previous.sections .filter( (section): section is CategorySection | EmptySection => - (section.kind === "category" || section.kind === "empty") && section.position >= currentCategory.position + 2, ) .map((section) => ({ diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx index 26d9ffb99..1f1a5aa6d 100644 --- a/apps/nextjs/src/components/board/sections/content.tsx +++ b/apps/nextjs/src/components/board/sections/content.tsx @@ -2,6 +2,7 @@ // Ignored because of gridstack attributes import type { RefObject } from "react"; +import cx from "clsx"; import { useAtomValue } from "jotai"; import { useScopedI18n } from "@homarr/translation/client"; @@ -20,11 +21,13 @@ import { useServerDataFor, } from "@homarr/widgets"; +import { useRequiredBoard } from "~/app/[locale]/boards/_context"; import type { Item } from "~/app/[locale]/boards/_types"; import { modalEvents } from "~/app/[locale]/modals"; import { editModeAtom } from "../editMode"; import { useItemActions } from "../items/item-actions"; import type { UseGridstackRefs } from "./gridstack/use-gridstack"; +import classes from "./item.module.css"; interface Props { items: Item[]; @@ -32,6 +35,8 @@ interface Props { } export const SectionContent = ({ items, refs }: Props) => { + const board = useRequiredBoard(); + return ( <> {items.map((item) => { @@ -50,7 +55,15 @@ export const SectionContent = ({ items, refs }: Props) => { gs-max-h={4} ref={refs.items.current[item.id] as RefObject} > - + diff --git a/apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts index 78cc0deb5..f31cfd5ca 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts +++ b/apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts @@ -21,24 +21,18 @@ export const initializeGridstack = ({ sectionColumnCount, }: InitializeGridstackProps) => { if (!refs.wrapper.current) return false; - // calculates the currently available count of columns - const columnCount = section.kind === "sidebar" ? 2 : sectionColumnCount; - const minRow = - section.kind !== "sidebar" - ? 1 - : Math.floor(refs.wrapper.current.offsetHeight / 128); // initialize gridstack const newGrid = refs.gridstack; newGrid.current = GridStack.init( { - column: columnCount, - margin: section.kind === "sidebar" ? 5 : 10, + column: sectionColumnCount, + margin: 10, cellHeight: 128, float: true, alwaysShowResizeHandle: true, acceptWidgets: true, staticGrid: true, - minRow, + minRow: 1, animate: false, styleInHead: true, disableRemoveNodeOnDrop: true, @@ -49,7 +43,7 @@ export const initializeGridstack = ({ const grid = newGrid.current; if (!grid) return false; // Must be used to update the column count after the initialization - grid.column(columnCount, "none"); + grid.column(sectionColumnCount, "none"); grid.batchUpdate(); grid.removeAll(false); diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts index 909b34297..cd186851c 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts +++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts @@ -48,7 +48,7 @@ export const useGridstack = ({ useCssVariableConfiguration({ section, mainRef, gridRef }); - const sectionColumnCount = useSectionColumnCount(section.kind); + const board = useRequiredBoard(); const items = useMemo(() => section.items, [section.items]); @@ -125,7 +125,7 @@ export const useGridstack = ({ wrapper: wrapperRef, gridstack: gridRef, }, - sectionColumnCount, + sectionColumnCount: board.columnCount, }); if (isReady) { @@ -134,7 +134,7 @@ export const useGridstack = ({ // Only run this effect when the section items change // eslint-disable-next-line react-hooks/exhaustive-deps - }, [items.length, section.items.length]); + }, [items.length, section.items.length, board.columnCount]); return { refs: { @@ -145,19 +145,6 @@ export const useGridstack = ({ }; }; -/** - * Get the column count for the section - * For the sidebar it's always 2 otherwise it's the column count of the board - * @param sectionKind kind of the section - * @returns count of columns - */ -const useSectionColumnCount = (sectionKind: Section["kind"]) => { - const board = useRequiredBoard(); - if (sectionKind === "sidebar") return 2; - - return board.columnCount; -}; - interface UseCssVariableConfiguration { section: Section; mainRef?: RefObject; @@ -177,7 +164,7 @@ const useCssVariableConfiguration = ({ mainRef, gridRef, }: UseCssVariableConfiguration) => { - const sectionColumnCount = useSectionColumnCount(section.kind); + const board = useRequiredBoard(); // Get reference to the :root element const typeofDocument = typeof document; @@ -188,20 +175,20 @@ const useCssVariableConfiguration = ({ // Define widget-width by calculating the width of one column with mainRef width and column count useEffect(() => { - if (section.kind === "sidebar" || !mainRef?.current) return; - const widgetWidth = mainRef.current.clientWidth / sectionColumnCount; + if (!mainRef?.current) return; + const widgetWidth = mainRef.current.clientWidth / board.columnCount; // widget width is used to define sizes of gridstack items within global.scss root?.style.setProperty("--gridstack-widget-width", widgetWidth.toString()); gridRef.current?.cellHeight(widgetWidth); // gridRef.current is required otherwise the cellheight is run on production as undefined // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sectionColumnCount, root, section.kind, mainRef, gridRef.current]); + }, [board.columnCount, root, section.kind, mainRef, gridRef.current]); // Define column count by using the sectionColumnCount useEffect(() => { root?.style.setProperty( "--gridstack-column-count", - sectionColumnCount.toString(), + board.columnCount.toString(), ); - }, [sectionColumnCount, root]); + }, [board.columnCount, root]); }; diff --git a/apps/nextjs/src/components/board/sections/item.module.css b/apps/nextjs/src/components/board/sections/item.module.css new file mode 100644 index 000000000..aacc4803e --- /dev/null +++ b/apps/nextjs/src/components/board/sections/item.module.css @@ -0,0 +1,10 @@ +.itemCard { + @mixin dark { + background-color: rgba(46, 46, 46, var(--opacity)); + border-color: rgba(66, 66, 66, var(--opacity)); + } + @mixin light { + background-color: rgba(255, 255, 255, var(--opacity)); + border-color: rgba(222, 226, 230, var(--opacity)); + } +} diff --git a/apps/nextjs/src/components/layout/background.tsx b/apps/nextjs/src/components/layout/background.tsx new file mode 100644 index 000000000..763d49324 --- /dev/null +++ b/apps/nextjs/src/components/layout/background.tsx @@ -0,0 +1,62 @@ +import { usePathname } from "next/navigation"; + +import type { AppShellProps } from "@homarr/ui"; + +import { useOptionalBoard } from "~/app/[locale]/boards/_context"; + +const supportedVideoFormats = ["mp4", "webm", "ogg"]; +const isVideo = (url: string) => + supportedVideoFormats.some((format) => + url.toLowerCase().endsWith(`.${format}`), + ); + +export const useOptionalBackgroundProps = (): Partial => { + const board = useOptionalBoard(); + const pathname = usePathname(); + + if (!board?.backgroundImageUrl) return {}; + + // Check if we are on a client board page + if (pathname.split("/").length > 3) return {}; + + if (isVideo(board.backgroundImageUrl)) { + return {}; + } + + return { + bg: `url(${board?.backgroundImageUrl})`, + bgp: "center center", + bgsz: board?.backgroundImageSize ?? "cover", + bgr: board?.backgroundImageRepeat ?? "no-repeat", + bga: board?.backgroundImageAttachment ?? "fixed", + }; +}; + +export const BoardBackgroundVideo = () => { + const board = useOptionalBoard(); + + if (!board?.backgroundImageUrl) return null; + if (!isVideo(board.backgroundImageUrl)) return null; + + const videoFormat = board.backgroundImageUrl.split(".").pop()?.toLowerCase(); + + if (!videoFormat) return null; + + return ( + + ); +}; diff --git a/apps/nextjs/src/components/layout/logo/board-logo.tsx b/apps/nextjs/src/components/layout/logo/board-logo.tsx index 13379f2fa..f174dd3ea 100644 --- a/apps/nextjs/src/components/layout/logo/board-logo.tsx +++ b/apps/nextjs/src/components/layout/logo/board-logo.tsx @@ -25,14 +25,19 @@ export const BoardLogo = ({ size }: LogoProps) => { interface CommonLogoWithTitleProps { size: LogoWithTitleProps["size"]; + hideTitleOnMobile?: boolean; } -export const BoardLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => { +export const BoardLogoWithTitle = ({ + size, + hideTitleOnMobile, +}: CommonLogoWithTitleProps) => { const board = useRequiredBoard(); const imageOptions = useImageOptions(); return ( diff --git a/apps/nextjs/src/components/layout/logo/logo.tsx b/apps/nextjs/src/components/layout/logo/logo.tsx index 6332adb74..6c1af8aac 100644 --- a/apps/nextjs/src/components/layout/logo/logo.tsx +++ b/apps/nextjs/src/components/layout/logo/logo.tsx @@ -34,15 +34,27 @@ export interface LogoWithTitleProps { size: keyof typeof logoWithTitleSizes; title: string; image: Omit; + hideTitleOnMobile?: boolean; } -export const LogoWithTitle = ({ size, title, image }: LogoWithTitleProps) => { +export const LogoWithTitle = ({ + size, + title, + image, + hideTitleOnMobile, +}: LogoWithTitleProps) => { const { logoSize, titleOrder } = logoWithTitleSizes[size]; return ( - {title} + + {title} + ); }; diff --git a/apps/nextjs/src/components/layout/shell.tsx b/apps/nextjs/src/components/layout/shell.tsx index ec9d94cc9..9e5f73f90 100644 --- a/apps/nextjs/src/components/layout/shell.tsx +++ b/apps/nextjs/src/components/layout/shell.tsx @@ -5,6 +5,7 @@ import { useAtomValue } from "jotai"; import { AppShell } from "@homarr/ui"; +import { useOptionalBackgroundProps } from "./background"; import { navigationCollapsedAtom } from "./header/burger"; interface ClientShellProps { @@ -18,9 +19,11 @@ export const ClientShell = ({ children, }: PropsWithChildren) => { const collapsed = useAtomValue(navigationCollapsedAtom); + const backgroundProps = useOptionalBackgroundProps(); return ( .grid-stack-item[gs-w="#{$i}"] { - width: 128px * $i; - } - .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-min-w="#{$i}"] { - min-width: 128px * $i; - } - .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-max-w="#{$i}"] { - max-width: 128px * $i; - } -} - -@for $i from 1 to 96 { - .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-h="#{$i}"] { - height: 128px * $i; - } - .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-min-h="#{$i}"] { - min-height: 128px * $i; - } - .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-max-h="#{$i}"] { - max-height: 128px * $i; - } -} - -@for $i from 1 to 3 { - .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-x="#{$i}"] { - left: 128px * $i; - } -} - -@for $i from 1 to 96 { - .grid-stack.grid-stack-sidebar > .grid-stack-item[gs-y="#{$i}"] { - top: 128px * $i; - } -} - -.grid-stack.grid-stack-sidebar > .grid-stack-item { - min-width: 128px; -} - // General gridstack styling .grid-stack > .grid-stack-item > .grid-stack-item-content, .grid-stack > .grid-stack-item > .placeholder-content { diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index a0eeb6f59..af5c32dce 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -79,6 +79,24 @@ export const boardRouter = createTRPCRouter({ }); }); }), + rename: publicProcedure + .input(validation.board.rename) + .mutation(async ({ ctx, input }) => { + await noBoardWithSimilarName(ctx.db, input.name, [input.id]); + + await ctx.db + .update(boards) + .set({ name: input.name }) + .where(eq(boards.id, input.id)); + }), + changeVisibility: publicProcedure + .input(validation.board.changeVisibility) + .mutation(async ({ ctx, input }) => { + await ctx.db + .update(boards) + .set({ isPublic: input.visibility === "public" }) + .where(eq(boards.id, input.id)); + }), delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { @@ -92,11 +110,11 @@ export const boardRouter = createTRPCRouter({ .query(async ({ input, ctx }) => { return await getFullBoardWithWhere(ctx.db, eq(boards.name, input.name)); }), - saveGeneralSettings: publicProcedure - .input(validation.board.saveGeneralSettings) + savePartialSettings: publicProcedure + .input(validation.board.savePartialSettings) .mutation(async ({ ctx, input }) => { const board = await ctx.db.query.boards.findFirst({ - where: eq(boards.id, input.boardId), + where: eq(boards.id, input.id), }); if (!board) { @@ -109,12 +127,30 @@ export const boardRouter = createTRPCRouter({ await ctx.db .update(boards) .set({ + // general settings pageTitle: input.pageTitle, metaTitle: input.metaTitle, logoImageUrl: input.logoImageUrl, faviconImageUrl: input.faviconImageUrl, + + // background settings + backgroundImageUrl: input.backgroundImageUrl, + backgroundImageAttachment: input.backgroundImageAttachment, + backgroundImageRepeat: input.backgroundImageRepeat, + backgroundImageSize: input.backgroundImageSize, + + // color settings + primaryColor: input.primaryColor, + secondaryColor: input.secondaryColor, + opacity: input.opacity, + + // custom css + customCss: input.customCss, + + // layout settings + columnCount: input.columnCount, }) - .where(eq(boards.id, input.boardId)); + .where(eq(boards.id, input.id)); }), save: publicProcedure .input(validation.board.save) @@ -122,7 +158,7 @@ export const boardRouter = createTRPCRouter({ await ctx.db.transaction(async (tx) => { const dbBoard = await getFullBoardWithWhere( tx, - eq(boards.id, input.boardId), + eq(boards.id, input.id), ); const addedSections = filterAddedItems( @@ -276,6 +312,32 @@ export const boardRouter = createTRPCRouter({ }), }); +const noBoardWithSimilarName = async ( + db: Database, + name: string, + ignoredIds: string[] = [], +) => { + const boards = await db.query.boards.findMany({ + columns: { + id: true, + name: true, + }, + }); + + const board = boards.find( + (board) => + board.name.toLowerCase() === name.toLowerCase() && + !ignoredIds.includes(board.id), + ); + + if (board) { + throw new TRPCError({ + code: "CONFLICT", + message: "Board with similar name already exists", + }); + } +}; + const getFullBoardWithWhere = async (db: Database, where: SQL) => { const board = await db.query.boards.findFirst({ where, diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index 8d3e5afa5..e8f0347f7 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -66,7 +66,7 @@ describe("byName should return board by name", () => { it("should throw error when not present"); }); -describe("saveGeneralSettings should save general settings", () => { +describe("savePartialSettings should save general settings", () => { it("should save general settings", async () => { const db = createDb(); const caller = boardRouter.createCaller({ db, session: null }); @@ -78,12 +78,12 @@ describe("saveGeneralSettings should save general settings", () => { const { boardId } = await createFullBoardAsync(db, "default"); - await caller.saveGeneralSettings({ + await caller.savePartialSettings({ pageTitle: newPageTitle, metaTitle: newMetaTitle, logoImageUrl: newLogoImageUrl, faviconImageUrl: newFaviconImageUrl, - boardId, + id: boardId, }); }); @@ -92,12 +92,12 @@ describe("saveGeneralSettings should save general settings", () => { const caller = boardRouter.createCaller({ db, session: null }); const act = async () => - await caller.saveGeneralSettings({ + await caller.savePartialSettings({ pageTitle: "newPageTitle", metaTitle: "newMetaTitle", logoImageUrl: "http://logo.image/url.png", faviconImageUrl: "http://favicon.image/url.png", - boardId: "nonExistentBoardId", + id: "nonExistentBoardId", }); await expect(act()).rejects.toThrowError("Board not found"); @@ -112,7 +112,7 @@ describe("save should save full board", () => { const { boardId, sectionId } = await createFullBoardAsync(db, "default"); await caller.save({ - boardId, + id: boardId, sections: [ { id: createId(), @@ -149,7 +149,7 @@ describe("save should save full board", () => { ); await caller.save({ - boardId, + id: boardId, sections: [ { id: sectionId, @@ -208,7 +208,7 @@ describe("save should save full board", () => { await db.insert(integrations).values(anotherIntegration); await caller.save({ - boardId, + id: boardId, sections: [ { id: sectionId, @@ -269,7 +269,7 @@ describe("save should save full board", () => { const newSectionId = createId(); await caller.save({ - boardId, + id: boardId, sections: [ { id: newSectionId, @@ -319,7 +319,7 @@ describe("save should save full board", () => { const newItemId = createId(); await caller.save({ - boardId, + id: boardId, sections: [ { id: sectionId, @@ -392,7 +392,7 @@ describe("save should save full board", () => { await db.insert(integrations).values(integration); await caller.save({ - boardId, + id: boardId, sections: [ { id: sectionId, @@ -459,7 +459,7 @@ describe("save should save full board", () => { }); await caller.save({ - boardId, + id: boardId, sections: [ { id: sectionId, @@ -512,7 +512,7 @@ describe("save should save full board", () => { ); await caller.save({ - boardId, + id: boardId, sections: [ { id: sectionId, @@ -569,7 +569,7 @@ describe("save should save full board", () => { const act = async () => await caller.save({ - boardId: "nonExistentBoardId", + id: "nonExistentBoardId", sections: [], }); diff --git a/packages/db/migrations/0000_true_red_wolf.sql b/packages/db/migrations/0000_sloppy_bloodstorm.sql similarity index 92% rename from packages/db/migrations/0000_true_red_wolf.sql rename to packages/db/migrations/0000_sloppy_bloodstorm.sql index 3a9c02707..78217a38a 100644 --- a/packages/db/migrations/0000_true_red_wolf.sql +++ b/packages/db/migrations/0000_sloppy_bloodstorm.sql @@ -26,13 +26,10 @@ CREATE TABLE `board` ( `background_image_attachment` text DEFAULT 'fixed' NOT NULL, `background_image_repeat` text DEFAULT 'no-repeat' NOT NULL, `background_image_size` text DEFAULT 'cover' NOT NULL, - `primary_color` text DEFAULT 'red' NOT NULL, - `secondary_color` text DEFAULT 'orange' NOT NULL, - `primary_shade` integer DEFAULT 6 NOT NULL, - `app_opacity` integer DEFAULT 100 NOT NULL, + `primary_color` text DEFAULT '#fa5252' NOT NULL, + `secondary_color` text DEFAULT '#fd7e14' NOT NULL, + `opacity` integer DEFAULT 100 NOT NULL, `custom_css` text, - `show_right_sidebar` integer DEFAULT false NOT NULL, - `show_left_sidebar` integer DEFAULT false NOT NULL, `column_count` integer DEFAULT 10 NOT NULL ); --> statement-breakpoint @@ -106,6 +103,7 @@ CREATE TABLE `verificationToken` ( ); --> statement-breakpoint CREATE INDEX `userId_idx` ON `account` (`userId`);--> statement-breakpoint +CREATE UNIQUE INDEX `board_name_unique` ON `board` (`name`);--> statement-breakpoint CREATE INDEX `integration_secret__kind_idx` ON `integrationSecret` (`kind`);--> statement-breakpoint CREATE INDEX `integration_secret__updated_at_idx` ON `integrationSecret` (`updated_at`);--> statement-breakpoint CREATE INDEX `integration__kind_idx` ON `integration` (`kind`);--> statement-breakpoint diff --git a/packages/db/migrations/meta/0000_snapshot.json b/packages/db/migrations/meta/0000_snapshot.json index 2a8c974ab..dd9337888 100644 --- a/packages/db/migrations/meta/0000_snapshot.json +++ b/packages/db/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "5", "dialect": "sqlite", - "id": "c9ea435a-5bbf-4439-84a1-55d3e2581b13", + "id": "7ba12cbf-d8eb-4469-b7c5-7f31acb7717d", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "account": { @@ -201,7 +201,7 @@ "primaryKey": false, "notNull": true, "autoincrement": false, - "default": "'red'" + "default": "'#fa5252'" }, "secondary_color": { "name": "secondary_color", @@ -209,18 +209,10 @@ "primaryKey": false, "notNull": true, "autoincrement": false, - "default": "'orange'" + "default": "'#fd7e14'" }, - "primary_shade": { - "name": "primary_shade", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 6 - }, - "app_opacity": { - "name": "app_opacity", + "opacity": { + "name": "opacity", "type": "integer", "primaryKey": false, "notNull": true, @@ -234,22 +226,6 @@ "notNull": false, "autoincrement": false }, - "show_right_sidebar": { - "name": "show_right_sidebar", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "show_left_sidebar": { - "name": "show_left_sidebar", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, "column_count": { "name": "column_count", "type": "integer", @@ -259,7 +235,13 @@ "default": 10 } }, - "indexes": {}, + "indexes": { + "board_name_unique": { + "name": "board_name_unique", + "columns": ["name"], + "isUnique": true + } + }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 843dad674..f74d224ab 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "5", - "when": 1707511343363, - "tag": "0000_true_red_wolf", + "when": 1709409142712, + "tag": "0000_sloppy_bloodstorm", "breakpoints": true } ] diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index a154cf4f7..3655b38b6 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -1,5 +1,4 @@ import type { AdapterAccount } from "@auth/core/adapters"; -import type { MantineColor } from "@mantine/core"; import type { InferSelectModel } from "drizzle-orm"; import { relations } from "drizzle-orm"; import { @@ -11,6 +10,11 @@ import { text, } from "drizzle-orm/sqlite-core"; +import { + backgroundImageAttachments, + backgroundImageRepeats, + backgroundImageSizes, +} from "@homarr/definitions"; import type { BackgroundImageAttachment, BackgroundImageRepeat, @@ -125,37 +129,20 @@ export const boards = sqliteTable("board", { backgroundImageUrl: text("background_image_url"), backgroundImageAttachment: text("background_image_attachment") .$type() - .default("fixed") + .default(backgroundImageAttachments.defaultValue) .notNull(), backgroundImageRepeat: text("background_image_repeat") .$type() - .default("no-repeat") + .default(backgroundImageRepeats.defaultValue) .notNull(), backgroundImageSize: text("background_image_size") .$type() - .default("cover") + .default(backgroundImageSizes.defaultValue) .notNull(), - primaryColor: text("primary_color") - .$type() - .default("red") - .notNull(), - secondaryColor: text("secondary_color") - .$type() - .default("orange") - .notNull(), - primaryShade: int("primary_shade").default(6).notNull(), - appOpacity: int("app_opacity").default(100).notNull(), + primaryColor: text("primary_color").default("#fa5252").notNull(), + secondaryColor: text("secondary_color").default("#fd7e14").notNull(), + opacity: int("opacity").default(100).notNull(), customCss: text("custom_css"), - showRightSidebar: int("show_right_sidebar", { - mode: "boolean", - }) - .default(false) - .notNull(), - showLeftSidebar: int("show_left_sidebar", { - mode: "boolean", - }) - .default(false) - .notNull(), columnCount: int("column_count").default(10).notNull(), }); diff --git a/packages/definitions/src/_definition.ts b/packages/definitions/src/_definition.ts new file mode 100644 index 000000000..41c63f4c8 --- /dev/null +++ b/packages/definitions/src/_definition.ts @@ -0,0 +1,20 @@ +export const createDefinition = < + const TKeys extends string[], + TOptions extends { defaultValue: TKeys[number] } | void, +>( + values: TKeys, + options: TOptions, +) => ({ + values, + defaultValue: options?.defaultValue as TOptions extends { + defaultValue: infer T; + } + ? T + : undefined, +}); + +export type inferDefinitionType = TDefinition extends { + values: readonly (infer T)[]; +} + ? T + : never; diff --git a/packages/definitions/src/board.ts b/packages/definitions/src/board.ts index e66e6ba7e..e27490a59 100644 --- a/packages/definitions/src/board.ts +++ b/packages/definitions/src/board.ts @@ -1,13 +1,24 @@ -export const backgroundImageAttachments = ["fixed", "scroll"] as const; -export const backgroundImageRepeats = [ - "repeat", - "repeat-x", - "repeat-y", - "no-repeat", -] as const; -export const backgroundImageSizes = ["cover", "contain"] as const; +import type { inferDefinitionType } from "./_definition"; +import { createDefinition } from "./_definition"; -export type BackgroundImageAttachment = - (typeof backgroundImageAttachments)[number]; -export type BackgroundImageRepeat = (typeof backgroundImageRepeats)[number]; -export type BackgroundImageSize = (typeof backgroundImageSizes)[number]; +export const backgroundImageAttachments = createDefinition( + ["fixed", "scroll"], + { defaultValue: "fixed" }, +); +export const backgroundImageRepeats = createDefinition( + ["repeat", "repeat-x", "repeat-y", "no-repeat"], + { defaultValue: "no-repeat" }, +); +export const backgroundImageSizes = createDefinition(["cover", "contain"], { + defaultValue: "cover", +}); + +export type BackgroundImageAttachment = inferDefinitionType< + typeof backgroundImageAttachments +>; +export type BackgroundImageRepeat = inferDefinitionType< + typeof backgroundImageRepeats +>; +export type BackgroundImageSize = inferDefinitionType< + typeof backgroundImageSizes +>; diff --git a/packages/definitions/src/section.ts b/packages/definitions/src/section.ts index 0276fe021..847581914 100644 --- a/packages/definitions/src/section.ts +++ b/packages/definitions/src/section.ts @@ -1,2 +1,2 @@ -export const sectionKinds = ["category", "empty", "sidebar"] as const; +export const sectionKinds = ["category", "empty"] as const; export type SectionKind = (typeof sectionKinds)[number]; diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index e9ced59ff..99b6f9b8f 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -153,6 +153,12 @@ export default { multiSelect: { placeholder: "Pick one or more values", }, + select: { + placeholder: "Pick value", + badge: { + recommended: "Recommended", + }, + }, search: { placeholder: "Search for anything...", nothingFound: "Nothing found", @@ -172,6 +178,10 @@ export default { }, }, noResults: "No results found", + preview: { + show: "Show preview", + hide: "Hide preview", + }, }, section: { category: { @@ -299,18 +309,98 @@ export default { faviconImageUrl: { label: "Favicon image URL", }, + backgroundImageUrl: { + label: "Background image URL", + }, + backgroundImageAttachment: { + label: "Background image attachment", + option: { + fixed: { + label: "Fixed", + description: "Background stays in the same position.", + }, + scroll: { + label: "Scroll", + description: "Background scrolls with your mouse.", + }, + }, + }, + backgroundImageRepeat: { + label: "Background image repeat", + option: { + repeat: { + label: "Repeat", + description: + "The image is repeated as much as needed to cover the whole background image painting area.", + }, + "no-repeat": { + label: "No repeat", + description: + "The image is not repeated and may not fill the entire space.", + }, + "repeat-x": { + label: "Repeat X", + description: "Same as 'Repeat' but only on horizontal axis.", + }, + "repeat-y": { + label: "Repeat Y", + description: "Same as 'Repeat' but only on vertical axis.", + }, + }, + }, + backgroundImageSize: { + label: "Background image size", + option: { + cover: { + label: "Cover", + description: + "Scales the image as small as possible to cover the entire window by cropping excessive space.", + }, + contain: { + label: "Contain", + description: + "Scales the image as large as possible within its container without cropping or stretching the image.", + }, + }, + }, + primaryColor: { + label: "Primary color", + }, + secondaryColor: { + label: "Secondary color", + }, + opacity: { + label: "Opacity", + }, + customCss: { + label: "Custom CSS", + }, + columnCount: { + label: "Column count", + }, + name: { + label: "Name", + }, }, setting: { title: "Settings for {boardName} board", section: { general: { title: "General", + unrecognizedLink: + "The provided link is not recognized and won't preview, it might still work.", }, layout: { title: "Layout", }, - appearance: { - title: "Appearance", + background: { + title: "Background", + }, + color: { + title: "Colors", + }, + customCss: { + title: "Custom css", }, dangerZone: { title: "Danger Zone", @@ -320,6 +410,9 @@ export default { description: "Changing the name will break any links to this board.", button: "Change name", + modal: { + title: "Rename board", + }, }, visibility: { label: "Change board visibility", @@ -331,12 +424,29 @@ export default { public: "Make private", private: "Make public", }, + confirm: { + public: { + title: "Make board private", + description: + "Are you sure you want to make this board private? This will hide the board from the public. Links for guest users will break.", + }, + private: { + title: "Make board public", + description: + "Are you sure you want to make this board public? This will make the board accessible to everyone.", + }, + }, }, delete: { label: "Delete this board", description: "Once you delete a board, there is no going back. Please be certain.", button: "Delete this board", + confirm: { + title: "Delete board", + description: + "Are you sure you want to delete this board? This will permanently delete the board and all its content.", + }, }, }, }, diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index 1cefa3819..e2a475377 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -1 +1,3 @@ export * from "./count-badge"; +export * from "./select-with-description"; +export * from "./select-with-description-and-badge"; diff --git a/packages/ui/src/components/select-with-custom-items.tsx b/packages/ui/src/components/select-with-custom-items.tsx new file mode 100644 index 000000000..f8185e0f5 --- /dev/null +++ b/packages/ui/src/components/select-with-custom-items.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import type { SelectProps } from "@mantine/core"; +import { Combobox, Input, InputBase, useCombobox } from "@mantine/core"; +import { useUncontrolled } from "@mantine/hooks"; + +interface BaseSelectItem { + value: string; + label: string; +} + +export interface SelectWithCustomItemsProps + extends Pick< + SelectProps, + "label" | "error" | "defaultValue" | "value" | "onChange" | "placeholder" + > { + data: TSelectItem[]; + onBlur?: (event: React.FocusEvent) => void; + onFocus?: (event: React.FocusEvent) => void; +} + +type Props = + SelectWithCustomItemsProps & { + SelectOption: React.ComponentType; + }; + +export const SelectWithCustomItems = ({ + data, + onChange, + value, + defaultValue, + placeholder, + SelectOption, + ...props +}: Props) => { + const combobox = useCombobox({ + onDropdownClose: () => combobox.resetSelectedOption(), + }); + + const [_value, setValue] = useUncontrolled({ + value, + defaultValue, + finalValue: null, + onChange, + }); + + const selectedOption = useMemo( + () => data.find((item) => item.value === _value), + [data, _value], + ); + + const options = data.map((item) => ( + + + + )); + + const toggle = useCallback(() => combobox.toggleDropdown(), [combobox]); + const onOptionSubmit = useCallback( + (value: string) => { + setValue( + value, + data.find((item) => item.value === value), + ); + combobox.closeDropdown(); + }, + [setValue, data, combobox], + ); + + return ( + + + } + onClick={toggle} + rightSectionPointerEvents="none" + multiline + > + {selectedOption ? ( + + ) : ( + {placeholder} + )} + + + + + {options} + + + ); +}; diff --git a/packages/ui/src/components/select-with-description-and-badge.tsx b/packages/ui/src/components/select-with-description-and-badge.tsx new file mode 100644 index 000000000..0293c1e83 --- /dev/null +++ b/packages/ui/src/components/select-with-description-and-badge.tsx @@ -0,0 +1,49 @@ +"use client"; + +import type { MantineColor } from "@mantine/core"; +import { Badge, Group, Text } from "@mantine/core"; + +import type { SelectWithCustomItemsProps } from "./select-with-custom-items"; +import { SelectWithCustomItems } from "./select-with-custom-items"; + +export interface SelectItemWithDescriptionBadge { + value: string; + label: string; + badge?: { label: string; color: MantineColor }; + description: string; +} +type Props = SelectWithCustomItemsProps; + +export const SelectWithDescriptionBadge = (props: Props) => { + return ( + + {...props} + SelectOption={SelectOption} + /> + ); +}; + +const SelectOption = ({ + label, + description, + badge, +}: SelectItemWithDescriptionBadge) => { + return ( + +
+ + {label} + + + {description} + +
+ + {badge && ( + + {badge.label} + + )} +
+ ); +}; diff --git a/packages/ui/src/components/select-with-description.tsx b/packages/ui/src/components/select-with-description.tsx new file mode 100644 index 000000000..c301effcc --- /dev/null +++ b/packages/ui/src/components/select-with-description.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Text } from "@mantine/core"; + +import type { SelectWithCustomItemsProps } from "./select-with-custom-items"; +import { SelectWithCustomItems } from "./select-with-custom-items"; + +export interface SelectItemWithDescription { + value: string; + label: string; + description: string; +} +type Props = SelectWithCustomItemsProps; + +export const SelectWithDescription = (props: Props) => { + return ( + + {...props} + SelectOption={SelectOption} + /> + ); +}; + +const SelectOption = ({ label, description }: SelectItemWithDescription) => { + return ( +
+ + {label} + + + {description} + +
+ ); +}; diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index 96d0e4f9e..36faf2af7 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -1,7 +1,15 @@ import { z } from "zod"; +import { + backgroundImageAttachments, + backgroundImageRepeats, + backgroundImageSizes, +} from "@homarr/definitions"; + import { commonItemSchema, createSectionSchema } from "./shared"; +const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/); + const boardNameSchema = z .string() .min(1) @@ -12,36 +20,56 @@ const byNameSchema = z.object({ name: boardNameSchema, }); -const saveGeneralSettingsSchema = z.object({ - pageTitle: z - .string() - .nullable() - .transform((value) => (value?.trim().length === 0 ? null : value)), - metaTitle: z - .string() - .nullable() - .transform((value) => (value?.trim().length === 0 ? null : value)), - logoImageUrl: z - .string() - .nullable() - .transform((value) => (value?.trim().length === 0 ? null : value)), - faviconImageUrl: z - .string() - .nullable() - .transform((value) => (value?.trim().length === 0 ? null : value)), - boardId: z.string(), +const renameSchema = z.object({ + id: z.string(), + name: boardNameSchema, }); +const changeVisibilitySchema = z.object({ + id: z.string(), + visibility: z.enum(["public", "private"]), +}); + +const trimmedNullableString = z + .string() + .nullable() + .transform((value) => (value?.trim().length === 0 ? null : value)); + +const savePartialSettingsSchema = z + .object({ + pageTitle: trimmedNullableString, + metaTitle: trimmedNullableString, + logoImageUrl: trimmedNullableString, + faviconImageUrl: trimmedNullableString, + backgroundImageUrl: trimmedNullableString, + backgroundImageAttachment: z.enum(backgroundImageAttachments.values), + backgroundImageRepeat: z.enum(backgroundImageRepeats.values), + backgroundImageSize: z.enum(backgroundImageSizes.values), + primaryColor: hexColorSchema, + secondaryColor: hexColorSchema, + opacity: z.number().min(0).max(100), + customCss: z.string().max(16384), + columnCount: z.number().min(1).max(24), + }) + .partial() + .and( + z.object({ + id: z.string(), + }), + ); + const saveSchema = z.object({ - boardId: z.string(), + id: z.string(), sections: z.array(createSectionSchema(commonItemSchema)), }); -const createSchema = z.object({ name: z.string() }); +const createSchema = z.object({ name: boardNameSchema }); export const boardSchemas = { byName: byNameSchema, - saveGeneralSettings: saveGeneralSettingsSchema, + savePartialSettings: savePartialSettingsSchema, save: saveSchema, create: createSchema, + rename: renameSchema, + changeVisibility: changeVisibilitySchema, }; diff --git a/packages/validation/src/shared.ts b/packages/validation/src/shared.ts index 662ae55f4..7bae24c45 100644 --- a/packages/validation/src/shared.ts +++ b/packages/validation/src/shared.ts @@ -48,21 +48,6 @@ const createEmptySchema = ( items: z.array(itemSchema), }); -const createSidebarSchema = ( - itemSchema: TItemSchema, -) => - z.object({ - id: z.string(), - kind: z.literal("sidebar"), - position: z.union([z.literal(0), z.literal(1)]), - items: z.array(itemSchema), - }); - export const createSectionSchema = ( itemSchema: TItemSchema, -) => - z.union([ - createCategorySchema(itemSchema), - createEmptySchema(itemSchema), - createSidebarSchema(itemSchema), - ]); +) => z.union([createCategorySchema(itemSchema), createEmptySchema(itemSchema)]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b98b3c556..705ccda3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: '@homarr/widgets': specifier: workspace:^0.1.0 version: link:../../packages/widgets + '@mantine/colors-generator': + specifier: ^7.5.3 + version: 7.5.3(chroma-js@2.4.2) '@mantine/hooks': specifier: ^7.5.3 version: 7.5.3(react@18.2.0) @@ -134,6 +137,9 @@ importers: '@trpc/server': specifier: next version: 11.0.0-next-beta.289 + chroma-js: + specifier: ^2.4.2 + version: 2.4.2 dayjs: specifier: ^1.11.10 version: 1.11.10 @@ -174,6 +180,9 @@ importers: '@homarr/tsconfig': specifier: workspace:^0.1.0 version: link:../../tooling/typescript + '@types/chroma-js': + specifier: 2.4.4 + version: 2.4.4 '@types/node': specifier: ^20.11.24 version: 20.11.24 @@ -1550,6 +1559,14 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true + /@mantine/colors-generator@7.5.3(chroma-js@2.4.2): + resolution: {integrity: sha512-jWG9G53jq2htcNgR7b0KS3bL5yygJnhOQH6b/qcUw61I8cShwBg6xzNNnp4RHMmlRbzVRKCWXqttPwtmksMzSw==} + peerDependencies: + chroma-js: ^2.4.2 + dependencies: + chroma-js: 2.4.2 + dev: false + /@mantine/core@7.5.3(@mantine/hooks@7.5.3)(@types/react@18.2.61)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-Wvv6DJXI+GX9mmKG5HITTh/24sCZ0RoYQHdTHh0tOfGnEy+RleyhA82UjnMsp0n2NjfCISBwbiKgfya6b2iaFw==} peerDependencies: @@ -2537,6 +2554,10 @@ packages: '@types/node': 20.11.24 dev: true + /@types/chroma-js@2.4.4: + resolution: {integrity: sha512-/DTccpHTaKomqussrn+ciEvfW4k6NAHzNzs/sts1TCqg333qNxOhy8TNIoQCmbGG3Tl8KdEhkGAssb1n3mTXiQ==} + dev: true + /@types/connect@3.4.38: resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} dependencies: @@ -3448,6 +3469,10 @@ packages: engines: {node: '>=10'} dev: false + /chroma-js@2.4.2: + resolution: {integrity: sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==} + dev: false + /clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'}