├── .env.example
├── .eslintrc.json
├── .gitignore
├── .idea
├── .gitignore
├── blog-nextjs.iml
├── inspectionProfiles
│ └── Project_Default.xml
├── modules.xml
└── vcs.xml
├── README.md
├── next.config.mjs
├── package.json
├── postcss.config.js
├── public
├── loading.gif
├── next.svg
└── vercel.svg
├── src
├── app
│ ├── about
│ │ └── page.tsx
│ ├── detail
│ │ └── [id]
│ │ │ ├── Avatar.tsx
│ │ │ ├── Comment.tsx
│ │ │ ├── CommentForm.tsx
│ │ │ ├── Content.tsx
│ │ │ ├── Copy.tsx
│ │ │ ├── Nav.tsx
│ │ │ ├── Recommend.tsx
│ │ │ ├── Title.tsx
│ │ │ └── page.tsx
│ ├── error.tsx
│ ├── favicon.ico
│ ├── home
│ │ ├── Articles.tsx
│ │ ├── Aside.tsx
│ │ ├── Category.tsx
│ │ ├── FullPage.tsx
│ │ ├── Notice.tsx
│ │ ├── Profile.tsx
│ │ ├── RecentUpdate.tsx
│ │ └── Tags.tsx
│ ├── layout.tsx
│ ├── layout
│ │ ├── Footer.tsx
│ │ ├── Header.tsx
│ │ ├── Nav.tsx
│ │ ├── Search.tsx
│ │ ├── Statistics.tsx
│ │ └── error.tsx
│ ├── loading.tsx
│ ├── not-found.tsx
│ ├── onlineTools
│ │ ├── excalidraw
│ │ │ ├── Excalidraw.tsx
│ │ │ └── page.tsx
│ │ └── imageZip
│ │ │ ├── Introduction.tsx
│ │ │ ├── Zip.tsx
│ │ │ ├── page.tsx
│ │ │ └── utils.ts
│ ├── page.tsx
│ ├── robots.ts
│ ├── sitemap.ts
│ └── timeLine
│ │ └── page.tsx
├── assets
│ ├── js
│ │ └── ribbon.js
│ └── style
│ │ ├── common.scss
│ │ ├── detail.scss
│ │ ├── globals.css
│ │ └── loading.scss
├── hooks
│ └── useFetch.ts
├── types
│ ├── article.typings.d.ts
│ ├── aside.typings.d.ts
│ ├── comment.typings.d.ts
│ └── common.typings.d.ts
└── utils
│ ├── common.ts
│ ├── fetch.ts
│ └── metadata.ts
├── tailwind.config.ts
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_CRYPTO_SECRET_KEY=
2 | NEXT_PUBLIC_BASE_URL=
3 | NEXT_PUBLIC_HOST=
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 | pnpm-lock.yaml
38 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 | # Datasource local storage ignored files
7 | /dataSources/
8 | /dataSources.local.xml
9 |
--------------------------------------------------------------------------------
/.idea/blog-nextjs.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | 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.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
37 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: false,
4 | async rewrites() {
5 | return [
6 | {
7 | source: '/blog/:path*',
8 | destination: 'https://server.jszoo.com/blog/:path*',
9 | },
10 | {
11 | source: '/image-proxy/:path*',
12 | destination: 'https://jszoo-file.oss-cn-beijing.aliyuncs.com/:path*',
13 | },
14 | {
15 | source: '/ip/:path*',
16 | destination: 'http://ip-api.com/:path*',
17 | },
18 | ];
19 | },
20 | };
21 |
22 | export default nextConfig;
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "blog-nextjs",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@ant-design/icons": "^5.2.6",
13 | "@excalidraw/excalidraw": "^0.17.2",
14 | "antd": "^5.13.2",
15 | "avataaars": "^2.0.0",
16 | "crypto-js": "^4.2.0",
17 | "file-saver": "^2.0.5",
18 | "jszip": "^3.10.1",
19 | "lodash": "^4.17.21",
20 | "markdown-navbar": "^1.4.3",
21 | "next": "14.1.0",
22 | "platform": "^1.3.6",
23 | "react": "^18",
24 | "react-dom": "^18",
25 | "react-markdown": "^9.0.1",
26 | "react-syntax-highlighter": "^15.5.0",
27 | "rehype-raw": "^7.0.0",
28 | "remark-gfm": "^4.0.0",
29 | "sass": "^1.70.0"
30 | },
31 | "devDependencies": {
32 | "@types/crypto-js": "^4.2.2",
33 | "@types/file-saver": "^2.0.7",
34 | "@types/lodash": "^4.14.202",
35 | "@types/markdown-navbar": "^1.4.4",
36 | "@types/node": "^20",
37 | "@types/platform": "^1.3.6",
38 | "@types/react": "^18",
39 | "@types/react-dom": "^18",
40 | "@types/react-syntax-highlighter": "^15.5.11",
41 | "autoprefixer": "^10.0.1",
42 | "eslint": "^8",
43 | "eslint-config-next": "14.1.0",
44 | "postcss": "^8",
45 | "tailwindcss": "^3.3.0",
46 | "typescript": "^5"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/loading.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SpectreAlan/blog-nextjs/3d3c243a585f09e806fc17c3151c41a9fdbcbcb5/public/loading.gif
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/about/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {Descriptions, DescriptionsProps, Divider} from 'antd'
3 |
4 | import {GithubOutlined, TwitterOutlined} from '@ant-design/icons'
5 | import {Metadata} from "next";
6 |
7 | export const metadata: Metadata = {
8 | title: '关于 - SpectreAlan',
9 | description: '近些年一直从事web前端开发工作,熟悉前端主要技术栈,平时喜欢研究web前端、服务器方面的各种技术',
10 | keywords: 'vercel,NestJS,NextJS,Umi Max,MongoDb,阿里云OSS,Antd,Sass'
11 | };
12 |
13 | const About = () => {
14 | const me: DescriptionsProps['items'] = [
15 | {
16 | key: 'name',
17 | label: 'Name',
18 | children: 'SpectreAlan'
19 | },
20 | {
21 | key: 'email',
22 | label: 'Email',
23 | children: 'comjszoo@gmail.com'
24 | },
25 | {
26 | key: 'hometown',
27 | label: 'Hometown',
28 | children: '四川 - 成都'
29 | },
30 | {
31 | key: 'live',
32 | label: 'Live',
33 | children: 'Singapore'
34 | },
35 | {
36 | key: 'contacts',
37 | label: 'Other Contacts',
38 | children: <>
39 |
40 |
41 |
42 |
43 |
44 |
45 | >
46 | },
47 | {
48 | key: 'summary',
49 | label: 'Summary',
50 | children: metadata.description
51 | },
52 | {
53 | key: 'web',
54 | label: 'Skills - 前端',
55 | children: <>
56 | 技术栈:Vue、React
57 | UI框架:ElementUI、Vant、Antd-design等
58 | 移动端:Flutter、React Native、UniApp、Taro
59 | >
60 | },
61 | {
62 | key: 'backed',
63 | label: 'Skills - 后端',
64 | children: <>
65 | 主要技术栈:NodeJs、Mysql、MongoDB
66 | 框架:NestJS、Koa2、Egg.js
67 | >
68 | }
69 | ]
70 | const blog: DescriptionsProps['items'] = [
71 | {
72 | key: '1',
73 | label: '博客前台',
74 | children: <> 基于Next.js 14.1开发
75 | UI层使用Antd、Sass、Tailwind
76 | 数据请求使用Fetch
77 | 解析markdown采用 marked
78 | 生成文章目录使用 markdown-navbar ,语法高亮 highlight.js>,
79 | },
80 | {
81 | key: 'blog-nextjs',
82 | label: '前台源码',
83 | children:
84 |
88 |
89 | },
90 | {
91 | key: '3',
92 | label: '后台管理系统',
93 | children: <>基于Umijs max开发
94 | UI层使用 @ant-design/pro-components
95 | 图表采用react-echarts
96 | 新建/编辑文章使用 @toast-ui/editor>
97 | },
98 | {
99 | key: 'blog-admin-umijs-max',
100 | label: '后台管理源码',
101 | children:
102 |
106 |
107 | },
108 | {
109 | key: '5',
110 | label: '中台数据接口',
111 | children: <>基于NestJS + MongoDB开发
112 | 所有的图片使用阿里云OSS存储
113 | 定时任务每天获取一言、Bing壁纸
114 | 响应数据通过crypto-js加密
115 | 后台服务通过Vercel免费部署
116 | 数据库使用MongoDB Cloud免费部署>
117 | },
118 | {
119 | key: 'blog-server-nestjs-vercel',
120 | label: '中台源码',
121 | children:
122 |
126 |
127 | },
128 | ]
129 | return <>
130 |
134 |
135 |
关于我
136 | - SpectreAlan
137 |
138 |
145 | >
146 | }
147 | export default About
148 |
--------------------------------------------------------------------------------
/src/app/detail/[id]/Avatar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Avatar from 'avataaars';
3 |
4 | const getRandomValue = (array: any) => array[Math.floor(Math.random() * array.length)];
5 | const RandomAvatar = () => {
6 | const randomOptions = {
7 | topType: ['NoHair', 'ShortHairShortCurly', 'Eyepatch', 'Hat'],
8 | accessoriesType: ['Blank', 'Round', 'Sunglasses'],
9 | facialHairType: ['BeardMedium', 'MoustacheFancy', 'Blank'],
10 | clotheType: ['Hoodie', 'Overall', 'ShirtCrewNeck'],
11 | eyeType: ['Happy', 'Squint', 'Dizzy'],
12 | eyebrowType: ['Angry', 'FlatNatural', 'Default'],
13 | mouthType: ['Twinkle', 'Disbelief', 'Serious'],
14 | skinColor: ['Light', 'Tanned', 'Yellow'],
15 | };
16 |
17 | const randomAvatarOptions = Object.fromEntries(
18 | Object.entries(randomOptions).map(([key, value]) => [key, getRandomValue(value)])
19 | );
20 |
21 | return (
22 |
29 | );
30 | };
31 | export default RandomAvatar;
32 |
--------------------------------------------------------------------------------
/src/app/detail/[id]/Comment.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import React, {useState, useEffect} from 'react'
3 | import {Divider, Spin, Row, Col, Button, Tag, Avatar} from 'antd'
4 | import httpRequest from "@/utils/fetch";
5 | import {CommentOutlined, GlobalOutlined, EnvironmentOutlined} from '@ant-design/icons'
6 | import RandomAvatar from '@/app/detail/[id]/Avatar'
7 | import CommentForm from "@/app/detail/[id]/CommentForm";
8 |
9 | const Comment: React.FC<{ id: string }> = ({id}) => {
10 | const defaultInfo: Comment.Info = {
11 | article: id,
12 | parentId: '-1'
13 | }
14 | const [loading, setLoading] = useState(false)
15 | const [info, setInfo] = useState(defaultInfo)
16 | const [comments, setComments] = useState([])
17 | const queryComments = async () => {
18 | setLoading(true)
19 | const res: { list: Comment.Item[] } | null = await httpRequest({
20 | url: `${process.env.NEXT_PUBLIC_BASE_URL}/blog/comment`,
21 | data: {id}
22 | })
23 | setLoading(false)
24 | const list = res?.list ?? []
25 | setComments(generateComments(list))
26 | }
27 | const generateComments = (list: Comment.Item[]): Comment.Item[] => {
28 | const hash: { [key: string]: Comment.Item[] } = {}
29 | let topLevel: Comment.Item[] = []
30 | const secondLevel: Comment.Item[] = []
31 | for (let i = 0; i < list.length; i++) {
32 | if (list[i].parentId === '-1') {
33 | topLevel.push(list[i])
34 | hash[list[i].id] = []
35 | } else {
36 | secondLevel.push(list[i])
37 | }
38 | }
39 | for (let i = 0; i < secondLevel.length; i++) {
40 | secondLevel[i].parentName = topLevel.find(item => item.id === secondLevel[i].parentId)!.nickName
41 | hash[secondLevel[i].parentId].push(secondLevel[i])
42 | }
43 | topLevel.sort((a, b) => (new Date(a.createdAt) as any) - (new Date(b.createdAt) as any))
44 |
45 | for (let i = 0; i < list.length; i++) {
46 | const temp: Comment.Item[] = hash[topLevel[i].id]
47 | if (temp && temp.length > 0) {
48 | const l = topLevel.slice(0, i + 1)
49 | const r = topLevel.slice(i + 1, topLevel.length)
50 | temp.sort((a, b) => (new Date(a.createdAt) as any) - (new Date(b.createdAt) as any))
51 | topLevel = l.concat(temp, r)
52 | i += temp.length
53 | }
54 | }
55 | const i = topLevel.findIndex(item => item.pinned)
56 | if (i > 0) {
57 | const o = {...topLevel[i]}
58 | topLevel.splice(i, 1);
59 | topLevel.unshift(o);
60 | }
61 | return topLevel
62 | }
63 | const handelFinished = () => {
64 | setInfo(defaultInfo)
65 | queryComments()
66 | }
67 |
68 | useEffect(() => {
69 | queryComments()
70 | }, [])
71 |
72 | return <>
73 |
74 |
75 |
吐槽一下
76 | {
77 | info.parentId === '-1' &&
78 | }
79 |
80 | {
81 | comments.map((comment, i) => (
82 |
84 |
85 |
86 | {
87 | comment.author ?
88 | :
90 | }
91 |
92 |
93 | {comment.pinned ? 置顶 : (
94 |
95 | {
96 | comment.nickName ?
97 | {comment.nickName} :
98 | 博主
99 | }
100 | {
101 | comment.parentName &&
102 | @ {comment.parentName}
103 | }
104 | {
105 | comment?.platform && {comment.platform}
106 | }
107 | {comment.region}
108 |
109 | )}
110 | {comment.createdAt}
111 | {comment.content}
112 |
113 |
114 | {
115 | info.parentId === comment.id ?
116 | :
117 |
122 | }
123 |
124 |
125 | {
126 | info.parentId === comment.id &&
127 |
128 | }
129 |
))
130 | }
131 |
132 |
133 | >
134 | }
135 |
136 | export default Comment
--------------------------------------------------------------------------------
/src/app/detail/[id]/CommentForm.tsx:
--------------------------------------------------------------------------------
1 | import {Button, Form, Input} from 'antd';
2 | import {MailOutlined, MessageOutlined, UserOutlined} from '@ant-design/icons';
3 | import React, {useState} from 'react';
4 | import httpRequest from "@/utils/fetch";
5 |
6 | interface IProps {
7 | info: Comment.Info
8 | handelFinished: () => void
9 | }
10 |
11 | const CommentForm: React.FC = ({info, handelFinished}) => {
12 | const [loading, setLoading] = useState(false)
13 |
14 | const [form] = Form.useForm()
15 |
16 | const placeholder = info?.nickName ? '@' + info?.nickName : 'Talk is cheap,show me the code!'
17 | const onFinish = async (values: { [key: string]: string }) => {
18 | setLoading(true)
19 | const deviceInfo: { [key: string]: string } = {}
20 | const deviceJSON = sessionStorage.getItem('device')
21 | if (deviceJSON) {
22 | const {province, os} = JSON.parse(deviceJSON)
23 | deviceInfo.platform = os
24 | deviceInfo.region = province
25 | }
26 | httpRequest({
27 | url: '/blog/comment',
28 | method: 'POST',
29 | data: {
30 | ...values,
31 | ...info,
32 | ...deviceInfo
33 | }
34 | }).then(() => {
35 | setLoading(false)
36 | form.resetFields()
37 | handelFinished()
38 | }).catch(() => {
39 | setLoading(false)
40 | })
41 | };
42 | return ( 昵称 } name="nickName"
45 | rules={[{required: true, message: '请输入您的昵称!',}]}
46 | >
47 |
48 |
49 | 邮箱 } name="email"
51 | rules={[{type: 'email', message: '邮箱格式有误!',}, {
52 | required: true,
53 | message: '必填!但不会公开,站长回复以后会收到邮件通知',
54 | }]}
55 | >
56 |
57 |
58 | 评论 } name="content"
60 | rules={[{required: true, message: '请输入您的评论!',}]}
61 | >
62 |
63 |
64 |
65 |
66 |
67 | )
68 | }
69 | export default CommentForm
70 |
--------------------------------------------------------------------------------
/src/app/detail/[id]/Content.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Markdown from 'react-markdown'
3 | import remarkGfm from 'remark-gfm'
4 | import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter'
5 | import {vscDarkPlus} from 'react-syntax-highlighter/dist/esm/styles/prism'
6 | import rehypeRaw from 'rehype-raw';
7 | import '@/assets/style/detail.scss'
8 | import Copy from "@/app/detail/[id]/Copy";
9 |
10 |
11 | const Content: React.FC<{ content: string }> = ({content}) => {
12 |
13 | return
14 |
2
23 | return match ? (
24 |
25 |
26 |
35 | {String(children).replace(/\n$/, '')}
36 |
37 |
38 |
39 | ) : (
40 |
41 | {children}
42 |
43 | )
44 | }
45 | }}
46 | >
47 | {content}
48 |
49 |
50 | }
51 |
52 | export default Content
--------------------------------------------------------------------------------
/src/app/detail/[id]/Copy.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import React from "react";
3 | import {CopyOutlined} from "@ant-design/icons";
4 | import {message} from 'antd'
5 |
6 | const Copy: React.FC<{ code: string }> = ({code}) => {
7 |
8 | const copy = () => {
9 | navigator.clipboard.writeText(code)
10 | message.success('已复制到剪切板')
11 | }
12 |
13 | return copy()}
17 | title='点击复制'
18 | />
19 | }
20 |
21 | export default Copy
--------------------------------------------------------------------------------
/src/app/detail/[id]/Nav.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import React from 'react'
3 | import MarkdownNavbar from 'markdown-navbar'
4 | import 'markdown-navbar/dist/navbar.css';
5 | const Nav:React.FC<{content: string}> = ({content})=>{
6 | return
7 |
文章目录
8 |
13 |
14 | }
15 |
16 | export default Nav
--------------------------------------------------------------------------------
/src/app/detail/[id]/Recommend.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {Divider} from 'antd'
3 | import httpRequest from "@/utils/fetch";
4 | import {LikeFilled} from '@ant-design/icons'
5 | import Link from 'next/link'
6 | import Image from 'next/image'
7 |
8 | const Recommend: React.FC<{ tags: string, id: string }> = async ({tags, id}) => {
9 | const res: { list: Article.ArticleItem[] } | null = await httpRequest({
10 | url: `${process.env.NEXT_PUBLIC_BASE_URL}/blog/related`,
11 | data: {tags}
12 | })
13 | if (!res) {
14 | return null
15 | }
16 | const recommend: Article.ArticleItem[] = res.list.filter(article => article.id !== id)
17 | return <>
18 |
19 |
20 |
相关推荐
21 |
22 | {
23 | recommend.map((article) => (
24 |
30 |
31 |
39 |
{article.title}
41 |
{article.createdAt}
42 |
43 |
44 | ))
45 | }
46 |
47 |
48 | >
49 | }
50 |
51 | export default Recommend
--------------------------------------------------------------------------------
/src/app/detail/[id]/Title.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {Space, Tag} from "antd";
3 | import {
4 | ClockCircleOutlined,
5 | InboxOutlined,
6 | ProfileOutlined,
7 | RiseOutlined,
8 | ScheduleOutlined,
9 | TagOutlined
10 | } from "@ant-design/icons";
11 |
12 | const colors: string[] = ['green', 'cyan', 'blue', 'geekblue', 'purple', 'lime', 'gold']
13 |
14 | const Title: React.FC<{ detail: Article.Detail }> = ({detail}) => {
15 | const {content, createdAt, updatedAt, scan, title, category, tags} = detail
16 | return
22 |
23 |
26 |
{title}
27 |
28 | 标签:
29 |
30 | {tags.map((tag, i) => {tag.title})}
31 |
32 |
33 |
34 | 类别:{category.title}
35 |
36 |
37 | 创建时间: {createdAt}
38 |
39 |
40 |
41 | 字数总计: {(content.length / 1000).toFixed(2) + ' k'}
42 | 建议阅读时长: {Math.ceil(content.length / 1000) || 1} 分钟
44 | 阅读量: {scan}
45 |
46 |
47 |
48 |
49 | }
50 | export default Title
--------------------------------------------------------------------------------
/src/app/detail/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import httpRequest from "@/utils/fetch";
3 | import Nav from '@/app/detail/[id]/Nav'
4 | import Title from '@/app/detail/[id]/Title'
5 | import Content from '@/app/detail/[id]/Content'
6 | import Recommend from '@/app/detail/[id]/Recommend'
7 | import Comment from '@/app/detail/[id]/Comment'
8 | import Error from '@/app/layout/error'
9 | import {Metadata} from 'next'
10 | import DefaultMetadata from '@/utils/metadata'
11 |
12 | type Props = {
13 | params: { id: string }
14 | }
15 |
16 | export async function generateMetadata(
17 | {params}: Props
18 | ): Promise {
19 | const detail: Article.Detail | null = await httpRequest({
20 | url: `${process.env.NEXT_PUBLIC_BASE_URL}/blog/detail`,
21 | data: params,
22 | options: {
23 | cache: 'no-store'
24 | }
25 | })
26 | if (detail) {
27 | const {title, description, category, tags} = detail
28 | return {
29 | title,
30 | description,
31 | category: category.title,
32 | keywords: tags.map(item => item.title).join(',')
33 | }
34 | }
35 | return DefaultMetadata
36 | }
37 |
38 | const DetailPage = async ({params}: { params: { id: string } }) => {
39 | const {id} = params
40 | const detail: Article.Detail | null = await httpRequest({
41 | url: `${process.env.NEXT_PUBLIC_BASE_URL}/blog/detail`,
42 | data: {id},
43 | options: {
44 | cache: 'no-store'
45 | }
46 | })
47 | if (!detail) {
48 | return
49 | }
50 | const {content, tags, catalogue} = detail
51 | const tagsString = tags.map(tag => tag.title).join(',')
52 | return
53 | {
54 | catalogue &&
63 | }
64 |
65 | export default DetailPage
--------------------------------------------------------------------------------
/src/app/error.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import React from 'react'
3 | import {Button, Result} from "antd";
4 |
5 | interface IProps {
6 | reset: () => void
7 | }
8 |
9 | const Error: React.FC = ({reset}) => {
10 | return Try again}
15 | />
16 |
17 | }
18 |
19 | export default Error
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SpectreAlan/blog-nextjs/3d3c243a585f09e806fc17c3151c41a9fdbcbcb5/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/home/Articles.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {Row, Col} from 'antd';
3 | import {InboxOutlined, ScheduleOutlined} from '@ant-design/icons';
4 | import Link from 'next/link'
5 | import httpRequest from "@/utils/fetch";
6 | import Image from 'next/image'
7 |
8 | const ArticleList: React.FC = async ({searchParams}) => {
9 | const res: { list: Article.ArticleItem[], total: number } | null = await httpRequest({
10 | url: `${process.env.NEXT_PUBLIC_BASE_URL}/blog/list`,
11 | data: searchParams,
12 | options: {
13 | cache: 'no-store'
14 | }
15 | })
16 | if (!res) {
17 | return null
18 | }
19 | return
20 | {
21 | res.list.map((item, index) => (
22 |
24 |
25 |
27 |
28 |
29 |
30 | {item.title}
31 |
32 |
33 | {item.createdAt} |
34 | {item.category.title}
35 |
36 |
38 | {item.description}
39 |
40 |
41 |
42 | ))
43 | }
44 | {
45 | res?.total &&
46 | Total {res.total} items
47 | {
48 | Array.from({length: Math.ceil(res.total / 10)}, (_, i) => i + 1).map((_) => {_})
52 | }
53 |
54 | }
55 |
56 | };
57 | export default ArticleList
58 |
--------------------------------------------------------------------------------
/src/app/home/Aside.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import httpRequest from "@/utils/fetch";
3 | import Profile from "@/app/home/Profile";
4 | import Notice from "@/app/home/Notice";
5 | import RecentUpdate from "@/app/home/RecentUpdate";
6 | import Category from "@/app/home/Category";
7 | import Tags from "@/app/home/Tags";
8 |
9 | const ArticleList: React.FC = async () => {
10 | const aside: Aside.Items | null = await httpRequest({
11 | url: `${process.env.NEXT_PUBLIC_BASE_URL}/blog/aside`,
12 | options: {cache: 'no-store'}
13 | })
14 | if(!aside){
15 | return null
16 | }
17 | return <>
18 |
19 |
20 |
21 |
22 |
23 | >
24 | };
25 | export default ArticleList
26 |
--------------------------------------------------------------------------------
/src/app/home/Category.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {Col, Row} from "antd";
3 | import Link from 'next/link';
4 | import {FolderOpenOutlined} from "@ant-design/icons";
5 |
6 | const RecentUpdate: React.FC<{ categoryList: Aside.Category[] }> = ({categoryList}) => {
7 | return
8 |
分类
9 | {
10 | categoryList.map((item) => (
11 |
16 |
17 |
18 | {item.category}
19 |
20 |
21 | {item.count}
22 |
23 |
24 |
25 | ))
26 | }
27 |
28 | }
29 |
30 | export default RecentUpdate
--------------------------------------------------------------------------------
/src/app/home/FullPage.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import React, {useState, useEffect} from 'react'
3 | import {DoubleLeftOutlined} from '@ant-design/icons'
4 | import httpRequest from "@/utils/fetch";
5 |
6 | interface IPoem {
7 | content: string
8 | }
9 |
10 | const FullPage: React.FC = () => {
11 | const [poem, setPoem] = useState([{content: '茶若醉人何须酒,唯有碎银解千愁'}])
12 | const [count, setCount] = useState(0)
13 | const [index, setIndex] = useState(0)
14 |
15 | const query = async () => {
16 | const res: { list: IPoem[] } | null = await httpRequest({
17 | url: `${process.env.NEXT_PUBLIC_BASE_URL}/blog/poem`,
18 | })
19 | setPoem(res?.list ?? [])
20 | }
21 |
22 | useEffect(() => {
23 | query()
24 | }, [])
25 | useEffect(() => {
26 | const timer = setInterval(() => {
27 | setCount(prevCount => {
28 | const i = prevCount < poem[index].content.length ? prevCount + 1 : 0;
29 | if (i === 0) {
30 | setIndex(Math.floor(Math.random() * (poem.length)))
31 | }
32 | return i;
33 | });
34 | }, 300)
35 | return () => {
36 | clearInterval(timer)
37 | }
38 | }, [poem])
39 | return
42 |
43 |
45 | {poem?.[index]?.content.slice(0, count)}
46 |
49 | |
50 |
51 |
52 |
55 |
56 |
57 |
58 | }
59 |
60 |
61 | export default FullPage
62 |
--------------------------------------------------------------------------------
/src/app/home/Notice.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import React, {useEffect, useRef, useState} from 'react'
3 | import {SoundOutlined} from "@ant-design/icons";
4 |
5 | export const Notice: React.FC<{ notice: string }> = ({notice}) => {
6 | const canvas = useRef(null)
7 | const [x, setX] = useState(252)
8 | useEffect(() => {
9 | // @ts-ignore
10 | const ctx = canvas?.current?.getContext('2d')
11 | const timer = setInterval(() => {
12 | ctx.clearRect(0, 0, 252, 40)
13 | ctx.fillStyle = 'black'
14 | ctx.font = '14px 微软雅黑'
15 | setX((x) => x - 2)
16 | ctx.fillText(notice, x, 25)
17 | if (x < -(14 * notice.length)) {
18 | setX(200)
19 | }
20 | }, 30)
21 | return () => {
22 | clearInterval(timer)
23 | }
24 | }, [x])
25 | return
26 |
27 | 公告
28 |
29 |
30 |
31 | }
32 |
33 | export default Notice
--------------------------------------------------------------------------------
/src/app/home/Profile.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {Col, Row, Tooltip} from "antd";
3 | import {BookOutlined, GithubOutlined, MailOutlined, TwitterOutlined} from "@ant-design/icons";
4 | import Image from "next/image";
5 |
6 | const Profile: React.FC<{ aside: Aside.Items }> = ({aside}) => {
7 | return
37 | }
38 |
39 | export default Profile
--------------------------------------------------------------------------------
/src/app/home/RecentUpdate.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {Col, Row} from "antd";
3 | import Link from 'next/link';
4 | import {FieldTimeOutlined} from "@ant-design/icons";
5 | import Image from 'next/image'
6 |
7 | const RecentUpdate: React.FC<{ articles: Article.ArticleItem[] }> = ({articles}) => {
8 | return
9 |
最近更新
10 | {
11 | articles.map((item, index) => (
12 |
13 |
14 |
15 |
16 |
17 |
18 | {item.title}
19 | {item.createdAt}
20 |
21 |
22 |
23 | ))
24 | }
25 |
26 | }
27 |
28 | export default RecentUpdate
--------------------------------------------------------------------------------
/src/app/home/Tags.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {TagOutlined} from "@ant-design/icons";
3 | import Link from 'next/link';
4 |
5 | const RecentUpdate: React.FC<{ tags: string[] }> = ({tags}) => {
6 | return
30 | }
31 |
32 | export default RecentUpdate
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type {Metadata} from "next";
2 | import "@/assets/style/globals.css";
3 | import '@/assets/style/common.scss'
4 | import Header from '@/app/layout/Header'
5 | import Footer from '@/app/layout/Footer'
6 | import React from "react";
7 | import DefaultMetadata from '@/utils/metadata'
8 |
9 |
10 | export const metadata: Metadata = DefaultMetadata;
11 |
12 | export default function RootLayout({
13 | children,
14 | }: Readonly<{
15 | children: React.ReactNode;
16 | }>) {
17 | return (
18 |
19 |
20 |
21 |
22 | {children}
23 |
24 |
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/app/layout/Footer.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Image from 'next/image'
3 | import httpRequest from "@/utils/fetch";
4 | import { Divider} from 'antd'
5 | import Statistics from "@/app/layout/Statistics";
6 | import { headers } from 'next/headers';
7 | const Footer: React.FC = async () => {
8 | const ip = headers().get('x-forwarded-for')?.split(',')[0] || headers().get('x-real-ip') || ''
9 | const response: { visitor: number, total: number, today: number } | null = await httpRequest({
10 | url: `${process.env.NEXT_PUBLIC_BASE_URL}/blog/visitor`,
11 | })
12 | return
13 |
14 |
15 | - ©2018 - {new Date().getFullYear()} by SpectreAlan
16 | - 访客(总数/今日): {response?.total ?? 0} / {response?.today ?? 0}
17 | - 总访问量: {response?.visitor ?? 0}
18 |
19 |
20 |
21 |
22 | }
23 |
24 | export default Footer
--------------------------------------------------------------------------------
/src/app/layout/Header.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 | import React, {useState, useEffect} from 'react'
3 | import _ from 'lodash';
4 | import Nav from "@/app/layout/Nav";
5 | import Image from 'next/image'
6 | import {useRouter} from "next/navigation";
7 |
8 | const Header = () => {
9 | const router = useRouter()
10 | const [scrollY, setScrollY] = useState(0);
11 | const [isUp, setIsUp] = useState(false);
12 |
13 | useEffect(() => {
14 | window.onscroll = _.throttle(() => {
15 | const y = document.documentElement.scrollTop
16 | setIsUp(y > 200 && y > scrollY)
17 | setScrollY(y)
18 | }, 300)
19 | })
20 | return (
21 |
23 |
24 |
router.push('/')}>
25 |
26 | {'SpectreAlan\'s blogs'}
27 |
28 |
29 |
30 |
31 | )
32 | }
33 |
34 | export default Header
--------------------------------------------------------------------------------
/src/app/layout/Nav.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 | import React, {useEffect, useState} from 'react'
3 | import {
4 | HomeOutlined,
5 | SearchOutlined,
6 | CalendarOutlined,
7 | TagOutlined,
8 | FileTextOutlined,
9 | ToolOutlined,
10 | FileZipOutlined,
11 | MenuFoldOutlined,
12 | DeploymentUnitOutlined,
13 | FormatPainterOutlined
14 | } from '@ant-design/icons'
15 | import {useRouter, usePathname} from 'next/navigation'
16 | import {Menu, Drawer} from 'antd'
17 | import type {MenuProps} from 'antd';
18 | import dynamic from 'next/dynamic'
19 |
20 | const Search = dynamic(() => import('@/app/layout/Search'))
21 |
22 | const Nav: React.FC = () => {
23 | const router = useRouter()
24 | const pathname = usePathname()
25 |
26 | const [current, setCurrent] = useState('home');
27 | const [searchModal, setSearchModal] = useState(false);
28 | const [navDrawer, setNavDrawer] = useState(false);
29 |
30 | const onClick: MenuProps['onClick'] = ({key}) => {
31 | setNavDrawer(false)
32 | if (key === 'note') {
33 | return
34 | }
35 | if (key === 'search') {
36 | setSearchModal(true)
37 | return
38 | }
39 | router.push(key)
40 | };
41 | const items: MenuProps['items'] = [
42 | {
43 | label: '搜索',
44 | key: 'search',
45 | icon: ,
46 | },
47 | {
48 | label: '主页',
49 | key: '/',
50 | icon: ,
51 | },
52 | {
53 | label: '时间轴',
54 | key: '/timeLine',
55 | icon: ,
56 | },
57 | {
58 | label: (
59 |
60 | 趣导航
61 |
62 | ),
63 | key: 'note',
64 | icon:
65 | },
66 | {
67 | label: (
68 |
69 | 个人文档
70 |
71 | ),
72 | key: 'note',
73 | icon:
74 | },
75 | {
76 | label: '工具',
77 | key: '/onlineTools',
78 | icon: ,
79 | children: [
80 | {
81 | label: '图片无损压缩',
82 | key: '/onlineTools/imageZip',
83 | icon: ,
84 | },
85 | {
86 | label: '手绘白板',
87 | key: '/onlineTools/excalidraw',
88 | icon: ,
89 | },
90 | ]
91 | },
92 | {
93 | label: '关于',
94 | key: '/about',
95 | icon: ,
96 | },
97 | ]
98 |
99 | useEffect(() => {
100 | setCurrent(pathname)
101 | }, [pathname])
102 |
103 | const renderMenu = (mode: any) =>
113 | return
114 |
{renderMenu('horizontal')}
115 |
setNavDrawer(true)}/>
117 |
118 | {
119 | navDrawer &&
setNavDrawer(false)}
125 | >
126 | {renderMenu('vertical')}
127 |
128 | }
129 | {searchModal &&
}
130 |
131 | }
132 |
133 | export default Nav
--------------------------------------------------------------------------------
/src/app/layout/Search.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react'
2 | import {List, Modal, Input, message} from 'antd';
3 | import {useRouter} from "next/navigation";
4 | import useFetch from "@/hooks/useFetch";
5 |
6 | interface IProps {
7 | setSearchModal: (visible: boolean) => void
8 | }
9 |
10 | const Search: React.FC = ({setSearchModal}) => {
11 |
12 | const router = useRouter()
13 | const [keywords, setKeywords] = useState('');
14 | const {response, loading, handleFetch} = useFetch<{ list: Article.ArticleItem[] }>({
15 | url: '/blog/list',
16 | method: 'GET',
17 | })
18 |
19 | const onSelect = (id: string) => {
20 | setSearchModal(false)
21 | router.push(`/detail/${id}`)
22 | }
23 | const onSearch = async () => {
24 | if (!keywords) {
25 | message.info('老铁,请输入关键字');
26 | return
27 | }
28 | handleFetch({keywords})
29 | };
30 | return setSearchModal(false)}
34 | open={true}
35 | destroyOnClose
36 | >
37 | setKeywords(e.target.value)}
42 | value={keywords}
43 | className='mb-2'
44 | />
45 | (
52 | onSelect(item.id)}>
53 | {item.title}
54 |
55 | )}
56 | />
57 |
58 | }
59 |
60 | export default Search
--------------------------------------------------------------------------------
/src/app/layout/Statistics.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import React, {useEffect} from 'react'
3 | import httpRequest from "@/utils/fetch";
4 | import platform from 'platform'
5 | import '@/assets/js/ribbon'
6 |
7 | const fetchIp = async (ip:string): Promise => {
8 | const res = await fetch(`/ip/json/${ip}?lang=zh-CN`)
9 | return await res.json()
10 | }
11 |
12 | const Statistics:React.FC<{ip:string}> = ({ip}) => {
13 | useEffect(() => {
14 | const last = sessionStorage.getItem('last')
15 | if (!last || ((new Date().getTime() - Number(last)) > 300000)) {
16 | fetchIp(ip).then(({status, ...res}) => {
17 | if (status === 'success') {
18 | const {country, city, org: organization, regionName: province, query: ip} = res
19 | const {product, os, name} = platform
20 | let osName = ''
21 | if (os) {
22 | osName = (os?.family ?? '') + ' ' + (os?.version ?? '')
23 | }
24 | const data = {country, city, organization, province, ip, device: product, os: osName, browser: name}
25 | sessionStorage.setItem('device', JSON.stringify(data))
26 | sessionStorage.setItem('last', new Date().getTime() + '')
27 | httpRequest({
28 | url: '/blog/statistics',
29 | method: 'POST',
30 | data
31 | })
32 | }
33 | })
34 | }
35 | }, [ip])
36 | return null
37 | }
38 |
39 | export default Statistics
40 |
--------------------------------------------------------------------------------
/src/app/layout/error.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import React, {useEffect, useState} from "react";
3 | import {Button, Result} from "antd";
4 | import {useRouter} from 'next/navigation'
5 |
6 | const config = {
7 | '404': {
8 | title: '迷路啦',
9 | subTitle: 'Sorry, the page you visited does not exist.'
10 | },
11 | '500': {
12 | title: '出错啦',
13 | subTitle: 'Sorry, something went wrong.'
14 | }
15 | }
16 |
17 | const Error: React.FC<{status: '404' | '500'}> = ({status}) => {
18 | const router = useRouter()
19 | const [seconds, setSeconds] = useState(5)
20 | const [loading, setLoading] = useState(false)
21 |
22 | const backHome = () => {
23 | if(loading){
24 | return
25 | }
26 | setLoading(true)
27 | router.push('/')
28 | }
29 | useEffect(() => {
30 | const timer = setInterval(() => {
31 | setSeconds(seconds => {
32 | const now = seconds - 1
33 | if (!now) {
34 | clearInterval(timer)
35 | backHome()
36 | }
37 | return now
38 | })
39 | }, 1000)
40 | return () => {
41 | clearInterval(timer)
42 | }
43 | }, [])
44 | return
45 |
50 | {seconds}秒后跳转首页
51 |
52 |
}
53 | />
54 |
55 | }
56 |
57 | export default Error
58 |
--------------------------------------------------------------------------------
/src/app/loading.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import '@/assets/style/loading.scss'
3 | import Image from 'next/image'
4 |
5 | const Loading: React.FC = () => {
6 | return
15 | }
16 |
17 | export default Loading
--------------------------------------------------------------------------------
/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Error from "@/app/layout/error";
3 |
4 | export default function NotFound() {
5 | return
6 | }
--------------------------------------------------------------------------------
/src/app/onlineTools/excalidraw/Excalidraw.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import React, {useState} from "react";
3 | import dynamic from "next/dynamic";
4 | const Excalidraw = dynamic(
5 | async () => (await import("@excalidraw/excalidraw")).Excalidraw,
6 | {
7 | ssr: false,
8 | },
9 | );
10 |
11 | const Draw = () => {
12 | return ;
13 |
14 | }
15 |
16 | export default Draw
--------------------------------------------------------------------------------
/src/app/onlineTools/excalidraw/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {Metadata} from "next";
3 | import Draw from "@/app/onlineTools/excalidraw/Excalidraw";
4 |
5 | export const metadata: Metadata = {
6 | title: '在线虚拟手绘风格白板',
7 | description: '一款开源的、在线的虚拟手绘风格白板。 协作和端到端加密',
8 | keywords: '白板、手绘风格、在线、思维导图、脑图'
9 | };
10 |
11 | const Excalidraw = () => {
12 | return
26 | }
27 |
28 | export default Excalidraw
--------------------------------------------------------------------------------
/src/app/onlineTools/imageZip/Introduction.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {ReadOutlined} from '@ant-design/icons'
3 |
4 | const Introduction = () => {
5 | return <>
6 | 工具说明:
7 |
8 | 该工具是一款小巧的在线批量无损压缩图片工具,图片不会上传,纯浏览器压缩,压缩方式并非单纯的裁剪尺寸,通过压缩比控制图片的输出质量,压缩比值越小压缩力度越大,对应图片质量越低,建议使用默认值压缩,单次可以压缩50张
9 |
10 | >
11 | }
12 |
13 | export default Introduction
--------------------------------------------------------------------------------
/src/app/onlineTools/imageZip/Zip.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import React, {useState} from "react";
3 | import {QuestionCircleOutlined, UploadOutlined, SyncOutlined, FileZipOutlined, RobotOutlined} from '@ant-design/icons'
4 | import {Steps, Row, Col, Slider, Upload, Button, message} from 'antd'
5 | import JSZip from 'jszip'
6 | import FileSaver from 'file-saver'
7 | import {base64ToBlob, photoCompress} from './utils'
8 | import type {UploadProps, UploadFile} from 'antd';
9 |
10 | const Zip = () => {
11 | const [step, setStep] = useState(0)
12 | const [count, setCount] = useState(0)
13 | const [list, setList] = useState([])
14 | const [quality, setQuality] = useState(7)
15 | const [state, setState] = useState(false)
16 |
17 | const marks = {
18 | 1: {style: {color: '#f50',}, label: 1},
19 | 10: {style: {color: '#f50',}, label: 10}
20 | }
21 | const uploadConfig: UploadProps = {
22 | fileList: list,
23 | maxCount: 50,
24 | multiple: true,
25 | listType: 'picture',
26 | beforeUpload: (file: UploadFile) => {
27 | if (!file.type || !file.type.includes('image')) {
28 | message.error('年轻人不讲武德 ' + file.name + ' 不是一张图片呀铁汁')
29 | return
30 | }
31 | setList((list) => {
32 | const files = [...list, file]
33 | if (files.length > 0) {
34 | setStep(1)
35 | }
36 | return files
37 | })
38 | },
39 | onRemove: (file: any) => {
40 | const files = list.filter((v) => v.name !== file.name)
41 | setList(files)
42 | },
43 | };
44 | const getImgArrayBuffer = (img: Blob) => new Promise((resolve, reject) => {
45 | photoCompress(img, {quality: quality * 0.1, type: img.type}, (data: string) => {
46 | setCount((count) => ++count)
47 | resolve(base64ToBlob(data))
48 | })
49 | })
50 | const zipImg = () => {
51 | if (state) {
52 | message.info('别急呀大佬,上一波操作还没结束呢')
53 | return
54 | }
55 | setCount(0)
56 | setStep(2)
57 | if (list.length === 0) {
58 | message.error('你还没有选择图片呀大佬')
59 | return
60 | }
61 | setState(true)
62 | const zip = new JSZip();
63 | const promises: Promise[] = []
64 | list.map((item: UploadFile) => {
65 | // @ts-ignore
66 | const promise = getImgArrayBuffer(item).then((data) => {
67 | // @ts-ignore
68 | zip.file(item.name, data, {binary: true})
69 | })
70 | promises.push(promise)
71 | })
72 | Promise.all(promises).then(() => {
73 | zip.generateAsync({type: 'blob'}).then((content: Blob) => {
74 | FileSaver.saveAs(content, `${window.location.host}.zip`);
75 | message.success('压缩完毕,保存压缩包即可')
76 | setState(false)
77 | setList([])
78 | setStep(0)
79 | });
80 | })
81 | .catch(() => {
82 | message.error('文件压缩失败,刷新重试')
83 | });
84 | }
85 | return <>
86 | 使用步骤:
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 | 压缩比 (默认7)
95 | setQuality(Number(val))}
101 | />
102 |
103 |
104 |
105 |
109 |
110 |
111 |
112 |
116 |
117 |
118 | >
119 | }
120 |
121 | export default Zip
--------------------------------------------------------------------------------
/src/app/onlineTools/imageZip/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Introduction from "@/app/onlineTools/imageZip/Introduction";
3 | import {Metadata} from "next";
4 | import Zip from "@/app/onlineTools/imageZip/Zip";
5 |
6 | export const metadata: Metadata = {
7 | title: '在线批量无损压缩图片',
8 | description: '一款小巧的在线批量无损压缩图片工具,批量压缩,打包下载,压缩质量设置',
9 | keywords: '在线压缩,图片压缩,无损压缩,批量下载'
10 | };
11 |
12 | const ImageZipPage = () => {
13 | return
14 |
18 |
19 |
在线批量无损压缩图片
20 |
21 |
27 |
28 | }
29 |
30 | export default ImageZipPage
--------------------------------------------------------------------------------
/src/app/onlineTools/imageZip/utils.ts:
--------------------------------------------------------------------------------
1 | interface IOption {
2 | quality: number
3 | type: string
4 | width?: number
5 | height?: number
6 | }
7 |
8 | function canvasDataURL(path: string, options: IOption, callback: (params: any) => any) {
9 | const img = new Image();
10 | img.src = path;
11 | img.onload = function () {
12 | let that: any = this;
13 | let w = that.width,
14 | h = that.height,
15 | scale = w / h;
16 | w = options.width || w;
17 | h = options.height || (w / scale);
18 | let quality = 0.7;
19 | const canvas = document.createElement('canvas');
20 | const ctx: CanvasRenderingContext2D = canvas.getContext('2d')!;
21 | const anw = document.createAttribute('width');
22 | anw.nodeValue = w;
23 | const anh = document.createAttribute('height');
24 | anh.nodeValue = h;
25 | canvas.setAttributeNode(anw);
26 | canvas.setAttributeNode(anh);
27 | ctx.drawImage(that, 0, 0, w, h);
28 | if (options.quality && options.quality <= 1 && options.quality > 0) {
29 | quality = options.quality;
30 | }
31 | const base64 = canvas.toDataURL(options.type === 'image/png' ? 'image/png' : 'image/jpeg', quality)
32 | callback(base64);
33 | }
34 | }
35 |
36 | export function photoCompress(file: Blob, options: IOption, callback: (params: any) => any) {
37 | let ready = new FileReader();
38 | ready.readAsDataURL(file);
39 | ready.onload = function () {
40 | let path = this.result as string;
41 | canvasDataURL(path, options, callback)
42 | }
43 | }
44 |
45 | export function base64ToBlob(data: string) {
46 | const arr: string[] = data.split(',')
47 | const mime: string = arr[0].match(/:(.*?);/)?.[1] as string
48 | const str: string = atob(arr[1])
49 | let n: number = str.length
50 | let u8arr: Uint8Array = new Uint8Array(n);
51 | while (n--) {
52 | u8arr[n] = str.charCodeAt(n);
53 | }
54 | return new Blob([u8arr], {type: mime});
55 | }
56 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import FullPage from "@/app/home/FullPage";
2 | import Articles from "@/app/home/Articles";
3 | import Aside from "@/app/home/Aside";
4 | import React from "react";
5 | import {Col, Row} from 'antd'
6 |
7 | const HomePage: React.FC = ({searchParams}) => {
8 | return (
9 | <>
10 |
11 |
12 |
13 |
14 |
15 |
20 |
21 |
22 |
23 |
24 | >
25 | );
26 | }
27 | export default HomePage
28 |
--------------------------------------------------------------------------------
/src/app/robots.ts:
--------------------------------------------------------------------------------
1 | import { MetadataRoute } from 'next'
2 |
3 | export default function robots(): MetadataRoute.Robots {
4 | return {
5 | rules: {
6 | userAgent: '*',
7 | allow: '/'
8 | },
9 | sitemap: 'https://jszoo.com/sitemap.xml',
10 | }
11 | }
--------------------------------------------------------------------------------
/src/app/sitemap.ts:
--------------------------------------------------------------------------------
1 | import {MetadataRoute} from "next";
2 | import httpRequest from "@/utils/fetch";
3 |
4 | export default async function sitemap(): Promise {
5 | const baseURL = process.env.NEXT_PUBLIC_HOST
6 | const now = new Date()
7 | const month: number = now.getMonth() + 1
8 | const day: number = now.getDate()
9 | const today = `${now.getFullYear()}-${month < 10 ? '0' + month : month}-${day < 10 ? '0' + day : day}`
10 | const sitemap: MetadataRoute.Sitemap = [
11 | {url: baseURL + '/about', lastModified: today, changeFrequency: 'daily'},
12 | {url: baseURL + '/timeLine', lastModified: today, changeFrequency: 'daily'},
13 | {url: baseURL + '/onlineTools/imageZip', lastModified: today, changeFrequency: 'monthly'},
14 | {url: baseURL + '/', lastModified: today, changeFrequency: 'daily'},
15 | ]
16 | const res: { list: Article.ArticleItem[] } | null = await httpRequest({
17 | url: `${process.env.NEXT_PUBLIC_BASE_URL}/blog/timeLine`,
18 | })
19 | res?.list?.map((article)=>{
20 | sitemap.push({
21 | url: baseURL + '/detail/' + article.id,
22 | lastModified: article.updatedAt.split(' ')[0],
23 | changeFrequency: 'weekly'
24 | })
25 | })
26 | return sitemap
27 | }
--------------------------------------------------------------------------------
/src/app/timeLine/page.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Timeline} from 'antd'
3 | import Link from 'next/link'
4 | import httpRequest from "@/utils/fetch";
5 | import Image from 'next/image'
6 |
7 | const TimeLinePage = async () => {
8 | const res: { list: Article.ArticleItem[] } | null = await httpRequest({
9 | url: `${process.env.NEXT_PUBLIC_BASE_URL}/blog/timeLine`,
10 | options: {
11 | cache: 'no-store'
12 | }
13 | })
14 | if (!res) {
15 | return null
16 | }
17 | return (
18 | <>
19 |
23 |
24 |
流年不念终将安,时光不老你还在
25 |
26 |
27 |
28 |
({
32 | label: article.createdAt,
33 | children:
38 |
46 | {article.title}
47 |
48 | }))
49 | }
50 | />
51 |
52 |
53 | >
54 | )
55 | }
56 | export default TimeLinePage
57 |
--------------------------------------------------------------------------------
/src/assets/js/ribbon.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Ribbons Class File.
3 | * Creates low-poly ribbons background effect inside a target container.
4 | */
5 | (function (name, factory) {
6 | if (typeof window === 'object') {
7 | window[name] = factory();
8 | }
9 |
10 | })('Ribbons', function () {
11 | let _w = window,
12 | _b = document.body, // 返回html dom中的body节点 即
13 | _d = document.documentElement;// 返回html dom中的root 节点 即
14 |
15 | // random helper
16 | var random = function () {
17 | if (arguments.length === 1) // only 1 argument
18 | {
19 | if (Array.isArray(arguments[0])) // extract index from array
20 | {
21 | let index = Math.round(random(0, arguments[0].length - 1));
22 | return arguments[0][index];
23 | }
24 | return random(0, arguments[0]); // assume numeric
25 | } else
26 | if (arguments.length === 2) // two arguments range
27 | {
28 | return Math.random() * (arguments[1] - arguments[0]) + arguments[0];
29 | }
30 | return 0; // default
31 | };
32 |
33 | // screen helper
34 | let screenInfo = function (e) {
35 | let width = Math.max(0, _w.innerWidth || _d.clientWidth || _b.clientWidth || 0),
36 | height = Math.max(0, _w.innerHeight || _d.clientHeight || _b.clientHeight || 0),
37 | scrollx = Math.max(0, _w.pageXOffset || _d.scrollLeft || _b.scrollLeft || 0) - (_d.clientLeft || 0),
38 | scrolly = Math.max(0, _w.pageYOffset || _d.scrollTop || _b.scrollTop || 0) - (_d.clientTop || 0);
39 |
40 | return {
41 | width: width,
42 | height: height,
43 | ratio: width / height,
44 | centerx: width / 2,
45 | centery: height / 2,
46 | scrollx: scrollx,
47 | scrolly: scrolly };
48 |
49 | };
50 |
51 | // mouse/input helper
52 | let mouseInfo = function (e) {
53 | let screen = screenInfo(e),
54 | mousex = e ? Math.max(0, e.pageX || e.clientX || 0) : 0,
55 | mousey = e ? Math.max(0, e.pageY || e.clientY || 0) : 0;
56 |
57 | return {
58 | mousex: mousex,
59 | mousey: mousey,
60 | centerx: mousex - screen.width / 2,
61 | centery: mousey - screen.height / 2 };
62 |
63 | };
64 |
65 | // point object
66 | let Point = function (x, y) {
67 | this.x = 0;
68 | this.y = 0;
69 | this.set(x, y);
70 | };
71 | Point.prototype = {
72 | constructor: Point,
73 |
74 | set: function (x, y) {
75 | this.x = x || 0;
76 | this.y = y || 0;
77 | },
78 | copy: function (point) {
79 | this.x = point.x || 0;
80 | this.y = point.y || 0;
81 | return this;
82 | },
83 | multiply: function (x, y) {
84 | this.x *= x || 1;
85 | this.y *= y || 1;
86 | return this;
87 | },
88 | divide: function (x, y) {
89 | this.x /= x || 1;
90 | this.y /= y || 1;
91 | return this;
92 | },
93 | add: function (x, y) {
94 | this.x += x || 0;
95 | this.y += y || 0;
96 | return this;
97 | },
98 | subtract: function (x, y) {
99 | this.x -= x || 0;
100 | this.y -= y || 0;
101 | return this;
102 | },
103 | clampX: function (min, max) {
104 | this.x = Math.max(min, Math.min(this.x, max));
105 | return this;
106 | },
107 | clampY: function (min, max) {
108 | this.y = Math.max(min, Math.min(this.y, max));
109 | return this;
110 | },
111 | flipX: function () {
112 | this.x *= -1;
113 | return this;
114 | },
115 | flipY: function () {
116 | this.y *= -1;
117 | return this;
118 | } };
119 |
120 | // class constructor
121 | let Factory = function (options) {
122 | this._canvas = null;
123 | this._context = null;
124 | this._sto = null;
125 | this._width = 0;
126 | this._height = 0;
127 | this._scroll = 0;
128 | this._ribbons = [];
129 | this._options = {
130 | // ribbon color HSL saturation amount
131 | colorSaturation: '80%',
132 | // ribbon color HSL brightness amount
133 | colorBrightness: '60%',
134 | // ribbon color opacity amount
135 | colorAlpha: 0.65,
136 | // how fast to cycle through colors in the HSL color space
137 | colorCycleSpeed: 6,
138 | // where to start from on the Y axis on each side (top|min, middle|center, bottom|max, random)
139 | verticalPosition: 'center',
140 | // how fast to get to the other side of the screen
141 | horizontalSpeed: 200,
142 | // how many ribbons to keep on screen at any given time
143 | ribbonCount: 3,
144 | // add stroke along with ribbon fill colors
145 | strokeSize: 0,
146 | // move ribbons vertically by a factor on page scroll
147 | parallaxAmount: -0.5,
148 | // add animation effect to each ribbon section over time
149 | animateSections: true };
150 |
151 | this._onDraw = this._onDraw.bind(this);
152 | this._onResize = this._onResize.bind(this);
153 | // this._onScroll = this._onScroll.bind(this);
154 | this.setOptions(options);
155 | this.init();
156 | };
157 |
158 | // class prototype
159 | Factory.prototype = {
160 | constructor: Factory,
161 |
162 | // Set and merge local options
163 | setOptions: function (options) {
164 | if (typeof options === 'object') {
165 | for (let key in options) {
166 | if (options.hasOwnProperty(key)) {
167 | this._options[key] = options[key];
168 | }
169 | }
170 | }
171 | },
172 |
173 | // Initialize the ribbons effect
174 | init: function () {
175 | try {
176 | this._canvas = document.createElement('canvas');
177 | this._canvas.style['display'] = 'block';
178 | this._canvas.style['position'] = 'fixed';
179 | this._canvas.style['margin'] = '0';
180 | this._canvas.style['padding'] = '0';
181 | this._canvas.style['border'] = '0';
182 | this._canvas.style['outline'] = '0';
183 | this._canvas.style['left'] = '0';
184 | this._canvas.style['top'] = '0';
185 | this._canvas.style['width'] = '100%';
186 | this._canvas.style['height'] = '100%';
187 | this._canvas.style['z-index'] = '-1';
188 | this._canvas.style['background-color'] = '#fff';
189 | this._canvas.id = 'bgCanvas';
190 | this._onResize();
191 |
192 | this._context = this._canvas.getContext('2d');
193 | this._context.clearRect(0, 0, this._width, this._height);
194 | this._context.globalAlpha = this._options.colorAlpha;
195 |
196 | window.addEventListener('resize', this._onResize);
197 | window.addEventListener('scroll', this._onScroll);
198 | document.body.appendChild(this._canvas);
199 | } catch (e) {
200 | console.warn('Canvas Context Error: ' + e.toString());
201 | return;
202 | }
203 | this._onDraw();
204 | },
205 |
206 | // Create a new random ribbon and to the list
207 | addRibbon: function () {
208 | // movement data
209 | let dir = Math.round(random(1, 9)) > 5 ? 'right' : 'left',
210 | stop = 1000,
211 | hide = 200,
212 | min = 0 - hide,
213 | max = this._width + hide,
214 | movex = 0,
215 | movey = 0,
216 | startx = dir === 'right' ? min : max,
217 | starty = Math.round(random(0, this._height));
218 |
219 | // asjust starty based on options
220 | if (/^(top|min)$/i.test(this._options.verticalPosition)) {
221 | starty = 0 + hide;
222 | } else
223 | if (/^(middle|center)$/i.test(this._options.verticalPosition)) {
224 | starty = this._height / 2;
225 | } else
226 | if (/^(bottom|max)$/i.test(this._options.verticalPosition)) {
227 | starty = this._height - hide;
228 | }
229 |
230 | // ribbon sections data
231 | let ribbon = [],
232 | point1 = new Point(startx, starty),
233 | point2 = new Point(startx, starty),
234 | point3 = null,
235 | color = Math.round(random(0, 360)),
236 | delay = 0;
237 |
238 | // buils ribbon sections
239 | while (true) {
240 | if (stop <= 0) {break;}stop--;
241 |
242 | movex = Math.round((Math.random() * 1 - 0.2) * this._options.horizontalSpeed);
243 | movey = Math.round((Math.random() * 1 - 0.5) * (this._height * 0.25));
244 |
245 | point3 = new Point();
246 | point3.copy(point2);
247 |
248 | if (dir === 'right') {
249 | point3.add(movex, movey);
250 | if (point2.x >= max) {break;}
251 | } else
252 | if (dir === 'left') {
253 | point3.subtract(movex, movey);
254 | if (point2.x <= min) {break;}
255 | }
256 | // point3.clampY( 0, this._height );
257 |
258 | ribbon.push({ // single ribbon section
259 | point1: new Point(point1.x, point1.y),
260 | point2: new Point(point2.x, point2.y),
261 | point3: point3,
262 | color: color,
263 | delay: delay,
264 | dir: dir,
265 | alpha: 0,
266 | phase: 0 });
267 |
268 | point1.copy(point2);
269 | point2.copy(point3);
270 |
271 | delay += 4;
272 | color += this._options.colorCycleSpeed;
273 | }
274 | this._ribbons.push(ribbon);
275 | },
276 |
277 | // Draw single section
278 | _drawRibbonSection: function (section) {
279 | if (section) {
280 | if (section.phase >= 1 && section.alpha <= 0) {
281 | return true; // done
282 | }
283 | if (section.delay <= 0) {
284 | section.phase += 0.02;
285 | section.alpha = Math.sin(section.phase) * 1;
286 | section.alpha = section.alpha <= 0 ? 0 : section.alpha;
287 | section.alpha = section.alpha >= 1 ? 1 : section.alpha;
288 |
289 | if (this._options.animateSections) {
290 | let mod = Math.sin(1 + section.phase * Math.PI / 2) * 0.1;
291 |
292 | if (section.dir === 'right') {
293 | section.point1.add(mod, 0);
294 | section.point2.add(mod, 0);
295 | section.point3.add(mod, 0);
296 | } else {
297 | section.point1.subtract(mod, 0);
298 | section.point2.subtract(mod, 0);
299 | section.point3.subtract(mod, 0);
300 | }
301 | section.point1.add(0, mod);
302 | section.point2.add(0, mod);
303 | section.point3.add(0, mod);
304 | }
305 | } else {section.delay -= 0.5;}
306 |
307 | let s = this._options.colorSaturation,
308 | l = this._options.colorBrightness,
309 | c = 'hsla(' + section.color + ', ' + s + ', ' + l + ', ' + section.alpha + ' )';
310 |
311 | this._context.save();
312 |
313 | if (this._options.parallaxAmount !== 0) {
314 | this._context.translate(0, this._scroll * this._options.parallaxAmount);
315 | }
316 | this._context.beginPath();
317 | this._context.moveTo(section.point1.x, section.point1.y);
318 | this._context.lineTo(section.point2.x, section.point2.y);
319 | this._context.lineTo(section.point3.x, section.point3.y);
320 | this._context.fillStyle = c;
321 | this._context.fill();
322 |
323 | if (this._options.strokeSize > 0) {
324 | this._context.lineWidth = this._options.strokeSize;
325 | this._context.strokeStyle = c;
326 | this._context.lineCap = 'round';
327 | this._context.stroke();
328 | }
329 | this._context.restore();
330 | }
331 | return false; // not done yet
332 | },
333 |
334 | // Draw ribbons
335 | _onDraw: function () {
336 | // cleanup on ribbons list to rtemoved finished ribbons
337 | for (let i = 0, t = this._ribbons.length; i < t; ++i) {
338 | if (!this._ribbons[i]) {
339 | this._ribbons.splice(i, 1);
340 | }
341 | }
342 |
343 | // draw new ribbons
344 | this._context.clearRect(0, 0, this._width, this._height);
345 |
346 | for (let a = 0; a < this._ribbons.length; ++a) // single ribbon
347 | {
348 | let ribbon = this._ribbons[a],
349 | numSections = ribbon.length,
350 | numDone = 0;
351 |
352 | for (let b = 0; b < numSections; ++b) // ribbon section
353 | {
354 | if (this._drawRibbonSection(ribbon[b])) {
355 | numDone++; // section done
356 | }
357 | }
358 | if (numDone >= numSections) // ribbon done
359 | {
360 | this._ribbons[a] = null;
361 | }
362 | }
363 | // maintain optional number of ribbons on canvas
364 | if (this._ribbons.length < this._options.ribbonCount) {
365 | this.addRibbon();
366 | }
367 | requestAnimationFrame(this._onDraw);
368 | },
369 |
370 | // Update container size info
371 | _onResize: function (e) {
372 | let screen = screenInfo(e);
373 | this._width = screen.width;
374 | this._height = screen.height;
375 |
376 | if (this._canvas) {
377 | this._canvas.width = this._width;
378 | this._canvas.height = this._height;
379 |
380 | if (this._context) {
381 | this._context.globalAlpha = this._options.colorAlpha;
382 | }
383 | }
384 | },
385 |
386 | // Update container size info
387 | _onScroll: function (e) {
388 | let screen = screenInfo(e);
389 | this._scroll = screen.scrolly;
390 | } };
391 | // export
392 | new Factory();
393 | });
--------------------------------------------------------------------------------
/src/assets/style/common.scss:
--------------------------------------------------------------------------------
1 | @keyframes animation {
2 | 0% {
3 | margin-bottom: 6px;
4 | color: grey;
5 | }
6 | 100% {
7 | margin-bottom: 1px;
8 | color: #fff;
9 | }
10 | }
11 |
12 | .fuck-shadow {
13 | box-shadow: rgba(7, 17, 27, 0.06) 0 4px 8px 6px;
14 |
15 | &:hover {
16 | box-shadow: 0 4px 12px 12px rgba(7, 17, 27, 0.15);
17 | }
18 | }
19 |
20 | @keyframes soundOutlined {
21 | 0% {
22 | transform: scale(1);
23 | }
24 | 100% {
25 | transform: scale(1.3);
26 | }
27 | }
28 |
29 | .soundOutlined {
30 | color: red;
31 | animation: soundOutlined 0.3s linear infinite
32 | }
33 |
34 | .ant-descriptions-item-label {
35 | color: rgb(61, 80, 100) !important;
36 | font-weight: bold !important;
37 | }
38 |
39 | .ant-descriptions-item {
40 | padding-bottom: 6px !important;
41 | }
42 |
43 | .ant-descriptions-item-content {
44 | font-size: 14px !important;
45 | line-height: 20px !important;
46 | color: rgba(0, 0, 0, 0.85) !important;
47 | }
48 |
49 | .custom-nav{
50 | transition: transform 0.8s ease-in-out, opacity 0.8s ease-in-out;
51 | }
52 | .dropdown-menu-group {
53 | display: none;
54 | }
--------------------------------------------------------------------------------
/src/assets/style/detail.scss:
--------------------------------------------------------------------------------
1 | @keyframes msg {
2 | 0% {
3 | transform: scale(0.01);
4 | }
5 | 80% {
6 | transform: scale(1);
7 | }
8 | 100% {
9 | transform: scale(0.01);
10 | }
11 | }
12 |
13 | .detail-content {
14 | color: #252933;
15 | word-break: break-word;
16 | line-height: 1.75;
17 | font-weight: 400;
18 | font-size: 16px;
19 | table {
20 | width: 100%;
21 | margin: 10px auto;
22 | border-spacing: 0;
23 | border-collapse: collapse;
24 | border: 1px solid rgba(5, 5, 5, 0.06);
25 |
26 | thead {
27 | background-color: #fafafa;
28 |
29 | th {
30 | color: #5c6b77;
31 | font-weight: 500;
32 | white-space: nowrap;
33 | background: rgba(0, 0, 0, 0.02);
34 | border: 1px solid rgba(5, 5, 5, 0.06);
35 | padding: 12px 24px;
36 | text-align: center;
37 |
38 | &:not(:last-child):before {
39 | position: absolute;
40 | top: 50%;
41 | inset-inline-end: 0;
42 | width: 1px;
43 | height: 16px;
44 | background-color: #fff;
45 | transform: translateY(-50%);
46 | content: "";
47 | }
48 | }
49 | }
50 |
51 | tbody {
52 | tr {
53 | border-bottom: 1px solid #f0f0f0;
54 | color: rgba(0, 0, 0, 0.88);
55 |
56 | td {
57 | text-align: center;
58 | padding: 12px 24px;
59 | border: 1px solid rgba(5, 5, 5, 0.06);
60 | color: rgba(0, 0, 0, 0.88);
61 | font-size: 13px;
62 | }
63 | }
64 |
65 | }
66 |
67 | }
68 |
69 | p {
70 | padding: 0;
71 | margin: 0;
72 | }
73 |
74 | blockquote {
75 | padding: 12px;
76 | border-left: 0.2rem solid rgb(73, 177, 245);
77 | background-color: rgb(233, 245, 254);
78 | color: rgb(106, 115, 125);
79 | display: block;
80 | margin-left: 0 !important;
81 | }
82 |
83 | h1, h2, h3, h4, h5, h6 {
84 | margin: 0.5rem 0;
85 | cursor: pointer;
86 | transition: all 0.2s ease-out;
87 | color: #344c67;
88 | font-weight: bold;
89 | }
90 |
91 | h1:hover,
92 | h2:hover,
93 | h3:hover,
94 | h4:hover,
95 | h5:hover,
96 | h6:hover {
97 | padding-left: 14px;
98 | }
99 |
100 | code {
101 | display: inline-block;
102 | border-radius: 3px;
103 | font-size: 12px;
104 | padding-left: 5px;
105 | padding-right: 5px;
106 | color: #4f4f4f;
107 | margin: 0 3px;
108 | }
109 |
110 | .linenumber{
111 | user-select: none!important;
112 | }
113 |
114 | img {
115 | max-width: 100%;
116 | display: block;
117 | margin: 10px 0;
118 | }
119 |
120 | .react-syntax-highlighter-line-number {
121 | color: rgba(255, 255, 255, 0.3) !important;
122 | font-size: 12px;
123 | min-width: 16px !important;
124 | margin-right: 10px !important;
125 | padding: 2px 4px 2px 0 !important;
126 | border-right: 1px solid rgba(255, 255, 255, 0.3);
127 | }
128 |
129 | .relative {
130 | div {
131 | border-radius: 4px;
132 | padding-bottom: 12px!important;
133 | }
134 | }
135 | }
--------------------------------------------------------------------------------
/src/assets/style/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 214, 219, 220;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | body {
20 | color: rgb(var(--foreground-rgb));
21 | margin: 0;
22 | padding: 0;
23 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
24 | }
25 |
26 | @layer utilities {
27 | .text-balance {
28 | text-wrap: balance;
29 | }
30 | }
31 | *::selection {
32 | background: #00c4b6;
33 | color: #f7f7f7;
34 | }
35 | *::-webkit-scrollbar-thumb {
36 | background: #49b1f5;
37 | }
38 | *::-webkit-scrollbar {
39 | width: 4px;
40 | height: 4px;
41 | }
42 | *::-webkit-scrollbar-track {
43 | background-color: transparent;
44 | }
45 | .ant-avatar{
46 | cursor: pointer;
47 | transition: transform 1s;
48 | }
49 | .ant-avatar:hover{
50 | transform: rotate(360deg);
51 | }
52 | a{
53 | text-decoration: none;
54 | color: rgba(0,0,0,0.85);
55 | }
56 |
--------------------------------------------------------------------------------
/src/assets/style/loading.scss:
--------------------------------------------------------------------------------
1 | .global-loading {
2 | background-color: rgba(0, 0, 0, 0.24);
3 | z-index: 9999;
4 |
5 | .loading {
6 | position: absolute;
7 | top: 50%;
8 | left: 50%;
9 | width: 96px;
10 | height: 96px;
11 | animation: satellite 1s infinite linear;
12 | border-radius: 100%;
13 | border: 1px solid rgba(255, 255, 255, 0.4);
14 | }
15 |
16 | @media (max-width: 768px) {
17 | .loading {
18 | left: 40%;
19 | }
20 | }
21 |
22 | .loading:before {
23 | position: absolute;
24 | left: 1px;
25 | top: 1px;
26 | width: 24px;
27 | height: 24px;
28 | content: "";
29 | border-radius: 100%;
30 | box-shadow: 0 0 10px #000;
31 | background-image: linear-gradient(to right, #d7d2cc 0%, #304352 100%);
32 | }
33 |
34 | .loading:after {
35 | position: absolute;
36 | top: 1px;
37 | left: 1px;
38 | right: 0;
39 | content: "";
40 | border-radius: 100%;
41 | width: 40px;
42 | height: 40px;
43 | margin: 27px;
44 | background-image: linear-gradient(to top, #30cfd0 0%, #330867 100%);
45 | animation: satellite 1s infinite linear reverse;
46 | }
47 |
48 | @keyframes satellite {
49 | from {
50 | transform: rotate(0) translateZ(0);
51 | }
52 | to {
53 | transform: rotate(360deg) translateZ(0);
54 | }
55 | }
56 | }
--------------------------------------------------------------------------------
/src/hooks/useFetch.ts:
--------------------------------------------------------------------------------
1 | import {useState,useCallback} from 'react';
2 | import {message} from 'antd'
3 | import httpRequest from "@/utils/fetch";
4 |
5 |
6 | const useFetch = (props: Common.UseFetchProps): Common.UseFetchResult => {
7 | const [response, setData] = useState();
8 | const [loading, setLoading] = useState(false);
9 |
10 | const handleFetch = useCallback(async (params?:any) => {
11 | setLoading(true)
12 | if(params){
13 | props.data = params
14 | }
15 | const res:any = await httpRequest(props)
16 | setData(res);
17 | setLoading(false)
18 | }, []);
19 |
20 | return {response, loading, handleFetch};
21 | };
22 |
23 | export default useFetch;
24 |
--------------------------------------------------------------------------------
/src/types/article.typings.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace Article {
2 | interface ArticleItem {
3 | id: string;
4 | title: string;
5 | cover: string;
6 | description: string;
7 | createdAt: string;
8 | updatedAt: string;
9 | category: {
10 | title: string
11 | }
12 | tags: {
13 | title: string
14 | _id: string
15 | }[]
16 | }
17 |
18 | interface Detail extends ArticleItem{
19 | content: string
20 | catalogue: boolean
21 | scan: number
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/types/aside.typings.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace Aside {
2 | interface Category {
3 | category: string
4 | count: number
5 | }
6 |
7 | interface Items {
8 | categoryList: Category[]
9 | totalArticle: number
10 | totalCategory: number
11 | list: Article.ArticleItem[]
12 | tags: string[]
13 | notice: string
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/types/comment.typings.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace Comment {
2 | interface Item {
3 | id: string;
4 | content: string;
5 | article: string;
6 | nickName: string;
7 | email: string;
8 | parentId: string;
9 | parentName: string;
10 | author: number;
11 | pinned: number;
12 | createdAt: string
13 | region: string
14 | platform?: string
15 | }
16 | interface Info {
17 | article: string
18 | parentId: string
19 | nickName?: string
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/types/common.typings.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace Common {
2 | interface UseFetchProps {
3 | url: string;
4 | method?: 'GET' | 'POST'
5 | data?: any;
6 | options?: {
7 | [key:string] : any
8 | }
9 | }
10 |
11 | interface UseFetchResult {
12 | response: T;
13 | loading: boolean;
14 | handleFetch: (data?: any) => void
15 | }
16 | interface IProps {
17 | searchParams: { [key: string]: string }
18 | }
19 |
20 | interface IIP {
21 | city: string
22 | country:string
23 | query:string
24 | regionName:string
25 | org:string
26 | status: 'success' | 'fail'
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/utils/common.ts:
--------------------------------------------------------------------------------
1 | import CryptoJS from "crypto-js";
2 |
3 | export function decrypt(data: string) {
4 | const key: string = process.env.NEXT_PUBLIC_CRYPTO_SECRET_KEY!
5 | const bytes = CryptoJS.AES.decrypt(data as string, key)
6 | const decrypt = bytes.toString(CryptoJS.enc.Utf8)
7 | return JSON.parse(decrypt)
8 | }
--------------------------------------------------------------------------------
/src/utils/fetch.ts:
--------------------------------------------------------------------------------
1 | import {decrypt} from "@/utils/common";
2 | import {message} from 'antd'
3 |
4 | async function httpRequest({url, method = 'GET', data, options = {}}: Common.UseFetchProps): Promise {
5 | try {
6 | const fetchConfig: RequestInit = {
7 | method,
8 | ...options
9 | }
10 | if (method === 'GET') {
11 | const queryString = new URLSearchParams(data).toString();
12 | url += `?${queryString}`
13 | } else {
14 | fetchConfig.headers = {'Content-Type': 'application/json'}
15 | fetchConfig.body = JSON.stringify(data)
16 | }
17 | const response = await fetch(url, fetchConfig);
18 |
19 | if (!response.ok) {
20 | message.error(response.statusText)
21 | return null
22 | }
23 | const result: string = await response.text();
24 | const {data:res, code, message:msg} = decrypt(result)
25 | if(code){
26 | message.error(msg)
27 | return null
28 | }
29 | if(msg !== 'success'){
30 | message.success(msg)
31 | }
32 | return res
33 |
34 | } catch (error: any) {
35 | console.log(error);
36 | // message?.error(error.message || 'Service exception')
37 | return null
38 | }
39 | }
40 |
41 | export default httpRequest;
42 |
--------------------------------------------------------------------------------
/src/utils/metadata.ts:
--------------------------------------------------------------------------------
1 | import type {Metadata} from "next";
2 |
3 | const DefaultMetadata: Metadata = {
4 | title: `SpectreAlan's Blog`,
5 | keywords: 'SpectreAlan,web前端,Nginx,Linux,Vue,Flutter,React,NextJS,react-hooks,NodeJS,NestJS, Vercel',
6 | description: "SpectreAlan的个人博客,一个有内涵的web前端,专注vue/react/nodejs/flutter",
7 | authors: [{name: 'SpectreAlan', url: 'https://jszoo.com'}],
8 | creator: 'SpectreAlan',
9 | icons: {
10 | shortcut: '/favicon.ico',
11 | },
12 | }
13 | export default DefaultMetadata
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | corePlugins: {
5 | preflight: false
6 | },
7 | important: true,
8 | content: [
9 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
10 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
11 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
12 | ],
13 | theme: {
14 | extend: {
15 | backgroundImage: {
16 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
17 | "gradient-conic":
18 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
19 | },
20 | },
21 | },
22 | plugins: [],
23 | };
24 | export default config;
25 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------