mkedit
|
@ -21,8 +21,8 @@
|
|||
"postcss": "css"
|
||||
},
|
||||
"tailwindCSS.experimental.configFile": {
|
||||
"apps/web/src/app/tailwind-config.ts": "apps/web/src/app/**"
|
||||
|
||||
"apps/web/src/app/tailwind-config.ts": "apps/web/src/app/**",
|
||||
"apps/web2/src/app/tailwind-config.ts": "apps/web2/src/app/**",
|
||||
},
|
||||
"stylelint.enable": true,
|
||||
"stylelint.snippet": ["css", "scss", "less", "postcss"],
|
||||
|
|
|
@ -13,6 +13,7 @@ const Wrapper: FC = () => {
|
|||
const lang = useLocalStore((state) => state.lang);
|
||||
const locale = useMemo(() => localeData[lang], [lang]);
|
||||
const algorithm = useAntdAlgorithm();
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={locale.antd}
|
||||
|
|
|
@ -21,8 +21,8 @@
|
|||
"lint:style": "stylelint \"**/*.css\" --fix --cache --cache-location node_modules/.cache/stylelint/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@3rapp/utils": "workspace:*",
|
||||
"@3rapp/store": "workspace:*",
|
||||
"@3rapp/utils": "workspace:*",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@prisma/client": "^5.16.1",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
|
@ -49,7 +49,7 @@
|
|||
"autoprefixer": "^10.4.19",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"postcss": "^8.4.38",
|
||||
"postcss": "^8.4.40",
|
||||
"postcss-import": "^16.1.0",
|
||||
"postcss-mixins": "^10.0.1",
|
||||
"postcss-nested": "^6.0.1",
|
||||
|
@ -61,7 +61,7 @@
|
|||
"stylelint-config-recess-order": "^5.0.1",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"stylelint-prettier": "^5.0.2",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind-config.ts",
|
||||
"css": "styles/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": "tw-"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/app/_components",
|
||||
"ui": "@/app/_components/shadcn",
|
||||
"utils": "@/libs/utils"
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@ const App: React.FC = () => (
|
|||
<main className={container}>
|
||||
<div className={block}>
|
||||
欢迎来到3R教室,这是<span>Nextjs课程的开始</span>
|
||||
<Button asChild variant="destructive">
|
||||
<Button className="" asChild variant="destructive">
|
||||
<p> 点我 </p>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -7,4 +7,4 @@
|
|||
@import 'tailwindcss/utilities';
|
||||
@import './tailwind/utilities.css';
|
||||
|
||||
@config "../tailwind.config.ts";
|
||||
/* @config "../tailwind.config.ts"; */
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
/* eslint-disable global-require */
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config = {
|
||||
darkMode: ['class'],
|
||||
content: ['./src/app/**/*.{ts,tsx}'],
|
||||
prefix: 'tw-',
|
||||
theme: {
|
||||
extend: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
xs: '480px',
|
||||
sm: '576px',
|
||||
md: '768px',
|
||||
lg: '992px',
|
||||
xl: '1200px',
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: '0' },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
},
|
||||
boxShadow: {
|
||||
nysm: '0 0 2px 0 rgb(0 0 0 / 0.05)',
|
||||
ny: '0 0 3px 0 rgb(0 0 0 / 0.1), 0 0 2px - 1px rgb(0 0 0 / 0.1)',
|
||||
nymd: '0 0 6px -1px rgb(0 0 0 / 0.1), 0 0 4px -2px rgb(0 0 0 / 0.1)',
|
||||
nylg: '0 0 15px -3px rgb(0 0 0 / 0.1), 0 0 6px -4px rgb(0 0 0 / 0.1)',
|
||||
spread: '0 5px 40px rgb(0 0 0 / 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
} satisfies Config;
|
||||
|
||||
export default config;
|
|
@ -1,19 +1,8 @@
|
|||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config = {
|
||||
content: ['./src/**/*.{ts,tsx}'],
|
||||
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
||||
prefix: 'tw-',
|
||||
theme: {
|
||||
extend: {
|
||||
boxShadow: {
|
||||
nysm: '0 0 2px 0 rgb(0 0 0 / 0.05)',
|
||||
ny: '0 0 3px 0 rgb(0 0 0 / 0.1), 0 0 2px - 1px rgb(0 0 0 / 0.1)',
|
||||
nymd: '0 0 6px -1px rgb(0 0 0 / 0.1), 0 0 4px -2px rgb(0 0 0 / 0.1)',
|
||||
nylg: '0 0 15px -3px rgb(0 0 0 / 0.1), 0 0 6px -4px rgb(0 0 0 / 0.1)',
|
||||
spread: '0 5px 40px rgb(0 0 0 / 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Config;
|
||||
|
||||
export default config;
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
public
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
docker
|
||||
Dockerfile*
|
||||
LICENSE
|
||||
yarn-error.log
|
||||
.next
|
||||
.history
|
||||
.dockerignore
|
||||
.DS_Store
|
||||
.eslintignore
|
||||
.editorconfig
|
||||
.gitignore
|
||||
.prettierignore
|
||||
.eslintcache
|
||||
*.lock
|
||||
*.png
|
||||
*.eot
|
||||
*.ttf
|
||||
*.woff
|
|
@ -0,0 +1,132 @@
|
|||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
parserOptions: {
|
||||
project: './tsconfig.eslint.json',
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'unused-imports', 'prettier'],
|
||||
extends: [
|
||||
'airbnb',
|
||||
'airbnb-typescript',
|
||||
'airbnb/hooks',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
'plugin:@next/next/recommended',
|
||||
'prettier',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
ignorePatterns: ['.next', 'node_modules/'],
|
||||
rules: {
|
||||
/* ********************************** ES6+ ********************************** */
|
||||
'no-console': 0,
|
||||
'no-var-requires': 0,
|
||||
'no-restricted-syntax': 0,
|
||||
'no-continue': 0,
|
||||
'no-await-in-loop': 0,
|
||||
'no-return-await': 0,
|
||||
'no-unused-vars': 0,
|
||||
'no-multi-assign': 0,
|
||||
'no-param-reassign': [2, { props: false }],
|
||||
'import/prefer-default-export': 0,
|
||||
'import/no-cycle': 0,
|
||||
'import/no-dynamic-require': 0,
|
||||
'max-classes-per-file': 0,
|
||||
'class-methods-use-this': 0,
|
||||
'guard-for-in': 0,
|
||||
'no-underscore-dangle': 0,
|
||||
'no-plusplus': 0,
|
||||
'no-lonely-if': 0,
|
||||
'no-bitwise': ['error', { allow: ['~'] }],
|
||||
|
||||
/* ********************************** Module Import ********************************** */
|
||||
|
||||
'import/no-absolute-path': 0,
|
||||
'import/extensions': 0,
|
||||
'import/no-named-default': 0,
|
||||
'no-restricted-exports': 0,
|
||||
'import/no-extraneous-dependencies': 0,
|
||||
// 模块导入顺序规则
|
||||
'import/order': [
|
||||
1,
|
||||
{
|
||||
pathGroups: [
|
||||
{
|
||||
pattern: '@/**',
|
||||
group: 'external',
|
||||
position: 'after',
|
||||
},
|
||||
],
|
||||
alphabetize: { order: 'asc', caseInsensitive: false },
|
||||
'newlines-between': 'always-and-inside-groups',
|
||||
warnOnUnassignedImports: true,
|
||||
},
|
||||
],
|
||||
// 自动删除未使用的导入
|
||||
// https://github.com/sweepline/eslint-plugin-unused-imports
|
||||
'unused-imports/no-unused-imports': 1,
|
||||
'unused-imports/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
vars: 'all',
|
||||
args: 'none',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
|
||||
/* ********************************** Typescript ********************************** */
|
||||
'@typescript-eslint/no-unused-vars': 0,
|
||||
'@typescript-eslint/no-empty-interface': 0,
|
||||
'@typescript-eslint/no-this-alias': 0,
|
||||
'@typescript-eslint/no-var-requires': 0,
|
||||
'@typescript-eslint/no-use-before-define': 0,
|
||||
'@typescript-eslint/explicit-member-accessibility': 0,
|
||||
'@typescript-eslint/no-non-null-assertion': 0,
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 0,
|
||||
'@typescript-eslint/require-await': 0,
|
||||
'@typescript-eslint/no-for-in-array': 0,
|
||||
'@typescript-eslint/interface-name-prefix': 0,
|
||||
'@typescript-eslint/explicit-function-return-type': 0,
|
||||
'@typescript-eslint/no-explicit-any': 0,
|
||||
'@typescript-eslint/explicit-module-boundary-types': 0,
|
||||
'@typescript-eslint/no-floating-promises': 0,
|
||||
'@typescript-eslint/restrict-template-expressions': 0,
|
||||
'@typescript-eslint/no-unsafe-assignment': 0,
|
||||
'@typescript-eslint/no-unsafe-return': 0,
|
||||
'@typescript-eslint/no-unused-expressions': 0,
|
||||
'@typescript-eslint/no-misused-promises': 0,
|
||||
'@typescript-eslint/no-unsafe-member-access': 0,
|
||||
'@typescript-eslint/no-unsafe-call': 0,
|
||||
'@typescript-eslint/no-unsafe-argument': 0,
|
||||
'@typescript-eslint/ban-ts-comment': 0,
|
||||
'@typescript-eslint/naming-convention': 0,
|
||||
|
||||
/* ********************************** React and Hooks ********************************** */
|
||||
'react/jsx-uses-react': 1,
|
||||
'react/jsx-uses-vars': 1,
|
||||
'react/jsx-no-useless-fragment': 0,
|
||||
'react/display-name': 0,
|
||||
'react/button-has-type': 0,
|
||||
'react/prop-types': 0,
|
||||
'react/jsx-props-no-spreading': 0,
|
||||
'react/destructuring-assignment': 0,
|
||||
'react/static-property-placement': 0,
|
||||
'react/react-in-jsx-scope': 0,
|
||||
'react/require-default-props': 0,
|
||||
'react/jsx-filename-extension': [1, { extensions: ['.jsx', '.tsx'] }],
|
||||
'react/function-component-definition': 0,
|
||||
'react-hooks/exhaustive-deps': 0,
|
||||
|
||||
/* ********************************** jax-a11y ********************************** */
|
||||
'jsx-a11y/anchor-is-valid': 0,
|
||||
'jsx-a11y/no-static-element-interactions': 0,
|
||||
'jsx-a11y/click-events-have-key-events': 0,
|
||||
'jsx-a11y/label-has-associated-control': [
|
||||
'error',
|
||||
{
|
||||
required: {
|
||||
some: ['nesting', 'id'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
|
@ -0,0 +1 @@
|
|||
module.exports = require.resolve('@3rapp/core/prettier');
|
|
@ -0,0 +1,22 @@
|
|||
public
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
docker
|
||||
Dockerfile*
|
||||
LICENSE
|
||||
yarn-error.log
|
||||
.next
|
||||
.history
|
||||
.dockerignore
|
||||
.DS_Store
|
||||
.eslintignore
|
||||
.editorconfig
|
||||
.gitignore
|
||||
.prettierignore
|
||||
.eslintcache
|
||||
*.lock
|
||||
*.png
|
||||
*.eot
|
||||
*.ttf
|
||||
*.woff
|
|
@ -0,0 +1,29 @@
|
|||
dist
|
||||
back
|
||||
public
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
docker
|
||||
Dockerfile*
|
||||
LICENSE
|
||||
yarn-error.log
|
||||
.next
|
||||
.history
|
||||
.docusaurus
|
||||
.dockerignore
|
||||
.DS_Store
|
||||
.eslintignore
|
||||
.editorconfig
|
||||
.gitignore
|
||||
.prettierignore
|
||||
.eslintcache
|
||||
*.lock
|
||||
*.js
|
||||
*.tsx
|
||||
*.ts
|
||||
*.json
|
||||
*.png
|
||||
*.eot
|
||||
*.ttf
|
||||
*.woff
|
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
extends: [require.resolve('@3rapp/core/stylelint')],
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind-config.ts",
|
||||
"css": "@/app/styles/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": "tw-"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/app/_components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/app/_components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
import createMDX from '@next/mdx';
|
||||
import rehypePrism from 'rehype-prism-plus';
|
||||
|
||||
const withMDX = createMDX({
|
||||
options: {
|
||||
rehypePlugins: [[rehypePrism, { showLineNumbers: true }]],
|
||||
},
|
||||
});
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
experimental: {
|
||||
cpus: 28,
|
||||
},
|
||||
transpilePackages: ['@3rapp/store'],
|
||||
pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'],
|
||||
};
|
||||
|
||||
export default withMDX(nextConfig);
|
|
@ -0,0 +1,96 @@
|
|||
{
|
||||
"name": "web2",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"------------------ db command": "----",
|
||||
"------ db command": "生成创建数据库表结构的sql文件",
|
||||
"dbmc": "cross-env NODE_ENV=development prisma migrate dev --create-only --skip-generate",
|
||||
"-------- db command": "应用上面的sql文件到数据库中",
|
||||
"dbp": "cross-env NODE_ENV=development prisma db push",
|
||||
"------------------------- db command": "生成prisma client代码.生成ts文件(数据操作的类型、方法等)",
|
||||
"dbg": "cross-env NODE_ENV=development prisma generate",
|
||||
"- db command": "上面3个命令合并执行,pnpm dbm --name=update",
|
||||
"dbm": "cross-env NODE_ENV=development prisma migrate dev --skip-seed",
|
||||
"dbms": "cross-env NODE_ENV=development prisma migrate dev",
|
||||
"------------- db command": "重置数据库,删除所有数据,如何重新生成数据库表结构",
|
||||
"dbmr": "cross-env NODE_ENV=development prisma migrate reset -f --skip-seed",
|
||||
"dbmrs": "cross-env NODE_ENV=development prisma migrate reset -f",
|
||||
"---------- db command": "部署数据库变更到生产环境",
|
||||
"dbmd": "cross-env NODE_ENV=production prisma migrate deploy",
|
||||
"dbs": "prisma db seed",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@3rapp/utils": "workspace:*",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@mdx-js/loader": "^3.0.1",
|
||||
"@mdx-js/react": "^3.0.1",
|
||||
"@next/mdx": "^14.2.13",
|
||||
"@prisma/client": "5.17.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@vavt/cm-extension": "^1.5.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.441.0",
|
||||
"md-editor-rt": "^4.20.2",
|
||||
"micromatch": "^4.0.8",
|
||||
"next": "14.2.12",
|
||||
"next-mdx-remote": "^5.0.0",
|
||||
"prism-themes": "^1.9.0",
|
||||
"prisma-paginate": "^5.2.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-use": "^17.5.0",
|
||||
"rehype-prism-plus": "^2.0.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^10.0.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@3rapp/core": "workspace:*",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/node": "^20.12.12",
|
||||
"@types/react": "^18.3.2",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"postcss": "^8.4.40",
|
||||
"postcss-import": "^16.1.0",
|
||||
"postcss-mixins": "^10.0.1",
|
||||
"postcss-nested": "^6.0.1",
|
||||
"postcss-nesting": "^12.1.4",
|
||||
"prisma": "^5.16.1",
|
||||
"prisma-extension-bark": "^0.2.2",
|
||||
"stylelint": "^16.5.0",
|
||||
"stylelint-config-css-modules": "^4.4.0",
|
||||
"stylelint-config-recess-order": "^5.0.1",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"stylelint-prettier": "^5.0.2",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.4.5",
|
||||
"utility-types": "^3.11.0"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "src/database/schema",
|
||||
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} -r tsconfig-paths/register src/database/seed/index.ts"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'postcss-nesting': {},
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'postcss-mixins': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 146 KiB |
After Width: | Height: | Size: 60 KiB |
After Width: | Height: | Size: 147 KiB |
After Width: | Height: | Size: 83 KiB |
After Width: | Height: | Size: 52 KiB |
After Width: | Height: | Size: 178 KiB |
After Width: | Height: | Size: 80 KiB |
|
@ -0,0 +1,3 @@
|
|||
export default function Default(): any {
|
||||
return null;
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { FC, PropsWithChildren } from 'react';
|
||||
|
||||
import { EditorModal } from '@/app/_components/modal/editor-modal';
|
||||
import { PostActionForm } from '@/app/_components/post/action-form';
|
||||
|
||||
const PostCreatePage: FC<PropsWithChildren> = () => {
|
||||
return (
|
||||
<EditorModal title="创建文章" match={['/post-create']}>
|
||||
<PostActionForm type="create" />
|
||||
</EditorModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostCreatePage;
|
|
@ -0,0 +1,11 @@
|
|||
import { notFound } from 'next/navigation';
|
||||
import { FC } from 'react';
|
||||
|
||||
import { PostActionForm } from '@/app/_components/post/action-form';
|
||||
import { queryPostItemById } from '@/app/actions/post';
|
||||
|
||||
export const PostEditForm: FC<{ id: string }> = async ({ id }) => {
|
||||
const post = await queryPostItemById(id);
|
||||
if (!post) return notFound();
|
||||
return <PostActionForm type="update" post={post} />;
|
||||
};
|
|
@ -0,0 +1,14 @@
|
|||
import { FC } from 'react';
|
||||
|
||||
import { EditorModal } from '@/app/_components/modal/editor-modal';
|
||||
|
||||
import { PostEditForm } from './form';
|
||||
|
||||
const PostEditPage: FC<{ params: { item: string } }> = async ({ params: { item } }) => {
|
||||
return (
|
||||
<EditorModal title="编辑文章" match={['/post-edit/*']}>
|
||||
<PostEditForm id={item} />
|
||||
</EditorModal>
|
||||
);
|
||||
};
|
||||
export default PostEditPage;
|
|
@ -0,0 +1,3 @@
|
|||
import { default as DefaultHomePage } from './page';
|
||||
|
||||
export default DefaultHomePage;
|
|
@ -0,0 +1,15 @@
|
|||
import React, { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
import { Header } from '../_components/header';
|
||||
|
||||
const appLayout: React.FC<PropsWithChildren & { modal: ReactNode }> = ({ children, modal }) => (
|
||||
<>
|
||||
<div className=" tw-app-layout">
|
||||
<Header />
|
||||
|
||||
{children}
|
||||
</div>
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
export default appLayout;
|
|
@ -0,0 +1,18 @@
|
|||
import { Tools } from '@/app/_components/home/tools';
|
||||
import $styles from '../post/[item]/page.module.css';
|
||||
import Content from './content.mdx';
|
||||
import { MdxTitle } from './title.tsx'
|
||||
|
||||
<div className="tw-page-container">
|
||||
<Tools back />
|
||||
<div className={$styles.item}>
|
||||
<div className={$styles.content}>
|
||||
<header className={$styles.title}>
|
||||
<MdxTitle />
|
||||
</header>
|
||||
<div className={$styles.body}>
|
||||
<Content />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,3 @@
|
|||
import { FC } from 'react';
|
||||
|
||||
export const MdxTitle: FC = () => <h1>class-validator和class-transformer的中文文档</h1>;
|
|
@ -0,0 +1,6 @@
|
|||
.block {
|
||||
@apply tw-flex tw-flex-col !tw-flex-none tw-w-full tw-space-y-4 tw-p-5 tw-items-center tw-justify-center
|
||||
tw-rounded-sm tw-backdrop-blur-sm tw-shadow-nysm tw-shadow-neutral-800 tw-duration-300
|
||||
tw-text-lg tw-text-center tw-text-white tw-bg-black/50
|
||||
hover:tw-shadow-nylg hover:tw-shadow-neutral-200;
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
import { isNil } from 'lodash';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { AiOutlineCalendar } from 'react-icons/ai';
|
||||
|
||||
import { Tools } from '../_components/home/tools';
|
||||
import { PostDelete } from '../_components/post/delete';
|
||||
import { PostEditButton } from '../_components/post/edit-button';
|
||||
import { PostListPaginate } from '../_components/post/paginate';
|
||||
import { queryPostPaginate } from '../actions/post';
|
||||
|
||||
const App: React.FC<{ searchParams: Record<string, any> }> = async ({ searchParams }) => {
|
||||
const page =
|
||||
isNil(searchParams.page) || Number(searchParams.page) < 1 ? 1 : Number(searchParams.page);
|
||||
const limit = isNil(searchParams.limit) ? 10 : Number(searchParams.limit);
|
||||
const { items, meta } = await queryPostPaginate({ page, limit });
|
||||
if (meta.totalPages && meta.totalPages > 0 && page > meta.totalPages) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className=" tw-page-container">
|
||||
<Tools />
|
||||
<div className=" tw-w-full ">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className=" hover:before:tw-opacity-100
|
||||
last:tw-mb-0 tw-rounded-md tw-mb-8 tw-flex tw-flex-col tw-backdrop-blur-md tw-duration-300 tw-drop-shadow-[5px_5px_5px_rgba(0,0,0,0.35)]"
|
||||
>
|
||||
<Link
|
||||
href={`/post/${item.id}`}
|
||||
className="tw-relative tw-w-full tw-h-36 md:tw-h-48 lg:tw-h-72 tw-block"
|
||||
>
|
||||
<Image
|
||||
className=" tw-rounded-tl-md tw-rounded-tr-md tw-opacity-60"
|
||||
src={item.thumb}
|
||||
fill
|
||||
alt=""
|
||||
unoptimized
|
||||
priority
|
||||
sizes="100%"
|
||||
/>
|
||||
</Link>
|
||||
<div className=" hover:tw-bg-white tw-w-full tw-bg-zinc-100/80 tw-rounded-bl-md tw-rounded-br-md tw-px-5">
|
||||
<div className=" tw-py-3">
|
||||
<Link
|
||||
className=" tw-w-full tw-block tw-overflow-hidden"
|
||||
href={`/post/${item.id}`}
|
||||
>
|
||||
<h2 className=" tw-text-lg tw-font-bold tw-ellips tw-animate-decoration tw-animate-decoration-lg">
|
||||
{item.title}
|
||||
</h2>
|
||||
</Link>
|
||||
</div>
|
||||
<div className=" tw-py-3 tw-text-sm">
|
||||
{isNil(item.summary) ? item.body.substring(0, 99) : item.summary}
|
||||
</div>
|
||||
<div className=" tw-py-3 tw-flex tw-justify-between">
|
||||
<div className=" tw-flex tw-items-center">
|
||||
<span>
|
||||
<AiOutlineCalendar />
|
||||
</span>
|
||||
<time dateTime="2024-08-10" />
|
||||
</div>
|
||||
<div className="flex tw-items-center">
|
||||
<PostEditButton id={item.id} />
|
||||
{/* 删除按钮 */}
|
||||
<PostDelete id={item.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{meta.totalPages! > 1 && (
|
||||
<PostListPaginate totalPages={meta.totalPages} page={page} limit={limit} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default App;
|
|
@ -0,0 +1,31 @@
|
|||
.item {
|
||||
@apply tw-bg-white/80 tw-flex-auto tw-w-full tw-drop-shadow-lg tw-rounded-md tw-flex tw-flex-col;
|
||||
|
||||
& > .thumb {
|
||||
@apply tw-relative tw-w-full tw-h-36 md:tw-h-48 lg:tw-h-64 tw-block;
|
||||
}
|
||||
|
||||
& > .thumb img {
|
||||
@apply tw-rounded-tl-md tw-rounded-tr-md tw-opacity-60;
|
||||
}
|
||||
|
||||
& > .content {
|
||||
@apply tw-p-3 tw-flex tw-flex-col;
|
||||
|
||||
& > .title {
|
||||
@apply tw-my-2;
|
||||
}
|
||||
|
||||
& > .meta {
|
||||
@apply tw-flex tw-my-2 tw-justify-between;
|
||||
|
||||
& > div {
|
||||
@apply tw-flex tw-items-center;
|
||||
}
|
||||
|
||||
& time {
|
||||
@apply tw-ml-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import Image from 'next/image';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { FC } from 'react';
|
||||
|
||||
import { Tools } from '@/app/_components/home/tools';
|
||||
import { MarkdownPreview } from '@/app/_components/markdown/preivew';
|
||||
import { queryPostItemById } from '@/app/actions/post';
|
||||
|
||||
const PostItemPage: FC<{ params: { item: string } }> = async ({ params }) => {
|
||||
const post = await queryPostItemById(params.item);
|
||||
if (!post) return notFound();
|
||||
return (
|
||||
<div className="tw-page-container">
|
||||
<Tools back />
|
||||
<div className="tw-bg-white/80 tw-flex-auto tw-w-full tw-drop-shadow-lg tw-rounded-md tw-flex tw-flex-col">
|
||||
<div className=" tw-relative tw-w-full tw-h-64 tw-overflow-hidden">
|
||||
<Image
|
||||
className=" tw-rounded-t-lg tw-opacity-50"
|
||||
src={post.thumb}
|
||||
alt={post.title}
|
||||
priority
|
||||
fill
|
||||
objectFit="cover"
|
||||
layout="fill"
|
||||
sizes="100%"
|
||||
/>
|
||||
</div>
|
||||
<div className=" tw-my-2">
|
||||
<header>
|
||||
<h1 className=" tw-font-bold">{post.title}</h1>
|
||||
</header>
|
||||
<div>
|
||||
<div className=" tw-mx-auto tw-justify-center tw-flex tw-mb-5">
|
||||
<time className="tw-ellips tw-mx-auto" dateTime="2024-08-10">
|
||||
{post.updatedAt.toString()}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
<MarkdownPreview text={post.body} previewTheme="arknights" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default PostItemPage;
|
|
@ -0,0 +1,23 @@
|
|||
'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" />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
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,7 @@
|
|||
import { Logo } from './logo';
|
||||
|
||||
export const Header = () => (
|
||||
<header className=" tw-flex tw-justify-center tw-items-center tw-pt-6 tw-max-h-24 tw-flex-auto">
|
||||
<Logo />
|
||||
</header>
|
||||
);
|
|
@ -0,0 +1,20 @@
|
|||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import abc from './next.svg';
|
||||
|
||||
import $styles from './page.module.css';
|
||||
|
||||
export const Logo = () => (
|
||||
<Link href="/" className={$styles.link}>
|
||||
<Image
|
||||
src={abc}
|
||||
alt="avatar logo"
|
||||
sizes="100vw"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
|
@ -0,0 +1,14 @@
|
|||
.link {
|
||||
@apply tw-w-20 tw-h-20 tw-block tw-rounded-full tw-shadow-nymd tw-p-1 tw-bg-white;
|
||||
transition-duration: 300ms;
|
||||
animation: breathe-light 4s ease-in-out infinite;
|
||||
|
||||
&:hover {
|
||||
@apply tw-shadow-nylg tw-shadow-amber-400;
|
||||
transform: scale(1.2) rotate(360deg);
|
||||
}
|
||||
|
||||
& > img {
|
||||
@apply tw-rounded-full;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { MouseEventHandler, useCallback, useEffect, useState } from 'react';
|
||||
import { TiArrowBack } from 'react-icons/ti';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export const BackButton = () => {
|
||||
const router = useRouter();
|
||||
const [historyLength, setHistoryLength] = useState(0);
|
||||
const goback: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
historyLength > 1 && router.back();
|
||||
},
|
||||
[historyLength],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setHistoryLength(window.history.length);
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={clsx('tw-rounded-sm', {
|
||||
'tw-pointer-events-none tw-opacity-50': historyLength <= 1,
|
||||
})}
|
||||
disabled={historyLength <= 1}
|
||||
aria-disabled={historyLength <= 1}
|
||||
onClick={goback}
|
||||
>
|
||||
返回 <TiArrowBack />
|
||||
</Button>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,12 @@
|
|||
import { CreateButton } from '../post/create-button';
|
||||
|
||||
import { BackButton } from './back-button';
|
||||
|
||||
export const Tools: React.FC<{ back?: boolean }> = ({ back }) => {
|
||||
return (
|
||||
<div className=" tw-flex tw-justify-between tw-my-5 mx3 tw-w-full tw-max-h-12">
|
||||
{back && <BackButton />}
|
||||
<CreateButton />
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,51 @@
|
|||
'use client';
|
||||
|
||||
import { isNil } from 'lodash';
|
||||
import { ExposeParam, MdEditor } from 'md-editor-rt';
|
||||
import 'md-editor-rt/lib/style.css';
|
||||
import { FC, forwardRef, useRef, useState } from 'react';
|
||||
import { useMount } from 'react-use';
|
||||
|
||||
import { MarkdownEditorProps } from './type';
|
||||
|
||||
export const MarkdownEditor: FC<MarkdownEditorProps> = forwardRef((props, _) => {
|
||||
const { content, setContent, handlers, ...rest } = props;
|
||||
const editorRef = useRef<ExposeParam>();
|
||||
const [pageFullscreen, setPageFullScreen] = useState<boolean>(false);
|
||||
// useMount钩子会在组件挂载(第一次渲染)后执行其内部的函数
|
||||
useMount(() => {
|
||||
/**
|
||||
* 绑定页面全屏事件: pageFullscreen
|
||||
* 当编辑器在页面级别的全屏状态改变时,调用setPageFullScreen设置pageFullscreen的值
|
||||
* 如果有外部父组件传入的页面全屏函数则继续调用该函数
|
||||
*/
|
||||
|
||||
editorRef.current?.on('pageFullscreen', (value) => {
|
||||
setPageFullScreen(value);
|
||||
if (!isNil(handlers?.onPageScreen)) {
|
||||
handlers.onPageScreen(value);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* 绑定浏览器全屏事件: fullscreen
|
||||
* 当编辑器在浏览器级别的全屏状态改变时,把页面级别的全屏状态设置为false(根据上面绑定的pageFullscreen事件,所以同时也会把pageFullscreen设置为false)
|
||||
* 如果有外部父组件传入的浏览器全屏函数则继续调用该函数
|
||||
*/
|
||||
editorRef.current?.on('fullscreen', (value) => {
|
||||
editorRef.current?.togglePageFullscreen(false);
|
||||
if (!isNil(handlers?.onBroswerScreen)) {
|
||||
handlers.onBroswerScreen(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
return (
|
||||
<MdEditor
|
||||
{...rest}
|
||||
editorId="markdown-editor"
|
||||
modelValue={content}
|
||||
onChange={setContent}
|
||||
pageFullscreen={pageFullscreen}
|
||||
ref={editorRef}
|
||||
/>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,71 @@
|
|||
'use client';
|
||||
|
||||
import { isNil } from 'lodash';
|
||||
import { FC, PropsWithChildren, useRef, useState } from 'react';
|
||||
|
||||
export const MdxCodeCopy: FC<PropsWithChildren & Record<string, any>> = ({ children, ...rest }) => {
|
||||
const textInput = useRef<HTMLDivElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onEnter = () => {
|
||||
setHovered(true);
|
||||
};
|
||||
const onExit = () => {
|
||||
setHovered(false);
|
||||
setCopied(false);
|
||||
};
|
||||
const onCopy = () => {
|
||||
setCopied(true);
|
||||
!isNil(textInput.current?.textContent) &&
|
||||
navigator.clipboard.writeText(textInput.current.textContent);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
};
|
||||
return (
|
||||
<div ref={textInput} onMouseEnter={onEnter} onMouseLeave={onExit} className="tw-relative">
|
||||
{hovered && (
|
||||
<button
|
||||
aria-label="Copy code"
|
||||
type="button"
|
||||
className={`tw-absolute tw-right-2 tw-top-2 tw-h-8 tw-w-8 tw-rounded tw-border-2 tw-bg-gray-700 tw-p-1 dark:tw-bg-gray-800 ${
|
||||
copied
|
||||
? 'tw-border-green-400 focus:tw-border-green-400 focus:tw-outline-none'
|
||||
: 'tw-border-gray-300'
|
||||
}`}
|
||||
onClick={onCopy}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
className={copied ? 'tw-text-green-400' : 'tw-text-gray-300'}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<pre {...rest}>{children}</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
import { FC, PropsWithChildren } from 'react';
|
||||
|
||||
import { MdxCodeCopy } from './mdx-code-copy';
|
||||
|
||||
export const MDXPre: FC<PropsWithChildren & { copyEnabled?: boolean } & Record<string, any>> = ({
|
||||
copyEnabled = true,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return copyEnabled ? (
|
||||
<MdxCodeCopy {...props}>{children}</MdxCodeCopy>
|
||||
) : (
|
||||
<pre {...props}>{children}</pre>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,21 @@
|
|||
import { deepMerge } from '@3rapp/utils';
|
||||
import { MDXRemoteProps, MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { FC } from 'react';
|
||||
import rehypePrism from 'rehype-prism-plus';
|
||||
|
||||
import { MDXPre } from './mdx-pre';
|
||||
|
||||
const defaultMdxOptions: Omit<MDXRemoteProps, 'source'> = {
|
||||
options: {
|
||||
mdxOptions: {
|
||||
rehypePlugins: [[rehypePrism, { showLineNumbers: true }]],
|
||||
},
|
||||
},
|
||||
components: {
|
||||
pre: MDXPre,
|
||||
},
|
||||
};
|
||||
|
||||
export const MdxRemoteRender: FC<MDXRemoteProps> = (props) => {
|
||||
return <MDXRemote {...(deepMerge(defaultMdxOptions, props, 'merge') as MDXRemoteProps)} />;
|
||||
};
|
|
@ -0,0 +1,17 @@
|
|||
'use client';
|
||||
|
||||
import { MdPreview } from 'md-editor-rt';
|
||||
import 'md-editor-rt/lib/preview.css';
|
||||
import { FC } from 'react';
|
||||
|
||||
import { MarkdownPreviewProps } from './type';
|
||||
|
||||
export const MarkdownPreview: FC<MarkdownPreviewProps> = (props) => {
|
||||
return (
|
||||
<MdPreview
|
||||
editorId="markdown-preview-editor"
|
||||
modelValue={props.text}
|
||||
previewTheme={props.previewTheme ?? 'default'}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* 使用md-editor-rt的markdown编辑器组件的参数类型
|
||||
*/
|
||||
export type MarkdownEditorProps = {
|
||||
/**
|
||||
* 预览部分的主题样式
|
||||
*/
|
||||
previewTheme?: string;
|
||||
/**
|
||||
* 编辑器中的内容
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* 编辑器中内容改变后触发的函数
|
||||
* @param content
|
||||
*/
|
||||
setContent: (content: string) => void;
|
||||
/**
|
||||
* 一些额外的处理器函数
|
||||
*/
|
||||
handlers?: {
|
||||
/**
|
||||
* 浏览器全屏切换事件处理器
|
||||
* @param value
|
||||
*/
|
||||
onBroswerScreen?: (value: boolean) => void;
|
||||
/**
|
||||
* 页面全屏事件处理器
|
||||
* @param value
|
||||
*/
|
||||
onPageScreen?: (value: boolean) => void;
|
||||
};
|
||||
} & Record<string, any>;
|
||||
/**
|
||||
* markdown预览组件的参数类型
|
||||
*/
|
||||
export type MarkdownPreviewProps = {
|
||||
/**
|
||||
* 编辑器ID,默认为"markdown-preview-editor"
|
||||
*/
|
||||
editorId?: string;
|
||||
/**
|
||||
* 主题风格,这里没有定义为"theme",因为这是为后续明暗主题切换保留的
|
||||
*/
|
||||
previewTheme?: string;
|
||||
/**
|
||||
* md文档的内容
|
||||
*/
|
||||
text: string;
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
import { createContext } from 'react';
|
||||
|
||||
import { EditorModalState } from './types';
|
||||
|
||||
export const EditorModalContext = createContext<EditorModalState | null>(null);
|
|
@ -0,0 +1,27 @@
|
|||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { createContext, FC, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { PageModal } from './page-modal';
|
||||
import { EditorModalState, PageModalProps } from './types';
|
||||
|
||||
export const EditorModal: FC<PageModalProps> = ({ children, className, ...rest }) => {
|
||||
const EditorModalContext = createContext<EditorModalState | null>(null);
|
||||
const [fullScreen, setFullScreen] = useState(false);
|
||||
const editorFullScreen = useCallback(
|
||||
(v: boolean) => setFullScreen(document.fullscreenElement !== null || v),
|
||||
[],
|
||||
);
|
||||
const fullscreenClassName = useMemo(
|
||||
() => (fullScreen ? '!tw-max-w-[100%] sm:!tw-max-w-[100%] tw-h-full' : ''),
|
||||
[fullScreen],
|
||||
);
|
||||
const value = useMemo<EditorModalState>(() => ({ editorFullScreen }), [editorFullScreen]);
|
||||
|
||||
return (
|
||||
<PageModal {...rest} className={clsx(className, fullscreenClassName)}>
|
||||
<EditorModalContext.Provider value={value}>{children}</EditorModalContext.Provider>
|
||||
</PageModal>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,12 @@
|
|||
import { isNil } from 'lodash';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import { EditorModalState } from './types';
|
||||
|
||||
export const useEditorModalContext = (): EditorModalState => {
|
||||
const EditorModalContext = createContext<EditorModalState | null>(null);
|
||||
const context = useContext(EditorModalContext);
|
||||
|
||||
if (isNil(context)) return {};
|
||||
return context;
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
.modalWrapper {
|
||||
@apply tw-w-full tw-h-full tw-flex-auto;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
@apply tw-overflow-y-auto tw-max-h-[80vh] tw-w-full tw-px-3;
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { trim } from 'lodash';
|
||||
import glob from 'micromatch';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { FC, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
|
||||
import { PageModalProps } from './types';
|
||||
|
||||
export const PageModal: FC<PageModalProps> = ({ title, match, className, children }) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [show, setShow] = useState(false);
|
||||
useEffect(() => {
|
||||
setShow(
|
||||
glob.isMatch(
|
||||
trim(pathname, '/'),
|
||||
match.map((m) => trim(m, '/')),
|
||||
),
|
||||
);
|
||||
}, [pathname, ...match]);
|
||||
const close = useCallback(() => router.back(), []);
|
||||
return show ? (
|
||||
<div className="tw-w-full tw-h-full tw-flex-auto">
|
||||
<Dialog open defaultOpen onOpenChange={close}>
|
||||
<DialogContent
|
||||
className={clsx('sm:tw-max-w-[80%]', className)}
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
<div className="tw-overflow-y-auto tw-max-h-[80vh] tw-w-full tw-px-3">
|
||||
{children}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
|
@ -0,0 +1,22 @@
|
|||
import { PropsWithChildren } from 'react';
|
||||
|
||||
/**
|
||||
* 页面弹出框的参数类型
|
||||
*/
|
||||
export type PageModalProps = PropsWithChildren<{
|
||||
/**
|
||||
* 弹出框标题
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* 匹配的路由(只有当当前路由包含在此选项内时,才有可能显示弹出框)
|
||||
*/
|
||||
match: string[];
|
||||
/**
|
||||
* 自定义的DialogContent样式类
|
||||
*/
|
||||
className?: string;
|
||||
}>;
|
||||
export type EditorModalState = {
|
||||
editorFullScreen?: (value: boolean) => void;
|
||||
};
|
|
@ -0,0 +1,72 @@
|
|||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { FC, useCallback, useEffect } from 'react';
|
||||
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '../ui/pagination';
|
||||
|
||||
const SimplePaginate: FC<{ totalPages: number; currentPage: number }> = ({
|
||||
totalPages,
|
||||
currentPage,
|
||||
}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const getPageUrl = useCallback(
|
||||
(page: number) => {
|
||||
const parems = new URLSearchParams(searchParams);
|
||||
page <= 1 ? parems.delete('page') : parems.set('page', page.toString());
|
||||
return pathname + (parems.toString() ? `?${parems.toString()}` : '');
|
||||
},
|
||||
[searchParams],
|
||||
);
|
||||
useEffect(() => {
|
||||
// 在当前页面小于等于1时,删除URL中的页面查询参数
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (currentPage <= 1) params.delete('page');
|
||||
router.replace(pathname + (params.toString() ? `?${params.toString()}` : ''));
|
||||
}, [currentPage]);
|
||||
if (currentPage < 1) return null;
|
||||
return (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
className={clsx(
|
||||
'tw-rounded-sm,',
|
||||
currentPage <= 1
|
||||
? ' tw-shadow-gray-50 tw-bg-slate-50/70'
|
||||
: ' tw-bg-white/90 hover:tw-shadow-nylg hover:tw-shadow-white',
|
||||
)}
|
||||
href={getPageUrl(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
aria-label="访问上一页"
|
||||
text="上一页"
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
className={clsx(
|
||||
'tw-rounded-sm,',
|
||||
currentPage >= totalPages
|
||||
? ' tw-shadow-gray-50 tw-bg-slate-50/70'
|
||||
: ' tw-bg-white/90 hover:tw-shadow-nylg hover:tw-shadow-white',
|
||||
)}
|
||||
href={getPageUrl(currentPage + 1)}
|
||||
disabled={totalPages <= currentPage}
|
||||
aria-label="访问下一页"
|
||||
text="下一页"
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
);
|
||||
};
|
||||
export default SimplePaginate;
|
|
@ -0,0 +1,97 @@
|
|||
'use client';
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
||||
|
||||
import { MarkdownEditor } from '../markdown/editor';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '../ui/form';
|
||||
import { Input } from '../ui/input';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
|
||||
import { usePostActionForm, usePostEditorScreenHandler, usePostFormSubmitHandler } from './hooks';
|
||||
import { PostActionFormProps, PostCreateFormRef } from './types';
|
||||
|
||||
export const PostActionForm = forwardRef<PostCreateFormRef, PostActionFormProps>((props, ref) => {
|
||||
// 表单中的数据值获取
|
||||
const form = usePostActionForm(
|
||||
props.type === 'create' ? { type: props.type } : { type: props.type, post: props.post },
|
||||
);
|
||||
const submitHandler = usePostFormSubmitHandler(
|
||||
props.type === 'create' ? { type: 'create' } : { type: 'update', post: props.post },
|
||||
);
|
||||
const [body, setBody] = useState(props.type === 'create' ? '正文' : props.post.body);
|
||||
const PostEditorScreenHandler = usePostEditorScreenHandler();
|
||||
useEffect(() => {
|
||||
form.setValue('body', body);
|
||||
}, [body]);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() =>
|
||||
props.type === 'create'
|
||||
? {
|
||||
create: form.handleSubmit(submitHandler),
|
||||
}
|
||||
: {},
|
||||
[props.type],
|
||||
);
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(submitHandler)} className="tw-space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>文章标题</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="请输入标题" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="summary"
|
||||
render={({ field }) => (
|
||||
<FormItem className="tw-mt-2 tw-pb-1 tw-border-b tw-border-dashed">
|
||||
<FormLabel>摘要简述</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="请输入文章摘要" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>摘要会显示在文章列表页</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="body"
|
||||
render={({ field }) => (
|
||||
<FormItem className=" tw-flex tw-flex-col">
|
||||
<FormLabel>文章内容</FormLabel>
|
||||
<FormControl>
|
||||
<MarkdownEditor
|
||||
{...field}
|
||||
content={body}
|
||||
setContent={setBody}
|
||||
handlers={PostEditorScreenHandler}
|
||||
previewTheme="arknights"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,25 @@
|
|||
'use client';
|
||||
|
||||
import { isNil } from 'lodash';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useMemo } from 'react';
|
||||
import { IoMdAdd } from 'react-icons/io';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export const CreateButton = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const getUrlQuery = useMemo(() => {
|
||||
const query = new URLSearchParams(searchParams.toString()).toString();
|
||||
// 保留当前分页的url查询,不至于在打开创建文章后,导致首页的文章列表重置分页
|
||||
return isNil(query) || query.length < 1 ? '' : `?${query}`;
|
||||
}, [searchParams]);
|
||||
return (
|
||||
<Button asChild variant="outline" className=" tw-justify-end tw-rounded-sm tw-ml-auto">
|
||||
<Link href={`/post/create${getUrlQuery}`}>
|
||||
<IoMdAdd /> create
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,52 @@
|
|||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FC, useCallback } from 'react';
|
||||
|
||||
import { AiOutlineDelete } from 'react-icons/ai';
|
||||
|
||||
import { deletePostItem } from '@/app/actions/post';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '../ui/alert-dialog';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export const PostDelete: FC<{ id: string }> = ({ id }) => {
|
||||
const router = useRouter();
|
||||
const deletePost = useCallback(async () => {
|
||||
try {
|
||||
await deletePostItem(id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
router.refresh();
|
||||
}, []);
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<AiOutlineDelete className=" tw-mr-2" /> 删除
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle> 删除确认 </AlertDialogTitle>
|
||||
<AlertDialogDescription>确认删除该文章吗?</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={deletePost}>确认</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,26 @@
|
|||
'use client';
|
||||
|
||||
import { isNil } from 'lodash';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { CiEdit } from 'react-icons/ci';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export function PostEditButton({ id }: { id: string }) {
|
||||
const searchParams = useSearchParams();
|
||||
const getUrlQuery = useMemo(() => {
|
||||
const query = new URLSearchParams(searchParams.toString()).toString();
|
||||
return isNil(query) ? '' : `?${query}`;
|
||||
}, [searchParams]);
|
||||
return (
|
||||
<Button asChild className=" tw-mr-3">
|
||||
<Link href={`/post-edit/${id}${getUrlQuery}`}>
|
||||
<CiEdit className=" tw-mr-2" />
|
||||
编辑
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
'use client';
|
||||
|
||||
import { getRandomInt } from '@3rapp/utils';
|
||||
import { Post } from '@prisma/client';
|
||||
import { isNil } from 'lodash';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { createPostItem, updatePostItem } from '@/app/actions/post';
|
||||
|
||||
import { MarkdownEditorProps } from '../markdown/type';
|
||||
import { useEditorModalContext } from '../modal/hooks';
|
||||
|
||||
import { PostActionFormProps, PostCreateData, PostFormData, PostUpdateData } from './types';
|
||||
|
||||
export function usePostActionForm(params: PostActionFormProps) {
|
||||
const defaultValues = useMemo(() => {
|
||||
if (params.type === 'create') {
|
||||
return {
|
||||
title: '文章标题111',
|
||||
body: '文章内容111',
|
||||
summary: '212',
|
||||
} as NonNullable<PostCreateData>;
|
||||
}
|
||||
return {
|
||||
title: params.post.title,
|
||||
body: params.post.body,
|
||||
summary: isNil(params.post.summary) ? '' : params.post.summary,
|
||||
} as NonNullable<PostUpdateData>;
|
||||
}, [params.type]);
|
||||
|
||||
return useForm<NonNullable<PostFormData>>({ defaultValues });
|
||||
}
|
||||
|
||||
export function usePostFormSubmitHandler(params: PostActionFormProps) {
|
||||
const router = useRouter();
|
||||
const submitHandle = async (data: PostFormData) => {
|
||||
let post: Post | null;
|
||||
for (const key of Object.keys(data) as Array<keyof PostFormData>) {
|
||||
if (typeof data[key] === 'string' && data[key].trim() === '') {
|
||||
delete data[key];
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (params.type === 'update') {
|
||||
post = await updatePostItem(params.post.id, data);
|
||||
} else {
|
||||
console.log(data, 'create data');
|
||||
|
||||
post = await createPostItem({
|
||||
thumb: `/uploads/thumb/post-${getRandomInt(1, 8)}.png`,
|
||||
...data,
|
||||
} as PostCreateData);
|
||||
}
|
||||
// 创建或更新文章后跳转到文章详情页
|
||||
// 注意,这里不要用push,防止在详情页后退后返回到创建或编辑页面的弹出框
|
||||
if (!isNil(post)) router.replace(`/post/${post.id}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
return submitHandle;
|
||||
}
|
||||
|
||||
export const usePostEditorScreenHandler = () => {
|
||||
const { editorFullScreen } = useEditorModalContext();
|
||||
|
||||
return useMemo<MarkdownEditorProps['handlers']>(
|
||||
() => ({
|
||||
onBroswerScreen: editorFullScreen,
|
||||
onPageScreen: editorFullScreen,
|
||||
}),
|
||||
[editorFullScreen],
|
||||
);
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
import { FC } from 'react';
|
||||
|
||||
import SimplePaginate from '../paginate/simple';
|
||||
|
||||
export const PostListPaginate: FC<{ totalPages: number; page: number; limit: number }> = async ({
|
||||
totalPages,
|
||||
page,
|
||||
limit,
|
||||
}) => {
|
||||
return (
|
||||
<div className=" tw-w-full tw-flex-none tw-mb-5">
|
||||
<SimplePaginate totalPages={totalPages} currentPage={page} />
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,20 @@
|
|||
import { Post, Prisma } from '@prisma/client';
|
||||
import { BaseSyntheticEvent } from 'react';
|
||||
|
||||
export interface PostCreateFormProps {
|
||||
type: 'create';
|
||||
}
|
||||
export interface PostUpdateFormProps {
|
||||
type: 'update';
|
||||
post: Post;
|
||||
}
|
||||
export type PostActionFormProps = PostCreateFormProps | PostUpdateFormProps;
|
||||
export type PostCreateData = Prisma.PostCreateInput;
|
||||
export type PostUpdateData = Partial<Omit<Post, 'id'>> & Pick<Post, 'id'>;
|
||||
export type PostFormData = PostCreateData | PostUpdateData;
|
||||
/**
|
||||
* 文章创建表单的Ref类型
|
||||
*/
|
||||
export interface PostCreateFormRef {
|
||||
create?: (e?: BaseSyntheticEvent) => Promise<void>;
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
'use client';
|
||||
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import * as React from 'react';
|
||||
|
||||
import { buttonVariants } from '@/app/_components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'tw-fixed tw-inset-0 tw-z-50 tw-bg-black/80 data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=open]:tw-fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'tw-fixed tw-left-[50%] tw-top-[50%] tw-z-50 tw-grid tw-w-full tw-max-w-lg tw-translate-x-[-50%] tw-translate-y-[-50%] tw-gap-4 tw-border tw-bg-background tw-p-6 tw-shadow-lg tw-duration-200 data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=open]:tw-fade-in-0 data-[state=closed]:tw-zoom-out-95 data-[state=open]:tw-zoom-in-95 data-[state=closed]:tw-slide-out-to-left-1/2 data-[state=closed]:tw-slide-out-to-top-[48%] data-[state=open]:tw-slide-in-from-left-1/2 data-[state=open]:tw-slide-in-from-top-[48%] sm:tw-rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('tw-flex tw-flex-col tw-space-y-2 tw-text-center sm:tw-text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'tw-flex tw-flex-col-reverse sm:tw-flex-row sm:tw-justify-end sm:tw-space-x-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('tw-text-lg tw-font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('tw-text-sm tw-text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'tw-mt-2 sm:tw-mt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
|
@ -0,0 +1,57 @@
|
|||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'tw-inline-flex tw-items-center tw-justify-center tw-whitespace-nowrap tw-rounded-md tw-text-sm tw-font-medium tw-transition-colors focus-visible:tw-outline-none focus-visible:tw-ring-1 focus-visible:tw-ring-ring disabled:tw-pointer-events-none disabled:tw-opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'tw-bg-primary tw-text-primary-foreground tw-shadow hover:tw-bg-primary/90',
|
||||
destructive:
|
||||
'tw-bg-destructive tw-text-destructive-foreground tw-shadow-sm hover:tw-bg-destructive/90',
|
||||
outline:
|
||||
'tw-border tw-border-input tw-bg-background tw-shadow-sm hover:tw-bg-accent hover:tw-text-accent-foreground',
|
||||
secondary:
|
||||
'tw-bg-secondary tw-text-secondary-foreground tw-shadow-sm hover:tw-bg-secondary/80',
|
||||
ghost: 'hover:tw-bg-accent hover:tw-text-accent-foreground',
|
||||
link: 'tw-text-primary tw-underline-offset-4 hover:tw-underline',
|
||||
},
|
||||
size: {
|
||||
default: 'tw-h-9 tw-px-4 tw-py-2',
|
||||
sm: 'tw-h-8 tw-rounded-md tw-px-3 tw-text-xs',
|
||||
lg: 'tw-h-10 tw-rounded-md tw-px-8',
|
||||
icon: 'tw-h-9 tw-w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
|
@ -0,0 +1,122 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"tw-fixed tw-inset-0 tw-z-50 tw-bg-black/80 tw- data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=open]:tw-fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"tw-fixed tw-left-[50%] tw-top-[50%] tw-z-50 tw-grid tw-w-full tw-max-w-lg tw-translate-x-[-50%] tw-translate-y-[-50%] tw-gap-4 tw-border tw-bg-background tw-p-6 tw-shadow-lg tw-duration-200 data-[state=open]:tw-animate-in data-[state=closed]:tw-animate-out data-[state=closed]:tw-fade-out-0 data-[state=open]:tw-fade-in-0 data-[state=closed]:tw-zoom-out-95 data-[state=open]:tw-zoom-in-95 data-[state=closed]:tw-slide-out-to-left-1/2 data-[state=closed]:tw-slide-out-to-top-[48%] data-[state=open]:tw-slide-in-from-left-1/2 data-[state=open]:tw-slide-in-from-top-[48%] sm:tw-rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="tw-absolute tw-right-4 tw-top-4 tw-rounded-sm tw-opacity-70 tw-ring-offset-background tw-transition-opacity hover:tw-opacity-100 focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-ring focus:tw-ring-offset-2 disabled:tw-pointer-events-none data-[state=open]:tw-bg-accent data-[state=open]:tw-text-muted-foreground">
|
||||
<Cross2Icon className="tw-h-4 tw-w-4" />
|
||||
<span className="tw-sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"tw-flex tw-flex-col tw-space-y-1.5 tw-text-center sm:tw-text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"tw-flex tw-flex-col-reverse sm:tw-flex-row sm:tw-justify-end sm:tw-space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"tw-text-lg tw-font-semibold tw-leading-none tw-tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("tw-text-sm tw-text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
|
@ -0,0 +1,172 @@
|
|||
'use client';
|
||||
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from 'react-hook-form';
|
||||
|
||||
import { Label } from '@/app/_components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
const value = React.useMemo(() => ({ name: props.name }), [props.name]);
|
||||
return (
|
||||
<FormFieldContext.Provider value={value}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
|
||||
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
const value = React.useMemo(() => ({ id }), [id]);
|
||||
return (
|
||||
<FormItemContext.Provider value={value}>
|
||||
<div ref={ref} className={cn('tw-space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
FormItem.displayName = 'FormItem';
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && 'tw-text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = 'FormLabel';
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = 'FormControl';
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn('tw-text-[0.8rem] tw-text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = 'FormDescription';
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn('tw-text-[0.8rem] tw-font-medium tw-text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = 'FormMessage';
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
|
@ -0,0 +1,25 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"tw-flex tw-h-9 tw-w-full tw-rounded-md tw-border tw-border-input tw-bg-transparent tw-px-3 tw-py-1 tw-text-sm tw-shadow-sm tw-transition-colors file:tw-border-0 file:tw-bg-transparent file:tw-text-sm file:tw-font-medium file:tw-text-foreground placeholder:tw-text-muted-foreground focus-visible:tw-outline-none focus-visible:tw-ring-1 focus-visible:tw-ring-ring disabled:tw-cursor-not-allowed disabled:tw-opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
|
@ -0,0 +1,26 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"tw-text-sm tw-font-medium tw-leading-none peer-disabled:tw-cursor-not-allowed peer-disabled:tw-opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
|
@ -0,0 +1,125 @@
|
|||
import { ChevronLeftIcon, ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons';
|
||||
import clsx from 'clsx';
|
||||
import { isNil } from 'lodash';
|
||||
import * as React from 'react';
|
||||
|
||||
import { ButtonProps, buttonVariants } from '@/app/_components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn('tw-mx-auto tw-flex tw-w-full tw-justify-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Pagination.displayName = 'Pagination';
|
||||
|
||||
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn('tw-flex tw-flex-row tw-items-center tw-gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
PaginationContent.displayName = 'PaginationContent';
|
||||
|
||||
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<'li'>>(
|
||||
({ className, ...props }, ref) => <li ref={ref} className={cn('tw-', className)} {...props} />,
|
||||
);
|
||||
PaginationItem.displayName = 'PaginationItem';
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
disabled?: boolean;
|
||||
'aria-label'?: string;
|
||||
text?: string;
|
||||
} & Pick<ButtonProps, 'size'> &
|
||||
React.ComponentProps<'a'>;
|
||||
|
||||
const PaginationLink = ({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
size,
|
||||
}),
|
||||
clsx({ 'tw-pointer-events-none tw-opacity-50': props.disabled }),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
aria-disabled={props.disabled}
|
||||
href={isNil(props.href) ? ':' : props.href}
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
PaginationLink.displayName = 'PaginationLink';
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
text,
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label={ariaLabel || 'Go to previous page'}
|
||||
size="default"
|
||||
className={cn('tw-gap-1 tw-pl-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon className="tw-h-4 tw-w-4" />
|
||||
<span>{text}</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
};
|
||||
PaginationPrevious.displayName = 'PaginationPrevious';
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
text,
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label={ariaLabel || 'Go to next page'}
|
||||
size="default"
|
||||
className={cn('tw-gap-1 tw-pr-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<span>{text ?? 'Next'}</span>
|
||||
<ChevronRightIcon className="tw-h-4 tw-w-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationNext.displayName = 'PaginationNext';
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
text,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> & { text?: string }) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('tw-flex tw-h-9 tw-w-9 tw-items-center tw-justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<DotsHorizontalIcon className="tw-h-4 tw-w-4" />
|
||||
<span className="tw-sr-only">{text ?? 'More pages'}</span>
|
||||
</span>
|
||||
);
|
||||
PaginationEllipsis.displayName = 'PaginationEllipsis';
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
};
|
|
@ -0,0 +1,24 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"tw-flex tw-min-h-[60px] tw-w-full tw-rounded-md tw-border tw-border-input tw-bg-transparent tw-px-3 tw-py-2 tw-text-sm tw-shadow-sm placeholder:tw-text-muted-foreground focus-visible:tw-outline-none focus-visible:tw-ring-1 focus-visible:tw-ring-ring disabled:tw-cursor-not-allowed disabled:tw-opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
|
@ -0,0 +1,87 @@
|
|||
'use server';
|
||||
|
||||
import { Post, Prisma } from '@prisma/client';
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
import { revalidateTag } from 'next/cache';
|
||||
|
||||
import db from '@/lib/db/client';
|
||||
import { PaginateOptions, PaginateReturn } from '@/lib/db/types';
|
||||
import { paginateTransform } from '@/lib/db/utils';
|
||||
|
||||
/**
|
||||
* 查询分页文章列表信息
|
||||
* @param options
|
||||
*/
|
||||
export const queryPostPaginate = async (
|
||||
options?: PaginateOptions,
|
||||
): Promise<PaginateReturn<Post>> => {
|
||||
// 此处使用倒序,以便新增的文章可以排在最前面
|
||||
const posts = await db.post.paginate({
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
page: 1,
|
||||
limit: 8,
|
||||
...options,
|
||||
});
|
||||
return paginateTransform(posts);
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据查询条件获取文章总页数
|
||||
* @param limit
|
||||
*/
|
||||
export const queryPostTotalPages = async (limit = 8): Promise<number> => {
|
||||
const data = await queryPostPaginate({ page: 1, limit });
|
||||
return data.meta.totalPages ?? 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据ID查询文章信息
|
||||
* @param id
|
||||
*/
|
||||
export const queryPostItemById = async (id: string): Promise<Post | null> => {
|
||||
const item = await db.post.findUniqueOrThrow({ where: { id } });
|
||||
return item;
|
||||
};
|
||||
|
||||
/**
|
||||
* 新增文章
|
||||
* @param data
|
||||
*/
|
||||
export const createPostItem = async (data: Prisma.PostCreateInput): Promise<Post> => {
|
||||
const item = await db.post.create({ data });
|
||||
revalidateTag('posts');
|
||||
return item;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新文章
|
||||
* @param id
|
||||
* @param data
|
||||
*/
|
||||
export const updatePostItem = async (
|
||||
id: string,
|
||||
data: Partial<Omit<Prisma.PostCreateInput, 'id'>>,
|
||||
): Promise<Post | null> => {
|
||||
const item = await db.post.update({ where: { id }, data });
|
||||
revalidateTag('posts');
|
||||
return item;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除文章
|
||||
* @param id
|
||||
*/
|
||||
export const deletePostItem = async (id: string): Promise<null> => {
|
||||
const item = await db.post.findUniqueOrThrow({ where: { id } });
|
||||
if (isNil(item)) {
|
||||
console.log('文章不存在');
|
||||
|
||||
return null;
|
||||
}
|
||||
await db.post.delete({ where: { id } });
|
||||
revalidateTag('posts');
|
||||
console.log('文章删除成功');
|
||||
|
||||
return null;
|
||||
};
|
After Width: | Height: | Size: 25 KiB |
|
@ -0,0 +1,17 @@
|
|||
import { Metadata } from 'next';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
|
||||
import './styles/index.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'nextappweb2',
|
||||
description: '3r教室Next.js全栈开发课程',
|
||||
};
|
||||
const RootLayout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
export default RootLayout;
|
|
@ -0,0 +1,5 @@
|
|||
body {
|
||||
/* 设置全局背景图片 */
|
||||
@apply tw-bg-[url(images/bg.png)]
|
||||
tw-bg-fixed tw-bg-cover tw-bg-no-repeat tw-bg-center;
|
||||
}
|
After Width: | Height: | Size: 257 KiB |
|
@ -0,0 +1,13 @@
|
|||
@import 'tailwindcss/base';
|
||||
@import './tailwind/base.css';
|
||||
@import 'tailwindcss/components';
|
||||
@import './tailwind/components.css';
|
||||
@import 'tailwindcss/utilities';
|
||||
@import './tailwind/utilities.css';
|
||||
@import './vars.css';
|
||||
@import './app.css';
|
||||
|
||||
/* @import './prism.css'; */
|
||||
@import '@vavt/cm-extension/dist/previewTheme/arknights.css';
|
||||
|
||||
@config '../tailwind-config.ts';
|
|
@ -0,0 +1,170 @@
|
|||
/* 我选择自定义皮肤,使用prism-themes中的皮肤,或者自定义一个 */
|
||||
|
||||
/* @import 'prism-themes/themes/prism-one-dark.css'; */
|
||||
|
||||
/* 使用firecode最为代码字体. 无论使用自定义皮肤还是还是prism-themes中的皮肤,这里都可以设置为自己喜欢的代码字体 */
|
||||
code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
font-family: var(--font-family-code);
|
||||
}
|
||||
|
||||
/* 代码行数显示样式. 无论使用自定义皮肤还是还是prism-themes中的皮肤请务必添加(除非你关闭了'rehype-prism-plus'的行数显示功能) */
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-highlight {
|
||||
float: left; /* 1 */
|
||||
min-width: 100%; /* 2 */
|
||||
}
|
||||
|
||||
.code-line {
|
||||
display: block;
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
margin-right: -16px;
|
||||
margin-left: -16px;
|
||||
border-left: 4px solid rgb(0 0 0 / 0%); /* Set placeholder for highlight accent border color to transparent */
|
||||
}
|
||||
|
||||
.code-line.inserted {
|
||||
background-color: rgb(16 185 129 / 20%); /* Set inserted line (+) color */
|
||||
}
|
||||
|
||||
.code-line.deleted {
|
||||
background-color: rgb(239 68 68 / 20%); /* Set deleted line (-) color */
|
||||
}
|
||||
|
||||
.highlight-line {
|
||||
margin-right: -16px;
|
||||
margin-left: -16px;
|
||||
background-color: rgb(55 65 81 / 50%); /* Set highlight bg color */
|
||||
border-left: 4px solid rgb(59 130 246); /* Set highlight accent border color */
|
||||
}
|
||||
|
||||
.line-number::before {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
margin-right: 16px;
|
||||
margin-left: -8px;
|
||||
color: rgb(156 163 175); /* Line number color */
|
||||
text-align: right;
|
||||
content: attr(line);
|
||||
}
|
||||
|
||||
/* 以下为自定义皮肤,如果使用prism-themes中的皮肤,则下面的代码就不需要了 */
|
||||
code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
hyphens: none;
|
||||
line-height: 1.4;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
text-shadow: 0 1px 1px #000;
|
||||
word-wrap: normal;
|
||||
tab-size: 4;
|
||||
white-space: pre;
|
||||
background: none;
|
||||
border: 0;
|
||||
word-spacing: normal;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
pre[class*='language-'] code {
|
||||
float: left;
|
||||
padding: 0 15px 0 0;
|
||||
}
|
||||
|
||||
pre[class*='language-'],
|
||||
:not(pre) > code[class*='language-'] {
|
||||
background: #222;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*='language-'] {
|
||||
padding: 15px;
|
||||
margin: 1em 0;
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre) > code[class*='language-'] {
|
||||
padding: 5px 10px;
|
||||
line-height: 1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #797979;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.operator,
|
||||
.token.punctuation {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.token.namespace {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.token.tag,
|
||||
.token.boolean {
|
||||
color: #ffd893;
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.hex,
|
||||
.token.string {
|
||||
color: #b0c975;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.token.attr-name,
|
||||
.token.keyword {
|
||||
color: #c27628;
|
||||
}
|
||||
|
||||
.token.regex {
|
||||
color: #9b71c6;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.token.function,
|
||||
.token.constant {
|
||||
color: #e5a638;
|
||||
}
|
||||
|
||||
.token.variable {
|
||||
color: #fdfba8;
|
||||
}
|
||||
|
||||
.token.number {
|
||||
color: #8799b0;
|
||||
}
|
||||
|
||||
.token.important,
|
||||
.token.deliminator {
|
||||
color: #e45734;
|
||||
}
|
||||
|
||||
.line-highlight.line-highlight {
|
||||
background: rgb(255 255 255 / 20%);
|
||||
}
|
||||
|
||||
.line-highlight.line-highlight::before,
|
||||
.line-highlight.line-highlight[data-end]::after {
|
||||
top: 0.3em;
|
||||
color: #fff;
|
||||
background-color: rgb(255 255 255 / 30%);
|
||||
border-radius: 8px;
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
@layer base {
|
||||
* {
|
||||
@apply tw-border-border;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply tw-h-[100vh] tw-w-full tw-p-0 tw-m-0 tw-text-[var(--font-color-base)];
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-family-standard);
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
a {
|
||||
@apply tw-text-[var(--font-color-link)];
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply tw-text-3xl;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply tw-text-2xl;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply tw-text-xl;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply tw-text-lg;
|
||||
}
|
||||
|
||||
h5 {
|
||||
@apply tw-text-sm;
|
||||
}
|
||||
|
||||
h6 {
|
||||
@apply tw-text-xs;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
@layer components {
|
||||
/* 全局布局 */
|
||||
.tw-app-layout {
|
||||
@apply tw-bg-fixed tw-bg-cover tw-bg-no-repeat tw-bg-center tw-min-h-full tw-w-full tw-flex tw-p-0 tw-m-0 tw-flex-col;
|
||||
}
|
||||
|
||||
/* 页面容器 */
|
||||
.tw-page-container {
|
||||
@apply tw-flex tw-flex-auto tw-flex-col tw-mt-8 tw-items-center;
|
||||
}
|
||||
|
||||
/* 页面包装容器 */
|
||||
.tw-page-container > div {
|
||||
@apply tw-max-w-[90%] md:tw-max-w-[80%] lg:tw-max-w-[70%] xl:tw-max-w-[60%] tw-flex-auto tw-mb-5;
|
||||
}
|
||||
|
||||
/* 空白页样式,一般用于404和error页面 */
|
||||
.tw-page-blank {
|
||||
@apply tw-bg-white/80 tw-h-56 tw-w-full tw-drop-shadow-lg tw-rounded-md tw-flex tw-justify-center tw-items-center;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
@layer utilities {
|
||||
/* 鼠标移动到文字或链接加深颜色 */
|
||||
.tw-hover {
|
||||
@apply hover:tw-text-[var(--font-color-link-hover)];
|
||||
|
||||
/* 只显示一行文字, 溢出部分以省略号代替 */
|
||||
.tw-ellips {
|
||||
@apply tw-inline-block tw-overflow-hidden tw-max-w-full tw-whitespace-nowrap tw-text-ellipsis tw-break-all;
|
||||
}
|
||||
|
||||
/* 鼠标移动到文字或链接出现动画下划线 */
|
||||
.tw-animate-decoration {
|
||||
background: linear-gradient(var(--font-color-link-hover), var(--font-color-link-hover))
|
||||
0% 100% / 0% 1px no-repeat;
|
||||
transition: background-size ease-out 200ms;
|
||||
|
||||
&:not(:focus):hover {
|
||||
background-size: 100% 1px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 粗下划线 */
|
||||
.tw-animate-decoration-lg:not(:focus):hover {
|
||||
background-size: 100% 2px;
|
||||
}
|
||||
|
||||
/* 取消下划线 */
|
||||
.tw-none-animate-decoration {
|
||||
background-size: 0 !important;
|
||||
transition: none !important;
|
||||
|
||||
&:hover,
|
||||
&:not(:focus):hover {
|
||||
background-size: 100% 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
/* 这些变量是从shadcn/ui写入src/app/globals.css中的样式复制而来的 */
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 以下为自定义变量 */
|
||||
@layer base {
|
||||
:root {
|
||||
/* 默认文字大小 */
|
||||
--font-size-base: 0.875rem;
|
||||
|
||||
/* 默认字体 */
|
||||
--font-family-standard: 'Source Sans Pro', 'Hiragino Sans GB', 'Microsoft Yahei', simsun,
|
||||
helvetica, arial, sans-serif, monospace;
|
||||
|
||||
/* 默认代码字体 */
|
||||
--font-family-code: 'Fira Code', 'Inconsolata', 'Monaco', 'Consolas', 'Courier New',
|
||||
'Courier', monospace;
|
||||
|
||||
/* 默认文字颜色 */
|
||||
--font-color-base: #2d2929;
|
||||
|
||||
/* 链接文字颜色 */
|
||||
--font-color-link: #131212;
|
||||
|
||||
/* 鼠标移动到文字或链接上后的文字颜色 */
|
||||
--font-color-link-hover: #0b0b0b;
|
||||
}
|
||||
}
|
|
@ -16,7 +16,7 @@ const config = {
|
|||
md: '768px',
|
||||
lg: '992px',
|
||||
xl: '1200px',
|
||||
'2xl': '1400px',
|
||||
'2xl': '1401px',
|
||||
},
|
||||
},
|
||||
extend: {
|
|
@ -0,0 +1,10 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { truncateExt } from './extensions/truncate';
|
||||
|
||||
const prisma = new PrismaClient().$extends(
|
||||
truncateExt('mysql', {
|
||||
foreignKeyChecks: false,
|
||||
}),
|
||||
);
|
||||
export { prisma };
|
|
@ -0,0 +1,15 @@
|
|||
import type { ConnectorType } from './types';
|
||||
|
||||
export class PrismaExtensionsTruncateError extends Error {
|
||||
constructor(message: string) {
|
||||
super(`truncate: ${message}`);
|
||||
this.name = 'PrismaExtensionsError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PrismaExtensionsTruncateUnknownConnectorError extends PrismaExtensionsTruncateError {
|
||||
constructor(connector: ConnectorType) {
|
||||
super(`Unknown connector. You provided: \`${connector}\``);
|
||||
this.name = 'PrismaExtensionsTruncateUnknownConnectorError';
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { PrismaExtensionsTruncateUnknownConnectorError } from './errors';
|
||||
import type { ConnectorType, RootConfig } from './types';
|
||||
import { getConnectorExtension, supportedConnector } from './utils';
|
||||
|
||||
export function truncateExt<T extends ConnectorType>(connector: T, config?: RootConfig<T>) {
|
||||
const { ...rootConfig } = config || {};
|
||||
|
||||
if (!supportedConnector(connector))
|
||||
throw new PrismaExtensionsTruncateUnknownConnectorError(connector);
|
||||
|
||||
const extension = getConnectorExtension(connector);
|
||||
|
||||
return Prisma.defineExtension((client) => {
|
||||
return client.$extends({
|
||||
name: `truncate/${connector}`,
|
||||
model: {
|
||||
$allModels: {
|
||||
async $truncate<Model>(this: Model, localConfig?: RootConfig<T>) {
|
||||
const ctx = Prisma.getExtensionContext(this);
|
||||
const execConfig = { ...rootConfig, ...localConfig };
|
||||
const tableName = (client as any)._runtimeDataModel.models[ctx.$name!]
|
||||
.dbName;
|
||||
await extension(
|
||||
client as Prisma.DefaultPrismaClient,
|
||||
tableName,
|
||||
execConfig,
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export { truncateMySQLTable } from './mysql';
|
||||
export type { MySQLConfig, MySQLExtensionConfig } from './types';
|
|
@ -0,0 +1,24 @@
|
|||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import type { MySQLConfig } from './types';
|
||||
|
||||
export async function truncateMySQLTable(
|
||||
client: Prisma.DefaultPrismaClient,
|
||||
modelName: string,
|
||||
config: MySQLConfig = {},
|
||||
): Promise<void> {
|
||||
const { foreignKeyChecks = false } = config;
|
||||
|
||||
const sql = `TRUNCATE TABLE ${modelName};`;
|
||||
|
||||
if (foreignKeyChecks) {
|
||||
await client.$executeRawUnsafe(sql);
|
||||
return;
|
||||
}
|
||||
|
||||
await client.$transaction([
|
||||
client.$executeRawUnsafe(`SET FOREIGN_KEY_CHECKS=0;`),
|
||||
client.$executeRawUnsafe(sql),
|
||||
client.$executeRawUnsafe(`SET FOREIGN_KEY_CHECKS=1;`),
|
||||
]);
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
export interface MySQLConfig {
|
||||
/**
|
||||
* false: Temporarily disables referential constraints (set FOREIGN_KEY_CHECKS to 0) before truncating the tables.
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
foreignKeyChecks?: boolean;
|
||||
|
||||
// TODO: Add support
|
||||
// schema?: string
|
||||
}
|
||||
|
||||
export interface MySQLExtensionConfig extends MySQLConfig {
|
||||
connector: 'mysql';
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
export { truncatePostgresTable } from './postgres';
|
||||
export {
|
||||
PostgresForeignKeys,
|
||||
PostgresIdentity,
|
||||
type PostgresConfig,
|
||||
type PostgresExtensionConfig,
|
||||
} from './types';
|