Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d4956077 | ||
|
|
84588c956d | ||
|
|
73550ce998 | ||
|
|
41fe5928f1 | ||
|
|
6eb59d6f53 | ||
|
|
2c6b7a818e | ||
|
|
fd10788838 | ||
|
|
fe4598f722 | ||
|
|
a755692d81 | ||
|
|
3ff0d72ba1 | ||
|
|
a63a191203 | ||
|
|
1be906a0cd | ||
|
|
63d8fa8c26 | ||
|
|
b5dd6ae8ef | ||
|
|
d540ad374a | ||
|
|
6a971c1986 | ||
|
|
d86fe3ddf0 | ||
|
|
06db8960a7 | ||
|
|
a5719acf34 | ||
|
|
d1d597c8e1 | ||
|
|
657e42e4bb | ||
|
|
256ebcb864 | ||
|
|
72e86dee01 | ||
|
|
b7910b16e0 | ||
|
|
5a83bb6e39 | ||
|
|
433b66582d | ||
|
|
acbcb6818b | ||
|
|
07d991a0a1 | ||
|
|
92364e9574 | ||
|
|
2991777b0a | ||
|
|
6cc2ac56a9 | ||
|
|
92086e060f | ||
|
|
2aa5ab95e6 | ||
|
|
650f761325 | ||
|
|
be61602576 | ||
|
|
d01834d975 | ||
|
|
4a67bf0ef3 | ||
|
|
4454e3fb7a | ||
|
|
71d7332505 | ||
|
|
eae51a8447 | ||
|
|
e9308db5dc | ||
|
|
fb06b55845 | ||
|
|
108b488250 | ||
|
|
10cedbd906 | ||
|
|
f53527e2e7 | ||
|
|
121cff20d7 | ||
|
|
e14da6f747 |
No files matched your search
@@ -7,7 +7,7 @@
|
||||
"build": "pnpm with-env next build",
|
||||
"clean": "git clean -xdf .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"start": "pnpm with-env next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||
import { createTheme, DirectionProvider, MantineProvider } from "@mantine/core";
|
||||
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||
import { theme } from "@homarr/ui";
|
||||
|
||||
export const CustomMantineProvider = ({
|
||||
children,
|
||||
@@ -20,11 +21,8 @@ export const CustomMantineProvider = ({
|
||||
<MantineProvider
|
||||
defaultColorScheme={defaultColorScheme}
|
||||
colorSchemeManager={manager}
|
||||
theme={createTheme({
|
||||
primaryColor: "red",
|
||||
autoContrast: true,
|
||||
defaultRadius: "sm",
|
||||
})}
|
||||
theme={theme}
|
||||
cssVariablesResolver={v8CssVariablesResolver}
|
||||
>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
|
||||
@@ -59,7 +59,7 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card withBorder w={64 * 6} maw="90vw">
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<RegistrationForm invite={invite} />
|
||||
</Card>
|
||||
<Text size="xs" c="gray.5" ta="center">
|
||||
|
||||
@@ -37,7 +37,7 @@ export default async function Login(props: LoginProps) {
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card withBorder w={64 * 6} maw="90vw">
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<LoginForm
|
||||
providers={env.AUTH_PROVIDERS}
|
||||
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { cache } from "react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import { createBoardContentPage } from "../_creator";
|
||||
|
||||
const getHomeBoardAsync = cache(() => api.board.getHomeBoard());
|
||||
|
||||
export default createBoardContentPage<{ locale: string }>({
|
||||
async getInitialBoardAsync() {
|
||||
return await api.board.getHomeBoard();
|
||||
},
|
||||
getInitialBoardAsync: getHomeBoardAsync,
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { cache } from "react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import { createBoardContentPage } from "../../_creator";
|
||||
|
||||
const getBoardByNameAsync = cache((name: string) => api.board.getBoardByName({ name }));
|
||||
|
||||
export default createBoardContentPage<{ locale: string; name: string }>({
|
||||
async getInitialBoardAsync({ name }) {
|
||||
return await api.board.getBoardByName({ name });
|
||||
},
|
||||
getInitialBoardAsync: async ({ name }) => getBoardByNameAsync(name),
|
||||
});
|
||||
@@ -1,33 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Stack } from "@mantine/core";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
import { StaticBoardGrid } from "~/components/board/sections/static-grid";
|
||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
|
||||
const EditModeBoard = dynamic(() => import("./_edit-board"), { ssr: false });
|
||||
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
|
||||
const fullWidthSortedSections = board.sections
|
||||
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<BoardBackgroundVideo />
|
||||
<Stack h="100%">
|
||||
{fullWidthSortedSections.map((section) =>
|
||||
section.kind === "empty" ? (
|
||||
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
) : (
|
||||
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
{isEditMode ? <EditModeBoard /> : <StaticBoardGrid board={board} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
|
||||
ssr: false,
|
||||
});
|
||||
export { ClientBoard as DynamicClientBoard } from "./_client";
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Stack } from "@mantine/core";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
|
||||
const EditModeBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
|
||||
const fullWidthSortedSections = board.sections
|
||||
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
|
||||
return (
|
||||
<Stack h="100%">
|
||||
{fullWidthSortedSections.map((section) =>
|
||||
section.kind === "empty" ? (
|
||||
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
) : (
|
||||
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditModeBoard;
|
||||
@@ -109,7 +109,7 @@ const AddMenu = () => {
|
||||
}, [openAppSelectModal, createItem]);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end" withArrow>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
@@ -183,7 +183,7 @@ const SelectBoardsMenu = () => {
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end" withArrow>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<IconReplace stroke={1.5} />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorsTuple } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core";
|
||||
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
@@ -33,7 +33,12 @@ export const BoardMantineProvider = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
|
||||
<MantineProvider
|
||||
defaultColorScheme={defaultColorScheme}
|
||||
theme={theme}
|
||||
colorSchemeManager={colorSchemeManager}
|
||||
cssVariablesResolver={v8CssVariablesResolver}
|
||||
>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ export const InitFinish = async () => {
|
||||
const tFinish = await getScopedI18n("init.step.finish");
|
||||
|
||||
return (
|
||||
<Card w={64 * 6} maw="90vw" withBorder>
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<Stack>
|
||||
<Text>{tFinish("description")}</Text>
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export const InitGroup = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card w={64 * 6} maw="90vw" withBorder>
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
|
||||
@@ -13,7 +13,7 @@ interface FileInfoCardProps {
|
||||
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
||||
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
|
||||
return (
|
||||
<Card w={64 * 12 + 8} maw="90vw">
|
||||
<Card w={64 * 12 + 8} maw="90vw" withBorder={false}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group>
|
||||
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
|
||||
|
||||
@@ -20,7 +20,7 @@ export const InitImport = () => {
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
||||
<Card w={64 * 12 + 8} maw="90vw">
|
||||
<ImportDropZone
|
||||
loading={isPending}
|
||||
updateFile={(file) => {
|
||||
|
||||
@@ -30,7 +30,7 @@ export const InitSettings = () => {
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack>
|
||||
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
||||
<Card w={64 * 12 + 8} maw="90vw">
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>{tSection("analytics.title")}</Text>
|
||||
|
||||
@@ -39,7 +39,7 @@ export const InitSettings = () => {
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
||||
<Card w={64 * 12 + 8} maw="90vw">
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export const InitStart = async () => {
|
||||
const tStart = await getScopedI18n("init.step.start");
|
||||
|
||||
return (
|
||||
<Card w={64 * 6} maw="90vw" withBorder>
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<Stack>
|
||||
<Text>{tStart("description")}</Text>
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
import { Button, Stack, TextInput } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { signIn } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { CustomPasswordInput } from "@homarr/ui";
|
||||
import { userInitSchema } from "@homarr/validation/user";
|
||||
|
||||
export const InitUserForm = () => {
|
||||
@@ -59,12 +59,10 @@ export const InitUserForm = () => {
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
||||
<CustomPasswordInput
|
||||
withPasswordRequirements
|
||||
label={t("field.password.label")}
|
||||
{...form.getInputProps("password")}
|
||||
<UserCreatePasswordFields
|
||||
passwordInputProps={form.getInputProps("password")}
|
||||
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
|
||||
/>
|
||||
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
|
||||
<Button type="submit" fullWidth loading={isPending}>
|
||||
{t("action.create")}
|
||||
</Button>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { InitUserForm } from "./init-user-form";
|
||||
|
||||
export const InitUser = () => {
|
||||
return (
|
||||
<Card w={64 * 6} maw="90vw" withBorder>
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<InitUserForm />
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -44,13 +44,13 @@ const fontSans = Inter({
|
||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||
title: "Homarr",
|
||||
description:
|
||||
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||
openGraph: {
|
||||
title: "Homarr Dashboard",
|
||||
description:
|
||||
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||
url: "https://homarr.dev",
|
||||
siteName: "Homarr Documentation",
|
||||
siteName: "Homarr",
|
||||
},
|
||||
icons: {
|
||||
icon: "/logo/logo.png",
|
||||
|
||||
@@ -45,7 +45,6 @@ export const HeroBanner = async () => {
|
||||
<Image
|
||||
key={`grid-column-${columnIndex}-scroll-1-${index}`}
|
||||
src={icon}
|
||||
radius="md"
|
||||
fit={"contain"}
|
||||
w={50}
|
||||
h={50}
|
||||
@@ -57,7 +56,6 @@ export const HeroBanner = async () => {
|
||||
<Image
|
||||
key={`grid-column-${columnIndex}-scroll-2-${index}`}
|
||||
src={icon}
|
||||
radius="md"
|
||||
fit={"contain"}
|
||||
w={50}
|
||||
h={50}
|
||||
|
||||
@@ -90,9 +90,7 @@ export default async function AboutPage() {
|
||||
<Group py="lg">
|
||||
<Image src={homarrLogoPath} width={100} height={100} alt="" />
|
||||
<Stack gap={0}>
|
||||
<Title order={1} tt="uppercase">
|
||||
Homarr
|
||||
</Title>
|
||||
<Title order={1}>Homarr</Title>
|
||||
<Title order={2}>{t("version", { version })}</Title>
|
||||
</Stack>
|
||||
</Group>
|
||||
@@ -195,9 +193,9 @@ export default async function AboutPage() {
|
||||
href={`https://www.npmjs.com/package/${name}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Card withBorder radius="md" p="xs" className={classes.dependencyCard}>
|
||||
<Card radius="md" p="xs" className={classes.dependencyCard}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" size="lg" radius="md">
|
||||
<ThemeIcon variant="light" size="lg">
|
||||
<IconPackage size="1.5rem" stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ const AppCard = async ({ app }: AppCardProps) => {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Card>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
|
||||
<Avatar
|
||||
|
||||
@@ -70,16 +70,14 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||
|
||||
return (
|
||||
<Card radius="lg" withBorder>
|
||||
<Card>
|
||||
<CardSection p="sm" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm">
|
||||
<Tooltip label={t(`visibility.${visibility}`)}>
|
||||
<VisibilityIcon size={20} stroke={1.5} />
|
||||
</Tooltip>
|
||||
<Text fw="bolder" tt="uppercase">
|
||||
{board.name}
|
||||
</Text>
|
||||
<Text fw={700}>{board.name}</Text>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
@@ -127,6 +125,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("action.settings.label")}
|
||||
>
|
||||
<IconDotsVertical size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
+7
-2
@@ -33,7 +33,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||
const KindIcon = integrationSecretIcons[secret.kind];
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Card>
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
@@ -58,7 +58,12 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
{isPublic ? (
|
||||
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
|
||||
<ActionIcon
|
||||
color="gray"
|
||||
variant="subtle"
|
||||
onClick={togglePublicSecretDisplay}
|
||||
aria-label={t(publicSecretDisplayOpened ? "common.action.hide" : "common.action.show")}
|
||||
>
|
||||
<DisplayIcon size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
|
||||
|
||||
return (
|
||||
<Card withBorder style={{ borderColor: getMantineColor("red", 8) }}>
|
||||
<Card style={{ borderColor: getMantineColor("red", 8) }}>
|
||||
<Stack>
|
||||
<Stack gap="sm">
|
||||
<Text size="lg" fw={500} c="red.8">
|
||||
|
||||
+1
-1
@@ -216,7 +216,7 @@ export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps)
|
||||
const tCertificateField = useScopedI18n("certificate.field");
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Card>
|
||||
<Text fw={500}>{tDetails("title")}</Text>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
transition: transform 150ms ease;
|
||||
transition: background-color 150ms ease;
|
||||
overflow: hidden;
|
||||
|
||||
&[data-first="true"] {
|
||||
@@ -20,11 +20,9 @@
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
transform: scale(1.01);
|
||||
z-index: 1;
|
||||
background-color: var(--mantine-color-body);
|
||||
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ export default async function NotFound() {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center">
|
||||
<Title order={1} tt="uppercase">
|
||||
{t("title")}
|
||||
</Title>
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<Text>{t("text")}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
|
||||
@@ -29,20 +29,20 @@ export default async function ManagementPage() {
|
||||
<Space h="md" />
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||
{statistics.map((statistic) => (
|
||||
<Card component={Link} href={statistic.path} key={statistic.path} radius="lg">
|
||||
<Card component={Link} href={statistic.path} key={statistic.path}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Text size="2.4rem" fw="bolder">
|
||||
<Text size="xl" fw={700}>
|
||||
{statistic.count}
|
||||
</Text>
|
||||
<Stack gap={0}>
|
||||
<Text c="red" size="xs">
|
||||
<Text c="dimmed" size="xs">
|
||||
{t(`statisticLabel.${statistic.subtitleKey}`)}
|
||||
</Text>
|
||||
<Text fw="bold">{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<IconArrowRight />
|
||||
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -74,7 +74,7 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Card>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Avatar
|
||||
|
||||
@@ -41,8 +41,8 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
||||
<>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
|
||||
<Card pos="relative" withBorder>
|
||||
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||
<Card pos="relative">
|
||||
<LoadingOverlay visible={isPending} />
|
||||
<Stack>
|
||||
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
||||
</Stack>
|
||||
|
||||
+2
-2
@@ -41,11 +41,11 @@ export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexing
|
||||
<>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
|
||||
<Card pos="relative" withBorder>
|
||||
<Card pos="relative">
|
||||
<Text c={"dimmed"} mb={"lg"}>
|
||||
{t("warning")}
|
||||
</Text>
|
||||
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||
<LoadingOverlay visible={isPending} />
|
||||
<Stack>
|
||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||
|
||||
@@ -79,7 +79,7 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
|
||||
|
||||
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
|
||||
{x509Certificates.map((cert) => (
|
||||
<Card key={cert.fileName} withBorder>
|
||||
<Card key={cert.fileName}>
|
||||
<Group wrap="nowrap">
|
||||
{cert.isError ? (
|
||||
<IconAlertTriangle
|
||||
|
||||
@@ -107,6 +107,23 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const relativeTime = useTimeAgo(data.timestamp);
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
showSuccessNotification({
|
||||
title: tDocker("action.refresh.notification.success.title"),
|
||||
message: tDocker("action.refresh.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tDocker("action.refresh.notification.error.title"),
|
||||
message: tDocker("action.refresh.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data: data.containers,
|
||||
enableDensityToggle: false,
|
||||
@@ -125,35 +142,11 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
|
||||
},
|
||||
|
||||
initialState: { density: "xs", showGlobalFilter: true },
|
||||
renderTopToolbarCustomActions: () => {
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
showSuccessNotification({
|
||||
title: tDocker("action.refresh.notification.success.title"),
|
||||
message: tDocker("action.refresh.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tDocker("action.refresh.notification.error.title"),
|
||||
message: tDocker("action.refresh.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
rightSection={<IconRefresh size="1rem" />}
|
||||
onClick={() => mutate()}
|
||||
loading={isPending}
|
||||
>
|
||||
{tDocker("action.refresh.label")}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
|
||||
{tDocker("action.refresh.label")}
|
||||
</Button>
|
||||
),
|
||||
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
|
||||
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
|
||||
return (
|
||||
@@ -202,7 +195,7 @@ const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) =>
|
||||
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
||||
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light" radius="md">
|
||||
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light">
|
||||
{t("addToHomarr.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -253,7 +246,6 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||
onClick={handleClickAsync}
|
||||
loading={isPending}
|
||||
variant="light"
|
||||
radius="md"
|
||||
>
|
||||
{t(`${props.action}.label`)}
|
||||
</Button>
|
||||
|
||||
+7
-19
@@ -4,28 +4,16 @@
|
||||
overflow: hidden;
|
||||
padding: var(--mantine-spacing-xs);
|
||||
padding-left: calc(var(--mantine-spacing-xs) * 2);
|
||||
|
||||
@mixin hover {
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 6px;
|
||||
background-image: linear-gradient(0, var(--mantine-color-blue-4), var(--mantine-color-blue-9));
|
||||
}
|
||||
border-left: 1px solid var(--mantine-color-blue-6);
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-0),
|
||||
color-mix(in oklch, var(--mantine-color-blue-9), transparent 85%)
|
||||
);
|
||||
|
||||
@mixin rtl {
|
||||
padding-left: var(--mantine-spacing-xs);
|
||||
padding-right: calc(var(--mantine-spacing-xs) * 2);
|
||||
|
||||
&::before {
|
||||
left: unset;
|
||||
right: 0;
|
||||
}
|
||||
border-left: none;
|
||||
border-right: 1px solid var(--mantine-color-blue-6);
|
||||
}
|
||||
}
|
||||
-1
@@ -23,7 +23,6 @@ export function HeaderCard(props: HeaderCardProps) {
|
||||
<Flex align="center" justify={isRTL ? "space-between" : "flex-start"} gap="md" direction={"row"}>
|
||||
<ThemeIcon
|
||||
size="xl"
|
||||
radius="md"
|
||||
variant="gradient"
|
||||
gradient={{
|
||||
deg: 0,
|
||||
|
||||
+4
-2
@@ -8,6 +8,8 @@ import { useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||
import { ResourceIcon } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-icon";
|
||||
import classes from "./resource-gauge.module.css";
|
||||
|
||||
const KUBERNETES_BRAND_COLOR = "#326ce5";
|
||||
|
||||
interface KubernetesResourceGaugeProps {
|
||||
kubernetesCapacity: KubernetesCapacity;
|
||||
}
|
||||
@@ -18,8 +20,8 @@ export function ResourceGauge(props: KubernetesResourceGaugeProps) {
|
||||
const isRTL = Boolean(isLocaleRTL(currentLocale));
|
||||
|
||||
return (
|
||||
<Paper radius="md" withBorder className={classes.paper} mt={20}>
|
||||
<ThemeIcon className={classes.icon} size={60} radius={60} bg={"#326ce5"}>
|
||||
<Paper className={classes.paper} mt={20}>
|
||||
<ThemeIcon className={classes.icon} size={60} radius={60} bg={KUBERNETES_BRAND_COLOR}>
|
||||
<ResourceIcon type={props.kubernetesCapacity.type} />
|
||||
</ThemeIcon>
|
||||
|
||||
|
||||
+1
-6
@@ -17,12 +17,7 @@ interface ResourceTileProps {
|
||||
export function ResourceTile(props: ResourceTileProps) {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
component={Link}
|
||||
href={`/manage/tools/kubernetes/${props.label}`}
|
||||
className={classes.cardContainer}
|
||||
>
|
||||
<Card component={Link} href={`/manage/tools/kubernetes/${props.label}`} className={classes.cardContainer}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
|
||||
<Group gap="xs">
|
||||
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { userChangeHomeBoardsSchema } from "@homarr/validation/user";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
|
||||
interface ChangeHomeBoardFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||
}
|
||||
|
||||
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.changeHomeBoards.useMutation({
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
form.setInitialValues({
|
||||
homeBoardId: variables.homeBoardId,
|
||||
mobileHomeBoardId: variables.mobileHomeBoardId,
|
||||
});
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changeHomeBoard.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("user.action.changeHomeBoard.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useZodForm(userChangeHomeBoardsSchema, {
|
||||
initialValues: {
|
||||
homeBoardId: user.homeBoardId,
|
||||
mobileHomeBoardId: user.mobileHomeBoardId,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate({
|
||||
userId: user.id,
|
||||
...values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<BoardSelect
|
||||
label={t("management.page.user.setting.general.item.board.type.general")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<BoardSelect
|
||||
label={t("management.page.user.setting.general.item.board.type.mobile")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof userChangeHomeBoardsSchema>;
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Select, Stack, Switch } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { userChangeSearchPreferencesSchema } from "@homarr/validation/user";
|
||||
|
||||
interface ChangeSearchPreferencesFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
searchEnginesData: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeSearchPreferencesFormProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.changeSearchPreferences.useMutation({
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
form.setInitialValues({
|
||||
defaultSearchEngineId: variables.defaultSearchEngineId,
|
||||
openInNewTab: variables.openInNewTab,
|
||||
ddgBangsEnabled: variables.ddgBangsEnabled,
|
||||
});
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changeSearchPreferences.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("user.action.changeSearchPreferences.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useZodForm(userChangeSearchPreferencesSchema, {
|
||||
initialValues: {
|
||||
defaultSearchEngineId: user.defaultSearchEngineId,
|
||||
openInNewTab: user.openSearchInNewTab,
|
||||
ddgBangsEnabled: user.ddgBangs,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate({
|
||||
userId: user.id,
|
||||
...values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label={t("user.field.defaultSearchEngine.label")}
|
||||
w="100%"
|
||||
data={searchEnginesData}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
<Switch
|
||||
label={t("user.field.openSearchInNewTab.label")}
|
||||
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("user.field.ddgBangs.label")}
|
||||
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof userChangeSearchPreferencesSchema>;
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Radio, Stack } from "@mantine/core";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
import localeData from "dayjs/plugin/localeData";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { userFirstDayOfWeekSchema } from "@homarr/validation/user";
|
||||
|
||||
dayjs.extend(localeData);
|
||||
|
||||
interface FirstDayOfWeekProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
}
|
||||
|
||||
const weekDays = dayjs.weekdays(false);
|
||||
|
||||
export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.changeFirstDayOfWeek.useMutation({
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
form.setInitialValues({
|
||||
firstDayOfWeek: variables.firstDayOfWeek as DayOfWeek,
|
||||
});
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changeFirstDayOfWeek.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("user.action.changeFirstDayOfWeek.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useZodForm(userFirstDayOfWeekSchema, {
|
||||
initialValues: {
|
||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate({
|
||||
id: user.id,
|
||||
...values,
|
||||
});
|
||||
};
|
||||
|
||||
const inputProps = form.getInputProps("firstDayOfWeek");
|
||||
const onChange = inputProps.onChange as (value: number) => void;
|
||||
const value = (inputProps.value as number).toString();
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Radio.Group {...inputProps} value={value} onChange={(nextValue: string) => onChange(parseInt(nextValue, 10))}>
|
||||
<Group mt="xs">
|
||||
<Radio value="1" label={weekDays[1]} />
|
||||
<Radio value="6" label={weekDays[6]} />
|
||||
<Radio value="0" label={weekDays[0]} />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof userFirstDayOfWeekSchema>;
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Radio,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
import localeData from "dayjs/plugin/localeData";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
userChangeHomeBoardsSchema,
|
||||
userChangeSearchPreferencesSchema,
|
||||
userEditProfileSchema,
|
||||
userFirstDayOfWeekSchema,
|
||||
userPingIconsEnabledSchema,
|
||||
} from "@homarr/validation/user";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||
|
||||
dayjs.extend(localeData);
|
||||
|
||||
const userGeneralSettingsSchema = z.object({
|
||||
name: userEditProfileSchema.shape.name,
|
||||
email: userEditProfileSchema.shape.email,
|
||||
homeBoardId: userChangeHomeBoardsSchema.shape.homeBoardId,
|
||||
mobileHomeBoardId: userChangeHomeBoardsSchema.shape.mobileHomeBoardId,
|
||||
defaultSearchEngineId: userChangeSearchPreferencesSchema.shape.defaultSearchEngineId,
|
||||
openInNewTab: userChangeSearchPreferencesSchema.shape.openInNewTab,
|
||||
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
|
||||
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
|
||||
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
|
||||
|
||||
const FIRST_DAY_OPTIONS: { value: DayOfWeek; labelKey: number }[] = [
|
||||
{ value: 1, labelKey: 1 },
|
||||
{ value: 6, labelKey: 6 },
|
||||
{ value: 0, labelKey: 0 },
|
||||
];
|
||||
|
||||
interface UserGeneralSettingsFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||
searchEnginesData: { value: string; label: string }[];
|
||||
showLanguageSelector?: boolean;
|
||||
}
|
||||
|
||||
const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({
|
||||
name: user.name ?? "",
|
||||
email: user.email ?? "",
|
||||
homeBoardId: user.homeBoardId,
|
||||
mobileHomeBoardId: user.mobileHomeBoardId,
|
||||
defaultSearchEngineId: user.defaultSearchEngineId,
|
||||
openInNewTab: user.openSearchInNewTab,
|
||||
ddgBangsEnabled: user.ddgBangs,
|
||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||
pingIconsEnabled: user.pingIconsEnabled,
|
||||
});
|
||||
|
||||
export const UserGeneralSettingsForm = ({
|
||||
user,
|
||||
boardsData,
|
||||
searchEnginesData,
|
||||
showLanguageSelector = false,
|
||||
}: UserGeneralSettingsFormProps) => {
|
||||
const t = useI18n();
|
||||
const tGeneral = useScopedI18n("management.page.user.setting.general");
|
||||
const isCredentialsUser = user.provider === "credentials";
|
||||
|
||||
const editProfileMutation = clientApi.user.editProfile.useMutation();
|
||||
const changeHomeBoardsMutation = clientApi.user.changeHomeBoards.useMutation();
|
||||
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
|
||||
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
|
||||
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
|
||||
|
||||
const initialValues = buildInitialValues(user);
|
||||
const initialValuesRef = useRef(initialValues);
|
||||
|
||||
const form = useZodForm(userGeneralSettingsSchema, {
|
||||
initialValues,
|
||||
});
|
||||
|
||||
const isDirtyRef = useRef(false);
|
||||
isDirtyRef.current = form.isDirty();
|
||||
|
||||
const weekDays = useMemo(() => dayjs.weekdays(false), []);
|
||||
|
||||
const mutations = [
|
||||
editProfileMutation,
|
||||
changeHomeBoardsMutation,
|
||||
changeSearchPreferencesMutation,
|
||||
changeFirstDayOfWeekMutation,
|
||||
changePingIconsEnabledMutation,
|
||||
];
|
||||
const isPending = mutations.some((m) => m.isPending);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (env.NODE_ENV === "development") return;
|
||||
if (isDirtyRef.current) e.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, []);
|
||||
|
||||
const handleSubmitAsync = async (values: FormValues) => {
|
||||
const parsed = userGeneralSettingsSchema.safeParse(values);
|
||||
if (!parsed.success) return;
|
||||
|
||||
const defaults = initialValuesRef.current;
|
||||
const changed = <K extends keyof FormValues>(...fields: K[]) =>
|
||||
fields.some((f) => (f === "email" ? (values[f] || null) !== (defaults[f] || null) : values[f] !== defaults[f]));
|
||||
|
||||
const saveActions: { when: boolean; action: () => Promise<unknown> }[] = [
|
||||
{
|
||||
when: isCredentialsUser && changed("name", "email"),
|
||||
action: () =>
|
||||
editProfileMutation.mutateAsync({ id: user.id, name: parsed.data.name, email: parsed.data.email ?? "" }),
|
||||
},
|
||||
{
|
||||
when: changed("homeBoardId", "mobileHomeBoardId"),
|
||||
action: () =>
|
||||
changeHomeBoardsMutation.mutateAsync({
|
||||
userId: user.id,
|
||||
homeBoardId: parsed.data.homeBoardId,
|
||||
mobileHomeBoardId: parsed.data.mobileHomeBoardId,
|
||||
}),
|
||||
},
|
||||
{
|
||||
when: changed("defaultSearchEngineId", "openInNewTab", "ddgBangsEnabled"),
|
||||
action: () =>
|
||||
changeSearchPreferencesMutation.mutateAsync({
|
||||
userId: user.id,
|
||||
defaultSearchEngineId: parsed.data.defaultSearchEngineId,
|
||||
openInNewTab: parsed.data.openInNewTab,
|
||||
ddgBangsEnabled: parsed.data.ddgBangsEnabled,
|
||||
}),
|
||||
},
|
||||
{
|
||||
when: changed("firstDayOfWeek"),
|
||||
action: () =>
|
||||
changeFirstDayOfWeekMutation.mutateAsync({ id: user.id, firstDayOfWeek: parsed.data.firstDayOfWeek }),
|
||||
},
|
||||
{
|
||||
when: changed("pingIconsEnabled"),
|
||||
action: () =>
|
||||
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }),
|
||||
},
|
||||
];
|
||||
|
||||
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
|
||||
if (promises.length === 0) return;
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
const newValues = { ...parsed.data, email: parsed.data.email ?? "" };
|
||||
initialValuesRef.current = newValues;
|
||||
form.setInitialValues(newValues);
|
||||
form.resetDirty();
|
||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: t("common.notification.update.success"),
|
||||
});
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("common.notification.update.error"),
|
||||
message: t("common.notification.update.error"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
form.setValues(initialValuesRef.current);
|
||||
form.resetDirty();
|
||||
};
|
||||
|
||||
const firstDayOfWeekInputProps = form.getInputProps("firstDayOfWeek");
|
||||
const firstDayOfWeekOnChange = firstDayOfWeekInputProps.onChange as (value: number) => void;
|
||||
const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString();
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{t("user.name")}</Title>
|
||||
{!isCredentialsUser && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("management.page.user.fieldsDisabledExternalProvider")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Divider />
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={t("user.field.username.label")}
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
disabled={!isCredentialsUser}
|
||||
label={t("user.field.email.label")}
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.board.title")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.general")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<BoardSelect
|
||||
label={tGeneral("item.board.type.mobile")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
withinPortal
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.search")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Select
|
||||
label={t("user.field.defaultSearchEngine.label")}
|
||||
w="100%"
|
||||
data={searchEnginesData}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
{...form.getInputProps("defaultSearchEngineId")}
|
||||
/>
|
||||
<Switch
|
||||
label={t("user.field.openSearchInNewTab.label")}
|
||||
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("user.field.ddgBangs.label")}
|
||||
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>{tGeneral("item.language")}</Title>
|
||||
</Stack>
|
||||
<Divider />
|
||||
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
|
||||
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||
<Radio.Group
|
||||
{...firstDayOfWeekInputProps}
|
||||
value={firstDayOfWeekValue}
|
||||
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
|
||||
>
|
||||
<Group mt="xs" wrap="wrap">
|
||||
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
|
||||
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
|
||||
))}
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
<Divider my="xs" />
|
||||
<Title order={4}>{tGeneral("item.accessibility")}</Title>
|
||||
<Switch
|
||||
label={t("user.field.pingIconsEnabled.label")}
|
||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
{form.isDirty() && (
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||
<Group>
|
||||
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack, Switch } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { userPingIconsEnabledSchema } from "@homarr/validation/user";
|
||||
|
||||
interface PingIconsEnabledProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
}
|
||||
|
||||
export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
form.setInitialValues({
|
||||
pingIconsEnabled: variables.pingIconsEnabled,
|
||||
});
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changePingIconsEnabled.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("user.action.changePingIconsEnabled.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useZodForm(userPingIconsEnabledSchema, {
|
||||
initialValues: {
|
||||
pingIconsEnabled: user.pingIconsEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate({
|
||||
id: user.id,
|
||||
...values,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Switch
|
||||
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
|
||||
label={t("user.field.pingIconsEnabled.label")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof userPingIconsEnabledSchema>;
|
||||
+5
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useDisclosure, useMediaQuery } from "@mantine/hooks";
|
||||
import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
@@ -25,6 +25,7 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
||||
},
|
||||
});
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const t = useI18n();
|
||||
const tManageAvatar = useScopedI18n("user.action.manageAvatar");
|
||||
@@ -94,11 +95,11 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
||||
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
|
||||
|
||||
return (
|
||||
<Box pos="relative">
|
||||
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow>
|
||||
<Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}>
|
||||
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow withinPortal>
|
||||
<Menu.Target>
|
||||
<UnstyledButton onClick={toggle}>
|
||||
<UserAvatar user={user} size={200} />
|
||||
<UserAvatar user={user} size={isMobile ? 120 : 200} />
|
||||
<Button
|
||||
component="div"
|
||||
pos="absolute"
|
||||
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { userEditProfileSchema } from "@homarr/validation/user";
|
||||
|
||||
interface UserProfileFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
}
|
||||
|
||||
export const UserProfileForm = ({ user }: UserProfileFormProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.editProfile.useMutation({
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync("/manage/users");
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
// Reset form initial values to reset dirty state
|
||||
form.setInitialValues({
|
||||
name: variables.name,
|
||||
email: variables.email ?? "",
|
||||
});
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: t("user.action.editProfile.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError(error) {
|
||||
const message =
|
||||
error.data?.code === "CONFLICT"
|
||||
? t("user.error.usernameTaken")
|
||||
: t("user.action.editProfile.notification.error.message");
|
||||
showErrorNotification({
|
||||
title: t("common.notification.update.error"),
|
||||
message,
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useZodForm(userEditProfileSchema.omit({ id: true }), {
|
||||
initialValues: {
|
||||
name: user.name ?? "",
|
||||
email: user.email ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
// Only credentials users can edit their profile
|
||||
const isProviderCredentials = user.provider === "credentials";
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: FormType) => {
|
||||
if (!isProviderCredentials) return;
|
||||
mutate({
|
||||
...values,
|
||||
id: user.id,
|
||||
});
|
||||
},
|
||||
[isProviderCredentials, mutate, user.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
disabled={!isProviderCredentials}
|
||||
label={t("user.field.username.label")}
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
disabled={!isProviderCredentials}
|
||||
label={t("user.field.email.label")}
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
|
||||
{isProviderCredentials && (
|
||||
<Group justify="end">
|
||||
<Button type="submit" disabled={!form.isDirty()} loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = Omit<RouterInputs["user"]["editProfile"], "id">;
|
||||
@@ -6,18 +6,13 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { canAccessUserEditPage } from "../access";
|
||||
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
||||
import { ChangeSearchPreferencesForm } from "./_components/_change-search-preferences";
|
||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
||||
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
|
||||
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||
import { UserProfileForm } from "./_components/_profile-form";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{
|
||||
@@ -63,7 +58,7 @@ export default async function EditUserPage(props: Props) {
|
||||
|
||||
const boards = await api.board.getAllBoards();
|
||||
const searchEngines = await api.searchEngine.getSelectable();
|
||||
|
||||
const isSelf = session?.user.id === user.id;
|
||||
const isCredentialsUser = user.provider === "credentials";
|
||||
|
||||
return (
|
||||
@@ -73,51 +68,25 @@ export default async function EditUserPage(props: Props) {
|
||||
{t("management.page.user.fieldsDisabledExternalProvider")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Title>{tGeneral("title")}</Title>
|
||||
<Group gap="xl">
|
||||
<Box flex={1}>
|
||||
<UserProfileForm user={user} />
|
||||
<Group gap="xl" align="flex-start" wrap="wrap">
|
||||
<Box flex={1} miw={{ base: "100%", md: 540 }}>
|
||||
<UserGeneralSettingsForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
}))}
|
||||
searchEnginesData={searchEngines}
|
||||
showLanguageSelector={isSelf}
|
||||
/>
|
||||
</Box>
|
||||
<Box w={{ base: "100%", lg: 200 }}>
|
||||
<Box w={{ base: "100%", lg: 260 }}>
|
||||
<UserProfileAvatarForm user={user} />
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{session?.user.id === user.id && (
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.language")}</Title>
|
||||
<CurrentLanguageCombobox />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.board.title")}</Title>
|
||||
<ChangeHomeBoardForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.search")}</Title>
|
||||
<ChangeSearchPreferencesForm user={user} searchEnginesData={searchEngines} />
|
||||
</Stack>
|
||||
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
|
||||
<FirstDayOfWeek user={user} />
|
||||
</Stack>
|
||||
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.accessibility")}</Title>
|
||||
<PingIconsEnabled user={user} />
|
||||
</Stack>
|
||||
|
||||
<DangerZoneRoot>
|
||||
<DangerZoneItem
|
||||
label={t("user.action.delete.label")}
|
||||
|
||||
+12
-32
@@ -1,20 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useCallback, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Stepper,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Badge, Button, Card, Group, Stack, Stepper, Table, Text, TextInput, Title, Tooltip } from "@mantine/core";
|
||||
import { useListState } from "@mantine/hooks";
|
||||
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
@@ -24,10 +11,11 @@ import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
|
||||
import type { IsValid } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
|
||||
import { userPasswordSchema } from "@homarr/validation/user";
|
||||
|
||||
@@ -149,7 +137,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
||||
color={!generalForm.isValid() ? "red" : undefined}
|
||||
>
|
||||
<form>
|
||||
<Card p="xl" shadow="md" withBorder>
|
||||
<Card p="xl">
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={tUserField("username.label")}
|
||||
@@ -165,27 +153,19 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
||||
</Stepper.Step>
|
||||
<Stepper.Step label={t("step.security.label")} allowStepSelect={false} allowStepClick={false}>
|
||||
<form>
|
||||
<Card p="xl" shadow="md" withBorder>
|
||||
<Card p="xl">
|
||||
<Stack gap="md">
|
||||
<CustomPasswordInput
|
||||
withPasswordRequirements
|
||||
label={tUserField("password.label")}
|
||||
<UserCreatePasswordFields
|
||||
variant="filled"
|
||||
withAsterisk
|
||||
{...securityForm.getInputProps("password")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={tUserField("passwordConfirm.label")}
|
||||
variant="filled"
|
||||
withAsterisk
|
||||
{...securityForm.getInputProps("confirmPassword")}
|
||||
passwordInputProps={securityForm.getInputProps("password")}
|
||||
confirmPasswordInputProps={securityForm.getInputProps("confirmPassword")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</form>
|
||||
</Stepper.Step>
|
||||
<Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
|
||||
<Card p="xl" shadow="md" withBorder>
|
||||
<Card p="xl">
|
||||
<GroupsForm
|
||||
initialGroups={initialGroups}
|
||||
addGroup={(groupId) =>
|
||||
@@ -198,20 +178,20 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
||||
</Card>
|
||||
</Stepper.Step>
|
||||
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
|
||||
<Card p="xl" shadow="md" withBorder>
|
||||
<Card p="xl">
|
||||
<Stack maw={300} align="center" mx="auto">
|
||||
<UserAvatar
|
||||
size="xl"
|
||||
user={{ name: generalForm.values.username, email: generalForm.values.email ?? null, image: null }}
|
||||
/>
|
||||
<Text tt="uppercase" fw="bolder" size="xl">
|
||||
<Text fw={700} size="xl">
|
||||
{generalForm.values.username}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stepper.Step>
|
||||
<Stepper.Completed>
|
||||
<Card p="xl" shadow="md" withBorder>
|
||||
<Card p="xl">
|
||||
<Stack align="center" maw={300} mx="auto">
|
||||
<IconUserCheck size="3rem" />
|
||||
<Title order={2}>{t("step.completed.title")}</Title>
|
||||
|
||||
@@ -25,7 +25,7 @@ export const StepperNavigationComponent = ({
|
||||
}: StepperNavigationComponentProps) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Card shadow="md" withBorder>
|
||||
<Card>
|
||||
{!isComplete ? (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Button
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ export const SaveAffix = ({ groupId }: SaveAffixProps) => {
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
<Transition transition="slide-up" mounted={form.isDirty()}>
|
||||
{(transitionStyles) => (
|
||||
<Card style={transitionStyles} withBorder>
|
||||
<Card style={transitionStyles}>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>{tForm("unsavedChanges")}</Text>
|
||||
<Group>
|
||||
|
||||
@@ -68,7 +68,6 @@ const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
|
||||
}}
|
||||
|
||||
@@ -259,7 +259,7 @@ const SaveAffix = ({ visible, isPending, onDiscard, onSave }: SaveAffixProps) =>
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
<Transition transition="slide-up" mounted={visible}>
|
||||
{(transitionStyles) => (
|
||||
<Card style={transitionStyles} withBorder>
|
||||
<Card style={transitionStyles}>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||
<Group>
|
||||
|
||||
@@ -99,7 +99,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card withBorder w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
||||
<Card w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
@@ -123,17 +123,35 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
</QueryErrorResetBoundary>
|
||||
</Card>
|
||||
<Affix bottom={12} right={72}>
|
||||
<ActionIcon size={48} variant="default" radius="xl" onClick={handleOpenEditWidgetModal}>
|
||||
<ActionIcon
|
||||
size={48}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
onClick={handleOpenEditWidgetModal}
|
||||
aria-label={t("common.action.edit")}
|
||||
>
|
||||
<IconPencil size={24} />
|
||||
</ActionIcon>
|
||||
</Affix>
|
||||
<Affix bottom={12} right={72 + 60}>
|
||||
<ActionIcon size={48} variant="default" radius="xl" onClick={toggleEditMode}>
|
||||
<ActionIcon
|
||||
size={48}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
onClick={toggleEditMode}
|
||||
aria-label={editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled")}
|
||||
>
|
||||
{editMode ? <IconToggleLeft size={24} /> : <IconToggleRight size={24} />}
|
||||
</ActionIcon>
|
||||
</Affix>
|
||||
<Affix bottom={12} right={72 + 120}>
|
||||
<ActionIcon size={48} variant="default" radius="xl" onClick={openDimensionsModal}>
|
||||
<ActionIcon
|
||||
size={48}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
onClick={openDimensionsModal}
|
||||
aria-label={t("widgetPreview.dimensions.title")}
|
||||
>
|
||||
<IconDimensions size={24} />
|
||||
</ActionIcon>
|
||||
</Affix>
|
||||
|
||||
@@ -19,6 +19,10 @@ import { BoardItemMenu } from "./item-menu";
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
fallbackDimensions?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
||||
@@ -27,9 +31,11 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
export const BoardItemContent = ({ item, fallbackDimensions }: BoardItemContentProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const board = useRequiredBoard();
|
||||
const fallbackWidth = fallbackDimensions?.width ?? 0;
|
||||
const fallbackHeight = fallbackDimensions?.height ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -42,7 +48,6 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
@@ -53,7 +58,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
<InnerContent item={item} width={width} height={height} />
|
||||
<InnerContent item={item} width={width || fallbackWidth} height={height || fallbackHeight} />
|
||||
</Card>
|
||||
{item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
|
||||
@@ -12,7 +12,7 @@ import { widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useOptionalSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { ItemMoveModal } from "./item-move-modal";
|
||||
|
||||
@@ -41,7 +41,8 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const sectionContext = useOptionalSectionContext();
|
||||
const gridstack = sectionContext?.refs.gridstack;
|
||||
const settings = useSettings();
|
||||
|
||||
// Reset error boundary on next render if item has been edited
|
||||
@@ -105,9 +106,17 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={offset} right={offset} style={{ zIndex: 10 }}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={"xl"}
|
||||
pos="absolute"
|
||||
top={offset}
|
||||
right={offset}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
@@ -119,7 +128,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
<Menu.Item
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onClick={() => {
|
||||
if (!gridstack.current) return;
|
||||
if (!gridstack?.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -145,7 +145,7 @@ const WidgetItem = ({
|
||||
</Text>
|
||||
</Stack>
|
||||
<SupportedIntegrations mt="auto" integrations={item.supportedIntegrations} />
|
||||
<Button onClick={onSelect} variant="light" size="xs" radius="md" fullWidth>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
|
||||
{t(`item.create.addToBoard`)}
|
||||
</Button>
|
||||
</Stack>
|
||||
@@ -170,7 +170,7 @@ const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps)
|
||||
|
||||
return (
|
||||
<Center mt={mt}>
|
||||
<Tooltip.Group openDelay={300} closeDelay={100}>
|
||||
<Tooltip.Group closeDelay={100}>
|
||||
<Group gap={2}>
|
||||
{integrations.slice(0, countToShow).map((integration) => (
|
||||
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
|
||||
|
||||
@@ -1,57 +1,24 @@
|
||||
import { Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { CategoryMenu } from "./category/category-menu";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
const localStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`;
|
||||
import { useCategoryCollapse } from "./use-category-collapse";
|
||||
|
||||
interface Props {
|
||||
section: CategorySection;
|
||||
}
|
||||
|
||||
export const BoardCategorySection = ({ section }: Props) => {
|
||||
const { status } = useSession();
|
||||
const { mutate } = clientApi.section.changeCollapsed.useMutation();
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const initialCollapsed =
|
||||
typeof window !== "undefined"
|
||||
? (localStorage.getItem(localStorageKey(section.id)) ?? String(section.collapsed)) === "true"
|
||||
: section.collapsed;
|
||||
|
||||
const [opened, { toggle }] = useDisclosure(initialCollapsed, {
|
||||
onOpen() {
|
||||
if (status === "authenticated") {
|
||||
mutate({ sectionId: section.id, collapsed: true });
|
||||
} else if (status === "unauthenticated") {
|
||||
localStorage.setItem(localStorageKey(section.id), "true");
|
||||
}
|
||||
},
|
||||
onClose() {
|
||||
if (status === "authenticated") {
|
||||
mutate({ sectionId: section.id, collapsed: false });
|
||||
} else if (status === "unauthenticated") {
|
||||
localStorage.setItem(localStorageKey(section.id), "false");
|
||||
}
|
||||
},
|
||||
});
|
||||
const [opened, { toggle }] = useCategoryCollapse(section);
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ "--opacity": board.opacity / 100 }}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
p={0}
|
||||
className={classes.itemCard}
|
||||
>
|
||||
<Card style={{ "--opacity": board.opacity / 100 }} radius={board.itemRadius} p={0} className={classes.itemCard}>
|
||||
<Stack>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<UnstyledButton w="100%" p="sm" onClick={toggle}>
|
||||
|
||||
@@ -30,9 +30,9 @@ export const CategoryMenu = ({ category }: Props) => {
|
||||
const t = useScopedI18n("section.category");
|
||||
|
||||
return (
|
||||
<Menu withArrow>
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon mr="sm" variant="transparent">
|
||||
<ActionIcon mr="sm" variant="transparent" aria-label={t("menu.label.settings")}>
|
||||
<IconDotsVertical size={20} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
@@ -28,7 +28,6 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
||||
className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))}
|
||||
w="100%"
|
||||
h="100%"
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
overflow: "visible",
|
||||
|
||||
@@ -43,9 +43,17 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={4} right={4} style={{ zIndex: 10 }}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={"xl"}
|
||||
pos="absolute"
|
||||
top={4}
|
||||
right={4}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
@@ -20,4 +20,8 @@ export const useSectionContext = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useOptionalSectionContext = () => {
|
||||
return useContext(SectionContext);
|
||||
};
|
||||
|
||||
export const SectionProvider = SectionContext.Provider;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { BoardMockBuilder } from "../items/actions/test/mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "../items/actions/test/mocks/dynamic-section-mock";
|
||||
import { ItemMockBuilder } from "../items/actions/test/mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "../items/actions/test/mocks/layout-mock";
|
||||
import {
|
||||
getEstimatedGridWidth,
|
||||
getFallbackDimensions,
|
||||
getFullWidthSortedSections,
|
||||
getStaticGridElements,
|
||||
getStaticGridLayouts,
|
||||
getStaticGridRowCount,
|
||||
getStaticLayoutSelectionCss,
|
||||
} from "./static-grid-layout";
|
||||
|
||||
describe("static grid layout", () => {
|
||||
test("sorts layouts and creates breakpoint CSS in mobile-first order", () => {
|
||||
const mobile = new LayoutMockBuilder({ id: "mobile", breakpoint: 0, columnCount: 4 }).build();
|
||||
const tablet = new LayoutMockBuilder({ id: "tablet", breakpoint: 768, columnCount: 8 }).build();
|
||||
const desktop = new LayoutMockBuilder({ id: "desktop", breakpoint: 1024, columnCount: 12 }).build();
|
||||
|
||||
const sortedLayouts = getStaticGridLayouts([desktop, mobile, tablet]);
|
||||
const css = getStaticLayoutSelectionCss("board-1", sortedLayouts);
|
||||
|
||||
expect(sortedLayouts.map((layout) => layout.id)).toEqual(["mobile", "tablet", "desktop"]);
|
||||
expect(css).toContain('[data-static-layout="mobile"] { display: block; }');
|
||||
expect(css).toContain("@media (min-width: 768px)");
|
||||
expect(css).toContain('[data-static-layout="tablet"] { display: block; }');
|
||||
expect(css).toContain("@media (min-width: 1024px)");
|
||||
expect(css).toContain('[data-static-layout="desktop"] { display: block; }');
|
||||
});
|
||||
|
||||
test("selects and sorts layout-specific section elements", () => {
|
||||
const rootSectionId = "root";
|
||||
const mobileLayoutId = "mobile";
|
||||
const desktopLayoutId = "desktop";
|
||||
const mobile = new LayoutMockBuilder({ id: mobileLayoutId, breakpoint: 0, columnCount: 4 }).build();
|
||||
const desktop = new LayoutMockBuilder({ id: desktopLayoutId, breakpoint: 1024, columnCount: 12 }).build();
|
||||
const item = new ItemMockBuilder({ id: "item" })
|
||||
.addLayout({ layoutId: mobileLayoutId, sectionId: rootSectionId, xOffset: 2, yOffset: 1, width: 1, height: 1 })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSectionId, xOffset: 0, yOffset: 1, width: 4, height: 2 })
|
||||
.build();
|
||||
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
|
||||
.addLayout({
|
||||
layoutId: mobileLayoutId,
|
||||
parentSectionId: rootSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 2,
|
||||
})
|
||||
.addLayout({
|
||||
layoutId: desktopLayoutId,
|
||||
parentSectionId: rootSectionId,
|
||||
xOffset: 4,
|
||||
yOffset: 1,
|
||||
width: 4,
|
||||
height: 2,
|
||||
})
|
||||
.build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addEmptySection({ id: rootSectionId, yOffset: 2 })
|
||||
.addEmptySection({ id: "first", yOffset: 0 })
|
||||
.addSection(dynamicSection)
|
||||
.addItem(item)
|
||||
.build();
|
||||
board.layouts = [desktop, mobile];
|
||||
|
||||
const mobileElements = getStaticGridElements(board, rootSectionId, mobileLayoutId);
|
||||
const desktopElements = getStaticGridElements(board, rootSectionId, desktopLayoutId);
|
||||
const fullWidthSections = getFullWidthSortedSections(board);
|
||||
|
||||
expect(mobileElements.map((element) => element.id)).toEqual(["dynamic", "item"]);
|
||||
expect(desktopElements.map((element) => ({ id: element.id, width: element.width }))).toEqual([
|
||||
{ id: "item", width: 4 },
|
||||
{ id: "dynamic", width: 4 },
|
||||
]);
|
||||
expect(getStaticGridRowCount(mobileElements)).toBe(2);
|
||||
expect(fullWidthSections.map((section) => section.id)).toEqual(["first", rootSectionId]);
|
||||
});
|
||||
|
||||
test("estimates grid and item dimensions per layout range", () => {
|
||||
const mobile = new LayoutMockBuilder({ id: "mobile", breakpoint: 0, columnCount: 4 }).build();
|
||||
const tablet = new LayoutMockBuilder({ id: "tablet", breakpoint: 768, columnCount: 8 }).build();
|
||||
const desktop = new LayoutMockBuilder({ id: "desktop", breakpoint: 1024, columnCount: 12 }).build();
|
||||
|
||||
expect(getEstimatedGridWidth(mobile, tablet)).toBe(384);
|
||||
expect(getEstimatedGridWidth(tablet, desktop)).toBe(896);
|
||||
expect(getEstimatedGridWidth(desktop)).toBe(1280);
|
||||
expect(getFallbackDimensions({ width: 2, height: 3 }, 400, 4)).toEqual({ width: 180, height: 280 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Board, DynamicSectionItem, EmptySection, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
type BoardLayout = Board["layouts"][number];
|
||||
type StaticGridElement = SectionItem | DynamicSectionItem;
|
||||
|
||||
const STATIC_GRID_ITEM_INSET = 20;
|
||||
const DEFAULT_SMALL_VIEWPORT_WIDTH = 360;
|
||||
const DEFAULT_LARGE_VIEWPORT_WIDTH = 1280;
|
||||
|
||||
export const getStaticGridLayouts = (layouts: Board["layouts"]) =>
|
||||
[...layouts].toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint);
|
||||
|
||||
const escapeCssString = (value: string) => value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
||||
|
||||
export const getStaticLayoutSelectionCss = (boardId: string, layouts: Board["layouts"]) => {
|
||||
const sortedLayouts = getStaticGridLayouts(layouts);
|
||||
const initialLayout = sortedLayouts.at(0);
|
||||
|
||||
if (!initialLayout) return "";
|
||||
|
||||
const scope = `[data-static-board="${escapeCssString(boardId)}"]`;
|
||||
const css = [
|
||||
`${scope} .static-board-layout { display: none; }`,
|
||||
`${scope} .static-board-layout[data-static-layout="${escapeCssString(initialLayout.id)}"] { display: block; }`,
|
||||
];
|
||||
|
||||
for (const layout of sortedLayouts.slice(1)) {
|
||||
css.push(
|
||||
`@media (min-width: ${layout.breakpoint}px) { ${scope} .static-board-layout { display: none; } ${scope} .static-board-layout[data-static-layout="${escapeCssString(layout.id)}"] { display: block; } }`,
|
||||
);
|
||||
}
|
||||
|
||||
return css.join("\n");
|
||||
};
|
||||
|
||||
export const getFullWidthSortedSections = (board: Board) =>
|
||||
board.sections
|
||||
.filter(
|
||||
(section): section is EmptySection | Extract<Board["sections"][number], { kind: "category" }> =>
|
||||
section.kind === "empty" || section.kind === "category",
|
||||
)
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
|
||||
export const getSectionItemsForLayout = (board: Board, sectionId: string, layoutId: string): SectionItem[] => {
|
||||
return board.items
|
||||
.map(({ layouts, ...item }) => {
|
||||
const layout = layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) return null;
|
||||
return { ...layout, ...item, type: "item" as const };
|
||||
})
|
||||
.filter((item): item is SectionItem => item !== null && item.sectionId === sectionId);
|
||||
};
|
||||
|
||||
export const getDynamicSectionsForLayout = (
|
||||
board: Board,
|
||||
sectionId: string,
|
||||
layoutId: string,
|
||||
): DynamicSectionItem[] => {
|
||||
return board.sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.map(({ layouts, ...section }) => {
|
||||
const layout = layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) return null;
|
||||
return { ...layout, ...section, type: "section" as const };
|
||||
})
|
||||
.filter((entry): entry is DynamicSectionItem => entry !== null && entry.parentSectionId === sectionId);
|
||||
};
|
||||
|
||||
export const getStaticGridElements = (board: Board, sectionId: string, layoutId: string): StaticGridElement[] => {
|
||||
return [
|
||||
...getSectionItemsForLayout(board, sectionId, layoutId),
|
||||
...getDynamicSectionsForLayout(board, sectionId, layoutId),
|
||||
].toSorted((itemA, itemB) => {
|
||||
if (itemA.yOffset === itemB.yOffset) return itemA.xOffset - itemB.xOffset;
|
||||
return itemA.yOffset - itemB.yOffset;
|
||||
});
|
||||
};
|
||||
|
||||
export const getStaticGridRowCount = (items: StaticGridElement[], fixedRowCount?: number) => {
|
||||
return fixedRowCount ?? Math.max(...items.map((item) => item.yOffset + item.height));
|
||||
};
|
||||
|
||||
export const getEstimatedGridWidth = (layout: BoardLayout, nextLayout?: BoardLayout) => {
|
||||
if (nextLayout) {
|
||||
const upperBound = Math.max(nextLayout.breakpoint - 1, DEFAULT_SMALL_VIEWPORT_WIDTH);
|
||||
return Math.max(DEFAULT_SMALL_VIEWPORT_WIDTH, Math.round((layout.breakpoint + upperBound) / 2));
|
||||
}
|
||||
|
||||
return Math.max(layout.breakpoint, DEFAULT_LARGE_VIEWPORT_WIDTH);
|
||||
};
|
||||
|
||||
export const getFallbackDimensions = (
|
||||
item: Pick<StaticGridElement, "width" | "height">,
|
||||
estimatedGridWidth: number,
|
||||
columnCount: number,
|
||||
) => {
|
||||
const cellSize = estimatedGridWidth / columnCount;
|
||||
|
||||
return {
|
||||
width: Math.max(0, item.width * cellSize - STATIC_GRID_ITEM_INSET),
|
||||
height: Math.max(0, item.height * cellSize - STATIC_GRID_ITEM_INSET),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type {
|
||||
Board,
|
||||
CategorySection,
|
||||
DynamicSectionItem,
|
||||
EmptySection,
|
||||
SectionItem,
|
||||
} from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { CategoryMenu } from "./category/category-menu";
|
||||
import classes from "./item.module.css";
|
||||
import {
|
||||
getEstimatedGridWidth,
|
||||
getFallbackDimensions,
|
||||
getFullWidthSortedSections,
|
||||
getStaticGridElements,
|
||||
getStaticGridLayouts,
|
||||
getStaticGridRowCount,
|
||||
getStaticLayoutSelectionCss,
|
||||
} from "./static-grid-layout";
|
||||
import { useCategoryCollapse } from "./use-category-collapse";
|
||||
|
||||
interface StaticBoardGridProps {
|
||||
board: Board;
|
||||
}
|
||||
|
||||
export const StaticBoardGrid = ({ board }: StaticBoardGridProps) => {
|
||||
const boardId = `board-${board.id}`;
|
||||
const layouts = getStaticGridLayouts(board.layouts);
|
||||
const layoutSelectionCss = getStaticLayoutSelectionCss(boardId, layouts);
|
||||
const fullWidthSortedSections = getFullWidthSortedSections(board);
|
||||
|
||||
if (layouts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{layoutSelectionCss}</style>
|
||||
<div data-static-board={boardId}>
|
||||
{layouts.map((layout, index) => (
|
||||
<div key={layout.id} className="static-board-layout" data-static-layout={layout.id}>
|
||||
<Stack h="100%">
|
||||
{fullWidthSortedSections.map((section) =>
|
||||
section.kind === "empty" ? (
|
||||
<StaticEmptySection
|
||||
key={section.id}
|
||||
section={section}
|
||||
board={board}
|
||||
layoutId={layout.id}
|
||||
columnCount={layout.columnCount}
|
||||
estimatedGridWidth={getEstimatedGridWidth(layout, layouts.at(index + 1))}
|
||||
/>
|
||||
) : (
|
||||
<StaticCategorySection
|
||||
key={section.id}
|
||||
section={section}
|
||||
board={board}
|
||||
layoutId={layout.id}
|
||||
columnCount={layout.columnCount}
|
||||
estimatedGridWidth={getEstimatedGridWidth(layout, layouts.at(index + 1))}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface StaticSectionGridProps {
|
||||
sectionId: string;
|
||||
board: Board;
|
||||
layoutId: string;
|
||||
columnCount: number;
|
||||
estimatedGridWidth: number;
|
||||
isDynamic?: boolean;
|
||||
fixedRowCount?: number;
|
||||
}
|
||||
|
||||
const StaticSectionGrid = ({
|
||||
sectionId,
|
||||
board,
|
||||
layoutId,
|
||||
columnCount,
|
||||
estimatedGridWidth,
|
||||
isDynamic,
|
||||
fixedRowCount,
|
||||
}: StaticSectionGridProps) => {
|
||||
const allItems = getStaticGridElements(board, sectionId, layoutId);
|
||||
|
||||
if (allItems.length === 0) return null;
|
||||
|
||||
const rowCount = getStaticGridRowCount(allItems, fixedRowCount);
|
||||
|
||||
return (
|
||||
<div className="static-grid-container" style={{ height: isDynamic ? "100%" : undefined }}>
|
||||
<div
|
||||
className="static-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
|
||||
gridTemplateRows: isDynamic
|
||||
? `repeat(${rowCount}, 1fr)`
|
||||
: `repeat(${rowCount}, calc(100cqi / ${columnCount}))`,
|
||||
height: isDynamic ? "100%" : undefined,
|
||||
}}
|
||||
>
|
||||
{allItems.map((item) => (
|
||||
<StaticGridItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
board={board}
|
||||
layoutId={layoutId}
|
||||
estimatedGridWidth={estimatedGridWidth}
|
||||
columnCount={columnCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StaticGridItemProps {
|
||||
item: SectionItem | DynamicSectionItem;
|
||||
board: Board;
|
||||
layoutId: string;
|
||||
estimatedGridWidth: number;
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
const StaticGridItem = ({ item, board, layoutId, estimatedGridWidth, columnCount }: StaticGridItemProps) => {
|
||||
const fallbackDimensions = getFallbackDimensions(item, estimatedGridWidth, columnCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
gridColumn: `${item.xOffset + 1} / span ${item.width}`,
|
||||
gridRow: `${item.yOffset + 1} / span ${item.height}`,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div className="static-grid-item-content">
|
||||
{item.type === "item" ? (
|
||||
<BoardItemContent item={item} fallbackDimensions={fallbackDimensions} />
|
||||
) : (
|
||||
<StaticDynamicSection
|
||||
section={item}
|
||||
board={board}
|
||||
layoutId={layoutId}
|
||||
estimatedGridWidth={fallbackDimensions.width}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StaticEmptySectionProps {
|
||||
section: EmptySection;
|
||||
board: Board;
|
||||
layoutId: string;
|
||||
columnCount: number;
|
||||
estimatedGridWidth: number;
|
||||
}
|
||||
|
||||
const StaticEmptySection = ({ section, board, layoutId, columnCount, estimatedGridWidth }: StaticEmptySectionProps) => {
|
||||
const items = getStaticGridElements(board, section.id, layoutId);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<StaticSectionGrid
|
||||
sectionId={section.id}
|
||||
board={board}
|
||||
layoutId={layoutId}
|
||||
columnCount={columnCount}
|
||||
estimatedGridWidth={estimatedGridWidth}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface StaticCategorySectionProps {
|
||||
section: CategorySection;
|
||||
board: Board;
|
||||
layoutId: string;
|
||||
columnCount: number;
|
||||
estimatedGridWidth: number;
|
||||
}
|
||||
|
||||
const StaticCategorySection = ({
|
||||
section,
|
||||
board,
|
||||
layoutId,
|
||||
columnCount,
|
||||
estimatedGridWidth,
|
||||
}: StaticCategorySectionProps) => {
|
||||
const boardData = useRequiredBoard();
|
||||
const [opened, { toggle }] = useCategoryCollapse(section);
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ "--opacity": boardData.opacity / 100 }}
|
||||
radius={boardData.itemRadius}
|
||||
p={0}
|
||||
className={classes.itemCard}
|
||||
>
|
||||
<Stack>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<UnstyledButton w="100%" p="sm" onClick={toggle}>
|
||||
<Group wrap="nowrap">
|
||||
{opened ? <IconChevronUp size={20} /> : <IconChevronDown size={20} />}
|
||||
<Title order={3}>{section.name}</Title>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<CategoryMenu category={section} />
|
||||
</Group>
|
||||
<Collapse expanded={opened} p="sm" pt={0}>
|
||||
<StaticSectionGrid
|
||||
sectionId={section.id}
|
||||
board={board}
|
||||
layoutId={layoutId}
|
||||
columnCount={columnCount}
|
||||
estimatedGridWidth={estimatedGridWidth}
|
||||
/>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface StaticDynamicSectionProps {
|
||||
section: DynamicSectionItem;
|
||||
board: Board;
|
||||
layoutId: string;
|
||||
estimatedGridWidth: number;
|
||||
}
|
||||
|
||||
const StaticDynamicSection = ({ section, board, layoutId, estimatedGridWidth }: StaticDynamicSectionProps) => {
|
||||
const boardData = useRequiredBoard();
|
||||
const options = section.options;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={classes.itemCard}
|
||||
w="100%"
|
||||
h="100%"
|
||||
styles={{
|
||||
root: {
|
||||
overflow: "visible",
|
||||
"--opacity": boardData.opacity / 100,
|
||||
"--border-color": options.borderColor || undefined,
|
||||
},
|
||||
}}
|
||||
radius={boardData.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
{options.title && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
top={-15}
|
||||
left={10}
|
||||
size="md"
|
||||
radius={boardData.itemRadius}
|
||||
color="var(--background-color)"
|
||||
c="var(--mantine-color-text)"
|
||||
bd="1px solid var(--border-color)"
|
||||
>
|
||||
{options.title}
|
||||
</Badge>
|
||||
)}
|
||||
<StaticSectionGrid
|
||||
sectionId={section.id}
|
||||
board={board}
|
||||
layoutId={layoutId}
|
||||
columnCount={section.width}
|
||||
estimatedGridWidth={estimatedGridWidth}
|
||||
isDynamic
|
||||
fixedRowCount={section.height}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const getCategoryCollapseStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`;
|
||||
|
||||
const categoryCollapseChangeEventName = "homarr-section-collapse-change";
|
||||
|
||||
interface CategoryExpandedChangeEventDetail {
|
||||
sectionId: string;
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
const getInitialExpanded = (section: CategorySection) => {
|
||||
if (typeof window === "undefined") return section.collapsed;
|
||||
|
||||
return (localStorage.getItem(getCategoryCollapseStorageKey(section.id)) ?? String(section.collapsed)) === "true";
|
||||
};
|
||||
|
||||
export const useCategoryCollapse = (section: CategorySection) => {
|
||||
const { status } = useSession();
|
||||
const { mutate } = clientApi.section.changeCollapsed.useMutation();
|
||||
const [opened, setOpened] = useState(() => getInitialExpanded(section));
|
||||
|
||||
const persistExpanded = useCallback(
|
||||
(expanded: boolean) => {
|
||||
if (status === "authenticated") {
|
||||
mutate({ sectionId: section.id, collapsed: expanded });
|
||||
} else if (status === "unauthenticated") {
|
||||
localStorage.setItem(getCategoryCollapseStorageKey(section.id), String(expanded));
|
||||
}
|
||||
},
|
||||
[mutate, section.id, status],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCollapseChange = (event: Event) => {
|
||||
const { sectionId, expanded } = (event as CustomEvent<CategoryExpandedChangeEventDetail>).detail;
|
||||
if (sectionId === section.id) {
|
||||
setOpened(expanded);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(categoryCollapseChangeEventName, handleCollapseChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(categoryCollapseChangeEventName, handleCollapseChange);
|
||||
};
|
||||
}, [section.id]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setOpened((current) => {
|
||||
const expanded = !current;
|
||||
persistExpanded(expanded);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<CategoryExpandedChangeEventDetail>(categoryCollapseChangeEventName, {
|
||||
detail: {
|
||||
sectionId: section.id,
|
||||
expanded,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return expanded;
|
||||
});
|
||||
}, [persistExpanded, section.id]);
|
||||
|
||||
return [opened, { toggle }] as const;
|
||||
};
|
||||
@@ -6,11 +6,20 @@ import { LanguageCombobox } from "./language-combobox";
|
||||
|
||||
interface CurrentLanguageComboboxProps {
|
||||
width?: string;
|
||||
withinPortal?: boolean;
|
||||
}
|
||||
|
||||
export const CurrentLanguageCombobox = ({ width }: CurrentLanguageComboboxProps) => {
|
||||
export const CurrentLanguageCombobox = ({ width, withinPortal }: CurrentLanguageComboboxProps) => {
|
||||
const currentLocale = useCurrentLocale();
|
||||
const { changeLocale, isPending } = useChangeLocale();
|
||||
|
||||
return <LanguageCombobox value={currentLocale} onChange={changeLocale} isPending={isPending} width={width} />;
|
||||
return (
|
||||
<LanguageCombobox
|
||||
value={currentLocale}
|
||||
onChange={changeLocale}
|
||||
isPending={isPending}
|
||||
width={width}
|
||||
withinPortal={withinPortal}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -16,9 +16,17 @@ interface LanguageComboboxProps {
|
||||
onChange: (value: SupportedLanguage) => void;
|
||||
isPending?: boolean;
|
||||
width?: string;
|
||||
withinPortal?: boolean;
|
||||
}
|
||||
|
||||
export const LanguageCombobox = ({ label, value, onChange, isPending, width }: LanguageComboboxProps) => {
|
||||
export const LanguageCombobox = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
isPending,
|
||||
width,
|
||||
withinPortal = false,
|
||||
}: LanguageComboboxProps) => {
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
@@ -39,7 +47,7 @@ export const LanguageCombobox = ({ label, value, onChange, isPending, width }: L
|
||||
}, [combobox]);
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} onOptionSubmit={handleOnOptionSubmit}>
|
||||
<Combobox store={combobox} withinPortal={withinPortal} onOptionSubmit={handleOnOptionSubmit}>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
component="button"
|
||||
|
||||
@@ -12,16 +12,7 @@ export const CommonNavLink = (props: ClientNavigationLink) =>
|
||||
const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
const pathname = usePathname();
|
||||
return props.external ? (
|
||||
<NavLink
|
||||
component="a"
|
||||
label={props.label}
|
||||
leftSection={props.icon}
|
||||
href={props.href}
|
||||
style={{
|
||||
borderRadius: 5,
|
||||
}}
|
||||
target="_blank"
|
||||
/>
|
||||
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
|
||||
) : (
|
||||
<NavLink
|
||||
component={Link}
|
||||
@@ -29,9 +20,6 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
leftSection={props.icon}
|
||||
href={props.href}
|
||||
active={pathname === props.href}
|
||||
style={{
|
||||
borderRadius: 5,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -40,14 +28,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||
const pathname = usePathname();
|
||||
const isActive = props.items.some((item) => item.href === pathname);
|
||||
return (
|
||||
<NavLink
|
||||
style={{
|
||||
borderRadius: 5,
|
||||
}}
|
||||
label={props.label}
|
||||
leftSection={props.icon}
|
||||
defaultOpened={isActive}
|
||||
>
|
||||
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
||||
{props.items.map((item) => (
|
||||
<NavLinkHref key={item.label} {...item} />
|
||||
))}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => {
|
||||
<Title c="red.8" order={2}>
|
||||
{t("common.dangerZone")}
|
||||
</Title>
|
||||
<Card withBorder style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}>
|
||||
<Card style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}>
|
||||
<Stack gap="sm">
|
||||
{Array.isArray(children)
|
||||
? children.map((child, index) => (
|
||||
|
||||
@@ -14,7 +14,7 @@ interface NoResultsProps {
|
||||
|
||||
export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => {
|
||||
return (
|
||||
<Card withBorder bg="transparent">
|
||||
<Card bg="transparent" withBorder={false}>
|
||||
<Stack align="center" gap="sm">
|
||||
<Icon size="2rem" />
|
||||
<Text fw={500} size="lg">
|
||||
|
||||
@@ -52,7 +52,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
|
||||
return (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withArrow withinPortal keepMounted>
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
|
||||
@@ -112,3 +112,17 @@
|
||||
.grid-stack-empty-wrapper {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.static-grid-container {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.static-grid-item-content {
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
|
||||
> .grid-stack-item-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=10.33.4"
|
||||
"pnpm": ">=10.34.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"packageManager": "pnpm@10.34.1",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3"
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { chromium } from "@playwright/test";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import SuperJSON from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import * as sqliteSchema from "../packages/db/schema/sqlite";
|
||||
import { hashPasswordAsync } from "../packages/auth/security";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import type { SqliteDatabase } from "./shared/e2e-db";
|
||||
|
||||
const boardName = "responsive-board";
|
||||
const credentials = {
|
||||
username: "responsive-admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
};
|
||||
const viewports = [
|
||||
{ width: 320, height: 568 },
|
||||
{ width: 375, height: 667 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 1024, height: 768 },
|
||||
{ width: 1366, height: 768 },
|
||||
{ width: 1920, height: 1080 },
|
||||
];
|
||||
|
||||
describe("Board responsive layout", () => {
|
||||
test("renders static board layouts across common viewports and loads edit mode", async () => {
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const { categorySectionId } = await seedResponsiveBoardAsync(db);
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
const browser = await chromium.launch();
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
|
||||
try {
|
||||
for (const viewport of viewports) {
|
||||
const context = await browser.newContext({ viewport });
|
||||
await context.addInitScript(() => {
|
||||
window.__homarrLayoutShiftScore = 0;
|
||||
new PerformanceObserver((entries) => {
|
||||
for (const entry of entries.getEntries()) {
|
||||
const layoutShift = entry as PerformanceEntry & { value: number; hadRecentInput: boolean };
|
||||
if (!layoutShift.hadRecentInput) {
|
||||
window.__homarrLayoutShiftScore += layoutShift.value;
|
||||
}
|
||||
}
|
||||
}).observe({ type: "layout-shift", buffered: true });
|
||||
});
|
||||
await context.addInitScript(
|
||||
({ sectionId }) => {
|
||||
localStorage.setItem(`homarr-section-collapsed-${sectionId}`, "true");
|
||||
},
|
||||
{ sectionId: categorySectionId },
|
||||
);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`${baseUrl}/boards/${boardName}`);
|
||||
await page.waitForSelector(".static-grid-item-content", { state: "visible" });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const visibleLayout = Array.from(document.querySelectorAll<HTMLElement>(".static-board-layout")).find(
|
||||
(element) => getComputedStyle(element).display !== "none",
|
||||
);
|
||||
const grids = Array.from(visibleLayout?.querySelectorAll<HTMLElement>(".static-grid") ?? []);
|
||||
const hasOverlappingItems = grids.some((grid) => {
|
||||
const rects = Array.from(grid.children)
|
||||
.map((element) => element.getBoundingClientRect())
|
||||
.filter((rect) => rect.width > 0 && rect.height > 0);
|
||||
|
||||
return rects.some((rect, index) =>
|
||||
rects.slice(index + 1).some((otherRect) => {
|
||||
const horizontalOverlap = Math.min(rect.right, otherRect.right) - Math.max(rect.left, otherRect.left);
|
||||
const verticalOverlap = Math.min(rect.bottom, otherRect.bottom) - Math.max(rect.top, otherRect.top);
|
||||
return horizontalOverlap > 1 && verticalOverlap > 1;
|
||||
}),
|
||||
);
|
||||
});
|
||||
const visibleItems = Array.from(
|
||||
visibleLayout?.querySelectorAll<HTMLElement>(".static-grid-item-content") ?? [],
|
||||
).filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
|
||||
return {
|
||||
hasVisibleLayout: Boolean(visibleLayout),
|
||||
visibleItemCount: visibleItems.length,
|
||||
hasHorizontalOverflow: document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||
hasOverlappingItems,
|
||||
layoutShiftScore: window.__homarrLayoutShiftScore,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.hasVisibleLayout).toBe(true);
|
||||
expect(result.visibleItemCount).toBe(4);
|
||||
expect(result.hasHorizontalOverflow).toBe(false);
|
||||
expect(result.hasOverlappingItems).toBe(false);
|
||||
expect(result.layoutShiftScore).toBeLessThanOrEqual(0.01);
|
||||
|
||||
await context.close();
|
||||
}
|
||||
|
||||
const context = await browser.newContext({ viewport: { width: 1366, height: 768 } });
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
await page.getByLabel("Username").fill(credentials.username);
|
||||
await page.getByLabel("Password").fill(credentials.password);
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await page.waitForURL(baseUrl);
|
||||
await page.goto(`${baseUrl}/boards/${boardName}`);
|
||||
await page.waitForSelector(".static-grid-item-content", { state: "visible" });
|
||||
|
||||
await page.keyboard.press("ControlOrMeta+E");
|
||||
|
||||
await page.waitForSelector(".grid-stack", { state: "visible" });
|
||||
await expect.poll(() => page.locator(".grid-stack-item").count()).toBeGreaterThan(0);
|
||||
await context.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
const seedResponsiveBoardAsync = async (db: SqliteDatabase) => {
|
||||
const userId = createId();
|
||||
const boardId = createId();
|
||||
const mobileLayoutId = createId();
|
||||
const tabletLayoutId = createId();
|
||||
const desktopLayoutId = createId();
|
||||
const rootSectionId = createId();
|
||||
const categorySectionId = createId();
|
||||
const dynamicSectionId = createId();
|
||||
|
||||
await db.update(sqliteSchema.onboarding).set({ step: "finish" });
|
||||
await db.insert(sqliteSchema.users).values({
|
||||
id: userId,
|
||||
name: credentials.username,
|
||||
password: await hashPasswordAsync(credentials.password),
|
||||
});
|
||||
await db.insert(sqliteSchema.boards).values({
|
||||
id: boardId,
|
||||
name: boardName,
|
||||
isPublic: true,
|
||||
creatorId: userId,
|
||||
});
|
||||
await db.insert(sqliteSchema.layouts).values([
|
||||
{ id: mobileLayoutId, name: "Mobile", boardId, columnCount: 4, breakpoint: 0 },
|
||||
{ id: tabletLayoutId, name: "Tablet", boardId, columnCount: 8, breakpoint: 768 },
|
||||
{ id: desktopLayoutId, name: "Desktop", boardId, columnCount: 12, breakpoint: 1024 },
|
||||
]);
|
||||
await db.insert(sqliteSchema.sections).values([
|
||||
{ id: rootSectionId, boardId, kind: "empty", xOffset: 0, yOffset: 0 },
|
||||
{ id: categorySectionId, boardId, kind: "category", xOffset: 0, yOffset: 1, name: "Systems" },
|
||||
{
|
||||
id: dynamicSectionId,
|
||||
boardId,
|
||||
kind: "dynamic",
|
||||
options: SuperJSON.stringify({ title: "Nested", borderColor: "", customCssClasses: [] }),
|
||||
},
|
||||
]);
|
||||
await db.insert(sqliteSchema.sectionLayouts).values([
|
||||
{
|
||||
sectionId: dynamicSectionId,
|
||||
layoutId: mobileLayoutId,
|
||||
parentSectionId: categorySectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 4,
|
||||
height: 2,
|
||||
},
|
||||
{
|
||||
sectionId: dynamicSectionId,
|
||||
layoutId: tabletLayoutId,
|
||||
parentSectionId: categorySectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 4,
|
||||
height: 2,
|
||||
},
|
||||
{
|
||||
sectionId: dynamicSectionId,
|
||||
layoutId: desktopLayoutId,
|
||||
parentSectionId: categorySectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 6,
|
||||
height: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
const rootItemId = await insertClockItemAsync(db, boardId);
|
||||
const categoryItemId = await insertClockItemAsync(db, boardId);
|
||||
const dynamicItemId = await insertClockItemAsync(db, boardId);
|
||||
await db.insert(sqliteSchema.itemLayouts).values([
|
||||
{
|
||||
itemId: rootItemId,
|
||||
sectionId: rootSectionId,
|
||||
layoutId: mobileLayoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
itemId: rootItemId,
|
||||
sectionId: rootSectionId,
|
||||
layoutId: tabletLayoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 3,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
itemId: rootItemId,
|
||||
sectionId: rootSectionId,
|
||||
layoutId: desktopLayoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 4,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
itemId: categoryItemId,
|
||||
sectionId: categorySectionId,
|
||||
layoutId: mobileLayoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 2,
|
||||
width: 4,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
itemId: categoryItemId,
|
||||
sectionId: categorySectionId,
|
||||
layoutId: tabletLayoutId,
|
||||
xOffset: 4,
|
||||
yOffset: 0,
|
||||
width: 4,
|
||||
height: 2,
|
||||
},
|
||||
{
|
||||
itemId: categoryItemId,
|
||||
sectionId: categorySectionId,
|
||||
layoutId: desktopLayoutId,
|
||||
xOffset: 6,
|
||||
yOffset: 0,
|
||||
width: 6,
|
||||
height: 2,
|
||||
},
|
||||
{
|
||||
itemId: dynamicItemId,
|
||||
sectionId: dynamicSectionId,
|
||||
layoutId: mobileLayoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
itemId: dynamicItemId,
|
||||
sectionId: dynamicSectionId,
|
||||
layoutId: tabletLayoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
itemId: dynamicItemId,
|
||||
sectionId: dynamicSectionId,
|
||||
layoutId: desktopLayoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 3,
|
||||
height: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
categorySectionId,
|
||||
};
|
||||
};
|
||||
|
||||
const insertClockItemAsync = async (db: SqliteDatabase, boardId: string) => {
|
||||
const itemId = createId();
|
||||
await db.insert(sqliteSchema.items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "clock",
|
||||
options: SuperJSON.stringify({ is24HourFormat: true }),
|
||||
advancedOptions: SuperJSON.stringify({ title: null, customCssClasses: [], borderColor: "" }),
|
||||
});
|
||||
return itemId;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__homarrLayoutShiftScore: number;
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -17,8 +17,8 @@
|
||||
"db:studio": "pnpm -F db studio",
|
||||
"dev": "pnpm -F nextjs dev",
|
||||
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
|
||||
"format": "turbo format --continue",
|
||||
"format:fix": "turbo format --continue -- --write",
|
||||
"format": "turbo format --continue -- --check",
|
||||
"format:fix": "turbo format --continue",
|
||||
"lint": "turbo lint --continue",
|
||||
"lint:fix": "turbo lint --continue -- --fix",
|
||||
"lint:ws": "pnpm dlx sherif@latest",
|
||||
@@ -60,9 +60,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=10.33.4"
|
||||
"pnpm": ">=10.34.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"packageManager": "pnpm@10.34.1",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@tree-sitter-grammars/tree-sitter-yaml",
|
||||
@@ -76,14 +76,14 @@
|
||||
"tree-sitter-json"
|
||||
],
|
||||
"overrides": {
|
||||
"@babel/helpers@<7.26.10": ">=7.29.2",
|
||||
"@babel/runtime@<7.26.10": ">=7.29.2",
|
||||
"@babel/helpers@<7.26.10": ">=7.29.7",
|
||||
"@babel/runtime@<7.26.10": ">=7.29.7",
|
||||
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
|
||||
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
|
||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
|
||||
"esbuild@<=0.24.2": ">=0.28.0",
|
||||
"form-data@>=4.0.0 <4.0.4": ">=4.0.5",
|
||||
"hono@<4.6.5": ">=4.12.22",
|
||||
"hono@<4.6.5": ">=4.12.23",
|
||||
"linkifyjs@<4.3.2": ">=4.3.3",
|
||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
|
||||
"prismjs@<1.30.0": ">=1.30.0",
|
||||
@@ -93,7 +93,7 @@
|
||||
"sha.js@<=2.4.11": ">=2.4.12",
|
||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
||||
"tmp@<=0.2.3": ">=0.2.5",
|
||||
"tmp@<=0.2.3": ">=0.2.7",
|
||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -81,13 +81,7 @@ describe("initUser should initialize the first user", () => {
|
||||
await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["aB2%"], // too short
|
||||
["abc123DEF"], // does not contain special characters
|
||||
["abcDEFghi+"], // does not contain numbers
|
||||
["ABC123+/-"], // does not contain lowercase
|
||||
["abc123+/-"], // does not contain uppercase
|
||||
])("should throw error that password requirements do not match for '%s' as password", async (password) => {
|
||||
it.each([["aB2%"], ["short"]])("should reject passwords shorter than 8 characters for '%s'", async (password) => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
@@ -103,7 +97,55 @@ describe("initUser should initialize the first user", () => {
|
||||
confirmPassword: password,
|
||||
});
|
||||
|
||||
await expect(actAsync()).rejects.toThrow("passwordRequirements");
|
||||
await expect(actAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should accept passwords without complexity requirements", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
await caller.initUser({
|
||||
username: "test",
|
||||
password: "abc123DEF",
|
||||
confirmPassword: "abc123DEF",
|
||||
});
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it("should accept passwords with special characters like LoveHomarr<3", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
await caller.initUser({
|
||||
username: "test",
|
||||
password: "LoveHomarr<3",
|
||||
confirmPassword: "LoveHomarr<3",
|
||||
});
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ export const env = createEnv({
|
||||
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
|
||||
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
|
||||
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
|
||||
AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: z
|
||||
.enum(["client_secret_basic", "client_secret_post", "client_secret_jwt", "none"])
|
||||
.default("client_secret_basic"),
|
||||
}
|
||||
: {}),
|
||||
...(authProviders.includes("ldap")
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,9 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
|
||||
type: "oidc",
|
||||
clientId: env.AUTH_OIDC_CLIENT_ID,
|
||||
clientSecret: env.AUTH_OIDC_CLIENT_SECRET,
|
||||
client: {
|
||||
token_endpoint_auth_method: env.AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD,
|
||||
},
|
||||
issuer: env.AUTH_OIDC_ISSUER,
|
||||
allowDangerousEmailAccountLinking: env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING,
|
||||
authorization: {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"scripts": {
|
||||
"build": "esbuild src/index.ts --bundle --platform=node --outfile=cli.cjs --external:bcrypt --external:cpu-features --loader:.html=text --loader:.node=text",
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"run": "pnpm with-env node cli.cjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { EVERY_HOUR } from "@homarr/cron-jobs-core/expressions";
|
||||
import { EVERY_DAY } from "@homarr/cron-jobs-core/expressions";
|
||||
import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
|
||||
|
||||
import { createCronJob } from "../lib";
|
||||
|
||||
export const updateCheckerJob = createCronJob("updateChecker", EVERY_HOUR, {
|
||||
export const updateCheckerJob = createCronJob("updateChecker", EVERY_DAY, {
|
||||
runOnStart: true,
|
||||
}).withCallback(async () => {
|
||||
const handler = updateCheckerRequestHandler.handler({});
|
||||
await handler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: true,
|
||||
});
|
||||
await handler.getCachedOrUpdatedDataAsync({});
|
||||
});
|
||||
@@ -20,7 +20,7 @@
|
||||
"build:postgresql": "esbuild migrations/postgresql/migrate.ts --bundle --platform=node --outfile=migrations/postgresql/migrate.cjs",
|
||||
"build:sqlite": "esbuild migrations/sqlite/migrate.ts --bundle --platform=node --outfile=migrations/sqlite/migrate.cjs",
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"migration:custom": "pnpm with-env tsx ./migrations/custom/run-custom.ts",
|
||||
"migration:mysql:drop": "pnpm with-env drizzle-kit drop --config ./configs/mysql.config.ts",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"postinstall": "tsx ./src/docs/codegen.ts",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -108,10 +108,9 @@ export const IconPicker = ({
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className={classes.iconCard}
|
||||
withBorder
|
||||
>
|
||||
<Box w={25} h={25}>
|
||||
<Image src={item.url} w={25} h={25} radius="md" />
|
||||
<Image src={item.url} w={25} h={25} />
|
||||
</Box>
|
||||
</Card>
|
||||
</Indicator>
|
||||
|
||||
@@ -5,3 +5,5 @@ export * from "./icon-picker/icon-picker";
|
||||
export * from "./new-app/icon-matcher";
|
||||
|
||||
export * from "./upload-media/upload-media";
|
||||
|
||||
export * from "./user-create/user-create-password-fields";
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { PasswordInput, Stack } from "@mantine/core";
|
||||
import type { PasswordInputProps } from "@mantine/core";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { CustomPasswordInput } from "@homarr/ui";
|
||||
|
||||
interface UserCreatePasswordFieldsProps {
|
||||
passwordInputProps: PasswordInputProps;
|
||||
confirmPasswordInputProps: PasswordInputProps;
|
||||
variant?: PasswordInputProps["variant"];
|
||||
}
|
||||
|
||||
export const UserCreatePasswordFields = ({
|
||||
passwordInputProps,
|
||||
confirmPasswordInputProps,
|
||||
variant,
|
||||
}: UserCreatePasswordFieldsProps) => {
|
||||
const t = useScopedI18n("user.field");
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<CustomPasswordInput
|
||||
withPasswordRequirements
|
||||
label={t("password.label")}
|
||||
variant={variant}
|
||||
{...passwordInputProps}
|
||||
/>
|
||||
<PasswordInput label={t("passwordConfirm.label")} variant={variant} {...confirmPasswordInputProps} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt --check .",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user