├── .env.example ├── .github └── workflows │ └── sync-juejin-articles.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── app ├── (main) │ ├── article │ │ ├── [id] │ │ │ ├── anchor │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ │ └── page.tsx │ │ └── list │ │ │ └── page.tsx │ ├── guestbook │ │ ├── AddMessage.tsx │ │ ├── Header.tsx │ │ ├── MessagesList.tsx │ │ └── page.tsx │ ├── home │ │ ├── ContentCard.tsx │ │ ├── GithubProject.tsx │ │ ├── JueJinArticles.tsx │ │ ├── TechnologyStack.tsx │ │ └── UserProfile.tsx │ ├── layout.tsx │ └── page.tsx ├── api │ ├── articles │ │ ├── add │ │ │ └── route.ts │ │ ├── all │ │ │ └── route.ts │ │ ├── articleCountByMonth │ │ │ └── route.ts │ │ ├── delete │ │ │ └── route.ts │ │ ├── details │ │ │ └── route.ts │ │ ├── list │ │ │ └── route.ts │ │ ├── syncJuejinArticles │ │ │ └── route.ts │ │ └── update │ │ │ └── route.ts │ ├── auth │ │ └── [...nextauth] │ │ │ ├── authOptions.ts │ │ │ └── route.ts │ ├── feed.xml │ │ └── route.ts │ ├── guestbook │ │ └── route.ts │ ├── imagekit │ │ ├── getFileInfoByHash │ │ │ └── route.ts │ │ └── getToken │ │ │ └── route.ts │ ├── proxy │ │ ├── github │ │ │ ├── pinnedRepos │ │ │ │ └── route.ts │ │ │ └── userInfo │ │ │ │ └── route.ts │ │ └── juejin │ │ │ ├── articles │ │ │ └── route.ts │ │ │ └── userInfo │ │ │ └── route.ts │ ├── route.ts │ ├── sendEmail │ │ └── route.ts │ └── user │ │ └── details │ │ └── route.ts ├── article │ ├── Header.tsx │ ├── PublishDialog.tsx │ ├── add │ │ └── page.tsx │ └── edit │ │ └── [id] │ │ └── page.tsx ├── auth │ └── verify-request │ │ └── page.tsx ├── globals.css ├── layout.tsx ├── not-found.tsx └── providers.tsx ├── commitlint.config.mjs ├── components.json ├── components ├── artdots.tsx ├── base-loading.tsx ├── blog-logo.tsx ├── bytemd │ ├── dark-theme.scss │ ├── editor.scss │ ├── editor.tsx │ ├── plugins.ts │ └── viewer.tsx ├── layout │ ├── footer.tsx │ └── header.tsx ├── login-dialog │ ├── EmailLoginButton.tsx │ ├── GithubLoginButton.tsx │ ├── LoginForm.tsx │ └── index.tsx ├── no-found.tsx ├── screen-loading.tsx ├── theme-switch.tsx └── ui │ ├── avatar.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ ├── skeleton.tsx │ ├── sonner.tsx │ ├── textarea.tsx │ └── tooltip.tsx ├── eslint.config.mjs ├── lib ├── date.ts ├── enums.ts ├── getReadingTime.ts ├── imagekit.ts ├── routers.ts └── utils.ts ├── middleware.ts ├── next-sitemap.config.js ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── prisma ├── index.ts ├── migrations │ ├── 20240930094220_next_auth │ │ └── migration.sql │ ├── 20240930094537_next_auth │ │ └── migration.sql │ ├── 20240930094630_next_auth │ │ └── migration.sql │ ├── 20241008134643_add_msg_table │ │ └── migration.sql │ ├── 20241024133557_add_subscriber_table │ │ └── migration.sql │ ├── 20241120125022_create_ai_message_table │ │ └── migration.sql │ ├── 20241121140117_update_ai_table │ │ └── migration.sql │ ├── 20241121142017_update_ai_message_table │ │ └── migration.sql │ ├── 20241125120705_ai_message_add_token │ │ └── migration.sql │ ├── 20241126122640_update_conversation_fields │ │ └── migration.sql │ ├── 20241201083638_update_on_delete │ │ └── migration.sql │ ├── 20241201084418_update_add_deleted_at_field │ │ └── migration.sql │ ├── 20250303123335_del_ai │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public ├── TANSHI.jpg ├── dark-logo.svg ├── favicon │ ├── apple-touch-icon.png │ ├── favicon-96x96.png │ ├── favicon.ico │ ├── favicon.svg │ ├── site.webmanifest │ ├── web-app-manifest-192x192.png │ └── web-app-manifest-512x512.png ├── light-logo.svg ├── og │ └── opengraph-image.png └── user-icon.png ├── tsconfig.json └── types ├── github.d.ts ├── index.d.ts ├── juejin.d.ts └── next-auth.d.ts /.env.example: -------------------------------------------------------------------------------- 1 | # next-auth 2 | NEXTAUTH_SECRET="" 3 | NEXTAUTH_URL="" 4 | 5 | # mysql 数据库连接 6 | DATABASE_URL="" 7 | # 掘金 userid 8 | JUEJIN_USER_ID="" 9 | 10 | # github 11 | NEXT_PUBLIC_GITHUB_USER_NAME="" 12 | GITHUB_API_TOKEN="" 13 | # github repository 14 | GITHUB_REPOSITORY_API_KEY="" 15 | 16 | # github auth app 17 | AUTH_GITHUB_CLIENT_ID="" 18 | AUTH_GITHUB_CLIENT_SECRET="" 19 | 20 | # 邮箱验证登录配置 21 | EMAIL_SERVER_USER="" 22 | EMAIL_SERVER_PASSWORD="" 23 | EMAIL_SERVER_HOST="" 24 | EMAIL_SERVER_PORT="" 25 | EMAIL_FROM="" 26 | 27 | # 站点 28 | NEXT_PUBLIC_SITE_URL="" 29 | 30 | # imagekit 图片存储服务 31 | NEXT_PUBLIC_IMAGEKIT_PUBLIC_KEY="" 32 | NEXT_PUBLIC_IMAGEKIT_URL_ENDPOINT="" 33 | IMAGEKIT_PRIVATE_KEY="" 34 | -------------------------------------------------------------------------------- /.github/workflows/sync-juejin-articles.yml: -------------------------------------------------------------------------------- 1 | name: Sync Juejin Articles 2 | 3 | on: 4 | schedule: 5 | # 每天凌晨 2 点运行 6 | - cron: '0 2 * * *' 7 | 8 | jobs: 9 | sync-articles: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Call Sync Juejin Articles API 14 | env: 15 | API_KEY: ${{ secrets.API_KEY }} 16 | run: | 17 | curl -X GET "https://blog.vaebe.cn/api/articles/syncJuejinArticles" -H "x-api-key: $API_KEY" 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | # sitemap 40 | robots.txt 41 | sitemap.xml 42 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | .next 4 | dist 5 | pnpm-lock.yaml 6 | components/ui/* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "singleQuote": true, 6 | "semi": false, 7 | "trailingComma": "none", 8 | "bracketSpacing": true 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, // 启用保存时自动格式化 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", // 使用 Prettier 作为默认格式化工具 4 | "[javascript]": { 5 | "editor.defaultFormatter": "esbenp.prettier-vscode" 6 | }, 7 | "[typescript]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | }, 10 | "[typescriptreact]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | }, 13 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], 14 | "[prisma]": { 15 | "editor.defaultFormatter": "Prisma.prisma" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 ve 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # blog 2 | 3 | 一个简单的博客 4 | 5 | ## 项目介绍 6 | 7 | 项目依赖 mysql、 github 仓库 api_key 、next-auth(邮箱登录、github 登录) 需要在 `.env` 文件中配置 8 | 9 | mysql:存储博客的相关的数据,用户信息、文章信息、留言信息 10 | 11 | github 仓库 api_key:目前仅是为了 github acitons 调用同步掘金文章接口的时候做鉴权防止恶意调用 12 | 13 | next-auth: 实现 github 登录、邮箱登录、账号密码登录功能 14 | 15 | ## 启动项目 16 | 17 | 执行 `pnpm i` 安装依赖 18 | 19 | 执行 `npx prisma generate` 生成 prisma 模型的 ts 类型 20 | 21 | 执行 `pnpm run dev 启动项目` 22 | 23 | ## prisma 24 | 25 | prisma 仅支持 `.env` 文件配置的环境变量 26 | 27 | 生成数据库迁移 28 | 29 | ```bash 30 | npx prisma migrate dev --name update_string_fields 31 | ``` 32 | 33 | 生成 ts 类型 34 | 35 | ```bash 36 | npx prisma generate 37 | ``` 38 | 39 | ## ui 40 | 41 | 添加组件 42 | 43 | ```bash 44 | npx shadcn@latest add scroll-area 45 | ``` 46 | -------------------------------------------------------------------------------- /app/(main)/article/[id]/anchor/index.css: -------------------------------------------------------------------------------- 1 | .anchor ul { 2 | list-style: none; 3 | padding-left: 0; 4 | } 5 | 6 | .anchor li { 7 | margin: 5px 0; 8 | cursor: pointer; 9 | } 10 | 11 | .anchor li.active a { 12 | color: #3b82f6; 13 | font-weight: bold; 14 | } 15 | 16 | .level-h1 { 17 | font-size: 0.9rem; 18 | } 19 | 20 | .level-h2 { 21 | font-size: 0.9rem; 22 | padding-left: 10px; 23 | } 24 | 25 | .level-h3 { 26 | font-size: 0.9rem; 27 | padding-left: 20px; 28 | } 29 | 30 | .level-h4 { 31 | font-size: 0.9rem; 32 | padding-left: 30px; 33 | } 34 | -------------------------------------------------------------------------------- /app/(main)/article/[id]/anchor/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { useEffect, useState, useRef } from 'react' 4 | import { v4 as uuidv4 } from 'uuid' 5 | import './index.css' 6 | 7 | interface Heading { 8 | id: string 9 | text: string 10 | level: string 11 | } 12 | 13 | interface AnchorProps { 14 | content: string 15 | } 16 | 17 | export const Anchor: React.FC = ({ content }) => { 18 | const [headings, setHeadings] = useState([]) 19 | const [activeId, setActiveId] = useState('') 20 | const observerRef = useRef(null) 21 | const mutationObserverRef = useRef(null) 22 | 23 | useEffect(() => { 24 | if (!content) return 25 | 26 | const generateHeadings = () => { 27 | const elements: Heading[] = Array.from( 28 | document.querySelectorAll( 29 | '.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4' 30 | ) 31 | ).map((elem) => { 32 | const element = elem as HTMLElement 33 | if (!element.id) { 34 | element.id = uuidv4() 35 | } 36 | return { 37 | id: element.id, 38 | text: element.innerText, 39 | level: element.tagName.toLowerCase() 40 | } 41 | }) 42 | setHeadings(elements) 43 | observeHeadings(elements) 44 | } 45 | 46 | const observeHeadings = (elements: Heading[]) => { 47 | if (observerRef.current) { 48 | observerRef.current.disconnect() 49 | } 50 | 51 | observerRef.current = new IntersectionObserver( 52 | (entries) => { 53 | const visibleEntries = entries.filter((entry) => entry.isIntersecting) 54 | if (visibleEntries.length > 0) { 55 | const nearestEntry = visibleEntries.reduce((nearest, entry) => { 56 | return entry.boundingClientRect.top < nearest.boundingClientRect.top ? entry : nearest 57 | }) 58 | setActiveId(nearestEntry.target.id) 59 | } 60 | }, 61 | { rootMargin: '0px 0px -60% 0px' } 62 | ) 63 | 64 | elements.forEach((heading) => { 65 | const element = document.getElementById(heading.id) 66 | if (element) { 67 | observerRef.current?.observe(element) 68 | } 69 | }) 70 | } 71 | 72 | const markdownBody = document.querySelector('.markdown-body') 73 | if (markdownBody) { 74 | mutationObserverRef.current = new MutationObserver(generateHeadings) 75 | mutationObserverRef.current.observe(markdownBody, { childList: true, subtree: true }) 76 | } 77 | 78 | generateHeadings() 79 | 80 | return () => { 81 | observerRef.current?.disconnect() 82 | mutationObserverRef.current?.disconnect() 83 | } 84 | }, [content]) 85 | 86 | const handleScroll = (id: string) => { 87 | const element = document.getElementById(id) 88 | if (element) { 89 | element.scrollIntoView({ behavior: 'smooth' }) 90 | } 91 | } 92 | 93 | return ( 94 |
95 | 105 |
106 | ) 107 | } 108 | -------------------------------------------------------------------------------- /app/(main)/article/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useState, use } from 'react' 4 | import { Article } from '@prisma/client' 5 | import { Card, CardContent } from '@/components/ui/card' 6 | import { toast } from 'sonner' 7 | import { getReadingTime } from '@/lib/getReadingTime' 8 | import { Anchor } from './anchor/index' 9 | import { BytemdViewer } from '@/components/bytemd/viewer' 10 | import { Icon } from '@iconify/react' 11 | 12 | export default function Component(props: { params: Promise<{ id: string }> }) { 13 | const params = use(props.params) 14 | const [article, setArticle] = useState
() 15 | const [readingTime, setReadingTime] = useState(0) 16 | 17 | useEffect(() => { 18 | async function fetchArticleDetails() { 19 | const res = await fetch(`/api/articles/details?id=${params.id}`).then((res) => res.json()) 20 | if (res.code !== 0) { 21 | toast('获取文章详情失败!') 22 | return null 23 | } 24 | return res.data 25 | } 26 | 27 | async function getData() { 28 | const articleData = await fetchArticleDetails() 29 | if (!articleData) return 30 | 31 | setArticle(articleData) 32 | setReadingTime(getReadingTime(articleData.content).minutes) 33 | } 34 | 35 | getData() 36 | }, [params.id]) 37 | 38 | return ( 39 |
40 |
41 |

{article?.title}

42 |
43 | 阅读时间: {readingTime} 分钟 44 |
45 |
46 | 47 |
48 |
49 |

导读:

50 |

{article?.summary}

51 |
52 | 53 | 54 |
55 | 56 | 57 | 58 |

章节

59 | 60 |
61 |
62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /app/(main)/article/list/page.tsx: -------------------------------------------------------------------------------- 1 | import { Article } from '@prisma/client' 2 | import { Card } from '@/components/ui/card' 3 | import Link from 'next/link' 4 | import { Eye } from 'lucide-react' 5 | import { getJumpArticleDetailsUrl } from '@/lib/utils' 6 | import { NoFound } from '@/components/no-found' 7 | import { TimeInSeconds } from '@/lib/enums' 8 | 9 | type GroupedArticles = Record 10 | 11 | const ArticleInfo = ({ info }: { info: Article }) => { 12 | const date = new Date(info.createdAt).toLocaleDateString() 13 | 14 | return ( 15 | 20 |

{info.title}

21 | 22 |

{info.summary}

23 | 24 |
25 | 26 | 27 | 30 |
31 | 32 | ) 33 | } 34 | 35 | const ArticleList = ({ articleInfo }: { articleInfo: GroupedArticles }) => ( 36 | <> 37 | {Object.entries(articleInfo) 38 | .sort(([a], [b]) => Number(b) - Number(a)) 39 | .map(([year, articles]) => ( 40 |
41 |

{year}

42 |
43 | {articles.map((article) => ( 44 | 45 | 46 | 47 | ))} 48 |
49 |
50 | ))} 51 | 52 | ) 53 | 54 | const groupArticlesByYear = (articles: Article[]): GroupedArticles => { 55 | return articles.reduce((acc: GroupedArticles, article: Article) => { 56 | const year = new Date(article.createdAt).getFullYear().toString() 57 | acc[year] = [...(acc[year] || []), article] 58 | return acc 59 | }, {}) 60 | } 61 | 62 | async function getArticles() { 63 | try { 64 | const res = await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/articles/all`, { 65 | next: { revalidate: TimeInSeconds.oneHour } // 缓存 1小时 or 使用 cache: 'no-store' 不缓存 66 | }) 67 | 68 | if (!res.ok) { 69 | throw new Error('Failed to fetch articles') 70 | } 71 | 72 | const data = await res.json() 73 | if (data.code !== 0) { 74 | throw new Error(data.message || '获取全部文章失败!') 75 | } 76 | 77 | return groupArticlesByYear(data.data ?? []) 78 | } catch (error) { 79 | console.error('Failed to fetch articles:', error) 80 | throw error 81 | } 82 | } 83 | 84 | export default async function ArticlesPage() { 85 | let articles: GroupedArticles = {} 86 | 87 | try { 88 | articles = await getArticles() 89 | } catch (error) { 90 | return ( 91 |
92 |
93 |

94 | {error instanceof Error ? error.message : '获取全部文章失败!'} 95 |

96 |
97 |
98 | ) 99 | } 100 | 101 | return ( 102 |
103 | {Object.keys(articles).length > 0 ? : } 104 |
105 | ) 106 | } 107 | -------------------------------------------------------------------------------- /app/(main)/guestbook/AddMessage.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useState } from 'react' 2 | import { Button } from '@/components/ui/button' 3 | import { toast } from 'sonner' 4 | import { useSession } from 'next-auth/react' 5 | import { Icon } from '@iconify/react' 6 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' 7 | import { GuestbookMessage } from '@/types' 8 | import { LoginDialog } from '@/components/login-dialog' 9 | import { BytemdViewer } from '@/components/bytemd/viewer' 10 | 11 | interface MessageInputProps { 12 | message: string 13 | onChange: (value: string) => void 14 | } 15 | 16 | // 留言输入组件 17 | const MessageInput = ({ message, onChange }: MessageInputProps) => { 18 | return ( 19 | 26 | ) 27 | } 28 | 29 | // 留言预览组件 30 | const MessagePreview = ({ message }: { message: string }) => { 31 | return ( 32 |
33 | 34 |
35 | ) 36 | } 37 | 38 | interface MessageControlsProps { 39 | messageLength: number 40 | messageView: boolean 41 | onToggleView: () => void 42 | onSendMsg: () => void 43 | } 44 | 45 | function MessageControls({ 46 | messageLength, 47 | messageView, 48 | onToggleView, 49 | onSendMsg 50 | }: MessageControlsProps) { 51 | return ( 52 |
53 |

支持 Markdown 格式

54 |
55 |

{messageLength} / 1000

56 | 57 | 58 | 59 | 65 | 66 | {messageView ? '关闭预览' : '预览一下'} 67 | 68 | 69 | 70 | 71 | 72 | 73 | 79 | 80 | 发送 81 | 82 | 83 |
84 |
85 | ) 86 | } 87 | 88 | interface AddMessageProps { 89 | setMessages: Dispatch> 90 | } 91 | 92 | function AddMessage({ setMessages }: AddMessageProps) { 93 | const [showLoginDialog, setShowLoginDialog] = useState(false) 94 | 95 | const [messageView, setMessageView] = useState(false) 96 | const [message, setMessage] = useState('') 97 | const { data: session, status } = useSession() 98 | 99 | const sendMsg = async () => { 100 | if (!message.trim()) { 101 | toast('留言内容不能为空!') 102 | return 103 | } 104 | 105 | const res = await fetch('/api/guestbook', { 106 | method: 'POST', 107 | headers: { 108 | 'Content-Type': 'application/json' 109 | }, 110 | body: JSON.stringify({ 111 | content: message, 112 | userEmail: session?.user?.email 113 | }) 114 | }).then((res) => res.json()) 115 | 116 | if (res.code !== 0) { 117 | toast(res.msg) 118 | return 119 | } 120 | 121 | toast('留言成功!') 122 | 123 | setMessageView(false) 124 | 125 | setMessages((oldData) => [{ ...res.data, author: session?.user }, ...oldData]) 126 | 127 | setMessage('') 128 | } 129 | 130 | function messageChange(value: string) { 131 | if (message.length > 1000) { 132 | return 133 | } 134 | 135 | setMessage(value) 136 | } 137 | 138 | return ( 139 |
140 | {status === 'authenticated' ? ( 141 |
142 | {messageView ? ( 143 | 144 | ) : ( 145 | 146 | )} 147 | 148 | setMessageView(!messageView)} 152 | onSendMsg={sendMsg} 153 | /> 154 |
155 | ) : ( 156 | 160 | )} 161 | 162 | setShowLoginDialog(false)}> 163 |
164 | ) 165 | } 166 | 167 | export { AddMessage } 168 | export default AddMessage 169 | -------------------------------------------------------------------------------- /app/(main)/guestbook/Header.tsx: -------------------------------------------------------------------------------- 1 | export function Header() { 2 | return ( 3 |
4 |

欢迎来到我的留言板

5 |

6 | 牢记社会主义核心价值观:富强、民主、文明、和谐,自由、平等、公正、法治,爱国、敬业、诚信、友善🤔! 7 |

8 | 9 |

10 | 在这里你可以留下一些内容、对我说的话、建议、你的想法等等一切不违反中国法律的内容! 11 |

12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /app/(main)/guestbook/MessagesList.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useEffect, useState } from 'react' 2 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' 3 | import dayjs from 'dayjs' 4 | import { GuestbookMessage } from '@/types' 5 | import { BytemdViewer } from '@/components/bytemd/viewer' 6 | 7 | interface MessagesListProps { 8 | list: Array 9 | setMessages: Dispatch> 10 | } 11 | 12 | export function MessagesList({ list, setMessages }: MessagesListProps) { 13 | const [loading, setLoading] = useState(true) 14 | 15 | useEffect(() => { 16 | const getMessages = async () => { 17 | try { 18 | const res = await fetch(`/api/guestbook?page=1&pageSize=9999`).then((res) => res.json()) 19 | 20 | if (res.code === 0) { 21 | setMessages(res.data.list) 22 | } 23 | } catch (error) { 24 | console.error('Failed to fetch messages', error) 25 | } finally { 26 | setLoading(false) 27 | } 28 | } 29 | 30 | getMessages() 31 | }, [setMessages]) 32 | 33 | if (loading) { 34 | return
加载中...
35 | } 36 | 37 | return ( 38 |
39 | {list.map((message, index) => ( 40 | 41 | ))} 42 |
43 | ) 44 | } 45 | 46 | export function MessagesListItem({ info, isLast }: { info: GuestbookMessage; isLast: boolean }) { 47 | return ( 48 | <> 49 |
50 | 51 | 52 | {info?.author?.name} 53 | 54 | 55 |
56 |

57 | {info?.author?.name ?? '未知'} 58 | 59 | {dayjs(info.createdAt).locale('zh-cn').fromNow()} 60 | 61 |

62 | 63 | 64 |
65 |
66 | 67 | {!isLast &&

} 68 | 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /app/(main)/guestbook/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | import { Header } from './Header' 5 | import { AddMessage } from './AddMessage' 6 | import { MessagesList } from './MessagesList' 7 | import { GuestbookMessage } from '@/types' 8 | 9 | export default function GuestBook() { 10 | const [messages, setMessages] = useState>([]) 11 | 12 | return ( 13 |
14 |
15 | 16 | 17 | 18 | 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /app/(main)/home/ContentCard.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | 3 | interface Props { 4 | children: ReactNode 5 | title: string 6 | } 7 | 8 | export function ContentCard({ children, title }: Props) { 9 | return ( 10 |
11 |

{title}

12 | {children} 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /app/(main)/home/GithubProject.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ContentCard } from './ContentCard' 4 | import { useState, useEffect } from 'react' 5 | import { Icon } from '@iconify/react' 6 | import type { GithubPinnedRepoInfo } from '@/types/github' 7 | import { Skeleton } from '@/components/ui/skeleton' 8 | import Link from 'next/link' 9 | import { TimeInSeconds } from '@/lib/enums' 10 | 11 | function NoFound() { 12 | return ( 13 |
No repositories found.
14 | ) 15 | } 16 | 17 | function LoadingComponent() { 18 | return ( 19 |
20 | {[...Array(6)].map((_, index) => ( 21 |
22 | 23 | 24 | 25 | {index < 2 &&
} 26 |
27 | ))} 28 |
29 | ) 30 | } 31 | 32 | function ProjectInfo({ repos }: { repos: GithubPinnedRepoInfo[] }) { 33 | return ( 34 |
35 | {repos.map((repo) => ( 36 |
40 | 41 |

{repo.name}

42 |

43 | {repo.description || 'No description available'} 44 |

45 |
46 | 47 | 48 | {repo.stargazerCount} 49 | 50 | 51 | 52 | {repo.forkCount} 53 | 54 | {repo.primaryLanguage && ( 55 | 56 | 60 | {repo.primaryLanguage.name} 61 | 62 | )} 63 |
64 | 65 |
66 | ))} 67 |
68 | ) 69 | } 70 | 71 | export function GithubProject() { 72 | const [repos, setRepos] = useState([]) 73 | const [isLoading, setIsLoading] = useState(true) 74 | 75 | useEffect(() => { 76 | const loadData = async () => { 77 | try { 78 | const res = await fetch('/api/proxy/github/pinnedRepos', { 79 | next: { revalidate: TimeInSeconds.oneHour } 80 | }).then((res) => res.json()) 81 | 82 | if (res.code === 0) { 83 | setRepos(res.data) 84 | } 85 | } catch (error) { 86 | console.error('Failed to fetch GitHub data:', error) 87 | } finally { 88 | setIsLoading(false) 89 | } 90 | } 91 | loadData() 92 | }, []) 93 | 94 | return ( 95 | 96 | {isLoading ? : } 97 | 98 | {repos.length === 0 && !isLoading && } 99 | 100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /app/(main)/home/JueJinArticles.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Icon } from '@iconify/react' 4 | import { ContentCard } from './ContentCard' 5 | import { useEffect, useState } from 'react' 6 | import { Skeleton } from '@/components/ui/skeleton' 7 | import Link from 'next/link' 8 | import { Article } from '@prisma/client' 9 | import { TimeInSeconds } from '@/lib/enums' 10 | 11 | function NoFound() { 12 | return

No articles found.

13 | } 14 | 15 | function LoadingComponent() { 16 | return ( 17 |
18 | {[...Array(6)].map((_, index) => ( 19 |
20 | 21 | 22 | 23 | {index < 2 &&
} 24 |
25 | ))} 26 |
27 | ) 28 | } 29 | 30 | function ArticleInfo({ articles }: { articles: Article[] }) { 31 | return ( 32 |
33 | {articles?.map((article) => ( 34 |
38 | 43 |

{article.title}

44 |

45 | {article.summary || 'No description available'} 46 |

47 |
48 | 49 | 50 | {article.favorites} 51 | 52 | 53 | 54 | {article.likes} 55 | 56 | 57 | 58 | {article.views} 59 | 60 |
61 | 62 |
63 | ))} 64 |
65 | ) 66 | } 67 | 68 | export function JueJinArticles() { 69 | const [articles, setArticles] = useState([]) 70 | const [isLoading, setIsLoading] = useState(true) 71 | 72 | useEffect(() => { 73 | const loadData = async () => { 74 | try { 75 | const res = await fetch('/api/articles/all', { 76 | next: { revalidate: TimeInSeconds.oneHour } 77 | }).then((res) => res.json()) 78 | 79 | if (res.code === 0) { 80 | const list: Article[] = res?.data || [] 81 | const sortedData = list.sort((a, b) => b.likes - a.likes).slice(0, 6) 82 | setArticles(sortedData) 83 | } 84 | } catch (error) { 85 | console.error('Failed to fetch articles:', error) 86 | } finally { 87 | setIsLoading(false) 88 | } 89 | } 90 | loadData() 91 | }, []) 92 | 93 | return ( 94 | 95 | {isLoading ? : } 96 | 97 | {articles.length === 0 && !isLoading && } 98 | 99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /app/(main)/home/TechnologyStack.tsx: -------------------------------------------------------------------------------- 1 | import { techIcons, techStackData } from '@/lib/enums' 2 | import { Icon } from '@iconify/react' 3 | import { ContentCard } from './ContentCard' 4 | 5 | export function TechnologyStack() { 6 | return ( 7 | 8 |
9 | {techStackData.map((tech) => ( 10 |
14 | 18 | {tech} 19 |
20 | ))} 21 |
22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /app/(main)/home/UserProfile.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Skeleton } from '@/components/ui/skeleton' 4 | import { Icon } from '@iconify/react' 5 | import { useState, useEffect } from 'react' 6 | import type { JuejinUserInfo } from '@/types/juejin' 7 | import type { GithubUserInfo } from '@/types/github' 8 | import Link from 'next/link' 9 | import Image from 'next/image' 10 | import userIcon from '@/public/user-icon.png' 11 | import { TimeInSeconds } from '@/lib/enums' 12 | 13 | // 社交媒体链接配置 14 | const SOCIAL_LINKS = { 15 | juejin: { 16 | url: 'https://juejin.cn/user/712139266339694', 17 | icon: 'simple-icons:juejin', 18 | label: '掘金' 19 | }, 20 | github: { 21 | url: 'https://github.com/vaebe', 22 | icon: 'mdi:github', 23 | label: 'GitHub' 24 | } 25 | } as const 26 | 27 | // 统计项组件 28 | const StatItem = ({ icon, label, value }: { icon: string; label: string; value?: number }) => ( 29 |
30 | 31 | {label}: 32 | {value ?? '-'} 33 |
34 | ) 35 | 36 | interface SocialStatsSectionProps { 37 | platform: keyof typeof SOCIAL_LINKS 38 | stats: { icon: string; label: string; value?: number }[] 39 | } 40 | 41 | // 社交媒体统计区块 42 | const SocialStatsSection = ({ platform, stats }: SocialStatsSectionProps) => ( 43 |
44 | 50 |

51 | 52 | {SOCIAL_LINKS[platform].label} 53 |

54 | 55 |
56 | {stats.map((stat, index) => ( 57 | 58 | ))} 59 |
60 |
61 | ) 62 | 63 | // 加载骨架屏组件 64 | const SkeletonStatItem = () => ( 65 |
66 | 67 | 68 | 69 |
70 | ) 71 | 72 | const UserProfileSkeleton = () => ( 73 |
74 |
75 | 76 |
77 | 78 | 79 |
80 |
81 | 82 |
83 | {[0, 1].map((section) => ( 84 |
85 | 86 |
87 | {[0, 1, 2].map((item) => ( 88 | 89 | ))} 90 |
91 |
92 | ))} 93 |
94 |
95 | ) 96 | 97 | // 主要用户信息组件 98 | const UserInfo = ({ 99 | githubUserInfo, 100 | description 101 | }: { 102 | githubUserInfo?: GithubUserInfo 103 | description: string 104 | }) => ( 105 |
106 | {`${githubUserInfo?.login}'s 115 |
116 |

117 | {githubUserInfo?.login ?? 'Loading...'} 118 |

119 |

{description}

120 |
121 |
122 | ) 123 | 124 | // 自定义Hook: 获取用户数据 125 | const useUserData = () => { 126 | const [data, setData] = useState<{ 127 | github?: GithubUserInfo 128 | juejin?: JuejinUserInfo 129 | isLoading: boolean 130 | error?: string 131 | }>({ 132 | isLoading: true 133 | }) 134 | 135 | useEffect(() => { 136 | const fetchData = async () => { 137 | try { 138 | const [githubRes, juejinRes] = await Promise.all([ 139 | fetch('/api/proxy/github/userInfo', { next: { revalidate: TimeInSeconds.oneHour } }), 140 | fetch('/api/proxy/juejin/userInfo', { next: { revalidate: TimeInSeconds.oneHour } }) 141 | ]) 142 | 143 | const [githubData, juejinData] = await Promise.all([githubRes.json(), juejinRes.json()]) 144 | 145 | setData({ 146 | github: githubData.code === 0 ? githubData.data : undefined, 147 | juejin: juejinData.code === 0 ? juejinData.data : undefined, 148 | isLoading: false 149 | }) 150 | } catch (error) { 151 | setData((prev) => ({ 152 | ...prev, 153 | isLoading: false, 154 | error: 'Failed to fetch user data' 155 | })) 156 | console.error('Error fetching user data:', error) 157 | } 158 | } 159 | 160 | fetchData() 161 | }, []) 162 | 163 | return data 164 | } 165 | 166 | const Userdescription = ` 167 | 我是 Vaebe,一名全栈开发者,专注于前端技术。 168 | 我的主要技术栈是 Vue 全家桶,目前也在使用 React 来构建项目,比如这个博客它使用 Next.js。 169 | 我会将自己的实践过程以文章的形式分享在掘金上,并在 GitHub上参与开源项目,不断提升自己的编程技能。 170 | 欢迎访问我的掘金主页和 GitHub主页,了解更多关于我的信息!` 171 | 172 | // 主组件 173 | export function UserProfile() { 174 | const { github: githubUserInfo, juejin: juejinUserInfo, isLoading, error } = useUserData() 175 | 176 | if (error) { 177 | return
Error: {error}
178 | } 179 | 180 | return ( 181 | <> 182 | {isLoading ? ( 183 | 184 | ) : ( 185 |
186 | 187 | 188 |
189 | 201 | 202 | 218 |
219 |
220 | )} 221 | 222 | ) 223 | } 224 | -------------------------------------------------------------------------------- /app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import LayoutHeader from '@/components/layout/header' 2 | import LayoutFooter from '@/components/layout/footer' 3 | import { ThemeSwitch } from '@/components/theme-switch' 4 | import { Artdots } from '@/components/artdots' 5 | 6 | export default function BaseLayout({ children }: { children: React.ReactNode }) { 7 | return ( 8 | <> 9 | 10 |
{children}
11 | 12 | 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /app/(main)/page.tsx: -------------------------------------------------------------------------------- 1 | import { JueJinArticles } from './home/JueJinArticles' 2 | import { GithubProject } from './home/GithubProject' 3 | import { UserProfile } from './home/UserProfile' 4 | import { TechnologyStack } from './home/TechnologyStack' 5 | 6 | export default function About() { 7 | return ( 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /app/api/articles/add/route.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'crypto' 2 | import { sendJson } from '@/lib/utils' 3 | import { prisma } from '@/prisma' 4 | 5 | export async function POST(req: Request) { 6 | try { 7 | const body = await req.json() 8 | const { title, content, classify, coverImg, summary } = body 9 | 10 | const newArticle = await prisma.article.create({ 11 | data: { 12 | id: randomUUID().replaceAll('-', ''), 13 | title, 14 | content, 15 | classify, 16 | coverImg, 17 | summary, 18 | status: '01', 19 | source: '00', 20 | userId: 1 // 示例,通常从认证信息中获取用户ID 21 | } 22 | }) 23 | return sendJson({ data: newArticle }) 24 | } catch (error) { 25 | sendJson({ code: -1, msg: `创建文章失败:${error}` }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/api/articles/all/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson } from '@/lib/utils' 2 | import { prisma } from '@/prisma' 3 | 4 | export async function GET() { 5 | try { 6 | const articles = await prisma.article.findMany({ 7 | orderBy: { createdAt: 'desc' } 8 | }) 9 | 10 | return sendJson({ data: articles }) 11 | } catch (error) { 12 | return sendJson({ code: -1, msg: `获取所有文章失败:${error}` }) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/api/articles/articleCountByMonth/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson } from '@/lib/utils' 2 | import { prisma } from '@/prisma' 3 | 4 | export async function GET() { 5 | try { 6 | const list = await prisma.$queryRaw<{ month: string; count: number }[]>` 7 | SELECT DATE_FORMAT(createdAt, '%Y-%m') AS month, COUNT(*) AS count 8 | FROM article 9 | WHERE isDeleted = 0 10 | GROUP BY month 11 | ORDER BY month ASC; 12 | ` 13 | 14 | const formattedCounts = list.map((item) => ({ 15 | month: item.month, 16 | count: Number(item.count) 17 | })) 18 | 19 | return sendJson({ data: formattedCounts }) 20 | } catch (error) { 21 | return sendJson({ code: -1, msg: `按月查询文章数量失败:${error}` }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/api/articles/delete/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson } from '@/lib/utils' 2 | import { prisma } from '@/prisma' 3 | 4 | export async function DELETE(req: Request) { 5 | try { 6 | const { id } = await req.json() 7 | 8 | await prisma.article.delete({ 9 | where: { id } 10 | }) 11 | 12 | return sendJson({ msg: 'success' }) 13 | } catch (error) { 14 | return sendJson({ code: -1, msg: `删除文章失败:${error}` }) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/api/articles/details/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson } from '@/lib/utils' 2 | import { prisma } from '@/prisma' 3 | 4 | export async function GET(req: Request) { 5 | const url = new URL(req.url) 6 | const id = url.searchParams.get('id') // 从查询参数中获取 id 7 | 8 | try { 9 | if (!id) { 10 | return sendJson({ code: -1, msg: 'id 不存在' }) 11 | } 12 | 13 | const article = await prisma.article.findUnique({ 14 | where: { id: id } 15 | }) 16 | 17 | return sendJson({ data: article }) 18 | } catch (error) { 19 | console.error(error) 20 | return sendJson({ code: -1, msg: '获取文章详情失败!' }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/api/articles/list/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson } from '@/lib/utils' 2 | import { prisma } from '@/prisma' 3 | 4 | export async function GET(req: Request) { 5 | try { 6 | // 从 URL 获取查询参数 7 | const { searchParams } = new URL(req.url) 8 | const page = parseInt(searchParams.get('page') || '1') 9 | const pageSize = parseInt(searchParams.get('pageSize') || '10') 10 | const searchTerm = searchParams.get('searchTerm') || '' 11 | 12 | const skip = (page - 1) * pageSize 13 | 14 | // 查询带有分页和模糊检索的文章 15 | const articles = await prisma.article.findMany({ 16 | where: { 17 | title: { 18 | contains: searchTerm 19 | } 20 | }, 21 | orderBy: { createdAt: 'desc' }, 22 | skip: skip, 23 | take: pageSize 24 | }) 25 | 26 | // 获取文章总数,用于前端分页 27 | const totalArticles = await prisma.article.count({ 28 | where: { 29 | title: { 30 | contains: searchTerm 31 | } 32 | } 33 | }) 34 | 35 | return sendJson({ 36 | data: { 37 | articles, 38 | totalArticles, 39 | currentPage: page, 40 | totalPages: Math.ceil(totalArticles / pageSize) 41 | } 42 | }) 43 | } catch (error) { 44 | return sendJson({ code: -1, msg: `获取文章列表失败:${error}` }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/api/articles/syncJuejinArticles/route.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import { sendJson } from '@/lib/utils' 3 | import { prisma } from '@/prisma' 4 | import { AnyObject } from '@/types' 5 | 6 | async function addArticle(info: AnyObject) { 7 | const { 8 | article_id, 9 | title, 10 | cover_image, 11 | brief_content, 12 | view_count, 13 | ctime, 14 | collect_count, 15 | digg_count 16 | } = info.article_info 17 | 18 | const data = { 19 | title: title, 20 | content: '', 21 | classify: '', 22 | coverImg: cover_image, 23 | summary: brief_content, 24 | status: '', 25 | source: '01', 26 | userId: 1, 27 | views: view_count, 28 | likes: digg_count, 29 | favorites: collect_count, 30 | createdAt: dayjs(ctime * 1000).toDate(), 31 | updatedAt: dayjs(ctime * 1000).toDate() 32 | } 33 | 34 | // 存在则更新 否则新增 35 | await prisma.article.upsert({ 36 | where: { id: article_id }, 37 | update: data, 38 | create: { 39 | id: article_id, 40 | ...data 41 | } 42 | }) 43 | } 44 | 45 | let syncArticleNameList: string[] = [] 46 | 47 | async function getArticles(index: number) { 48 | const res = await fetch( 49 | `${process.env.NEXT_PUBLIC_SITE_URL}/api/proxy/juejin/articles?cursor=${index}` 50 | ).then((res) => res.json()) 51 | 52 | if (res?.code !== 0) { 53 | throw new Error('同步掘金文章失败!') 54 | } 55 | 56 | const info = res.data 57 | 58 | for (const item of info.data) { 59 | addArticle(item) 60 | 61 | syncArticleNameList.push(item.article_info.title) 62 | } 63 | 64 | const nextIndex = index + 10 65 | 66 | // 是否还有更多文章 67 | if (info.has_more) { 68 | await getArticles(nextIndex) 69 | } 70 | } 71 | 72 | export async function GET(req: Request) { 73 | syncArticleNameList = [] 74 | 75 | const apiKey = req.headers.get('x-api-key') 76 | const expectedApiKey = process.env.GITHUB_REPOSITORY_API_KEY 77 | 78 | // 验证 API 密钥 79 | if (!apiKey || apiKey !== expectedApiKey) { 80 | return sendJson({ code: -1, msg: '无效的 API 密钥' }) 81 | } 82 | 83 | try { 84 | const index = 0 85 | 86 | await getArticles(index) 87 | 88 | console.log(syncArticleNameList) 89 | 90 | return sendJson({ data: syncArticleNameList, msg: '同步掘金文章成功' }) 91 | } catch (error) { 92 | return sendJson({ code: -1, msg: `同步掘金文章失败:${error}` }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /app/api/articles/update/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson } from '@/lib/utils' 2 | import { prisma } from '@/prisma' 3 | 4 | export async function PUT(req: Request) { 5 | try { 6 | const body = await req.json() 7 | const { id, title, content, classify, coverImg, summary, status } = body 8 | 9 | const updatedArticle = await prisma.article.update({ 10 | where: { id }, 11 | data: { 12 | title, 13 | content, 14 | classify, 15 | coverImg, 16 | summary, 17 | status 18 | } 19 | }) 20 | 21 | return sendJson({ data: updatedArticle }) 22 | } catch (error) { 23 | console.error(error) 24 | return sendJson({ code: -1, msg: '更新文章数据失败!' }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/authOptions.ts: -------------------------------------------------------------------------------- 1 | import type { AuthOptions } from 'next-auth' 2 | import GitHubProvider from 'next-auth/providers/github' 3 | import CredentialsProvider from 'next-auth/providers/credentials' 4 | import EmailProvider from 'next-auth/providers/email' 5 | import { PrismaAdapter } from '@auth/prisma-adapter' 6 | import { prisma } from '@/prisma/index' 7 | import { AnyObject } from '@/types' 8 | import { v4 as uuidv4 } from 'uuid' 9 | 10 | const AUTH_GITHUB_CLIENT_ID = process.env.AUTH_GITHUB_CLIENT_ID 11 | const AUTH_GITHUB_CLIENT_SECRET = process.env.AUTH_GITHUB_CLIENT_SECRET 12 | 13 | // 更新用户头像 14 | async function updateUserProfilePicture(user?: AnyObject) { 15 | if (!user) { 16 | return 17 | } 18 | 19 | try { 20 | await prisma.user.update({ 21 | where: { 22 | id: user.id 23 | }, 24 | data: { 25 | image: user.image 26 | } 27 | }) 28 | } catch (error) { 29 | console.error(error) 30 | } 31 | } 32 | 33 | export const authOptions: AuthOptions = { 34 | adapter: PrismaAdapter(prisma), 35 | providers: [ 36 | CredentialsProvider({ 37 | name: 'Credentials', 38 | credentials: { 39 | account: { label: 'Account', type: 'text' }, 40 | password: { label: 'Password', type: 'password' } 41 | }, 42 | async authorize(credentials) { 43 | try { 44 | const user = await prisma.user.findUnique({ 45 | where: { email: credentials?.account, password: credentials?.password } 46 | }) 47 | 48 | return { 49 | id: user!.id, 50 | name: user?.name, 51 | email: user?.email, 52 | image: user?.image, 53 | role: user?.role 54 | } 55 | } catch (error) { 56 | console.error('授权过程中发生错误:', error) 57 | } 58 | 59 | return null 60 | } 61 | }), 62 | GitHubProvider({ 63 | clientId: AUTH_GITHUB_CLIENT_ID ?? '', 64 | clientSecret: AUTH_GITHUB_CLIENT_SECRET ?? '', 65 | httpOptions: { 66 | timeout: 10000 // 将超时时间设置为10秒(10000毫秒) 67 | } 68 | }), 69 | EmailProvider({ 70 | server: { 71 | host: process.env.EMAIL_SERVER_HOST, 72 | port: parseInt(process.env.EMAIL_SERVER_PORT as string), 73 | auth: { 74 | user: process.env.EMAIL_SERVER_USER, 75 | pass: process.env.EMAIL_SERVER_PASSWORD 76 | } 77 | }, 78 | from: process.env.EMAIL_FROM 79 | }) 80 | ], 81 | pages: { 82 | signIn: '/', 83 | verifyRequest: '/auth/verify-request' 84 | }, 85 | session: { 86 | strategy: 'jwt', 87 | maxAge: 24 * 60 * 60 // 过期时间, 88 | }, 89 | secret: process.env.NEXTAUTH_SECRET, 90 | callbacks: { 91 | async signIn({ account, user }) { 92 | // todo 邮箱登录没有头像则设置一个默认头像 93 | if (account?.type === 'email' && !user?.image) { 94 | user.image = `https://api.dicebear.com/7.x/bottts-neutral/svg?seed=${uuidv4().replaceAll('-', '')}&size=64` 95 | updateUserProfilePicture(user) 96 | } 97 | return true 98 | }, 99 | async redirect({ baseUrl }) { 100 | return baseUrl 101 | }, 102 | async jwt({ token, user }) { 103 | // 如果 user 存在,存储角色信息 104 | if (user) { 105 | token.role = user.role // 将角色存储到 token 中 106 | token.id = user.id // 将用户 id 存储到 token 中 107 | } 108 | return token 109 | }, 110 | async session({ session, token }) { 111 | if (token?.role) { 112 | session.user.role = token.role as string 113 | } 114 | 115 | if (token?.id) { 116 | session.user.id = token.id as string 117 | } 118 | return session 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth' 2 | import { authOptions } from './authOptions' 3 | 4 | const handler = NextAuth(authOptions) 5 | export { handler as GET, handler as POST } 6 | -------------------------------------------------------------------------------- /app/api/feed.xml/route.ts: -------------------------------------------------------------------------------- 1 | import RSS from 'rss' 2 | import { Article } from '@prisma/client' 3 | 4 | export async function GET() { 5 | const NEXT_PUBLIC_SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? '' 6 | 7 | const feed = new RSS({ 8 | title: 'vaebe blog | 开发者', 9 | description: 10 | '我是 Vaebe,一名全栈开发者,专注于前端技术。我的主要技术栈是 Vue 及其全家桶,目前也在使用 React 来构建项目,比如这个博客,它使用 Next.js。', 11 | site_url: NEXT_PUBLIC_SITE_URL ?? '', 12 | feed_url: `${NEXT_PUBLIC_SITE_URL}/feed.xml`, 13 | language: 'zh-CN', // 网站语言代码 14 | image_url: `${NEXT_PUBLIC_SITE_URL}/og/opengraph-image.png` // 放一个叫 opengraph-image.png 的1200x630尺寸的图片到你的 app 目录下即可 15 | }) 16 | 17 | const res = await fetch(`${NEXT_PUBLIC_SITE_URL}/api/articles/all`) 18 | 19 | if (!res.ok) throw new Error('Network response was not ok') 20 | 21 | const data = await res.json() 22 | 23 | if (data.code !== 0) { 24 | return new Response(feed.xml(), { 25 | headers: { 26 | 'content-type': 'application/xml' 27 | } 28 | }) 29 | } 30 | 31 | data.data.forEach((post: Article) => { 32 | feed.item({ 33 | title: post.title, // 文章名 34 | guid: post.id, // 文章 ID 35 | url: `${NEXT_PUBLIC_SITE_URL}/article/${post.id}`, 36 | description: post.summary, // 文章的介绍 37 | date: new Date(post.createdAt).toISOString(), // 文章的发布时间 38 | enclosure: { 39 | url: post.coverImg ?? '' 40 | } 41 | }) 42 | }) 43 | 44 | return new Response(feed.xml(), { 45 | headers: { 46 | 'content-type': 'application/xml' 47 | } 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /app/api/guestbook/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson } from '@/lib/utils' 2 | import { prisma } from '@/prisma' 3 | 4 | // 添加留言 5 | export async function POST(req: Request) { 6 | try { 7 | const body = await req.json() 8 | const { content, userEmail } = body 9 | 10 | if (!content) { 11 | return sendJson({ code: -1, msg: '留言内容不能为空!' }) 12 | } 13 | 14 | if (!userEmail) { 15 | return sendJson({ code: -1, msg: '用户邮箱不能为空!' }) 16 | } 17 | 18 | const message = await prisma.message.create({ 19 | data: { 20 | content, 21 | author: { connect: { email: userEmail } } 22 | } 23 | }) 24 | return sendJson({ data: message }) 25 | } catch { 26 | return sendJson({ code: -1, msg: '添加留言失败!' }) 27 | } 28 | } 29 | 30 | // 查询留言列表 31 | export async function GET(req: Request) { 32 | const { searchParams } = new URL(req.url) 33 | const page = parseInt(searchParams.get('page') || '1') 34 | const pageSize = parseInt(searchParams.get('pageSize') || '10') 35 | 36 | const skip = (page - 1) * pageSize 37 | 38 | try { 39 | const list = await prisma.message.findMany({ 40 | include: { 41 | author: { 42 | select: { 43 | name: true, 44 | email: true, 45 | image: true 46 | } 47 | } 48 | }, 49 | orderBy: { 50 | createdAt: 'desc' 51 | }, 52 | skip: skip, 53 | take: pageSize 54 | }) 55 | 56 | const total = await prisma.message.count() 57 | 58 | return sendJson({ 59 | data: { 60 | list, 61 | total, 62 | currentPage: page, 63 | totalPages: Math.ceil(total / pageSize) 64 | } 65 | }) 66 | } catch (error) { 67 | console.error(error) 68 | return sendJson({ code: -1, msg: '获取留言信息失败' }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/api/imagekit/getFileInfoByHash/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson } from '@/lib/utils' 2 | 3 | // 根据 hash 获取图片信息 4 | export async function GET(req: Request) { 5 | const url = new URL(req.url) 6 | const fileHash = url.searchParams.get('hash') 7 | 8 | try { 9 | if (!fileHash) { 10 | return sendJson({ code: -1, msg: '文件 hash 不存在' }) 11 | } 12 | 13 | const query = { 14 | type: 'file', 15 | searchQuery: `"customMetadata.md5" = "${fileHash}"`, 16 | limit: '1' 17 | } 18 | 19 | const url = `https://api.imagekit.io/v1/files?${new URLSearchParams(query).toString()}` 20 | 21 | const res = await fetch(url, { 22 | method: 'GET', 23 | headers: { 24 | 'Content-Type': 'application/json', 25 | Authorization: `Basic ${btoa(process.env.IMAGEKIT_PRIVATE_KEY + ':')}` 26 | } 27 | }).then((res) => res.json()) 28 | 29 | if (!Array.isArray(res) || res.length === 0) { 30 | return sendJson({ data: [] }) 31 | } 32 | 33 | return sendJson({ data: res }) 34 | } catch (error) { 35 | console.error(error) 36 | return sendJson({ code: -1, msg: 'Failed to fetch article' }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/api/imagekit/getToken/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson } from '@/lib/utils' 2 | import jwt from 'jsonwebtoken' 3 | 4 | // 添加留言 5 | export async function POST(req: Request) { 6 | try { 7 | const body = await req.json() 8 | const { payload } = body 9 | 10 | const privateKey = process.env.IMAGEKIT_PRIVATE_KEY ?? '' 11 | 12 | const token = jwt.sign(payload.uploadPayload, privateKey, { 13 | expiresIn: 60, // token 过期时间最大 3600 秒 14 | header: { 15 | alg: 'HS256', 16 | typ: 'JWT', 17 | kid: process.env.NEXT_PUBLIC_IMAGEKIT_PUBLIC_KEY 18 | } 19 | }) 20 | 21 | return sendJson({ data: token }) 22 | } catch (err) { 23 | console.error(err) 24 | return sendJson({ code: -1, msg: '获取上传 token 失败!' }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/api/proxy/github/pinnedRepos/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson } from '@/lib/utils' 2 | 3 | const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql' 4 | 5 | const buildQuery = (username: string) => ` 6 | { 7 | user(login: "${username}") { 8 | pinnedItems(first: 6, types: [REPOSITORY]) { 9 | totalCount 10 | edges { 11 | node { 12 | ... on Repository { 13 | id 14 | name 15 | description 16 | url 17 | stargazerCount 18 | forkCount 19 | primaryLanguage { 20 | name 21 | color 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } 29 | ` 30 | 31 | async function fetchPinnedRepos() { 32 | const query = buildQuery(process.env.NEXT_PUBLIC_GITHUB_USER_NAME!) 33 | 34 | const response = await fetch(GITHUB_GRAPHQL_URL, { 35 | method: 'POST', 36 | headers: { 37 | 'Content-Type': 'application/json', 38 | Authorization: `Bearer ${process.env.GITHUB_API_TOKEN}` 39 | }, 40 | body: JSON.stringify({ query }) 41 | }) 42 | 43 | if (!response.ok) { 44 | const errorMessage = await response.text() 45 | throw new Error(`获取 GitHub 仓库信息失败: ${response.statusText} - ${errorMessage}`) 46 | } 47 | 48 | const res = await response.json() 49 | return ( 50 | res?.data?.user?.pinnedItems?.edges?.map( 51 | (edge: Record) => edge.node 52 | ) || [] 53 | ) 54 | } 55 | 56 | export async function GET() { 57 | try { 58 | const pinnedRepos = await fetchPinnedRepos() 59 | return sendJson({ data: pinnedRepos }) 60 | } catch (error) { 61 | console.error(error) 62 | return sendJson({ code: -1, msg: '获取 GitHub 仓库信息失败!' }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/api/proxy/github/userInfo/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson } from '@/lib/utils' 2 | 3 | const GITHUB_API_URL = `https://api.github.com/users/${process.env.NEXT_PUBLIC_GITHUB_USER_NAME}` 4 | 5 | async function fetchUserInfo() { 6 | const response = await fetch(GITHUB_API_URL, { 7 | headers: { 8 | Accept: 'application/vnd.github.v3+json', 9 | Authorization: `Bearer ${process.env.GITHUB_API_TOKEN}` 10 | } 11 | }) 12 | 13 | if (!response.ok) { 14 | const errorMessage = await response.text() 15 | throw new Error(`获取 GitHub 用户信息失败: ${response.statusText} - ${errorMessage}`) 16 | } 17 | 18 | return response.json() 19 | } 20 | 21 | export async function GET() { 22 | try { 23 | const userInfo = await fetchUserInfo() 24 | return sendJson({ data: userInfo }) 25 | } catch (error) { 26 | console.error(error) 27 | return sendJson({ code: -1, msg: '获取 GitHub 用户信息失败!' }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/api/proxy/juejin/articles/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson } from '@/lib/utils' 2 | 3 | export async function GET(req: Request) { 4 | try { 5 | // 从 URL 获取查询参数 6 | const { searchParams } = new URL(req.url) 7 | const cursor = parseInt(searchParams.get('cursor') || '0') 8 | 9 | const res = await fetch('https://api.juejin.cn/content_api/v1/article/query_list', { 10 | method: 'POST', 11 | headers: { 12 | 'Content-Type': 'application/json' 13 | }, 14 | body: JSON.stringify({ 15 | user_id: process.env.JUEJIN_USER_ID, 16 | sort_type: 2, 17 | cursor: `${cursor}` 18 | }) 19 | }) 20 | 21 | const data = await res.json() 22 | return sendJson({ data }) 23 | } catch (error) { 24 | return sendJson({ code: -1, msg: `${error}` }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/api/proxy/juejin/userInfo/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson } from '@/lib/utils' 2 | 3 | const JUEJIN_API_URL = `https://api.juejin.cn/user_api/v1/user/get?user_id=${process.env.JUEJIN_USER_ID}` 4 | 5 | // 获取掘金用户信息 6 | async function fetchUserInfo() { 7 | const response = await fetch(JUEJIN_API_URL) 8 | 9 | if (!response.ok) { 10 | const errorInfo = await response.json() 11 | throw new Error(errorInfo.msg || 'Failed to fetch user info from Juejin') 12 | } 13 | const info = await response.json() 14 | 15 | if (info.err_no !== 0) { 16 | throw new Error(info.err_msg) 17 | } 18 | return info.data 19 | } 20 | 21 | export async function GET() { 22 | try { 23 | const userInfo = await fetchUserInfo() 24 | return sendJson({ data: userInfo }) 25 | } catch (error) { 26 | console.error(error) 27 | return sendJson({ code: -1, msg: '获取掘金用户信息失败!' }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/api/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson } from '@/lib/utils' 2 | 3 | export async function GET() { 4 | return sendJson({ data: 'hello world' }) 5 | } 6 | -------------------------------------------------------------------------------- /app/api/sendEmail/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson, emailRegex } from '@/lib/utils' 2 | import { prisma } from '@/prisma' 3 | import nodemailer from 'nodemailer' 4 | 5 | // 添加留言 6 | export async function POST(req: Request) { 7 | try { 8 | const body = await req.json() 9 | const { toEmail, subject, text, html } = body 10 | 11 | // 检查邮箱格式是否正确 12 | if (!emailRegex.test(toEmail)) { 13 | return sendJson({ code: -1, msg: '邮箱格式不正确' }) 14 | } 15 | 16 | if (!subject || !(text || html)) { 17 | return sendJson({ code: -1, msg: '参数不正确' }) 18 | } 19 | 20 | // 先查询邮箱是否已经存在 21 | const existingEmail = await prisma.subscriber.findUnique({ 22 | where: { 23 | email: toEmail 24 | } 25 | }) 26 | 27 | if (existingEmail) { 28 | return sendJson({ code: -1, msg: '邮箱已订阅' }) 29 | } 30 | 31 | const transporter = nodemailer.createTransport({ 32 | host: process.env.EMAIL_SERVER_HOST, 33 | port: parseInt(process.env.EMAIL_SERVER_PORT as string), 34 | secure: true, // 465 端口为 true 其他为 false 35 | auth: { 36 | user: process.env.EMAIL_SERVER_USER, // 你的QQ邮箱 37 | pass: process.env.EMAIL_SERVER_PASSWORD // QQ邮箱授权码,不是QQ密码 38 | } 39 | }) 40 | 41 | // 邮件内容 42 | const mailData = { 43 | from: process.env.EMAIL_FROM, 44 | to: toEmail, // 收件人邮箱 45 | subject: subject, // 邮件主题 46 | text: text, // 纯文本内容 47 | html: html // HTML内容 48 | } 49 | 50 | await transporter.sendMail(mailData) 51 | 52 | // 发送邮件成功后,将邮箱保存到数据库 53 | await prisma.subscriber.create({ 54 | data: { 55 | email: toEmail 56 | } 57 | }) 58 | 59 | return sendJson({ msg: '邮件发送成功' }) 60 | } catch (error) { 61 | return sendJson({ code: -1, msg: `发送邮件失败:${error}` }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/api/user/details/route.ts: -------------------------------------------------------------------------------- 1 | import { sendJson } from '@/lib/utils' 2 | import { prisma } from '@/prisma' 3 | 4 | export async function GET(req: Request) { 5 | const url = new URL(req.url) 6 | const account = url.searchParams.get('account') 7 | 8 | try { 9 | if (!account) { 10 | return sendJson({ code: -1, msg: '账号不存在' }) 11 | } 12 | 13 | const user = await prisma.user.findUnique({ 14 | where: { email: account } 15 | }) 16 | 17 | return sendJson({ data: user }) 18 | } catch (error) { 19 | console.log('获取用户详情失败', error) 20 | return sendJson({ code: -1, msg: `获取用户详情失败` }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/article/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button' 2 | import { Input } from '@/components/ui/input' 3 | import { PublishArticleInfo } from '@/types' 4 | import type { Dispatch, SetStateAction } from 'react' 5 | import { Updater } from 'use-immer' 6 | 7 | interface HeaderProps { 8 | updateArticleInfo: Updater 9 | setPublishDialogShow: Dispatch> 10 | title: string 11 | butName: string 12 | } 13 | 14 | function HeaderComponent({ updateArticleInfo, setPublishDialogShow, title, butName }: HeaderProps) { 15 | return ( 16 |
17 | 20 | updateArticleInfo((d) => { 21 | d.title = e.target.value 22 | }) 23 | } 24 | placeholder="输入文章标题..." 25 | className="bg-white text-xl text-black font-bold border-none rounded-none shadow-none ring-0 !ring-offset-0 focus-visible:ring-0" 26 | /> 27 | 30 |
31 | ) 32 | } 33 | 34 | export { HeaderComponent } 35 | export default HeaderComponent 36 | -------------------------------------------------------------------------------- /app/article/PublishDialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ChangeEvent, memo } from 'react' 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogHeader, 8 | DialogTitle, 9 | DialogFooter, 10 | DialogDescription 11 | } from '@/components/ui/dialog' 12 | import { Button } from '@/components/ui/button' 13 | import { Label } from '@/components/ui/label' 14 | import { Textarea } from '@/components/ui/textarea' 15 | import { toast } from 'sonner' 16 | import { PublishArticleInfo } from '@/types' 17 | import { Updater } from 'use-immer' 18 | import Image from 'next/image' 19 | import { imagekitUploadFile } from '@/lib/imagekit' 20 | 21 | const CATEGORIES = ['后端', '前端', 'Android', 'iOS', '人工智能', '阅读'] as const 22 | type Category = (typeof CATEGORIES)[number] 23 | 24 | interface ImageUploadProps { 25 | coverImg?: string 26 | onImageUpload: (event: ChangeEvent) => Promise 27 | } 28 | 29 | const ImageUpload = memo(({ coverImg, onImageUpload }: ImageUploadProps) => ( 30 |
31 | 32 |
33 |
34 | {coverImg && ( 35 | Cover 43 | )} 44 |
45 | 46 |
47 | 60 |
61 |
62 |

建议尺寸: 192*128px (封面仅展示在首页信息流中)

63 |
64 | )) 65 | 66 | // 当使用 React DevTools 调试应用时,它会显示组件的名称 67 | // 当组件发生错误时,React 会在错误信息中包含组件名 68 | ImageUpload.displayName = 'ImageUpload' 69 | 70 | interface CategorySelectorProps { 71 | selectedCategory?: Category 72 | onCategorySelect: (category: Category) => void 73 | } 74 | const CategorySelector = memo(({ selectedCategory, onCategorySelect }: CategorySelectorProps) => ( 75 |
76 | 79 |
80 | {CATEGORIES.map((cat) => ( 81 | 89 | ))} 90 |
91 |
92 | )) 93 | 94 | CategorySelector.displayName = 'CategorySelector' 95 | 96 | interface PublishDialogProps { 97 | isOpen: boolean 98 | articleInfo: PublishArticleInfo 99 | updateArticleInfo: Updater 100 | onClose: () => void 101 | onPublish: () => void 102 | } 103 | 104 | export function PublishDialog({ 105 | isOpen, 106 | articleInfo, 107 | updateArticleInfo, 108 | onClose, 109 | onPublish 110 | }: PublishDialogProps) { 111 | const validateForm = (): boolean => { 112 | if (!articleInfo.classify) { 113 | toast('文章分类不能为空!') 114 | return false 115 | } 116 | 117 | if (!articleInfo.summary?.trim()) { 118 | toast('文章简介不能为空!') 119 | return false 120 | } 121 | 122 | return true 123 | } 124 | 125 | const handlePublish = () => { 126 | if (validateForm()) { 127 | onPublish() 128 | } 129 | } 130 | 131 | const handleImageUpload = async (event: ChangeEvent) => { 132 | try { 133 | const file = event.target.files?.[0] 134 | if (!file) return 135 | 136 | const res = await imagekitUploadFile({ file, fileName: file.name }) 137 | 138 | if (res?.code === 0 && res.data?.url) { 139 | updateArticleInfo((draft) => { 140 | draft.coverImg = res.data.url ?? '' 141 | 142 | console.log(res.data) 143 | }) 144 | } else { 145 | toast('图片上传失败!') 146 | } 147 | } catch { 148 | toast('图片上传失败,似乎遇到了一些什么问题!') 149 | } finally { 150 | event.target.value = '' 151 | } 152 | } 153 | 154 | const handleCategorySelect = (category: Category) => { 155 | updateArticleInfo((draft) => { 156 | draft.classify = category 157 | }) 158 | } 159 | 160 | return ( 161 | 162 | 163 | 164 | 发布文章 165 | 请填写文章的必要信息以完成发布 166 | 167 | 168 |
169 | 173 | 174 |
175 | 178 |