@@ -27,12 +27,6 @@ LinuxDo_CLIENT_SECRET=
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL="wrdo <support@wr.do>"
|
||||
|
||||
# Open Signup
|
||||
NEXT_PUBLIC_OPEN_SIGNUP=1
|
||||
|
||||
# Enable subdomain apply, default is false(0). If set to 1, will enable subdomain apply
|
||||
NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY=0
|
||||
|
||||
# Google Analytics
|
||||
NEXT_PUBLIC_GOOGLE_ID=
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- fix/docker
|
||||
- i18n
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
pull_request:
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ WR.DO 是一个一站式网络工具平台,集成短链服务、临时邮箱
|
||||
|
||||
### 使用 Vercel 部署
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=GITHUB_TOKEN)
|
||||
|
||||
记得填写必要的环境变量。
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ See step by step installation tutorial at [Quick Start for Developer](https://wr
|
||||
|
||||
### Deploy with Vercel
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=GITHUB_TOKEN)
|
||||
|
||||
Remember to fill in the necessary environment variables.
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NavMobile } from "@/components/layout/mobile-nav";
|
||||
import { NavBar } from "@/components/layout/navbar";
|
||||
import { Notification } from "@/components/layout/notification";
|
||||
import { SiteFooter } from "@/components/layout/site-footer";
|
||||
|
||||
interface MarketingLayoutProps {
|
||||
@@ -11,6 +12,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||
<div className="flex min-h-screen flex-col dark:bg-black">
|
||||
<NavMobile />
|
||||
<NavBar scroll={true} />
|
||||
<Notification />
|
||||
<main className="flex-1 bg-[radial-gradient(circle_500px_at_50%_300px,#a1fffc36,#ffffff)] dark:bg-[radial-gradient(circle_500px_at_50%_300px,#a1fffc36,#000)]">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { getUserRecordCount } from "@/lib/dto/cloudflare-dns-record";
|
||||
import {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import UserRecordsList from "../../dashboard/records/record-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "DNS Records - WR.DO",
|
||||
title: "DNS Records",
|
||||
description: "List and manage records.",
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
import { SkeletonSection } from "@/components/shared/section-skeleton";
|
||||
|
||||
export default function AppConfigs({}: {}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { data: configs, isLoading } = useSWR<Record<string, any>>(
|
||||
"/api/admin/configs",
|
||||
fetcher,
|
||||
);
|
||||
const [notification, setNotification] = useState("");
|
||||
|
||||
const t = useTranslations("Setting");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && configs?.system_notification) {
|
||||
setNotification(configs.system_notification);
|
||||
}
|
||||
}, [configs, isLoading]);
|
||||
|
||||
const handleChange = (value: any, key: string, type: string) => {
|
||||
startTransition(async () => {
|
||||
const res = await fetch("/api/admin/configs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ key, value, type }),
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success("Updated!");
|
||||
} else {
|
||||
toast.error("Failed!", {
|
||||
description: await res.text(),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<SkeletonSection />
|
||||
<SkeletonSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-neutral-50 dark:bg-neutral-900">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-lg font-bold">{t("App Configs")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mt-3 space-y-6 border-t pt-6">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">{t("User Registration")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Allow users to sign up")}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<Switch
|
||||
defaultChecked={configs.enable_user_registration}
|
||||
onCheckedChange={(v) =>
|
||||
handleChange(v, "enable_user_registration", "BOOLEAN")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">{t("Subdomain Apply Mode")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Enable subdomain apply mode, each submission requires administrator review",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<Switch
|
||||
defaultChecked={configs.enable_subdomain_apply}
|
||||
onCheckedChange={(v) =>
|
||||
handleChange(v, "enable_subdomain_apply", "BOOLEAN")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start gap-3">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">{t("Notification")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Set system notification, this will be displayed in the header",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<Textarea
|
||||
className="h-16 max-h-32 min-h-9 resize-y"
|
||||
placeholder="Support HTML format, such as <div>info</div>"
|
||||
rows={5}
|
||||
defaultValue={configs.system_notification}
|
||||
value={notification}
|
||||
onChange={(e) => setNotification(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className="h-9 text-nowrap"
|
||||
disabled={
|
||||
isPending || notification === configs.system_notification
|
||||
}
|
||||
onClick={() =>
|
||||
handleChange(notification, "system_notification", "STRING")
|
||||
}
|
||||
>
|
||||
{isPending && (
|
||||
<Icons.spinner className="mr-1 size-4 animate-spin" />
|
||||
)}
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+5
-8
@@ -12,12 +12,7 @@ import { DomainFormData } from "@/lib/dto/domains";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -296,7 +291,7 @@ export default function DomainList({ user, action }: DomainListProps) {
|
||||
</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"
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground sm:px-1.5"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
@@ -306,7 +301,9 @@ export default function DomainList({ user, action }: DomainListProps) {
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p className="hidden sm:block">{t("Edit")}</p>
|
||||
<p className="hidden text-nowrap sm:block">
|
||||
{t("Edit")}
|
||||
</p>
|
||||
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
+4
-1
@@ -1,10 +1,13 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import { SkeletonSection } from "@/components/shared/section-skeleton";
|
||||
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Domains Management" text="" />
|
||||
<DashboardHeader heading="System Settings" text="" />
|
||||
<SkeletonSection />
|
||||
<SkeletonSection />
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
@@ -4,11 +4,13 @@ import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import AppConfigs from "./app-configs";
|
||||
import DomainList from "./domain-list";
|
||||
import PlanList from "./plan-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Domains - WR.DO",
|
||||
description: "List and manage domains.",
|
||||
title: "System Settings",
|
||||
description: "",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
@@ -18,12 +20,8 @@ export default async function DashboardPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Domains Management"
|
||||
text="List and manage domains"
|
||||
link="/docs/developer/cloudflare"
|
||||
linkText="domains"
|
||||
/>
|
||||
<DashboardHeader heading="System Settings" text="" />
|
||||
<AppConfigs />
|
||||
<DomainList
|
||||
user={{
|
||||
id: user.id,
|
||||
@@ -35,6 +33,17 @@ export default async function DashboardPage() {
|
||||
}}
|
||||
action="/api/admin/domain"
|
||||
/>
|
||||
<PlanList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/admin/plan"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { User } from "@prisma/client";
|
||||
import { PenLine, RefreshCwIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
import { PlanQuotaFormData } from "@/lib/dto/plan";
|
||||
import { fetcher, nFormatter } from "@/lib/utils";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, 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 { PlanForm } from "@/components/forms/plan-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";
|
||||
import { TimeAgoIntl } from "@/components/shared/time-ago";
|
||||
|
||||
export interface PlanListProps {
|
||||
user: Pick<User, "id" | "name" | "email" | "apiKey" | "role" | "team">;
|
||||
action: string;
|
||||
}
|
||||
|
||||
function TableColumnSekleton() {
|
||||
return (
|
||||
<TableRow className="grid grid-cols-4 items-center sm:grid-cols-8">
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm: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 hidden sm: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-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-32" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlanList({ user, action }: PlanListProps) {
|
||||
const { isMobile } = useMediaQuery();
|
||||
const t = useTranslations("List");
|
||||
const [isShowForm, setShowForm] = useState(false);
|
||||
const [formType, setFormType] = useState<FormType>("add");
|
||||
const [currentEditPlan, setCurrentEditPlan] =
|
||||
useState<PlanQuotaFormData | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
slug: "",
|
||||
target: "",
|
||||
userName: "",
|
||||
});
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data, isLoading } = useSWR<{
|
||||
total: number;
|
||||
list: PlanQuotaFormData[];
|
||||
}>(
|
||||
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
const handleRefresh = () => {
|
||||
mutate(
|
||||
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
|
||||
undefined,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-lg font-bold">
|
||||
<span className="text-nowrap">{t("Quota Settings")}</span>
|
||||
</div>
|
||||
|
||||
<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="flex shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCurrentEditPlan(null);
|
||||
setShowForm(false);
|
||||
setFormType("add");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<Icons.add className="size-4" />
|
||||
<span className="hidden sm:inline">{t("Add Plan")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
||||
<TableRow className="grid grid-cols-4 items-center text-xs sm:grid-cols-8">
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
{t("Plan Name")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
{t("Short Limit")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
{t("Email Limit")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
{t("Send Limit")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
{t("Record Limit")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
|
||||
{t("Active")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
{t("Updated")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
{t("Actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
</>
|
||||
) : data && data.list && data.list.length ? (
|
||||
data.list.map((plan) => (
|
||||
<div className="border-b" key={plan.id}>
|
||||
<TableRow className="grid grid-cols-4 items-center sm:grid-cols-8">
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
{plan.name}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||
{nFormatter(plan.slNewLinks)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||
{nFormatter(plan.emEmailAddresses)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||
{nFormatter(plan.emSendEmails)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||
{nFormatter(plan.rcNewRecords)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Switch
|
||||
disabled
|
||||
defaultChecked={plan.isActive}
|
||||
// onCheckedChange={(value) =>
|
||||
// handleChangeStatus(value, "active", domain)
|
||||
// }
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center truncate">
|
||||
<TimeAgoIntl date={plan.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 sm:px-1.5"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setCurrentEditPlan(plan);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p className="hidden text-nowrap sm:block">
|
||||
{t("Edit")}
|
||||
</p>
|
||||
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyPlaceholder className="shadow-none">
|
||||
<EmptyPlaceholder.Icon name="settings" />
|
||||
<EmptyPlaceholder.Title>
|
||||
{t("No Plans")}
|
||||
</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any plans yet. Start creating one.
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
layout={isMobile ? "right" : "split"}
|
||||
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}
|
||||
>
|
||||
<PlanForm
|
||||
user={{ id: user.id, name: user.name || "" }}
|
||||
isShowForm={isShowForm}
|
||||
setShowForm={setShowForm}
|
||||
type={formType}
|
||||
initData={currentEditPlan}
|
||||
action={action}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import UserUrlsList from "../../dashboard/urls/url-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Short URLs - WR.DO",
|
||||
title: "Short URLs",
|
||||
description: "List and manage records.",
|
||||
});
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { getUserRecordCount } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getAllUserEmailsCount } from "@/lib/dto/email";
|
||||
import { getPlanQuota, PlanQuota } from "@/lib/dto/plan";
|
||||
import { getUserShortUrlCount } from "@/lib/dto/short-urls";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
@@ -19,16 +19,16 @@ import UserRecordsList from "./records/record-list";
|
||||
import UserUrlsList from "./urls/url-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Dashboard - WR.DO",
|
||||
title: "Dashboard",
|
||||
description: "List and manage records.",
|
||||
});
|
||||
|
||||
async function EmailHeroCardSection({
|
||||
userId,
|
||||
team,
|
||||
plan,
|
||||
}: {
|
||||
userId: string;
|
||||
team: string;
|
||||
plan: PlanQuota;
|
||||
}) {
|
||||
const email_count = await getAllUserEmailsCount(userId);
|
||||
|
||||
@@ -36,17 +36,17 @@ async function EmailHeroCardSection({
|
||||
<HeroCard
|
||||
total={email_count.total}
|
||||
monthTotal={email_count.month_total}
|
||||
limit={TeamPlanQuota[team].EM_EmailAddresses}
|
||||
limit={plan.emEmailAddresses}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function ShortUrlsCardSection({
|
||||
userId,
|
||||
team,
|
||||
plan,
|
||||
}: {
|
||||
userId: string;
|
||||
team: string;
|
||||
plan: PlanQuota;
|
||||
}) {
|
||||
const url_count = await getUserShortUrlCount(userId);
|
||||
|
||||
@@ -56,7 +56,7 @@ async function ShortUrlsCardSection({
|
||||
title="Short URLs"
|
||||
total={url_count.total}
|
||||
monthTotal={url_count.month_total}
|
||||
limit={TeamPlanQuota[team].SL_NewLinks}
|
||||
limit={plan.slNewLinks}
|
||||
link="/dashboard/urls"
|
||||
icon="link"
|
||||
/>
|
||||
@@ -65,10 +65,10 @@ async function ShortUrlsCardSection({
|
||||
|
||||
async function DnsRecordsCardSection({
|
||||
userId,
|
||||
team,
|
||||
plan,
|
||||
}: {
|
||||
userId: string;
|
||||
team: string;
|
||||
plan: PlanQuota;
|
||||
}) {
|
||||
const record_count = await getUserRecordCount(userId);
|
||||
|
||||
@@ -78,7 +78,7 @@ async function DnsRecordsCardSection({
|
||||
title="DNS Records"
|
||||
total={record_count.total}
|
||||
monthTotal={record_count.month_total}
|
||||
limit={TeamPlanQuota[team].RC_NewRecords}
|
||||
limit={plan.rcNewRecords}
|
||||
link="/dashboard/records"
|
||||
icon="globeLock"
|
||||
/>
|
||||
@@ -140,6 +140,8 @@ export default async function DashboardPage() {
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
const plan = await getPlanQuota(user.team);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-5">
|
||||
@@ -150,7 +152,7 @@ export default async function DashboardPage() {
|
||||
<Suspense
|
||||
fallback={<Skeleton className="h-32 w-full rounded-lg" />}
|
||||
>
|
||||
<EmailHeroCardSection userId={user.id} team={user.team} />
|
||||
<EmailHeroCardSection userId={user.id} plan={plan} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary
|
||||
@@ -159,7 +161,7 @@ export default async function DashboardPage() {
|
||||
<Suspense
|
||||
fallback={<Skeleton className="h-32 w-full rounded-lg" />}
|
||||
>
|
||||
<ShortUrlsCardSection userId={user.id} team={user.team} />
|
||||
<ShortUrlsCardSection userId={user.id} plan={plan} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary
|
||||
@@ -168,7 +170,7 @@ export default async function DashboardPage() {
|
||||
<Suspense
|
||||
fallback={<Skeleton className="h-32 w-full rounded-lg" />}
|
||||
>
|
||||
<DnsRecordsCardSection userId={user.id} team={user.team} />
|
||||
<DnsRecordsCardSection userId={user.id} plan={plan} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import UserRecordsList from "./record-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "DNS Records - WR.DO",
|
||||
title: "DNS Records",
|
||||
description: "List and manage records.",
|
||||
});
|
||||
|
||||
|
||||
@@ -275,12 +275,13 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center justify-center gap-1 sm:flex">
|
||||
{record.active !== 2 ? (
|
||||
{[0, 1].includes(record.active) && (
|
||||
<SwitchWrapper
|
||||
record={record}
|
||||
onChangeStatu={handleChangeStatu}
|
||||
/>
|
||||
) : (
|
||||
)}
|
||||
{record.active === 2 && (
|
||||
<Badge
|
||||
className="text-nowrap rounded-md"
|
||||
variant={"yellow"}
|
||||
@@ -288,7 +289,16 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
{t("Pending")}
|
||||
</Badge>
|
||||
)}
|
||||
{record.active !== 1 && (
|
||||
{record.active === 3 && (
|
||||
<Badge
|
||||
className="text-nowrap rounded-md"
|
||||
variant={"outline"}
|
||||
>
|
||||
{t("Rejected")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{![1, 3].includes(record.active) && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger className="truncate">
|
||||
@@ -336,7 +346,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger className="truncate">
|
||||
{record.user.name ?? record.user.email}
|
||||
{record.user.name}: {record.user.email}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{record.user.name ?? record.user.email}
|
||||
@@ -350,9 +360,27 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex justify-center">
|
||||
{record.active !== 2 ? (
|
||||
{record.active === 3 ? (
|
||||
<Button
|
||||
className="text-nowrap text-sm hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
className="h-7 text-nowrap px-1 text-xs sm:px-1.5"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
disabled
|
||||
onClick={() => {
|
||||
setCurrentEditRecord(record);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p className="hidden text-nowrap sm:block">
|
||||
{t("Reject")}
|
||||
</p>
|
||||
<Icons.close className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
) : [0, 1].includes(record.active) ? (
|
||||
<Button
|
||||
className="h-7 text-nowrap px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground sm:px-1.5"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
@@ -362,14 +390,16 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p>{t("Edit")}</p>
|
||||
<PenLine className="ml-1 size-4" />
|
||||
<p className="hidden text-nowrap sm:block">
|
||||
{t("Edit")}
|
||||
</p>
|
||||
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
) : record.active === 2 &&
|
||||
user.role === "ADMIN" &&
|
||||
isAdmin ? (
|
||||
<Button
|
||||
className="text-sm hover:bg-blue-400 dark:hover:text-primary-foreground"
|
||||
className="h-7 text-nowrap px-1 text-xs hover:bg-blue-400 dark:hover:text-primary-foreground sm:px-1.5"
|
||||
size="sm"
|
||||
variant={"blue"}
|
||||
onClick={() => {
|
||||
@@ -379,7 +409,10 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p>{t("Review")}</p>
|
||||
<p className="hidden text-nowrap sm:block">
|
||||
{t("Review")}
|
||||
</p>
|
||||
<Icons.eye className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
) : (
|
||||
"--"
|
||||
|
||||
@@ -8,7 +8,7 @@ import ApiReference from "@/components/shared/api-reference";
|
||||
import { MarkdownScraping, TextScraping } from "../scrapes";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Url to Markdown API - WR.DO",
|
||||
title: "Url to Markdown API",
|
||||
description:
|
||||
"Quickly extract website content and convert it to Markdown format",
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import ApiReference from "@/components/shared/api-reference";
|
||||
import { MetaScraping } from "../scrapes";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Url to Meta API - WR.DO",
|
||||
title: "Url to Meta API",
|
||||
description: "Quickly extract valuable structured website data",
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import DashboardScrapeCharts from "./charts";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Scraping API - WR.DO",
|
||||
title: "Scraping API",
|
||||
description: "Quickly extract valuable structured website data",
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import QRCodeEditor from "@/components/shared/qr";
|
||||
import { CodeLight } from "../scrapes";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Url to QR Code API - WR.DO",
|
||||
title: "Url to QR Code API",
|
||||
description: "Generate QR Code from URL",
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import DashboardScrapeCharts from "../charts";
|
||||
import { ScreenshotScraping } from "../scrapes";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Url to Screenshot API - WR.DO",
|
||||
title: "Url to Screenshot API",
|
||||
description:
|
||||
"Quickly extract website screenshots. It's free and unlimited to use!",
|
||||
});
|
||||
|
||||
@@ -9,8 +9,8 @@ import { TopoJSONMap } from "@unovis/ts";
|
||||
import { WorldMapTopoJSON } from "@unovis/ts/maps";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import {
|
||||
getBotName,
|
||||
getCountryName,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
getRegionName,
|
||||
} from "@/lib/contries";
|
||||
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
|
||||
import { isLink, removeUrlSuffix } from "@/lib/utils";
|
||||
import { fetcher, isLink, removeUrlSuffix } from "@/lib/utils";
|
||||
import { useElementSize } from "@/hooks/use-element-size";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -179,6 +179,11 @@ export function DailyPVUVChart({
|
||||
|
||||
const t = useTranslations("Components");
|
||||
|
||||
const { data: plan } = useSWR<{ slAnalyticsRetention: number }>(
|
||||
`/api/plan?team=${user.team}`,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
const processedData = processUrlMeta(data).map((entry) => ({
|
||||
date: entry.date,
|
||||
pv: entry.clicks,
|
||||
@@ -254,40 +259,39 @@ export function DailyPVUVChart({
|
||||
<CardDescription>{lastVisitorInfo}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setTimeRange(value);
|
||||
}}
|
||||
name="time range"
|
||||
defaultValue={timeRange}
|
||||
>
|
||||
<SelectTrigger className="mx-4 w-full shadow-inner">
|
||||
<SelectValue placeholder="Select a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_DIMENSION_ENUMS.map((e, i) => (
|
||||
<div key={e.value}>
|
||||
<SelectItem
|
||||
disabled={
|
||||
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
|
||||
}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{t(e.label)}
|
||||
{e.key >
|
||||
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{plan && (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setTimeRange(value);
|
||||
}}
|
||||
name="time range"
|
||||
defaultValue={timeRange}
|
||||
>
|
||||
<SelectTrigger className="mx-4 w-full min-w-28 shadow-inner">
|
||||
<SelectValue placeholder="Select a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_DIMENSION_ENUMS.map((e, i) => (
|
||||
<div key={e.value}>
|
||||
<SelectItem
|
||||
disabled={e.key > plan.slAnalyticsRetention}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{t(e.label)}
|
||||
{e.key > plan.slAnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{["pv", "uv"].map((key) => {
|
||||
const chart = key as keyof typeof chartConfig;
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,6 @@ import { UrlMeta, User } from "@prisma/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import {
|
||||
@@ -37,6 +36,11 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
|
||||
{ focusThrottleInterval: 30000 }, // 30 seconds,
|
||||
);
|
||||
|
||||
const { data: plan } = useSWR<{ slAnalyticsRetention: number }>(
|
||||
`/api/plan?team=${user.team}`,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -55,40 +59,39 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
|
||||
"",
|
||||
)}
|
||||
.
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setTimeRange(value);
|
||||
}}
|
||||
name="time range"
|
||||
defaultValue={timeRange}
|
||||
>
|
||||
<SelectTrigger className="mt-4 w-full shadow-inner">
|
||||
<SelectValue placeholder="Select a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_DIMENSION_ENUMS.map((e, i) => (
|
||||
<div key={e.value}>
|
||||
<SelectItem
|
||||
disabled={
|
||||
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
|
||||
}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{t(e.label)}
|
||||
{e.key >
|
||||
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{plan && (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setTimeRange(value);
|
||||
}}
|
||||
name="time range"
|
||||
defaultValue={timeRange}
|
||||
>
|
||||
<SelectTrigger className="mt-4 w-full shadow-inner">
|
||||
<SelectValue placeholder="Select a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_DIMENSION_ENUMS.map((e, i) => (
|
||||
<div key={e.value}>
|
||||
<SelectItem
|
||||
disabled={e.key > plan.slAnalyticsRetention}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{t(e.label)}
|
||||
{e.key > plan.slAnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import UserUrlsList from "./url-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Short URLs - WR.DO",
|
||||
title: "Short URLs",
|
||||
description: "List and manage records.",
|
||||
});
|
||||
|
||||
|
||||
@@ -400,7 +400,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground sm:px-1.5"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
@@ -410,7 +410,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p className="hidden sm:block">{t("Edit")}</p>
|
||||
<p className="hidden text-nowrap sm:block">{t("Edit")}</p>
|
||||
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MobileSheetSidebar,
|
||||
} from "@/components/layout/dashboard-sidebar";
|
||||
import { ModeToggle } from "@/components/layout/mode-toggle";
|
||||
import { Notification } from "@/components/layout/notification";
|
||||
import { UserAccountNav } from "@/components/layout/user-account-nav";
|
||||
import MaxWidthWrapper from "@/components/shared/max-width-wrapper";
|
||||
|
||||
@@ -32,6 +33,7 @@ export default async function Dashboard({ children }: ProtectedLayoutProps) {
|
||||
<DashboardSidebar links={filteredLinks} />
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Notification />
|
||||
<header className="sticky top-0 z-50 flex h-14 bg-background px-4 lg:h-[60px] xl:px-8">
|
||||
<MaxWidthWrapper className="flex max-w-7xl items-center gap-x-3 px-0">
|
||||
<MobileSheetSidebar links={filteredLinks} />
|
||||
|
||||
@@ -7,14 +7,12 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
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";
|
||||
|
||||
@@ -202,7 +200,7 @@ function SetAdminRole({ id, email }: { id: string; email: string }) {
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
{t("Allow Sign Up")}:
|
||||
</span>
|
||||
{siteConfig.openSignup ? ReadyBadge : <Skeleton className="h-4 w-12" />}
|
||||
{ReadyBadge}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
getMultipleConfigs,
|
||||
updateSystemConfig,
|
||||
} from "@/lib/dto/system-config";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs([
|
||||
"enable_user_registration",
|
||||
"enable_subdomain_apply",
|
||||
"system_notification",
|
||||
]);
|
||||
|
||||
return Response.json(configs, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
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 { key, value, type } = await req.json();
|
||||
if (!key || !type) {
|
||||
return Response.json("key and value is required", { status: 400 });
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs([key]);
|
||||
|
||||
if (key in configs) {
|
||||
await updateSystemConfig(key, { value, type });
|
||||
return Response.json("Success", { status: 200 });
|
||||
}
|
||||
return Response.json("Invalid key", { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
createPlan,
|
||||
deletePlan,
|
||||
getAllPlans,
|
||||
updatePlanQuota,
|
||||
} from "@/lib/dto/plan";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// const url = new URL(req.url);
|
||||
// const page = url.searchParams.get("page");
|
||||
// const size = url.searchParams.get("size");
|
||||
// const target = url.searchParams.get("target") || "";
|
||||
|
||||
const data = await getAllPlans();
|
||||
|
||||
return Response.json(data, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
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 { plan } = await req.json();
|
||||
|
||||
const data = await createPlan({
|
||||
name: plan.name,
|
||||
slTrackedClicks: plan.slTrackedClicks,
|
||||
slNewLinks: plan.slNewLinks,
|
||||
slAnalyticsRetention: plan.slAnalyticsRetention,
|
||||
slDomains: plan.slDomains,
|
||||
slAdvancedAnalytics: plan.slAdvancedAnalytics,
|
||||
slCustomQrCodeLogo: plan.slCustomQrCodeLogo,
|
||||
rcNewRecords: plan.rcNewRecords,
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
appSupport: plan.appSupport.toUpperCase() as any,
|
||||
appApiAccess: plan.appApiAccess,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
return Response.json(data, { status: 200 });
|
||||
}
|
||||
|
||||
return Response.json(null, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
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 { plan } = await req.json();
|
||||
if (!plan) {
|
||||
return Response.json("Invalid request body", { status: 400 });
|
||||
}
|
||||
|
||||
const res = await updatePlanQuota({
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
slTrackedClicks: plan.slTrackedClicks,
|
||||
slNewLinks: plan.slNewLinks,
|
||||
slAnalyticsRetention: plan.slAnalyticsRetention,
|
||||
slDomains: plan.slDomains,
|
||||
slAdvancedAnalytics: plan.slAdvancedAnalytics,
|
||||
slCustomQrCodeLogo: plan.slCustomQrCodeLogo,
|
||||
rcNewRecords: plan.rcNewRecords,
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
appSupport: plan.appSupport.toUpperCase() as any,
|
||||
appApiAccess: plan.appApiAccess,
|
||||
isActive: plan.isActive,
|
||||
});
|
||||
|
||||
if (res) {
|
||||
return Response.json(res, { status: 200 });
|
||||
}
|
||||
|
||||
return Response.json(null, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
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 { id } = await req.json();
|
||||
if (!id) {
|
||||
return Response.json("id is required", { status: 400 });
|
||||
}
|
||||
|
||||
const data = await deletePlan(id);
|
||||
|
||||
if (data) {
|
||||
return Response.json(data, { status: 200 });
|
||||
}
|
||||
|
||||
return Response.json(null, { status: 400 });
|
||||
} 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 { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
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 keys = url.searchParams.getAll("key") || [];
|
||||
|
||||
if (keys.length === 0) {
|
||||
return Response.json("key is required", { status: 400 });
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs(keys);
|
||||
|
||||
// "enable_user_registration",
|
||||
// "enable_subdomain_apply",
|
||||
// "system_notification",
|
||||
|
||||
return Response.json(configs, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { createUserEmail, getAllUserEmails } from "@/lib/dto/email";
|
||||
import { getPlanQuota } from "@/lib/dto/plan";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { reservedAddressSuffix } from "@/lib/enums";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
@@ -43,11 +43,13 @@ export async function POST(req: NextRequest) {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const plan = await getPlanQuota(user.team);
|
||||
|
||||
// check limit
|
||||
const limit = await restrictByTimeRange({
|
||||
model: "userEmail",
|
||||
userId: user.id,
|
||||
limit: TeamPlanQuota[user.team].EM_EmailAddresses,
|
||||
limit: plan.emEmailAddresses,
|
||||
rangeType: "month",
|
||||
});
|
||||
if (limit)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { checkDomainIsConfiguratedResend } from "@/lib/dto/domains";
|
||||
import { getUserSendEmailCount, saveUserSendEmail } from "@/lib/dto/email";
|
||||
import { getPlanQuota } from "@/lib/dto/plan";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { resend } from "@/lib/email";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
@@ -14,11 +14,13 @@ export async function POST(req: NextRequest) {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const plan = await getPlanQuota(user.team);
|
||||
|
||||
// check limit
|
||||
const limit = await restrictByTimeRange({
|
||||
model: "userSendEmail",
|
||||
userId: user.id,
|
||||
limit: TeamPlanQuota[user.team].EM_SendEmails,
|
||||
limit: plan.emSendEmails,
|
||||
rangeType: "month",
|
||||
});
|
||||
if (limit)
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { getConfigValue } from "@/lib/dto/system-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const registration = await getConfigValue<boolean>(
|
||||
"enable_user_registration",
|
||||
);
|
||||
if (process.env.VERCEL) {
|
||||
return Response.json({
|
||||
google: !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
|
||||
github: !!(env.GITHUB_ID && env.GITHUB_SECRET),
|
||||
linuxdo: !!(env.LinuxDo_CLIENT_ID && env.LinuxDo_CLIENT_SECRET),
|
||||
resend: !!(env.RESEND_API_KEY && env.RESEND_FROM_EMAIL),
|
||||
registration,
|
||||
});
|
||||
} else {
|
||||
// TODO: (docker) cannot get env on docker environment
|
||||
@@ -16,6 +23,7 @@ export async function GET(req: Request) {
|
||||
github: true,
|
||||
linuxdo: true,
|
||||
resend: true,
|
||||
registration,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { getPlanNames } from "@/lib/dto/plan";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Get plan names for frontend
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const res = await getPlanNames();
|
||||
if (!res) {
|
||||
return Response.json("Plans not found", { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json(res, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { getAllPlans, getPlanQuota } from "@/lib/dto/plan";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Get one plan by plan name for frontend
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const isAll = url.searchParams.get("all") || "0";
|
||||
const team = url.searchParams.get("team") || "free";
|
||||
|
||||
if (isAll === "1") {
|
||||
const res = await getAllPlans();
|
||||
if (res) {
|
||||
return Response.json(res, { status: 200 });
|
||||
}
|
||||
} else {
|
||||
const res = await getPlanQuota(team);
|
||||
if (res) {
|
||||
return Response.json(res, { status: 200 });
|
||||
}
|
||||
}
|
||||
return Response.json("Plan not found", { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { createDNSRecord } from "@/lib/cloudflare";
|
||||
import {
|
||||
createUserRecord,
|
||||
@@ -8,6 +7,8 @@ import {
|
||||
getUserRecordCount,
|
||||
} from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { getPlanQuota } from "@/lib/dto/plan";
|
||||
import { getConfigValue } from "@/lib/dto/system-config";
|
||||
import { checkUserStatus, getFirstAdminUser } from "@/lib/dto/user";
|
||||
import { applyRecordEmailHtml, resend } from "@/lib/email";
|
||||
import { reservedDomains } from "@/lib/enums";
|
||||
@@ -27,8 +28,10 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const plan = await getPlanQuota(user.team);
|
||||
|
||||
const { total } = await getUserRecordCount(user.id);
|
||||
if (total >= TeamPlanQuota[user.team].RC_NewRecords) {
|
||||
if (total >= plan.rcNewRecords) {
|
||||
return Response.json("Your records have reached the free limit.", {
|
||||
status: 409,
|
||||
});
|
||||
@@ -82,8 +85,12 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const enableSubdomainApply = await getConfigValue<boolean>(
|
||||
"enable_subdomain_apply",
|
||||
);
|
||||
|
||||
// apply subdomain
|
||||
if (siteConfig.enableSubdomainApply) {
|
||||
if (enableSubdomainApply) {
|
||||
const res = await createUserRecord(user.id, {
|
||||
record_id: generateSecret(16),
|
||||
zone_id: matchedZone.cf_zone_id,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { createDNSRecord } from "@/lib/cloudflare";
|
||||
import {
|
||||
createUserRecord,
|
||||
@@ -6,6 +5,7 @@ import {
|
||||
getUserRecordCount,
|
||||
} from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { getPlanQuota } from "@/lib/dto/plan";
|
||||
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
|
||||
import { reservedDomains } from "@/lib/enums";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
@@ -39,8 +39,10 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const plan = await getPlanQuota(user.team);
|
||||
|
||||
const { total } = await getUserRecordCount(target_user.id);
|
||||
if (total >= TeamPlanQuota[target_user.team!].RC_NewRecords) {
|
||||
if (total >= plan.rcNewRecords) {
|
||||
return Response.json("Your records have reached the free limit.", {
|
||||
status: 409,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createDNSRecord } from "@/lib/cloudflare";
|
||||
import { updateUserRecordReview } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
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 { record: reviewRecord, userId, recordId } = await req.json();
|
||||
const record = {
|
||||
...reviewRecord,
|
||||
recordId,
|
||||
};
|
||||
|
||||
let matchedZone;
|
||||
|
||||
for (const zone of zones) {
|
||||
if (record.zone_name === zone.domain_name) {
|
||||
matchedZone = zone;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// const data = await createDNSRecord(
|
||||
// matchedZone.cf_zone_id,
|
||||
// matchedZone.cf_api_key,
|
||||
// matchedZone.cf_email,
|
||||
// record,
|
||||
// );
|
||||
|
||||
const res = await updateUserRecordReview(userId, recordId, {
|
||||
record_id: recordId,
|
||||
zone_id: matchedZone.cf_zone_id,
|
||||
zone_name: matchedZone.domain_name,
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
content: record.content,
|
||||
proxied: record.proxied,
|
||||
proxiable: record.proxiable,
|
||||
ttl: record.ttl,
|
||||
comment: record.comment ?? "",
|
||||
tags: "",
|
||||
created_on: new Date().toISOString(),
|
||||
modified_on: new Date().toISOString(),
|
||||
active: 3,
|
||||
});
|
||||
|
||||
if (res.status !== "success") {
|
||||
return Response.json(res.status, {
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
return Response.json(res.data);
|
||||
} catch (error) {
|
||||
console.error("[错误]", error);
|
||||
return Response.json(error, {
|
||||
status: error?.status || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { getPlanQuota } from "@/lib/dto/plan";
|
||||
import { createUserShortUrl } from "@/lib/dto/short-urls";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
@@ -11,11 +11,12 @@ export async function POST(req: Request) {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const plan = await getPlanQuota(user.team);
|
||||
// check limit
|
||||
const limit = await restrictByTimeRange({
|
||||
model: "userUrl",
|
||||
userId: user.id,
|
||||
limit: TeamPlanQuota[user.team].SL_NewLinks,
|
||||
limit: plan.slNewLinks,
|
||||
rangeType: "month",
|
||||
});
|
||||
if (limit) return Response.json(limit.statusText, { status: limit.status });
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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 { getPlanQuota } from "@/lib/dto/plan";
|
||||
import { reservedAddressSuffix } from "@/lib/enums";
|
||||
import { restrictByTimeRange } from "@/lib/team";
|
||||
|
||||
@@ -32,11 +31,13 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const plan = await getPlanQuota(user.team!);
|
||||
|
||||
// check limit
|
||||
const limit = await restrictByTimeRange({
|
||||
model: "userEmail",
|
||||
userId: user.id,
|
||||
limit: TeamPlanQuota[user.team!].EM_EmailAddresses,
|
||||
limit: plan.emEmailAddresses,
|
||||
rangeType: "month",
|
||||
});
|
||||
if (limit)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { checkApiKey } from "@/lib/dto/api-key";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { getPlanQuota } from "@/lib/dto/plan";
|
||||
import { createUserShortUrl } from "@/lib/dto/short-urls";
|
||||
import { restrictByTimeRange } from "@/lib/team";
|
||||
import { createUrlSchema } from "@/lib/validations/url";
|
||||
@@ -23,11 +23,13 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const plan = await getPlanQuota(user.team!);
|
||||
|
||||
// check limit
|
||||
const limit = await restrictByTimeRange({
|
||||
model: "userUrl",
|
||||
userId: user.id,
|
||||
limit: TeamPlanQuota[user.team!].SL_NewLinks,
|
||||
limit: plan.slNewLinks,
|
||||
rangeType: "month",
|
||||
});
|
||||
if (limit) return Response.json(limit.statusText, { status: limit.status });
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { constructMetadata } from "@/lib/utils";
|
||||
import { EmailDashboard } from "./email";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Emails - WR.DO",
|
||||
title: "Emails",
|
||||
description: "List and manage emails.",
|
||||
});
|
||||
|
||||
|
||||
+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.7.0",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
"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 { create } from "lodash";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { PlanQuotaFormData } from "@/lib/dto/plan";
|
||||
import { createPlanSchema } from "@/lib/validations/plan";
|
||||
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 { Switch } from "../ui/switch";
|
||||
|
||||
export type FormData = PlanQuotaFormData;
|
||||
|
||||
export type FormType = "add" | "edit";
|
||||
|
||||
export interface PlanFormProps {
|
||||
user: Pick<User, "id" | "name">;
|
||||
isShowForm: boolean;
|
||||
setShowForm: Dispatch<SetStateAction<boolean>>;
|
||||
type: FormType;
|
||||
initData?: FormData | null;
|
||||
action: string;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function PlanForm({
|
||||
setShowForm,
|
||||
type,
|
||||
initData,
|
||||
action,
|
||||
onRefresh,
|
||||
}: PlanFormProps) {
|
||||
const t = useTranslations("List");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isDeleting, startDeleteTransition] = useTransition();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(createPlanSchema),
|
||||
defaultValues: {
|
||||
id: initData?.id || "",
|
||||
name: initData?.name || "",
|
||||
slTrackedClicks: initData?.slTrackedClicks || 10000,
|
||||
slNewLinks: initData?.slNewLinks || 100,
|
||||
slAnalyticsRetention: initData?.slAnalyticsRetention || 180,
|
||||
slDomains: initData?.slDomains || 1,
|
||||
slAdvancedAnalytics: initData?.slAdvancedAnalytics || false,
|
||||
slCustomQrCodeLogo: initData?.slCustomQrCodeLogo || false,
|
||||
rcNewRecords: initData?.rcNewRecords || 1,
|
||||
emEmailAddresses: initData?.emEmailAddresses || 100,
|
||||
emDomains: initData?.emDomains || 1,
|
||||
emSendEmails: initData?.emSendEmails || 100,
|
||||
appSupport: initData?.appSupport || "BASIC",
|
||||
appApiAccess: initData?.appApiAccess || false,
|
||||
isActive: initData?.isActive || false,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (type === "add") {
|
||||
handleCreatePlan(data);
|
||||
} else if (type === "edit") {
|
||||
handleUpdatePlan(data);
|
||||
}
|
||||
});
|
||||
|
||||
const handleCreatePlan = async (data: FormData) => {
|
||||
startTransition(async () => {
|
||||
const response = await fetch(`${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
plan: 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 handleUpdatePlan = async (data: FormData) => {
|
||||
startTransition(async () => {
|
||||
if (type === "edit") {
|
||||
const response = await fetch(`${action}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ plan: 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 handleDeletePlan = async () => {
|
||||
if (type === "edit") {
|
||||
startDeleteTransition(async () => {
|
||||
const response = await fetch(`${action}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({
|
||||
id: initData?.id,
|
||||
}),
|
||||
});
|
||||
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" ? t("Create Plan") : t("Edit Plan")}
|
||||
</div>
|
||||
<form className="space-y-3 p-4" onSubmit={onSubmit}>
|
||||
<div className="relative grid-cols-1 gap-2 rounded-md border bg-neutral-50 px-3 pb-3 pt-8 dark:bg-neutral-900 md:grid md:grid-cols-2">
|
||||
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
|
||||
{t("Base")}
|
||||
</h2>
|
||||
<FormSectionColumns title={t("Plan Name")} required>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="Plan-Name">
|
||||
{t("Plan Name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
className="flex-1 shadow-inner"
|
||||
size={32}
|
||||
{...register("name")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.name ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
{t("Required")}. {t("Plan name must be unique")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
<FormSectionColumns title={t("Active")}>
|
||||
<Label className="sr-only" htmlFor="active">
|
||||
{t("Active")}:
|
||||
</Label>
|
||||
<Switch
|
||||
id="active"
|
||||
className="mb-3"
|
||||
{...register("isActive")}
|
||||
defaultChecked={initData?.isActive ?? true}
|
||||
onCheckedChange={(value) => setValue("isActive", value)}
|
||||
/>
|
||||
<p className="pb-1 text-[13px] text-muted-foreground">
|
||||
{t("Only active plans can be used")}
|
||||
</p>
|
||||
</FormSectionColumns>
|
||||
</div>
|
||||
|
||||
<div className="relative grid-cols-1 gap-2 rounded-md border bg-neutral-50 px-3 pb-3 pt-8 dark:bg-neutral-900 md:grid md:grid-cols-2">
|
||||
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
|
||||
{t("Shorten Service")}
|
||||
</h2>
|
||||
{/* Short Limit - slNewLinks */}
|
||||
<FormSectionColumns title={t("Short Limit")} required>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="Short-Limit">
|
||||
{t("Short Limit")}
|
||||
</Label>
|
||||
<Input
|
||||
id="short-limit"
|
||||
className="flex-1 shadow-inner"
|
||||
size={32}
|
||||
type="number"
|
||||
{...register("slNewLinks", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.slNewLinks ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.slNewLinks.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
{t("Monthly limit of short links created")}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
{/* Short Limit - slAnalyticsRetention*/}
|
||||
<FormSectionColumns title={t("View Period")}>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="View-Period">
|
||||
{t("View Period")}
|
||||
</Label>
|
||||
<Input
|
||||
id="short-limit"
|
||||
className="flex-1 shadow-inner"
|
||||
size={32}
|
||||
type="number"
|
||||
{...register("slAnalyticsRetention", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.slAnalyticsRetention ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.slAnalyticsRetention.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
{t(
|
||||
"Time range for viewing short link visitor statistics data (days)",
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
{/* Short Limit - slTrackedClicks*/}
|
||||
<FormSectionColumns title={t("Tracked Limit")}>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="Click-Limit">
|
||||
{t("Tracked Limit")}
|
||||
</Label>
|
||||
<Input
|
||||
id="short-limit"
|
||||
className="flex-1 shadow-inner"
|
||||
size={32}
|
||||
type="number"
|
||||
{...register("slTrackedClicks", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.slTrackedClicks ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.slTrackedClicks.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
{t("Monthly limit of tracked clicks (times)")}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
{/* Short Limit - slDomains */}
|
||||
<FormSectionColumns title={t("Domain Limit")}>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="Domain-Limit">
|
||||
{t("Domain Limit")}
|
||||
</Label>
|
||||
<Input
|
||||
id="short-limit"
|
||||
className="flex-1 shadow-inner"
|
||||
size={32}
|
||||
type="number"
|
||||
disabled
|
||||
{...register("slDomains", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.slDomains ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.slDomains.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
{t("Limit on the number of allowed domains")}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
</div>
|
||||
|
||||
<div className="relative grid-cols-1 gap-2 rounded-md border bg-neutral-50 px-3 pb-3 pt-8 dark:bg-neutral-900 md:grid md:grid-cols-2">
|
||||
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
|
||||
{t("Email Service")}
|
||||
</h2>
|
||||
{/* Email Limit - emEmailAddresses */}
|
||||
<FormSectionColumns title={t("Email Limit")}>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="Email-Limit">
|
||||
{t("Email Limit")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email-limit"
|
||||
className="flex-1 shadow-inner"
|
||||
size={32}
|
||||
type="number"
|
||||
{...register("emEmailAddresses", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.emEmailAddresses ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.emEmailAddresses.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
{t("Monthly limit of email addresses created")}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
{/* "Send Limit" - emSendEmails */}
|
||||
<FormSectionColumns title={t("Send Limit")}>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="Send-Limit">
|
||||
{t("Send Limit")}
|
||||
</Label>
|
||||
<Input
|
||||
id="send-limit"
|
||||
className="flex-1 shadow-inner"
|
||||
size={32}
|
||||
type="number"
|
||||
{...register("emSendEmails", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.emSendEmails ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.emSendEmails.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
{t("Monthly limit of emails sent")}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
</div>
|
||||
|
||||
<div className="relative grid-cols-1 gap-2 rounded-md border bg-neutral-50 px-3 pb-3 pt-8 dark:bg-neutral-900 md:grid md:grid-cols-2">
|
||||
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
|
||||
{t("Subdomain Service")}
|
||||
</h2>
|
||||
{/* Record Limit - rcNewRecords */}
|
||||
<FormSectionColumns title={t("Record Limit")} required>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="Record-Limit">
|
||||
{t("Record Limit")}
|
||||
</Label>
|
||||
<Input
|
||||
id="record-limit"
|
||||
className="flex-1 shadow-inner"
|
||||
size={32}
|
||||
type="number"
|
||||
{...register("rcNewRecords", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.rcNewRecords ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.rcNewRecords.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
{t("Monthly limit of subdomains created")}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-3 flex justify-end gap-3">
|
||||
{type === "edit" && initData?.name !== "free" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="mr-auto w-[80px] px-0"
|
||||
onClick={() => handleDeletePlan()}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Icons.spinner className="size-4 animate-spin" />
|
||||
) : (
|
||||
<p>{t("Delete")}</p>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="reset"
|
||||
variant="outline"
|
||||
className="w-[80px] px-0"
|
||||
onClick={() => setShowForm(false)}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</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" ? t("Update") : t("Save")}</p>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,6 @@ 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";
|
||||
@@ -77,6 +76,7 @@ export function RecordForm({
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
getValues,
|
||||
setValue,
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(createRecordSchema),
|
||||
@@ -101,6 +101,11 @@ export function RecordForm({
|
||||
},
|
||||
);
|
||||
|
||||
const { data: configs } = useSWR<Record<string, any>>(
|
||||
"/api/configs?key=enable_subdomain_apply",
|
||||
fetcher,
|
||||
);
|
||||
|
||||
const validDefaultDomain = useMemo(() => {
|
||||
if (!recordDomains?.length) return undefined;
|
||||
|
||||
@@ -125,7 +130,7 @@ export function RecordForm({
|
||||
});
|
||||
|
||||
const handleCreateRecord = async (data: CreateDNSRecord) => {
|
||||
if (siteConfig.enableSubdomainApply && data.comment!.length < 20) {
|
||||
if (configs?.enable_subdomain_apply && data.comment!.length < 20) {
|
||||
toast.warning("Apply reason must be at least 20 characters!");
|
||||
} else {
|
||||
startTransition(async () => {
|
||||
@@ -165,7 +170,30 @@ export function RecordForm({
|
||||
description: await response.text(),
|
||||
});
|
||||
} else {
|
||||
const res = await response.json();
|
||||
toast.success(`Update successfully!`);
|
||||
setShowForm(false);
|
||||
onRefresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRejectRecord = async (data: CreateDNSRecord) => {
|
||||
startTransition(async () => {
|
||||
if (type === "edit") {
|
||||
const response = await fetch(`${action}/reject`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
recordId: initData?.record_id,
|
||||
record: data,
|
||||
userId: initData?.userId,
|
||||
}),
|
||||
});
|
||||
if (!response.ok || response.status !== 200) {
|
||||
toast.error("Update Failed", {
|
||||
description: await response.text(),
|
||||
});
|
||||
} else {
|
||||
toast.success(`Update successfully!`);
|
||||
setShowForm(false);
|
||||
onRefresh();
|
||||
@@ -228,7 +256,7 @@ export function RecordForm({
|
||||
<div className="rounded-t-lg bg-muted px-4 py-2 text-lg font-semibold">
|
||||
{type === "add" ? t("Create record") : t("Edit record")}
|
||||
</div>
|
||||
{siteConfig.enableSubdomainApply && (
|
||||
{configs?.enable_subdomain_apply && (
|
||||
<ul className="m-2 list-disc gap-1 rounded-md bg-yellow-600/10 p-2 px-5 pr-2 text-xs font-medium text-yellow-600 dark:bg-yellow-500/10 dark:text-yellow-500">
|
||||
<li>{t("The administrator has enabled application mode")}.</li>
|
||||
<li>
|
||||
@@ -271,7 +299,7 @@ export function RecordForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{siteConfig.enableSubdomainApply && (
|
||||
{configs?.enable_subdomain_apply && (
|
||||
<FormSectionColumns
|
||||
title={t("What are you planning to use the subdomain for?")}
|
||||
required
|
||||
@@ -483,6 +511,7 @@ export function RecordForm({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="reset"
|
||||
variant="outline"
|
||||
@@ -491,6 +520,20 @@ export function RecordForm({
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
{type === "edit" && initData?.active === 2 && isAdmin && (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-[80px] px-0"
|
||||
onClick={() => handleRejectRecord(getValues())}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.spinner className="size-4 animate-spin" />
|
||||
) : (
|
||||
<p>{t("Reject")}</p>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="blue"
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { User } from "@prisma/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { userApiKeySchema } from "@/lib/validations/user";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SectionColumns } from "@/components/dashboard/section-columns";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
@@ -110,7 +110,7 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
|
||||
signIn("google");
|
||||
}}
|
||||
disabled={
|
||||
!siteConfig.openSignup ||
|
||||
!loginMethod.registration ||
|
||||
isLoading ||
|
||||
isGoogleLoading ||
|
||||
isGithubLoading ||
|
||||
@@ -134,7 +134,7 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
|
||||
signIn("github");
|
||||
}}
|
||||
disabled={
|
||||
!siteConfig.openSignup ||
|
||||
!loginMethod.registration ||
|
||||
isLoading ||
|
||||
isGithubLoading ||
|
||||
isGoogleLoading ||
|
||||
@@ -158,7 +158,7 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
|
||||
signIn("linuxdo");
|
||||
}}
|
||||
disabled={
|
||||
!siteConfig.openSignup ||
|
||||
!loginMethod.registration ||
|
||||
isLoading ||
|
||||
isGithubLoading ||
|
||||
isGoogleLoading ||
|
||||
@@ -210,7 +210,7 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
|
||||
<button
|
||||
className={cn(buttonVariants(), "mt-3")}
|
||||
disabled={
|
||||
!siteConfig.openSignup ||
|
||||
!loginMethod.registration ||
|
||||
isLoading ||
|
||||
isGoogleLoading ||
|
||||
isGithubLoading
|
||||
|
||||
@@ -5,8 +5,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { User, UserRole } from "@prisma/client";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { ROLE_ENUM } from "@/lib/enums";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { updateUserSchema } from "@/lib/validations/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { Switch } from "../ui/switch";
|
||||
|
||||
export type FormData = User;
|
||||
@@ -64,6 +67,15 @@ export function UserForm({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: plans, isLoading } = useSWR<string[]>(
|
||||
"/api/plan/names",
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (type === "edit") {
|
||||
handleUpdate(data);
|
||||
@@ -175,24 +187,31 @@ export function UserForm({
|
||||
</Select>
|
||||
</FormSectionColumns>
|
||||
<FormSectionColumns title="Plan">
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setValue("team", value);
|
||||
}}
|
||||
name="team"
|
||||
defaultValue={`${initData?.team}` || "free"}
|
||||
>
|
||||
<SelectTrigger className="w-full shadow-inner">
|
||||
<SelectValue placeholder="Select a plan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["free", "premium", "business"].map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-9 w-full rounded-r-none border-r-0 shadow-inner" />
|
||||
) : (
|
||||
plans &&
|
||||
plans.length > 0 && (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setValue("team", value);
|
||||
}}
|
||||
name="plan"
|
||||
defaultValue={`${initData?.team}` || "free"}
|
||||
>
|
||||
<SelectTrigger className="w-full shadow-inner">
|
||||
<SelectValue placeholder="Select a plan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{plans.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
)}
|
||||
</FormSectionColumns>
|
||||
</div>
|
||||
<div className="items-center justify-start gap-4 md:flex">
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { fetcher } from "@/lib/utils";
|
||||
|
||||
import { Icons } from "../shared/icons";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
export function Notification() {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
const { data, isLoading, error } = useSWR<Record<string, any>>(
|
||||
"/api/configs?key=system_notification",
|
||||
fetcher,
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
if (error || isLoading || !data || !data.system_notification) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="relative flex max-h-24 w-full items-center justify-center bg-muted text-sm text-primary"
|
||||
>
|
||||
<div
|
||||
className="max-w-3xl flex-1 px-8 py-2.5 text-center"
|
||||
dangerouslySetInnerHTML={{ __html: data.system_notification }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
className="absolute right-1.5 top-[18px] flex size-6 -translate-y-1/2 items-center justify-center"
|
||||
>
|
||||
<Icons.close className="size-4 text-primary" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { version } from "package.json";
|
||||
import pkg from "package.json";
|
||||
|
||||
import { footerLinks, siteConfig } from "@/config/site";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -76,7 +76,7 @@ export function SiteFooter({ className }: React.HTMLAttributes<HTMLElement>) {
|
||||
rel="noreferrer"
|
||||
className="font-thin underline-offset-2 hover:underline"
|
||||
>
|
||||
v{version}
|
||||
v{pkg.version}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function UserAccountNav() {
|
||||
<p className="font-medium">{user.name || "Anonymous"}</p>
|
||||
<Link href={"/pricing"} target="_blank">
|
||||
<Badge className="text-xs font-semibold" variant="default">
|
||||
{user.team.toUpperCase()}
|
||||
{user.team}
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -155,7 +155,7 @@ export function UserAccountNav() {
|
||||
<p className="font-medium">{user.name || "Anonymous"}</p>
|
||||
<Link href={"/pricing"} target="_blank">
|
||||
<Badge className="text-xs font-semibold" variant="default">
|
||||
{user.team.toUpperCase()}
|
||||
{user.team}
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -6,68 +6,74 @@ import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { cn, nFormatter } from "@/lib/utils";
|
||||
import { PlanQuotaFormData } from "@/lib/dto/plan";
|
||||
import { cn, fetcher, nFormatter } from "@/lib/utils";
|
||||
|
||||
import { Icons } from "../shared/icons";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const getBenefits = (plan) => [
|
||||
const getBenefits = (plan: PlanQuotaFormData) => [
|
||||
{
|
||||
text: `${nFormatter(plan.SL_TrackedClicks)} tracked clicks/mo`,
|
||||
text: `${nFormatter(plan.slTrackedClicks)} tracked clicks/mo`,
|
||||
checked: true,
|
||||
icon: <Icons.mousePointerClick className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: `${nFormatter(plan.SL_NewLinks)} new links/mo`,
|
||||
text: `${nFormatter(plan.slNewLinks)} new links/mo`,
|
||||
checked: true,
|
||||
icon: <Icons.link className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: `${plan.SL_AnalyticsRetention}-day analytics retention`,
|
||||
text: `${plan.slAnalyticsRetention}-day analytics retention`,
|
||||
checked: true,
|
||||
icon: <Icons.calendar className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: `Customize short link QR code`,
|
||||
checked: plan.SL_CustomQrCodeLogo,
|
||||
checked: plan.slCustomQrCodeLogo,
|
||||
icon: <Icons.qrcode className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: `${nFormatter(plan.EM_EmailAddresses)} email addresses/mo`,
|
||||
text: `${nFormatter(plan.emEmailAddresses)} email addresses/mo`,
|
||||
checked: true,
|
||||
icon: <Icons.mail className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: `${nFormatter(plan.EM_SendEmails)} send emails/mo`,
|
||||
text: `${nFormatter(plan.emSendEmails)} send emails/mo`,
|
||||
checked: true,
|
||||
icon: <Icons.send className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: `${plan.SL_Domains === 1 ? "One" : plan.SL_Domains} domain${plan.SL_Domains > 1 ? "s" : ""}`,
|
||||
text: `${plan.slDomains === 1 ? "One" : plan.slDomains} domain${plan.slDomains > 1 ? "s" : ""}`,
|
||||
checked: true,
|
||||
icon: <Icons.globe className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "Advanced analytics",
|
||||
checked: plan.SL_AdvancedAnalytics,
|
||||
checked: plan.slAdvancedAnalytics,
|
||||
icon: <Icons.lineChart className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: `${plan.APP_Support.charAt(0).toUpperCase() + plan.APP_Support.slice(1)} support`,
|
||||
text: `${plan.appSupport.charAt(0).toUpperCase() + plan.appSupport.slice(1)} support`,
|
||||
checked: true,
|
||||
icon: <Icons.help className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "Open API Access",
|
||||
checked: plan.APP_ApiAccess,
|
||||
checked: plan.appApiAccess,
|
||||
icon: <Icons.unplug className="size-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
export const PricingSection = () => {
|
||||
const t = useTranslations("Landing");
|
||||
const { data: plan } = useSWR<{
|
||||
total: number;
|
||||
list: PlanQuotaFormData[];
|
||||
}>(`/api/plan?all=1`, fetcher);
|
||||
|
||||
return (
|
||||
<section
|
||||
id="pricing"
|
||||
@@ -85,45 +91,36 @@ export const PricingSection = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<PriceCard
|
||||
tier={t("freeTier")}
|
||||
price={t("freePrice")}
|
||||
bestFor={t("freeBestFor")}
|
||||
CTA={
|
||||
<Link href={"/dashboard"}>
|
||||
<Button className="w-full" variant={"default"}>
|
||||
{t("getStartedFree")}
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
benefits={getBenefits(TeamPlanQuota.free)}
|
||||
/>
|
||||
{/* <PriceCard
|
||||
tier={t("premiumTier")}
|
||||
price={t("premiumPrice")}
|
||||
bestFor={t("premiumBestFor")}
|
||||
CTA={
|
||||
<Link href={"/pricing"}>
|
||||
<Button className="w-full bg-zinc-800 text-zinc-50 hover:bg-zinc-700 hover:text-zinc-50 dark:bg-zinc-50 dark:text-zinc-950 dark:hover:bg-zinc-200 dark:hover:text-zinc-900">
|
||||
{t("getFreeTrial")}
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
benefits={getBenefits(TeamPlanQuota.premium)}
|
||||
/> */}
|
||||
<PriceCard
|
||||
tier={t("enterpriseTier")}
|
||||
price={t("enterprisePrice")}
|
||||
bestFor={t("enterpriseBestFor")}
|
||||
CTA={
|
||||
<Link href={"mailto:support@wr.do"}>
|
||||
<Button className="w-full" variant="outline">
|
||||
{t("contactUs")}
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
benefits={getBenefits(TeamPlanQuota.business)}
|
||||
/>
|
||||
{plan && (
|
||||
<PriceCard
|
||||
tier={t("freeTier")}
|
||||
price={t("freePrice")}
|
||||
bestFor={t("freeBestFor")}
|
||||
CTA={
|
||||
<Link href={"/dashboard"}>
|
||||
<Button className="w-full" variant={"default"}>
|
||||
{t("getStartedFree")}
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
benefits={getBenefits(plan.list[0])}
|
||||
/>
|
||||
)}
|
||||
{plan && (
|
||||
<PriceCard
|
||||
tier={t("enterpriseTier")}
|
||||
price={t("enterprisePrice")}
|
||||
bestFor={t("enterpriseBestFor")}
|
||||
CTA={
|
||||
<Link href={"mailto:support@wr.do"}>
|
||||
<Button className="w-full" variant="outline">
|
||||
{t("contactUs")}
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
benefits={getBenefits(plan.list[plan.list.length - 1])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+16
-14
@@ -59,6 +59,7 @@ import {
|
||||
Trash2,
|
||||
Unplug,
|
||||
User,
|
||||
UserCog,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -267,6 +268,7 @@ export const Icons = {
|
||||
refreshCw: RefreshCw,
|
||||
search: Search,
|
||||
settings: Settings,
|
||||
userSettings: UserCog,
|
||||
spinner: Loader2,
|
||||
sun: SunMedium,
|
||||
trash: Trash2,
|
||||
@@ -490,26 +492,26 @@ export const Icons = {
|
||||
fill="url(#paint0_linear_1_13)"
|
||||
></path>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M49.4 6H28.6C24.0701 6 20.8531 6.00233 18.3355 6.20802C15.853 6.41085 14.316 6.79637 13.0981 7.41692C10.652 8.66327 8.66327 10.652 7.41692 13.0981C6.79637 14.316 6.41085 15.853 6.20802 18.3355C6.00233 20.8531 6 24.0701 6 28.6V49.4C6 53.9299 6.00233 57.1469 6.20802 59.6645C6.41085 62.147 6.79637 63.684 7.41692 64.9019C8.66327 67.348 10.652 69.3367 13.0981 70.5831C14.316 71.2036 15.853 71.5891 18.3355 71.792C20.8531 71.9977 24.0701 72 28.6 72H49.4C53.9299 72 57.1469 71.9977 59.6645 71.792C62.147 71.5891 63.684 71.2036 64.9019 70.5831C67.348 69.3367 69.3367 67.348 70.5831 64.9019C71.2036 63.684 71.5891 62.147 71.792 59.6645C71.9977 57.1469 72 53.9299 72 49.4V28.6C72 24.0701 71.9977 20.8531 71.792 18.3355C71.5891 15.853 71.2036 14.316 70.5831 13.0981C69.3367 10.652 67.348 8.66327 64.9019 7.41692C63.684 6.79637 62.147 6.41085 59.6645 6.20802C57.1469 6.00233 53.9299 6 49.4 6ZM4.7439 11.7362C3 15.1587 3 19.6392 3 28.6V49.4C3 58.3608 3 62.8413 4.7439 66.2638C6.27787 69.2744 8.72556 71.7221 11.7362 73.2561C15.1587 75 19.6392 75 28.6 75H49.4C58.3608 75 62.8413 75 66.2638 73.2561C69.2744 71.7221 71.7221 69.2744 73.2561 66.2638C75 62.8413 75 58.3608 75 49.4V28.6C75 19.6392 75 15.1587 73.2561 11.7362C71.7221 8.72556 69.2744 6.27787 66.2638 4.7439C62.8413 3 58.3608 3 49.4 3H28.6C19.6392 3 15.1587 3 11.7362 4.7439C8.72556 6.27787 6.27787 8.72556 4.7439 11.7362Z"
|
||||
fill="black"
|
||||
fill-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
></path>
|
||||
<path
|
||||
d="M3 28.6C3 19.6392 3 15.1587 4.7439 11.7362C6.27787 8.72556 8.72556 6.27787 11.7362 4.7439C15.1587 3 19.6392 3 28.6 3H49.4C58.3608 3 62.8413 3 66.2638 4.7439C69.2744 6.27787 71.7221 8.72556 73.2561 11.7362C75 15.1587 75 19.6392 75 28.6V49.4C75 58.3608 75 62.8413 73.2561 66.2638C71.7221 69.2744 69.2744 71.7221 66.2638 73.2561C62.8413 75 58.3608 75 49.4 75H28.6C19.6392 75 15.1587 75 11.7362 73.2561C8.72556 71.7221 6.27787 69.2744 4.7439 66.2638C3 62.8413 3 58.3608 3 49.4V28.6Z"
|
||||
fill="url(#paint1_linear_1_13)"
|
||||
></path>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M49.532 -4.96464e-07C53.9006 -2.33846e-05 57.3626 -4.14997e-05 60.1531 0.22795C63.0066 0.461095 65.4211 0.947527 67.6258 2.07088C71.2009 3.89247 74.1075 6.7991 75.9291 10.3742C77.0525 12.5789 77.5389 14.9934 77.772 17.8469C78 20.6374 78 24.0994 78 28.4679V49.5321C78 53.9006 78 57.3626 77.772 60.1531C77.5389 63.0066 77.0525 65.4211 75.9291 67.6258C74.1075 71.2009 71.2009 74.1075 67.6258 75.9291C65.4211 77.0525 63.0066 77.5389 60.1531 77.772C57.3626 78 53.9006 78 49.5321 78H28.4679C24.0994 78 20.6374 78 17.8469 77.772C14.9934 77.5389 12.5789 77.0525 10.3742 75.9291C6.7991 74.1075 3.89247 71.2009 2.07088 67.6258C0.947527 65.4211 0.461096 63.0066 0.227951 60.1531C-4.05461e-05 57.3626 -2.2431e-05 53.9006 4.5721e-07 49.532V28.468C-2.2431e-05 24.0994 -4.05461e-05 20.6374 0.227951 17.8469C0.461096 14.9934 0.947528 12.5789 2.07088 10.3742C3.89247 6.7991 6.7991 3.89247 10.3742 2.07088C12.5789 0.947526 14.9934 0.461095 17.8469 0.22795C20.6374 -4.14997e-05 24.0994 -2.33846e-05 28.468 -4.96464e-07H49.532ZM4.7439 11.7362C3 15.1587 3 19.6392 3 28.6V49.4C3 58.3608 3 62.8413 4.7439 66.2638C6.27787 69.2744 8.72556 71.7221 11.7362 73.2561C15.1587 75 19.6392 75 28.6 75H49.4C58.3608 75 62.8413 75 66.2638 73.2561C69.2744 71.7221 71.7221 69.2744 73.2561 66.2638C75 62.8413 75 58.3608 75 49.4V28.6C75 19.6392 75 15.1587 73.2561 11.7362C71.7221 8.72556 69.2744 6.27787 66.2638 4.7439C62.8413 3 58.3608 3 49.4 3H28.6C19.6392 3 15.1587 3 11.7362 4.7439C8.72556 6.27787 6.27787 8.72556 4.7439 11.7362Z"
|
||||
fill="black"
|
||||
fill-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
></path>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M49.532 -4.96464e-07C53.9006 -2.33846e-05 57.3626 -4.14997e-05 60.1531 0.22795C63.0066 0.461095 65.4211 0.947527 67.6258 2.07088C71.2009 3.89247 74.1075 6.7991 75.9291 10.3742C77.0525 12.5789 77.5389 14.9934 77.772 17.8469C78 20.6374 78 24.0994 78 28.4679V49.5321C78 53.9006 78 57.3626 77.772 60.1531C77.5389 63.0066 77.0525 65.4211 75.9291 67.6258C74.1075 71.2009 71.2009 74.1075 67.6258 75.9291C65.4211 77.0525 63.0066 77.5389 60.1531 77.772C57.3626 78 53.9006 78 49.5321 78H28.4679C24.0994 78 20.6374 78 17.8469 77.772C14.9934 77.5389 12.5789 77.0525 10.3742 75.9291C6.7991 74.1075 3.89247 71.2009 2.07088 67.6258C0.947527 65.4211 0.461096 63.0066 0.227951 60.1531C-4.05461e-05 57.3626 -2.2431e-05 53.9006 4.5721e-07 49.532V28.468C-2.2431e-05 24.0994 -4.05461e-05 20.6374 0.227951 17.8469C0.461096 14.9934 0.947528 12.5789 2.07088 10.3742C3.89247 6.7991 6.7991 3.89247 10.3742 2.07088C12.5789 0.947526 14.9934 0.461095 17.8469 0.22795C20.6374 -4.14997e-05 24.0994 -2.33846e-05 28.468 -4.96464e-07H49.532ZM4.7439 11.7362C3 15.1587 3 19.6392 3 28.6V49.4C3 58.3608 3 62.8413 4.7439 66.2638C6.27787 69.2744 8.72556 71.7221 11.7362 73.2561C15.1587 75 19.6392 75 28.6 75H49.4C58.3608 75 62.8413 75 66.2638 73.2561C69.2744 71.7221 71.7221 69.2744 73.2561 66.2638C75 62.8413 75 58.3608 75 49.4V28.6C75 19.6392 75 15.1587 73.2561 11.7362C71.7221 8.72556 69.2744 6.27787 66.2638 4.7439C62.8413 3 58.3608 3 49.4 3H28.6C19.6392 3 15.1587 3 11.7362 4.7439C8.72556 6.27787 6.27787 8.72556 4.7439 11.7362Z"
|
||||
fill="url(#paint2_linear_1_13)"
|
||||
fill-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
></path>
|
||||
<path
|
||||
d="M28.3999 54.1V23.1H40.1775C42.5903 23.1 44.6146 23.5137 46.2504 24.3412C47.8964 25.1687 49.1386 26.3292 49.9769 27.8227C50.8255 29.3061 51.2497 31.0367 51.2497 33.0146C51.2497 35.0025 50.8203 36.7281 49.9616 38.1913C49.113 39.6444 47.8606 40.7696 46.2044 41.5668C44.5481 42.3539 42.5136 42.7475 40.1009 42.7475H31.7124V38.0854H39.3341C40.7449 38.0854 41.9002 37.8936 42.7999 37.5102C43.6996 37.1166 44.3641 36.5465 44.7935 35.7997C45.2331 35.0429 45.4529 34.1145 45.4529 33.0146C45.4529 31.9146 45.2331 30.9761 44.7935 30.1991C44.3539 29.412 43.6842 28.8166 42.7846 28.413C41.8849 27.9993 40.7245 27.7924 39.3034 27.7924H34.0894V54.1H28.3999ZM44.6248 40.0531L52.3999 54.1H46.051L38.414 40.0531H44.6248Z"
|
||||
@@ -524,8 +526,8 @@ export const Icons = {
|
||||
y1="-20"
|
||||
y2="75"
|
||||
>
|
||||
<stop stop-color="#4C4C57"></stop>
|
||||
<stop offset="0.444444" stop-color="#05050A"></stop>
|
||||
<stop stopColor="#4C4C57"></stop>
|
||||
<stop offset="0.444444" stopColor="#05050A"></stop>
|
||||
<stop offset="1"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
@@ -536,8 +538,8 @@ export const Icons = {
|
||||
y1="-20"
|
||||
y2="75"
|
||||
>
|
||||
<stop stop-color="#4C4C57"></stop>
|
||||
<stop offset="0.444444" stop-color="#05050A"></stop>
|
||||
<stop stopColor="#4C4C57"></stop>
|
||||
<stop offset="0.444444" stopColor="#05050A"></stop>
|
||||
<stop offset="1"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
@@ -548,8 +550,8 @@ export const Icons = {
|
||||
y1="3"
|
||||
y2="75"
|
||||
>
|
||||
<stop stop-color="white" stop-opacity="0.6"></stop>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.2"></stop>
|
||||
<stop stopColor="white" stopOpacity="0.6"></stop>
|
||||
<stop offset="1" stopColor="white" stopOpacity="0.2"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
@@ -559,8 +561,8 @@ export const Icons = {
|
||||
y1="25.6833"
|
||||
y2="72.588"
|
||||
>
|
||||
<stop stop-color="white"></stop>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0"></stop>
|
||||
<stop stopColor="white"></stop>
|
||||
<stop offset="1" stopColor="white" stopOpacity="0"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
@@ -5,6 +5,10 @@ export function TimeAgoIntl({ date }: { date: Date }) {
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<TimeAgo datetime={date} locale={locale === "zh" ? "zh_CN" : locale} />
|
||||
<TimeAgo
|
||||
className="text-nowrap"
|
||||
datetime={date}
|
||||
locale={locale === "zh" ? "zh_CN" : locale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { User } from "@prisma/client";
|
||||
import { AvatarProps } from "@radix-ui/react-avatar";
|
||||
|
||||
import { generateGradientClasses } from "@/lib/enums";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar, AvatarImage } from "@/components/ui/avatar";
|
||||
|
||||
interface UserAvatarProps extends AvatarProps {
|
||||
|
||||
+7
-7
@@ -54,12 +54,6 @@ 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",
|
||||
@@ -78,12 +72,18 @@ export const sidebarLinks: SidebarNavItem[] = [
|
||||
title: "Records",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/system",
|
||||
icon: "settings",
|
||||
title: "System Settings",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "OPTIONS",
|
||||
items: [
|
||||
{ href: "/dashboard/settings", icon: "settings", title: "Settings" },
|
||||
{ href: "/dashboard/settings", icon: "userSettings", title: "Settings" },
|
||||
{ href: "/docs", icon: "bookOpen", title: "Documentation" },
|
||||
{
|
||||
href: siteConfig.links.feedback,
|
||||
|
||||
@@ -2,9 +2,7 @@ import { SidebarNavItem, SiteConfig } from "types";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
const site_url = env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
||||
const open_signup = env.NEXT_PUBLIC_OPEN_SIGNUP;
|
||||
const email_r2_domain = env.NEXT_PUBLIC_EMAIL_R2_DOMAIN || "";
|
||||
const enable_subdomain_apply = env.NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY || "0";
|
||||
|
||||
export const siteConfig: SiteConfig = {
|
||||
name: "WR.DO",
|
||||
@@ -20,9 +18,7 @@ export const siteConfig: SiteConfig = {
|
||||
oichat: "https://oi.wr.do",
|
||||
},
|
||||
mailSupport: "support@wr.do",
|
||||
openSignup: open_signup === "1" ? true : false,
|
||||
emailR2Domain: email_r2_domain,
|
||||
enableSubdomainApply: enable_subdomain_apply === "1" ? true : false,
|
||||
};
|
||||
|
||||
export const footerLinks: SidebarNavItem[] = [
|
||||
|
||||
@@ -11,7 +11,7 @@ description: 选择你的部署方式
|
||||
|
||||
## 使用 Vercel 部署(推荐)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=GITHUB_TOKEN)
|
||||
|
||||
## 使用 Docker Compose 部署
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ description: Choose your deployment method
|
||||
|
||||
## Deploy with Vercel (Recommended)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=GITHUB_TOKEN)
|
||||
|
||||
Remember to fill in the necessary environment variables.
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ pnpm install
|
||||
| RESEND\_API\_KEY | `123465` | Resend 的 API 密钥。 |
|
||||
| RESEND\_FROM\_EMAIL | `"you <support@your-domain.com>"` | 用于发送邮件的邮箱地址。 |
|
||||
| NEXT\_PUBLIC\_OPEN\_SIGNUP | `1` | 开放注册。 |
|
||||
| NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY | `0` | 启用子域名申请模式 |
|
||||
| SCREENSHOTONE\_BASE\_URL | `https://api.example.com` | 待补充 |
|
||||
| GITHUB\_TOKEN | `ghp_sscsfarwetqet` | [https://github.com/settings/tokens](https://github.com/settings/tokens) |
|
||||
| SKIP_DB_CHECK | `false` | 跳过数据库连接检测 |
|
||||
|
||||
@@ -59,9 +59,7 @@ Copy/paste the `.env.example` in the `.env` file:
|
||||
| GITHUB_SECRET | `123465` | The secret of the GitHub OAuth client. |
|
||||
| RESEND_API_KEY | `123465` | The API key for Resend. |
|
||||
| RESEND_FROM_EMAIL | `"you <support@your-domain.com>"` | The email address to send emails from. |
|
||||
| NEXT_PUBLIC_OPEN_SIGNUP | `1` | Open signup. |
|
||||
| NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY | `0` | Enable subdomain apply. |
|
||||
| SCREENSHOTONE_BASE_URL | `https://api.example.com` | pending |
|
||||
| SCREENSHOTONE_BASE_URL | `https://api.example.com` | The base URL of the screenshotone API. |
|
||||
| GITHUB_TOKEN | `ghp_sscsfarwetqet` | https://github.com/settings/tokens |
|
||||
| SKIP_DB_CHECK | `false` | Skip database check. |
|
||||
| SKIP_DB_MIGRATION | `false` | Skip database migration. |
|
||||
|
||||
@@ -166,16 +166,7 @@ https://dash.cloudflare.com/[account_id]/r2/default/buckets/[bucket]/settings
|
||||
NEXT_PUBLIC_EMAIL_R2_DOMAIN=https://email-attachment.wr.do
|
||||
```
|
||||
|
||||
## 4. 添加业务配置
|
||||
|
||||
```js title=".env"
|
||||
# 允许任何人注册
|
||||
NEXT_PUBLIC_OPEN_SIGNUP=1
|
||||
# 子域名申请模式,默认关闭(0 关闭, 1 开启),若设置为1,用户创建子域名后需要等待管理员审核,才能解析到 cloudflare
|
||||
NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY=0
|
||||
```
|
||||
|
||||
## 5. 添加 SCREENSHOTONE\_BASE\_URL 环境变量
|
||||
## 4. 添加 SCREENSHOTONE\_BASE\_URL 环境变量
|
||||
|
||||
这是 screenshotone API 的基础地址。
|
||||
|
||||
@@ -186,7 +177,7 @@ NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY=0
|
||||
SCREENSHOTONE_BASE_URL=https://api.screenshotone.com
|
||||
```
|
||||
|
||||
## 6. 添加 GITHUB\_TOKEN 环境变量
|
||||
## 5. 添加 GITHUB\_TOKEN 环境变量
|
||||
|
||||
通过 [https://github.com/settings/tokens](https://github.com/settings/tokens) 获取你的 token:
|
||||
|
||||
@@ -194,7 +185,7 @@ SCREENSHOTONE_BASE_URL=https://api.screenshotone.com
|
||||
GITHUB_TOKEN=
|
||||
```
|
||||
|
||||
## 7. 启动开发服务器
|
||||
## 6. 启动开发服务器
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
@@ -202,7 +193,7 @@ pnpm dev
|
||||
|
||||
通过浏览器访问:[http://localhost:3000](http://localhost:3000)
|
||||
|
||||
## 8. 设置系统
|
||||
## 7. 设置系统
|
||||
|
||||
#### 创建第一个账号并将账号权限更改为 ADMIN
|
||||
|
||||
@@ -222,7 +213,7 @@ pnpm dev
|
||||
<strong>你必须至少添加一个域名,才能使用短链接、邮件或子域名管理等功能。</strong>
|
||||
</Callout>
|
||||
|
||||
## 9. 部署
|
||||
## 8. 部署
|
||||
|
||||
详见:[部署指南](/docs/developer/deploy)
|
||||
|
||||
|
||||
@@ -156,16 +156,7 @@ Via:
|
||||
NEXT_PUBLIC_EMAIL_R2_DOMAIN=https://email-attachment.wr.do
|
||||
```
|
||||
|
||||
## 4. Add the Bussiness Configs
|
||||
|
||||
```js title=".env"
|
||||
# Allow anyone to sign up
|
||||
NEXT_PUBLIC_OPEN_SIGNUP=1
|
||||
# The default is closed (0 closed, 1 open), if set to 1, the user needs to wait for the administrator to approve the subdomain after creating a subdomain
|
||||
NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY=0
|
||||
```
|
||||
|
||||
## 5. Add the SCREENSHOTONE_BASE_URL Environment Variable
|
||||
## 4. Add the SCREENSHOTONE_BASE_URL Environment Variable
|
||||
|
||||
It's the base URL for the screenshotone API.
|
||||
|
||||
@@ -176,7 +167,7 @@ Deploy docs via [here](https://jasonraimondi.github.io/url-to-png/)
|
||||
SCREENSHOTONE_BASE_URL=https://api.screenshotone.com
|
||||
```
|
||||
|
||||
## 6. Add the GITHUB_TOKEN Environment Variable
|
||||
## 5. Add the GITHUB_TOKEN Environment Variable
|
||||
|
||||
Via https://github.com/settings/tokens to get your token.
|
||||
|
||||
@@ -184,14 +175,14 @@ Via https://github.com/settings/tokens to get your token.
|
||||
GITHUB_TOKEN=
|
||||
```
|
||||
|
||||
## 7. Start the Dev Server
|
||||
## 6. Start the Dev Server
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
Via [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
## 8. Setup System
|
||||
## 7. Setup System
|
||||
|
||||
#### Create the first account and Change the account's role to ADMIN
|
||||
|
||||
@@ -211,7 +202,7 @@ Follow the steps below:
|
||||
<strong>You must add at least one domain to start using short links, email or subdomain management features.</strong>
|
||||
</Callout>
|
||||
|
||||
## 9. Deploy
|
||||
## 8. Deploy
|
||||
|
||||
See [Deploy Guide](/docs/developer/deploy).
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ services:
|
||||
DATABASE_URL: postgres://postgres:postgres@postgres:5432/wrdo
|
||||
AUTH_SECRET: ${AUTH_SECRET:-your-auth-secret}
|
||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
|
||||
NEXT_PUBLIC_OPEN_SIGNUP: ${NEXT_PUBLIC_OPEN_SIGNUP}
|
||||
NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY: ${NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
|
||||
GITHUB_ID: ${GITHUB_ID}
|
||||
|
||||
@@ -10,8 +10,6 @@ services:
|
||||
AUTH_SECRET: ${AUTH_SECRET:-your-auth-secret}
|
||||
AUTH_URL: ${AUTH_URL}
|
||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
|
||||
NEXT_PUBLIC_OPEN_SIGNUP: ${NEXT_PUBLIC_OPEN_SIGNUP}
|
||||
NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY: ${NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
|
||||
GITHUB_ID: ${GITHUB_ID}
|
||||
|
||||
@@ -19,9 +19,7 @@ export const env = createEnv({
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_APP_URL: z.string().optional(),
|
||||
NEXT_PUBLIC_OPEN_SIGNUP: z.string().default("1"),
|
||||
NEXT_PUBLIC_EMAIL_R2_DOMAIN: z.string().optional(),
|
||||
NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY: z.string().default("0"),
|
||||
},
|
||||
runtimeEnv: {
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
@@ -34,10 +32,7 @@ export const env = createEnv({
|
||||
RESEND_API_KEY: process.env.RESEND_API_KEY,
|
||||
RESEND_FROM_EMAIL: process.env.RESEND_FROM_EMAIL,
|
||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||
NEXT_PUBLIC_OPEN_SIGNUP: process.env.NEXT_PUBLIC_OPEN_SIGNUP,
|
||||
NEXT_PUBLIC_EMAIL_R2_DOMAIN: process.env.NEXT_PUBLIC_EMAIL_R2_DOMAIN,
|
||||
NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY:
|
||||
process.env.NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY,
|
||||
SCREENSHOTONE_BASE_URL: process.env.SCREENSHOTONE_BASE_URL,
|
||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
||||
LinuxDo_CLIENT_ID: process.env.LinuxDo_CLIENT_ID,
|
||||
|
||||
@@ -24,7 +24,7 @@ export type UserRecordFormData = {
|
||||
tags: string;
|
||||
created_on?: string;
|
||||
modified_on?: string;
|
||||
active: number; // 0: inactive, 1: active, 2: pending
|
||||
active: number; // 0: inactive, 1: active, 2: pending, 3: rejected
|
||||
user: Pick<User, "name" | "email">;
|
||||
};
|
||||
|
||||
@@ -103,7 +103,7 @@ export async function updateUserRecordReview(
|
||||
|
||||
const res = await prisma.userRecord.update({
|
||||
where: {
|
||||
id,
|
||||
record_id,
|
||||
},
|
||||
data: {
|
||||
userId,
|
||||
@@ -125,7 +125,7 @@ export async function updateUserRecordReview(
|
||||
});
|
||||
return { status: "success", data: res };
|
||||
} catch (error) {
|
||||
// console.log(error);
|
||||
console.log(error);
|
||||
return { status: error };
|
||||
}
|
||||
}
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import { prisma } from "../db";
|
||||
|
||||
export interface PlanQuota {
|
||||
name: string;
|
||||
slTrackedClicks: number;
|
||||
slNewLinks: number;
|
||||
slAnalyticsRetention: number;
|
||||
slDomains: number;
|
||||
slAdvancedAnalytics: boolean;
|
||||
slCustomQrCodeLogo: boolean;
|
||||
rcNewRecords: number;
|
||||
emEmailAddresses: number;
|
||||
emDomains: number;
|
||||
emSendEmails: number;
|
||||
appSupport: string;
|
||||
appApiAccess: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface PlanQuotaFormData extends PlanQuota {
|
||||
id?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
// 获取计划配额
|
||||
export async function getPlanQuota(planName: string) {
|
||||
const plan = await prisma.plan.findUnique({
|
||||
where: { name: planName },
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
return {
|
||||
name: planName,
|
||||
slTrackedClicks: 0,
|
||||
slNewLinks: 0,
|
||||
slAnalyticsRetention: 0,
|
||||
slDomains: 0,
|
||||
slAdvancedAnalytics: false,
|
||||
slCustomQrCodeLogo: false,
|
||||
rcNewRecords: 0,
|
||||
emEmailAddresses: 0,
|
||||
emDomains: 0,
|
||||
emSendEmails: 0,
|
||||
appSupport: "BASIC",
|
||||
appApiAccess: true,
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: planName,
|
||||
slTrackedClicks: plan.slTrackedClicks,
|
||||
slNewLinks: plan.slNewLinks,
|
||||
slAnalyticsRetention: plan.slAnalyticsRetention,
|
||||
slDomains: plan.slDomains,
|
||||
slAdvancedAnalytics: plan.slAdvancedAnalytics,
|
||||
slCustomQrCodeLogo: plan.slCustomQrCodeLogo,
|
||||
rcNewRecords: plan.rcNewRecords,
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
appSupport: plan.appSupport.toLowerCase(),
|
||||
appApiAccess: plan.appApiAccess,
|
||||
isActive: plan.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
// 获取所有计划
|
||||
export async function getAllPlans(page = 1, size = 10, target: string = "") {
|
||||
let option: any;
|
||||
|
||||
if (target) {
|
||||
option = {
|
||||
name: {
|
||||
contains: target,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const [total, list] = await prisma.$transaction([
|
||||
prisma.plan.count({
|
||||
where: option,
|
||||
}),
|
||||
prisma.plan.findMany({
|
||||
where: option,
|
||||
skip: (page - 1) * size,
|
||||
take: size,
|
||||
orderBy: {
|
||||
slTrackedClicks: "asc",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { list, total };
|
||||
}
|
||||
|
||||
// 获取计划所有名称
|
||||
export async function getPlanNames() {
|
||||
const data = await prisma.plan.findMany({
|
||||
where: { isActive: true },
|
||||
select: { name: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return data.map((item) => item.name);
|
||||
}
|
||||
|
||||
// 更新计划配额
|
||||
export async function updatePlanQuota(plan: PlanQuotaFormData) {
|
||||
return await prisma.plan.update({
|
||||
where: { id: plan.id },
|
||||
data: {
|
||||
// name: plan.name,
|
||||
slTrackedClicks: plan.slTrackedClicks,
|
||||
slNewLinks: plan.slNewLinks,
|
||||
slAnalyticsRetention: plan.slAnalyticsRetention,
|
||||
slDomains: plan.slDomains,
|
||||
slAdvancedAnalytics: plan.slAdvancedAnalytics,
|
||||
slCustomQrCodeLogo: plan.slCustomQrCodeLogo,
|
||||
rcNewRecords: plan.rcNewRecords,
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
appSupport: plan.appSupport.toUpperCase() as any,
|
||||
appApiAccess: plan.appApiAccess,
|
||||
isActive: plan.isActive,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 创建新计划
|
||||
export async function createPlan(plan: PlanQuota) {
|
||||
return await prisma.plan.create({
|
||||
data: {
|
||||
name: plan.name,
|
||||
slTrackedClicks: plan.slTrackedClicks,
|
||||
slNewLinks: plan.slNewLinks,
|
||||
slAnalyticsRetention: plan.slAnalyticsRetention,
|
||||
slDomains: plan.slDomains,
|
||||
slAdvancedAnalytics: plan.slAdvancedAnalytics,
|
||||
slCustomQrCodeLogo: plan.slCustomQrCodeLogo,
|
||||
rcNewRecords: plan.rcNewRecords,
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
appSupport: plan.appSupport.toUpperCase() as any,
|
||||
appApiAccess: plan.appApiAccess,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 删除计划(软删除)
|
||||
export async function deletePlan(id: string) {
|
||||
return await prisma.plan.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { prisma } from "../db";
|
||||
|
||||
export type ConfigType = "BOOLEAN" | "STRING" | "NUMBER" | "OBJECT";
|
||||
|
||||
export interface SystemConfigData {
|
||||
key: string;
|
||||
value: any; // 解析后的实际值
|
||||
type: ConfigType;
|
||||
description?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface CreateSystemConfigData {
|
||||
key: string;
|
||||
value: any;
|
||||
type: ConfigType;
|
||||
description?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSystemConfigData {
|
||||
value?: any;
|
||||
type?: ConfigType;
|
||||
description?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
// 解析配置值
|
||||
function parseConfigValue(value: string, type: ConfigType): any {
|
||||
switch (type) {
|
||||
case "BOOLEAN":
|
||||
return value === "true";
|
||||
case "NUMBER":
|
||||
return Number(value);
|
||||
case "OBJECT":
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
case "STRING":
|
||||
default:
|
||||
// 如果是 JSON 字符串格式的字符串,需要解析
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// 序列化配置值
|
||||
function serializeConfigValue(value: any, type: ConfigType): string {
|
||||
switch (type) {
|
||||
case "BOOLEAN":
|
||||
return String(Boolean(value));
|
||||
case "NUMBER":
|
||||
return String(Number(value));
|
||||
case "OBJECT":
|
||||
return JSON.stringify(value);
|
||||
case "STRING":
|
||||
default:
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单个配置
|
||||
export async function getSystemConfig(
|
||||
key: string,
|
||||
): Promise<SystemConfigData | null> {
|
||||
const config = await prisma.systemConfig.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: config.key,
|
||||
value: parseConfigValue(config.value, config.type as ConfigType),
|
||||
type: config.type as ConfigType,
|
||||
description: config.description || undefined,
|
||||
version: config.version,
|
||||
};
|
||||
}
|
||||
|
||||
// 获取配置值(简化版本,直接返回解析后的值)
|
||||
export async function getConfigValue<T = any>(key: string): Promise<T | null> {
|
||||
const config = await getSystemConfig(key);
|
||||
return config ? config.value : null;
|
||||
}
|
||||
|
||||
// 获取所有配置
|
||||
export async function getAllSystemConfigs(): Promise<SystemConfigData[]> {
|
||||
const configs = await prisma.systemConfig.findMany({
|
||||
orderBy: { key: "asc" },
|
||||
});
|
||||
|
||||
return configs.map((config) => ({
|
||||
key: config.key,
|
||||
value: parseConfigValue(config.value, config.type as ConfigType),
|
||||
type: config.type as ConfigType,
|
||||
description: config.description || undefined,
|
||||
version: config.version,
|
||||
}));
|
||||
}
|
||||
|
||||
// 获取配置的原始数据(包含元数据)
|
||||
export async function getSystemConfigRaw(key: string) {
|
||||
return await prisma.systemConfig.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
}
|
||||
|
||||
// 创建配置
|
||||
export async function createSystemConfig(data: CreateSystemConfigData) {
|
||||
const serializedValue = serializeConfigValue(data.value, data.type);
|
||||
|
||||
return await prisma.systemConfig.create({
|
||||
data: {
|
||||
key: data.key,
|
||||
value: serializedValue,
|
||||
type: data.type,
|
||||
description: data.description,
|
||||
version: data.version || "0.5.0",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
export async function updateSystemConfig(
|
||||
key: string,
|
||||
data: UpdateSystemConfigData,
|
||||
) {
|
||||
const updateData: any = {};
|
||||
|
||||
if (data.value !== undefined && data.type) {
|
||||
updateData.value = serializeConfigValue(data.value, data.type);
|
||||
}
|
||||
if (data.type !== undefined) {
|
||||
updateData.type = data.type;
|
||||
}
|
||||
if (data.description !== undefined) {
|
||||
updateData.description = data.description;
|
||||
}
|
||||
if (data.version !== undefined) {
|
||||
updateData.version = data.version;
|
||||
}
|
||||
|
||||
return await prisma.systemConfig.update({
|
||||
where: { key },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
// 设置配置值(upsert操作)
|
||||
export async function setSystemConfig(
|
||||
key: string,
|
||||
value: any,
|
||||
type: ConfigType,
|
||||
description?: string,
|
||||
) {
|
||||
const serializedValue = serializeConfigValue(value, type);
|
||||
|
||||
return await prisma.systemConfig.upsert({
|
||||
where: { key },
|
||||
update: {
|
||||
value: serializedValue,
|
||||
type,
|
||||
description,
|
||||
},
|
||||
create: {
|
||||
key,
|
||||
value: serializedValue,
|
||||
type,
|
||||
description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 删除配置
|
||||
export async function deleteSystemConfig(key: string) {
|
||||
return await prisma.systemConfig.delete({
|
||||
where: { key },
|
||||
});
|
||||
}
|
||||
|
||||
// 批量获取配置
|
||||
export async function getMultipleConfigs(
|
||||
keys: string[],
|
||||
): Promise<Record<string, any>> {
|
||||
const configs = await prisma.systemConfig.findMany({
|
||||
where: {
|
||||
key: {
|
||||
in: keys,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
configs.forEach((config) => {
|
||||
result[config.key] = parseConfigValue(
|
||||
config.value,
|
||||
config.type as ConfigType,
|
||||
);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 按类型获取配置
|
||||
export async function getConfigsByType(
|
||||
type: ConfigType,
|
||||
): Promise<SystemConfigData[]> {
|
||||
const configs = await prisma.systemConfig.findMany({
|
||||
where: { type },
|
||||
orderBy: { key: "asc" },
|
||||
});
|
||||
|
||||
return configs.map((config) => ({
|
||||
key: config.key,
|
||||
value: parseConfigValue(config.value, config.type as ConfigType),
|
||||
type: config.type as ConfigType,
|
||||
description: config.description || undefined,
|
||||
version: config.version,
|
||||
}));
|
||||
}
|
||||
|
||||
// 搜索配置
|
||||
export async function searchConfigs(
|
||||
searchTerm: string,
|
||||
): Promise<SystemConfigData[]> {
|
||||
const configs = await prisma.systemConfig.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ key: { contains: searchTerm, mode: "insensitive" } },
|
||||
{ description: { contains: searchTerm, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
orderBy: { key: "asc" },
|
||||
});
|
||||
|
||||
return configs.map((config) => ({
|
||||
key: config.key,
|
||||
value: parseConfigValue(config.value, config.type as ConfigType),
|
||||
type: config.type as ConfigType,
|
||||
description: config.description || undefined,
|
||||
version: config.version,
|
||||
}));
|
||||
}
|
||||
|
||||
// 配置是否存在
|
||||
export async function configExists(key: string): Promise<boolean> {
|
||||
const count = await prisma.systemConfig.count({
|
||||
where: { key },
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
// 获取配置统计
|
||||
export async function getConfigStats() {
|
||||
const total = await prisma.systemConfig.count();
|
||||
const byType = await prisma.systemConfig.groupBy({
|
||||
by: ["type"],
|
||||
_count: {
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
total,
|
||||
byType: byType.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.type] = item._count.type;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Usage Example */
|
||||
|
||||
/**
|
||||
|
||||
// 取单个配置值
|
||||
const appName = await getConfigValue<string>('app_name');
|
||||
|
||||
// 设置配置
|
||||
await setSystemConfig('maintenance_mode', true, 'BOOLEAN', 'Enable maintenance mode');
|
||||
|
||||
// 批量获取配置
|
||||
const configs = await getMultipleConfigs(['app_name', 'maintenance_mode', 'api_rate_limit']);
|
||||
|
||||
// 搜索配置
|
||||
const emailConfigs = await searchConfigs('email');
|
||||
|
||||
// 获取统计信息
|
||||
const stats = await getConfigStats();
|
||||
|
||||
*/
|
||||
@@ -32,7 +32,9 @@ export function constructMetadata({
|
||||
"Cloudflare",
|
||||
"DNS",
|
||||
"DNS Records",
|
||||
"Subdomains",
|
||||
"Short Link",
|
||||
"Email",
|
||||
"Open API",
|
||||
"Screenshot API",
|
||||
],
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export const createPlanSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().min(3).max(32),
|
||||
slTrackedClicks: z.number().optional().default(0),
|
||||
slNewLinks: z.number().optional().default(0),
|
||||
slAnalyticsRetention: z.number().optional().default(0),
|
||||
slDomains: z.number().optional().default(0),
|
||||
slAdvancedAnalytics: z.boolean().optional().default(true),
|
||||
slCustomQrCodeLogo: z.boolean().optional().default(false),
|
||||
rcNewRecords: z.number().optional().default(0),
|
||||
emEmailAddresses: z.number().optional().default(0),
|
||||
emDomains: z.number().optional().default(0),
|
||||
emSendEmails: z.number().optional().default(0),
|
||||
appSupport: z.string().optional().default("BASIC"),
|
||||
appApiAccess: z.boolean().optional().default(true),
|
||||
isActive: z.boolean().optional().default(true),
|
||||
});
|
||||
+37
-4
@@ -139,7 +139,31 @@
|
||||
"send email service": "send email service",
|
||||
"How to get resend api key?": "How to get resend api key?",
|
||||
"Analytics": "Analytics",
|
||||
"Edit URL": "Edit URL"
|
||||
"Edit URL": "Edit URL",
|
||||
"Plan Name": "Plan",
|
||||
"Quota Settings": "Quota Settings",
|
||||
"Short Limit": "Short Limit",
|
||||
"Record Limit": "Record Limit",
|
||||
"Email Limit": "Email Limit",
|
||||
"Send Limit": "Send Limit",
|
||||
"Domain Limit": "Domain Limit",
|
||||
"Add Plan": "Add Plan",
|
||||
"No Plans": "No Plans",
|
||||
"Create Plan": "Create Plan",
|
||||
"Edit Plan": "Edit Plan",
|
||||
"Monthly limit of short links created": "Monthly limit of short links created",
|
||||
"Monthly limit of subdomains created": "Monthly limit of subdomains created",
|
||||
"Monthly limit of emails sent": "Monthly limit of emails sent",
|
||||
"Monthly limit of email addresses created": "Monthly limit of email addresses created",
|
||||
"Reject": "Reject",
|
||||
"Rejected": "Rejected",
|
||||
"View Period": "View Period",
|
||||
"Time range for viewing short link visitor statistics data (days)": "Time range for viewing short link visitor statistics data (days)",
|
||||
"Tracked Limit": "Tracked Limit",
|
||||
"Monthly limit of tracked clicks (times)": "Monthly limit of tracked clicks (times)",
|
||||
"Limit on the number of allowed domains": "Limit on the number of allowed domains",
|
||||
"Only active plans can be used": "Only active plans can be used",
|
||||
"Plan name must be unique": "Plan name must be unique"
|
||||
},
|
||||
"Components": {
|
||||
"Dashboard": "Dashboard",
|
||||
@@ -254,7 +278,8 @@
|
||||
"Last 1 Year": "Last 1 Year",
|
||||
"All the time": "All the time",
|
||||
"No Visits": "No Visits",
|
||||
"You don't have any visits yet in": "You don't have any visits yet in"
|
||||
"You don't have any visits yet in": "You don't have any visits yet in",
|
||||
"System Settings": "System Settings"
|
||||
},
|
||||
"Landing": {
|
||||
"settings": "Settings",
|
||||
@@ -339,7 +364,8 @@
|
||||
"System": "System",
|
||||
"Admin": "Admin",
|
||||
"Sign in": "Sign in",
|
||||
"Log out": "Log out"
|
||||
"Log out": "Log out",
|
||||
"System Settings": "System Settings"
|
||||
},
|
||||
"Email": {
|
||||
"Search emails": "Search emails",
|
||||
@@ -427,6 +453,13 @@
|
||||
"This action cannot be undone - please proceed with caution": "This action cannot be undone - please proceed with caution",
|
||||
"Warning": "Warning",
|
||||
"This will permanently delete your account and your active subscription!": "This will permanently delete your account and your active subscription!",
|
||||
"verification": "verification"
|
||||
"verification": "verification",
|
||||
"App Configs": "App Configs",
|
||||
"User Registration": "User Registration",
|
||||
"Allow users to sign up": "Allow users to sign up",
|
||||
"Subdomain Apply Mode": "Subdomain Apply Mode",
|
||||
"Enable subdomain apply mode, each submission requires administrator review": "Enable subdomain apply mode, each submission requires administrator review",
|
||||
"Notification": "系统通知",
|
||||
"Set system notification, this will be displayed in the header": "Set system notification, this will be displayed in the header"
|
||||
}
|
||||
}
|
||||
|
||||
+38
-5
@@ -110,7 +110,7 @@
|
||||
"Time To Live": "生效时间",
|
||||
"Proxy": "代理记录",
|
||||
"Proxy status": "DNS 响应被 Cloudflare Anycast IP 替代",
|
||||
"Total Domains": "总计",
|
||||
"Total Domains": "域名管理",
|
||||
"Add Domain": "添加域名",
|
||||
"Domain Name": "域名",
|
||||
"Shorten Service": "短链服务",
|
||||
@@ -139,7 +139,31 @@
|
||||
"send email service": "用于发送邮件服务",
|
||||
"How to get resend api key?": "如何获取 Resend API 密钥?",
|
||||
"Analytics": "访客分析",
|
||||
"Edit URL": "编辑短链"
|
||||
"Edit URL": "编辑短链",
|
||||
"Plan Name": "计划名称",
|
||||
"Quota Settings": "配额设置",
|
||||
"Short Limit": "创建短链数量",
|
||||
"Record Limit": "创建子域名数量",
|
||||
"Email Limit": "创建邮箱数量",
|
||||
"Send Limit": "发件数量",
|
||||
"Domain Limit": "域名限制",
|
||||
"Add Plan": "添加计划",
|
||||
"No Plans": "暂无计划",
|
||||
"Create Plan": "创建计划",
|
||||
"Edit Plan": "编辑计划",
|
||||
"Monthly limit of short links created": "每月新创建短链数量限制",
|
||||
"Monthly limit of subdomains created": "每月新创建子域名数量限制",
|
||||
"Monthly limit of emails sent": "每月发送邮件数量限制",
|
||||
"Monthly limit of email addresses created": "每月接收邮件数量限制",
|
||||
"Reject": "拒绝",
|
||||
"Rejected": "已拒绝",
|
||||
"View Period": "时间范围",
|
||||
"Time range for viewing short link visitor statistics data (days)": "查看短链访问量统计数据的时间范围(天)",
|
||||
"Tracked Limit": "跟踪统计限制",
|
||||
"Monthly limit of tracked clicks (times)": "每月最大跟踪点击量限制(次)",
|
||||
"Limit on the number of allowed domains": "允许的域名数量限制",
|
||||
"Only active plans can be used": "只有启用的计划才能生效",
|
||||
"Plan name must be unique": "计划名称必须唯一"
|
||||
},
|
||||
"Components": {
|
||||
"Dashboard": "用户面板",
|
||||
@@ -254,7 +278,8 @@
|
||||
"Last 1 Year": "最近 1 年",
|
||||
"All the time": "所有时间",
|
||||
"No Visits": "无访问记录",
|
||||
"You don't have any visits yet in": "您还没有任何访问记录于"
|
||||
"You don't have any visits yet in": "您还没有任何访问记录于",
|
||||
"System Settings": "系统设置"
|
||||
},
|
||||
"Landing": {
|
||||
"settings": "设置",
|
||||
@@ -339,7 +364,8 @@
|
||||
"System": "跟随系统",
|
||||
"Admin": "管理面板",
|
||||
"Sign in": "登录",
|
||||
"Log out": "退出登录"
|
||||
"Log out": "退出登录",
|
||||
"System Settings": "系统设置"
|
||||
},
|
||||
"Email": {
|
||||
"Search emails": "搜索邮箱...",
|
||||
@@ -427,6 +453,13 @@
|
||||
"This action cannot be undone - please proceed with caution": "此操作不可逆,请谨慎操作",
|
||||
"Warning": "警告",
|
||||
"This will permanently delete your account and your active subscription!": "这将永久删除您的账户和订阅!",
|
||||
"verification": "为了验证,请在下方输入 <confirm>确认删除账户</confirm>"
|
||||
"verification": "为了验证,请在下方输入 <confirm>确认删除账户</confirm>",
|
||||
"App Configs": "应用配置",
|
||||
"User Registration": "用户注册",
|
||||
"Allow users to sign up": "是否允许用户注册",
|
||||
"Subdomain Apply Mode": "子域名申请模式",
|
||||
"Enable subdomain apply mode, each submission requires administrator review": "启用子域名申请模式,每次提交需要管理员审核",
|
||||
"Notification": "系统通知",
|
||||
"Set system notification, this will be displayed in the header": "设置系统通知,将在网页顶部显示"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wr.do",
|
||||
"version": "0.7.0",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "oiov",
|
||||
"url": "https://github.com/oiov"
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
CREATE TABLE "system_configs"
|
||||
(
|
||||
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"key" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"version" TEXT NOT NULL DEFAULT '0.5.0',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 1. 是否开启注册配置
|
||||
INSERT INTO "system_configs"
|
||||
(
|
||||
"key",
|
||||
"value",
|
||||
"type",
|
||||
"description"
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'enable_user_registration',
|
||||
'true',
|
||||
'BOOLEAN',
|
||||
'是否允许新用户注册'
|
||||
);
|
||||
|
||||
-- 2. 系统通知配置
|
||||
INSERT INTO "system_configs"
|
||||
(
|
||||
"key",
|
||||
"value",
|
||||
"type",
|
||||
"description"
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'system_notification',
|
||||
'',
|
||||
'STRING',
|
||||
'系统全局通知消息'
|
||||
);
|
||||
|
||||
-- 3. 是否子域名申请模式,默认false
|
||||
INSERT INTO "system_configs"
|
||||
(
|
||||
"key",
|
||||
"value",
|
||||
"type",
|
||||
"description"
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'enable_subdomain_apply',
|
||||
'false',
|
||||
'BOOLEAN',
|
||||
'是否启用子域名申请模式'
|
||||
);
|
||||
|
||||
-- 创建计划表
|
||||
CREATE TABLE "plans"
|
||||
(
|
||||
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"name" TEXT NOT NULL,
|
||||
"slTrackedClicks" INTEGER NOT NULL,
|
||||
"slNewLinks" INTEGER NOT NULL,
|
||||
"slAnalyticsRetention" INTEGER NOT NULL,
|
||||
"slDomains" INTEGER NOT NULL,
|
||||
"slAdvancedAnalytics" BOOLEAN NOT NULL,
|
||||
"slCustomQrCodeLogo" BOOLEAN NOT NULL,
|
||||
"rcNewRecords" INTEGER NOT NULL,
|
||||
"emEmailAddresses" INTEGER NOT NULL,
|
||||
"emDomains" INTEGER NOT NULL,
|
||||
"emSendEmails" INTEGER NOT NULL,
|
||||
"appSupport" TEXT NOT NULL,
|
||||
"appApiAccess" BOOLEAN NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 创建唯一索引
|
||||
CREATE UNIQUE INDEX "plans_name_key" ON "plans"("name");
|
||||
|
||||
-- 插入初始数据
|
||||
INSERT INTO "plans"
|
||||
(
|
||||
"id",
|
||||
"name",
|
||||
"slTrackedClicks",
|
||||
"slNewLinks",
|
||||
"slAnalyticsRetention",
|
||||
"slDomains",
|
||||
"slAdvancedAnalytics",
|
||||
"slCustomQrCodeLogo",
|
||||
"rcNewRecords",
|
||||
"emEmailAddresses",
|
||||
"emDomains",
|
||||
"emSendEmails",
|
||||
"appSupport",
|
||||
"appApiAccess"
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'45fc1184-f7e7-4768-b28d-3f6e73d5a766',
|
||||
'free',
|
||||
100000,
|
||||
1000,
|
||||
180,
|
||||
2,
|
||||
true,
|
||||
false,
|
||||
3,
|
||||
1000,
|
||||
2,
|
||||
200,
|
||||
'BASIC',
|
||||
true
|
||||
),
|
||||
(
|
||||
'45fc1184-f7e7-4768-b28f-3e6e73d5a769',
|
||||
'premium',
|
||||
1000000,
|
||||
5000,
|
||||
365,
|
||||
2,
|
||||
true,
|
||||
true,
|
||||
2,
|
||||
5000,
|
||||
2,
|
||||
1000,
|
||||
'LIVE',
|
||||
true
|
||||
),
|
||||
(
|
||||
'45fc1184-f7e7-4768-b28d-3f6e73d5a678',
|
||||
'business',
|
||||
10000000,
|
||||
10000,
|
||||
1000,
|
||||
2,
|
||||
true,
|
||||
true,
|
||||
10,
|
||||
10000,
|
||||
2,
|
||||
2000,
|
||||
'LIVE',
|
||||
true
|
||||
);
|
||||
+43
-11
@@ -274,15 +274,47 @@ model Domain {
|
||||
@@map("domains")
|
||||
}
|
||||
|
||||
// model SystemConfig {
|
||||
// id String @id @default(uuid())
|
||||
// key String @unique
|
||||
// value String // JSON String
|
||||
// type String // BOOLEAN, STRING, NUMBER, OBJECT
|
||||
// description String?
|
||||
// version String @default("0.5.0")
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @default(now())
|
||||
model SystemConfig {
|
||||
id String @id @default(uuid())
|
||||
key String @unique
|
||||
value String // JSON String
|
||||
type String // BOOLEAN, STRING, NUMBER, OBJECT
|
||||
description String?
|
||||
version String @default("0.5.0")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now())
|
||||
|
||||
// @@map("system_configs")
|
||||
// }
|
||||
@@map("system_configs")
|
||||
}
|
||||
|
||||
model Plan {
|
||||
id String @id @default(uuid())
|
||||
name String @unique // "free", "premium", "business"
|
||||
|
||||
// Short Link (SL) related quotas
|
||||
slTrackedClicks Int
|
||||
slNewLinks Int
|
||||
slAnalyticsRetention Int // days
|
||||
slDomains Int
|
||||
slAdvancedAnalytics Boolean
|
||||
slCustomQrCodeLogo Boolean
|
||||
|
||||
// Record (RC) related quotas
|
||||
rcNewRecords Int
|
||||
|
||||
// Email (EM) related quotas
|
||||
emEmailAddresses Int
|
||||
emDomains Int
|
||||
emSendEmails Int
|
||||
|
||||
// App (APP) related settings
|
||||
appSupport String // "BASIC", "LIVE"
|
||||
appApiAccess Boolean
|
||||
|
||||
// Metadata
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now())
|
||||
|
||||
@@map("plans")
|
||||
}
|
||||
|
||||
@@ -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.7.0",
|
||||
"versionName": "1.0.0",
|
||||
"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.7.0",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?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/api/feature</loc><lastmod>2025-06-07T14:03:50.540Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-06-07T14:03:50.541Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-06-07T14:03:50.541Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-06-07T14:03:50.541Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/api/feature</loc><lastmod>2025-06-11T09:48:19.047Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-06-11T09:48:19.047Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-06-11T09:48:19.047Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-06-11T09:48:19.047Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
</urlset>
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
-2
@@ -16,9 +16,7 @@ export type SiteConfig = {
|
||||
discord: string;
|
||||
oichat: string;
|
||||
};
|
||||
openSignup: boolean;
|
||||
emailR2Domain: string;
|
||||
enableSubdomainApply: boolean;
|
||||
};
|
||||
|
||||
export type NavItem = {
|
||||
|
||||
Reference in New Issue
Block a user