master
ubuntu lee 2024-10-14 11:38:41 +08:00
parent ebd045b7ea
commit 22dfa0204e
98 changed files with 4670 additions and 756 deletions

View File

@ -0,0 +1 @@
src/app/(pages)/mdx/*.*

View File

@ -4,7 +4,7 @@
"rsc": true, "rsc": true,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "tailwind-config.ts", "config": "src/app/tailwind-config.ts",
"css": "@/app/styles/index.css", "css": "@/app/styles/index.css",
"baseColor": "zinc", "baseColor": "zinc",
"cssVariables": true, "cssVariables": true,

View File

@ -26,67 +26,84 @@
}, },
"dependencies": { "dependencies": {
"@3rapp/utils": "workspace:*", "@3rapp/utils": "workspace:*",
"@auth/prisma-adapter": "^2.6.0",
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@mdx-js/loader": "^3.0.1", "@mdx-js/loader": "^3.0.1",
"@mdx-js/react": "^3.0.1", "@mdx-js/react": "^3.0.1",
"@next/mdx": "^14.2.13", "@next/mdx": "^14.2.14",
"@prisma/client": "5.17.0", "@prisma/client": "5.20.0",
"@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2",
"@types/mdx": "^2.0.13", "@types/mdx": "^2.0.13",
"@types/nodemailer": "^6.4.16",
"@vavt/cm-extension": "^1.5.0", "@vavt/cm-extension": "^1.5.0",
"bcrypt": "^5.1.1",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"ioredis": "^5.4.1",
"lucide-react": "^0.441.0", "lucide-react": "^0.441.0",
"md-editor-rt": "^4.20.2", "md-editor-rt": "^4.20.2",
"micromatch": "^4.0.8", "micromatch": "^4.0.8",
"next": "14.2.12", "next": "14.2.12",
"next-auth": "5.0.0-beta.22",
"next-mdx-remote": "^5.0.0", "next-mdx-remote": "^5.0.0",
"nodemailer": "^6.9.15",
"pinyin": "4.0.0-alpha.2",
"prism-themes": "^1.9.0", "prism-themes": "^1.9.0",
"prisma-paginate": "^5.2.1", "prisma-paginate": "^5.2.1",
"react": "^18", "react": "^18.3.1",
"react-dom": "^18", "react-dom": "^18.3.1",
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
"react-icons": "^5.3.0", "react-icons": "^5.3.0",
"react-use": "^17.5.0", "react-spinners": "^0.14.1",
"react-use": "^17.5.1",
"rehype-prism-plus": "^2.0.0", "rehype-prism-plus": "^2.0.0",
"tailwind-merge": "^2.3.0", "resend": "^4.0.0",
"segmentit": "^2.0.3",
"sharp": "^0.33.5",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@3rapp/core": "workspace:*", "@3rapp/core": "workspace:*",
"@types/lodash": "^4.17.5", "@types/bcrypt": "^5.0.2",
"@types/bcryptjs": "^2.4.6",
"@types/lodash": "^4.17.9",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/node": "^20.12.12", "@types/node": "^20.16.10",
"@types/react": "^18.3.2", "@types/pinyin": "^2.10.2",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.20",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^8.57.0", "eslint": "^8.57.1",
"postcss": "^8.4.40", "postcss": "^8.4.47",
"postcss-import": "^16.1.0", "postcss-import": "^16.1.0",
"postcss-mixins": "^10.0.1", "postcss-mixins": "^10.0.1",
"postcss-nested": "^6.0.1", "postcss-nested": "^6.2.0",
"postcss-nesting": "^12.1.4", "postcss-nesting": "^12.1.5",
"prisma": "^5.16.1", "prisma": "^5.20.0",
"prisma-extension-bark": "^0.2.2", "prisma-extension-bark": "^0.2.2",
"stylelint": "^16.5.0", "stylelint": "^16.9.0",
"stylelint-config-css-modules": "^4.4.0", "stylelint-config-css-modules": "^4.4.0",
"stylelint-config-recess-order": "^5.0.1", "stylelint-config-recess-order": "^5.1.1",
"stylelint-config-standard": "^36.0.1", "stylelint-config-standard": "^36.0.1",
"stylelint-prettier": "^5.0.2", "stylelint-prettier": "^5.0.2",
"tailwindcss": "^3.4.7", "tailwindcss": "^3.4.13",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.4.5", "typescript": "^5.6.2",
"utility-types": "^3.11.0" "utility-types": "^3.11.0"
}, },
"prisma": { "prisma": {

View File

@ -2,10 +2,10 @@ import { notFound } from 'next/navigation';
import { FC } from 'react'; import { FC } from 'react';
import { PostActionForm } from '@/app/_components/post/action-form'; import { PostActionForm } from '@/app/_components/post/action-form';
import { queryPostItemById } from '@/app/actions/post'; import { queryPostItemByIdOrSlug } from '@/app/actions/post';
export const PostEditForm: FC<{ id: string }> = async ({ id }) => { export const PostEditForm: FC<{ id: string }> = async ({ id }) => {
const post = await queryPostItemById(id); const post = await queryPostItemByIdOrSlug(id);
if (!post) return notFound(); if (!post) return notFound();
return <PostActionForm type="update" post={post} />; return <PostActionForm type="update" post={post} />;
}; };

View File

@ -0,0 +1,10 @@
import type { AppProps } from 'next/app';
import { SessionProvider } from 'next-auth/react';
export default function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
return (
<SessionProvider session={session}>
<Component {...pageProps} />;
</SessionProvider>
);
}

View File

@ -0,0 +1,6 @@
import { NewValidatorForm } from '@/app/_components/auth/new-validator-form';
const NewVerificationPage = () => {
return <NewValidatorForm />;
};
export default NewVerificationPage;

View File

@ -0,0 +1,114 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { FC, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { CardWrapper } from '@/app/_components/auth/card-wrapper';
import { registerSchema } from '@/app/_components/auth/user-form-validator';
import { Button } from '@/app/_components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/app/_components/ui/form';
import { Input } from '@/app/_components/ui/input';
import { register } from '@/app/actions/user';
interface RegisterPageProps extends React.HTMLAttributes<HTMLDivElement> {}
const RegiserForm: FC<RegisterPageProps> = ({ className, ...props }) => {
const [erre, setErre] = useState('');
const [success, setSuccess] = useState('');
const form = useForm<z.infer<typeof registerSchema>>({
resolver: zodResolver(registerSchema),
mode: 'all',
defaultValues: {
email: '',
password: '',
username: '',
},
});
const onSubmit = async (values: z.infer<typeof registerSchema>) => {
const user = await register(values);
if (user.error) setErre(user.error);
if (user.seccess) setSuccess(user.seccess);
};
return (
<div className=" tw-bg-slate-200 tw-space-x-2 tw-flex tw-flex-col tw-w-2/5 tw-mx-auto">
<CardWrapper
headerLabel="Create an account"
backButtonLabel="已经有一个账号?"
backButtonHref="/"
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className=" tw-space-y-6 tw-w-full tw-flex tw-flex-col ">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem className="tw-flex tw-flex-col tw-space-y-2 tw-items-center tw-mt-4">
<div className="tw-flex tw-flex-row tw-space-y-2 tw-items-center tw-w-full">
<FormLabel className="tw-w-1/3">:</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="tw-flex tw-flex-col tw-space-y-2 tw-items-center tw-mt-4">
<div className="tw-flex tw-flex-row tw-space-y-2 tw-items-center tw-w-full">
<FormLabel className="tw-w-1/3">:</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="tw-flex tw-flex-col tw-space-y-2 tw-items-center tw-mt-4">
<div className="tw-flex tw-flex-row tw-space-y-2 tw-items-center tw-w-full">
<FormLabel className="tw-w-1/3">:</FormLabel>
<FormControl>
<Input
type="password"
placeholder="********"
{...field}
/>
</FormControl>
</div>
<FormMessage className="" />
</FormItem>
)}
/>
<Button
className="tw-mx-auto"
onClick={form.handleSubmit(onSubmit)}
disabled={form.formState.isSubmitting}
>
</Button>
</div>
</form>
</Form>
</CardWrapper>
</div>
);
};
export default RegiserForm;

View File

@ -0,0 +1,17 @@
import { auth } from '@/auth';
export default function Dashboard({ session }: { session: any }) {
if (!session.user) return <div>Not authenticated</div>;
return <div>{JSON.stringify(session, null, 2)}</div>;
}
export async function getServerSideProps(ctx: any) {
const session = await auth(ctx);
return {
props: {
session,
},
};
}

View File

@ -1,15 +1,27 @@
import React, { PropsWithChildren, ReactNode } from 'react'; import { Metadata } from 'next';
import React, { PropsWithChildren, ReactNode, Suspense } from 'react';
import { Header } from '../_components/header'; import { Header } from '../_components/header';
import { PageSkeleton } from '../_components/loading/page';
import { Toast, ToastProvider } from '../_components/ui/toast';
export const metadata: Metadata = {
title: 'about rext blog',
description:
'个人博客,提供一些ts、react、node.js、php、golang相关的技术文档以及分享一些生活琐事',
keywords: 'react, next.js, web application',
};
const appLayout: React.FC<PropsWithChildren & { modal: ReactNode }> = ({ children, modal }) => ( const appLayout: React.FC<PropsWithChildren & { modal: ReactNode }> = ({ children, modal }) => (
<> <>
<div className=" tw-app-layout"> <div className=" tw-app-layout">
<Header /> <Header />
<Suspense fallback={<PageSkeleton />}>{children}</Suspense>
{children}
</div> </div>
{modal} {modal}
<ToastProvider>
<Toast />
</ToastProvider>
</> </>
); );
export default appLayout; export default appLayout;

View File

@ -49,7 +49,7 @@ const App: React.FC<{ searchParams: Record<string, any> }> = async ({ searchPara
<div className=" tw-py-3"> <div className=" tw-py-3">
<Link <Link
className=" tw-w-full tw-block tw-overflow-hidden" className=" tw-w-full tw-block tw-overflow-hidden"
href={`/post/${item.id}`} href={`/post/${item.slug ? item.slug : item.id}`}
> >
<h2 className=" tw-text-lg tw-font-bold tw-ellips tw-animate-decoration tw-animate-decoration-lg"> <h2 className=" tw-text-lg tw-font-bold tw-ellips tw-animate-decoration tw-animate-decoration-lg">
{item.title} {item.title}

View File

@ -1,3 +1,4 @@
import { ResolvingMetadata } from 'next';
import Image from 'next/image'; import Image from 'next/image';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
@ -5,10 +6,23 @@ import { FC } from 'react';
import { Tools } from '@/app/_components/home/tools'; import { Tools } from '@/app/_components/home/tools';
import { MarkdownPreview } from '@/app/_components/markdown/preivew'; import { MarkdownPreview } from '@/app/_components/markdown/preivew';
import { queryPostItemById } from '@/app/actions/post'; import { queryPostItemByIdOrSlug } from '@/app/actions/post';
export const generateMetadata = async (
{ params }: { params: { item: string } },
parent: ResolvingMetadata,
) => {
const post = await queryPostItemByIdOrSlug(params.item);
if (!post) return {};
return {
title: `${post.title} - ${(await parent).title?.absolute}`,
keywords: post.keywords,
description: post.description,
};
};
const PostItemPage: FC<{ params: { item: string } }> = async ({ params }) => { const PostItemPage: FC<{ params: { item: string } }> = async ({ params }) => {
const post = await queryPostItemById(params.item); const post = await queryPostItemByIdOrSlug(params.item);
if (!post) return notFound(); if (!post) return notFound();
return ( return (
<div className="tw-page-container"> <div className="tw-page-container">

View File

@ -1,23 +0,0 @@
'use client';
import { useRef } from 'react';
import { BackButton } from '@/app/_components/home/back-button';
import { PostActionForm } from '@/app/_components/post/action-form';
import { Button } from '@/app/_components/ui/button';
export const PostCreateForm = () => {
const ref = useRef(null);
return (
<>
<div className=" tw-flex tw-justify-between tw-mb-6 tw-mx-5 tw-my-4 ">
<BackButton />
<Button variant="default" onClick={() => ref.current.create()}>
</Button>
</div>
<PostActionForm ref={ref} type="create" />
</>
);
};

View File

@ -1,10 +0,0 @@
import { PostCreateForm } from './form';
const PostCreatePage1 = () => (
<div className="tw-page-container ">
<div className=" tw-bg-sky-50 tw-rounded-xl">
<PostCreateForm />
</div>
</div>
);
export default PostCreatePage1;

View File

@ -0,0 +1,9 @@
import { FC } from 'react';
import { PostActionForm } from '@/app/_components/post/action-form';
import { queryPostItemByIdOrSlug } from '@/app/actions/post';
export const PostEditForm: FC<{ id: string }> = async ({ id }) => {
const post = await queryPostItemByIdOrSlug(id);
return <PostActionForm type="update" post={post} />;
};

View File

@ -0,0 +1,14 @@
import { FC } from 'react';
import { PostEditForm } from './form';
const PostEditPage: FC<{ params: { item: string } }> = async ({ params: { item } }) => {
return (
<div className="tw-page-container tw-w-2/3 tw-mx-auto">
<div className="tw-flex tw-flex-auto tw-flex-col tw-space-y-6 tw-bg-white/90 tw-w-auto !tw-max-w-full tw-px-5 tw-py-3 tw-rounded-md">
<PostEditForm id={item} />
</div>
</div>
);
};
export default PostEditPage;

View File

@ -0,0 +1,15 @@
'use client';
import { useRef } from 'react';
import { PostActionForm } from '@/app/_components/post/action-form';
export const PostCreateForm = () => {
const ref = useRef(null);
return (
<>
<PostActionForm ref={ref} type="create" />
</>
);
};

View File

@ -0,0 +1,10 @@
import { PostCreateForm } from './form';
const PostCreatePage1 = () => (
<div className="tw-page-container">
<div className="tw-flex tw-flex-auto tw-flex-col tw-space-y-6 tw-bg-white/90 tw-w-auto !tw-max-w-full tw-px-5 tw-py-3 tw-rounded-md">
<PostCreateForm />
</div>
</div>
);
export default PostCreatePage1;

View File

@ -0,0 +1,9 @@
import { PageSkeleton } from '@/app/_components/loading/page';
export default function PostLoading() {
return (
<div className="tw-page-container">
<PageSkeleton />
</div>
);
}

View File

@ -0,0 +1,10 @@
import { CheckCircledIcon } from '@radix-ui/react-icons';
export const SuccessMessage: React.FC<{ message: string }> = ({ message }) => {
return !message ? null : (
<div className="success-message">
<CheckCircledIcon />
<p>{message}</p>
</div>
);
};

View File

@ -0,0 +1,15 @@
import Image from 'next/image';
import { auth } from '@/auth';
export default async function UserAvatar() {
const session = await auth();
if (!session.user) return null;
return (
<div>
<Image src={session.user.image} alt="User Avatar" />
</div>
);
}

View File

@ -0,0 +1,35 @@
import Link from 'next/link';
import { FC, PropsWithChildren } from 'react';
import { Button } from '../ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '../ui/card';
type CardWrapperProps = PropsWithChildren & {
headerLabel: string;
backButtonLabel: string;
backButtonHref: string;
showSocial?: boolean;
};
export const CardWrapper: FC<CardWrapperProps> = ({
children,
headerLabel,
backButtonLabel,
backButtonHref,
}) => {
return (
<Card>
<CardHeader>
<CardTitle className="tw-text-xl tw-font-bold tw-mx-auto tw-tracking-[10px]">
</CardTitle>
<p className=" tw-text-muted-foreground tw-mx-auto">{headerLabel}</p>
</CardHeader>
<CardContent>{children}</CardContent>
<CardFooter>
<Button className=" tw-mx-auto" variant="link">
<Link href={backButtonHref}>{backButtonLabel}</Link>
</Button>
</CardFooter>
</Card>
);
};

View File

@ -0,0 +1,10 @@
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
export const ErrorMessage: React.FC<{ message: string }> = ({ message }) => {
return !message ? null : (
<div className="bg-destructive/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-destructive">
<ExclamationTriangleIcon className="h-4 w-4" />
<p>{message}</p>
</div>
);
};

View File

@ -0,0 +1,40 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { FC } from 'react';
import { Form, useForm } from 'react-hook-form';
import { z } from 'zod';
import { LoginSchema } from '@/lib/validations /auth';
import { FormField } from '../ui/form';
import { CardWrapper } from './card-wrapper';
const LoginForm: FC = () => {
const form = useForm<z.infer<typeof LoginSchema>>({
mode: 'all',
defaultValues: {
email: '',
password: '',
},
resolver: zodResolver(LoginSchema),
});
return (
<CardWrapper
headerLabel="欢迎登录"
backButtonLabel="还没有账号?"
backButtonHref="/auth/register"
showSocial
>
<Form {...form}>
<form>
<div>
<FormField />
</div>
</form>
</Form>
</CardWrapper>
);
};
export default LoginForm;

View File

@ -0,0 +1,47 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { FC, useCallback, useEffect, useState } from 'react';
import { BeatLoader } from 'react-spinners';
import { newValidator } from '@/app/actions/new-validator';
import { SuccessMessage } from './SuccessMessage ';
import { CardWrapper } from './card-wrapper';
import { ErrorMessage } from './error-message';
export const NewValidatorForm: FC = () => {
const [error, setError] = useState<string | undefined>();
const [success, setSuccess] = useState<string | undefined>();
const searchParams = useSearchParams();
const token = searchParams.get('token');
const onSubmit = useCallback(async () => {
if (success || error) return null;
if (!token) {
setError('缺少token!');
return null;
}
const sultan = await newValidator(token);
if (sultan.error) {
setError(sultan.error);
} else {
setSuccess('你的账号已经通过验证了!');
}
return null;
}, [token, success, error]);
useEffect(() => {
onSubmit();
}, [onSubmit]);
return (
<CardWrapper headerLabel="验证账号" backButtonLabel="返回登录" backButtonHref="/auth/login">
<div>
{!success && !error && <BeatLoader />}
<SuccessMessage message={success} />
{!success && <ErrorMessage message={error} />}
</div>
</CardWrapper>
);
};

View File

@ -0,0 +1,23 @@
import { signIn } from '@/auth';
export function SignIn() {
return (
<form
action={async (formData) => {
'use server';
await signIn('credentials', formData);
}}
>
<label>
Email
<input name="email" type="email" />
</label>
<label>
Password
<input name="password" type="password" />
</label>
<button type="submit">Sign in</button>
</form>
);
}

View File

@ -0,0 +1,15 @@
import { signIn } from '@/auth';
export function SignIn() {
return (
<form
action={async () => {
'use server';
await signIn('github', { redirectTo: '/dashboard' });
}}
>
<button type="submit">Sign in</button>
</form>
);
}

View File

@ -0,0 +1,15 @@
import { signOut } from '@/auth';
export const SignOut = () => {
return (
<form
action={async () => {
'use server';
await signOut();
}}
>
<button type="submit">Sign Out</button>
</form>
);
};

View File

@ -0,0 +1,27 @@
import { isNil } from 'lodash';
import { object, optional, string } from 'zod';
import { getUserByEmail } from '@/data/user';
const uniqueEmailValidator = async (email: string) => {
if (isNil(email)) return true;
const user = await getUserByEmail(email);
if (isNil(user)) return true;
return false;
};
export const registerSchema = object({
email: string()
.email('填写正确的邮箱格式')
.refine(uniqueEmailValidator, 'Email already exists'),
password: string({ required_error: 'Password is required' })
.min(6, 'Password must be more than 8 characters')
.max(32, 'Password must be less than 32 characters'),
username: string()
.min(3, 'Username must be more than 2 characters')
.max(12, 'Username must be less than 12 characters'),
});
export const loginSchema = object({
email: string().email('填写正确的邮箱格式'),
password: string({ required_error: '密码至少6位' }),
code: optional(string()),
});

View File

@ -0,0 +1,7 @@
.details {
@apply tw-border tw-p-2 tw-overflow-hidden;
& > .content {
@apply tw-overflow-hidden tw-px-2 tw-py-3;
}
}

View File

@ -0,0 +1,80 @@
'use client';
import { clsx } from 'clsx';
import { FC, MouseEventHandler, PropsWithChildren, useCallback, useRef, useState } from 'react';
import { useMount } from 'react-use';
import $styles from './details.module.css';
export const Details: FC<PropsWithChildren<{ defaultOpen?: boolean; summary: string }>> = ({
defaultOpen = false,
summary,
children,
}) => {
const [open, setOpen] = useState(defaultOpen);
const detailsRef = useRef<HTMLDetailsElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const openDetails = useCallback((isInit = false) => {
if (detailsRef.current && contentRef.current) {
detailsRef.current.setAttribute('open', '');
contentRef.current.style.maxHeight = isInit
? 'none'
: `${contentRef.current.scrollHeight}px`;
switch (isInit) {
case true:
setOpen(true);
break;
default:
contentRef.current.addEventListener('transitionend', () => setOpen(true), {
once: true,
});
break;
}
}
}, []);
const closeDetails = useCallback((isInit = false) => {
if (detailsRef.current && contentRef.current) {
contentRef.current.style.maxHeight = '0px';
setOpen(false);
switch (isInit) {
case true:
detailsRef.current.removeAttribute('open');
break;
default:
contentRef.current.addEventListener(
'transitionend',
() => {
detailsRef.current.removeAttribute('open');
},
{
once: true,
},
);
break;
}
}
}, []);
const handleToggle: MouseEventHandler<HTMLElement> = useCallback(
(event) => {
event.preventDefault();
setOpen(!open);
open ? closeDetails() : openDetails();
},
[open],
);
useMount(() => {
open ? openDetails(true) : closeDetails(true);
if (contentRef.current) contentRef.current.style.transition = 'max-height 0.3s ease-out';
});
return (
<details className={$styles.details} ref={detailsRef}>
<summary className="tw-cursor-pointer" onClick={handleToggle}>
{summary}
</summary>
<div ref={contentRef} className={clsx($styles.content)}>
{children}
</div>
</details>
);
};

View File

@ -8,6 +8,7 @@ import $styles from './page.module.css';
export const Logo = () => ( export const Logo = () => (
<Link href="/" className={$styles.link}> <Link href="/" className={$styles.link}>
<Image <Image
priority
src={abc} src={abc}
alt="avatar logo" alt="avatar logo"
sizes="100vw" sizes="100vw"

View File

@ -0,0 +1,13 @@
import { FC } from 'react';
import { Skeleton } from '../ui/skeleton';
export const ModalSkelton: FC = () => (
<div className="">
<Skeleton className="" />
<div className="">
<Skeleton className="" />
<Skeleton className="" />
</div>
</div>
);

View File

@ -0,0 +1,22 @@
import { FC } from 'react';
import { Skeleton } from '../ui/skeleton';
export const PageSkeleton: FC = () => (
<div className=" tw-w-full tw-justify-center tw-space-y-5">
<div className=" tw-flex tw-flex-col tw-space-y-3">
<Skeleton className=" tw-w-full tw-h-52" />
<div className=" tw-w-full tw-scroll-px-20 tw-flex tw-justify-between tw-h-16">
<Skeleton className=" tw-w-1/3 tw-flex-none tw-backdrop-blur-sm tw-bg-gray-950/50" />
<Skeleton className="tw-flex-auto tw-backdrop-blur-sm tw-bg-gray-950/30" />
</div>
</div>
<div className="">
<Skeleton className="" />
<div className="">
<Skeleton className="" />
<Skeleton className="" />
</div>
</div>
</div>
);

View File

@ -38,9 +38,11 @@ export const MarkdownEditor: FC<MarkdownEditorProps> = forwardRef((props, _) =>
} }
}); });
}); });
return ( return (
<MdEditor <MdEditor
{...rest} {...rest}
style={{ height: 'auto', minHeight: '600px' }}
editorId="markdown-editor" editorId="markdown-editor"
modelValue={content} modelValue={content}
onChange={setContent} onChange={setContent}

View File

@ -1,8 +1,15 @@
'use client'; 'use client';
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; import { trim } from 'lodash';
import Link from 'next/link';
import { forwardRef, MouseEventHandler, useEffect, useImperativeHandle, useState } from 'react';
import { generateLowerString } from '@/lib/utils';
import { Details } from '../collapsible/details';
import { BackButton } from '../home/back-button';
import { MarkdownEditor } from '../markdown/editor'; import { MarkdownEditor } from '../markdown/editor';
import { Button } from '../ui/button';
import { import {
Form, Form,
FormControl, FormControl,
@ -27,10 +34,19 @@ export const PostActionForm = forwardRef<PostCreateFormRef, PostActionFormProps>
props.type === 'create' ? { type: 'create' } : { type: 'update', post: props.post }, props.type === 'create' ? { type: 'create' } : { type: 'update', post: props.post },
); );
const [body, setBody] = useState(props.type === 'create' ? '正文' : props.post.body); const [body, setBody] = useState(props.type === 'create' ? '正文' : props.post.body);
const [sulg, setSulg] = useState(props.type === 'create' ? '' : props.post.slug || '');
const PostEditorScreenHandler = usePostEditorScreenHandler(); const PostEditorScreenHandler = usePostEditorScreenHandler();
const generateTitleSlug: MouseEventHandler<HTMLAnchorElement> = (e) => {
e.preventDefault();
const title = trim(form.getValues('title'), '');
if (title) setSulg(generateLowerString(title));
};
useEffect(() => { useEffect(() => {
form.setValue('body', body); form.setValue('body', body);
}, [body]); }, [body]);
useEffect(() => {
form.setValue('slug', sulg);
}, [sulg]);
useImperativeHandle( useImperativeHandle(
ref, ref,
() => () =>
@ -38,10 +54,23 @@ export const PostActionForm = forwardRef<PostCreateFormRef, PostActionFormProps>
? { ? {
create: form.handleSubmit(submitHandler), create: form.handleSubmit(submitHandler),
} }
: {}, : {
update: form.handleSubmit(submitHandler),
},
[props.type], [props.type],
); );
return ( return (
<>
<div className=" tw-flex tw-justify-between tw-mx-5 tw-my-4 ">
<BackButton />
<Button
variant="default"
onClick={form.handleSubmit(submitHandler)}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? '更新中...' : '保存'}
</Button>
</div>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(submitHandler)} className="tw-space-y-8"> <form onSubmit={form.handleSubmit(submitHandler)} className="tw-space-y-8">
<FormField <FormField
@ -51,12 +80,17 @@ export const PostActionForm = forwardRef<PostCreateFormRef, PostActionFormProps>
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<Input placeholder="请输入标题" {...field} /> <Input
placeholder="请输入标题"
{...field}
disabled={form.formState.isSubmitting}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<Details summary="可选">
<FormField <FormField
control={form.control} control={form.control}
name="summary" name="summary"
@ -64,13 +98,92 @@ export const PostActionForm = forwardRef<PostCreateFormRef, PostActionFormProps>
<FormItem className="tw-mt-2 tw-pb-1 tw-border-b tw-border-dashed"> <FormItem className="tw-mt-2 tw-pb-1 tw-border-b tw-border-dashed">
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<Textarea placeholder="请输入文章摘要" {...field} /> <Textarea
placeholder="请输入文章摘要"
{...field}
disabled={form.formState.isSubmitting}
/>
</FormControl> </FormControl>
<FormDescription></FormDescription> <FormDescription></FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<div>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>url</FormLabel>
<FormControl>
<Input
value={sulg}
onChange={(e) => setSulg(e.target.value)}
disabled={form.formState.isSubmitting}
placeholder="请输入url"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
,访id
<Link
onClick={generateTitleSlug}
className="tw-ml-5 tw-mr-1"
href="#"
>
[]
</Link>
slug(使&apos;-&apos;,)
</FormDescription>
</FormItem>
)}
/>
</div>
<div>
<FormField
control={form.control}
name="keywords"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
disabled={form.formState.isSubmitting}
placeholder="请输入关键词"
{...field}
/>
</FormControl>
<FormDescription>
SEO
</FormDescription>
</FormItem>
)}
/>
</div>
<div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
disabled={form.formState.isSubmitting}
placeholder="请输入文章描述"
{...field}
/>
</FormControl>
<FormDescription>
SEO150
</FormDescription>
</FormItem>
)}
/>
</div>
</Details>
<FormField <FormField
control={form.control} control={form.control}
@ -82,6 +195,7 @@ export const PostActionForm = forwardRef<PostCreateFormRef, PostActionFormProps>
<MarkdownEditor <MarkdownEditor
{...field} {...field}
content={body} content={body}
disabled={form.formState.isSubmitting}
setContent={setBody} setContent={setBody}
handlers={PostEditorScreenHandler} handlers={PostEditorScreenHandler}
previewTheme="arknights" previewTheme="arknights"
@ -93,5 +207,6 @@ export const PostActionForm = forwardRef<PostCreateFormRef, PostActionFormProps>
/> />
</form> </form>
</Form> </Form>
</>
); );
}); });

View File

@ -6,18 +6,30 @@ import { useSearchParams } from 'next/navigation';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { IoMdAdd } from 'react-icons/io'; import { IoMdAdd } from 'react-icons/io';
import { useToast } from '@/hooks/use-toast';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
export const CreateButton = () => { export const CreateButton = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { toast } = useToast();
const getUrlQuery = useMemo(() => { const getUrlQuery = useMemo(() => {
const query = new URLSearchParams(searchParams.toString()).toString(); const query = new URLSearchParams(searchParams.toString()).toString();
// 保留当前分页的url查询不至于在打开创建文章后导致首页的文章列表重置分页 // 保留当前分页的url查询不至于在打开创建文章后导致首页的文章列表重置分页
return isNil(query) || query.length < 1 ? '' : `?${query}`; return isNil(query) || query.length < 1 ? '' : `?${query}`;
}, [searchParams]); }, [searchParams]);
return ( return (
<Button asChild variant="outline" className=" tw-justify-end tw-rounded-sm tw-ml-auto"> <Button
<Link href={`/post/create${getUrlQuery}`}> asChild
variant="outline"
className=" tw-justify-end tw-rounded-sm tw-ml-auto"
onClick={() =>
toast({
title: 'cesititle',
})
}
>
<Link href={`/post/edit${getUrlQuery}`}>
<IoMdAdd /> create <IoMdAdd /> create
</Link> </Link>
</Button> </Button>

View File

@ -7,6 +7,8 @@ import { AiOutlineDelete } from 'react-icons/ai';
import { deletePostItem } from '@/app/actions/post'; import { deletePostItem } from '@/app/actions/post';
import { toast } from '@/hooks/use-toast';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -26,7 +28,11 @@ export const PostDelete: FC<{ id: string }> = ({ id }) => {
try { try {
await deletePostItem(id); await deletePostItem(id);
} catch (error) { } catch (error) {
console.error(error); toast({
variant: 'destructive',
title: '遇到服务器错误,请联系管理员处理',
description: (error as Error).message,
});
} }
router.refresh(); router.refresh();
}, []); }, []);

View File

@ -17,7 +17,7 @@ export function PostEditButton({ id }: { id: string }) {
}, [searchParams]); }, [searchParams]);
return ( return (
<Button asChild className=" tw-mr-3"> <Button asChild className=" tw-mr-3">
<Link href={`/post-edit/${id}${getUrlQuery}`}> <Link href={`/post/edit/${id}${getUrlQuery}`}>
<CiEdit className=" tw-mr-2" /> <CiEdit className=" tw-mr-2" />
</Link> </Link>

View File

@ -0,0 +1,59 @@
import { isNil } from 'lodash';
import { z } from 'zod';
import { queryPostItemByIdOrSlug } from '@/app/actions/post';
export const uniqueValidator = (id?: string) => async (val?: string) => {
if (isNil(val) || !val.length) return true;
const post = await queryPostItemByIdOrSlug(val);
if (isNil(post) || post.id === id) return true;
return false;
};
export const generatePostFormValidator = (id?: string) => {
const slugUnique = uniqueValidator(id);
return z
.object({
title: z
.string()
.min(4, {
message: '标题不得少于4个字符',
})
.max(200, {
message: '标题不得超过200个字符',
}),
body: z.string().min(1, {
message: '标题不得少于1个字符',
}),
summary: z
.string()
.min(1, {
message: '摘要不得少于1个字符',
})
.max(300, {
message: '摘要不得超过300个字符',
})
.optional(),
slug: z
.string()
.max(250, {
message: 'slug不得超过250个字符',
})
.optional()
.refine(slugUnique, {
message: 'slug已存在',
}),
keywords: z
.string()
.max(200, {
message: '描述不得超过200个字符',
})
.optional(),
description: z
.string()
.max(300, {
message: '描述不得超过300个字符',
})
.optional(),
})
.strict();
};

View File

@ -1,41 +1,59 @@
'use client'; 'use client';
import { getRandomInt } from '@3rapp/utils'; import { getRandomInt } from '@3rapp/utils';
import { zodResolver } from '@hookform/resolvers/zod';
import { Post } from '@prisma/client'; import { Post } from '@prisma/client';
import { isNil } from 'lodash'; import { isNil } from 'lodash';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { DeepNonNullable } from 'utility-types';
import { createPostItem, updatePostItem } from '@/app/actions/post'; import { createPostItem, updatePostItem } from '@/app/actions/post';
import { useToast } from '@/hooks/use-toast';
import { MarkdownEditorProps } from '../markdown/type'; import { MarkdownEditorProps } from '../markdown/type';
import { useEditorModalContext } from '../modal/hooks'; import { useEditorModalContext } from '../modal/hooks';
import { generatePostFormValidator } from './form-validator';
import { PostActionFormProps, PostCreateData, PostFormData, PostUpdateData } from './types'; import { PostActionFormProps, PostCreateData, PostFormData, PostUpdateData } from './types';
export function usePostActionForm(params: PostActionFormProps) { export function usePostActionForm(params: PostActionFormProps) {
const defaultValues = useMemo(() => { const defaultValues = useMemo(() => {
if (params.type === 'create') { if (params.type === 'create') {
return { return {
title: '文章标题111', title: '',
body: '文章内容111', body: '',
summary: '212', summary: '',
} as NonNullable<PostCreateData>; slug: '',
keywords: '',
description: '',
} as DeepNonNullable<PostCreateData>;
} }
return { return {
title: params.post.title, title: params.post.title,
body: params.post.body, body: params.post.body,
summary: isNil(params.post.summary) ? '' : params.post.summary, summary: isNil(params.post.summary) ? '' : params.post.summary,
} as NonNullable<PostUpdateData>; slug: isNil(params.post.slug) ? null : params.post.slug,
keywords: isNil(params.post.keywords) ? '' : params.post.keywords,
description: isNil(params.post.description) ? '' : params.post.description,
} as DeepNonNullable<PostUpdateData>;
}, [params.type]); }, [params.type]);
return useForm<NonNullable<PostFormData>>({ defaultValues }); return useForm<DeepNonNullable<PostFormData>>({
defaultValues,
mode: 'all',
resolver: zodResolver(
generatePostFormValidator(params.type === 'update' ? params.post.id : undefined),
),
});
} }
export function usePostFormSubmitHandler(params: PostActionFormProps) { export function usePostFormSubmitHandler(params: PostActionFormProps) {
const router = useRouter(); const router = useRouter();
const { toast } = useToast();
const submitHandle = async (data: PostFormData) => { const submitHandle = async (data: PostFormData) => {
let post: Post | null; let post: Post | null;
for (const key of Object.keys(data) as Array<keyof PostFormData>) { for (const key of Object.keys(data) as Array<keyof PostFormData>) {
@ -47,8 +65,6 @@ export function usePostFormSubmitHandler(params: PostActionFormProps) {
if (params.type === 'update') { if (params.type === 'update') {
post = await updatePostItem(params.post.id, data); post = await updatePostItem(params.post.id, data);
} else { } else {
console.log(data, 'create data');
post = await createPostItem({ post = await createPostItem({
thumb: `/uploads/thumb/post-${getRandomInt(1, 8)}.png`, thumb: `/uploads/thumb/post-${getRandomInt(1, 8)}.png`,
...data, ...data,
@ -58,7 +74,11 @@ export function usePostFormSubmitHandler(params: PostActionFormProps) {
// 注意,这里不要用push,防止在详情页后退后返回到创建或编辑页面的弹出框 // 注意,这里不要用push,防止在详情页后退后返回到创建或编辑页面的弹出框
if (!isNil(post)) router.replace(`/post/${post.id}`); if (!isNil(post)) router.replace(`/post/${post.id}`);
} catch (error) { } catch (error) {
console.error(error); toast({
variant: 'destructive',
title: '遇到服务器错误,请联系管理员处理',
description: (error as Error).message,
});
} }
}; };
return submitHandle; return submitHandle;

View File

@ -1,5 +1,8 @@
import { Post, Prisma } from '@prisma/client'; import { Post, Prisma } from '@prisma/client';
import { BaseSyntheticEvent } from 'react'; import { BaseSyntheticEvent } from 'react';
import { z } from 'zod';
import { generatePostFormValidator } from './form-validator';
export interface PostCreateFormProps { export interface PostCreateFormProps {
type: 'create'; type: 'create';
@ -11,7 +14,7 @@ export interface PostUpdateFormProps {
export type PostActionFormProps = PostCreateFormProps | PostUpdateFormProps; export type PostActionFormProps = PostCreateFormProps | PostUpdateFormProps;
export type PostCreateData = Prisma.PostCreateInput; export type PostCreateData = Prisma.PostCreateInput;
export type PostUpdateData = Partial<Omit<Post, 'id'>> & Pick<Post, 'id'>; export type PostUpdateData = Partial<Omit<Post, 'id'>> & Pick<Post, 'id'>;
export type PostFormData = PostCreateData | PostUpdateData; export type PostFormData = z.infer<ReturnType<typeof generatePostFormValidator>>;
/** /**
* Ref * Ref
*/ */

View File

@ -0,0 +1,53 @@
'use client';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDownIcon } from '@radix-ui/react-icons';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn('tw-border-b', className)} {...props} />
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="tw-flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'tw-flex tw-flex-1 tw-items-center tw-justify-between tw-py-4 tw-text-sm tw-font-medium tw-transition-all hover:tw-underline [&[data-state=open]>svg]:tw-rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="tw-h-4 tw-w-4 tw-shrink-0 tw-text-muted-foreground tw-transition-transform tw-duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="tw-overflow-hidden tw-text-sm data-[state=closed]:tw-animate-accordion-up data-[state=open]:tw-animate-accordion-down"
{...props}
>
<div className={cn('tw-pb-4 tw-pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -0,0 +1,69 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'tw-rounded-xl tw-border tw-bg-card tw-text-card-foreground tw-shadow',
className,
)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('tw-flex tw-flex-col tw-space-y-1.5 tw-p-6', className)}
{...props}
/>
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('tw-font-semibold tw-leading-none tw-tracking-tight', className)}
{...props}
>
{props.children}
</h3>
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('tw-text-sm tw-text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('tw-p-6 tw-pt-0', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('tw-flex tw-items-center tw-p-6 tw-pt-0', className)}
{...props}
/>
),
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@ -1,18 +1,18 @@
"use client" 'use client';
import * as React from "react" import * as DialogPrimitive from '@radix-ui/react-dialog';
import * as DialogPrimitive from "@radix-ui/react-dialog" import { Cross2Icon } from '@radix-ui/react-icons';
import { Cross2Icon } from "@radix-ui/react-icons" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef< const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
@ -21,13 +21,13 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"tw-fixed tw-inset-0 tw-z-50 tw-bg-black/80 tw- data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=open]:tw-fade-in-0", 'tw-fixed tw-inset-0 tw-z-50 tw-bg-black/80 tw- data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=open]:tw-fade-in-0',
className className,
)} )}
{...props} {...props}
/> />
)) ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
@ -38,8 +38,8 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"tw-fixed tw-left-[50%] tw-top-[50%] tw-z-50 tw-grid tw-w-full tw-max-w-lg tw-translate-x-[-50%] tw-translate-y-[-50%] tw-gap-4 tw-border tw-bg-background tw-p-6 tw-shadow-lg tw-duration-200 data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=open]:tw-fade-in-0 data-[state=closed]:tw-zoom-out-95 data-[state=open]:tw-zoom-in-95 data-[state=closed]:tw-slide-out-to-left-1/2 data-[state=closed]:tw-slide-out-to-top-[48%] data-[state=open]:tw-slide-in-from-left-1/2 data-[state=open]:tw-slide-in-from-top-[48%] sm:tw-rounded-lg", 'tw-fixed tw-left-[50%] tw-top-[50%] tw-z-50 tw-grid tw-w-full tw-max-w-lg tw-translate-x-[-50%] tw-translate-y-[-50%] tw-gap-4 tw-border tw-bg-background tw-p-6 tw-shadow-lg tw-duration-200 data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=open]:tw-fade-in-0 data-[state=closed]:tw-zoom-out-95 data-[state=open]:tw-zoom-in-95 data-[state=closed]:tw-slide-out-to-left-1/2 data-[state=closed]:tw-slide-out-to-top-[48%] data-[state=open]:tw-slide-in-from-left-1/2 data-[state=open]:tw-slide-in-from-top-[48%] sm:tw-rounded-lg',
className className,
)} )}
{...props} {...props}
> >
@ -50,36 +50,30 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)) ));
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"tw-flex tw-flex-col tw-space-y-1.5 tw-text-center sm:tw-text-left", 'tw-flex tw-flex-col tw-space-y-1.5 tw-text-center sm:tw-text-left',
className className,
)} )}
{...props} {...props}
/> />
) );
DialogHeader.displayName = "DialogHeader" DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"tw-flex tw-flex-col-reverse sm:tw-flex-row sm:tw-justify-end sm:tw-space-x-2", 'tw-flex tw-flex-col-reverse sm:tw-flex-row sm:tw-justify-end sm:tw-space-x-2',
className className,
)} )}
{...props} {...props}
/> />
) );
DialogFooter.displayName = "DialogFooter" DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>, React.ElementRef<typeof DialogPrimitive.Title>,
@ -87,14 +81,11 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn( className={cn('tw-text-lg tw-font-semibold tw-leading-none tw-tracking-tight', className)}
"tw-text-lg tw-font-semibold tw-leading-none tw-tracking-tight",
className
)}
{...props} {...props}
/> />
)) ));
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef< const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>, React.ElementRef<typeof DialogPrimitive.Description>,
@ -102,11 +93,11 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={cn("tw-text-sm tw-text-muted-foreground", className)} className={cn('tw-text-sm tw-text-muted-foreground', className)}
{...props} {...props}
/> />
)) ));
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { export {
Dialog, Dialog,
@ -119,4 +110,4 @@ export {
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} };

View File

@ -1,9 +1,8 @@
import * as React from "react" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
export interface InputProps export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
@ -11,15 +10,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( className={cn(
"tw-flex tw-h-9 tw-w-full tw-rounded-md tw-border tw-border-input tw-bg-transparent tw-px-3 tw-py-1 tw-text-sm tw-shadow-sm tw-transition-colors file:tw-border-0 file:tw-bg-transparent file:tw-text-sm file:tw-font-medium file:tw-text-foreground placeholder:tw-text-muted-foreground focus-visible:tw-outline-none focus-visible:tw-ring-1 focus-visible:tw-ring-ring disabled:tw-cursor-not-allowed disabled:tw-opacity-50", 'tw-flex tw-h-9 tw-w-full tw-rounded-md tw-border tw-border-input tw-bg-transparent tw-px-3 tw-py-1 tw-text-sm tw-shadow-sm tw-transition-colors file:tw-border-0 file:tw-bg-transparent file:tw-text-sm file:tw-font-medium file:tw-text-foreground placeholder:tw-text-muted-foreground focus-visible:tw-outline-none focus-visible:tw-ring-1 focus-visible:tw-ring-ring disabled:tw-cursor-not-allowed disabled:tw-opacity-50',
className className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Input.displayName = "Input" Input.displayName = 'Input';
export { Input } export { Input };

View File

@ -1,26 +1,21 @@
"use client" 'use client';
import * as React from "react" import * as LabelPrimitive from '@radix-ui/react-label';
import * as LabelPrimitive from "@radix-ui/react-label" import { cva, type VariantProps } from 'class-variance-authority';
import { cva, type VariantProps } from "class-variance-authority" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const labelVariants = cva( const labelVariants = cva(
"tw-text-sm tw-font-medium tw-leading-none peer-disabled:tw-cursor-not-allowed peer-disabled:tw-opacity-70" 'tw-text-sm tw-font-medium tw-leading-none peer-disabled:tw-cursor-not-allowed peer-disabled:tw-opacity-70',
) );
const Label = React.forwardRef< const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<LabelPrimitive.Root <LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
ref={ref} ));
className={cn(labelVariants(), className)} Label.displayName = LabelPrimitive.Root.displayName;
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label } export { Label };

View File

@ -0,0 +1,15 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('tw-animate-pulse tw-rounded-md tw-bg-primary/10', className)}
{...props}
>
{' '}
jiazaizhong
</div>
);
}
export { Skeleton };

View File

@ -1,24 +1,23 @@
import * as React from "react" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
export interface TextareaProps export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {
return ( return (
<textarea <textarea
className={cn( className={cn(
"tw-flex tw-min-h-[60px] tw-w-full tw-rounded-md tw-border tw-border-input tw-bg-transparent tw-px-3 tw-py-2 tw-text-sm tw-shadow-sm placeholder:tw-text-muted-foreground focus-visible:tw-outline-none focus-visible:tw-ring-1 focus-visible:tw-ring-ring disabled:tw-cursor-not-allowed disabled:tw-opacity-50", 'tw-flex tw-min-h-[60px] tw-w-full tw-rounded-md tw-border tw-border-input tw-bg-transparent tw-px-3 tw-py-2 tw-text-sm tw-shadow-sm placeholder:tw-text-muted-foreground focus-visible:tw-outline-none focus-visible:tw-ring-1 focus-visible:tw-ring-ring disabled:tw-cursor-not-allowed disabled:tw-opacity-50',
className className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Textarea.displayName = "Textarea" Textarea.displayName = 'Textarea';
export { Textarea } export { Textarea };

View File

@ -0,0 +1,128 @@
'use client';
import { Cross2Icon } from '@radix-ui/react-icons';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'tw-fixed tw-top-0 tw-z-[100] tw-flex tw-max-h-screen tw-w-full tw-flex-col-reverse tw-p-4 sm:tw-bottom-0 sm:tw-right-0 sm:tw-top-auto sm:tw-flex-col md:tw-max-w-[420px]',
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'tw-group tw-pointer-events-auto tw-relative tw-flex tw-w-full tw-items-center tw-justify-between tw-space-x-2 tw-overflow-hidden tw-rounded-md tw-border tw-p-4 tw-pr-6 tw-shadow-lg tw-transition-all data-[swipe=cancel]:tw-translate-x-0 data-[swipe=end]:tw-translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:tw-translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:tw-transition-none data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[swipe=end]:tw-animate-out data-[state=closed]:tw-fade-out-80 data-[state=closed]:tw-slide-out-to-right-full data-[state=open]:tw-slide-in-from-top-full data-[state=open]:sm:tw-slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'tw-border tw-bg-background tw-text-foreground',
destructive:
'tw-destructive tw-group tw-border-destructive tw-bg-destructive tw-text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'tw-inline-flex tw-h-8 tw-shrink-0 tw-items-center tw-justify-center tw-rounded-md tw-border tw-bg-transparent tw-px-3 tw-text-sm tw-font-medium tw-transition-colors hover:tw-bg-secondary focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-ring disabled:tw-pointer-events-none disabled:tw-opacity-50 group-[.destructive]:tw-border-muted/40 group-[.destructive]:hover:tw-border-destructive/30 group-[.destructive]:hover:tw-bg-destructive group-[.destructive]:hover:tw-text-destructive-foreground group-[.destructive]:focus:tw-ring-destructive',
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'tw-absolute tw-right-1 tw-top-1 tw-rounded-md tw-p-1 tw-text-foreground/50 tw-opacity-0 tw-transition-opacity hover:tw-text-foreground focus:tw-opacity-100 focus:tw-outline-none focus:tw-ring-1 group-hover:tw-opacity-100 group-[.destructive]:tw-text-red-300 group-[.destructive]:hover:tw-text-red-50 group-[.destructive]:focus:tw-ring-red-400 group-[.destructive]:focus:tw-ring-offset-red-600',
className,
)}
toast-close=""
{...props}
>
<Cross2Icon className="tw-h-4 tw-w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('tw-text-sm tw-font-semibold [&+div]:tw-text-xs', className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('tw-text-sm tw-opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@ -0,0 +1,33 @@
'use client';
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/app/_components/ui/toast';
import { useToast } from '@/hooks/use-toast';
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(({ id, title, description, action, ...props }) => {
return (
<Toast key={id} {...props}>
<div className="tw-grid tw-gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@ -0,0 +1,30 @@
'use server';
import { getUserByEmail } from '@/data/user';
import { getValidatorByToken } from '@/data/validator';
import db from '@/lib/db/client';
export const newValidator = async (token: string) => {
const existToken = await getValidatorByToken(token);
if (!existToken) {
return { error: 'Token does not exist!' };
}
const hasExpired = new Date(existToken.expires) < new Date();
if (hasExpired) {
return { error: 'Token has expired!' };
}
const existingUser = await getUserByEmail(existToken.email);
if (!existingUser) {
return { error: 'Email does not exist' };
}
await db.user.update({
where: { id: existingUser.id },
data: {
emailVerified: new Date(),
},
});
await db.verificationToken.delete({
where: { id: existToken.id },
});
return { success: 'Email verified!' };
};

View File

@ -5,10 +5,12 @@ import { isNil } from 'lodash';
import { revalidateTag } from 'next/cache'; import { revalidateTag } from 'next/cache';
import paginateExt from 'prisma-paginate';
import db from '@/lib/db/client'; import db from '@/lib/db/client';
import { PaginateOptions, PaginateReturn } from '@/lib/db/types'; import { PaginateOptions, PaginateReturn } from '@/lib/db/types';
import { paginateTransform } from '@/lib/db/utils'; import { paginateTransform } from '@/lib/db/utils';
import { redis } from '@/lib/redis/client';
/** /**
* *
* @param options * @param options
@ -17,7 +19,7 @@ export const queryPostPaginate = async (
options?: PaginateOptions, options?: PaginateOptions,
): Promise<PaginateReturn<Post>> => { ): Promise<PaginateReturn<Post>> => {
// 此处使用倒序,以便新增的文章可以排在最前面 // 此处使用倒序,以便新增的文章可以排在最前面
const posts = await db.post.paginate({ const posts = await db.$extends(paginateExt).post.paginate({
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
page: 1, page: 1,
limit: 8, limit: 8,
@ -39,8 +41,9 @@ export const queryPostTotalPages = async (limit = 8): Promise<number> => {
* ID * ID
* @param id * @param id
*/ */
export const queryPostItemById = async (id: string): Promise<Post | null> => { export const queryPostItemByIdOrSlug = async (id: string): Promise<Post | null> => {
const item = await db.post.findUniqueOrThrow({ where: { id } }); // throw new Error('数据加载错误,请稍后重试!');
const item = await db.post.findFirst({ where: { OR: [{ id }, { slug: id }] } });
return item; return item;
}; };
@ -50,6 +53,7 @@ export const queryPostItemById = async (id: string): Promise<Post | null> => {
*/ */
export const createPostItem = async (data: Prisma.PostCreateInput): Promise<Post> => { export const createPostItem = async (data: Prisma.PostCreateInput): Promise<Post> => {
const item = await db.post.create({ data }); const item = await db.post.create({ data });
await redis.set(item.id, item.slug);
revalidateTag('posts'); revalidateTag('posts');
return item; return item;
}; };
@ -61,8 +65,9 @@ export const createPostItem = async (data: Prisma.PostCreateInput): Promise<Post
*/ */
export const updatePostItem = async ( export const updatePostItem = async (
id: string, id: string,
data: Partial<Omit<Prisma.PostCreateInput, 'id'>>, data: Partial<Omit<Post, 'id'>>,
): Promise<Post | null> => { ): Promise<Post> => {
await redis.set(id, data.slug);
const item = await db.post.update({ where: { id }, data }); const item = await db.post.update({ where: { id }, data });
revalidateTag('posts'); revalidateTag('posts');
return item; return item;
@ -80,6 +85,7 @@ export const deletePostItem = async (id: string): Promise<null> => {
return null; return null;
} }
await db.post.delete({ where: { id } }); await db.post.delete({ where: { id } });
await redis.del(id);
revalidateTag('posts'); revalidateTag('posts');
console.log('文章删除成功'); console.log('文章删除成功');

View File

@ -0,0 +1,28 @@
import { v7 } from 'uuid';
import db from '@/lib/db/client';
export const generateVerificationToken = async (email: string) => {
const token = v7();
const expires = new Date(Date.now() + 1000 * 60 * 60 * 24); // 24 hours
const existingEmail = await getVerificationTokenByEmail(email);
if (existingEmail) {
await db.verificationToken.delete({ where: { id: existingEmail.id } });
}
const verificationToken = await db.verificationToken.create({
data: {
email,
token,
expires,
},
});
return verificationToken;
};
export const getVerificationTokenByEmail = async (email: string) => {
try {
const token = await db.verificationToken.findFirst({ where: { email } });
return token;
} catch (error) {
return null;
}
};

View File

@ -0,0 +1,57 @@
'use server';
import bcrypt from 'bcrypt';
import { z } from 'zod';
import { sendVerificationEmail } from '@/data/email';
import db from '@/lib/db/client';
import { registerSchema } from '../_components/auth/user-form-validator';
import { generateVerificationToken } from './token';
export const register = async (data: z.infer<typeof registerSchema>) => {
const validateFields = await registerSchema.safeParseAsync(data);
if (!validateFields.success) {
return {
error: validateFields.error.message,
status: 400,
};
}
const { email, password, username } = validateFields.data;
const hashpassword = await bcrypt.hash(password, 10);
const existingEmail = await db.user.findUnique({ where: { email } });
const existingUsername = await db.user.findFirst({ where: { name: username } });
if (existingEmail || existingUsername) {
return {
error: 'Email or username already exists',
status: 400,
};
}
const user = await db.user.create({
data: {
email,
password: hashpassword,
name: username,
},
});
const verificationToken = await generateVerificationToken(email);
await sendVerificationEmail({ email, token: verificationToken.token });
if (!user) {
return {
error: 'Error creating user',
status: 500,
};
}
return {
seccess: 'User created successfully',
status: 201,
};
};
export const getUser = async ({ email, password }: { email: string; password: string }) => {
const user = await db.user.findUnique({ where: { email, password } });
return user;
};

View File

@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';
import { auth } from '@/auth';
export const GET = auth(function GET(req) {
if (req.auth) return NextResponse.json(req.auth);
return NextResponse.json({ message: 'Not authenticated' }, { status: 401 });
});

View File

@ -0,0 +1,3 @@
import { handlers } from '@/auth';
export const { GET, POST } = handlers;

View File

@ -0,0 +1,26 @@
'use client';
import { FC } from 'react';
import { Header } from './_components/header';
import { Button } from './_components/ui/button';
const ErrorBoundaryPage: FC<{ error: Error & { digest?: string }; reset: () => void }> = ({
error,
reset,
}) => (
<div className="tw-app-layout">
<Header />
<div className="tw-page-container">
<div className="tw-page-blank tw-flex tw-flex-col tw-space-y-4">
<h2>!...</h2>
<p>: {error.message} .</p>
<p>
<b>,.,,</b>
</p>
<Button onClick={reset}></Button>
</div>
</div>
</div>
);
export default ErrorBoundaryPage;

View File

@ -0,0 +1,26 @@
import Link from 'next/link';
import { FC } from 'react';
import { Header } from './_components/header';
const NotFoundPage: FC = () => (
<div className="tw-app-layout">
<Header />
<div className="tw-page-container">
<div className="tw-page-blank">
<h2>Not Found</h2>
<span className="tw-mx-3">|</span>
<p>
404, &nbsp;
<b>
<Link className="tw-animate-decoration" href="/">
</Link>
</b>
</p>
</div>
</div>
</div>
);
export default NotFoundPage;

View File

@ -62,12 +62,20 @@ const config = {
}, },
keyframes: { keyframes: {
'accordion-down': { 'accordion-down': {
from: { height: '0' }, from: {
to: { height: 'var(--radix-accordion-content-height)' }, height: '0',
},
to: {
height: 'var(--radix-accordion-content-height)',
},
}, },
'accordion-up': { 'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' }, from: {
to: { height: '0' }, height: 'var(--radix-accordion-content-height)',
},
to: {
height: '0',
},
}, },
}, },
animation: { animation: {

View File

@ -0,0 +1,29 @@
import bcrypt from 'bcryptjs';
import type { NextAuthConfig } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { getUserByEmail } from './data/user';
import { LoginSchema } from './lib/validations /auth';
export default {
providers: [
Credentials({
async authorize(credentials) {
const validatedFields = LoginSchema.safeParse(credentials);
if (validatedFields.success) {
const { email, password } = validatedFields.data;
const user = await getUserByEmail(email);
if (!user || !user.password) return null;
const passwordsMatch = await bcrypt.compare(password, user.password);
if (passwordsMatch) return user;
}
return null;
},
}),
],
} satisfies NextAuthConfig;

View File

@ -0,0 +1,95 @@
import { PrismaAdapter } from '@auth/prisma-adapter';
import { UserRole } from '@prisma/client';
import NextAuth from 'next-auth';
import authConfig from '@/auth.config';
import { getAccountByUserId } from '@/data/account';
import { getTwoFactorConfirmationByUserId } from '@/data/two-factor-confirmation';
import { getUserById } from '@/data/user';
import db from './lib/db/client';
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
pages: {
signIn: '/auth/login',
error: '/auth/error',
},
events: {
async linkAccount({ user }) {
await db.user.update({
where: { id: user.id },
data: { emailVerified: new Date() },
});
},
},
callbacks: {
async signIn({ user, account }) {
// Allow OAuth without email verification
if (account?.provider !== 'credentials') return true;
const existingUser = await getUserById(user.id);
// Prevent sign in without email verification
if (!existingUser?.emailVerified) return false;
if (existingUser.isTwoFactorEnabled) {
const twoFactorConfirmation = await getTwoFactorConfirmationByUserId(
existingUser.id,
);
if (!twoFactorConfirmation) return false;
// Delete two factor confirmation for next sign in
await db.twoFactorConfirmation.delete({
where: { id: twoFactorConfirmation.id },
});
}
return true;
},
async session({ token, session }) {
if (token.sub && session.user) {
session.user.id = token.sub;
}
if (token.role && session.user) {
session.user.role = token.role as UserRole;
}
if (session.user) {
session.user.isTwoFactorEnabled = token.isTwoFactorEnabled as boolean;
session.user.name = token.name;
session.user.email = token.email;
session.user.isOAuth = token.isOAuth as boolean;
}
return session;
},
async jwt({ token }) {
if (!token.sub) return token;
const existingUser = await getUserById(token.sub);
if (!existingUser) return token;
const existingAccount = await getAccountByUserId(existingUser.id);
token.isOAuth = !!existingAccount;
token.name = existingUser.name;
token.email = existingUser.email;
token.role = existingUser.role;
token.isTwoFactorEnabled = existingUser.isTwoFactorEnabled;
return token;
},
},
adapter: PrismaAdapter(db),
session: { strategy: 'jwt' },
...authConfig,
});

View File

@ -0,0 +1,10 @@
import db from '@/lib/db/client';
export const getAccountByUserId = async (userId: string) => {
try {
return await db.account.findFirst({ where: { userId } });
} catch (error) {
console.log(error);
return null;
}
};

View File

@ -0,0 +1,30 @@
// pnpm install nodemailer --save @types/nodemailer
import { transporter } from '@/lib/db/resend';
// qq mail service zrruwguzxhbebija
const domain = process.env.NEXT_PUBLIC_APP_URL;
interface Emailparams {
email: string;
token: string;
}
export const sendVerificationEmail = async (params: Emailparams) => {
const confirmLink = `${domain}/auth/new-verification?token=${params.token}`;
await transporter.sendMail({
from: '450255477@qq.com',
to: params.email,
subject: 'Confirm your email',
html: `<p>Click <a href="${confirmLink}">here</a> to confirm email.</p>`,
});
};
export const sendPasswordResetEmail = async (params: Emailparams) => {
const resetLink = `${domain}/auth/new-password?token=${params.token}`;
await transporter.sendMail({
from: '450255477@qq.com',
to: params.email,
subject: 'Reset your password',
html: `<p>Click <a href="${resetLink}">here</a> to reset password.</p>`,
});
};

View File

@ -0,0 +1,10 @@
import db from '@/lib/db/client';
export const getTwoFactorConfirmationByUserId = async (userId: string) => {
try {
return await db.twoFactorConfirmation.findFirst({ where: { userId } });
} catch (error) {
console.log(error);
return null;
}
};

View File

@ -0,0 +1,18 @@
import db from '@/lib/db/client';
export const getUserByEmail = async (email: string) => {
try {
return await db.user.findUnique({ where: { email } });
} catch (error) {
console.log(error);
return null;
}
};
export const getUserById = async (id: string) => {
try {
return await db.user.findUnique({ where: { id } });
} catch (error) {
console.log(error);
return null;
}
};

View File

@ -0,0 +1,24 @@
'use server';
import { VerificationToken } from '@prisma/client';
import db from '@/lib/db/client';
export const getValidatorByToken = async (token: string): Promise<VerificationToken> => {
try {
const validator = await db.verificationToken.findFirst({ where: { token } });
return validator;
} catch (error) {
console.log(error);
return null;
}
};
export const getValidatorByEmail = async (email: string): Promise<VerificationToken> => {
try {
const validator = await db.verificationToken.findFirst({ where: { email } });
return validator;
} catch (error) {
console.log(error);
return null;
}
};

View File

@ -0,0 +1,13 @@
/*
Warnings:
- A unique constraint covering the columns `[slug]` on the table `posts` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE `posts` ADD COLUMN `description` VARCHAR(191) NULL,
ADD COLUMN `keywords` VARCHAR(191) NULL,
ADD COLUMN `slug` VARCHAR(191) NULL;
-- CreateIndex
CREATE UNIQUE INDEX `posts_slug_key` ON `posts`(`slug`);

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `posts` MODIFY `description` TEXT NULL,
MODIFY `slug` VARCHAR(255) NULL;

View File

@ -0,0 +1,18 @@
-- AlterTable
ALTER TABLE `posts` MODIFY `keywords` VARCHAR(255) NULL;
-- CreateTable
CREATE TABLE `users` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`emailVerified` DATETIME(3) NULL,
`image` VARCHAR(191) NULL,
`Post` VARCHAR(191) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `users_name_key`(`name`),
UNIQUE INDEX `users_email_key`(`email`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `password` to the `users` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE `users` ADD COLUMN `password` VARCHAR(191) NOT NULL;

View File

@ -0,0 +1,14 @@
/*
Warnings:
- You are about to drop the column `Post` on the `users` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE `posts` ADD COLUMN `userId` VARCHAR(255) NOT NULL DEFAULT 'cm1wyshjn000013e4ek229mpu';
-- AlterTable
ALTER TABLE `users` DROP COLUMN `Post`;
-- AddForeignKey
ALTER TABLE `posts` ADD CONSTRAINT `posts_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `posts` ALTER COLUMN `userId` DROP DEFAULT;

View File

@ -0,0 +1,99 @@
/*
Warnings:
- You are about to drop the column `created_at` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `emailVerified` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `password` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `updated_at` on the `users` table. All the data in the column will be lost.
- A unique constraint covering the columns `[username]` on the table `users` will be added. If there are existing duplicate values, this will fail.
- Added the required column `updatedAt` to the `users` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX `users_name_key` ON `users`;
-- AlterTable
ALTER TABLE `users` DROP COLUMN `created_at`,
DROP COLUMN `emailVerified`,
DROP COLUMN `password`,
DROP COLUMN `updated_at`,
ADD COLUMN `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
ADD COLUMN `email_verified` DATETIME(3) NULL,
ADD COLUMN `updatedAt` DATETIME(3) NOT NULL,
ADD COLUMN `username` VARCHAR(191) NULL,
MODIFY `name` VARCHAR(191) NULL,
MODIFY `email` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `Account` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`type` VARCHAR(191) NOT NULL,
`provider` VARCHAR(191) NOT NULL,
`provider_account_id` VARCHAR(191) NOT NULL,
`refresh_token` TEXT NULL,
`access_token` TEXT NULL,
`expires_at` INTEGER NULL,
`token_type` VARCHAR(191) NULL,
`scope` VARCHAR(191) NULL,
`id_token` TEXT NULL,
`session_state` VARCHAR(191) NULL,
`refresh_token_expires_in` INTEGER NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Account_user_id_key`(`user_id`),
INDEX `Account_user_id_idx`(`user_id`),
UNIQUE INDEX `Account_provider_provider_account_id_key`(`provider`, `provider_account_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Authenticator` (
`credentialID` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`providerAccountId` VARCHAR(191) NOT NULL,
`credentialPublicKey` VARCHAR(191) NOT NULL,
`counter` INTEGER NOT NULL,
`credentialDeviceType` VARCHAR(191) NOT NULL,
`credentialBackedUp` BOOLEAN NOT NULL,
`transports` VARCHAR(191) NULL,
UNIQUE INDEX `Authenticator_credentialID_key`(`credentialID`),
PRIMARY KEY (`userId`, `credentialID`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sessions` (
`id` VARCHAR(191) NOT NULL,
`session_token` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`expires` DATETIME(3) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `sessions_session_token_key`(`session_token`),
INDEX `sessions_user_id_idx`(`user_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `verification_tokens` (
`identifier` VARCHAR(191) NOT NULL,
`token` VARCHAR(191) NOT NULL,
`expires` DATETIME(3) NOT NULL,
UNIQUE INDEX `verification_tokens_identifier_token_key`(`identifier`, `token`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateIndex
CREATE UNIQUE INDEX `users_username_key` ON `users`(`username`);
-- AddForeignKey
ALTER TABLE `Account` ADD CONSTRAINT `Account_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Authenticator` ADD CONSTRAINT `Authenticator_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sessions` ADD CONSTRAINT `sessions_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `users` ADD COLUMN `hashpassword` VARCHAR(191) NULL;

View File

@ -0,0 +1,158 @@
/*
Warnings:
- The primary key for the `users` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `createdAt` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `email_verified` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `hashpassword` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `id` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `updatedAt` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `username` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `identifier` on the `verification_tokens` table. All the data in the column will be lost.
- You are about to drop the `Account` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Authenticator` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `posts` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `sessions` table. If the table is not empty, all the data it contains will be lost.
- A unique constraint covering the columns `[token]` on the table `verification_tokens` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[email,token]` on the table `verification_tokens` will be added. If there are existing duplicate values, this will fail.
- The required column `_id` was added to the `users` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
- The required column `_id` was added to the `verification_tokens` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
- Added the required column `email` to the `verification_tokens` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE `Account` DROP FOREIGN KEY `Account_user_id_fkey`;
-- DropForeignKey
ALTER TABLE `Authenticator` DROP FOREIGN KEY `Authenticator_userId_fkey`;
-- DropForeignKey
ALTER TABLE `posts` DROP FOREIGN KEY `posts_userId_fkey`;
-- DropForeignKey
ALTER TABLE `sessions` DROP FOREIGN KEY `sessions_user_id_fkey`;
-- DropIndex
DROP INDEX `users_username_key` ON `users`;
-- DropIndex
DROP INDEX `verification_tokens_identifier_token_key` ON `verification_tokens`;
-- AlterTable
ALTER TABLE `users` DROP PRIMARY KEY,
DROP COLUMN `createdAt`,
DROP COLUMN `email_verified`,
DROP COLUMN `hashpassword`,
DROP COLUMN `id`,
DROP COLUMN `updatedAt`,
DROP COLUMN `username`,
ADD COLUMN `_id` VARCHAR(191) NOT NULL,
ADD COLUMN `emailVerified` DATETIME(3) NULL,
ADD COLUMN `isTwoFactorEnabled` BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN `password` VARCHAR(191) NULL,
ADD COLUMN `role` ENUM('ADMIN', 'USER') NOT NULL DEFAULT 'USER',
ADD COLUMN `twoFactorConfirmationId` VARCHAR(191) NULL,
ADD PRIMARY KEY (`_id`);
-- AlterTable
ALTER TABLE `verification_tokens` DROP COLUMN `identifier`,
ADD COLUMN `_id` VARCHAR(191) NOT NULL,
ADD COLUMN `email` VARCHAR(191) NOT NULL,
ADD PRIMARY KEY (`_id`);
-- DropTable
DROP TABLE `Account`;
-- DropTable
DROP TABLE `Authenticator`;
-- DropTable
DROP TABLE `posts`;
-- DropTable
DROP TABLE `sessions`;
-- CreateTable
CREATE TABLE `accounts` (
`_id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`type` VARCHAR(191) NOT NULL,
`provider` VARCHAR(191) NOT NULL,
`providerAccountId` VARCHAR(191) NOT NULL,
`refresh_token` VARCHAR(191) NULL,
`access_token` VARCHAR(191) NULL,
`expires_at` INTEGER NULL,
`token_type` VARCHAR(191) NULL,
`scope` VARCHAR(191) NULL,
`id_token` VARCHAR(191) NULL,
`session_state` VARCHAR(191) NULL,
UNIQUE INDEX `accounts_provider_providerAccountId_key`(`provider`, `providerAccountId`),
PRIMARY KEY (`_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `password_reset_tokens` (
`_id` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`token` VARCHAR(191) NOT NULL,
`expires` DATETIME(3) NOT NULL,
UNIQUE INDEX `password_reset_tokens_token_key`(`token`),
UNIQUE INDEX `password_reset_tokens_email_token_key`(`email`, `token`),
PRIMARY KEY (`_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `post` (
`id` VARCHAR(191) NOT NULL,
`thumb` VARCHAR(191) NOT NULL,
`title` TEXT NOT NULL,
`summary` TEXT NOT NULL,
`body` TEXT NOT NULL,
`slug` VARCHAR(255) NULL,
`keywords` VARCHAR(255) NULL,
`description` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
`userId` VARCHAR(255) NOT NULL,
UNIQUE INDEX `post_slug_key`(`slug`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `two_factor_tokens` (
`_id` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`token` VARCHAR(191) NOT NULL,
`expires` DATETIME(3) NOT NULL,
UNIQUE INDEX `two_factor_tokens_token_key`(`token`),
UNIQUE INDEX `two_factor_tokens_email_token_key`(`email`, `token`),
PRIMARY KEY (`_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `twp_factor_confirmation` (
`_id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
UNIQUE INDEX `twp_factor_confirmation_userId_key`(`userId`),
PRIMARY KEY (`_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateIndex
CREATE UNIQUE INDEX `verification_tokens_token_key` ON `verification_tokens`(`token`);
-- CreateIndex
CREATE UNIQUE INDEX `verification_tokens_email_token_key` ON `verification_tokens`(`email`, `token`);
-- AddForeignKey
ALTER TABLE `accounts` ADD CONSTRAINT `accounts_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`_id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `post` ADD CONSTRAINT `post_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`_id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `twp_factor_confirmation` ADD CONSTRAINT `twp_factor_confirmation_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`_id`) ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,19 @@
model Account {
id String @id @default(uuid()) @map("_id")
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}

View File

@ -0,0 +1,9 @@
model PasswordResetToken {
id String @id @default(uuid()) @map("_id")
email String
token String @unique
expires DateTime
@@unique([email, token])
@@map("password_reset_tokens")
}

View File

@ -4,8 +4,13 @@ model Post {
title String @db.Text title String @db.Text
summary String @db.Text summary String @db.Text
body String @db.Text body String @db.Text
slug String? @unique @db.VarChar(255)
keywords String? @db.VarChar(255)
description String? @db.Text
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
userId String @db.VarChar(255)
@@map("posts") @@map("post")
} }

View File

@ -0,0 +1,9 @@
model TwoFactorToken {
id String @id @default(uuid()) @map("_id")
email String
token String @unique
expires DateTime
@@unique([email, token])
@@map("two_factor_tokens")
}

View File

@ -0,0 +1,9 @@
model TwoFactorConfirmation {
id String @id @default(uuid()) @map("_id")
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId])
@@map("twp_factor_confirmation")
}

View File

@ -0,0 +1,21 @@
enum UserRole {
ADMIN
USER
}
model User {
id String @id @default(uuid()) @map("_id")
name String?
email String? @unique
emailVerified DateTime?
image String?
password String?
role UserRole @default(USER)
accounts Account[]
isTwoFactorEnabled Boolean @default(false)
twoFactorConfirmation TwoFactorConfirmation?
twoFactorConfirmationId String?
Post Post[]
@@map("users")
}

View File

@ -0,0 +1,9 @@
model VerificationToken {
id String @id @default(uuid()) @map("_id")
email String
token String @unique
expires DateTime
@@unique([email, token])
@@map("verification_tokens")
}

View File

@ -11,6 +11,8 @@ export const createPostData = async () => {
await prisma.post.create({ await prisma.post.create({
select: { id: true }, select: { id: true },
data: { data: {
user: null,
// 随机封面图 // 随机封面图
thumb: `/uploads/thumb/post-${getRandomInt(1, 8)}.png`, thumb: `/uploads/thumb/post-${getRandomInt(1, 8)}.png`,
// 生成1到3个段落的标题 // 生成1到3个段落的标题

View File

@ -0,0 +1,194 @@
'use client';
// Inspired by react-hot-toast library
import * as React from 'react';
import type { ToastActionElement, ToastProps } from '@/app/_components/ui/toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case 'DISMISS_TOAST': {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
// eslint-disable-next-line @typescript-eslint/no-shadow
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
default:
return null;
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (prop: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...prop, id },
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
export { useToast, toast };

View File

@ -1,6 +1,5 @@
/* eslint-disable vars-on-top */ /* eslint-disable vars-on-top */
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import paginateExt from 'prisma-paginate';
declare global { declare global {
// eslint-disable-next-line no-var // eslint-disable-next-line no-var
@ -8,7 +7,7 @@ declare global {
} }
const prismaClientSingleton = () => { const prismaClientSingleton = () => {
return new PrismaClient().$extends(paginateExt); return new PrismaClient();
}; };
const db = globalThis.prismaGlobal ?? prismaClientSingleton(); const db = globalThis.prismaGlobal ?? prismaClientSingleton();

View File

@ -0,0 +1,33 @@
// pnpm install nodemailer --save @types/nodemailer
// qq mail service zrruwguzxhbebija
import nodemailer from 'nodemailer';
export const transporter = nodemailer.createTransport<{ host: string }>({
host: 'smtp.qq.com',
secureConnection: true, // use SSL
port: 587,
secure: false,
auth: {
user: '450255477@qq.com',
pass: 'zrruwguzxhbebija',
},
});
// let mailOptions = {
// from: '"白小明 ?" <80583600@qq.com>', // 发件人
// to: 'xx1@qq.com, xx2@qq.com', // 收件人
// subject: 'Hello ✔', // 主题
// text: '这是一封来自 Node.js 的测试邮件', // plain text body
// html: '<b>这是一封来自 Node.js 的测试邮件</b>', // html body
// // 下面是发送附件,不需要就注释掉
// attachments: [{
// filename: 'test.md',
// path: './test.md'
// },
// {
// filename: 'content',
// content: '发送内容'
// }
// ]
// };

View File

@ -0,0 +1,9 @@
import { Redis } from 'ioredis';
import { prisma } from '@/database/client';
export const redis = new Redis();
const posts = await prisma.post.findMany({ select: { id: true, slug: true } });
posts.forEach((post) => {
redis.set(post.id, post.slug);
});

View File

@ -1,4 +1,6 @@
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
import { lowerCase, trim } from 'lodash';
import pinyin from 'pinyin';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
@ -8,3 +10,15 @@ export function cn(...inputs: ClassValue[]) {
* *
*/ */
export const isFullscreen = () => document.fullscreenElement !== null; export const isFullscreen = () => document.fullscreenElement !== null;
export const generateLowerString = (from: string) => {
const slug = pinyin(from, {
style: 0,
segment: false,
})
.map((words) => words[0])
.join('-');
return lowerCase(slug)
.split(' ')
.map((v) => trim(v, ' '))
.join('-');
};

View File

@ -0,0 +1,72 @@
import { UserRole } from '@prisma/client';
import * as z from 'zod';
export const LoginSchema = z.object({
email: z.string().email({
message: 'Email is required',
}),
password: z.string().min(1, {
message: 'Password is required',
}),
code: z.optional(z.string()),
});
export const RegisterSchema = z.object({
email: z.string().email({
message: 'Email is required',
}),
password: z.string().min(6, {
message: 'Minimum 6 characters required',
}),
name: z.string().min(1, {
message: 'Name is required',
}),
});
export const ResetSchema = z.object({
email: z.string().email({
message: 'Email is required',
}),
});
export const NewPasswordSchema = z.object({
password: z.string().min(6, {
message: 'Minimum 6 characters required!',
}),
});
export const SettingsSchema = z
.object({
name: z.optional(z.string()),
isTwoFactorEnabled: z.optional(z.boolean()),
role: z.enum([UserRole.ADMIN, UserRole.USER]),
email: z.optional(z.string().email()),
password: z.optional(z.string().min(6)),
newPassword: z.optional(z.string().min(6)),
})
.refine(
(data) => {
if (data.password && !data.newPassword) {
return false;
}
return true;
},
{
message: 'New password is required!',
path: ['newPassword'],
},
)
.refine(
(data) => {
if (data.newPassword && !data.password) {
return false;
}
return true;
},
{
message: 'Password is required!',
path: ['password'],
},
);

View File

@ -0,0 +1,40 @@
import NextAuth from 'next-auth';
import authConfig from '@/auth.config';
import { DEFAULT_LOGIN_REDIRECT, apiAuthPrefix, authRoutes, publicRoutes } from '@/routes';
const { auth } = NextAuth(authConfig);
export default auth((req) => {
const { nextUrl } = req;
const isLoggedIn = !!req.auth;
const isApiAuthRoute = nextUrl.pathname.startsWith(apiAuthPrefix);
const isPublicRoute = publicRoutes.includes(nextUrl.pathname);
const isAuthRoute = authRoutes.includes(nextUrl.pathname);
if (isApiAuthRoute) return null;
if (isAuthRoute) {
if (isLoggedIn) {
return Response.redirect(new URL(DEFAULT_LOGIN_REDIRECT, nextUrl));
}
return null;
}
if (!isLoggedIn && !isPublicRoute) {
let callbackUrl = nextUrl.pathname;
if (nextUrl.search) {
callbackUrl += nextUrl.search;
}
const encodedCallbackUrl = encodeURIComponent(callbackUrl);
return Response.redirect(new URL(`/auth/login?callbackUrl=${encodedCallbackUrl}`, nextUrl));
}
});
// Optionally, don't invoke Middleware on some paths
export const config = {
matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(api|trpc)(.*)'],
};

14
apps/web2/src/next-auth.d.ts vendored 100644
View File

@ -0,0 +1,14 @@
import { UserRole } from '@prisma/client';
import { DefaultSession } from 'next-auth';
export type ExtendedUser = DefaultSession['user'] & {
role: UserRole;
isTwoFactorEnabled: boolean;
isOAuth: boolean;
};
declare module 'next-auth' {
interface Session {
user: ExtendedUser;
}
}

View File

@ -0,0 +1,32 @@
/**
* An array of routes that are accessible to the public
* These routes do not require authentication
* @type {string[]}
*/
export const publicRoutes = ['/', '/auth/new-verification', '/post/edit'];
/**
* An array of routes that are used for authentication
* These routes will redirect logged in users to /settings
* @type {string[]}
*/
export const authRoutes = [
'/auth/login',
'/auth/register',
'/auth/error',
'/auth/reset',
'/auth/new-password',
];
/**
* The prefix for API authentication routes
* Routes that start with this prefix are used for API authentication puposes
* @type {string}
*/
export const apiAuthPrefix = '/api/auth';
/**
* The default redirect path after loggin in
* @type {string}
*/
export const DEFAULT_LOGIN_REDIRECT = '/settings';

View File

@ -6,5 +6,5 @@
} }
}, },
"include": ["next-env.d.ts", "typings/**/*.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], "include": ["next-env.d.ts", "typings/**/*.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"] "exclude": ["node_modules","src/lib/db/resend.ts"]
} }

File diff suppressed because it is too large Load Diff