auth1
parent
ebd045b7ea
commit
22dfa0204e
|
@ -0,0 +1 @@
|
||||||
|
src/app/(pages)/mdx/*.*
|
|
@ -4,7 +4,7 @@
|
||||||
"rsc": true,
|
"rsc": true,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind-config.ts",
|
"config": "src/app/tailwind-config.ts",
|
||||||
"css": "@/app/styles/index.css",
|
"css": "@/app/styles/index.css",
|
||||||
"baseColor": "zinc",
|
"baseColor": "zinc",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
|
|
|
@ -26,67 +26,84 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@3rapp/utils": "workspace:*",
|
"@3rapp/utils": "workspace:*",
|
||||||
|
"@auth/prisma-adapter": "^2.6.0",
|
||||||
"@faker-js/faker": "^8.4.1",
|
"@faker-js/faker": "^8.4.1",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@mdx-js/loader": "^3.0.1",
|
"@mdx-js/loader": "^3.0.1",
|
||||||
"@mdx-js/react": "^3.0.1",
|
"@mdx-js/react": "^3.0.1",
|
||||||
"@next/mdx": "^14.2.13",
|
"@next/mdx": "^14.2.14",
|
||||||
"@prisma/client": "5.17.0",
|
"@prisma/client": "5.20.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@radix-ui/react-accordion": "^1.2.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-toast": "^1.2.2",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
|
"@types/nodemailer": "^6.4.16",
|
||||||
"@vavt/cm-extension": "^1.5.0",
|
"@vavt/cm-extension": "^1.5.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"ioredis": "^5.4.1",
|
||||||
"lucide-react": "^0.441.0",
|
"lucide-react": "^0.441.0",
|
||||||
"md-editor-rt": "^4.20.2",
|
"md-editor-rt": "^4.20.2",
|
||||||
"micromatch": "^4.0.8",
|
"micromatch": "^4.0.8",
|
||||||
"next": "14.2.12",
|
"next": "14.2.12",
|
||||||
|
"next-auth": "5.0.0-beta.22",
|
||||||
"next-mdx-remote": "^5.0.0",
|
"next-mdx-remote": "^5.0.0",
|
||||||
|
"nodemailer": "^6.9.15",
|
||||||
|
"pinyin": "4.0.0-alpha.2",
|
||||||
"prism-themes": "^1.9.0",
|
"prism-themes": "^1.9.0",
|
||||||
"prisma-paginate": "^5.2.1",
|
"prisma-paginate": "^5.2.1",
|
||||||
"react": "^18",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
"react-icons": "^5.3.0",
|
"react-icons": "^5.3.0",
|
||||||
"react-use": "^17.5.0",
|
"react-spinners": "^0.14.1",
|
||||||
|
"react-use": "^17.5.1",
|
||||||
"rehype-prism-plus": "^2.0.0",
|
"rehype-prism-plus": "^2.0.0",
|
||||||
"tailwind-merge": "^2.3.0",
|
"resend": "^4.0.0",
|
||||||
|
"segmentit": "^2.0.3",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@3rapp/core": "workspace:*",
|
"@3rapp/core": "workspace:*",
|
||||||
"@types/lodash": "^4.17.5",
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/lodash": "^4.17.9",
|
||||||
"@types/micromatch": "^4.0.9",
|
"@types/micromatch": "^4.0.9",
|
||||||
"@types/node": "^20.12.12",
|
"@types/node": "^20.16.10",
|
||||||
"@types/react": "^18.3.2",
|
"@types/pinyin": "^2.10.2",
|
||||||
|
"@types/react": "^18.3.10",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.20",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.1",
|
||||||
"postcss": "^8.4.40",
|
"postcss": "^8.4.47",
|
||||||
"postcss-import": "^16.1.0",
|
"postcss-import": "^16.1.0",
|
||||||
"postcss-mixins": "^10.0.1",
|
"postcss-mixins": "^10.0.1",
|
||||||
"postcss-nested": "^6.0.1",
|
"postcss-nested": "^6.2.0",
|
||||||
"postcss-nesting": "^12.1.4",
|
"postcss-nesting": "^12.1.5",
|
||||||
"prisma": "^5.16.1",
|
"prisma": "^5.20.0",
|
||||||
"prisma-extension-bark": "^0.2.2",
|
"prisma-extension-bark": "^0.2.2",
|
||||||
"stylelint": "^16.5.0",
|
"stylelint": "^16.9.0",
|
||||||
"stylelint-config-css-modules": "^4.4.0",
|
"stylelint-config-css-modules": "^4.4.0",
|
||||||
"stylelint-config-recess-order": "^5.0.1",
|
"stylelint-config-recess-order": "^5.1.1",
|
||||||
"stylelint-config-standard": "^36.0.1",
|
"stylelint-config-standard": "^36.0.1",
|
||||||
"stylelint-prettier": "^5.0.2",
|
"stylelint-prettier": "^5.0.2",
|
||||||
"tailwindcss": "^3.4.7",
|
"tailwindcss": "^3.4.13",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.6.2",
|
||||||
"utility-types": "^3.11.0"
|
"utility-types": "^3.11.0"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
|
|
|
@ -2,10 +2,10 @@ import { notFound } from 'next/navigation';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
|
|
||||||
import { PostActionForm } from '@/app/_components/post/action-form';
|
import { PostActionForm } from '@/app/_components/post/action-form';
|
||||||
import { queryPostItemById } from '@/app/actions/post';
|
import { queryPostItemByIdOrSlug } from '@/app/actions/post';
|
||||||
|
|
||||||
export const PostEditForm: FC<{ id: string }> = async ({ id }) => {
|
export const PostEditForm: FC<{ id: string }> = async ({ id }) => {
|
||||||
const post = await queryPostItemById(id);
|
const post = await queryPostItemByIdOrSlug(id);
|
||||||
if (!post) return notFound();
|
if (!post) return notFound();
|
||||||
return <PostActionForm type="update" post={post} />;
|
return <PostActionForm type="update" post={post} />;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 { Header } from '../_components/header';
|
||||||
|
import { PageSkeleton } from '../_components/loading/page';
|
||||||
|
import { Toast, ToastProvider } from '../_components/ui/toast';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'about rext blog',
|
||||||
|
description:
|
||||||
|
'个人博客,提供一些ts、react、node.js、php、golang相关的技术文档以及分享一些生活琐事',
|
||||||
|
keywords: 'react, next.js, web application',
|
||||||
|
};
|
||||||
|
|
||||||
const appLayout: React.FC<PropsWithChildren & { modal: ReactNode }> = ({ children, modal }) => (
|
const appLayout: React.FC<PropsWithChildren & { modal: ReactNode }> = ({ children, modal }) => (
|
||||||
<>
|
<>
|
||||||
<div className=" tw-app-layout">
|
<div className=" tw-app-layout">
|
||||||
<Header />
|
<Header />
|
||||||
|
<Suspense fallback={<PageSkeleton />}>{children}</Suspense>
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
{modal}
|
{modal}
|
||||||
|
<ToastProvider>
|
||||||
|
<Toast />
|
||||||
|
</ToastProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
export default appLayout;
|
export default appLayout;
|
||||||
|
|
|
@ -49,7 +49,7 @@ const App: React.FC<{ searchParams: Record<string, any> }> = async ({ searchPara
|
||||||
<div className=" tw-py-3">
|
<div className=" tw-py-3">
|
||||||
<Link
|
<Link
|
||||||
className=" tw-w-full tw-block tw-overflow-hidden"
|
className=" tw-w-full tw-block tw-overflow-hidden"
|
||||||
href={`/post/${item.id}`}
|
href={`/post/${item.slug ? item.slug : item.id}`}
|
||||||
>
|
>
|
||||||
<h2 className=" tw-text-lg tw-font-bold tw-ellips tw-animate-decoration tw-animate-decoration-lg">
|
<h2 className=" tw-text-lg tw-font-bold tw-ellips tw-animate-decoration tw-animate-decoration-lg">
|
||||||
{item.title}
|
{item.title}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { ResolvingMetadata } from 'next';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
@ -5,10 +6,23 @@ import { FC } from 'react';
|
||||||
|
|
||||||
import { Tools } from '@/app/_components/home/tools';
|
import { Tools } from '@/app/_components/home/tools';
|
||||||
import { MarkdownPreview } from '@/app/_components/markdown/preivew';
|
import { MarkdownPreview } from '@/app/_components/markdown/preivew';
|
||||||
import { queryPostItemById } from '@/app/actions/post';
|
import { queryPostItemByIdOrSlug } from '@/app/actions/post';
|
||||||
|
|
||||||
|
export const generateMetadata = async (
|
||||||
|
{ params }: { params: { item: string } },
|
||||||
|
parent: ResolvingMetadata,
|
||||||
|
) => {
|
||||||
|
const post = await queryPostItemByIdOrSlug(params.item);
|
||||||
|
if (!post) return {};
|
||||||
|
return {
|
||||||
|
title: `${post.title} - ${(await parent).title?.absolute}`,
|
||||||
|
keywords: post.keywords,
|
||||||
|
description: post.description,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const PostItemPage: FC<{ params: { item: string } }> = async ({ params }) => {
|
const PostItemPage: FC<{ params: { item: string } }> = async ({ params }) => {
|
||||||
const post = await queryPostItemById(params.item);
|
const post = await queryPostItemByIdOrSlug(params.item);
|
||||||
if (!post) return notFound();
|
if (!post) return notFound();
|
||||||
return (
|
return (
|
||||||
<div className="tw-page-container">
|
<div className="tw-page-container">
|
||||||
|
|
|
@ -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 = () => (
|
export const Logo = () => (
|
||||||
<Link href="/" className={$styles.link}>
|
<Link href="/" className={$styles.link}>
|
||||||
<Image
|
<Image
|
||||||
|
priority
|
||||||
src={abc}
|
src={abc}
|
||||||
alt="avatar logo"
|
alt="avatar logo"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
|
|
@ -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 (
|
return (
|
||||||
<MdEditor
|
<MdEditor
|
||||||
{...rest}
|
{...rest}
|
||||||
|
style={{ height: 'auto', minHeight: '600px' }}
|
||||||
editorId="markdown-editor"
|
editorId="markdown-editor"
|
||||||
modelValue={content}
|
modelValue={content}
|
||||||
onChange={setContent}
|
onChange={setContent}
|
||||||
|
|
|
@ -1,8 +1,15 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
import { trim } from 'lodash';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { forwardRef, MouseEventHandler, useEffect, useImperativeHandle, useState } from 'react';
|
||||||
|
|
||||||
|
import { generateLowerString } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { Details } from '../collapsible/details';
|
||||||
|
import { BackButton } from '../home/back-button';
|
||||||
import { MarkdownEditor } from '../markdown/editor';
|
import { MarkdownEditor } from '../markdown/editor';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
@ -27,10 +34,19 @@ export const PostActionForm = forwardRef<PostCreateFormRef, PostActionFormProps>
|
||||||
props.type === 'create' ? { type: 'create' } : { type: 'update', post: props.post },
|
props.type === 'create' ? { type: 'create' } : { type: 'update', post: props.post },
|
||||||
);
|
);
|
||||||
const [body, setBody] = useState(props.type === 'create' ? '正文' : props.post.body);
|
const [body, setBody] = useState(props.type === 'create' ? '正文' : props.post.body);
|
||||||
|
const [sulg, setSulg] = useState(props.type === 'create' ? '' : props.post.slug || '');
|
||||||
const PostEditorScreenHandler = usePostEditorScreenHandler();
|
const PostEditorScreenHandler = usePostEditorScreenHandler();
|
||||||
|
const generateTitleSlug: MouseEventHandler<HTMLAnchorElement> = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const title = trim(form.getValues('title'), '');
|
||||||
|
if (title) setSulg(generateLowerString(title));
|
||||||
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.setValue('body', body);
|
form.setValue('body', body);
|
||||||
}, [body]);
|
}, [body]);
|
||||||
|
useEffect(() => {
|
||||||
|
form.setValue('slug', sulg);
|
||||||
|
}, [sulg]);
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() =>
|
() =>
|
||||||
|
@ -38,10 +54,23 @@ export const PostActionForm = forwardRef<PostCreateFormRef, PostActionFormProps>
|
||||||
? {
|
? {
|
||||||
create: form.handleSubmit(submitHandler),
|
create: form.handleSubmit(submitHandler),
|
||||||
}
|
}
|
||||||
: {},
|
: {
|
||||||
|
update: form.handleSubmit(submitHandler),
|
||||||
|
},
|
||||||
[props.type],
|
[props.type],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<div className=" tw-flex tw-justify-between tw-mx-5 tw-my-4 ">
|
||||||
|
<BackButton />
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={form.handleSubmit(submitHandler)}
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{form.formState.isSubmitting ? '更新中...' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(submitHandler)} className="tw-space-y-8">
|
<form onSubmit={form.handleSubmit(submitHandler)} className="tw-space-y-8">
|
||||||
<FormField
|
<FormField
|
||||||
|
@ -51,12 +80,17 @@ export const PostActionForm = forwardRef<PostCreateFormRef, PostActionFormProps>
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>文章标题</FormLabel>
|
<FormLabel>文章标题</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="请输入标题" {...field} />
|
<Input
|
||||||
|
placeholder="请输入标题"
|
||||||
|
{...field}
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<Details summary="可选">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="summary"
|
name="summary"
|
||||||
|
@ -64,13 +98,92 @@ export const PostActionForm = forwardRef<PostCreateFormRef, PostActionFormProps>
|
||||||
<FormItem className="tw-mt-2 tw-pb-1 tw-border-b tw-border-dashed">
|
<FormItem className="tw-mt-2 tw-pb-1 tw-border-b tw-border-dashed">
|
||||||
<FormLabel>摘要简述</FormLabel>
|
<FormLabel>摘要简述</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea placeholder="请输入文章摘要" {...field} />
|
<Textarea
|
||||||
|
placeholder="请输入文章摘要"
|
||||||
|
{...field}
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>摘要会显示在文章列表页</FormDescription>
|
<FormDescription>摘要会显示在文章列表页</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>url</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
value={sulg}
|
||||||
|
onChange={(e) => setSulg(e.target.value)}
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
placeholder="请输入url"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
如果留空,则文章访问地址是id
|
||||||
|
<Link
|
||||||
|
onClick={generateTitleSlug}
|
||||||
|
className="tw-ml-5 tw-mr-1"
|
||||||
|
href="#"
|
||||||
|
>
|
||||||
|
[点此]
|
||||||
|
</Link>
|
||||||
|
自动生成slug(根据标题使用'-'连接字符拼接而成,中文字自动转换为拼音)
|
||||||
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
@ -82,6 +195,7 @@ export const PostActionForm = forwardRef<PostCreateFormRef, PostActionFormProps>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
{...field}
|
{...field}
|
||||||
content={body}
|
content={body}
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
setContent={setBody}
|
setContent={setBody}
|
||||||
handlers={PostEditorScreenHandler}
|
handlers={PostEditorScreenHandler}
|
||||||
previewTheme="arknights"
|
previewTheme="arknights"
|
||||||
|
@ -93,5 +207,6 @@ export const PostActionForm = forwardRef<PostCreateFormRef, PostActionFormProps>
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,18 +6,30 @@ import { useSearchParams } from 'next/navigation';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { IoMdAdd } from 'react-icons/io';
|
import { IoMdAdd } from 'react-icons/io';
|
||||||
|
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
export const CreateButton = () => {
|
export const CreateButton = () => {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { toast } = useToast();
|
||||||
const getUrlQuery = useMemo(() => {
|
const getUrlQuery = useMemo(() => {
|
||||||
const query = new URLSearchParams(searchParams.toString()).toString();
|
const query = new URLSearchParams(searchParams.toString()).toString();
|
||||||
// 保留当前分页的url查询,不至于在打开创建文章后,导致首页的文章列表重置分页
|
// 保留当前分页的url查询,不至于在打开创建文章后,导致首页的文章列表重置分页
|
||||||
return isNil(query) || query.length < 1 ? '' : `?${query}`;
|
return isNil(query) || query.length < 1 ? '' : `?${query}`;
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
return (
|
return (
|
||||||
<Button asChild variant="outline" className=" tw-justify-end tw-rounded-sm tw-ml-auto">
|
<Button
|
||||||
<Link href={`/post/create${getUrlQuery}`}>
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
className=" tw-justify-end tw-rounded-sm tw-ml-auto"
|
||||||
|
onClick={() =>
|
||||||
|
toast({
|
||||||
|
title: 'cesititle',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Link href={`/post/edit${getUrlQuery}`}>
|
||||||
<IoMdAdd /> create
|
<IoMdAdd /> create
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -7,6 +7,8 @@ import { AiOutlineDelete } from 'react-icons/ai';
|
||||||
|
|
||||||
import { deletePostItem } from '@/app/actions/post';
|
import { deletePostItem } from '@/app/actions/post';
|
||||||
|
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
@ -26,7 +28,11 @@ export const PostDelete: FC<{ id: string }> = ({ id }) => {
|
||||||
try {
|
try {
|
||||||
await deletePostItem(id);
|
await deletePostItem(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '遇到服务器错误,请联系管理员处理',
|
||||||
|
description: (error as Error).message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
@ -17,7 +17,7 @@ export function PostEditButton({ id }: { id: string }) {
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
return (
|
return (
|
||||||
<Button asChild className=" tw-mr-3">
|
<Button asChild className=" tw-mr-3">
|
||||||
<Link href={`/post-edit/${id}${getUrlQuery}`}>
|
<Link href={`/post/edit/${id}${getUrlQuery}`}>
|
||||||
<CiEdit className=" tw-mr-2" />
|
<CiEdit className=" tw-mr-2" />
|
||||||
编辑
|
编辑
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -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';
|
'use client';
|
||||||
|
|
||||||
import { getRandomInt } from '@3rapp/utils';
|
import { getRandomInt } from '@3rapp/utils';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Post } from '@prisma/client';
|
import { Post } from '@prisma/client';
|
||||||
import { isNil } from 'lodash';
|
import { isNil } from 'lodash';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { DeepNonNullable } from 'utility-types';
|
||||||
|
|
||||||
import { createPostItem, updatePostItem } from '@/app/actions/post';
|
import { createPostItem, updatePostItem } from '@/app/actions/post';
|
||||||
|
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
import { MarkdownEditorProps } from '../markdown/type';
|
import { MarkdownEditorProps } from '../markdown/type';
|
||||||
import { useEditorModalContext } from '../modal/hooks';
|
import { useEditorModalContext } from '../modal/hooks';
|
||||||
|
|
||||||
|
import { generatePostFormValidator } from './form-validator';
|
||||||
import { PostActionFormProps, PostCreateData, PostFormData, PostUpdateData } from './types';
|
import { PostActionFormProps, PostCreateData, PostFormData, PostUpdateData } from './types';
|
||||||
|
|
||||||
export function usePostActionForm(params: PostActionFormProps) {
|
export function usePostActionForm(params: PostActionFormProps) {
|
||||||
const defaultValues = useMemo(() => {
|
const defaultValues = useMemo(() => {
|
||||||
if (params.type === 'create') {
|
if (params.type === 'create') {
|
||||||
return {
|
return {
|
||||||
title: '文章标题111',
|
title: '',
|
||||||
body: '文章内容111',
|
body: '',
|
||||||
summary: '212',
|
summary: '',
|
||||||
} as NonNullable<PostCreateData>;
|
slug: '',
|
||||||
|
keywords: '',
|
||||||
|
description: '',
|
||||||
|
} as DeepNonNullable<PostCreateData>;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: params.post.title,
|
title: params.post.title,
|
||||||
body: params.post.body,
|
body: params.post.body,
|
||||||
summary: isNil(params.post.summary) ? '' : params.post.summary,
|
summary: isNil(params.post.summary) ? '' : params.post.summary,
|
||||||
} as NonNullable<PostUpdateData>;
|
slug: isNil(params.post.slug) ? null : params.post.slug,
|
||||||
|
keywords: isNil(params.post.keywords) ? '' : params.post.keywords,
|
||||||
|
description: isNil(params.post.description) ? '' : params.post.description,
|
||||||
|
} as DeepNonNullable<PostUpdateData>;
|
||||||
}, [params.type]);
|
}, [params.type]);
|
||||||
|
|
||||||
return useForm<NonNullable<PostFormData>>({ defaultValues });
|
return useForm<DeepNonNullable<PostFormData>>({
|
||||||
|
defaultValues,
|
||||||
|
mode: 'all',
|
||||||
|
resolver: zodResolver(
|
||||||
|
generatePostFormValidator(params.type === 'update' ? params.post.id : undefined),
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePostFormSubmitHandler(params: PostActionFormProps) {
|
export function usePostFormSubmitHandler(params: PostActionFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
const submitHandle = async (data: PostFormData) => {
|
const submitHandle = async (data: PostFormData) => {
|
||||||
let post: Post | null;
|
let post: Post | null;
|
||||||
for (const key of Object.keys(data) as Array<keyof PostFormData>) {
|
for (const key of Object.keys(data) as Array<keyof PostFormData>) {
|
||||||
|
@ -47,8 +65,6 @@ export function usePostFormSubmitHandler(params: PostActionFormProps) {
|
||||||
if (params.type === 'update') {
|
if (params.type === 'update') {
|
||||||
post = await updatePostItem(params.post.id, data);
|
post = await updatePostItem(params.post.id, data);
|
||||||
} else {
|
} else {
|
||||||
console.log(data, 'create data');
|
|
||||||
|
|
||||||
post = await createPostItem({
|
post = await createPostItem({
|
||||||
thumb: `/uploads/thumb/post-${getRandomInt(1, 8)}.png`,
|
thumb: `/uploads/thumb/post-${getRandomInt(1, 8)}.png`,
|
||||||
...data,
|
...data,
|
||||||
|
@ -58,7 +74,11 @@ export function usePostFormSubmitHandler(params: PostActionFormProps) {
|
||||||
// 注意,这里不要用push,防止在详情页后退后返回到创建或编辑页面的弹出框
|
// 注意,这里不要用push,防止在详情页后退后返回到创建或编辑页面的弹出框
|
||||||
if (!isNil(post)) router.replace(`/post/${post.id}`);
|
if (!isNil(post)) router.replace(`/post/${post.id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '遇到服务器错误,请联系管理员处理',
|
||||||
|
description: (error as Error).message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return submitHandle;
|
return submitHandle;
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
import { Post, Prisma } from '@prisma/client';
|
import { Post, Prisma } from '@prisma/client';
|
||||||
import { BaseSyntheticEvent } from 'react';
|
import { BaseSyntheticEvent } from 'react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { generatePostFormValidator } from './form-validator';
|
||||||
|
|
||||||
export interface PostCreateFormProps {
|
export interface PostCreateFormProps {
|
||||||
type: 'create';
|
type: 'create';
|
||||||
|
@ -11,7 +14,7 @@ export interface PostUpdateFormProps {
|
||||||
export type PostActionFormProps = PostCreateFormProps | PostUpdateFormProps;
|
export type PostActionFormProps = PostCreateFormProps | PostUpdateFormProps;
|
||||||
export type PostCreateData = Prisma.PostCreateInput;
|
export type PostCreateData = Prisma.PostCreateInput;
|
||||||
export type PostUpdateData = Partial<Omit<Post, 'id'>> & Pick<Post, 'id'>;
|
export type PostUpdateData = Partial<Omit<Post, 'id'>> & Pick<Post, 'id'>;
|
||||||
export type PostFormData = PostCreateData | PostUpdateData;
|
export type PostFormData = z.infer<ReturnType<typeof generatePostFormValidator>>;
|
||||||
/**
|
/**
|
||||||
* 文章创建表单的Ref类型
|
* 文章创建表单的Ref类型
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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,18 +1,18 @@
|
||||||
"use client"
|
'use client';
|
||||||
|
|
||||||
import * as React from "react"
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import { Cross2Icon } from '@radix-ui/react-icons';
|
||||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
import * as React from 'react';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
const DialogTrigger = DialogPrimitive.Trigger
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
const DialogPortal = DialogPrimitive.Portal
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
const DialogClose = DialogPrimitive.Close
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
const DialogOverlay = React.forwardRef<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
@ -21,13 +21,13 @@ const DialogOverlay = React.forwardRef<
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"tw-fixed tw-inset-0 tw-z-50 tw-bg-black/80 tw- data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=open]:tw-fade-in-0",
|
'tw-fixed tw-inset-0 tw-z-50 tw-bg-black/80 tw- data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=open]:tw-fade-in-0',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
@ -38,8 +38,8 @@ const DialogContent = React.forwardRef<
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"tw-fixed tw-left-[50%] tw-top-[50%] tw-z-50 tw-grid tw-w-full tw-max-w-lg tw-translate-x-[-50%] tw-translate-y-[-50%] tw-gap-4 tw-border tw-bg-background tw-p-6 tw-shadow-lg tw-duration-200 data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=open]:tw-fade-in-0 data-[state=closed]:tw-zoom-out-95 data-[state=open]:tw-zoom-in-95 data-[state=closed]:tw-slide-out-to-left-1/2 data-[state=closed]:tw-slide-out-to-top-[48%] data-[state=open]:tw-slide-in-from-left-1/2 data-[state=open]:tw-slide-in-from-top-[48%] sm:tw-rounded-lg",
|
'tw-fixed tw-left-[50%] tw-top-[50%] tw-z-50 tw-grid tw-w-full tw-max-w-lg tw-translate-x-[-50%] tw-translate-y-[-50%] tw-gap-4 tw-border tw-bg-background tw-p-6 tw-shadow-lg tw-duration-200 data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=open]:tw-fade-in-0 data-[state=closed]:tw-zoom-out-95 data-[state=open]:tw-zoom-in-95 data-[state=closed]:tw-slide-out-to-left-1/2 data-[state=closed]:tw-slide-out-to-top-[48%] data-[state=open]:tw-slide-in-from-left-1/2 data-[state=open]:tw-slide-in-from-top-[48%] sm:tw-rounded-lg',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
@ -50,36 +50,30 @@ const DialogContent = React.forwardRef<
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
))
|
));
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DialogHeader = ({
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"tw-flex tw-flex-col tw-space-y-1.5 tw-text-center sm:tw-text-left",
|
'tw-flex tw-flex-col tw-space-y-1.5 tw-text-center sm:tw-text-left',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DialogHeader.displayName = "DialogHeader"
|
DialogHeader.displayName = 'DialogHeader';
|
||||||
|
|
||||||
const DialogFooter = ({
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"tw-flex tw-flex-col-reverse sm:tw-flex-row sm:tw-justify-end sm:tw-space-x-2",
|
'tw-flex tw-flex-col-reverse sm:tw-flex-row sm:tw-justify-end sm:tw-space-x-2',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DialogFooter.displayName = "DialogFooter"
|
DialogFooter.displayName = 'DialogFooter';
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
const DialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
@ -87,14 +81,11 @@ const DialogTitle = React.forwardRef<
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn('tw-text-lg tw-font-semibold tw-leading-none tw-tracking-tight', className)}
|
||||||
"tw-text-lg tw-font-semibold tw-leading-none tw-tracking-tight",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
const DialogDescription = React.forwardRef<
|
const DialogDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
@ -102,11 +93,11 @@ const DialogDescription = React.forwardRef<
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("tw-text-sm tw-text-muted-foreground", className)}
|
className={cn('tw-text-sm tw-text-muted-foreground', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
@ -119,4 +110,4 @@ export {
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
}
|
};
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import * as React from "react"
|
import * as React from 'react';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
|
@ -11,15 +10,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"tw-flex tw-h-9 tw-w-full tw-rounded-md tw-border tw-border-input tw-bg-transparent tw-px-3 tw-py-1 tw-text-sm tw-shadow-sm tw-transition-colors file:tw-border-0 file:tw-bg-transparent file:tw-text-sm file:tw-font-medium file:tw-text-foreground placeholder:tw-text-muted-foreground focus-visible:tw-outline-none focus-visible:tw-ring-1 focus-visible:tw-ring-ring disabled:tw-cursor-not-allowed disabled:tw-opacity-50",
|
'tw-flex tw-h-9 tw-w-full tw-rounded-md tw-border tw-border-input tw-bg-transparent tw-px-3 tw-py-1 tw-text-sm tw-shadow-sm tw-transition-colors file:tw-border-0 file:tw-bg-transparent file:tw-text-sm file:tw-font-medium file:tw-text-foreground placeholder:tw-text-muted-foreground focus-visible:tw-outline-none focus-visible:tw-ring-1 focus-visible:tw-ring-ring disabled:tw-cursor-not-allowed disabled:tw-opacity-50',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
Input.displayName = "Input"
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
|
|
@ -1,26 +1,21 @@
|
||||||
"use client"
|
'use client';
|
||||||
|
|
||||||
import * as React from "react"
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import * as React from 'react';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const labelVariants = cva(
|
const labelVariants = cva(
|
||||||
"tw-text-sm tw-font-medium tw-leading-none peer-disabled:tw-cursor-not-allowed peer-disabled:tw-opacity-70"
|
'tw-text-sm tw-font-medium tw-leading-none peer-disabled:tw-cursor-not-allowed peer-disabled:tw-opacity-70',
|
||||||
)
|
);
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
const Label = React.forwardRef<
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||||
VariantProps<typeof labelVariants>
|
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<LabelPrimitive.Root
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
ref={ref}
|
));
|
||||||
className={cn(labelVariants(), className)}
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Label.displayName = LabelPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Label }
|
export { Label };
|
||||||
|
|
|
@ -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
|
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
|
||||||
|
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
({ className, ...props }, ref) => {
|
({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"tw-flex tw-min-h-[60px] tw-w-full tw-rounded-md tw-border tw-border-input tw-bg-transparent tw-px-3 tw-py-2 tw-text-sm tw-shadow-sm placeholder:tw-text-muted-foreground focus-visible:tw-outline-none focus-visible:tw-ring-1 focus-visible:tw-ring-ring disabled:tw-cursor-not-allowed disabled:tw-opacity-50",
|
'tw-flex tw-min-h-[60px] tw-w-full tw-rounded-md tw-border tw-border-input tw-bg-transparent tw-px-3 tw-py-2 tw-text-sm tw-shadow-sm placeholder:tw-text-muted-foreground focus-visible:tw-outline-none focus-visible:tw-ring-1 focus-visible:tw-ring-ring disabled:tw-cursor-not-allowed disabled:tw-opacity-50',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
Textarea.displayName = "Textarea"
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
export { Textarea }
|
export { Textarea };
|
||||||
|
|
|
@ -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 { revalidateTag } from 'next/cache';
|
||||||
|
|
||||||
|
import paginateExt from 'prisma-paginate';
|
||||||
|
|
||||||
import db from '@/lib/db/client';
|
import db from '@/lib/db/client';
|
||||||
import { PaginateOptions, PaginateReturn } from '@/lib/db/types';
|
import { PaginateOptions, PaginateReturn } from '@/lib/db/types';
|
||||||
import { paginateTransform } from '@/lib/db/utils';
|
import { paginateTransform } from '@/lib/db/utils';
|
||||||
|
import { redis } from '@/lib/redis/client';
|
||||||
/**
|
/**
|
||||||
* 查询分页文章列表信息
|
* 查询分页文章列表信息
|
||||||
* @param options
|
* @param options
|
||||||
|
@ -17,7 +19,7 @@ export const queryPostPaginate = async (
|
||||||
options?: PaginateOptions,
|
options?: PaginateOptions,
|
||||||
): Promise<PaginateReturn<Post>> => {
|
): Promise<PaginateReturn<Post>> => {
|
||||||
// 此处使用倒序,以便新增的文章可以排在最前面
|
// 此处使用倒序,以便新增的文章可以排在最前面
|
||||||
const posts = await db.post.paginate({
|
const posts = await db.$extends(paginateExt).post.paginate({
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 8,
|
limit: 8,
|
||||||
|
@ -39,8 +41,9 @@ export const queryPostTotalPages = async (limit = 8): Promise<number> => {
|
||||||
* 根据ID查询文章信息
|
* 根据ID查询文章信息
|
||||||
* @param id
|
* @param id
|
||||||
*/
|
*/
|
||||||
export const queryPostItemById = async (id: string): Promise<Post | null> => {
|
export const queryPostItemByIdOrSlug = async (id: string): Promise<Post | null> => {
|
||||||
const item = await db.post.findUniqueOrThrow({ where: { id } });
|
// throw new Error('数据加载错误,请稍后重试!');
|
||||||
|
const item = await db.post.findFirst({ where: { OR: [{ id }, { slug: id }] } });
|
||||||
return item;
|
return item;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -50,6 +53,7 @@ export const queryPostItemById = async (id: string): Promise<Post | null> => {
|
||||||
*/
|
*/
|
||||||
export const createPostItem = async (data: Prisma.PostCreateInput): Promise<Post> => {
|
export const createPostItem = async (data: Prisma.PostCreateInput): Promise<Post> => {
|
||||||
const item = await db.post.create({ data });
|
const item = await db.post.create({ data });
|
||||||
|
await redis.set(item.id, item.slug);
|
||||||
revalidateTag('posts');
|
revalidateTag('posts');
|
||||||
return item;
|
return item;
|
||||||
};
|
};
|
||||||
|
@ -61,8 +65,9 @@ export const createPostItem = async (data: Prisma.PostCreateInput): Promise<Post
|
||||||
*/
|
*/
|
||||||
export const updatePostItem = async (
|
export const updatePostItem = async (
|
||||||
id: string,
|
id: string,
|
||||||
data: Partial<Omit<Prisma.PostCreateInput, 'id'>>,
|
data: Partial<Omit<Post, 'id'>>,
|
||||||
): Promise<Post | null> => {
|
): Promise<Post> => {
|
||||||
|
await redis.set(id, data.slug);
|
||||||
const item = await db.post.update({ where: { id }, data });
|
const item = await db.post.update({ where: { id }, data });
|
||||||
revalidateTag('posts');
|
revalidateTag('posts');
|
||||||
return item;
|
return item;
|
||||||
|
@ -80,6 +85,7 @@ export const deletePostItem = async (id: string): Promise<null> => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
await db.post.delete({ where: { id } });
|
await db.post.delete({ where: { id } });
|
||||||
|
await redis.del(id);
|
||||||
revalidateTag('posts');
|
revalidateTag('posts');
|
||||||
console.log('文章删除成功');
|
console.log('文章删除成功');
|
||||||
|
|
||||||
|
|
|
@ -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: {
|
keyframes: {
|
||||||
'accordion-down': {
|
'accordion-down': {
|
||||||
from: { height: '0' },
|
from: {
|
||||||
to: { height: 'var(--radix-accordion-content-height)' },
|
height: '0',
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
height: 'var(--radix-accordion-content-height)',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'accordion-up': {
|
'accordion-up': {
|
||||||
from: { height: 'var(--radix-accordion-content-height)' },
|
from: {
|
||||||
to: { height: '0' },
|
height: 'var(--radix-accordion-content-height)',
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
height: '0',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
|
@ -4,8 +4,13 @@ model Post {
|
||||||
title String @db.Text
|
title String @db.Text
|
||||||
summary String @db.Text
|
summary String @db.Text
|
||||||
body String @db.Text
|
body String @db.Text
|
||||||
|
slug String? @unique @db.VarChar(255)
|
||||||
|
keywords String? @db.VarChar(255)
|
||||||
|
description String? @db.Text
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
userId String @db.VarChar(255)
|
||||||
|
|
||||||
@@map("posts")
|
@@map("post")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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({
|
await prisma.post.create({
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
data: {
|
data: {
|
||||||
|
user: null,
|
||||||
|
|
||||||
// 随机封面图
|
// 随机封面图
|
||||||
thumb: `/uploads/thumb/post-${getRandomInt(1, 8)}.png`,
|
thumb: `/uploads/thumb/post-${getRandomInt(1, 8)}.png`,
|
||||||
// 生成1到3个段落的标题
|
// 生成1到3个段落的标题
|
||||||
|
|
|
@ -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 */
|
/* eslint-disable vars-on-top */
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import paginateExt from 'prisma-paginate';
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line no-var
|
// eslint-disable-next-line no-var
|
||||||
|
@ -8,7 +7,7 @@ declare global {
|
||||||
}
|
}
|
||||||
|
|
||||||
const prismaClientSingleton = () => {
|
const prismaClientSingleton = () => {
|
||||||
return new PrismaClient().$extends(paginateExt);
|
return new PrismaClient();
|
||||||
};
|
};
|
||||||
|
|
||||||
const db = globalThis.prismaGlobal ?? prismaClientSingleton();
|
const db = globalThis.prismaGlobal ?? prismaClientSingleton();
|
||||||
|
|
|
@ -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 { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { lowerCase, trim } from 'lodash';
|
||||||
|
import pinyin from 'pinyin';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
@ -8,3 +10,15 @@ export function cn(...inputs: ClassValue[]) {
|
||||||
* 判断浏览器是否处于全屏状态
|
* 判断浏览器是否处于全屏状态
|
||||||
*/
|
*/
|
||||||
export const isFullscreen = () => document.fullscreenElement !== null;
|
export const isFullscreen = () => document.fullscreenElement !== null;
|
||||||
|
export const generateLowerString = (from: string) => {
|
||||||
|
const slug = pinyin(from, {
|
||||||
|
style: 0,
|
||||||
|
segment: false,
|
||||||
|
})
|
||||||
|
.map((words) => words[0])
|
||||||
|
.join('-');
|
||||||
|
return lowerCase(slug)
|
||||||
|
.split(' ')
|
||||||
|
.map((v) => trim(v, ' '))
|
||||||
|
.join('-');
|
||||||
|
};
|
||||||
|
|
|
@ -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"],
|
"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