chore: update prettier configuration for print width (#519)

* feat: update prettier configuration for print width

* chore: apply code formatting to entire repository

* fix: remove build files

* fix: format issue

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Thomas Camlong
2024-05-19 22:38:39 +02:00
committed by GitHub
parent 919161798e
commit f1b1ec59ec
234 changed files with 2444 additions and 5375 deletions

5
.gitignore vendored
View File

@@ -51,4 +51,7 @@ yarn-error.log*
db.sqlite db.sqlite
# logs # logs
*.log *.log
apps/tasks/tasks.cjs
apps/websocket/wssServer.cjs

View File

@@ -6,9 +6,10 @@
], ],
"typescript.tsdk": "node_modules\\typescript\\lib", "typescript.tsdk": "node_modules\\typescript\\lib",
"js/ts.implicitProjectConfig.experimentalDecorators": true, "js/ts.implicitProjectConfig.experimentalDecorators": true,
"prettier.configPath": "./tooling/prettier/index.mjs",
"cSpell.words": [ "cSpell.words": [
"superjson", "superjson",
"homarr", "homarr",
"trpc" "trpc"
] ]
} }

View File

@@ -10,19 +10,9 @@ const config = {
eslint: { ignoreDuringBuilds: true }, eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true }, typescript: { ignoreBuildErrors: true },
experimental: { experimental: {
optimizePackageImports: [ optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
"@mantine/core",
"@mantine/hooks",
"@tabler/icons-react",
],
}, },
transpilePackages: [ transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
"@homarr/ui",
"@homarr/notifications",
"@homarr/modals",
"@homarr/spotlight",
"@homarr/widgets",
],
images: { images: {
domains: ["cdn.jsdelivr.net"], domains: ["cdn.jsdelivr.net"],
}, },

View File

@@ -3,10 +3,7 @@ import type { PropsWithChildren } from "react";
import { defaultLocale } from "@homarr/translation"; import { defaultLocale } from "@homarr/translation";
import { I18nProviderClient } from "@homarr/translation/client"; import { I18nProviderClient } from "@homarr/translation/client";
export const NextInternationalProvider = ({ export const NextInternationalProvider = ({ children, locale }: PropsWithChildren<{ locale: string }>) => {
children,
locale,
}: PropsWithChildren<{ locale: string }>) => {
return ( return (
<I18nProviderClient locale={locale} fallback={defaultLocale}> <I18nProviderClient locale={locale} fallback={defaultLocale}>
{children} {children}

View File

@@ -9,9 +9,6 @@ interface AuthProviderProps {
session: Session | null; session: Session | null;
} }
export const AuthProvider = ({ export const AuthProvider = ({ children, session }: PropsWithChildren<AuthProviderProps>) => {
children,
session,
}: PropsWithChildren<AuthProviderProps>) => {
return <SessionProvider session={session}>{children}</SessionProvider>; return <SessionProvider session={session}>{children}</SessionProvider>;
}; };

View File

@@ -5,12 +5,7 @@ import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { import { createWSClient, loggerLink, unstable_httpBatchStreamLink, wsLink } from "@trpc/client";
createWSClient,
loggerLink,
unstable_httpBatchStreamLink,
wsLink,
} from "@trpc/client";
import superjson from "superjson"; import superjson from "superjson";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
@@ -37,8 +32,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
links: [ links: [
loggerLink({ loggerLink({
enabled: (opts) => enabled: (opts) =>
process.env.NODE_ENV === "development" || process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
(opts.direction === "down" && opts.result instanceof Error),
}), }),
(args) => { (args) => {
return ({ op, next }) => { return ({ op, next }) => {
@@ -69,9 +63,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
return ( return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}> <clientApi.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration transformer={superjson}> <ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
{props.children}
</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider> </QueryClientProvider>
</clientApi.Provider> </clientApi.Provider>

View File

@@ -5,10 +5,7 @@ import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -32,9 +29,7 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
}, },
}); });
const handleSubmit = ( const handleSubmit = (values: z.infer<typeof validation.user.registration>) => {
values: z.infer<typeof validation.user.registration>,
) => {
mutate( mutate(
{ {
...values, ...values,
@@ -63,11 +58,7 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
<Stack gap="xl"> <Stack gap="xl">
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="lg"> <Stack gap="lg">
<TextInput <TextInput label={t("field.username.label")} autoComplete="off" {...form.getInputProps("username")} />
label={t("field.username.label")}
autoComplete="off"
{...form.getInputProps("username")}
/>
<PasswordInput <PasswordInput
label={t("field.password.label")} label={t("field.password.label")}
autoComplete="new-password" autoComplete="new-password"

View File

@@ -18,18 +18,12 @@ interface InviteUsagePageProps {
}; };
} }
export default async function InviteUsagePage({ export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
params,
searchParams,
}: InviteUsagePageProps) {
const session = await auth(); const session = await auth();
if (session) notFound(); if (session) notFound();
const invite = await db.query.invites.findFirst({ const invite = await db.query.invites.findFirst({
where: and( where: and(eq(invites.id, params.id), eq(invites.token, searchParams.token)),
eq(invites.id, params.id),
eq(invites.token, searchParams.token),
),
columns: { columns: {
id: true, id: true,
token: true, token: true,

View File

@@ -2,22 +2,12 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import { Alert, Button, PasswordInput, rem, Stack, TextInput } from "@mantine/core";
Alert,
Button,
PasswordInput,
rem,
Stack,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle } from "@tabler/icons-react";
import { signIn } from "@homarr/auth/client"; import { signIn } from "@homarr/auth/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -34,9 +24,7 @@ export const LoginForm = () => {
}, },
}); });
const handleSubmitAsync = async ( const handleSubmitAsync = async (values: z.infer<typeof validation.user.signIn>) => {
values: z.infer<typeof validation.user.signIn>,
) => {
setIsLoading(true); setIsLoading(true);
setError(undefined); setError(undefined);
await signIn("credentials", { await signIn("credentials", {
@@ -67,18 +55,10 @@ export const LoginForm = () => {
return ( return (
<Stack gap="xl"> <Stack gap="xl">
<form <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}
>
<Stack gap="lg"> <Stack gap="lg">
<TextInput <TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
label={t("field.username.label")} <PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
{...form.getInputProps("name")}
/>
<PasswordInput
label={t("field.password.label")}
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth loading={isLoading}> <Button type="submit" fullWidth loading={isLoading}>
{t("action.login.label")} {t("action.login.label")}
</Button> </Button>

View File

@@ -18,9 +18,7 @@ export const updateBoardName = (name: string | null) => {
boardName = name; boardName = name;
}; };
type UpdateCallback = ( type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"];
prev: RouterOutputs["board"]["getHomeBoard"],
) => RouterOutputs["board"]["getHomeBoard"];
export const useUpdateBoard = () => { export const useUpdateBoard = () => {
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
@@ -46,9 +44,7 @@ export const ClientBoard = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const isReady = useIsBoardReady(); const isReady = useIsBoardReady();
const sortedSections = board.sections.sort( const sortedSections = board.sections.sort((sectionA, sectionB) => sectionA.position - sectionB.position);
(sectionA, sectionB) => sectionA.position - sectionB.position,
);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -61,24 +57,12 @@ export const ClientBoard = () => {
loaderProps={{ size: "lg" }} loaderProps={{ size: "lg" }}
h={fullHeightWithoutHeaderAndFooter} h={fullHeightWithoutHeaderAndFooter}
/> />
<Stack <Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
ref={ref}
h="100%"
style={{ visibility: isReady ? "visible" : "hidden" }}
>
{sortedSections.map((section) => {sortedSections.map((section) =>
section.kind === "empty" ? ( section.kind === "empty" ? (
<BoardEmptySection <BoardEmptySection key={section.id} section={section} mainRef={ref} />
key={section.id}
section={section}
mainRef={ref}
/>
) : ( ) : (
<BoardCategorySection <BoardCategorySection key={section.id} section={section} mainRef={ref} />
key={section.id}
section={section}
mainRef={ref}
/>
), ),
)} )}
</Stack> </Stack>

View File

@@ -1,13 +1,7 @@
"use client"; "use client";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { import { createContext, useCallback, useContext, useEffect, useState } from "react";
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -52,18 +46,12 @@ export const BoardProvider = ({
}, [pathname, utils, initialBoard.name]); }, [pathname, utils, initialBoard.name]);
useEffect(() => { useEffect(() => {
setReadySections((previous) => setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
previous.filter((id) =>
data.sections.some((section) => section.id === id),
),
);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.sections.length, setReadySections]); }, [data.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => { const markAsReady = useCallback((id: string) => {
setReadySections((previous) => setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
previous.includes(id) ? previous : [...previous, id],
);
}, []); }, []);
return ( return (

View File

@@ -18,9 +18,7 @@ interface Props<TParams extends Params> {
getInitialBoardAsync: (params: TParams) => Promise<Board>; getInitialBoardAsync: (params: TParams) => Promise<Board>;
} }
export const createBoardContentPage = < export const createBoardContentPage = <TParams extends Record<string, unknown>>({
TParams extends Record<string, unknown>,
>({
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
}: Props<TParams>) => { }: Props<TParams>) => {
return { return {
@@ -32,21 +30,13 @@ export const createBoardContentPage = <
page: () => { page: () => {
return <ClientBoard />; return <ClientBoard />;
}, },
generateMetadataAsync: async ({ generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
params,
}: {
params: TParams;
}): Promise<Metadata> => {
try { try {
const board = await getInitialBoard(params); const board = await getInitialBoard(params);
const t = await getI18n(); const t = await getI18n();
return { return {
title: title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
board.metaTitle ??
createMetaTitle(
t("board.content.metaTitle", { boardName: board.name }),
),
icons: { icons: {
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined, icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
}, },

View File

@@ -16,10 +16,7 @@ import { useAtom, useAtomValue } from "jotai";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
@@ -54,8 +51,7 @@ export const BoardContentHeaderActions = () => {
}; };
const AddMenu = () => { const AddMenu = () => {
const { openModal: openCategoryEditModal } = const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { addCategoryToEnd } = useCategoryActions(); const { addCategoryToEnd } = useCategoryActions();
const t = useI18n(); const t = useI18n();
@@ -95,22 +91,14 @@ const AddMenu = () => {
</HeaderButton> </HeaderButton>
</Menu.Target> </Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}> <Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item <Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectItem}>
leftSection={<IconBox size={20} />}
onClick={handleSelectItem}
>
{t("item.action.create")} {t("item.action.create")}
</Menu.Item> </Menu.Item>
<Menu.Item leftSection={<IconPackageImport size={20} />}> <Menu.Item leftSection={<IconPackageImport size={20} />}>{t("item.action.import")}</Menu.Item>
{t("item.action.import")}
</Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
leftSection={<IconBoxAlignTop size={20} />}
onClick={handleAddCategory}
>
{t("section.category.action.create")} {t("section.category.action.create")}
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
@@ -123,24 +111,23 @@ const EditModeMenu = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit"); const t = useScopedI18n("board.action.edit");
const { mutate: saveBoard, isPending } = const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
clientApi.board.saveBoard.useMutation({ onSuccess() {
onSuccess() { showSuccessNotification({
showSuccessNotification({ title: t("notification.success.title"),
title: t("notification.success.title"), message: t("notification.success.message"),
message: t("notification.success.message"), });
}); void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getBoardByName.invalidate({ name: board.name }); void revalidatePathActionAsync(`/boards/${board.name}`);
void revalidatePathActionAsync(`/boards/${board.name}`); setEditMode(false);
setEditMode(false); },
}, onError() {
onError() { showErrorNotification({
showErrorNotification({ title: t("notification.error.title"),
title: t("notification.error.title"), message: t("notification.error.message"),
message: t("notification.error.message"), });
}); },
}, });
});
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (isEditMode) return saveBoard(board); if (isEditMode) return saveBoard(board);
@@ -149,11 +136,7 @@ const EditModeMenu = () => {
return ( return (
<HeaderButton onClick={toggle} loading={isPending}> <HeaderButton onClick={toggle} loading={isPending}>
{isEditMode ? ( {isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
<IconPencilOff stroke={1.5} />
) : (
<IconPencil stroke={1.5} />
)}
</HeaderButton> </HeaderButton>
); );
}; };

View File

@@ -22,9 +22,7 @@ export const BoardMantineProvider = ({ children }: PropsWithChildren) => {
}; };
export const generateColors = (hex: string) => { export const generateColors = (hex: string) => {
const lightnessForColors = [ const lightnessForColors = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const;
-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2,
] as const;
const rgbaColors = lightnessForColors.map((lightness) => { const rgbaColors = lightnessForColors.map((lightness) => {
if (lightness < 0) { if (lightness < 0) {
return lighten(hex, -lightness); return lighten(hex, -lightness);

View File

@@ -44,11 +44,7 @@ export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
<Tabs.List grow> <Tabs.List grow>
<TabItem value="user" count={counts.user} icon={IconUser} /> <TabItem value="user" count={counts.user} icon={IconUser} />
<TabItem value="group" count={counts.group} icon={IconUsersGroup} /> <TabItem value="group" count={counts.group} icon={IconUsersGroup} />
<TabItem <TabItem value="inherited" count={initialPermissions.inherited.length} icon={IconUserDown} />
value="inherited"
count={initialPermissions.inherited.length}
icon={IconUserDown}
/>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="user"> <Tabs.Panel value="user">

View File

@@ -1,21 +1,8 @@
import { useCallback } from "react"; import { useCallback } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { SelectProps } from "@mantine/core"; import type { SelectProps } from "@mantine/core";
import { import { Button, Flex, Group, Select, TableTd, TableTr, Text } from "@mantine/core";
Button, import { IconCheck, IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
Flex,
Group,
Select,
TableTd,
TableTr,
Text,
} from "@mantine/core";
import {
IconCheck,
IconEye,
IconPencil,
IconSettings,
} from "@tabler/icons-react";
import type { BoardPermission } from "@homarr/definitions"; import type { BoardPermission } from "@homarr/definitions";
import { boardPermissions } from "@homarr/definitions"; import { boardPermissions } from "@homarr/definitions";
@@ -38,12 +25,7 @@ interface BoardAccessSelectRowProps {
onCountChange: OnCountChange; onCountChange: OnCountChange;
} }
export const BoardAccessSelectRow = ({ export const BoardAccessSelectRow = ({ itemContent, permission, index, onCountChange }: BoardAccessSelectRowProps) => {
itemContent,
permission,
index,
onCountChange,
}: BoardAccessSelectRowProps) => {
const tRoot = useI18n(); const tRoot = useI18n();
const tPermissions = useScopedI18n("board.setting.section.access.permission"); const tPermissions = useScopedI18n("board.setting.section.access.permission");
const form = useFormContext(); const form = useFormContext();
@@ -61,11 +43,7 @@ export const BoardAccessSelectRow = ({
<TableTr> <TableTr>
<TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd> <TableTd w={{ sm: 128, lg: 256 }}>{itemContent}</TableTd>
<TableTd> <TableTd>
<Flex <Flex direction={{ base: "column", xs: "row" }} align={{ base: "end", xs: "center" }} wrap="nowrap">
direction={{ base: "column", xs: "row" }}
align={{ base: "end", xs: "center" }}
wrap="nowrap"
>
<Select <Select
allowDeselect={false} allowDeselect={false}
flex="1" flex="1"
@@ -93,10 +71,7 @@ interface BoardAccessDisplayRowProps {
permission: BoardPermission | "board-full"; permission: BoardPermission | "board-full";
} }
export const BoardAccessDisplayRow = ({ export const BoardAccessDisplayRow = ({ itemContent, permission }: BoardAccessDisplayRowProps) => {
itemContent,
permission,
}: BoardAccessDisplayRowProps) => {
const tPermissions = useScopedI18n("board.setting.section.access.permission"); const tPermissions = useScopedI18n("board.setting.section.access.permission");
const Icon = icons[permission]; const Icon = icons[permission];
@@ -106,10 +81,7 @@ export const BoardAccessDisplayRow = ({
<TableTd> <TableTd>
<Group gap={0}> <Group gap={0}>
<Flex w={34} h={34} align="center" justify="center"> <Flex w={34} h={34} align="center" justify="center">
<Icon <Icon size="1rem" color="var(--input-section-color, var(--mantine-color-dimmed))" />
size="1rem"
color="var(--input-section-color, var(--mantine-color-dimmed))"
/>
</Flex> </Flex>
<Text size="sm">{tPermissions(`item.${permission}.label`)}</Text> <Text size="sm">{tPermissions(`item.${permission}.label`)}</Text>
</Group> </Group>
@@ -131,9 +103,7 @@ const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
<Group flex="1" gap="xs" wrap="nowrap"> <Group flex="1" gap="xs" wrap="nowrap">
<Icon {...iconProps} /> <Icon {...iconProps} />
{option.label} {option.label}
{checked && ( {checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
)}
</Group> </Group>
); );
}; };

View File

@@ -8,7 +8,6 @@ export interface BoardAccessFormType {
}[]; }[];
} }
export const [FormProvider, useFormContext, useForm] = export const [FormProvider, useFormContext, useForm] = createFormContext<BoardAccessFormType>();
createFormContext<BoardAccessFormType>();
export type OnCountChange = (callback: (prev: number) => number) => void; export type OnCountChange = (callback: (prev: number) => number) => void;

View File

@@ -1,16 +1,6 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
Anchor,
Button,
Group,
Stack,
Table,
TableTbody,
TableTh,
TableThead,
TableTr,
} from "@mantine/core";
import { IconPlus } from "@tabler/icons-react"; import { IconPlus } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -24,30 +14,21 @@ import { FormProvider, useForm } from "./form";
import { GroupSelectModal } from "./group-select-modal"; import { GroupSelectModal } from "./group-select-modal";
import type { FormProps } from "./user-access"; import type { FormProps } from "./user-access";
export const GroupsForm = ({ export const GroupsForm = ({ board, initialPermissions, onCountChange }: FormProps) => {
board, const { mutate, isPending } = clientApi.board.saveGroupBoardPermissions.useMutation();
initialPermissions,
onCountChange,
}: FormProps) => {
const { mutate, isPending } =
clientApi.board.saveGroupBoardPermissions.useMutation();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const [groups, setGroups] = useState<Map<string, Group>>( const [groups, setGroups] = useState<Map<string, Group>>(
new Map( new Map(initialPermissions.groupPermissions.map(({ group }) => [group.id, group])),
initialPermissions.groupPermissions.map(({ group }) => [group.id, group]),
),
); );
const { openModal } = useModalAction(GroupSelectModal); const { openModal } = useModalAction(GroupSelectModal);
const t = useI18n(); const t = useI18n();
const tPermissions = useScopedI18n("board.setting.section.access.permission"); const tPermissions = useScopedI18n("board.setting.section.access.permission");
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
items: initialPermissions.groupPermissions.map( items: initialPermissions.groupPermissions.map(({ group, permission }) => ({
({ group, permission }) => ({ itemId: group.id,
itemId: group.id, permission,
permission, })),
}),
),
}, },
}); });
@@ -92,9 +73,7 @@ export const GroupsForm = ({
<Table> <Table>
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh style={{ whiteSpace: "nowrap" }}> <TableTh style={{ whiteSpace: "nowrap" }}>{tPermissions("field.group.label")}</TableTh>
{tPermissions("field.group.label")}
</TableTh>
<TableTh>{tPermissions("field.permission.label")}</TableTh> <TableTh>{tPermissions("field.permission.label")}</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
@@ -102,9 +81,7 @@ export const GroupsForm = ({
{form.values.items.map((row, index) => ( {form.values.items.map((row, index) => (
<BoardAccessSelectRow <BoardAccessSelectRow
key={row.itemId} key={row.itemId}
itemContent={ itemContent={<GroupItemContent group={groups.get(row.itemId)!} />}
<GroupItemContent group={groups.get(row.itemId)!} />
}
permission={row.permission} permission={row.permission}
index={index} index={index}
onCountChange={onCountChange} onCountChange={onCountChange}
@@ -114,11 +91,7 @@ export const GroupsForm = ({
</Table> </Table>
<Group justify="space-between"> <Group justify="space-between">
<Button <Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
rightSection={<IconPlus size="1rem" />}
variant="light"
onClick={handleAddUser}
>
{t("common.action.add")} {t("common.action.add")}
</Button> </Button>
<Button type="submit" loading={isPending} color="teal"> <Button type="submit" loading={isPending} color="teal">
@@ -133,16 +106,10 @@ export const GroupsForm = ({
export const GroupItemContent = ({ group }: { group: Group }) => { export const GroupItemContent = ({ group }: { group: Group }) => {
return ( return (
<Anchor <Anchor component={Link} href={`/manage/users/groups/${group.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
component={Link}
href={`/manage/users/groups/${group.id}`}
size="sm"
style={{ whiteSpace: "nowrap" }}
>
{group.name} {group.name}
</Anchor> </Anchor>
); );
}; };
type Group = type Group = RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"];
RouterOutputs["board"]["getBoardPermissions"]["groupPermissions"][0]["group"];

View File

@@ -16,59 +16,52 @@ interface GroupSelectFormType {
groupId: string; groupId: string;
} }
export const GroupSelectModal = createModal<InnerProps>( export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
({ actions, innerProps }) => { const t = useI18n();
const t = useI18n(); const { data: groups, isPending } = clientApi.group.selectable.useQuery();
const { data: groups, isPending } = clientApi.group.selectable.useQuery(); const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false); const form = useForm<GroupSelectFormType>();
const form = useForm<GroupSelectFormType>(); const handleSubmitAsync = async (values: GroupSelectFormType) => {
const handleSubmitAsync = async (values: GroupSelectFormType) => { const currentGroup = groups?.find((group) => group.id === values.groupId);
const currentGroup = groups?.find((group) => group.id === values.groupId); if (!currentGroup) return;
if (!currentGroup) return; setLoading(true);
setLoading(true); await innerProps.onSelect({
await innerProps.onSelect({ id: currentGroup.id,
id: currentGroup.id, name: currentGroup.name,
name: currentGroup.name, });
});
setLoading(false); setLoading(false);
actions.closeModal(); actions.closeModal();
}; };
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add"); const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
return ( return (
<form <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))} <Stack>
> <Select
<Stack> {...form.getInputProps("groupId")}
<Select label={t("group.action.select.label")}
{...form.getInputProps("groupId")} clearable
label={t("group.action.select.label")} searchable
clearable leftSection={isPending ? <Loader size="xs" /> : undefined}
searchable nothingFoundMessage={t("group.action.select.notFound")}
leftSection={isPending ? <Loader size="xs" /> : undefined} limit={5}
nothingFoundMessage={t("group.action.select.notFound")} data={groups
limit={5} ?.filter((group) => !innerProps.presentGroupIds.includes(group.id))
data={groups .map((group) => ({ value: group.id, label: group.name }))}
?.filter( />
(group) => !innerProps.presentGroupIds.includes(group.id), <Group justify="end">
) <Button variant="default" onClick={actions.closeModal}>
.map((group) => ({ value: group.id, label: group.name }))} {t("common.action.cancel")}
/> </Button>
<Group justify="end"> <Button type="submit" loading={loading}>
<Button variant="default" onClick={actions.closeModal}> {confirmLabel}
{t("common.action.cancel")} </Button>
</Button> </Group>
<Button type="submit" loading={loading}> </Stack>
{confirmLabel} </form>
</Button> );
</Group> }).withOptions({
</Stack> defaultTitle: (t) => t("board.setting.section.access.permission.groupSelect.title"),
</form>
);
},
).withOptions({
defaultTitle: (t) =>
t("board.setting.section.access.permission.groupSelect.title"),
}); });

View File

@@ -1,11 +1,4 @@
import { import { Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
Stack,
Table,
TableTbody,
TableTh,
TableThead,
TableTr,
} from "@mantine/core";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { getPermissionsWithChildren } from "@homarr/definitions"; import { getPermissionsWithChildren } from "@homarr/definitions";
@@ -41,9 +34,7 @@ export const InheritTable = ({ initialPermissions }: InheritTableProps) => {
const boardPermission = const boardPermission =
permission in mapPermissions permission in mapPermissions
? mapPermissions[permission as keyof typeof mapPermissions] ? mapPermissions[permission as keyof typeof mapPermissions]
: getPermissionsWithChildren([permission]).includes( : getPermissionsWithChildren([permission]).includes("board-full-access")
"board-full-access",
)
? "board-full" ? "board-full"
: null; : null;

View File

@@ -1,17 +1,6 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
Anchor,
Box,
Button,
Group,
Stack,
Table,
TableTbody,
TableTh,
TableThead,
TableTr,
} from "@mantine/core";
import { IconPlus } from "@tabler/icons-react"; import { IconPlus } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -21,10 +10,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui"; import { UserAvatar } from "@homarr/ui";
import type { Board } from "../../../_types"; import type { Board } from "../../../_types";
import { import { BoardAccessDisplayRow, BoardAccessSelectRow } from "./board-access-table-rows";
BoardAccessDisplayRow,
BoardAccessSelectRow,
} from "./board-access-table-rows";
import type { BoardAccessFormType, OnCountChange } from "./form"; import type { BoardAccessFormType, OnCountChange } from "./form";
import { FormProvider, useForm } from "./form"; import { FormProvider, useForm } from "./form";
import { UserSelectModal } from "./user-select-modal"; import { UserSelectModal } from "./user-select-modal";
@@ -35,18 +21,11 @@ export interface FormProps {
onCountChange: OnCountChange; onCountChange: OnCountChange;
} }
export const UsersForm = ({ export const UsersForm = ({ board, initialPermissions, onCountChange }: FormProps) => {
board, const { mutate, isPending } = clientApi.board.saveUserBoardPermissions.useMutation();
initialPermissions,
onCountChange,
}: FormProps) => {
const { mutate, isPending } =
clientApi.board.saveUserBoardPermissions.useMutation();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const [users, setUsers] = useState<Map<string, User>>( const [users, setUsers] = useState<Map<string, User>>(
new Map( new Map(initialPermissions.userPermissions.map(({ user }) => [user.id, user])),
initialPermissions.userPermissions.map(({ user }) => [user.id, user]),
),
); );
const { openModal } = useModalAction(UserSelectModal); const { openModal } = useModalAction(UserSelectModal);
const t = useI18n(); const t = useI18n();
@@ -81,9 +60,7 @@ export const UsersForm = ({
const presentUserIds = form.values.items.map(({ itemId: id }) => id); const presentUserIds = form.values.items.map(({ itemId: id }) => id);
openModal({ openModal({
presentUserIds: board.creatorId presentUserIds: board.creatorId ? presentUserIds.concat(board.creatorId) : presentUserIds,
? presentUserIds.concat(board.creatorId)
: presentUserIds,
onSelect: (user) => { onSelect: (user) => {
setUsers((prev) => new Map(prev).set(user.id, user)); setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [ form.setFieldValue("items", [
@@ -111,17 +88,12 @@ export const UsersForm = ({
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{board.creator && ( {board.creator && (
<BoardAccessDisplayRow <BoardAccessDisplayRow itemContent={<UserItemContent user={board.creator} />} permission="board-full" />
itemContent={<UserItemContent user={board.creator} />}
permission="board-full"
/>
)} )}
{form.values.items.map((row, index) => ( {form.values.items.map((row, index) => (
<BoardAccessSelectRow <BoardAccessSelectRow
key={row.itemId} key={row.itemId}
itemContent={ itemContent={<UserItemContent user={users.get(row.itemId)!} />}
<UserItemContent user={users.get(row.itemId)!} />
}
permission={row.permission} permission={row.permission}
index={index} index={index}
onCountChange={onCountChange} onCountChange={onCountChange}
@@ -131,11 +103,7 @@ export const UsersForm = ({
</Table> </Table>
<Group justify="space-between"> <Group justify="space-between">
<Button <Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
rightSection={<IconPlus size="1rem" />}
variant="light"
onClick={handleAddUser}
>
{t("common.action.add")} {t("common.action.add")}
</Button> </Button>
<Button type="submit" loading={isPending} color="teal"> <Button type="submit" loading={isPending} color="teal">
@@ -154,12 +122,7 @@ const UserItemContent = ({ user }: { user: User }) => {
<Box visibleFrom="xs"> <Box visibleFrom="xs">
<UserAvatar user={user} size="sm" /> <UserAvatar user={user} size="sm" />
</Box> </Box>
<Anchor <Anchor component={Link} href={`/manage/users/${user.id}`} size="sm" style={{ whiteSpace: "nowrap" }}>
component={Link}
href={`/manage/users/${user.id}`}
size="sm"
style={{ whiteSpace: "nowrap" }}
>
{user.name} {user.name}
</Anchor> </Anchor>
</Group> </Group>

View File

@@ -12,11 +12,7 @@ import { UserAvatar } from "@homarr/ui";
interface InnerProps { interface InnerProps {
presentUserIds: string[]; presentUserIds: string[];
onSelect: (props: { onSelect: (props: { id: string; name: string; image: string }) => void | Promise<void>;
id: string;
name: string;
image: string;
}) => void | Promise<void>;
confirmLabel?: string; confirmLabel?: string;
} }
@@ -24,68 +20,59 @@ interface UserSelectFormType {
userId: string; userId: string;
} }
export const UserSelectModal = createModal<InnerProps>( export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
({ actions, innerProps }) => { const t = useI18n();
const t = useI18n(); const { data: users, isPending } = clientApi.user.selectable.useQuery();
const { data: users, isPending } = clientApi.user.selectable.useQuery(); const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false); const form = useForm<UserSelectFormType>();
const form = useForm<UserSelectFormType>(); const handleSubmitAsync = async (values: UserSelectFormType) => {
const handleSubmitAsync = async (values: UserSelectFormType) => { const currentUser = users?.find((user) => user.id === values.userId);
const currentUser = users?.find((user) => user.id === values.userId); if (!currentUser) return;
if (!currentUser) return; setLoading(true);
setLoading(true); await innerProps.onSelect({
await innerProps.onSelect({ id: currentUser.id,
id: currentUser.id, name: currentUser.name ?? "",
name: currentUser.name ?? "", image: currentUser.image ?? "",
image: currentUser.image ?? "", });
});
setLoading(false); setLoading(false);
actions.closeModal(); actions.closeModal();
}; };
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add"); const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
const currentUser = users?.find((user) => user.id === form.values.userId); const currentUser = users?.find((user) => user.id === form.values.userId);
return ( return (
<form <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))} <Stack>
> <Select
<Stack> {...form.getInputProps("userId")}
<Select label={t("user.action.select.label")}
{...form.getInputProps("userId")} searchable
label={t("user.action.select.label")} clearable
searchable leftSection={
clearable isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined
leftSection={ }
isPending ? ( nothingFoundMessage={t("user.action.select.notFound")}
<Loader size="xs" /> renderOption={createRenderOption(users ?? [])}
) : currentUser ? ( limit={5}
<UserAvatar user={currentUser} size="xs" /> data={users
) : undefined ?.filter((user) => !innerProps.presentUserIds.includes(user.id))
} .map((user) => ({ value: user.id, label: user.name ?? "" }))}
nothingFoundMessage={t("user.action.select.notFound")} />
renderOption={createRenderOption(users ?? [])} <Group justify="end">
limit={5} <Button variant="default" onClick={actions.closeModal}>
data={users {t("common.action.cancel")}
?.filter((user) => !innerProps.presentUserIds.includes(user.id)) </Button>
.map((user) => ({ value: user.id, label: user.name ?? "" }))} <Button type="submit" loading={loading}>
/> {confirmLabel}
<Group justify="end"> </Button>
<Button variant="default" onClick={actions.closeModal}> </Group>
{t("common.action.cancel")} </Stack>
</Button> </form>
<Button type="submit" loading={loading}> );
{confirmLabel} }).withOptions({
</Button> defaultTitle: (t) => t("board.setting.section.access.permission.userSelect.title"),
</Group>
</Stack>
</form>
);
},
).withOptions({
defaultTitle: (t) =>
t("board.setting.section.access.permission.userSelect.title"),
}); });
const iconProps = { const iconProps = {
@@ -95,9 +82,7 @@ const iconProps = {
size: "1rem", size: "1rem",
}; };
const createRenderOption = ( const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] =>
users: RouterOutputs["user"]["selectable"],
): SelectProps["renderOption"] =>
function InnerRenderRoot({ option, checked }) { function InnerRenderRoot({ option, checked }) {
const user = users.find((user) => user.id === option.value); const user = users.find((user) => user.id === option.value);
if (!user) return null; if (!user) return null;
@@ -106,9 +91,7 @@ const createRenderOption = (
<Group flex="1" gap="xs"> <Group flex="1" gap="xs">
<UserAvatar user={user} size="xs" /> <UserAvatar user={user} size="xs" />
{option.label} {option.label}
{checked && ( {checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
)}
</Group> </Group>
); );
}; };

View File

@@ -2,11 +2,7 @@
import { Button, Grid, Group, Stack, TextInput } from "@mantine/core"; import { Button, Grid, Group, Stack, TextInput } from "@mantine/core";
import { import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
} from "@homarr/definitions";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import type { TranslationObject } from "@homarr/translation"; import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -22,8 +18,7 @@ interface Props {
} }
export const BackgroundSettingsContent = ({ board }: Props) => { export const BackgroundSettingsContent = ({ board }: Props) => {
const t = useI18n(); const t = useI18n();
const { mutate: savePartialSettings, isPending } = const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
useSavePartialSettingsMutation(board);
const form = useZodForm(validation.board.savePartialSettings, { const form = useZodForm(validation.board.savePartialSettings, {
initialValues: { initialValues: {
backgroundImageUrl: board.backgroundImageUrl ?? "", backgroundImageUrl: board.backgroundImageUrl ?? "",
@@ -37,14 +32,8 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
"backgroundImageAttachment", "backgroundImageAttachment",
backgroundImageAttachments, backgroundImageAttachments,
); );
const backgroundImageSizeData = useBackgroundOptionData( const backgroundImageSizeData = useBackgroundOptionData("backgroundImageSize", backgroundImageSizes);
"backgroundImageSize", const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
backgroundImageSizes,
);
const backgroundImageRepeatData = useBackgroundOptionData(
"backgroundImageRepeat",
backgroundImageRepeats,
);
return ( return (
<form <form
@@ -96,13 +85,9 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
); );
}; };
type BackgroundImageKey = type BackgroundImageKey = "backgroundImageAttachment" | "backgroundImageSize" | "backgroundImageRepeat";
| "backgroundImageAttachment"
| "backgroundImageSize"
| "backgroundImageRepeat";
type inferOptions<TKey extends BackgroundImageKey> = type inferOptions<TKey extends BackgroundImageKey> = TranslationObject["board"]["field"][TKey]["option"];
TranslationObject["board"]["field"][TKey]["option"];
const useBackgroundOptionData = < const useBackgroundOptionData = <
TKey extends BackgroundImageKey, TKey extends BackgroundImageKey,
@@ -120,9 +105,7 @@ const useBackgroundOptionData = <
(value) => (value) =>
({ ({
label: t(`board.field.${key}.option.${value as string}.label` as never), label: t(`board.field.${key}.option.${value as string}.label` as never),
description: t( description: t(`board.field.${key}.option.${value as string}.description` as never),
`board.field.${key}.option.${value as string}.description` as never,
),
value: value as string, value: value as string,
badge: badge:
data.defaultValue === value data.defaultValue === value

View File

@@ -44,8 +44,7 @@ export const ColorSettingsContent = ({ board }: Props) => {
const [showPreview, { toggle }] = useDisclosure(false); const [showPreview, { toggle }] = useDisclosure(false);
const t = useI18n(); const t = useI18n();
const theme = useMantineTheme(); const theme = useMantineTheme();
const { mutate: savePartialSettings, isPending } = const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
useSavePartialSettingsMutation(board);
return ( return (
<form <form
@@ -77,11 +76,7 @@ export const ColorSettingsContent = ({ board }: Props) => {
/> />
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<Anchor onClick={toggle}> <Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
{showPreview
? t("common.preview.hide")
: t("common.preview.show")}
</Anchor>
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<Collapse in={showPreview}> <Collapse in={showPreview}>
@@ -121,10 +116,7 @@ interface ColorsPreviewProps {
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => { const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
const theme = useMantineTheme(); const theme = useMantineTheme();
const colors = const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000");
previewColor && hexRegex.test(previewColor)
? generateColors(previewColor)
: generateColors("#000000");
return ( return (
<Group gap={0} wrap="nowrap"> <Group gap={0} wrap="nowrap">

View File

@@ -20,8 +20,7 @@ export const DangerZoneSettingsContent = () => {
const { openModal } = useModalAction(BoardRenameModal); const { openModal } = useModalAction(BoardRenameModal);
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } = const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
clientApi.board.changeBoardVisibility.useMutation(); clientApi.board.changeBoardVisibility.useMutation();
const { mutate: deleteBoard, isPending: isDeletePending } = const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation();
clientApi.board.deleteBoard.useMutation();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const visibility = board.isPublic ? "public" : "private"; const visibility = board.isPublic ? "public" : "private";
@@ -37,12 +36,8 @@ export const DangerZoneSettingsContent = () => {
const onVisibilityClick = useCallback(() => { const onVisibilityClick = useCallback(() => {
openConfirmModal({ openConfirmModal({
title: t( title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`),
`section.dangerZone.action.visibility.confirm.${visibility}.title`, children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`),
),
children: t(
`section.dangerZone.action.visibility.confirm.${visibility}.description`,
),
onConfirm: () => { onConfirm: () => {
changeVisibility( changeVisibility(
{ {
@@ -98,12 +93,8 @@ export const DangerZoneSettingsContent = () => {
<Divider /> <Divider />
<DangerZoneRow <DangerZoneRow
label={t("section.dangerZone.action.visibility.label")} label={t("section.dangerZone.action.visibility.label")}
description={t( description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
`section.dangerZone.action.visibility.description.${visibility}`, buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
)}
buttonText={t(
`section.dangerZone.action.visibility.button.${visibility}`,
)}
onClick={onVisibilityClick} onClick={onVisibilityClick}
isPending={isChangeVisibilityPending} isPending={isChangeVisibilityPending}
/> />
@@ -127,13 +118,7 @@ interface DangerZoneRowProps {
onClick: () => void; onClick: () => void;
} }
const DangerZoneRow = ({ const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => {
label,
description,
buttonText,
onClick,
isPending,
}: DangerZoneRowProps) => {
return ( return (
<Group justify="space-between" px="md" className={classes.dangerZoneGroup}> <Group justify="space-between" px="md" className={classes.dangerZoneGroup}>
<Stack gap={0}> <Stack gap={0}>
@@ -143,12 +128,7 @@ const DangerZoneRow = ({
<Text size="sm">{description}</Text> <Text size="sm">{description}</Text>
</Stack> </Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}> <Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button <Button variant="subtle" color="red" loading={isPending} onClick={onClick}>
variant="subtle"
color="red"
loading={isPending}
onClick={onClick}
>
{buttonText} {buttonText}
</Button> </Button>
</Group> </Group>

View File

@@ -1,20 +1,8 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { import { Button, Grid, Group, Loader, Stack, TextInput, Tooltip } from "@mantine/core";
Button, import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks";
Grid,
Group,
Loader,
Stack,
TextInput,
Tooltip,
} from "@mantine/core";
import {
useDebouncedValue,
useDocumentTitle,
useFavicon,
} from "@mantine/hooks";
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle } from "@tabler/icons-react";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
@@ -38,8 +26,7 @@ export const GeneralSettingsContent = ({ board }: Props) => {
}); });
const { updateBoard } = useUpdateBoard(); const { updateBoard } = useUpdateBoard();
const { mutate: savePartialSettings, isPending } = const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
useSavePartialSettingsMutation(board);
const form = useZodForm( const form = useZodForm(
validation.board.savePartialSettings validation.board.savePartialSettings
.pick({ .pick({
@@ -106,9 +93,7 @@ export const GeneralSettingsContent = ({ board }: Props) => {
<Grid.Col span={{ xs: 12, md: 6 }}> <Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput <TextInput
label={t("board.field.metaTitle.label")} label={t("board.field.metaTitle.label")}
placeholder={createMetaTitle( placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
t("board.content.metaTitle", { boardName: board.name }),
)}
rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />} rightSection={<PendingOrInvalidIndicator {...metaTitleStatus} />}
{...form.getInputProps("metaTitle")} {...form.getInputProps("metaTitle")}
/> />
@@ -140,22 +125,12 @@ export const GeneralSettingsContent = ({ board }: Props) => {
); );
}; };
const PendingOrInvalidIndicator = ({ const PendingOrInvalidIndicator = ({ isPending, isInvalid }: { isPending: boolean; isInvalid?: boolean }) => {
isPending,
isInvalid,
}: {
isPending: boolean;
isInvalid?: boolean;
}) => {
const t = useI18n(); const t = useI18n();
if (isInvalid) { if (isInvalid) {
return ( return (
<Tooltip <Tooltip multiline w={220} label={t("board.setting.section.general.unrecognizedLink")}>
multiline
w={220}
label={t("board.setting.section.general.unrecognizedLink")}
>
<IconAlertTriangle size="1rem" color="red" /> <IconAlertTriangle size="1rem" color="red" />
</Tooltip> </Tooltip>
); );
@@ -197,8 +172,7 @@ const useMetaTitlePreview = (title: string | null) => {
const validFaviconExtensions = ["ico", "png", "svg", "gif"]; const validFaviconExtensions = ["ico", "png", "svg", "gif"];
const isValidUrl = (url: string) => const isValidUrl = (url: string) =>
url.includes("/") && url.includes("/") && validFaviconExtensions.some((extension) => url.endsWith(`.${extension}`));
validFaviconExtensions.some((extension) => url.endsWith(`.${extension}`));
const useFaviconPreview = (url: string | null) => { const useFaviconPreview = (url: string | null) => {
const [faviconDebounced] = useDebouncedValue(url ?? "", 500); const [faviconDebounced] = useDebouncedValue(url ?? "", 500);

View File

@@ -14,16 +14,12 @@ interface Props {
} }
export const LayoutSettingsContent = ({ board }: Props) => { export const LayoutSettingsContent = ({ board }: Props) => {
const t = useI18n(); const t = useI18n();
const { mutate: savePartialSettings, isPending } = const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
useSavePartialSettingsMutation(board); const form = useZodForm(validation.board.savePartialSettings.pick({ columnCount: true }).required(), {
const form = useZodForm( initialValues: {
validation.board.savePartialSettings.pick({ columnCount: true }).required(), columnCount: board.columnCount,
{
initialValues: {
columnCount: board.columnCount,
},
}, },
); });
return ( return (
<form <form
@@ -38,13 +34,7 @@ export const LayoutSettingsContent = ({ board }: Props) => {
<Grid> <Grid>
<Grid.Col span={{ sm: 12, md: 6 }}> <Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("board.field.columnCount.label")}> <Input.Wrapper label={t("board.field.columnCount.label")}>
<Slider <Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps("columnCount")} />
mt="xs"
min={1}
max={24}
step={1}
{...form.getInputProps("columnCount")}
/>
</Input.Wrapper> </Input.Wrapper>
</Grid.Col> </Grid.Col>
</Grid> </Grid>

View File

@@ -1,14 +1,6 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text, Title } from "@mantine/core";
AccordionControl,
AccordionItem,
AccordionPanel,
Container,
Stack,
Text,
Title,
} from "@mantine/core";
import { import {
IconAlertTriangle, IconAlertTriangle,
IconBrush, IconBrush,
@@ -69,10 +61,7 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
} }
}; };
export default async function BoardSettingsPage({ export default async function BoardSettingsPage({ params, searchParams }: Props) {
params,
searchParams,
}: Props) {
const { board, permissions } = await getBoardAndPermissionsAsync(params); const { board, permissions } = await getBoardAndPermissionsAsync(params);
const { hasFullAccess } = await getBoardPermissionsAsync(board); const { hasFullAccess } = await getBoardPermissionsAsync(board);
const t = await getScopedI18n("board.setting"); const t = await getScopedI18n("board.setting");
@@ -81,10 +70,7 @@ export default async function BoardSettingsPage({
<Container> <Container>
<Stack> <Stack>
<Title>{t("title", { boardName: capitalize(board.name) })}</Title> <Title>{t("title", { boardName: capitalize(board.name) })}</Title>
<ActiveTabAccordion <ActiveTabAccordion variant="separated" defaultValue={searchParams.tab ?? "general"}>
variant="separated"
defaultValue={searchParams.tab ?? "general"}
>
<AccordionItemFor value="general" icon={IconSettings}> <AccordionItemFor value="general" icon={IconSettings}>
<GeneralSettingsContent board={board} /> <GeneralSettingsContent board={board} />
</AccordionItemFor> </AccordionItemFor>
@@ -103,17 +89,9 @@ export default async function BoardSettingsPage({
{hasFullAccess && ( {hasFullAccess && (
<> <>
<AccordionItemFor value="access" icon={IconUser}> <AccordionItemFor value="access" icon={IconUser}>
<AccessSettingsContent <AccessSettingsContent board={board} initialPermissions={permissions} />
board={board}
initialPermissions={permissions}
/>
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor <AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
value="dangerZone"
icon={IconAlertTriangle}
danger
noPadding
>
<DangerZoneSettingsContent /> <DangerZoneSettingsContent />
</AccordionItemFor> </AccordionItemFor>
</> </>
@@ -131,13 +109,7 @@ type AccordionItemForProps = PropsWithChildren<{
noPadding?: boolean; noPadding?: boolean;
}>; }>;
const AccordionItemFor = async ({ const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding }: AccordionItemForProps) => {
value,
children,
icon: Icon,
danger,
noPadding,
}: AccordionItemForProps) => {
const t = await getScopedI18n("board.setting.section"); const t = await getScopedI18n("board.setting.section");
return ( return (
<AccordionItem <AccordionItem
@@ -158,13 +130,7 @@ const AccordionItemFor = async ({
{t(`${value}.title`)} {t(`${value}.title`)}
</Text> </Text>
</AccordionControl> </AccordionControl>
<AccordionPanel <AccordionPanel styles={noPadding ? { content: { paddingRight: 0, paddingLeft: 0 } } : undefined}>
styles={
noPadding
? { content: { paddingRight: 0, paddingLeft: 0 } }
: undefined
}
>
{children} {children}
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>

View File

@@ -41,10 +41,7 @@ export const createBoardLayout = <TParams extends Params>({
}); });
return ( return (
<GlobalItemServerDataRunner <GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}>
board={initialBoard}
shouldRun={isBoardContentPage}
>
<BoardProvider initialBoard={initialBoard}> <BoardProvider initialBoard={initialBoard}>
<BoardMantineProvider> <BoardMantineProvider>
<ClientShell hasNavigation={false}> <ClientShell hasNavigation={false}>

View File

@@ -8,7 +8,4 @@ export type Item = Section["items"][number];
export type CategorySection = Extract<Section, { kind: "category" }>; export type CategorySection = Extract<Section, { kind: "category" }>;
export type EmptySection = Extract<Section, { kind: "empty" }>; export type EmptySection = Extract<Section, { kind: "empty" }>;
export type ItemOfKind<TKind extends WidgetKind> = Extract< export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
Item,
{ kind: TKind }
>;

View File

@@ -5,14 +5,12 @@ type PropsWithChildren = Required<React.PropsWithChildren>;
export const composeWrappers = ( export const composeWrappers = (
wrappers: React.FunctionComponent<PropsWithChildren>[], wrappers: React.FunctionComponent<PropsWithChildren>[],
): React.FunctionComponent<PropsWithChildren> => { ): React.FunctionComponent<PropsWithChildren> => {
return wrappers return wrappers.reverse().reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => {
.reverse() // eslint-disable-next-line react/display-name
.reduce((Acc, Current): React.FunctionComponent<PropsWithChildren> => { return (props) => (
// eslint-disable-next-line react/display-name <Current>
return (props) => ( <Acc {...props} />
<Current> </Current>
<Acc {...props} /> );
</Current> });
);
});
}; };

View File

@@ -5,10 +5,7 @@ import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -16,8 +13,7 @@ import { validation } from "@homarr/validation";
export const InitUserForm = () => { export const InitUserForm = () => {
const router = useRouter(); const router = useRouter();
const t = useScopedI18n("user"); const t = useScopedI18n("user");
const { mutateAsync, error, isPending } = const { mutateAsync, error, isPending } = clientApi.user.initUser.useMutation();
clientApi.user.initUser.useMutation();
const form = useZodForm(validation.user.init, { const form = useZodForm(validation.user.init, {
initialValues: { initialValues: {
username: "", username: "",
@@ -53,18 +49,9 @@ export const InitUserForm = () => {
)} )}
> >
<Stack gap="lg"> <Stack gap="lg">
<TextInput <TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
label={t("field.username.label")} <PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
{...form.getInputProps("username")} <PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
/>
<PasswordInput
label={t("field.password.label")}
{...form.getInputProps("password")}
/>
<PasswordInput
label={t("field.passwordConfirm.label")}
{...form.getInputProps("confirmPassword")}
/>
<Button type="submit" fullWidth loading={isPending}> <Button type="submit" fullWidth loading={isPending}>
{t("action.create")} {t("action.create")}
</Button> </Button>

View File

@@ -48,10 +48,7 @@ export const viewport: Viewport = {
], ],
}; };
export default function Layout(props: { export default function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
children: React.ReactNode;
params: { locale: string };
}) {
const colorScheme = "dark"; const colorScheme = "dark";
const StackedProvider = composeWrappers([ const StackedProvider = composeWrappers([
@@ -61,9 +58,7 @@ export default function Layout(props: {
}, },
(innerProps) => <JotaiProvider {...innerProps} />, (innerProps) => <JotaiProvider {...innerProps} />,
(innerProps) => <TRPCReactProvider {...innerProps} />, (innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => ( (innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />,
<NextInternationalProvider {...innerProps} locale={props.params.locale} />
),
(innerProps) => ( (innerProps) => (
<MantineProvider <MantineProvider
{...innerProps} {...innerProps}

View File

@@ -48,13 +48,7 @@ export const HeroBanner = () => {
<Title>Homarr Dashboard</Title> <Title>Homarr Dashboard</Title>
</Group> </Group>
</Stack> </Stack>
<Box <Box className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
className={classes.scrollContainer}
w={"30%"}
top={0}
right={0}
pos="absolute"
>
<Grid> <Grid>
{Array(countIconGroups) {Array(countIconGroups)
.fill(0) .fill(0)
@@ -67,24 +61,12 @@ export const HeroBanner = () => {
}} }}
> >
{arrayInChunks[columnIndex]?.map((icon, index) => ( {arrayInChunks[columnIndex]?.map((icon, index) => (
<Image <Image key={`grid-column-${columnIndex}-scroll-1-${index}`} src={icon} radius="md" w={50} h={50} />
key={`grid-column-${columnIndex}-scroll-1-${index}`}
src={icon}
radius="md"
w={50}
h={50}
/>
))} ))}
{/* This is used for making the animation seem seamless */} {/* This is used for making the animation seem seamless */}
{arrayInChunks[columnIndex]?.map((icon, index) => ( {arrayInChunks[columnIndex]?.map((icon, index) => (
<Image <Image key={`grid-column-${columnIndex}-scroll-2-${index}`} src={icon} radius="md" w={50} h={50} />
key={`grid-column-${columnIndex}-scroll-2-${index}`}
src={icon}
radius="md"
w={50}
h={50}
/>
))} ))}
</Stack> </Stack>
</GridCol> </GridCol>

View File

@@ -1,6 +1,3 @@
.contributorCard { .contributorCard {
background-color: light-dark( background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
} }

View File

@@ -55,9 +55,7 @@ export default async function AboutPage({ params: { locale } }: PageProps) {
<Title order={1} tt="uppercase"> <Title order={1} tt="uppercase">
Homarr Homarr
</Title> </Title>
<Title order={2}> <Title order={2}>{t("version", { version: attributes.version })}</Title>
{t("version", { version: attributes.version })}
</Title>
</Stack> </Stack>
</Group> </Group>
</Center> </Center>
@@ -150,20 +148,10 @@ interface GenericContributorLinkCardProps {
image: string; image: string;
} }
const GenericContributorLinkCard = ({ const GenericContributorLinkCard = ({ name, image, link }: GenericContributorLinkCardProps) => {
name,
image,
link,
}: GenericContributorLinkCardProps) => {
return ( return (
<AspectRatio ratio={1}> <AspectRatio ratio={1}>
<Card <Card className={classes.contributorCard} component="a" href={link} target="_blank" w={100}>
className={classes.contributorCard}
component="a"
href={link}
target="_blank"
w={100}
>
<Stack align="center"> <Stack align="center">
<Avatar src={image} alt={name} size={40} display="block" /> <Avatar src={image} alt={name} size={40} display="block" />
<Text lineClamp={1} size="sm"> <Text lineClamp={1} size="sm">

View File

@@ -7,10 +7,7 @@ import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "../../../revalidatePathAction"; import { revalidatePathActionAsync } from "../../../revalidatePathAction";
@@ -52,13 +49,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
}, [app, mutate, t, openConfirmModal]); }, [app, mutate, t, openConfirmModal]);
return ( return (
<ActionIcon <ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label="Delete app">
loading={isPending}
variant="subtle"
color="red"
onClick={onClick}
aria-label="Delete app"
>
<IconTrash color="red" size={16} stroke={1.5} /> <IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
); );

View File

@@ -21,8 +21,7 @@ interface AppFormProps {
} }
export const AppForm = (props: AppFormProps) => { export const AppForm = (props: AppFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending } = const { submitButtonTranslation, handleSubmit, initialValues, isPending } = props;
props;
const t = useI18n(); const t = useI18n();
const form = useZodForm(validation.app.manage, { const form = useZodForm(validation.app.manage, {
@@ -38,10 +37,7 @@ export const AppForm = (props: AppFormProps) => {
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label="Name" /> <TextInput {...form.getInputProps("name")} withAsterisk label="Name" />
<IconPicker <IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
initialValue={initialValues?.iconUrl}
{...form.getInputProps("iconUrl")}
/>
<Textarea {...form.getInputProps("description")} label="Description" /> <Textarea {...form.getInputProps("description")} label="Description" />
<TextInput {...form.getInputProps("href")} label="URL" /> <TextInput {...form.getInputProps("href")} label="URL" />

View File

@@ -5,10 +5,7 @@ import { useRouter } from "next/navigation";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation"; import type { validation, z } from "@homarr/validation";
@@ -52,10 +49,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
[mutate, app.id], [mutate, app.id],
); );
const submitButtonTranslation = useCallback( const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
(t: TranslationFunction) => t("common.action.save"),
[],
);
return ( return (
<AppForm <AppForm

View File

@@ -4,10 +4,7 @@ import { useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation"; import type { validation, z } from "@homarr/validation";
@@ -44,16 +41,9 @@ export const AppNewForm = () => {
[mutate], [mutate],
); );
const submitButtonTranslation = useCallback( const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
(t: TranslationFunction) => t("common.action.create"),
[],
);
return ( return (
<AppForm <AppForm submitButtonTranslation={submitButtonTranslation} handleSubmit={handleSubmit} isPending={isPending} />
submitButtonTranslation={submitButtonTranslation}
handleSubmit={handleSubmit}
isPending={isPending}
/>
); );
}; };

View File

@@ -107,9 +107,7 @@ const AppNoResults = async () => {
<Text fw={500} size="lg"> <Text fw={500} size="lg">
{t("app.page.list.noResults.title")} {t("app.page.list.noResults.title")}
</Text> </Text>
<Anchor href="/manage/apps/new"> <Anchor href="/manage/apps/new">{t("app.page.list.noResults.description")}</Anchor>
{t("app.page.list.noResults.description")}
</Anchor>
</Stack> </Stack>
</Card> </Card>
); );

View File

@@ -21,18 +21,11 @@ const iconProps = {
interface BoardCardMenuDropdownProps { interface BoardCardMenuDropdownProps {
board: Pick< board: Pick<
RouterOutputs["board"]["getAllBoards"][number], RouterOutputs["board"]["getAllBoards"][number],
| "id" "id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
| "name"
| "creator"
| "userPermissions"
| "groupPermissions"
| "isPublic"
>; >;
} }
export const BoardCardMenuDropdown = ({ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
board,
}: BoardCardMenuDropdownProps) => {
const t = useScopedI18n("management.page.board.action"); const t = useScopedI18n("management.page.board.action");
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
@@ -73,10 +66,7 @@ export const BoardCardMenuDropdown = ({
return ( return (
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
onClick={handleSetHomeBoard}
leftSection={<IconHome {...iconProps} />}
>
{t("setHomeBoard.label")} {t("setHomeBoard.label")}
</Menu.Item> </Menu.Item>
{hasChangeAccess && ( {hasChangeAccess && (

View File

@@ -37,11 +37,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
}, [mutateAsync, boardNames, openModal]); }, [mutateAsync, boardNames, openModal]);
return ( return (
<Button <Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}>
leftSection={<IconCategoryPlus size="1rem" />}
onClick={onClick}
loading={isPending}
>
{t("management.page.board.action.new.label")} {t("management.page.board.action.new.label")}
</Button> </Button>
); );

View File

@@ -14,12 +14,7 @@ import {
Title, Title,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
IconDotsVertical,
IconHomeFilled,
IconLock,
IconWorld,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -59,8 +54,7 @@ interface BoardCardProps {
const BoardCard = async ({ board }: BoardCardProps) => { const BoardCard = async ({ board }: BoardCardProps) => {
const t = await getScopedI18n("management.page.board"); const t = await getScopedI18n("management.page.board");
const { hasChangeAccess: isMenuVisible } = const { hasChangeAccess: isMenuVisible } = await getBoardPermissionsAsync(board);
await getBoardPermissionsAsync(board);
const visibility = board.isPublic ? "public" : "private"; const visibility = board.isPublic ? "public" : "private";
const VisibilityIcon = board.isPublic ? IconWorld : IconLock; const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
@@ -80,12 +74,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
<Group> <Group>
{board.isHome && ( {board.isHome && (
<Tooltip label={t("action.setHomeBoard.badge.tooltip")}> <Tooltip label={t("action.setHomeBoard.badge.tooltip")}>
<Badge <Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
tt="none"
color="yellow"
variant="light"
leftSection={<IconHomeFilled size=".7rem" />}
>
{t("action.setHomeBoard.badge.label")} {t("action.setHomeBoard.badge.label")}
</Badge> </Badge>
</Tooltip> </Tooltip>
@@ -103,12 +92,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
<CardSection p="sm"> <CardSection p="sm">
<Group wrap="nowrap"> <Group wrap="nowrap">
<Button <Button component={Link} href={`/boards/${board.name}`} variant="default" fullWidth>
component={Link}
href={`/boards/${board.name}`}
variant="default"
fullWidth
>
{t("action.open.label")} {t("action.open.label")}
</Button> </Button>
{isMenuVisible && ( {isMenuVisible && (

View File

@@ -6,10 +6,7 @@ import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "../../../revalidatePathAction"; import { revalidatePathActionAsync } from "../../../revalidatePathAction";
@@ -19,10 +16,7 @@ interface DeleteIntegrationActionButtonProps {
integration: { id: string; name: string }; integration: { id: string; name: string };
} }
export const DeleteIntegrationActionButton = ({ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteIntegrationActionButtonProps) => {
count,
integration,
}: DeleteIntegrationActionButtonProps) => {
const t = useScopedI18n("integration.page.delete"); const t = useScopedI18n("integration.page.delete");
const router = useRouter(); const router = useRouter();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();

View File

@@ -2,17 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { import { ActionIcon, Avatar, Button, Card, Collapse, Group, Kbd, Stack, Text } from "@mantine/core";
ActionIcon,
Avatar,
Button,
Card,
Collapse,
Group,
Kbd,
Stack,
Text,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { IconEye, IconEyeOff } from "@tabler/icons-react"; import { IconEye, IconEyeOff } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -36,8 +26,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
const params = useParams<{ locale: string }>(); const params = useParams<{ locale: string }>();
const t = useI18n(); const t = useI18n();
const { isPublic } = integrationSecretKindObject[secret.kind]; const { isPublic } = integrationSecretKindObject[secret.kind];
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false);
useDisclosure(false);
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const DisplayIcon = publicSecretDisplayOpened ? IconEye : IconEyeOff; const DisplayIcon = publicSecretDisplayOpened ? IconEye : IconEyeOff;
const KindIcon = integrationSecretIcons[secret.kind]; const KindIcon = integrationSecretIcons[secret.kind];
@@ -50,9 +39,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
<Avatar> <Avatar>
<KindIcon size={16} /> <KindIcon size={16} />
</Avatar> </Avatar>
<Text fw={500}> <Text fw={500}>{t(`integration.secrets.kind.${secret.kind}.label`)}</Text>
{t(`integration.secrets.kind.${secret.kind}.label`)}
</Text>
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null} {publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
</Group> </Group>
<Group> <Group>
@@ -62,11 +49,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
})} })}
</Text> </Text>
{isPublic ? ( {isPublic ? (
<ActionIcon <ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
color="gray"
variant="subtle"
onClick={togglePublicSecretDisplay}
>
<DisplayIcon size={16} stroke={1.5} /> <DisplayIcon size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
) : null} ) : null}

View File

@@ -42,10 +42,7 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
); );
}; };
const PrivateSecretInput = ({ const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
kind,
...props
}: IntegrationSecretInputProps) => {
const t = useI18n(); const t = useI18n();
const Icon = integrationSecretIcons[kind]; const Icon = integrationSecretIcons[kind];

View File

@@ -6,10 +6,7 @@ import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
import type { RouterInputs } from "@homarr/api"; import type { RouterInputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
interface UseTestConnectionDirtyProps { interface UseTestConnectionDirtyProps {
@@ -20,10 +17,7 @@ interface UseTestConnectionDirtyProps {
}; };
} }
export const useTestConnectionDirty = ({ export const useTestConnectionDirty = ({ defaultDirty, initialFormValue }: UseTestConnectionDirtyProps) => {
defaultDirty,
initialFormValue,
}: UseTestConnectionDirtyProps) => {
const [isDirty, setIsDirty] = useState(defaultDirty); const [isDirty, setIsDirty] = useState(defaultDirty);
const prevFormValueRef = useRef(initialFormValue); const prevFormValueRef = useRef(initialFormValue);
@@ -36,10 +30,7 @@ export const useTestConnectionDirty = ({
prevFormValueRef.current.url !== values.url || prevFormValueRef.current.url !== values.url ||
!prevFormValueRef.current.secrets !prevFormValueRef.current.secrets
.map((secret) => secret.value) .map((secret) => secret.value)
.every( .every((secretValue, index) => values.secrets[index]?.value === secretValue)
(secretValue, index) =>
values.secrets[index]?.value === secretValue,
)
) { ) {
setIsDirty(true); setIsDirty(true);
return; return;
@@ -62,14 +53,9 @@ interface TestConnectionProps {
integration: RouterInputs["integration"]["testConnection"] & { name: string }; integration: RouterInputs["integration"]["testConnection"] & { name: string };
} }
export const TestConnection = ({ export const TestConnection = ({ integration, removeDirty, isDirty }: TestConnectionProps) => {
integration,
removeDirty,
isDirty,
}: TestConnectionProps) => {
const t = useScopedI18n("integration.testConnection"); const t = useScopedI18n("integration.testConnection");
const { mutateAsync, ...mutation } = const { mutateAsync, ...mutation } = clientApi.integration.testConnection.useMutation();
clientApi.integration.testConnection.useMutation();
return ( return (
<Group> <Group>
@@ -125,13 +111,7 @@ interface TestConnectionIconProps {
size: number; size: number;
} }
const TestConnectionIcon = ({ const TestConnectionIcon = ({ isDirty, isPending, isSuccess, isError, size }: TestConnectionIconProps) => {
isDirty,
isPending,
isSuccess,
isError,
size,
}: TestConnectionIconProps) => {
if (isPending) return <Loader color="blue" size={size} />; if (isPending) return <Loader color="blue" size={size} />;
if (isDirty) return null; if (isDirty) return null;
if (isSuccess) return <IconCheck size={size} stroke={1.5} color="green" />; if (isSuccess) return <IconCheck size={size} stroke={1.5} color="green" />;
@@ -142,12 +122,7 @@ const TestConnectionIcon = ({
export const TestConnectionNoticeAlert = () => { export const TestConnectionNoticeAlert = () => {
const t = useI18n(); const t = useI18n();
return ( return (
<Alert <Alert variant="light" color="yellow" title="Test Connection" icon={<IconInfoCircle />}>
variant="light"
color="yellow"
title="Test Connection"
icon={<IconInfoCircle />}
>
{t("integration.testConnection.alertNotice")} {t("integration.testConnection.alertNotice")}
</Alert> </Alert>
); );

View File

@@ -6,16 +6,10 @@ import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
getAllSecretKindOptions,
getDefaultSecretKinds,
} from "@homarr/definitions";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -23,11 +17,7 @@ import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { SecretCard } from "../../_integration-secret-card"; import { SecretCard } from "../../_integration-secret-card";
import { IntegrationSecretInput } from "../../_integration-secret-inputs"; import { IntegrationSecretInput } from "../../_integration-secret-inputs";
import { import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../../_integration-test-connection";
TestConnection,
TestConnectionNoticeAlert,
useTestConnectionDirty,
} from "../../_integration-test-connection";
interface EditIntegrationForm { interface EditIntegrationForm {
integration: RouterOutputs["integration"]["byId"]; integration: RouterOutputs["integration"]["byId"];
@@ -45,8 +35,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
url: integration.url, url: integration.url,
secrets: secretsKinds.map((kind) => ({ secrets: secretsKinds.map((kind) => ({
kind, kind,
value: value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
})), })),
}; };
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({ const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
@@ -61,9 +50,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
}); });
const { mutateAsync, isPending } = clientApi.integration.update.useMutation(); const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
const secretsMap = new Map( const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
integration.secrets.map((secret) => [secret.kind, secret]),
);
const handleSubmitAsync = async (values: FormType) => { const handleSubmitAsync = async (values: FormType) => {
if (isDirty) return; if (isDirty) return;
@@ -82,9 +69,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
title: t("integration.page.edit.notification.success.title"), title: t("integration.page.edit.notification.success.title"),
message: t("integration.page.edit.notification.success.message"), message: t("integration.page.edit.notification.success.message"),
}); });
void revalidatePathActionAsync("/manage/integrations").then(() => void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
router.push("/manage/integrations"),
);
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
@@ -101,17 +86,9 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
<Stack> <Stack>
<TestConnectionNoticeAlert /> <TestConnectionNoticeAlert />
<TextInput <TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
withAsterisk
label={t("integration.field.name.label")}
{...form.getInputProps("name")}
/>
<TextInput <TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
withAsterisk
label={t("integration.field.url.label")}
{...form.getInputProps("url")}
/>
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">
@@ -122,10 +99,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
onCancel={() => onCancel={() =>
new Promise((res) => { new Promise((res) => {
// When nothing changed, just close the secret card // When nothing changed, just close the secret card
if ( if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) {
(form.values.secrets[index]?.value ?? "") ===
(secretsMap.get(kind)?.value ?? "")
) {
return res(true); return res(true);
} }
openConfirmModal({ openConfirmModal({
@@ -133,10 +107,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
children: t("integration.secrets.reset.message"), children: t("integration.secrets.reset.message"),
onCancel: () => res(false), onCancel: () => res(false),
onConfirm: () => { onConfirm: () => {
form.setFieldValue( form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)!.value ?? "");
`secrets.${index}.value`,
secretsMap.get(kind)!.value ?? "",
);
res(true); res(true);
}, },
}); });
@@ -165,11 +136,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
}} }}
/> />
<Group> <Group>
<Button <Button variant="default" component={Link} href="/manage/integrations">
variant="default"
component={Link}
href="/manage/integrations"
>
{t("common.action.backToOverview")} {t("common.action.backToOverview")}
</Button> </Button>
<Button type="submit" loading={isPending} disabled={isDirty}> <Button type="submit" loading={isPending} disabled={isDirty}>

View File

@@ -11,9 +11,7 @@ interface EditIntegrationPageProps {
params: { id: string }; params: { id: string };
} }
export default async function EditIntegrationPage({ export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
params,
}: EditIntegrationPageProps) {
const t = await getScopedI18n("integration.page.edit"); const t = await getScopedI18n("integration.page.edit");
const integration = await api.integration.byId({ id: params.id }); const integration = await api.integration.byId({ id: params.id });
@@ -22,9 +20,7 @@ export default async function EditIntegrationPage({
<Stack> <Stack>
<Group align="center"> <Group align="center">
<IntegrationAvatar kind={integration.kind} size="md" /> <IntegrationAvatar kind={integration.kind} size="md" />
<Title> <Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title>
{t("title", { name: getIntegrationName(integration.kind) })}
</Title>
</Group> </Group>
<EditIntegrationForm integration={integration} /> <EditIntegrationForm integration={integration} />
</Stack> </Stack>

View File

@@ -16,9 +16,7 @@ export const IntegrationCreateDropdownContent = () => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => { const filteredKinds = useMemo(() => {
return integrationKinds.filter((kind) => return integrationKinds.filter((kind) => kind.includes(search.toLowerCase()));
kind.includes(search.toLowerCase()),
);
}, [search]); }, [search]);
const handleSearch = React.useCallback( const handleSearch = React.useCallback(
@@ -38,11 +36,7 @@ export const IntegrationCreateDropdownContent = () => {
{filteredKinds.length > 0 ? ( {filteredKinds.length > 0 ? (
<ScrollArea.Autosize mah={384}> <ScrollArea.Autosize mah={384}>
{filteredKinds.map((kind) => ( {filteredKinds.map((kind) => (
<Menu.Item <Menu.Item component={Link} href={`/manage/integrations/new?kind=${kind}`} key={kind}>
component={Link}
href={`/manage/integrations/new?kind=${kind}`}
key={kind}
>
<Group> <Group>
<IntegrationAvatar kind={kind} size="sm" /> <IntegrationAvatar kind={kind} size="sm" />
<Text size="sm">{getIntegrationName(kind)}</Text> <Text size="sm">{getIntegrationName(kind)}</Text>

View File

@@ -3,37 +3,20 @@
import { useCallback } from "react"; import { useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import { Button, Fieldset, Group, SegmentedControl, Stack, TextInput } from "@mantine/core";
Button,
Fieldset,
Group,
SegmentedControl,
Stack,
TextInput,
} from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
IntegrationKind,
IntegrationSecretKind,
} from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions"; import { getAllSecretKindOptions } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { IntegrationSecretInput } from "../_integration-secret-inputs"; import { IntegrationSecretInput } from "../_integration-secret-inputs";
import { import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../_integration-test-connection";
TestConnection,
TestConnectionNoticeAlert,
useTestConnectionDirty,
} from "../_integration-test-connection";
import { revalidatePathActionAsync } from "../../../../revalidatePathAction"; import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
interface NewIntegrationFormProps { interface NewIntegrationFormProps {
@@ -42,9 +25,7 @@ interface NewIntegrationFormProps {
}; };
} }
export const NewIntegrationForm = ({ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
searchParams,
}: NewIntegrationFormProps) => {
const t = useI18n(); const t = useI18n();
const secretKinds = getAllSecretKindOptions(searchParams.kind); const secretKinds = getAllSecretKindOptions(searchParams.kind);
const initialFormValues = { const initialFormValues = {
@@ -79,9 +60,7 @@ export const NewIntegrationForm = ({
title: t("integration.page.create.notification.success.title"), title: t("integration.page.create.notification.success.title"),
message: t("integration.page.create.notification.success.message"), message: t("integration.page.create.notification.success.message"),
}); });
void revalidatePathActionAsync("/manage/integrations").then(() => void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
router.push("/manage/integrations"),
);
}, },
onError: () => { onError: () => {
showErrorNotification({ showErrorNotification({
@@ -98,26 +77,13 @@ export const NewIntegrationForm = ({
<Stack> <Stack>
<TestConnectionNoticeAlert /> <TestConnectionNoticeAlert />
<TextInput <TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
withAsterisk
label={t("integration.field.name.label")}
{...form.getInputProps("name")}
/>
<TextInput <TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
withAsterisk
label={t("integration.field.url.label")}
{...form.getInputProps("url")}
/>
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">
{secretKinds.length > 1 && ( {secretKinds.length > 1 && <SecretKindsSegmentedControl secretKinds={secretKinds} form={form} />}
<SecretKindsSegmentedControl
secretKinds={secretKinds}
form={form}
/>
)}
{form.values.secrets.map(({ kind }, index) => ( {form.values.secrets.map(({ kind }, index) => (
<IntegrationSecretInput <IntegrationSecretInput
withAsterisk withAsterisk
@@ -141,11 +107,7 @@ export const NewIntegrationForm = ({
/> />
<Group> <Group>
<Button <Button variant="default" component={Link} href="/manage/integrations">
variant="default"
component={Link}
href="/manage/integrations"
>
{t("common.action.backToOverview")} {t("common.action.backToOverview")}
</Button> </Button>
<Button type="submit" loading={isPending} disabled={isDirty}> <Button type="submit" loading={isPending} disabled={isDirty}>
@@ -163,10 +125,7 @@ interface SecretKindsSegmentedControlProps {
form: UseFormReturnType<FormType, (values: FormType) => FormType>; form: UseFormReturnType<FormType, (values: FormType) => FormType>;
} }
const SecretKindsSegmentedControl = ({ const SecretKindsSegmentedControl = ({ secretKinds, form }: SecretKindsSegmentedControlProps) => {
secretKinds,
form,
}: SecretKindsSegmentedControlProps) => {
const t = useScopedI18n("integration.secrets"); const t = useScopedI18n("integration.secrets");
const secretKindGroups = secretKinds.map((kinds) => ({ const secretKindGroups = secretKinds.map((kinds) => ({
@@ -186,13 +145,7 @@ const SecretKindsSegmentedControl = ({
[form], [form],
); );
return ( return <SegmentedControl fullWidth data={secretKindGroups} onChange={onChange}></SegmentedControl>;
<SegmentedControl
fullWidth
data={secretKindGroups}
onChange={onChange}
></SegmentedControl>
);
}; };
type FormType = Omit<z.infer<typeof validation.integration.create>, "kind">; type FormType = Omit<z.infer<typeof validation.integration.create>, "kind">;

View File

@@ -16,12 +16,8 @@ interface NewIntegrationPageProps {
}; };
} }
export default async function IntegrationsNewPage({ export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
searchParams, const result = z.enum([integrationKinds[0]!, ...integrationKinds.slice(1)]).safeParse(searchParams.kind);
}: NewIntegrationPageProps) {
const result = z
.enum([integrationKinds[0]!, ...integrationKinds.slice(1)])
.safeParse(searchParams.kind);
if (!result.success) { if (!result.success) {
notFound(); notFound();
} }

View File

@@ -43,9 +43,7 @@ interface IntegrationsPageProps {
}; };
} }
export default async function IntegrationsPage({ export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
searchParams,
}: IntegrationsPageProps) {
const integrations = await api.integration.all(); const integrations = await api.integration.all();
const t = await getScopedI18n("integration"); const t = await getScopedI18n("integration");
@@ -54,18 +52,9 @@ export default async function IntegrationsPage({
<Stack> <Stack>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title> <Title>{t("page.list.title")}</Title>
<Menu <Menu width={256} trapFocus position="bottom-start" withinPortal shadow="md" keepMounted={false}>
width={256}
trapFocus
position="bottom-start"
withinPortal
shadow="md"
keepMounted={false}
>
<MenuTarget> <MenuTarget>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}> <Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
{t("action.create")}
</Button>
</MenuTarget> </MenuTarget>
<MenuDropdown> <MenuDropdown>
<IntegrationCreateDropdownContent /> <IntegrationCreateDropdownContent />
@@ -73,10 +62,7 @@ export default async function IntegrationsPage({
</Menu> </Menu>
</Group> </Group>
<IntegrationList <IntegrationList integrations={integrations} activeTab={searchParams.tab} />
integrations={integrations}
activeTab={searchParams.tab}
/>
</Stack> </Stack>
</Container> </Container>
); );
@@ -87,10 +73,7 @@ interface IntegrationListProps {
activeTab?: IntegrationKind; activeTab?: IntegrationKind;
} }
const IntegrationList = async ({ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => {
integrations,
activeTab,
}: IntegrationListProps) => {
const t = await getScopedI18n("integration"); const t = await getScopedI18n("integration");
if (integrations.length === 0) { if (integrations.length === 0) {
@@ -134,12 +117,7 @@ const IntegrationList = async ({
<TableTr key={integration.id}> <TableTr key={integration.id}>
<TableTd>{integration.name}</TableTd> <TableTd>{integration.name}</TableTd>
<TableTd> <TableTd>
<Anchor <Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
href={integration.url}
target="_blank"
rel="noreferrer"
size="sm"
>
{integration.url} {integration.url}
</Anchor> </Anchor>
</TableTd> </TableTd>
@@ -155,10 +133,7 @@ const IntegrationList = async ({
> >
<IconPencil size={16} stroke={1.5} /> <IconPencil size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
<DeleteIntegrationActionButton <DeleteIntegrationActionButton integration={integration} count={integrations.length} />
integration={integration}
count={integrations.length}
/>
</ActionIconGroup> </ActionIconGroup>
</Group> </Group>
</TableTd> </TableTd>

View File

@@ -71,12 +71,7 @@ export default async function ManagementPage() {
<Space h="md" /> <Space h="md" />
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}> <SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{links.map((link, index) => ( {links.map((link, index) => (
<Card <Card component={Link} href={link.href} key={`link-${index}`} withBorder>
component={Link}
href={link.href}
key={`link-${index}`}
withBorder
>
<Group justify="space-between"> <Group justify="space-between">
<Group> <Group>
<Text size="2.4rem" fw="bolder"> <Text size="2.4rem" fw="bolder">

View File

@@ -3,16 +3,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import React from "react"; import React from "react";
import type { MantineSpacing } from "@mantine/core"; import type { MantineSpacing } from "@mantine/core";
import { import { Card, Group, LoadingOverlay, Stack, Switch, Text, Title, UnstyledButton } from "@mantine/core";
Card,
Group,
LoadingOverlay,
Stack,
Switch,
Text,
Title,
UnstyledButton,
} from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
@@ -37,9 +28,7 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
if ( if (
!updatedValues.enableGeneral && !updatedValues.enableGeneral &&
(updatedValues.enableWidgetData || (updatedValues.enableWidgetData || updatedValues.enableIntegrationData || updatedValues.enableUserData)
updatedValues.enableIntegrationData ||
updatedValues.enableUserData)
) { ) {
updatedValues.enableIntegrationData = false; updatedValues.enableIntegrationData = false;
updatedValues.enableUserData = false; updatedValues.enableUserData = false;
@@ -53,30 +42,20 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
}, },
}); });
const { mutateAsync, isPending } = const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
clientApi.serverSettings.saveSettings.useMutation({ onSettled: async () => {
onSettled: async () => { await revalidatePathActionAsync("/manage/settings");
await revalidatePathActionAsync("/manage/settings"); },
}, });
});
return ( return (
<> <>
<Title order={2}>{t("title")}</Title> <Title order={2}>{t("title")}</Title>
<Card pos="relative" withBorder> <Card pos="relative" withBorder>
<LoadingOverlay <LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
visible={isPending}
zIndex={1000}
overlayProps={{ radius: "sm", blur: 2 }}
/>
<Stack> <Stack>
<SwitchSetting <SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
form={form}
formKey="enableGeneral"
title={t("general.title")}
text={t("general.text")}
/>
<SwitchSetting <SwitchSetting
form={form} form={form}
formKey="enableIntegrationData" formKey="enableIntegrationData"
@@ -122,13 +101,7 @@ const SwitchSetting = ({
}, [form, formKey]); }, [form, formKey]);
return ( return (
<UnstyledButton onClick={handleClick}> <UnstyledButton onClick={handleClick}>
<Group <Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
ms={ms}
justify="space-between"
gap="lg"
align="center"
wrap="nowrap"
>
<Stack gap={0}> <Stack gap={0}>
<Text fw="bold">{title}</Text> <Text fw="bold">{title}</Text>
<Text c="gray.5">{text}</Text> <Text c="gray.5">{text}</Text>

View File

@@ -23,12 +23,7 @@ const ClientSideTerminalComponent = dynamic(() => import("./terminal"), {
export default function LogsManagementPage() { export default function LogsManagementPage() {
return ( return (
<Box <Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
style={{ borderRadius: 6 }}
h={fullHeightWithoutHeaderAndFooter}
p="md"
bg="black"
>
<ClientSideTerminalComponent /> <ClientSideTerminalComponent />
</Box> </Box>
); );

View File

@@ -16,9 +16,7 @@ export default function TerminalComponent() {
const terminalRef = useRef<Terminal>(); const terminalRef = useRef<Terminal>();
clientApi.log.subscribe.useSubscription(undefined, { clientApi.log.subscribe.useSubscription(undefined, {
onData(data) { onData(data) {
terminalRef.current?.writeln( terminalRef.current?.writeln(`${data.timestamp} ${data.level} ${data.message}`);
`${data.timestamp} ${data.level} ${data.message}`,
);
terminalRef.current?.refresh(0, terminalRef.current.rows - 1); terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
}, },
onError(err) { onError(err) {
@@ -55,12 +53,5 @@ export default function TerminalComponent() {
canvasAddon.dispose(); canvasAddon.dispose();
}; };
}, []); }, []);
return ( return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
<Box
ref={ref}
id="terminal"
className={classes.outerTerminal}
h="100%"
></Box>
);
} }

View File

@@ -1,9 +1,6 @@
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
export const canAccessUserEditPage = ( export const canAccessUserEditPage = (session: Session | null, userId: string) => {
session: Session | null,
userId: string,
) => {
if (!session) { if (!session) {
return false; return false;
} }

View File

@@ -18,14 +18,11 @@ interface DeleteUserButtonProps {
export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => { export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
const t = useI18n(); const t = useI18n();
const router = useRouter(); const router = useRouter();
const { mutateAsync: mutateUserDeletionAsync } = const { mutateAsync: mutateUserDeletionAsync } = clientApi.user.delete.useMutation({
clientApi.user.delete.useMutation({ async onSuccess() {
async onSuccess() { await revalidatePathActionAsync("/manage/users").then(() => router.push("/manage/users"));
await revalidatePathActionAsync("/manage/users").then(() => },
router.push("/manage/users"), });
);
},
});
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const handleDelete = useCallback( const handleDelete = useCallback(

View File

@@ -8,10 +8,7 @@ import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui"; import { UserAvatar } from "@homarr/ui";
@@ -46,25 +43,18 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
// Revalidate all as the avatar is used in multiple places // Revalidate all as the avatar is used in multiple places
await revalidatePathActionAsync("/"); await revalidatePathActionAsync("/");
showSuccessNotification({ showSuccessNotification({
message: tManageAvatar( message: tManageAvatar("changeImage.notification.success.message"),
"changeImage.notification.success.message",
),
}); });
}, },
onError(error) { onError(error) {
if (error.shape?.data.code === "BAD_REQUEST") { if (error.shape?.data.code === "BAD_REQUEST") {
showErrorNotification({ showErrorNotification({
title: tManageAvatar("changeImage.notification.toLarge.title"), title: tManageAvatar("changeImage.notification.toLarge.title"),
message: tManageAvatar( message: tManageAvatar("changeImage.notification.toLarge.message", { size: "256KB" }),
"changeImage.notification.toLarge.message",
{ size: "256KB" },
),
}); });
} else { } else {
showErrorNotification({ showErrorNotification({
message: tManageAvatar( message: tManageAvatar("changeImage.notification.error.message"),
"changeImage.notification.error.message",
),
}); });
} }
}, },
@@ -89,16 +79,12 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
// Revalidate all as the avatar is used in multiple places // Revalidate all as the avatar is used in multiple places
await revalidatePathActionAsync("/"); await revalidatePathActionAsync("/");
showSuccessNotification({ showSuccessNotification({
message: tManageAvatar( message: tManageAvatar("removeImage.notification.success.message"),
"removeImage.notification.success.message",
),
}); });
}, },
onError() { onError() {
showErrorNotification({ showErrorNotification({
message: tManageAvatar( message: tManageAvatar("removeImage.notification.error.message"),
"removeImage.notification.error.message",
),
}); });
}, },
}, },
@@ -109,13 +95,7 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
return ( return (
<Box pos="relative"> <Box pos="relative">
<Menu <Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow>
opened={opened}
keepMounted
onChange={toggle}
position="bottom-start"
withArrow
>
<Menu.Target> <Menu.Target>
<UnstyledButton onClick={toggle}> <UnstyledButton onClick={toggle}>
<UserAvatar user={user} size={200} /> <UserAvatar user={user} size={200} />
@@ -134,24 +114,15 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
</UnstyledButton> </UnstyledButton>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<FileButton <FileButton onChange={handleAvatarChange} accept="image/png,image/jpeg,image/webp,image/gif">
onChange={handleAvatarChange}
accept="image/png,image/jpeg,image/webp,image/gif"
>
{(props) => ( {(props) => (
<Menu.Item <Menu.Item {...props} leftSection={<IconPhotoEdit size={16} stroke={1.5} />}>
{...props}
leftSection={<IconPhotoEdit size={16} stroke={1.5} />}
>
{tManageAvatar("changeImage.label")} {tManageAvatar("changeImage.label")}
</Menu.Item> </Menu.Item>
)} )}
</FileButton> </FileButton>
{user.image && ( {user.image && (
<Menu.Item <Menu.Item onClick={handleRemoveAvatar} leftSection={<IconPhotoX size={16} stroke={1.5} />}>
onClick={handleRemoveAvatar}
leftSection={<IconPhotoX size={16} stroke={1.5} />}
>
{tManageAvatar("removeImage.label")} {tManageAvatar("removeImage.label")}
</Menu.Item> </Menu.Item>
)} )}

View File

@@ -6,10 +6,7 @@ import { Button, Group, Stack, TextInput } from "@mantine/core";
import type { RouterInputs, RouterOutputs } from "@homarr/api"; import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -58,15 +55,8 @@ export const UserProfileForm = ({ user }: UserProfileFormProps) => {
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <Stack>
<TextInput <TextInput label={t("user.field.username.label")} withAsterisk {...form.getInputProps("name")} />
label={t("user.field.username.label")} <TextInput label={t("user.field.email.label")} {...form.getInputProps("email")} />
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
label={t("user.field.email.label")}
{...form.getInputProps("email")}
/>
<Group justify="end"> <Group justify="end">
<Button type="submit" color="teal" loading={isPending}> <Button type="submit" color="teal" loading={isPending}>

View File

@@ -5,10 +5,7 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
DangerZoneItem,
DangerZoneRoot,
} from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-not-found"; import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access"; import { canAccessUserEditPage } from "../access";

View File

@@ -1,16 +1,7 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { import { Button, Container, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
Button,
Container,
Grid,
GridCol,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { IconSettings, IconShieldLock } from "@tabler/icons-react"; import { IconSettings, IconShieldLock } from "@tabler/icons-react";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -26,16 +17,11 @@ interface LayoutProps {
params: { userId: string }; params: { userId: string };
} }
export default async function Layout({ export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
children,
params,
}: PropsWithChildren<LayoutProps>) {
const session = await auth(); const session = await auth();
const t = await getI18n(); const t = await getI18n();
const tUser = await getScopedI18n("management.page.user"); const tUser = await getScopedI18n("management.page.user");
const user = await api.user const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound);
.getById({ userId: params.userId })
.catch(catchTrpcNotFound);
if (!canAccessUserEditPage(session, user.id)) { if (!canAccessUserEditPage(session, user.id)) {
notFound(); notFound();
@@ -54,12 +40,7 @@ export default async function Layout({
</Stack> </Stack>
</Group> </Group>
{session?.user.permissions.includes("admin") && ( {session?.user.permissions.includes("admin") && (
<Button <Button component={Link} href="/manage/users" color="gray" variant="light">
component={Link}
href="/manage/users"
color="gray"
variant="light"
>
{tUser("back")} {tUser("back")}
</Button> </Button>
)} )}

View File

@@ -6,10 +6,7 @@ import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -74,11 +71,7 @@ export const ChangePasswordForm = ({ user }: ChangePasswordFormProps) => {
/> />
)} )}
<PasswordInput <PasswordInput withAsterisk label={t("user.field.password.label")} {...form.getInputProps("password")} />
withAsterisk
label={t("user.field.password.label")}
{...form.getInputProps("password")}
/>
<PasswordInput <PasswordInput
withAsterisk withAsterisk

View File

@@ -17,9 +17,7 @@ interface Props {
export default async function UserSecurityPage({ params }: Props) { export default async function UserSecurityPage({ params }: Props) {
const session = await auth(); const session = await auth();
const tSecurity = await getScopedI18n( const tSecurity = await getScopedI18n("management.page.user.setting.security");
"management.page.user.setting.security",
);
const user = await api.user const user = await api.user
.getById({ .getById({
userId: params.userId, userId: params.userId,

View File

@@ -15,18 +15,14 @@ interface UserListComponentProps {
initialUserList: RouterOutputs["user"]["getAll"]; initialUserList: RouterOutputs["user"]["getAll"];
} }
export const UserListComponent = ({ export const UserListComponent = ({ initialUserList }: UserListComponentProps) => {
initialUserList,
}: UserListComponentProps) => {
const tUserList = useScopedI18n("management.page.user.list"); const tUserList = useScopedI18n("management.page.user.list");
const t = useI18n(); const t = useI18n();
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, { const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
initialData: initialUserList, initialData: initialUserList,
}); });
const columns = useMemo< const columns = useMemo<MRT_ColumnDef<RouterOutputs["user"]["getAll"][number]>[]>(
MRT_ColumnDef<RouterOutputs["user"]["getAll"][number]>[]
>(
() => [ () => [
{ {
accessorKey: "name", accessorKey: "name",

View File

@@ -1,16 +1,7 @@
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { import { Avatar, Card, PasswordInput, Stack, Stepper, Text, TextInput, Title } from "@mantine/core";
Avatar,
Card,
PasswordInput,
Stack,
Stepper,
Text,
TextInput,
Title,
} from "@mantine/core";
import { IconUserCheck } from "@tabler/icons-react"; import { IconUserCheck } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -28,14 +19,10 @@ export const UserCreateStepperComponent = () => {
const stepperMax = 4; const stepperMax = 4;
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const nextStep = useCallback( const nextStep = useCallback(
() => () => setActive((current) => (current < stepperMax ? current + 1 : current)),
setActive((current) => (current < stepperMax ? current + 1 : current)),
[setActive],
);
const prevStep = useCallback(
() => setActive((current) => (current > 0 ? current - 1 : current)),
[setActive], [setActive],
); );
const prevStep = useCallback(() => setActive((current) => (current > 0 ? current - 1 : current)), [setActive]);
const hasNext = active < stepperMax; const hasNext = active < stepperMax;
const hasPrevious = active > 0; const hasPrevious = active > 0;
@@ -72,14 +59,9 @@ export const UserCreateStepperComponent = () => {
}, },
); );
const allForms = useMemo( const allForms = useMemo(() => [generalForm, securityForm], [generalForm, securityForm]);
() => [generalForm, securityForm],
[generalForm, securityForm],
);
const isCurrentFormValid = allForms[active] const isCurrentFormValid = allForms[active] ? (allForms[active]!.isValid satisfies () => boolean) : () => true;
? (allForms[active]!.isValid satisfies () => boolean)
: () => true;
const canNavigateToNextStep = isCurrentFormValid(); const canNavigateToNextStep = isCurrentFormValid();
const controlledGoToNextStep = useCallback(async () => { const controlledGoToNextStep = useCallback(async () => {
@@ -104,12 +86,7 @@ export const UserCreateStepperComponent = () => {
return ( return (
<> <>
<Title mb="md">{t("title")}</Title> <Title mb="md">{t("title")}</Title>
<Stepper <Stepper active={active} onStepClick={setActive} allowNextStepsSelect={false} mb="md">
active={active}
onStepClick={setActive}
allowNextStepsSelect={false}
mb="md"
>
<Stepper.Step <Stepper.Step
label={t("step.personalInformation.label")} label={t("step.personalInformation.label")}
allowStepSelect={false} allowStepSelect={false}
@@ -126,20 +103,12 @@ export const UserCreateStepperComponent = () => {
{...generalForm.getInputProps("username")} {...generalForm.getInputProps("username")}
/> />
<TextInput <TextInput label={tUserField("email.label")} variant="filled" {...generalForm.getInputProps("email")} />
label={tUserField("email.label")}
variant="filled"
{...generalForm.getInputProps("email")}
/>
</Stack> </Stack>
</Card> </Card>
</form> </form>
</Stepper.Step> </Stepper.Step>
<Stepper.Step <Stepper.Step label={t("step.security.label")} allowStepSelect={false} allowStepClick={false}>
label={t("step.security.label")}
allowStepSelect={false}
allowStepClick={false}
>
<form> <form>
<Card p="xl"> <Card p="xl">
<Stack gap="md"> <Stack gap="md">
@@ -167,11 +136,7 @@ export const UserCreateStepperComponent = () => {
> >
3 3
</Stepper.Step> </Stepper.Step>
<Stepper.Step <Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
label={t("step.review.label")}
allowStepSelect={false}
allowStepClick={false}
>
<Card p="xl"> <Card p="xl">
<Stack maw={300} align="center" mx="auto"> <Stack maw={300} align="center" mx="auto">
<Avatar size="xl">{generalForm.values.username}</Avatar> <Avatar size="xl">{generalForm.values.username}</Avatar>

View File

@@ -1,11 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { Button, Card, Group } from "@mantine/core"; import { Button, Card, Group } from "@mantine/core";
import { import { IconArrowBackUp, IconArrowLeft, IconArrowRight, IconRotate } from "@tabler/icons-react";
IconArrowBackUp,
IconArrowLeft,
IconArrowRight,
IconRotate,
} from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -51,18 +46,10 @@ export const StepperNavigationComponent = ({
</Group> </Group>
) : ( ) : (
<Group justify="end" wrap="nowrap"> <Group justify="end" wrap="nowrap">
<Button <Button variant="light" leftSection={<IconRotate size="1rem" />} onClick={reset}>
variant="light"
leftSection={<IconRotate size="1rem" />}
onClick={reset}
>
{t("management.page.user.create.action.createAnother")} {t("management.page.user.create.action.createAnother")}
</Button> </Button>
<Button <Button leftSection={<IconArrowBackUp size="1rem" />} component={Link} href="/manage/users">
leftSection={<IconArrowBackUp size="1rem" />}
component={Link}
href="/manage/users"
>
{t("management.page.user.create.action.back")} {t("management.page.user.create.action.back")}
</Button> </Button>
</Group> </Group>

View File

@@ -6,10 +6,7 @@ import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
@@ -63,15 +60,7 @@ export const DeleteGroup = ({ group }: DeleteGroupProps) => {
); );
}, },
}); });
}, [ }, [tDelete, tRoot, openConfirmModal, group.id, group.name, mutateAsync, router]);
tDelete,
tRoot,
openConfirmModal,
group.id,
group.name,
mutateAsync,
router,
]);
return ( return (
<Button variant="subtle" color="red" onClick={handleDeletion}> <Button variant="subtle" color="red" onClick={handleDeletion}>

View File

@@ -14,13 +14,5 @@ interface NavigationLinkProps {
export const NavigationLink = ({ href, icon, label }: NavigationLinkProps) => { export const NavigationLink = ({ href, icon, label }: NavigationLinkProps) => {
const pathName = usePathname(); const pathName = usePathname();
return ( return <NavLink component={Link} href={href} active={pathName === href} label={label} leftSection={icon} />;
<NavLink
component={Link}
href={href}
active={pathName === href}
label={label}
leftSection={icon}
/>
);
}; };

View File

@@ -5,10 +5,7 @@ import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -64,10 +61,7 @@ export const RenameGroupForm = ({ group }: RenameGroupFormProps) => {
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <Stack>
<TextInput <TextInput label={t("group.field.name")} {...form.getInputProps("name")} />
label={t("group.field.name")}
{...form.getInputProps("name")}
/>
<Group justify="end"> <Group justify="end">
<Button type="submit" color="teal" loading={isPending}> <Button type="submit" color="teal" loading={isPending}>

View File

@@ -5,10 +5,7 @@ import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access/user-select-modal"; import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access/user-select-modal";
@@ -21,9 +18,7 @@ interface TransferGroupOwnershipProps {
}; };
} }
export const TransferGroupOwnership = ({ export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) => {
group,
}: TransferGroupOwnershipProps) => {
const tTransfer = useScopedI18n("group.action.transfer"); const tTransfer = useScopedI18n("group.action.transfer");
const tRoot = useI18n(); const tRoot = useI18n();
const [innerOwnerId, setInnerOwnerId] = useState(group.ownerId); const [innerOwnerId, setInnerOwnerId] = useState(group.ownerId);
@@ -77,16 +72,7 @@ export const TransferGroupOwnership = ({
title: tTransfer("label"), title: tTransfer("label"),
}, },
); );
}, [ }, [group.id, group.name, innerOwnerId, mutateAsync, openConfirmModal, openModal, tRoot, tTransfer]);
group.id,
group.name,
innerOwnerId,
mutateAsync,
openConfirmModal,
openModal,
tRoot,
tTransfer,
]);
return ( return (
<Button variant="subtle" color="red" onClick={handleTransfer}> <Button variant="subtle" color="red" onClick={handleTransfer}>

View File

@@ -1,15 +1,6 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import Link from "next/link"; import Link from "next/link";
import { import { Button, Container, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
Button,
Container,
Grid,
GridCol,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react"; import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -21,10 +12,7 @@ interface LayoutProps {
params: { id: string }; params: { id: string };
} }
export default async function Layout({ export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
children,
params,
}: PropsWithChildren<LayoutProps>) {
const t = await getI18n(); const t = await getI18n();
const tGroup = await getScopedI18n("management.page.group"); const tGroup = await getScopedI18n("management.page.group");
const group = await api.group.getById({ id: params.id }); const group = await api.group.getById({ id: params.id });
@@ -38,12 +26,7 @@ export default async function Layout({
<Title order={3}>{group.name}</Title> <Title order={3}>{group.name}</Title>
<Text c="gray.5">{t("group.name")}</Text> <Text c="gray.5">{t("group.name")}</Text>
</Stack> </Stack>
<Button <Button component={Link} href="/manage/users/groups" color="gray" variant="light">
component={Link}
href="/manage/users/groups"
color="gray"
variant="light"
>
{tGroup("back")} {tGroup("back")}
</Button> </Button>
</Group> </Group>

View File

@@ -15,10 +15,7 @@ interface AddGroupMemberProps {
presentUserIds: string[]; presentUserIds: string[];
} }
export const AddGroupMember = ({ export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps) => {
groupId,
presentUserIds,
}: AddGroupMemberProps) => {
const tMembersAdd = useScopedI18n("group.action.addMember"); const tMembersAdd = useScopedI18n("group.action.addMember");
const { mutateAsync } = clientApi.group.addMember.useMutation(); const { mutateAsync } = clientApi.group.addMember.useMutation();
const { openModal } = useModalAction(UserSelectModal); const { openModal } = useModalAction(UserSelectModal);
@@ -32,9 +29,7 @@ export const AddGroupMember = ({
userId: id, userId: id,
groupId, groupId,
}); });
await revalidatePathActionAsync( await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
`/manage/users/groups/${groupId}}/members`,
);
}, },
presentUserIds, presentUserIds,
}, },

View File

@@ -14,10 +14,7 @@ interface RemoveGroupMemberProps {
user: { id: string; name: string | null }; user: { id: string; name: string | null };
} }
export const RemoveGroupMember = ({ export const RemoveGroupMember = ({ groupId, user }: RemoveGroupMemberProps) => {
groupId,
user,
}: RemoveGroupMemberProps) => {
const t = useI18n(); const t = useI18n();
const tRemoveMember = useScopedI18n("group.action.removeMember"); const tRemoveMember = useScopedI18n("group.action.removeMember");
const { mutateAsync } = clientApi.group.removeMember.useMutation(); const { mutateAsync } = clientApi.group.removeMember.useMutation();
@@ -35,27 +32,13 @@ export const RemoveGroupMember = ({
groupId, groupId,
userId: user.id, userId: user.id,
}); });
await revalidatePathActionAsync( await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
`/manage/users/groups/${groupId}/members`,
);
}, },
}); });
}, [ }, [openConfirmModal, mutateAsync, groupId, user.id, user.name, tRemoveMember]);
openConfirmModal,
mutateAsync,
groupId,
user.id,
user.name,
tRemoveMember,
]);
return ( return (
<Button <Button variant="subtle" color="red.9" size="compact-sm" onClick={handleRemove}>
variant="subtle"
color="red.9"
size="compact-sm"
onClick={handleRemove}
>
{t("common.action.remove")} {t("common.action.remove")}
</Button> </Button>
); );

View File

@@ -1,16 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { import { Anchor, Center, Group, Stack, Table, TableTbody, TableTd, TableTr, Text, Title } from "@mantine/core";
Anchor,
Center,
Group,
Stack,
Table,
TableTbody,
TableTd,
TableTr,
Text,
Title,
} from "@mantine/core";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -29,20 +18,13 @@ interface GroupsDetailPageProps {
}; };
} }
export default async function GroupsDetailPage({ export default async function GroupsDetailPage({ params, searchParams }: GroupsDetailPageProps) {
params,
searchParams,
}: GroupsDetailPageProps) {
const t = await getI18n(); const t = await getI18n();
const tMembers = await getScopedI18n("management.page.group.setting.members"); const tMembers = await getScopedI18n("management.page.group.setting.members");
const group = await api.group.getById({ id: params.id }); const group = await api.group.getById({ id: params.id });
const filteredMembers = searchParams.search const filteredMembers = searchParams.search
? group.members.filter((member) => ? group.members.filter((member) => member.name?.toLowerCase().includes(searchParams.search!.trim().toLowerCase()))
member.name
?.toLowerCase()
.includes(searchParams.search!.trim().toLowerCase()),
)
: group.members; : group.members;
return ( return (
@@ -56,10 +38,7 @@ export default async function GroupsDetailPage({
})} })}
defaultValue={searchParams.search} defaultValue={searchParams.search}
/> />
<AddGroupMember <AddGroupMember groupId={group.id} presentUserIds={group.members.map((member) => member.id)} />
groupId={group.id}
presentUserIds={group.members.map((member) => member.id)}
/>
</Group> </Group>
{filteredMembers.length === 0 && ( {filteredMembers.length === 0 && (
<Center py="sm"> <Center py="sm">

View File

@@ -3,10 +3,7 @@ import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
DangerZoneItem,
DangerZoneRoot,
} from "~/components/manage/danger-zone";
import { DeleteGroup } from "./_delete-group"; import { DeleteGroup } from "./_delete-group";
import { RenameGroupForm } from "./_rename-group-form"; import { RenameGroupForm } from "./_rename-group-form";
import { TransferGroupOwnership } from "./_transfer-group-ownership"; import { TransferGroupOwnership } from "./_transfer-group-ownership";
@@ -17,9 +14,7 @@ interface GroupsDetailPageProps {
}; };
} }
export default async function GroupsDetailPage({ export default async function GroupsDetailPage({ params }: GroupsDetailPageProps) {
params,
}: GroupsDetailPageProps) {
const group = await api.group.getById({ id: params.id }); const group = await api.group.getById({ id: params.id });
const tGeneral = await getScopedI18n("management.page.group.setting.general"); const tGeneral = await getScopedI18n("management.page.group.setting.general");
const tGroupAction = await getScopedI18n("group.action"); const tGroupAction = await getScopedI18n("group.action");

View File

@@ -9,10 +9,7 @@ import { objectEntries } from "@homarr/common";
import type { GroupPermissionKey } from "@homarr/definitions"; import type { GroupPermissionKey } from "@homarr/definitions";
import { groupPermissionKeys } from "@homarr/definitions"; import { groupPermissionKeys } from "@homarr/definitions";
import { createFormContext } from "@homarr/form"; import { createFormContext } from "@homarr/form";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
const [FormProvider, useFormContext, useForm] = createFormContext<FormType>(); const [FormProvider, useFormContext, useForm] = createFormContext<FormType>();
@@ -21,10 +18,7 @@ interface PermissionFormProps {
initialPermissions: GroupPermissionKey[]; initialPermissions: GroupPermissionKey[];
} }
export const PermissionForm = ({ export const PermissionForm = ({ children, initialPermissions }: PropsWithChildren<PermissionFormProps>) => {
children,
initialPermissions,
}: PropsWithChildren<PermissionFormProps>) => {
const form = useForm({ const form = useForm({
initialValues: groupPermissionKeys.reduce((acc, key) => { initialValues: groupPermissionKeys.reduce((acc, key) => {
acc[key] = initialPermissions.includes(key); acc[key] = initialPermissions.includes(key);
@@ -73,9 +67,7 @@ interface SaveAffixProps {
export const SaveAffix = ({ groupId }: SaveAffixProps) => { export const SaveAffix = ({ groupId }: SaveAffixProps) => {
const t = useI18n(); const t = useI18n();
const tForm = useScopedI18n("management.page.group.setting.permissions.form"); const tForm = useScopedI18n("management.page.group.setting.permissions.form");
const tNotification = useScopedI18n( const tNotification = useScopedI18n("group.action.changePermissions.notification");
"group.action.changePermissions.notification",
);
const form = useFormContext(); const form = useFormContext();
const { mutate, isPending } = clientApi.group.savePermissions.useMutation(); const { mutate, isPending } = clientApi.group.savePermissions.useMutation();

View File

@@ -1,13 +1,5 @@
import React from "react"; import React from "react";
import { import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
Card,
CardSection,
Divider,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { objectKeys } from "@homarr/common"; import { objectKeys } from "@homarr/common";
@@ -15,11 +7,7 @@ import type { GroupPermissionKey } from "@homarr/definitions";
import { groupPermissions } from "@homarr/definitions"; import { groupPermissions } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form";
PermissionForm,
PermissionSwitch,
SaveAffix,
} from "./_group-permission-form";
interface GroupPermissionsPageProps { interface GroupPermissionsPageProps {
params: { params: {
@@ -27,9 +15,7 @@ interface GroupPermissionsPageProps {
}; };
} }
export default async function GroupPermissionsPage({ export default async function GroupPermissionsPage({ params }: GroupPermissionsPageProps) {
params,
}: GroupPermissionsPageProps) {
const group = await api.group.getById({ id: params.id }); const group = await api.group.getById({ id: params.id });
const tPermissions = await getScopedI18n("group.permission"); const tPermissions = await getScopedI18n("group.permission");
const t = await getI18n(); const t = await getI18n();
@@ -99,10 +85,7 @@ const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
); );
}; };
const createGroupPermissionKey = ( const createGroupPermissionKey = (group: keyof typeof groupPermissions, permission: string): GroupPermissionKey => {
group: keyof typeof groupPermissions,
permission: string,
): GroupPermissionKey => {
if (typeof groupPermissions[group] === "boolean") { if (typeof groupPermissions[group] === "boolean") {
return group as GroupPermissionKey; return group as GroupPermissionKey;
} }

View File

@@ -6,10 +6,7 @@ import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals"; import { createModal, useModalAction } from "@homarr/modals";
import { import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
@@ -61,11 +58,7 @@ const AddGroupModal = createModal<void>(({ actions }) => {
})} })}
> >
<Stack> <Stack>
<TextInput <TextInput label={t("group.field.name")} data-autofocus {...form.getInputProps("name")} />
label={t("group.field.name")}
data-autofocus
{...form.getInputProps("name")}
/>
<Group justify="right"> <Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray"> <Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")} {t("common.action.cancel")}

View File

@@ -27,25 +27,18 @@ const searchParamsSchema = z.object({
page: z.string().regex(/\d+/).transform(Number).catch(1), page: z.string().regex(/\d+/).transform(Number).catch(1),
}); });
type SearchParamsSchemaInputFromSchema< type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
TSchema extends Record<string, unknown>, [K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[]
? string[]
: string;
}>; }>;
interface GroupsListPageProps { interface GroupsListPageProps {
searchParams: SearchParamsSchemaInputFromSchema< searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
z.infer<typeof searchParamsSchema>
>;
} }
export default async function GroupsListPage(props: GroupsListPageProps) { export default async function GroupsListPage(props: GroupsListPageProps) {
const t = await getI18n(); const t = await getI18n();
const searchParams = searchParamsSchema.parse(props.searchParams); const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: groups, totalCount } = const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
await api.group.getPaginated(searchParams);
return ( return (
<Container size="xl"> <Container size="xl">
@@ -76,9 +69,7 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
</Table> </Table>
<Group justify="end"> <Group justify="end">
<TablePagination <TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
total={Math.ceil(totalCount / searchParams.pageSize)}
/>
</Group> </Group>
</Stack> </Stack>
</Container> </Container>

View File

@@ -6,9 +6,7 @@ import type { RouterOutputs } from "@homarr/api";
import { createModal } from "@homarr/modals"; import { createModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
export const InviteCopyModal = createModal< export const InviteCopyModal = createModal<RouterOutputs["invite"]["createInvite"]>(({ actions, innerProps }) => {
RouterOutputs["invite"]["createInvite"]
>(({ actions, innerProps }) => {
const t = useScopedI18n("management.page.user.invite"); const t = useScopedI18n("management.page.user.invite");
const inviteUrl = useInviteUrl(innerProps); const inviteUrl = useInviteUrl(innerProps);
@@ -50,13 +48,9 @@ export const InviteCopyModal = createModal<
}, },
}); });
const createPath = ({ id, token }: RouterOutputs["invite"]["createInvite"]) => const createPath = ({ id, token }: RouterOutputs["invite"]["createInvite"]) => `/auth/invite/${id}?token=${token}`;
`/auth/invite/${id}?token=${token}`;
const useInviteUrl = ({ const useInviteUrl = ({ id, token }: RouterOutputs["invite"]["createInvite"]) => {
id,
token,
}: RouterOutputs["invite"]["createInvite"]) => {
const pathname = usePathname(); const pathname = usePathname();
return window.location.href.replace(pathname, createPath({ id, token })); return window.location.href.replace(pathname, createPath({ id, token }));

View File

@@ -21,9 +21,7 @@ interface InviteListComponentProps {
initialInvites: RouterOutputs["invite"]["getAll"]; initialInvites: RouterOutputs["invite"]["getAll"];
} }
export const InviteListComponent = ({ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps) => {
initialInvites,
}: InviteListComponentProps) => {
const t = useScopedI18n("management.page.user.invite"); const t = useScopedI18n("management.page.user.invite");
const { data, isLoading } = clientApi.invite.getAll.useQuery(undefined, { const { data, isLoading } = clientApi.invite.getAll.useQuery(undefined, {
initialData: initialInvites, initialData: initialInvites,
@@ -32,9 +30,7 @@ export const InviteListComponent = ({
refetchOnReconnect: false, refetchOnReconnect: false,
}); });
const columns = useMemo< const columns = useMemo<MRT_ColumnDef<RouterOutputs["invite"]["getAll"][number]>[]>(
MRT_ColumnDef<RouterOutputs["invite"]["getAll"][number]>[]
>(
() => [ () => [
{ {
accessorKey: "id", accessorKey: "id",
@@ -100,11 +96,7 @@ const RenderTopToolbarCustomActions = () => {
); );
}; };
const RenderRowActions = ({ const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
row,
}: {
row: MRT_Row<RouterOutputs["invite"]["getAll"][number]>;
}) => {
const t = useScopedI18n("management.page.user.invite"); const t = useScopedI18n("management.page.user.invite");
const { mutate, isPending } = clientApi.invite.deleteInvite.useMutation(); const { mutate, isPending } = clientApi.invite.deleteInvite.useMutation();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
@@ -121,12 +113,7 @@ const RenderRowActions = ({
}, [openConfirmModal, row.original.id, mutate, utils, t]); }, [openConfirmModal, row.original.id, mutate, utils, t]);
return ( return (
<ActionIcon <ActionIcon variant="subtle" color="red" onClick={handleDelete} loading={isPending}>
variant="subtle"
color="red"
onClick={handleDelete}
loading={isPending}
>
<IconTrash color="red" size={20} stroke={1.5} /> <IconTrash color="red" size={20} stroke={1.5} />
</ActionIcon> </ActionIcon>
); );

View File

@@ -2,12 +2,7 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { ActionIcon, Affix, Card } from "@mantine/core"; import { ActionIcon, Affix, Card } from "@mantine/core";
import { import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
IconDimensions,
IconPencil,
IconToggleLeft,
IconToggleRight,
} from "@tabler/icons-react";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
@@ -34,19 +29,11 @@ interface WidgetPreviewPageContentProps {
}[]; }[];
} }
export const WidgetPreviewPageContent = ({ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => {
kind,
integrationData,
}: WidgetPreviewPageContentProps) => {
const t = useScopedI18n("widgetPreview"); const t = useScopedI18n("widgetPreview");
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal); const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction( const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
PreviewDimensionsModal, const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]);
);
const currentDefinition = useMemo(
() => widgetImports[kind].definition,
[kind],
);
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [dimensions, setDimensions] = useState<Dimensions>({ const [dimensions, setDimensions] = useState<Dimensions>({
width: 128, width: 128,
@@ -70,9 +57,7 @@ export const WidgetPreviewPageContent = ({
integrationData: integrationData.filter( integrationData: integrationData.filter(
(integration) => (integration) =>
"supportedIntegrations" in currentDefinition && "supportedIntegrations" in currentDefinition &&
(currentDefinition.supportedIntegrations as string[]).some( (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
(kind) => kind === integration.kind,
),
), ),
integrationSupport: "supportedIntegrations" in currentDefinition, integrationSupport: "supportedIntegrations" in currentDefinition,
}); });
@@ -96,19 +81,11 @@ export const WidgetPreviewPageContent = ({
return ( return (
<> <>
<Card <Card withBorder w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
withBorder
w={dimensions.width}
h={dimensions.height}
p={dimensions.height >= 96 ? undefined : 4}
>
<Comp <Comp
options={state.options as never} options={state.options as never}
integrations={state.integrations.map( integrations={state.integrations.map(
(stateIntegration) => (stateIntegration) => integrationData.find((integration) => integration.id === stateIntegration.id)!,
integrationData.find(
(integration) => integration.id === stateIntegration.id,
)!,
)} )}
width={dimensions.width} width={dimensions.width}
height={dimensions.height} height={dimensions.height}
@@ -118,36 +95,17 @@ export const WidgetPreviewPageContent = ({
/> />
</Card> </Card>
<Affix bottom={12} right={72}> <Affix bottom={12} right={72}>
<ActionIcon <ActionIcon size={48} variant="default" radius="xl" onClick={handleOpenEditWidgetModal}>
size={48}
variant="default"
radius="xl"
onClick={handleOpenEditWidgetModal}
>
<IconPencil size={24} /> <IconPencil size={24} />
</ActionIcon> </ActionIcon>
</Affix> </Affix>
<Affix bottom={12} right={72 + 60}> <Affix bottom={12} right={72 + 60}>
<ActionIcon <ActionIcon size={48} variant="default" radius="xl" onClick={toggleEditMode}>
size={48} {editMode ? <IconToggleLeft size={24} /> : <IconToggleRight size={24} />}
variant="default"
radius="xl"
onClick={toggleEditMode}
>
{editMode ? (
<IconToggleLeft size={24} />
) : (
<IconToggleRight size={24} />
)}
</ActionIcon> </ActionIcon>
</Affix> </Affix>
<Affix bottom={12} right={72 + 120}> <Affix bottom={12} right={72 + 120}>
<ActionIcon <ActionIcon size={48} variant="default" radius="xl" onClick={openDimensionsModal}>
size={48}
variant="default"
radius="xl"
onClick={openDimensionsModal}
>
<IconDimensions size={24} /> <IconDimensions size={24} />
</ActionIcon> </ActionIcon>
</Affix> </Affix>

View File

@@ -11,48 +11,36 @@ interface InnerProps {
setDimensions: (dimensions: Dimensions) => void; setDimensions: (dimensions: Dimensions) => void;
} }
export const PreviewDimensionsModal = createModal<InnerProps>( export const PreviewDimensionsModal = createModal<InnerProps>(({ actions, innerProps }) => {
({ actions, innerProps }) => { const t = useI18n();
const t = useI18n(); const form = useForm({
const form = useForm({ initialValues: innerProps.dimensions,
initialValues: innerProps.dimensions, });
});
const handleSubmit = (values: Dimensions) => { const handleSubmit = (values: Dimensions) => {
innerProps.setDimensions(values); innerProps.setDimensions(values);
actions.closeModal(); actions.closeModal();
}; };
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack> <Stack>
<InputWrapper label={t("item.move.field.width.label")}> <InputWrapper label={t("item.move.field.width.label")}>
<Slider <Slider min={64} max={1024} step={64} {...form.getInputProps("width")} />
min={64} </InputWrapper>
max={1024} <InputWrapper label={t("item.move.field.height.label")}>
step={64} <Slider min={64} max={1024} step={64} {...form.getInputProps("height")} />
{...form.getInputProps("width")} </InputWrapper>
/> <Group justify="end">
</InputWrapper> <Button variant="subtle" color="gray" onClick={actions.closeModal}>
<InputWrapper label={t("item.move.field.height.label")}> {t("common.action.cancel")}
<Slider </Button>
min={64} <Button type="submit">{t("common.action.confirm")}</Button>
max={1024} </Group>
step={64} </Stack>
{...form.getInputProps("height")} </form>
/> );
</InputWrapper> }).withOptions({
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.confirm")}</Button>
</Group>
</Stack>
</form>
);
},
).withOptions({
defaultTitle: (t) => t("widgetPreview.dimensions.title"), defaultTitle: (t) => t("widgetPreview.dimensions.title"),
}); });

View File

@@ -28,12 +28,9 @@ const handler = auth(async (req) => {
endpoint: "/api/trpc", endpoint: "/api/trpc",
router: appRouter, router: appRouter,
req, req,
createContext: () => createContext: () => createTRPCContext({ session: req.auth, headers: req.headers }),
createTRPCContext({ session: req.auth, headers: req.headers }),
onError({ error, path, type }) { onError({ error, path, type }) {
logger.error( logger.error(`tRPC Error with ${type} on '${path}': (${error.code}) - ${error.message}`);
`tRPC Error with ${type} on '${path}': (${error.code}) - ${error.message}`,
);
}, },
}); });

View File

@@ -7,28 +7,16 @@ import type { AccordionProps } from "@mantine/core";
import { Accordion } from "@mantine/core"; import { Accordion } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks"; import { useShallowEffect } from "@mantine/hooks";
type ActiveTabAccordionProps = PropsWithChildren< type ActiveTabAccordionProps = PropsWithChildren<Omit<AccordionProps<false>, "onChange">>;
Omit<AccordionProps<false>, "onChange">
>;
// Replace state without fetchign new data // Replace state without fetchign new data
const replace = (newUrl: string) => { const replace = (newUrl: string) => {
window.history.replaceState( window.history.replaceState({ ...window.history.state, as: newUrl, url: newUrl }, "", newUrl);
{ ...window.history.state, as: newUrl, url: newUrl },
"",
newUrl,
);
}; };
export const ActiveTabAccordion = ({ export const ActiveTabAccordion = ({ children, ...props }: ActiveTabAccordionProps) => {
children,
...props
}: ActiveTabAccordionProps) => {
const pathname = usePathname(); const pathname = usePathname();
const onChange = useCallback( const onChange = useCallback((tab: string | null) => (tab ? replace(`?tab=${tab}`) : replace(pathname)), [pathname]);
(tab: string | null) => (tab ? replace(`?tab=${tab}`) : replace(pathname)),
[pathname],
);
useShallowEffect(() => { useShallowEffect(() => {
if (props.defaultValue) { if (props.defaultValue) {

View File

@@ -47,12 +47,8 @@ export const useItemActions = () => {
({ kind }: CreateItem) => { ({ kind }: CreateItem) => {
updateBoard((previous) => { updateBoard((previous) => {
const lastSection = previous.sections const lastSection = previous.sections
.filter( .filter((section): section is EmptySection => section.kind === "empty")
(section): section is EmptySection => section.kind === "empty", .sort((sectionA, sectionB) => sectionB.position - sectionA.position)[0];
)
.sort(
(sectionA, sectionB) => sectionB.position - sectionA.position,
)[0];
if (!lastSection) return previous; if (!lastSection) return previous;
@@ -91,8 +87,7 @@ export const useItemActions = () => {
...previous, ...previous,
sections: previous.sections.map((section) => { sections: previous.sections.map((section) => {
// Return same section if item is not in it // Return same section if item is not in it
if (!section.items.some((item) => item.id === itemId)) if (!section.items.some((item) => item.id === itemId)) return section;
return section;
return { return {
...section, ...section,
items: section.items.map((item) => { items: section.items.map((item) => {
@@ -119,8 +114,7 @@ export const useItemActions = () => {
...previous, ...previous,
sections: previous.sections.map((section) => { sections: previous.sections.map((section) => {
// Return same section if item is not in it // Return same section if item is not in it
if (!section.items.some((item) => item.id === itemId)) if (!section.items.some((item) => item.id === itemId)) return section;
return section;
return { return {
...section, ...section,
items: section.items.map((item) => { items: section.items.map((item) => {
@@ -128,9 +122,7 @@ export const useItemActions = () => {
if (item.id !== itemId) return item; if (item.id !== itemId) return item;
return { return {
...item, ...item,
...("integrations" in item ...("integrations" in item ? { integrations: newIntegrations } : {}),
? { integrations: newIntegrations }
: {}),
}; };
}), }),
}; };
@@ -168,18 +160,14 @@ export const useItemActions = () => {
const moveItemToSection = useCallback( const moveItemToSection = useCallback(
({ itemId, sectionId, ...positionProps }: MoveItemToSection) => { ({ itemId, sectionId, ...positionProps }: MoveItemToSection) => {
updateBoard((previous) => { updateBoard((previous) => {
const currentSection = previous.sections.find((section) => const currentSection = previous.sections.find((section) => section.items.some((item) => item.id === itemId));
section.items.some((item) => item.id === itemId),
);
// If item is in the same section (on initial loading) don't do anything // If item is in the same section (on initial loading) don't do anything
if (!currentSection) { if (!currentSection) {
return previous; return previous;
} }
const currentItem = currentSection.items.find( const currentItem = currentSection.items.find((item) => item.id === itemId);
(item) => item.id === itemId,
);
if (!currentItem) { if (!currentItem) {
return previous; return previous;
} }

View File

@@ -13,14 +13,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
return ( return (
<Grid> <Grid>
{objectEntries(widgetImports).map(([key, value]) => { {objectEntries(widgetImports).map(([key, value]) => {
return ( return <WidgetItem key={key} kind={key} definition={value.definition} closeModal={actions.closeModal} />;
<WidgetItem
key={key}
kind={key}
definition={value.definition}
closeModal={actions.closeModal}
/>
);
})} })}
</Grid> </Grid>
); );
@@ -56,13 +49,7 @@ const WidgetItem = ({
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center"> <Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
{t(`widget.${kind}.name`)} {t(`widget.${kind}.name`)}
</Text> </Text>
<Text <Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
lh={1.2}
style={{ whiteSpace: "normal" }}
size="xs"
ta="center"
c="dimmed"
>
{t(`widget.${kind}.description`)} {t(`widget.${kind}.description`)}
</Text> </Text>
</Stack> </Stack>

View File

@@ -15,62 +15,55 @@ interface InnerProps {
onSuccess?: (name: string) => void; onSuccess?: (name: string) => void;
} }
export const BoardRenameModal = createModal<InnerProps>( export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }) => {
({ actions, innerProps }) => { const utils = clientApi.useUtils();
const utils = clientApi.useUtils(); const t = useI18n();
const t = useI18n(); const { mutate, isPending } = clientApi.board.renameBoard.useMutation({
const { mutate, isPending } = clientApi.board.renameBoard.useMutation({ onSettled() {
onSettled() { void utils.board.getBoardByName.invalidate({
void utils.board.getBoardByName.invalidate({
name: innerProps.previousName,
});
void utils.board.getHomeBoard.invalidate();
},
});
const form = useZodForm(validation.board.rename.omit({ id: true }), {
initialValues: {
name: innerProps.previousName, name: innerProps.previousName,
});
void utils.board.getHomeBoard.invalidate();
},
});
const form = useZodForm(validation.board.rename.omit({ id: true }), {
initialValues: {
name: innerProps.previousName,
},
});
const handleSubmit = (values: FormType) => {
mutate(
{
id: innerProps.id,
name: values.name,
}, },
}); {
onSuccess: () => {
const handleSubmit = (values: FormType) => { actions.closeModal();
mutate( innerProps.onSuccess?.(values.name);
{
id: innerProps.id,
name: values.name,
}, },
{ },
onSuccess: () => {
actions.closeModal();
innerProps.onSuccess?.(values.name);
},
},
);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("board.field.name.label")}
{...form.getInputProps("name")}
data-autofocus
/>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{t("common.action.confirm")}
</Button>
</Group>
</Stack>
</form>
); );
}, };
).withOptions({
defaultTitle: (t) => return (
t("board.setting.section.dangerZone.action.rename.modal.title"), <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput label={t("board.field.name.label")} {...form.getInputProps("name")} data-autofocus />
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{t("common.action.confirm")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("board.setting.section.dangerZone.action.rename.modal.title"),
}); });
type FormType = Omit<z.infer<(typeof validation)["board"]["rename"]>, "id">; type FormType = Omit<z.infer<(typeof validation)["board"]["rename"]>, "id">;

View File

@@ -2,9 +2,7 @@ import { auth } from "@homarr/auth/next";
import type { BoardPermissionsProps } from "@homarr/auth/shared"; import type { BoardPermissionsProps } from "@homarr/auth/shared";
import { constructBoardPermissions } from "@homarr/auth/shared"; import { constructBoardPermissions } from "@homarr/auth/shared";
export const getBoardPermissionsAsync = async ( export const getBoardPermissionsAsync = async (board: BoardPermissionsProps) => {
board: BoardPermissionsProps,
) => {
const session = await auth(); const session = await auth();
return constructBoardPermissions(board, session); return constructBoardPermissions(board, session);
}; };

View File

@@ -1,12 +1,5 @@
import type { RefObject } from "react"; import type { RefObject } from "react";
import { import { Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core";
Card,
Collapse,
Group,
Stack,
Title,
UnstyledButton,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
@@ -30,23 +23,14 @@ export const BoardCategorySection = ({ section, mainRef }: Props) => {
<Group wrap="nowrap" gap="sm"> <Group wrap="nowrap" gap="sm">
<UnstyledButton w="100%" p="sm" onClick={toggle}> <UnstyledButton w="100%" p="sm" onClick={toggle}>
<Group wrap="nowrap"> <Group wrap="nowrap">
{opened ? ( {opened ? <IconChevronUp size={20} /> : <IconChevronDown size={20} />}
<IconChevronUp size={20} />
) : (
<IconChevronDown size={20} />
)}
<Title order={3}>{section.name}</Title> <Title order={3}>{section.name}</Title>
</Group> </Group>
</UnstyledButton> </UnstyledButton>
<CategoryMenu category={section} /> <CategoryMenu category={section} />
</Group> </Group>
<Collapse in={opened} p="sm" pt={0}> <Collapse in={opened} p="sm" pt={0}>
<div <div className="grid-stack grid-stack-category" data-category data-section-id={section.id} ref={refs.wrapper}>
className="grid-stack grid-stack-category"
data-category
data-section-id={section.id}
ref={refs.wrapper}
>
<SectionContent items={section.items} refs={refs} /> <SectionContent items={section.items} refs={refs} />
</div> </div>
</Collapse> </Collapse>

View File

@@ -2,11 +2,7 @@ import { useCallback } from "react";
import { createId } from "@homarr/db/client"; import { createId } from "@homarr/db/client";
import type { import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
CategorySection,
EmptySection,
Section,
} from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client"; import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
interface AddCategory { interface AddCategory {
@@ -41,9 +37,7 @@ export const useCategoryActions = () => {
sections: [ sections: [
// Place sections before the new category // Place sections before the new category
...previous.sections.filter( ...previous.sections.filter(
(section) => (section) => (section.kind === "category" || section.kind === "empty") && section.position < position,
(section.kind === "category" || section.kind === "empty") &&
section.position < position,
), ),
{ {
id: createId(), id: createId(),
@@ -62,8 +56,7 @@ export const useCategoryActions = () => {
...previous.sections ...previous.sections
.filter( .filter(
(section): section is CategorySection | EmptySection => (section): section is CategorySection | EmptySection =>
(section.kind === "category" || section.kind === "empty") && (section.kind === "category" || section.kind === "empty") && section.position >= position,
section.position >= position,
) )
.map((section) => ({ .map((section) => ({
...section, ...section,
@@ -134,29 +127,19 @@ export const useCategoryActions = () => {
({ id, direction }: MoveCategory) => { ({ id, direction }: MoveCategory) => {
updateBoard((previous) => { updateBoard((previous) => {
const currentCategory = previous.sections.find( const currentCategory = previous.sections.find(
(section): section is CategorySection => (section): section is CategorySection => section.kind === "category" && section.id === id,
section.kind === "category" && section.id === id,
); );
if (!currentCategory) return previous; if (!currentCategory) return previous;
if (currentCategory?.position === 1 && direction === "up") if (currentCategory?.position === 1 && direction === "up") return previous;
return previous; if (currentCategory?.position === previous.sections.length - 2 && direction === "down") return previous;
if (
currentCategory?.position === previous.sections.length - 2 &&
direction === "down"
)
return previous;
return { return {
...previous, ...previous,
sections: previous.sections.map((section) => { sections: previous.sections.map((section) => {
if (section.kind !== "category" && section.kind !== "empty") if (section.kind !== "category" && section.kind !== "empty") return section;
return section;
const offset = direction === "up" ? -2 : 2; const offset = direction === "up" ? -2 : 2;
// Move category and empty section // Move category and empty section
if ( if (section.position === currentCategory.position || section.position - 1 === currentCategory.position) {
section.position === currentCategory.position ||
section.position - 1 === currentCategory.position
) {
return { return {
...section, ...section,
position: section.position + offset, position: section.position + offset,
@@ -165,8 +148,7 @@ export const useCategoryActions = () => {
if ( if (
direction === "up" && direction === "up" &&
(section.position === currentCategory.position - 2 || (section.position === currentCategory.position - 2 || section.position === currentCategory.position - 1)
section.position === currentCategory.position - 1)
) { ) {
return { return {
...section, ...section,
@@ -176,8 +158,7 @@ export const useCategoryActions = () => {
if ( if (
direction === "down" && direction === "down" &&
(section.position === currentCategory.position + 2 || (section.position === currentCategory.position + 2 || section.position === currentCategory.position + 3)
section.position === currentCategory.position + 3)
) { ) {
return { return {
...section, ...section,
@@ -197,21 +178,18 @@ export const useCategoryActions = () => {
({ id: categoryId }: RemoveCategory) => { ({ id: categoryId }: RemoveCategory) => {
updateBoard((previous) => { updateBoard((previous) => {
const currentCategory = previous.sections.find( const currentCategory = previous.sections.find(
(section): section is CategorySection => (section): section is CategorySection => section.kind === "category" && section.id === categoryId,
section.kind === "category" && section.id === categoryId,
); );
if (!currentCategory) return previous; if (!currentCategory) return previous;
const aboveWrapper = previous.sections.find( const aboveWrapper = previous.sections.find(
(section): section is EmptySection => (section): section is EmptySection =>
section.kind === "empty" && section.kind === "empty" && section.position === currentCategory.position - 1,
section.position === currentCategory.position - 1,
); );
const removedWrapper = previous.sections.find( const removedWrapper = previous.sections.find(
(section): section is EmptySection => (section): section is EmptySection =>
section.kind === "empty" && section.kind === "empty" && section.position === currentCategory.position + 1,
section.position === currentCategory.position + 1,
); );
if (!aboveWrapper || !removedWrapper) return previous; if (!aboveWrapper || !removedWrapper) return previous;
@@ -232,16 +210,10 @@ export const useCategoryActions = () => {
return { return {
...previous, ...previous,
sections: [ sections: [
...previous.sections.filter( ...previous.sections.filter((section) => section.position < currentCategory.position - 1),
(section) => section.position < currentCategory.position - 1,
),
{ {
...aboveWrapper, ...aboveWrapper,
items: [ items: [...aboveWrapper.items, ...previousCategoryItems, ...previousBelowWrapperItems],
...aboveWrapper.items,
...previousCategoryItems,
...previousBelowWrapperItems,
],
}, },
...previous.sections ...previous.sections
.filter( .filter(

View File

@@ -16,41 +16,35 @@ interface InnerProps {
onSuccess: (category: Category) => void; onSuccess: (category: Category) => void;
} }
export const CategoryEditModal = createModal<InnerProps>( export const CategoryEditModal = createModal<InnerProps>(({ actions, innerProps }) => {
({ actions, innerProps }) => { const t = useI18n();
const t = useI18n(); const form = useZodForm(z.object({ name: z.string().min(1) }), {
const form = useZodForm(z.object({ name: z.string().min(1) }), { initialValues: {
initialValues: { name: innerProps.category.name,
name: innerProps.category.name, },
}, });
});
return ( return (
<form <form
onSubmit={form.onSubmit((values) => { onSubmit={form.onSubmit((values) => {
void innerProps.onSuccess({ void innerProps.onSuccess({
...innerProps.category, ...innerProps.category,
name: values.name, name: values.name,
}); });
actions.closeModal(); actions.closeModal();
})} })}
> >
<Stack> <Stack>
<TextInput <TextInput label={t("section.category.field.name.label")} data-autofocus {...form.getInputProps("name")} />
label={t("section.category.field.name.label")} <Group justify="right">
data-autofocus <Button onClick={actions.closeModal} variant="subtle" color="gray">
{...form.getInputProps("name")} {t("common.action.cancel")}
/> </Button>
<Group justify="right"> <Button type="submit" color="teal">
<Button onClick={actions.closeModal} variant="subtle" color="gray"> {innerProps.submitLabel}
{t("common.action.cancel")} </Button>
</Button> </Group>
<Button type="submit" color="teal"> </Stack>
{innerProps.submitLabel} </form>
</Button> );
</Group> }).withOptions({});
</Stack>
</form>
);
},
).withOptions({});

View File

@@ -11,8 +11,7 @@ import { CategoryEditModal } from "./category-edit-modal";
export const useCategoryMenuActions = (category: CategorySection) => { export const useCategoryMenuActions = (category: CategorySection) => {
const { openModal } = useModalAction(CategoryEditModal); const { openModal } = useModalAction(CategoryEditModal);
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const { addCategory, moveCategory, removeCategory, renameCategory } = const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
useCategoryActions();
const t = useI18n(); const t = useI18n();
const createCategoryAtPosition = useCallback( const createCategoryAtPosition = useCallback(

View File

@@ -67,14 +67,8 @@ const useActions = (category: CategorySection) => {
}; };
const useEditModeActions = (category: CategorySection) => { const useEditModeActions = (category: CategorySection) => {
const { const { addCategoryAbove, addCategoryBelow, moveCategoryUp, moveCategoryDown, edit, remove } =
addCategoryAbove, useCategoryMenuActions(category);
addCategoryBelow,
moveCategoryUp,
moveCategoryDown,
edit,
remove,
} = useCategoryMenuActions(category);
return [ return [
{ {

View File

@@ -5,12 +5,7 @@ import { useMemo } from "react";
import type { RefObject } from "react"; import type { RefObject } from "react";
import { ActionIcon, Card, Menu } from "@mantine/core"; import { ActionIcon, Card, Menu } from "@mantine/core";
import { useElementSize } from "@mantine/hooks"; import { useElementSize } from "@mantine/hooks";
import { import { IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
IconDotsVertical,
IconLayoutKanban,
IconPencil,
IconTrash,
} from "@tabler/icons-react";
import combineClasses from "clsx"; import combineClasses from "clsx";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
@@ -43,12 +38,7 @@ export const SectionContent = ({ items, refs }: Props) => {
return ( return (
<> <>
{items.map((item) => ( {items.map((item) => (
<BoardItem <BoardItem key={item.id} refs={refs} item={item} opacity={board.opacity} />
key={item.id}
refs={refs}
item={item}
opacity={board.opacity}
/>
))} ))}
</> </>
); );
@@ -133,14 +123,9 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
const { openModal } = useModalAction(WidgetEditModal); const { openModal } = useModalAction(WidgetEditModal);
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const isEditMode = useAtomValue(editModeAtom); const isEditMode = useAtomValue(editModeAtom);
const { updateItemOptions, updateItemIntegrations, removeItem } = const { updateItemOptions, updateItemIntegrations, removeItem } = useItemActions();
useItemActions(); const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const { data: integrationData, isPending } = const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
clientApi.integration.all.useQuery();
const currentDefinition = useMemo(
() => widgetImports[item.kind].definition,
[item.kind],
);
if (!isEditMode || isPending) return null; if (!isEditMode || isPending) return null;
@@ -164,9 +149,7 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
integrationData: (integrationData ?? []).filter( integrationData: (integrationData ?? []).filter(
(integration) => (integration) =>
"supportedIntegrations" in currentDefinition && "supportedIntegrations" in currentDefinition &&
(currentDefinition.supportedIntegrations as string[]).some( (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
(kind) => kind === integration.kind,
),
), ),
integrationSupport: "supportedIntegrations" in currentDefinition, integrationSupport: "supportedIntegrations" in currentDefinition,
}); });
@@ -185,34 +168,19 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
return ( return (
<Menu withinPortal withArrow position="right-start" arrowPosition="center"> <Menu withinPortal withArrow position="right-start" arrowPosition="center">
<Menu.Target> <Menu.Target>
<ActionIcon <ActionIcon variant="transparent" pos="absolute" top={offset} right={offset} style={{ zIndex: 1 }}>
variant="transparent"
pos="absolute"
top={offset}
right={offset}
style={{ zIndex: 1 }}
>
<IconDotsVertical /> <IconDotsVertical />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown miw={128}> <Menu.Dropdown miw={128}>
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label> <Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
<Menu.Item <Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
leftSection={<IconPencil size={16} />}
onClick={openEditModal}
>
{tItem("action.edit")} {tItem("action.edit")}
</Menu.Item> </Menu.Item>
<Menu.Item leftSection={<IconLayoutKanban size={16} />}> <Menu.Item leftSection={<IconLayoutKanban size={16} />}>{tItem("action.move")}</Menu.Item>
{tItem("action.move")}
</Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label> <Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item <Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
c="red.6"
leftSection={<IconTrash size={16} />}
onClick={openRemoveModal}
>
{tItem("action.remove")} {tItem("action.remove")}
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>

View File

@@ -19,11 +19,7 @@ export const BoardEmptySection = ({ section, mainRef }: Props) => {
return ( return (
<div <div
className={ className={section.items.length > 0 || isEditMode ? defaultClasses : `${defaultClasses} gridstack-empty-wrapper`}
section.items.length > 0 || isEditMode
? defaultClasses
: `${defaultClasses} gridstack-empty-wrapper`
}
style={{ transitionDuration: "0s" }} style={{ transitionDuration: "0s" }}
data-empty data-empty
data-section-id={section.id} data-section-id={section.id}

View File

@@ -15,20 +15,14 @@ interface InitializeGridstackProps {
sectionColumnCount: number; sectionColumnCount: number;
} }
export const initializeGridstack = ({ export const initializeGridstack = ({ section, refs, sectionColumnCount }: InitializeGridstackProps) => {
section,
refs,
sectionColumnCount,
}: InitializeGridstackProps) => {
if (!refs.wrapper.current) return false; if (!refs.wrapper.current) return false;
// initialize gridstack // initialize gridstack
const newGrid = refs.gridstack; const newGrid = refs.gridstack;
newGrid.current = GridStack.init( newGrid.current = GridStack.init(
{ {
column: sectionColumnCount, column: sectionColumnCount,
margin: Math.round( margin: Math.round(Math.max(Math.min(refs.wrapper.current.offsetWidth / 100, 10), 1)),
Math.max(Math.min(refs.wrapper.current.offsetWidth / 100, 10), 1),
),
cellHeight: 128, cellHeight: 128,
float: true, float: true,
alwaysShowResizeHandle: true, alwaysShowResizeHandle: true,

Some files were not shown because too many files have changed in this diff Show More