diff --git a/.vscode/settings.json b/.vscode/settings.json index 3f57224..759b456 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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"], diff --git a/apps/admin/src/app.tsx b/apps/admin/src/app.tsx index cabe967..851af62 100644 --- a/apps/admin/src/app.tsx +++ b/apps/admin/src/app.tsx @@ -13,6 +13,7 @@ const Wrapper: FC = () => { const lang = useLocalStore((state) => state.lang); const locale = useMemo(() => localeData[lang], [lang]); const algorithm = useAntdAlgorithm(); + return ( (
欢迎来到3R教室,这是Nextjs课程的开始 -
diff --git a/apps/web/src/app/styles/index.css b/apps/web/src/app/styles/index.css index c3cef20..56ba93c 100644 --- a/apps/web/src/app/styles/index.css +++ b/apps/web/src/app/styles/index.css @@ -7,4 +7,4 @@ @import 'tailwindcss/utilities'; @import './tailwind/utilities.css'; -@config "../tailwind.config.ts"; +/* @config "../tailwind.config.ts"; */ diff --git a/apps/web/src/app/styles/tailwind-config.ts b/apps/web/src/app/styles/tailwind-config.ts new file mode 100644 index 0000000..ce7936f --- /dev/null +++ b/apps/web/src/app/styles/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; diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 2db053d..43f3d38 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -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; diff --git a/apps/web2/.eslintignore b/apps/web2/.eslintignore new file mode 100644 index 0000000..4b4091f --- /dev/null +++ b/apps/web2/.eslintignore @@ -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 \ No newline at end of file diff --git a/apps/web2/.eslintrc.js b/apps/web2/.eslintrc.js new file mode 100644 index 0000000..b59f7ac --- /dev/null +++ b/apps/web2/.eslintrc.js @@ -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'], + }, + }, + ], + }, +}; diff --git a/apps/web2/.gitignore b/apps/web2/.gitignore new file mode 100644 index 0000000..fd3dbb5 --- /dev/null +++ b/apps/web2/.gitignore @@ -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 diff --git a/apps/web2/.prettierrc.js b/apps/web2/.prettierrc.js new file mode 100644 index 0000000..20f5b1f --- /dev/null +++ b/apps/web2/.prettierrc.js @@ -0,0 +1 @@ +module.exports = require.resolve('@3rapp/core/prettier'); \ No newline at end of file diff --git a/apps/web2/.prettierrcignore b/apps/web2/.prettierrcignore new file mode 100644 index 0000000..4b4091f --- /dev/null +++ b/apps/web2/.prettierrcignore @@ -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 \ No newline at end of file diff --git a/apps/web2/.stylelintignore b/apps/web2/.stylelintignore new file mode 100644 index 0000000..2bd9cff --- /dev/null +++ b/apps/web2/.stylelintignore @@ -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 \ No newline at end of file diff --git a/apps/web2/.stylelintrc.js b/apps/web2/.stylelintrc.js new file mode 100644 index 0000000..02f8738 --- /dev/null +++ b/apps/web2/.stylelintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@3rapp/core/stylelint')], +} \ No newline at end of file diff --git a/apps/web2/README.md b/apps/web2/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/apps/web2/README.md @@ -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. diff --git a/apps/web2/components.json b/apps/web2/components.json new file mode 100644 index 0000000..0eb4459 --- /dev/null +++ b/apps/web2/components.json @@ -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" + } +} \ No newline at end of file diff --git a/apps/web2/next.config.mjs b/apps/web2/next.config.mjs new file mode 100644 index 0000000..37d4ff6 --- /dev/null +++ b/apps/web2/next.config.mjs @@ -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); diff --git a/apps/web2/package.json b/apps/web2/package.json new file mode 100644 index 0000000..4da1b20 --- /dev/null +++ b/apps/web2/package.json @@ -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" + } +} diff --git a/apps/web2/postcss.config.mjs b/apps/web2/postcss.config.mjs new file mode 100644 index 0000000..1b2069a --- /dev/null +++ b/apps/web2/postcss.config.mjs @@ -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; diff --git a/apps/web2/public/uploads/thumb/post-1.png b/apps/web2/public/uploads/thumb/post-1.png new file mode 100644 index 0000000..60b5384 Binary files /dev/null and b/apps/web2/public/uploads/thumb/post-1.png differ diff --git a/apps/web2/public/uploads/thumb/post-2.png b/apps/web2/public/uploads/thumb/post-2.png new file mode 100644 index 0000000..e258967 Binary files /dev/null and b/apps/web2/public/uploads/thumb/post-2.png differ diff --git a/apps/web2/public/uploads/thumb/post-3.png b/apps/web2/public/uploads/thumb/post-3.png new file mode 100644 index 0000000..3955dfe Binary files /dev/null and b/apps/web2/public/uploads/thumb/post-3.png differ diff --git a/apps/web2/public/uploads/thumb/post-4.png b/apps/web2/public/uploads/thumb/post-4.png new file mode 100644 index 0000000..b2f978c Binary files /dev/null and b/apps/web2/public/uploads/thumb/post-4.png differ diff --git a/apps/web2/public/uploads/thumb/post-5.png b/apps/web2/public/uploads/thumb/post-5.png new file mode 100644 index 0000000..38a3954 Binary files /dev/null and b/apps/web2/public/uploads/thumb/post-5.png differ diff --git a/apps/web2/public/uploads/thumb/post-6.png b/apps/web2/public/uploads/thumb/post-6.png new file mode 100644 index 0000000..ccc9db7 Binary files /dev/null and b/apps/web2/public/uploads/thumb/post-6.png differ diff --git a/apps/web2/public/uploads/thumb/post-7.png b/apps/web2/public/uploads/thumb/post-7.png new file mode 100644 index 0000000..fa5fd0f Binary files /dev/null and b/apps/web2/public/uploads/thumb/post-7.png differ diff --git a/apps/web2/public/uploads/thumb/post-8.png b/apps/web2/public/uploads/thumb/post-8.png new file mode 100644 index 0000000..a6c69b7 Binary files /dev/null and b/apps/web2/public/uploads/thumb/post-8.png differ diff --git a/apps/web2/src/app/(pages)/@modal/default.tsx b/apps/web2/src/app/(pages)/@modal/default.tsx new file mode 100644 index 0000000..f68c71b --- /dev/null +++ b/apps/web2/src/app/(pages)/@modal/default.tsx @@ -0,0 +1,3 @@ +export default function Default(): any { + return null; +} diff --git a/apps/web2/src/app/(pages)/@modal/post-create/page.tsx b/apps/web2/src/app/(pages)/@modal/post-create/page.tsx new file mode 100644 index 0000000..feb4953 --- /dev/null +++ b/apps/web2/src/app/(pages)/@modal/post-create/page.tsx @@ -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 = () => { + return ( + + + + ); +}; + +export default PostCreatePage; diff --git a/apps/web2/src/app/(pages)/@modal/post-edit/[item]/form.tsx b/apps/web2/src/app/(pages)/@modal/post-edit/[item]/form.tsx new file mode 100644 index 0000000..a1447dd --- /dev/null +++ b/apps/web2/src/app/(pages)/@modal/post-edit/[item]/form.tsx @@ -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 ; +}; diff --git a/apps/web2/src/app/(pages)/@modal/post-edit/[item]/page.tsx b/apps/web2/src/app/(pages)/@modal/post-edit/[item]/page.tsx new file mode 100644 index 0000000..255c059 --- /dev/null +++ b/apps/web2/src/app/(pages)/@modal/post-edit/[item]/page.tsx @@ -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 ( + + + + ); +}; +export default PostEditPage; diff --git a/apps/web2/src/app/(pages)/default.tsx b/apps/web2/src/app/(pages)/default.tsx new file mode 100644 index 0000000..8f7eeac --- /dev/null +++ b/apps/web2/src/app/(pages)/default.tsx @@ -0,0 +1,3 @@ +import { default as DefaultHomePage } from './page'; + +export default DefaultHomePage; diff --git a/apps/web2/src/app/(pages)/layout.tsx b/apps/web2/src/app/(pages)/layout.tsx new file mode 100644 index 0000000..d7f606b --- /dev/null +++ b/apps/web2/src/app/(pages)/layout.tsx @@ -0,0 +1,15 @@ +import React, { PropsWithChildren, ReactNode } from 'react'; + +import { Header } from '../_components/header'; + +const appLayout: React.FC = ({ children, modal }) => ( + <> +
+
+ + {children} +
+ {modal} + +); +export default appLayout; diff --git a/apps/web2/src/app/(pages)/mdx/content.mdx b/apps/web2/src/app/(pages)/mdx/content.mdx new file mode 100644 index 0000000..2508326 --- /dev/null +++ b/apps/web2/src/app/(pages)/mdx/content.mdx @@ -0,0 +1,1373 @@ +> 随着时间的推移,可能部分内容无法与官方最新版本同步,请自行对比查看,我有空会更新 + +用于Typescript或ES6+的类验证,基于[validator.js](https://github.com/chriso/validator.js) + +[手动验证方法列表](https://github.com/typestack/class-validator#manual-validation)和[验证装饰器列表](https://github.com/typestack/class-validator#validation-decorators) + +```python +print("Hello, World!") +``` + +## class-validator中文文档 + +### 安装 + +```shell +npm install class-validator --save +``` + +#### 基本使用 + +创建一个`Post`作为演示,在每个属性上添加不同的验证[装饰器](https://test.3rcd.com/notes/decorator.html)尝试 + +```javascript +import {validate, validateOrReject, Contains, IsInt, Length, IsEmail, IsFQDN, IsDate, Min, Max} from "class-validator"; + +export class Post { + + @Length(10, 20) + title: string; + + @Contains("hello") + text: string; + + @IsInt() + @Min(0) + @Max(10) + rating: number; + + @IsEmail() + email: string; + + @IsFQDN() + site: string; + + @IsDate() + createDate: Date; + +} + +let post = new Post(); +post.title = "Hello"; // should not pass +post.text = "this is a great post about hell world"; // should not pass +post.rating = 11; // should not pass +post.email = "google.com"; // should not pass +post.site = "googlecom"; // should not pass + +// 如果验证失败不会停止运行程序 +validate(post).then(errors => { + if (errors.length > 0) { + console.log("validation failed. errors: ", errors); + } else { + console.log("validation succeed"); + } +}); + +// 验证失败就停止运行程序 +validateOrReject(post).catch(errors => { + console.log("Promise rejected (validation failed). Errors: ", errors); +}); +// 或者 +async function validateOrRejectExample(input) { + try { + await validateOrReject(input); + } catch (errors) { + console.log("Caught promise rejection (validation failed). Errors: ", errors) + } +} +``` + +#### 选项 + +``validate``函数的第二个参数是一个选项对象,尽量设置`forbidNonWhitelisted`为`true`以避免unkown对象的输入验证 + +```javascript +export interface ValidatorOptions { + + skipMissingProperties?: boolean; + whitelist?: boolean; + forbidNonWhitelisted?: boolean; + groups?: string[]; + dismissDefaultMessages?: boolean; + validationError?: { + target?: boolean; + value?: boolean; + }; + + forbidUnknownValues?: boolean; +} +``` + +#### 验证错误 + +验证失败返回的错误数组是`ValidationError`类的对象的数组,格式如下 + +```javascript +{ + target: Object; // Object that was validated. + property: string; // Object's property that haven't pass validation. + value: any; // Value that haven't pass a validation. + constraints?: { // Constraints that failed validation with error messages. + [type: string]: string; + }; + children?: ValidationError[]; // Contains all nested validation errors of the property +} +``` + +返回的格式如下 + +```javascript +[{ + target: /* post object */, + property: "title", + value: "Hello", + constraints: { + length: "$property must be longer than or equal to 10 characters" + } +}, { + target: /* post object */, + property: "text", + value: "this is a great post about hell world", + constraints: { + contains: "text must contain a hello string" + } +}, +// and other errors +] +``` + +在http响应中我们一般不想在错误中暴露`target`,那么就可以如下方式禁用它 + +```javascript +validator.validate(post, { validationError: { target: false } }); +``` + +#### 验证消息 + +我们可以自定义在`ValidationError`对象中返回的错误消息 + +```javascript +import {MinLength, MaxLength} from "class-validator"; + +export class Post { + + @MinLength(10, { + message: "Title is too short" + }) + @MaxLength(50, { + message: "Title is too long" + }) + title: string; +} +``` + +消息可以接受几个参数作为变量,用字符串混合的方式放入,比如`"$constraint1 characters"` + +```javascript +import {MinLength, MaxLength} from "class-validator"; + +export class Post { + + @MinLength(10, { // here, $constraint1 will be replaced with "10", and $value with actual supplied value + message: "Title is too short. Minimal length is $constraint1 characters, but actual is $value" + }) + @MaxLength(50, { // here, $constraint1 will be replaced with "50", and $value with actual supplied value + message: "Title is too long. Maximal length is $constraint1 characters, but actual is $value" + }) + title: string; +} +``` + +能接受的变量如下 + +- `value` - 被验证的值 +- `constraints` - 由指定验证类型定义的约束数组 +- `targetName` - 验证对象的类的名称 +- `object` - 被验证的对象 +- `property` - 被验证的属性名 + +当然`message`还可以接受一个函数的返回值,这个函数的参数为`ValidationArguments`类的对象,而`ValidationArguments`类的属性就是上面的变量列表 + +```javascript +import {MinLength, MaxLength, ValidationArguments} from "class-validator"; + +export class Post { + + @MinLength(10, { + message: (args: ValidationArguments) => { + if (args.value.length === 1) { + return "Too short, minimum length is 1 character"; + } else { + return "Too short, minimum length is " + args.constraints[0] + " characters"; + } + } + }) + title: string; +} +``` + +### 特殊类型 + +`class-validator`对一些经常使用的特殊类型有专门的处理方法 + +#### 集合类型 + +验证数组,`Sets`,`Map`等集合类型需要开启`each`选项 + +#### 验证数组 + +```javascript +import {MinLength, MaxLength} from "class-validator"; + +export class Post { + + @MaxLength(20, { + each: true + }) + tags: string[]; +} +``` + +#### 验证Sets + +```javascript +import {MinLength, MaxLength} from "class-validator"; + +export class Post { + + @MaxLength(20, { + each: true + }) + tags: Set; +} +``` + +#### 验证Map + +```javascript +import {MinLength, MaxLength} from "class-validator"; + +export class Post { + + @MaxLength(20, { + each: true + }) + tags: Map; +} +``` + +#### 嵌套对象 + +一个验证的类中的某些属性可能是类一个的对象,比如`Post`类的`user`属性为`User`类,则可以使用`@ValidateNested()`方式来同时验证`Post`和嵌入的`User`类 + +```javascript +import {ValidateNested} from "class-validator"; + +export class Post { + + @ValidateNested() + user: User; + +} +``` + +#### Promise对象 + +如果待验证的属性是一个`Promise`对象,比如通过`await`关键字返回的值,则可以使用`@ValidatePromise()` + +```javascript +import {ValidatePromise, Min} from "class-validator"; + +export class Post { + + @Min(0) + @ValidatePromise() + userId: Promise; + +} +``` + +`@ValidatePromise()`也可以和`@ValidateNested()`一起使用 + +```javascript +import {ValidateNested, ValidatePromise} from "class-validator"; + +export class Post { + + @ValidateNested() + @ValidatePromise() + user: Promise; + +} +``` + +### 高级主题 + +#### 子类验证 + +如果定义一个从另一个继承的子类时,子类将自动继承父级的装饰器。如果在后代类中重新定义了属性,则装饰器将从该类和基类中继承 + +```javascript +import {validate} from "class-validator"; + +class BaseContent { + + @IsEmail() + email: string; + + @IsString() + password: string; +} + +class User extends BaseContent { + + @MinLength(10) + @MaxLength(20) + name: string; + + @Contains("hello") + welcome: string; + + @MinLength(20) + password: string; / +} + +let user = new User(); + +user.email = "invalid email"; // inherited property +user.password = "too short" // password wil be validated not only against IsString, but against MinLength as well +user.name = "not valid"; +user.welcome = "helo"; + +validate(user).then(errors => { + // ... +}); // it will return errors for email, title and text properties +``` + +#### 条件验证 + +当某个属性需要满足一定条件验证时可以使用(`@ValidateIf`)装饰器 + +```javascript +import {ValidateIf, IsNotEmpty} from "class-validator"; + +export class Post { + otherProperty:string; + + @ValidateIf(o => o.otherProperty === "value") + @IsNotEmpty() + example:string; +} +``` + +#### 白名单 + +一个被验证的类的对象可以定义在类中不存在的属性,在验证时不会产生错误。为了使只有添加了**验证装饰器**的属性才能被定义,你需要把`whitelist`设置为`true`,那么如果对象中定义一个类中不存在的属性就无法通过验证了。 + +```javascript +import {validate} from "class-validator"; +// ... +validate(post, { whitelist: true }); +``` + +开启白名单之后所有没有加上**验证装饰器**的属性被定义后都将无法通过验证,如果你想一些属性可以被定义但是又不想被验证,如果[条件验证](#条件验证)中的`otherProperty`属性,那么你需要在该属性上面添加一个`@Allow`装饰器 + +```javascript +/** + * title可以被定义 + * nonWhitelistedProperty不能被定义,否则验证失败 + */ +import {validate, Allow, Min} from "class-validator"; + +export class Post { + + @Allow() + title: string; + + @Min(0) + views: number; + + nonWhitelistedProperty: number; +} + +let post = new Post(); +post.title = 'Hello world!'; +post.views = 420; + +post.nonWhitelistedProperty = 69; +// 额外属性不能被添加,否则验证失败 +(post as any).anotherNonWhitelistedProperty = "something"; + +validate(post).then(errors => { + // post.nonWhitelistedProperty is not defined + // (post as any).anotherNonWhitelistedProperty is not defined + ... +}); +``` + +如果你想要所有没有添加**验证装饰器**的属性都无法定义,则可以设置`forbidNonWhitelisted`为`true` + +> 这个一般不要设置,否则属性添加@Allow会都没用了 + +```javascript +import {validate} from "class-validator"; +// ... +validate(post, { whitelist: true, forbidNonWhitelisted: true }); +``` + +#### 添加上下文 + +你可以在验证装饰其中添加一个自定义的上下文对象,此对象在验证失败时被`ValidationError`的实例获取 + +```javascript +import { validate } from 'class-validator'; + +class MyClass { + @MinLength(32, { + message: "EIC code must be at least 32 characters", + context: { + errorCode: 1003, + developerNote: "The validated string must contain 32 or more characters." + } + }) + eicCode: string; +} + +const model = new MyClass(); + +validate(model).then(errors => { + //errors[0].contexts['minLength'].errorCode === 1003 +}); +``` + +#### 跳过缺失属性 + +有时候你需要跳过一些对象中没有设置的属性,比如更新数据模型时,与创建模型不同的是你只会更新部分值,那么这时候你就需要设置`skipMissingProperties`为`true`,当然可能一部分属性是你不想被跳过验证的,那么需要在这些属性上加上`@IsDefined()`装饰器,加了`@IsDefined()`装饰器的属性会忽略`skipMissingProperties`而必定被验证 + +```javascript +import {validate} from "class-validator"; +// ... +validate(post, { skipMissingProperties: true }); +``` + +#### 验证组 + +```javascript +import {validate, Min, Length} from "class-validator"; + +export class User { + + @Min(12, { + groups: ["registration"] + }) + age: number; + + @Length(2, 20, { + groups: ["registration", "admin"] + }) + name: string; +} + +let user = new User(); +user.age = 10; +user.name = "Alex"; + +validate(user, { + groups: ["registration"] +}); // 无法通过验证 + +validate(user, { + groups: ["admin"] +}); // 可以通过验证 + +validate(user, { + groups: ["registration", "admin"] +}); // 无法通过验证 + +validate(user, { + groups: undefined // 默认模式 +}); // 无法通过验证,因为没有指定group则所有属性都将被验证 + +validate(user, { + groups: [] +}); // 无法通过验证 (与'groups: undefined'相同) +``` + +在验证中还有一个`always: true`选项,如果添加了此选项,无论验证时设定的是哪种模式的`groups`,都将被验证 + +#### 使用服务容器 + +你可以使用服务容器来加载验证器通过依赖注入的方式使用。以下如何将其与[typedi](https://github.com/pleerock/typedi)集成的示例: + +```javascript +import {Container} from "typedi"; +import {useContainer, Validator} from "class-validator"; + +// do this somewhere in the global application level: +useContainer(Container); +let validator = Container.get(Validator); + +// now everywhere you can inject Validator class which will go from the container +// also you can inject classes using constructor injection into your custom ValidatorConstraint-s +``` + +#### 非装饰器验证 + +如果你的运行环境不支持装饰器请看[这里](https://github.com/typestack/class-validator#defining-validation-schema-without-decorators) + +#### 验证普通对象 + +> Nest.js中使用的验证管道就是class-validator+class-transformer结合的方式 + +由于装饰器的性质,必须使用`new class()`语法实例化待验证的对象。如果你使用了class-validator装饰器定义了类,并且想要验证普通的JS对象(文本对象或JSON.parse返回),则需要将其转换为类实例 +(例如,使用[class-transformer](https://github.com/pleerock/class-transformer)或仅使用[class-transformer-validator](https://github.com/19majkel94/class-transformer-validator)扩展可以为您完成此任务。 + +### 自定义验证 + +#### 自定义规则类 + +你可以创建一个自定义的验证规则的类,并在规则类上添加`@ValidatorConstraint`装饰器。 还可以设置验证约束名称(`name`选项)-该名称将在`ValidationError`中用作“error type”。 如果您不提供约束名称,它将自动生成。 + +规则类必须实现`ValidatorConstraintInterface`接口及`validate`方法,该接口定义了验证逻辑。 如果验证成功,则方法返回`true`,否则返回`false`。 自定义验证器可以是异步的,如果您想在执行一些异步操作后执行验证,只需在`validate`方法中返回带有布尔值的`promise`。 + +我们还可以定义了可选方法`defaultMessage`,它在属性上的装饰器未设置错误消息的情况下定义了默认错误消息。 + +首选我们创建一个`CustomTextLength`演示用的验证规则类 + +```javascript +import {ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments} from "class-validator"; + +@ValidatorConstraint({ name: "customText", async: false }) +export class CustomTextLength implements ValidatorConstraintInterface { + + validate(text: string, args: ValidationArguments) { + return text.length > 1 && text.length < 10; // 对于异步验证,您必须在此处返回Promise + } + + defaultMessage(args: ValidationArguments) { // 如果验证失败,您可以在此处提供默认错误消息 + return "Text ($value) is too short or too long!"; + } + +} +``` + +定义好规则后我们就可以在类中使用了 + +```javascript +import {Validate} from "class-validator"; +import {CustomTextLength} from "./CustomTextLength"; + +class Post { + + @Validate(CustomTextLength, { + message: "Title is too short or long!" + }) + title: string; + +} + + +validate(post).then(errors => { + // ... +}); +``` + +你也可以将自定义的约束传入规则类,并通过约束来设定验证的条件 + +```javascript +import {Validate} from "class-validator"; +import {CustomTextLength} from "./CustomTextLength"; + +import {ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface} from "class-validator"; + +@ValidatorConstraint() +class CustomTextLength implements ValidatorConstraintInterface { + + validate(text: string, validationArguments: ValidationArguments) { + return text.length > validationArguments.constraints[0] && text.length < validationArguments.constraints[1]; + } + +} + +class Post { + + @Validate(CustomTextLength, [3, 20], { + message: "Wrong post title" + }) + title: string; + +} +``` + +#### 自定义装饰器 + +创建自定义装饰器的方法类似创建自定义规则类,只是使用装饰器而已 + +> 装饰器的详细使用请看我[这篇文章](https://test.3rcd.com/notes/decorator.html) + +```javascript +import {registerDecorator, ValidationOptions, ValidationArguments} from "class-validator"; + +function IsLongerThan(property: string, validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + name: "isLongerThan", + target: object.constructor, + propertyName: propertyName, + constraints: [property], + options: validationOptions, + validator: { + validate(value: any, args: ValidationArguments) { + const [relatedPropertyName] = args.constraints; + const relatedValue = (args.object as any)[relatedPropertyName]; + return typeof value === "string" && + typeof relatedValue === "string" && + value.length > relatedValue.length; // you can return a Promise here as well, if you want to make async validation + } + } + }); + }; +} + +export class Post { + + title: string; + + @IsLongerThan("title", { + /* you can also use additional validation options, like "groups" in your custom validation decorators. "each" is not supported */ + message: "Text must be longer than the title" + }) + text: string; + +} +``` + +在自定义装饰器上仍然可以使用`ValidationConstraint`装饰器。我们在创建一个`IsUserAlreadyExist`验证装饰器演示 + +```javascript +import {registerDecorator, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments} from "class-validator"; + +@ValidatorConstraint({ async: true }) +class IsUserAlreadyExistConstraint implements ValidatorConstraintInterface { + + validate(userName: any, args: ValidationArguments) { + return UserRepository.findOneByName(userName).then(user => { + if (user) return false; + return true; + }); + } + +} + +function IsUserAlreadyExist(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: IsUserAlreadyExistConstraint + }); + }; +} + +class User { + + @IsUserAlreadyExist({ + message: "User $value already exists. Choose another name." + }) + name: string; + +} +``` + +#### 同步验证 + +如果只是想简单的进行同步验证,可以使用`validateSync`代替`validate`。不过需要注意的是`validateSync`会忽略所有的异步验证。 + +## class-transfomer中文文档 + +类转换器的作用是将普通的javascript对象转换成类对象。我们通过api端点或者json文件访问所得的是普通的json文本,一般我们通过`JSON.parse`把其转换成普通的javascript对象,但是有时候我们想让它变成一个类的对象而不是普通的javascript对象。比如用`class-validator`来验证从后端api获取的json字符串时,我们就需要自动把json转为待验证类的对象而不是一个js对象。 + +例如我们现在可以读取远程api的一个`users.json`的内容如下 + +```json +[{ + "id": 1, + "firstName": "Johny", + "lastName": "Cage", + "age": 27 +}, +{ + "id": 2, + "firstName": "Ismoil", + "lastName": "Somoni", + "age": 50 +}, +{ + "id": 3, + "firstName": "Luke", + "lastName": "Dacascos", + "age": 12 +}] +``` + +我们有一个`User`类 + +```javascript +export class User { + id: number; + firstName: string; + lastName: string; + age: number; + + getName() { + return this.firstName + " " + this.lastName; + } + + isAdult() { + return this.age > 36 && this.age < 60; + } +} +``` + +然后你想通过`user.json`来获取`User`的对象数组 + +```javascript +fetch("users.json").then((users: User[]) => { + // you can use users here, and type hinting also will be available to you, + // but users are not actually instances of User class + // this means that you can't use methods of User class +}); +``` + +现在你可以获取`users[0].firstname`但是由于你获取的是普通的js对象而非`User`类的对象,所以你无法调用`users[0].getName()`方法,而**class-transformer**就是为了把普通的js对象按你的需求转换成类对象而生的。 + +你只要像下面这样就可以创建真正的`User[]`对象数组了 + +```javascript +fetch("users.json").then((users: Object[]) => { + const realUsers = plainToClass(User, users); + // now each user in realUsers is instance of User class +}); +``` + +### 安装 + +1. 安装class-transformer: + `npm install class-transformer --save` + +2. 安装`reflect-metadata` + + > reflect-metadata是必须的,具体使用请看[这篇博文](./2022-06-23-ts-decorator.md) + +安装后在`app.ts`这种顶层文件你需要`import "reflect-metadata";` + +### 基础方法 + +#### plainToClass + +普通对象转换为类对象 + +```javascript +import {plainToClass} from "class-transformer"; + +let users = plainToClass(User, userJson); // to convert user plain object a single user. also supports arrays +``` + +#### plainToClassFromExist + +普通对象合并已经创建的类实例 + +```javascript +const defaultUser = new User(); +defaultUser.role = 'user'; + +let mixedUser = plainToClassFromExist(defaultUser, user); // mixed user should have the value role = user when no value is set otherwise. +``` + +#### classToPlain + +类实例转换为普通对象 + +> 转换后可以使用`JSON.stringify`再转成普通的json文本 + +```javascript +import {classToPlain} from "class-transformer"; +let photo = classToPlain(photo); +``` + +#### classToClass + +克隆类实例 + +```javascript +import {classToClass} from "class-transformer"; +let photo = classToClass(photo); +``` + +可以使用`ignoreDecorators`选项去除所有原实例中的装饰器 + +#### serialize + +直接把类实例转换为json文本,是不是数组都可以转换 + +```javascript +import {serialize} from "class-transformer"; +let photo = serialize(photo); +``` + +#### deserialize 和 deserializeArray + +直接把json文本转换为类对象 + +```javascript +import {deserialize} from "class-transformer"; +let photo = deserialize(Photo, photo); +``` + +如果json文本是个对象数组请使用`deserializeArray`方法 + +```javascript +import {deserializeArray} from "class-transformer"; +let photos = deserializeArray(Photo, photos); +``` + +#### 强制类型安全 + +`plainToClass`会把所有的被转换对象的属性全部类实例的属性,即时类中并不存在某些属性 + +```javascript +import {plainToClass} from "class-transformer"; + +class User { + id: number + firstName: string + lastName: string +} + +const fromPlainUser = { + unkownProp: 'hello there', + firstName: 'Umed', + lastName: 'Khudoiberdiev', +} + +console.log(plainToClass(User, fromPlainUser)) + +// User { +// unkownProp: 'hello there', +// firstName: 'Umed', +// lastName: 'Khudoiberdiev', +// } +``` + +你可以使用`excludeExtraneousValues`选项结合`Expose`装饰器来指定需要公开的属性 + +```javascript +import {Expose, plainToClass} from "class-transformer"; + +class User { + @Expose() id: number; + @Expose() firstName: string; + @Expose() lastName: string; +} + +const fromPlainUser = { + unkownProp: 'hello there', + firstName: 'Umed', + lastName: 'Khudoiberdiev', +} + +console.log(plainToClass(User, fromPlainUser, { excludeExtraneousValues: true })) + +// User { +// id: undefined, +// firstName: 'Umed', +// lastName: 'Khudoiberdiev' +// } +``` + +### 子类型转换 + +#### 嵌套对象 + +由于现在Typescript对反射还没有非常好的支持,所以你需要使用`@Type`装饰器来隐式地指定属性所属的类 + +```javascript +import {Type, plainToClass} from "class-transformer"; + +export class Album { + + id: number; + + name: string; + + @Type(() => Photo) + photos: Photo[]; +} + +export class Photo { + id: number; + filename: string; +} + +let album = plainToClass(Album, albumJson); +// now album is Album object with Photo objects inside +``` + +#### 多类型选项 + +一个嵌套的子类型也可以匹配多个类型,这可以通过判断器实现。判断器需要指定一个` property`,而被转换js对象中的嵌套对象的也必须拥有与`property`相同的一个字段,并把值设置为需要转换的子类型的名称。判断器还需要指定所有的子类型值以及其名称,具体示例如下 + +```javascript +import {Type, plainToClass} from "class-transformer"; + +const albumJson = { + "id": 1, + "name": "foo", + "topPhoto": { + "id": 9, + "filename": "cool_wale.jpg", + "depth": 1245, + "__type": "underwater" + } +} + +export abstract class Photo { + id: number; + filename: string; +} + +export class Landscape extends Photo { + panorama: boolean; +} + +export class Portrait extends Photo { + person: Person; +} + +export class UnderWater extends Photo { + depth: number; +} + +export class Album { + + id: number; + name: string; + + @Type(() => Photo, { + discriminator: { + property: "__type", + subTypes: [ + { value: Landscape, name: "landscape" }, + { value: Portrait, name: "portrait" }, + { value: UnderWater, name: "underwater" } + ] + } + }) + topPhoto: Landscape | Portrait | UnderWater; + +} + +let album = plainToClass(Album, albumJson); +// now album is Album object with a UnderWater object without `__type` property. +``` + +此外可以设置`keepDiscriminatorProperty: true`,这样可以把判断器的属性也包含在转换后的对象中 + +### 排除与公开 + +#### 公开方法的返回值 + +添加`@Expose`装饰器即可公开getter和方法的返回值 + +```javascript +import {Expose} from "class-transformer"; + +export class User { + + id: number; + firstName: string; + lastName: string; + password: string; + + @Expose() + get name() { + return this.firstName + " " + this.lastName; + } + + @Expose() + getFullName() { + return this.firstName + " " + this.lastName; + } +} +``` + +#### 公开属性为不同名称 + +如果要使用其他名称公开某些属性,可以通过为`@Expose`装饰器指定`name`选项来实现: + +```javascript +import {Expose} from "class-transformer"; + +export class User { + + @Expose({ name: "uid" }) + id: number; + + firstName: string; + + lastName: string; + + @Expose({ name: "secretKey" }) + password: string; + + @Expose({ name: "fullName" }) + getFullName() { + return this.firstName + " " + this.lastName; + } +} +``` + +#### 跳过指定属性 + +有时您想在转换过程中跳过一些属性。这可以使用`@Exclude`装饰器完成: + +```javascript +import {Exclude} from "class-transformer"; + +export class User { + + id: number; + + email: string; + + @Exclude() + password: string; +} +``` + +现在,当您转换用户时,`password`属性将被跳过,并且不包含在转换结果中。 + +#### 根据操作决定跳过 + +我们可以通过`toClassOnly`或者`toPlainOnly`来控制一个属性在哪些操作中需要排除 + +```javascript +import {Exclude} from "class-transformer"; + +export class User { + + id: number; + + email: string; + + @Exclude({ toPlainOnly: true }) + password: string; +} +``` + +现在`password`属性将会在`classToPlain`操作中排除,相反的可以使用`toClassOnly` + +#### 跳过类的所有属性 + +你可以通过在类上添加`@Exclude`装饰器并且在需要公开的属性上添加`@Expose`装饰器来只公开指定的属性 + +```javascript +import {Exclude, Expose} from "class-transformer"; + +@Exclude() +export class User { + + @Expose() + id: number; + + @Expose() + email: string; + + password: string; +} +``` + +另外,您可以在转换期间设置排除策略: + +```javascript +import {classToPlain} from "class-transformer"; +let photo = classToPlain(photo, { strategy: "excludeAll" }); +``` + +这时你不需要在添加`@Exclude`装饰器了 + +#### 跳过私有属性或某些前缀属性 + +我们可以排除公开具有指定前缀的属性以及私有属性 + +```javascript +import {Expose} from "class-transformer"; + +export class User { + + id: number; + private _firstName: string; + private _lastName: string; + _password: string; + + setName(firstName: string, lastName: string) { + this._firstName = firstName; + this._lastName = lastName; + } + + @Expose() + get name() { + return this.firstName + " " + this.lastName; + } + +} + +const user = new User(); +user.id = 1; +user.setName("Johny", "Cage"); +user._password = 123; + +const plainUser = classToPlain(user, { excludePrefixes: ["_"] }); +// here plainUser will be equal to +// { id: 1, name: "Johny Cage" } +``` + +#### 使用组来控制排除的属性 + +```javascript +import {Exclude, Expose} from "class-transformer"; + +@Exclude() +export class User { + + id: number; + + name: string; + + @Expose({ groups: ["user", "admin"] }) // this means that this data will be exposed only to users and admins + email: string; + + @Expose({ groups: ["user"] }) // this means that this data will be exposed only to users + password: string; +} + +let user1 = classToPlain(user, { groups: ["user"] }); // will contain id, name, email and password +let user2 = classToPlain(user, { groups: ["admin"] }); // will contain id, name and email +``` + +#### 使用版本范围来控制公开和排除的属性 + +如果要构建具有不同版本的API,则class-transformer具有非常有用的工具。您可以控制应在哪个版本中公开或排除模型的哪些属性。示例 + +```javascript +import {Exclude, Expose} from "class-transformer"; + +@Exclude() +export class User { + + id: number; + + name: string; + + @Expose({ since: 0.7, until: 1 }) // this means that this property will be exposed for version starting from 0.7 until 1 + email: string; + + @Expose({ since: 2.1 }) // this means that this property will be exposed for version starting from 2.1 + password: string; +} + +let user1 = classToPlain(user, { version: 0.5 }); // will contain id and name +let user2 = classToPlain(user, { version: 0.7 }); // will contain id, name and email +let user3 = classToPlain(user, { version: 1 }); // will contain id and name +let user4 = classToPlain(user, { version: 2 }); // will contain id and name +let user5 = classToPlain(user, { version: 2.1 }); // will contain id, name nad password +``` + +### 特殊处理 + +#### 将日期字符串转换为Date对象 + +有时,您的JavaScript对象中有一个以字符串格式接收的Date。您想从中创建一个真正的javascript Date对象。您只需将Date对象传递给`@Type`装饰器即可完成此操作: + +> 当从类对象反向转换为普通对象时registrationDate将会被转回为字符串 + +```javascript +import {Type} from "class-transformer"; + +export class User { + + id: number; + + email: string; + + password: string; + + @Type(() => Date) + registrationDate: Date; +} +``` + +当您想将值转换为`Number`, `String`, `Boolean` 类型时也是这样做 + +#### 数组处理 + +当你想转换数组时,你必须使用`@Type`装饰器指定数组项的类型也可以使用自定义的数组类型 + +`Set`和`Map`也是一样 + +```javascript +import {Type} from "class-transformer"; + +export class AlbumCollection extends Array { + // custom array functions ... +} + +export class Photo { + + id: number; + + name: string; + + @Type(() => Album) + albums: Album[]; + // albums: AlbumCollection; 使用自定义类型 +} + +export class Skill { + name: string; +} + +export class Weapon { + name: string; + range: number; +} + +export class Player { + name: string; + + @Type(() => Skill) + skills: Set; + + @Type(() => Weapon) + weapons: Map; +} +``` + +### 自定义转换 + +#### 基本使用 + +你可以使用`@Transform`添加额外的数据转换,例如当你想把通过普通对象中的字符串日期转换后的`date`对象继续转换变成`moment`库的对象: + +```javascript +import {Transform} from "class-transformer"; +import * as moment from "moment"; +import {Moment} from "moment"; + +export class Photo { + + id: number; + + @Type(() => Date) + @Transform(value => moment(value), { toClassOnly: true }) + date: Moment; +} +``` + +现在当执行`plainToClass`转换后的对象中的`date`属性将是一个`Moment`对象。`@Transform`同样支持组和版本。 + +#### 高级用法 + +`@Transform`有更多的参数给你创建自定义的转换逻辑 + +```javascript +@Transform((value, obj, type) => value) +``` + +| 参数 | 描述 | +| :------ | ------------------------ | +| `value` | 自定义转换执行前的属性值 | +| `obj` | 转换源对象 | +| `type` | 转换的类型 | + +#### 其他装饰器 + +| 签名 | 示例 | +| ------------------------ | ---------------------------------------------------- | +| `@TransformClassToPlain` | `@TransformClassToPlain({ groups: ["user"] })` | +| `@TransformClassToClass` | `@TransformClassToClass({ groups: ["user"] })` | +| `@TransformPlainToClas` | `@TransformPlainToClass(User, { groups: ["user"] })` | + +上述装饰器接受一个可选参数:`ClassTransformOptions`-转换选项,例如groups, version, name,示例: + +```javascript +@Exclude() +class User { + + id: number; + + @Expose() + firstName: string; + + @Expose() + lastName: string; + + @Expose({ groups: ['user.email'] }) + email: string; + + password: string; +} + +class UserController { + + @TransformClassToPlain({ groups: ['user.email'] }) + getUser() { + const user = new User(); + user.firstName = "Snir"; + user.lastName = "Segal"; + user.password = "imnosuperman"; + + return user; + } +} + +const controller = new UserController(); +const user = controller.getUser(); +``` + +`user`对象将包含firstname,latstname和email + +#### 使用泛型 + +由于目前Typescript对反射的支持还没有完善,所以只能使用其它替代方案,具体可以查看[这个例子](https://github.com/pleerock/class-transformer/tree/master/sample/sample4-generics) + +#### 隐式类型转换 + +> 你如果将class-validator与class-transformer一起使用,则可能不想启用此功能。 + +根据Typescript提供的类型信息,启用内置类型之间的自动转换。默认禁用。 + +```javascript +import { IsString } from 'class-validator' + +class MyPayload { + + @IsString() + prop: string +} + + +const result1 = plainToClass(MyPayload, { prop: 1234 }, { enableImplicitConversion: true }); +const result2 = plainToClass(MyPayload, { prop: 1234 }, { enableImplicitConversion: false }); + +/** + * result1 will be `{ prop: "1234" }` - notice how the prop value has been converted to string. + * result2 will be `{ prop: 1234 }` - default behaviour + */ +``` + +#### 循环引用 + +如果`User`包含一个`Photo`类型的`photos`数组属性,而`Photo`又包含一个属性链接到`User`,则转换过程中此属性会被忽略,除了`classToClass`操作。 \ No newline at end of file diff --git a/apps/web2/src/app/(pages)/mdx/page.mdx b/apps/web2/src/app/(pages)/mdx/page.mdx new file mode 100644 index 0000000..cc8a7e3 --- /dev/null +++ b/apps/web2/src/app/(pages)/mdx/page.mdx @@ -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' + +
+ +
+
+
+ +
+
+ +
+
+
+
\ No newline at end of file diff --git a/apps/web2/src/app/(pages)/mdx/title.tsx b/apps/web2/src/app/(pages)/mdx/title.tsx new file mode 100644 index 0000000..8081d01 --- /dev/null +++ b/apps/web2/src/app/(pages)/mdx/title.tsx @@ -0,0 +1,3 @@ +import { FC } from 'react'; + +export const MdxTitle: FC = () =>

class-validator和class-transformer的中文文档

; diff --git a/apps/web2/src/app/(pages)/page.module.css b/apps/web2/src/app/(pages)/page.module.css new file mode 100644 index 0000000..d077f2e --- /dev/null +++ b/apps/web2/src/app/(pages)/page.module.css @@ -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; +} diff --git a/apps/web2/src/app/(pages)/page.tsx b/apps/web2/src/app/(pages)/page.tsx new file mode 100644 index 0000000..8ff7b0a --- /dev/null +++ b/apps/web2/src/app/(pages)/page.tsx @@ -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 }> = 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 ( +
+ +
+ {items.map((item) => ( +
+ + + +
+
+ +

+ {item.title} +

+ +
+
+ {isNil(item.summary) ? item.body.substring(0, 99) : item.summary} +
+
+
+ + + +
+
+ + {/* 删除按钮 */} + +
+
+
+
+ ))} +
+ {meta.totalPages! > 1 && ( + + )} +
+ ); +}; +export default App; diff --git a/apps/web2/src/app/(pages)/post/[item]/page.module.css b/apps/web2/src/app/(pages)/post/[item]/page.module.css new file mode 100644 index 0000000..f63ca88 --- /dev/null +++ b/apps/web2/src/app/(pages)/post/[item]/page.module.css @@ -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; + } + } + } +} diff --git a/apps/web2/src/app/(pages)/post/[item]/page.tsx b/apps/web2/src/app/(pages)/post/[item]/page.tsx new file mode 100644 index 0000000..053b933 --- /dev/null +++ b/apps/web2/src/app/(pages)/post/[item]/page.tsx @@ -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 ( +
+ +
+
+ {post.title} +
+
+
+

{post.title}

+
+
+
+ +
+
+ +
+
+
+ ); +}; +export default PostItemPage; diff --git a/apps/web2/src/app/(pages)/post/create/form.tsx b/apps/web2/src/app/(pages)/post/create/form.tsx new file mode 100644 index 0000000..f58c78d --- /dev/null +++ b/apps/web2/src/app/(pages)/post/create/form.tsx @@ -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 ( + <> +
+ + +
+ + + ); +}; diff --git a/apps/web2/src/app/(pages)/post/create/page.tsx b/apps/web2/src/app/(pages)/post/create/page.tsx new file mode 100644 index 0000000..95a9f15 --- /dev/null +++ b/apps/web2/src/app/(pages)/post/create/page.tsx @@ -0,0 +1,10 @@ +import { PostCreateForm } from './form'; + +const PostCreatePage1 = () => ( +
+
+ +
+
+); +export default PostCreatePage1; diff --git a/apps/web2/src/app/_components/header/index.tsx b/apps/web2/src/app/_components/header/index.tsx new file mode 100644 index 0000000..558e050 --- /dev/null +++ b/apps/web2/src/app/_components/header/index.tsx @@ -0,0 +1,7 @@ +import { Logo } from './logo'; + +export const Header = () => ( +
+ +
+); diff --git a/apps/web2/src/app/_components/header/logo.tsx b/apps/web2/src/app/_components/header/logo.tsx new file mode 100644 index 0000000..ea316ac --- /dev/null +++ b/apps/web2/src/app/_components/header/logo.tsx @@ -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 = () => ( + + avatar logo + +); diff --git a/apps/web2/src/app/_components/header/next.svg b/apps/web2/src/app/_components/header/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/apps/web2/src/app/_components/header/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web2/src/app/_components/header/page.module.css b/apps/web2/src/app/_components/header/page.module.css new file mode 100644 index 0000000..e73bf95 --- /dev/null +++ b/apps/web2/src/app/_components/header/page.module.css @@ -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; + } +} diff --git a/apps/web2/src/app/_components/home/back-button.tsx b/apps/web2/src/app/_components/home/back-button.tsx new file mode 100644 index 0000000..351e8b7 --- /dev/null +++ b/apps/web2/src/app/_components/home/back-button.tsx @@ -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 = useCallback( + (e) => { + e.preventDefault(); + historyLength > 1 && router.back(); + }, + [historyLength], + ); + + useEffect(() => { + if (typeof window !== 'undefined') { + setHistoryLength(window.history.length); + } + }, []); + return ( + + ); +}; diff --git a/apps/web2/src/app/_components/home/tools.tsx b/apps/web2/src/app/_components/home/tools.tsx new file mode 100644 index 0000000..239ff6c --- /dev/null +++ b/apps/web2/src/app/_components/home/tools.tsx @@ -0,0 +1,12 @@ +import { CreateButton } from '../post/create-button'; + +import { BackButton } from './back-button'; + +export const Tools: React.FC<{ back?: boolean }> = ({ back }) => { + return ( +
+ {back && } + +
+ ); +}; diff --git a/apps/web2/src/app/_components/markdown/editor.tsx b/apps/web2/src/app/_components/markdown/editor.tsx new file mode 100644 index 0000000..006ed5a --- /dev/null +++ b/apps/web2/src/app/_components/markdown/editor.tsx @@ -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 = forwardRef((props, _) => { + const { content, setContent, handlers, ...rest } = props; + const editorRef = useRef(); + const [pageFullscreen, setPageFullScreen] = useState(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 ( + + ); +}); diff --git a/apps/web2/src/app/_components/markdown/mdx-code-copy.tsx b/apps/web2/src/app/_components/markdown/mdx-code-copy.tsx new file mode 100644 index 0000000..e88807f --- /dev/null +++ b/apps/web2/src/app/_components/markdown/mdx-code-copy.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { isNil } from 'lodash'; +import { FC, PropsWithChildren, useRef, useState } from 'react'; + +export const MdxCodeCopy: FC> = ({ children, ...rest }) => { + const textInput = useRef(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 ( +
+ {hovered && ( + + )} +
{children}
+
+ ); +}; diff --git a/apps/web2/src/app/_components/markdown/mdx-pre.tsx b/apps/web2/src/app/_components/markdown/mdx-pre.tsx new file mode 100644 index 0000000..55eaaea --- /dev/null +++ b/apps/web2/src/app/_components/markdown/mdx-pre.tsx @@ -0,0 +1,15 @@ +import { FC, PropsWithChildren } from 'react'; + +import { MdxCodeCopy } from './mdx-code-copy'; + +export const MDXPre: FC> = ({ + copyEnabled = true, + children, + ...props +}) => { + return copyEnabled ? ( + {children} + ) : ( +
{children}
+ ); +}; diff --git a/apps/web2/src/app/_components/markdown/mdx-remote-render.tsx b/apps/web2/src/app/_components/markdown/mdx-remote-render.tsx new file mode 100644 index 0000000..01273d9 --- /dev/null +++ b/apps/web2/src/app/_components/markdown/mdx-remote-render.tsx @@ -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 = { + options: { + mdxOptions: { + rehypePlugins: [[rehypePrism, { showLineNumbers: true }]], + }, + }, + components: { + pre: MDXPre, + }, +}; + +export const MdxRemoteRender: FC = (props) => { + return ; +}; diff --git a/apps/web2/src/app/_components/markdown/preivew.tsx b/apps/web2/src/app/_components/markdown/preivew.tsx new file mode 100644 index 0000000..b781c31 --- /dev/null +++ b/apps/web2/src/app/_components/markdown/preivew.tsx @@ -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 = (props) => { + return ( + + ); +}; diff --git a/apps/web2/src/app/_components/markdown/type.ts b/apps/web2/src/app/_components/markdown/type.ts new file mode 100644 index 0000000..a0797c9 --- /dev/null +++ b/apps/web2/src/app/_components/markdown/type.ts @@ -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; +/** + * markdown预览组件的参数类型 + */ +export type MarkdownPreviewProps = { + /** + * 编辑器ID,默认为"markdown-preview-editor" + */ + editorId?: string; + /** + * 主题风格,这里没有定义为"theme",因为这是为后续明暗主题切换保留的 + */ + previewTheme?: string; + /** + * md文档的内容 + */ + text: string; +}; diff --git a/apps/web2/src/app/_components/modal/constants.ts b/apps/web2/src/app/_components/modal/constants.ts new file mode 100644 index 0000000..5060f23 --- /dev/null +++ b/apps/web2/src/app/_components/modal/constants.ts @@ -0,0 +1,5 @@ +import { createContext } from 'react'; + +import { EditorModalState } from './types'; + +export const EditorModalContext = createContext(null); diff --git a/apps/web2/src/app/_components/modal/editor-modal.tsx b/apps/web2/src/app/_components/modal/editor-modal.tsx new file mode 100644 index 0000000..f7049b0 --- /dev/null +++ b/apps/web2/src/app/_components/modal/editor-modal.tsx @@ -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 = ({ children, className, ...rest }) => { + const EditorModalContext = createContext(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(() => ({ editorFullScreen }), [editorFullScreen]); + + return ( + + {children} + + ); +}; diff --git a/apps/web2/src/app/_components/modal/hooks.ts b/apps/web2/src/app/_components/modal/hooks.ts new file mode 100644 index 0000000..9de08a6 --- /dev/null +++ b/apps/web2/src/app/_components/modal/hooks.ts @@ -0,0 +1,12 @@ +import { isNil } from 'lodash'; +import { createContext, useContext } from 'react'; + +import { EditorModalState } from './types'; + +export const useEditorModalContext = (): EditorModalState => { + const EditorModalContext = createContext(null); + const context = useContext(EditorModalContext); + + if (isNil(context)) return {}; + return context; +}; diff --git a/apps/web2/src/app/_components/modal/page-modal.module.css b/apps/web2/src/app/_components/modal/page-modal.module.css new file mode 100644 index 0000000..179e591 --- /dev/null +++ b/apps/web2/src/app/_components/modal/page-modal.module.css @@ -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; +} diff --git a/apps/web2/src/app/_components/modal/page-modal.tsx b/apps/web2/src/app/_components/modal/page-modal.tsx new file mode 100644 index 0000000..07ba63d --- /dev/null +++ b/apps/web2/src/app/_components/modal/page-modal.tsx @@ -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 = ({ 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 ? ( +
+ + event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + > + + {title} + + +
+ {children} +
+
+
+
+ ) : null; +}; diff --git a/apps/web2/src/app/_components/modal/types.ts b/apps/web2/src/app/_components/modal/types.ts new file mode 100644 index 0000000..d0eddb4 --- /dev/null +++ b/apps/web2/src/app/_components/modal/types.ts @@ -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; +}; diff --git a/apps/web2/src/app/_components/paginate/simple.tsx b/apps/web2/src/app/_components/paginate/simple.tsx new file mode 100644 index 0000000..b9a2652 --- /dev/null +++ b/apps/web2/src/app/_components/paginate/simple.tsx @@ -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 ( + + + + + + + = 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="下一页" + /> + + + + ); +}; +export default SimplePaginate; diff --git a/apps/web2/src/app/_components/post/action-form.tsx b/apps/web2/src/app/_components/post/action-form.tsx new file mode 100644 index 0000000..3b8cfa0 --- /dev/null +++ b/apps/web2/src/app/_components/post/action-form.tsx @@ -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((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 ( +
+ + ( + + 文章标题 + + + + + + )} + /> + ( + + 摘要简述 + +