Files
homarr/packages/onboarding/src/welcome.tsx
T

182 lines
6.7 KiB
TypeScript

"use client";
import { useState } from "react";
import { Alert, Badge, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import { IconArrowRight, IconDatabaseImport, IconLayoutDashboard, IconSparkles } from "@tabler/icons-react";
import { motion, useReducedMotion } from "motion/react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { OnboardingStudioProps } from "./types";
import classes from "./onboarding-studio.module.css";
export const Welcome = ({ environment, sqliteRestore }: OnboardingStudioProps) => {
const t = useScopedI18n("init.studio.welcome");
const reduceMotion = useReducedMotion();
const [showRestore, setShowRestore] = useState(false);
const [claimPending, setClaimPending] = useState(false);
const [claimError, setClaimError] = useState<string | null>(null);
const start = clientApi.onboard.nextStep.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
const claimOnboardingAsync = async () => {
setClaimPending(true);
setClaimError(null);
try {
const response = await fetch("/api/onboarding/claim", { method: "POST" });
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error ?? t("errorTitle"));
}
return true;
} catch (error) {
setClaimError(error instanceof Error ? error.message : t("errorTitle"));
return false;
} finally {
setClaimPending(false);
}
};
const startOnboardingAsync = async () => {
if (await claimOnboardingAsync()) await start.mutateAsync();
};
const toggleRestoreAsync = async () => {
if (showRestore) {
setShowRestore(false);
return;
}
if (await claimOnboardingAsync()) setShowRestore(true);
};
return (
<main className={classes.page}>
<div className={classes.shell}>
<Group className={classes.topbar} justify="space-between">
<motion.img
src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.svg"
alt="Homarr"
className={classes.wordmark}
initial={reduceMotion ? false : { opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45 }}
/>
<Badge variant="light" size="lg">
{t("badge")}
</Badge>
</Group>
<section className={classes.hero} aria-labelledby="onboarding-welcome-title">
<Stack gap="xl" align="flex-start">
<Stack gap="md">
<Text c="dimmed" fw={650} size="sm">
{t("eyebrow")}
</Text>
<Title id="onboarding-welcome-title" className={classes.heroTitle}>
{t("title")}
</Title>
<Text size="lg" c="dimmed" maw="38rem">
{t("description")}
</Text>
</Stack>
<Group className={classes.primaryActions} gap="sm">
<Button
size="lg"
rightSection={<IconArrowRight size={18} />}
loading={start.isPending || claimPending}
onClick={() => void startOnboardingAsync()}
>
{t("start")}
</Button>
{environment.databaseDriver === "sqlite" && sqliteRestore ? (
<Button
size="lg"
variant="default"
leftSection={<IconDatabaseImport size={18} />}
onClick={() => void toggleRestoreAsync()}
loading={claimPending}
aria-expanded={showRestore}
aria-controls="sqlite-restore-flow"
>
{t("restore")}
</Button>
) : null}
</Group>
{showRestore && sqliteRestore ? (
<Paper id="sqlite-restore-flow" withBorder p="lg" radius="lg" w="100%" maw="38rem">
<Stack>
<Group gap="xs">
<ThemeIcon variant="light" color="orange">
<IconDatabaseImport size={18} />
</ThemeIcon>
<Text fw={650}>{t("restoreTitle")}</Text>
</Group>
<Text size="sm" c="dimmed">
{t("restoreDescription")}
</Text>
{sqliteRestore}
</Stack>
</Paper>
) : null}
{claimError || start.error ? (
<Alert color="red" title={t("errorTitle")} maw="38rem">
{claimError ?? start.error?.message}
</Alert>
) : null}
</Stack>
<motion.div
initial={reduceMotion ? false : { opacity: 0, scale: 0.96, y: 24 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.55, delay: 0.08 }}
>
<Paper className={classes.heroPanel} radius="lg" p="xl">
<div className={classes.orbit} aria-hidden>
{[IconLayoutDashboard, IconSparkles, IconDatabaseImport, IconLayoutDashboard].map((Icon, index) => (
<motion.div
key={index}
className={classes.orbitItem}
animate={reduceMotion ? undefined : { y: [0, index % 2 === 0 ? -6 : 6, 0] }}
transition={{ duration: 3.4 + index * 0.35, repeat: Infinity, ease: "easeInOut" }}
>
<Icon size={22} />
</motion.div>
))}
</div>
<Stack h="100%" justify="space-between" pos="relative">
<Badge variant="dot" color="green">
{t("ready")}
</Badge>
<Stack gap={4} align="center">
<ThemeIcon size={72} radius="xl" variant="light">
<IconLayoutDashboard size={34} />
</ThemeIcon>
<Text fw={700} size="lg">
{t("panelTitle")}
</Text>
<Text size="sm" c="dimmed" ta="center" maw="19rem">
{t("panelDescription")}
</Text>
</Stack>
<Group gap="xs" justify="center">
<Badge variant="light">{t("chipDiscover")}</Badge>
<Badge variant="light">{t("chipConnect")}</Badge>
<Badge variant="light">{t("chipBuild")}</Badge>
</Group>
</Stack>
</Paper>
</motion.div>
</section>
</div>
</main>
);
};