├── .gitignore ├── LICENSE ├── README.md ├── admin-blog ├── .gitignore ├── README.md ├── build.zip ├── craco.config.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ ├── noto-sans-sc-700.woff │ └── robots.txt └── src │ ├── App.js │ ├── App.less │ ├── App.test.js │ ├── app │ └── store.js │ ├── components │ ├── AllCategories.jsx │ ├── AllImages.jsx │ ├── AllLinks.jsx │ ├── AllPosts.jsx │ ├── AllTags.jsx │ ├── ArticleInfo.jsx │ ├── BlogDashboard.jsx │ ├── BlogSetting.jsx │ ├── CategoryNav.jsx │ ├── ChangePassword.jsx │ ├── Clickable.jsx │ ├── CommentBox.jsx │ ├── CommentNav.jsx │ ├── CopyButton.jsx │ ├── EditLink.jsx │ ├── EditableTable.jsx │ ├── FilterSelectWrap.jsx │ ├── HCenterSpin.jsx │ ├── ImageCard.jsx │ ├── ImageUploadBox.jsx │ ├── LinkForm.jsx │ ├── LinkNav.jsx │ ├── Loading.jsx │ ├── MDComponent.jsx │ ├── ManageNav.jsx │ ├── MarkDownEditor.jsx │ ├── MediaNav.jsx │ ├── NewLink.jsx │ ├── NewPost.jsx │ ├── PanelFooter.jsx │ ├── PanelHeader.jsx │ ├── PostCategory.jsx │ ├── PostComments.jsx │ ├── PostEdit.jsx │ ├── PostForm.jsx │ ├── PostNav.jsx │ ├── PostPreview.jsx │ ├── PostSearch.jsx │ ├── PostTags.jsx │ ├── RecentComments.jsx │ ├── RequireAuth.jsx │ ├── Spinner.jsx │ ├── TagNav.jsx │ └── WordCloud.jsx │ ├── css │ ├── AdminPanel.css │ ├── AllImages.module.css │ ├── AllItems.module.css │ ├── AllLinks.module.css │ ├── BlogDashboard.module.css │ ├── BlogSetting.module.css │ ├── EditPostForm.module.css │ ├── HCenterSpin.module.css │ ├── Loading.module.css │ ├── Login.module.css │ ├── MarkDownEditor.module.css │ ├── NewForm.module.css │ ├── NewItem.module.css │ ├── NewLink.module.css │ ├── PostComments.module.css │ ├── PostPreview.module.css │ ├── PostSearch.module.css │ ├── PostTC.module.css │ ├── RecentComments.module.css │ └── ThemeSetting.module.css │ ├── features │ ├── auth │ │ ├── authService.js │ │ └── authSlice.js │ ├── comments │ │ ├── commentService.js │ │ └── commentSlice.js │ ├── links │ │ ├── linkService.js │ │ └── linkSlice.js │ ├── posts │ │ ├── postService.js │ │ └── postSlice.js │ └── profile │ │ ├── profileService.js │ │ └── profileSlice.js │ ├── hooks │ ├── useColumnSearch.js │ ├── useGetData.js │ ├── usePrevious.js │ ├── useRedirect.js │ └── useStackedLineChart.js │ ├── images │ ├── butterfly-logo.svg │ ├── check-success-svgrepo-com.svg │ ├── copy-svgrepo-com.svg │ └── slanted-gradient_blue.svg │ ├── index.css │ ├── index.js │ ├── pages │ ├── AdminPanel.js │ ├── Loading.jsx │ └── Login.jsx │ ├── reportWebVitals.js │ ├── setupProxy.js │ └── setupTests.js ├── backend ├── .envexample ├── .gitignore ├── config │ └── db.js ├── controllers │ ├── commentController.js │ ├── imageController.js │ ├── linkController.js │ ├── postController.js │ ├── profileController.js │ ├── uploadController.js │ └── userController.js ├── middleware │ ├── authMiddleware.js │ ├── demoMiddleware.js │ └── errorMiddleware.js ├── models │ ├── commentModel.js │ ├── imageModel.js │ ├── linkModel.js │ ├── pageModel.js │ ├── postModel.js │ ├── profileModel.js │ └── userModel.js ├── package-lock.json ├── package.json ├── routes │ ├── commentRoutes.js │ ├── imageRoutes.js │ ├── linkRoutes.js │ ├── postRoutes.js │ ├── profileRoutes.js │ ├── uploadRoutes.js │ └── userRoutes.js ├── server.js └── uploads │ └── image │ ├── avatar.ico │ ├── logo.svg │ └── og-image.png ├── docs └── images │ ├── blog-system-small.PNG │ └── blog-system.PNG └── nextjs-blog ├── .envexample ├── .eslintrc.json ├── .gitignore ├── README.md ├── components ├── CommentForm.js ├── CommentSection.js ├── Comments.js ├── CopyButton.js ├── Footer.js ├── Header.js ├── LoadMoreBtn.js ├── MarkDown.js ├── PostLayout.js ├── PostList.js ├── PostLoading.js ├── PostTimeline.js ├── SEO.js ├── SearchBar.js ├── ShrinkHeader.js ├── ThemeChanger.js └── TocAndMD.js ├── lib ├── generate-rss.js ├── linksData.js ├── posts.js ├── siteData.js └── utils │ ├── db.js │ ├── dbConnect.js │ ├── htmlEscaper.js │ ├── linkModel.js │ ├── pageModel.js │ ├── postModel.js │ ├── profileModel.js │ └── userModel.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.js ├── api │ └── revalidate.js ├── categories.js ├── categories │ └── [category].js ├── index.js ├── links.js ├── posts │ └── [slug].js ├── sitemap.xml.js ├── tags.js ├── tags │ └── [tag].js └── timeline.js ├── public ├── favicon.ico ├── rainbow_wave.svg └── robots.txt └── styles ├── AnimatePublic.module.css ├── Categories.module.css ├── CategoryPage.module.css ├── CommentForm.module.css ├── Comments.module.css ├── CopyButton.module.css ├── Footer.module.css ├── Header.module.css ├── Home.module.css ├── Links.module.css ├── LoadMoreBtn.module.css ├── MarkDown.module.css ├── PostLayout.module.css ├── PostList.module.css ├── PostLoading.module.css ├── PostTimeline.module.css ├── SearchBar.module.css ├── TagPage.module.css ├── Tags.module.css ├── ThemeChanger.module.css ├── TocAndMD.module.css ├── fonts ├── icomoon.eot ├── icomoon.svg ├── icomoon.ttf └── icomoon.woff ├── globals.css ├── globals.scss └── icomoonStyle.css /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | nextjs-blog/public/tags/ 4 | nextjs-blog/public/categories/ 5 | nextjs-blog/public/feed.xml 6 | 7 | .VSCodeCounter/ 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 manfred 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /admin-blog/.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 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | 26 | -------------------------------------------------------------------------------- /admin-blog/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ManfredMT/nextjs-blog-react-admin/7ff31f568f03655a5c730038e001870fd7b84dac/admin-blog/README.md -------------------------------------------------------------------------------- /admin-blog/build.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ManfredMT/nextjs-blog-react-admin/7ff31f568f03655a5c730038e001870fd7b84dac/admin-blog/build.zip -------------------------------------------------------------------------------- /admin-blog/craco.config.js: -------------------------------------------------------------------------------- 1 | 2 | const { whenProd } = require("@craco/craco"); 3 | const zlib = require("zlib"); 4 | // const BundleAnalyzerPlugin = 5 | // require("webpack-bundle-analyzer").BundleAnalyzerPlugin; 6 | const CompressionWebpackPlugin = require("compression-webpack-plugin"); 7 | 8 | // 查看打包时间 9 | // const SpeedMeasurePlugin = require("speed-measure-webpack-plugin"); 10 | // const smp = new SpeedMeasurePlugin(); 11 | 12 | const overrideConfig = { 13 | plugins: [ 14 | 15 | ], 16 | babel: { 17 | plugins: [ 18 | 19 | ], 20 | }, 21 | webpack: { 22 | plugins: whenProd( 23 | () => [ 24 | //new BundleAnalyzerPlugin(), 25 | new CompressionWebpackPlugin({ 26 | filename: "[path][base].gz", 27 | algorithm: "gzip", // 使用gzip压缩 28 | test: new RegExp( 29 | "\\.(js|css|html)$" // 压缩 js 与 css 30 | ), 31 | threshold: 8192, // 资源文件大于8192B时会被压缩 32 | minRatio: 0.8, // 最小压缩比达到0.8时才会被压缩 33 | deleteOriginalAssets: false, 34 | }), 35 | new CompressionWebpackPlugin({ 36 | filename: "[path][base].br", 37 | algorithm: "brotliCompress", // 使用brotli算法压缩 38 | test: new RegExp( 39 | "\\.(js|css|html|svg)$" // 压缩 js,html,css,svg 40 | ), 41 | compressionOptions: { 42 | params: { 43 | [zlib.constants.BROTLI_PARAM_QUALITY]: 11, 44 | }, 45 | }, 46 | threshold: 8192, 47 | minRatio: 0.8, 48 | deleteOriginalAssets: false, 49 | }), 50 | ], 51 | [] 52 | ), 53 | configure: whenProd(() => { 54 | return { 55 | mode: "production", 56 | devtool: "nosources-source-map", 57 | }; 58 | }, {}), 59 | }, 60 | }; 61 | 62 | module.exports = overrideConfig; 63 | -------------------------------------------------------------------------------- /admin-blog/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "admin-blog", 3 | "sideEffects": [ 4 | "*.css" 5 | ], 6 | "version": "0.3.0", 7 | "private": true, 8 | "dependencies": { 9 | "@ant-design/icons": "^4.7.0", 10 | "@react-three/drei": "^9.73.0", 11 | "@react-three/fiber": "^8.0.27", 12 | "@reduxjs/toolkit": "^1.8.1", 13 | "@testing-library/jest-dom": "^5.16.4", 14 | "@testing-library/react": "^13.2.0", 15 | "@testing-library/user-event": "^14.2.0", 16 | "antd": "^5.13.2", 17 | "axios": "^0.27.2", 18 | "copy-to-clipboard": "^3.3.1", 19 | "dayjs": "^1.11.10", 20 | "easymde": "^2.16.1", 21 | "echarts": "^5.3.3", 22 | "font-awesome": "^4.7.0", 23 | "github-markdown-css": "^5.1.0", 24 | "http-proxy-middleware": "^2.0.6", 25 | "js-file-download": "^0.4.12", 26 | "parse-numeric-range": "^1.3.0", 27 | "react": "^18.1.0", 28 | "react-copy-to-clipboard": "^5.1.0", 29 | "react-dom": "^18.1.0", 30 | "react-highlight-words": "^0.18.0", 31 | "react-markdown": "^8.0.3", 32 | "react-redux": "^8.0.1", 33 | "react-responsive": "^9.0.0-beta.8", 34 | "react-router-dom": "^6.3.0", 35 | "react-scripts": "5.0.1", 36 | "react-simplemde-editor": "^5.0.2", 37 | "react-syntax-highlighter": "^15.5.0", 38 | "react-toastify": "^9.0.1", 39 | "rehype-katex": "^6.0.2", 40 | "remark-footnotes": "^4.0.1", 41 | "remark-gfm": "^3.0.1", 42 | "remark-math": "^5.1.1", 43 | "three": "^0.141.0", 44 | "web-vitals": "^2.1.4", 45 | "yet-another-react-lightbox": "^3.17.1" 46 | }, 47 | "scripts": { 48 | "start": "craco start", 49 | "build": "craco build", 50 | "test": "craco test", 51 | "eject": "react-scripts eject" 52 | }, 53 | "eslintConfig": { 54 | "extends": [ 55 | "react-app", 56 | "react-app/jest" 57 | ] 58 | }, 59 | "browserslist": { 60 | "production": [ 61 | ">0.2%", 62 | "not dead", 63 | "not op_mini all" 64 | ], 65 | "development": [ 66 | "last 1 chrome version", 67 | "last 1 firefox version", 68 | "last 1 safari version" 69 | ] 70 | }, 71 | "devDependencies": { 72 | "@craco/craco": "^7.1.0", 73 | "babel-plugin-import": "^1.13.5", 74 | "compression-webpack-plugin": "^10.0.0", 75 | "speed-measure-webpack-plugin": "^1.5.0", 76 | "webpack-bundle-analyzer": "^4.5.0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /admin-blog/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ManfredMT/nextjs-blog-react-admin/7ff31f568f03655a5c730038e001870fd7b84dac/admin-blog/public/favicon.ico -------------------------------------------------------------------------------- /admin-blog/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | 博客管理 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /admin-blog/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ManfredMT/nextjs-blog-react-admin/7ff31f568f03655a5c730038e001870fd7b84dac/admin-blog/public/logo192.png -------------------------------------------------------------------------------- /admin-blog/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ManfredMT/nextjs-blog-react-admin/7ff31f568f03655a5c730038e001870fd7b84dac/admin-blog/public/logo512.png -------------------------------------------------------------------------------- /admin-blog/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Blog Admin", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#457fca", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /admin-blog/public/noto-sans-sc-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ManfredMT/nextjs-blog-react-admin/7ff31f568f03655a5c730038e001870fd7b84dac/admin-blog/public/noto-sans-sc-700.woff -------------------------------------------------------------------------------- /admin-blog/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /admin-blog/src/App.less: -------------------------------------------------------------------------------- 1 | //@import '~antd/dist/antd.less'; 2 | 3 | // .App { 4 | // text-align: center; 5 | // } 6 | 7 | // .App-logo { 8 | // height: 40vmin; 9 | // pointer-events: none; 10 | // } 11 | 12 | // @media (prefers-reduced-motion: no-preference) { 13 | // .App-logo { 14 | // animation: App-logo-float infinite 3s ease-in-out; 15 | // } 16 | // } 17 | 18 | // .App-header { 19 | // min-height: 100vh; 20 | // display: flex; 21 | // flex-direction: column; 22 | // align-items: center; 23 | // justify-content: center; 24 | // font-size: calc(10px + 2vmin); 25 | // } 26 | 27 | // .App-link { 28 | // color: rgb(112, 76, 182); 29 | // } 30 | 31 | // @keyframes App-logo-float { 32 | // 0% { 33 | // transform: translateY(0); 34 | // } 35 | // 50% { 36 | // transform: translateY(10px); 37 | // } 38 | // 100% { 39 | // transform: translateY(0px); 40 | // } 41 | // } 42 | -------------------------------------------------------------------------------- /admin-blog/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { Provider } from 'react-redux'; 4 | import { store } from './app/store'; 5 | import App from './App'; 6 | 7 | test('renders learn react link', () => { 8 | const { getByText } = render( 9 | 10 | 11 | 12 | ); 13 | 14 | expect(getByText(/learn/i)).toBeInTheDocument(); 15 | }); 16 | -------------------------------------------------------------------------------- /admin-blog/src/app/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import authReducer from '../features/auth/authSlice' 3 | import linkReducer from '../features/links/linkSlice' 4 | import postReducer from '../features/posts/postSlice' 5 | import profileReducer from '../features/profile/profileSlice' 6 | import commentReducer from '../features/comments/commentSlice' 7 | 8 | 9 | export const store = configureStore({ 10 | reducer: { 11 | auth: authReducer, 12 | links:linkReducer, 13 | posts:postReducer, 14 | profile:profileReducer, 15 | comments:commentReducer 16 | }, 17 | }); 18 | 19 | -------------------------------------------------------------------------------- /admin-blog/src/components/AllPosts.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useSearchParams} from "react-router-dom"; 3 | import PostEdit from "./PostEdit"; 4 | import PostPreview from "./PostPreview"; 5 | import PostSearch from "./PostSearch" 6 | 7 | function AllPosts() { 8 | const [searchParams] = useSearchParams(); 9 | const previewParams = searchParams.get('preview'); 10 | const editParams = searchParams.get('edit'); 11 | if(previewParams) { 12 | return ; 13 | } 14 | if(editParams) { 15 | return ; 16 | } 17 | 18 | return ( 19 | 20 | ) 21 | } 22 | 23 | export default AllPosts -------------------------------------------------------------------------------- /admin-blog/src/components/ArticleInfo.jsx: -------------------------------------------------------------------------------- 1 | function ArticleInfo({ 2 | boxClass, 3 | titleClass, 4 | title, 5 | dateClass, 6 | date, 7 | authorClass, 8 | authors, 9 | }) { 10 | return ( 11 |
12 |

{title}

13 |

14 | 发布日期: 15 | {date} 16 |

17 | 18 |

19 | 作者: 20 | {authors.map((author) => ( 21 | {`${author} `} 22 | ))} 23 |

24 |
25 | ); 26 | } 27 | 28 | export default ArticleInfo; 29 | -------------------------------------------------------------------------------- /admin-blog/src/components/CategoryNav.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Outlet } from "react-router-dom"; 3 | import useRedirect from "../hooks/useRedirect.js"; 4 | 5 | function CategoryNav() { 6 | useRedirect("/manage/category", "all-categories"); 7 | return ; 8 | } 9 | 10 | export default CategoryNav -------------------------------------------------------------------------------- /admin-blog/src/components/Clickable.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function Clickable({children}) { 4 | return ( 5 | 12 | ) 13 | } 14 | 15 | export default Clickable -------------------------------------------------------------------------------- /admin-blog/src/components/CommentBox.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function CommentBox({ className, author, avatar, content, datetime }) { 4 | return ( 5 |
6 |
7 |
15 | {avatar} 16 |
17 |
20 |
28 | {author} 29 | {datetime} 30 |
31 |
{content}
32 |
33 |
34 |
35 | ); 36 | } 37 | 38 | export default CommentBox; 39 | -------------------------------------------------------------------------------- /admin-blog/src/components/CommentNav.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Outlet } from "react-router-dom"; 3 | import useRedirect from "../hooks/useRedirect.js"; 4 | 5 | function CommentNav() { 6 | useRedirect("/manage/comment", "recent-comments"); 7 | return ; 8 | } 9 | 10 | export default CommentNav -------------------------------------------------------------------------------- /admin-blog/src/components/CopyButton.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { CopyToClipboard } from "react-copy-to-clipboard"; 3 | import copySvg from "../images/copy-svgrepo-com.svg"; 4 | import copiedSvg from "../images/check-success-svgrepo-com.svg"; 5 | 6 | function CopyButton({ text, classCopy, classCopied }) { 7 | const [copied, setCopied] = useState(false); 8 | useEffect(() => { 9 | if (copied) { 10 | setTimeout(() => { 11 | setCopied(false); 12 | }, 2000); 13 | } 14 | }, [copied]); 15 | return ( 16 | { 19 | if (result) { 20 | setCopied(true); 21 | } 22 | }} 23 | > 24 | {copied ? ( 25 | copied 26 | ) : ( 27 | copy 33 | )} 34 | 35 | ); 36 | } 37 | 38 | export default CopyButton; 39 | -------------------------------------------------------------------------------- /admin-blog/src/components/EditableTable.jsx: -------------------------------------------------------------------------------- 1 | import { Form, Input, InputNumber, Table } from "antd"; 2 | 3 | function EditableTable({ 4 | form, 5 | data, 6 | columns, 7 | editingKey, 8 | setEditingKey, 9 | tableClass, 10 | tableRowClass, 11 | }) { 12 | const EditableCell = ({ 13 | editing, 14 | dataIndex, 15 | title, 16 | inputType, 17 | record, 18 | index, 19 | children, 20 | ...restProps 21 | }) => { 22 | const inputNode = inputType === "number" ? : ; 23 | return ( 24 | 25 | {editing ? ( 26 | 38 | {inputNode} 39 | 40 | ) : ( 41 | children 42 | )} 43 | 44 | ); 45 | }; 46 | const isEditing = (record) => record.key === editingKey; 47 | const mergedColumns = columns.map((col) => { 48 | if (!col.editable) { 49 | return col; 50 | } 51 | 52 | return { 53 | ...col, 54 | onCell: (record) => ({ 55 | record, 56 | inputType: col.dataIndex === "postNumber" ? "number" : "text", 57 | dataIndex: col.dataIndex, 58 | title: col.title, 59 | editing: isEditing(record), 60 | }), 61 | }; 62 | }); 63 | const cancel = () => { 64 | setEditingKey(""); 65 | }; 66 | return ( 67 |
68 | 82 | 83 | ); 84 | } 85 | 86 | export default EditableTable; 87 | -------------------------------------------------------------------------------- /admin-blog/src/components/FilterSelectWrap.jsx: -------------------------------------------------------------------------------- 1 | import { Select } from "antd"; 2 | import { useMediaQuery } from "react-responsive"; 3 | const { Option } = Select; 4 | 5 | function FilterSelectWrap({ 6 | label, 7 | placeholder, 8 | onChange, 9 | selectClass, 10 | selectValue, 11 | allItems, 12 | }) { 13 | const isTabletOrMobile = useMediaQuery({ query: "(max-width: 1224px)" }); 14 | return ( 15 | <> 16 | {`${label} : `} 17 | 36 | 37 | ); 38 | } 39 | 40 | export default FilterSelectWrap; 41 | -------------------------------------------------------------------------------- /admin-blog/src/components/HCenterSpin.jsx: -------------------------------------------------------------------------------- 1 | import { LoadingOutlined } from "@ant-design/icons"; 2 | import { Spin } from "antd"; 3 | import style from "../css/HCenterSpin.module.css"; 4 | 5 | const antIcon = ( 6 | 12 | ); 13 | 14 | function HCenterSpin({verticallyCenter=false}) { 15 | return ; 19 | } 20 | 21 | export default HCenterSpin; 22 | -------------------------------------------------------------------------------- /admin-blog/src/components/ImageUploadBox.jsx: -------------------------------------------------------------------------------- 1 | import { PlusOutlined } from "@ant-design/icons"; 2 | import { Upload } from "antd"; 3 | 4 | const uploadButton = ( 5 |
6 | 7 |
12 | Upload 13 |
14 |
15 | ); 16 | 17 | const defaultUploadProps = { 18 | beforeUpload: (file) => { 19 | return false; 20 | }, 21 | maxCount: 1, 22 | name: "file", 23 | listType: "picture-card", 24 | showUploadList: false, 25 | }; 26 | 27 | function ImageUploadBox({ 28 | imageUrl, 29 | alt, 30 | uploadProps = defaultUploadProps, 31 | onChange = null, 32 | }) { 33 | return ( 34 | 35 | {imageUrl ? ( 36 | {alt} 43 | ) : ( 44 | uploadButton 45 | )} 46 | 47 | ); 48 | } 49 | 50 | export default ImageUploadBox; 51 | -------------------------------------------------------------------------------- /admin-blog/src/components/LinkNav.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Outlet } from "react-router-dom"; 3 | import useRedirect from "../hooks/useRedirect.js"; 4 | 5 | function LinkNav(props) { 6 | useRedirect(props.from, "all-links"); 7 | return ; 8 | } 9 | 10 | export default LinkNav -------------------------------------------------------------------------------- /admin-blog/src/components/Loading.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Spin } from "antd"; 3 | import { LoadingOutlined } from "@ant-design/icons"; 4 | import style from "../css/Loading.module.css"; 5 | 6 | function Loading() { 7 | const antIcon = ; 8 | return ( 9 |
10 | 11 |
12 | ); 13 | } 14 | 15 | export default Loading; -------------------------------------------------------------------------------- /admin-blog/src/components/ManageNav.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Outlet } from "react-router-dom"; 3 | import useRedirect from "../hooks/useRedirect.js"; 4 | 5 | function ManageNav() { 6 | useRedirect("/manage", "dashboard"); 7 | return ; 8 | } 9 | 10 | export default ManageNav -------------------------------------------------------------------------------- /admin-blog/src/components/MarkDownEditor.jsx: -------------------------------------------------------------------------------- 1 | import "easymde/dist/easymde.min.css"; 2 | import "github-markdown-css"; 3 | import "katex/dist/katex.min.css"; 4 | import { useMemo } from "react"; 5 | import ReactDOMServer from "react-dom/server"; 6 | import "font-awesome/css/font-awesome.min.css"; 7 | import SimpleMDE from "react-simplemde-editor"; 8 | import style from "../css/MarkDownEditor.module.css"; 9 | import MDComponent from "./MDComponent"; 10 | 11 | function MarkDownEditor({ 12 | value = null, 13 | onChange = null, 14 | id = "post_content", 15 | autoSave = false, 16 | }) { 17 | const mdeOptions = useMemo(() => { 18 | return { 19 | autoDownloadFontAwesome: false, 20 | autofocus: false, 21 | spellChecker: false, 22 | sideBySideFullscreen: false, 23 | autosave: { 24 | enabled: autoSave, 25 | uniqueId: id, 26 | delay: 60000, 27 | }, 28 | showIcons: [ 29 | "code", 30 | "strikethrough", 31 | "table", 32 | "horizontal-rule", 33 | "undo", 34 | "redo", 35 | ], 36 | previewRender(value) { 37 | return ReactDOMServer.renderToString( 38 | 45 | ); 46 | }, 47 | }; 48 | }, [autoSave, id]); 49 | return ( 50 | 51 | ); 52 | } 53 | 54 | export default MarkDownEditor; 55 | -------------------------------------------------------------------------------- /admin-blog/src/components/MediaNav.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Outlet } from "react-router-dom"; 3 | import useRedirect from "../hooks/useRedirect.js"; 4 | 5 | function MediaNav(props) { 6 | useRedirect(props.from, "all-images"); 7 | return ; 8 | } 9 | 10 | export default MediaNav -------------------------------------------------------------------------------- /admin-blog/src/components/NewLink.jsx: -------------------------------------------------------------------------------- 1 | import { Button, Form, message as antMessage } from "antd"; 2 | import { useEffect, useRef, useState } from "react"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import style from "../css/NewLink.module.css"; 5 | import { createLink, resetError } from "../features/links/linkSlice"; 6 | import LinkForm from "./LinkForm"; 7 | 8 | const NewLink = () => { 9 | const dispatch = useDispatch(); 10 | 11 | const { isSuccess, isError, message } = useSelector((state) => state.links); 12 | 13 | const formRef = useRef(null); 14 | 15 | const [isImgValid, setIsImgValid] = useState(false); 16 | 17 | useEffect(() => { 18 | if (isError) { 19 | antMessage.error(message); 20 | } 21 | if (isSuccess && message === "成功创建友链") { 22 | antMessage.success(message); 23 | formRef.current.resetFields(); 24 | } 25 | return () => { 26 | dispatch(resetError()); 27 | }; 28 | }, [isError, isSuccess, message, dispatch]); 29 | 30 | const onFinish = (values) => { 31 | //console.log(values); 32 | const linkFormData = new FormData(); 33 | linkFormData.append("name", values.link.name); 34 | linkFormData.append("website", values.link.address); 35 | if (values.link.introduction) { 36 | linkFormData.append("description", values.link.introduction); 37 | } 38 | if (values.dragger && isImgValid) { 39 | //console.log("uploaded image: ", values.dragger[0].originFileObj); 40 | const imageFile = values.dragger[0].originFileObj; 41 | linkFormData.append("picture", imageFile); 42 | } 43 | dispatch(createLink(linkFormData)); 44 | }; 45 | 46 | return ( 47 | 55 | 56 | 63 | 64 | 65 | ); 66 | }; 67 | 68 | export default NewLink; 69 | -------------------------------------------------------------------------------- /admin-blog/src/components/PanelFooter.jsx: -------------------------------------------------------------------------------- 1 | import { Layout } from "antd"; 2 | import React from "react"; 3 | import "../css/AdminPanel.css"; 4 | 5 | const { Footer } = Layout; 6 | 7 | function PanelFooter() { 8 | return ( 9 |
10 | Blog Admin ©{new Date().getFullYear()} Created by manfred 11 |
12 | ); 13 | } 14 | 15 | export default PanelFooter; 16 | -------------------------------------------------------------------------------- /admin-blog/src/components/PanelHeader.jsx: -------------------------------------------------------------------------------- 1 | import { DownOutlined, MenuOutlined } from "@ant-design/icons"; 2 | import { Avatar, Dropdown, Layout } from "antd"; 3 | import React from "react"; 4 | import { useDispatch } from "react-redux"; 5 | import { useMediaQuery } from "react-responsive"; 6 | import { useNavigate } from "react-router-dom"; 7 | import "../css/AdminPanel.css"; 8 | import { logout, reset } from "../features/auth/authSlice"; 9 | 10 | const { Header } = Layout; 11 | 12 | function PanelHeader({ setVisible }) { 13 | const isTabletOrMobile = useMediaQuery({ query: "(max-width: 1224px)" }); 14 | const navigate = useNavigate(); 15 | const dispatch = useDispatch(); 16 | 17 | const showDrawer = () => { 18 | setVisible(true); 19 | }; 20 | 21 | //用户下拉菜单点击事件 22 | const handleUserMenuClick = ({ key }) => { 23 | switch (key) { 24 | case "change-password": 25 | navigate("change-password"); 26 | break; 27 | case "logout": 28 | dispatch(logout()); 29 | dispatch(reset()); 30 | break; 31 | default: 32 | navigate("/manage"); 33 | } 34 | }; 35 | 36 | return ( 37 |
41 | {isTabletOrMobile ? ( 42 | 43 | ) : null} 44 | 45 | triggerNode.parentNode} 63 | > 64 | 84 | 85 |
86 | ); 87 | } 88 | 89 | export default PanelHeader; 90 | -------------------------------------------------------------------------------- /admin-blog/src/components/PostNav.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Outlet } from "react-router-dom"; 3 | import useRedirect from "../hooks/useRedirect.js"; 4 | 5 | function PostNav() { 6 | useRedirect("/manage/post", "all-posts"); 7 | return ; 8 | } 9 | 10 | export default PostNav; 11 | -------------------------------------------------------------------------------- /admin-blog/src/components/RequireAuth.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { useLocation, useNavigate } from "react-router-dom"; 4 | import { checkJWT, reset } from "../features/auth/authSlice"; 5 | import Loading from "../pages/Loading"; 6 | 7 | function RequireAuth({ children }) { 8 | const location = useLocation(); 9 | const dispatch = useDispatch(); 10 | const navigate = useNavigate(); 11 | const { isError, isSuccess, message } = useSelector((state) => state.auth); 12 | const [isAuth, setIsAuth] = useState(false); 13 | 14 | useEffect(() => { 15 | dispatch(checkJWT()); 16 | }, [dispatch]); 17 | 18 | useEffect(() => { 19 | if (isError) { 20 | console.error("Auth error: ", message); 21 | setIsAuth(false); 22 | navigate("/", { replace: true, state: { from: location } }); 23 | dispatch(reset()); 24 | } 25 | if (isSuccess) { 26 | setIsAuth(true); 27 | } 28 | }, [dispatch, isError, isSuccess, message, location, navigate]); 29 | 30 | return isAuth ? <>{children} : ; 31 | } 32 | 33 | export default RequireAuth; 34 | -------------------------------------------------------------------------------- /admin-blog/src/components/Spinner.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function Spinner() { 4 | return ( 5 |
6 |
7 |
8 | ) 9 | } 10 | 11 | export default Spinner -------------------------------------------------------------------------------- /admin-blog/src/components/TagNav.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Outlet } from "react-router-dom"; 3 | import useRedirect from "../hooks/useRedirect.js"; 4 | 5 | function TagNav() { 6 | useRedirect("/manage/tag", "all-tags"); 7 | return ; 8 | } 9 | 10 | export default TagNav; 11 | -------------------------------------------------------------------------------- /admin-blog/src/components/WordCloud.jsx: -------------------------------------------------------------------------------- 1 | import { Text, TrackballControls } from "@react-three/drei"; 2 | import { Canvas, useFrame } from "@react-three/fiber"; 3 | import { useMemo, useRef } from "react"; 4 | //import * as THREE from "three"; 5 | import {Vector3, Quaternion, Spherical} from "three"; 6 | 7 | 8 | const defaultColors = [ 9 | "BlueViolet", 10 | "GoldenRod", 11 | "Lime", 12 | "Crimson", 13 | "Blue", 14 | "OrangeRed", 15 | ]; 16 | 17 | function rotate(L, camera, ctr, speed) { 18 | const vector = ctr.current.target.clone(); 19 | const l = new Vector3().subVectors(camera.position, vector).length(); 20 | const up = camera.up.clone(); 21 | const quaternion = new Quaternion(); 22 | 23 | // Zoom correction 24 | camera.translateZ(L - l); 25 | 26 | quaternion.setFromAxisAngle(up, 0.00001 * speed); 27 | camera.position.applyQuaternion(quaternion); 28 | } 29 | 30 | function Word({ children, ctr, speed, cZ, ...props }) { 31 | const fontProps = { 32 | font: "/noto-sans-sc-700.woff", 33 | fontSize: 2.5, 34 | lineHeight: 1, 35 | "material-toneMapped": false, 36 | }; 37 | const ref = useRef(); 38 | 39 | useFrame(({ camera }) => { 40 | // Make text face the camera 41 | ref.current.quaternion.copy(camera.quaternion); 42 | rotate(cZ, camera, ctr, speed); 43 | }); 44 | return ; 45 | } 46 | 47 | function Cloud({ 48 | count = 4, 49 | radius = 20, 50 | ctr, 51 | colors, 52 | speed, 53 | cZ, 54 | modifiedWords, 55 | }) { 56 | // Create a count x count words with spherical distribution 57 | const words = useMemo(() => { 58 | const temp = []; 59 | const spherical = new Spherical(); 60 | const phiSpan = Math.PI / (count + 1); 61 | const thetaSpan = (Math.PI * 2) / count; 62 | for (let i = 1; i < count + 1; i++) 63 | for (let j = 0; j < count; j++) { 64 | const wordIndex = (i - 1) * count + j; 65 | temp.push([ 66 | new Vector3().setFromSpherical( 67 | spherical.set(radius, phiSpan * i, thetaSpan * j) 68 | ), 69 | modifiedWords[wordIndex], 70 | ]); 71 | } 72 | 73 | return temp; 74 | }, [count, radius, modifiedWords]); 75 | return words.map(([pos, word], index) => ( 76 | 85 | )); 86 | } 87 | 88 | function getMSquare(n) { 89 | for (let i = 3; i < 30; i++) { 90 | if (n <= i * i) { 91 | return i; 92 | } 93 | } 94 | return 3; 95 | } 96 | 97 | function changeWords(words) { 98 | const wordsN = words.length; 99 | let newWords = words; 100 | const addNumber = getMSquare(wordsN) * getMSquare(wordsN) - wordsN; 101 | for (let i = 0; i < addNumber; i++) { 102 | newWords.push(words[i]); 103 | } 104 | return newWords; 105 | } 106 | 107 | function WordCloud({ 108 | colors = defaultColors, 109 | cZ = 30, 110 | radius = 20, 111 | speed = 1, 112 | words, 113 | }) { 114 | 115 | const controlsRef = useRef(); 116 | const wordsNumber = useMemo(() => words.length, [words]); 117 | const countCloudT = useMemo(() => getMSquare(wordsNumber), [wordsNumber]); 118 | const modifiedWords = useMemo(() => changeWords(words), [words]); 119 | 120 | return ( 121 | 122 | 123 | 132 | 136 | 137 | ); 138 | } 139 | 140 | export default WordCloud; 141 | -------------------------------------------------------------------------------- /admin-blog/src/css/AdminPanel.css: -------------------------------------------------------------------------------- 1 | .admin-panel-layout .logo { 2 | height: 32px; 3 | margin: 16px; 4 | } 5 | 6 | .site-layout-sub-header-background { 7 | background: #fff; 8 | box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px; 9 | padding: 0; 10 | } 11 | 12 | .admin-panel-layout .drawer-trigger { 13 | margin-left: 2rem; 14 | } 15 | 16 | .site-layout-background { 17 | background: #f0f2f5; 18 | } 19 | 20 | .admin-panel-layout .logout-button { 21 | margin-right: 10vw; 22 | } 23 | 24 | .admin-panel-layout .side-box { 25 | position: sticky; 26 | top: 0; 27 | height: 100vh; 28 | } 29 | 30 | .admin-panel-layout .no-style-button { 31 | background: none; 32 | color: inherit; 33 | border: none; 34 | padding: 0; 35 | font: inherit; 36 | outline: inherit; 37 | cursor: pointer; 38 | } 39 | 40 | .admin-panel-layout .user-menu-box { 41 | display: flex; 42 | align-items: center; 43 | margin-right: 5vw; 44 | color: #06223c; 45 | } 46 | 47 | .admin-panel-layout .user-menu-box:hover { 48 | color: #1890ff; 49 | } 50 | 51 | .admin-panel-layout .menu-fold-button { 52 | position: absolute; 53 | bottom: 0rem; 54 | width: 100%; 55 | background: #001c37; 56 | background: transparent; 57 | border: 0px; 58 | border-top: 1px #4d5c6a solid; 59 | padding: 0; 60 | font: inherit; 61 | outline: inherit; 62 | cursor: default; 63 | } 64 | 65 | .admin-panel-layout .trigger { 66 | padding: 30px; 67 | font-size: 18px; 68 | cursor: pointer; 69 | transition: color 0.3s; 70 | color: white; 71 | } 72 | 73 | .admin-panel-layout .trigger:hover { 74 | color: #1890ff; 75 | } 76 | 77 | .admin-panel-layout .logo-svg { 78 | transition: all 0.5s; 79 | } 80 | 81 | .admin-panel-layout .site-layout-sub-header-background { 82 | display: flex; 83 | align-items: center; 84 | justify-content: space-between; 85 | } 86 | 87 | /* 桌面端css */ 88 | @media (min-width:1224px) { 89 | .site-layout-sub-header-background { 90 | border-radius: 1rem; 91 | margin: 1.5rem 2rem 0; 92 | box-shadow: none; 93 | } 94 | 95 | .admin-panel-layout .site-layout-sub-header-background { 96 | display: flex; 97 | align-items: center; 98 | justify-content: right; 99 | } 100 | } -------------------------------------------------------------------------------- /admin-blog/src/css/AllImages.module.css: -------------------------------------------------------------------------------- 1 | .all-images-box { 2 | padding: 1rem; 3 | } 4 | 5 | .small-title { 6 | font-size: 0.8rem; 7 | margin-left: 0.5rem; 8 | } 9 | 10 | .divider { 11 | margin-top: 0.5rem; 12 | } 13 | 14 | .move-to-right { 15 | display: flex; 16 | justify-content: right; 17 | padding-right: 1rem; 18 | } 19 | 20 | .grid-wrapper { 21 | display: grid; 22 | grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); 23 | justify-content: space-evenly; 24 | gap: 1rem; 25 | margin-bottom: 2rem; 26 | } 27 | 28 | .title-label { 29 | display: block; 30 | margin-top: 1rem; 31 | } 32 | 33 | .description-label { 34 | display: block; 35 | margin-top: 1rem; 36 | } 37 | 38 | @media (min-width: 1224px) { 39 | .grid-wrapper { 40 | display: grid; 41 | grid-template-columns: repeat(auto-fit, 290px); 42 | } 43 | .all-images-box { 44 | margin: 0 2rem; 45 | background-color: #ffffff; 46 | border-radius: 1rem; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /admin-blog/src/css/AllItems.module.css: -------------------------------------------------------------------------------- 1 | .edit-button { 2 | margin-right: 1rem; 3 | } 4 | 5 | 6 | .edit-button-desktop-box { 7 | color: rgb(62, 159, 255); 8 | padding: 0; 9 | margin: 0; 10 | display: inherit; 11 | } 12 | 13 | .edit-button-desktop-box:hover { 14 | color: rgb(0, 128, 255); 15 | } 16 | 17 | .item-table { 18 | margin: 0; 19 | } 20 | 21 | .no-style-button { 22 | background: none; 23 | color: inherit; 24 | border: none; 25 | padding: 0; 26 | font: inherit; 27 | outline: inherit; 28 | cursor: pointer; 29 | } 30 | 31 | .hover-blue:hover { 32 | color: #6b9dd6; 33 | } 34 | 35 | .delete-button-mobile { 36 | color: rgb(255, 96, 96); 37 | } 38 | 39 | @media (min-width: 1224px) { 40 | .item-table { 41 | margin: 0rem 2rem; 42 | } 43 | 44 | .delete-button-desktop { 45 | margin-left: 2rem; 46 | border-radius: 5px; 47 | padding: 0.5rem; 48 | color: white; 49 | background-color: rgb(255, 96, 96); 50 | } 51 | 52 | .delete-button-desktop:hover { 53 | color: white; 54 | background-color: rgb(255, 57, 57); 55 | } 56 | } -------------------------------------------------------------------------------- /admin-blog/src/css/AllLinks.module.css: -------------------------------------------------------------------------------- 1 | .links-box { 2 | display: grid; 3 | grid-template-columns: 1fr; 4 | grid-gap: 0.5rem; 5 | margin: 0 0.5rem; 6 | } 7 | 8 | .card-body { 9 | border: 1px solid red; 10 | } 11 | 12 | .card-action { 13 | border: 1px solid purple; 14 | } 15 | 16 | .menu-button { 17 | color: gray; 18 | font-size: 22px; 19 | } 20 | 21 | .menu-button:hover { 22 | color: black; 23 | } 24 | 25 | .spin-center { 26 | width: 100%; 27 | text-align: center; 28 | 29 | } 30 | 31 | 32 | @media (min-width:1224px) { 33 | .links-box { 34 | grid-template-columns: 1fr 1fr; 35 | grid-gap: 1.5em; 36 | margin: 0 2rem; 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /admin-blog/src/css/BlogSetting.module.css: -------------------------------------------------------------------------------- 1 | .main-box { 2 | margin:1rem 1.5rem 0 1.5rem; 3 | } 4 | 5 | @media (min-width:1224px) { 6 | .main-box { 7 | background-color: #fff; 8 | border-radius: 1rem; 9 | margin: 0 2rem; 10 | padding: 2rem 5rem; 11 | } 12 | 13 | } -------------------------------------------------------------------------------- /admin-blog/src/css/EditPostForm.module.css: -------------------------------------------------------------------------------- 1 | .new-form-body { 2 | margin:1rem 1.5rem 0 1.5rem; 3 | } 4 | 5 | .second-button { 6 | margin-left: 1.5rem; 7 | } 8 | 9 | .link-top-box { 10 | display: flex; 11 | justify-content: right; 12 | margin-bottom: 2rem; 13 | margin-right: 0; 14 | } 15 | 16 | @media (min-width:1224px) { 17 | .new-form-body { 18 | background-color: #fff; 19 | border-radius: 1rem; 20 | margin: 0 2rem; 21 | padding: 2rem 5rem; 22 | } 23 | 24 | .link-top-box { 25 | margin-right: 5rem; 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /admin-blog/src/css/HCenterSpin.module.css: -------------------------------------------------------------------------------- 1 | .spin-center { 2 | width: 100%; 3 | text-align: center; 4 | } 5 | 6 | .spin-center.v-center { 7 | height: 100%; 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | } 12 | -------------------------------------------------------------------------------- /admin-blog/src/css/Loading.module.css: -------------------------------------------------------------------------------- 1 | .horizontal-vertical-center { 2 | display: flex; 3 | width: 100%; 4 | height: 100vh; 5 | justify-content:center; 6 | align-items:center; 7 | } -------------------------------------------------------------------------------- /admin-blog/src/css/Login.module.css: -------------------------------------------------------------------------------- 1 | .login-page-box { 2 | display: flex; 3 | height: 100vh; 4 | justify-content: center; 5 | align-items: center; 6 | background-repeat: no-repeat; 7 | background-attachment: fixed; 8 | background-size: cover; 9 | background-position: top; 10 | background-image: url(../images/slanted-gradient_blue.svg); 11 | } 12 | 13 | .blurred-box { 14 | position: relative; 15 | width: 80vw; 16 | background: inherit; 17 | border-radius: 1.5rem; 18 | overflow: hidden; 19 | box-shadow: inset 0 0 0 200px hsla(0,0%,100%,.05); 20 | } 21 | 22 | .blurred-box::after { 23 | content: ''; 24 | width: 95vw; 25 | height: 50vh; 26 | background: inherit; 27 | position: absolute; 28 | left: -55px; 29 | right: 0px; 30 | top: -55px; 31 | bottom: 0; 32 | filter: blur(25px); 33 | 34 | } 35 | 36 | .form-box { 37 | position: relative; 38 | width: 80vw; 39 | display: flex; 40 | flex-direction: column; 41 | align-items: center; 42 | padding: 3rem 2rem 1rem 2rem; 43 | border-radius: 1.5rem; 44 | z-index: 1; 45 | } 46 | 47 | .user-name { 48 | margin-top: 1rem; 49 | margin-bottom: 1rem; 50 | color: white; 51 | font-family: Arial, Helvetica; 52 | letter-spacing: 0.02em; 53 | font-weight: 400; 54 | -webkit-font-smoothing: antialiased; 55 | font-size: 1.1rem; 56 | } 57 | 58 | .input-password>* { 59 | border-radius: 0; 60 | } 61 | 62 | .input-password { 63 | opacity: 0.4; 64 | } 65 | 66 | .label-password { 67 | color: white; 68 | } 69 | 70 | 71 | 72 | .submit-button { 73 | background-image: linear-gradient(to right, #457fca 0%, #5691c8 51%, #457fca 100%); 74 | margin: 0.3rem; 75 | padding: 0.5rem 1.5rem; 76 | text-align: center; 77 | text-transform: uppercase; 78 | transition: 0.5s; 79 | background-size: 200% auto; 80 | color: white; 81 | border: none; 82 | cursor: pointer; 83 | border-radius: 10px; 84 | display: block; 85 | } 86 | 87 | 88 | 89 | @media (min-width:1224px) { 90 | .blurred-box { 91 | width: 30vw; 92 | box-shadow: rgba(0, 0, 0, 0.15) 0px 2px 8px; 93 | } 94 | 95 | .blurred-box::after { 96 | width: 40vw; 97 | } 98 | 99 | .form-box { 100 | width: 30vw; 101 | padding: 3rem 0 1rem 0; 102 | } 103 | 104 | 105 | .submit-button:hover { 106 | background-position: right center; 107 | color: #fff; 108 | text-decoration: none; 109 | } 110 | 111 | 112 | 113 | } -------------------------------------------------------------------------------- /admin-blog/src/css/MarkDownEditor.module.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | .preview-body { 4 | padding:2rem; 5 | margin:0; 6 | background-color: #fafafa; 7 | } 8 | 9 | -------------------------------------------------------------------------------- /admin-blog/src/css/NewForm.module.css: -------------------------------------------------------------------------------- 1 | .new-form-box { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: stretch; 5 | max-width: 40rem; 6 | padding: 0 1rem; 7 | margin: 0 auto; 8 | 9 | } 10 | 11 | .submit-button { 12 | position: absolute; 13 | left: 40%; 14 | } 15 | 16 | .submit-button-edit { 17 | position: absolute; 18 | left: 20%; 19 | } 20 | 21 | .cancel-button { 22 | position: absolute; 23 | left: 55% 24 | } 25 | 26 | .spin-center { 27 | width: 100%; 28 | text-align: center; 29 | 30 | } 31 | 32 | .warning-info { 33 | color: red; 34 | text-align: center; 35 | } 36 | 37 | .input-password>* { 38 | border-radius: 0; 39 | } 40 | 41 | @media (min-width: 1224px) { 42 | .new-form-box { 43 | max-width: 50rem; 44 | padding-top: 2rem; 45 | padding-bottom: 2rem; 46 | display: block; 47 | } 48 | 49 | .main-box { 50 | background-color: #ffffff; 51 | border-radius: 1rem; 52 | margin: 0 2rem; 53 | } 54 | 55 | .submit-button-box { 56 | text-align: center; 57 | } 58 | 59 | .submit-button { 60 | position: relative; 61 | left: 0; 62 | 63 | } 64 | 65 | .warning-info { 66 | margin-bottom: 1rem; 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /admin-blog/src/css/NewItem.module.css: -------------------------------------------------------------------------------- 1 | .new-item-submit-button { 2 | width: 100%; 3 | text-align: center; 4 | } 5 | 6 | .add-field-button { 7 | width: auto; 8 | text-align: center; 9 | } 10 | 11 | .font-item-outlined { 12 | font-size: 3rem; 13 | width: 100%; 14 | text-align: center; 15 | margin-bottom: 2rem; 16 | margin-top: 2rem; 17 | color: green; 18 | } 19 | 20 | .input-box>* { 21 | border-radius: 0px; 22 | } 23 | 24 | @media (min-width:1224px) { 25 | .new-item-box { 26 | background-color: #ffffff; 27 | border-radius: 1rem; 28 | margin: 0 2rem; 29 | padding: 0 0 2rem 0; 30 | } 31 | } -------------------------------------------------------------------------------- /admin-blog/src/css/NewLink.module.css: -------------------------------------------------------------------------------- 1 | .new-link-form { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: stretch; 5 | max-width: 40rem; 6 | padding: 0 1rem; 7 | margin: 0 auto; 8 | 9 | } 10 | 11 | .submit-button { 12 | position: absolute; 13 | left: 40%; 14 | } 15 | 16 | .submit-button-edit { 17 | position: absolute; 18 | left: 20%; 19 | } 20 | 21 | .cancel-button { 22 | position: absolute; 23 | left: 55% 24 | } 25 | 26 | .spin-center { 27 | width: 100%; 28 | text-align: center; 29 | 30 | } 31 | 32 | @media (min-width: 1224px) { 33 | .new-link-form { 34 | 35 | padding: 2rem 15rem 2rem 0; 36 | width: auto; 37 | margin: 0 2rem; 38 | background-color: #ffffff; 39 | border-radius: 1rem; 40 | max-width: 100%; 41 | } 42 | 43 | .submit-button { 44 | width: auto; 45 | left: 40rem; 46 | } 47 | 48 | .submit-button-edit { 49 | width: auto; 50 | left: 35rem; 51 | } 52 | 53 | .cancel-button { 54 | left: 45rem; 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /admin-blog/src/css/PostComments.module.css: -------------------------------------------------------------------------------- 1 | .comments-header { 2 | display: block; 3 | } 4 | 5 | .post-comments-box { 6 | margin: 0 0.5rem; 7 | padding: 1rem; 8 | background-color: #ffffff; 9 | border-radius: 1rem; 10 | } 11 | 12 | .pagination { 13 | display: flex; 14 | justify-content: center; 15 | flex-wrap: wrap; 16 | margin: 1rem auto 0rem auto; 17 | 18 | } 19 | 20 | .checkbox-group { 21 | width: 100%; 22 | } 23 | 24 | /* antd5中Checkbox Group为inline-flex而antd4为inline-block,为了使.source-right的marginLeft auto生效 */ 25 | .checkbox-group > div { 26 | width: 100%; 27 | } 28 | 29 | .delete-selected-icon-button { 30 | display: inline-block; 31 | border: none; 32 | color: red; 33 | font-size: 1.1rem; 34 | background-color: transparent; 35 | } 36 | 37 | .delete-selected-icon-button:disabled { 38 | background-color: transparent; 39 | color: lightgrey; 40 | } 41 | 42 | .comment-box { 43 | margin-left: 0.5rem; 44 | word-break: break-all; 45 | } 46 | 47 | .divider { 48 | margin-top: 0.5rem; 49 | margin-bottom: 0.5rem; 50 | } 51 | 52 | .source-right { 53 | display: block; 54 | margin: 0rem; 55 | margin-top: 1.5rem; 56 | color: rgb(124, 124, 124); 57 | } 58 | 59 | .delete-comment-button-mobile { 60 | display: block; 61 | margin-left: auto; 62 | margin-right: 1rem; 63 | border: none; 64 | border-radius: 1rem; 65 | color: rgb(255, 255, 255); 66 | background-image: linear-gradient(to right, #EB3349 0%, #F45C43 51%, #EB3349 100%); 67 | background-size: 200% auto; 68 | padding: 0.25rem 0.75rem; 69 | transition: 0.5s; 70 | box-shadow: 0 0 20px #eee; 71 | border-radius: 10px; 72 | 73 | } 74 | 75 | .post-select { 76 | display: block; 77 | width: 15rem; 78 | margin: 0rem 0.3rem 1rem auto; 79 | 80 | } 81 | 82 | @media (min-width:1224px) { 83 | 84 | .comments-header { 85 | display: flex; 86 | justify-content: left; 87 | align-items: center; 88 | padding-right: 5rem; 89 | } 90 | 91 | .comments-header>*+* { 92 | margin-left: 1rem; 93 | } 94 | 95 | .comments-header>.post-select { 96 | margin-left: auto; 97 | } 98 | 99 | 100 | 101 | .post-comments-box { 102 | margin: 0 2rem; 103 | padding: 2rem; 104 | } 105 | 106 | .pagination { 107 | display: flex; 108 | justify-content: right; 109 | margin: 1rem 5rem 0rem auto; 110 | } 111 | 112 | .comment-wrap { 113 | display: flex; 114 | justify-content: left; 115 | align-items: center; 116 | padding-right: 5rem; 117 | 118 | } 119 | 120 | .comment-box { 121 | width: 30vw; 122 | 123 | } 124 | 125 | .source-right { 126 | margin-left: auto; 127 | width: 20rem; 128 | } 129 | 130 | .delete-comment-button { 131 | align-self: end; 132 | border: 0; 133 | color: rgba(255, 66, 66, 0.708); 134 | background-color: inherit; 135 | cursor: pointer; 136 | } 137 | 138 | .delete-comment-button:hover { 139 | color: red; 140 | } 141 | 142 | .post-select { 143 | width: 20rem; 144 | margin: 0; 145 | display: inherit; 146 | } 147 | 148 | .delete-comment-button { 149 | visibility: hidden; 150 | } 151 | 152 | .comment-wrap:hover .delete-comment-button { 153 | visibility: visible; 154 | } 155 | } -------------------------------------------------------------------------------- /admin-blog/src/css/RecentComments.module.css: -------------------------------------------------------------------------------- 1 | .recent-comment-box { 2 | display: grid; 3 | grid-template-columns: minmax(0, 1fr); 4 | grid-gap: 0.5rem; 5 | margin: 0 0.5rem; 6 | } 7 | 8 | .delete-button { 9 | background: none; 10 | color: rgb(255, 0, 0); 11 | border: none; 12 | padding: 0; 13 | font: inherit; 14 | outline: inherit; 15 | cursor: pointer; 16 | font-size: 1rem; 17 | } 18 | 19 | .source-delete-box { 20 | display: flex; 21 | justify-content: space-between; 22 | } 23 | 24 | .divider { 25 | margin-top: 0rem; 26 | } 27 | 28 | @media (min-width: 1224px) { 29 | /* minmax(0, 1fr)可以防止网格布局中的元素溢出 */ 30 | .recent-comment-box { 31 | display: grid; 32 | grid-template-columns: repeat(3, minmax(0, 1fr)); 33 | grid-gap: 1rem; 34 | margin: 0 2rem; 35 | } 36 | 37 | .delete-button { 38 | background: none; 39 | color: rgb(241, 54, 54); 40 | border: none; 41 | padding: 0; 42 | font: inherit; 43 | outline: inherit; 44 | cursor: pointer; 45 | } 46 | 47 | .delete-button:hover { 48 | color: red; 49 | } 50 | 51 | .source-delete-box { 52 | display: flex; 53 | justify-content: space-between; 54 | } 55 | 56 | .comment-content { 57 | height: 4rem; 58 | overflow: auto; 59 | } 60 | 61 | .comment-content::-webkit-scrollbar { 62 | width: 6px; 63 | height: 6px; 64 | background-color: transparent; 65 | } 66 | 67 | .comment-content::-webkit-scrollbar-track { 68 | border-radius: 1rem; 69 | background-color: #e7e7e7; 70 | } 71 | 72 | .comment-content::-webkit-scrollbar-thumb { 73 | border-radius: 1rem; 74 | background-color: rgb(175, 175, 175); 75 | } 76 | 77 | .comment-content::-webkit-scrollbar-thumb:hover { 78 | background-color: rgb(145, 145, 145); 79 | width: 0.5rem; 80 | } 81 | 82 | .divider { 83 | margin-top: 0rem; 84 | } 85 | 86 | .delete-button { 87 | visibility: hidden; 88 | } 89 | 90 | .card-box:hover .delete-button { 91 | visibility: visible; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /admin-blog/src/css/ThemeSetting.module.css: -------------------------------------------------------------------------------- 1 | .out-box { 2 | background-color: white; 3 | } -------------------------------------------------------------------------------- /admin-blog/src/features/auth/authService.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const API_URL = "/api/users/"; 4 | 5 | const register = async (userData) => { 6 | const response = await axios.post(API_URL, userData); 7 | 8 | if (response.data) { 9 | localStorage.setItem("user", JSON.stringify(response.data)); 10 | } 11 | 12 | //console.log("response.data", response.data); 13 | 14 | return response.data; 15 | }; 16 | 17 | const login = async (userData) => { 18 | const response = await axios.post(API_URL + "login", userData); 19 | 20 | if (response.data) { 21 | localStorage.setItem("user", JSON.stringify(response.data)); 22 | } 23 | 24 | return response.data; 25 | }; 26 | 27 | const getUserData = async (token) => { 28 | const config = { 29 | headers: { 30 | Authorization: `Bearer ${token}`, 31 | }, 32 | }; 33 | 34 | const response = await axios.get(API_URL + "me", config); 35 | return response.data; 36 | }; 37 | 38 | const logout = () => { 39 | localStorage.removeItem("user"); 40 | //console.log("清除localStorage中的user"); 41 | }; 42 | 43 | const changePassword = async (token, passwordData) => { 44 | const config = { 45 | headers: { 46 | Authorization: `Bearer ${token}`, 47 | }, 48 | }; 49 | const response = await axios.put( 50 | API_URL + "update-password", 51 | passwordData, 52 | config 53 | ); 54 | return response.data; 55 | }; 56 | 57 | const authService = { 58 | register, 59 | logout, 60 | login, 61 | getUserData, 62 | changePassword, 63 | }; 64 | 65 | export default authService; 66 | -------------------------------------------------------------------------------- /admin-blog/src/features/comments/commentService.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const API_URL = '/api/comments/' 4 | 5 | 6 | 7 | const getComments = async (token)=>{ 8 | const config = { 9 | headers: { 10 | Authorization:`Bearer ${token}` 11 | } 12 | } 13 | 14 | const response = await axios.get(API_URL+'all', config) 15 | 16 | return response.data 17 | 18 | } 19 | 20 | const deleteComment = async (commentId, token)=>{ 21 | const config = { 22 | headers: { 23 | Authorization:`Bearer ${token}` 24 | } 25 | } 26 | 27 | const response = await axios.delete(API_URL+commentId, config) 28 | 29 | return response.data 30 | 31 | } 32 | 33 | const updateComment = async (commentId, commentData, token)=>{ 34 | 35 | commentData.commentId=undefined; 36 | const config = { 37 | headers: { 38 | Authorization:`Bearer ${token}`, 39 | 40 | } 41 | } 42 | 43 | const response = await axios.put(API_URL+commentId, commentData ,config) 44 | 45 | return response.data 46 | 47 | } 48 | 49 | 50 | 51 | const commentService = { 52 | 53 | getComments, 54 | deleteComment, 55 | updateComment 56 | } 57 | 58 | export default commentService; -------------------------------------------------------------------------------- /admin-blog/src/features/links/linkService.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const API_URL = '/api/links/' 4 | 5 | const createLink = async (linkData, token)=>{ 6 | const config = { 7 | headers: { 8 | Authorization:`Bearer ${token}`, 9 | 'content-type': 'multipart/form-data' 10 | } 11 | } 12 | 13 | const response = await axios.post(API_URL, linkData, config) 14 | 15 | return response.data 16 | 17 | } 18 | 19 | const getLinks = async (token)=>{ 20 | const config = { 21 | headers: { 22 | Authorization:`Bearer ${token}` 23 | } 24 | } 25 | 26 | const response = await axios.get(API_URL, config) 27 | 28 | return response.data 29 | 30 | } 31 | 32 | const deleteLink = async (linkId, token)=>{ 33 | const config = { 34 | headers: { 35 | Authorization:`Bearer ${token}` 36 | } 37 | } 38 | 39 | const response = await axios.delete(API_URL+linkId, config) 40 | 41 | return response.data 42 | 43 | } 44 | 45 | const updateLink = async (linkId, linkData, token)=>{ 46 | 47 | linkData.delete('linkId') 48 | const config = { 49 | headers: { 50 | Authorization:`Bearer ${token}`, 51 | 'content-type': 'multipart/form-data' 52 | } 53 | } 54 | 55 | const response = await axios.put(API_URL+linkId, linkData ,config) 56 | 57 | return response.data 58 | 59 | } 60 | 61 | 62 | 63 | const linkService = { 64 | createLink, 65 | getLinks, 66 | deleteLink, 67 | updateLink 68 | } 69 | 70 | export default linkService; -------------------------------------------------------------------------------- /admin-blog/src/features/posts/postService.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const API_URL = '/api/posts/' 4 | 5 | const createPost = async (postData, token)=>{ 6 | const config = { 7 | headers: { 8 | Authorization:`Bearer ${token}`, 9 | 'content-type': 'multipart/form-data' 10 | } 11 | } 12 | 13 | const response = await axios.post(API_URL, postData, config) 14 | 15 | return response.data 16 | 17 | } 18 | 19 | const getPosts = async (token)=>{ 20 | const config = { 21 | headers: { 22 | Authorization:`Bearer ${token}` 23 | } 24 | } 25 | 26 | const response = await axios.get(API_URL, config) 27 | 28 | return response.data 29 | 30 | } 31 | 32 | const deletePost = async (postId, token)=>{ 33 | const config = { 34 | headers: { 35 | Authorization:`Bearer ${token}` 36 | } 37 | } 38 | 39 | const response = await axios.delete(API_URL+postId, config) 40 | 41 | return response.data 42 | 43 | } 44 | 45 | const updatePost = async (postId, postData, token)=>{ 46 | 47 | postData.delete('postId') 48 | const config = { 49 | headers: { 50 | Authorization:`Bearer ${token}`, 51 | 'content-type': 'multipart/form-data' 52 | } 53 | } 54 | 55 | const response = await axios.put(API_URL+postId, postData ,config) 56 | 57 | return response.data 58 | 59 | } 60 | 61 | const updateCategory = async (categoryData, token)=>{ 62 | const config = { 63 | headers: { 64 | Authorization:`Bearer ${token}`, 65 | 'content-type': 'multipart/form-data' 66 | } 67 | } 68 | const response = await axios.put(API_URL+'category', categoryData, config) 69 | return response.data 70 | } 71 | 72 | const updateTag = async (tagData, token)=>{ 73 | const config = { 74 | headers: { 75 | Authorization:`Bearer ${token}`, 76 | 'content-type': 'multipart/form-data' 77 | } 78 | } 79 | const response = await axios.put(API_URL+'tag', tagData, config) 80 | return response.data 81 | } 82 | 83 | const deleteTag = async (tagData, token)=>{ 84 | const config = { 85 | headers: { 86 | Authorization:`Bearer ${token}`, 87 | 'content-type': 'multipart/form-data' 88 | }, 89 | data: tagData 90 | } 91 | const response = await axios.delete(API_URL+'tag', config) 92 | return response.data 93 | } 94 | 95 | const getOnePost = async (postId, token)=>{ 96 | const config = { 97 | headers: { 98 | Authorization:`Bearer ${token}` 99 | } 100 | } 101 | 102 | const response = await axios.get(API_URL+postId, config) 103 | 104 | return response.data 105 | 106 | } 107 | 108 | 109 | 110 | const postService = { 111 | createPost, 112 | getPosts, 113 | deletePost, 114 | updatePost, 115 | updateCategory, 116 | updateTag, 117 | deleteTag, 118 | getOnePost, 119 | } 120 | 121 | export default postService; -------------------------------------------------------------------------------- /admin-blog/src/features/profile/profileService.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const API_URL = '/api/profile/' 4 | 5 | 6 | const getProfile = async (token)=>{ 7 | const config = { 8 | headers: { 9 | Authorization:`Bearer ${token}` 10 | } 11 | } 12 | 13 | const response = await axios.get(API_URL, config) 14 | 15 | return response.data 16 | 17 | } 18 | 19 | const updateProfile = async (profileId, profileData, token)=>{ 20 | 21 | profileData.delete('profileId') 22 | const config = { 23 | headers: { 24 | Authorization:`Bearer ${token}`, 25 | 'content-type': 'multipart/form-data' 26 | } 27 | } 28 | 29 | const response = await axios.put(API_URL+profileId, profileData ,config) 30 | 31 | return response.data 32 | 33 | } 34 | 35 | 36 | 37 | const profileService = { 38 | getProfile, 39 | updateProfile 40 | } 41 | 42 | export default profileService; -------------------------------------------------------------------------------- /admin-blog/src/features/profile/profileSlice.js: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; 2 | import profileService from "./profileService"; 3 | 4 | const initialState = { 5 | profile: [], 6 | isError: false, 7 | isLoading: false, 8 | isLoadEnd: false, 9 | isSuccess: false, 10 | message: "", 11 | }; 12 | 13 | 14 | export const getProfile = createAsyncThunk( 15 | "profile/get", 16 | async (_, thunkAPI) => { 17 | try { 18 | const token = thunkAPI.getState().auth.user.token; 19 | return await profileService.getProfile(token); 20 | } catch (error) { 21 | const message = 22 | (error.response && 23 | error.response.data && 24 | error.response.data.message) || 25 | error.message || 26 | error.toString(); 27 | return thunkAPI.rejectWithValue(message); 28 | } 29 | } 30 | ); 31 | 32 | 33 | 34 | export const updateProfile = createAsyncThunk( 35 | "profile/update", 36 | async (profileData, thunkAPI) => { 37 | try { 38 | const token = thunkAPI.getState().auth.user.token; 39 | return await profileService.updateProfile(profileData.get('profileId'),profileData,token); 40 | } catch (error) { 41 | const message = 42 | (error.response && 43 | error.response.data && 44 | error.response.data.message) || 45 | error.message || 46 | error.toString(); 47 | return thunkAPI.rejectWithValue(message); 48 | } 49 | } 50 | ); 51 | 52 | export const profileSlice = createSlice({ 53 | name: "profile", 54 | initialState, 55 | reducers: { 56 | reset: (state) => initialState, 57 | resetError: (state)=>{ 58 | state.isError = false; 59 | state.message = ""; 60 | }, 61 | }, 62 | extraReducers: (builder) => { 63 | builder 64 | .addCase(getProfile.pending, (state) => { 65 | state.isLoading = true; 66 | state.isLoadEnd = false; 67 | }) 68 | .addCase(getProfile.fulfilled, (state, action) => { 69 | state.isLoading = false; 70 | state.isSuccess = true; 71 | state.profile = action.payload; 72 | state.message = ""; 73 | state.isLoadEnd = true; 74 | }) 75 | .addCase(getProfile.rejected, (state, action) => { 76 | state.isLoading = false; 77 | state.isError = true; 78 | state.message = action.payload; 79 | state.isLoadEnd = false; 80 | }) 81 | .addCase(updateProfile.pending, (state) => { 82 | state.isLoading = true; 83 | }) 84 | .addCase(updateProfile.fulfilled, (state, action) => { 85 | state.isLoading = false; 86 | state.isSuccess = true; 87 | state.profile = [action.payload]; 88 | state.message = "设置已更改"; 89 | }) 90 | .addCase(updateProfile.rejected, (state, action) => { 91 | state.isLoading = false; 92 | state.isError = true; 93 | state.isSuccess = false; 94 | state.message = action.payload; 95 | }); 96 | }, 97 | }); 98 | 99 | export const { reset, resetError } = profileSlice.actions; 100 | export default profileSlice.reducer; 101 | -------------------------------------------------------------------------------- /admin-blog/src/hooks/useColumnSearch.js: -------------------------------------------------------------------------------- 1 | import { SearchOutlined } from "@ant-design/icons"; 2 | import { Button, Input, Space } from "antd"; 3 | import { useState } from "react"; 4 | 5 | export default function useColumnSearch(inputPlaceholder, renderColumn) { 6 | const [searchText, setSearchText] = useState(""); 7 | const [searchedColumn, setSearchedColumn] = useState(""); 8 | 9 | const handleSearch = (selectedKeys, confirm, dataIndex) => { 10 | confirm(); 11 | setSearchText(selectedKeys[0]); 12 | setSearchedColumn(dataIndex); 13 | }; 14 | 15 | const handleReset = (clearFilters) => { 16 | clearFilters(); 17 | setSearchText(""); 18 | }; 19 | let searchInput; 20 | 21 | const getColumnSearchProps = (dataIndex) => ({ 22 | filterDropdown: ({ 23 | setSelectedKeys, 24 | selectedKeys, 25 | confirm, 26 | clearFilters, 27 | }) => ( 28 |
29 | { 31 | searchInput = node; 32 | }} 33 | placeholder={inputPlaceholder} 34 | value={selectedKeys[0]} 35 | onChange={(e) => 36 | setSelectedKeys(e.target.value ? [e.target.value] : []) 37 | } 38 | onPressEnter={() => handleSearch(selectedKeys, confirm, dataIndex)} 39 | style={{ marginBottom: 8, display: "block" }} 40 | /> 41 | 42 | 51 | 58 | 69 | 70 |
71 | ), 72 | filterIcon: (filtered) => ( 73 | 74 | ), 75 | onFilter: (value, record) => 76 | record[dataIndex] 77 | ? record[dataIndex] 78 | .toString() 79 | .toLowerCase() 80 | .includes(value.toLowerCase()) 81 | : "", 82 | onFilterDropdownOpenChange: (visible) => { 83 | if (visible) { 84 | setTimeout(() => searchInput.select(), 100); 85 | } 86 | }, 87 | render: (text) => renderColumn(text, dataIndex, searchedColumn, searchText), 88 | }); 89 | 90 | return getColumnSearchProps; 91 | } 92 | -------------------------------------------------------------------------------- /admin-blog/src/hooks/useGetData.js: -------------------------------------------------------------------------------- 1 | import { message as antMessage } from "antd"; 2 | import { useEffect } from "react"; 3 | import { useDispatch } from "react-redux"; 4 | 5 | export default function useGetData(getData, reset, isError, message, resetError) { 6 | const dispatch = useDispatch(); 7 | useEffect(() => { 8 | dispatch(getData()); 9 | return () => { 10 | dispatch(reset()); 11 | }; 12 | }, [dispatch,getData,reset]); 13 | 14 | 15 | useEffect(() => { 16 | if (isError) { 17 | antMessage.error(message); 18 | } 19 | return ()=>{ 20 | dispatch(resetError()); 21 | } 22 | }, [isError, message, dispatch, resetError]); 23 | } 24 | -------------------------------------------------------------------------------- /admin-blog/src/hooks/usePrevious.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | export default function usePrevious(value) { 4 | const ref = useRef(); 5 | useEffect(() => { 6 | ref.current = value; 7 | }); 8 | return ref.current; 9 | } 10 | -------------------------------------------------------------------------------- /admin-blog/src/hooks/useRedirect.js: -------------------------------------------------------------------------------- 1 | import { useNavigate, useLocation } from "react-router-dom"; 2 | import { useEffect } from "react"; 3 | export default function useRedirect(from, toRelative) { 4 | const navigate = useNavigate(); 5 | const location = useLocation(); 6 | useEffect(() => { 7 | if (location.pathname === from) { 8 | navigate(toRelative, { replace: true }); 9 | } 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /admin-blog/src/images/check-success-svgrepo-com.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /admin-blog/src/images/copy-svgrepo-com.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 17 | 20 | 23 | 26 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /admin-blog/src/images/slanted-gradient_blue.svg: -------------------------------------------------------------------------------- 1 |  -------------------------------------------------------------------------------- /admin-blog/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | [data="highlight"] { 16 | background-color: #353b45; 17 | background-color: black; 18 | background-color: #282c34; 19 | display: block; 20 | position: relative; 21 | left:-5px; 22 | border-left: 5px solid skyblue; 23 | 24 | 25 | } 26 | -------------------------------------------------------------------------------- /admin-blog/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { Provider } from "react-redux"; 4 | import { store } from "./app/store"; 5 | import App from "./App"; 6 | import "./index.css"; 7 | 8 | import { App as AntdApp } from "antd"; 9 | 10 | import zhCN from "antd/locale/zh_CN"; 11 | import { ConfigProvider } from "antd"; 12 | import dayjs from "dayjs"; 13 | import "dayjs/locale/zh-cn"; 14 | dayjs.locale("zh-cn"); 15 | 16 | 17 | 18 | const container = document.getElementById("root"); 19 | const root = createRoot(container); 20 | 21 | root.render( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | 33 | // If you want to start measuring performance in your app, pass a function 34 | // to log results (for example: reportWebVitals(console.log)) 35 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 36 | //reportWebVitals(console.log); 37 | -------------------------------------------------------------------------------- /admin-blog/src/pages/Loading.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Spin } from "antd"; 3 | import { LoadingOutlined } from "@ant-design/icons"; 4 | import style from "../css/Loading.module.css"; 5 | 6 | function Loading() { 7 | const antIcon = ; 8 | return ( 9 |
10 | 11 |
12 | ); 13 | } 14 | 15 | export default Loading; 16 | -------------------------------------------------------------------------------- /admin-blog/src/pages/Login.jsx: -------------------------------------------------------------------------------- 1 | import { Form, Input } from "antd"; 2 | import React, { useEffect } from "react"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import { useLocation, useNavigate } from "react-router-dom"; 5 | import { toast } from "react-toastify"; 6 | import style from "../css/Login.module.css"; 7 | import { checkJWT, login, reset } from "../features/auth/authSlice"; 8 | 9 | function Login() { 10 | const navigate = useNavigate(); 11 | const dispatch = useDispatch(); 12 | const location = useLocation(); 13 | 14 | const { user, isError, isSuccess, message } = useSelector( 15 | (state) => state.auth 16 | ); 17 | 18 | useEffect(() => { 19 | //console.log('user before : ',user) 20 | dispatch(checkJWT()); 21 | //console.log('user after : ',user) 22 | }, [dispatch]); 23 | 24 | const from = location.state?.from?.pathname || "/manage"; 25 | 26 | useEffect(() => { 27 | if (isError) { 28 | toast.error(message); 29 | dispatch(reset()); 30 | } 31 | 32 | if (isSuccess) { 33 | //console.log('isSuccess; ',isSuccess); 34 | //console.log('user navigate : ',user) 35 | //navigate("/manage",{ replace: true }); 36 | navigate(from, { replace: true }); 37 | } 38 | }, [user, isError, isSuccess, message, navigate, dispatch, from]); 39 | 40 | const onFinish = (values) => { 41 | //console.log("Success:", values); 42 | const userDate = { 43 | password: values.password, 44 | }; 45 | dispatch(login(userDate)); 46 | }; 47 | 48 | const onFinishFailed = (errorInfo) => { 49 | //console.log("Failed:", errorInfo); 50 | toast.error(errorInfo); 51 | }; 52 | 53 | return ( 54 | <> 55 |
56 |
57 |
73 |
Admin
74 | {`密码 :`} 77 | } 78 | name="password" 79 | colon={false} 80 | rules={[ 81 | { 82 | required: true, 83 | message: "请输入密码!", 84 | }, 85 | ]} 86 | > 87 | 88 | 89 | 90 | 96 | 99 | 100 | 101 |
102 |
103 | 104 | ); 105 | } 106 | 107 | export default Login; 108 | -------------------------------------------------------------------------------- /admin-blog/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /admin-blog/src/setupProxy.js: -------------------------------------------------------------------------------- 1 | const { createProxyMiddleware } = require('http-proxy-middleware'); 2 | 3 | module.exports = function(app) { 4 | app.use( 5 | '/api', 6 | createProxyMiddleware({ 7 | target: 'http://localhost:5000', 8 | changeOrigin: true, 9 | }) 10 | ); 11 | }; -------------------------------------------------------------------------------- /admin-blog/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /backend/.envexample: -------------------------------------------------------------------------------- 1 | NODE_ENV = development 2 | PORT = 5000 3 | MONGO_URI = mongodb://localhost:27017 4 | JWT_SECRET = replaceWithADifferentValue 5 | USER_NAME = admin 6 | USER_PASSWORD = admin 7 | MY_SECRET_TOKEN=replaceWithADifferentValue 8 | DB_NAME = blog 9 | NEXTJS_PORT = 3001 10 | DEMO = 0 -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # dotenv environment variable files 5 | .env 6 | .env.development.local 7 | .env.test.local 8 | .env.production.local 9 | .env.local 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | lerna-debug.log* 18 | .pnpm-debug.log* 19 | 20 | # upload images 21 | /uploads/image/* 22 | !/uploads/image/avatar.ico 23 | !/uploads/image/logo.svg 24 | !/uploads/image/og-image.png 25 | 26 | # production 27 | /build 28 | -------------------------------------------------------------------------------- /backend/config/db.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const bcrypt = require("bcryptjs"); 3 | const User = require("../models/userModel"); 4 | const Profile = require("../models/profileModel"); 5 | const Page = require("../models/pageModel"); 6 | const dbName = process.env.DB_NAME; 7 | const nextJsPort = process.env.NEXTJS_PORT; 8 | 9 | const connectDB = async () => { 10 | try { 11 | const conn = await mongoose.connect(process.env.MONGO_URI, { 12 | dbName: dbName, 13 | }); 14 | console.log(`MongoDB Connected: ${conn.connection.host}`); 15 | } catch (error) { 16 | console.log(error); 17 | process.exit(1); 18 | } 19 | }; 20 | 21 | //设置管理员默认账号 22 | const setDefaultPasswd = async () => { 23 | const name = process.env.USER_NAME; 24 | const userExists = await User.findOne({ name }); 25 | 26 | if (!userExists) { 27 | const password = process.env.USER_PASSWORD; 28 | const salt = await bcrypt.genSalt(10); 29 | const hashedPassword = await bcrypt.hash(password, salt); 30 | const user = await User.create({ 31 | name, 32 | password: hashedPassword, 33 | }); 34 | if (user) { 35 | console.log(`初始化密码设置成功`); 36 | } else { 37 | throw new Error("初始化密码设置失败"); 38 | } 39 | } 40 | }; 41 | 42 | //设置默认的博客网站信息 43 | const setDefaultProfile = async () => { 44 | const userName = process.env.USER_NAME; 45 | const userExists = await User.findOne({ name: userName }); 46 | let profileExists = null; 47 | if (userExists) { 48 | profileExists = await Profile.findOne({ user: userExists.id }); 49 | } else { 50 | throw new Error("初始用户创建失败"); 51 | } 52 | if (!profileExists) { 53 | const profileObj = { 54 | user: userExists.id, 55 | name: `${userName}的个人博客`, 56 | title: `${userName}的个人网站`, 57 | author: "Doe", 58 | language: "zh-CN", 59 | siteUrl: `http://localhost:${nextJsPort}`, 60 | siteRepo: "https://github.com", 61 | locale: "zh-CN", 62 | email: "name@site.com", 63 | description: "个人博客", 64 | logo: "/api/image/logo.svg", 65 | avatar: "/api/image/avatar.ico", 66 | socialBanner: "/api/image/og-image.png", 67 | }; 68 | try { 69 | await Profile.create(profileObj); 70 | } catch (error) { 71 | throw new Error(error); 72 | } 73 | } 74 | }; 75 | 76 | //设置默认的待构建页面表 77 | const setDefaultPage = async () => { 78 | const userName = process.env.USER_NAME; 79 | const userExists = await User.findOne({ name: userName }); 80 | let pageExists = null; 81 | if (userExists) { 82 | pageExists = await Page.findOne({ user: userExists.id }); 83 | } else { 84 | throw new Error("初始用户创建失败"); 85 | } 86 | if (!pageExists) { 87 | const pageObj = { 88 | user: userExists.id, 89 | pages: [], 90 | }; 91 | try { 92 | await Page.create(pageObj); 93 | } catch (error) { 94 | throw new Error(error); 95 | } 96 | } 97 | }; 98 | 99 | module.exports = { 100 | connectDB, 101 | setDefaultPasswd, 102 | setDefaultProfile, 103 | setDefaultPage, 104 | }; 105 | -------------------------------------------------------------------------------- /backend/controllers/commentController.js: -------------------------------------------------------------------------------- 1 | const asyncHandler = require("express-async-handler"); 2 | 3 | const Comment = require("../models/commentModel"); 4 | const Post = require("../models/postModel"); 5 | 6 | // @desc Get comments by post id 7 | // @route GET /api/comments/:postId 8 | // @access Public 9 | const getComments = asyncHandler(async (req, res) => { 10 | const comments = await Comment.find({ post: req.params.postId }).select( 11 | "-email -adminUser" 12 | ); //with unpublished comments 13 | res.status(200).json(comments); 14 | }); 15 | 16 | // @desc Get all comments 17 | // @route GET /api/comments/all 18 | // @access Private 19 | const getAllComments = asyncHandler(async (req, res) => { 20 | const comments = await Comment.find({ adminUser: req.user.id }); 21 | res.status(200).json(comments); 22 | }); 23 | 24 | // @desc Set comments 25 | // @route POST /api/comments 26 | // @access Public 27 | const setComments = asyncHandler(async (req, res) => { 28 | if (!req.body.postId) { 29 | res.status(400); 30 | throw new Error("Please set the postId"); 31 | } 32 | if (!req.body.username) { 33 | res.status(400); 34 | throw new Error("Please add a username"); 35 | } 36 | if (!req.body.email) { 37 | res.status(400); 38 | throw new Error("Please add an email"); 39 | } 40 | if (!req.body.comment) { 41 | res.status(400); 42 | throw new Error("Comment is empty"); 43 | } 44 | const post = await Post.findById(req.body.postId); 45 | 46 | const userExists = await Comment.findOne({ username: req.body.username }); 47 | 48 | if (userExists && userExists.email !== req.body.email) { 49 | res.status(400); 50 | throw new Error("User already exists"); 51 | } 52 | try { 53 | const comment = await Comment.create({ 54 | adminUser: post.user, 55 | source: post.title, 56 | post: req.body.postId, 57 | username: req.body.username, 58 | email: req.body.email, 59 | comment: req.body.comment, 60 | }); 61 | comment.adminUser = undefined; 62 | res.status(200).json(comment); 63 | } catch (error) { 64 | res.status(500); 65 | throw new Error(error); 66 | } 67 | }); 68 | 69 | // @desc Publish/Unpublish comment 70 | // @route PUT /api/comments/:id 71 | // @access Private 72 | const updateComment = asyncHandler(async (req, res) => { 73 | if (!req.body.published) { 74 | res.status(400); 75 | throw new Error("Please set the value of published"); 76 | } 77 | const comment = await Comment.findById(req.params.id); 78 | if (!comment) { 79 | res.status(400); 80 | throw new Error("Comment not found"); 81 | } 82 | 83 | //const user = await User.findById(req.user.id) 84 | if (!req.user) { 85 | res.status(401); 86 | throw new Error("User not founded"); 87 | } 88 | 89 | if (comment.adminUser.toString() !== req.user.id) { 90 | res.status(401); 91 | throw new Error("User not authorized"); 92 | } 93 | const updatedComment = await Comment.findByIdAndUpdate( 94 | req.params.id, 95 | { published: req.body.published }, 96 | { new: true } 97 | ); 98 | res.status(200).json(updatedComment); 99 | }); 100 | 101 | // @desc Delete comments 102 | // @route DELETE /api/comments/:id 103 | // @access Private 104 | const deleteComment = asyncHandler(async (req, res) => { 105 | const comment = await Comment.findById(req.params.id); 106 | if (!comment) { 107 | res.status(400); 108 | throw new Error("Comment not found"); 109 | } 110 | 111 | //const user = await User.findById(req.user.id) 112 | 113 | if (!req.user) { 114 | res.status(401); 115 | throw new Error("User not founded"); 116 | } 117 | 118 | if (comment.adminUser.toString() !== req.user.id) { 119 | res.status(401); 120 | throw new Error("User not authorized"); 121 | } 122 | 123 | await comment.remove(); 124 | 125 | res.status(200).json({ id: req.params.id }); 126 | }); 127 | 128 | module.exports = { 129 | getComments, 130 | getAllComments, 131 | setComments, 132 | updateComment, 133 | deleteComment, 134 | }; 135 | -------------------------------------------------------------------------------- /backend/controllers/imageController.js: -------------------------------------------------------------------------------- 1 | const asyncHandler = require("express-async-handler"); 2 | const fs = require("fs"); 3 | 4 | const Image = require("../models/imageModel"); 5 | 6 | // @desc Get images 获取所有图片 7 | // @route GET /api/images 8 | // @access Private 9 | const getImages = asyncHandler(async (req, res) => { 10 | const images = await Image.find({ user: req.user.id }); 11 | res.status(200).json(images); 12 | }); 13 | 14 | // @desc Set images 上传一张图片 15 | // @route POST /api/images 16 | // @access Private 17 | const setImages = asyncHandler(async (req, res) => { 18 | if (!req.body.title) { 19 | res.status(400); 20 | throw new Error("Please add a title to the image"); 21 | } 22 | const imageExists = await Image.findOne({ title: req.body.title }); 23 | if (imageExists) { 24 | res.status(400); 25 | throw new Error("图片标题已存在"); 26 | } 27 | 28 | let imageObj = { 29 | title: req.body.title, 30 | user: req.user.id, 31 | }; 32 | if (req.body.description) { 33 | imageObj.description = req.body.description; 34 | } 35 | if (req.file) { 36 | if (!req.file.mimetype.startsWith("image")) { 37 | res.status(400); 38 | throw new Error("Type error"); 39 | } 40 | imageObj.imageUrl = "/api/cloud_photo/" + req.file.filename; 41 | } 42 | try { 43 | const image = await Image.create(imageObj); 44 | res.status(200).json(image); 45 | } catch (error) { 46 | res.status(500); 47 | throw new Error(error); 48 | } 49 | }); 50 | 51 | // @desc Update images 修改图片信息 52 | // @route PUT /api/images/:id 53 | // @access Private 54 | const updateImage = asyncHandler(async (req, res) => { 55 | const image = await Image.findById(req.params.id); 56 | if (!image) { 57 | res.status(400); 58 | throw new Error("Image not found"); 59 | } 60 | //const user = await User.findById(req.user.id) 61 | if (!req.user) { 62 | res.status(401); 63 | throw new Error("User not founded"); 64 | } 65 | if (image.user.toString() !== req.user.id) { 66 | res.status(401); 67 | throw new Error("User not authorized"); 68 | } 69 | let newImageData = req.body; 70 | try { 71 | const updatedImage = await Image.findByIdAndUpdate( 72 | req.params.id, 73 | newImageData, 74 | { 75 | new: true, 76 | } 77 | ); 78 | updatedImage.imageUrl = undefined; 79 | res.status(200).json(updatedImage); 80 | } catch (error) { 81 | res.status(500); 82 | throw new Error(error); 83 | } 84 | }); 85 | 86 | // @desc Delete images 87 | // @route DELETE /api/images/:id 88 | // @access Private 89 | const deleteImage = asyncHandler(async (req, res) => { 90 | const image = await Image.findById(req.params.id); 91 | if (!image) { 92 | res.status(400); 93 | throw new Error("Image not found"); 94 | } 95 | 96 | if (!req.user) { 97 | res.status(401); 98 | throw new Error("User not founded"); 99 | } 100 | 101 | if (image.user.toString() !== req.user.id) { 102 | res.status(401); 103 | throw new Error("User not authorized"); 104 | } 105 | try { 106 | const imageUrl = image.imageUrl; 107 | const path = 108 | "./uploads/cloud_photo" + imageUrl.substring(imageUrl.lastIndexOf("/")); 109 | fs.unlinkSync(path); 110 | await image.remove(); 111 | res.status(200).json({ id: req.params.id }); 112 | } catch (error) { 113 | res.status(500); 114 | throw new Error(error); 115 | } 116 | }); 117 | 118 | module.exports = { 119 | getImages, 120 | setImages, 121 | updateImage, 122 | deleteImage, 123 | }; 124 | -------------------------------------------------------------------------------- /backend/controllers/profileController.js: -------------------------------------------------------------------------------- 1 | const asyncHandler = require("express-async-handler"); 2 | const fetch = (...args) => 3 | import("node-fetch").then(({ default: fetch }) => fetch(...args)); 4 | 5 | const Profile = require("../models/profileModel"); 6 | const Page = require("../models/pageModel"); 7 | const Post = require("../models/postModel"); 8 | 9 | // @desc Get profile 10 | // @route GET /api/profile 11 | // @access Private 12 | const getProfile = asyncHandler(async (req, res) => { 13 | const profile = await Profile.find({ user: req.user.id }); 14 | res.status(200).json(profile); 15 | }); 16 | 17 | // @desc Update profile 18 | // @route PUT /api/profile/:id 19 | // @access Private 20 | const updateProfile = asyncHandler(async (req, res) => { 21 | const profile = await Profile.findById(req.params.id); 22 | if (!profile) { 23 | res.status(400); 24 | throw new Error("Profile not found"); 25 | } 26 | //const user = await User.findById(req.user.id) 27 | if (!req.user) { 28 | res.status(401); 29 | throw new Error("User not founded"); 30 | } 31 | if (profile.user.toString() !== req.user.id) { 32 | res.status(401); 33 | throw new Error("User not authorized"); 34 | } 35 | let newProfileData = req.body; 36 | //console.log('req.files: ',req.files); 37 | 38 | if (req?.files?.logo && req?.files?.logo[0]) { 39 | newProfileData.logo = "/api/image/" + req.files["logo"][0].filename; 40 | newProfileData.logoType = req.files["logo"][0].mimetype; 41 | //console.log("req.files.logo[0]: ",req.files["logo"][0]); 42 | } 43 | if (req?.files?.avatar && req?.files?.avatar[0]) { 44 | newProfileData.avatar = "/api/image/" + req.files["avatar"][0].filename; 45 | newProfileData.avatarType = req.files["avatar"][0].mimetype; 46 | } 47 | if (req?.files?.socialBanner && req?.files?.socialBanner[0]) { 48 | newProfileData.socialBanner = 49 | "/api/image/" + req.files["socialBanner"][0].filename; 50 | //newProfileData.socialBanner = req.files["socialBanner"][0].buffer; 51 | newProfileData.socialBannerType = req.files["socialBanner"][0].mimetype; 52 | } 53 | 54 | const revalidateUrl = `http://localhost:${process.env.NEXTJS_PORT}/api/revalidate?secret=${process.env.MY_SECRET_TOKEN}&change=post`; 55 | 56 | try { 57 | const updatedProfile = await Profile.findByIdAndUpdate( 58 | req.params.id, 59 | newProfileData, 60 | { 61 | new: true, 62 | } 63 | ); 64 | 65 | const pages = ["/", "/tags", "/timeline", "/categories"]; 66 | let categories = []; 67 | let tags = []; 68 | const posts = await Post.find({ user: req.user.id, draft: false }); 69 | 70 | posts.forEach((doc) => { 71 | pages.push("/posts/" + doc.title); 72 | }); 73 | posts.forEach((doc) => { 74 | if (!categories.includes(doc.category)) { 75 | pages.push(`/categories/${doc.category}`); 76 | categories.push(doc.category); 77 | } 78 | doc.tags.forEach((tag) => { 79 | if (!tags.includes(tag)) { 80 | pages.push(`/tags/${tag}`); 81 | tags.push(tag); 82 | } 83 | }); 84 | pages.push(`/posts/${doc.title}`); 85 | }); 86 | 87 | const pageDoc = await Page.findOne({ user: req.user.id }); 88 | const mergePages = [...pages, ...pageDoc.pages]; 89 | const deDupPages = [...new Set(mergePages)]; 90 | await Page.findOneAndUpdate({ user: req.user.id }, { pages: deDupPages }); 91 | //console.log("pages: ", deDupPages); 92 | 93 | res.status(200).json(updatedProfile); 94 | } catch (error) { 95 | res.status(500); 96 | throw new Error(error); 97 | } finally { 98 | await fetch(revalidateUrl); 99 | } 100 | }); 101 | 102 | module.exports = { 103 | getProfile, 104 | updateProfile, 105 | }; 106 | -------------------------------------------------------------------------------- /backend/controllers/uploadController.js: -------------------------------------------------------------------------------- 1 | const asyncHandler = require("express-async-handler"); 2 | 3 | // @desc Upload files 4 | // @route POST /api/upload 5 | // @access Public 6 | const uploadFile = asyncHandler(async (req, res) => { 7 | // console.log("upload req body: ",req.body); 8 | // console.log("upload req files: ",req.files); 9 | res.status(200).json("ok"); 10 | 11 | }); 12 | 13 | module.exports = { uploadFile } 14 | -------------------------------------------------------------------------------- /backend/controllers/userController.js: -------------------------------------------------------------------------------- 1 | const jwt = require("jsonwebtoken"); 2 | const bcrypt = require("bcryptjs"); 3 | const asyncHandler = require("express-async-handler"); 4 | const User = require("../models/userModel"); 5 | 6 | // @desc Register new user 7 | // @route POST /api/users 8 | // @access Public 9 | const registerUser = asyncHandler(async (req, res) => { 10 | const { name, password } = req.body; 11 | 12 | if (!name || !password) { 13 | res.status(500); 14 | throw new Error("请填写完整信息"); 15 | } 16 | 17 | const userExists = await User.findOne({ name }); 18 | 19 | if (userExists) { 20 | res.status(400); 21 | throw new Error("用户已存在"); 22 | } 23 | 24 | //console.log(`申请注册新用户:用户名:(${name})`); 25 | 26 | const salt = await bcrypt.genSalt(10); 27 | const hashedPassword = await bcrypt.hash(password, salt); 28 | //console.log(`申请注册新用户:用户名:(${name}),密码哈希成功`); 29 | 30 | const user = await User.create({ 31 | name, 32 | password: hashedPassword, 33 | }); 34 | 35 | //console.log(`用户名:(${name}),user:${user}`); 36 | if (user) { 37 | res.status(201).json({ 38 | _id: user.id, 39 | name: user.name, 40 | token: generateToken(user._id), 41 | }); 42 | //console.log(`新用户注册成功,用户名为${user.name}`); 43 | } else { 44 | res.status(400); 45 | throw new Error("用户注册失败"); 46 | } 47 | }); 48 | 49 | // @desc Authenticate a user 50 | // @route POST /api/users/login 51 | // @access Public 52 | const loginUser = asyncHandler(async (req, res) => { 53 | //console.log("login from: ",req.ip); 54 | if(!req.body.password) { 55 | res.status(500); 56 | throw new Error("请提供密码"); 57 | } 58 | if(typeof req.body.password !== 'string') { 59 | res.status(500); 60 | throw new Error("密码类型错误"); 61 | } 62 | const { password } = req.body; 63 | const name = process.env.USER_NAME; 64 | const user = await User.findOne({ name }); 65 | if (user && (await bcrypt.compare(password, user.password))) { 66 | res.json({ 67 | _id: user.id, 68 | name: user.name, 69 | token: generateToken(user._id), 70 | }); 71 | } else { 72 | res.status(400); 73 | throw new Error("密码错误"); 74 | } 75 | }); 76 | 77 | // @desc Get user data 78 | // @route GET /api/users/me 79 | // @access Private 80 | const getMe = asyncHandler(async (req, res) => { 81 | res.status(200).json(req.user); 82 | }); 83 | 84 | // @desc Update user password 85 | // @route PUT /api/users/update-password 86 | // @access Private 87 | const updatePassword = asyncHandler(async (req, res) => { 88 | const { origin, password } = req.body; 89 | 90 | if (!origin || !password) { 91 | res.status(400); 92 | throw new Error("请填写完整信息"); 93 | } 94 | 95 | if (!req.user) { 96 | res.status(401); 97 | throw new Error("User not founded"); 98 | } 99 | 100 | const user = await User.findOne({ name: req.user.name }); 101 | if (!user) { 102 | res.status(400); 103 | throw new Error("User not founded"); 104 | } 105 | if (!(await bcrypt.compare(origin, user.password))) { 106 | res.status(400); 107 | throw new Error("密码错误"); 108 | } 109 | const salt = await bcrypt.genSalt(10); 110 | const hashedPassword = await bcrypt.hash(password, salt); 111 | const updatedUser = await User.findByIdAndUpdate( 112 | req.user.id, 113 | { password: hashedPassword }, 114 | { 115 | new: true, 116 | } 117 | ); 118 | 119 | if (updatedUser) { 120 | res.status(200).json({ 121 | _id: updatedUser.id, 122 | name: updatedUser.name, 123 | token: generateToken(updatedUser._id), 124 | }); 125 | } else { 126 | res.status(500); 127 | throw new Error("密码更改失败"); 128 | } 129 | }); 130 | 131 | // Generate JWT 132 | const generateToken = (id) => { 133 | return jwt.sign({ id }, process.env.JWT_SECRET, { 134 | expiresIn: "5d", 135 | }); 136 | }; 137 | 138 | module.exports = { loginUser, getMe, updatePassword }; 139 | -------------------------------------------------------------------------------- /backend/middleware/authMiddleware.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const asyncHandler = require('express-async-handler'); 3 | const User = require('../models/userModel') 4 | 5 | const protect = asyncHandler(async (req,res,next)=>{ 6 | let token; 7 | 8 | if(req.headers.authorization && req.headers.authorization.startsWith('Bearer')) { 9 | try { 10 | token = req.headers.authorization.split(' ')[1]; 11 | const decoded = jwt.verify(token, process.env.JWT_SECRET) 12 | 13 | req.user = await User.findById(decoded.id).select('-password'); 14 | if(!req.user) { 15 | res.status(401) 16 | throw new Error('not authorized') 17 | } 18 | const userUpdatedAt = parseInt(req.user.updatedAt.valueOf()/1000); 19 | const jwtIssueAt = decoded.iat; 20 | if (userUpdatedAt > jwtIssueAt) { 21 | res.status(401) 22 | throw new Error('not authorized') 23 | } 24 | 25 | 26 | next() 27 | } catch (error) { 28 | console.log(error) 29 | res.status(401) 30 | throw new Error('not authorized') 31 | } 32 | } 33 | 34 | if(!token) { 35 | res.status(401) 36 | throw new Error('not authorized, no token') 37 | } 38 | 39 | }) 40 | 41 | module.exports = {protect} -------------------------------------------------------------------------------- /backend/middleware/demoMiddleware.js: -------------------------------------------------------------------------------- 1 | const asyncHandler = require("express-async-handler"); 2 | 3 | const handleDemoReq = asyncHandler(async (req, res, next) => { 4 | if (parseInt(process.env.DEMO,10) === 0) { 5 | next(); 6 | } else { 7 | res.status(403); 8 | throw new Error("演示环境无法修改数据"); 9 | } 10 | }); 11 | 12 | module.exports = { handleDemoReq }; 13 | -------------------------------------------------------------------------------- /backend/middleware/errorMiddleware.js: -------------------------------------------------------------------------------- 1 | const errorHandler = (err, req, res, next) => { 2 | const statusCode = res.statusCode ? res.statusCode : 500; 3 | 4 | res.status(statusCode); 5 | 6 | res.json({ 7 | message: err.message, 8 | stack: process.env.NODE_ENV === "production" ? null : err.stack, 9 | }); 10 | }; 11 | 12 | 13 | module.exports = {errorHandler} -------------------------------------------------------------------------------- /backend/models/commentModel.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const commentSchema = mongoose.Schema({ 4 | adminUser:{ 5 | type: mongoose.Schema.Types.ObjectId, 6 | required:true, 7 | ref: 'User' 8 | }, 9 | post:{ 10 | type: mongoose.Schema.Types.ObjectId, 11 | required:true, 12 | ref: 'Post' 13 | }, 14 | source:{ 15 | type:String, 16 | required:true, 17 | }, 18 | username: { 19 | type: String, 20 | required: [true, 'Please add a username to the comment'] 21 | }, 22 | email: { 23 | type: String, 24 | required: [true, 'Please add an email'] 25 | }, 26 | comment: { 27 | type: String, 28 | required: true 29 | }, 30 | published: { 31 | type: Boolean, 32 | required: true, 33 | default: false, 34 | } 35 | 36 | 37 | },{ 38 | timestamps: true, 39 | }) 40 | 41 | module.exports = mongoose.model('Comment', commentSchema); -------------------------------------------------------------------------------- /backend/models/imageModel.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const imageSchema = mongoose.Schema({ 4 | user:{ 5 | type: mongoose.Schema.Types.ObjectId, 6 | required:true, 7 | ref: 'User' 8 | }, 9 | title:{ 10 | type: String, 11 | required: [true, 'Please add a title to the image'] 12 | }, 13 | description:{ 14 | type: String, 15 | }, 16 | imageUrl:{ 17 | type: String, 18 | required:true 19 | }, 20 | // hotlinkProtection: { 21 | // required:true, 22 | // type: Boolean, 23 | // }, 24 | },{ 25 | timestamps: true, 26 | }) 27 | 28 | module.exports = mongoose.model('Image', imageSchema) -------------------------------------------------------------------------------- /backend/models/linkModel.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const linkSchema = mongoose.Schema({ 4 | user:{ 5 | type: mongoose.Schema.Types.ObjectId, 6 | required:true, 7 | ref: 'User' 8 | }, 9 | name:{ 10 | type: String, 11 | required: [true, 'Please add a name to the link'] 12 | }, 13 | website:{ 14 | type: String, 15 | required: [true, 'Please add a url'] 16 | }, 17 | description:{ 18 | type: String, 19 | }, 20 | picture:{ 21 | type: String, 22 | } 23 | }) 24 | 25 | module.exports = mongoose.model('Link', linkSchema) -------------------------------------------------------------------------------- /backend/models/pageModel.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | 3 | const pageSchema = mongoose.Schema({ 4 | user: { 5 | type: mongoose.Schema.Types.ObjectId, 6 | required: true, 7 | ref: "User", 8 | }, 9 | pages: { 10 | type: [String], 11 | } 12 | }); 13 | 14 | module.exports = mongoose.model("Page", pageSchema); -------------------------------------------------------------------------------- /backend/models/postModel.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const postSchema = mongoose.Schema({ 4 | user:{ 5 | type: mongoose.Schema.Types.ObjectId, 6 | required:true, 7 | ref: 'User' 8 | }, 9 | title: { 10 | type: String, 11 | required: [true, 'Please add a title to the post'] 12 | }, 13 | authors: { 14 | type: [String], 15 | required: true 16 | }, 17 | tags: { 18 | type: [String], 19 | }, 20 | category: { 21 | type: String, 22 | required: [true, 'Please add a category to the post'] 23 | }, 24 | draft: { 25 | required:true, 26 | type: Boolean, 27 | }, 28 | summary: { 29 | type: String, 30 | }, 31 | canonicalUrl: { 32 | type: String, 33 | }, 34 | image: { 35 | type: Buffer, 36 | }, 37 | content: { 38 | type: String, 39 | } 40 | 41 | },{ 42 | timestamps: true, 43 | }) 44 | 45 | module.exports = mongoose.model('Post', postSchema); -------------------------------------------------------------------------------- /backend/models/profileModel.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | 3 | const profileSchema = mongoose.Schema({ 4 | user: { 5 | type: mongoose.Schema.Types.ObjectId, 6 | required: true, 7 | ref: "User", 8 | }, 9 | name: { 10 | type: String, 11 | required: [true, "Please add a title to the blog logo"], 12 | }, 13 | title: { 14 | type: String, 15 | required: [true, "Please add a html title"], 16 | }, 17 | author: { 18 | type: String, 19 | required: [true, "Please add a site author"], 20 | }, 21 | language: { 22 | type: String, 23 | required: true, 24 | }, 25 | siteUrl: { 26 | type: String, 27 | required: true, 28 | }, 29 | siteRepo: { 30 | type: String, 31 | required: true, 32 | }, 33 | locale: { 34 | type: String, 35 | required: true, 36 | }, 37 | email: { 38 | type: String, 39 | required: true, 40 | }, 41 | github: { 42 | type: String, 43 | }, 44 | zhihu: { 45 | type: String, 46 | }, 47 | juejin: { 48 | type: String, 49 | }, 50 | wx: { 51 | type: String, 52 | }, 53 | description: { 54 | type: String, 55 | required: true, 56 | }, 57 | logo: { 58 | type: String, 59 | required: true, 60 | }, 61 | logoType: { 62 | type: String, 63 | }, 64 | avatar: { 65 | type: String, 66 | required: true, 67 | }, 68 | avatarType: { 69 | type: String, 70 | }, 71 | socialBanner: { 72 | type: String, 73 | required: true, 74 | }, 75 | socialBannerType: { 76 | type: String, 77 | }, 78 | }); 79 | 80 | module.exports = mongoose.model("Profile", profileSchema); 81 | -------------------------------------------------------------------------------- /backend/models/userModel.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const userSchema = mongoose.Schema({ 4 | name: { 5 | type: String, 6 | required: [true, 'Please add a name'], 7 | unique: true, 8 | }, 9 | 10 | password: { 11 | type: String, 12 | required: [true, 'Please add a password'] 13 | }, 14 | 15 | }, { 16 | timestamps: true, 17 | }) 18 | 19 | module.exports = mongoose.model('User', userSchema) -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "description": "blog system backend", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node server.js", 9 | "server": "nodemon server.js" 10 | }, 11 | "author": "manfred", 12 | "license": "MIT", 13 | "dependencies": { 14 | "bcryptjs": "^2.4.3", 15 | "cors": "^2.8.5", 16 | "dotenv": "^16.0.1", 17 | "express": "^4.18.1", 18 | "express-async-handler": "^1.2.0", 19 | "express-rate-limit": "^6.4.0", 20 | "express-static-gzip": "^2.1.7", 21 | "jsonwebtoken": "^8.5.1", 22 | "mongoose": "^6.3.3", 23 | "multer": "^1.4.5-lts.1", 24 | "node-fetch": "^3.2.10" 25 | }, 26 | "devDependencies": { 27 | "nodemon": "^2.0.16" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /backend/routes/commentRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const { 4 | getComments, 5 | getAllComments, 6 | setComments, 7 | updateComment, 8 | deleteComment, 9 | } = require("../controllers/commentController"); 10 | const { protect } = require("../middleware/authMiddleware"); 11 | 12 | router.route("/all").get(protect, getAllComments); 13 | router.route("/").post(setComments); 14 | router.route("/:postId").get(getComments); 15 | router.route("/:id").put(protect, updateComment).delete(protect, deleteComment); 16 | 17 | module.exports = router; 18 | -------------------------------------------------------------------------------- /backend/routes/imageRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const path = require("path"); 4 | const { 5 | getImages, 6 | setImages, 7 | updateImage, 8 | deleteImage, 9 | } = require("../controllers/imageController"); 10 | 11 | const { protect } = require("../middleware/authMiddleware"); 12 | const { handleDemoReq } = require("../middleware/demoMiddleware"); 13 | const multer = require("multer"); 14 | const storage = multer.diskStorage({ 15 | destination: "./uploads/cloud_photo", 16 | filename: function (req, file, cb) { 17 | cb(null, Date.now() + path.extname(file.originalname)); 18 | }, 19 | }); 20 | function fileFilter(req, file, cb) { 21 | //console.log("fileFilter file: ", file); 22 | if (file.mimetype.startsWith("image")) { 23 | cb(null, true); 24 | } else { 25 | cb(new Error("Type error")); 26 | } 27 | } 28 | 29 | const upload = multer({ 30 | storage, 31 | fileFilter, 32 | }); 33 | 34 | router 35 | .route("/") 36 | .get(protect, getImages) 37 | .post(protect, upload.single("image"), setImages); 38 | router 39 | .route("/:id") 40 | .put(protect, updateImage) 41 | .delete(protect, deleteImage); 42 | 43 | module.exports = router; 44 | -------------------------------------------------------------------------------- /backend/routes/linkRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const path = require("path"); 4 | const { 5 | getLinks, 6 | setLinks, 7 | updateLink, 8 | deleteLink, 9 | } = require("../controllers/linkController"); 10 | const { protect } = require("../middleware/authMiddleware"); 11 | const { handleDemoReq } = require("../middleware/demoMiddleware"); 12 | const multer = require("multer"); 13 | const storage = multer.diskStorage({ 14 | destination: function (req, file, cb) { 15 | cb(null, "./uploads/image"); 16 | }, 17 | filename: function (req, file, cb) { 18 | cb(null, Date.now() + path.extname(file.originalname)); 19 | }, 20 | }); 21 | function fileFilter(req, file, cb) { 22 | //console.log("fileFilter file: ", file); 23 | if (file.mimetype === "image/png" || file.mimetype === "image/jpeg") { 24 | cb(null, true); 25 | } else { 26 | cb(new Error("Image type error")); 27 | } 28 | } 29 | const upload = multer({ 30 | storage: storage, 31 | fileFilter, 32 | limits: { fileSize: 1024 * 1024 }, 33 | }); 34 | 35 | router 36 | .route("/") 37 | .get(protect, getLinks) 38 | .post(protect, handleDemoReq, upload.single("picture"), setLinks); 39 | router 40 | .route("/:id") 41 | .put(protect, handleDemoReq, upload.single("picture"), updateLink) 42 | .delete(protect, handleDemoReq, deleteLink); 43 | 44 | module.exports = router; 45 | -------------------------------------------------------------------------------- /backend/routes/postRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const { 4 | getPosts, 5 | setPosts, 6 | updatePost, 7 | deletePost, 8 | updateCategory, 9 | updateTag, 10 | deleteTag, 11 | getPostById, 12 | } = require("../controllers/postController"); 13 | const { protect } = require("../middleware/authMiddleware"); 14 | const multer = require("multer"); 15 | const upload = multer(); 16 | const { handleDemoReq } = require("../middleware/demoMiddleware"); 17 | 18 | router 19 | .route("/") 20 | .get(protect, getPosts) 21 | .post(protect, handleDemoReq, upload.single("image"), setPosts); 22 | router 23 | .route("/category") 24 | .put(protect, handleDemoReq, upload.none(), updateCategory); 25 | router 26 | .route("/tag") 27 | .put(protect, handleDemoReq, upload.none(), updateTag) 28 | .delete(protect, handleDemoReq, upload.none(), deleteTag); 29 | router 30 | .route("/:id") 31 | .put(protect, handleDemoReq, upload.single("image"), updatePost) 32 | .delete(protect, handleDemoReq, deletePost) 33 | .get(protect, getPostById); 34 | 35 | module.exports = router; 36 | -------------------------------------------------------------------------------- /backend/routes/profileRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const path = require("path"); 4 | const { 5 | getProfile, 6 | updateProfile, 7 | } = require("../controllers/profileController"); 8 | const { protect } = require("../middleware/authMiddleware"); 9 | const { handleDemoReq } = require("../middleware/demoMiddleware"); 10 | const multer = require("multer"); 11 | const storage = multer.diskStorage({ 12 | destination: function (req, file, cb) { 13 | cb(null, "./uploads/image"); 14 | }, 15 | filename: function (req, file, cb) { 16 | cb(null, Date.now() + path.extname(file.originalname)); 17 | }, 18 | }); 19 | function fileFilter(req, file, cb) { 20 | //console.log("fileFilter file: ", file); 21 | if ( 22 | file.mimetype === "image/png" || 23 | file.mimetype === "image/svg+xml" || 24 | file.mimetype === "image/x-icon" || 25 | file.mimetype === "image/vnd.microsoft.icon" 26 | ) { 27 | cb(null, true); 28 | } else { 29 | cb(new Error("Image type error")); 30 | } 31 | } 32 | const upload = multer({ 33 | storage: storage, 34 | fileFilter, 35 | limits: { fileSize: 300 * 1024 }, 36 | }); 37 | 38 | router.route("/").get(protect, getProfile); 39 | router.route("/:id").put( 40 | protect, 41 | handleDemoReq, 42 | upload.fields([ 43 | { name: "avatar", maxCount: 1 }, 44 | { name: "logo", maxCount: 1 }, 45 | { name: "socialBanner", maxCount: 1 }, 46 | ]), 47 | updateProfile 48 | ); 49 | 50 | module.exports = router; 51 | -------------------------------------------------------------------------------- /backend/routes/uploadRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const {uploadFile} = require("../controllers/uploadController") 4 | 5 | const multer = require("multer"); 6 | const upload = multer({ dest: "uploads/" }); 7 | 8 | router.route('/').post(upload.single('avatar'),uploadFile) 9 | 10 | module.exports = router; -------------------------------------------------------------------------------- /backend/routes/userRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const { 4 | loginUser, 5 | getMe, 6 | updatePassword, 7 | } = require("../controllers/userController"); 8 | const { protect } = require("../middleware/authMiddleware"); 9 | const rateLimit = require("express-rate-limit"); 10 | const { handleDemoReq } = require("../middleware/demoMiddleware"); 11 | 12 | const limiterLogin = rateLimit({ 13 | windowMs: 2 * 60 * 1000, // ms 14 | max: 3, // Limit each IP to xx requests per `window` 15 | standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers 16 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers 17 | }); 18 | 19 | //router.post("/", registerUser) 20 | router.post("/login", limiterLogin, loginUser); 21 | router.get("/me", protect, getMe); 22 | router.put("/update-password", protect, handleDemoReq, updatePassword); 23 | 24 | module.exports = router; 25 | -------------------------------------------------------------------------------- /backend/server.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const http = require("http"); 3 | const dotenv = require("dotenv").config(); 4 | const port = process.env.PORT || 5000; 5 | const { 6 | connectDB, 7 | setDefaultPasswd, 8 | setDefaultProfile, 9 | setDefaultPage, 10 | } = require("./config/db"); 11 | const { errorHandler } = require("./middleware/errorMiddleware"); 12 | const cors = require("cors"); 13 | const rateLimit = require("express-rate-limit"); 14 | 15 | connectDB(); 16 | 17 | //设置管理员默认账号和其他初始化数据 18 | setDefaultPasswd().then(() => { 19 | setDefaultProfile(); 20 | setDefaultPage(); 21 | } 22 | ); 23 | 24 | const app = express(); 25 | if (process.env.NODE_ENV === "production") { 26 | app.set("trust proxy", true); 27 | } 28 | 29 | const httpServer = http.createServer(app); 30 | 31 | app.use( 32 | cors( 33 | //设置origin为false取消所有cors. 34 | //{origin: false} 35 | ) 36 | ); 37 | 38 | const limiter = rateLimit({ 39 | windowMs: 10 * 60 * 1000, 40 | max: 500, 41 | standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers 42 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers 43 | }); 44 | 45 | const limiterComment = rateLimit({ 46 | windowMs: 20 * 60 * 1000, // ms 47 | max: 500, // Limit each IP to xx requests per `window` 48 | standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers 49 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers 50 | }); 51 | 52 | app.use(express.json()); 53 | app.use(express.urlencoded({ extended: false })); 54 | 55 | // const multer = require('multer') 56 | // const upload = multer() 57 | 58 | // app.post('/api/upload', upload.any(), (req, res, next) => { 59 | // req.body contains the text fields 60 | // console.log("upload req file type: ",typeof req.files[0]); 61 | // console.log("upload req files: ",req.files); 62 | // console.log("upload req body: ",req.body); 63 | // res.status(200).json("ok"); 64 | // }) 65 | 66 | app.use("/api/users", limiter, require("./routes/userRoutes")); 67 | app.use("/api/links", limiter, require("./routes/linkRoutes")); 68 | app.use("/api/posts", limiter, require("./routes/postRoutes")); 69 | app.use("/api/profile", limiter, require("./routes/profileRoutes")); 70 | app.use("/api/comments", limiterComment, require("./routes/commentRoutes")); 71 | app.use("/api/images", limiter, require("./routes/imageRoutes")); 72 | 73 | app.use(errorHandler); 74 | 75 | //Serving static image files 76 | const limiterStatic = rateLimit({ 77 | windowMs: 1 * 60 * 1000, // 1 minutes 78 | max: 200, // Limit each IP to 150 requests per `window` 79 | standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers 80 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers 81 | }); 82 | const path = require("path"); 83 | app.use( 84 | "/api/image", 85 | limiterStatic, 86 | express.static(path.join(__dirname, "uploads/image")) 87 | ); 88 | app.use( 89 | "/api/cloud_photo", 90 | limiterStatic, 91 | express.static(path.join(__dirname, "uploads/cloud_photo")) 92 | ); 93 | 94 | //博客管理页面的静态服务器 95 | function setCustomCacheControl (res, path) { 96 | if(path.includes("static") || path.endsWith(".woff")) { 97 | res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); 98 | } 99 | if(path.endsWith(".html")) { 100 | res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate'); 101 | } 102 | } 103 | 104 | if (process.env.NODE_ENV === "production") { 105 | const expressStaticGzip = require("express-static-gzip"); 106 | app.use( 107 | limiterStatic, 108 | expressStaticGzip(path.join(__dirname, "../admin-blog/build"),{ 109 | enableBrotli: true, 110 | orderPreference: ["br"], 111 | serveStatic:{ 112 | setHeaders: setCustomCacheControl 113 | } 114 | }), 115 | ); 116 | app.get("/*", limiterStatic, function (req, res) { 117 | res.sendFile(path.join(__dirname, "../admin-blog/build", "index.html")); 118 | }); 119 | } 120 | 121 | httpServer.listen(port, () => { 122 | console.log(`HTTP服务器启动,端口为: ${port}`); 123 | }); 124 | -------------------------------------------------------------------------------- /backend/uploads/image/avatar.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ManfredMT/nextjs-blog-react-admin/7ff31f568f03655a5c730038e001870fd7b84dac/backend/uploads/image/avatar.ico -------------------------------------------------------------------------------- /backend/uploads/image/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ManfredMT/nextjs-blog-react-admin/7ff31f568f03655a5c730038e001870fd7b84dac/backend/uploads/image/og-image.png -------------------------------------------------------------------------------- /docs/images/blog-system-small.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ManfredMT/nextjs-blog-react-admin/7ff31f568f03655a5c730038e001870fd7b84dac/docs/images/blog-system-small.PNG -------------------------------------------------------------------------------- /docs/images/blog-system.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ManfredMT/nextjs-blog-react-admin/7ff31f568f03655a5c730038e001870fd7b84dac/docs/images/blog-system.PNG -------------------------------------------------------------------------------- /nextjs-blog/.envexample: -------------------------------------------------------------------------------- 1 | USER_NAME = admin 2 | MONGODB_URI = mongodb://localhost:27017 3 | DB_NAME = blog 4 | MY_SECRET_TOKEN=replaceWithADifferentValue 5 | PORT = 5000 6 | ANALYZE=false -------------------------------------------------------------------------------- /nextjs-blog/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals"] 3 | } 4 | -------------------------------------------------------------------------------- /nextjs-blog/.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # rss 35 | /public/*.xml 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /nextjs-blog/README.md: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /nextjs-blog/components/CommentForm.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import styles from "../styles/CommentForm.module.css"; 3 | 4 | export default function CommentForm({ postId, setIsCommentChange }) { 5 | const [comment, setComment] = useState(""); 6 | const [username, setUsername] = useState(""); 7 | const [email, setEmail] = useState(""); 8 | const [message, setMessage] = useState(""); 9 | const [showNotice, setShowNotice] = useState(false); 10 | const form = { 11 | postId, 12 | username, 13 | email, 14 | comment, 15 | }; 16 | const onSubmit = (e) => { 17 | e.preventDefault(); 18 | const postData = async () => { 19 | const response = await fetch("/api/comments", { 20 | method: "POST", 21 | headers: { 22 | "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", 23 | }, 24 | body: new URLSearchParams(form), 25 | }); 26 | if (response.status >= 400 && response.status < 600) { 27 | throw new Error("Bad response from server"); 28 | } 29 | const json = await response.json(); 30 | return json; 31 | }; 32 | postData() 33 | .then((data) => { 34 | //console.log("response data: ", data); 35 | setMessage("评论已发送"); 36 | setShowNotice(true); 37 | setIsCommentChange(true); 38 | }) 39 | .catch((error) => { 40 | console.error("error: ", error.message); 41 | setMessage("发送失败"); 42 | setShowNotice(true); 43 | }) 44 | .finally(() => { 45 | setComment(""); 46 | setUsername(""); 47 | setEmail(""); 48 | }); 49 | 50 | }; 51 | useEffect(() => { 52 | if (showNotice) { 53 | setTimeout(() => { 54 | setShowNotice(false); 55 | }, 3000); 56 | } 57 | }, [showNotice]); 58 | 59 | return ( 60 |
61 |
62 |