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
This commit is contained in:
Meier Lukas
2024-02-03 22:26:12 +01:00
committed by GitHub
parent cfd1c14034
commit 9d520874f4
88 changed files with 3431 additions and 262 deletions

View File

@@ -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": {

View File

@@ -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 (
<ActionIcon

View File

@@ -3,6 +3,7 @@
import { useRef, useState } from "react";
import type { RouterInputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
showErrorNotification,
showSuccessNotification,
@@ -18,8 +19,6 @@ import {
Loader,
} from "@homarr/ui";
import { api } from "~/trpc/react";
interface UseTestConnectionDirtyProps {
defaultDirty: boolean;
initialFormValue: {
@@ -77,7 +76,7 @@ export const TestConnection = ({
}: TestConnectionProps) => {
const t = useScopedI18n("integration.testConnection");
const { mutateAsync, ...mutation } =
api.integration.testConnection.useMutation();
clientApi.integration.testConnection.useMutation();
return (
<Group>

View File

@@ -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]),

View File

@@ -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;

View File

@@ -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 (
<api.Provider client={trpcClient} queryClient={queryClient}>
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration transformer={superjson}>
{props.children}
</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</api.Provider>
</clientApi.Provider>
);
}

View File

@@ -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 (
<Center>
<Stack align="center" mt="xl">
<LogoWithTitle size="lg" />
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}

View File

@@ -0,0 +1,3 @@
import headerActions from "../../[name]/@headeractions/page";
export default headerActions;

View File

@@ -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();
},
});

View File

@@ -0,0 +1,5 @@
import definition from "./_definition";
const { layout } = definition;
export default layout;

View File

@@ -0,0 +1,7 @@
import definition from "./_definition";
const { generateMetadata, page } = definition;
export default page;
export { generateMetadata };

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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 });
},
});

View File

@@ -0,0 +1,5 @@
import definition from "./_definition";
const { layout } = definition;
export default layout;

View File

@@ -0,0 +1,7 @@
import definition from "./_definition";
const { generateMetadata, page } = definition;
export default page;
export { generateMetadata };

View File

@@ -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 : "");
};

View File

@@ -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) })}</Title>
<Accordion variant="separated" defaultValue="general">
<AccordionItem value="general">
<AccordionControl icon={<IconSettings />}>
<Text fw="bold" size="lg">
{t("section.general.title")}
</Text>
</AccordionControl>
<AccordionPanel>
<GeneralSettingsContent board={board} />
</AccordionPanel>
</AccordionItem>
<AccordionItem value="layout">
<AccordionControl icon={<IconLayout />}>
<Text fw="bold" size="lg">
{t("section.layout.title")}
</Text>
</AccordionControl>
<AccordionPanel></AccordionPanel>
</AccordionItem>
<AccordionItem value="appearance">
<AccordionControl icon={<IconBrush />}>
<Text fw="bold" size="lg">
{t("section.appearance.title")}
</Text>
</AccordionControl>
<AccordionPanel></AccordionPanel>
</AccordionItem>
<AccordionItem
value="danger"
styles={{
item: {
"--__item-border-color": "rgba(248,81,73,0.4)",
},
}}
>
<AccordionControl icon={<IconAlertTriangle />}>
<Text fw="bold" size="lg">
{t("section.dangerZone.title")}
</Text>
</AccordionControl>
<AccordionPanel
styles={{ content: { paddingRight: 0, paddingLeft: 0 } }}
>
<Stack gap="sm">
<Divider />
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{t("section.dangerZone.action.rename.label")}
</Text>
<Text size="sm">
{t("section.dangerZone.action.rename.description")}
</Text>
</Stack>
<Button variant="subtle" color="red">
{t("section.dangerZone.action.rename.button")}
</Button>
</Group>
<Divider />
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{t("section.dangerZone.action.visibility.label")}
</Text>
<Text size="sm">
{t(
"section.dangerZone.action.visibility.description.private",
)}
</Text>
</Stack>
<Button variant="subtle" color="red">
{t("section.dangerZone.action.visibility.button.private")}
</Button>
</Group>
<Divider />
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{t("section.dangerZone.action.delete.label")}
</Text>
<Text size="sm">
{t("section.dangerZone.action.delete.description")}
</Text>
</Stack>
<Button variant="subtle" color="red">
{t("section.dangerZone.action.delete.button")}
</Button>
</Group>
</Stack>
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
</Container>
);
}

View File

@@ -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<HTMLDivElement>(null);
return (
<Box h="100%" pos="relative">
<LoadingOverlay
visible={!isReady}
transitionProps={{ duration: 500 }}
loaderProps={{ size: "lg", variant: "bars" }}
h="calc(100dvh - var(--app-shell-header-offset, 0px) - var(--app-shell-padding) - var(--app-shell-footer-offset, 0px) - var(--app-shell-padding))"
/>
<Stack
ref={ref}
h="100%"
style={{ visibility: isReady ? "visible" : "hidden" }}
>
{sectionsWithoutSidebars.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection
key={section.id}
section={section}
mainRef={ref}
/>
) : (
<BoardCategorySection
key={section.id}
section={section}
mainRef={ref}
/>
),
)}
</Stack>
</Box>
);
};

View File

@@ -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<string[]>([]);
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 (
<BoardContext.Provider
value={{
board: data,
isReady: data.sections.length === readySections.length,
markAsReady,
}}
>
{children}
</BoardContext.Provider>
);
};
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;
};

View File

@@ -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<string, unknown>;
interface Props<TParams extends Params> {
getInitialBoard: (params: TParams) => Promise<Board>;
}
export const createBoardPage = <TParams extends Record<string, unknown>>({
getInitialBoard,
}: Props<TParams>) => {
return {
layout: async ({
params,
children,
headeractions,
}: PropsWithChildren<{ params: TParams; headeractions: ReactNode }>) => {
const initialBoard = await getInitialBoard(params);
return (
<BoardProvider initialBoard={initialBoard}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" />}
actions={headeractions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardProvider>
);
},
page: () => {
// TODO: Add check if board is private and user is not logged in
return <ClientBoard />;
},
generateMetadata: async ({
params,
}: {
params: TParams;
}): Promise<Metadata> => {
const board = await getInitialBoard(params);
return {
title: board.metaTitle ?? `${capitalize(board.name)} board | Homarr`,
icons: {
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
},
};
},
};
};

View File

@@ -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<Section, { kind: "category" }>;
export type EmptySection = Extract<Section, { kind: "empty" }>;
export type SidebarSection = Extract<Section, { kind: "sidebar" }>;
export type ItemOfKind<TKind extends WidgetKind> = Extract<
Item,
{ kind: TKind }
>;

View File

@@ -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<FormType>({
validate: zodResolver(validation.user.init),
validateInputOnBlur: true,

View File

@@ -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 (
<Center>
<Stack align="center" mt="xl">
<LogoWithTitle size="lg" />
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}

View File

@@ -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,
});

View File

@@ -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 &&

View File

@@ -10,7 +10,7 @@ const getLinks = () => {
return {
href: `/widgets/${key}`,
icon: value.definition.icon,
label: value.definition.sort,
label: value.definition.kind,
};
});
};

View File

@@ -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>
);
}

View File

@@ -0,0 +1,3 @@
import { atom } from "jotai";
export const editModeAtom = atom(false);

View File

@@ -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,
};
};

View File

@@ -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>
);
};

View File

@@ -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}</Title>
</Group>
</UnstyledButton>
<CategoryMenu category={section} />
</Group>
<Collapse in={opened} p="sm" pt={0}>
<div
className="grid-stack grid-stack-category"
data-category
data-section-id={section.id}
ref={refs.wrapper}
>
<SectionContent items={section.items} refs={refs} />
</div>
</Collapse>
</Stack>
</Card>
);
};

View File

@@ -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);

View File

@@ -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<InnerProps> = ({
actions,
innerProps,
}) => {
const t = useI18n();
const form = useForm({
initialValues: {
name: innerProps.category.name,
},
});
return (
<form
onSubmit={form.onSubmit((v) => {
void innerProps.onSuccess({
...innerProps.category,
name: v.name,
});
actions.closeModal();
})}
>
<Stack>
<TextInput
label={t("section.category.field.name.label")}
data-autofocus
{...form.getInputProps("name")}
/>
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" color="teal">
{innerProps.submitLabel}
</Button>
</Group>
</Stack>
</form>
);
};

View File

@@ -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,
};
};

View File

@@ -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 (
<Menu withArrow>
<Menu.Target>
<ActionIcon mr="sm" variant="transparent">
<IconDotsVertical size={20} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{actions.map((action) => (
<React.Fragment key={action.label}>
{"group" in action && <Menu.Label>{t(action.group)}</Menu.Label>}
<Menu.Item
leftSection={<action.icon size="1rem" />}
onClick={action.onClick}
color={"color" in action ? action.color : undefined}
>
{t(action.label)}
</Menu.Item>
</React.Fragment>
))}
</Menu.Dropdown>
</Menu>
);
};
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;
}

View File

@@ -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) => (
<div
key={item.id}
className="grid-stack-item"
data-id={item.id}
gs-x={item.xOffset}
gs-y={item.yOffset}
gs-w={item.width}
gs-h={item.height}
gs-min-w={1}
gs-min-h={1}
gs-max-w={4}
gs-max-h={4}
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
>
<Card className="grid-stack-item-content" withBorder>
<BoardItem item={item} />
</Card>
</div>
))}
</>
);
};
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 (
<>
<ItemMenu offset={8} item={newItem} />
<Comp options={options as never} integrations={item.integrations} />
</>
);
};
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 (
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
<Menu.Target>
<ActionIcon
variant="transparent"
pos="absolute"
top={offset}
right={offset}
>
<IconDotsVertical />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Label>{t("menu.label.settings")}</Menu.Label>
<Menu.Item
leftSection={<IconPencil size={16} />}
onClick={openEditModal}
>
{t("action.edit")}
</Menu.Item>
<Menu.Item leftSection={<IconLayoutKanban size={16} />}>
{t("action.move")}
</Menu.Item>
<Menu.Divider />
<Menu.Label c="red.6">{t("menu.label.dangerZone")}</Menu.Label>
<Menu.Item
c="red.6"
leftSection={<IconTrash size={16} />}
onClick={openRemoveModal}
>
{t("action.remove")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};

View File

@@ -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<HTMLDivElement>;
}
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 (
<div
className={
section.items.length > 0 || isEditMode
? defaultClasses
: `${defaultClasses} gridstack-empty-wrapper`
}
style={{ transitionDuration: "0s" }}
data-empty
data-section-id={section.id}
ref={refs.wrapper}
>
<SectionContent items={section.items} refs={refs} />
</div>
);
};

View File

@@ -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<HTMLDivElement>;
items: MutableRefObject<Record<string, RefObject<GridItemHTMLElement>>>;
gridstack: MutableRefObject<GridStack | undefined>;
};
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;
};

View File

@@ -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<HTMLDivElement>;
items: MutableRefObject<Record<string, RefObject<GridItemHTMLElement>>>;
gridstack: MutableRefObject<GridStack | undefined>;
}
interface UseGristackReturnType {
refs: UseGridstackRefs;
}
interface UseGridstackProps {
section: Section;
mainRef?: RefObject<HTMLDivElement>;
}
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<HTMLDivElement>(null);
// references to the diffrent items contained in the gridstack
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement>>>({});
// reference of the gridstack object for modifications after initialization
const gridRef = useRef<GridStack>();
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<HTMLDivElement>;
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]);
};

View File

@@ -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 (
<AppShellHeader>
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
<ClientBurger />
{hasNavigation && <ClientBurger />}
<UnstyledButton component={Link} href="/">
<LogoWithTitle size="md" />
{logo ?? <HomarrLogoWithTitle size="md" />}
</UnstyledButton>
</Group>
<DesktopSearchInput />
<Group h="100%" align="center" justify="end" style={{ flex: 1 }}>
<Group
h="100%"
align="center"
justify="end"
style={{ flex: 1 }}
wrap="nowrap"
>
{actions}
<MobileSearchButton />
<UserButton />
</Group>

View File

@@ -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<ActionIconProps>;
const headerButtonActionIconProps: ActionIconProps = {
variant: "subtle",
style: { border: "none" },
color: "gray",
size: "lg",
};
// eslint-disable-next-line react/display-name
export const HeaderButton = forwardRef<HTMLButtonElement, HeaderButtonProps>(
(props, ref) => {
if ("href" in props) {
return (
<ActionIcon
ref={ref as ForwardedRef<HTMLAnchorElement>}
component={Link}
{...props}
{...headerButtonActionIconProps}
>
{props.children}
</ActionIcon>
);
}
return (
<ActionIcon ref={ref} {...props} {...headerButtonActionIconProps}>
{props.children}
</ActionIcon>
);
},
);

View File

@@ -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 (
<ActionIcon
className={classes.mobileSearch}
variant="subtle"
color="gray"
onClick={spotlight.open}
>
<HeaderButton onClick={spotlight.open} className={classes.mobileSearch}>
<IconSearch size={20} stroke={1.5} />
</ActionIcon>
</HeaderButton>
);
};

View File

@@ -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) => (
<Image src="/logo/homarr.png" alt="Homarr logo" width={size} height={size} />
);
const logoWithTitleSizes = {
lg: { logoSize: 48, titleOrder: 1 },
md: { logoSize: 32, titleOrder: 2 },
sm: { logoSize: 24, titleOrder: 3 },
} satisfies Record<string, { logoSize: number; titleOrder: TitleOrder }>;
interface LogoWithTitleProps {
size: keyof typeof logoWithTitleSizes;
}
export const LogoWithTitle = ({ size }: LogoWithTitleProps) => {
const { logoSize, titleOrder } = logoWithTitleSizes[size];
return (
<Group gap={0} wrap="nowrap">
<Logo size={logoSize} />
<Title order={titleOrder}>lparr</Title>
</Group>
);
};

View File

@@ -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 <Logo size={size} {...imageOptions} />;
};
interface CommonLogoWithTitleProps {
size: LogoWithTitleProps["size"];
}
export const BoardLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => {
const board = useRequiredBoard();
const imageOptions = useImageOptions();
return (
<LogoWithTitle
size={size}
title={board.pageTitle ?? homarrPageTitle}
image={imageOptions}
/>
);
};

View File

@@ -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) => (
<Logo size={size} {...imageOptions} />
);
interface CommonLogoWithTitleProps {
size: LogoWithTitleProps["size"];
}
export const HomarrLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => {
return (
<LogoWithTitle size={size} title={homarrPageTitle} image={imageOptions} />
);
};

View File

@@ -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 ? (
<Image src={src} alt={alt} width={size} height={size} />
) : (
// 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
<img src={src} alt={alt} width={size} height={size} />
);
const logoWithTitleSizes = {
lg: { logoSize: 48, titleOrder: 1 },
md: { logoSize: 32, titleOrder: 2 },
sm: { logoSize: 24, titleOrder: 3 },
} satisfies Record<string, { logoSize: number; titleOrder: TitleOrder }>;
export interface LogoWithTitleProps {
size: keyof typeof logoWithTitleSizes;
title: string;
image: Omit<LogoProps, "size">;
}
export const LogoWithTitle = ({ size, title, image }: LogoWithTitleProps) => {
const { logoSize, titleOrder } = logoWithTitleSizes[size];
return (
<Group gap="xs" wrap="nowrap">
<Logo {...image} size={logoSize} />
<Title order={titleOrder}>{title}</Title>
</Group>
);
};

View File

@@ -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;
}

View File

@@ -1,7 +0,0 @@
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@homarr/api";
export const api = createTRPCReact<AppRouter>();
export { type RouterInputs, type RouterOutputs } from "@homarr/api";

View File

@@ -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": {

View File

@@ -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']

View File

@@ -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",

View File

@@ -0,0 +1,5 @@
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "..";
export const clientApi = createTRPCReact<AppRouter>();

View File

@@ -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

View File

@@ -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 = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter(
(inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id),
);
const filterRemovedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
dbArray.filter(
(dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id),
);
const filterUpdatedItems = <TInput extends { id: string }>(
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<Record<string, unknown>>(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 = <T extends WidgetKind>(kind: T) =>
z.object({
kind: z.literal(kind),
options: z.custom<Partial<WidgetComponentProps<T>["options"]>>(),
}) as UnionizeSpecificItemSchemaForWidgetKind<T>;
type SpecificItemSchemaForWidgetKind<TKind extends WidgetKind> = z.ZodObject<{
kind: z.ZodLiteral<TKind>;
options: z.ZodType<
Partial<WidgetComponentProps<TKind>["options"]>,
z.ZodTypeDef,
Partial<WidgetComponentProps<TKind>["options"]>
>;
}>;
type UnionizeSpecificItemSchemaForWidgetKind<T> = T extends WidgetKind
? SpecificItemSchemaForWidgetKind<T>
: 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;
};

1
packages/db/client.ts Normal file
View File

@@ -0,0 +1 @@
export { createId } from "@paralleldrive/cuid2";

View File

@@ -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<typeof schema>;
export { createId } from "@paralleldrive/cuid2";

View File

@@ -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",

View File

@@ -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<BackgroundImageAttachment>()
.default("fixed")
.notNull(),
backgroundImageRepeat: text("background_image_repeat")
.$type<BackgroundImageRepeat>()
.default("no-repeat")
.notNull(),
backgroundImageSize: text("background_image_size")
.$type<BackgroundImageSize>()
.default("cover")
.notNull(),
primaryColor: text("primary_color")
.$type<MantineColor>()
.default("red")
.notNull(),
secondaryColor: text("secondary_color")
.$type<MantineColor>()
.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<SectionKind>().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<WidgetKind>().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<typeof users>;
export type Account = InferSelectModel<typeof accounts>;
export type Session = InferSelectModel<typeof sessions>;

View File

@@ -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];

View File

@@ -1 +1,4 @@
export * from "./board";
export * from "./integration";
export * from "./section";
export * from "./widget";

View File

@@ -0,0 +1,2 @@
export const sectionKinds = ["category", "empty", "sidebar"] as const;
export type SectionKind = (typeof sectionKinds)[number];

View File

@@ -0,0 +1,2 @@
export const widgetKinds = ["clock", "weather"] as const;
export type WidgetKind = (typeof widgetKinds)[number];

View File

@@ -33,6 +33,6 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@mantine/form": "^7.4.0"
"@mantine/form": "^7.5.1"
}
}

View File

@@ -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": {

View File

@@ -34,6 +34,6 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@mantine/spotlight": "^7.4.0"
"@mantine/spotlight": "^7.5.1"
}
}

View File

@@ -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,
},
);

View File

@@ -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: {

View File

@@ -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,
},
);

View File

@@ -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"
}
}

View File

@@ -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,
};

View File

@@ -1,4 +1,11 @@
import { z } from "zod";
export const zodEnumFromArray = <T extends string>(arr: T[]) =>
z.enum([arr[0]!, ...arr.slice(1)]);
type CouldBeReadonlyArray<T> = T[] | readonly T[];
export const zodEnumFromArray = <T extends string>(
array: CouldBeReadonlyArray<T>,
) => z.enum([array[0]!, ...array.slice(1)]);
export const zodUnionFromArray = <T extends z.ZodTypeAny>(
array: CouldBeReadonlyArray<T>,
) => z.union([array[0]!, array[1]!, ...array.slice(2)]);

View File

@@ -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";

View File

@@ -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 = <TItemSchema extends z.ZodTypeAny>(
itemSchema: TItemSchema,
) =>
z.object({
id: z.string(),
name: z.string(),
kind: z.literal("category"),
position: z.number(),
items: z.array(itemSchema),
});
const createEmptySchema = <TItemSchema extends z.ZodTypeAny>(
itemSchema: TItemSchema,
) =>
z.object({
id: z.string(),
kind: z.literal("empty"),
position: z.number(),
items: z.array(itemSchema),
});
const createSidebarSchema = <TItemSchema extends z.ZodTypeAny>(
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 = <TItemSchema extends z.ZodTypeAny>(
itemSchema: TItemSchema,
) =>
z.union([
createCategorySchema(itemSchema),
createEmptySchema(itemSchema),
createSidebarSchema(itemSchema),
]);

View File

@@ -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",

View File

@@ -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<TKey extends WidgetOptionType> {
sort: WidgetSort;
kind: WidgetKind;
property: string;
options: Omit<WidgetOptionOfType<TKey>, "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.<sort>.option.<property> 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.<kind>.option.<property> 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;
};

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -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<WidgetComponentProps<TSort>>,
componentLoader: () => LoaderComponent<WidgetComponentProps<TKind>>,
) => ({
definition: {
sort,
kind,
...definition,
},
componentLoader,
}),
});
interface Definition {
export interface WidgetDefinition {
icon: (props: TablerIconsProps) => JSX.Element;
supportedIntegrations?: IntegrationKind[];
options: WidgetOptionsRecord;
}
export interface WidgetComponentProps<TSort extends WidgetSort> {
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TSort>>;
export interface WidgetComponentProps<TKind extends WidgetKind> {
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>;
integrations: inferIntegrationsFromDefinition<
WidgetImports[TSort]["definition"]
WidgetImports[TKind]["definition"]
>;
}
type inferIntegrationsFromDefinition<TDefinition extends Definition> =
type inferIntegrationsFromDefinition<TDefinition extends WidgetDefinition> =
TDefinition extends {
supportedIntegrations: infer TSupportedIntegrations;
} // check if definition has supportedIntegrations
@@ -57,5 +57,5 @@ interface IntegrationSelectOptionFor<TIntegration extends IntegrationKind> {
kind: TIntegration[number];
}
export type WidgetOptionsRecordOf<TSort extends WidgetSort> =
WidgetImports[TSort]["definition"]["options"];
export type WidgetOptionsRecordOf<TKind extends WidgetKind> =
WidgetImports[TKind]["definition"]["options"];

View File

@@ -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;
};

View File

@@ -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 = <TSort extends WidgetSort>(sort: TSort) =>
dynamic<WidgetComponentProps<TSort>>(
widgetImports[sort].componentLoader as Loader<WidgetComponentProps<TSort>>,
const loadedComponents = new Map<
WidgetKind,
ComponentType<WidgetComponentProps<WidgetKind>>
>();
export const loadWidgetDynamic = <TKind extends WidgetKind>(kind: TKind) => {
const existingComponent = loadedComponents.get(kind);
if (existingComponent) return existingComponent;
const newlyLoadedComponent = dynamic<WidgetComponentProps<TKind>>(
widgetImports[kind].componentLoader as Loader<WidgetComponentProps<TKind>>,
{
loading: () => <UiLoader />,
},
);
loadedComponents.set(kind, newlyLoadedComponent as never);
return newlyLoadedComponent;
};

View File

@@ -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<string, unknown>;
integrations: string[];
}
interface ModalProps<TSort extends WidgetSort> {
sort: TSort;
state: [WidgetEditModalState, Dispatch<SetStateAction<WidgetEditModalState>>];
definition: WidgetOptionsRecordOf<TSort>;
interface ModalProps<TSort extends WidgetKind> {
kind: TSort;
value: WidgetEditModalState;
onSuccessfulEdit: (value: WidgetEditModalState) => void;
integrationData: IntegrationSelectOption[];
integrationSupport: boolean;
}
export const WidgetEditModal: ManagedModal<ModalProps<WidgetSort>> = ({
export const WidgetEditModal: ManagedModal<ModalProps<WidgetKind>> = ({
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 (
<form
onSubmit={form.onSubmit((v) => {
setValue(v);
innerProps.onSuccessfulEdit(v);
actions.closeModal();
})}
>
@@ -48,12 +48,12 @@ export const WidgetEditModal: ManagedModal<ModalProps<WidgetSort>> = ({
<Stack>
{innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={t("integrations.label")}
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
{...form.getInputProps("integrations")}
/>
)}
{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<ModalProps<WidgetSort>> = ({
return (
<Input
key={key}
sort={innerProps.sort}
kind={innerProps.kind}
property={key}
options={value as never}
/>
@@ -73,10 +73,10 @@ export const WidgetEditModal: ManagedModal<ModalProps<WidgetSort>> = ({
)}
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
Close
{t("common.action.cancel")}
</Button>
<Button type="submit" color="teal">
Save
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>

View File

@@ -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<TType> {
defaultValue?: TType;
withDescription?: boolean;
@@ -143,13 +146,16 @@ export const opt = {
};
export const reduceWidgetOptionsWithDefaultValues = (
optionsDefinition: Record<string, WidgetOptionDefinition>,
kind: WidgetKind,
currentValue: Record<string, unknown> = {},
) =>
objectEntries(optionsDefinition).reduce(
) => {
const definition = widgetImports[kind].definition;
const options = definition.options as Record<string, WidgetOptionDefinition>;
return objectEntries(options).reduce(
(prev, [key, value]) => ({
...prev,
[key]: currentValue[key] ?? value.defaultValue,
}),
{} as Record<string, unknown>,
);
};

264
pnpm-lock.yaml generated
View File

@@ -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