mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-27 00:40:58 +01:00
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:
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import headerActions from "../../[name]/@headeractions/page";
|
||||
|
||||
export default headerActions;
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
5
apps/nextjs/src/app/[locale]/boards/(default)/layout.tsx
Normal file
5
apps/nextjs/src/app/[locale]/boards/(default)/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import definition from "./_definition";
|
||||
|
||||
const { layout } = definition;
|
||||
|
||||
export default layout;
|
||||
7
apps/nextjs/src/app/[locale]/boards/(default)/page.tsx
Normal file
7
apps/nextjs/src/app/[locale]/boards/(default)/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import definition from "./_definition";
|
||||
|
||||
const { generateMetadata, page } = definition;
|
||||
|
||||
export default page;
|
||||
|
||||
export { generateMetadata };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
5
apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx
Normal file
5
apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import definition from "./_definition";
|
||||
|
||||
const { layout } = definition;
|
||||
|
||||
export default layout;
|
||||
7
apps/nextjs/src/app/[locale]/boards/[name]/page.tsx
Normal file
7
apps/nextjs/src/app/[locale]/boards/[name]/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import definition from "./_definition";
|
||||
|
||||
const { generateMetadata, page } = definition;
|
||||
|
||||
export default page;
|
||||
|
||||
export { generateMetadata };
|
||||
115
apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx
Normal file
115
apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx
Normal 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 : "");
|
||||
};
|
||||
133
apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx
Normal file
133
apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
apps/nextjs/src/app/[locale]/boards/_client.tsx
Normal file
79
apps/nextjs/src/app/[locale]/boards/_client.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
80
apps/nextjs/src/app/[locale]/boards/_context.tsx
Normal file
80
apps/nextjs/src/app/[locale]/boards/_context.tsx
Normal 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;
|
||||
};
|
||||
66
apps/nextjs/src/app/[locale]/boards/_creator.tsx
Normal file
66
apps/nextjs/src/app/[locale]/boards/_creator.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
15
apps/nextjs/src/app/[locale]/boards/_types.ts
Normal file
15
apps/nextjs/src/app/[locale]/boards/_types.ts
Normal 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 }
|
||||
>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
@@ -10,7 +10,7 @@ const getLinks = () => {
|
||||
return {
|
||||
href: `/widgets/${key}`,
|
||||
icon: value.definition.icon,
|
||||
label: value.definition.sort,
|
||||
label: value.definition.kind,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
3
apps/nextjs/src/components/board/editMode.ts
Normal file
3
apps/nextjs/src/components/board/editMode.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const editModeAtom = atom(false);
|
||||
201
apps/nextjs/src/components/board/items/item-actions.tsx
Normal file
201
apps/nextjs/src/components/board/items/item-actions.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
84
apps/nextjs/src/components/board/items/item-select-modal.tsx
Normal file
84
apps/nextjs/src/components/board/items/item-select-modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
153
apps/nextjs/src/components/board/sections/content.tsx
Normal file
153
apps/nextjs/src/components/board/sections/content.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
35
apps/nextjs/src/components/board/sections/empty-section.tsx
Normal file
35
apps/nextjs/src/components/board/sections/empty-section.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
47
apps/nextjs/src/components/layout/header/button.tsx
Normal file
47
apps/nextjs/src/components/layout/header/button.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
40
apps/nextjs/src/components/layout/logo/board-logo.tsx
Normal file
40
apps/nextjs/src/components/layout/logo/board-logo.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
29
apps/nextjs/src/components/layout/logo/homarr-logo.tsx
Normal file
29
apps/nextjs/src/components/layout/logo/homarr-logo.tsx
Normal 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} />
|
||||
);
|
||||
};
|
||||
48
apps/nextjs/src/components/layout/logo/logo.tsx
Normal file
48
apps/nextjs/src/components/layout/logo/logo.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
124
apps/nextjs/src/styles/gridstack.scss
Normal file
124
apps/nextjs/src/styles/gridstack.scss
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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": {
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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",
|
||||
|
||||
5
packages/api/src/client.ts
Normal file
5
packages/api/src/client.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createTRPCReact } from "@trpc/react-query";
|
||||
|
||||
import type { AppRouter } from "..";
|
||||
|
||||
export const clientApi = createTRPCReact<AppRouter>();
|
||||
@@ -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
|
||||
|
||||
290
packages/api/src/router/board.ts
Normal file
290
packages/api/src/router/board.ts
Normal 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
1
packages/db/client.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createId } from "@paralleldrive/cuid2";
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
13
packages/definitions/src/board.ts
Normal file
13
packages/definitions/src/board.ts
Normal 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];
|
||||
@@ -1 +1,4 @@
|
||||
export * from "./board";
|
||||
export * from "./integration";
|
||||
export * from "./section";
|
||||
export * from "./widget";
|
||||
|
||||
2
packages/definitions/src/section.ts
Normal file
2
packages/definitions/src/section.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const sectionKinds = ["category", "empty", "sidebar"] as const;
|
||||
export type SectionKind = (typeof sectionKinds)[number];
|
||||
2
packages/definitions/src/widget.ts
Normal file
2
packages/definitions/src/widget.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const widgetKinds = ["clock", "weather"] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
@@ -33,6 +33,6 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@mantine/form": "^7.4.0"
|
||||
"@mantine/form": "^7.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -34,6 +34,6 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@mantine/spotlight": "^7.4.0"
|
||||
"@mantine/spotlight": "^7.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
43
packages/validation/src/board.ts
Normal file
43
packages/validation/src/board.ts
Normal 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,
|
||||
};
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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";
|
||||
|
||||
68
packages/validation/src/shared.ts
Normal file
68
packages/validation/src/shared.ts
Normal 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),
|
||||
]);
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
264
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user