Files
2025-06-17 19:46:43 +08:00

353 lines
11 KiB
TypeScript

"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { cn, removeUrlSuffix } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Modal } from "@/components/ui/modal";
import { FormSectionColumns } from "@/components/dashboard/form-section-columns";
import { Icons } from "@/components/shared/icons";
export default function StepGuide({
user,
}: {
user: { id: string; email: string };
}) {
const router = useRouter();
const [currentStep, setCurrentStep] = useState(1);
const [direction, setDirection] = useState(0);
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
const t = useTranslations("Common");
const steps = [
{
id: 1,
title: t("Set up an administrator"),
component: () => <SetAdminRole id={user.id} email={user.email} />,
},
{
id: 2,
title: t("Add the first domain"),
component: () => <AddDomain onNextStep={goToNextStep} />,
},
{
id: 3,
title: t("Congrats on completing setup 🎉"),
component: () => <Congrats />,
},
];
const goToNextStep = () => {
if (currentStep < steps.length) {
setDirection(1);
setCurrentStep(currentStep + 1);
if (!completedSteps.includes(currentStep)) {
setCompletedSteps([...completedSteps, currentStep]);
}
} else if (currentStep === steps.length) {
router.push("/admin");
}
};
const goToPreviousStep = () => {
if (currentStep > 1) {
setDirection(-1);
setCurrentStep(currentStep - 1);
}
};
const currentStepData =
steps.find((step) => step.id === currentStep) || steps[0];
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 100 : -100,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 100 : -100,
opacity: 0,
}),
};
return (
<Modal className="md:max-w-2xl">
<div className="w-full px-4 py-2 md:px-8 md:py-4">
<div className="mb-6 mt-3 flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold">{t("Admin Setup Guide")}</h2>
<div className="flex items-center gap-2 rounded-full bg-muted/50 px-3 py-1.5 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
{currentStep}
</span>
<span className="text-muted-foreground">of</span>
<span>{steps.length}</span>
</div>
</div>
{/* Content area */}
<div className="relative w-full rounded-lg">
<AnimatePresence custom={direction} mode="wait">
<motion.div
key={currentStep}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.4, ease: "easeInOut" }}
className="flex flex-col justify-center gap-6"
>
<div className="flex h-full w-full flex-col">
<div className="mb-2 flex items-center gap-1 rounded-lg bg-neutral-100 p-2 dark:bg-neutral-800">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{currentStep}
</span>
<motion.h3
className="text-base font-semibold"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
{currentStepData.title}
</motion.h3>
</div>
<motion.div
className="h-full"
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
{currentStepData.component()}
</motion.div>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
<motion.div
className="mt-auto flex justify-between px-4 pb-4 pt-3 md:px-8 md:pb-6"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.4 }}
>
<button
onClick={goToPreviousStep}
disabled={currentStep === 1}
className={cn(
"flex items-center gap-2 rounded-md px-4 py-2 transition-colors",
currentStep === 1
? "cursor-not-allowed bg-muted text-muted-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
)}
>
<ChevronLeft className="h-4 w-4" />
{t("Previous")}
</button>
<button
onClick={goToNextStep}
// disabled={currentStep === steps.length}
className={cn(
"flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90",
)}
>
{currentStep === steps.length ? t("🚀 Start") : t("Next")}
<ChevronRight className="h-4 w-4" />
</button>
</motion.div>
</Modal>
);
}
function SetAdminRole({ id, email }: { id: string; email: string }) {
const [isPending, startTransition] = useTransition();
const [isAdmin, setIsAdmin] = useState(false);
const t = useTranslations("Common");
const handleSetAdmin = async () => {
startTransition(async () => {
const res = await fetch("/api/setup");
if (res.ok) {
setIsAdmin(true);
}
});
};
const ReadyBadge = (
<Badge className="text-xs font-semibold" variant="green">
<Icons.check className="mr-1 size-3" />
{t("Ready")}
</Badge>
);
return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-muted-foreground">
{t("Allow Sign Up")}:
</span>
{ReadyBadge}
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-muted-foreground">
{t("Set {email} as ADMIN", { email })}:
</span>
{isAdmin ? (
ReadyBadge
) : (
<Button
variant={"default"}
size={"sm"}
onClick={handleSetAdmin}
disabled={isPending}
>
{isPending && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
{t("Active Now")}
</Button>
)}
</div>
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
<p className="flex items-start gap-1">
{t("After v1-0-2, this setup guide is not needed anymore")}.
</p>
<p className="flex items-start gap-1">
{" "}
{t(
"Only by becoming an administrator can one access the admin panel and add domain names",
)}
.
</p>
<p className="my-1">
{" "}
{t(
"Administrators can set all user permissions, allocate quotas, view and edit all resources (short links, subdomains, email), etc",
)}
.
</p>
<p>
{t("Via")}{" "}
<a
className="text-blue-500 after:content-['_↗']"
target="_blank"
href="/docs/developer/quick-start"
>
{t("quick start")}
</a>{" "}
{t("docs to get more information")}.
</p>
</div>
</div>
);
}
function AddDomain({ onNextStep }: { onNextStep: () => void }) {
const [isPending, startTransition] = useTransition();
const [domain, setDomain] = useState("");
const t = useTranslations("Common");
const handleCreateDomain = async () => {
if (!domain) {
toast.warning("Domain name cannot be empty");
return;
}
startTransition(async () => {
const res = await fetch("/api/admin/domain", {
method: "POST",
body: JSON.stringify({
data: {
domain_name: removeUrlSuffix(domain),
enable_short_link: true,
enable_email: true,
enable_dns: true,
cf_zone_id: "",
cf_api_key: "",
cf_email: "",
cf_api_key_encrypted: false,
max_short_links: 0,
max_email_forwards: 0,
max_dns_records: 0,
active: true,
},
}),
});
if (res.ok) {
onNextStep();
} else {
toast.error("Created Failed!", {
description: await res.text(),
});
}
});
};
return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900">
<FormSectionColumns title={t("Domain Name")}>
<div className="flex w-full flex-col items-start justify-between gap-2">
<Label className="sr-only" htmlFor="domain_name">
{t("Domain Name")}
</Label>
<div className="w-full">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner dark:bg-neutral-600"
size={32}
placeholder="example.com"
onChange={(e) => setDomain(e.target.value)}
/>
</div>
<p className="text-xs text-muted-foreground">
{t(
"Please enter a valid domain name (must be hosted on Cloudflare)",
)}
.
</p>
</div>
<div className="mt-2 flex w-full items-center justify-end gap-3">
<Button
className="text-xs text-muted-foreground"
variant={"ghost"}
size={"sm"}
onClick={onNextStep}
>
{t("Or add later")}
</Button>
<Button
className="flex items-center gap-1"
size={"sm"}
variant={"blue"}
disabled={isPending}
onClick={handleCreateDomain}
>
{isPending && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
{t("Submit")}
</Button>
</div>
</FormSectionColumns>
</div>
);
}
function Congrats() {
return <></>;
}