353 lines
11 KiB
TypeScript
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 <></>;
|
|
}
|