chore: upd some codes
This commit is contained in:
11
.env.example
11
.env.example
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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);
|
||||
|
||||
7
components/dashboard/count-up.tsx
Normal file
7
components/dashboard/count-up.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import CountUp from "react-countup";
|
||||
|
||||
export default ({ count }: { count: number }) => {
|
||||
return <CountUp end={count} duration={3} />;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
2
env.mjs
2
env.mjs
@@ -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,
|
||||
|
||||
16
lib/utils.ts
16
lib/utils.ts
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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
18
pnpm-lock.yaml
generated
@@ -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
4
types/index.d.ts
vendored
@@ -13,6 +13,10 @@ export type SiteConfig = {
|
||||
twitter: string;
|
||||
github: string;
|
||||
};
|
||||
freeQuota: {
|
||||
record: number;
|
||||
url: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type NavItem = {
|
||||
|
||||
Reference in New Issue
Block a user