auth1
parent
ebd045b7ea
commit
22dfa0204e
|
@ -0,0 +1 @@
|
|||
src/app/(pages)/mdx/*.*
|
|
@ -4,7 +4,7 @@
|
|||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind-config.ts",
|
||||
"config": "src/app/tailwind-config.ts",
|
||||
"css": "@/app/styles/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
|
|
|
@ -26,67 +26,84 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@3rapp/utils": "workspace:*",
|
||||
"@auth/prisma-adapter": "^2.6.0",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@mdx-js/loader": "^3.0.1",
|
||||
"@mdx-js/react": "^3.0.1",
|
||||
"@next/mdx": "^14.2.13",
|
||||
"@prisma/client": "5.17.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@next/mdx": "^14.2.14",
|
||||
"@prisma/client": "5.20.0",
|
||||
"@radix-ui/react-accordion": "^1.2.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-label": "^2.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/nodemailer": "^6.4.16",
|
||||
"@vavt/cm-extension": "^1.5.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"lucide-react": "^0.441.0",
|
||||
"md-editor-rt": "^4.20.2",
|
||||
"micromatch": "^4.0.8",
|
||||
"next": "14.2.12",
|
||||
"next-auth": "5.0.0-beta.22",
|
||||
"next-mdx-remote": "^5.0.0",
|
||||
"nodemailer": "^6.9.15",
|
||||
"pinyin": "4.0.0-alpha.2",
|
||||
"prism-themes": "^1.9.0",
|
||||
"prisma-paginate": "^5.2.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.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",
|
||||
"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",
|
||||
"uuid": "^10.0.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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/node": "^20.12.12",
|
||||
"@types/react": "^18.3.2",
|
||||
"@types/node": "^20.16.10",
|
||||
"@types/pinyin": "^2.10.2",
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"postcss": "^8.4.40",
|
||||
"eslint": "^8.57.1",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-import": "^16.1.0",
|
||||
"postcss-mixins": "^10.0.1",
|
||||
"postcss-nested": "^6.0.1",
|
||||
"postcss-nesting": "^12.1.4",
|
||||
"prisma": "^5.16.1",
|
||||
"postcss-nested": "^6.2.0",
|
||||
"postcss-nesting": "^12.1.5",
|
||||
"prisma": "^5.20.0",
|
||||
"prisma-extension-bark": "^0.2.2",
|
||||
"stylelint": "^16.5.0",
|
||||
"stylelint": "^16.9.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-prettier": "^5.0.2",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript": "^5.6.2",
|
||||
"utility-types": "^3.11.0"
|
||||
},
|
||||
"prisma": {
|
||||
|
|
|
@ -2,10 +2,10 @@ import { notFound } from 'next/navigation';
|
|||
import { FC } from 'react';
|
||||
|
||||
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 }) => {
|
||||
const post = await queryPostItemById(id);
|
||||
const post = await queryPostItemByIdOrSlug(id);
|
||||
if (!post) return notFound();
|
||||
return <PostActionForm type="update" post={post} />;
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { NewValidatorForm } from '@/app/_components/auth/new-validator-form';
|
||||
|
||||
const NewVerificationPage = () => {
|
||||
return <NewValidatorForm />;
|
||||
};
|
||||
export default NewVerificationPage;
|
|
@ -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;
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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 { 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 }) => (
|
||||
<>
|
||||
<div className=" tw-app-layout">
|
||||
<Header />
|
||||
|
||||
{children}
|
||||
<Suspense fallback={<PageSkeleton />}>{children}</Suspense>
|
||||
</div>
|
||||
{modal}
|
||||
<ToastProvider>
|
||||
<Toast />
|
||||
</ToastProvider>
|
||||
</>
|
||||
);
|
||||
export default appLayout;
|
||||
|
|
|
@ -49,7 +49,7 @@ const App: React.FC<{ searchParams: Record<string, any> }> = async ({ searchPara
|
|||
<div className=" tw-py-3">
|
||||
<Link
|
||||
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">
|
||||
{item.title}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { ResolvingMetadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
|
@ -5,10 +6,23 @@ import { FC } from 'react';
|
|||
|
||||
import { Tools } from '@/app/_components/home/tools';
|
||||
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 post = await queryPostItemById(params.item);
|
||||
const post = await queryPostItemByIdOrSlug(params.item);
|
||||
if (!post) return notFound();
|
||||
return (
|
||||
<div className="tw-page-container">
|
||||
|
|
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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} />;
|
||||
};
|
|
@ -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;
|
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -0,0 +1,9 @@
|
|||
import { PageSkeleton } from '@/app/_components/loading/page';
|
||||
|
||||
export default function PostLoading() {
|
||||
return (
|
||||
<div className="tw-page-container">
|
||||
<PageSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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()),
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -8,6 +8,7 @@ import $styles from './page.module.css';
|
|||
export const Logo = () => (
|
||||
<Link href="/" className={$styles.link}>
|
||||
<Image
|
||||
priority
|
||||
src={abc}
|
||||
alt="avatar logo"
|
||||
sizes="100vw"
|
||||
|
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
|
@ -38,9 +38,11 @@ export const MarkdownEditor: FC<MarkdownEditorProps> = forwardRef((props, _) =>
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<MdEditor
|
||||
{...rest}
|
||||
style={{ height: 'auto', minHeight: '600px' }}
|
||||
editorId="markdown-editor"
|
||||
modelValue={content}
|
||||
onChange={setContent}
|
||||
|
|
|
@ -1,8 +1,15 @@
|
|||
'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 { Button } from '../ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
|
@ -27,10 +34,19 @@ export const PostActionForm = forwardRef<PostCreateFormRef, PostActionFormProps>
|
|||
props.type === 'create' ? { type: 'create' } : { type: 'update', post: props.post },
|
||||
);
|
||||
const [body, setBody] = useState(props.type === 'create' ? '正文' : props.post.body);
|
||||
const [sulg, setSulg] = useState(props.type === 'create' ? '' : props.post.slug || '');
|
||||
const PostEditorScreenHandler = usePostEditorScreenHandler();
|
||||
const generateTitleSlug: MouseEventHandler<HTMLAnchorElement> = (e) => {
|
||||
e.preventDefault();
|
||||
const title = trim(form.getValues('title'), '');
|
||||
if (title) setSulg(generateLowerString(title));
|
||||
};
|
||||
useEffect(() => {
|
||||
form.setValue('body', body);
|
||||
}, [body]);
|
||||
useEffect(() => {
|
||||
form.setValue('slug', sulg);
|
||||
}, [sulg]);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() =>
|
||||
|
@ -38,60 +54,159 @@ export const PostActionForm = forwardRef<PostCreateFormRef, PostActionFormProps>
|
|||
? {
|
||||
create: form.handleSubmit(submitHandler),
|
||||
}
|
||||
: {},
|
||||
: {
|
||||
update: form.handleSubmit(submitHandler),
|
||||
},
|
||||
[props.type],
|
||||
);
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(submitHandler)} className="tw-space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>文章标题</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="请输入标题" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="summary"
|
||||
render={({ field }) => (
|
||||
<FormItem className="tw-mt-2 tw-pb-1 tw-border-b tw-border-dashed">
|
||||
<FormLabel>摘要简述</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="请输入文章摘要" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>摘要会显示在文章列表页</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
<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 onSubmit={form.handleSubmit(submitHandler)} className="tw-space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>文章标题</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="请输入标题"
|
||||
{...field}
|
||||
disabled={form.formState.isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Details summary="可选">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="summary"
|
||||
render={({ field }) => (
|
||||
<FormItem className="tw-mt-2 tw-pb-1 tw-border-b tw-border-dashed">
|
||||
<FormLabel>摘要简述</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="请输入文章摘要"
|
||||
{...field}
|
||||
disabled={form.formState.isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>摘要会显示在文章列表页</FormDescription>
|
||||
<FormMessage />
|
||||
</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(根据标题使用'-'连接字符拼接而成,中文字自动转换为拼音)
|
||||
</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>
|
||||
文章描述,用于SEO优化,长度不超过150个字符
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Details>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="body"
|
||||
render={({ field }) => (
|
||||
<FormItem className=" tw-flex tw-flex-col">
|
||||
<FormLabel>文章内容</FormLabel>
|
||||
<FormControl>
|
||||
<MarkdownEditor
|
||||
{...field}
|
||||
content={body}
|
||||
setContent={setBody}
|
||||
handlers={PostEditorScreenHandler}
|
||||
previewTheme="arknights"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="body"
|
||||
render={({ field }) => (
|
||||
<FormItem className=" tw-flex tw-flex-col">
|
||||
<FormLabel>文章内容</FormLabel>
|
||||
<FormControl>
|
||||
<MarkdownEditor
|
||||
{...field}
|
||||
content={body}
|
||||
disabled={form.formState.isSubmitting}
|
||||
setContent={setBody}
|
||||
handlers={PostEditorScreenHandler}
|
||||
previewTheme="arknights"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -6,18 +6,30 @@ import { useSearchParams } from 'next/navigation';
|
|||
import { useMemo } from 'react';
|
||||
import { IoMdAdd } from 'react-icons/io';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export const CreateButton = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const getUrlQuery = useMemo(() => {
|
||||
const query = new URLSearchParams(searchParams.toString()).toString();
|
||||
// 保留当前分页的url查询,不至于在打开创建文章后,导致首页的文章列表重置分页
|
||||
return isNil(query) || query.length < 1 ? '' : `?${query}`;
|
||||
}, [searchParams]);
|
||||
return (
|
||||
<Button asChild variant="outline" className=" tw-justify-end tw-rounded-sm tw-ml-auto">
|
||||
<Link href={`/post/create${getUrlQuery}`}>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className=" tw-justify-end tw-rounded-sm tw-ml-auto"
|
||||
onClick={() =>
|
||||
toast({
|
||||
title: 'cesititle',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Link href={`/post/edit${getUrlQuery}`}>
|
||||
<IoMdAdd /> create
|
||||
</Link>
|
||||
</Button>
|
||||
|
|
|
@ -7,6 +7,8 @@ import { AiOutlineDelete } from 'react-icons/ai';
|
|||
|
||||
import { deletePostItem } from '@/app/actions/post';
|
||||
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
@ -26,7 +28,11 @@ export const PostDelete: FC<{ id: string }> = ({ id }) => {
|
|||
try {
|
||||
await deletePostItem(id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '遇到服务器错误,请联系管理员处理',
|
||||
description: (error as Error).message,
|
||||
});
|
||||
}
|
||||
router.refresh();
|
||||
}, []);
|
||||
|
|
|
@ -17,7 +17,7 @@ export function PostEditButton({ id }: { id: string }) {
|
|||
}, [searchParams]);
|
||||
return (
|
||||
<Button asChild className=" tw-mr-3">
|
||||
<Link href={`/post-edit/${id}${getUrlQuery}`}>
|
||||
<Link href={`/post/edit/${id}${getUrlQuery}`}>
|
||||
<CiEdit className=" tw-mr-2" />
|
||||
编辑
|
||||
</Link>
|
||||
|
|
|
@ -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();
|
||||
};
|
|
@ -1,41 +1,59 @@
|
|||
'use client';
|
||||
|
||||
import { getRandomInt } from '@3rapp/utils';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Post } from '@prisma/client';
|
||||
import { isNil } from 'lodash';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { DeepNonNullable } from 'utility-types';
|
||||
|
||||
import { createPostItem, updatePostItem } from '@/app/actions/post';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
import { MarkdownEditorProps } from '../markdown/type';
|
||||
import { useEditorModalContext } from '../modal/hooks';
|
||||
|
||||
import { generatePostFormValidator } from './form-validator';
|
||||
import { PostActionFormProps, PostCreateData, PostFormData, PostUpdateData } from './types';
|
||||
|
||||
export function usePostActionForm(params: PostActionFormProps) {
|
||||
const defaultValues = useMemo(() => {
|
||||
if (params.type === 'create') {
|
||||
return {
|
||||
title: '文章标题111',
|
||||
body: '文章内容111',
|
||||
summary: '212',
|
||||
} as NonNullable<PostCreateData>;
|
||||
title: '',
|
||||
body: '',
|
||||
summary: '',
|
||||
slug: '',
|
||||
keywords: '',
|
||||
description: '',
|
||||
} as DeepNonNullable<PostCreateData>;
|
||||
}
|
||||
return {
|
||||
title: params.post.title,
|
||||
body: params.post.body,
|
||||
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]);
|
||||
|
||||
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) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const submitHandle = async (data: PostFormData) => {
|
||||
let post: Post | null;
|
||||
for (const key of Object.keys(data) as Array<keyof PostFormData>) {
|
||||
|
@ -47,8 +65,6 @@ export function usePostFormSubmitHandler(params: PostActionFormProps) {
|
|||
if (params.type === 'update') {
|
||||
post = await updatePostItem(params.post.id, data);
|
||||
} else {
|
||||
console.log(data, 'create data');
|
||||
|
||||
post = await createPostItem({
|
||||
thumb: `/uploads/thumb/post-${getRandomInt(1, 8)}.png`,
|
||||
...data,
|
||||
|
@ -58,7 +74,11 @@ export function usePostFormSubmitHandler(params: PostActionFormProps) {
|
|||
// 注意,这里不要用push,防止在详情页后退后返回到创建或编辑页面的弹出框
|
||||
if (!isNil(post)) router.replace(`/post/${post.id}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '遇到服务器错误,请联系管理员处理',
|
||||
description: (error as Error).message,
|
||||
});
|
||||
}
|
||||
};
|
||||
return submitHandle;
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import { Post, Prisma } from '@prisma/client';
|
||||
import { BaseSyntheticEvent } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { generatePostFormValidator } from './form-validator';
|
||||
|
||||
export interface PostCreateFormProps {
|
||||
type: 'create';
|
||||
|
@ -11,7 +14,7 @@ export interface PostUpdateFormProps {
|
|||
export type PostActionFormProps = PostCreateFormProps | PostUpdateFormProps;
|
||||
export type PostCreateData = Prisma.PostCreateInput;
|
||||
export type PostUpdateData = Partial<Omit<Post, 'id'>> & Pick<Post, 'id'>;
|
||||
export type PostFormData = PostCreateData | PostUpdateData;
|
||||
export type PostFormData = z.infer<ReturnType<typeof generatePostFormValidator>>;
|
||||
/**
|
||||
* 文章创建表单的Ref类型
|
||||
*/
|
||||
|
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -1,122 +1,113 @@
|
|||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
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<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
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',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="tw-absolute tw-right-4 tw-top-4 tw-rounded-sm tw-opacity-70 tw-ring-offset-background tw-transition-opacity hover:tw-opacity-100 focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-ring focus:tw-ring-offset-2 disabled:tw-pointer-events-none data-[state=open]:tw-bg-accent data-[state=open]:tw-text-muted-foreground">
|
||||
<Cross2Icon className="tw-h-4 tw-w-4" />
|
||||
<span className="tw-sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
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',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="tw-absolute tw-right-4 tw-top-4 tw-rounded-sm tw-opacity-70 tw-ring-offset-background tw-transition-opacity hover:tw-opacity-100 focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-ring focus:tw-ring-offset-2 disabled:tw-pointer-events-none data-[state=open]:tw-bg-accent data-[state=open]:tw-text-muted-foreground">
|
||||
<Cross2Icon className="tw-h-4 tw-w-4" />
|
||||
<span className="tw-sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"tw-flex tw-flex-col tw-space-y-1.5 tw-text-center sm:tw-text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'tw-flex tw-flex-col tw-space-y-1.5 tw-text-center sm:tw-text-left',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"tw-flex tw-flex-col-reverse sm:tw-flex-row sm:tw-justify-end sm:tw-space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'tw-flex tw-flex-col-reverse sm:tw-flex-row sm:tw-justify-end sm:tw-space-x-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"tw-text-lg tw-font-semibold tw-leading-none tw-tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('tw-text-lg tw-font-semibold tw-leading-none tw-tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("tw-text-sm tw-text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('tw-text-sm tw-text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
|
|
|
@ -1,25 +1,24 @@
|
|||
import * as React from "react"
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
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',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
|
|
@ -1,26 +1,21 @@
|
|||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
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(
|
||||
"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<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
|
|
@ -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 };
|
|
@ -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
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
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",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
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',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea }
|
||||
export { Textarea };
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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!' };
|
||||
};
|
|
@ -5,10 +5,12 @@ import { isNil } from 'lodash';
|
|||
|
||||
import { revalidateTag } from 'next/cache';
|
||||
|
||||
import paginateExt from 'prisma-paginate';
|
||||
|
||||
import db from '@/lib/db/client';
|
||||
import { PaginateOptions, PaginateReturn } from '@/lib/db/types';
|
||||
import { paginateTransform } from '@/lib/db/utils';
|
||||
|
||||
import { redis } from '@/lib/redis/client';
|
||||
/**
|
||||
* 查询分页文章列表信息
|
||||
* @param options
|
||||
|
@ -17,7 +19,7 @@ export const queryPostPaginate = async (
|
|||
options?: PaginateOptions,
|
||||
): Promise<PaginateReturn<Post>> => {
|
||||
// 此处使用倒序,以便新增的文章可以排在最前面
|
||||
const posts = await db.post.paginate({
|
||||
const posts = await db.$extends(paginateExt).post.paginate({
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
page: 1,
|
||||
limit: 8,
|
||||
|
@ -39,8 +41,9 @@ export const queryPostTotalPages = async (limit = 8): Promise<number> => {
|
|||
* 根据ID查询文章信息
|
||||
* @param id
|
||||
*/
|
||||
export const queryPostItemById = async (id: string): Promise<Post | null> => {
|
||||
const item = await db.post.findUniqueOrThrow({ where: { id } });
|
||||
export const queryPostItemByIdOrSlug = async (id: string): Promise<Post | null> => {
|
||||
// throw new Error('数据加载错误,请稍后重试!');
|
||||
const item = await db.post.findFirst({ where: { OR: [{ id }, { slug: id }] } });
|
||||
return item;
|
||||
};
|
||||
|
||||
|
@ -50,6 +53,7 @@ export const queryPostItemById = async (id: string): Promise<Post | null> => {
|
|||
*/
|
||||
export const createPostItem = async (data: Prisma.PostCreateInput): Promise<Post> => {
|
||||
const item = await db.post.create({ data });
|
||||
await redis.set(item.id, item.slug);
|
||||
revalidateTag('posts');
|
||||
return item;
|
||||
};
|
||||
|
@ -61,8 +65,9 @@ export const createPostItem = async (data: Prisma.PostCreateInput): Promise<Post
|
|||
*/
|
||||
export const updatePostItem = async (
|
||||
id: string,
|
||||
data: Partial<Omit<Prisma.PostCreateInput, 'id'>>,
|
||||
): Promise<Post | null> => {
|
||||
data: Partial<Omit<Post, 'id'>>,
|
||||
): Promise<Post> => {
|
||||
await redis.set(id, data.slug);
|
||||
const item = await db.post.update({ where: { id }, data });
|
||||
revalidateTag('posts');
|
||||
return item;
|
||||
|
@ -80,6 +85,7 @@ export const deletePostItem = async (id: string): Promise<null> => {
|
|||
return null;
|
||||
}
|
||||
await db.post.delete({ where: { id } });
|
||||
await redis.del(id);
|
||||
revalidateTag('posts');
|
||||
console.log('文章删除成功');
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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 });
|
||||
});
|
|
@ -0,0 +1,3 @@
|
|||
import { handlers } from '@/auth';
|
||||
|
||||
export const { GET, POST } = handlers;
|
|
@ -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;
|
|
@ -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错误意味着这个页面已经不存在了,请请点击头像
|
||||
<b>
|
||||
<Link className="tw-animate-decoration" href="/">
|
||||
返回首页
|
||||
</Link>
|
||||
</b>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default NotFoundPage;
|
|
@ -62,12 +62,20 @@ const config = {
|
|||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: '0' },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
from: {
|
||||
height: '0',
|
||||
},
|
||||
to: {
|
||||
height: 'var(--radix-accordion-content-height)',
|
||||
},
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
from: {
|
||||
height: 'var(--radix-accordion-content-height)',
|
||||
},
|
||||
to: {
|
||||
height: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
|
|
|
@ -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;
|
|
@ -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,
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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>`,
|
||||
});
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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`);
|
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE `posts` MODIFY `description` TEXT NULL,
|
||||
MODIFY `slug` VARCHAR(255) NULL;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE `posts` ALTER COLUMN `userId` DROP DEFAULT;
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE `users` ADD COLUMN `hashpassword` VARCHAR(191) NULL;
|
|
@ -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;
|
|
@ -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")
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -1,11 +1,16 @@
|
|||
model Post {
|
||||
id String @id @default(uuid())
|
||||
thumb String
|
||||
title String @db.Text
|
||||
summary String @db.Text
|
||||
body String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
thumb String
|
||||
title String @db.Text
|
||||
summary 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())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String @db.VarChar(255)
|
||||
|
||||
@@map("posts")
|
||||
@@map("post")
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -11,6 +11,8 @@ export const createPostData = async () => {
|
|||
await prisma.post.create({
|
||||
select: { id: true },
|
||||
data: {
|
||||
user: null,
|
||||
|
||||
// 随机封面图
|
||||
thumb: `/uploads/thumb/post-${getRandomInt(1, 8)}.png`,
|
||||
// 生成1到3个段落的标题
|
||||
|
|
|
@ -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 };
|
|
@ -1,6 +1,5 @@
|
|||
/* eslint-disable vars-on-top */
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import paginateExt from 'prisma-paginate';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
|
@ -8,7 +7,7 @@ declare global {
|
|||
}
|
||||
|
||||
const prismaClientSingleton = () => {
|
||||
return new PrismaClient().$extends(paginateExt);
|
||||
return new PrismaClient();
|
||||
};
|
||||
|
||||
const db = globalThis.prismaGlobal ?? prismaClientSingleton();
|
||||
|
|
|
@ -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: '发送内容'
|
||||
// }
|
||||
// ]
|
||||
// };
|
|
@ -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);
|
||||
});
|
|
@ -1,4 +1,6 @@
|
|||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { lowerCase, trim } from 'lodash';
|
||||
import pinyin from 'pinyin';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
|
@ -8,3 +10,15 @@ export function cn(...inputs: ClassValue[]) {
|
|||
* 判断浏览器是否处于全屏状态
|
||||
*/
|
||||
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('-');
|
||||
};
|
||||
|
|
|
@ -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'],
|
||||
},
|
||||
);
|
|
@ -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)(.*)'],
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -6,5 +6,5 @@
|
|||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "typings/**/*.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules","src/lib/db/resend.ts"]
|
||||
}
|
2523
pnpm-lock.yaml
2523
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue