├── public
├── TANSHI.jpg
├── user-icon.png
├── favicon
│ ├── favicon.ico
│ ├── favicon-96x96.png
│ ├── apple-touch-icon.png
│ ├── web-app-manifest-192x192.png
│ ├── web-app-manifest-512x512.png
│ ├── site.webmanifest
│ └── favicon.svg
├── og
│ └── opengraph-image.png
├── dark-logo.svg
├── light-logo.svg
├── grid-black.svg
└── grid.svg
├── .prettierignore
├── commitlint.config.mjs
├── postcss.config.mjs
├── app
├── api
│ ├── route.ts
│ ├── auth
│ │ └── [...nextauth]
│ │ │ ├── route.ts
│ │ │ └── authOptions.ts
│ ├── articles
│ │ ├── all
│ │ │ └── route.ts
│ │ ├── delete
│ │ │ └── route.ts
│ │ ├── details
│ │ │ └── route.ts
│ │ ├── update
│ │ │ └── route.ts
│ │ ├── articleCountByMonth
│ │ │ └── route.ts
│ │ ├── add
│ │ │ └── route.ts
│ │ └── list
│ │ │ └── route.ts
│ ├── cache-data
│ │ └── route.ts
│ ├── sync-data
│ │ ├── route.ts
│ │ └── juejin-data.ts
│ ├── feed.xml
│ │ └── route.ts
│ └── guestbook
│ │ └── route.ts
├── (main)
│ ├── home
│ │ ├── ContentCard.tsx
│ │ ├── TechnologyStack.tsx
│ │ ├── GithubProject.tsx
│ │ ├── JueJinArticles.tsx
│ │ └── UserProfile.tsx
│ ├── guestbook
│ │ ├── Header.tsx
│ │ ├── page.tsx
│ │ ├── MessagesList.tsx
│ │ └── AddMessage.tsx
│ ├── page.tsx
│ ├── article
│ │ ├── [id]
│ │ │ ├── anchor
│ │ │ │ ├── index.css
│ │ │ │ └── index.tsx
│ │ │ └── page.tsx
│ │ └── list
│ │ │ └── page.tsx
│ └── layout.tsx
├── providers.tsx
├── article
│ ├── components
│ │ ├── publish-dialog
│ │ │ ├── form-summary-field.tsx
│ │ │ ├── form-category-field.tsx
│ │ │ ├── index.tsx
│ │ │ ├── form.tsx
│ │ │ └── form-cover-upload.tsx
│ │ └── header.tsx
│ ├── add
│ │ └── page.tsx
│ └── edit
│ │ └── [id]
│ │ └── page.tsx
├── not-found.tsx
├── layout.tsx
├── auth
│ └── verify-request
│ │ └── page.tsx
├── actions
│ ├── email.ts
│ └── image-kit.ts
└── globals.css
├── prisma
├── migrations
│ ├── migration_lock.toml
│ └── 20250907134927_init
│ │ └── migration.sql
├── prisma.config.ts
└── schema.prisma
├── lib
├── date.ts
├── routers.ts
├── prisma.ts
├── enums.ts
├── juejin
│ ├── fetch-user-info.ts
│ └── fetch-user-articles.ts
├── utils.ts
├── getReadingTime.ts
├── github
│ ├── user-info.ts
│ └── pinned-repos.ts
└── cache-data.ts
├── .prettierrc
├── components
├── no-found.tsx
├── bytemd
│ ├── viewer.tsx
│ ├── editor.scss
│ ├── plugins.ts
│ ├── editor.tsx
│ └── dark-theme.scss
├── ui
│ ├── skeleton.tsx
│ ├── sonner.tsx
│ ├── label.tsx
│ ├── textarea.tsx
│ ├── input.tsx
│ ├── avatar.tsx
│ ├── tooltip.tsx
│ ├── button.tsx
│ ├── card.tsx
│ ├── dialog.tsx
│ ├── form.tsx
│ ├── select.tsx
│ └── dropdown-menu.tsx
├── base-loading.tsx
├── screen-loading.tsx
├── blog-logo.tsx
├── login-dialog
│ ├── GithubLoginButton.tsx
│ ├── index.tsx
│ └── LoginForm.tsx
├── theme-switch.tsx
└── layout
│ ├── email-subscription.tsx
│ ├── footer.tsx
│ └── header.tsx
├── types
├── next-auth.d.ts
└── index.d.ts
├── .github
└── workflows
│ └── sync-data.yml
├── components.json
├── .vscode
└── settings.json
├── eslint.config.mjs
├── .gitignore
├── next.config.ts
├── tsconfig.json
├── .env.example
├── README.md
├── next-sitemap.config.js
├── LICENSE
├── proxy.ts
└── package.json
/public/TANSHI.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaebe/blog/HEAD/public/TANSHI.jpg
--------------------------------------------------------------------------------
/public/user-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaebe/blog/HEAD/public/user-icon.png
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
3 | .next
4 | dist
5 | pnpm-lock.yaml
6 | components/ui/*
--------------------------------------------------------------------------------
/public/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaebe/blog/HEAD/public/favicon/favicon.ico
--------------------------------------------------------------------------------
/public/og/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaebe/blog/HEAD/public/og/opengraph-image.png
--------------------------------------------------------------------------------
/public/favicon/favicon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaebe/blog/HEAD/public/favicon/favicon-96x96.png
--------------------------------------------------------------------------------
/commitlint.config.mjs:
--------------------------------------------------------------------------------
1 | const config = { extends: ['@commitlint/config-conventional'] }
2 | export default config
3 |
--------------------------------------------------------------------------------
/public/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaebe/blog/HEAD/public/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: {
3 | '@tailwindcss/postcss': {}
4 | }
5 | }
6 | export default config
7 |
--------------------------------------------------------------------------------
/public/favicon/web-app-manifest-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaebe/blog/HEAD/public/favicon/web-app-manifest-192x192.png
--------------------------------------------------------------------------------
/public/favicon/web-app-manifest-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vaebe/blog/HEAD/public/favicon/web-app-manifest-512x512.png
--------------------------------------------------------------------------------
/app/api/route.ts:
--------------------------------------------------------------------------------
1 | import { sendJson } from '@/lib/utils'
2 |
3 | export async function GET() {
4 | return sendJson({ data: 'hello world' })
5 | }
6 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (e.g., Git)
3 | provider = "mysql"
4 |
--------------------------------------------------------------------------------
/lib/date.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs'
2 | import 'dayjs/locale/zh-cn'
3 | import relativeTime from 'dayjs/plugin/relativeTime'
4 |
5 | dayjs.locale('zh-cn')
6 | dayjs.extend(relativeTime)
7 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/no-found.tsx:
--------------------------------------------------------------------------------
1 | function NoFound() {
2 | return (
3 |
4 |
这里曾经有一些东西 , 现在不见了!
5 |
6 | )
7 | }
8 |
9 | export { NoFound }
10 | export default NoFound
11 |
--------------------------------------------------------------------------------
/prisma/prisma.config.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv/config'
2 | import { defineConfig, env } from 'prisma/config'
3 |
4 | export default defineConfig({
5 | schema: 'prisma/schema.prisma',
6 | migrations: {
7 | path: 'prisma/migrations',
8 | },
9 | datasource: {
10 | url: env('DATABASE_URL'),
11 | },
12 | })
--------------------------------------------------------------------------------
/components/bytemd/viewer.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Viewer } from '@bytemd/react'
4 | import plugins from '@/components/bytemd/plugins'
5 | import './editor.scss'
6 | import './dark-theme.scss'
7 |
8 | export function BytemdViewer({ content }: { content: string }) {
9 | return
10 | }
11 |
--------------------------------------------------------------------------------
/lib/routers.ts:
--------------------------------------------------------------------------------
1 | export const routerList = [
2 | {
3 | path: '/',
4 | name: '首页',
5 | icon: 'mdi-light:home'
6 | },
7 | {
8 | path: '/article/list',
9 | name: '文章',
10 | icon: 'ph:article-light'
11 | },
12 | {
13 | path: '/guestbook',
14 | name: '留言板',
15 | icon: 'mynaui:message-dots'
16 | }
17 | ]
18 |
--------------------------------------------------------------------------------
/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4 | return (
5 |
10 | )
11 | }
12 |
13 | export { Skeleton }
14 |
--------------------------------------------------------------------------------
/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/api/articles/all/route.ts:
--------------------------------------------------------------------------------
1 | import { sendJson } from '@/lib/utils'
2 | import { prisma } from '@/lib/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 |
--------------------------------------------------------------------------------
/types/next-auth.d.ts:
--------------------------------------------------------------------------------
1 | // next-auth.d.ts
2 | import { DefaultSession } from 'next-auth'
3 |
4 | declare module 'next-auth' {
5 | interface Session {
6 | user: {
7 | id: string
8 | name: string | null
9 | email: string | null
10 | image: string | null
11 | role?: string | null // 扩展 Session 类型,添加 role 属性
12 | }
13 | }
14 | interface User {
15 | role?: string | null
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/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/api/articles/delete/route.ts:
--------------------------------------------------------------------------------
1 | import { sendJson } from '@/lib/utils'
2 | import { prisma } from '@/lib/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 |
--------------------------------------------------------------------------------
/.github/workflows/sync-data.yml:
--------------------------------------------------------------------------------
1 | name: Sync Data
2 |
3 | on:
4 | schedule:
5 | # 每天凌晨 2 点运行
6 | - cron: '0 2 * * *'
7 | workflow_dispatch: # 允许手动触发
8 |
9 | jobs:
10 | sync-articles:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Start Sync Data
15 | env:
16 | API_KEY: ${{ secrets.API_KEY }}
17 | run: |
18 | curl -X GET "https://blog.vaebe.cn/api/sync-data" -H "x-api-key: $API_KEY"
19 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/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/providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import '@/lib/date'
4 | import { ThemeProvider } from 'next-themes'
5 | import { SessionProvider } from 'next-auth/react'
6 | import { Toaster } from '@/components/ui/sonner'
7 |
8 | export function Providers({ children }: { children: React.ReactNode }) {
9 | return (
10 |
11 |
12 | {children}
13 |
14 |
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/public/favicon/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vaebe blog",
3 | "short_name": "vaebe blog",
4 | "icons": [
5 | {
6 | "src": "/web-app-manifest-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png",
9 | "purpose": "maskable"
10 | },
11 | {
12 | "src": "/web-app-manifest-512x512.png",
13 | "sizes": "512x512",
14 | "type": "image/png",
15 | "purpose": "maskable"
16 | }
17 | ],
18 | "theme_color": "#ffffff",
19 | "background_color": "#ffffff",
20 | "display": "standalone"
21 | }
22 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/prisma.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import { PrismaMariaDb } from '@prisma/adapter-mariadb';
3 | import { PrismaClient } from '../generated/prisma/client';
4 |
5 | const adapter = new PrismaMariaDb({
6 | host: process.env.DATABASE_HOST,
7 | user: process.env.DATABASE_USER,
8 | password: process.env.DATABASE_PASSWORD,
9 | database: process.env.DATABASE_NAME,
10 | port: parseInt(process.env.DATABASE_PORT || '3306'),
11 | connectionLimit: 5,
12 | });
13 |
14 | const prisma = new PrismaClient({
15 | adapter,
16 | log: ['error', 'warn'],
17 | });
18 |
19 | export { prisma }
--------------------------------------------------------------------------------
/lib/enums.ts:
--------------------------------------------------------------------------------
1 | // 技术栈图标映射
2 | export const techIcons: Record = {
3 | TypeScript: 'logos:typescript-icon',
4 | Vue: 'logos:vue',
5 | NuxtJs: 'logos:nuxt',
6 | NextJs: 'logos:nextjs-icon',
7 | NestJs: 'logos:nestjs',
8 | Go: 'logos:go',
9 | Mysql: 'logos:mysql'
10 | }
11 |
12 | export const techStackData = ['TypeScript', 'Vue', 'NuxtJs', 'NextJs', 'NestJs', 'Go', 'Mysql']
13 |
14 | export const TimeInSeconds = {
15 | oneHour: 3600, // 1小时 = 3600秒
16 | oneDay: 86400, // 1天 = 86400秒
17 | oneWeek: 604800, // 1周 = 604800秒
18 | oneMonth: 2592000 // 1个月 = 2592000秒(以30天计算)
19 | }
20 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/app/api/articles/details/route.ts:
--------------------------------------------------------------------------------
1 | import { sendJson } from '@/lib/utils'
2 | import { prisma } from '@/lib/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 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from 'path'
2 | import { fileURLToPath } from 'url'
3 | import { FlatCompat } from '@eslint/eslintrc'
4 |
5 | const __filename = fileURLToPath(import.meta.url)
6 | const __dirname = dirname(__filename)
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname
10 | })
11 |
12 | const eslintConfig = [
13 | {
14 | ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts']
15 | },
16 | ...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'),
17 | {
18 | ignores: ['**/ui/**', '**/artdots.tsx']
19 | }
20 | ]
21 |
22 | export default eslintConfig
23 |
--------------------------------------------------------------------------------
/.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 |
43 | # 生成的文件
44 | generated
45 |
--------------------------------------------------------------------------------
/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 |
5 | export default function BaseLayout({ children }: { children: React.ReactNode }) {
6 | return (
7 | <>
8 |
9 | {children}
10 |
11 |
12 |
13 |
14 | >
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/components/base-loading.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Icon } from '@iconify/react'
4 | import { cn } from '@/lib/utils'
5 |
6 | interface LoadingProps {
7 | isLoading: boolean
8 | size?: number
9 | className?: string
10 | }
11 |
12 | export function BaseLoading({ isLoading, size = 24, className }: LoadingProps) {
13 | if (!isLoading) return null
14 |
15 | return (
16 |
17 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTheme } from "next-themes"
4 | import { Toaster as Sonner, ToasterProps } from "sonner"
5 |
6 | const Toaster = ({ ...props }: ToasterProps) => {
7 | const { theme = "system" } = useTheme()
8 |
9 | return (
10 |
22 | )
23 | }
24 |
25 | export { Toaster }
26 |
--------------------------------------------------------------------------------
/app/api/cache-data/route.ts:
--------------------------------------------------------------------------------
1 | import { sendJson } from '@/lib/utils'
2 | import { prisma } from '@/lib/prisma'
3 |
4 | // 这里获取数据仍设置为 api 接口的原因是可以使用 nextjs 的缓存功能
5 | export async function GET(req: Request) {
6 | try {
7 | const { searchParams } = new URL(req.url)
8 | const key = searchParams.get('key')
9 |
10 | if (!key) {
11 | return sendJson({ code: 400, msg: '缓存数据的 key 不能为空!' })
12 | }
13 |
14 | const cacheData = await prisma.cacheData.findUnique({
15 | where: {
16 | key: key
17 | }
18 | })
19 |
20 | return sendJson({ data: cacheData })
21 | } catch (error) {
22 | return sendJson({ code: -1, msg: `获取缓存数据失败:${error}` })
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Label({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 | )
22 | }
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/app/api/articles/update/route.ts:
--------------------------------------------------------------------------------
1 | import { sendJson } from '@/lib/utils'
2 | import { prisma } from '@/lib/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/articles/articleCountByMonth/route.ts:
--------------------------------------------------------------------------------
1 | import { sendJson } from '@/lib/utils'
2 | import { prisma } from '@/lib/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/add/route.ts:
--------------------------------------------------------------------------------
1 | import { sendJson, generateUUID } from '@/lib/utils'
2 | import { prisma } from '@/lib/prisma'
3 |
4 | export async function POST(req: Request) {
5 | try {
6 | const body = await req.json()
7 | const { title, content, classify, coverImg, summary } = body
8 |
9 | const newArticle = await prisma.article.create({
10 | data: {
11 | id: generateUUID(),
12 | title,
13 | content,
14 | classify,
15 | coverImg,
16 | summary,
17 | status: '01',
18 | source: '00',
19 | userId: 1 // 示例,通常从认证信息中获取用户ID
20 | }
21 | })
22 | return sendJson({ data: newArticle })
23 | } catch (error) {
24 | sendJson({ code: -1, msg: `创建文章失败:${error}` })
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/components/screen-loading.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { FC } from 'react'
3 | import ReactDOM from 'react-dom'
4 |
5 | interface Props {
6 | isLoading: boolean
7 | message?: string
8 | }
9 |
10 | export const FullScreenLoading: FC = ({ isLoading, message = 'Loading...' }) => {
11 | if (!isLoading) return null
12 |
13 | return ReactDOM.createPortal(
14 | ,
20 | document.body
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6 | return (
7 |
15 | )
16 | }
17 |
18 | export { Textarea }
19 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | import { Message } from '@/generated/prisma/client'
2 |
3 | declare global {
4 | // 扩展 fetch Response 字段
5 | interface Response {
6 | code?: number
7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
8 | data?: any
9 | msg?: string
10 | }
11 | }
12 |
13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
14 | export type AnyObject = Record
15 |
16 | export interface PublishArticleInfo {
17 | id?: string
18 | title: string
19 | content: string
20 | classify: string
21 | coverImg: string
22 | summary: string
23 | }
24 |
25 | // =================================== 留言板 ===================================
26 | export interface GuestbookMessage extends Message {
27 | author: {
28 | name: string
29 | email: string
30 | image: string
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from 'next'
2 |
3 | const nextConfig: NextConfig = {
4 | reactStrictMode: false,
5 | images: {
6 | remotePatterns: [
7 | {
8 | protocol: 'https',
9 | hostname: 'avatars.githubusercontent.com',
10 | port: '',
11 | pathname: '/u/**'
12 | },
13 | {
14 | protocol: 'https',
15 | hostname: 'ik.imagekit.io',
16 | port: ''
17 | }
18 | ]
19 | },
20 |
21 | async rewrites() {
22 | return [
23 | {
24 | source: '/rss',
25 | destination: '/api/feed.xml'
26 | },
27 | {
28 | source: '/rss.xml',
29 | destination: '/api/feed.xml'
30 | },
31 | {
32 | source: '/feed',
33 | destination: '/api/feed.xml'
34 | }
35 | ]
36 | }
37 | }
38 |
39 | export default nextConfig
40 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "noEmit": true,
13 | "esModuleInterop": true,
14 | "module": "esnext",
15 | "moduleResolution": "bundler",
16 | "resolveJsonModule": true,
17 | "isolatedModules": true,
18 | "jsx": "react-jsx",
19 | "incremental": true,
20 | "plugins": [
21 | {
22 | "name": "next"
23 | }
24 | ],
25 | "paths": {
26 | "@/*": [
27 | "./*"
28 | ]
29 | }
30 | },
31 | "include": [
32 | "next-env.d.ts",
33 | "**/*.ts",
34 | "**/*.tsx",
35 | ".next/types/**/*.ts",
36 | ".next/dev/types/**/*.ts"
37 | ],
38 | "exclude": [
39 | "node_modules"
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # 站点
2 | NEXT_PUBLIC_SITE_URL=""
3 |
4 | # mysql 数据库连接
5 | DATABASE_URL=""
6 | DATABASE_USER=""
7 | DATABASE_PASSWORD=""
8 | DATABASE_NAME="blog"
9 | DATABASE_HOST="sh.sql.tencentcdb.com"
10 | DATABASE_PORT=27244
11 |
12 | # 掘金 userid
13 | JUEJIN_USER_ID=""
14 |
15 | # next-auth
16 | NEXTAUTH_SECRET=""
17 |
18 | # github
19 | NEXT_PUBLIC_GITHUB_USER_NAME=""
20 | GITHUB_API_TOKEN=""
21 |
22 | # github auth app
23 | AUTH_GITHUB_CLIENT_ID=""
24 | AUTH_GITHUB_CLIENT_SECRET=""
25 |
26 | # github repository
27 | GITHUB_REPOSITORY_API_KEY=""
28 |
29 | # 邮箱验证登录配置
30 | EMAIL_SERVER_USER="xxxxx@qq.com"
31 | EMAIL_SERVER_PASSWORD="mqoovoeimxjmpcaei"
32 | EMAIL_SERVER_HOST="smtp.qq.com"
33 | EMAIL_SERVER_PORT="465"
34 | EMAIL_FROM="vaebe "
35 |
36 | # imagekit 图片存储服务
37 | NEXT_PUBLIC_IMAGEKIT_PUBLIC_KEY=""
38 | NEXT_PUBLIC_IMAGEKIT_URL_ENDPOINT=""
39 | IMAGEKIT_PRIVATE_KEY=""
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # blog
2 |
3 | vaebe 的个人博客,记录自己的一些想法
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 |
47 | ## 性能看起来还行
48 |
49 | 
50 |
--------------------------------------------------------------------------------
/components/blog-logo.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from 'next-themes'
2 | import { useEffect, useState } from 'react'
3 | import Image from 'next/image'
4 | import LightLogo from '@/public/light-logo.svg'
5 | import DarkLogo from '@/public/dark-logo.svg'
6 |
7 | function BlogLogo() {
8 | const { theme, systemTheme } = useTheme()
9 | const [mounted, setMounted] = useState(false)
10 |
11 | useEffect(() => setMounted(true), [])
12 |
13 | if (!mounted) return null
14 |
15 | const currentTheme = theme === 'system' ? systemTheme : theme
16 | const logoSrc = currentTheme === 'dark' ? LightLogo : DarkLogo
17 |
18 | return (
19 |
27 | )
28 | }
29 |
30 | export { BlogLogo }
31 | export default BlogLogo
32 |
--------------------------------------------------------------------------------
/app/article/components/publish-dialog/form-summary-field.tsx:
--------------------------------------------------------------------------------
1 | import { Control } from 'react-hook-form'
2 | import { FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'
3 | import { Textarea } from '@/components/ui/textarea'
4 | import { FormValues } from './form'
5 |
6 | export function FormSummaryField({ control }: { control: Control }) {
7 | return (
8 | (
12 |
13 |
14 | 编辑摘要* :
15 |
16 |
17 |
18 |
19 |
20 |
21 | )}
22 | />
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/public/dark-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/public/light-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/components/bytemd/editor.scss:
--------------------------------------------------------------------------------
1 | @import 'bytemd/dist/index.css';
2 | @import 'juejin-markdown-themes/dist/juejin.css';
3 | // @import 'highlight.js/styles/default.css';
4 | @import 'highlight.js/styles/atom-one-dark.css';
5 | // @import 'highlight.js/styles/a11y-dark.css';
6 |
7 | .bytemd {
8 | height: calc(100vh - 40px);
9 | border: 0;
10 | overflow: hidden;
11 | }
12 |
13 | .markdown-body {
14 | width: 100%;
15 |
16 | ol,
17 | ul {
18 | counter-reset: custom-counter; // 重置计数器
19 |
20 | li {
21 | counter-increment: custom-counter; // 增加计数器
22 |
23 | &::marker {
24 | content: counter(custom-counter) '. '; // 显示计数器的值
25 | color: inherit;
26 | unicode-bidi: isolate;
27 | font-variant-numeric: tabular-nums;
28 | text-transform: none;
29 | text-indent: 0px !important;
30 | text-align: start !important;
31 | text-align-last: start !important;
32 | }
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/components/login-dialog/GithubLoginButton.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react'
2 | import { Button } from '@/components/ui/button'
3 | import { toast } from 'sonner'
4 | import { signIn } from 'next-auth/react'
5 |
6 | interface Props {
7 | setIsLoading: (status: boolean) => void
8 | }
9 |
10 | function GithubLoginButton({ setIsLoading }: Props) {
11 | function handleGithubLogin() {
12 | setIsLoading(true)
13 |
14 | signIn('github', { redirect: false }).catch((error) => {
15 | setIsLoading(false)
16 | toast(`登录失败:${error}`)
17 | })
18 | }
19 |
20 | return (
21 |
25 |
26 |
27 | Github
28 |
29 | )
30 | }
31 |
32 | export { GithubLoginButton }
33 | export default GithubLoginButton
34 |
--------------------------------------------------------------------------------
/components/bytemd/plugins.ts:
--------------------------------------------------------------------------------
1 | import breaks from '@bytemd/plugin-breaks'
2 | import frontmatter from '@bytemd/plugin-frontmatter'
3 | import gemoji from '@bytemd/plugin-gemoji'
4 | import gfm from '@bytemd/plugin-gfm'
5 | import highlightSsr from '@bytemd/plugin-highlight-ssr'
6 | import mediumZoom from '@bytemd/plugin-medium-zoom'
7 | import mermaid from '@bytemd/plugin-mermaid'
8 | import footnotes from '@bytemd/plugin-footnotes'
9 | import math from '@bytemd/plugin-math-ssr'
10 | import mermaid_zhHans from '@bytemd/plugin-mermaid/lib/locales/zh_Hans.json'
11 | import math_zhHans from '@bytemd/plugin-math/lib/locales/zh_Hans.json'
12 | import gfm_zhHans from '@bytemd/plugin-gfm/lib/locales/zh_Hans.json'
13 |
14 | const plugins = [
15 | breaks(),
16 | frontmatter(),
17 | gemoji(),
18 | gfm({ locale: gfm_zhHans }),
19 | math({ locale: math_zhHans }),
20 | highlightSsr(),
21 | mermaid({ locale: mermaid_zhHans }),
22 | mediumZoom(),
23 | footnotes()
24 | ]
25 |
26 | export default plugins
27 |
--------------------------------------------------------------------------------
/next-sitemap.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next-sitemap').IConfig} */
2 |
3 | const { NEXT_PUBLIC_SITE_URL } = process.env
4 |
5 | module.exports = {
6 | siteUrl: NEXT_PUBLIC_SITE_URL,
7 | generateRobotsTxt: true,
8 | generateIndexSitemap: false,
9 | exclude: ['/api', '/api/**', '/article/add'],
10 | additionalPaths: async () => {
11 | let articles = []
12 |
13 | // 本地打包不会生成-因为调用接口会失败!
14 | try {
15 | const res = await fetch(`${NEXT_PUBLIC_SITE_URL}/api/articles/all`)
16 | const json = await res.json()
17 | articles = json.code === 0 ? json.data : []
18 | } catch {
19 | articles = []
20 | }
21 |
22 | const result = []
23 |
24 | articles.map((item) => {
25 | result.push({
26 | loc: `${NEXT_PUBLIC_SITE_URL}/article/${item.id}`, // 页面位置
27 | changefreq: 'daily', // 更新频率
28 | priority: 0.8, // 优先级
29 | lastmod: new Date(item.createdAt).toISOString() // 最后修改时间
30 | })
31 | })
32 |
33 | return result
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/app/article/add/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { BytemdEditor } from '@/components/bytemd/editor'
4 | import { useImmer } from 'use-immer'
5 | import { PublishArticleInfo } from '@/types'
6 | import { LayoutHeader } from '@/app/article/components/header'
7 |
8 | export default function PublishArticle() {
9 | const [articleInfo, updateArticleInfo] = useImmer({
10 | id: '',
11 | title: '',
12 | content: '',
13 | classify: '',
14 | coverImg: '',
15 | summary: ''
16 | })
17 |
18 | return (
19 |
20 |
25 |
26 |
29 | updateArticleInfo((draft) => {
30 | draft.content = val || ''
31 | })
32 | }
33 | >
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6 | return (
7 |
18 | )
19 | }
20 |
21 | export { Input }
22 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/juejin/fetch-user-info.ts:
--------------------------------------------------------------------------------
1 | import { TimeInSeconds } from '../enums'
2 |
3 | const JUEJIN_API_URL = `https://api.juejin.cn/user_api/v1/user/get`
4 |
5 | export interface JuejinUserInfo {
6 | user_name: string
7 | avatar_large: string
8 | power: string
9 | description: string
10 | followee_count: number
11 | follower_count: number
12 | post_article_count: number
13 | got_digg_count: number
14 | got_view_count: number
15 | }
16 |
17 | export async function fetchJuejinUserInfo(): Promise {
18 | const userId = process.env.JUEJIN_USER_ID
19 |
20 | if (!userId) {
21 | throw new Error('JUEJIN_USER_ID 未配置')
22 | }
23 |
24 | const url = `${JUEJIN_API_URL}?user_id=${userId}`
25 |
26 | const response = await fetch(url, { next: { revalidate: TimeInSeconds.oneHour } })
27 |
28 | if (!response.ok) {
29 | const errorInfo = await response.json().catch(() => ({}))
30 | throw new Error(errorInfo.msg || '掘金用户信息请求失败')
31 | }
32 |
33 | const info = await response.json()
34 |
35 | if (info.err_no !== 0) {
36 | throw new Error(info.err_msg || '掘金用户信息返回错误')
37 | }
38 |
39 | return info.data
40 | }
41 |
--------------------------------------------------------------------------------
/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import { Icon } from '@iconify/react'
3 | import { Button } from '@/components/ui/button'
4 |
5 | export default function NotFound() {
6 | return (
7 |
8 |
9 |
404
10 |
11 |
这里曾经或许有些什么,但是现在它不见了!
12 |
13 |
23 |
24 |
不过无需担心,点击下方按钮可以返回首页
25 |
26 |
27 |
28 |
29 | 返回首页
30 |
31 |
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/components/bytemd/editor.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import './editor.scss'
4 | import plugins from './plugins'
5 | import { Editor } from '@bytemd/react'
6 | import zh_Hans from 'bytemd/locales/zh_Hans.json'
7 | import { uploadFile } from '@/app/actions/image-kit'
8 | import { toast } from 'sonner'
9 |
10 | async function uploadImages(files: File[]) {
11 | const resultData: Record<'url' | 'alt' | 'title', string>[] = []
12 |
13 | for (const item of files) {
14 | const res = await uploadFile({ file: item, fileName: item.name })
15 |
16 | if (res?.code === 0) {
17 | console.log('res', res)
18 | resultData.push({
19 | url: res!.data?.url ?? '',
20 | alt: item.name,
21 | title: item.name
22 | })
23 | } else {
24 | toast('图片上传失败,请重试!')
25 | }
26 | }
27 |
28 | return resultData
29 | }
30 |
31 | interface BytemdEditorProps {
32 | content: string
33 | setContent: (content: string) => void
34 | }
35 |
36 | export function BytemdEditor({ content, setContent }: BytemdEditorProps) {
37 | return (
38 | {
43 | setContent(v)
44 | }}
45 | uploadImages={uploadImages}
46 | />
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/app/api/articles/list/route.ts:
--------------------------------------------------------------------------------
1 | import { sendJson } from '@/lib/utils'
2 | import { prisma } from '@/lib/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 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Avatar({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 | )
22 | }
23 |
24 | function AvatarImage({
25 | className,
26 | ...props
27 | }: React.ComponentProps) {
28 | return (
29 |
34 | )
35 | }
36 |
37 | function AvatarFallback({
38 | className,
39 | ...props
40 | }: React.ComponentProps) {
41 | return (
42 |
50 | )
51 | }
52 |
53 | export { Avatar, AvatarImage, AvatarFallback }
54 |
--------------------------------------------------------------------------------
/proxy.ts:
--------------------------------------------------------------------------------
1 | import { withAuth } from 'next-auth/middleware'
2 | import { NextResponse } from 'next/server'
3 | import { sendJson } from '@/lib/utils'
4 |
5 | const ADMIN_PAGES = ['/article/add', '/article/edit']
6 |
7 | const ADMIN_APIS = ['/api/articles/delete', '/api/articles/add', '/api/articles/update']
8 |
9 | export default withAuth(
10 | function middleware(req) {
11 | console.log('request:', req.method, req.url)
12 |
13 | const token = req.nextauth.token
14 | const path = req.nextUrl.pathname
15 |
16 | if (token?.role !== '00') {
17 | if (ADMIN_APIS.some((item) => path.startsWith(item))) {
18 | // 如果没有访问接口的权限返回 401
19 | return sendJson({ code: 401, msg: '无权限' })
20 | }
21 |
22 | if (ADMIN_PAGES.some((item) => path.startsWith(item))) {
23 | // 如果用户没有权限访问管理页面,重定向到404页面
24 | const url = req.nextUrl.clone()
25 | url.pathname = '/404'
26 | return NextResponse.rewrite(url)
27 | }
28 | }
29 | },
30 | {
31 | callbacks: {
32 | authorized: function () {
33 | // 这里返回 false 会到登录页面可以做额外的鉴权
34 | return true
35 | }
36 | }
37 | }
38 | )
39 |
40 | export const config = {
41 | matcher: [
42 | // 匹配页面路由
43 | '/((?!_next/static|_next/image|.*\\.png$).*)',
44 | // 匹配 API 路由
45 | '/api/:path*'
46 | ]
47 | }
48 |
--------------------------------------------------------------------------------
/app/api/sync-data/route.ts:
--------------------------------------------------------------------------------
1 | import { sendJson } from '@/lib/utils'
2 | import { getArticles } from './juejin-data'
3 | import { saveGitHubPinnedReposToCache } from '@/lib/github/pinned-repos'
4 | import { saveGithubUserInfoToCache } from '@/lib/github/user-info'
5 | export async function GET(req: Request) {
6 | const apiKey = req.headers.get('x-api-key')
7 | const expectedApiKey = process.env.GITHUB_REPOSITORY_API_KEY
8 |
9 | // 验证 API 密钥
10 | if (!apiKey || apiKey !== expectedApiKey) {
11 | return sendJson({ code: -1, msg: '无效的 API 密钥' })
12 | }
13 |
14 | try {
15 | console.log('开始缓存 GitHub 用户信息...')
16 | const githubUserInfoToCacheRes = await saveGithubUserInfoToCache()
17 | console.log(githubUserInfoToCacheRes, '/r/n')
18 |
19 | console.log('开始同步 GitHub 置顶仓库...')
20 | const gitHubPinnedReposToCacheRes = await saveGitHubPinnedReposToCache()
21 | console.log(gitHubPinnedReposToCacheRes, '/r/n')
22 |
23 | console.log('开始同步掘金文章...')
24 | const syncArticleNameList = await getArticles(0)
25 | console.log(syncArticleNameList, '/r/n')
26 |
27 | return sendJson({
28 | code: 0,
29 | msg: '同步数据成功',
30 | data: { syncArticleNameList, gitHubPinnedReposToCacheRes, githubUserInfoToCacheRes }
31 | })
32 | } catch (error) {
33 | return sendJson({ code: -1, msg: `同步数据失败:${error}` })
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/app/article/components/publish-dialog/form-category-field.tsx:
--------------------------------------------------------------------------------
1 | import { Control } from 'react-hook-form'
2 | import { FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'
3 | import {
4 | Select,
5 | SelectContent,
6 | SelectItem,
7 | SelectTrigger,
8 | SelectValue
9 | } from '@/components/ui/select'
10 | import { FormValues } from './form'
11 |
12 | const CATEGORIES = ['后端', '前端', 'Android', 'iOS', '人工智能', '阅读'] as const
13 |
14 | export function FormCategoryField({ control }: { control: Control }) {
15 | return (
16 | (
20 |
21 |
22 | 分类* :
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | {CATEGORIES.map((cat) => (
32 |
33 | {cat}
34 |
35 | ))}
36 |
37 |
38 |
39 |
40 | )}
41 | />
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/public/grid-black.svg:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
--------------------------------------------------------------------------------
/public/grid.svg:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
--------------------------------------------------------------------------------
/app/api/feed.xml/route.ts:
--------------------------------------------------------------------------------
1 | import RSS from 'rss'
2 | import { Article } from '@/generated/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 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx'
2 | import { NextResponse } from 'next/server'
3 | import { twMerge } from 'tailwind-merge'
4 | import { Article } from '@/generated/prisma/client'
5 | import { v4 as uuidv4 } from 'uuid'
6 |
7 | export function cn(...inputs: ClassValue[]) {
8 | return twMerge(clsx(inputs))
9 | }
10 |
11 | interface SendJson {
12 | code?: number
13 | data?: T
14 | msg?: string
15 | }
16 |
17 | export interface ApiRes {
18 | code: number
19 | msg: string
20 | data?: T
21 | }
22 |
23 | export interface PaginationResData {
24 | code: number
25 | msg: string
26 | data?: {
27 | list: T[]
28 | total: number
29 | currentPage: number
30 | totalPages: number
31 | }
32 | }
33 |
34 | export function sendJson(opts: SendJson) {
35 | return NextResponse.json({ code: 0, msg: '', ...opts }, { status: 200 })
36 | }
37 |
38 | export const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
39 |
40 | export async function getFileHash(file: File) {
41 | const arrayBuffer = await file.arrayBuffer()
42 | const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)
43 | const hashArray = Array.from(new Uint8Array(hashBuffer))
44 | return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
45 | }
46 |
47 | // 获取跳转文章详情路径
48 | export function getJumpArticleDetailsUrl(info: Article) {
49 | return info.source === '00' ? `/article/${info.id}` : `https://juejin.cn/post/${info.id}`
50 | }
51 |
52 | export function generateUUID() {
53 | return uuidv4().replaceAll('-', '')
54 | }
55 |
--------------------------------------------------------------------------------
/app/api/sync-data/juejin-data.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs'
2 | import { prisma } from '@/lib/prisma'
3 | import { AnyObject } from '@/types'
4 | import { fetchJuejinUserArticles } from '@/lib/juejin/fetch-user-articles'
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 | const syncArticleNameList: string[] = []
46 |
47 | export async function getArticles(index: number) {
48 | const info = await fetchJuejinUserArticles(index)
49 |
50 | for (const item of info.data) {
51 | await addArticle(item)
52 |
53 | syncArticleNameList.push(item.article_info.title)
54 | }
55 |
56 | const nextIndex = index + 10
57 |
58 | // 是否还有更多文章
59 | if (info.has_more) {
60 | await getArticles(nextIndex)
61 | }
62 |
63 | return syncArticleNameList
64 | }
65 |
--------------------------------------------------------------------------------
/app/article/components/publish-dialog/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react'
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogHeader,
8 | DialogTitle,
9 | DialogDescription
10 | } from '@/components/ui/dialog'
11 | import { PublishArticleInfo } from '@/types'
12 | import { PublishForm, FormValues } from './form'
13 | import { ApiRes } from '@/lib/utils'
14 |
15 | interface PublishDialogProps {
16 | children: React.ReactNode
17 | articleInfo: PublishArticleInfo
18 | onPublish: (data: FormValues) => Promise
19 | }
20 |
21 | export function PublishDialog({ children, articleInfo, onPublish }: PublishDialogProps) {
22 | const [isOpen, setIsOpen] = useState(false)
23 |
24 | function publishArticle(data: FormValues) {
25 | onPublish(data).then((res) => {
26 | if (res.code === 0) {
27 | setIsOpen(false)
28 | }
29 | })
30 | }
31 |
32 | return (
33 | <>
34 | setIsOpen(true)}>{children}
35 |
36 |
37 |
38 | 发布文章
39 | 请填写必要信息以完成发布
40 |
41 |
42 | {
45 | publishArticle(data)
46 | }}
47 | onCancel={() => setIsOpen(false)}
48 | />
49 |
50 |
51 | >
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/lib/getReadingTime.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 从 Markdown 内容中去除代码块
3 | * @param content
4 | * @returns {string}
5 | */
6 | function removeCodeBlocks(content: string): string {
7 | // 匹配 Markdown 中的代码块 ``` ``` 并将其去除
8 | return content.replace(/```[\s\S]*?```/g, '')
9 | }
10 |
11 | /**
12 | * 从内容中提取汉字
13 | * @param content
14 | * @returns {string[]}
15 | */
16 | function getChinese(content: string): string[] {
17 | return content.match(/\p{Script=Han}/gu) || []
18 | }
19 |
20 | /**
21 | * 从内容中提取拉丁文单词
22 | * @param content
23 | * @returns {string[]}
24 | */
25 | function getWords(content: string): string[] {
26 | // \p{L} 匹配任何字母字符,\p{N} 匹配任何数字字符,允许符号 @ 和 .
27 | return content.match(/[\p{L}\p{N}@./]+/gu) || []
28 | }
29 |
30 | /**
31 | * 计算内容字数,排除代码块
32 | * @param content
33 | * @returns {number}
34 | */
35 | function getWordCount(content: string): number {
36 | const cleanedContent = removeCodeBlocks(content) // 去除代码块后的内容
37 | const chineseCount = getChinese(cleanedContent).length
38 | const wordCount = getWords(cleanedContent).reduce((acc, word) => {
39 | const trimmed = word.trim()
40 | return acc + (trimmed === '' ? 0 : trimmed.split(/\s+/u).length)
41 | }, 0)
42 |
43 | return chineseCount + wordCount
44 | }
45 |
46 | /**
47 | * 计算阅读时间,排除代码块
48 | * @param content
49 | * @param wordsPerMinute 每分钟阅读字数,默认200
50 | * @returns {{minutes: number, words: number}}
51 | */
52 | export function getReadingTime(
53 | content: string,
54 | wordsPerMinute = 300
55 | ): { minutes: number; words: number } {
56 | const wordCount = getWordCount(content)
57 | const minutes = Math.round((wordCount / wordsPerMinute) * 100) / 100 // 保留两位小数
58 |
59 | return { minutes, words: wordCount }
60 | }
61 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import './globals.css'
2 | import { Inter as FontSans } from 'next/font/google'
3 | import { cn } from '@/lib/utils'
4 | import Script from 'next/script'
5 | import { Analytics } from '@vercel/analytics/react'
6 | import { Providers } from './providers'
7 | import { Metadata } from 'next'
8 |
9 | const fontSans = FontSans({
10 | subsets: ['latin'],
11 | variable: '--font-sans'
12 | })
13 |
14 | export const metadata: Metadata = {
15 | title: 'vaebe blog',
16 | description:
17 | '我是 Vaebe,一名全栈开发者,专注于前端技术。我的主要技术栈是 Vue 及其全家桶,目前也在使用 React 来构建项目,比如这个博客,它使用 Next.js。',
18 | icons: {
19 | icon: [
20 | {
21 | url: '/favicon/favicon-96x96.png',
22 | sizes: '96x96',
23 | type: 'image/png'
24 | },
25 | {
26 | url: '/favicon/favicon.svg',
27 | type: 'image/svg+xml'
28 | }
29 | ],
30 | shortcut: ['/favicon/favicon.ico'],
31 | apple: [
32 | {
33 | url: '/favicon/apple-touch-icon.png',
34 | sizes: '180x180'
35 | }
36 | ]
37 | },
38 | manifest: '/favicon/site.webmanifest',
39 | alternates: {
40 | types: {
41 | 'application/rss+xml': '/feed.xml'
42 | }
43 | }
44 | }
45 |
46 | export default function RootLayout({ children }: { children: React.ReactNode }) {
47 | return (
48 |
49 |
55 | {children}
56 |
57 |
58 |
59 |
60 |
61 | )
62 | }
63 |
--------------------------------------------------------------------------------
/app/api/guestbook/route.ts:
--------------------------------------------------------------------------------
1 | import { sendJson } from '@/lib/utils'
2 | import { prisma } from '@/lib/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 |
--------------------------------------------------------------------------------
/components/theme-switch.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Icon } from '@iconify/react'
4 | import { useTheme } from 'next-themes'
5 | import { Button } from '@/components/ui/button'
6 | import {
7 | DropdownMenu,
8 | DropdownMenuContent,
9 | DropdownMenuItem,
10 | DropdownMenuTrigger
11 | } from '@/components/ui/dropdown-menu'
12 |
13 | const themes = [
14 | { value: 'dark', label: '深色', icon: 'ph:moon-bold' },
15 | { value: 'light', label: '浅色', icon: 'ph:sun-bold' },
16 | { value: 'system', label: '系统', icon: 'ph:desktop-bold' }
17 | ] as const
18 |
19 | export function ThemeSwitch() {
20 | const { theme, setTheme } = useTheme()
21 |
22 | return (
23 |
24 |
25 |
26 |
31 | t.value === theme)?.icon || themes[0].icon}
33 | className="w-5 h-5 text-white dark:text-black"
34 | />
35 |
36 |
37 |
38 |
39 | {themes.map((t) => (
40 | {
44 | setTheme(t.value)
45 | }}
46 | >
47 |
48 |
49 | {t.label}
50 |
51 |
52 | ))}
53 |
54 |
55 |
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/app/article/edit/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect, use } from 'react'
4 | import { toast } from 'sonner'
5 | import { BytemdEditor } from '@/components/bytemd/editor'
6 | import { useImmer } from 'use-immer'
7 | import { PublishArticleInfo } from '@/types'
8 | import { LayoutHeader } from '@/app/article/components/header'
9 |
10 | export default function PublishArticle(props: { params: Promise<{ id: string }> }) {
11 | const params = use(props.params)
12 |
13 | const [articleInfo, updateArticleInfo] = useImmer({
14 | id: params.id,
15 | title: '',
16 | content: '',
17 | classify: '',
18 | coverImg: '',
19 | summary: ''
20 | })
21 |
22 | useEffect(() => {
23 | async function getData() {
24 | const res = await fetch(`/api/articles/details?id=${params.id}`).then((res) => res.json())
25 |
26 | if (res.code !== 0) {
27 | toast('获取文章详情失败!')
28 | return
29 | }
30 |
31 | updateArticleInfo((draft) => {
32 | draft.title = res.data.title || ''
33 | draft.classify = res.data.classify || ''
34 | draft.coverImg = res.data.coverImg || ''
35 | draft.summary = res.data.summary || ''
36 | draft.content = res.data.content || ''
37 | })
38 | }
39 | getData()
40 | }, [params.id, updateArticleInfo])
41 |
42 | return (
43 |
44 |
49 |
50 |
53 | updateArticleInfo((draft) => {
54 | draft.content = val || ''
55 | })
56 | }
57 | >
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/app/article/components/publish-dialog/form.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect } from 'react'
4 | import { useForm } from 'react-hook-form'
5 | import { z } from 'zod'
6 | import { zodResolver } from '@hookform/resolvers/zod'
7 | import { Button } from '@/components/ui/button'
8 | import { DialogFooter } from '@/components/ui/dialog'
9 | import { Form } from '@/components/ui/form'
10 | import { PublishArticleInfo } from '@/types'
11 | import { FormCategoryField } from './form-category-field'
12 | import { FormCoverUpload } from './form-cover-upload'
13 | import { FormSummaryField } from './form-summary-field'
14 |
15 | const formSchema = z.object({
16 | classify: z.string().min(1, '请选择分类'),
17 | summary: z.string().min(1, '请输入摘要'),
18 | coverImg: z.string().optional()
19 | })
20 |
21 | export type FormValues = z.infer
22 |
23 | interface PublishFormProps {
24 | articleInfo: PublishArticleInfo
25 | onPublish: (data: FormValues) => void
26 | onCancel: () => void
27 | }
28 |
29 | export function PublishForm({ articleInfo, onPublish, onCancel }: PublishFormProps) {
30 | const form = useForm({ resolver: zodResolver(formSchema) })
31 | const { handleSubmit, reset } = form
32 |
33 | useEffect(() => {
34 | if (articleInfo) reset(articleInfo)
35 | }, [articleInfo, reset])
36 |
37 | return (
38 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/app/auth/verify-request/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Icon } from '@iconify/react'
4 | import Link from 'next/link'
5 |
6 | export default function EmailVerificationSent() {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
验证邮件已发送
14 |
15 | 我们已经向您的邮箱发送了一封包含验证链接的邮件。
16 |
17 |
18 |
19 |
20 |
21 |
22 |
27 |
28 |
29 |
请注意
30 |
31 |
验证链接将在30分钟后过期。如果您没有收到邮件,请检查您的垃圾邮件文件夹。
32 |
33 |
34 |
35 |
36 |
37 |
38 |
42 | 返回首页
43 |
44 |
45 |
46 |
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/components/login-dialog/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React, { useState } from 'react'
4 | import { LoginForm } from './LoginForm'
5 | import { GithubLoginButton } from './GithubLoginButton'
6 | import {
7 | Dialog,
8 | DialogContent,
9 | DialogTitle,
10 | DialogHeader,
11 | DialogDescription
12 | } from '@/components/ui/dialog'
13 | import { Icon } from '@iconify/react'
14 |
15 | interface Props {
16 | children: React.ReactNode
17 | onClose?: () => void
18 | }
19 |
20 | function LoginTips() {
21 | return (
22 |
23 |
24 | 正在登录中
25 |
26 |
27 |
28 | )
29 | }
30 |
31 | const LoginDialog = ({ onClose, children }: Props) => {
32 | const [isLoading, setIsLoading] = useState(false)
33 |
34 | const [isOpen, setIsOpen] = useState(false)
35 |
36 | function openDialog() {
37 | setIsOpen(true)
38 | }
39 |
40 | function closeDialog() {
41 | setIsOpen(false)
42 | onClose?.()
43 | }
44 |
45 | return (
46 | <>
47 | {children}
48 |
49 |
50 |
51 |
52 | 登录
53 |
54 |
55 | {isLoading ? : '请选择下方任意一种方式登录'}
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | >
67 | )
68 | }
69 |
70 | export { LoginDialog }
71 | export default LoginDialog
72 |
--------------------------------------------------------------------------------
/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function TooltipProvider({
9 | delayDuration = 0,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
18 | )
19 | }
20 |
21 | function Tooltip({
22 | ...props
23 | }: React.ComponentProps) {
24 | return (
25 |
26 |
27 |
28 | )
29 | }
30 |
31 | function TooltipTrigger({
32 | ...props
33 | }: React.ComponentProps) {
34 | return
35 | }
36 |
37 | function TooltipContent({
38 | className,
39 | sideOffset = 0,
40 | children,
41 | ...props
42 | }: React.ComponentProps) {
43 | return (
44 |
45 |
54 | {children}
55 |
56 |
57 |
58 | )
59 | }
60 |
61 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
62 |
--------------------------------------------------------------------------------
/app/actions/email.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { prisma } from '@/lib/prisma'
4 | import nodemailer from 'nodemailer'
5 | import { z } from 'zod'
6 | import type { ApiRes } from '@/lib/utils'
7 |
8 | const SendEmailSchema = z.object({
9 | toEmail: z.email({ message: '邮箱格式不正确!' }),
10 | subject: z.string().optional(),
11 | text: z.string().optional(),
12 | html: z.string().optional()
13 | })
14 |
15 | // 发送邮件
16 | export async function sendEmail(props: z.infer): Promise {
17 | try {
18 | const parsed = SendEmailSchema.safeParse(props)
19 |
20 | if (!parsed.success) {
21 | // 当解析失败时,返回第一个错误信息
22 | const errorMessage = parsed.error.issues[0].message
23 | return { code: 400, data: null, msg: errorMessage }
24 | }
25 |
26 | const { toEmail, subject, text, html } = parsed.data
27 |
28 | if (!subject || !(text || html)) {
29 | return { code: -1, msg: '参数不正确' }
30 | }
31 |
32 | // 先查询邮箱是否已经存在
33 | const existingEmail = await prisma.subscriber.findUnique({
34 | where: {
35 | email: toEmail
36 | }
37 | })
38 |
39 | if (existingEmail) {
40 | return { code: -1, msg: '邮箱已订阅' }
41 | }
42 |
43 | const transporter = nodemailer.createTransport({
44 | host: process.env.EMAIL_SERVER_HOST,
45 | port: parseInt(process.env.EMAIL_SERVER_PORT as string),
46 | secure: true, // 465 端口为 true 其他为 false
47 | auth: {
48 | user: process.env.EMAIL_SERVER_USER, // 你的QQ邮箱
49 | pass: process.env.EMAIL_SERVER_PASSWORD // QQ邮箱授权码,不是QQ密码
50 | }
51 | })
52 |
53 | // 邮件内容
54 | const mailData = {
55 | from: process.env.EMAIL_FROM,
56 | to: toEmail, // 收件人邮箱
57 | subject: subject, // 邮件主题
58 | text: text, // 纯文本内容
59 | html: html // HTML内容
60 | }
61 |
62 | await transporter.sendMail(mailData)
63 |
64 | // 发送邮件成功后,将邮箱保存到数据库
65 | await prisma.subscriber.create({
66 | data: {
67 | email: toEmail
68 | }
69 | })
70 |
71 | return { code: 0, msg: '邮件发送成功' }
72 | } catch (error) {
73 | return { code: -1, msg: `发送邮件失败:${error}` }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/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 | import { Card } from '@/components/ui/card'
7 | import { toast } from 'sonner'
8 |
9 | interface MessagesListProps {
10 | list: Array
11 | setMessages: Dispatch>
12 | }
13 |
14 | export function MessagesList({ list, setMessages }: MessagesListProps) {
15 | const [loading, setLoading] = useState(true)
16 |
17 | useEffect(() => {
18 | const getMessages = async () => {
19 | try {
20 | const res = await fetch(`/api/guestbook?page=1&pageSize=9999`).then((res) => res.json())
21 |
22 | if (res.code === 0) {
23 | setMessages(res.data.list)
24 | }
25 | } catch (error) {
26 | toast('获取留言列表失败!')
27 | console.error('Failed to fetch messages', error)
28 | } finally {
29 | setLoading(false)
30 | }
31 | }
32 |
33 | getMessages()
34 | }, [setMessages])
35 |
36 | if (loading) {
37 | return 正在获取留言...
38 | }
39 |
40 | return (
41 |
42 | {list.map((message) => (
43 |
44 | ))}
45 |
46 | )
47 | }
48 |
49 | export function MessagesListItem({ info }: { info: GuestbookMessage }) {
50 | return (
51 |
52 |
53 |
54 |
55 | {info?.author?.name}
56 |
57 |
58 | {info?.author?.name ?? '未知'}
59 |
60 | {dayjs(info.createdAt).locale('zh-cn').fromNow()}
61 |
62 |
63 |
64 |
65 |
66 |
67 | )
68 | }
69 |
--------------------------------------------------------------------------------
/app/(main)/article/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect, useState, use } from 'react'
4 | import { Article } from '@/generated/prisma/client'
5 | import { toast } from 'sonner'
6 | import { getReadingTime } from '@/lib/getReadingTime'
7 | import { Anchor } from './anchor/index'
8 | import { BytemdViewer } from '@/components/bytemd/viewer'
9 | import { Icon } from '@iconify/react'
10 |
11 | export default function Component(props: { params: Promise<{ id: string }> }) {
12 | const params = use(props.params)
13 | const [article, setArticle] = useState()
14 | const [readingTime, setReadingTime] = useState(0)
15 |
16 | useEffect(() => {
17 | async function fetchArticleDetails() {
18 | const res = await fetch(`/api/articles/details?id=${params.id}`).then((res) => res.json())
19 | if (res.code !== 0) {
20 | toast('获取文章详情失败!')
21 | return null
22 | }
23 | return res.data
24 | }
25 |
26 | async function getData() {
27 | const articleData = await fetchArticleDetails()
28 | if (!articleData) return
29 |
30 | setArticle(articleData)
31 | setReadingTime(getReadingTime(articleData.content).minutes)
32 | }
33 |
34 | getData()
35 | }, [params.id])
36 |
37 | return (
38 |
39 |
40 |
{article?.title}
41 |
42 | 阅读时间: {readingTime} 分钟
43 |
44 |
45 |
46 |
47 |
导读:
48 |
{article?.summary}
49 |
50 |
51 |
55 |
56 |
57 |
58 |
59 |
60 | )
61 | }
62 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16 | outline:
17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20 | ghost:
21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22 | link: "text-primary underline-offset-4 hover:underline",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28 | icon: "size-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | )
37 |
38 | function Button({
39 | className,
40 | variant,
41 | size,
42 | asChild = false,
43 | ...props
44 | }: React.ComponentProps<"button"> &
45 | VariantProps & {
46 | asChild?: boolean
47 | }) {
48 | const Comp = asChild ? Slot : "button"
49 |
50 | return (
51 |
56 | )
57 | }
58 |
59 | export { Button, buttonVariants }
60 |
--------------------------------------------------------------------------------
/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Card({ className, ...props }: React.ComponentProps<"div">) {
6 | return (
7 |
15 | )
16 | }
17 |
18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
19 | return (
20 |
28 | )
29 | }
30 |
31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
32 | return (
33 |
38 | )
39 | }
40 |
41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
42 | return (
43 |
48 | )
49 | }
50 |
51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) {
52 | return (
53 |
61 | )
62 | }
63 |
64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) {
65 | return (
66 |
71 | )
72 | }
73 |
74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
75 | return (
76 |
81 | )
82 | }
83 |
84 | export {
85 | Card,
86 | CardHeader,
87 | CardFooter,
88 | CardTitle,
89 | CardAction,
90 | CardDescription,
91 | CardContent,
92 | }
93 |
--------------------------------------------------------------------------------
/lib/juejin/fetch-user-articles.ts:
--------------------------------------------------------------------------------
1 | export interface ArticleInfo {
2 | article_id: string
3 | user_id: string
4 | category_id: string
5 | tag_ids: number[]
6 | visible_level: number
7 | link_url: string
8 | cover_image: string
9 | is_gfw: number
10 | title: string
11 | brief_content: string
12 | is_english: number
13 | is_original: number
14 | user_index: number
15 | original_type: number
16 | original_author: string
17 | content: string
18 | ctime: string
19 | mtime: string
20 | rtime: string
21 | draft_id: string
22 | view_count: number
23 | collect_count: number
24 | digg_count: number
25 | comment_count: number
26 | hot_index: number
27 | is_hot: number
28 | rank_index: number
29 | status: number
30 | verify_status: number
31 | audit_status: number
32 | mark_content: string
33 | display_count: number
34 | is_markdown: number
35 | app_html_content: string
36 | version: number
37 | web_html_content: string
38 | meta_info: string
39 | catalog: string
40 | homepage_top_time: number
41 | homepage_top_status: number
42 | content_count: number
43 | read_time: string
44 | pics_expire_time: number
45 | }
46 |
47 | export interface Tag {
48 | id: number
49 | tag_id: string
50 | tag_name: string
51 | color: string
52 | icon: string
53 | back_ground: string
54 | show_navi: number
55 | ctime: number
56 | mtime: number
57 | id_type: number
58 | tag_alias: string
59 | post_article_count: number
60 | concern_user_count: number
61 | }
62 |
63 | export interface JuejinArticle {
64 | article_info: ArticleInfo
65 | tags: Tag[]
66 | }
67 |
68 | export interface JuejinArticlesInfo {
69 | count: number
70 | cursor: string
71 | data: JuejinArticle[]
72 | has_more: boolean
73 | }
74 |
75 | export async function fetchJuejinUserArticles(cursor = 0): Promise {
76 | const userId = process.env.JUEJIN_USER_ID
77 |
78 | if (!userId) {
79 | throw new Error('JUEJIN_USER_ID 未配置')
80 | }
81 |
82 | const res = await fetch('https://api.juejin.cn/content_api/v1/article/query_list', {
83 | method: 'POST',
84 | headers: {
85 | 'Content-Type': 'application/json'
86 | },
87 | body: JSON.stringify({
88 | user_id: userId,
89 | sort_type: 2,
90 | cursor: `${cursor}`
91 | })
92 | })
93 |
94 | if (!res.ok) {
95 | const errorText = await res.text()
96 | throw new Error(`请求掘金文章失败: ${res.status} - ${errorText}`)
97 | }
98 |
99 | return res.json()
100 | }
101 |
--------------------------------------------------------------------------------
/components/layout/email-subscription.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from '@/components/ui/button'
4 | import { Input } from '@/components/ui/input'
5 | import { useState } from 'react'
6 | import { toast } from 'sonner'
7 | import { emailRegex } from '@/lib/utils'
8 | import { sendEmail } from '@/app/actions/email'
9 |
10 | const htmlTemplate = `
11 |
12 |
13 |
14 |
15 |
16 | 感谢您的订阅
17 |
18 |
19 |
20 |
首先感谢您订阅我的博客!
21 |
我会不定期更新自己在 vue 、react 相关的实践!
22 |
如果您有任何问题或建议,可以使用邮件或留言板与我联系。
23 |
再次感谢您的支持!
24 |
祝您生活愉快!
25 |
26 |
27 |
28 | `
29 |
30 | interface LoadingPromiseProps {
31 | message: string
32 | description: string
33 | }
34 |
35 | function EmailSubscription() {
36 | const [email, setEmail] = useState('')
37 |
38 | async function sendSubscriptionEmail() {
39 | // 判断是否是一个合法的邮箱
40 | if (!emailRegex.test(email)) {
41 | toast.warning('请输入合法的邮箱地址')
42 | return
43 | }
44 |
45 | const loadingPromise = new Promise((resolve) => {
46 | sendEmail({ toEmail: email, subject: '感谢您订阅 vaebe 博客', html: htmlTemplate })
47 | .then((res) => {
48 | if (res.code !== 0) {
49 | resolve({ message: '订阅失败!', description: res.msg })
50 | return
51 | }
52 |
53 | setEmail('')
54 |
55 | resolve({ message: '订阅成功!', description: res.msg })
56 | })
57 | .catch((error) => {
58 | resolve({ message: '订阅失败!', description: error })
59 | })
60 | })
61 |
62 | toast.promise(loadingPromise, {
63 | loading: 'Loading...',
64 | success: (data) => data,
65 | error: 'Error'
66 | })
67 | }
68 |
69 | return (
70 |
71 | setEmail(e.target.value)}
75 | placeholder="输入您的邮箱"
76 | className="rounded-r-none"
77 | >
78 |
79 |
80 | 订阅
81 |
82 |
83 | )
84 | }
85 |
86 | export { EmailSubscription }
87 |
--------------------------------------------------------------------------------
/app/article/components/publish-dialog/form-cover-upload.tsx:
--------------------------------------------------------------------------------
1 | import { ChangeEvent } from 'react'
2 | import { Control, useWatch, useFormContext } from 'react-hook-form'
3 | import { FormField, FormItem, FormLabel } from '@/components/ui/form'
4 | import Image from 'next/image'
5 | import { toast } from 'sonner'
6 | import { uploadFile } from '@/app/actions/image-kit'
7 | import { FormValues } from './form'
8 |
9 | export function FormCoverUpload({ control }: { control: Control }) {
10 | const { setValue } = useFormContext()
11 | const coverImg = useWatch({ control, name: 'coverImg' })
12 |
13 | const handleImageUpload = async (event: ChangeEvent) => {
14 | try {
15 | const file = event.target.files?.[0]
16 | if (!file) {
17 | return
18 | }
19 |
20 | const res = await uploadFile({ file, fileName: file.name })
21 |
22 | if (res?.code === 0 && res.data?.url) {
23 | setValue('coverImg', res.data.url)
24 | toast.success('封面上传成功')
25 | } else {
26 | toast.error('图片上传失败')
27 | }
28 | } catch {
29 | toast.error('上传图片失败,请稍后重试')
30 | } finally {
31 | event.target.value = ''
32 | }
33 | }
34 |
35 | return (
36 | (
40 |
41 | 文章封面
42 |
46 |
47 | {coverImg ? (
48 |
56 | ) : (
57 | 暂无封面
58 | )}
59 |
60 |
67 |
68 | 建议尺寸: 192×128 px(仅在首页展示)
69 |
70 | )}
71 | />
72 | )
73 | }
74 |
--------------------------------------------------------------------------------
/components/layout/footer.tsx:
--------------------------------------------------------------------------------
1 | import { routerList } from '@/lib/routers'
2 | import Link from 'next/link'
3 | import { Icon } from '@iconify/react'
4 | import { EmailSubscription } from './email-subscription'
5 |
6 | export default function LayoutFooter() {
7 | const githubUserName = process.env.NEXT_PUBLIC_GITHUB_USER_NAME ?? ''
8 |
9 | return (
10 |
19 | )
20 | }
21 |
22 | function NavList() {
23 | return (
24 |
25 |
快速链接
26 |
27 | {routerList.map((item) => (
28 |
29 |
30 | {item.name}
31 |
32 |
33 | ))}
34 |
35 |
36 | )
37 | }
38 |
39 | function Subscription({ className }: { className: string }) {
40 | return (
41 |
42 |
订阅
43 |
可以及时获取我的最新动态
44 |
45 |
46 |
47 |
48 |
53 |
RSS 订阅
54 |
55 |
56 |
57 |
58 | )
59 | }
60 |
61 | function Copyright({ githubUserName }: { githubUserName: string }) {
62 | return (
63 |
64 |
70 | Released under the MIT License.
71 |
72 |
78 | Copyright © 2024-present {githubUserName}.
79 |
80 |
81 | )
82 | }
83 |
--------------------------------------------------------------------------------
/lib/github/user-info.ts:
--------------------------------------------------------------------------------
1 | import { createCacheData } from '../cache-data'
2 | import { TimeInSeconds } from '../enums'
3 | import { ApiRes } from '../utils'
4 |
5 | export interface GithubUserInfo {
6 | login: string
7 | id: number
8 | node_id: string
9 | avatar_url: string
10 | gravatar_id: string
11 | url: string
12 | html_url: string
13 | followers_url: string
14 | following_url: string
15 | gists_url: string
16 | starred_url: string
17 | subscriptions_url: string
18 | organizations_url: string
19 | repos_url: string
20 | events_url: string
21 | received_events_url: string
22 | type: string
23 | site_admin: boolean
24 | name: string
25 | company: string
26 | blog: string
27 | location: string
28 | email: string
29 | hireable: string
30 | bio: string
31 | twitter_username: string
32 | public_repos: number
33 | public_gists: number
34 | followers: number
35 | following: number
36 | created_at: string
37 | updated_at: string
38 | }
39 |
40 | const GITHUB_USER_NAME = process.env.NEXT_PUBLIC_GITHUB_USER_NAME
41 | const GITHUB_API_URL = `https://api.github.com/users/${GITHUB_USER_NAME}`
42 | const GITHUB_API_TOKEN = process.env.GITHUB_API_TOKEN
43 |
44 | export async function getGithubUserInfo(): Promise> {
45 | if (!GITHUB_USER_NAME || !GITHUB_API_TOKEN) {
46 | return { code: -1, msg: 'GitHub 用户名或 Token 未配置' }
47 | }
48 |
49 | try {
50 | const res = await fetch(GITHUB_API_URL, {
51 | headers: {
52 | Accept: 'application/vnd.github.v3+json',
53 | Authorization: `Bearer ${GITHUB_API_TOKEN}`
54 | },
55 | next: { revalidate: TimeInSeconds.oneHour } // 数据缓存一个小时
56 | })
57 |
58 | if (!res.ok) {
59 | const errMsg = await res.text()
60 | return { code: -1, msg: `获取 GitHub 用户信息失败: ${res.statusText} - ${errMsg}` }
61 | }
62 |
63 | const data = await res.json()
64 |
65 | return { code: 0, data, msg: '获取 GitHub 用户信息成功' }
66 | } catch (error) {
67 | return { code: -1, msg: `获取 GitHub 用户信息失败:${error}` }
68 | }
69 | }
70 |
71 | export const GithubUserInfoCacheDataKey = 'github_user_info'
72 |
73 | export async function saveGithubUserInfoToCache(): Promise {
74 | try {
75 | const res = await getGithubUserInfo()
76 |
77 | if (res.code !== 0) {
78 | return { code: -1, msg: res.msg }
79 | }
80 |
81 | const data = res.data
82 |
83 | if (!data) {
84 | return { code: -1, msg: '获取 GitHub 用户信息失败' }
85 | }
86 |
87 | return createCacheData({
88 | key: GithubUserInfoCacheDataKey,
89 | data: JSON.stringify(data),
90 | desc: 'GitHub 用户信息'
91 | })
92 | } catch (error) {
93 | return { code: -1, msg: `保存 GitHub 用户信息失败:${error}` }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/components/login-dialog/LoginForm.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React, { useState } from 'react'
4 | import { signIn } from 'next-auth/react'
5 | import { Icon } from '@iconify/react'
6 | import { Button } from '@/components/ui/button'
7 | import { Input } from '@/components/ui/input'
8 | import { toast } from 'sonner'
9 |
10 | interface Props {
11 | setIsLoading: (status: boolean) => void
12 | closeDialog: () => void
13 | }
14 |
15 | const LoginForm = ({ setIsLoading, closeDialog }: Props) => {
16 | const [account, setAccount] = useState('')
17 | const [password, setPassword] = useState('')
18 |
19 | const handleSubmit = async (e: React.FormEvent) => {
20 | e.preventDefault()
21 |
22 | if (!account) {
23 | toast(`请输入邮箱!`)
24 | return
25 | }
26 |
27 | if (!password) {
28 | toast(`请输入密码!`)
29 | return
30 | }
31 |
32 | setIsLoading(true)
33 |
34 | const res = await signIn('credentials', {
35 | account,
36 | password,
37 | redirect: false
38 | })
39 |
40 | setIsLoading(false)
41 |
42 | if (res?.error) {
43 | toast('请检查您的用户名和密码!')
44 | } else {
45 | closeDialog()
46 | toast('欢迎回来!')
47 | }
48 | }
49 |
50 | return (
51 | <>
52 |
88 | >
89 | )
90 | }
91 |
92 | export { LoginForm }
93 | export default LoginForm
94 |
--------------------------------------------------------------------------------
/app/(main)/home/GithubProject.tsx:
--------------------------------------------------------------------------------
1 | import { ContentCard } from './ContentCard'
2 | import { Icon } from '@iconify/react'
3 | import Link from 'next/link'
4 | import type { GithubPinnedRepoInfo } from '@/lib/github/pinned-repos'
5 | import { GitHubPinnedReposCacheDataKey } from '@/lib/github/pinned-repos'
6 | import { getCacheDataByKey } from '@/lib/cache-data'
7 | import { TimeInSeconds } from '@/lib/enums'
8 |
9 | function NoFound() {
10 | return (
11 | No repositories found.
12 | )
13 | }
14 |
15 | function ProjectInfo({ repos }: { repos: GithubPinnedRepoInfo[] }) {
16 | return (
17 |
18 | {repos.map((repo) => (
19 |
23 |
24 |
{repo.name}
25 |
26 | {repo.description || 'No description available'}
27 |
28 |
29 |
30 |
31 | {repo.stargazerCount}
32 |
33 |
34 |
35 | {repo.forkCount}
36 |
37 | {repo.primaryLanguage && (
38 |
39 |
43 | {repo.primaryLanguage.name}
44 |
45 | )}
46 |
47 |
48 |
49 | ))}
50 |
51 | )
52 | }
53 |
54 | export async function GithubProject() {
55 | let repos: GithubPinnedRepoInfo[] = []
56 |
57 | try {
58 | const res = await getCacheDataByKey({
59 | key: GitHubPinnedReposCacheDataKey,
60 | next: { revalidate: TimeInSeconds.oneHour }
61 | })
62 |
63 | if (res.code === 0 && res.data) {
64 | repos = res.data
65 | }
66 | } catch {
67 | repos = []
68 | }
69 |
70 | return (
71 |
72 | {repos.length === 0 ? : }
73 |
74 | )
75 | }
76 |
--------------------------------------------------------------------------------
/lib/cache-data.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import type { ApiRes } from './utils'
3 | import { prisma } from '@/lib/prisma'
4 | import { generateUUID } from '@/lib/utils'
5 | import type { CacheData } from '@/generated/prisma/client'
6 |
7 | const createCacheDataSchema = z.object({
8 | key: z.string().min(1, { message: '缓存数据的 key 不能为空!' }),
9 | data: z.string().min(1, { message: '缓存数据不能为空' }),
10 | desc: z.string().optional()
11 | })
12 |
13 | export async function createCacheData(
14 | props: z.infer
15 | ): Promise {
16 | try {
17 | const parsed = createCacheDataSchema.safeParse(props)
18 |
19 | if (!parsed.success) {
20 | // 当解析失败时,返回第一个错误信息
21 | const errorMessage = parsed.error.issues[0].message
22 | return { code: 400, data: null, msg: errorMessage }
23 | }
24 |
25 | const { key, data, desc = '' } = parsed.data
26 |
27 | const res = await prisma.cacheData.upsert({
28 | where: {
29 | key: key
30 | },
31 | update: {
32 | data: data,
33 | desc: desc
34 | },
35 | create: {
36 | id: generateUUID(),
37 | key: key,
38 | data: data,
39 | desc: desc
40 | }
41 | })
42 |
43 | return { code: 0, msg: '创建缓存数据成功!', data: res }
44 | } catch (error) {
45 | return { code: -1, msg: `创建缓存数据失败:${error}` }
46 | }
47 | }
48 |
49 | interface GetCacheDataProps {
50 | key: string
51 | next?: NextFetchRequestConfig
52 | }
53 |
54 | // 不向外暴露此方法
55 | async function getCacheData(props: GetCacheDataProps): Promise> {
56 | try {
57 | if (!props.key) {
58 | return { code: 400, msg: '缓存数据的 key 不能为空!' }
59 | }
60 |
61 | const response = await fetch(
62 | `${process.env.NEXT_PUBLIC_SITE_URL}/api/cache-data?key=${props.key}`,
63 | {
64 | headers: {
65 | 'Content-Type': 'application/json'
66 | },
67 | next: props.next
68 | }
69 | )
70 |
71 | if (!response.ok) {
72 | const errorMessage = await response.text()
73 | return { code: -1, msg: `获取缓存数据失败: ${response.statusText} - ${errorMessage}` }
74 | }
75 |
76 | return response.json()
77 | } catch (error) {
78 | return { code: -1, msg: `获取缓存数据失败:${error}` }
79 | }
80 | }
81 |
82 | export async function getCacheDataByKey(props: GetCacheDataProps): Promise> {
83 | try {
84 | const res = await getCacheData(props)
85 |
86 | if (res.code !== 0) {
87 | return { code: -1, msg: '获取缓存数据失败' }
88 | }
89 |
90 | const raw = res.data?.data
91 | if (!raw) {
92 | return { code: 0, msg: '缓存数据为空' }
93 | }
94 |
95 | const data = JSON.parse(raw) as T
96 |
97 | return { code: 0, data, msg: '获取缓存数据成功' }
98 | } catch (error) {
99 | return { code: -1, msg: `获取缓存数据失败:${error}` }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/app/(main)/home/JueJinArticles.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react'
2 | import { ContentCard } from './ContentCard'
3 | import Link from 'next/link'
4 | import { Article } from '@/generated/prisma/client'
5 | import { TimeInSeconds } from '@/lib/enums'
6 | import dayjs from 'dayjs'
7 |
8 | function NoFound() {
9 | return No articles found.
10 | }
11 |
12 | function ArticleList({ articles }: { articles: Article[] }) {
13 | return (
14 |
15 | {articles?.map((article) => (
16 |
20 |
25 |
{article.title}
26 |
27 | {article.summary || 'No description available'}
28 |
29 |
30 |
31 |
32 | {article.favorites}
33 |
34 |
35 |
36 | {article.likes}
37 |
38 |
39 |
40 | {article.views}
41 |
42 |
43 |
44 | {dayjs(article.createdAt).format('YYYY-MM-DD HH:mm:ss')}
45 |
46 |
47 |
48 |
49 | ))}
50 |
51 | )
52 | }
53 |
54 | async function getJueJinArticles() {
55 | try {
56 | const res = await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/articles/all`, {
57 | next: { revalidate: TimeInSeconds.oneHour }
58 | })
59 | const json = await res.json()
60 | return json.code === 0 ? json.data : []
61 | } catch {
62 | return []
63 | }
64 | }
65 |
66 | export async function JueJinArticles() {
67 | const list = (await getJueJinArticles()) as Article[]
68 |
69 | // 先安收藏数排序获取前六个,然后根据创建时间排序
70 | const articles = list
71 | .sort((a, b) => b.likes - a.likes)
72 | .slice(0, 6)
73 | .sort((a, b) => dayjs(b.createdAt).unix() - dayjs(a.createdAt).unix())
74 |
75 | return (
76 |
77 | {articles.length === 0 ? : }
78 |
79 | )
80 | }
81 |
--------------------------------------------------------------------------------
/lib/github/pinned-repos.ts:
--------------------------------------------------------------------------------
1 | import { TimeInSeconds } from '../enums'
2 | import { createCacheData } from '../cache-data'
3 | import type { ApiRes } from '../utils'
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 | export interface GithubPinnedRepoInfo {
32 | id: number
33 | name: string
34 | description: string
35 | url: string
36 | stargazerCount: number
37 | forkCount: number
38 | primaryLanguage: {
39 | name: string
40 | color: string
41 | }
42 | }
43 |
44 | type Edges = Record[]
45 |
46 | const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql'
47 | const GITHUB_API_TOKEN = process.env.GITHUB_API_TOKEN
48 | const GITHUB_USER_NAME = process.env.NEXT_PUBLIC_GITHUB_USER_NAME
49 |
50 | // 获取 GitHub 置顶项目
51 | async function getPinnedRepos(): Promise> {
52 | if (!GITHUB_USER_NAME || !GITHUB_API_TOKEN) {
53 | return { code: -1, msg: 'GitHub 用户名或 token 未配置' }
54 | }
55 |
56 | try {
57 | const res = await fetch(GITHUB_GRAPHQL_URL, {
58 | method: 'POST',
59 | headers: {
60 | 'Content-Type': 'application/json',
61 | Authorization: `Bearer ${GITHUB_API_TOKEN}`
62 | },
63 | body: JSON.stringify({ query: buildQuery(GITHUB_USER_NAME) }),
64 | next: { revalidate: TimeInSeconds.oneHour } // 数据缓存一个小时
65 | })
66 |
67 | if (!res.ok) {
68 | const errMsg = await res.text()
69 | return { code: -1, msg: `获取 GitHub 仓库信息失败: ${res.statusText} - ${errMsg}` }
70 | }
71 |
72 | const dataRes = await res.json()
73 | const edges: Edges = dataRes?.data?.user?.pinnedItems?.edges ?? []
74 | const data = edges.map((edge) => edge.node as GithubPinnedRepoInfo) || []
75 |
76 | return { code: 0, data, msg: '获取 GitHub 置顶项目成功' }
77 | } catch (error) {
78 | return { code: -1, msg: `获取 GitHub 置顶项目失败:${error}` }
79 | }
80 | }
81 |
82 | export const GitHubPinnedReposCacheDataKey = 'github_pinned_repos'
83 |
84 | export async function saveGitHubPinnedReposToCache(): Promise {
85 | try {
86 | const res = await getPinnedRepos()
87 |
88 | if (res.code !== 0) {
89 | return { code: -1, msg: res.msg }
90 | }
91 |
92 | const data = res.data
93 |
94 | if (!data || !Array.isArray(data)) {
95 | return { code: -1, msg: '获取 GitHub 置顶项目失败' }
96 | }
97 |
98 | return createCacheData({
99 | key: GitHubPinnedReposCacheDataKey,
100 | data: JSON.stringify(data),
101 | desc: 'GitHub 置顶项目'
102 | })
103 | } catch (error) {
104 | return { code: -1, msg: `保存 GitHub 置顶项目失败:${error}` }
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/public/favicon/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/article/components/header.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button'
2 | import { Input } from '@/components/ui/input'
3 | import { AnyObject, PublishArticleInfo } from '@/types'
4 | import { PublishDialog } from '@/app/article/components/publish-dialog'
5 | import { useRouter, usePathname } from 'next/navigation'
6 | import { toast } from 'sonner'
7 | import { Updater } from 'use-immer'
8 | import { ApiRes } from '@/lib/utils'
9 |
10 | const SubmitArticleConfig = {
11 | add: {
12 | url: '/api/articles/add',
13 | method: 'POST',
14 | successMsg: '文章发布成功!',
15 | errorMsg: '发布文章时出现错误!'
16 | },
17 | update: {
18 | url: '/api/articles/update',
19 | method: 'PUT',
20 | successMsg: '编辑成功!',
21 | errorMsg: '编辑文章时出现错误!'
22 | }
23 | }
24 |
25 | async function submitArticle(info: PublishArticleInfo, type: 'add' | 'update'): Promise {
26 | if (!info.title) {
27 | toast('文章标题不能为空!')
28 | return { code: -1, msg: '文章标题不能为空!' }
29 | }
30 |
31 | if (!info.content) {
32 | toast('文章内容不能为空!')
33 | return { code: -1, msg: '文章内容不能为空!' }
34 | }
35 |
36 | const config = SubmitArticleConfig[type]
37 |
38 | try {
39 | const res = await fetch(config.url, {
40 | method: config.method,
41 | headers: {
42 | 'Content-Type': 'application/json'
43 | },
44 | body: JSON.stringify(info)
45 | }).then((res) => res.json())
46 |
47 | if (res.code === 0) {
48 | toast(config.successMsg)
49 | return { code: 0, msg: config.successMsg }
50 | } else {
51 | toast(config.errorMsg)
52 | return { code: 500, msg: config.errorMsg }
53 | }
54 | } catch (error) {
55 | console.error(error)
56 | toast(config.errorMsg)
57 | return { code: 500, msg: config.errorMsg }
58 | }
59 | }
60 |
61 | interface HeaderProps {
62 | articleInfo: PublishArticleInfo
63 | publishButName: string
64 | updateArticleInfo: Updater
65 | }
66 |
67 | function LayoutHeader({ publishButName, articleInfo, updateArticleInfo }: HeaderProps) {
68 | const pathName = usePathname()
69 |
70 | const router = useRouter()
71 |
72 | async function onPublish(info: AnyObject) {
73 | const res = pathName.includes('edit')
74 | ? await submitArticle({ ...articleInfo, ...info }, 'update')
75 | : await submitArticle({ ...articleInfo, ...info }, 'add')
76 |
77 | if (res.code == 0) {
78 | router.push('/article/list')
79 | }
80 |
81 | return res
82 | }
83 |
84 | return (
85 |
86 |
89 | updateArticleInfo((d) => {
90 | d.title = e.target.value
91 | })
92 | }
93 | placeholder="输入文章标题..."
94 | className="bg-white !text-2xl text-black font-medium border-none rounded-none shadow-none ring-0 !ring-offset-0 focus-visible:ring-0"
95 | />
96 |
97 |
98 | {publishButName}
99 |
100 |
101 | )
102 | }
103 |
104 | export { LayoutHeader }
105 |
--------------------------------------------------------------------------------
/app/(main)/article/[id]/anchor/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React, { useEffect, useState, useRef } from 'react'
4 | import { generateUUID } from '@/lib/utils'
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 = generateUUID()
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 |
106 | )
107 | }
108 |
--------------------------------------------------------------------------------
/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 '@/lib/prisma'
7 | import { AnyObject } from '@/types'
8 | import { generateUUID } from '@/lib/utils'
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 | function createAvatar() {
14 | return `https://api.dicebear.com/7.x/bottts-neutral/svg?seed=${generateUUID()}&size=64`
15 | }
16 |
17 | // 更新用户头像-这里不走接口,更安全
18 | async function updateUserProfilePicture(user?: AnyObject) {
19 | if (!user) {
20 | return
21 | }
22 |
23 | try {
24 | await prisma.user.update({
25 | where: {
26 | id: user.id
27 | },
28 | data: {
29 | image: user.image
30 | }
31 | })
32 | } catch (error) {
33 | console.error(error)
34 | }
35 | }
36 |
37 | export const authOptions: AuthOptions = {
38 | adapter: PrismaAdapter(prisma),
39 | providers: [
40 | CredentialsProvider({
41 | name: 'Credentials',
42 | credentials: {
43 | account: { label: 'Account', type: 'text' },
44 | password: { label: 'Password', type: 'password' }
45 | },
46 | async authorize(credentials) {
47 | const user = await prisma.user.findUnique({
48 | where: { email: credentials?.account, password: credentials?.password }
49 | })
50 |
51 | if (!user) {
52 | return null
53 | } else {
54 | return {
55 | id: user.id,
56 | name: user?.name,
57 | email: user?.email,
58 | image: user?.image,
59 | role: user.role
60 | }
61 | }
62 | }
63 | }),
64 | GitHubProvider({
65 | clientId: AUTH_GITHUB_CLIENT_ID ?? '',
66 | clientSecret: AUTH_GITHUB_CLIENT_SECRET ?? '',
67 | httpOptions: {
68 | timeout: 20000 // 将超时时间设置为10秒(10000毫秒)
69 | }
70 | }),
71 | EmailProvider({
72 | server: {
73 | host: process.env.EMAIL_SERVER_HOST,
74 | port: parseInt(process.env.EMAIL_SERVER_PORT as string),
75 | auth: {
76 | user: process.env.EMAIL_SERVER_USER,
77 | pass: process.env.EMAIL_SERVER_PASSWORD
78 | }
79 | },
80 | from: process.env.EMAIL_FROM
81 | })
82 | ],
83 | pages: {
84 | signIn: '/',
85 | verifyRequest: '/auth/verify-request'
86 | },
87 | session: {
88 | strategy: 'jwt',
89 | maxAge: 24 * 60 * 60 // 过期时间,
90 | },
91 | secret: process.env.NEXTAUTH_SECRET,
92 | callbacks: {
93 | async signIn({ user }) {
94 | // 登录没有头像则设置一个默认头像
95 | if (!user?.image) {
96 | user.image = createAvatar()
97 | updateUserProfilePicture(user)
98 | }
99 | return true
100 | },
101 | async redirect({ baseUrl }) {
102 | return baseUrl
103 | },
104 | async jwt({ token, user }) {
105 | // 如果 user 存在,存储角色信息
106 | if (user) {
107 | token.role = user.role // 将角色存储到 token 中
108 | token.id = user.id // 将用户 id 存储到 token 中
109 | }
110 | return token
111 | },
112 | async session({ session, token }) {
113 | if (token?.role) {
114 | session.user.role = token.role as string
115 | }
116 |
117 | if (token?.id) {
118 | session.user.id = token.id as string
119 | }
120 | return session
121 | }
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/app/actions/image-kit.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { ApiRes } from '@/lib/utils'
4 | import jwt from 'jsonwebtoken'
5 | import { getFileHash } from '@/lib/utils'
6 | import dayjs from 'dayjs'
7 |
8 | interface ImagekitUploadFileRes {
9 | fileId: string
10 | name: string
11 | size: string
12 | versionInfo: {
13 | id: string
14 | name: string
15 | }
16 | filePath: string
17 | url: string
18 | fileType: string
19 | height: number
20 | width: number
21 | thumbnailUrl: string
22 | AITags: null | string
23 | }
24 |
25 | // 根据 hash 获取图片信息
26 | export async function getFileInfoByHash(
27 | fileHash: string
28 | ): Promise> {
29 | try {
30 | if (!fileHash) {
31 | return { code: -1, msg: '文件 hash 不存在' }
32 | }
33 |
34 | const query = {
35 | type: 'file',
36 | searchQuery: `"customMetadata.md5" = "${fileHash}"`,
37 | limit: '1'
38 | }
39 |
40 | const url = `https://api.imagekit.io/v1/files?${new URLSearchParams(query).toString()}`
41 |
42 | const res = await fetch(url, {
43 | method: 'GET',
44 | headers: {
45 | 'Content-Type': 'application/json',
46 | Authorization: `Basic ${btoa(process.env.IMAGEKIT_PRIVATE_KEY + ':')}`
47 | }
48 | }).then((res) => res.json())
49 |
50 | if (!Array.isArray(res) || res.length === 0) {
51 | return { code: 0, data: [], msg: '文件不存在!' }
52 | }
53 |
54 | return { code: 0, data: res, msg: '获取文件成功!' }
55 | } catch (error) {
56 | console.error(error)
57 | return { code: -1, msg: 'Failed to fetch article' }
58 | }
59 | }
60 |
61 | export async function generateToken(payload: Record): Promise> {
62 | try {
63 | const privateKey = process.env.IMAGEKIT_PRIVATE_KEY ?? ''
64 |
65 | if (!privateKey) {
66 | return { code: -1, msg: 'IMAGEKIT_PRIVATE_KEY 不存在创建 token 失败!' }
67 | }
68 |
69 | const token = jwt.sign(payload, privateKey, {
70 | expiresIn: 60, // token 过期时间最大 3600 秒
71 | header: {
72 | alg: 'HS256',
73 | typ: 'JWT',
74 | kid: process.env.NEXT_PUBLIC_IMAGEKIT_PUBLIC_KEY
75 | }
76 | })
77 |
78 | return { code: 0, data: token, msg: '创建 token 成功!' }
79 | } catch (err) {
80 | console.error(err)
81 | return { code: -1, msg: '创建 token 失败!' }
82 | }
83 | }
84 |
85 | interface ImagekitUploadFileOpts {
86 | file: File
87 | fileName: string
88 | }
89 |
90 | // 文件上传
91 | export async function uploadFile({
92 | file,
93 | fileName
94 | }: ImagekitUploadFileOpts): Promise> {
95 | try {
96 | const fileHash = await getFileHash(file)
97 |
98 | console.log(fileHash)
99 |
100 | const exist = await getFileInfoByHash(fileHash)
101 |
102 | console.log('exist', exist)
103 |
104 | if (exist.code === 0 && exist.data?.length) {
105 | return { code: 0, data: exist.data[0], msg: '上传文件成功!' }
106 | }
107 |
108 | const payload = {
109 | fileName,
110 | customMetadata: JSON.stringify({ md5: fileHash }),
111 | folder: `/blog/${dayjs().format('YYYY-MM-DD')}`
112 | }
113 |
114 | const tokenRes = await generateToken(payload)
115 | console.log('tokenRes', tokenRes)
116 |
117 | if (tokenRes.code !== 0) {
118 | return { ...tokenRes, data: undefined }
119 | }
120 |
121 | const formData = new FormData()
122 | Object.entries({ ...payload, file, token: tokenRes.data }).forEach(([key, value]) =>
123 | formData.append(key, value as Blob | string)
124 | )
125 |
126 | const uploadRes = await fetch('https://upload.imagekit.io/api/v2/files/upload', {
127 | method: 'POST',
128 | body: formData
129 | })
130 |
131 | console.log('uploadRes', uploadRes)
132 |
133 | if (!uploadRes.ok) {
134 | return { code: -1, msg: '上传文件失败!' }
135 | }
136 |
137 | const data = await uploadRes.json()
138 |
139 | return { code: 0, data, msg: '上传成功!' }
140 | } catch (error) {
141 | console.log(error)
142 | return { code: 0, data: undefined, msg: '上传异常!' }
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "blog",
3 | "version": "0.2.0",
4 | "scripts": {
5 | "preinstall": "npx only-allow pnpm",
6 | "dev": "next dev --turbopack",
7 | "build": "next build --turbopack",
8 | "postbuild": "next-sitemap",
9 | "start": "next start",
10 | "lint": "eslint .",
11 | "lint:fix": "eslint --fix",
12 | "format": "prettier --write .",
13 | "prepare": "npx simple-git-hooks"
14 | },
15 | "dependencies": {
16 | "@auth/prisma-adapter": "^2.11.1",
17 | "@bytemd/plugin-breaks": "^1.22.0",
18 | "@bytemd/plugin-footnotes": "^1.12.4",
19 | "@bytemd/plugin-frontmatter": "^1.22.0",
20 | "@bytemd/plugin-gemoji": "^1.22.0",
21 | "@bytemd/plugin-gfm": "^1.22.0",
22 | "@bytemd/plugin-highlight-ssr": "^1.22.0",
23 | "@bytemd/plugin-math": "^1.22.0",
24 | "@bytemd/plugin-math-ssr": "^1.22.0",
25 | "@bytemd/plugin-medium-zoom": "^1.22.0",
26 | "@bytemd/plugin-mermaid": "^1.22.0",
27 | "@bytemd/react": "^1.22.0",
28 | "@hookform/resolvers": "^5.2.2",
29 | "@iconify/react": "^6.0.2",
30 | "@prisma/adapter-mariadb": "^7.1.0",
31 | "@prisma/client": "^7.1.0",
32 | "@radix-ui/react-alert-dialog": "^1.1.15",
33 | "@radix-ui/react-avatar": "^1.1.11",
34 | "@radix-ui/react-dialog": "^1.1.15",
35 | "@radix-ui/react-dropdown-menu": "^2.1.16",
36 | "@radix-ui/react-label": "^2.1.8",
37 | "@radix-ui/react-popover": "^1.1.15",
38 | "@radix-ui/react-scroll-area": "^1.2.10",
39 | "@radix-ui/react-select": "^2.2.6",
40 | "@radix-ui/react-separator": "^1.1.8",
41 | "@radix-ui/react-slot": "^1.2.4",
42 | "@radix-ui/react-tabs": "^1.1.13",
43 | "@radix-ui/react-toast": "^1.2.15",
44 | "@radix-ui/react-tooltip": "^1.2.8",
45 | "@tailwindcss/typography": "^0.5.19",
46 | "@vercel/analytics": "^1.6.1",
47 | "bcrypt": "^6.0.0",
48 | "bytemd": "^1.22.0",
49 | "class-variance-authority": "^0.7.1",
50 | "clsx": "^2.1.1",
51 | "dayjs": "^1.11.19",
52 | "dotenv": "^17.2.3",
53 | "framer-motion": "^12.23.25",
54 | "highlight.js": "^11.11.1",
55 | "jsonwebtoken": "^9.0.3",
56 | "juejin-markdown-themes": "^1.34.0",
57 | "lucide-react": "^0.556.0",
58 | "next": "^16.0.7",
59 | "next-auth": "^4.24.13",
60 | "next-themes": "^0.4.6",
61 | "nodemailer": "^7.0.11",
62 | "react": "^19.2.1",
63 | "react-dom": "^19.2.1",
64 | "react-hook-form": "^7.68.0",
65 | "react-spinners": "^0.17.0",
66 | "rss": "^1.2.2",
67 | "sonner": "^2.0.7",
68 | "tailwind-merge": "^3.4.0",
69 | "tailwindcss-animate": "^1.0.7",
70 | "use-immer": "^0.11.0",
71 | "uuid": "^13.0.0",
72 | "zod": "^4.1.13"
73 | },
74 | "devDependencies": {
75 | "@commitlint/cli": "^20.2.0",
76 | "@commitlint/config-conventional": "^20.2.0",
77 | "@eslint/eslintrc": "^3.3.3",
78 | "@tailwindcss/postcss": "^4.1.17",
79 | "@types/jsonwebtoken": "^9.0.10",
80 | "@types/node": "^22.19.1",
81 | "@types/nodemailer": "^7.0.4",
82 | "@types/react": "^19.2.7",
83 | "@types/react-dom": "^19.2.3",
84 | "@types/rss": "^0.0.32",
85 | "@types/uuid": "^10.0.0",
86 | "eslint": "^9.39.1",
87 | "eslint-config-next": "15.0.3",
88 | "eslint-config-prettier": "^10.1.8",
89 | "lint-staged": "^16.2.7",
90 | "next-sitemap": "^4.2.3",
91 | "postcss": "^8.5.6",
92 | "prettier": "^3.7.4",
93 | "prisma": "^7.1.0",
94 | "sass": "^1.94.2",
95 | "simple-git-hooks": "^2.13.1",
96 | "tailwindcss": "^4.1.17",
97 | "typescript": "^5.9.3"
98 | },
99 | "simple-git-hooks": {
100 | "pre-commit": "npx lint-staged",
101 | "commit-msg": "npx commitlint --edit $1"
102 | },
103 | "lint-staged": {
104 | "src/**/*.{tsx,ts}": [
105 | "eslint --fix"
106 | ]
107 | },
108 | "pnpm": {
109 | "onlyBuiltDependencies": [
110 | "@parcel/watcher",
111 | "@prisma/client",
112 | "@prisma/engines",
113 | "@tailwindcss/oxide",
114 | "bcrypt",
115 | "prisma",
116 | "sharp",
117 | "simple-git-hooks",
118 | "unrs-resolver"
119 | ]
120 | }
121 | }
--------------------------------------------------------------------------------
/app/(main)/article/list/page.tsx:
--------------------------------------------------------------------------------
1 | import { Article } from '@/generated/prisma/client'
2 | import { Card } from '@/components/ui/card'
3 | import Link from 'next/link'
4 | import { Eye, ThumbsUp, Star } 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 |
16 | {info.title}
17 |
18 | {info.summary}
19 |
20 |
21 |
22 |
23 |
24 | {info.likes}
25 |
26 |
27 |
28 |
29 | {info.favorites}
30 |
31 |
32 |
33 |
34 | {info.views}
35 |
36 |
37 |
38 |
{date}
39 |
40 |
41 | )
42 | }
43 |
44 | const ArticleList = ({ articleInfo }: { articleInfo: GroupedArticles }) => (
45 | <>
46 | {Object.entries(articleInfo)
47 | .sort(([a], [b]) => Number(b) - Number(a))
48 | .map(([year, articles]) => (
49 |
50 |
51 |
{year}
52 |
53 | {articles.length ?? 0} 篇
54 |
55 |
56 |
57 |
58 | {articles.map((article) => (
59 |
63 |
64 |
65 | ))}
66 |
67 |
68 | ))}
69 | >
70 | )
71 |
72 | const groupArticlesByYear = (articles: Article[]): GroupedArticles => {
73 | return articles.reduce((acc: GroupedArticles, article: Article) => {
74 | const year = new Date(article.createdAt).getFullYear().toString()
75 | acc[year] = [...(acc[year] || []), article]
76 | return acc
77 | }, {})
78 | }
79 |
80 | async function getArticles() {
81 | try {
82 | const res = await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/articles/all`, {
83 | next: { revalidate: TimeInSeconds.oneHour } // 缓存 1小时 or 使用 cache: 'no-store' 不缓存
84 | })
85 |
86 | if (!res.ok) {
87 | throw new Error('Failed to fetch articles')
88 | }
89 |
90 | const data = await res.json()
91 | if (data.code !== 0) {
92 | throw new Error(data.message || '获取全部文章失败!')
93 | }
94 |
95 | return groupArticlesByYear(data.data ?? [])
96 | } catch (error) {
97 | console.error('Failed to fetch articles:', error)
98 | throw error
99 | }
100 | }
101 |
102 | export default async function ArticlesPage() {
103 | let articles: GroupedArticles = {}
104 |
105 | try {
106 | articles = await getArticles()
107 | } catch (error) {
108 | return (
109 |
110 |
111 |
112 | {error instanceof Error ? error.message : '获取全部文章失败!'}
113 |
114 |
115 |
116 | )
117 | }
118 |
119 | return (
120 |
121 | {Object.keys(articles).length > 0 ?
:
}
122 |
123 | )
124 | }
125 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { XIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Dialog({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function DialogTrigger({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function DialogPortal({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function DialogClose({
28 | ...props
29 | }: React.ComponentProps) {
30 | return
31 | }
32 |
33 | function DialogOverlay({
34 | className,
35 | ...props
36 | }: React.ComponentProps) {
37 | return (
38 |
46 | )
47 | }
48 |
49 | function DialogContent({
50 | className,
51 | children,
52 | ...props
53 | }: React.ComponentProps) {
54 | return (
55 |
56 |
57 |
65 | {children}
66 |
67 |
68 | Close
69 |
70 |
71 |
72 | )
73 | }
74 |
75 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
76 | return (
77 |
82 | )
83 | }
84 |
85 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
86 | return (
87 |
95 | )
96 | }
97 |
98 | function DialogTitle({
99 | className,
100 | ...props
101 | }: React.ComponentProps) {
102 | return (
103 |
108 | )
109 | }
110 |
111 | function DialogDescription({
112 | className,
113 | ...props
114 | }: React.ComponentProps) {
115 | return (
116 |
121 | )
122 | }
123 |
124 | export {
125 | Dialog,
126 | DialogClose,
127 | DialogContent,
128 | DialogDescription,
129 | DialogFooter,
130 | DialogHeader,
131 | DialogOverlay,
132 | DialogPortal,
133 | DialogTitle,
134 | DialogTrigger,
135 | }
136 |
--------------------------------------------------------------------------------
/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { Slot } from "@radix-ui/react-slot"
6 | import {
7 | Controller,
8 | FormProvider,
9 | useFormContext,
10 | useFormState,
11 | type ControllerProps,
12 | type FieldPath,
13 | type FieldValues,
14 | } from "react-hook-form"
15 |
16 | import { cn } from "@/lib/utils"
17 | import { Label } from "@/components/ui/label"
18 |
19 | const Form = FormProvider
20 |
21 | type FormFieldContextValue<
22 | TFieldValues extends FieldValues = FieldValues,
23 | TName extends FieldPath = FieldPath,
24 | > = {
25 | name: TName
26 | }
27 |
28 | const FormFieldContext = React.createContext(
29 | {} as FormFieldContextValue
30 | )
31 |
32 | const FormField = <
33 | TFieldValues extends FieldValues = FieldValues,
34 | TName extends FieldPath = FieldPath,
35 | >({
36 | ...props
37 | }: ControllerProps) => {
38 | return (
39 |
40 |
41 |
42 | )
43 | }
44 |
45 | const useFormField = () => {
46 | const fieldContext = React.useContext(FormFieldContext)
47 | const itemContext = React.useContext(FormItemContext)
48 | const { getFieldState } = useFormContext()
49 | const formState = useFormState({ name: fieldContext.name })
50 | const fieldState = getFieldState(fieldContext.name, formState)
51 |
52 | if (!fieldContext) {
53 | throw new Error("useFormField should be used within ")
54 | }
55 |
56 | const { id } = itemContext
57 |
58 | return {
59 | id,
60 | name: fieldContext.name,
61 | formItemId: `${id}-form-item`,
62 | formDescriptionId: `${id}-form-item-description`,
63 | formMessageId: `${id}-form-item-message`,
64 | ...fieldState,
65 | }
66 | }
67 |
68 | type FormItemContextValue = {
69 | id: string
70 | }
71 |
72 | const FormItemContext = React.createContext(
73 | {} as FormItemContextValue
74 | )
75 |
76 | function FormItem({ className, ...props }: React.ComponentProps<"div">) {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
86 |
87 | )
88 | }
89 |
90 | function FormLabel({
91 | className,
92 | ...props
93 | }: React.ComponentProps) {
94 | const { error, formItemId } = useFormField()
95 |
96 | return (
97 |
104 | )
105 | }
106 |
107 | function FormControl({ ...props }: React.ComponentProps) {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | }
124 |
125 | function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
126 | const { formDescriptionId } = useFormField()
127 |
128 | return (
129 |
135 | )
136 | }
137 |
138 | function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
139 | const { error, formMessageId } = useFormField()
140 | const body = error ? String(error?.message ?? "") : props.children
141 |
142 | if (!body) {
143 | return null
144 | }
145 |
146 | return (
147 |
153 | {body}
154 |
155 | )
156 | }
157 |
158 | export {
159 | useFormField,
160 | Form,
161 | FormItem,
162 | FormLabel,
163 | FormControl,
164 | FormDescription,
165 | FormMessage,
166 | FormField,
167 | }
168 |
--------------------------------------------------------------------------------
/components/layout/header.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Icon } from '@iconify/react'
4 | import { signOut, useSession } from 'next-auth/react'
5 | import { routerList } from '@/lib/routers'
6 | import Link from 'next/link'
7 | import { useState, useEffect } from 'react'
8 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
9 | import { BlogLogo } from '@/components/blog-logo'
10 | import { Session } from 'next-auth'
11 | import {
12 | DropdownMenu,
13 | DropdownMenuContent,
14 | DropdownMenuItem,
15 | DropdownMenuLabel,
16 | DropdownMenuSeparator,
17 | DropdownMenuTrigger
18 | } from '@/components/ui/dropdown-menu'
19 | import { LoginDialog } from '@/components/login-dialog'
20 | import { usePathname } from 'next/navigation'
21 |
22 | function NavList() {
23 | const pathname = usePathname()
24 |
25 | return (
26 |
27 | {routerList.map((item) => (
28 |
29 |
33 |
34 | {item.name}
35 |
36 |
37 | ))}
38 |
39 | )
40 | }
41 |
42 | function UserAvatar({ session }: { session: Session }) {
43 | return (
44 |
45 |
46 |
47 |
48 |
49 | {session?.user?.name ?? 'll'}
50 |
51 |
52 |
{session?.user?.name ?? 'll'}
53 |
54 |
55 |
56 |
57 | {session?.user.email}
58 |
59 |
60 |
61 | {session?.user?.role === '00' && (
62 |
63 |
64 |
65 |
66 | 写文章
67 |
68 |
69 |
70 | )}
71 |
72 | signOut()}>
73 |
74 |
75 | 退出登录
76 |
77 |
78 |
79 |
80 | )
81 | }
82 |
83 | export default function LayoutHeader() {
84 | const { data: session, status } = useSession()
85 | const [scrolled, setScrolled] = useState(false)
86 |
87 | useEffect(() => {
88 | const handleScroll = () => {
89 | setScrolled(window.scrollY > 10)
90 | }
91 | window.addEventListener('scroll', handleScroll)
92 | return () => window.removeEventListener('scroll', handleScroll)
93 | }, [])
94 |
95 | return (
96 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 | {status === 'unauthenticated' && (
108 |
109 |
110 |
111 | 登录
112 |
113 |
114 | )}
115 |
116 | {status === 'authenticated' &&
}
117 |
118 |
119 |
120 | )
121 | }
122 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client"
3 | output = "../generated/prisma"
4 | }
5 |
6 | datasource db {
7 | provider = "mysql"
8 | }
9 |
10 | model Article {
11 | id String @id @unique
12 | userId Int
13 | title String @db.Text
14 | content String @db.LongText
15 | classify String? @db.Text
16 | coverImg String? @db.Text
17 | summary String @db.Text
18 | source String? @db.Text // 00 博客创建 01 掘金同步
19 | views Int @default(1)
20 | likes Int @default(1)
21 | favorites Int @default(1)
22 | showNumber Int @default(1)
23 | status String @db.Text
24 | createdAt DateTime @default(now())
25 | updatedAt DateTime? @updatedAt
26 | deletedAt DateTime?
27 | isDeleted Int? @default(0)
28 |
29 | @@map("article")
30 | }
31 |
32 | model User {
33 | id String @id @default(cuid())
34 | name String?
35 | username String? @unique
36 | email String? @unique
37 | password String? @db.Text
38 | role String? @default("01")
39 | emailVerified DateTime?
40 | image String?
41 | accounts Account[]
42 | sessions Session[]
43 | // Optional for WebAuthn support
44 | Authenticator Authenticator[]
45 | // 留言
46 | messages Message[]
47 |
48 | createdAt DateTime @default(now())
49 | updatedAt DateTime @updatedAt
50 |
51 | @@map("user")
52 | }
53 |
54 | model Account {
55 | id String @id @default(cuid())
56 | userId String @unique
57 | type String
58 | provider String
59 | providerAccountId String
60 | refresh_token String? @db.Text
61 | access_token String? @db.Text
62 | expires_at Int?
63 | token_type String?
64 | scope String?
65 | id_token String? @db.Text
66 | session_state String?
67 | refresh_token_expires_in Int?
68 | user User? @relation(fields: [userId], references: [id])
69 |
70 | createdAt DateTime @default(now())
71 | updatedAt DateTime @updatedAt
72 |
73 | @@unique([provider, providerAccountId])
74 | @@index([userId])
75 | @@map("account")
76 | }
77 |
78 | model Session {
79 | id String @id @default(cuid())
80 | sessionToken String @unique
81 | userId String
82 | expires DateTime
83 | user User @relation(fields: [userId], references: [id])
84 |
85 | createdAt DateTime @default(now())
86 | updatedAt DateTime @updatedAt
87 |
88 | @@index([userId])
89 | @@map("session")
90 | }
91 |
92 | model VerificationToken {
93 | identifier String
94 | token String
95 | expires DateTime
96 |
97 | @@unique([identifier, token])
98 | @@map("verification_token")
99 | }
100 |
101 | // Optional for WebAuthn support
102 | model Authenticator {
103 | credentialID String @unique
104 | userId String
105 | providerAccountId String
106 | credentialPublicKey String
107 | counter Int
108 | credentialDeviceType String
109 | credentialBackedUp Boolean
110 | transports String?
111 |
112 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
113 |
114 | @@id([userId, credentialID])
115 | @@map("authenticator")
116 | }
117 |
118 | // =============================== 留言板 ===============================
119 | model Message {
120 | id String @id @default(cuid())
121 | content String
122 | createdAt DateTime @default(now())
123 | updatedAt DateTime @updatedAt
124 | deletedAt DateTime? // 删除时间
125 |
126 | author User @relation(fields: [authorId], references: [id])
127 | authorId String
128 |
129 | @@map("message")
130 | }
131 |
132 | // =============================== 订阅 ===============================
133 | model Subscriber {
134 | id String @id @default(cuid())
135 | email String @unique
136 | createdAt DateTime @default(now())
137 | updatedAt DateTime @updatedAt
138 | deletedAt DateTime? // 删除时间
139 |
140 | @@map("subscriber")
141 | }
142 |
143 | // =============================== 缓存数据 ===============================
144 | model CacheData {
145 | id String @id @default(cuid())
146 | key String @unique // 存储数据的 key
147 | data String @db.LongText // 存储的数据 文本 或 json 字符串
148 | desc String @db.Text // 存储数据的描述
149 | createdAt DateTime @default(now())
150 | updatedAt DateTime @updatedAt
151 | deletedAt DateTime? // 删除时间
152 |
153 | @@map("cache-data")
154 | }
155 |
--------------------------------------------------------------------------------
/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 [messageView, setMessageView] = useState(false)
94 | const [message, setMessage] = useState('')
95 | const { data: session, status } = useSession()
96 |
97 | const sendMsg = async () => {
98 | if (!message.trim()) {
99 | toast('留言内容不能为空!')
100 | return
101 | }
102 |
103 | const res = await fetch('/api/guestbook', {
104 | method: 'POST',
105 | headers: {
106 | 'Content-Type': 'application/json'
107 | },
108 | body: JSON.stringify({
109 | content: message,
110 | userEmail: session?.user?.email
111 | })
112 | }).then((res) => res.json())
113 |
114 | if (res.code !== 0) {
115 | toast(res.msg)
116 | return
117 | }
118 |
119 | toast('留言成功!')
120 |
121 | setMessageView(false)
122 |
123 | setMessages((oldData) => [{ ...res.data, author: session?.user }, ...oldData])
124 |
125 | setMessage('')
126 | }
127 |
128 | function messageChange(value: string) {
129 | if (message.length > 1000) {
130 | return
131 | }
132 |
133 | setMessage(value)
134 | }
135 |
136 | return (
137 |
138 | {status === 'authenticated' ? (
139 |
140 | {messageView ? (
141 |
142 | ) : (
143 |
144 | )}
145 |
146 | setMessageView(!messageView)}
150 | onSendMsg={sendMsg}
151 | />
152 |
153 | ) : (
154 |
155 |
156 |
157 | 登录后才可以留言!
158 |
159 |
160 | )}
161 |
162 | )
163 | }
164 |
165 | export { AddMessage }
166 | export default AddMessage
167 |
--------------------------------------------------------------------------------
/app/(main)/home/UserProfile.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@iconify/react'
2 | import Link from 'next/link'
3 | import Image from 'next/image'
4 | import userIcon from '@/public/user-icon.png'
5 | import type { GithubUserInfo } from '@/lib/github/user-info'
6 | import { GithubUserInfoCacheDataKey } from '@/lib/github/user-info'
7 | import { getCacheDataByKey } from '@/lib/cache-data'
8 | import type { JuejinUserInfo } from '@/lib/juejin/fetch-user-info'
9 | import { fetchJuejinUserInfo } from '@/lib/juejin/fetch-user-info'
10 | import { TimeInSeconds } from '@/lib/enums'
11 |
12 | // 统计项组件
13 | const StatItem = ({ icon, label, value }: { icon: string; label: string; value?: number }) => (
14 |
15 |
16 | {label}:
17 | {value ?? '-'}
18 |
19 | )
20 |
21 | // 社交媒体链接配置
22 | const SOCIAL_LINKS = {
23 | juejin: {
24 | url: 'https://juejin.cn/user/712139266339694',
25 | icon: 'simple-icons:juejin',
26 | label: '掘金'
27 | },
28 | github: {
29 | url: 'https://github.com/vaebe',
30 | icon: 'mdi:github',
31 | label: 'GitHub'
32 | }
33 | } as const
34 |
35 | interface SocialStatsSectionProps {
36 | platform: keyof typeof SOCIAL_LINKS
37 | stats: { icon: string; label: string; value?: number }[]
38 | }
39 |
40 | // 社交媒体统计区块
41 | const SocialStatsSection = ({ platform, stats }: SocialStatsSectionProps) => (
42 |
43 |
49 |
50 |
51 | {SOCIAL_LINKS[platform].label}
52 |
53 |
54 |
55 | {stats.map((stat, index) => (
56 |
57 | ))}
58 |
59 |
60 | )
61 |
62 | function GitHubSocialStatsSection({ info }: { info?: GithubUserInfo }) {
63 | return (
64 |
80 | )
81 | }
82 |
83 | async function JuejinSocialStatsSection() {
84 | let info: JuejinUserInfo | undefined
85 |
86 | try {
87 | info = await fetchJuejinUserInfo()
88 | } catch {
89 | info = undefined
90 | }
91 |
92 | return (
93 |
105 | )
106 | }
107 |
108 | const Userdescription = `
109 | 我是 Vaebe,我的主要技术栈是 Vue 全家桶,目前也在使用 React 来构建项目,比如这个博客它使用 Next.js。
110 | 我会将自己的实践过程以文章的形式分享在掘金上,并在 GitHub上参与开源项目,不断提升自己的编程技能。
111 | 欢迎访问我的掘金主页和 GitHub主页,了解更多关于我的信息!`
112 |
113 | // 主要用户信息组件
114 | const UserInfo = ({ githubUserInfo }: { githubUserInfo?: GithubUserInfo }) => (
115 |
116 |
124 |
125 |
126 | {githubUserInfo?.login ?? 'Loading...'}
127 |
128 |
{Userdescription}
129 |
130 |
131 | )
132 |
133 | export async function UserProfile() {
134 | let githubUserInfo: GithubUserInfo | undefined
135 | try {
136 | const res = await getCacheDataByKey({
137 | key: GithubUserInfoCacheDataKey,
138 | next: { revalidate: TimeInSeconds.oneHour }
139 | })
140 | if (res.code === 0) {
141 | githubUserInfo = res.data
142 | }
143 | } catch {
144 | githubUserInfo = undefined
145 | }
146 |
147 | return (
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 | )
157 | }
158 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 |
3 | @plugin "tailwindcss-animate";
4 |
5 | @custom-variant dark (&:is(.dark *));
6 |
7 | @theme {
8 | --font-sans: var(--font-geist-sans);
9 | --font-mono: var(--font-geist-mono);
10 | }
11 |
12 | :root {
13 | --radius: 0.625rem;
14 |
15 | --background: oklch(1 0 0);
16 |
17 | --foreground: oklch(0.145 0 0);
18 |
19 | --card: oklch(1 0 0);
20 |
21 | --card-foreground: oklch(0.145 0 0);
22 |
23 | --popover: oklch(1 0 0);
24 |
25 | --popover-foreground: oklch(0.145 0 0);
26 |
27 | --primary: oklch(0.205 0 0);
28 |
29 | --primary-foreground: oklch(0.985 0 0);
30 |
31 | --secondary: oklch(0.97 0 0);
32 |
33 | --secondary-foreground: oklch(0.205 0 0);
34 |
35 | --muted: oklch(0.97 0 0);
36 |
37 | --muted-foreground: oklch(0.556 0 0);
38 |
39 | --accent: oklch(0.97 0 0);
40 |
41 | --accent-foreground: oklch(0.205 0 0);
42 |
43 | --destructive: oklch(0.577 0.245 27.325);
44 |
45 | --border: oklch(0.922 0 0);
46 |
47 | --input: oklch(0.922 0 0);
48 |
49 | --ring: oklch(0.708 0 0);
50 |
51 | --chart-1: oklch(0.646 0.222 41.116);
52 |
53 | --chart-2: oklch(0.6 0.118 184.704);
54 |
55 | --chart-3: oklch(0.398 0.07 227.392);
56 |
57 | --chart-4: oklch(0.828 0.189 84.429);
58 |
59 | --chart-5: oklch(0.769 0.188 70.08);
60 |
61 | --sidebar: oklch(0.985 0 0);
62 |
63 | --sidebar-foreground: oklch(0.145 0 0);
64 |
65 | --sidebar-primary: oklch(0.205 0 0);
66 |
67 | --sidebar-primary-foreground: oklch(0.985 0 0);
68 |
69 | --sidebar-accent: oklch(0.97 0 0);
70 |
71 | --sidebar-accent-foreground: oklch(0.205 0 0);
72 |
73 | --sidebar-border: oklch(0.922 0 0);
74 |
75 | --sidebar-ring: oklch(0.708 0 0);
76 | }
77 |
78 | .dark {
79 | --background: oklch(0.145 0 0);
80 |
81 | --foreground: oklch(0.985 0 0);
82 |
83 | --card: oklch(0.205 0 0);
84 |
85 | --card-foreground: oklch(0.985 0 0);
86 |
87 | --popover: oklch(0.205 0 0);
88 |
89 | --popover-foreground: oklch(0.985 0 0);
90 |
91 | --primary: oklch(0.922 0 0);
92 |
93 | --primary-foreground: oklch(0.205 0 0);
94 |
95 | --secondary: oklch(0.269 0 0);
96 |
97 | --secondary-foreground: oklch(0.985 0 0);
98 |
99 | --muted: oklch(0.269 0 0);
100 |
101 | --muted-foreground: oklch(0.708 0 0);
102 |
103 | --accent: oklch(0.269 0 0);
104 |
105 | --accent-foreground: oklch(0.985 0 0);
106 |
107 | --destructive: oklch(0.704 0.191 22.216);
108 |
109 | --border: oklch(1 0 0 / 10%);
110 |
111 | --input: oklch(1 0 0 / 15%);
112 |
113 | --ring: oklch(0.556 0 0);
114 |
115 | --chart-1: oklch(0.488 0.243 264.376);
116 |
117 | --chart-2: oklch(0.696 0.17 162.48);
118 |
119 | --chart-3: oklch(0.769 0.188 70.08);
120 |
121 | --chart-4: oklch(0.627 0.265 303.9);
122 |
123 | --chart-5: oklch(0.645 0.246 16.439);
124 |
125 | --sidebar: oklch(0.205 0 0);
126 |
127 | --sidebar-foreground: oklch(0.985 0 0);
128 |
129 | --sidebar-primary: oklch(0.488 0.243 264.376);
130 |
131 | --sidebar-primary-foreground: oklch(0.985 0 0);
132 |
133 | --sidebar-accent: oklch(0.269 0 0);
134 |
135 | --sidebar-accent-foreground: oklch(0.985 0 0);
136 |
137 | --sidebar-border: oklch(1 0 0 / 10%);
138 |
139 | --sidebar-ring: oklch(0.556 0 0);
140 | }
141 |
142 | @theme inline {
143 | --radius-sm: calc(var(--radius) - 4px);
144 |
145 | --radius-md: calc(var(--radius) - 2px);
146 |
147 | --radius-lg: var(--radius);
148 |
149 | --radius-xl: calc(var(--radius) + 4px);
150 |
151 | --color-background: var(--background);
152 |
153 | --color-foreground: var(--foreground);
154 |
155 | --color-card: var(--card);
156 |
157 | --color-card-foreground: var(--card-foreground);
158 |
159 | --color-popover: var(--popover);
160 |
161 | --color-popover-foreground: var(--popover-foreground);
162 |
163 | --color-primary: var(--primary);
164 |
165 | --color-primary-foreground: var(--primary-foreground);
166 |
167 | --color-secondary: var(--secondary);
168 |
169 | --color-secondary-foreground: var(--secondary-foreground);
170 |
171 | --color-muted: var(--muted);
172 |
173 | --color-muted-foreground: var(--muted-foreground);
174 |
175 | --color-accent: var(--accent);
176 |
177 | --color-accent-foreground: var(--accent-foreground);
178 |
179 | --color-destructive: var(--destructive);
180 |
181 | --color-border: var(--border);
182 |
183 | --color-input: var(--input);
184 |
185 | --color-ring: var(--ring);
186 |
187 | --color-chart-1: var(--chart-1);
188 |
189 | --color-chart-2: var(--chart-2);
190 |
191 | --color-chart-3: var(--chart-3);
192 |
193 | --color-chart-4: var(--chart-4);
194 |
195 | --color-chart-5: var(--chart-5);
196 |
197 | --color-sidebar: var(--sidebar);
198 |
199 | --color-sidebar-foreground: var(--sidebar-foreground);
200 |
201 | --color-sidebar-primary: var(--sidebar-primary);
202 |
203 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
204 |
205 | --color-sidebar-accent: var(--sidebar-accent);
206 |
207 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
208 |
209 | --color-sidebar-border: var(--sidebar-border);
210 |
211 | --color-sidebar-ring: var(--sidebar-ring);
212 | }
213 |
214 | @layer base {
215 | * {
216 | @apply border-border outline-ring/50;
217 | }
218 |
219 | body {
220 | @apply bg-background text-foreground;
221 | }
222 | }
223 |
224 | /* Chrome, Edge, Safari 和其他 WebKit 内核的浏览器 */
225 | ::-webkit-scrollbar {
226 | width: 0;
227 | background: transparent;
228 | }
229 |
230 | /* Firefox */
231 | * {
232 | scrollbar-width: none;
233 | -ms-overflow-style: none;
234 | }
235 |
236 |
237 | @keyframes gradient-x {
238 | 0% {
239 | background-position: 0% 50%;
240 | }
241 |
242 | 50% {
243 | background-position: 100% 50%;
244 | }
245 |
246 | 100% {
247 | background-position: 0% 50%;
248 | }
249 | }
250 |
251 | .gradient-anim {
252 | background-size: 200% 200%;
253 | animation: gradient-x 3s ease infinite;
254 | }
--------------------------------------------------------------------------------
/prisma/migrations/20250907134927_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE `article` (
3 | `id` VARCHAR(191) NOT NULL,
4 | `userId` INTEGER NOT NULL,
5 | `title` TEXT NOT NULL,
6 | `content` LONGTEXT NOT NULL,
7 | `classify` TEXT NULL,
8 | `coverImg` TEXT NULL,
9 | `summary` TEXT NOT NULL,
10 | `source` TEXT NULL,
11 | `views` INTEGER NOT NULL DEFAULT 1,
12 | `likes` INTEGER NOT NULL DEFAULT 1,
13 | `favorites` INTEGER NOT NULL DEFAULT 1,
14 | `showNumber` INTEGER NOT NULL DEFAULT 1,
15 | `status` TEXT NOT NULL,
16 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
17 | `updatedAt` DATETIME(3) NULL,
18 | `deletedAt` DATETIME(3) NULL,
19 | `isDeleted` INTEGER NULL DEFAULT 0,
20 |
21 | UNIQUE INDEX `article_id_key`(`id`),
22 | PRIMARY KEY (`id`)
23 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
24 |
25 | -- CreateTable
26 | CREATE TABLE `user` (
27 | `id` VARCHAR(191) NOT NULL,
28 | `name` VARCHAR(191) NULL,
29 | `username` VARCHAR(191) NULL,
30 | `email` VARCHAR(191) NULL,
31 | `password` TEXT NULL,
32 | `role` VARCHAR(191) NULL DEFAULT '01',
33 | `emailVerified` DATETIME(3) NULL,
34 | `image` VARCHAR(191) NULL,
35 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
36 | `updatedAt` DATETIME(3) NOT NULL,
37 |
38 | UNIQUE INDEX `user_username_key`(`username`),
39 | UNIQUE INDEX `user_email_key`(`email`),
40 | PRIMARY KEY (`id`)
41 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
42 |
43 | -- CreateTable
44 | CREATE TABLE `account` (
45 | `id` VARCHAR(191) NOT NULL,
46 | `userId` VARCHAR(191) NOT NULL,
47 | `type` VARCHAR(191) NOT NULL,
48 | `provider` VARCHAR(191) NOT NULL,
49 | `providerAccountId` VARCHAR(191) NOT NULL,
50 | `refresh_token` TEXT NULL,
51 | `access_token` TEXT NULL,
52 | `expires_at` INTEGER NULL,
53 | `token_type` VARCHAR(191) NULL,
54 | `scope` VARCHAR(191) NULL,
55 | `id_token` TEXT NULL,
56 | `session_state` VARCHAR(191) NULL,
57 | `refresh_token_expires_in` INTEGER NULL,
58 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
59 | `updatedAt` DATETIME(3) NOT NULL,
60 |
61 | UNIQUE INDEX `account_userId_key`(`userId`),
62 | INDEX `account_userId_idx`(`userId`),
63 | UNIQUE INDEX `account_provider_providerAccountId_key`(`provider`, `providerAccountId`),
64 | PRIMARY KEY (`id`)
65 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
66 |
67 | -- CreateTable
68 | CREATE TABLE `session` (
69 | `id` VARCHAR(191) NOT NULL,
70 | `sessionToken` VARCHAR(191) NOT NULL,
71 | `userId` VARCHAR(191) NOT NULL,
72 | `expires` DATETIME(3) NOT NULL,
73 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
74 | `updatedAt` DATETIME(3) NOT NULL,
75 |
76 | UNIQUE INDEX `session_sessionToken_key`(`sessionToken`),
77 | INDEX `session_userId_idx`(`userId`),
78 | PRIMARY KEY (`id`)
79 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
80 |
81 | -- CreateTable
82 | CREATE TABLE `verification_token` (
83 | `identifier` VARCHAR(191) NOT NULL,
84 | `token` VARCHAR(191) NOT NULL,
85 | `expires` DATETIME(3) NOT NULL,
86 |
87 | UNIQUE INDEX `verification_token_identifier_token_key`(`identifier`, `token`)
88 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
89 |
90 | -- CreateTable
91 | CREATE TABLE `authenticator` (
92 | `credentialID` VARCHAR(191) NOT NULL,
93 | `userId` VARCHAR(191) NOT NULL,
94 | `providerAccountId` VARCHAR(191) NOT NULL,
95 | `credentialPublicKey` VARCHAR(191) NOT NULL,
96 | `counter` INTEGER NOT NULL,
97 | `credentialDeviceType` VARCHAR(191) NOT NULL,
98 | `credentialBackedUp` BOOLEAN NOT NULL,
99 | `transports` VARCHAR(191) NULL,
100 |
101 | UNIQUE INDEX `authenticator_credentialID_key`(`credentialID`),
102 | PRIMARY KEY (`userId`, `credentialID`)
103 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
104 |
105 | -- CreateTable
106 | CREATE TABLE `message` (
107 | `id` VARCHAR(191) NOT NULL,
108 | `content` VARCHAR(191) NOT NULL,
109 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
110 | `updatedAt` DATETIME(3) NOT NULL,
111 | `deletedAt` DATETIME(3) NULL,
112 | `authorId` VARCHAR(191) NOT NULL,
113 |
114 | PRIMARY KEY (`id`)
115 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
116 |
117 | -- CreateTable
118 | CREATE TABLE `subscriber` (
119 | `id` VARCHAR(191) NOT NULL,
120 | `email` VARCHAR(191) NOT NULL,
121 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
122 | `updatedAt` DATETIME(3) NOT NULL,
123 | `deletedAt` DATETIME(3) NULL,
124 |
125 | UNIQUE INDEX `subscriber_email_key`(`email`),
126 | PRIMARY KEY (`id`)
127 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
128 |
129 | -- CreateTable
130 | CREATE TABLE `cache-data` (
131 | `id` VARCHAR(191) NOT NULL,
132 | `key` VARCHAR(191) NOT NULL,
133 | `data` LONGTEXT NOT NULL,
134 | `desc` TEXT NOT NULL,
135 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
136 | `updatedAt` DATETIME(3) NOT NULL,
137 | `deletedAt` DATETIME(3) NULL,
138 |
139 | UNIQUE INDEX `cache-data_key_key`(`key`),
140 | PRIMARY KEY (`id`)
141 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
142 |
143 | -- AddForeignKey
144 | ALTER TABLE `account` ADD CONSTRAINT `account_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
145 |
146 | -- AddForeignKey
147 | ALTER TABLE `session` ADD CONSTRAINT `session_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
148 |
149 | -- AddForeignKey
150 | ALTER TABLE `authenticator` ADD CONSTRAINT `authenticator_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
151 |
152 | -- AddForeignKey
153 | ALTER TABLE `message` ADD CONSTRAINT `message_authorId_fkey` FOREIGN KEY (`authorId`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
154 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Select({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function SelectGroup({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function SelectValue({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function SelectTrigger({
28 | className,
29 | size = "default",
30 | children,
31 | ...props
32 | }: React.ComponentProps & {
33 | size?: "sm" | "default"
34 | }) {
35 | return (
36 |
45 | {children}
46 |
47 |
48 |
49 |
50 | )
51 | }
52 |
53 | function SelectContent({
54 | className,
55 | children,
56 | position = "popper",
57 | ...props
58 | }: React.ComponentProps) {
59 | return (
60 |
61 |
72 |
73 |
80 | {children}
81 |
82 |
83 |
84 |
85 | )
86 | }
87 |
88 | function SelectLabel({
89 | className,
90 | ...props
91 | }: React.ComponentProps) {
92 | return (
93 |
98 | )
99 | }
100 |
101 | function SelectItem({
102 | className,
103 | children,
104 | ...props
105 | }: React.ComponentProps) {
106 | return (
107 |
115 |
116 |
117 |
118 |
119 |
120 | {children}
121 |
122 | )
123 | }
124 |
125 | function SelectSeparator({
126 | className,
127 | ...props
128 | }: React.ComponentProps) {
129 | return (
130 |
135 | )
136 | }
137 |
138 | function SelectScrollUpButton({
139 | className,
140 | ...props
141 | }: React.ComponentProps) {
142 | return (
143 |
151 |
152 |
153 | )
154 | }
155 |
156 | function SelectScrollDownButton({
157 | className,
158 | ...props
159 | }: React.ComponentProps) {
160 | return (
161 |
169 |
170 |
171 | )
172 | }
173 |
174 | export {
175 | Select,
176 | SelectContent,
177 | SelectGroup,
178 | SelectItem,
179 | SelectLabel,
180 | SelectScrollDownButton,
181 | SelectScrollUpButton,
182 | SelectSeparator,
183 | SelectTrigger,
184 | SelectValue,
185 | }
186 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function DropdownMenu({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function DropdownMenuPortal({
16 | ...props
17 | }: React.ComponentProps) {
18 | return (
19 |
20 | )
21 | }
22 |
23 | function DropdownMenuTrigger({
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
31 | )
32 | }
33 |
34 | function DropdownMenuContent({
35 | className,
36 | sideOffset = 4,
37 | ...props
38 | }: React.ComponentProps) {
39 | return (
40 |
41 |
50 |
51 | )
52 | }
53 |
54 | function DropdownMenuGroup({
55 | ...props
56 | }: React.ComponentProps) {
57 | return (
58 |
59 | )
60 | }
61 |
62 | function DropdownMenuItem({
63 | className,
64 | inset,
65 | variant = "default",
66 | ...props
67 | }: React.ComponentProps & {
68 | inset?: boolean
69 | variant?: "default" | "destructive"
70 | }) {
71 | return (
72 |
82 | )
83 | }
84 |
85 | function DropdownMenuCheckboxItem({
86 | className,
87 | children,
88 | checked,
89 | ...props
90 | }: React.ComponentProps) {
91 | return (
92 |
101 |
102 |
103 |
104 |
105 |
106 | {children}
107 |
108 | )
109 | }
110 |
111 | function DropdownMenuRadioGroup({
112 | ...props
113 | }: React.ComponentProps) {
114 | return (
115 |
119 | )
120 | }
121 |
122 | function DropdownMenuRadioItem({
123 | className,
124 | children,
125 | ...props
126 | }: React.ComponentProps) {
127 | return (
128 |
136 |
137 |
138 |
139 |
140 |
141 | {children}
142 |
143 | )
144 | }
145 |
146 | function DropdownMenuLabel({
147 | className,
148 | inset,
149 | ...props
150 | }: React.ComponentProps & {
151 | inset?: boolean
152 | }) {
153 | return (
154 |
163 | )
164 | }
165 |
166 | function DropdownMenuSeparator({
167 | className,
168 | ...props
169 | }: React.ComponentProps) {
170 | return (
171 |
176 | )
177 | }
178 |
179 | function DropdownMenuShortcut({
180 | className,
181 | ...props
182 | }: React.ComponentProps<"span">) {
183 | return (
184 |
192 | )
193 | }
194 |
195 | function DropdownMenuSub({
196 | ...props
197 | }: React.ComponentProps) {
198 | return
199 | }
200 |
201 | function DropdownMenuSubTrigger({
202 | className,
203 | inset,
204 | children,
205 | ...props
206 | }: React.ComponentProps & {
207 | inset?: boolean
208 | }) {
209 | return (
210 |
219 | {children}
220 |
221 |
222 | )
223 | }
224 |
225 | function DropdownMenuSubContent({
226 | className,
227 | ...props
228 | }: React.ComponentProps) {
229 | return (
230 |
238 | )
239 | }
240 |
241 | export {
242 | DropdownMenu,
243 | DropdownMenuPortal,
244 | DropdownMenuTrigger,
245 | DropdownMenuContent,
246 | DropdownMenuGroup,
247 | DropdownMenuLabel,
248 | DropdownMenuItem,
249 | DropdownMenuCheckboxItem,
250 | DropdownMenuRadioGroup,
251 | DropdownMenuRadioItem,
252 | DropdownMenuSeparator,
253 | DropdownMenuShortcut,
254 | DropdownMenuSub,
255 | DropdownMenuSubTrigger,
256 | DropdownMenuSubContent,
257 | }
258 |
--------------------------------------------------------------------------------
/components/bytemd/dark-theme.scss:
--------------------------------------------------------------------------------
1 | .dark {
2 | .markdown-body {
3 | font-family:
4 | -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif,
5 | 'Apple Color Emoji', 'Segoe UI Emoji';
6 | color: #ffffff;
7 | // background-color: #0d1117;
8 | font-size: 16px;
9 | line-height: 1.5;
10 | word-wrap: break-word;
11 | }
12 |
13 | .markdown-body::before {
14 | display: table;
15 | content: '';
16 | }
17 |
18 | .markdown-body::after {
19 | display: table;
20 | clear: both;
21 | content: '';
22 | }
23 |
24 | .markdown-body > *:first-child {
25 | margin-top: 0 !important;
26 | }
27 |
28 | .markdown-body > *:last-child {
29 | margin-bottom: 0 !important;
30 | }
31 |
32 | .markdown-body a:not([href]) {
33 | color: inherit;
34 | text-decoration: none;
35 | }
36 |
37 | .markdown-body .absent {
38 | color: #f85149;
39 | }
40 |
41 | .markdown-body .anchor {
42 | float: left;
43 | padding-right: 4px;
44 | margin-left: -20px;
45 | line-height: 1;
46 | }
47 |
48 | .markdown-body .anchor:focus {
49 | outline: none;
50 | }
51 |
52 | .markdown-body p,
53 | .markdown-body ul,
54 | .markdown-body ol,
55 | .markdown-body dl,
56 | .markdown-body table,
57 | .markdown-body pre,
58 | .markdown-body details {
59 | margin-top: 0;
60 | margin-bottom: 16px;
61 | }
62 |
63 | .markdown-body hr {
64 | height: 0.25em;
65 | padding: 0;
66 | margin: 24px 0;
67 | background-color: #30363d;
68 | border: 0;
69 | }
70 |
71 | .markdown-body blockquote {
72 | color: #ffffffb3;
73 | padding: 1px 23px;
74 | margin: 22px 0;
75 | border-left: 4px solid #fff3;
76 | background-color: #ffffff1f;
77 |
78 | & p {
79 | margin: 10px 0;
80 | }
81 | }
82 |
83 | .markdown-body h1,
84 | .markdown-body h2,
85 | .markdown-body h3,
86 | .markdown-body h4,
87 | .markdown-body h5,
88 | .markdown-body h6 {
89 | margin-top: 24px;
90 | margin-bottom: 16px;
91 | font-weight: 600;
92 | line-height: 1.25;
93 | }
94 |
95 | .markdown-body h1 .octicon-link,
96 | .markdown-body h2 .octicon-link,
97 | .markdown-body h3 .octicon-link,
98 | .markdown-body h4 .octicon-link,
99 | .markdown-body h5 .octicon-link,
100 | .markdown-body h6 .octicon-link {
101 | color: #c9d1d9;
102 | vertical-align: middle;
103 | visibility: hidden;
104 | }
105 |
106 | .markdown-body h1:hover .anchor,
107 | .markdown-body h2:hover .anchor,
108 | .markdown-body h3:hover .anchor,
109 | .markdown-body h4:hover .anchor,
110 | .markdown-body h5:hover .anchor,
111 | .markdown-body h6:hover .anchor {
112 | text-decoration: none;
113 | }
114 |
115 | .markdown-body h1:hover .anchor .octicon-link,
116 | .markdown-body h2:hover .anchor .octicon-link,
117 | .markdown-body h3:hover .anchor .octicon-link,
118 | .markdown-body h4:hover .anchor .octicon-link,
119 | .markdown-body h5:hover .anchor .octicon-link,
120 | .markdown-body h6:hover .anchor .octicon-link {
121 | visibility: visible;
122 | }
123 |
124 | .markdown-body h1 tt,
125 | .markdown-body h1 code,
126 | .markdown-body h2 tt,
127 | .markdown-body h2 code,
128 | .markdown-body h3 tt,
129 | .markdown-body h3 code,
130 | .markdown-body h4 tt,
131 | .markdown-body h4 code,
132 | .markdown-body h5 tt,
133 | .markdown-body h5 code,
134 | .markdown-body h6 tt,
135 | .markdown-body h6 code {
136 | padding: 0 0.2em;
137 | font-size: inherit;
138 | }
139 |
140 | .markdown-body h1 {
141 | padding-bottom: 0.3em;
142 | font-size: 2em;
143 | border-bottom: 1px solid #21262d;
144 | }
145 |
146 | .markdown-body h2 {
147 | padding-bottom: 0.3em;
148 | font-size: 1.5em;
149 | border-bottom: 1px solid #21262d;
150 | }
151 |
152 | .markdown-body h3 {
153 | font-size: 1.25em;
154 | }
155 |
156 | .markdown-body h4 {
157 | font-size: 1em;
158 | }
159 |
160 | .markdown-body h5 {
161 | font-size: 0.875em;
162 | }
163 |
164 | .markdown-body h6 {
165 | font-size: 0.85em;
166 | color: #8b949e;
167 | }
168 |
169 | .markdown-body ul,
170 | .markdown-body ol {
171 | padding-left: 2em;
172 | }
173 |
174 | .markdown-body ul.no-list,
175 | .markdown-body ol.no-list {
176 | padding: 0;
177 | list-style-type: none;
178 | }
179 |
180 | .markdown-body ul ul,
181 | .markdown-body ul ol,
182 | .markdown-body ol ol,
183 | .markdown-body ol ul {
184 | margin-top: 0;
185 | margin-bottom: 0;
186 | }
187 |
188 | .markdown-body li > p {
189 | margin-top: 16px;
190 | }
191 |
192 | .markdown-body li + li {
193 | margin-top: 0.25em;
194 | }
195 |
196 | .markdown-body dl {
197 | padding: 0;
198 | }
199 |
200 | .markdown-body dl dt {
201 | padding: 0;
202 | margin-top: 16px;
203 | font-size: 1em;
204 | font-style: italic;
205 | font-weight: 600;
206 | }
207 |
208 | .markdown-body dl dd {
209 | padding: 0 16px;
210 | margin-bottom: 16px;
211 | }
212 |
213 | .markdown-body table {
214 | display: block;
215 | width: 100%;
216 | overflow: auto;
217 | }
218 |
219 | .markdown-body table th {
220 | font-weight: 600;
221 | }
222 |
223 | .markdown-body table th,
224 | .markdown-body table td {
225 | padding: 6px 13px;
226 | border: 1px solid #30363d;
227 | }
228 |
229 | .markdown-body table tr {
230 | background-color: #0d1117;
231 | border-top: 1px solid #21262d;
232 | }
233 |
234 | .markdown-body table tr:nth-child(2n) {
235 | background-color: #161b22;
236 | }
237 |
238 | .markdown-body table img {
239 | background-color: transparent;
240 | }
241 |
242 | .markdown-body img {
243 | max-width: 100%;
244 | box-sizing: content-box;
245 | background-color: #0d1117;
246 | }
247 |
248 | .markdown-body img[align='right'] {
249 | padding-left: 20px;
250 | }
251 |
252 | .markdown-body img[align='left'] {
253 | padding-right: 20px;
254 | }
255 |
256 | .markdown-body .emoji {
257 | max-width: none;
258 | vertical-align: text-top;
259 | background-color: transparent;
260 | }
261 |
262 | .markdown-body span.frame {
263 | display: block;
264 | overflow: hidden;
265 | }
266 |
267 | .markdown-body span.frame > span {
268 | display: block;
269 | float: left;
270 | width: auto;
271 | padding: 7px;
272 | margin: 13px 0 0;
273 | overflow: hidden;
274 | border: 1px solid #30363d;
275 | }
276 |
277 | .markdown-body span.frame span img {
278 | display: block;
279 | float: left;
280 | }
281 |
282 | .markdown-body span.frame span span {
283 | display: block;
284 | padding: 5px 0 0;
285 | clear: both;
286 | color: #c9d1d9;
287 | }
288 |
289 | .markdown-body span.align-center {
290 | display: block;
291 | overflow: hidden;
292 | clear: both;
293 | }
294 |
295 | .markdown-body span.align-center > span {
296 | display: block;
297 | margin: 13px auto 0;
298 | overflow: hidden;
299 | text-align: center;
300 | }
301 |
302 | .markdown-body span.align-center span img {
303 | margin: 0 auto;
304 | text-align: center;
305 | }
306 |
307 | .markdown-body span.align-right {
308 | display: block;
309 | overflow: hidden;
310 | clear: both;
311 | }
312 |
313 | .markdown-body span.align-right > span {
314 | display: block;
315 | margin: 13px 0 0;
316 | overflow: hidden;
317 | text-align: right;
318 | }
319 |
320 | .markdown-body span.align-right span img {
321 | margin: 0;
322 | text-align: right;
323 | }
324 |
325 | .markdown-body span.float-left {
326 | display: block;
327 | float: left;
328 | margin-right: 13px;
329 | overflow: hidden;
330 | }
331 |
332 | .markdown-body span.float-left span {
333 | margin: 13px 0 0;
334 | }
335 |
336 | .markdown-body span.float-right {
337 | display: block;
338 | float: right;
339 | margin-left: 13px;
340 | overflow: hidden;
341 | }
342 |
343 | .markdown-body span.float-right > span {
344 | display: block;
345 | margin: 13px auto 0;
346 | overflow: hidden;
347 | text-align: right;
348 | }
349 |
350 | .markdown-body code,
351 | .markdown-body tt {
352 | padding: 0.2em 0.4em;
353 | margin: 0;
354 | font-size: 85%;
355 | background-color: rgba(110, 118, 129, 0.4);
356 | border-radius: 6px;
357 | }
358 |
359 | .markdown-body code br,
360 | .markdown-body tt br {
361 | display: none;
362 | }
363 |
364 | .markdown-body pre > code {
365 | color: #fffc;
366 | }
367 |
368 | .markdown-body del code {
369 | text-decoration: inherit;
370 | }
371 |
372 | .markdown-body pre {
373 | word-wrap: normal;
374 | }
375 |
376 | .markdown-body pre > code {
377 | padding: 0;
378 | margin: 0;
379 | font-size: 100%;
380 | word-break: normal;
381 | white-space: pre;
382 | background: transparent;
383 | border: 0;
384 | }
385 |
386 | .markdown-body .highlight {
387 | margin-bottom: 16px;
388 | }
389 |
390 | .markdown-body .highlight pre {
391 | margin-bottom: 0;
392 | word-break: normal;
393 | }
394 |
395 | .markdown-body .highlight pre,
396 | .markdown-body pre {
397 | padding: 16px;
398 | overflow: auto;
399 | font-size: 85%;
400 | line-height: 1.45;
401 | background-color: #161b22;
402 | border-radius: 6px;
403 | }
404 |
405 | .markdown-body pre code,
406 | .markdown-body pre tt {
407 | display: inline;
408 | max-width: auto;
409 | padding: 0;
410 | margin: 0;
411 | overflow: visible;
412 | line-height: inherit;
413 | word-wrap: normal;
414 | background-color: transparent;
415 | border: 0;
416 | }
417 |
418 | .markdown-body .csv-data td,
419 | .markdown-body .csv-data th {
420 | padding: 5px;
421 | overflow: hidden;
422 | font-size: 12px;
423 | line-height: 1;
424 | text-align: left;
425 | white-space: nowrap;
426 | }
427 |
428 | .markdown-body .csv-data .blob-num {
429 | padding: 10px 8px 9px;
430 | text-align: right;
431 | background: #0d1117;
432 | border: 0;
433 | }
434 |
435 | .markdown-body .csv-data tr {
436 | border-top: 0;
437 | }
438 |
439 | .markdown-body .csv-data th {
440 | font-weight: 600;
441 | background: #161b22;
442 | border-top: 0;
443 | }
444 |
445 | .markdown-body [data-footnote-ref]::before {
446 | content: '[';
447 | }
448 |
449 | .markdown-body [data-footnote-ref]::after {
450 | content: ']';
451 | }
452 |
453 | .markdown-body .footnotes {
454 | font-size: 12px;
455 | color: #8b949e;
456 | border-top: 1px solid #30363d;
457 | }
458 |
459 | .markdown-body .footnotes ol {
460 | padding-left: 16px;
461 | }
462 |
463 | .markdown-body .footnotes li {
464 | position: relative;
465 | }
466 |
467 | .markdown-body .footnotes li:target::before {
468 | position: absolute;
469 | top: -8px;
470 | right: -8px;
471 | bottom: -8px;
472 | left: -24px;
473 | pointer-events: none;
474 | content: '';
475 | border: 2px solid #1f6feb;
476 | border-radius: 6px;
477 | }
478 |
479 | .markdown-body .footnotes li:target {
480 | color: #c9d1d9;
481 | }
482 |
483 | .markdown-body .footnotes .data-footnote-backref g-emoji {
484 | font-family: monospace;
485 | }
486 |
487 | .markdown-body .task-list-item {
488 | list-style-type: none;
489 | }
490 |
491 | .markdown-body .task-list-item label {
492 | font-weight: 400;
493 | }
494 |
495 | .markdown-body .task-list-item.enabled label {
496 | cursor: pointer;
497 | }
498 |
499 | .markdown-body .task-list-item + .task-list-item {
500 | margin-top: 3px;
501 | }
502 |
503 | .markdown-body .task-list-item .handle {
504 | display: none;
505 | }
506 |
507 | .markdown-body .task-list-item-checkbox {
508 | margin: 0 0.2em 0.25em -1.6em;
509 | vertical-align: middle;
510 | }
511 |
512 | .markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox {
513 | margin: 0 -1.6em 0.25em 0.2em;
514 | }
515 |
516 | .markdown-body ::-webkit-calendar-picker-indicator {
517 | filter: invert(50%);
518 | }
519 | }
520 |
--------------------------------------------------------------------------------