feat: support setup guide for admin
This commit is contained in:
+14
-3
@@ -56,11 +56,22 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
#### 初始化数据库
|
||||
|
||||
```bash
|
||||
pnpm postinstall
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
#### 激活管理员面板
|
||||
|
||||
Follow https://localhost:3000/setup
|
||||
|
||||
## 社区群组
|
||||
|
||||
- Discord: https://discord.gg/AHPQYuZu3m
|
||||
- 微信群:
|
||||
|
||||
- Discord: https://discord.gg/AHPQYuZu3m
|
||||
- 微信群:
|
||||
|
||||

|
||||
|
||||
## 许可证
|
||||
|
||||
@@ -57,6 +57,17 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
#### Init database
|
||||
|
||||
```bash
|
||||
pnpm postinstall
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
#### Setup Admin Panel
|
||||
|
||||
Follow https://localhost:3000/setup
|
||||
|
||||
## Legitimacy review
|
||||
|
||||
- To avoid abuse, applications without website content will be rejected
|
||||
@@ -68,9 +79,9 @@ pnpm dev
|
||||
|
||||
## Community Group
|
||||
|
||||
- Discord: https://discord.gg/AHPQYuZu3m
|
||||
- 微信群:
|
||||
|
||||
- Discord: https://discord.gg/AHPQYuZu3m
|
||||
- 微信群:
|
||||
|
||||

|
||||
|
||||
## License
|
||||
|
||||
@@ -2,20 +2,13 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Domain, User } from "@prisma/client";
|
||||
import { User } from "@prisma/client";
|
||||
import { PenLine, RefreshCwIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
import { DomainFormData } from "@/lib/dto/domains";
|
||||
import { ShortUrlFormData } from "@/lib/dto/short-urls";
|
||||
import {
|
||||
cn,
|
||||
expirationTime,
|
||||
fetcher,
|
||||
removeUrlSuffix,
|
||||
timeAgo,
|
||||
} from "@/lib/utils";
|
||||
import { fetcher, timeAgo } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -23,7 +16,6 @@ import {
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -81,10 +73,10 @@ export default function DomainList({ user, action }: DomainListProps) {
|
||||
useState<DomainFormData | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [isShowDomainInfo, setShowDomainInfo] = useState(false);
|
||||
const [selectedDomain, setSelectedDomain] = useState<DomainFormData | null>(
|
||||
null,
|
||||
);
|
||||
// const [isShowDomainInfo, setShowDomainInfo] = useState(false);
|
||||
// const [selectedDomain, setSelectedDomain] = useState<DomainFormData | null>(
|
||||
// null,
|
||||
// );
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
slug: "",
|
||||
target: "",
|
||||
@@ -169,7 +161,7 @@ export default function DomainList({ user, action }: DomainListProps) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
|
||||
{/* <div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="h-8 text-xs md:text-xs"
|
||||
@@ -192,7 +184,7 @@ export default function DomainList({ user, action }: DomainListProps) {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
||||
@@ -274,6 +266,7 @@ export default function DomainList({ user, action }: DomainListProps) {
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Switch
|
||||
disabled
|
||||
defaultChecked={domain.active}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(value, "active", domain)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { ScrapeInfoCard } from "@/components/dashboard/dashboard-info-card";
|
||||
import { StaticInfoCard } from "@/components/dashboard/dashboard-info-card";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import DashboardScrapeCharts from "./charts";
|
||||
@@ -26,22 +26,19 @@ export default async function DashboardPage() {
|
||||
linkText="Open API."
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
<StaticInfoCard
|
||||
title="Url to Screenshot"
|
||||
desc="Take a screenshot of the webpage."
|
||||
link="/dashboard/scrape/screenshot"
|
||||
icon="camera"
|
||||
/>
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
<StaticInfoCard
|
||||
title="Url to Meta Info"
|
||||
desc="Extract website metadata."
|
||||
link="/dashboard/scrape/meta-info"
|
||||
icon="globe"
|
||||
/>
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
<StaticInfoCard
|
||||
title="Url to QR Code"
|
||||
desc="Generate QR Code from URL."
|
||||
link="/dashboard/scrape/qrcode"
|
||||
@@ -49,15 +46,13 @@ export default async function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
<StaticInfoCard
|
||||
title="Url to Markdown"
|
||||
desc="Convert website content to Markdown format."
|
||||
link="/dashboard/scrape/markdown"
|
||||
icon="heading1"
|
||||
/>
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
<StaticInfoCard
|
||||
title="Url to Text"
|
||||
desc="Extract website text."
|
||||
link="/dashboard/scrape/markdown"
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
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 { Skeleton } from "@/components/ui/skeleton";
|
||||
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 [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Set up an administrator",
|
||||
description:
|
||||
"Begin by entering your website URL or selecting an example site to reimagine your website with modern themes.",
|
||||
component: () => <SetAdminRole id={user.id} email={user.email} />,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Add the first domain",
|
||||
description:
|
||||
"Check out your reimagined site and click to Migrate & Download.",
|
||||
component: () => <AddDomain onNextStep={goToNextStep} />,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Congrats on completing setup 🎉",
|
||||
description:
|
||||
"Navigate to your GitHub dashboard where you'll manage your repository and project files.",
|
||||
component: () => <Congrats />,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", checkMobile);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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">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">
|
||||
<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" />
|
||||
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 ? "🚀 Start" : "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 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" />
|
||||
Ready
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-neutral-500">
|
||||
Allow Sign Up:
|
||||
</span>
|
||||
{siteConfig.openSignup ? ReadyBadge : <Skeleton className="h-4 w-12" />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-neutral-500">
|
||||
Set {email} as ADMIN:
|
||||
</span>
|
||||
{isAdmin ? (
|
||||
ReadyBadge
|
||||
) : (
|
||||
<Button
|
||||
className=""
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
onClick={handleSetAdmin}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending && (
|
||||
<Icons.spinner className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
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">
|
||||
📢 Only by becoming an administrator can one access the admin panel
|
||||
and add domain names.
|
||||
</p>
|
||||
<p className="my-1">
|
||||
📢 Administrators can set all user permissions, allocate quotas, view
|
||||
and edit all resources (short links, subdomains, email), etc.
|
||||
</p>
|
||||
<p>
|
||||
📢 Via{" "}
|
||||
<a
|
||||
className="text-blue-500"
|
||||
target="_blank"
|
||||
href="/docs/developer/quick-start"
|
||||
>
|
||||
quick start
|
||||
</a>{" "}
|
||||
docs to get more information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddDomain({ onNextStep }: { onNextStep: () => void }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [domain, setDomain] = useState("");
|
||||
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">
|
||||
<FormSectionColumns title="Domain Name">
|
||||
<div className="flex w-full flex-col items-start justify-between gap-2">
|
||||
<Label className="sr-only" htmlFor="domain_name">
|
||||
Domain Name
|
||||
</Label>
|
||||
<div className="w-full">
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 bg-neutral-50 shadow-inner"
|
||||
size={32}
|
||||
placeholder="example.com"
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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}
|
||||
>
|
||||
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" />
|
||||
)}
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Congrats() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Manage Short URLs"
|
||||
text="List and manage short urls."
|
||||
/>
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getAllUsersCount } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
|
||||
import SetupGuide from "./guide";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Setup Guide",
|
||||
description: "Setup Guide",
|
||||
});
|
||||
|
||||
export default async function SetupPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
if (user.role === "ADMIN") redirect("/admin");
|
||||
|
||||
const count = await getAllUsersCount();
|
||||
|
||||
if (count === 1 && user.role === "USER") {
|
||||
return <SetupGuide user={{ id: user.id, email: user.email! }} />;
|
||||
}
|
||||
|
||||
return redirect("/admin");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { FeatureMap, getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { FeatureMap, getDomainsByFeatureClient } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
@@ -22,7 +22,8 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const domainList = await getDomainsByFeature(FeatureMap[feature]);
|
||||
const domainList = await getDomainsByFeatureClient(FeatureMap[feature]);
|
||||
|
||||
return Response.json(domainList, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import {
|
||||
checkUserStatus,
|
||||
getAllUsersCount,
|
||||
setFirstUserAsAdmin,
|
||||
} from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const count = await getAllUsersCount();
|
||||
|
||||
if (count === 1 && user.role === "USER") {
|
||||
const res = await setFirstUserAsAdmin(user.id);
|
||||
if (res) {
|
||||
return Response.json({ admin: res.role === "ADMIN" }, { status: 201 });
|
||||
}
|
||||
return Response.json({ admin: false }, { status: 400 });
|
||||
}
|
||||
|
||||
return redirect("/admin");
|
||||
} catch (error) {
|
||||
return Response.json(error?.statusText || error, {
|
||||
status: error.status || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"short_name": "WR.DO",
|
||||
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
|
||||
"appid": "com.wr.do",
|
||||
"versionName": "0.6.0",
|
||||
"versionName": "0.6.1",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
@@ -145,14 +145,12 @@ export function HeroCard({
|
||||
);
|
||||
}
|
||||
|
||||
export async function ScrapeInfoCard({
|
||||
userId,
|
||||
export async function StaticInfoCard({
|
||||
title,
|
||||
desc,
|
||||
link,
|
||||
icon = "users",
|
||||
}: {
|
||||
userId: string;
|
||||
title: string;
|
||||
desc?: string;
|
||||
link: string;
|
||||
|
||||
@@ -603,32 +603,34 @@ export default function EmailSidebar({
|
||||
{isLoadingDomains ? (
|
||||
<Skeleton className="h-9 w-1/3 rounded-none border-x-0 shadow-inner" />
|
||||
) : (
|
||||
emailDomains && (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setDomainSuffix(value);
|
||||
}}
|
||||
name="suffix"
|
||||
defaultValue={
|
||||
domainSuffix || emailDomains[0].domain_name
|
||||
}
|
||||
disabled={isEdit}
|
||||
>
|
||||
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{emailDomains.map((v) => (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setDomainSuffix(value);
|
||||
}}
|
||||
name="suffix"
|
||||
defaultValue={domainSuffix || "wr.do"}
|
||||
disabled={isEdit}
|
||||
>
|
||||
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{emailDomains && emailDomains.length > 0 ? (
|
||||
emailDomains.map((v) => (
|
||||
<SelectItem
|
||||
key={v.domain_name}
|
||||
value={v.domain_name}
|
||||
>
|
||||
@{v.domain_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
))
|
||||
) : (
|
||||
<Button className="w-full" variant="ghost">
|
||||
No domain
|
||||
</Button>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button
|
||||
className="rounded-l-none"
|
||||
|
||||
@@ -205,6 +205,7 @@ export function DomainForm({
|
||||
{...register("active")}
|
||||
defaultChecked={initData?.active ?? true}
|
||||
onCheckedChange={(value) => setValue("active", value)}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -212,28 +212,32 @@ export function RecordForm({
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-9 w-full" />
|
||||
) : (
|
||||
recordDomains && (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setValue("zone_name", value);
|
||||
setCurrentZoneName(value);
|
||||
}}
|
||||
name="zone_name"
|
||||
defaultValue={String(initData?.zone_name || "wr.do")}
|
||||
disabled={type === "edit"}
|
||||
>
|
||||
<SelectTrigger className="w-full shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{recordDomains.map((v) => (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setValue("zone_name", value);
|
||||
setCurrentZoneName(value);
|
||||
}}
|
||||
name="zone_name"
|
||||
defaultValue={String(initData?.zone_name || "wr.do")}
|
||||
disabled={type === "edit"}
|
||||
>
|
||||
<SelectTrigger className="w-full shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{recordDomains && recordDomains.length > 0 ? (
|
||||
recordDomains.map((v) => (
|
||||
<SelectItem key={v.domain_name} value={v.domain_name}>
|
||||
{v.domain_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
))
|
||||
) : (
|
||||
<Button className="w-full" variant="ghost">
|
||||
No domain
|
||||
</Button>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<p className="p-1 text-[13px] text-muted-foreground">
|
||||
Required. Select a domain.
|
||||
|
||||
@@ -204,29 +204,31 @@ export function UrlForm({
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-9 w-1/3 rounded-r-none border-r-0 shadow-inner" />
|
||||
) : (
|
||||
shortDomains && (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setValue("prefix", value);
|
||||
}}
|
||||
name="prefix"
|
||||
defaultValue={
|
||||
initData?.prefix || shortDomains[0].domain_name
|
||||
}
|
||||
disabled={type === "edit"}
|
||||
>
|
||||
<SelectTrigger className="w-1/3 rounded-r-none border-r-0 shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shortDomains.map((v) => (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setValue("prefix", value);
|
||||
}}
|
||||
name="prefix"
|
||||
defaultValue={initData?.prefix || "wr.do"}
|
||||
disabled={type === "edit"}
|
||||
>
|
||||
<SelectTrigger className="w-1/3 rounded-r-none border-r-0 shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shortDomains && shortDomains.length > 0 ? (
|
||||
shortDomains.map((v) => (
|
||||
<SelectItem key={v.domain_name} value={v.domain_name}>
|
||||
{v.domain_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
))
|
||||
) : (
|
||||
<Button className="w-full" variant="ghost">
|
||||
No domain
|
||||
</Button>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Input
|
||||
id="url"
|
||||
|
||||
@@ -15,6 +15,8 @@ const badgeVariants = cva(
|
||||
destructive:
|
||||
"bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground",
|
||||
outline: "text-foreground",
|
||||
blue: "bg-blue-500 hover:bg-blue-600 border-transparent text-white",
|
||||
green: "bg-green-500 hover:bg-green-600 border-transparent text-white",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -54,7 +54,6 @@ Copy/paste the `.env.example` in the `.env` file:
|
||||
|
||||
- How to get `GOOGLE_CLIENT_ID`、`GITHUB_ID`, see [Authentification](/docs/developer/authentification).
|
||||
- How to get `RESEND_API_KEY`, see [Email](/docs/developer/email).
|
||||
- How to get `DATABASE_URL`, see [Database](/docs/developer/database).
|
||||
- How to active email worker, see [Email Worker](/docs/developer/cloudflare-email-worker).
|
||||
|
||||
For step by step installation, see [Quick Start](/docs/developer/quick-start).
|
||||
|
||||
@@ -45,25 +45,23 @@ DATABASE_URL=
|
||||
|
||||
### Deploy Postgres
|
||||
|
||||
#### Manually install (Recommended)
|
||||
|
||||
Via [migration.sql](https://github.com/oiov/wr.do/blob/main/prisma/migrations/20240705091917_init/migration.sql),
|
||||
copy the sql code to the database to initialize the database schema.
|
||||
|
||||
#### or
|
||||
|
||||
```bash
|
||||
pnpm postinstall
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
#### Or manually init
|
||||
|
||||
Via [migration.sql](https://github.com/oiov/wr.do/blob/main/prisma/migrations),
|
||||
copy the sql code to the database to initialize the database schema.
|
||||
|
||||
### Add the AUTH_SECRET Environment Variable
|
||||
|
||||
The `AUTH_SECRET` environment variable is used to encrypt tokens and email verification hashes(NextAuth.js).
|
||||
You can generate one from https://generate-secret.vercel.app/32:
|
||||
|
||||
```js title=".env"
|
||||
AUTH_SECRET=a3e686f39b2a878c6866e4604e6f1b1b
|
||||
AUTH_SECRET=10000032bsfasfafk4lkkfsa
|
||||
```
|
||||
|
||||
## 2. Configure Authentication Service
|
||||
@@ -197,10 +195,14 @@ Follow the steps below:
|
||||
- 2. Via [http://localhost:3000/setup](http://localhost:3000/setup), change the account's role to ADMIN.
|
||||
- 3. Then follow the **panel guide** to config the system and add the first domain.
|
||||
|
||||
<Callout type="info">
|
||||
After change change the account's role to ADMIN, and then you can refresh the website and access http://localhost:3000/admin.
|
||||

|
||||
|
||||
<strong>You must add at least one domain to start using short links, email or subdomain management functions.</strong>
|
||||

|
||||
|
||||
<Callout type="info">
|
||||
After change the account's role to ADMIN, then you can refresh the website and access http://localhost:3000/admin.
|
||||
|
||||
<strong>You must add at least one domain to start using short links, email or subdomain management features.</strong>
|
||||
</Callout>
|
||||
|
||||
## Q & A
|
||||
@@ -215,17 +217,6 @@ https://dash.cloudflare.com/[account_id]/[zone_name]/ssl-tls/configuration
|
||||
|
||||
Change the `SSL/TLS Encryption` Mode to `Full` in the Cloudflare dashboard.
|
||||
|
||||
### How can I access the admin panel after first deployment?
|
||||
|
||||
You need to first register an account and log in,
|
||||
and modify the `role` field of this account to `ADMIN` in the `users` table of the database.
|
||||
Then, refresh the website and access http://localhost:3000/admin.
|
||||
|
||||
<Callout type="note">
|
||||
Although it may not be convenient to do so, this is currently the fastest way to become an administrator.
|
||||
In future versions, we will implement the function of automatically setting up administrators as soon as possible.
|
||||
</Callout>
|
||||
|
||||
### How can I change the team plan quota?
|
||||
|
||||
Via team.ts:
|
||||
|
||||
@@ -81,6 +81,20 @@ export async function getDomainsByFeature(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDomainsByFeatureClient(feature: string) {
|
||||
try {
|
||||
const domains = await prisma.domain.findMany({
|
||||
where: { [feature]: true },
|
||||
select: {
|
||||
domain_name: true,
|
||||
},
|
||||
});
|
||||
return domains;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch domain config: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDomain(data: DomainConfig) {
|
||||
try {
|
||||
const createdDomain = await prisma.domain.create({ data });
|
||||
|
||||
@@ -85,6 +85,17 @@ export async function getAllUsersCount() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function setFirstUserAsAdmin(userId: string) {
|
||||
try {
|
||||
return await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { role: UserRole.ADMIN },
|
||||
});
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllUsersActiveApiKeyCount() {
|
||||
try {
|
||||
return await prisma.user.count({ where: { apiKey: { not: null } } });
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wr.do",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"author": {
|
||||
"name": "oiov",
|
||||
"url": "https://github.com/oiov"
|
||||
|
||||
@@ -274,7 +274,7 @@ CREATE TABLE "user_send_emails"
|
||||
"html" TEXT DEFAULT '',
|
||||
"replyTo" TEXT DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX "user_send_emails_userId_idx" ON "user_send_emails" ("userId");
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
@@ -3,7 +3,7 @@
|
||||
"short_name": "WR.DO",
|
||||
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
|
||||
"appid": "com.wr.do",
|
||||
"versionName": "0.6.0",
|
||||
"versionName": "0.6.1",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"short_name": "WR.DO",
|
||||
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
|
||||
"appid": "com.wr.do",
|
||||
"versionName": "0.6.0",
|
||||
"versionName": "0.6.1",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user