refactor(ui): adopt Mantine Marquee and drop mount guards
Rebuild the integration marquee on Mantine's Marquee, SimpleGrid and ThemeIcon instead of the hand-rolled grid, track and keyframes, and reduce the stylesheet to the mask and reduced-motion rule. Drop the useMounted guards from the login form and let the buttons rely on the pending state alone, and use Group for the restore flow's file summary.
This commit is contained in:
5 files changed
+81
-95
No files matched your search
@@ -4,7 +4,7 @@ import type { PropsWithChildren } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useDisclosure, useMounted } from "@mantine/hooks";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { signIn } from "@homarr/auth/client";
|
||||
@@ -32,7 +32,6 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
const searchParams = useSearchParams();
|
||||
const isError = searchParams.has("error");
|
||||
const router = useRouter();
|
||||
const isMounted = useMounted();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const form = useZodForm(extendedValidation, {
|
||||
initialValues: {
|
||||
@@ -140,7 +139,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
|
||||
{providers.includes("credentials") && (
|
||||
<Stack gap="sm">
|
||||
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="credentials">
|
||||
<SubmitButton isPending={isPending} form={form} provider="credentials">
|
||||
{t("action.login.label")}
|
||||
</SubmitButton>
|
||||
<PasswordForgottenCollapse username={form.values.name} />
|
||||
@@ -148,7 +147,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
)}
|
||||
|
||||
{providers.includes("ldap") && (
|
||||
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="ldap">
|
||||
<SubmitButton isPending={isPending} form={form} provider="ldap">
|
||||
{t("action.login.labelWith", { provider: "LDAP" })}
|
||||
</SubmitButton>
|
||||
)}
|
||||
@@ -159,13 +158,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
)}
|
||||
|
||||
{providers.includes("oidc") && (
|
||||
<Button
|
||||
type="button"
|
||||
fullWidth
|
||||
variant="light"
|
||||
disabled={!isMounted || isPending}
|
||||
onClick={() => void signInAsync("oidc")}
|
||||
>
|
||||
<Button type="button" fullWidth variant="light" disabled={isPending} onClick={() => void signInAsync("oidc")}>
|
||||
{t("action.login.labelWith", { provider: oidcClientName })}
|
||||
</Button>
|
||||
)}
|
||||
@@ -175,13 +168,12 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
|
||||
};
|
||||
|
||||
interface SubmitButtonProps {
|
||||
isMounted: boolean;
|
||||
isPending: boolean;
|
||||
form: UseFormReturnType<FormType>;
|
||||
provider: "credentials" | "ldap";
|
||||
}
|
||||
|
||||
const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
|
||||
const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
|
||||
const isCurrentProviderActive = form.getValues().provider === provider;
|
||||
|
||||
return (
|
||||
@@ -192,7 +184,7 @@ const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsW
|
||||
fullWidth
|
||||
onClick={() => form.setFieldValue("provider", provider)}
|
||||
loading={isPending && isCurrentProviderActive}
|
||||
disabled={!isMounted || (isPending && !isCurrentProviderActive)}
|
||||
disabled={isPending && !isCurrentProviderActive}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
@@ -254,14 +254,14 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" miw={0}>
|
||||
<IconFileZip size={20} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Group>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{file?.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
<Button variant="subtle" size="xs" onClick={handleClear} style={{ flexShrink: 0 }}>
|
||||
{t("changeFile")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Center, Paper, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
@@ -13,13 +14,18 @@ import { Finish } from "./finish";
|
||||
import { SetupStudio } from "./setup-studio";
|
||||
import { Welcome } from "./welcome";
|
||||
|
||||
type ClaimState = "checking" | "ready" | "locked" | "signIn" | "error";
|
||||
|
||||
const getInitialClaimState = (accessState: ReturnType<typeof getOnboardingAccessState>): ClaimState => {
|
||||
if (accessState === "claim") return "checking";
|
||||
return accessState;
|
||||
};
|
||||
|
||||
export const OnboardingStudio = (props: OnboardingStudioProps) => {
|
||||
const t = useScopedI18n("init.studio.claim");
|
||||
const tCommon = useScopedI18n("common.action");
|
||||
const accessState = getOnboardingAccessState(props.environment);
|
||||
const [claimState, setClaimState] = useState<"checking" | "ready" | "locked" | "signIn" | "error">(
|
||||
accessState === "claim" ? "checking" : accessState,
|
||||
);
|
||||
const [claimState, setClaimState] = useState<ClaimState>(() => getInitialClaimState(accessState));
|
||||
const [claimError, setClaimError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -56,43 +62,52 @@ export const OnboardingStudio = (props: OnboardingStudioProps) => {
|
||||
}, [accessState, t]);
|
||||
|
||||
if (claimState !== "ready") {
|
||||
const signIn = claimState === "signIn";
|
||||
let title = t("errorTitle");
|
||||
let description = claimError ?? t("errorDescription");
|
||||
let action: ReactNode = (
|
||||
<Stack align="center">
|
||||
<Alert color="red" role="alert">
|
||||
{description}
|
||||
</Alert>
|
||||
<Button onClick={() => window.location.reload()}>{tCommon("tryAgain")}</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
switch (claimState) {
|
||||
case "checking":
|
||||
title = t("checkingTitle");
|
||||
description = t("checking");
|
||||
action = null;
|
||||
break;
|
||||
case "signIn":
|
||||
title = t("signInTitle");
|
||||
description = t("signInDescription");
|
||||
action = (
|
||||
<Button component={Link} href="/auth/login?callbackUrl=%2Finit">
|
||||
{t("signIn")}
|
||||
</Button>
|
||||
);
|
||||
break;
|
||||
case "locked":
|
||||
title = t("lockedTitle");
|
||||
description = t("lockedDescription");
|
||||
action = <Button onClick={() => window.location.reload()}>{tCommon("tryAgain")}</Button>;
|
||||
break;
|
||||
case "error":
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Center mih="100dvh" p="md">
|
||||
<Paper withBorder radius="lg" p="xl" maw="32rem" w="100%">
|
||||
<Stack align="center">
|
||||
<Title order={1} size="h2" ta="center">
|
||||
{claimState === "checking"
|
||||
? t("checkingTitle")
|
||||
: signIn
|
||||
? t("signInTitle")
|
||||
: claimState === "locked"
|
||||
? t("lockedTitle")
|
||||
: t("errorTitle")}
|
||||
{title}
|
||||
</Title>
|
||||
<Text c="dimmed" ta="center">
|
||||
{signIn
|
||||
? t("signInDescription")
|
||||
: claimState === "locked"
|
||||
? t("lockedDescription")
|
||||
: claimState === "checking"
|
||||
? t("checking")
|
||||
: (claimError ?? t("errorDescription"))}
|
||||
{description}
|
||||
</Text>
|
||||
{signIn ? (
|
||||
<Button component={Link} href="/auth/login?callbackUrl=%2Finit">
|
||||
{t("signIn")}
|
||||
</Button>
|
||||
) : claimState === "locked" ? (
|
||||
<Button onClick={() => window.location.reload()}>{tCommon("tryAgain")}</Button>
|
||||
) : claimState === "error" ? (
|
||||
<Stack align="center">
|
||||
<Alert color="red" role="alert">
|
||||
{claimError ?? t("errorDescription")}
|
||||
</Alert>
|
||||
<Button onClick={() => window.location.reload()}>{tCommon("tryAgain")}</Button>
|
||||
</Stack>
|
||||
) : null}
|
||||
{action}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
|
||||
@@ -1,41 +1,9 @@
|
||||
.root {
|
||||
--integration-marquee-accent: var(--mantine-primary-color-filled);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--mantine-spacing-xl);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-xl);
|
||||
animation: integrationMarquee var(--marquee-duration) linear infinite;
|
||||
}
|
||||
|
||||
.iconFrame {
|
||||
display: grid;
|
||||
flex: 0 0 4.5rem;
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
place-items: center;
|
||||
border: 1px solid color-mix(in srgb, var(--integration-marquee-accent) 24%, transparent);
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
background: color-mix(in srgb, var(--integration-marquee-accent) 8%, var(--mantine-color-body));
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
|
||||
@keyframes integrationMarquee {
|
||||
to {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.marquee {
|
||||
mask-image: linear-gradient(to bottom, transparent, black 14%, black 86%, transparent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.track {
|
||||
.content {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { Image } from "@mantine/core";
|
||||
import { Image, Marquee, SimpleGrid, ThemeIcon } from "@mantine/core";
|
||||
|
||||
import { splitToNChunks } from "@homarr/common";
|
||||
import { integrationDefs } from "@homarr/definitions";
|
||||
@@ -15,19 +14,31 @@ const iconGroups = splitToNChunks(
|
||||
const animationDuration = iconGroups.flat().length * 2;
|
||||
|
||||
export const IntegrationMarquee = ({ className }: { className?: string }) => (
|
||||
<div className={[classes.root, className].filter(Boolean).join(" ")} aria-hidden>
|
||||
<SimpleGrid
|
||||
className={className}
|
||||
cols={3}
|
||||
spacing="xl"
|
||||
h="calc(100% + 6rem)"
|
||||
my="-3rem"
|
||||
style={{ transform: "rotate(10deg)" }}
|
||||
aria-hidden
|
||||
>
|
||||
{iconGroups.map((icons, columnIndex) => (
|
||||
<div
|
||||
<Marquee
|
||||
key={columnIndex}
|
||||
className={classes.track}
|
||||
style={{ "--marquee-duration": `${animationDuration - columnIndex * 3}s` } as CSSProperties}
|
||||
orientation="vertical"
|
||||
repeat={2}
|
||||
duration={(animationDuration - columnIndex * 3) * 1000}
|
||||
gap="xl"
|
||||
fadeEdges={false}
|
||||
h="100%"
|
||||
className={classes.marquee}
|
||||
classNames={{ content: classes.content }}
|
||||
>
|
||||
{[...icons, ...icons].map((icon, iconIndex) => (
|
||||
<div key={`${icon}-${iconIndex}`} className={classes.iconFrame}>
|
||||
<Image src={icon} alt="" fit="contain" w={52} h={52} />
|
||||
</div>
|
||||
{icons.map((icon) => (
|
||||
<Image key={`${icon}-${columnIndex}`} src={icon} alt="" fit="contain" w={52} h={52} />
|
||||
))}
|
||||
</div>
|
||||
</Marquee>
|
||||
))}
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
);
|
||||
Reference in new issue
Block a user