feat: add onboarding with oldmarr import (#1606)

This commit is contained in:
Meier Lukas
2024-12-15 15:40:26 +01:00
committed by GitHub
parent 82ec77d2da
commit 6de74d9525
108 changed files with 6045 additions and 312 deletions

View File

@@ -15,6 +15,7 @@ import {
wsLink,
} from "@trpc/client";
import superjson from "superjson";
import type { SuperJSONResult } from "superjson";
import type { AppRouter } from "@homarr/api";
import { clientApi, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client";
@@ -82,8 +83,8 @@ export function TRPCReactProvider(props: PropsWithChildren) {
serialize(object: unknown) {
return object;
},
deserialize(data: unknown) {
return data;
deserialize(data: SuperJSONResult) {
return superjson.deserialize<unknown>(data);
},
},
url: getTrpcUrl(),

View File

@@ -0,0 +1,23 @@
"use client";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useI18n } from "@homarr/translation/client";
export const BackToStart = () => {
const t = useI18n();
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
const handleBackToStartAsync = async () => {
await mutateAsync();
await revalidatePathActionAsync("/init");
};
return (
<Button loading={isPending} variant="subtle" color="gray" fullWidth onClick={handleBackToStartAsync}>
{t("init.backToStart")}
</Button>
);
};

View File

@@ -0,0 +1,87 @@
import Link from "next/link";
import type { MantineColor } from "@mantine/core";
import { Button, Card, Stack, Text } from "@mantine/core";
import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
import { isProviderEnabled } from "@homarr/auth/server";
import { getMantineColor } from "@homarr/common";
import { db } from "@homarr/db";
import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIcon } from "@homarr/ui";
export const InitFinish = async () => {
const firstBoard = await db.query.boards.findFirst({ columns: { name: true } });
const tFinish = await getScopedI18n("init.step.finish");
return (
<Card w={64 * 6} maw="90vw" withBorder>
<Stack>
<Text>{tFinish("description")}</Text>
{firstBoard ? (
<InternalLinkButton
href={`/auth/login?callbackUrl=/boards/${firstBoard.name}`}
iconProps={{ icon: IconLayoutDashboard, color: "blue" }}
>
{tFinish("action.goToBoard", { name: firstBoard.name })}
</InternalLinkButton>
) : (
<InternalLinkButton
href="/auth/login?callbackUrl=/manage/boards"
iconProps={{ icon: IconCategoryPlus, color: "blue" }}
>
{tFinish("action.createBoard")}
</InternalLinkButton>
)}
{isProviderEnabled("credentials") && (
<InternalLinkButton
href="/auth/login?callbackUrl=/manage/users/invites"
iconProps={{ icon: IconMailForward, color: "pink" }}
>
{tFinish("action.inviteUser")}
</InternalLinkButton>
)}
<ExternalLinkButton
href={createDocumentationLink("/docs/getting-started/after-the-installation")}
iconProps={{ icon: IconBook2, color: "yellow" }}
>
{tFinish("action.docs")}
</ExternalLinkButton>
</Stack>
</Card>
);
};
interface LinkButtonProps {
href: string;
children: string;
iconProps: IconProps;
}
interface IconProps {
icon: TablerIcon;
color: MantineColor;
}
const Icon = ({ icon: IcomComponent, color }: IconProps) => {
return <IcomComponent color={getMantineColor(color, 6)} size={16} stroke={1.5} />;
};
const InternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
return (
<Button variant="default" component={Link} href={href} leftSection={<Icon {...iconProps} />}>
{children}
</Button>
);
};
const ExternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
return (
<Button variant="default" component="a" href={href} leftSection={<Icon {...iconProps} />}>
{children}
</Button>
);
};

View File

@@ -0,0 +1,52 @@
"use client";
import { Button, Card, Stack, TextInput } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
export const InitGroup = () => {
const t = useI18n();
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation();
const form = useZodForm(validation.group.create, {
initialValues: {
name: "",
},
});
const handleSubmitAsync = async (values: z.infer<typeof validation.group.create>) => {
await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/init");
},
onError(error) {
if (error.data?.code === "CONFLICT") {
form.setErrors({ name: t("common.zod.errors.custom.groupNameTaken") });
}
},
});
};
return (
<Card w={64 * 6} maw="90vw" withBorder>
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack>
<TextInput
label={t("init.step.group.form.name.label")}
description={t("init.step.group.form.name.description")}
withAsterisk
{...form.getInputProps("name")}
/>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")}
</Button>
</Stack>
</form>
</Card>
);
};

View File

@@ -0,0 +1,41 @@
import { ActionIcon, Button, Card, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
interface FileInfoCardProps {
file: FileWithPath;
onRemove: () => void;
}
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
return (
<Card w={64 * 12 + 8} maw="90vw">
<Group justify="space-between" align="center" wrap="nowrap">
<Group>
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
{file.name}
</Text>
<Text visibleFrom="md" c="gray.6" size="sm">
{humanFileSize(file.size)}
</Text>
</Group>
<Button
variant="subtle"
color="gray"
rightSection={<IconPencil size={16} stroke={1.5} />}
onClick={onRemove}
visibleFrom="md"
>
{tFileInfo("action.change")}
</Button>
<ActionIcon size="sm" variant="subtle" color="gray" hiddenFrom="md" onClick={onRemove}>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
</Group>
</Card>
);
};

View File

@@ -0,0 +1,54 @@
import { Group, rem, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
import { useScopedI18n } from "@homarr/translation/client";
interface ImportDropZoneProps {
loading: boolean;
updateFile: (file: FileWithPath) => void;
}
export const ImportDropZone = ({ loading, updateFile }: ImportDropZoneProps) => {
const tDropzone = useScopedI18n("init.step.import.dropzone");
return (
<Dropzone
onDrop={(files) => {
const firstFile = files[0];
if (!firstFile) return;
updateFile(firstFile);
}}
acceptColor="blue.6"
rejectColor="red.6"
accept={[MIME_TYPES.zip]}
loading={loading}
multiple={false}
maxSize={1024 * 1024 * 1024 * 64} // 64 MB
>
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: "none" }}>
<Dropzone.Accept>
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
{tDropzone("title")}
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
{tDropzone("description")}
</Text>
</div>
</Group>
</Dropzone>
);
};

View File

@@ -0,0 +1,53 @@
"use client";
import { startTransition, useState } from "react";
import { Card, Stack } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { InitialOldmarrImport } from "@homarr/old-import/components";
import { FileInfoCard } from "./file-info-card";
import { ImportDropZone } from "./import-dropzone";
export const InitImport = () => {
const [file, setFile] = useState<FileWithPath | null>(null);
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
null,
);
if (!file) {
return (
<Card w={64 * 12 + 8} maw="90vw" withBorder>
<ImportDropZone
loading={isPending}
updateFile={(file) => {
const formData = new FormData();
formData.append("file", file);
mutate(formData, {
onSuccess: (result) => {
startTransition(() => {
setAnalyseResult(result);
setFile(file);
});
},
onError: (error) => {
console.error(error);
},
});
}}
/>
</Card>
);
}
return (
<Stack mb="sm">
<FileInfoCard file={file} onRemove={() => setFile(null)} />
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
</Stack>
);
};

View File

@@ -0,0 +1,156 @@
"use client";
import { startTransition } from "react";
import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import type { CheckboxProps } from "@homarr/form/types";
import { defaultServerSettings } from "@homarr/server-settings";
import type { TranslationObject } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
export const InitSettings = () => {
const tSection = useScopedI18n("management.page.settings.section");
const t = useI18n();
const { mutateAsync } = clientApi.serverSettings.initSettings.useMutation();
const form = useZodForm(validation.settings.init, { initialValues: defaultServerSettings });
form.watch("analytics.enableGeneral", ({ value }) => {
if (!value) {
startTransition(() => {
form.setFieldValue("analytics.enableWidgetData", false);
form.setFieldValue("analytics.enableIntegrationData", false);
form.setFieldValue("analytics.enableUserData", false);
});
}
});
const handleSubmitAsync = async (values: z.infer<typeof validation.settings.init>) => {
await mutateAsync(values, {
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
};
return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack>
<Card w={64 * 12 + 8} maw="90vw" withBorder>
<Stack gap="sm">
<Text fw={500}>{tSection("analytics.title")}</Text>
<Stack gap="xs">
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
<Stack gap="xs" ps="md" w="100%">
<AnalyticsRow
kind="integrationData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableWidgetData", { type: "checkbox" })}
/>
<AnalyticsRow
kind="widgetData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableIntegrationData", { type: "checkbox" })}
/>
<AnalyticsRow
kind="usersData"
disabled={!form.values.analytics.enableGeneral}
{...form.getInputProps("analytics.enableUserData", { type: "checkbox" })}
/>
</Stack>
</Stack>
</Stack>
</Card>
<Card w={64 * 12 + 8} maw="90vw" withBorder>
<Stack gap="sm">
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
<Stack gap="xs">
<CrawlingRow
kind="noIndex"
{...form.getInputProps("crawlingAndIndexing.noIndex", { type: "checkbox" })}
/>
<CrawlingRow
kind="noFollow"
{...form.getInputProps("crawlingAndIndexing.noFollow", { type: "checkbox" })}
/>
<CrawlingRow
kind="noTranslate"
{...form.getInputProps("crawlingAndIndexing.noTranslate", { type: "checkbox" })}
/>
<CrawlingRow
kind="noSiteLinksSearchBox"
{...form.getInputProps("crawlingAndIndexing.noSiteLinksSearchBox", { type: "checkbox" })}
/>
</Stack>
</Stack>
</Card>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")}
</Button>
</Stack>
</form>
);
};
interface AnalyticsRowProps {
kind: Exclude<keyof TranslationObject["management"]["page"]["settings"]["section"]["analytics"], "title">;
disabled?: boolean;
}
const AnalyticsRow = ({ kind, ...props }: AnalyticsRowProps & CheckboxProps) => {
const tSection = useI18n("management.page.settings.section");
return (
<SettingRow title={tSection(`analytics.${kind}.title`)} text={tSection(`analytics.${kind}.text`)} {...props} />
);
};
interface CrawlingRowProps {
kind: Exclude<
keyof TranslationObject["management"]["page"]["settings"]["section"]["crawlingAndIndexing"],
"title" | "warning"
>;
}
const CrawlingRow = ({ kind, ...inputProps }: CrawlingRowProps & CheckboxProps) => {
const tSection = useI18n("management.page.settings.section");
return (
<SettingRow
title={tSection(`crawlingAndIndexing.${kind}.title`)}
text={tSection(`crawlingAndIndexing.${kind}.text`)}
{...inputProps}
/>
);
};
const SettingRow = ({
title,
text,
disabled,
...inputProps
}: { title: string; text: string; disabled?: boolean } & CheckboxProps) => {
return (
<Group wrap="nowrap" align="center">
<Stack gap={0} style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{title}
</Text>
<Text size="xs" c="gray.5">
{text}
</Text>
</Stack>
<Switch disabled={disabled} {...inputProps} />
</Group>
);
};

View File

@@ -0,0 +1,32 @@
import { Card, Stack, Text } from "@mantine/core";
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import { InitStartButton } from "./next-button";
export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start");
return (
<Card w={64 * 6} maw="90vw" withBorder>
<Stack>
<Text>{tStart("description")}</Text>
<InitStartButton
preferredStep={undefined}
icon={<IconPlayerPlay color={getMantineColor("green", 6)} size={16} stroke={1.5} />}
>
{tStart("action.scratch")}
</InitStartButton>
<InitStartButton
preferredStep="import"
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
>
{tStart("action.importOldmarr")}
</InitStartButton>
</Stack>
</Card>
);
};

View File

@@ -0,0 +1,28 @@
"use client";
import type { PropsWithChildren, ReactNode } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { OnboardingStep } from "@homarr/definitions";
interface InitStartButtonProps {
icon: ReactNode;
preferredStep: OnboardingStep | undefined;
}
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
const handleClickAsync = async () => {
await mutateAsync({ preferredStep });
await revalidatePathActionAsync("/init");
};
return (
<Button onClick={handleClickAsync} variant="default" leftSection={icon}>
{children}
</Button>
);
};

View File

@@ -1,9 +1,9 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
@@ -12,9 +12,9 @@ import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
export const InitUserForm = () => {
const router = useRouter();
const t = useScopedI18n("user");
const { mutateAsync, error, isPending } = clientApi.user.initUser.useMutation();
const tUser = useScopedI18n("init.step.user");
const { mutateAsync, isPending } = clientApi.user.initUser.useMutation();
const form = useZodForm(validation.user.init, {
initialValues: {
username: "",
@@ -25,17 +25,17 @@ export const InitUserForm = () => {
const handleSubmitAsync = async (values: FormType) => {
await mutateAsync(values, {
onSuccess: () => {
async onSuccess() {
showSuccessNotification({
title: "User created",
message: "You can now log in",
title: tUser("notification.success.title"),
message: tUser("notification.success.message"),
});
router.push("/auth/login");
await revalidatePathActionAsync("/init");
},
onError: () => {
onError: (error) => {
showErrorNotification({
title: "User creation failed",
message: error?.message ?? "Unknown error",
title: tUser("notification.error.title"),
message: error.message,
});
},
});

View File

@@ -0,0 +1,11 @@
import { Card } from "@mantine/core";
import { InitUserForm } from "./init-user-form";
export const InitUser = () => {
return (
<Card w={64 * 6} maw="90vw" withBorder>
<InitUserForm />
</Card>
);
};

View File

@@ -0,0 +1,56 @@
import type { JSX } from "react";
import { Box, Center, Stack, Text, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import type { MaybePromise } from "@homarr/common/types";
import type { OnboardingStep } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { BackToStart } from "./_steps/back";
import { InitFinish } from "./_steps/finish/init-finish";
import { InitGroup } from "./_steps/group/init-group";
import { InitImport } from "./_steps/import/init-import";
import { InitSettings } from "./_steps/settings/init-settings";
import { InitStart } from "./_steps/start/init-start";
import { InitUser } from "./_steps/user/init-user";
const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Element>)> = {
start: InitStart,
import: InitImport,
user: InitUser,
group: InitGroup,
settings: InitSettings,
finish: InitFinish,
};
export default async function InitPage() {
const t = await getScopedI18n("init.step");
const currentStep = await api.onboard.currentStep();
const CurrentComponent = stepComponents[currentStep.current];
return (
<Box mih="100dvh">
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t(`${currentStep.current}.title`)}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t(`${currentStep.current}.subtitle`)}
</Text>
</Stack>
<CurrentLanguageCombobox width="100%" />
<CurrentColorSchemeCombobox w="100%" />
{CurrentComponent && <CurrentComponent />}
{currentStep.previous === "start" && <BackToStart />}
</Stack>
</Center>
</Box>
);
}

View File

@@ -1,41 +0,0 @@
import { notFound } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { db } from "@homarr/db";
import { getScopedI18n } from "@homarr/translation/server";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { InitUserForm } from "./_init-user-form";
export default async function InitUser() {
const firstUser = await db.query.users.findFirst({
columns: {
id: true,
},
});
if (firstUser) {
notFound();
}
const t = await getScopedI18n("user.page.init");
return (
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t("subtitle")}
</Text>
</Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw">
<InitUserForm />
</Card>
</Stack>
</Center>
);
}

View File

@@ -1,13 +1,14 @@
"use client";
import { useState } from "react";
import { ActionIcon, Avatar, Button, Card, Collapse, Group, Kbd, Stack, Text } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Button, Card, Collapse, Group, Kbd, Stack, Text, Tooltip } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconEye, IconEyeOff } from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type { RouterOutputs } from "@homarr/api";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { integrationSecretKindObject } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
@@ -16,7 +17,9 @@ import { integrationSecretIcons } from "./integration-secret-icons";
dayjs.extend(relativeTime);
interface SecretCardProps {
secret: RouterOutputs["integration"]["byId"]["secrets"][number];
secret:
| RouterOutputs["integration"]["byId"]["secrets"][number]
| { kind: IntegrationSecretKind; value: null; updatedAt: null };
children: React.ReactNode;
onCancel: () => Promise<boolean>;
}
@@ -41,11 +44,19 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
</Group>
<Group>
<Text c="gray.6" size="sm">
{t("integration.secrets.lastUpdated", {
date: dayjs().to(dayjs(secret.updatedAt)),
})}
</Text>
{secret.updatedAt ? (
<Text c="gray.6" size="sm">
{t("integration.secrets.lastUpdated", {
date: dayjs().to(dayjs(secret.updatedAt)),
})}
</Text>
) : (
<Tooltip label={t("integration.secrets.notSet.tooltip")} position="left">
<Badge color="orange" variant="light" size="sm">
{t("integration.secrets.notSet.label")}
</Badge>
</Tooltip>
)}
{isPublic ? (
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
<DisplayIcon size={16} stroke={1.5} />

View File

@@ -98,8 +98,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
{secretsKinds.map((kind, index) => (
<SecretCard
key={kind}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secret={secretsMap.get(kind)!}
secret={secretsMap.get(kind) ?? { kind, value: null, updatedAt: null }}
onCancel={() =>
new Promise((resolve) => {
// When nothing changed, just close the secret card

View File

@@ -0,0 +1,49 @@
"use client";
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
interface CurrentColorSchemeComboboxProps {
w?: string;
}
export const CurrentColorSchemeCombobox = ({ w }: CurrentColorSchemeComboboxProps) => {
const tOptions = useScopedI18n("common.colorScheme.options");
const { colorScheme, setColorScheme } = useMantineColorScheme();
return (
<SelectWithCustomItems
value={colorScheme}
onChange={(value) => setColorScheme((value as ColorScheme | null) ?? "light")}
data={colorSchemes.map((scheme) => ({
value: scheme,
label: tOptions(scheme),
}))}
SelectOption={ColorSchemeCustomOption}
w={w}
/>
);
};
const appearanceIcons = {
light: IconSun,
dark: IconMoon,
};
const ColorSchemeCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
const Icon = appearanceIcons[value];
return (
<Group>
<Icon size={16} stroke={1.5} />
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
);
};

View File

@@ -4,9 +4,13 @@ import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
import { LanguageCombobox } from "./language-combobox";
export const CurrentLanguageCombobox = () => {
interface CurrentLanguageComboboxProps {
width?: string;
}
export const CurrentLanguageCombobox = ({ width }: CurrentLanguageComboboxProps) => {
const currentLocale = useCurrentLocale();
const { changeLocale, isPending } = useChangeLocale();
return <LanguageCombobox value={currentLocale} onChange={changeLocale} isPending={isPending} />;
return <LanguageCombobox value={currentLocale} onChange={changeLocale} isPending={isPending} width={width} />;
};

View File

@@ -9,14 +9,17 @@ import { localeConfigurations, supportedLanguages } from "@homarr/translation";
import classes from "./language-combobox.module.css";
import "flag-icons/css/flag-icons.min.css";
interface LanguageComboboxProps {
label?: string;
value: SupportedLanguage;
onChange: (value: SupportedLanguage) => void;
isPending?: boolean;
width?: string;
}
export const LanguageCombobox = ({ label, value, onChange, isPending }: LanguageComboboxProps) => {
export const LanguageCombobox = ({ label, value, onChange, isPending, width }: LanguageComboboxProps) => {
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
@@ -49,6 +52,7 @@ export const LanguageCombobox = ({ label, value, onChange, isPending }: Language
rightSectionPointerEvents="none"
onClick={handleOnClick}
variant="filled"
w={width}
>
<OptionItem currentLocale={value} localeKey={value} />
</InputBase>

View File

@@ -18,14 +18,11 @@ import {
IconTool,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import "flag-icons/css/flag-icons.min.css";
import type { RouterOutputs } from "@homarr/api";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";

View File

@@ -1,3 +1,4 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createTRPCClient, httpLink } from "@trpc/client";
import SuperJSON from "superjson";
@@ -11,6 +12,15 @@ export async function middleware(request: NextRequest) {
// In next 15 we will be able to use node apis and such the db directly
const culture = await serverFetchApi.serverSettings.getCulture.query();
// Redirect to onboarding if it's not finished yet
const pathname = request.nextUrl.pathname;
if (!pathname.endsWith("/init")) {
const currentOnboardingStep = await serverFetchApi.onboard.currentStep.query();
if (currentOnboardingStep.current !== "finish") {
return NextResponse.redirect(new URL("/init", request.url));
}
}
// We don't want to fallback to accept-language header so we clear it
request.headers.set("accept-language", "");
const next = createI18nMiddleware(culture.defaultLocale);