master
ubuntu lee 2024-09-28 14:58:45 +08:00
parent 3919b572c8
commit ebd045b7ea
123 changed files with 7229 additions and 177 deletions

View File

@ -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"],

View File

@ -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}

View File

@ -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"

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -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"
}
}

View File

@ -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>

View File

@ -7,4 +7,4 @@
@import 'tailwindcss/utilities';
@import './tailwind/utilities.css';
@config "../tailwind.config.ts";
/* @config "../tailwind.config.ts"; */

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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'],
},
},
],
},
};

36
apps/web2/.gitignore vendored 100644
View File

@ -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

View File

@ -0,0 +1 @@
module.exports = require.resolve('@3rapp/core/prettier');

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@3rapp/core/stylelint')],
}

View File

@ -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.

View File

@ -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"
}
}

View File

@ -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);

View File

@ -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"
}
}

View File

@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -0,0 +1,3 @@
export default function Default(): any {
return null;
}

View File

@ -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;

View File

@ -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} />;
};

View File

@ -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;

View File

@ -0,0 +1,3 @@
import { default as DefaultHomePage } from './page';
export default DefaultHomePage;

View File

@ -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;

File diff suppressed because it is too large Load Diff

View File

@ -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>

View File

@ -0,0 +1,3 @@
import { FC } from 'react';
export const MdxTitle: FC = () => <h1>class-validatorclass-transformer</h1>;

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}
}
}
}

View File

@ -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;

View File

@ -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" />
</>
);
};

View File

@ -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;

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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

View File

@ -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;
}
}

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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
* ,setPageFullScreenpageFullscreen
*
*/
editorRef.current?.on('pageFullscreen', (value) => {
setPageFullScreen(value);
if (!isNil(handlers?.onPageScreen)) {
handlers.onPageScreen(value);
}
});
/**
* : fullscreen
* ,false(pageFullscreen,pageFullscreenfalse)
*
*/
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}
/>
);
});

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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)} />;
};

View File

@ -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'}
/>
);
};

View File

@ -0,0 +1,50 @@
/**
* 使md-editor-rtmarkdown
*/
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;
};

View File

@ -0,0 +1,5 @@
import { createContext } from 'react';
import { EditorModalState } from './types';
export const EditorModalContext = createContext<EditorModalState | null>(null);

View File

@ -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>
);
};

View File

@ -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;
};

View File

@ -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;
}

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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;

View File

@ -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>
);
});

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
}

View File

@ -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],
);
};

View File

@ -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>
);
};

View File

@ -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>;
}

View File

@ -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,
};

View File

@ -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 };

View File

@ -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,
}

View File

@ -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,
};

View File

@ -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 }

View File

@ -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 }

View File

@ -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,
};

View File

@ -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 }

View File

@ -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;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Binary file not shown.

View File

@ -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;

View File

@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

View File

@ -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';

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}
}

View File

@ -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;
}
}

View File

@ -16,7 +16,7 @@ const config = {
md: '768px',
lg: '992px',
xl: '1200px',
'2xl': '1400px',
'2xl': '1401px',
},
},
extend: {

View File

@ -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 };

View File

@ -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';
}
}

View File

@ -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,
);
},
},
},
});
});
}

View File

@ -0,0 +1,2 @@
export { truncateMySQLTable } from './mysql';
export type { MySQLConfig, MySQLExtensionConfig } from './types';

View File

@ -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;`),
]);
}

View File

@ -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';
}

View File

@ -0,0 +1,7 @@
export { truncatePostgresTable } from './postgres';
export {
PostgresForeignKeys,
PostgresIdentity,
type PostgresConfig,
type PostgresExtensionConfig,
} from './types';

Some files were not shown because too many files have changed in this diff Show More