feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -0,0 +1,154 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, Group, InputWrapper, Slider, Stack, Switch, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react";
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 type { TablerIcon } from "@homarr/ui";
import { boardColumnCountSchema, boardCreateSchema, boardNameSchema } from "@homarr/validation/board";
interface BoardCreateFormProps {
onCancel?: () => void;
}
export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const router = useRouter();
const form = useZodForm(boardCreateSchema, {
mode: "controlled",
initialValues: {
name: "",
columnCount: 10,
isPublic: false,
},
});
const { mutate, isPending } = clientApi.board.createBoard.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/boards");
},
});
const boardNameStatus = useBoardNameStatus(form.values.name);
return (
<form
onSubmit={form.onSubmit((values) => {
// Prevent submit before name availability check
if (!boardNameStatus.canSubmit) return;
mutate(values, {
onSuccess: (result) => {
showSuccessNotification({
title: tBoard("action.create.notification.success.title"),
message: tBoard("action.create.notification.success.message", { name: values.name }),
});
const name = encodeURIComponent(result.name);
router.push(`/boards/${name}`);
},
onError() {
showErrorNotification({
title: tBoard("action.create.notification.error.title"),
message: tBoard("action.create.notification.error.message", { name: values.name }),
});
},
});
})}
>
<Stack maw={720}>
<TextInput
label={tCommon("field.name")}
withAsterisk
autoFocus
{...form.getInputProps("name")}
description={<BoardNameAvailability status={boardNameStatus} />}
/>
<InputWrapper label={tBoard("field.columnCount.label")} {...form.getInputProps("columnCount")}>
<Slider
aria-label={tBoard("field.columnCount.label")}
min={boardColumnCountSchema.minValue ?? undefined}
max={boardColumnCountSchema.maxValue ?? undefined}
step={1}
{...form.getInputProps("columnCount")}
/>
</InputWrapper>
<Switch
label={tBoard("field.isPublic.label")}
description={tBoard("field.isPublic.description")}
{...form.getInputProps("isPublic")}
/>
<Group justify="end" wrap="wrap">
{onCancel && (
<Button variant="default" onClick={onCancel}>
{tCommon("action.cancel")}
</Button>
)}
<Button type="submit" loading={isPending} disabled={!boardNameStatus.canSubmit}>
{tCommon("action.create")}
</Button>
</Group>
</Stack>
</form>
);
};
export const useBoardNameStatus = (name: string) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const [debouncedName] = useDebouncedValue(name, 250);
const isValidName = boardNameSchema.safeParse(name).success;
const isDebouncing = name !== debouncedName;
const { data: boardExists, isLoading } = clientApi.board.exists.useQuery(debouncedName, {
enabled: boardNameSchema.safeParse(debouncedName).success,
});
let description: { label: string; icon?: TablerIcon; color?: string } | undefined;
if (debouncedName.trim() !== "") {
if (isDebouncing || isLoading) {
description = {
label: tBoard("action.create.availability.checking"),
};
} else if (boardExists !== undefined) {
if (boardExists) {
description = {
icon: IconAlertTriangle,
label: tCommon("zod.errors.custom.boardAlreadyExists"),
color: "red",
};
} else {
description = {
icon: IconCircleCheck,
label: tBoard("action.create.availability.available", { name: debouncedName }),
color: "green",
};
}
}
}
return {
canSubmit: isValidName && !isDebouncing && boardExists === false && !isLoading,
description,
};
};
export type BoardNameStatus = ReturnType<typeof useBoardNameStatus>;
export const BoardNameAvailability = ({ status }: { status: BoardNameStatus }) => {
if (!status.description) return null;
const Icon = status.description.icon;
return (
<Group component="output" c={status.description.color} gap="xs" align="center" aria-live="polite">
{Icon && <Icon size={16} />}
<span>{status.description.label}</span>
</Group>
);
};
@@ -43,7 +43,7 @@ vi.mock("@homarr/auth/client", () => ({
}));
vi.mock("@homarr/translation", () => ({ supportedLanguages: [] }));
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
useI18n: () => (key: string, params?: Record<string, string>) =>
params ? `${key}:${Object.values(params).join(",")}` : key,
}));
vi.mock("../upload-media/upload-media", () => ({
@@ -1,6 +1,6 @@
"use client";
import type { FocusEventHandler, KeyboardEventHandler } from "react";
import type { FocusEventHandler, KeyboardEventHandler, ReactNode } from "react";
import { startTransition, useEffect, useMemo, useState } from "react";
import {
ActionIcon,
@@ -35,7 +35,7 @@ import {
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { supportedLanguages } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { UploadMedia } from "../upload-media/upload-media";
import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
@@ -48,6 +48,7 @@ interface IconPickerProps {
onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler;
label?: string;
description?: ReactNode;
placeholder?: string;
suggestedSearch?: string | null;
withAsterisk?: boolean;
@@ -63,6 +64,7 @@ export const IconPicker = ({
onBlur,
withAsterisk = true,
label,
description,
placeholder,
suggestedSearch,
}: IconPickerProps) => {
@@ -72,7 +74,7 @@ export const IconPicker = ({
const [keyboardIndex, setKeyboardIndex] = useState(-1);
const [previewState, setPreviewState] = useState<PreviewState>("idle");
const { data: session } = useSession();
const tCommon = useScopedI18n("common");
const tCommon = useI18n("common");
const [debouncedDraft] = useDebouncedValue(draft, 250);
const directUrl = isDirectImageUrl(draft) ? draft.trim() : null;
@@ -272,6 +274,7 @@ export const IconPicker = ({
(query.data
? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) })
: tCommon("iconPicker.headerLoading"));
const inputDescription = description === undefined ? tCommon("iconPicker.description") : description;
return (
<Combobox
@@ -359,7 +362,7 @@ export const IconPicker = ({
withAsterisk={withAsterisk}
error={error}
label={label ?? tCommon("iconPicker.label")}
description={tCommon("iconPicker.description")}
description={inputDescription}
styles={{
description: { color: "var(--mantine-color-text)" },
input: { color: "var(--mantine-color-text)" },
+2
View File
@@ -1,6 +1,8 @@
export * from "./new-app/_app-new-form";
export * from "./new-app/_form";
export * from "./board-create/board-create-form";
export * from "./icon-picker/icon-picker";
export * from "./upload-media/upload-media";
@@ -7,7 +7,7 @@ import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app";
import { AppForm } from "./_form";
@@ -19,14 +19,15 @@ export const AppNewForm = ({
showCreateAnother: boolean;
showBackToOverview: boolean;
}) => {
const tScoped = useScopedI18n("app.page.create.notification");
const t = useI18n();
const tScoped = useI18n("app.page.create.notification");
const tCommon = useI18n("common");
const router = useRouter();
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.app.create.useMutation({
onError: () => {
showErrorNotification({
title: tScoped("error.title"),
title: tCommon("notification.create.error"),
message: tScoped("error.message"),
});
},
@@ -36,8 +37,9 @@ export const AppNewForm = ({
(values: z.infer<typeof appManageSchema>, redirect: boolean, afterSuccess?: () => void) => {
mutate(values, {
onSuccess() {
void utils.app.invalidate();
showSuccessNotification({
title: tScoped("success.title"),
title: tCommon("notification.create.success"),
message: tScoped("success.message"),
});
afterSuccess?.();
@@ -51,14 +53,14 @@ export const AppNewForm = ({
},
});
},
[mutate, router, tScoped],
[mutate, router, tCommon, tScoped, utils],
);
return (
<AppForm
buttonLabels={{
submit: t("common.action.create"),
submitAndCreateAnother: showCreateAnother ? t("common.action.createAnother") : undefined,
submit: tCommon("action.create"),
submitAndCreateAnother: showCreateAnother ? tCommon("action.createAnother") : undefined,
}}
showBackToOverview={showBackToOverview}
handleSubmit={handleSubmit}
@@ -7,6 +7,7 @@ import { useDisclosure } from "@mantine/hooks";
import type { z } from "zod/v4";
import { useZodForm } from "@homarr/form";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { appManageSchema } from "@homarr/validation/app";
@@ -50,7 +51,8 @@ export const AppForm = ({
hideButtons,
formRef,
}: AppFormProps) => {
const t = useI18n();
const tApp = useI18n("app");
const tCommon = useI18n("common");
const form = useZodForm(appManageSchema, {
initialValues: toFormValues(initialValues),
@@ -127,25 +129,25 @@ export const AppForm = ({
const formFields = (
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<TextInput {...form.getInputProps("name")} withAsterisk label={tCommon("field.name")} />
<IconPicker
{...form.getInputProps("iconUrl")}
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<Textarea
{...form.getInputProps("description")}
label={t("app.field.description.label")}
label={tApp("field.description.label")}
autosize
minRows={2}
resize="vertical"
/>
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
<TextInput {...form.getInputProps("href")} label={invariantTechnicalLabels.url} />
<Checkbox
checked={opened}
onChange={handleClickDifferentUrlPing}
label={t("app.field.useDifferentUrlForPing.checkbox.label")}
description={t("app.field.useDifferentUrlForPing.checkbox.description")}
label={tApp("field.useDifferentUrlForPing.checkbox.label")}
description={tApp("field.useDifferentUrlForPing.checkbox.description")}
mt="md"
/>
@@ -157,7 +159,7 @@ export const AppForm = ({
<Group justify="end">
{showBackToOverview && (
<Button variant="default" component={Link} href="/manage/apps">
{t("common.action.backToOverview")}
{tCommon("action.backToOverview")}
</Button>
)}
{buttonLabels.submitAndCreateAnother && (
@@ -3,7 +3,7 @@
import { PasswordInput, Stack } from "@mantine/core";
import type { PasswordInputProps } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
interface UserCreatePasswordFieldsProps {
@@ -17,7 +17,7 @@ export const UserCreatePasswordFields = ({
confirmPasswordInputProps,
variant,
}: UserCreatePasswordFieldsProps) => {
const t = useScopedI18n("user.field");
const t = useI18n("user.field");
return (
<Stack gap="md">