Compare commits

..
134 changed files with 1795 additions and 3048 deletions

No files matched your search

+1 -1
View File
@@ -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 .",
"format": "oxfmt --check .",
"lint": "oxlint",
"start": "pnpm with-env next start",
"typecheck": "tsc --noEmit",
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
import { DirectionProvider, MantineProvider } from "@mantine/core";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
@@ -21,8 +21,11 @@ export const CustomMantineProvider = ({
<MantineProvider
defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager}
theme={theme}
cssVariablesResolver={v8CssVariablesResolver}
theme={createTheme({
primaryColor: "red",
autoContrast: true,
defaultRadius: "sm",
})}
>
{children}
</MantineProvider>
@@ -1,11 +1,9 @@
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 }>({
getInitialBoardAsync: getHomeBoardAsync,
async getInitialBoardAsync() {
return await api.board.getHomeBoard();
},
});
@@ -1,11 +1,9 @@
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 }>({
getInitialBoardAsync: async ({ name }) => getBoardByNameAsync(name),
async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name });
},
});
@@ -1,24 +1,33 @@
"use client";
import dynamic from "next/dynamic";
import { Box } from "@mantine/core";
import { Box, Stack } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { StaticBoardGrid } from "~/components/board/sections/static-grid";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardBackgroundVideo } from "~/components/layout/background";
const EditModeBoard = dynamic(() => import("./_edit-board"), { ssr: false });
export const ClientBoard = () => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const currentLayoutId = useCurrentLayout();
const fullWidthSortedSections = board.sections
.filter((section) => section.kind === "empty" || section.kind === "category")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
return (
<Box h="100%" pos="relative">
<BoardBackgroundVideo />
{isEditMode ? <EditModeBoard /> : <StaticBoardGrid board={board} />}
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
</Box>
);
};
@@ -1,3 +1,7 @@
"use client";
export { ClientBoard as DynamicClientBoard } from "./_client";
import dynamic from "next/dynamic";
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
ssr: false,
});
@@ -1,31 +0,0 @@
"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;
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions";
@@ -33,12 +33,7 @@ export const BoardMantineProvider = ({
});
return (
<MantineProvider
defaultColorScheme={defaultColorScheme}
theme={theme}
colorSchemeManager={colorSchemeManager}
cssVariablesResolver={v8CssVariablesResolver}
>
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
{children}
</MantineProvider>
);
@@ -1,15 +1,15 @@
"use client";
import { Button, Stack, TextInput } from "@mantine/core";
import { Button, PasswordInput, 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,10 +59,12 @@ export const InitUserForm = () => {
>
<Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
<UserCreatePasswordFields
passwordInputProps={form.getInputProps("password")}
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
<CustomPasswordInput
withPasswordRequirements
label={t("field.password.label")}
{...form.getInputProps("password")}
/>
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
<Button type="submit" fullWidth loading={isPending}>
{t("action.create")}
</Button>
@@ -185,7 +185,7 @@ export default async function AboutPage() {
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="md">
{Object.entries(dependencies)
.filter(([, value]) => !value.includes("workspace:"))
.sort(([key1], [key2]) => key1.localeCompare(key2))
.toSorted(([key1], [key2]) => key1.localeCompare(key2))
.map(([name, version]) => (
<UnstyledButton
key={name}
@@ -48,7 +48,7 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
} as const;
}
})
.sort((certA, certB) => {
.toSorted((certA, certB) => {
if (certA.isError) return -1;
if (certB.isError) return 1;
return certA.x509.validToDate.getTime() - certB.x509.validToDate.getTime();
@@ -107,23 +107,6 @@ 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,
@@ -142,11 +125,35 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
},
initialState: { density: "xs", showGlobalFilter: true },
renderTopToolbarCustomActions: () => (
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
{tDocker("action.refresh.label")}
</Button>
),
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>
);
},
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
return (
@@ -0,0 +1,85 @@
"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>;
@@ -0,0 +1,84 @@
"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>;
@@ -0,0 +1,83 @@
"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>;
@@ -1,332 +0,0 @@
"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>
);
};
@@ -0,0 +1,69 @@
"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>;
@@ -2,7 +2,7 @@
import { useCallback } from "react";
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
import { useDisclosure, useMediaQuery } from "@mantine/hooks";
import { useDisclosure } from "@mantine/hooks";
import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
@@ -25,7 +25,6 @@ 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");
@@ -95,11 +94,11 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
return (
<Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}>
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow withinPortal>
<Box pos="relative">
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start">
<Menu.Target>
<UnstyledButton onClick={toggle}>
<UserAvatar user={user} size={isMobile ? 120 : 200} />
<UserAvatar user={user} size={200} />
<Button
component="div"
pos="absolute"
@@ -0,0 +1,94 @@
"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,13 +6,18 @@ 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 { UserGeneralSettingsForm } from "./_components/_general-settings-form";
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
interface Props {
params: Promise<{
@@ -58,7 +63,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 (
@@ -68,25 +73,51 @@ export default async function EditUserPage(props: Props) {
{t("management.page.user.fieldsDisabledExternalProvider")}
</Alert>
)}
<Title>{tGeneral("title")}</Title>
<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}
/>
<Group gap="xl">
<Box flex={1}>
<UserProfileForm user={user} />
</Box>
<Box w={{ base: "100%", lg: 260 }}>
<Box w={{ base: "100%", lg: 200 }}>
<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")}
@@ -1,7 +1,20 @@
"use client";
import { startTransition, useCallback, useState } from "react";
import { Badge, Button, Card, Group, Stack, Stepper, Table, Text, TextInput, Title, Tooltip } from "@mantine/core";
import {
Badge,
Button,
Card,
Group,
PasswordInput,
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";
@@ -11,11 +24,10 @@ 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 { UserAvatar } from "@homarr/ui";
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
import { userPasswordSchema } from "@homarr/validation/user";
@@ -155,10 +167,18 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
<form>
<Card p="xl">
<Stack gap="md">
<UserCreatePasswordFields
<CustomPasswordInput
withPasswordRequirements
label={tUserField("password.label")}
variant="filled"
passwordInputProps={securityForm.getInputProps("password")}
confirmPasswordInputProps={securityForm.getInputProps("confirmPassword")}
withAsterisk
{...securityForm.getInputProps("password")}
/>
<PasswordInput
label={tUserField("passwordConfirm.label")}
variant="filled"
withAsterisk
{...securityForm.getInputProps("confirmPassword")}
/>
</Stack>
</Card>
@@ -19,10 +19,6 @@ import { BoardItemMenu } from "./item-menu";
interface BoardItemContentProps {
item: SectionItem;
fallbackDimensions?: {
width: number;
height: number;
};
}
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
@@ -31,11 +27,9 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
return undefined;
};
export const BoardItemContent = ({ item, fallbackDimensions }: BoardItemContentProps) => {
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const fallbackWidth = fallbackDimensions?.width ?? 0;
const fallbackHeight = fallbackDimensions?.height ?? 0;
return (
<>
@@ -58,7 +52,7 @@ export const BoardItemContent = ({ item, fallbackDimensions }: BoardItemContentP
}}
p={0}
>
<InnerContent item={item} width={width || fallbackWidth} height={height || fallbackHeight} />
<InnerContent item={item} width={width} height={height} />
</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 { useOptionalSectionContext } from "../sections/section-context";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
@@ -41,8 +41,7 @@ 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 sectionContext = useOptionalSectionContext();
const gridstack = sectionContext?.refs.gridstack;
const { gridstack } = useSectionContext().refs;
const settings = useSettings();
// Reset error boundary on next render if item has been edited
@@ -128,7 +127,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 });
}}
>
@@ -1,21 +1,48 @@
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";
import { useCategoryCollapse } from "./use-category-collapse";
const localStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`;
interface Props {
section: CategorySection;
}
export const BoardCategorySection = ({ section }: Props) => {
const { status } = useSession();
const { mutate } = clientApi.section.changeCollapsed.useMutation();
const board = useRequiredBoard();
const [opened, { toggle }] = useCategoryCollapse(section);
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");
}
},
});
return (
<Card style={{ "--opacity": board.opacity / 100 }} radius={board.itemRadius} p={0} className={classes.itemCard}>
@@ -20,8 +20,4 @@ export const useSectionContext = () => {
return context;
};
export const useOptionalSectionContext = () => {
return useContext(SectionContext);
};
export const SectionProvider = SectionContext.Provider;
@@ -1,93 +0,0 @@
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 });
});
});
@@ -1,103 +0,0 @@
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),
};
};
@@ -1,289 +0,0 @@
"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>
);
};
@@ -1,77 +0,0 @@
"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,20 +6,11 @@ import { LanguageCombobox } from "./language-combobox";
interface CurrentLanguageComboboxProps {
width?: string;
withinPortal?: boolean;
}
export const CurrentLanguageCombobox = ({ width, withinPortal }: CurrentLanguageComboboxProps) => {
export const CurrentLanguageCombobox = ({ width }: CurrentLanguageComboboxProps) => {
const currentLocale = useCurrentLocale();
const { changeLocale, isPending } = useChangeLocale();
return (
<LanguageCombobox
value={currentLocale}
onChange={changeLocale}
isPending={isPending}
width={width}
withinPortal={withinPortal}
/>
);
return <LanguageCombobox value={currentLocale} onChange={changeLocale} isPending={isPending} width={width} />;
};
@@ -16,17 +16,9 @@ interface LanguageComboboxProps {
onChange: (value: SupportedLanguage) => void;
isPending?: boolean;
width?: string;
withinPortal?: boolean;
}
export const LanguageCombobox = ({
label,
value,
onChange,
isPending,
width,
withinPortal = false,
}: LanguageComboboxProps) => {
export const LanguageCombobox = ({ label, value, onChange, isPending, width }: LanguageComboboxProps) => {
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
@@ -47,7 +39,7 @@ export const LanguageCombobox = ({
}, [combobox]);
return (
<Combobox store={combobox} withinPortal={withinPortal} onOptionSubmit={handleOnOptionSubmit}>
<Combobox store={combobox} onOptionSubmit={handleOnOptionSubmit}>
<Combobox.Target>
<InputBase
component="button"
-14
View File
@@ -112,17 +112,3 @@
.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%;
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
"scripts": {
"clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
+1 -1
View File
@@ -12,7 +12,7 @@
"scripts": {
"clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
+2 -2
View File
@@ -6,9 +6,9 @@
},
"engines": {
"node": ">=24.16.0",
"pnpm": ">=10.34.1"
"pnpm": ">=10.33.4"
},
"packageManager": "pnpm@10.34.1",
"packageManager": "pnpm@10.33.4",
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3"
-304
View File
@@ -1,304 +0,0 @@
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
View File
@@ -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 -- --check",
"format:fix": "turbo format --continue",
"format": "turbo format --continue",
"format:fix": "turbo format --continue -- --write",
"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.34.1"
"pnpm": ">=10.33.4"
},
"packageManager": "pnpm@10.34.1",
"packageManager": "pnpm@10.33.4",
"pnpm": {
"onlyBuiltDependencies": [
"@tree-sitter-grammars/tree-sitter-yaml",
@@ -76,14 +76,14 @@
"tree-sitter-json"
],
"overrides": {
"@babel/helpers@<7.26.10": ">=7.29.7",
"@babel/runtime@<7.26.10": ">=7.29.7",
"@babel/helpers@<7.26.10": ">=7.29.2",
"@babel/runtime@<7.26.10": ">=7.29.2",
"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.23",
"hono@<4.6.5": ">=4.12.22",
"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.7",
"tmp@<=0.2.3": ">=0.2.5",
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
},
"patchedDependencies": {
+1 -1
View File
@@ -16,7 +16,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -16,7 +16,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+8 -50
View File
@@ -81,7 +81,13 @@ describe("initUser should initialize the first user", () => {
await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch");
});
it.each([["aB2%"], ["short"]])("should reject passwords shorter than 8 characters for '%s'", async (password) => {
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) => {
const db = createDb();
await createOnboardingStepAsync(db, "user");
const caller = userRouter.createCaller({
@@ -97,55 +103,7 @@ describe("initUser should initialize the first user", () => {
confirmPassword: password,
});
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();
await expect(actAsync()).rejects.toThrow("passwordRequirements");
});
});
-3
View File
@@ -40,9 +40,6 @@ 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")
+1 -1
View File
@@ -18,7 +18,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
@@ -14,9 +14,6 @@ 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: {
+1 -1
View File
@@ -18,7 +18,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -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 .",
"format": "oxfmt --check .",
"lint": "oxlint",
"run": "pnpm with-env node cli.cjs",
"typecheck": "tsc --noEmit",
+1 -1
View File
@@ -20,7 +20,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -31,7 +31,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -17,7 +17,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -18,7 +18,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -17,7 +17,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
@@ -1,11 +1,13 @@
import { EVERY_DAY } from "@homarr/cron-jobs-core/expressions";
import { EVERY_HOUR } from "@homarr/cron-jobs-core/expressions";
import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
import { createCronJob } from "../lib";
export const updateCheckerJob = createCronJob("updateChecker", EVERY_DAY, {
export const updateCheckerJob = createCronJob("updateChecker", EVERY_HOUR, {
runOnStart: true,
}).withCallback(async () => {
const handler = updateCheckerRequestHandler.handler({});
await handler.getCachedOrUpdatedDataAsync({});
await handler.getCachedOrUpdatedDataAsync({
forceUpdate: true,
});
});
+1 -1
View File
@@ -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 .",
"format": "oxfmt --check .",
"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",
+1 -1
View File
@@ -16,7 +16,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"postinstall": "tsx ./src/docs/codegen.ts",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
+1 -1
View File
@@ -18,7 +18,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -17,7 +17,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -16,7 +16,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
-2
View File
@@ -5,5 +5,3 @@ 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";
@@ -1,33 +0,0 @@
"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>
);
};
+1 -1
View File
@@ -17,7 +17,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -16,7 +16,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -19,7 +19,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -16,7 +16,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -16,7 +16,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -17,7 +17,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -20,7 +20,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
@@ -44,7 +44,6 @@ const optionMapping: OptionMapping = {
hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon),
hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname),
openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab),
withBorder: () => undefined,
},
calendar: {
releaseType: (oldOptions) => [oldOptions.radarrReleaseType],
+1 -1
View File
@@ -16,7 +16,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -16,7 +16,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -16,7 +16,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -16,7 +16,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
@@ -1,7 +1,6 @@
import dayjs from "dayjs";
import type { Duration } from "dayjs/plugin/duration";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { createChannelWithLatestAndEvents } from "@homarr/redis";
@@ -16,7 +15,6 @@ interface Options<TData, TInput extends Record<string, unknown>> {
options: Options<TData, TInput>,
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
cacheDuration: Duration;
fallbackToStaleOnError?: boolean;
}
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
@@ -28,33 +26,13 @@ export const createCachedRequestHandler = <TData, TInput extends Record<string,
return {
async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
const channelData = await channel.getAsync();
const requestNewDataAsync = async () => {
try {
const data = await options.requestAsync(input);
await channel.publishAndUpdateLastStateAsync(data);
return {
data,
timestamp: new Date(),
};
} catch (error) {
if (options.fallbackToStaleOnError && channelData) {
logger.warn(
new ErrorWithMetadata(
"Cached request handler using stale cache after fetch failure",
{
channel: channel.name,
queryKey: options.queryKey,
},
{ cause: error },
),
);
return channelData;
}
throw error;
}
const data = await options.requestAsync(input);
await channel.publishAndUpdateLastStateAsync(data);
return {
data,
timestamp: new Date(),
};
};
if (forceUpdate) {
@@ -65,6 +43,8 @@ export const createCachedRequestHandler = <TData, TInput extends Record<string,
return await requestNewDataAsync();
}
const channelData = await channel.getAsync();
const shouldRequestNewData =
!channelData ||
dayjs().diff(channelData.timestamp, "milliseconds") > options.cacheDuration.asMilliseconds();
@@ -111,24 +111,6 @@ describe("getAvailableUpdatesAsync", () => {
// Assert
expect(result).toEqual([]);
});
test("should propagate GitHub API auth errors without blocking callers", async () => {
const currentVersion = "v1.2.3";
const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases");
listReleasesSpy.mockRejectedValue(new Error("Bad credentials"));
await expect(getAvailableUpdatesAsync(currentVersion)).rejects.toThrow("Bad credentials");
});
test("should propagate GitHub API quota exhausted errors without blocking callers", async () => {
const currentVersion = "v1.2.3";
const listReleasesSpy = vi.spyOn(new Octokit().rest.repos, "listReleases");
listReleasesSpy.mockRejectedValue(
new Error("Request quota exhausted for request GET /repos/{owner}/{repo}/releases"),
);
await expect(getAvailableUpdatesAsync(currentVersion)).rejects.toThrow("quota exhausted");
});
});
const fakeReleases = (...inputs: [string, boolean][]) => inputs.map(fakeRelease);
@@ -15,8 +15,7 @@ const logger = createLogger({ module: "updateCheckerRequestHandler" });
export const updateCheckerRequestHandler = createCachedRequestHandler({
queryKey: "homarr-update-checker",
cacheDuration: dayjs.duration(1, "day"),
fallbackToStaleOnError: true,
cacheDuration: dayjs.duration(1, "hour"),
async requestAsync(_) {
return {
availableUpdates: await getAvailableUpdatesAsync(packageJson.version),
@@ -59,7 +58,6 @@ export const getAvailableUpdatesAsync = async (currentVersion: string) => {
request: {
fetch: fetchWithTrustedCertificatesAsync,
},
throttle: { enabled: false },
});
const isCurrentPrerelease = isPrereleaseTag(currentVersion);
@@ -102,7 +100,7 @@ export const getAvailableUpdatesAsync = async (currentVersion: string) => {
const availableUpdates = semanticReleases
.filter((release) => isCurrentPrerelease || !release.isPrerelease)
.filter((release) => compareSemVer(release.tagName, currentVersion) > 0)
.toSorted((releaseA, releaseB) => compareSemVer(releaseB.tagName, releaseA.tagName));
.sort((releaseA, releaseB) => compareSemVer(releaseB.tagName, releaseA.tagName));
if (availableUpdates.length === 0) {
logger.debug("No available updates found", { currentVersion });
+1 -1
View File
@@ -16,7 +16,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -17,7 +17,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -17,7 +17,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+1 -1
View File
@@ -21,7 +21,7 @@
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"format": "oxfmt --check .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Contrasenya",
"suggestion": {
"requirement": {
"length": "Inclou com a mínim 8 caràcters",
"lowercase": "Inclou minúscules",
"uppercase": "Inclou majúscules",
@@ -1054,9 +1054,6 @@
"open": {
"label": ""
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "",
"checkLogs": "",
"tryAgain": "",
"loading": "",
"show": "",
"hide": ""
"loading": ""
},
"here": "",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "",
"changePosition": "",
"settings": ""
"changePosition": ""
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "",
"private": ""
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "密码",
"suggestion": {
"requirement": {
"length": "至少包含 8 个字符",
"lowercase": "包含小写字母",
"uppercase": "包含大写字母",
@@ -1054,9 +1054,6 @@
"open": {
"label": "打开媒体"
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "查阅文档",
"checkLogs": "检查日志获取更多详情",
"tryAgain": "重试",
"loading": "正在加载",
"show": "",
"hide": ""
"loading": "正在加载"
},
"here": "这里",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "新建分类",
"changePosition": "换位",
"settings": ""
"changePosition": "换位"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "此面板是公开的",
"private": "此面板是私有的"
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "crwdns26:0crwdne26:0",
"suggestion": {
"requirement": {
"length": "crwdns28:0crwdne28:0",
"lowercase": "crwdns30:0crwdne30:0",
"uppercase": "crwdns32:0crwdne32:0",
@@ -1054,9 +1054,6 @@
"open": {
"label": "crwdns2680:0crwdne2680:0"
}
},
"noResults": {
"title": "crwdns4251:0crwdne4251:0"
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "crwdns546:0crwdne546:0",
"checkLogs": "crwdns2260:0crwdne2260:0",
"tryAgain": "crwdns548:0crwdne548:0",
"loading": "crwdns550:0crwdne550:0",
"show": "crwdns4255:0crwdne4255:0",
"hide": "crwdns4257:0crwdne4257:0"
"loading": "crwdns550:0crwdne550:0"
},
"here": "crwdns552:0crwdne552:0",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "crwdns686:0crwdne686:0",
"changePosition": "crwdns688:0crwdne688:0",
"settings": "crwdns4259:0crwdne4259:0"
"changePosition": "crwdns688:0crwdne688:0"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "crwdns1656:0crwdne1656:0",
"private": "crwdns1658:0crwdne1658:0"
},
"noResults": {
"title": "crwdns4253:0crwdne4253:0"
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Heslo",
"suggestion": {
"requirement": {
"length": "Obsahuje alespoň 8 znaků",
"lowercase": "Obsahuje malé písmeno",
"uppercase": "Obsahuje velké písmeno",
@@ -1054,9 +1054,6 @@
"open": {
"label": ""
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "Přečtěte si dokumentaci",
"checkLogs": "Pro více podrobností zkontrolujte logy",
"tryAgain": "Zkusit znovu",
"loading": "Načítání",
"show": "",
"hide": ""
"loading": "Načítání"
},
"here": "zde",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "Nová kategorie",
"changePosition": "Změnit pozici",
"settings": ""
"changePosition": "Změnit pozici"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "",
"private": ""
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Adgangskode",
"suggestion": {
"requirement": {
"length": "Inkluderer mindst 8 tegn",
"lowercase": "Inkluderer små bogstaver",
"uppercase": "Inkluderer store bogstaver",
@@ -1054,9 +1054,6 @@
"open": {
"label": "Åben medie"
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "Tjek dokumentationen",
"checkLogs": "Tjek logs for flere detaljer",
"tryAgain": "Prøv igen",
"loading": "Indlæser",
"show": "",
"hide": ""
"loading": "Indlæser"
},
"here": "her",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "Ny kategori",
"changePosition": "Ændre placering",
"settings": ""
"changePosition": "Ændre placering"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "Denne tavle er offentlig",
"private": "Denne tavle er privat"
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Passwort",
"suggestion": {
"requirement": {
"length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben",
"uppercase": "Enthält Großbuchstaben",
@@ -1054,9 +1054,6 @@
"open": {
"label": "Medium öffnen"
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "Die Dokumentation ansehen",
"checkLogs": "Logs für weitere Details prüfen",
"tryAgain": "Erneut versuchen",
"loading": "Wird geladen",
"show": "",
"hide": ""
"loading": "Wird geladen"
},
"here": "Hier",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "Neue Kategorie",
"changePosition": "Position wechseln",
"settings": ""
"changePosition": "Position wechseln"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "Dieses Board ist öffentlich",
"private": "Dieses Board ist privat"
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Passwort",
"suggestion": {
"requirement": {
"length": "Enthält mindestens 8 Zeichen",
"lowercase": "Enthält Kleinbuchstaben",
"uppercase": "Enthält Großbuchstaben",
@@ -1054,9 +1054,6 @@
"open": {
"label": "Medium öffnen"
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "Die Dokumentation ansehen",
"checkLogs": "Logs für weitere Details prüfen",
"tryAgain": "Erneut versuchen",
"loading": "Wird geladen",
"show": "",
"hide": ""
"loading": "Wird geladen"
},
"here": "Hier",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "Neue Kategorie",
"changePosition": "Position wechseln",
"settings": ""
"changePosition": "Position wechseln"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "Dieses Board ist öffentlich",
"private": "Dieses Board ist privat"
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Κωδικός",
"suggestion": {
"requirement": {
"length": "",
"lowercase": "Περιλαμβάνει πεζό γράμμα",
"uppercase": "Περιλαμβάνει κεφαλαίο γράμμα",
@@ -1054,9 +1054,6 @@
"open": {
"label": ""
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "",
"checkLogs": "",
"tryAgain": "Προσπαθήστε ξανά",
"loading": "",
"show": "",
"hide": ""
"loading": ""
},
"here": "",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "",
"changePosition": "Αλλαγή θέσης",
"settings": ""
"changePosition": "Αλλαγή θέσης"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "",
"private": ""
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Password",
"suggestion": {
"requirement": {
"length": "Includes a minimum of 8 characters",
"lowercase": "Includes a lower-case letter",
"uppercase": "Includes an upper-case letter",
@@ -1054,9 +1054,6 @@
"open": {
"label": "Open media"
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "Check the documentation",
"checkLogs": "Check logs for more information",
"tryAgain": "Try again",
"loading": "Loading",
"show": "",
"hide": ""
"loading": "Loading"
},
"here": "here",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "New category",
"changePosition": "Change position",
"settings": ""
"changePosition": "Change position"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "",
"private": ""
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+2 -6
View File
@@ -132,9 +132,8 @@
},
"password": {
"label": "Password",
"suggestionsTitle": "Password suggestions",
"suggestion": {
"length": "At least 8 characters",
"requirement": {
"length": "Includes at least 8 characters",
"lowercase": "Includes lowercase letter",
"uppercase": "Includes uppercase letter",
"number": "Includes number",
@@ -1406,9 +1405,6 @@
"openNewTab": {
"label": "Open in new tab"
},
"withBorder": {
"label": "Show border"
},
"items": {
"label": "Bookmarks",
"add": "Add bookmark"
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Contraseña",
"suggestion": {
"requirement": {
"length": "Incluye al menos 8 caracteres",
"lowercase": "Incluye al menos una letra minúscula",
"uppercase": "Incluye al menos una letra mayúscula",
@@ -1054,9 +1054,6 @@
"open": {
"label": "Abrir imagen"
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "Consultar la documentación",
"checkLogs": "Comprueba los registros para más detalles",
"tryAgain": "Intentar de nuevo",
"loading": "Cargando",
"show": "",
"hide": ""
"loading": "Cargando"
},
"here": "aquí",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "Nueva Categoría",
"changePosition": "Cambiar posición",
"settings": ""
"changePosition": "Cambiar posición"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "Este tablero es público",
"private": "Este tablero es privado"
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "",
"suggestion": {
"requirement": {
"length": "",
"lowercase": "",
"uppercase": "",
@@ -1054,9 +1054,6 @@
"open": {
"label": ""
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "",
"checkLogs": "",
"tryAgain": "",
"loading": "",
"show": "",
"hide": ""
"loading": ""
},
"here": "",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "",
"changePosition": "",
"settings": ""
"changePosition": ""
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "",
"private": ""
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Mot de passe",
"suggestion": {
"requirement": {
"length": "Contient au moins 8 caractères",
"lowercase": "Inclut une lettre minuscule",
"uppercase": "Inclut une lettre majuscule",
@@ -1054,9 +1054,6 @@
"open": {
"label": "Ouvrir le média"
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "Voir la documentation",
"checkLogs": "Vérifiez les logs pour plus de détails",
"tryAgain": "Réessayer",
"loading": "Chargement",
"show": "",
"hide": ""
"loading": "Chargement"
},
"here": "ici",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "Nouvelle catégorie",
"changePosition": "Modifier la position",
"settings": ""
"changePosition": "Modifier la position"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "Ce tableau est public",
"private": "Ce tableau est privé"
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "סיסמה",
"suggestion": {
"requirement": {
"length": "כולל לפחות 8 תווים",
"lowercase": "אפשר אותיות קטנות",
"uppercase": "אפשר אותיות גדולות",
@@ -1054,9 +1054,6 @@
"open": {
"label": "פתיחת מדיה"
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "בדוק את התיעוד",
"checkLogs": "בדוק לוגים לפרטים נוספים",
"tryAgain": "נא לנסות שוב",
"loading": "טוען",
"show": "",
"hide": ""
"loading": "טוען"
},
"here": "כאן",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "קטגוריה חדשה",
"changePosition": "שנה מיקום",
"settings": ""
"changePosition": "שנה מיקום"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "הלוח הזה ציבורי",
"private": "הלוח הזה פרטי"
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Lozinka",
"suggestion": {
"requirement": {
"length": "",
"lowercase": "Uključuje mala slova",
"uppercase": "Uključuje veliko slovo",
@@ -1054,9 +1054,6 @@
"open": {
"label": ""
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "",
"checkLogs": "",
"tryAgain": "Pokušaj ponovno",
"loading": "",
"show": "",
"hide": ""
"loading": ""
},
"here": "",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "",
"changePosition": "Promijenjen položaj",
"settings": ""
"changePosition": "Promijenjen položaj"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "",
"private": ""
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Jelszó",
"suggestion": {
"requirement": {
"length": "Tartalmazzon legalább 8 karaktert",
"lowercase": "Tartalmazzon kisbetűt",
"uppercase": "Tartalmazzon nagybetűt",
@@ -1054,9 +1054,6 @@
"open": {
"label": "Média megnyitása"
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "Tekintse meg a dokumentációt",
"checkLogs": "További részletekért ellenőrizze a naplókat",
"tryAgain": "Próbálja újra",
"loading": "Töltés",
"show": "",
"hide": ""
"loading": "Töltés"
},
"here": "itt",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "Új kategória",
"changePosition": "Pozíció módosítása",
"settings": ""
"changePosition": "Pozíció módosítása"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "",
"private": ""
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Password",
"suggestion": {
"requirement": {
"length": "Include almeno 8 caratteri",
"lowercase": "Include lettera minuscola",
"uppercase": "Include lettera maiuscola",
@@ -1054,9 +1054,6 @@
"open": {
"label": "Apri media"
}
},
"noResults": {
"title": "Nessun media caricato"
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "Consulta la documentazione",
"checkLogs": "Controlla i log per maggiori dettagli",
"tryAgain": "Riprova",
"loading": "Caricamento",
"show": "Visualizza",
"hide": "Nascondi"
"loading": "Caricamento"
},
"here": "qui",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "Nuova categoria",
"changePosition": "Cambia posizione",
"settings": "Impostazioni"
"changePosition": "Cambia posizione"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "Questa board è pubblica",
"private": "Questa board è privata"
},
"noResults": {
"title": "Ancora nessuna board"
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "パスワード",
"suggestion": {
"requirement": {
"length": "少なくとも 8 文字以上を含む",
"lowercase": "小文字を含む",
"uppercase": "大文字を含む",
@@ -1054,9 +1054,6 @@
"open": {
"label": "メディアを開く"
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "ドキュメントを確認する",
"checkLogs": "詳細はログを確認してください",
"tryAgain": "リトライ",
"loading": "読み込み中",
"show": "",
"hide": ""
"loading": "読み込み中"
},
"here": "こちら",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "新しいカテゴリー",
"changePosition": "ポジションを変更する",
"settings": ""
"changePosition": "ポジションを変更する"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "このボードは公開中です",
"private": "このボードは非公開です"
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "비밀번호",
"suggestion": {
"requirement": {
"length": "",
"lowercase": "소문자 포함",
"uppercase": "대문자 포함",
@@ -1054,9 +1054,6 @@
"open": {
"label": ""
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "",
"checkLogs": "",
"tryAgain": "다시 시도",
"loading": "",
"show": "",
"hide": ""
"loading": ""
},
"here": "",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "",
"changePosition": "위치 변경",
"settings": ""
"changePosition": "위치 변경"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "",
"private": ""
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Slaptažodis",
"suggestion": {
"requirement": {
"length": "",
"lowercase": "",
"uppercase": "",
@@ -1054,9 +1054,6 @@
"open": {
"label": ""
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "",
"checkLogs": "",
"tryAgain": "",
"loading": "",
"show": "",
"hide": ""
"loading": ""
},
"here": "",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "",
"changePosition": "",
"settings": ""
"changePosition": ""
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "",
"private": ""
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Parole",
"suggestion": {
"requirement": {
"length": "",
"lowercase": "Ietver mazo burtu",
"uppercase": "Ietver lielo burtu",
@@ -1054,9 +1054,6 @@
"open": {
"label": ""
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "",
"checkLogs": "",
"tryAgain": "Mēģiniet vēlreiz",
"loading": "",
"show": "",
"hide": ""
"loading": ""
},
"here": "",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "",
"changePosition": "Mainīt pozīciju",
"settings": ""
"changePosition": "Mainīt pozīciju"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "",
"private": ""
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Wachtwoord",
"suggestion": {
"requirement": {
"length": "Bevat ten minste 8 tekens",
"lowercase": "Inclusief kleine letter",
"uppercase": "Inclusief hoofdletter",
@@ -1054,9 +1054,6 @@
"open": {
"label": "Media openen"
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "De documentatie bekijken",
"checkLogs": "Controleer logs voor meer details",
"tryAgain": "Probeer opnieuw",
"loading": "Laden",
"show": "",
"hide": ""
"loading": "Laden"
},
"here": "hier",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "Nieuwe categorie",
"changePosition": "Positie wijzigen",
"settings": ""
"changePosition": "Positie wijzigen"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "Dit bord is openbaar",
"private": "Dit bord is privé"
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
+3 -12
View File
@@ -132,7 +132,7 @@
},
"password": {
"label": "Passord",
"suggestion": {
"requirement": {
"length": "Inkluderer minst 8 tegn",
"lowercase": "Inkluderer liten bokstav",
"uppercase": "Inkluderer stor bokstav",
@@ -1054,9 +1054,6 @@
"open": {
"label": "Åpne medie"
}
},
"noResults": {
"title": ""
}
},
"common": {
@@ -1087,9 +1084,7 @@
"checkoutDocs": "Les dokumentasjonen",
"checkLogs": "Sjekk loggene for mer informasjon",
"tryAgain": "Prøv igjen",
"loading": "Laster",
"show": "",
"hide": ""
"loading": "Laster"
},
"here": "her",
"iconPicker": {
@@ -1254,8 +1249,7 @@
"menu": {
"label": {
"create": "Ny kategori",
"changePosition": "Endre posisjon",
"settings": ""
"changePosition": "Endre posisjon"
}
},
"openAllInNewTabs": {
@@ -3458,9 +3452,6 @@
"public": "Denne tavlen er offentlig",
"private": "Denne tavlen er privat"
},
"noResults": {
"title": ""
},
"modal": {
"createBoard": {
"field": {
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more