From 9d520874f4feb71587d365a82a75e2de93720b4e Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 3 Feb 2024 22:26:12 +0100 Subject: [PATCH] feat: add board (#15) * wip: Add gridstack board * wip: Centralize board pages, Add board settings page * fix: remove cyclic dependency and rename widget-sort to kind * improve: Add header actions as parallel route * feat: add item select modal, add category edit modal, * feat: add edit item modal * feat: add remove item modal * wip: add category actions * feat: add saving of board, wip: add app widget * Merge branch 'main' into add-board * chore: update turbo dependencies * chore: update mantine dependencies * chore: fix typescript errors, lint and format * feat: add confirm modal to category removal, move items of removed category to above wrapper * feat: remove app widget to continue in another branch * feat: add loading spinner until board is initialized * fix: issue with cellheight of gridstack items * feat: add translations for board * fix: issue with translation for settings page * chore: address pull request feedback --- apps/nextjs/package.json | 10 +- .../integrations/_integration-buttons.tsx | 4 +- .../_integration-test-connection.tsx | 5 +- .../edit/[id]/_integration-edit-form.tsx | 4 +- .../new/_integration-new-form.tsx | 4 +- .../app/[locale]/_client-providers/trpc.tsx | 9 +- .../src/app/[locale]/auth/login/page.tsx | 4 +- .../boards/(default)/@headeractions/page.tsx | 3 + .../[locale]/boards/(default)/_definition.ts | 8 + .../app/[locale]/boards/(default)/layout.tsx | 5 + .../app/[locale]/boards/(default)/page.tsx | 7 + .../boards/[name]/@headeractions/page.tsx | 141 +++++++++ .../[name]/@headeractions/settings/page.tsx | 16 + .../[locale]/boards/[name]/_definition.tsx | 8 + .../src/app/[locale]/boards/[name]/layout.tsx | 5 + .../src/app/[locale]/boards/[name]/page.tsx | 7 + .../boards/[name]/settings/_general.tsx | 115 +++++++ .../[locale]/boards/[name]/settings/page.tsx | 133 ++++++++ .../src/app/[locale]/boards/_client.tsx | 79 +++++ .../src/app/[locale]/boards/_context.tsx | 80 +++++ .../src/app/[locale]/boards/_creator.tsx | 66 ++++ apps/nextjs/src/app/[locale]/boards/_types.ts | 15 + .../[locale]/init/user/_init-user-form.tsx | 6 +- .../src/app/[locale]/init/user/page.tsx | 4 +- apps/nextjs/src/app/[locale]/modals.tsx | 5 + .../widgets/{[sort] => [kind]}/_content.tsx | 21 +- .../widgets/{[sort] => [kind]}/layout.tsx | 2 +- .../widgets/{[sort] => [kind]}/page.tsx | 13 +- apps/nextjs/src/components/board/editMode.ts | 3 + .../components/board/items/item-actions.tsx | 201 ++++++++++++ .../board/items/item-select-modal.tsx | 84 +++++ .../board/sections/category-section.tsx | 58 ++++ .../sections/category/category-actions.ts | 284 +++++++++++++++++ .../sections/category/category-edit-modal.tsx | 56 ++++ .../category/category-menu-actions.tsx | 107 +++++++ .../board/sections/category/category-menu.tsx | 128 ++++++++ .../src/components/board/sections/content.tsx | 153 +++++++++ .../board/sections/empty-section.tsx | 35 +++ .../sections/gridstack/init-gridstack.ts | 61 ++++ .../board/sections/gridstack/use-gridstack.ts | 209 +++++++++++++ apps/nextjs/src/components/layout/header.tsx | 24 +- .../src/components/layout/header/button.tsx | 47 +++ .../src/components/layout/header/search.tsx | 12 +- apps/nextjs/src/components/layout/logo.tsx | 33 -- .../src/components/layout/logo/board-logo.tsx | 40 +++ .../components/layout/logo/homarr-logo.tsx | 29 ++ .../src/components/layout/logo/logo.tsx | 48 +++ apps/nextjs/src/styles/gridstack.scss | 124 ++++++++ apps/nextjs/src/trpc/react.ts | 7 - package.json | 5 +- packages/api/index.ts | 1 - packages/api/package.json | 4 + packages/api/src/client.ts | 5 + packages/api/src/root.ts | 2 + packages/api/src/router/board.ts | 290 ++++++++++++++++++ packages/db/client.ts | 1 + packages/db/index.ts | 3 + packages/db/package.json | 5 + packages/db/schema/sqlite.ts | 127 ++++++++ packages/definitions/src/board.ts | 13 + packages/definitions/src/index.ts | 3 + packages/definitions/src/section.ts | 2 + packages/definitions/src/widget.ts | 2 + packages/form/package.json | 2 +- packages/notifications/package.json | 2 +- packages/spotlight/package.json | 2 +- packages/translation/src/client.ts | 9 +- packages/translation/src/lang/en.ts | 148 ++++++++- packages/translation/src/server.ts | 9 +- packages/ui/package.json | 4 +- packages/validation/src/board.ts | 43 +++ packages/validation/src/enums.ts | 11 +- packages/validation/src/index.ts | 4 + packages/validation/src/shared.ts | 68 ++++ packages/widgets/package.json | 1 + packages/widgets/src/_inputs/common.tsx | 12 +- .../src/_inputs/widget-multiselect-input.tsx | 4 +- .../src/_inputs/widget-number-input.tsx | 4 +- .../src/_inputs/widget-select-input.tsx | 4 +- .../src/_inputs/widget-slider-input.tsx | 4 +- .../src/_inputs/widget-switch-input.tsx | 4 +- .../widgets/src/_inputs/widget-text-input.tsx | 4 +- packages/widgets/src/definition.ts | 28 +- packages/widgets/src/import.ts | 4 +- packages/widgets/src/index.tsx | 23 +- .../widgets/src/modals/widget-edit-modal.tsx | 38 +-- packages/widgets/src/options.ts | 12 +- pnpm-lock.yaml | 264 ++++++++++------ 88 files changed, 3431 insertions(+), 262 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/boards/(default)/@headeractions/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/(default)/_definition.ts create mode 100644 apps/nextjs/src/app/[locale]/boards/(default)/layout.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/(default)/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/settings/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/_definition.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/_client.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/_context.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/_creator.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/_types.ts rename apps/nextjs/src/app/[locale]/widgets/{[sort] => [kind]}/_content.tsx (81%) rename apps/nextjs/src/app/[locale]/widgets/{[sort] => [kind]}/layout.tsx (94%) rename apps/nextjs/src/app/[locale]/widgets/{[sort] => [kind]}/page.tsx (62%) create mode 100644 apps/nextjs/src/components/board/editMode.ts create mode 100644 apps/nextjs/src/components/board/items/item-actions.tsx create mode 100644 apps/nextjs/src/components/board/items/item-select-modal.tsx create mode 100644 apps/nextjs/src/components/board/sections/category-section.tsx create mode 100644 apps/nextjs/src/components/board/sections/category/category-actions.ts create mode 100644 apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx create mode 100644 apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx create mode 100644 apps/nextjs/src/components/board/sections/category/category-menu.tsx create mode 100644 apps/nextjs/src/components/board/sections/content.tsx create mode 100644 apps/nextjs/src/components/board/sections/empty-section.tsx create mode 100644 apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts create mode 100644 apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts create mode 100644 apps/nextjs/src/components/layout/header/button.tsx delete mode 100644 apps/nextjs/src/components/layout/logo.tsx create mode 100644 apps/nextjs/src/components/layout/logo/board-logo.tsx create mode 100644 apps/nextjs/src/components/layout/logo/homarr-logo.tsx create mode 100644 apps/nextjs/src/components/layout/logo/logo.tsx create mode 100644 apps/nextjs/src/styles/gridstack.scss delete mode 100644 apps/nextjs/src/trpc/react.ts create mode 100644 packages/api/src/client.ts create mode 100644 packages/api/src/router/board.ts create mode 100644 packages/db/client.ts create mode 100644 packages/definitions/src/board.ts create mode 100644 packages/definitions/src/section.ts create mode 100644 packages/definitions/src/widget.ts create mode 100644 packages/validation/src/board.ts create mode 100644 packages/validation/src/shared.ts diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index c703a4458..51ad71e40 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -25,9 +25,9 @@ "@homarr/ui": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0", - "@mantine/hooks": "^7.4.0", - "@mantine/modals": "^7.4.0", - "@mantine/tiptap": "^7.4.0", + "@mantine/hooks": "^7.5.1", + "@mantine/modals": "^7.5.1", + "@mantine/tiptap": "^7.5.1", "@t3-oss/env-nextjs": "^0.7.1", "@tanstack/react-query": "^5.17.1", "@tanstack/react-query-devtools": "^5.17.1", @@ -40,12 +40,14 @@ "@trpc/react-query": "next", "@trpc/server": "next", "dayjs": "^1.11.10", + "fily-publish-gridstack": "^0.0.13", "jotai": "^2.6.1", - "mantine-modal-manager": "^7.4.0", + "mantine-modal-manager": "^7.5.1", "next": "^14.0.4", "postcss-preset-mantine": "^1.12.3", "react": "18.2.0", "react-dom": "18.2.0", + "sass": "^1.70.0", "superjson": "2.2.1" }, "devDependencies": { diff --git a/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-buttons.tsx b/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-buttons.tsx index 1606cc891..a0e45642f 100644 --- a/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-buttons.tsx +++ b/apps/nextjs/src/app/[locale]/(main)/integrations/_integration-buttons.tsx @@ -2,6 +2,7 @@ import { useRouter } from "next/navigation"; +import { clientApi } from "@homarr/api/client"; import { showErrorNotification, showSuccessNotification, @@ -9,7 +10,6 @@ import { import { useScopedI18n } from "@homarr/translation/client"; import { ActionIcon, IconTrash } from "@homarr/ui"; -import { api } from "~/trpc/react"; import { revalidatePathAction } from "../../../revalidatePathAction"; import { modalEvents } from "../../modals"; @@ -24,7 +24,7 @@ export const DeleteIntegrationActionButton = ({ }: DeleteIntegrationActionButtonProps) => { const t = useScopedI18n("integration.page.delete"); const router = useRouter(); - const { mutateAsync, isPending } = api.integration.delete.useMutation(); + const { mutateAsync, isPending } = clientApi.integration.delete.useMutation(); return ( { const t = useScopedI18n("integration.testConnection"); const { mutateAsync, ...mutation } = - api.integration.testConnection.useMutation(); + clientApi.integration.testConnection.useMutation(); return ( diff --git a/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/_integration-edit-form.tsx b/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/_integration-edit-form.tsx index 2de518af5..1f55d6635 100644 --- a/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/_integration-edit-form.tsx +++ b/apps/nextjs/src/app/[locale]/(main)/integrations/edit/[id]/_integration-edit-form.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; import { getSecretKinds } from "@homarr/definitions"; import { useForm, zodResolver } from "@homarr/form"; import { @@ -16,7 +17,6 @@ import type { z } from "@homarr/validation"; import { validation } from "@homarr/validation"; import { modalEvents } from "~/app/[locale]/modals"; -import { api } from "~/trpc/react"; import { SecretCard } from "../../_integration-secret-card"; import { IntegrationSecretInput } from "../../_integration-secret-inputs"; import { @@ -55,7 +55,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { ), onValuesChange, }); - const { mutateAsync, isPending } = api.integration.update.useMutation(); + const { mutateAsync, isPending } = clientApi.integration.update.useMutation(); const secretsMap = new Map( integration.secrets.map((secret) => [secret.kind, secret]), diff --git a/apps/nextjs/src/app/[locale]/(main)/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/(main)/integrations/new/_integration-new-form.tsx index 706cc3df1..c5fad467c 100644 --- a/apps/nextjs/src/app/[locale]/(main)/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/(main)/integrations/new/_integration-new-form.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; +import { clientApi } from "@homarr/api/client"; import type { IntegrationKind } from "@homarr/definitions"; import { getSecretKinds } from "@homarr/definitions"; import { useForm, zodResolver } from "@homarr/form"; @@ -15,7 +16,6 @@ import { Button, Fieldset, Group, Stack, TextInput } from "@homarr/ui"; import type { z } from "@homarr/validation"; import { validation } from "@homarr/validation"; -import { api } from "~/trpc/react"; import { IntegrationSecretInput } from "../_integration-secret-inputs"; import { TestConnection, @@ -53,7 +53,7 @@ export const NewIntegrationForm = ({ validate: zodResolver(validation.integration.create.omit({ kind: true })), onValuesChange, }); - const { mutateAsync, isPending } = api.integration.create.useMutation(); + const { mutateAsync, isPending } = clientApi.integration.create.useMutation(); const handleSubmit = async (values: FormType) => { if (isDirty) return; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 731c027ae..187c5c9f1 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -7,8 +7,9 @@ import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experime import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client"; import superjson from "superjson"; +import { clientApi } from "@homarr/api/client"; + import { env } from "~/env.mjs"; -import { api } from "~/trpc/react"; const getBaseUrl = () => { if (typeof window !== "undefined") return ""; // browser should use relative url @@ -33,7 +34,7 @@ export function TRPCReactProvider(props: { ); const [trpcClient] = useState(() => - api.createClient({ + clientApi.createClient({ transformer: superjson, links: [ loggerLink({ @@ -54,13 +55,13 @@ export function TRPCReactProvider(props: { ); return ( - + {props.children} - + ); } diff --git a/apps/nextjs/src/app/[locale]/auth/login/page.tsx b/apps/nextjs/src/app/[locale]/auth/login/page.tsx index e9d8ec451..4ad6bd3f8 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/page.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/page.tsx @@ -1,7 +1,7 @@ import { getScopedI18n } from "@homarr/translation/server"; import { Card, Center, Stack, Text, Title } from "@homarr/ui"; -import { LogoWithTitle } from "~/components/layout/logo"; +import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; import { LoginForm } from "./_login-form"; export default async function Login() { @@ -10,7 +10,7 @@ export default async function Login() { return (
- + {t("title")} diff --git a/apps/nextjs/src/app/[locale]/boards/(default)/@headeractions/page.tsx b/apps/nextjs/src/app/[locale]/boards/(default)/@headeractions/page.tsx new file mode 100644 index 000000000..faec58434 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(default)/@headeractions/page.tsx @@ -0,0 +1,3 @@ +import headerActions from "../../[name]/@headeractions/page"; + +export default headerActions; diff --git a/apps/nextjs/src/app/[locale]/boards/(default)/_definition.ts b/apps/nextjs/src/app/[locale]/boards/(default)/_definition.ts new file mode 100644 index 000000000..cf95e7c7c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(default)/_definition.ts @@ -0,0 +1,8 @@ +import { api } from "~/trpc/server"; +import { createBoardPage } from "../_creator"; + +export default createBoardPage<{ locale: string }>({ + async getInitialBoard() { + return await api.board.default.query(); + }, +}); diff --git a/apps/nextjs/src/app/[locale]/boards/(default)/layout.tsx b/apps/nextjs/src/app/[locale]/boards/(default)/layout.tsx new file mode 100644 index 000000000..7a2eb4b2c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(default)/layout.tsx @@ -0,0 +1,5 @@ +import definition from "./_definition"; + +const { layout } = definition; + +export default layout; diff --git a/apps/nextjs/src/app/[locale]/boards/(default)/page.tsx b/apps/nextjs/src/app/[locale]/boards/(default)/page.tsx new file mode 100644 index 000000000..1ff09b28b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(default)/page.tsx @@ -0,0 +1,7 @@ +import definition from "./_definition"; + +const { generateMetadata, page } = definition; + +export default page; + +export { generateMetadata }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx new file mode 100644 index 000000000..4388f9c7a --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/page.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useAtom, useAtomValue } from "jotai"; + +import { clientApi } from "@homarr/api/client"; +import { + showErrorNotification, + showSuccessNotification, +} from "@homarr/notifications"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { + Group, + IconBox, + IconBoxAlignTop, + IconChevronDown, + IconPackageImport, + IconPencil, + IconPencilOff, + IconPlus, + IconSettings, + Menu, +} from "@homarr/ui"; + +import { modalEvents } from "~/app/[locale]/modals"; +import { editModeAtom } from "~/components/board/editMode"; +import { useCategoryActions } from "~/components/board/sections/category/category-actions"; +import { HeaderButton } from "~/components/layout/header/button"; +import { useRequiredBoard } from "../../_context"; + +export default function BoardViewHeaderActions() { + const isEditMode = useAtomValue(editModeAtom); + const board = useRequiredBoard(); + + return ( + <> + {isEditMode && <AddMenu />} + + <EditModeMenu /> + + <HeaderButton href={`/boards/${board.name}/settings`}> + <IconSettings stroke={1.5} /> + </HeaderButton> + </> + ); +} + +const AddMenu = () => { + const { addCategoryToEnd } = useCategoryActions(); + const t = useI18n(); + + return ( + <Menu position="bottom-end" withArrow> + <Menu.Target> + <HeaderButton w="auto" px={4}> + <Group gap={4} wrap="nowrap"> + <IconPlus stroke={1.5} /> + <IconChevronDown color="gray" size={16} /> + </Group> + </HeaderButton> + </Menu.Target> + <Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}> + <Menu.Item + leftSection={<IconBox size={20} />} + onClick={() => + modalEvents.openManagedModal({ + title: t("item.create.title"), + size: "xl", + modal: "itemSelectModal", + innerProps: {}, + }) + } + > + {t("item.action.create")} + </Menu.Item> + <Menu.Item leftSection={<IconPackageImport size={20} />}> + {t("item.action.import")} + </Menu.Item> + + <Menu.Divider /> + + <Menu.Item + leftSection={<IconBoxAlignTop size={20} />} + onClick={() => + modalEvents.openManagedModal({ + title: t("section.category.create.title"), + modal: "categoryEditModal", + innerProps: { + submitLabel: t("section.category.create.submit"), + category: { + id: "new", + name: "", + }, + onSuccess({ name }) { + addCategoryToEnd({ name }); + }, + }, + }) + } + > + {t("section.category.action.create")} + </Menu.Item> + </Menu.Dropdown> + </Menu> + ); +}; + +const EditModeMenu = () => { + const [isEditMode, setEditMode] = useAtom(editModeAtom); + const board = useRequiredBoard(); + const t = useScopedI18n("board.action.edit"); + const { mutate, isPending } = clientApi.board.save.useMutation({ + onSuccess() { + showSuccessNotification({ + title: t("notification.success.title"), + message: t("notification.success.message"), + }); + setEditMode(false); + }, + onError() { + showErrorNotification({ + title: t("notification.error.title"), + message: t("notification.error.message"), + }); + }, + }); + + const toggle = () => { + if (isEditMode) return mutate(board); + setEditMode(true); + }; + + return ( + <HeaderButton onClick={toggle} loading={isPending}> + {isEditMode ? ( + <IconPencilOff stroke={1.5} /> + ) : ( + <IconPencil stroke={1.5} /> + )} + </HeaderButton> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/settings/page.tsx new file mode 100644 index 000000000..8651981c7 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/@headeractions/settings/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { IconLayoutBoard } from "@homarr/ui"; + +import { HeaderButton } from "~/components/layout/header/button"; +import { useRequiredBoard } from "../../../_context"; + +export default function BoardViewLayout() { + const board = useRequiredBoard(); + + return ( + <HeaderButton href={`/boards/${board.name}`}> + <IconLayoutBoard stroke={1.5} /> + </HeaderButton> + ); +} diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/_definition.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/_definition.tsx new file mode 100644 index 000000000..5436994e2 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/_definition.tsx @@ -0,0 +1,8 @@ +import { api } from "~/trpc/server"; +import { createBoardPage } from "../_creator"; + +export default createBoardPage<{ locale: string; name: string }>({ + async getInitialBoard({ name }) { + return await api.board.byName.query({ name }); + }, +}); diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx new file mode 100644 index 000000000..7a2eb4b2c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx @@ -0,0 +1,5 @@ +import definition from "./_definition"; + +const { layout } = definition; + +export default layout; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/page.tsx new file mode 100644 index 000000000..1ff09b28b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/page.tsx @@ -0,0 +1,7 @@ +import definition from "./_definition"; + +const { generateMetadata, page } = definition; + +export default page; + +export { generateMetadata }; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx new file mode 100644 index 000000000..57c589dcd --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useEffect } 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 { useUpdateBoard } from "../../_client"; +import type { Board } from "../../_types"; + +interface Props { + board: Board; +} + +export const GeneralSettingsContent = ({ board }: Props) => { + const t = useI18n(); + const { updateBoard } = useUpdateBoard(); + const { mutate, isPending } = + clientApi.board.saveGeneralSettings.useMutation(); + const form = useForm({ + initialValues: { + pageTitle: board.pageTitle, + logoImageUrl: board.logoImageUrl, + metaTitle: board.metaTitle, + faviconImageUrl: board.faviconImageUrl, + }, + onValuesChange({ pageTitle }) { + updateBoard((previous) => ({ + ...previous, + pageTitle, + })); + }, + }); + + useMetaTitlePreview(form.values.metaTitle); + useFaviconPreview(form.values.faviconImageUrl); + useLogoPreview(form.values.logoImageUrl); + + return ( + <form + onSubmit={form.onSubmit((values) => { + mutate(values); + })} + > + <Stack> + <Grid> + <Grid.Col span={{ xs: 12, md: 6 }}> + <TextInput + label={t("board.field.pageTitle.label")} + {...form.getInputProps("pageTitle")} + /> + </Grid.Col> + <Grid.Col span={{ xs: 12, md: 6 }}> + <TextInput + label={t("board.field.metaTitle.label")} + {...form.getInputProps("metaTitle")} + /> + </Grid.Col> + <Grid.Col span={{ xs: 12, md: 6 }}> + <TextInput + label={t("board.field.logoImageUrl.label")} + {...form.getInputProps("logoImageUrl")} + /> + </Grid.Col> + <Grid.Col span={{ xs: 12, md: 6 }}> + <TextInput + label={t("board.field.faviconImageUrl.label")} + {...form.getInputProps("faviconImageUrl")} + /> + </Grid.Col> + </Grid> + <Group justify="end"> + <Button type="submit" loading={isPending}> + {t("common.action.saveChanges")} + </Button> + </Group> + </Stack> + </form> + ); +}; + +const useLogoPreview = (url: string | null) => { + const { updateBoard } = useUpdateBoard(); + const [logoDebounced] = useDebouncedValue(url ?? "", 500); + + useEffect(() => { + if (!logoDebounced.includes(".")) return; + updateBoard((previous) => ({ + ...previous, + logoImageUrl: logoDebounced, + })); + }, [logoDebounced, updateBoard]); +}; + +const useMetaTitlePreview = (title: string | null) => { + const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200); + useDocumentTitle(metaTitleDebounced); +}; + +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 : ""); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx new file mode 100644 index 000000000..fcfe89938 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx @@ -0,0 +1,133 @@ +import { capitalize } from "@homarr/common"; +import { getScopedI18n } from "@homarr/translation/server"; +import { + Accordion, + AccordionControl, + AccordionItem, + AccordionPanel, + Button, + Container, + Divider, + Group, + IconAlertTriangle, + IconBrush, + IconLayout, + IconSettings, + Stack, + Text, + Title, +} from "@homarr/ui"; + +import { api } from "~/trpc/server"; +import { GeneralSettingsContent } from "./_general"; + +interface Props { + params: { + name: string; + }; +} + +export default async function BoardSettingsPage({ params }: Props) { + const board = await api.board.byName.query({ name: params.name }); + const t = await getScopedI18n("board.setting"); + + return ( + <Container> + <Stack> + <Title>{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")} + + + + + + + + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/boards/_client.tsx b/apps/nextjs/src/app/[locale]/boards/_client.tsx new file mode 100644 index 000000000..e3b18be59 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_client.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useCallback, useRef } from "react"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { Box, LoadingOverlay, Stack } from "@homarr/ui"; + +import { BoardCategorySection } from "~/components/board/sections/category-section"; +import { BoardEmptySection } from "~/components/board/sections/empty-section"; +import { useIsBoardReady, useRequiredBoard } from "./_context"; +import type { CategorySection, EmptySection } from "./_types"; + +type UpdateCallback = ( + prev: RouterOutputs["board"]["default"], +) => RouterOutputs["board"]["default"]; + +export const useUpdateBoard = () => { + const utils = clientApi.useUtils(); + + const updateBoard = useCallback( + (updaterWithoutUndefined: UpdateCallback) => { + utils.board.default.setData(undefined, (previous) => + previous ? updaterWithoutUndefined(previous) : previous, + ); + }, + [utils], + ); + + return { + updateBoard, + }; +}; + +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 ref = useRef(null); + + return ( + + + + {sectionsWithoutSidebars.map((section) => + section.kind === "empty" ? ( + + ) : ( + + ), + )} + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_context.tsx b/apps/nextjs/src/app/[locale]/boards/_context.tsx new file mode 100644 index 000000000..e3b69f1cb --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_context.tsx @@ -0,0 +1,80 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { createContext, useCallback, useContext, useState } from "react"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; + +const BoardContext = createContext<{ + board: RouterOutputs["board"]["default"]; + isReady: boolean; + markAsReady: (id: string) => void; +} | null>(null); + +export const BoardProvider = ({ + children, + initialBoard, +}: PropsWithChildren<{ initialBoard: RouterOutputs["board"]["default"] }>) => { + const [readySections, setReadySections] = useState([]); + const { data } = clientApi.board.default.useQuery(undefined, { + initialData: initialBoard, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const markAsReady = useCallback((id: string) => { + setReadySections((previous) => + previous.includes(id) ? previous : [...previous, id], + ); + }, []); + + return ( + + {children} + + ); +}; + +export const useMarkSectionAsReady = () => { + const context = useContext(BoardContext); + + if (!context) { + throw new Error("Board is required"); + } + + return context.markAsReady; +}; + +export const useIsBoardReady = () => { + const context = useContext(BoardContext); + + if (!context) { + throw new Error("Board is required"); + } + + return context.isReady; +}; + +export const useRequiredBoard = () => { + const optionalBoard = useOptionalBoard(); + + if (!optionalBoard) { + throw new Error("Board is required"); + } + + return optionalBoard; +}; + +export const useOptionalBoard = () => { + const context = useContext(BoardContext); + + return context?.board; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/_creator.tsx new file mode 100644 index 000000000..ffb9b61ff --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_creator.tsx @@ -0,0 +1,66 @@ +import type { PropsWithChildren, ReactNode } from "react"; +import type { Metadata } from "next"; + +import { capitalize } from "@homarr/common"; +import { AppShellMain } from "@homarr/ui"; + +import { MainHeader } from "~/components/layout/header"; +import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; +import { ClientShell } from "~/components/layout/shell"; +import { ClientBoard } from "./_client"; +import { BoardProvider } from "./_context"; +import type { Board } from "./_types"; +// This is placed here because it's used in the layout and the page and because it's here it's not needed to load it everywhere +import "../../../styles/gridstack.scss"; + +type Params = Record; + +interface Props { + getInitialBoard: (params: TParams) => Promise; +} + +export const createBoardPage = >({ + getInitialBoard, +}: Props) => { + return { + layout: async ({ + params, + children, + headeractions, + }: PropsWithChildren<{ params: TParams; headeractions: ReactNode }>) => { + const initialBoard = await getInitialBoard(params); + + return ( + + + } + actions={headeractions} + hasNavigation={false} + /> + {children} + + + ); + }, + page: () => { + // TODO: Add check if board is private and user is not logged in + + return ; + }, + generateMetadata: async ({ + params, + }: { + params: TParams; + }): Promise => { + const board = await getInitialBoard(params); + + return { + title: board.metaTitle ?? `${capitalize(board.name)} board | Homarr`, + icons: { + icon: board.faviconImageUrl ? board.faviconImageUrl : undefined, + }, + }; + }, + }; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_types.ts b/apps/nextjs/src/app/[locale]/boards/_types.ts new file mode 100644 index 000000000..0e9d0495c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_types.ts @@ -0,0 +1,15 @@ +import type { RouterOutputs } from "@homarr/api"; +import type { WidgetKind } from "@homarr/definitions"; + +export type Board = RouterOutputs["board"]["default"]; +export type Section = Board["sections"][number]; +export type Item = Section["items"][number]; + +export type CategorySection = Extract; +export type EmptySection = Extract; +export type SidebarSection = Extract; + +export type ItemOfKind = Extract< + Item, + { kind: TKind } +>; diff --git a/apps/nextjs/src/app/[locale]/init/user/_init-user-form.tsx b/apps/nextjs/src/app/[locale]/init/user/_init-user-form.tsx index 913d6ee57..af52171e4 100644 --- a/apps/nextjs/src/app/[locale]/init/user/_init-user-form.tsx +++ b/apps/nextjs/src/app/[locale]/init/user/_init-user-form.tsx @@ -2,6 +2,7 @@ import { useRouter } from "next/navigation"; +import { clientApi } from "@homarr/api/client"; import { useForm, zodResolver } from "@homarr/form"; import { showErrorNotification, @@ -12,12 +13,11 @@ import { Button, PasswordInput, Stack, TextInput } from "@homarr/ui"; import type { z } from "@homarr/validation"; import { validation } from "@homarr/validation"; -import { api } from "~/trpc/react"; - export const InitUserForm = () => { const router = useRouter(); const t = useScopedI18n("user"); - const { mutateAsync, error, isPending } = api.user.initUser.useMutation(); + const { mutateAsync, error, isPending } = + clientApi.user.initUser.useMutation(); const form = useForm({ validate: zodResolver(validation.user.init), validateInputOnBlur: true, diff --git a/apps/nextjs/src/app/[locale]/init/user/page.tsx b/apps/nextjs/src/app/[locale]/init/user/page.tsx index 63e87e249..1537c2970 100644 --- a/apps/nextjs/src/app/[locale]/init/user/page.tsx +++ b/apps/nextjs/src/app/[locale]/init/user/page.tsx @@ -4,7 +4,7 @@ import { db } from "@homarr/db"; import { getScopedI18n } from "@homarr/translation/server"; import { Card, Center, Stack, Text, Title } from "@homarr/ui"; -import { LogoWithTitle } from "~/components/layout/logo"; +import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; import { InitUserForm } from "./_init-user-form"; export default async function InitUser() { @@ -23,7 +23,7 @@ export default async function InitUser() { return (
- + {t("title")} diff --git a/apps/nextjs/src/app/[locale]/modals.tsx b/apps/nextjs/src/app/[locale]/modals.tsx index 331305f27..7ac3e15a5 100644 --- a/apps/nextjs/src/app/[locale]/modals.tsx +++ b/apps/nextjs/src/app/[locale]/modals.tsx @@ -4,6 +4,11 @@ import { createModalManager } from "mantine-modal-manager"; import { WidgetEditModal } from "@homarr/widgets"; +import { ItemSelectModal } from "~/components/board/items/item-select-modal"; +import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; + export const [ModalsManager, modalEvents] = createModalManager({ + categoryEditModal: CategoryEditModal, widgetEditModal: WidgetEditModal, + itemSelectModal: ItemSelectModal, }); diff --git a/apps/nextjs/src/app/[locale]/widgets/[sort]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx similarity index 81% rename from apps/nextjs/src/app/[locale]/widgets/[sort]/_content.tsx rename to apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx index de3e36f13..f1a43a538 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[sort]/_content.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx @@ -3,9 +3,8 @@ import { useState } from "react"; import type { WidgetOptionDefinition } from "node_modules/@homarr/widgets/src/options"; -import type { IntegrationKind } from "@homarr/definitions"; +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { ActionIcon, Affix, IconPencil } from "@homarr/ui"; -import type { WidgetSort } from "@homarr/widgets"; import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, @@ -15,7 +14,7 @@ import { import { modalEvents } from "../../modals"; interface WidgetPreviewPageContentProps { - sort: WidgetSort; + kind: WidgetKind; integrationData: { id: string; name: string; @@ -25,10 +24,10 @@ interface WidgetPreviewPageContentProps { } export const WidgetPreviewPageContent = ({ - sort, + kind, integrationData, }: WidgetPreviewPageContentProps) => { - const currentDefinition = widgetImports[sort].definition; + const currentDefinition = widgetImports[kind].definition; const options = currentDefinition.options as Record< string, WidgetOptionDefinition @@ -37,11 +36,11 @@ export const WidgetPreviewPageContent = ({ options: Record<string, unknown>; integrations: string[]; }>({ - options: reduceWidgetOptionsWithDefaultValues(options), + options: reduceWidgetOptionsWithDefaultValues(kind, options), integrations: [], }); - const Comp = loadWidgetDynamic(sort); + const Comp = loadWidgetDynamic(kind); return ( <> @@ -60,9 +59,11 @@ export const WidgetPreviewPageContent = ({ return modalEvents.openManagedModal({ modal: "widgetEditModal", innerProps: { - sort, - definition: currentDefinition.options, - state: [state, setState], + kind, + value: state, + onSuccessfulEdit: (value) => { + setState(value); + }, integrationData: integrationData.filter( (integration) => "supportedIntegrations" in currentDefinition && diff --git a/apps/nextjs/src/app/[locale]/widgets/[sort]/layout.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx similarity index 94% rename from apps/nextjs/src/app/[locale]/widgets/[sort]/layout.tsx rename to apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx index beae35636..cfd213f9d 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[sort]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx @@ -10,7 +10,7 @@ const getLinks = () => { return { href: `/widgets/${key}`, icon: value.definition.icon, - label: value.definition.sort, + label: value.definition.kind, }; }); }; diff --git a/apps/nextjs/src/app/[locale]/widgets/[sort]/page.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx similarity index 62% rename from apps/nextjs/src/app/[locale]/widgets/[sort]/page.tsx rename to apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx index 75419e656..14ea3693a 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[sort]/page.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx @@ -1,17 +1,18 @@ -import type { PropsWithChildren } from "react"; import { notFound } from "next/navigation"; import { db } from "@homarr/db"; +import type { WidgetKind } from "@homarr/definitions"; import { Center } from "@homarr/ui"; -import type { WidgetSort } from "@homarr/widgets"; import { widgetImports } from "@homarr/widgets"; import { WidgetPreviewPageContent } from "./_content"; -type Props = PropsWithChildren<{ params: { sort: string } }>; +interface Props { + params: { kind: string }; +} export default async function WidgetPreview(props: Props) { - if (!(props.params.sort in widgetImports)) { + if (!(props.params.kind in widgetImports)) { notFound(); } @@ -24,11 +25,11 @@ export default async function WidgetPreview(props: Props) { }, }); - const sort = props.params.sort as WidgetSort; + const sort = props.params.kind as WidgetKind; return ( <Center h="100vh"> - <WidgetPreviewPageContent sort={sort} integrationData={integrationData} /> + <WidgetPreviewPageContent kind={sort} integrationData={integrationData} /> </Center> ); } diff --git a/apps/nextjs/src/components/board/editMode.ts b/apps/nextjs/src/components/board/editMode.ts new file mode 100644 index 000000000..186b60a0f --- /dev/null +++ b/apps/nextjs/src/components/board/editMode.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai"; + +export const editModeAtom = atom(false); diff --git a/apps/nextjs/src/components/board/items/item-actions.tsx b/apps/nextjs/src/components/board/items/item-actions.tsx new file mode 100644 index 000000000..7ad175449 --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-actions.tsx @@ -0,0 +1,201 @@ +import { useCallback } from "react"; + +import { createId } from "@homarr/db/client"; +import type { WidgetKind } from "@homarr/definitions"; + +import { useUpdateBoard } from "~/app/[locale]/boards/_client"; +import type { EmptySection, Item } from "~/app/[locale]/boards/_types"; + +interface MoveAndResizeItem { + itemId: string; + xOffset: number; + yOffset: number; + width: number; + height: number; +} +interface MoveItemToSection { + itemId: string; + sectionId: string; + xOffset: number; + yOffset: number; + width: number; + height: number; +} +interface RemoveItem { + itemId: string; +} + +interface UpdateItemOptions { + itemId: string; + newOptions: Record<string, unknown>; +} + +interface CreateItem { + kind: WidgetKind; +} + +export const useItemActions = () => { + const { updateBoard } = useUpdateBoard(); + + const createItem = useCallback( + ({ kind }: CreateItem) => { + updateBoard((previous) => { + const lastSection = previous.sections + .filter((s): s is EmptySection => s.kind === "empty") + .sort((a, b) => b.position - a.position)[0]; + + if (!lastSection) return previous; + + const widget = { + id: createId(), + kind, + options: {}, + width: 1, + height: 1, + integrations: [], + } satisfies Omit<Item, "kind" | "yOffset" | "xOffset"> & { + kind: WidgetKind; + }; + + return { + ...previous, + sections: previous.sections.map((section) => { + // Return same section if item is not in it + if (section.id !== lastSection.id) return section; + return { + ...section, + items: section.items.concat(widget as unknown as Item), + }; + }), + }; + }); + }, + [updateBoard], + ); + + const updateItemOptions = useCallback( + ({ itemId, newOptions }: UpdateItemOptions) => { + updateBoard((previous) => { + if (!previous) return previous; + return { + ...previous, + sections: previous.sections.map((section) => { + // Return same section if item is not in it + if (!section.items.some((item) => item.id === itemId)) + return section; + return { + ...section, + items: section.items.map((item) => { + // Return same item if item is not the one we're moving + if (item.id !== itemId) return item; + return { + ...item, + options: newOptions, + }; + }), + }; + }), + }; + }); + }, + [updateBoard], + ); + + const moveAndResizeItem = useCallback( + ({ itemId, ...positionProps }: MoveAndResizeItem) => { + updateBoard((previous) => ({ + ...previous, + sections: previous.sections.map((section) => { + // Return same section if item is not in it + if (!section.items.some((item) => item.id === itemId)) return section; + return { + ...section, + items: section.items.map((item) => { + // Return same item if item is not the one we're moving + if (item.id !== itemId) return item; + return { + ...item, + ...positionProps, + } satisfies Item; + }), + }; + }), + })); + }, + [updateBoard], + ); + + const moveItemToSection = useCallback( + ({ itemId, sectionId, ...positionProps }: MoveItemToSection) => { + updateBoard((previous) => { + const currentSection = previous.sections.find((section) => + section.items.some((item) => item.id === itemId), + ); + + // If item is in the same section (on initial loading) don't do anything + if (!currentSection) { + return previous; + } + + const currentItem = currentSection.items.find( + (item) => item.id === itemId, + ); + if (!currentItem) { + return previous; + } + + if (currentSection.id === sectionId && currentItem.xOffset) { + return previous; + } + + return { + ...previous, + sections: previous.sections.map((section) => { + // Return sections without item if not section where it is moved to + if (section.id !== sectionId) + return { + ...section, + items: section.items.filter((item) => item.id !== itemId), + }; + + // Return section and add item to it + return { + ...section, + items: section.items + .filter((item) => item.id !== itemId) + .concat({ + ...currentItem, + ...positionProps, + }), + }; + }), + }; + }); + }, + [updateBoard], + ); + + const removeItem = useCallback( + ({ itemId }: RemoveItem) => { + updateBoard((previous) => { + return { + ...previous, + // Filter removed item out of items array + sections: previous.sections.map((section) => ({ + ...section, + items: section.items.filter((item) => item.id !== itemId), + })), + }; + }); + }, + [updateBoard], + ); + + return { + moveAndResizeItem, + moveItemToSection, + removeItem, + updateItemOptions, + createItem, + }; +}; diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx new file mode 100644 index 000000000..087c6bea8 --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -0,0 +1,84 @@ +import type { ManagedModal } from "mantine-modal-manager"; + +import type { WidgetKind } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { Button, Card, Center, Grid, Stack, Text } from "@homarr/ui"; + +import { objectEntries } from "../../../../../../packages/common/src"; +import { widgetImports } from "../../../../../../packages/widgets/src"; +import type { WidgetDefinition } from "../../../../../../packages/widgets/src/definition"; +import { useItemActions } from "./item-actions"; + +export const ItemSelectModal: ManagedModal<Record<string, never>> = ({ + actions, +}) => { + return ( + <Grid> + {objectEntries(widgetImports).map(([key, value]) => { + return ( + <WidgetItem + key={key} + kind={key} + definition={value.definition} + closeModal={actions.closeModal} + /> + ); + })} + </Grid> + ); +}; + +const WidgetItem = ({ + kind, + definition, + closeModal, +}: { + kind: WidgetKind; + definition: WidgetDefinition; + closeModal: () => void; +}) => { + const t = useI18n(); + const { createItem } = useItemActions(); + const handleAdd = (kind: WidgetKind) => { + createItem({ kind }); + closeModal(); + }; + + return ( + <Grid.Col span={{ xs: 12, sm: 4, md: 3 }}> + <Card h="100%"> + <Stack justify="space-between" h="100%"> + <Stack gap="xs"> + <Center> + <definition.icon /> + </Center> + <Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center"> + {t(`widget.${kind}.name`)} + </Text> + <Text + lh={1.2} + style={{ whiteSpace: "normal" }} + size="xs" + ta="center" + c="dimmed" + > + {t(`widget.${kind}.description`)} + </Text> + </Stack> + <Button + onClick={() => { + handleAdd(kind); + }} + variant="light" + size="xs" + mt="auto" + radius="md" + fullWidth + > + {t(`item.create.addToBoard`)} + </Button> + </Stack> + </Card> + </Grid.Col> + ); +}; diff --git a/apps/nextjs/src/components/board/sections/category-section.tsx b/apps/nextjs/src/components/board/sections/category-section.tsx new file mode 100644 index 000000000..3397b045d --- /dev/null +++ b/apps/nextjs/src/components/board/sections/category-section.tsx @@ -0,0 +1,58 @@ +import type { RefObject } from "react"; +import { useDisclosure } from "@mantine/hooks"; + +import { + Card, + Collapse, + Group, + IconChevronDown, + IconChevronUp, + Stack, + Title, + UnstyledButton, +} from "@homarr/ui"; + +import type { CategorySection } from "~/app/[locale]/boards/_types"; +import { CategoryMenu } from "./category/category-menu"; +import { SectionContent } from "./content"; +import { useGridstack } from "./gridstack/use-gridstack"; + +interface Props { + section: CategorySection; + mainRef: RefObject<HTMLDivElement>; +} + +export const BoardCategorySection = ({ section, mainRef }: Props) => { + const { refs } = useGridstack({ section, mainRef }); + const [opened, { toggle }] = useDisclosure(false); + + return ( + <Card withBorder p={0}> + <Stack> + <Group wrap="nowrap" gap="sm"> + <UnstyledButton w="100%" p="sm" onClick={toggle}> + <Group wrap="nowrap"> + {opened ? ( + <IconChevronUp size={20} /> + ) : ( + <IconChevronDown size={20} /> + )} + <Title order={3}>{section.name} + + + + + +
+ +
+
+
+ + ); +}; diff --git a/apps/nextjs/src/components/board/sections/category/category-actions.ts b/apps/nextjs/src/components/board/sections/category/category-actions.ts new file mode 100644 index 000000000..16c1b6714 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/category/category-actions.ts @@ -0,0 +1,284 @@ +import { useCallback } from "react"; + +import { createId } from "@homarr/db/client"; + +import { useUpdateBoard } from "~/app/[locale]/boards/_client"; +import type { + CategorySection, + EmptySection, + Section, +} from "~/app/[locale]/boards/_types"; + +interface AddCategory { + name: string; + position: number; +} + +interface RenameCategory { + id: string; + name: string; +} + +interface MoveCategory { + id: string; + direction: "up" | "down"; +} + +interface RemoveCategory { + id: string; +} + +export const useCategoryActions = () => { + const { updateBoard } = useUpdateBoard(); + + const addCategory = useCallback( + ({ name, position }: AddCategory) => { + if (position <= -1) { + return; + } + updateBoard((previous) => ({ + ...previous, + sections: [ + // Ignore sidebar sections + ...previous.sections.filter((section) => section.kind === "sidebar"), + // Place sections before the new category + ...previous.sections.filter( + (section) => + (section.kind === "category" || section.kind === "empty") && + section.position < position, + ), + { + id: createId(), + name, + kind: "category", + position, + items: [], + }, + { + id: createId(), + kind: "empty", + position: position + 1, + items: [], + }, + // Place sections after the new category + ...previous.sections + .filter( + (section): section is CategorySection | EmptySection => + (section.kind === "category" || section.kind === "empty") && + section.position >= position, + ) + .map((section) => ({ + ...section, + position: section.position + 2, + })), + ], + })); + }, + [updateBoard], + ); + + const addCategoryToEnd = useCallback( + ({ name }: { name: string }) => { + updateBoard((previous) => { + const lastSection = previous.sections + .filter( + (x): x is CategorySection | EmptySection => + x.kind === "empty" || x.kind === "category", + ) + .sort((a, b) => b.position - a.position) + .at(0); + + if (!lastSection) return previous; + const lastPosition = lastSection.position; + + return { + ...previous, + sections: [ + ...previous.sections, + { + id: createId(), + name, + kind: "category", + position: lastPosition + 1, + items: [], + }, + { + id: createId(), + kind: "empty", + position: lastPosition + 2, + items: [], + }, + ], + }; + }); + }, + [updateBoard], + ); + + const renameCategory = useCallback( + ({ id: categoryId, name }: RenameCategory) => { + updateBoard((previous) => ({ + ...previous, + sections: previous.sections.map((section) => { + if (section.kind !== "category") return section; + if (section.id !== categoryId) return section; + return { + ...section, + name, + }; + }), + })); + }, + [updateBoard], + ); + + const moveCategory = useCallback( + ({ id, direction }: MoveCategory) => { + updateBoard((previous) => { + const currentCategory = previous.sections.find( + (section): section is CategorySection => + section.kind === "category" && section.id === id, + ); + if (!currentCategory) return previous; + if (currentCategory?.position === 1 && direction === "up") + return previous; + if ( + currentCategory?.position === previous.sections.length - 2 && + direction === "down" + ) + return previous; + + return { + ...previous, + sections: previous.sections.map((section) => { + if (section.kind !== "category" && section.kind !== "empty") + return section; + const offset = direction === "up" ? -2 : 2; + // Move category and empty section + if ( + section.position === currentCategory.position || + section.position - 1 === currentCategory.position + ) { + return { + ...section, + position: section.position + offset, + }; + } + + if ( + direction === "up" && + (section.position === currentCategory.position - 2 || + section.position === currentCategory.position - 1) + ) { + return { + ...section, + position: section.position + 2, + }; + } + + if ( + direction === "down" && + (section.position === currentCategory.position + 2 || + section.position === currentCategory.position + 3) + ) { + return { + ...section, + position: section.position - 2, + }; + } + + return section; + }), + }; + }); + }, + [updateBoard], + ); + + const removeCategory = useCallback( + ({ id: categoryId }: RemoveCategory) => { + updateBoard((previous) => { + const currentCategory = previous.sections.find( + (section): section is CategorySection => + section.kind === "category" && section.id === categoryId, + ); + if (!currentCategory) return previous; + + const aboveWrapper = previous.sections.find( + (section): section is EmptySection => + section.kind === "empty" && + section.position === currentCategory.position - 1, + ); + + const removedWrapper = previous.sections.find( + (section): section is EmptySection => + section.kind === "empty" && + section.position === currentCategory.position + 1, + ); + + if (!aboveWrapper || !removedWrapper) return previous; + + // Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper + const aboveYOffset = calculateYHeightWithOffset(aboveWrapper); + const categoryYOffset = calculateYHeightWithOffset(currentCategory); + + const previousCategoryItems = currentCategory.items.map((item) => ({ + ...item, + yOffset: item.yOffset + aboveYOffset, + })); + const previousBelowWrapperItems = removedWrapper.items.map((item) => ({ + ...item, + yOffset: item.yOffset + aboveYOffset + categoryYOffset, + })); + + return { + ...previous, + sections: [ + ...previous.sections.filter( + (section) => section.kind === "sidebar", + ), + ...previous.sections.filter( + (section) => + (section.kind === "category" || section.kind === "empty") && + section.position < currentCategory.position - 1, + ), + { + ...aboveWrapper, + items: [ + ...aboveWrapper.items, + ...previousCategoryItems, + ...previousBelowWrapperItems, + ], + }, + ...previous.sections + .filter( + (section): section is CategorySection | EmptySection => + (section.kind === "category" || section.kind === "empty") && + section.position >= currentCategory.position + 2, + ) + .map((section) => ({ + ...section, + position: section.position - 2, + })), + ], + }; + }); + }, + [updateBoard], + ); + + return { + addCategory, + addCategoryToEnd, + renameCategory, + moveCategory, + removeCategory, + }; +}; + +const calculateYHeightWithOffset = (section: Section) => + section.items.reduce((acc, item) => { + const yHeightWithOffset = item.yOffset + item.height; + if (yHeightWithOffset > acc) return yHeightWithOffset; + return acc; + }, 0); diff --git a/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx b/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx new file mode 100644 index 000000000..35f975028 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx @@ -0,0 +1,56 @@ +import type { ManagedModal } from "mantine-modal-manager"; + +import { useForm } from "@homarr/form"; +import { useI18n } from "@homarr/translation/client"; +import { Button, Group, Stack, TextInput } from "@homarr/ui"; + +interface Category { + id: string; + name: string; +} + +interface InnerProps { + submitLabel: string; + category: Category; + onSuccess: (category: Category) => void; +} + +export const CategoryEditModal: ManagedModal = ({ + actions, + innerProps, +}) => { + const t = useI18n(); + const form = useForm({ + initialValues: { + name: innerProps.category.name, + }, + }); + + return ( +
{ + void innerProps.onSuccess({ + ...innerProps.category, + name: v.name, + }); + actions.closeModal(); + })} + > + + + + + + + +
+ ); +}; diff --git a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx new file mode 100644 index 000000000..697e99233 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx @@ -0,0 +1,107 @@ +import { useCallback } from "react"; + +import { createId } from "@homarr/db/client"; +import { useI18n } from "@homarr/translation/client"; + +import type { CategorySection } from "~/app/[locale]/boards/_types"; +import { modalEvents } from "~/app/[locale]/modals"; +import { useCategoryActions } from "./category-actions"; + +export const useCategoryMenuActions = (category: CategorySection) => { + const { addCategory, moveCategory, removeCategory, renameCategory } = + useCategoryActions(); + const t = useI18n(); + + const createCategoryAtPosition = useCallback( + (position: number) => { + modalEvents.openManagedModal({ + title: t("section.category.create.title"), + modal: "categoryEditModal", + innerProps: { + category: { + id: createId(), + name: t("section.category.create.title"), + }, + onSuccess: (category) => { + addCategory({ + name: category.name, + position, + }); + }, + submitLabel: t("section.category.create.submit"), + }, + }); + }, + [addCategory, t], + ); + + // creates a new category above the current + const addCategoryAbove = useCallback(() => { + const abovePosition = category.position; + createCategoryAtPosition(abovePosition); + }, [category.position, createCategoryAtPosition]); + + // creates a new category below the current + const addCategoryBelow = useCallback(() => { + const belowPosition = category.position + 2; + createCategoryAtPosition(belowPosition); + }, [category.position, createCategoryAtPosition]); + + const moveCategoryUp = useCallback(() => { + moveCategory({ + id: category.id, + direction: "up", + }); + }, [category.id, moveCategory]); + + const moveCategoryDown = useCallback(() => { + moveCategory({ + id: category.id, + direction: "down", + }); + }, [category.id, moveCategory]); + + // Removes the current category + const remove = useCallback(() => { + modalEvents.openConfirmModal({ + title: t("section.category.remove.title"), + children: t("section.category.remove.message", { + name: category.name, + }), + onConfirm: () => { + removeCategory({ + id: category.id, + }); + }, + confirmProps: { + color: "red", + }, + }); + }, [category.id, category.name, removeCategory, t]); + + const edit = () => { + modalEvents.openManagedModal({ + modal: "categoryEditModal", + title: t("section.category.edit.title"), + innerProps: { + category, + submitLabel: t("section.category.edit.submit"), + onSuccess: (category) => { + renameCategory({ + id: category.id, + name: category.name, + }); + }, + }, + }); + }; + + return { + addCategoryAbove, + addCategoryBelow, + moveCategoryUp, + moveCategoryDown, + remove, + edit, + }; +}; diff --git a/apps/nextjs/src/components/board/sections/category/category-menu.tsx b/apps/nextjs/src/components/board/sections/category/category-menu.tsx new file mode 100644 index 000000000..182ecbce8 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/category/category-menu.tsx @@ -0,0 +1,128 @@ +"use client"; + +import React, { useMemo } from "react"; +import { useAtomValue } from "jotai"; + +import { useScopedI18n } from "@homarr/translation/client"; +import type { TablerIconsProps } from "@homarr/ui"; +import { + ActionIcon, + IconDotsVertical, + IconEdit, + IconRowInsertBottom, + IconRowInsertTop, + IconTransitionBottom, + IconTransitionTop, + IconTrash, + Menu, +} from "@homarr/ui"; + +import type { CategorySection } from "~/app/[locale]/boards/_types"; +import { editModeAtom } from "../../editMode"; +import { useCategoryMenuActions } from "./category-menu-actions"; + +interface Props { + category: CategorySection; +} + +export const CategoryMenu = ({ category }: Props) => { + const actions = useActions(category); + const t = useScopedI18n("section.category"); + + if (actions.length === 0) return null; + + return ( + + + + + + + + {actions.map((action) => ( + + {"group" in action && {t(action.group)}} + } + onClick={action.onClick} + color={"color" in action ? action.color : undefined} + > + {t(action.label)} + + + ))} + + + ); +}; + +const useActions = (category: CategorySection) => { + const isEditMode = useAtomValue(editModeAtom); + const editModeActions = useEditModeActions(category); + const nonEditModeActions = useNonEditModeActions(category); + + return useMemo( + () => (isEditMode ? editModeActions : nonEditModeActions), + [isEditMode, editModeActions, nonEditModeActions], + ); +}; + +const useEditModeActions = (category: CategorySection) => { + const { + addCategoryAbove, + addCategoryBelow, + moveCategoryUp, + moveCategoryDown, + edit, + remove, + } = useCategoryMenuActions(category); + + return [ + { + icon: IconEdit, + label: "action.edit", + onClick: edit, + }, + { + icon: IconTrash, + color: "red", + label: "action.remove", + onClick: remove, + }, + { + group: "menu.label.changePosition", + icon: IconTransitionTop, + label: "action.moveUp", + onClick: moveCategoryUp, + }, + { + icon: IconTransitionBottom, + label: "action.moveDown", + onClick: moveCategoryDown, + }, + { + group: "menu.label.create", + icon: IconRowInsertTop, + label: "action.createAbove", + onClick: addCategoryAbove, + }, + { + icon: IconRowInsertBottom, + label: "action.createBelow", + onClick: addCategoryBelow, + }, + ] as const satisfies ActionDefinition[]; +}; + +// TODO: once apps are added we can use this for the open many apps action +const useNonEditModeActions = (_category: CategorySection) => { + return [] as const satisfies ActionDefinition[]; +}; + +interface ActionDefinition { + icon: (props: TablerIconsProps) => JSX.Element; + label: string; + onClick: () => void; + color?: string; + group?: string; +} diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx new file mode 100644 index 000000000..006c2c2fd --- /dev/null +++ b/apps/nextjs/src/components/board/sections/content.tsx @@ -0,0 +1,153 @@ +/* eslint-disable react/no-unknown-property */ +// Ignored because of gridstack attributes + +import type { RefObject } from "react"; +import { useAtomValue } from "jotai"; + +import { useScopedI18n } from "@homarr/translation/client"; +import { + ActionIcon, + Card, + IconDotsVertical, + IconLayoutKanban, + IconPencil, + IconTrash, + Menu, +} from "@homarr/ui"; +import { + loadWidgetDynamic, + reduceWidgetOptionsWithDefaultValues, +} from "@homarr/widgets"; + +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"; + +interface Props { + items: Item[]; + refs: UseGridstackRefs; +} + +export const SectionContent = ({ items, refs }: Props) => { + return ( + <> + {items.map((item) => ( +
} + > + + + +
+ ))} + + ); +}; + +interface ItemProps { + item: Item; +} + +const BoardItem = ({ item }: ItemProps) => { + const Comp = loadWidgetDynamic(item.kind); + const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options); + const newItem = { ...item, options }; + return ( + <> + + + + ); +}; + +const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => { + const t = useScopedI18n("item"); + const isEditMode = useAtomValue(editModeAtom); + const { updateItemOptions, removeItem } = useItemActions(); + + if (!isEditMode) return null; + + const openEditModal = () => { + modalEvents.openManagedModal({ + title: t("edit.title"), + modal: "widgetEditModal", + innerProps: { + kind: item.kind, + value: { + options: item.options, + integrations: item.integrations.map(({ id }) => id), + }, + onSuccessfulEdit: ({ options, integrations: _ }) => { + updateItemOptions({ + itemId: item.id, + newOptions: options, + }); + }, + integrationData: [], + integrationSupport: false, + }, + }); + }; + + const openRemoveModal = () => { + modalEvents.openConfirmModal({ + title: t("remove.title"), + children: t("remove.message"), + onConfirm: () => { + removeItem({ itemId: item.id }); + }, + confirmProps: { + color: "red", + }, + }); + }; + + return ( + + + + + + + + {t("menu.label.settings")} + } + onClick={openEditModal} + > + {t("action.edit")} + + }> + {t("action.move")} + + + {t("menu.label.dangerZone")} + } + onClick={openRemoveModal} + > + {t("action.remove")} + + + + ); +}; diff --git a/apps/nextjs/src/components/board/sections/empty-section.tsx b/apps/nextjs/src/components/board/sections/empty-section.tsx new file mode 100644 index 000000000..268a7ec88 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/empty-section.tsx @@ -0,0 +1,35 @@ +import type { RefObject } from "react"; +import { useAtomValue } from "jotai"; + +import type { EmptySection } from "~/app/[locale]/boards/_types"; +import { editModeAtom } from "../editMode"; +import { SectionContent } from "./content"; +import { useGridstack } from "./gridstack/use-gridstack"; + +interface Props { + section: EmptySection; + mainRef: RefObject; +} + +const defaultClasses = "grid-stack grid-stack-empty min-row"; + +export const BoardEmptySection = ({ section, mainRef }: Props) => { + const { refs } = useGridstack({ section, mainRef }); + const isEditMode = useAtomValue(editModeAtom); + + return ( +
0 || isEditMode + ? defaultClasses + : `${defaultClasses} gridstack-empty-wrapper` + } + style={{ transitionDuration: "0s" }} + data-empty + data-section-id={section.id} + ref={refs.wrapper} + > + +
+ ); +}; diff --git a/apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts new file mode 100644 index 000000000..a8d154356 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts @@ -0,0 +1,61 @@ +import type { MutableRefObject, RefObject } from "react"; +import type { GridItemHTMLElement } from "fily-publish-gridstack"; +import { GridStack } from "fily-publish-gridstack"; + +import type { Section } from "~/app/[locale]/boards/_types"; + +interface InitializeGridstackProps { + section: Section; + refs: { + wrapper: RefObject; + items: MutableRefObject>>; + gridstack: MutableRefObject; + }; + sectionColumnCount: number; +} + +export const initializeGridstack = ({ + section, + refs, + 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, + cellHeight: 128, + float: true, + alwaysShowResizeHandle: true, + acceptWidgets: true, + disableOneColumnMode: true, + staticGrid: true, + minRow, + animate: false, + styleInHead: true, + }, + // selector of the gridstack item (it's eather category or wrapper) + `.grid-stack-${section.kind}[data-section-id='${section.id}']`, + ); + const grid = newGrid.current; + if (!grid) return false; + // Must be used to update the column count after the initialization + grid.column(columnCount, "none"); + + grid.batchUpdate(); + grid.removeAll(false); + section.items.forEach(({ id }) => { + const ref = refs.items.current[id]?.current; + ref && grid.makeWidget(ref); + }); + grid.batchUpdate(false); + return true; +}; diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts new file mode 100644 index 000000000..1227a9c94 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts @@ -0,0 +1,209 @@ +import type { MutableRefObject, RefObject } from "react"; +import { createRef, useCallback, useEffect, useMemo, useRef } from "react"; +import type { + GridItemHTMLElement, + GridStack, + GridStackNode, +} from "fily-publish-gridstack"; +import { useAtomValue } from "jotai"; + +import { + useMarkSectionAsReady, + useRequiredBoard, +} from "~/app/[locale]/boards/_context"; +import type { Section } from "~/app/[locale]/boards/_types"; +import { editModeAtom } from "../../editMode"; +import { useItemActions } from "../../items/item-actions"; +import { initializeGridstack } from "./init-gridstack"; + +export interface UseGridstackRefs { + wrapper: RefObject; + items: MutableRefObject>>; + gridstack: MutableRefObject; +} + +interface UseGristackReturnType { + refs: UseGridstackRefs; +} + +interface UseGridstackProps { + section: Section; + mainRef?: RefObject; +} + +export const useGridstack = ({ + section, + mainRef, +}: UseGridstackProps): UseGristackReturnType => { + const isEditMode = useAtomValue(editModeAtom); + const markAsReady = useMarkSectionAsReady(); + const { moveAndResizeItem, moveItemToSection } = useItemActions(); + // define reference for wrapper - is used to calculate the width of the wrapper + const wrapperRef = useRef(null); + // references to the diffrent items contained in the gridstack + const itemRefs = useRef>>({}); + // reference of the gridstack object for modifications after initialization + const gridRef = useRef(); + + useCssVariableConfiguration({ section, mainRef, gridRef }); + + const sectionColumnCount = useSectionColumnCount(section.kind); + + const items = useMemo(() => section.items, [section.items]); + + // define items in itemRefs for easy access and reference to items + if (Object.keys(itemRefs.current).length !== items.length) { + items.forEach(({ id }: { id: keyof typeof itemRefs.current }) => { + itemRefs.current[id] = itemRefs.current[id] ?? createRef(); + }); + } + + useEffect(() => { + gridRef.current?.setStatic(!isEditMode); + }, [isEditMode]); + + const onChange = useCallback( + (changedNode: GridStackNode) => { + const itemId = changedNode.el?.getAttribute("data-id"); + if (!itemId) return; + + // Updates the react-query state + moveAndResizeItem({ + itemId, + xOffset: changedNode.x!, + yOffset: changedNode.y!, + width: changedNode.w!, + height: changedNode.h!, + }); + }, + [moveAndResizeItem], + ); + const onAdd = useCallback( + (addedNode: GridStackNode) => { + const itemId = addedNode.el?.getAttribute("data-id"); + if (!itemId) return; + + // Updates the react-query state + moveItemToSection({ + itemId, + sectionId: section.id, + xOffset: addedNode.x!, + yOffset: addedNode.y!, + width: addedNode.w!, + height: addedNode.h!, + }); + }, + [moveItemToSection, section.id], + ); + + useEffect(() => { + if (!isEditMode) return; + const currentGrid = gridRef.current; + // Add listener for moving items around in a wrapper + currentGrid?.on("change", (_, nodes) => { + (nodes as GridStackNode[]).forEach(onChange); + }); + + // Add listener for moving items in config from one wrapper to another + currentGrid?.on("added", (_, el) => { + const nodes = el as GridStackNode[]; + nodes.forEach((node) => onAdd(node)); + }); + + return () => { + currentGrid?.off("change"); + currentGrid?.off("added"); + }; + }, [isEditMode, onAdd, onChange]); + + // initialize the gridstack + useEffect(() => { + const isReady = initializeGridstack({ + section, + refs: { + items: itemRefs, + wrapper: wrapperRef, + gridstack: gridRef, + }, + sectionColumnCount, + }); + + if (isReady) { + markAsReady(section.id); + } + + // Only run this effect when the section items change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items.length, section.items.length]); + + return { + refs: { + items: itemRefs, + wrapper: wrapperRef, + gridstack: gridRef, + }, + }; +}; + +/** + * 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; + gridRef: UseGridstackRefs["gridstack"]; +} + +/** + * This hook is used to configure the css variables for the gridstack + * Those css variables are used to define the size of the gridstack items + * @see gridstack.scss + * @param section section of the board + * @param mainRef reference to the main div wrapping all sections + * @param gridRef reference to the gridstack object + */ +const useCssVariableConfiguration = ({ + section, + mainRef, + gridRef, +}: UseCssVariableConfiguration) => { + const sectionColumnCount = useSectionColumnCount(section.kind); + + // Get reference to the :root element + const typeofDocument = typeof document; + const root = useMemo(() => { + if (typeofDocument === "undefined") return; + return document.documentElement; + }, [typeofDocument]); + + // 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; + // widget width is used to define sizes of gridstack items within global.scss + root?.style.setProperty("--gridstack-widget-width", widgetWidth.toString()); + console.log("widgetWidth", widgetWidth); + console.log(gridRef.current); + 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]); + + // Define column count by using the sectionColumnCount + useEffect(() => { + root?.style.setProperty( + "--gridstack-column-count", + sectionColumnCount.toString(), + ); + }, [sectionColumnCount, root]); +}; diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx index 6a36bcd9f..c9c06744a 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import Link from "next/link"; import { AppShellHeader, Group, UnstyledButton } from "@homarr/ui"; @@ -6,20 +7,33 @@ import { ClientBurger } from "./header/burger"; import { DesktopSearchInput, MobileSearchButton } from "./header/search"; import { ClientSpotlight } from "./header/spotlight"; import { UserButton } from "./header/user"; -import { LogoWithTitle } from "./logo"; +import { HomarrLogoWithTitle } from "./logo/homarr-logo"; -export const MainHeader = () => { +interface Props { + logo?: ReactNode; + actions?: ReactNode; + hasNavigation?: boolean; +} + +export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => { return ( - + {hasNavigation && } - + {logo ?? } - + + {actions} diff --git a/apps/nextjs/src/components/layout/header/button.tsx b/apps/nextjs/src/components/layout/header/button.tsx new file mode 100644 index 000000000..967dd837a --- /dev/null +++ b/apps/nextjs/src/components/layout/header/button.tsx @@ -0,0 +1,47 @@ +import type { ForwardedRef, ReactNode } from "react"; +import { forwardRef } from "react"; +import Link from "next/link"; + +import type { ActionIconProps } from "@homarr/ui"; +import { ActionIcon } from "@homarr/ui"; + +type HeaderButtonProps = ( + | { + onClick?: () => void; + } + | { + href: string; + } +) & { + children: ReactNode; +} & Partial; + +const headerButtonActionIconProps: ActionIconProps = { + variant: "subtle", + style: { border: "none" }, + color: "gray", + size: "lg", +}; + +// eslint-disable-next-line react/display-name +export const HeaderButton = forwardRef( + (props, ref) => { + if ("href" in props) { + return ( + } + component={Link} + {...props} + {...headerButtonActionIconProps} + > + {props.children} + + ); + } + return ( + + {props.children} + + ); + }, +); diff --git a/apps/nextjs/src/components/layout/header/search.tsx b/apps/nextjs/src/components/layout/header/search.tsx index 1bb742640..dcccd3360 100644 --- a/apps/nextjs/src/components/layout/header/search.tsx +++ b/apps/nextjs/src/components/layout/header/search.tsx @@ -2,8 +2,9 @@ import { spotlight } from "@homarr/spotlight"; import { useScopedI18n } from "@homarr/translation/client"; -import { ActionIcon, IconSearch, TextInput, UnstyledButton } from "@homarr/ui"; +import { IconSearch, TextInput, UnstyledButton } from "@homarr/ui"; +import { HeaderButton } from "./button"; import classes from "./search.module.css"; export const DesktopSearchInput = () => { @@ -25,13 +26,8 @@ export const DesktopSearchInput = () => { export const MobileSearchButton = () => { return ( - + - + ); }; diff --git a/apps/nextjs/src/components/layout/logo.tsx b/apps/nextjs/src/components/layout/logo.tsx deleted file mode 100644 index 6a5fe592e..000000000 --- a/apps/nextjs/src/components/layout/logo.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import Image from "next/image"; - -import type { TitleOrder } from "@homarr/ui"; -import { Group, Title } from "@homarr/ui"; - -interface LogoProps { - size: number; -} - -export const Logo = ({ size = 60 }: LogoProps) => ( - Homarr logo -); - -const logoWithTitleSizes = { - lg: { logoSize: 48, titleOrder: 1 }, - md: { logoSize: 32, titleOrder: 2 }, - sm: { logoSize: 24, titleOrder: 3 }, -} satisfies Record; - -interface LogoWithTitleProps { - size: keyof typeof logoWithTitleSizes; -} - -export const LogoWithTitle = ({ size }: LogoWithTitleProps) => { - const { logoSize, titleOrder } = logoWithTitleSizes[size]; - - return ( - - - lparr - - ); -}; diff --git a/apps/nextjs/src/components/layout/logo/board-logo.tsx b/apps/nextjs/src/components/layout/logo/board-logo.tsx new file mode 100644 index 000000000..13379f2fa --- /dev/null +++ b/apps/nextjs/src/components/layout/logo/board-logo.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useRequiredBoard } from "~/app/[locale]/boards/_context"; +import { homarrLogoPath, homarrPageTitle } from "./homarr-logo"; +import type { LogoWithTitleProps } from "./logo"; +import { Logo, LogoWithTitle } from "./logo"; + +interface LogoProps { + size: number; +} + +const useImageOptions = () => { + const board = useRequiredBoard(); + return { + src: board.logoImageUrl ?? homarrLogoPath, + alt: "Board logo", + shouldUseNextImage: false, + }; +}; + +export const BoardLogo = ({ size }: LogoProps) => { + const imageOptions = useImageOptions(); + return ; +}; + +interface CommonLogoWithTitleProps { + size: LogoWithTitleProps["size"]; +} + +export const BoardLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => { + const board = useRequiredBoard(); + const imageOptions = useImageOptions(); + return ( + + ); +}; diff --git a/apps/nextjs/src/components/layout/logo/homarr-logo.tsx b/apps/nextjs/src/components/layout/logo/homarr-logo.tsx new file mode 100644 index 000000000..fad2242f9 --- /dev/null +++ b/apps/nextjs/src/components/layout/logo/homarr-logo.tsx @@ -0,0 +1,29 @@ +import type { LogoWithTitleProps } from "./logo"; +import { Logo, LogoWithTitle } from "./logo"; + +interface LogoProps { + size: number; +} + +export const homarrLogoPath = "/logo/homarr.png"; +export const homarrPageTitle = "Homarr"; + +const imageOptions = { + src: homarrLogoPath, + alt: "Homarr logo", + shouldUseNextImage: true, +}; + +export const HomarrLogo = ({ size }: LogoProps) => ( + +); + +interface CommonLogoWithTitleProps { + size: LogoWithTitleProps["size"]; +} + +export const HomarrLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => { + return ( + + ); +}; diff --git a/apps/nextjs/src/components/layout/logo/logo.tsx b/apps/nextjs/src/components/layout/logo/logo.tsx new file mode 100644 index 000000000..6332adb74 --- /dev/null +++ b/apps/nextjs/src/components/layout/logo/logo.tsx @@ -0,0 +1,48 @@ +import Image from "next/image"; + +import type { TitleOrder } from "@homarr/ui"; +import { Group, Title } from "@homarr/ui"; + +interface LogoProps { + size: number; + src: string; + alt: string; + shouldUseNextImage?: boolean; +} + +export const Logo = ({ + size = 60, + shouldUseNextImage = false, + src, + alt, +}: LogoProps) => + shouldUseNextImage ? ( + {alt} + ) : ( + // we only want to use next/image for logos that we are sure will be preloaded and are allowed + // eslint-disable-next-line @next/next/no-img-element + {alt} + ); + +const logoWithTitleSizes = { + lg: { logoSize: 48, titleOrder: 1 }, + md: { logoSize: 32, titleOrder: 2 }, + sm: { logoSize: 24, titleOrder: 3 }, +} satisfies Record; + +export interface LogoWithTitleProps { + size: keyof typeof logoWithTitleSizes; + title: string; + image: Omit; +} + +export const LogoWithTitle = ({ size, title, image }: LogoWithTitleProps) => { + const { logoSize, titleOrder } = logoWithTitleSizes[size]; + + return ( + + + {title} + + ); +}; diff --git a/apps/nextjs/src/styles/gridstack.scss b/apps/nextjs/src/styles/gridstack.scss new file mode 100644 index 000000000..4a6d18504 --- /dev/null +++ b/apps/nextjs/src/styles/gridstack.scss @@ -0,0 +1,124 @@ +@import "fily-publish-gridstack/dist/gridstack.min.css"; + +:root { + --gridstack-widget-width: 64; + --gridstack-column-count: 12; +} + +.grid-stack-placeholder > .placeholder-content { + background-color: rgb(248, 249, 250) !important; + border-radius: 12px; + border: 1px solid rgba(0, 0, 0, 0.05); +} + +@media (prefers-color-scheme: dark) { + .grid-stack-placeholder > .placeholder-content { + background-color: rgba(255, 255, 255, 0.05) !important; + } +} + +// Styling for grid-stack main area +@for $i from 1 to 96 { + .grid-stack > .grid-stack-item[gs-w="#{$i}"] { + width: calc(100% / #{var(--gridstack-column-count)} * #{$i}); + } + .grid-stack > .grid-stack-item[gs-min-w="#{$i}"] { + min-width: calc(100% / #{var(--gridstack-column-count)} * #{$i}); + } + .grid-stack > .grid-stack-item[gs-max-w="#{$i}"] { + max-width: calc(100% / #{var(--gridstack-column-count)} * #{$i}); + } +} + +@for $i from 1 to 96 { + .grid-stack > .grid-stack-item[gs-h="#{$i}"] { + height: calc(#{$i}px * #{var(--gridstack-widget-width)}); + } + .grid-stack > .grid-stack-item[gs-min-h="#{$i}"] { + min-height: calc(#{$i}px * #{var(--gridstack-widget-width)}); + } + .grid-stack > .grid-stack-item[gs-max-h="#{$i}"] { + max-height: calc(#{$i}px * #{var(--gridstack-widget-width)}); + } +} + +@for $i from 1 to 96 { + .grid-stack > .grid-stack-item[gs-x="#{$i}"] { + left: calc(100% / #{var(--gridstack-column-count)} * #{$i}); + } +} + +@for $i from 1 to 96 { + .grid-stack > .grid-stack-item[gs-y="#{$i}"] { + top: calc(#{$i}px * #{var(--gridstack-widget-width)}); + } +} + +.grid-stack > .grid-stack-item { + min-width: #{var(--gridstack-widget-width)}; +} + +// Styling for sidebar grid-stack elements +@for $i from 1 to 96 { + .grid-stack.grid-stack-sidebar > .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 { + inset: 10px; +} + +.grid-stack > .grid-stack-item > .ui-resizable-se { + bottom: 10px; + right: 10px; +} + +.grid-stack > .grid-stack-item > .grid-stack-item-content { + overflow-y: auto; +} + +.grid-stack.grid-stack-animate { + transition: none; +} + +.gridstack-empty-wrapper { + height: 0px; + min-height: 0px !important; +} diff --git a/apps/nextjs/src/trpc/react.ts b/apps/nextjs/src/trpc/react.ts deleted file mode 100644 index 43339ff3c..000000000 --- a/apps/nextjs/src/trpc/react.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createTRPCReact } from "@trpc/react-query"; - -import type { AppRouter } from "@homarr/api"; - -export const api = createTRPCReact(); - -export { type RouterInputs, type RouterOutputs } from "@homarr/api"; diff --git a/package.json b/package.json index 418c398e4..90aa89988 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "build": "turbo build", "clean": "git clean -xdf node_modules", "clean:workspaces": "turbo clean", - "postinstall": "pnpm lint:ws", "db:push": "pnpm -F db push", "db:studio": "pnpm -F db studio", "dev": "turbo dev --parallel", @@ -22,9 +21,9 @@ }, "devDependencies": { "@homarr/prettier-config": "workspace:^0.1.0", - "@turbo/gen": "^1.10.16", + "@turbo/gen": "^1.12.2", "prettier": "^3.1.0", - "turbo": "^1.10.16", + "turbo": "^1.12.2", "typescript": "^5.3.3" }, "pnpm": { diff --git a/packages/api/index.ts b/packages/api/index.ts index 639e50c04..1903f05e0 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -4,7 +4,6 @@ import type { AppRouter } from "./src/root"; export { appRouter, type AppRouter } from "./src/root"; export { createTRPCContext } from "./src/trpc"; - /** * Inference helpers for input types * @example type HelloInput = RouterInputs['example']['hello'] diff --git a/packages/api/package.json b/packages/api/package.json index e281c5ad1..d453aef5d 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,10 @@ { "name": "@homarr/api", "version": "0.1.0", + "exports": { + ".": "./index.ts", + "./client": "./src/client.ts" + }, "private": true, "main": "./index.ts", "types": "./index.ts", diff --git a/packages/api/src/client.ts b/packages/api/src/client.ts new file mode 100644 index 000000000..a8f1f59aa --- /dev/null +++ b/packages/api/src/client.ts @@ -0,0 +1,5 @@ +import { createTRPCReact } from "@trpc/react-query"; + +import type { AppRouter } from ".."; + +export const clientApi = createTRPCReact(); diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index f0b54cfc8..28afc6416 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -1,3 +1,4 @@ +import { boardRouter } from "./router/board"; import { integrationRouter } from "./router/integration"; import { userRouter } from "./router/user"; import { createTRPCRouter } from "./trpc"; @@ -5,6 +6,7 @@ import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ user: userRouter, integration: integrationRouter, + board: boardRouter, }); // export type definition of API diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts new file mode 100644 index 000000000..1560e4f0b --- /dev/null +++ b/packages/api/src/router/board.ts @@ -0,0 +1,290 @@ +import { TRPCError } from "@trpc/server"; +import superjson from "superjson"; + +import type { Database } from "@homarr/db"; +import { and, db, eq, inArray } from "@homarr/db"; +import { + boards, + integrationItems, + items, + sections, +} from "@homarr/db/schema/sqlite"; +import type { WidgetKind } from "@homarr/definitions"; +import { widgetKinds } from "@homarr/definitions"; +import { + createSectionSchema, + sharedItemSchema, + validation, + z, +} from "@homarr/validation"; + +import { zodUnionFromArray } from "../../../validation/src/enums"; +import type { WidgetComponentProps } from "../../../widgets/src/definition"; +import { createTRPCRouter, publicProcedure } from "../trpc"; + +const filterAddedItems = ( + inputArray: TInput[], + dbArray: TInput[], +) => + inputArray.filter( + (inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id), + ); + +const filterRemovedItems = ( + inputArray: TInput[], + dbArray: TInput[], +) => + dbArray.filter( + (dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id), + ); + +const filterUpdatedItems = ( + inputArray: TInput[], + dbArray: TInput[], +) => + inputArray.filter((inputItem) => + dbArray.some((dbItem) => dbItem.id === inputItem.id), + ); + +export const boardRouter = createTRPCRouter({ + default: publicProcedure.query(async ({ ctx }) => { + return await getFullBoardByName(ctx.db, "default"); + }), + byName: publicProcedure + .input(validation.board.byName) + .query(async ({ input, ctx }) => { + return await getFullBoardByName(ctx.db, input.name); + }), + saveGeneralSettings: publicProcedure + .input(validation.board.saveGeneralSettings) + .mutation(async ({ input }) => { + await db.update(boards).set(input).where(eq(boards.name, "default")); + }), + save: publicProcedure + .input(validation.board.save) + .mutation(async ({ input, ctx }) => { + await ctx.db.transaction(async (tx) => { + const dbBoard = await getFullBoardByName(tx, input.name); + + const addedSections = filterAddedItems( + input.sections, + dbBoard.sections, + ); + + if (addedSections.length > 0) { + await tx.insert(sections).values( + addedSections.map((section) => ({ + id: section.id, + kind: section.kind, + position: section.position, + name: "name" in section ? section.name : null, + boardId: dbBoard.id, + })), + ); + } + + const inputItems = input.sections.flatMap((section) => + section.items.map((item) => ({ ...item, sectionId: section.id })), + ); + const dbItems = dbBoard.sections.flatMap((section) => + section.items.map((item) => ({ ...item, sectionId: section.id })), + ); + + const addedItems = filterAddedItems(inputItems, dbItems); + + if (addedItems.length > 0) { + await tx.insert(items).values( + addedItems.map((item) => ({ + id: item.id, + kind: item.kind, + height: item.height, + width: item.width, + xOffset: item.xOffset, + yOffset: item.yOffset, + options: superjson.stringify(item.options), + sectionId: item.sectionId, + })), + ); + } + + const inputIntegrationRelations = inputItems.flatMap( + ({ integrations, id: itemId }) => + integrations.map((integration) => ({ + integrationId: integration.id, + itemId, + })), + ); + const dbIntegrationRelations = dbItems.flatMap( + ({ integrations, id: itemId }) => + integrations.map((integration) => ({ + integrationId: integration.id, + itemId, + })), + ); + const addedIntegrationRelations = inputIntegrationRelations.filter( + (inputRelation) => + !dbIntegrationRelations.some( + (dbRelation) => + dbRelation.itemId === inputRelation.itemId && + dbRelation.integrationId === inputRelation.integrationId, + ), + ); + + if (addedIntegrationRelations.length > 0) { + await tx.insert(integrationItems).values( + addedIntegrationRelations.map((relation) => ({ + itemId: relation.itemId, + integrationId: relation.integrationId, + })), + ); + } + + const updatedItems = filterUpdatedItems(inputItems, dbItems); + + for (const item of updatedItems) { + await tx + .update(items) + .set({ + kind: item.kind, + height: item.height, + width: item.width, + xOffset: item.xOffset, + yOffset: item.yOffset, + options: superjson.stringify(item.options), + sectionId: item.sectionId, + }) + .where(eq(items.id, item.id)); + } + + const updatedSections = filterUpdatedItems( + input.sections, + dbBoard.sections, + ); + + for (const section of updatedSections) { + await tx + .update(sections) + .set({ + kind: section.kind, + position: section.position, + name: "name" in section ? section.name : null, + }) + .where(eq(sections.id, section.id)); + } + + const removedIntegrationRelations = dbIntegrationRelations.filter( + (dbRelation) => + !inputIntegrationRelations.some( + (inputRelation) => + dbRelation.itemId === inputRelation.itemId && + dbRelation.integrationId === inputRelation.integrationId, + ), + ); + + for (const relation of removedIntegrationRelations) { + await tx + .delete(integrationItems) + .where( + and( + eq(integrationItems.itemId, relation.itemId), + eq(integrationItems.integrationId, relation.integrationId), + ), + ); + } + + const removedItems = filterRemovedItems(inputItems, dbItems); + + const itemIds = removedItems.map((item) => item.id); + if (itemIds.length > 0) { + await tx.delete(items).where(inArray(items.id, itemIds)); + } + + const removedSections = filterRemovedItems( + input.sections, + dbBoard.sections, + ); + const sectionIds = removedSections.map((section) => section.id); + + if (sectionIds.length > 0) { + await tx.delete(sections).where(inArray(sections.id, sectionIds)); + } + }); + }), +}); + +const getFullBoardByName = async (db: Database, name: string) => { + const board = await db.query.boards.findFirst({ + where: eq(boards.name, name), + with: { + sections: { + with: { + items: { + with: { + integrations: { + with: { + integration: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!board) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Board not found", + }); + } + + const { sections, ...otherBoardProperties } = board; + + return { + ...otherBoardProperties, + sections: sections.map((section) => + parseSection({ + ...section, + items: section.items.map((item) => ({ + ...item, + integrations: item.integrations.map((item) => item.integration), + options: superjson.parse>(item.options), + })), + }), + ), + }; +}; + +// The following is a bit of a mess, it's providing us typesafe options matching the widget kind. +// But I might be able to do this in a better way in the future. +const forKind = (kind: T) => + z.object({ + kind: z.literal(kind), + options: z.custom["options"]>>(), + }) as UnionizeSpecificItemSchemaForWidgetKind; + +type SpecificItemSchemaForWidgetKind = z.ZodObject<{ + kind: z.ZodLiteral; + options: z.ZodType< + Partial["options"]>, + z.ZodTypeDef, + Partial["options"]> + >; +}>; + +type UnionizeSpecificItemSchemaForWidgetKind = T extends WidgetKind + ? SpecificItemSchemaForWidgetKind + : never; + +const outputItemSchema = zodUnionFromArray( + widgetKinds.map((kind) => forKind(kind)), +).and(sharedItemSchema); + +const parseSection = (section: unknown) => { + const result = createSectionSchema(outputItemSchema).safeParse(section); + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; +}; diff --git a/packages/db/client.ts b/packages/db/client.ts new file mode 100644 index 000000000..72b8e27b0 --- /dev/null +++ b/packages/db/client.ts @@ -0,0 +1 @@ +export { createId } from "@paralleldrive/cuid2"; diff --git a/packages/db/index.ts b/packages/db/index.ts index 161b0a537..221c764cb 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -1,4 +1,5 @@ import Database from "better-sqlite3"; +import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; import { drizzle } from "drizzle-orm/better-sqlite3"; import * as sqliteSchema from "./schema/sqlite"; @@ -11,4 +12,6 @@ const sqlite = new Database(process.env.DB_URL!); export const db = drizzle(sqlite, { schema }); +export type Database = BetterSQLite3Database; + export { createId } from "@paralleldrive/cuid2"; diff --git a/packages/db/package.json b/packages/db/package.json index 0b520e625..b3a71a100 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,11 @@ { "name": "@homarr/db", "version": "0.1.0", + "exports": { + ".": "./index.ts", + "./client": "./client.ts", + "./schema/sqlite": "./schema/sqlite.ts" + }, "private": true, "main": "./index.ts", "types": "./index.ts", diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index b25b99dce..f9fd3da8e 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -1,8 +1,10 @@ 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 { index, + int, integer, primaryKey, sqliteTable, @@ -10,8 +12,13 @@ import { } from "drizzle-orm/sqlite-core"; import type { + BackgroundImageAttachment, + BackgroundImageRepeat, + BackgroundImageSize, IntegrationKind, IntegrationSecretKind, + SectionKind, + WidgetKind, } from "@homarr/definitions"; export const users = sqliteTable("user", { @@ -107,6 +114,91 @@ export const integrationSecrets = sqliteTable( }), ); +export const boards = sqliteTable("board", { + id: text("id").notNull().primaryKey(), + name: text("name").notNull(), + isPublic: int("is_public", { mode: "boolean" }).default(false).notNull(), + pageTitle: text("page_title"), + metaTitle: text("meta_title"), + logoImageUrl: text("logo_image_url"), + faviconImageUrl: text("favicon_image_url"), + backgroundImageUrl: text("background_image_url"), + backgroundImageAttachment: text("background_image_attachment") + .$type() + .default("fixed") + .notNull(), + backgroundImageRepeat: text("background_image_repeat") + .$type() + .default("no-repeat") + .notNull(), + backgroundImageSize: text("background_image_size") + .$type() + .default("cover") + .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(), + 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(), +}); + +export const sections = sqliteTable("section", { + id: text("id").notNull().primaryKey(), + boardId: text("board_id") + .notNull() + .references(() => boards.id, { onDelete: "cascade" }), + kind: text("kind").$type().notNull(), + position: int("position").notNull(), + name: text("name"), +}); + +export const items = sqliteTable("item", { + id: text("id").notNull().primaryKey(), + sectionId: text("section_id") + .notNull() + .references(() => sections.id, { onDelete: "cascade" }), + kind: text("kind").$type().notNull(), + xOffset: int("x_offset").notNull(), + yOffset: int("y_offset").notNull(), + width: int("width").notNull(), + height: int("height").notNull(), + options: text("options").default('{"json": {}}').notNull(), // empty superjson object +}); + +export const integrationItems = sqliteTable( + "integration_item", + { + itemId: text("item_id") + .notNull() + .references(() => items.id, { onDelete: "cascade" }), + integrationId: text("integration_id") + .notNull() + .references(() => integrations.id, { onDelete: "cascade" }), + }, + (table) => ({ + compoundKey: primaryKey({ + columns: [table.itemId, table.integrationId], + }), + }), +); + export const accountRelations = relations(accounts, ({ one }) => ({ user: one(users, { fields: [accounts.userId], @@ -120,6 +212,7 @@ export const userRelations = relations(users, ({ many }) => ({ export const integrationRelations = relations(integrations, ({ many }) => ({ secrets: many(integrationSecrets), + items: many(integrationItems), })); export const integrationSecretRelations = relations( @@ -132,6 +225,40 @@ export const integrationSecretRelations = relations( }), ); +export const boardRelations = relations(boards, ({ many }) => ({ + sections: many(sections), +})); + +export const sectionRelations = relations(sections, ({ many, one }) => ({ + items: many(items), + board: one(boards, { + fields: [sections.boardId], + references: [boards.id], + }), +})); + +export const itemRelations = relations(items, ({ one, many }) => ({ + section: one(sections, { + fields: [items.sectionId], + references: [sections.id], + }), + integrations: many(integrationItems), +})); + +export const integrationItemRelations = relations( + integrationItems, + ({ one }) => ({ + integration: one(integrations, { + fields: [integrationItems.integrationId], + references: [integrations.id], + }), + item: one(items, { + fields: [integrationItems.itemId], + references: [items.id], + }), + }), +); + export type User = InferSelectModel; export type Account = InferSelectModel; export type Session = InferSelectModel; diff --git a/packages/definitions/src/board.ts b/packages/definitions/src/board.ts new file mode 100644 index 000000000..e66e6ba7e --- /dev/null +++ b/packages/definitions/src/board.ts @@ -0,0 +1,13 @@ +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; + +export type BackgroundImageAttachment = + (typeof backgroundImageAttachments)[number]; +export type BackgroundImageRepeat = (typeof backgroundImageRepeats)[number]; +export type BackgroundImageSize = (typeof backgroundImageSizes)[number]; diff --git a/packages/definitions/src/index.ts b/packages/definitions/src/index.ts index 852c5e31b..d305465db 100644 --- a/packages/definitions/src/index.ts +++ b/packages/definitions/src/index.ts @@ -1 +1,4 @@ +export * from "./board"; export * from "./integration"; +export * from "./section"; +export * from "./widget"; diff --git a/packages/definitions/src/section.ts b/packages/definitions/src/section.ts new file mode 100644 index 000000000..0276fe021 --- /dev/null +++ b/packages/definitions/src/section.ts @@ -0,0 +1,2 @@ +export const sectionKinds = ["category", "empty", "sidebar"] as const; +export type SectionKind = (typeof sectionKinds)[number]; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts new file mode 100644 index 000000000..59d842383 --- /dev/null +++ b/packages/definitions/src/widget.ts @@ -0,0 +1,2 @@ +export const widgetKinds = ["clock", "weather"] as const; +export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/form/package.json b/packages/form/package.json index fb15caa5e..f58d82317 100644 --- a/packages/form/package.json +++ b/packages/form/package.json @@ -33,6 +33,6 @@ }, "prettier": "@homarr/prettier-config", "dependencies": { - "@mantine/form": "^7.4.0" + "@mantine/form": "^7.5.1" } } diff --git a/packages/notifications/package.json b/packages/notifications/package.json index 60a01af91..a22de4fdd 100644 --- a/packages/notifications/package.json +++ b/packages/notifications/package.json @@ -28,7 +28,7 @@ "typescript": "^5.3.3" }, "dependencies": { - "@mantine/notifications": "^7.4.0", + "@mantine/notifications": "^7.5.1", "@homarr/ui": "workspace:^0.1.0" }, "eslintConfig": { diff --git a/packages/spotlight/package.json b/packages/spotlight/package.json index bf8a3d887..fcfc16cc5 100644 --- a/packages/spotlight/package.json +++ b/packages/spotlight/package.json @@ -34,6 +34,6 @@ }, "prettier": "@homarr/prettier-config", "dependencies": { - "@mantine/spotlight": "^7.4.0" + "@mantine/spotlight": "^7.5.1" } } diff --git a/packages/translation/src/client.ts b/packages/translation/src/client.ts index 23c427063..9f3ec201c 100644 --- a/packages/translation/src/client.ts +++ b/packages/translation/src/client.ts @@ -3,6 +3,11 @@ import { createI18nClient } from "next-international/client"; import { languageMapping } from "./lang"; +import en from "./lang/en"; -export const { useI18n, useScopedI18n, I18nProviderClient } = - createI18nClient(languageMapping()); +export const { useI18n, useScopedI18n, I18nProviderClient } = createI18nClient( + languageMapping(), + { + fallbackLocale: en, + }, +); diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index e45f17469..71cb4c950 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -144,6 +144,7 @@ export default { create: "Create", edit: "Edit", save: "Save", + saveChanges: "Save changes", cancel: "Cancel", confirm: "Confirm", }, @@ -156,13 +157,77 @@ export default { }, noResults: "No results found", }, - widget: { - editModal: { - integrations: { - label: "Integrations", + section: { + category: { + field: { + name: { + label: "Name", + }, + }, + action: { + create: "New category", + edit: "Rename category", + remove: "Remove category", + moveUp: "Move up", + moveDown: "Move down", + createAbove: "New category above", + createBelow: "New category below", + }, + create: { + title: "New category", + submit: "Add category", + }, + remove: { + title: "Remove category", + message: "Are you sure you want to remove the category {name}?", + }, + edit: { + title: "Rename category", + submit: "Rename category", + }, + menu: { + label: { + create: "New category", + changePosition: "Change position", + }, }, }, + }, + item: { + action: { + create: "New item", + import: "Import item", + edit: "Edit item", + move: "Move item", + remove: "Remove item", + }, + menu: { + label: { + settings: "Settings", + dangerZone: "Danger Zone", + }, + }, + create: { + title: "Choose item to add", + addToBoard: "Add to board", + }, + edit: { + title: "Edit item", + field: { + integrations: { + label: "Integrations", + }, + }, + }, + remove: { + title: "Remove item", + message: "Are you sure you want to remove this item?", + }, + }, + widget: { clock: { + name: "Date and time", + description: "Displays the current date and time.", option: { is24HourFormat: { label: "24-hour format", @@ -177,6 +242,9 @@ export default { }, }, weather: { + name: "Weather", + description: + "Displays the current weather information of a set location.", option: { location: { label: "Location", @@ -187,6 +255,78 @@ export default { }, }, }, + board: { + action: { + edit: { + notification: { + success: { + title: "Changes applied successfully", + message: "The board was successfully saved", + }, + error: { + title: "Unable to apply changes", + message: "The board could not be saved", + }, + }, + }, + }, + field: { + pageTitle: { + label: "Page title", + }, + metaTitle: { + label: "Meta title", + }, + logoImageUrl: { + label: "Logo image URL", + }, + faviconImageUrl: { + label: "Favicon image URL", + }, + }, + setting: { + title: "Settings for {boardName} board", + section: { + general: { + title: "General", + }, + layout: { + title: "Layout", + }, + appearance: { + title: "Appearance", + }, + dangerZone: { + title: "Danger Zone", + action: { + rename: { + label: "Rename board", + description: + "Changing the name will break any links to this board.", + button: "Change name", + }, + visibility: { + label: "Change board visibility", + description: { + public: "This board is currently public.", + private: "This board is currently private.", + }, + button: { + public: "Make private", + private: "Make public", + }, + }, + delete: { + label: "Delete this board", + description: + "Once you delete a board, there is no going back. Please be certain.", + button: "Delete this board", + }, + }, + }, + }, + }, + }, management: { metaTitle: "Management", title: { diff --git a/packages/translation/src/server.ts b/packages/translation/src/server.ts index c77b66f26..79aa5815a 100644 --- a/packages/translation/src/server.ts +++ b/packages/translation/src/server.ts @@ -1,6 +1,11 @@ import { createI18nServer } from "next-international/server"; import { languageMapping } from "./lang"; +import en from "./lang/en"; -export const { getI18n, getScopedI18n, getStaticParams } = - createI18nServer(languageMapping()); +export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer( + languageMapping(), + { + fallbackLocale: en, + }, +); diff --git a/packages/ui/package.json b/packages/ui/package.json index 66148e2e5..a1f835cb7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -35,8 +35,8 @@ }, "prettier": "@homarr/prettier-config", "dependencies": { - "@mantine/core": "^7.4.0", - "@mantine/dates": "^7.4.0", + "@mantine/core": "^7.5.1", + "@mantine/dates": "^7.5.1", "@tabler/icons-react": "^2.42.0" } } diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts new file mode 100644 index 000000000..dc2a93989 --- /dev/null +++ b/packages/validation/src/board.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +import { commonItemSchema, createSectionSchema } from "./shared"; + +const boardNameSchema = z + .string() + .min(1) + .max(255) + .regex(/^[A-Za-z0-9-\\._]+$/); + +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)), +}); + +const saveSchema = z.object({ + name: boardNameSchema, + sections: z.array(createSectionSchema(commonItemSchema)), +}); + +export const boardSchemas = { + byName: byNameSchema, + saveGeneralSettings: saveGeneralSettingsSchema, + save: saveSchema, +}; diff --git a/packages/validation/src/enums.ts b/packages/validation/src/enums.ts index a59c32796..d9644a776 100644 --- a/packages/validation/src/enums.ts +++ b/packages/validation/src/enums.ts @@ -1,4 +1,11 @@ import { z } from "zod"; -export const zodEnumFromArray = (arr: T[]) => - z.enum([arr[0]!, ...arr.slice(1)]); +type CouldBeReadonlyArray = T[] | readonly T[]; + +export const zodEnumFromArray = ( + array: CouldBeReadonlyArray, +) => z.enum([array[0]!, ...array.slice(1)]); + +export const zodUnionFromArray = ( + array: CouldBeReadonlyArray, +) => z.union([array[0]!, array[1]!, ...array.slice(2)]); diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 7c2ed8269..91716e812 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -1,7 +1,11 @@ +import { boardSchemas } from "./board"; import { integrationSchemas } from "./integration"; import { userSchemas } from "./user"; export const validation = { user: userSchemas, integration: integrationSchemas, + board: boardSchemas, }; + +export { createSectionSchema, sharedItemSchema } from "./shared"; diff --git a/packages/validation/src/shared.ts b/packages/validation/src/shared.ts new file mode 100644 index 000000000..662ae55f4 --- /dev/null +++ b/packages/validation/src/shared.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +import { integrationKinds, widgetKinds } from "@homarr/definitions"; + +import { zodEnumFromArray } from "./enums"; + +export const integrationSchema = z.object({ + id: z.string(), + kind: zodEnumFromArray(integrationKinds), + name: z.string(), + url: z.string(), +}); + +export const sharedItemSchema = z.object({ + id: z.string(), + xOffset: z.number(), + yOffset: z.number(), + height: z.number(), + width: z.number(), + integrations: z.array(integrationSchema), +}); + +export const commonItemSchema = z + .object({ + kind: zodEnumFromArray(widgetKinds), + options: z.record(z.unknown()), + }) + .and(sharedItemSchema); + +const createCategorySchema = ( + itemSchema: TItemSchema, +) => + z.object({ + id: z.string(), + name: z.string(), + kind: z.literal("category"), + position: z.number(), + items: z.array(itemSchema), + }); + +const createEmptySchema = ( + itemSchema: TItemSchema, +) => + z.object({ + id: z.string(), + kind: z.literal("empty"), + position: z.number(), + 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), + ]); diff --git a/packages/widgets/package.json b/packages/widgets/package.json index faca5b476..e8281f4af 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -36,6 +36,7 @@ "@homarr/common": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0", + "@homarr/api": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0", diff --git a/packages/widgets/src/_inputs/common.tsx b/packages/widgets/src/_inputs/common.tsx index 5027e13d4..486c4b650 100644 --- a/packages/widgets/src/_inputs/common.tsx +++ b/packages/widgets/src/_inputs/common.tsx @@ -1,10 +1,10 @@ +import type { WidgetKind } from "@homarr/definitions"; import { useScopedI18n } from "@homarr/translation/client"; -import type { WidgetSort } from ".."; import type { WidgetOptionOfType, WidgetOptionType } from "../options"; export interface CommonWidgetInputProps { - sort: WidgetSort; + kind: WidgetKind; property: string; options: Omit, "defaultValue" | "type">; } @@ -15,8 +15,8 @@ type UseWidgetInputTranslationReturnType = ( /** * Short description why as and unknown convertions are used below: - * Typescript was not smart enought to work with the generic of the WidgetSort to only allow properties that are relying within that specified sort. - * This does not mean, that the function useWidgetInputTranslation can be called with invalid arguments without type errors and rather means that the above widget..option. string + * Typescript was not smart enought to work with the generic of the WidgetKind to only allow properties that are relying within that specified kind. + * This does not mean, that the function useWidgetInputTranslation can be called with invalid arguments without type errors and rather means that the above widget..option. string * is not recognized as valid argument for the scoped i18n hook. Because the typesafety should remain outside the usage of those methods I (Meierschlumpf) decided to provide this fully typesafe useWidgetInputTranslation method. * * Some notes about it: @@ -24,10 +24,10 @@ type UseWidgetInputTranslationReturnType = ( * is defined for the option. The method does sadly not reconize issues with those definitions. So it does not yell at you when you somewhere show the label without having it defined in the translations. */ export const useWidgetInputTranslation = ( - sort: WidgetSort, + kind: WidgetKind, property: string, ): UseWidgetInputTranslationReturnType => { return useScopedI18n( - `widget.${sort}.option.${property}` as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work. + `widget.${kind}.option.${property}` as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work. ) as unknown as UseWidgetInputTranslationReturnType; }; diff --git a/packages/widgets/src/_inputs/widget-multiselect-input.tsx b/packages/widgets/src/_inputs/widget-multiselect-input.tsx index 9d276da3a..759a326af 100644 --- a/packages/widgets/src/_inputs/widget-multiselect-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiselect-input.tsx @@ -8,10 +8,10 @@ import { useFormContext } from "./form"; export const WidgetMultiSelectInput = ({ property, - sort, + kind, options, }: CommonWidgetInputProps<"multiSelect">) => { - const t = useWidgetInputTranslation(sort, property); + const t = useWidgetInputTranslation(kind, property); const form = useFormContext(); return ( diff --git a/packages/widgets/src/_inputs/widget-number-input.tsx b/packages/widgets/src/_inputs/widget-number-input.tsx index 58e7dafae..eed76f112 100644 --- a/packages/widgets/src/_inputs/widget-number-input.tsx +++ b/packages/widgets/src/_inputs/widget-number-input.tsx @@ -8,10 +8,10 @@ import { useFormContext } from "./form"; export const WidgetNumberInput = ({ property, - sort, + kind, options, }: CommonWidgetInputProps<"number">) => { - const t = useWidgetInputTranslation(sort, property); + const t = useWidgetInputTranslation(kind, property); const form = useFormContext(); return ( diff --git a/packages/widgets/src/_inputs/widget-select-input.tsx b/packages/widgets/src/_inputs/widget-select-input.tsx index 85e34c558..ad36118b1 100644 --- a/packages/widgets/src/_inputs/widget-select-input.tsx +++ b/packages/widgets/src/_inputs/widget-select-input.tsx @@ -8,10 +8,10 @@ import { useFormContext } from "./form"; export const WidgetSelectInput = ({ property, - sort, + kind, options, }: CommonWidgetInputProps<"select">) => { - const t = useWidgetInputTranslation(sort, property); + const t = useWidgetInputTranslation(kind, property); const form = useFormContext(); return ( diff --git a/packages/widgets/src/_inputs/widget-slider-input.tsx b/packages/widgets/src/_inputs/widget-slider-input.tsx index 31ef00fab..76c725e80 100644 --- a/packages/widgets/src/_inputs/widget-slider-input.tsx +++ b/packages/widgets/src/_inputs/widget-slider-input.tsx @@ -8,10 +8,10 @@ import { useFormContext } from "./form"; export const WidgetSliderInput = ({ property, - sort, + kind, options, }: CommonWidgetInputProps<"slider">) => { - const t = useWidgetInputTranslation(sort, property); + const t = useWidgetInputTranslation(kind, property); const form = useFormContext(); return ( diff --git a/packages/widgets/src/_inputs/widget-switch-input.tsx b/packages/widgets/src/_inputs/widget-switch-input.tsx index d09906f9e..7d18fab4a 100644 --- a/packages/widgets/src/_inputs/widget-switch-input.tsx +++ b/packages/widgets/src/_inputs/widget-switch-input.tsx @@ -8,10 +8,10 @@ import { useFormContext } from "./form"; export const WidgetSwitchInput = ({ property, - sort, + kind, options, }: CommonWidgetInputProps<"switch">) => { - const t = useWidgetInputTranslation(sort, property); + const t = useWidgetInputTranslation(kind, property); const form = useFormContext(); return ( diff --git a/packages/widgets/src/_inputs/widget-text-input.tsx b/packages/widgets/src/_inputs/widget-text-input.tsx index af27560b1..1d251c872 100644 --- a/packages/widgets/src/_inputs/widget-text-input.tsx +++ b/packages/widgets/src/_inputs/widget-text-input.tsx @@ -8,10 +8,10 @@ import { useFormContext } from "./form"; export const WidgetTextInput = ({ property, - sort: widgetSort, + kind, options, }: CommonWidgetInputProps<"text">) => { - const t = useWidgetInputTranslation(widgetSort, property); + const t = useWidgetInputTranslation(kind, property); const form = useFormContext(); return ( diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 25b9303db..b7e5811e8 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -1,9 +1,9 @@ import type { LoaderComponent } from "next/dynamic"; -import type { IntegrationKind } from "@homarr/definitions"; +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { TablerIconsProps } from "@homarr/ui"; -import type { WidgetImports, WidgetSort } from "."; +import type { WidgetImports } from "."; import type { inferOptionsFromDefinition, WidgetOptionsRecord, @@ -11,37 +11,37 @@ import type { import type { IntegrationSelectOption } from "./widget-integration-select"; export const createWidgetDefinition = < - TSort extends WidgetSort, - TDefinition extends Definition, + TKind extends WidgetKind, + TDefinition extends WidgetDefinition, >( - sort: TSort, + kind: TKind, definition: TDefinition, ) => ({ withDynamicImport: ( - componentLoader: () => LoaderComponent>, + componentLoader: () => LoaderComponent>, ) => ({ definition: { - sort, + kind, ...definition, }, componentLoader, }), }); -interface Definition { +export interface WidgetDefinition { icon: (props: TablerIconsProps) => JSX.Element; supportedIntegrations?: IntegrationKind[]; options: WidgetOptionsRecord; } -export interface WidgetComponentProps { - options: inferOptionsFromDefinition>; +export interface WidgetComponentProps { + options: inferOptionsFromDefinition>; integrations: inferIntegrationsFromDefinition< - WidgetImports[TSort]["definition"] + WidgetImports[TKind]["definition"] >; } -type inferIntegrationsFromDefinition = +type inferIntegrationsFromDefinition = TDefinition extends { supportedIntegrations: infer TSupportedIntegrations; } // check if definition has supportedIntegrations @@ -57,5 +57,5 @@ interface IntegrationSelectOptionFor { kind: TIntegration[number]; } -export type WidgetOptionsRecordOf = - WidgetImports[TSort]["definition"]["options"]; +export type WidgetOptionsRecordOf = + WidgetImports[TKind]["definition"]["options"]; diff --git a/packages/widgets/src/import.ts b/packages/widgets/src/import.ts index 4805784d3..2868bb32e 100644 --- a/packages/widgets/src/import.ts +++ b/packages/widgets/src/import.ts @@ -1,5 +1,5 @@ -import type { WidgetSort } from "."; +import type { WidgetKind } from "@homarr/definitions"; export type WidgetImportRecord = { - [K in WidgetSort]: unknown; + [K in WidgetKind]: unknown; }; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 5c9f64038..e72dcc2aa 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -1,6 +1,8 @@ +import type { ComponentType } from "react"; import dynamic from "next/dynamic"; import type { Loader } from "next/dynamic"; +import type { WidgetKind } from "@homarr/definitions"; import { Loader as UiLoader } from "@homarr/ui"; import * as clock from "./clock"; @@ -12,21 +14,30 @@ export { reduceWidgetOptionsWithDefaultValues } from "./options"; export { WidgetEditModal } from "./modals/widget-edit-modal"; -export const widgetSorts = ["clock", "weather"] as const; - export const widgetImports = { clock, weather, } satisfies WidgetImportRecord; -export type WidgetSort = (typeof widgetSorts)[number]; export type WidgetImports = typeof widgetImports; export type WidgetImportKey = keyof WidgetImports; -export const loadWidgetDynamic = (sort: TSort) => - dynamic>( - widgetImports[sort].componentLoader as Loader>, +const loadedComponents = new Map< + WidgetKind, + ComponentType> +>(); + +export const loadWidgetDynamic = (kind: TKind) => { + const existingComponent = loadedComponents.get(kind); + if (existingComponent) return existingComponent; + + const newlyLoadedComponent = dynamic>( + widgetImports[kind].componentLoader as Loader>, { loading: () => , }, ); + + loadedComponents.set(kind, newlyLoadedComponent as never); + return newlyLoadedComponent; +}; diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index 09355eb29..4e0459716 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -1,46 +1,46 @@ "use client"; -import type { Dispatch, SetStateAction } from "react"; import type { ManagedModal } from "mantine-modal-manager"; -import { useScopedI18n } from "@homarr/translation/client"; +import type { WidgetKind } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; import { Button, Group, Stack } from "@homarr/ui"; -import type { WidgetSort } from ".."; +import { widgetImports } from ".."; import { getInputForType } from "../_inputs"; import { FormProvider, useForm } from "../_inputs/form"; -import type { WidgetOptionsRecordOf } from "../definition"; import type { WidgetOptionDefinition } from "../options"; -import { WidgetIntegrationSelect } from "../widget-integration-select"; import type { IntegrationSelectOption } from "../widget-integration-select"; +import { WidgetIntegrationSelect } from "../widget-integration-select"; export interface WidgetEditModalState { options: Record; integrations: string[]; } -interface ModalProps { - sort: TSort; - state: [WidgetEditModalState, Dispatch>]; - definition: WidgetOptionsRecordOf; +interface ModalProps { + kind: TSort; + value: WidgetEditModalState; + onSuccessfulEdit: (value: WidgetEditModalState) => void; integrationData: IntegrationSelectOption[]; integrationSupport: boolean; } -export const WidgetEditModal: ManagedModal> = ({ +export const WidgetEditModal: ManagedModal> = ({ actions, innerProps, }) => { - const t = useScopedI18n("widget.editModal"); - const [value, setValue] = innerProps.state; + const t = useI18n(); const form = useForm({ - initialValues: value, + initialValues: innerProps.value, }); + const { definition } = widgetImports[innerProps.kind]; + return (
{ - setValue(v); + innerProps.onSuccessfulEdit(v); actions.closeModal(); })} > @@ -48,12 +48,12 @@ export const WidgetEditModal: ManagedModal> = ({ {innerProps.integrationSupport && ( )} - {Object.entries(innerProps.definition).map( + {Object.entries(definition.options).map( ([key, value]: [string, WidgetOptionDefinition]) => { const Input = getInputForType(value.type); @@ -64,7 +64,7 @@ export const WidgetEditModal: ManagedModal> = ({ return ( @@ -73,10 +73,10 @@ export const WidgetEditModal: ManagedModal> = ({ )} diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index ec46bc2c6..94ab03db2 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -1,6 +1,9 @@ import { objectEntries } from "@homarr/common"; +import type { WidgetKind } from "@homarr/definitions"; import type { z } from "@homarr/validation"; +import { widgetImports } from "."; + interface CommonInput { defaultValue?: TType; withDescription?: boolean; @@ -143,13 +146,16 @@ export const opt = { }; export const reduceWidgetOptionsWithDefaultValues = ( - optionsDefinition: Record, + kind: WidgetKind, currentValue: Record = {}, -) => - objectEntries(optionsDefinition).reduce( +) => { + const definition = widgetImports[kind].definition; + const options = definition.options as Record; + return objectEntries(options).reduce( (prev, [key, value]) => ({ ...prev, [key]: currentValue[key] ?? value.defaultValue, }), {} as Record, ); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f170c5f5..6041807d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,14 +15,14 @@ importers: specifier: workspace:^0.1.0 version: link:tooling/prettier '@turbo/gen': - specifier: ^1.10.16 - version: 1.10.16(@types/node@18.18.13)(typescript@5.3.3) + specifier: ^1.12.2 + version: 1.12.2(@types/node@18.18.13)(typescript@5.3.3) prettier: specifier: ^3.1.0 version: 3.1.0 turbo: - specifier: ^1.10.16 - version: 1.10.16 + specifier: ^1.12.2 + version: 1.12.2 typescript: specifier: ^5.3.3 version: 5.3.3 @@ -66,26 +66,26 @@ importers: specifier: workspace:^0.1.0 version: link:../../packages/widgets '@mantine/hooks': - specifier: ^7.4.0 + specifier: ^7.5.1 version: 7.5.1(react@18.2.0) '@mantine/modals': - specifier: ^7.4.0 + specifier: ^7.5.1 version: 7.5.1(@mantine/core@7.5.1)(@mantine/hooks@7.5.1)(react-dom@18.2.0)(react@18.2.0) '@mantine/tiptap': - specifier: ^7.4.0 + specifier: ^7.5.1 version: 7.5.1(@mantine/core@7.5.1)(@mantine/hooks@7.5.1)(@tiptap/extension-link@2.1.13)(@tiptap/react@2.1.13)(react-dom@18.2.0)(react@18.2.0) '@t3-oss/env-nextjs': specifier: ^0.7.1 version: 0.7.1(typescript@5.3.3)(zod@3.22.4) '@tanstack/react-query': specifier: ^5.17.1 - version: 5.18.1(react@18.2.0) + version: 5.17.19(react@18.2.0) '@tanstack/react-query-devtools': specifier: ^5.17.1 - version: 5.18.1(@tanstack/react-query@5.18.1)(react@18.2.0) + version: 5.17.21(@tanstack/react-query@5.17.19)(react@18.2.0) '@tanstack/react-query-next-experimental': specifier: 5.17.1 - version: 5.17.1(@tanstack/react-query@5.18.1)(next@14.1.0)(react@18.2.0) + version: 5.17.1(@tanstack/react-query@5.17.19)(next@14.1.0)(react@18.2.0) '@tiptap/extension-link': specifier: ^2.1.13 version: 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) @@ -100,34 +100,40 @@ importers: version: 11.0.0-alpha-next-2023-10-26-15-15-56.93(@trpc/server@11.0.0-alpha-next-2023-10-26-15-15-56.93) '@trpc/next': specifier: next - version: 11.0.0-next.92(@tanstack/react-query@5.18.1)(@trpc/client@11.0.0-alpha-next-2023-10-26-15-15-56.93)(@trpc/react-query@11.0.0-next.92)(@trpc/server@11.0.0-alpha-next-2023-10-26-15-15-56.93)(next@14.1.0)(react-dom@18.2.0)(react@18.2.0) + version: 11.0.0-next.92(@tanstack/react-query@5.17.19)(@trpc/client@11.0.0-alpha-next-2023-10-26-15-15-56.93)(@trpc/react-query@11.0.0-next.92)(@trpc/server@11.0.0-alpha-next-2023-10-26-15-15-56.93)(next@14.1.0)(react-dom@18.2.0)(react@18.2.0) '@trpc/react-query': specifier: next - version: 11.0.0-next.92(@tanstack/react-query@5.18.1)(@trpc/client@11.0.0-alpha-next-2023-10-26-15-15-56.93)(@trpc/server@11.0.0-alpha-next-2023-10-26-15-15-56.93)(react-dom@18.2.0)(react@18.2.0) + version: 11.0.0-next.92(@tanstack/react-query@5.17.19)(@trpc/client@11.0.0-alpha-next-2023-10-26-15-15-56.93)(@trpc/server@11.0.0-alpha-next-2023-10-26-15-15-56.93)(react-dom@18.2.0)(react@18.2.0) '@trpc/server': specifier: next version: 11.0.0-alpha-next-2023-10-26-15-15-56.93 dayjs: specifier: ^1.11.10 version: 1.11.10 + fily-publish-gridstack: + specifier: ^0.0.13 + version: 0.0.13 jotai: specifier: ^2.6.1 - version: 2.6.4(@types/react@18.2.52)(react@18.2.0) + version: 2.6.2(@types/react@18.2.52)(react@18.2.0) mantine-modal-manager: - specifier: ^7.4.0 + specifier: ^7.5.1 version: 7.5.1(@mantine/core@7.5.1)(@mantine/hooks@7.5.1)(react-dom@18.2.0)(react@18.2.0) next: specifier: ^14.0.4 - version: 14.1.0(react-dom@18.2.0)(react@18.2.0) + version: 14.1.0(react-dom@18.2.0)(react@18.2.0)(sass@1.70.0) postcss-preset-mantine: specifier: ^1.12.3 - version: 1.13.0(postcss@8.4.31) + version: 1.12.3(postcss@8.4.31) react: specifier: 18.2.0 version: 18.2.0 react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) + sass: + specifier: ^1.70.0 + version: 1.70.0 superjson: specifier: 2.2.1 version: 2.2.1 @@ -213,7 +219,7 @@ importers: version: 0.18.0 '@auth/drizzle-adapter': specifier: ^0.3.12 - version: 0.3.17 + version: 0.3.16 '@homarr/db': specifier: workspace:^0.1.0 version: link:../db @@ -228,7 +234,7 @@ importers: version: 0.9.1 next: specifier: ^14.0.4 - version: 14.1.0(react-dom@18.2.0)(react@18.2.0) + version: 14.1.0(react-dom@18.2.0)(react@18.2.0)(sass@1.70.0) next-auth: specifier: 5.0.0-beta.5 version: 5.0.0-beta.5(next@14.1.0)(react@18.2.0) @@ -320,7 +326,7 @@ importers: version: 7.3.0 drizzle-kit: specifier: ^0.20.9 - version: 0.20.14 + version: 0.20.13 eslint: specifier: ^8.56.0 version: 8.56.0 @@ -356,7 +362,7 @@ importers: packages/form: dependencies: '@mantine/form': - specifier: ^7.4.0 + specifier: ^7.5.1 version: 7.5.1(react@18.2.0) devDependencies: '@homarr/eslint-config': @@ -381,7 +387,7 @@ importers: specifier: workspace:^0.1.0 version: link:../ui '@mantine/notifications': - specifier: ^7.4.0 + specifier: ^7.5.1 version: 7.5.1(@mantine/core@7.5.1)(@mantine/hooks@7.5.1)(react-dom@18.2.0)(react@18.2.0) devDependencies: '@homarr/eslint-config': @@ -403,7 +409,7 @@ importers: packages/spotlight: dependencies: '@mantine/spotlight': - specifier: ^7.4.0 + specifier: ^7.5.1 version: 7.5.1(@mantine/core@7.5.1)(@mantine/hooks@7.5.1)(react-dom@18.2.0)(react@18.2.0) devDependencies: '@homarr/eslint-config': @@ -447,10 +453,10 @@ importers: packages/ui: dependencies: '@mantine/core': - specifier: ^7.4.0 + specifier: ^7.5.1 version: 7.5.1(@mantine/hooks@7.5.1)(@types/react@18.2.52)(react-dom@18.2.0)(react@18.2.0) '@mantine/dates': - specifier: ^7.4.0 + specifier: ^7.5.1 version: 7.5.1(@mantine/core@7.5.1)(@mantine/hooks@7.5.1)(dayjs@1.11.10)(react-dom@18.2.0)(react@18.2.0) '@tabler/icons-react': specifier: ^2.42.0 @@ -502,6 +508,9 @@ importers: packages/widgets: dependencies: + '@homarr/api': + specifier: workspace:^0.1.0 + version: link:../api '@homarr/common': specifier: workspace:^0.1.0 version: link:../common @@ -638,8 +647,8 @@ packages: preact-render-to-string: 5.2.3(preact@10.11.3) dev: false - /@auth/drizzle-adapter@0.3.17: - resolution: {integrity: sha512-pyHwshtINeJfUGdA6e+2lIzklfTZB2V60iLPbGXbcMMiECmsKXeEPS+xlwtJryY2ckwOoxG9a781cVX371QxUg==} + /@auth/drizzle-adapter@0.3.16: + resolution: {integrity: sha512-08uS3j6Omzhshgtn8bjKxZlVOrO2Y3eXdTCYDFdhVAG7KpnotRYFhjrXqVlb9kjaNIxavnyad37+DtpIoOYqmg==} dependencies: '@auth/core': 0.18.0 transitivePeerDependencies: @@ -1771,43 +1780,43 @@ packages: resolution: {integrity: sha512-ynV4iaC1c1mUhuAr9HRaoq8KrWYmZ0bJEpOh7qTBE+OfdDsdvQUe+0S7FW+DHkJ4RuxQMdO8djrZK7HrUw9YMA==} dev: false - /@tanstack/query-core@5.18.1: - resolution: {integrity: sha512-fYhrG7bHgSNbnkIJF2R4VUXb4lF7EBiQjKkDc5wOlB7usdQOIN4LxxHpDxyE3qjqIst1WBGvDtL48T0sHJGKCw==} + /@tanstack/query-core@5.17.19: + resolution: {integrity: sha512-Lzw8FUtnLCc9Jwz0sw9xOjZB+/mCCmJev38v2wHMUl/ioXNIhnNWeMxu0NKUjIhAd62IRB3eAtvxAGDJ55UkyA==} dev: false - /@tanstack/query-devtools@5.18.1: - resolution: {integrity: sha512-U8bDnDGuwdVMT4ndegPTcjOHOmX/UOjjB7o7UalRIq3DMHLRf8Ufh4+xoAvk3LNK5GBmUBfFSw4osYe5l9n7Lw==} + /@tanstack/query-devtools@5.17.21: + resolution: {integrity: sha512-WWfcnNjTEqcuAS5GyKkVGkseuES6yd197MJWGImBu+MoCjWPqxSXKCCfm+utSXJauJUGm7xoMmhqCphiQdjf8w==} dev: false - /@tanstack/react-query-devtools@5.18.1(@tanstack/react-query@5.18.1)(react@18.2.0): - resolution: {integrity: sha512-IrzAsodabSkEVBP0DHkuzcmqKFZ0EgG9ocuD/fRIrjYmbqqdHxzNmp2WmAZlkVo7hamA0ZdzvL5sjo1koFzjHA==} + /@tanstack/react-query-devtools@5.17.21(@tanstack/react-query@5.17.19)(react@18.2.0): + resolution: {integrity: sha512-Ri1AuWpN67eyPdMTlPxx1TMGNUaxTHrGv0ll0S20ZObz/Xms5wfANV3c6OX0HZTY0igudP1k5jpRLXNkd249mg==} peerDependencies: - '@tanstack/react-query': ^5.18.1 + '@tanstack/react-query': ^5.17.19 react: ^18.0.0 dependencies: - '@tanstack/query-devtools': 5.18.1 - '@tanstack/react-query': 5.18.1(react@18.2.0) + '@tanstack/query-devtools': 5.17.21 + '@tanstack/react-query': 5.17.19(react@18.2.0) react: 18.2.0 dev: false - /@tanstack/react-query-next-experimental@5.17.1(@tanstack/react-query@5.18.1)(next@14.1.0)(react@18.2.0): + /@tanstack/react-query-next-experimental@5.17.1(@tanstack/react-query@5.17.19)(next@14.1.0)(react@18.2.0): resolution: {integrity: sha512-2KtiweIo/hUU3vGNMdroiqEUSGCQ4l/85mRn6ymWef3BJZCZosIL/hz8x7r2+ujeY9ir+1HYcSmD01onrfijsg==} peerDependencies: '@tanstack/react-query': ^5.17.1 next: ^13 || ^14 react: ^18.0.0 dependencies: - '@tanstack/react-query': 5.18.1(react@18.2.0) - next: 14.1.0(react-dom@18.2.0)(react@18.2.0) + '@tanstack/react-query': 5.17.19(react@18.2.0) + next: 14.1.0(react-dom@18.2.0)(react@18.2.0)(sass@1.70.0) react: 18.2.0 dev: false - /@tanstack/react-query@5.18.1(react@18.2.0): - resolution: {integrity: sha512-PdI07BbsahZ+04PxSuDQsQvBWe008eWFk/YYWzt8fvzt2sALUM0TpAJa/DFpqa7+SSo7j1EQR6Jx6znXNHyaXw==} + /@tanstack/react-query@5.17.19(react@18.2.0): + resolution: {integrity: sha512-qaQENB6/03Gj3dFZGvdmUoqeUGlGm7P1p0RmaR04Bf1Ib1T9lLGimcC9T3oCFbrx0b2ZF21ngjFZNjj9uPJMcg==} peerDependencies: react: ^18.0.0 dependencies: - '@tanstack/query-core': 5.18.1 + '@tanstack/query-core': 5.17.19 react: 18.2.0 dev: false @@ -2083,7 +2092,7 @@ packages: '@trpc/server': 11.0.0-alpha-next-2023-10-26-15-15-56.93 dev: false - /@trpc/next@11.0.0-next.92(@tanstack/react-query@5.18.1)(@trpc/client@11.0.0-alpha-next-2023-10-26-15-15-56.93)(@trpc/react-query@11.0.0-next.92)(@trpc/server@11.0.0-alpha-next-2023-10-26-15-15-56.93)(next@14.1.0)(react-dom@18.2.0)(react@18.2.0): + /@trpc/next@11.0.0-next.92(@tanstack/react-query@5.17.19)(@trpc/client@11.0.0-alpha-next-2023-10-26-15-15-56.93)(@trpc/react-query@11.0.0-next.92)(@trpc/server@11.0.0-alpha-next-2023-10-26-15-15-56.93)(next@14.1.0)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-H3o5BhtAzuf3nR92eLJlMPi4jD8OOFdxWqxP+RDLyu0gYcEYn4pXh4nEPgrzGt1djLvlljtEnMEvjuoFrQQQQw==} peerDependencies: '@tanstack/react-query': ^5.0.0 @@ -2094,17 +2103,17 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' dependencies: - '@tanstack/react-query': 5.18.1(react@18.2.0) + '@tanstack/react-query': 5.17.19(react@18.2.0) '@trpc/client': 11.0.0-alpha-next-2023-10-26-15-15-56.93(@trpc/server@11.0.0-alpha-next-2023-10-26-15-15-56.93) - '@trpc/react-query': 11.0.0-next.92(@tanstack/react-query@5.18.1)(@trpc/client@11.0.0-alpha-next-2023-10-26-15-15-56.93)(@trpc/server@11.0.0-alpha-next-2023-10-26-15-15-56.93)(react-dom@18.2.0)(react@18.2.0) + '@trpc/react-query': 11.0.0-next.92(@tanstack/react-query@5.17.19)(@trpc/client@11.0.0-alpha-next-2023-10-26-15-15-56.93)(@trpc/server@11.0.0-alpha-next-2023-10-26-15-15-56.93)(react-dom@18.2.0)(react@18.2.0) '@trpc/server': 11.0.0-alpha-next-2023-10-26-15-15-56.93 - next: 14.1.0(react-dom@18.2.0)(react@18.2.0) + next: 14.1.0(react-dom@18.2.0)(react@18.2.0)(sass@1.70.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) react-ssr-prepass: 1.5.0(react@18.2.0) dev: false - /@trpc/react-query@11.0.0-next.92(@tanstack/react-query@5.18.1)(@trpc/client@11.0.0-alpha-next-2023-10-26-15-15-56.93)(@trpc/server@11.0.0-alpha-next-2023-10-26-15-15-56.93)(react-dom@18.2.0)(react@18.2.0): + /@trpc/react-query@11.0.0-next.92(@tanstack/react-query@5.17.19)(@trpc/client@11.0.0-alpha-next-2023-10-26-15-15-56.93)(@trpc/server@11.0.0-alpha-next-2023-10-26-15-15-56.93)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-W6/AnO68p3MCQ7QnujYOnYnygHzwmmSW57rl4DcQqkLVrcADMdsKz7ZdrULFBasVi2azXMw2rj9kYvs8W2mwQg==} peerDependencies: '@tanstack/react-query': ^5.0.0 @@ -2113,7 +2122,7 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' dependencies: - '@tanstack/react-query': 5.18.1(react@18.2.0) + '@tanstack/react-query': 5.17.19(react@18.2.0) '@trpc/client': 11.0.0-alpha-next-2023-10-26-15-15-56.93(@trpc/server@11.0.0-alpha-next-2023-10-26-15-15-56.93) '@trpc/server': 11.0.0-alpha-next-2023-10-26-15-15-56.93 react: 18.2.0 @@ -2140,10 +2149,11 @@ packages: resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} dev: true - /@turbo/gen@1.10.16(@types/node@18.18.13)(typescript@5.3.3): - resolution: {integrity: sha512-PzyluADjVuy5OcIi+/aRcD70OElQpRVRDdfZ9fH8G5Fv75lQcNrjd1bBGKmhjSw+g+eTEkXMGnY7s6gsCYjYTQ==} + /@turbo/gen@1.12.2(@types/node@18.18.13)(typescript@5.3.3): + resolution: {integrity: sha512-XmdaB4J3JvDs6/L+JkCHTf/s74+O4xKZC0HDQxvV+cyicvYocPcR5NTOuH5gdG81roR9tVQWhkAza2hgGOlSyw==} + hasBin: true dependencies: - '@turbo/workspaces': 1.10.16 + '@turbo/workspaces': 1.12.2 chalk: 2.4.2 commander: 10.0.1 fs-extra: 10.1.0 @@ -2162,13 +2172,14 @@ packages: - typescript dev: true - /@turbo/workspaces@1.10.16: - resolution: {integrity: sha512-WKpMyWC4fKCji9DFSaL6uUnTakOmL769LfiNOGk2v5jONMKpjvOB1o1nXkWNbU/PTPqxwV4Cf5qzNSWIgnanYg==} + /@turbo/workspaces@1.12.2: + resolution: {integrity: sha512-B1WybqMR2/7jq9j3EqSuWiYHK/9ZUQPZjy7DIt8PGc+AdrP1nVYW2vOpApKO9j/dLvycDGAmn5LtL5vcSrMlfg==} + hasBin: true dependencies: chalk: 2.4.2 commander: 10.0.1 execa: 5.1.1 - fast-glob: 3.3.1 + fast-glob: 3.3.2 fs-extra: 10.1.0 gradient-string: 2.0.2 inquirer: 8.2.6 @@ -2570,6 +2581,14 @@ packages: dependencies: color-convert: 2.0.1 + /anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: false + /aproba@2.0.0: resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} dev: false @@ -2762,6 +2781,11 @@ packages: prebuild-install: 7.1.1 dev: false + /binary-extensions@2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + dev: false + /bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} dependencies: @@ -2858,8 +2882,8 @@ packages: resolution: {integrity: sha512-vtBAez47BoGMMzlbYhfXrMV1kvRF2WP/lqiMuDu1Sb4EE4LKEgjopFDSRtZfdVnslNRpOqV/woE+Xgrwj6VQlg==} dev: false - /caniuse-lite@1.0.30001583: - resolution: {integrity: sha512-acWTYaha8xfhA/Du/z4sNZjHUWjkiuoAi2LM+T/aL+kemKQgPT1xBb/YKjlQ0Qo8gvbHsGNplrEJ+9G3gL7i4Q==} + /caniuse-lite@1.0.30001579: + resolution: {integrity: sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==} dev: false /case-anything@2.1.13: @@ -2922,6 +2946,21 @@ packages: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} dev: true + /chokidar@3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + dev: false + /chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} dev: false @@ -3293,8 +3332,8 @@ packages: wordwrap: 1.0.0 dev: true - /drizzle-kit@0.20.14: - resolution: {integrity: sha512-0fHv3YIEaUcSVPSGyaaBfOi9bmpajjhbJNdPsRMIUvYdLVxBu9eGjH8mRc3Qk7HVmEidFc/lhG1YyJhoXrn5yA==} + /drizzle-kit@0.20.13: + resolution: {integrity: sha512-j9oZSQXNWG+KBJm0Sg3S/zJpncHGKnpqNfFuM4NUxUMGTcihDHhP9SW6Jncqwb5vsP1Xm0a8JLm3PZUIspC/oA==} hasBin: true dependencies: '@drizzle-team/studio': 0.0.39 @@ -3645,6 +3684,7 @@ packages: /escodegen@2.1.0: resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} engines: {node: '>=6.0'} + hasBin: true dependencies: esprima: 4.0.1 estraverse: 5.3.0 @@ -3881,6 +3921,7 @@ packages: /esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} + hasBin: true dev: true /esquery@1.5.0: @@ -3957,6 +3998,7 @@ packages: glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.5 + dev: false /fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} @@ -3967,7 +4009,6 @@ packages: glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.5 - dev: false /fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -4003,6 +4044,10 @@ packages: dependencies: to-regex-range: 5.0.1 + /fily-publish-gridstack@0.0.13: + resolution: {integrity: sha512-evN26y9qwzZcz63PJNCe1zqtf5yLG8UI/2FIBXrW1tcKCyyNIyC8+xkH0QoRalSpJETgAiqdBHgi3asVTU3umQ==} + dev: false + /find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -4058,6 +4103,14 @@ packages: /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: false + optional: true + /function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -4213,7 +4266,7 @@ packages: '@types/glob': 7.2.0 array-union: 2.1.0 dir-glob: 3.0.1 - fast-glob: 3.3.1 + fast-glob: 3.3.2 glob: 7.2.3 ignore: 5.2.4 merge2: 1.4.1 @@ -4255,6 +4308,7 @@ packages: /handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} + hasBin: true dependencies: minimist: 1.2.8 neo-async: 2.6.2 @@ -4383,6 +4437,10 @@ packages: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} + /immutable@4.3.4: + resolution: {integrity: sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==} + dev: false + /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -4499,6 +4557,13 @@ packages: has-bigints: 1.0.2 dev: false + /is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + dev: false + /is-boolean-object@1.1.2: resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} engines: {node: '>= 0.4'} @@ -4736,8 +4801,8 @@ packages: resolution: {integrity: sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==} dev: false - /jotai@2.6.4(@types/react@18.2.52)(react@18.2.0): - resolution: {integrity: sha512-RniwQPX4893YlNR1muOtyUGHYaTD1fhEN4qnOuZJSrDHj6xdEMrqlRSN/hCm2fshwk78ruecB/P2l+NCVWe6TQ==} + /jotai@2.6.2(@types/react@18.2.52)(react@18.2.0): + resolution: {integrity: sha512-kl4KguU1Fr+tFiLi3A3h9qPEzhvLTTDA10DO3QZAz6k7BEaQJ+qvSBwolzonnfNI4QzEovyQfUqVgnRxfnnQVQ==} engines: {node: '>=12.20.0'} peerDependencies: '@types/react': '>=17.0.0' @@ -4758,7 +4823,6 @@ packages: /js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true dependencies: argparse: 2.0.1 @@ -5072,6 +5136,7 @@ packages: /mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true dependencies: minimist: 1.2.8 dev: true @@ -5125,7 +5190,7 @@ packages: optional: true dependencies: '@auth/core': 0.18.0 - next: 14.1.0(react-dom@18.2.0)(react@18.2.0) + next: 14.1.0(react-dom@18.2.0)(react@18.2.0)(sass@1.70.0) react: 18.2.0 dev: false @@ -5141,7 +5206,7 @@ packages: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} dev: true - /next@14.1.0(react-dom@18.2.0)(react@18.2.0): + /next@14.1.0(react-dom@18.2.0)(react@18.2.0)(sass@1.70.0): resolution: {integrity: sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==} engines: {node: '>=18.17.0'} hasBin: true @@ -5159,11 +5224,12 @@ packages: '@next/env': 14.1.0 '@swc/helpers': 0.5.2 busboy: 1.6.0 - caniuse-lite: 1.0.30001583 + caniuse-lite: 1.0.30001579 graceful-fs: 4.2.11 postcss: 8.4.31 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + sass: 1.70.0 styled-jsx: 5.1.1(react@18.2.0) optionalDependencies: '@next/swc-darwin-arm64': 14.1.0 @@ -5237,6 +5303,11 @@ packages: abbrev: 1.1.1 dev: false + /normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: false + /npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -5555,8 +5626,8 @@ packages: postcss-selector-parser: 6.0.13 dev: false - /postcss-preset-mantine@1.13.0(postcss@8.4.31): - resolution: {integrity: sha512-1bv/mQz2K+/FixIMxYd83BYH7PusDZaI7LpUtKbb1l/5N5w6t1p/V9ONHfRJeeAZyfa6Xc+AtR+95VKdFXRH1g==} + /postcss-preset-mantine@1.12.3(postcss@8.4.31): + resolution: {integrity: sha512-cCwowf20mIyRXnV1cSVoMGfhYgy8ZqFJWsEJthdMZ3n7LijjucE9l/HO47gv5gAtr9nY1MkaEkpWS7ulhSTbSg==} peerDependencies: postcss: '>=8.0.0' dependencies: @@ -5956,6 +6027,13 @@ packages: string_decoder: 1.3.0 util-deprecate: 1.0.2 + /readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: false + /reflect.getprototypeof@1.0.3: resolution: {integrity: sha512-TTAOZpkJ2YLxl7mVHWrNo3iDMEkYlva/kgFcXndqMgbo/AZUmmavEkdXV+hXtE4P8xdyEKRzalaFqZVuwIk/Nw==} engines: {node: '>= 0.4'} @@ -6108,6 +6186,16 @@ packages: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} dev: true + /sass@1.70.0: + resolution: {integrity: sha512-uUxNQ3zAHeAx5nRFskBnrWzDUJrrvpCPD5FNAoRvTi0WwremlheES3tg+56PaVtCs5QDRX5CBLxxKMDJMEa1WQ==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + chokidar: 3.5.3 + immutable: 4.3.4 + source-map-js: 1.0.2 + dev: false + /scheduler@0.23.0: resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} dependencies: @@ -6539,63 +6627,64 @@ packages: safe-buffer: 5.2.1 dev: false - /turbo-darwin-64@1.10.16: - resolution: {integrity: sha512-+Jk91FNcp9e9NCLYlvDDlp2HwEDp14F9N42IoW3dmHI5ZkGSXzalbhVcrx3DOox3QfiNUHxzWg4d7CnVNCuuMg==} + /turbo-darwin-64@1.12.2: + resolution: {integrity: sha512-Aq/ePQ5KNx6XGwlZWTVTqpQYfysm1vkwkI6kAYgrX5DjMWn+tUXrSgNx4YNte0F+V4DQ7PtuWX+jRG0h0ZNg0A==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-darwin-arm64@1.10.16: - resolution: {integrity: sha512-jqGpFZipIivkRp/i+jnL8npX0VssE6IAVNKtu573LXtssZdV/S+fRGYA16tI46xJGxSAivrZ/IcgZrV6Jk80bw==} + /turbo-darwin-arm64@1.12.2: + resolution: {integrity: sha512-wTr+dqkwJo/eXE+4SPTSeNBKyyfQJhI6I9sKVlCSBmtaNEqoGNgdVzgMUdqrg9AIFzLIiKO+zhfskNaSWpVFow==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-linux-64@1.10.16: - resolution: {integrity: sha512-PpqEZHwLoizQ6sTUvmImcRmACyRk9EWLXGlqceogPZsJ1jTRK3sfcF9fC2W56zkSIzuLEP07k5kl+ZxJd8JMcg==} + /turbo-linux-64@1.12.2: + resolution: {integrity: sha512-BggBKrLojGarDaa2zBo+kUR3fmjpd6bLA8Unm3Aa2oJw0UvEi3Brd+w9lNsPZHXXQYBUzNUY2gCdxf3RteWb0g==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-linux-arm64@1.10.16: - resolution: {integrity: sha512-TMjFYz8to1QE0fKVXCIvG/4giyfnmqcQIwjdNfJvKjBxn22PpbjeuFuQ5kNXshUTRaTJihFbuuCcb5OYFNx4uw==} + /turbo-linux-arm64@1.12.2: + resolution: {integrity: sha512-v/apSRvVuwYjq1D9MJFsHv2EpGd1S4VoSdZvVfW6FaM06L8CFZa92urNR1svdGYN28YVKwK9Ikc9qudC6t/d5A==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-windows-64@1.10.16: - resolution: {integrity: sha512-+jsf68krs0N66FfC4/zZvioUap/Tq3sPFumnMV+EBo8jFdqs4yehd6+MxIwYTjSQLIcpH8KoNMB0gQYhJRLZzw==} + /turbo-windows-64@1.12.2: + resolution: {integrity: sha512-3uDdwXcRGkgopYFdPDpxQiuQjfQ12Fxq0fhj+iGymav0eWA4W4wzYwSdlUp6rT22qOBIzaEsrIspRwx1DsMkNg==} cpu: [x64] os: [win32] requiresBuild: true dev: true optional: true - /turbo-windows-arm64@1.10.16: - resolution: {integrity: sha512-sKm3hcMM1bl0B3PLG4ifidicOGfoJmOEacM5JtgBkYM48ncMHjkHfFY7HrJHZHUnXM4l05RQTpLFoOl/uIo2HQ==} + /turbo-windows-arm64@1.12.2: + resolution: {integrity: sha512-zNIHnwtQfJSjFi7movwhPQh2rfrcKZ7Xv609EN1yX0gEp9GxooCUi2yNnBQ8wTqFjioA2M5hZtGJQ0RrKaEm/Q==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /turbo@1.10.16: - resolution: {integrity: sha512-2CEaK4FIuSZiP83iFa9GqMTQhroW2QryckVqUydmg4tx78baftTOS0O+oDAhvo9r9Nit4xUEtC1RAHoqs6ZEtg==} + /turbo@1.12.2: + resolution: {integrity: sha512-BcoQjBZ+LJCMdjzWhzQflOinUjek28rWXj07aaaAQ8T3Ehs0JFSjIsXOm4qIbo52G4xk3gFVcUtJhh/QRADl7g==} + hasBin: true optionalDependencies: - turbo-darwin-64: 1.10.16 - turbo-darwin-arm64: 1.10.16 - turbo-linux-64: 1.10.16 - turbo-linux-arm64: 1.10.16 - turbo-windows-64: 1.10.16 - turbo-windows-arm64: 1.10.16 + turbo-darwin-64: 1.12.2 + turbo-darwin-arm64: 1.12.2 + turbo-linux-64: 1.12.2 + turbo-linux-arm64: 1.12.2 + turbo-windows-64: 1.12.2 + turbo-windows-arm64: 1.12.2 dev: true /type-check@0.4.0: @@ -6680,6 +6769,7 @@ packages: /uglify-js@3.17.4: resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==} engines: {node: '>=0.8.0'} + hasBin: true requiresBuild: true dev: true optional: true