Merge pull request #20 from oiov/config-domains
Config domains and setup guide
This commit is contained in:
@@ -25,14 +25,6 @@ DATABASE_URL='postgres://[user]:[password]@[neon_hostname]/[dbname]?sslmode=requ
|
||||
# -----------------------------------------------------------------------------
|
||||
RESEND_API_KEY=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Cloudflare
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME=wr.do,uv.do
|
||||
CLOUDFLARE_ZONE=[{"zone_id":"abc123", "zone_name": "wr.do"},{"zone_id":"abc456", "zone_name": "uv.do"}]
|
||||
CLOUDFLARE_API_KEY=
|
||||
CLOUDFLARE_EMAIL=
|
||||
|
||||
# Open Signup
|
||||
NEXT_PUBLIC_OPEN_SIGNUP=1
|
||||
|
||||
@@ -45,7 +37,5 @@ SCREENSHOTONE_BASE_URL=https://shot.wr.do
|
||||
# GitHub api token for getting gitHub stars count
|
||||
GITHUB_TOKEN=
|
||||
|
||||
# Short domains, split by ","
|
||||
NEXT_PUBLIC_SHORT_DOMAINS=wr.do,uv.do
|
||||
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
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 { fetcher, timeAgo } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { DomainForm } from "@/components/forms/domain-form";
|
||||
import { FormType } from "@/components/forms/record-form";
|
||||
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
import { PaginationWrapper } from "@/components/shared/pagination";
|
||||
|
||||
export interface DomainListProps {
|
||||
user: Pick<User, "id" | "name" | "email" | "apiKey" | "role" | "team">;
|
||||
action: string;
|
||||
}
|
||||
|
||||
function TableColumnSekleton() {
|
||||
return (
|
||||
<TableRow className="grid grid-cols-7 items-center">
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DomainList({ user, action }: DomainListProps) {
|
||||
const [isShowForm, setShowForm] = useState(false);
|
||||
const [formType, setFormType] = useState<FormType>("add");
|
||||
const [currentEditDomain, setCurrentEditDomain] =
|
||||
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 [searchParams, setSearchParams] = useState({
|
||||
slug: "",
|
||||
target: "",
|
||||
userName: "",
|
||||
});
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data, isLoading } = useSWR<{
|
||||
total: number;
|
||||
list: DomainFormData[];
|
||||
}>(
|
||||
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
const handleRefresh = () => {
|
||||
mutate(
|
||||
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
|
||||
undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangeStatus = async (
|
||||
checked: boolean,
|
||||
target: string,
|
||||
domain: DomainFormData,
|
||||
) => {
|
||||
const res = await fetch(action, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
id: domain.id,
|
||||
enable_short_link:
|
||||
target === "enable_short_link" ? checked : domain.enable_short_link,
|
||||
enable_email: target === "enable_email" ? checked : domain.enable_email,
|
||||
enable_dns: target === "enable_dns" ? checked : domain.enable_dns,
|
||||
active: target === "active" ? checked : domain.active,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data) {
|
||||
toast.success("Successed!");
|
||||
}
|
||||
} else {
|
||||
toast.error("Activation failed!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center">
|
||||
<CardDescription className="text-balance text-lg font-bold">
|
||||
<span>Total Domains:</span>{" "}
|
||||
<span className="font-bold">{data && data.total}</span>
|
||||
</CardDescription>
|
||||
|
||||
<div className="ml-auto flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => handleRefresh()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-[120px] shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCurrentEditDomain(null);
|
||||
setShowForm(false);
|
||||
setFormType("add");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
Add Domain
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* <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"
|
||||
placeholder="Search by slug..."
|
||||
value={searchParams.slug}
|
||||
onChange={(e) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
slug: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{searchParams.slug && (
|
||||
<Button
|
||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setSearchParams({ ...searchParams, slug: "" })}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
||||
<TableRow className="grid grid-cols-7 items-center text-xs">
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
Domain
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
|
||||
Shorten Service
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
|
||||
Email Service
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
|
||||
DNS Service
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
|
||||
Active
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Updated
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
</>
|
||||
) : data && data.list && data.list.length ? (
|
||||
data.list.map((domain) => (
|
||||
<div className="border-b" key={domain.id}>
|
||||
<TableRow className="grid grid-cols-7 items-center">
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Link
|
||||
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
|
||||
href={`https://${domain.domain_name}`}
|
||||
target="_blank"
|
||||
prefetch={false}
|
||||
title={domain.domain_name}
|
||||
>
|
||||
{domain.domain_name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Switch
|
||||
defaultChecked={domain.enable_short_link}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(
|
||||
value,
|
||||
"enable_short_link",
|
||||
domain,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Switch
|
||||
defaultChecked={domain.enable_email}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(value, "enable_email", domain)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Switch
|
||||
defaultChecked={domain.enable_dns}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(value, "enable_dns", domain)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Switch
|
||||
disabled
|
||||
defaultChecked={domain.active}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(value, "active", domain)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
{timeAgo(domain.updatedAt as Date)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setCurrentEditDomain(domain);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p className="hidden sm:block">Edit</p>
|
||||
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
{domain.cf_zone_id &&
|
||||
domain.cf_api_key &&
|
||||
domain.cf_email && (
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icons.cloudflare className="mx-0.5 size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/* {isShowDomainInfo && selectedDomain?.id === domain.id && (
|
||||
<DomainInfo domain={domain} />
|
||||
)} */}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyPlaceholder>
|
||||
<EmptyPlaceholder.Icon name="globeLock" />
|
||||
<EmptyPlaceholder.Title>No Domains</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any domains yet. Start creating one.
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
pageSize={pageSize}
|
||||
setPageSize={setPageSize}
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* form */}
|
||||
<Modal
|
||||
className="max-h-[90vh] overflow-y-auto md:max-w-2xl"
|
||||
showModal={isShowForm}
|
||||
setShowModal={setShowForm}
|
||||
>
|
||||
<DomainForm
|
||||
user={{ id: user.id, name: user.name || "" }}
|
||||
isShowForm={isShowForm}
|
||||
setShowForm={setShowForm}
|
||||
type={formType}
|
||||
initData={currentEditDomain}
|
||||
action={action}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DomainInfo({ domain }: { domain: DomainFormData }) {
|
||||
return <>{domain.domain_name}</>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Domains Management" text="" />
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import UserRecordsList from "../../dashboard/records/record-list";
|
||||
import DomainList from "./domain-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "DNS Records - WR.DO",
|
||||
description: "List and manage records.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Manage Domains"
|
||||
text="List and manage domains."
|
||||
link="/docs/domains"
|
||||
linkText="domains."
|
||||
/>
|
||||
<DomainList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/admin/domain"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import StatusDot from "@/components/dashboard/status-dot";
|
||||
import { UserForm } from "@/components/forms/user-form";
|
||||
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
@@ -189,7 +189,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
Add record
|
||||
Add Record
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -179,7 +179,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
Add url
|
||||
Add URL
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -345,7 +345,6 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Switch
|
||||
className="data-[state=checked]:bg-blue-500"
|
||||
defaultChecked={short.active === 1}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatu(value, short.id || "")
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
createDomain,
|
||||
deleteDomain,
|
||||
getAllDomains,
|
||||
invalidateDomainConfigCache,
|
||||
updateDomain,
|
||||
} from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
// Get domains list
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// TODO: Add pagination
|
||||
const domains = await getAllDomains();
|
||||
|
||||
return Response.json(
|
||||
{ list: domains, total: domains.length },
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Create domain
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { data } = await req.json();
|
||||
if (!data || !data.domain_name) {
|
||||
return Response.json("domain_name is required", { status: 400 });
|
||||
}
|
||||
|
||||
const newDomain = await createDomain({
|
||||
domain_name: data.domain_name,
|
||||
enable_short_link: !!data.enable_short_link,
|
||||
enable_email: !!data.enable_email,
|
||||
enable_dns: !!data.enable_dns,
|
||||
cf_zone_id: data.cf_zone_id,
|
||||
cf_api_key: data.cf_api_key,
|
||||
cf_email: data.cf_email,
|
||||
cf_api_key_encrypted: false,
|
||||
max_short_links: data.max_short_links,
|
||||
max_email_forwards: data.max_email_forwards,
|
||||
max_dns_records: data.max_dns_records,
|
||||
active: true,
|
||||
});
|
||||
|
||||
invalidateDomainConfigCache();
|
||||
|
||||
return Response.json(newDomain, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Update domain
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const {
|
||||
domain_name,
|
||||
enable_short_link,
|
||||
enable_email,
|
||||
enable_dns,
|
||||
cf_zone_id,
|
||||
cf_api_key,
|
||||
cf_email,
|
||||
max_short_links,
|
||||
max_email_forwards,
|
||||
max_dns_records,
|
||||
active,
|
||||
id,
|
||||
} = await req.json();
|
||||
if (!id) {
|
||||
return Response.json("domain id is required", { status: 400 });
|
||||
}
|
||||
|
||||
const updatedDomain = await updateDomain(id, {
|
||||
domain_name,
|
||||
enable_short_link: !!enable_short_link,
|
||||
enable_email: !!enable_email,
|
||||
enable_dns: !!enable_dns,
|
||||
active: !!active,
|
||||
cf_zone_id,
|
||||
cf_api_key,
|
||||
cf_email,
|
||||
cf_api_key_encrypted: false,
|
||||
max_short_links,
|
||||
max_email_forwards,
|
||||
max_dns_records,
|
||||
});
|
||||
|
||||
invalidateDomainConfigCache();
|
||||
|
||||
return Response.json(updatedDomain, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete domain
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { domain_name } = await req.json();
|
||||
if (!domain_name) {
|
||||
return Response.json("domain_name is required", { status: 400 });
|
||||
}
|
||||
|
||||
const deletedDomain = await deleteDomain(domain_name);
|
||||
|
||||
invalidateDomainConfigCache();
|
||||
|
||||
return Response.json(deletedDomain, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { FeatureMap, getDomainsByFeatureClient } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
// Get domains by feature for frontend
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const feature = url.searchParams.get("feature") || "";
|
||||
|
||||
if (!Object.keys(FeatureMap).includes(feature)) {
|
||||
return Response.json(
|
||||
"Invalid feature parameter. Use 'short', 'email', or 'record'.",
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const domainList = await getDomainsByFeatureClient(FeatureMap[feature]);
|
||||
|
||||
return Response.json(domainList, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
+14
-19
@@ -1,4 +1,3 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { createDNSRecord } from "@/lib/cloudflare";
|
||||
import {
|
||||
@@ -6,31 +5,27 @@ import {
|
||||
getUserRecordByTypeNameContent,
|
||||
getUserRecordCount,
|
||||
} from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { reservedDomains } from "@/lib/enums";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { generateSecret, parseZones } from "@/lib/utils";
|
||||
import { generateSecret } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
|
||||
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
|
||||
|
||||
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
|
||||
return Response.json("API key、zone iD and email are required", {
|
||||
const zones = await getDomainsByFeature("enable_dns", true);
|
||||
if (!zones.length) {
|
||||
return Response.json("Please add at least one domain", {
|
||||
status: 400,
|
||||
statusText: "API key、zone iD and email are required",
|
||||
statusText: "Please add at least one domain",
|
||||
});
|
||||
}
|
||||
|
||||
const { total } = await getUserRecordCount(user.id);
|
||||
if (
|
||||
user.role !== "ADMIN" &&
|
||||
total >= TeamPlanQuota[user.team].RC_NewRecords
|
||||
) {
|
||||
if (total >= TeamPlanQuota[user.team].RC_NewRecords) {
|
||||
return Response.json("Your records have reached the free limit.", {
|
||||
status: 409,
|
||||
});
|
||||
@@ -49,7 +44,7 @@ export async function POST(req: Request) {
|
||||
let matchedZone;
|
||||
|
||||
for (const zone of zones) {
|
||||
if (record.zone_name === zone.zone_name) {
|
||||
if (record.zone_name === zone.domain_name) {
|
||||
matchedZone = zone;
|
||||
break;
|
||||
}
|
||||
@@ -85,22 +80,22 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const data = await createDNSRecord(
|
||||
matchedZone.zone_id,
|
||||
CLOUDFLARE_API_KEY,
|
||||
CLOUDFLARE_EMAIL,
|
||||
matchedZone.cf_zone_id,
|
||||
matchedZone.cf_api_key,
|
||||
matchedZone.cf_email,
|
||||
record,
|
||||
);
|
||||
|
||||
if (!data.success || !data.result?.id) {
|
||||
console.log("[data]", data);
|
||||
// console.log("[data]", data);
|
||||
return Response.json(data.messages, {
|
||||
status: 501,
|
||||
});
|
||||
} else {
|
||||
const res = await createUserRecord(user.id, {
|
||||
record_id: data.result.id,
|
||||
zone_id: matchedZone.zone_id,
|
||||
zone_name: matchedZone.zone_name,
|
||||
zone_id: matchedZone.cf_zone_id,
|
||||
zone_name: matchedZone.domain_name,
|
||||
name: data.result.name,
|
||||
type: data.result.type,
|
||||
content: data.result.content,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { createDNSRecord } from "@/lib/cloudflare";
|
||||
import {
|
||||
@@ -6,10 +5,11 @@ import {
|
||||
getUserRecordByTypeNameContent,
|
||||
getUserRecordCount,
|
||||
} from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
|
||||
import { reservedDomains } from "@/lib/enums";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { generateSecret, parseZones } from "@/lib/utils";
|
||||
import { generateSecret } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -22,13 +22,11 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
|
||||
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
|
||||
|
||||
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
|
||||
return Response.json("API key、zone iD and email are required", {
|
||||
const zones = await getDomainsByFeature("enable_dns", true);
|
||||
if (!zones.length) {
|
||||
return Response.json("Please add at least one domain", {
|
||||
status: 400,
|
||||
statusText: "API key、zone iD and email are required",
|
||||
statusText: "Please add at least one domain",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,7 +58,7 @@ export async function POST(req: Request) {
|
||||
let matchedZone;
|
||||
|
||||
for (const zone of zones) {
|
||||
if (record.zone_name === zone.zone_name) {
|
||||
if (record.zone_name === zone.domain_name) {
|
||||
matchedZone = zone;
|
||||
break;
|
||||
}
|
||||
@@ -96,9 +94,9 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const data = await createDNSRecord(
|
||||
matchedZone.zone_id,
|
||||
CLOUDFLARE_API_KEY,
|
||||
CLOUDFLARE_EMAIL,
|
||||
matchedZone.cf_zone_id,
|
||||
matchedZone.cf_api_key,
|
||||
matchedZone.cf_email,
|
||||
record,
|
||||
);
|
||||
|
||||
@@ -110,8 +108,8 @@ export async function POST(req: Request) {
|
||||
} else {
|
||||
const res = await createUserRecord(target_user.id, {
|
||||
record_id: data.result.id,
|
||||
zone_id: matchedZone.zone_id,
|
||||
zone_name: matchedZone.zone_name,
|
||||
zone_id: matchedZone.cf_zone_id,
|
||||
zone_name: matchedZone.domain_name,
|
||||
name: data.result.name,
|
||||
type: data.result.type,
|
||||
content: data.result.content,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { deleteDNSRecord } from "@/lib/cloudflare";
|
||||
import { deleteUserRecord } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { parseZones } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -24,20 +23,15 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
|
||||
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
|
||||
|
||||
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
|
||||
return Response.json(
|
||||
"API key, zone configuration, and email are required",
|
||||
{
|
||||
status: 400,
|
||||
statusText: "Missing required configuration",
|
||||
},
|
||||
);
|
||||
const zones = await getDomainsByFeature("enable_dns", true);
|
||||
if (!zones.length) {
|
||||
return Response.json("Please add at least one domain", {
|
||||
status: 400,
|
||||
statusText: "Please add at least one domain",
|
||||
});
|
||||
}
|
||||
|
||||
const matchedZone = zones.find((zone) => zone.zone_id === zone_id);
|
||||
const matchedZone = zones.find((zone) => zone.cf_zone_id === zone_id);
|
||||
if (!matchedZone) {
|
||||
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
|
||||
status: 400,
|
||||
@@ -48,9 +42,9 @@ export async function POST(req: Request) {
|
||||
// force delete
|
||||
await deleteUserRecord(userId, record_id, zone_id, active);
|
||||
await deleteDNSRecord(
|
||||
matchedZone.zone_id,
|
||||
CLOUDFLARE_API_KEY,
|
||||
CLOUDFLARE_EMAIL,
|
||||
matchedZone.cf_zone_id!,
|
||||
matchedZone.cf_api_key!,
|
||||
matchedZone.cf_email!,
|
||||
record_id,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { updateDNSRecord } from "@/lib/cloudflare";
|
||||
import { updateUserRecord } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { parseZones } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -16,16 +15,11 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
|
||||
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
|
||||
|
||||
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
|
||||
const zones = await getDomainsByFeature("enable_dns", true);
|
||||
if (!zones.length) {
|
||||
return Response.json(
|
||||
"API key, zone configuration, and email are required",
|
||||
{
|
||||
status: 400,
|
||||
statusText: "Missing required configuration",
|
||||
},
|
||||
{ status: 401, statusText: "Missing required configuration" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +38,7 @@ export async function POST(req: Request) {
|
||||
let matchedZone;
|
||||
|
||||
for (const zone of zones) {
|
||||
if (record.zone_name === zone.zone_name) {
|
||||
if (record.zone_name === zone.domain_name) {
|
||||
matchedZone = zone;
|
||||
break;
|
||||
}
|
||||
@@ -61,9 +55,9 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const data = await updateDNSRecord(
|
||||
matchedZone.zone_id,
|
||||
CLOUDFLARE_API_KEY,
|
||||
CLOUDFLARE_EMAIL,
|
||||
matchedZone.cf_zone_id,
|
||||
matchedZone.cf_api_key,
|
||||
matchedZone.cf_email,
|
||||
recordId,
|
||||
{ ...record, name: record_name },
|
||||
);
|
||||
@@ -80,8 +74,8 @@ export async function POST(req: Request) {
|
||||
|
||||
const res = await updateUserRecord(userId, {
|
||||
record_id: data.result.id,
|
||||
zone_id: matchedZone.zone_id,
|
||||
zone_name: matchedZone.zone_name,
|
||||
zone_id: matchedZone.cf_zone_id,
|
||||
zone_name: matchedZone.domain_name,
|
||||
name: data.result.name,
|
||||
type: data.result.type,
|
||||
content: data.result.content,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { deleteDNSRecord } from "@/lib/cloudflare";
|
||||
import { deleteUserRecord } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { parseZones } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -12,20 +12,15 @@ export async function POST(req: Request) {
|
||||
|
||||
const { record_id, zone_id, active } = await req.json();
|
||||
|
||||
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
|
||||
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
|
||||
|
||||
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
|
||||
return Response.json(
|
||||
"API key, zone configuration, and email are required",
|
||||
{
|
||||
status: 400,
|
||||
statusText: "API key, zone configuration, and email are required",
|
||||
},
|
||||
);
|
||||
const zones = await getDomainsByFeature("enable_dns", true);
|
||||
if (!zones.length) {
|
||||
return Response.json("Please add at least one domain", {
|
||||
status: 400,
|
||||
statusText: "Please add at least one domain",
|
||||
});
|
||||
}
|
||||
|
||||
const matchedZone = zones.find((zone) => zone.zone_id === zone_id);
|
||||
const matchedZone = zones.find((zone) => zone.cf_zone_id === zone_id);
|
||||
if (!matchedZone) {
|
||||
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
|
||||
status: 400,
|
||||
@@ -34,9 +29,9 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const res = await deleteDNSRecord(
|
||||
matchedZone.zone_id,
|
||||
CLOUDFLARE_API_KEY,
|
||||
CLOUDFLARE_EMAIL,
|
||||
matchedZone.cf_zone_id!,
|
||||
matchedZone.cf_api_key!,
|
||||
matchedZone.cf_email!,
|
||||
record_id,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { updateDNSRecord } from "@/lib/cloudflare";
|
||||
import {
|
||||
updateUserRecord,
|
||||
updateUserRecordState,
|
||||
} from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { reservedDomains } from "@/lib/enums";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { parseZones } from "@/lib/utils";
|
||||
|
||||
// Update DNS record
|
||||
export async function POST(req: Request) {
|
||||
@@ -15,10 +14,8 @@ export async function POST(req: Request) {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
|
||||
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
|
||||
|
||||
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
|
||||
const zones = await getDomainsByFeature("enable_dns", true);
|
||||
if (!zones.length) {
|
||||
return Response.json(
|
||||
"API key, zone configuration, and email are required",
|
||||
{ status: 401, statusText: "Missing required configuration" },
|
||||
@@ -40,7 +37,7 @@ export async function POST(req: Request) {
|
||||
let matchedZone;
|
||||
|
||||
for (const zone of zones) {
|
||||
if (record.zone_name === zone.zone_name) {
|
||||
if (record.zone_name === zone.domain_name) {
|
||||
matchedZone = zone;
|
||||
break;
|
||||
}
|
||||
@@ -64,14 +61,13 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const data = await updateDNSRecord(
|
||||
matchedZone.zone_id,
|
||||
CLOUDFLARE_API_KEY,
|
||||
CLOUDFLARE_EMAIL,
|
||||
matchedZone.cf_zone_id,
|
||||
matchedZone.cf_api_key,
|
||||
matchedZone.cf_email,
|
||||
recordId,
|
||||
{ ...record, name: record_name },
|
||||
);
|
||||
|
||||
console.log("[updateDNSRecord]", data);
|
||||
if (!data.success || !data.result?.id) {
|
||||
return Response.json(
|
||||
data.errors?.[0]?.message || "Failed to update DNS record",
|
||||
@@ -81,8 +77,8 @@ export async function POST(req: Request) {
|
||||
|
||||
const res = await updateUserRecord(user.id, {
|
||||
record_id: data.result.id,
|
||||
zone_id: matchedZone.zone_id, // Use matched zone_id
|
||||
zone_name: matchedZone.zone_name, // Use matched zone_name
|
||||
zone_id: matchedZone.cf_zone_id,
|
||||
zone_name: matchedZone.domain_name,
|
||||
name: data.result.name,
|
||||
type: data.result.type,
|
||||
content: data.result.content,
|
||||
@@ -118,10 +114,8 @@ export async function PUT(req: Request) {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
|
||||
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
|
||||
|
||||
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
|
||||
const zones = await getDomainsByFeature("enable_dns", true);
|
||||
if (!zones.length) {
|
||||
return Response.json(
|
||||
"API key, zone configuration, and email are required",
|
||||
{ status: 401, statusText: "Missing required configuration" },
|
||||
@@ -136,7 +130,7 @@ export async function PUT(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const matchedZone = zones.find((zone) => zone.zone_id === zone_id);
|
||||
const matchedZone = zones.find((zone) => zone.cf_zone_id === zone_id);
|
||||
if (!matchedZone) {
|
||||
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
|
||||
status: 400,
|
||||
@@ -153,9 +147,9 @@ export async function PUT(req: Request) {
|
||||
isTargetAccessible = target_res.status === 200;
|
||||
} catch (fetchError) {
|
||||
isTargetAccessible = false;
|
||||
console.log(
|
||||
`[Fetch Error] Failed to access target ${target}: ${fetchError}`,
|
||||
);
|
||||
// console.log(
|
||||
// `[Fetch Error] Failed to access target ${target}: ${fetchError}`,
|
||||
// );
|
||||
}
|
||||
|
||||
const res = await updateUserRecordState(
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { createUserShortUrl } from "@/lib/dto/short-urls";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
@@ -23,6 +24,18 @@ export async function POST(req: Request) {
|
||||
|
||||
const { target, url, prefix, visible, active, expiration, password } =
|
||||
createUrlSchema.parse(data);
|
||||
|
||||
const zones = await getDomainsByFeature("enable_short_link");
|
||||
if (
|
||||
!zones.length ||
|
||||
!zones.map((zone) => zone.domain_name).includes(prefix)
|
||||
) {
|
||||
return Response.json("Invalid domain", {
|
||||
status: 400,
|
||||
statusText: "Invalid domain",
|
||||
});
|
||||
}
|
||||
|
||||
const res = await createUserShortUrl({
|
||||
userId: user.id,
|
||||
userName: user.name || "Anonymous",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { getUserShortUrls } from "@/lib/dto/short-urls";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { checkApiKey } from "@/lib/dto/api-key";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { createUserEmail, deleteUserEmailByAddress } from "@/lib/dto/email";
|
||||
import { reservedAddressSuffix } from "@/lib/enums";
|
||||
import { restrictByTimeRange } from "@/lib/team";
|
||||
@@ -53,7 +54,12 @@ export async function POST(req: NextRequest) {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!siteConfig.emailDomains.includes(suffix)) {
|
||||
|
||||
const zones = await getDomainsByFeature("enable_email", true);
|
||||
if (
|
||||
!zones.length ||
|
||||
!zones.map((zone) => zone.domain_name).includes(suffix)
|
||||
) {
|
||||
return NextResponse.json("Invalid email suffix address", { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { checkApiKey } from "@/lib/dto/api-key";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { createUserShortUrl } from "@/lib/dto/short-urls";
|
||||
import { restrictByTimeRange } from "@/lib/team";
|
||||
import { createUrlSchema } from "@/lib/validations/url";
|
||||
@@ -41,6 +42,17 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const zones = await getDomainsByFeature("enable_short_link");
|
||||
if (
|
||||
!zones.length ||
|
||||
!zones.map((zone) => zone.domain_name).includes(prefix)
|
||||
) {
|
||||
return Response.json("Invalid domain", {
|
||||
status: 409,
|
||||
statusText: "Invalid domain",
|
||||
});
|
||||
}
|
||||
|
||||
const res = await createUserShortUrl({
|
||||
userId: user.id,
|
||||
userName: user.name || "Anonymous",
|
||||
|
||||
+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.5.1",
|
||||
"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;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SectionColumnsType {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
@@ -14,7 +16,12 @@ export function FormSectionColumns({
|
||||
className,
|
||||
}: SectionColumnsType) {
|
||||
return (
|
||||
<div className="grid w-full grid-cols-1 items-center gap-x-12 gap-y-2 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full grid-cols-1 items-center gap-x-12 gap-y-2 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="col-span-4 flex items-start gap-0.5 text-sm leading-none">
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
{required && (
|
||||
|
||||
@@ -77,9 +77,7 @@ export default function EmailSidebar({
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [showEmailModal, setShowEmailModal] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [domainSuffix, setDomainSuffix] = useState<string | null>(
|
||||
siteConfig.shortDomains[0],
|
||||
);
|
||||
const [domainSuffix, setDomainSuffix] = useState<string | null>("wr.do");
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [emailToDelete, setEmailToDelete] = useState<string | null>(null);
|
||||
const [deleteInput, setDeleteInput] = useState("");
|
||||
@@ -109,6 +107,13 @@ export default function EmailSidebar({
|
||||
},
|
||||
);
|
||||
|
||||
const { data: emailDomains, isLoading: isLoadingDomains } = useSWR<
|
||||
{ domain_name: string }[]
|
||||
>("/api/domain?feature=email", fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
});
|
||||
|
||||
if (!selectedEmailAddress && data && data.list.length > 0) {
|
||||
onSelectEmail(data.list[0].emailAddress);
|
||||
}
|
||||
@@ -595,25 +600,38 @@ export default function EmailSidebar({
|
||||
isEdit ? selectedEmailAddress?.split("@")[0] || "" : ""
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setDomainSuffix(value);
|
||||
}}
|
||||
name="suffix"
|
||||
defaultValue={domainSuffix || siteConfig.emailDomains[0]}
|
||||
disabled={isEdit}
|
||||
>
|
||||
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteConfig.emailDomains.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
@{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isLoadingDomains ? (
|
||||
<Skeleton className="h-9 w-1/3 rounded-none border-x-0 shadow-inner" />
|
||||
) : (
|
||||
<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>
|
||||
))
|
||||
) : (
|
||||
<Button className="w-full" variant="ghost">
|
||||
No domain
|
||||
</Button>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button
|
||||
className="rounded-l-none"
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { User } from "@prisma/client";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { DomainFormData } from "@/lib/dto/domains";
|
||||
import { EXPIRATION_ENUMS } from "@/lib/enums";
|
||||
import { generateUrlSuffix } from "@/lib/utils";
|
||||
import { createDomainSchema } from "@/lib/validations/domain";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
import { FormSectionColumns } from "../dashboard/form-section-columns";
|
||||
import { Badge } from "../ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "../ui/collapsible";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Switch } from "../ui/switch";
|
||||
|
||||
export type FormData = DomainFormData;
|
||||
|
||||
export type FormType = "add" | "edit";
|
||||
|
||||
export interface DomainFormProps {
|
||||
user: Pick<User, "id" | "name">;
|
||||
isShowForm: boolean;
|
||||
setShowForm: Dispatch<SetStateAction<boolean>>;
|
||||
type: FormType;
|
||||
initData?: DomainFormData | null;
|
||||
action: string;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function DomainForm({
|
||||
setShowForm,
|
||||
type,
|
||||
initData,
|
||||
action,
|
||||
onRefresh,
|
||||
}: DomainFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isDeleting, startDeleteTransition] = useTransition();
|
||||
const [currentRecordStatus, setCurrentRecordStatus] = useState(
|
||||
initData?.enable_dns || false,
|
||||
);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
getValues,
|
||||
setValue,
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(createDomainSchema),
|
||||
defaultValues: {
|
||||
id: initData?.id || "",
|
||||
domain_name: initData?.domain_name || "",
|
||||
enable_short_link: initData?.enable_short_link || false,
|
||||
enable_email: initData?.enable_email || false,
|
||||
enable_dns: initData?.enable_dns || false,
|
||||
cf_zone_id: initData?.cf_zone_id || "",
|
||||
cf_api_key: initData?.cf_api_key || "",
|
||||
cf_email: initData?.cf_email || "",
|
||||
cf_api_key_encrypted: initData?.cf_api_key_encrypted || false,
|
||||
max_short_links: initData?.max_short_links || 0,
|
||||
max_email_forwards: initData?.max_email_forwards || 0,
|
||||
max_dns_records: initData?.max_dns_records || 0,
|
||||
active: initData?.active || true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (type === "add") {
|
||||
handleCreateDomain(data);
|
||||
} else if (type === "edit") {
|
||||
handleUpdateDomain(data);
|
||||
}
|
||||
});
|
||||
|
||||
const handleCreateDomain = async (data: DomainFormData) => {
|
||||
startTransition(async () => {
|
||||
const response = await fetch(`${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data,
|
||||
}),
|
||||
});
|
||||
if (!response.ok || response.status !== 200) {
|
||||
toast.error("Created Failed!", {
|
||||
description: await response.text(),
|
||||
});
|
||||
} else {
|
||||
// const res = await response.json();
|
||||
toast.success(`Created successfully!`);
|
||||
setShowForm(false);
|
||||
onRefresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateDomain = async (data: DomainFormData) => {
|
||||
startTransition(async () => {
|
||||
if (type === "edit") {
|
||||
const response = await fetch(`${action}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok || response.status !== 200) {
|
||||
toast.error("Update Failed", {
|
||||
description: await response.text(),
|
||||
});
|
||||
} else {
|
||||
await response.json();
|
||||
toast.success(`Update successfully!`);
|
||||
setShowForm(false);
|
||||
onRefresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteDomain = async () => {
|
||||
if (type === "edit") {
|
||||
startDeleteTransition(async () => {
|
||||
const response = await fetch(`${action}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({
|
||||
domain_name: initData?.domain_name,
|
||||
}),
|
||||
});
|
||||
if (!response.ok || response.status !== 200) {
|
||||
toast.error("Delete Failed", {
|
||||
description: await response.text(),
|
||||
});
|
||||
} else {
|
||||
await response.json();
|
||||
toast.success(`Success`);
|
||||
setShowForm(false);
|
||||
onRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-t-lg bg-muted px-4 py-2 text-lg font-semibold">
|
||||
{type === "add" ? "Create" : "Edit"} Domain
|
||||
</div>
|
||||
<form className="p-4" onSubmit={onSubmit}>
|
||||
<div className="relative flex flex-col items-center justify-start gap-0 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
|
||||
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
|
||||
Base
|
||||
</h2>
|
||||
<FormSectionColumns title="">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<Label className="mt-2.5 text-nowrap" htmlFor="domain_name">
|
||||
Domain Name:
|
||||
</Label>
|
||||
<div className="w-full sm:w-[60%]">
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 bg-neutral-50 shadow-inner"
|
||||
size={32}
|
||||
{...register("domain_name")}
|
||||
/>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.domain_name ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.domain_name.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
Required. eg: example.com
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Label className="" htmlFor="active">
|
||||
Active:
|
||||
</Label>
|
||||
<Switch
|
||||
id="active"
|
||||
{...register("active")}
|
||||
defaultChecked={initData?.active ?? true}
|
||||
onCheckedChange={(value) => setValue("active", value)}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-2 flex flex-col items-center justify-start gap-4 rounded-md bg-neutral-100 p-4 pt-10 dark:bg-neutral-800">
|
||||
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
|
||||
Services(Optional)
|
||||
</h2>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Label className="" htmlFor="short_url_service">
|
||||
Short URL Service:
|
||||
</Label>
|
||||
<Switch
|
||||
id="short_url_service"
|
||||
{...register("enable_short_link")}
|
||||
defaultChecked={initData?.enable_short_link ?? false}
|
||||
onCheckedChange={(value) => setValue("enable_short_link", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Label className="" htmlFor="email_service">
|
||||
Email Service:
|
||||
</Label>
|
||||
<Switch
|
||||
id="email_service"
|
||||
{...register("enable_email")}
|
||||
defaultChecked={initData?.enable_email ?? false}
|
||||
onCheckedChange={(value) => setValue("enable_email", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Label className="cursor-pointer" htmlFor="dns_record_service">
|
||||
DNS Record Service:
|
||||
</Label>
|
||||
<Switch
|
||||
id="dns_record_service"
|
||||
{...register("enable_dns")}
|
||||
defaultChecked={initData?.enable_dns ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
setValue("enable_dns", value);
|
||||
setCurrentRecordStatus(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible className="relative mt-2 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
||||
<h2 className="absolute left-2 top-4 text-xs font-semibold text-neutral-400">
|
||||
Cloudflare Configs(Optional)
|
||||
</h2>
|
||||
<Icons.chevronDown className="ml-auto size-4" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{!currentRecordStatus && (
|
||||
<div className="mt-3 flex items-center gap-1 rounded bg-neutral-200 p-2 text-xs dark:bg-neutral-700">
|
||||
<Icons.help className="size-3" /> Associate with "DNS Record
|
||||
Service" status
|
||||
</div>
|
||||
)}
|
||||
<FormSectionColumns title="">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<Label className="mt-2.5 text-nowrap" htmlFor="zone_id">
|
||||
Zone ID:
|
||||
</Label>
|
||||
<div className="w-full sm:w-[60%]">
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 bg-neutral-50 shadow-inner"
|
||||
size={32}
|
||||
{...register("cf_zone_id")}
|
||||
disabled={!currentRecordStatus}
|
||||
/>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.cf_zone_id ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.cf_zone_id.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
Optional.{" "}
|
||||
<Link
|
||||
className="text-blue-500"
|
||||
href="/docs/developer/cloudflare"
|
||||
target="_blank"
|
||||
>
|
||||
How to get zone id?
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
<FormSectionColumns title="">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<Label className="mt-2.5 text-nowrap" htmlFor="api-key">
|
||||
API Token:
|
||||
</Label>
|
||||
<div className="w-full sm:w-[60%]">
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 bg-neutral-50 shadow-inner"
|
||||
size={32}
|
||||
{...register("cf_api_key")}
|
||||
disabled={!currentRecordStatus}
|
||||
/>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.cf_api_key ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.cf_api_key.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
Optional.{" "}
|
||||
<Link
|
||||
className="text-blue-500"
|
||||
href="/docs/developer/cloudflare"
|
||||
target="_blank"
|
||||
>
|
||||
How to get api token?
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
<FormSectionColumns title="">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<Label className="mt-2.5 text-nowrap" htmlFor="email">
|
||||
Account Email:
|
||||
</Label>
|
||||
<div className="w-full sm:w-[60%]">
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 bg-neutral-50 shadow-inner"
|
||||
size={32}
|
||||
{...register("cf_email")}
|
||||
disabled={!currentRecordStatus}
|
||||
/>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.cf_email ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.cf_email.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
Optional.{" "}
|
||||
<Link
|
||||
className="text-blue-500"
|
||||
href="/docs/developer/cloudflare"
|
||||
target="_blank"
|
||||
>
|
||||
How to get cloudflare account email?
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-3 flex justify-end gap-3">
|
||||
{type === "edit" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="mr-auto w-[80px] px-0"
|
||||
onClick={() => handleDeleteDomain()}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Icons.spinner className="size-4 animate-spin" />
|
||||
) : (
|
||||
<p>Delete</p>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="reset"
|
||||
variant="outline"
|
||||
className="w-[80px] px-0"
|
||||
onClick={() => setShowForm(false)}
|
||||
>
|
||||
Cancle
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="blue"
|
||||
disabled={isPending}
|
||||
className="w-[80px] shrink-0 px-0"
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.spinner className="size-4 animate-spin" />
|
||||
) : (
|
||||
<p>{type === "edit" ? "Update" : "Save"}</p>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { User } from "@prisma/client";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { CreateDNSRecord, RecordType } from "@/lib/cloudflare";
|
||||
import { UserRecordFormData } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { RECORD_TYPE_ENUMS, TTL_ENUMS } from "@/lib/enums";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { createRecordSchema } from "@/lib/validations/record";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { Switch } from "../ui/switch";
|
||||
|
||||
export type FormData = CreateDNSRecord;
|
||||
@@ -77,6 +80,16 @@ export function RecordForm({
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch the record domains
|
||||
const { data: recordDomains, isLoading } = useSWR<{ domain_name: string }[]>(
|
||||
"/api/domain?feature=record",
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (type === "add") {
|
||||
handleCreateRecord(data);
|
||||
@@ -196,26 +209,36 @@ export function RecordForm({
|
||||
|
||||
<div className="items-center justify-start gap-4 md:flex">
|
||||
<FormSectionColumns title="Domain" required>
|
||||
<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>
|
||||
{siteConfig.recordDomains.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-9 w-full" />
|
||||
) : (
|
||||
<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>
|
||||
))
|
||||
) : (
|
||||
<Button className="w-full" variant="ghost">
|
||||
No domain
|
||||
</Button>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<p className="p-1 text-[13px] text-muted-foreground">
|
||||
Required. Select a domain.
|
||||
</p>
|
||||
|
||||
@@ -6,11 +6,12 @@ import { User } from "@prisma/client";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { ShortUrlFormData } from "@/lib/dto/short-urls";
|
||||
import { EXPIRATION_ENUMS } from "@/lib/enums";
|
||||
import { generateUrlSuffix } from "@/lib/utils";
|
||||
import { fetcher, generateUrlSuffix } from "@/lib/utils";
|
||||
import { createUrlSchema } from "@/lib/validations/url";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export type FormData = ShortUrlFormData;
|
||||
|
||||
@@ -64,13 +65,22 @@ export function UrlForm({
|
||||
target: initData?.target || "",
|
||||
url: initData?.url || "",
|
||||
active: initData?.active || 1,
|
||||
prefix: initData?.prefix || siteConfig.shortDomains[0],
|
||||
prefix: initData?.prefix || "wr.do",
|
||||
visible: initData?.visible || 0,
|
||||
expiration: initData?.expiration || "-1",
|
||||
password: initData?.password || "",
|
||||
},
|
||||
});
|
||||
|
||||
const { data: shortDomains, isLoading } = useSWR<{ domain_name: string }[]>(
|
||||
"/api/domain?feature=short",
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (type === "add") {
|
||||
handleCreateUrl(data);
|
||||
@@ -191,25 +201,35 @@ export function UrlForm({
|
||||
</Label>
|
||||
|
||||
<div className="relative flex w-full items-center">
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setValue("prefix", value);
|
||||
}}
|
||||
name="prefix"
|
||||
defaultValue={initData?.prefix || siteConfig.shortDomains[0]}
|
||||
disabled={type === "edit"}
|
||||
>
|
||||
<SelectTrigger className="w-1/3 rounded-r-none border-r-0 shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteConfig.shortDomains.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-9 w-1/3 rounded-r-none border-r-0 shadow-inner" />
|
||||
) : (
|
||||
<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>
|
||||
))
|
||||
) : (
|
||||
<Button className="w-full" variant="ghost">
|
||||
No domain
|
||||
</Button>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Input
|
||||
id="url"
|
||||
className="w-full rounded-none pl-[8px] shadow-inner"
|
||||
@@ -296,48 +316,6 @@ export function UrlForm({
|
||||
Expiration time, default for never.
|
||||
</p>
|
||||
</FormSectionColumns>
|
||||
|
||||
{/* <div>
|
||||
<p className="text-sm text-gray-700 dark:text-white">
|
||||
Your Final URL:
|
||||
</p>
|
||||
<p className="text-sm text-gray-700 dark:text-white">
|
||||
{getValues("prefix")}/s/{getValues("url")}
|
||||
</p>
|
||||
</div> */}
|
||||
{/* <FormSectionColumns title="Visible">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="visible">
|
||||
Visible
|
||||
</Label>
|
||||
<Switch
|
||||
id="visible"
|
||||
{...register("visible")}
|
||||
disabled
|
||||
defaultChecked={initData?.visible === 1 || false}
|
||||
onCheckedChange={(value) => setValue("visible", value ? 1 : 0)}
|
||||
/>
|
||||
</div>
|
||||
<p className="p-1 text-[13px] text-muted-foreground">
|
||||
Public or private short url.
|
||||
</p>
|
||||
</FormSectionColumns> */}
|
||||
{/* <FormSectionColumns title="Active">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="active">
|
||||
Active
|
||||
</Label>
|
||||
<Switch
|
||||
id="active"
|
||||
{...register("active")}
|
||||
defaultChecked={initData?.active === 1 || true}
|
||||
onCheckedChange={(value) => setValue("active", value ? 1 : 0)}
|
||||
/>
|
||||
</div>
|
||||
<p className="p-1 text-[13px] text-muted-foreground">
|
||||
Enable or disable short url.
|
||||
</p>
|
||||
</FormSectionColumns> */}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
|
||||
@@ -301,4 +301,33 @@ export const Icons = {
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
cloudflare: ({ ...props }: LucideProps) => (
|
||||
<svg
|
||||
width="800px"
|
||||
height="800px"
|
||||
viewBox="0 -70 256 256"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
{...props}
|
||||
>
|
||||
<g>
|
||||
<g transform="translate(0.000000, -1.000000)">
|
||||
<path
|
||||
d="M202.3569,50.394 L197.0459,48.27 C172.0849,104.434 72.7859,70.289 66.8109,86.997 C65.8149,98.283 121.0379,89.143 160.5169,91.056 C172.5559,91.639 178.5929,100.727 173.4809,115.54 L183.5499,115.571 C195.1649,79.362 232.2329,97.841 233.7819,85.891 C231.2369,78.034 191.1809,85.891 202.3569,50.394 Z"
|
||||
fill="#FFFFFF"
|
||||
></path>
|
||||
<path
|
||||
d="M176.332,109.3483 C177.925,104.0373 177.394,98.7263 174.739,95.5393 C172.083,92.3523 168.365,90.2283 163.585,89.6973 L71.17,88.6343 C70.639,88.6343 70.108,88.1033 69.577,88.1033 C69.046,87.5723 69.046,87.0413 69.577,86.5103 C70.108,85.4483 70.639,84.9163 71.701,84.9163 L164.647,83.8543 C175.801,83.3233 187.486,74.2943 191.734,63.6723 L197.046,49.8633 C197.046,49.3313 197.577,48.8003 197.046,48.2693 C191.203,21.1823 166.772,0.9993 138.091,0.9993 C111.535,0.9993 88.697,17.9953 80.73,41.8963 C75.419,38.1783 69.046,36.0533 61.61,36.5853 C48.863,37.6473 38.772,48.2693 37.178,61.0163 C36.647,64.2033 37.178,67.3903 37.71,70.5763 C16.996,71.1073 0,88.1033 0,109.3483 C0,111.4723 0,113.0663 0.531,115.1903 C0.531,116.2533 1.593,116.7843 2.125,116.7843 L172.614,116.7843 C173.676,116.7843 174.739,116.2533 174.739,115.1903 L176.332,109.3483 Z"
|
||||
fill="#F4811F"
|
||||
></path>
|
||||
<path
|
||||
d="M205.5436,49.8628 L202.8876,49.8628 C202.3566,49.8628 201.8256,50.3938 201.2946,50.9248 L197.5766,63.6718 C195.9836,68.9828 196.5146,74.2948 199.1706,77.4808 C201.8256,80.6678 205.5436,82.7918 210.3236,83.3238 L229.9756,84.3858 C230.5066,84.3858 231.0376,84.9168 231.5686,84.9168 C232.0996,85.4478 232.0996,85.9788 231.5686,86.5098 C231.0376,87.5728 230.5066,88.1038 229.4436,88.1038 L209.2616,89.1658 C198.1076,89.6968 186.4236,98.7258 182.1746,109.3478 L181.1116,114.1288 C180.5806,114.6598 181.1116,115.7218 182.1746,115.7218 L252.2826,115.7218 C253.3446,115.7218 253.8756,115.1908 253.8756,114.1288 C254.9376,109.8798 255.9996,105.0998 255.9996,100.3188 C255.9996,72.7008 233.1616,49.8628 205.5436,49.8628"
|
||||
fill="#FAAD3F"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+7
-1
@@ -11,7 +11,7 @@ export const sidebarLinks: SidebarNavItem[] = [
|
||||
{ href: "/dashboard", icon: "dashboard", title: "Dashboard" },
|
||||
{ href: "/dashboard/urls", icon: "link", title: "Short Urls" },
|
||||
{ href: "/emails", icon: "mail", title: "Emails" },
|
||||
{ href: "/dashboard/records", icon: "globeLock", title: "DNS Records" },
|
||||
{ href: "/dashboard/records", icon: "globe", title: "DNS Records" },
|
||||
{ href: "/chat", icon: "messages", title: "WRoom" },
|
||||
],
|
||||
},
|
||||
@@ -54,6 +54,12 @@ export const sidebarLinks: SidebarNavItem[] = [
|
||||
title: "Admin Panel",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/domains",
|
||||
icon: "globeLock",
|
||||
title: "Domains",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/users",
|
||||
icon: "users",
|
||||
|
||||
@@ -3,10 +3,7 @@ import { env } from "@/env.mjs";
|
||||
|
||||
const site_url = env.NEXT_PUBLIC_APP_URL;
|
||||
const open_signup = env.NEXT_PUBLIC_OPEN_SIGNUP;
|
||||
const short_domains = env.NEXT_PUBLIC_SHORT_DOMAINS || "";
|
||||
const email_domains = env.NEXT_PUBLIC_EMAIL_DOMAINS || "";
|
||||
const email_r2_domain = env.NEXT_PUBLIC_EMAIL_R2_DOMAIN || "";
|
||||
const record_domains = env.NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME || "";
|
||||
|
||||
export const siteConfig: SiteConfig = {
|
||||
name: "WR.DO",
|
||||
@@ -23,9 +20,6 @@ export const siteConfig: SiteConfig = {
|
||||
},
|
||||
mailSupport: "support@wr.do",
|
||||
openSignup: open_signup === "1" ? true : false,
|
||||
shortDomains: short_domains.split(","),
|
||||
emailDomains: email_domains.split(","),
|
||||
recordDomains: record_domains.split(","),
|
||||
emailR2Domain: email_r2_domain,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,49 @@ description: How to config the cloudflare api.
|
||||
|
||||
Before you start, you must have a Cloudflare account and be hosted on Cloudflare.
|
||||
|
||||
## Overview
|
||||
|
||||
Administrators can manage domain configurations at `/admin/domains`, including adding, deleting, and modifying domains.
|
||||
|
||||

|
||||
|
||||
### Domain Configuration Form
|
||||
|
||||
The `Short URL Service` and `Email Service` require no additional configuration and can be activated to enable short URL and email functionalities (email requires worker forwarding setup).
|
||||
|
||||
To enable the `DNS Record Service`, you must complete the `Cloudflare Configs(Optional)` form with the following fields:
|
||||
|
||||
- Zone ID
|
||||
- API Token
|
||||
- Email
|
||||
|
||||
These fields are used to configure the Cloudflare API. If your domain is hosted through Cloudflare, you can find these details in the Cloudflare dashboard.
|
||||
|
||||
### Zone ID
|
||||
|
||||
The unique identifier for a domain hosted on Cloudflare, located at:
|
||||
|
||||
https://dash.cloudflare.com/[account_id]/[zone_name]
|
||||
|
||||
### API Token
|
||||
|
||||
Visit https://dash.cloudflare.com/profile/api-tokens, and find the Global API Key under the API Tokens section.
|
||||
|
||||
### Email
|
||||
|
||||
Email for registering a Cloudflare account
|
||||
|
||||
<Callout type="info">
|
||||
You can manage domains hosted under different Cloudflare accounts,
|
||||
provided the API Token and Email are sourced from the same account.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
# This section is Deprecated since version v0.6.0
|
||||
|
||||
Before you start, you must have a Cloudflare account and be hosted on Cloudflare.
|
||||
|
||||
In this section, you can update these variables:
|
||||
|
||||
```js title=".env"
|
||||
|
||||
@@ -48,22 +48,12 @@ Copy/paste the `.env.example` in the `.env` file:
|
||||
| GITHUB_ID | `123465` | The ID of the GitHub OAuth client. |
|
||||
| GITHUB_SECRET | `123465` | The secret of the GitHub OAuth client. |
|
||||
| RESEND_API_KEY | `123465` | The API key for Resend. |
|
||||
| CLOUDFLARE_ZONE | `[{"zone_id":"abc465","zone_name":"example.com"}]` | The zone info for Cloudflare. |
|
||||
| NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME | `example.com,example2.com` | The zone name for Cloudflare. |
|
||||
| CLOUDFLARE_API_KEY | `123465` | The API key for Cloudflare. |
|
||||
| CLOUDFLARE_EMAIL | `123465` | The email for Cloudflare. |
|
||||
| NEXT_PUBLIC_OPEN_SIGNUP | `1` | Open signup. |
|
||||
| SCREENSHOTONE_BASE_URL | `https://api.example.com` | pending |
|
||||
| GITHUB_TOKEN | `ghp_sscsfarwetqet` | https://github.com/settings/tokens |
|
||||
| NEXT_PUBLIC_SHORT_DOMAINS | `wr.do,uv.do` | The list of short domains. Separated by `,` |
|
||||
| NEXT_PUBLIC_EMAIL_DOMAINS | `wr.do,uv.do` | The list of email domains. Separated by `,` |
|
||||
|
||||
> `NEXT_PUBLIC_SHORT_DOMAINS`、`NEXT_PUBLIC_EMAIL_DOMAINS` is used to limit the short domain name and email domain name.
|
||||
|
||||
- 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 `CLOUDFLARE_ZONE`、`CLOUDFLARE_API_KEY`、`CLOUDFLARE_EMAIL`、`NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME`, see [Cloudflare Configs](/docs/developer/cloudflare).
|
||||
- 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
|
||||
@@ -136,40 +134,7 @@ RESEND_API_KEY = re_your_resend_api_key;
|
||||
|
||||
</Steps>
|
||||
|
||||
## 3. Cloudflare Configs
|
||||
|
||||
Before you start, you must have a Cloudflare account and be hosted on Cloudflare.
|
||||
|
||||
### Add the CLOUDFLARE_ZONE Environment Variable
|
||||
|
||||
A JSON array of objects, each containing a zone_id and zone_name for your Cloudflare zones. The zone_id is the unique identifier for a domain, and the zone_name is the domain name (e.g., example.com).
|
||||
|
||||
> Follow [this way](https://dash.cloudflare.com/Your_Acount_Id/wr.do), and scroll down to `Zone ID`.
|
||||
|
||||
### Add the CLOUDFLARE_API_KEY Environment Variable
|
||||
|
||||
This is the API key that you use to authenticate requests to the Cloudflare API. You can generate or find your API key in the Cloudflare dashboard under the `profile` -> `api-tokens` section.
|
||||
|
||||
> Follow [https://dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens), and scroll down to `API Token`, the `Global API Key` should be used.
|
||||
|
||||
### Add the CLOUDFLARE_EMAIL Environment Variable
|
||||
|
||||
This is the email address associated with your Cloudflare account. It is used for authentication alongside the API key.
|
||||
|
||||
### Add the NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME Environment Variable
|
||||
|
||||
A comma-separated list of domain names (e.g., `example.com,example2.com`) used for frontend display. These must correspond to the zone_name values in CLOUDFLARE_ZONE.
|
||||
|
||||
In this section, you can update these variables:
|
||||
|
||||
```js title=".env"
|
||||
CLOUDFLARE_ZONE=[{"zone_id":"abc465","zone_name":"example.com"},{"zone_id":"abc465","zone_name":"example2.com"}]
|
||||
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME=example.com,example2.com
|
||||
CLOUDFLARE_API_KEY=1234567890abcdef1234567890abcdef
|
||||
CLOUDFLARE_EMAIL=user@example.com
|
||||
```
|
||||
|
||||
## 4. Email Worker Configs
|
||||
## 3. Email Worker Configs
|
||||
|
||||
See detail in [Email Worker](/docs/developer/cloudflare-email-worker).
|
||||
|
||||
@@ -187,20 +152,14 @@ Via:
|
||||
NEXT_PUBLIC_EMAIL_R2_DOMAIN=https://email-attachment.wr.do
|
||||
```
|
||||
|
||||
## 5. Add the Bussiness Configs
|
||||
## 4. Add the Bussiness Configs
|
||||
|
||||
```js title=".env"
|
||||
# Allow anyone to sign up
|
||||
NEXT_PUBLIC_OPEN_SIGNUP=1
|
||||
|
||||
# Short domains. Separated by `,`
|
||||
NEXT_PUBLIC_SHORT_DOMAINS=wr.do,uv.do
|
||||
|
||||
# Email domains. Separated by `,`
|
||||
NEXT_PUBLIC_EMAIL_DOMAINS=wr.do,uv.do
|
||||
```
|
||||
|
||||
## 6. Add the SCREENSHOTONE_BASE_URL Environment Variable
|
||||
## 5. Add the SCREENSHOTONE_BASE_URL Environment Variable
|
||||
|
||||
It's the base URL for the screenshotone API.
|
||||
|
||||
@@ -211,20 +170,40 @@ Deploy docs via [here](https://jasonraimondi.github.io/url-to-png/)
|
||||
SCREENSHOTONE_BASE_URL=https://api.screenshotone.com
|
||||
```
|
||||
|
||||
## 7. Add the GITHUB_TOKEN Environment Variable
|
||||
## 6. Add the GITHUB_TOKEN Environment Variable
|
||||
|
||||
Via https://github.com/settings/tokens to get your token.
|
||||
|
||||
```js title=".env"
|
||||
GITHUB_TOKEN=
|
||||
```
|
||||
## 8. Start the Dev Server
|
||||
|
||||
## 7. Start the Dev Server
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
Via [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
## 8. Setup System
|
||||
|
||||
#### Create the first account and Change the account's role to ADMIN
|
||||
|
||||
Follow the steps below:
|
||||
|
||||
- 1. Via [http://localhost:3000/login](http://localhost:3000/login), login with your account.
|
||||
- 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 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
|
||||
|
||||
@@ -238,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:
|
||||
|
||||
@@ -15,19 +15,13 @@ export const env = createEnv({
|
||||
LinuxDo_CLIENT_SECRET: z.string().optional(),
|
||||
DATABASE_URL: z.string().min(1),
|
||||
RESEND_API_KEY: z.string().optional(),
|
||||
CLOUDFLARE_ZONE: z.string().min(1),
|
||||
CLOUDFLARE_API_KEY: z.string().min(1),
|
||||
CLOUDFLARE_EMAIL: z.string().min(1),
|
||||
SCREENSHOTONE_BASE_URL: z.string().optional(),
|
||||
GITHUB_TOKEN: z.string().optional(),
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_APP_URL: z.string().min(1),
|
||||
NEXT_PUBLIC_OPEN_SIGNUP: z.string().min(1).default("1"),
|
||||
NEXT_PUBLIC_SHORT_DOMAINS: z.string().min(1).default(""),
|
||||
NEXT_PUBLIC_EMAIL_DOMAINS: z.string().min(1).default(""),
|
||||
NEXT_PUBLIC_EMAIL_R2_DOMAIN: z.string().min(1),
|
||||
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME: z.string().min(1),
|
||||
},
|
||||
runtimeEnv: {
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
@@ -40,14 +34,7 @@ export const env = createEnv({
|
||||
RESEND_API_KEY: process.env.RESEND_API_KEY,
|
||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||
NEXT_PUBLIC_OPEN_SIGNUP: process.env.NEXT_PUBLIC_OPEN_SIGNUP,
|
||||
NEXT_PUBLIC_SHORT_DOMAINS: process.env.NEXT_PUBLIC_SHORT_DOMAINS,
|
||||
NEXT_PUBLIC_EMAIL_DOMAINS: process.env.NEXT_PUBLIC_EMAIL_DOMAINS,
|
||||
NEXT_PUBLIC_EMAIL_R2_DOMAIN: process.env.NEXT_PUBLIC_EMAIL_R2_DOMAIN,
|
||||
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME:
|
||||
process.env.NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME,
|
||||
CLOUDFLARE_ZONE: process.env.CLOUDFLARE_ZONE,
|
||||
CLOUDFLARE_API_KEY: process.env.CLOUDFLARE_API_KEY,
|
||||
CLOUDFLARE_EMAIL: process.env.CLOUDFLARE_EMAIL,
|
||||
SCREENSHOTONE_BASE_URL: process.env.SCREENSHOTONE_BASE_URL,
|
||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
||||
LinuxDo_CLIENT_ID: process.env.LinuxDo_CLIENT_ID,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getAllDomains, invalidateDomainConfigCache } from "@/lib/dto/domains";
|
||||
|
||||
export async function getDomainConfig() {
|
||||
return await getAllDomains();
|
||||
}
|
||||
|
||||
export async function getCloudflareCredentials(domain_name: string) {
|
||||
try {
|
||||
const domains = await getAllDomains();
|
||||
const domain = domains.find((d) => d.domain_name === domain_name);
|
||||
if (!domain || !domain.cf_api_key || !domain.cf_email) {
|
||||
throw new Error(
|
||||
`No Cloudflare credentials found for domain: ${domain_name}`,
|
||||
);
|
||||
}
|
||||
|
||||
let apiKey = domain.cf_api_key;
|
||||
if (domain.cf_api_key_encrypted) {
|
||||
// TODO
|
||||
apiKey = decrypt(apiKey);
|
||||
}
|
||||
|
||||
return {
|
||||
api_key: apiKey,
|
||||
email: domain.cf_email,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch credentials: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function decrypt(encryptedKey: string) {
|
||||
return encryptedKey; // Replace with actual decryption logic
|
||||
}
|
||||
|
||||
export { invalidateDomainConfigCache };
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Domain } from "@prisma/client";
|
||||
|
||||
import { prisma } from "../db";
|
||||
|
||||
// In-memory cache
|
||||
let domainConfigCache: Domain[] | null = null;
|
||||
let lastCacheUpdate = 0;
|
||||
const CACHE_DURATION = 60 * 1000; // Cache for 1 minute in memory
|
||||
|
||||
export const FeatureMap = {
|
||||
short: "enable_short_link",
|
||||
email: "enable_email",
|
||||
record: "enable_dns",
|
||||
};
|
||||
|
||||
export interface DomainConfig {
|
||||
domain_name: string;
|
||||
enable_short_link: boolean;
|
||||
enable_email: boolean;
|
||||
enable_dns: boolean;
|
||||
cf_zone_id: string | null;
|
||||
cf_api_key: string | null;
|
||||
cf_email: string | null;
|
||||
cf_api_key_encrypted: boolean;
|
||||
max_short_links: number | null;
|
||||
max_email_forwards: number | null;
|
||||
max_dns_records: number | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface DomainFormData extends DomainConfig {
|
||||
id?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export async function getAllDomains() {
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (domainConfigCache && now - lastCacheUpdate < CACHE_DURATION) {
|
||||
return domainConfigCache;
|
||||
}
|
||||
|
||||
const domains = await prisma.domain.findMany({
|
||||
// where: { active: true },
|
||||
});
|
||||
|
||||
domainConfigCache = domains;
|
||||
lastCacheUpdate = now;
|
||||
return domains;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch domain config: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDomainsByFeature(
|
||||
feature: string,
|
||||
admin: boolean = false,
|
||||
) {
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (domainConfigCache && now - lastCacheUpdate < CACHE_DURATION) {
|
||||
return domainConfigCache;
|
||||
}
|
||||
|
||||
const domains = await prisma.domain.findMany({
|
||||
where: { [feature]: true },
|
||||
select: {
|
||||
domain_name: true,
|
||||
enable_short_link: admin,
|
||||
enable_email: admin,
|
||||
enable_dns: admin,
|
||||
cf_zone_id: admin,
|
||||
cf_api_key: admin,
|
||||
cf_email: admin,
|
||||
},
|
||||
});
|
||||
return domains;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch domain config: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
return createdDomain;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create domain: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDomain(id: string, data) {
|
||||
try {
|
||||
const updatedDomain = await prisma.domain.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return updatedDomain;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update domain: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDomain(domain_name: string) {
|
||||
try {
|
||||
const deletedDomain = await prisma.domain.delete({
|
||||
where: { domain_name },
|
||||
});
|
||||
return deletedDomain;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to delete domain`);
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateDomainConfigCache() {
|
||||
domainConfigCache = null;
|
||||
lastCacheUpdate = 0;
|
||||
}
|
||||
@@ -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 } } });
|
||||
|
||||
@@ -388,25 +388,3 @@ export function extractHost(url: string): string {
|
||||
const match = url.match(regex);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
// 解析 CLOUDFLARE_ZONE 环境变量并返回结构化的域名配置
|
||||
export function parseZones(raw: string) {
|
||||
let zones;
|
||||
|
||||
try {
|
||||
zones = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(zones)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsedZones = zones.map((zone) => {
|
||||
const { zone_id, zone_name } = zone;
|
||||
return { zone_id, zone_name };
|
||||
});
|
||||
|
||||
return parsedZones;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export const createDomainSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
domain_name: z.string().min(2),
|
||||
enable_short_link: z.boolean(),
|
||||
enable_email: z.boolean(),
|
||||
enable_dns: z.boolean(),
|
||||
cf_zone_id: z.string().optional(),
|
||||
cf_api_key: z.string().optional(),
|
||||
cf_email: z.string().optional(),
|
||||
cf_api_key_encrypted: z.boolean().default(false),
|
||||
max_short_links: z.number().optional(),
|
||||
max_email_forwards: z.number().optional(),
|
||||
max_dns_records: z.number().optional(),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wr.do",
|
||||
"version": "0.5.1",
|
||||
"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");
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE domains
|
||||
(
|
||||
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"domain_name" TEXT NOT NULL UNIQUE,
|
||||
"enable_short_link" BOOLEAN DEFAULT FALSE,
|
||||
"enable_email" BOOLEAN DEFAULT FALSE,
|
||||
"enable_dns" BOOLEAN DEFAULT FALSE,
|
||||
"cf_zone_id" TEXT NOT NULL,
|
||||
"cf_api_key" TEXT NOT NULL,
|
||||
"cf_email" TEXT NOT NULL,
|
||||
"cf_api_key_encrypted" BOOLEAN DEFAULT FALSE,
|
||||
"max_short_links" INTEGER,
|
||||
"max_email_forwards" INTEGER,
|
||||
"max_dns_records" INTEGER,
|
||||
"active" BOOLEAN DEFAULT TRUE,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -247,3 +247,24 @@ model UserSendEmail {
|
||||
@@index([createdAt])
|
||||
@@map("user_send_emails")
|
||||
}
|
||||
|
||||
model Domain {
|
||||
id String @id @default(uuid())
|
||||
domain_name String @unique
|
||||
enable_short_link Boolean @default(false)
|
||||
enable_email Boolean @default(false)
|
||||
enable_dns Boolean @default(false)
|
||||
cf_zone_id String?
|
||||
cf_api_key String?
|
||||
cf_email String?
|
||||
cf_api_key_encrypted Boolean
|
||||
max_short_links Int?
|
||||
max_email_forwards Int?
|
||||
max_dns_records Int?
|
||||
active Boolean @default(true)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now())
|
||||
|
||||
@@map("domains")
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 333 KiB |
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.5.1",
|
||||
"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.5.1",
|
||||
"versionName": "0.6.1",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
+36
-36
@@ -1,39 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-05-10T03:08:41.904Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/pricing</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/privacy</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/terms</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/chat</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/chat</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/pricing</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/privacy</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/terms</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
</urlset>
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
-3
@@ -17,9 +17,6 @@ export type SiteConfig = {
|
||||
oichat: string;
|
||||
};
|
||||
openSignup: boolean;
|
||||
shortDomains: string[];
|
||||
emailDomains: string[];
|
||||
recordDomains: string[];
|
||||
emailR2Domain: string;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user