chore: upd some codes

This commit is contained in:
oiov
2024-07-29 11:34:02 +08:00
parent f0027e6a0e
commit 44c1a0eee0
18 changed files with 165 additions and 30 deletions

View File

@@ -26,4 +26,13 @@ RESEND_API_KEY=
# -----------------------------------------------------------------------------
CLOUDFLARE_ZONE_ID=
CLOUDFLARE_API_KEY=
CLOUDFLARE_EMAIL=
CLOUDFLARE_EMAIL=
# -----------------------------------------------------------------------------
# Free Quota
# -----------------------------------------------------------------------------
NEXT_PUBLIC_FREE_RECORD_QUOTA=3
NEXT_PUBLIC_FREE_URL_QUOTA=100
# Open Signup
NEXT_PUBLIC_OPEN_SIGNUP=1

View File

@@ -15,7 +15,11 @@ export async function updateUserRole(userId: string, data: FormData) {
try {
const session = await auth();
if (!session?.user || session?.user.id !== userId) {
if (
!session?.user ||
session?.user.id !== userId ||
session.user.role !== "ADMIN"
) {
throw new Error("Unauthorized");
}

View File

@@ -1,10 +1,12 @@
import Link from "next/link";
import { GlobeLock, Link as LinkIcon } from "lucide-react";
import { siteConfig } from "@/config/site";
import { getUserRecordCount } from "@/lib/dto/cloudflare-dns-record";
import { getUserShortUrlCount } from "@/lib/dto/short-urls";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import CountUp from "@/components/dashboard/count-up";
export async function DNSInfoCard({ userId }: { userId: string }) {
const count = await getUserRecordCount(userId);
@@ -23,7 +25,12 @@ export async function DNSInfoCard({ userId }: { userId: string }) {
{[-1, undefined].includes(count) ? (
<Skeleton className="h-5 w-20" />
) : (
<div className="text-2xl font-bold">{count}</div>
<div className="flex items-end gap-2 text-2xl font-bold">
<CountUp count={count} />
<span className="align-top text-base text-slate-500">
/ {siteConfig.freeQuota.record}
</span>
</div>
)}
<p className="text-xs text-muted-foreground">total</p>
</CardContent>
@@ -47,7 +54,12 @@ export async function UrlsInfoCard({ userId }: { userId: string }) {
{[-1, undefined].includes(count) ? (
<Skeleton className="h-5 w-20" />
) : (
<div className="text-2xl font-bold">{count}</div>
<div className="flex items-end gap-2 text-2xl font-bold">
<CountUp count={count} />
<span className="align-top text-base text-slate-500">
/ {siteConfig.freeQuota.url}
</span>
</div>
)}
<p className="text-xs text-muted-foreground">total</p>
</CardContent>

View File

@@ -20,12 +20,14 @@ export default async function SettingsPage() {
return (
<>
<DashboardHeader
heading="Settings"
heading="Account Settings"
text="Manage account and website settings."
/>
<div className="divide-y divide-muted pb-10">
<UserNameForm user={{ id: user.id, name: user.name || "" }} />
<UserRoleForm user={{ id: user.id, role: user.role }} />
{user.role === "ADMIN" && (
<UserRoleForm user={{ id: user.id, role: user.role }} />
)}
<DeleteAccountSection />
</div>
</>

View File

@@ -6,10 +6,9 @@ import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import useSWR, { useSWRConfig } from "swr";
import { TTL_ENUMS } from "@/lib/cloudflare";
import { siteConfig } from "@/config/site";
import { ShortUrlFormData } from "@/lib/dto/short-urls";
import { fetcher } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { cn, fetcher } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -30,6 +29,7 @@ import {
import StatusDot from "@/components/dashboard/status-dot";
import { FormType } from "@/components/forms/record-form";
import { UrlForm } from "@/components/forms/url-form";
import { CopyButton } from "@/components/shared/copy-button";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
export interface UrlListProps {
@@ -138,7 +138,7 @@ export default function UserUrlsList({ user }: UrlListProps) {
Visible
</TableHead>
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
Active
Status
</TableHead>
<TableHead className="col-span-1 flex items-center justify-center font-bold">
Actions
@@ -164,14 +164,21 @@ export default function UserUrlsList({ user }: UrlListProps) {
{short.target}
</Link>
</TableCell>
<TableCell className="col-span-2">
<TableCell className="col-span-2 flex items-center gap-1">
<Link
className="font-semibold text-slate-600 after:content-['_↗'] hover:text-blue-400 hover:underline"
href={short.url}
className="font-semibold text-slate-600 hover:text-blue-400 hover:underline"
href={`/s/${short.url}`}
target="_blank"
>
{short.url.slice(16)}
{short.url}
</Link>
<CopyButton
value={`${siteConfig.url}/s/${short.url}`}
className={cn(
"size-[25px]",
"duration-250 transition-all group-hover:opacity-100",
)}
/>
</TableCell>
<TableCell className="col-span-1 hidden justify-center font-semibold sm:flex">
{short.visible === 1 ? "Public" : "Private"}

View File

@@ -1,5 +1,5 @@
import { env } from "@/env.mjs";
import { createUserShortUrl } from "@/lib/dto/short-urls";
import { createUserShortUrl, getUserShortUrlCount } from "@/lib/dto/short-urls";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
import { createUrlSchema } from "@/lib/validations/url";
@@ -9,6 +9,20 @@ export async function POST(req: Request) {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { NEXT_PUBLIC_FREE_URL_QUOTA } = env;
// check quota
const user_urls_count = await getUserShortUrlCount(user.id);
if (
Number(NEXT_PUBLIC_FREE_URL_QUOTA) > 0 &&
user_urls_count >= Number(NEXT_PUBLIC_FREE_URL_QUOTA)
) {
return Response.json("Your short urls have reached the free limit.", {
status: 409,
statusText: "Your short urls have reached the free limit.",
});
}
const { data } = await req.json();
const { target, url, visible, active } = createUrlSchema.parse(data);

View File

@@ -0,0 +1,7 @@
"use client";
import CountUp from "react-countup";
export default ({ count }: { count: number }) => {
return <CountUp end={count} duration={3} />;
};

View File

@@ -3,10 +3,12 @@
import { Dispatch, SetStateAction, useTransition } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "@prisma/client";
import { Sparkles } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { ShortUrlFormData } from "@/lib/dto/short-urls";
import { generateUrlSuffix } from "@/lib/utils";
import { createUrlSchema } from "@/lib/validations/url";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -156,7 +158,7 @@ export function UrlForm({
)}
</div>
</FormSectionColumns>
<FormSectionColumns title="Url">
<FormSectionColumns title="Url Suffix">
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="url">
Url
@@ -168,10 +170,23 @@ export function UrlForm({
</span>
<Input
id="url"
className="flex-1 pl-[116px] shadow-inner"
className="flex-1 rounded-r-none pl-[116px] shadow-inner"
size={20}
{...register("url")}
disabled={type === "edit"}
/>
<Button
className="rounded-l-none"
type="button"
size="sm"
variant="outline"
disabled={type === "edit"}
onClick={() => {
setValue("url", generateUrlSuffix(6));
}}
>
<Sparkles className="h-4 w-4 text-slate-500" />
</Button>
</div>
</div>
<div className="flex flex-col justify-between p-1">
@@ -181,7 +196,7 @@ export function UrlForm({
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
A random url.
A random url suffix.
</p>
)}
</div>

View File

@@ -57,7 +57,7 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
}
return (
<div className={cn("grid gap-6", className)} {...props}>
<div className={cn("grid gap-3", className)} {...props}>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="grid gap-2">
<div className="grid gap-1">
@@ -80,7 +80,10 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
</p>
)}
</div>
<button className={cn(buttonVariants())} disabled={isLoading}>
<button
className={cn(buttonVariants(), "mt-3")}
disabled={isLoading || isGoogleLoading || isGithubLoading}
>
{isLoading && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
@@ -88,7 +91,8 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
</button>
</div>
</form>
<div className="relative">
<div className="relative my-3">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
@@ -98,6 +102,7 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
</span>
</div>
</div>
<button
type="button"
className={cn(buttonVariants({ variant: "outline" }))}
@@ -105,7 +110,7 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
setIsGoogleLoading(true);
signIn("google");
}}
disabled={isLoading || isGoogleLoading}
disabled={isLoading || isGoogleLoading || isGithubLoading}
>
{isGoogleLoading ? (
<Icons.spinner className="mr-2 size-4 animate-spin" />
@@ -114,7 +119,6 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
)}{" "}
Google
</button>
<button
type="button"
className={cn(buttonVariants({ variant: "outline" }))}
@@ -122,7 +126,7 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
setIsGithubLoading(true);
signIn("github");
}}
disabled={isLoading || isGithubLoading}
disabled={isLoading || isGithubLoading || isGoogleLoading}
>
{isGithubLoading ? (
<Icons.spinner className="mr-2 size-4 animate-spin" />

View File

@@ -118,7 +118,7 @@ export function NavBar({ scroll = false }: NavBarProps) {
<Link href="login">
<Button
className="hidden gap-2 px-4 md:flex"
variant="link"
variant="default"
size="sm"
rounded="lg"
>

View File

@@ -2,8 +2,8 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Icons } from "./icons";
@@ -30,7 +30,7 @@ export function CopyButton({ value, className, ...props }: CopyButtonProps) {
size="sm"
variant="ghost"
className={cn(
"z-10 size-[30px] border border-white/25 bg-zinc-900 p-1.5 text-primary-foreground hover:text-foreground dark:text-foreground",
"z-10 size-[30px] p-1.5 text-foreground hover:border hover:text-foreground dark:text-foreground",
className,
)}
onClick={() => handleCopyValue(value)}

View File

@@ -2,6 +2,8 @@ import { SidebarNavItem, SiteConfig } from "types";
import { env } from "@/env.mjs";
const site_url = env.NEXT_PUBLIC_APP_URL;
const free_recored_quota = env.NEXT_PUBLIC_FREE_RECORD_QUOTA;
const free_url_quota = env.NEXT_PUBLIC_FREE_URL_QUOTA;
export const siteConfig: SiteConfig = {
name: "WRDO",
@@ -13,6 +15,10 @@ export const siteConfig: SiteConfig = {
github: "https://github.com/oiov/wr.do",
},
mailSupport: "support@wr.do",
freeQuota: {
record: Number(free_recored_quota),
url: Number(free_url_quota),
},
};
export const footerLinks: SidebarNavItem[] = [

View File

@@ -20,6 +20,7 @@ export const env = createEnv({
client: {
NEXT_PUBLIC_APP_URL: z.string().min(1),
NEXT_PUBLIC_FREE_RECORD_QUOTA: z.string().min(1),
NEXT_PUBLIC_FREE_URL_QUOTA: z.string().min(1),
},
runtimeEnv: {
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
@@ -32,6 +33,7 @@ export const env = createEnv({
RESEND_API_KEY: process.env.RESEND_API_KEY,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_FREE_RECORD_QUOTA: process.env.NEXT_PUBLIC_FREE_RECORD_QUOTA,
NEXT_PUBLIC_FREE_URL_QUOTA: process.env.NEXT_PUBLIC_FREE_URL_QUOTA,
CLOUDFLARE_ZONE_ID: process.env.CLOUDFLARE_ZONE_ID,
CLOUDFLARE_API_KEY: process.env.CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL: process.env.CLOUDFLARE_EMAIL,

View File

@@ -1,4 +1,4 @@
import crypto from "crypto";
import crypto, { randomBytes } from "crypto";
import { Metadata } from "next";
import { clsx, type ClassValue } from "clsx";
import ms from "ms";
@@ -168,3 +168,17 @@ export function generateSecret(length: number = 16): string {
// 将字节转换为十六进制字符串
return buffer.toString("hex");
}
export function generateUrlSuffix(length: number = 6): string {
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const charactersLength = characters.length;
let result = "";
const randomValues = randomBytes(length);
for (let i = 0; i < length; i++) {
result += characters[randomValues[i] % charactersLength];
}
return result;
}

View File

@@ -1,9 +1,25 @@
import * as z from "zod";
/*
support:
xxx
xxx-xx
xxxx123
xxx-1
not support:
-xxx
xxx-
xxx--xx
-xxx-
*/
const urlPattern = /^(?!-)[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*(?<!-)$/;
const targetPattern =
/^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(:\d+)?(\/[a-zA-Z0-9-]*)*\/?$/;
export const createUrlSchema = z.object({
id: z.string().optional(),
target: z.string().min(6), // TODO
url: z.string().min(2), // TODO
target: z.string().min(6).regex(targetPattern, "Invalid target URL format"),
url: z.string().min(2).regex(urlPattern, "Invalid URL format"),
visible: z.number().default(1),
active: z.number().default(1),
});

View File

@@ -74,6 +74,7 @@
"nodemailer": "^6.9.14",
"prop-types": "^15.8.1",
"react": "18.3.1",
"react-countup": "^6.5.3",
"react-day-picker": "^8.10.1",
"react-dom": "18.3.1",
"react-email": "2.1.5",

18
pnpm-lock.yaml generated
View File

@@ -173,6 +173,9 @@ importers:
react:
specifier: 18.3.1
version: 18.3.1
react-countup:
specifier: ^6.5.3
version: 6.5.3(react@18.3.1)
react-day-picker:
specifier: ^8.10.1
version: 8.10.1(date-fns@3.6.0)(react@18.3.1)
@@ -3012,6 +3015,9 @@ packages:
typescript:
optional: true
countup.js@2.8.0:
resolution: {integrity: sha512-f7xEhX0awl4NOElHulrl4XRfKoNH3rB+qfNSZZyjSZhaAoUk6elvhH+MNxMmlmuUJ2/QNTWPSA7U4mNtIAKljQ==}
cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
@@ -4911,6 +4917,11 @@ packages:
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
react-countup@6.5.3:
resolution: {integrity: sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w==}
peerDependencies:
react: '>= 16.3.0'
react-day-picker@8.10.1:
resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==}
peerDependencies:
@@ -8861,6 +8872,8 @@ snapshots:
optionalDependencies:
typescript: 5.5.3
countup.js@2.8.0: {}
cross-spawn@7.0.3:
dependencies:
path-key: 3.1.1
@@ -11231,6 +11244,11 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
react-countup@6.5.3(react@18.3.1):
dependencies:
countup.js: 2.8.0
react: 18.3.1
react-day-picker@8.10.1(date-fns@3.6.0)(react@18.3.1):
dependencies:
date-fns: 3.6.0

4
types/index.d.ts vendored
View File

@@ -13,6 +13,10 @@ export type SiteConfig = {
twitter: string;
github: string;
};
freeQuota: {
record: number;
url: number;
};
};
export type NavItem = {