diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..5e514b8 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,38 @@ +name: Build and Push Docker Images + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push frontend image + uses: docker/build-push-action@v5 + with: + context: ./web + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/neo-blog-frontend:latest + + - name: Build and push backend image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/neo-blog-backend:latest \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e69de29..c90dd10 100644 --- a/Dockerfile +++ b/Dockerfile @@ -0,0 +1,31 @@ +# build +FROM golang:1.24.2-alpine3.21 AS builder + +ENV TZ=Asia/Chongqing + +WORKDIR /app + +RUN apk --no-cache add build-base git tzdata + +COPY go.mod go.sum ./ + +RUN go mod download + +COPY . . + +RUN go build -o server ./cmd/server + +# production +FROM alpine:latest AS prod + +ENV TZ=Asia/Chongqing + +WORKDIR /app + +COPY --from=builder /app/server /app/server + +EXPOSE 8888 + +RUN chmod +x ./server + +ENTRYPOINT ["./server"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..869aafd --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +FRONTEND_IMAGE = snowykami/neo-blog-frontend:latest +BACKEND_IMAGE = snowykami/neo-blog-backend:latest + +# 镜像名 +FRONTEND_IMAGE = snowykami/neo-blog-frontend:latest +BACKEND_IMAGE = snowykami/neo-blog-backend:latest + +# 构建前端镜像 +.PHONY: build-frontend +build-frontend: + docker build -t $(FRONTEND_IMAGE) ./web + +# 构建后端镜像 +.PHONY: build-backend +build-backend: + docker build -t $(BACKEND_IMAGE) . + +# 构建全部镜像 +.PHONY: build +build: build-frontend build-backend + +# 推送前端镜像 +.PHONY: push-frontend +push-frontend: + docker push $(FRONTEND_IMAGE) + +# 推送后端镜像 +.PHONY: push-backend +push-backend: + docker push $(BACKEND_IMAGE) + +# 推送全部镜像 +.PHONY: push +push: push-frontend push-backend \ No newline at end of file diff --git a/docker-compose.example.yaml b/docker-compose.example.yaml index e69de29..53b896c 100644 --- a/docker-compose.example.yaml +++ b/docker-compose.example.yaml @@ -0,0 +1,21 @@ +services: + frontend: + container_name: neo-blog-frontend + networks: + - neo-blog-network + image: snowykami/neo-blog-frontend:latest + restart: always + + backend: + container_name: neo-blog-backend + networks: + - neo-blog-network + image: snowykami/neo-blog-backend:latest + restart: always + volumes: + - ./data:/app/data + - ./.env:/app/.env + +networks: + neo-blog-network: + driver: bridge diff --git a/internal/controller/v1/post.go b/internal/controller/v1/post.go index 32b764a..051ec74 100644 --- a/internal/controller/v1/post.go +++ b/internal/controller/v1/post.go @@ -2,6 +2,7 @@ package v1 import ( "context" + "fmt" "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/common/utils" "github.com/snowykami/neo-blog/internal/ctxutils" @@ -93,6 +94,7 @@ func (p *PostController) Update(ctx context.Context, c *app.RequestContext) { func (p *PostController) List(ctx context.Context, c *app.RequestContext) { pagination := ctxutils.GetPaginationParams(c) + fmt.Println(pagination) if pagination.OrderedBy == "" { pagination.OrderedBy = constant.OrderedByUpdatedAt } diff --git a/internal/repo/post.go b/internal/repo/post.go index 1272c3d..f8da712 100644 --- a/internal/repo/post.go +++ b/internal/repo/post.go @@ -1,6 +1,7 @@ package repo import ( + "fmt" "github.com/snowykami/neo-blog/internal/model" "github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/errs" @@ -64,12 +65,13 @@ func (p *postRepo) ListPosts(currentUserID uint, keywords []string, page, size u } else { query = query.Where("is_private = ?", false) } + fmt.Println(keywords) if len(keywords) > 0 { for _, keyword := range keywords { if keyword != "" { // 使用LIKE进行模糊匹配,搜索标题、内容和标签 - query = query.Where("title LIKE ? OR content LIKE ? OR tags LIKE ?", - "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + query = query.Where("title LIKE ? OR content LIKE ?", // TODO: 支持标签搜索 + "%"+keyword+"%", "%"+keyword+"%") } } } diff --git a/pkg/constant/constant.go b/pkg/constant/constant.go index 635bd94..cc443cd 100644 --- a/pkg/constant/constant.go +++ b/pkg/constant/constant.go @@ -32,12 +32,13 @@ const ( OrderedByLikeCount = "like_count" // 按点赞数排序 OrderedByCommentCount = "comment_count" // 按评论数排序 OrderedByViewCount = "view_count" // 按浏览量排序 - HeatFactorViewWeight = 1 // 热度因子:浏览量权重 - HeatFactorLikeWeight = 5 // 热度因子:点赞权重 - HeatFactorCommentWeight = 10 // 热度因子:评论权重 + OrderedByHeat = "heat" + HeatFactorViewWeight = 1 // 热度因子:浏览量权重 + HeatFactorLikeWeight = 5 // 热度因子:点赞权重 + HeatFactorCommentWeight = 10 // 热度因子:评论权重 ) var ( - OrderedByEnumPost = []string{OrderedByCreatedAt, OrderedByUpdatedAt, OrderedByLikeCount, OrderedByCommentCount, OrderedByViewCount} // 帖子可用的排序方式 + OrderedByEnumPost = []string{OrderedByCreatedAt, OrderedByUpdatedAt, OrderedByLikeCount, OrderedByCommentCount, OrderedByViewCount, OrderedByHeat} // 帖子可用的排序方式 ) diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..72e9aa4 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,7 @@ +Dockerfile +.dockerignore +node_modules +npm-debug.log +README.md +.next +.git \ No newline at end of file diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..2e33410 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,66 @@ +# syntax=docker.io/docker/dockerfile:1 + +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN \ + if [ -f yarn.lock ]; then yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] \ No newline at end of file diff --git a/web/next.config.ts b/web/next.config.ts index a3eb05e..5f3c4ce 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + output: "standalone", images: { remotePatterns: [ { @@ -28,5 +29,4 @@ const nextConfig: NextConfig = { ] } }; - export default nextConfig; diff --git a/web/src/api/label.ts b/web/src/api/label.ts new file mode 100644 index 0000000..d8d6ae1 --- /dev/null +++ b/web/src/api/label.ts @@ -0,0 +1,10 @@ +import type { Label } from "@/models/label"; +import type { BaseResponse } from "@/models/resp"; +import axiosClient from "./client"; + + +export async function listLabels(): Promise> { + const res = await axiosClient.get>("/label/list", { + }); + return res.data; +} \ No newline at end of file diff --git a/web/src/api/post.ts b/web/src/api/post.ts index 794cc19..a38db86 100644 --- a/web/src/api/post.ts +++ b/web/src/api/post.ts @@ -10,6 +10,16 @@ interface ListPostsParams { keywords?: string; } +export async function getPostById(id: string): Promise { + try { + const res = await axiosClient.get>(`/post/p/${id}`); + return res.data.data; + } catch (error) { + console.error("Error fetching post by ID:", error); + return null; + } +} + export async function listPosts({ page = 1, size = 10, diff --git a/web/src/app/(main)/page.tsx b/web/src/app/(main)/page.tsx index 7c45bf7..2a48e70 100644 --- a/web/src/app/(main)/page.tsx +++ b/web/src/app/(main)/page.tsx @@ -1,189 +1,8 @@ "use client"; -import { BlogCardGrid } from "@/components/blog-card"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Search, TrendingUp, Clock, Heart, Eye } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import config from '../../config'; -import type { Label } from "@/models/label"; -import type { Post } from "@/models/post"; -import { listPosts } from "@/api/post"; - -import { useEffect, useState } from "react"; +import BlogHome from "@/components/blog-home"; -export default function Home() { - const [labels, setLabels] = useState([]); - const [posts, setPosts] = useState([]); - - useEffect(() => { - listPosts().then(data => { - setPosts(data.data); - console.log(posts); - }).catch(error => { - console.error("Failed to fetch posts:", error); - }); - }, []); - - - return ( -
- {/* Hero Section */} -
- {/* 背景装饰 */} -
- - {/* 容器 - 关键布局 */} -
-
-

- Snowykami's Blog -

-

- {config.metadata.description} -

- - {/* 搜索框 */} -
- - -
- - {/* 热门标签 */} -
- {['React', 'TypeScript', 'Next.js', 'Node.js', 'AI', '前端开发'].map((tag) => ( - - {tag} - - ))} -
-
-
-
- - {/* 主内容区域 */} -
- {/* 容器 - 关键布局 */} -
-
- {/* 主要内容区域 */} -
- {/* 文章列表标题 */} -
-

最新文章

-
- - -
-
- - {/* 博客卡片网格 */} - - - {/* 加载更多按钮 */} -
- -
-
- - {/* 侧边栏 */} -
- {/* 关于我 */} - - - - - 关于我 - - - -
-
- S -
-

{config.owner.name}

-

{config.owner.motto}

-
-

- {config.owner.description} -

-
-
- - {/* 热门文章 */} - - - - - 热门文章 - - - - {posts.slice(0, 3).map((post, index) => ( -
- - {index + 1} - -
-

- {post.title} -

-
- - - {post.viewCount} - - - - {post.likeCount} - -
-
-
- ))} -
-
- - {/* 标签云 */} - - - 标签云 - - -
- {['React', 'TypeScript', 'Next.js', 'Node.js', 'JavaScript', 'CSS', 'HTML', 'Vue', 'Angular', 'Webpack'].map((tag) => ( - - {tag} - - ))} -
-
-
-
-
-
-
-
- ); -} \ No newline at end of file +export default function Page(){ + return +} diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index b1a9549..b5b501f 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -1,10 +1,9 @@ -import { GalleryVerticalEnd } from "lucide-react" - import Image from "next/image" import { LoginForm } from "@/components/login-form" import config from "@/config" +import { Suspense } from "react" -export default function LoginPage() { +function LoginPageContent() { return (
@@ -25,3 +24,28 @@ export default function LoginPage() {
) } + +export default function LoginPage() { + return ( + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }> + + + ) +} \ No newline at end of file diff --git a/web/src/app/p/[id]/page.tsx b/web/src/app/p/[id]/page.tsx new file mode 100644 index 0000000..9d174b0 --- /dev/null +++ b/web/src/app/p/[id]/page.tsx @@ -0,0 +1,82 @@ +import { getPostById } from "@/api/post"; +import { notFound } from "next/navigation"; + +interface Props { + params: Promise<{ + id: string; + }> +} + +export default async function PostPage({ params }: Props) { + const { id } = await params; + + try { + const post = await getPostById(id); + + if (!post) { + notFound(); + } + + return ( +
+
+

{post.title}

+
+ + · + 阅读量: {post.viewCount || 0} +
+
+ +
+
+
+ + {post.labels && post.labels.length > 0 && ( +
+
+ {post.labels.map((label) => ( + + #{label.key} + + ))} +
+
+ )} +
+ ); + } catch (error) { + console.error("Failed to fetch post:", error); + notFound(); + } +} + +// 生成元数据 +export async function generateMetadata({ params }: Props) { + const { id } = await params; + + try { + const post = await getPostById(id); + + if (!post) { + return { + title: '文章未找到', + }; + } + + return { + title: post.title, + description: post.content?.substring(0, 160), + openGraph: { + title: post.title, + description: post.content?.substring(0, 160), + type: 'article', + publishedTime: post.createdAt, + }, + }; + } catch (error) { + return { + title: '文章未找到', + }; + } +} \ No newline at end of file diff --git a/web/src/app/u/[id]/page.tsx b/web/src/app/u/[id]/page.tsx new file mode 100644 index 0000000..2c10c2d --- /dev/null +++ b/web/src/app/u/[id]/page.tsx @@ -0,0 +1,8 @@ +export default function Page() { + return ( +
+

Page Title

+

This is the User content.

+
+ ) +} \ No newline at end of file diff --git a/web/src/components/Gravatar.tsx b/web/src/components/Gravatar.tsx index b2b3690..9a4f307 100644 --- a/web/src/components/Gravatar.tsx +++ b/web/src/components/Gravatar.tsx @@ -41,8 +41,7 @@ const GravatarAvatar: React.FC = ({ ); } - // 使用 Gravatar - const gravatarUrl = getGravatarUrl(email, size, defaultType); + const gravatarUrl = getGravatarUrl(email, size * 10, defaultType); return (
{/* 左侧内容 */} - Snowykami's Blog + {config.metadata.name}
{/* 中间内容 - 完全居中 */} @@ -67,7 +54,7 @@ export function Navbar() {
{/* 右侧内容 */} - +
) diff --git a/web/src/components/blog-card.tsx b/web/src/components/blog-card.tsx index 94a6649..a5f8044 100644 --- a/web/src/components/blog-card.tsx +++ b/web/src/components/blog-card.tsx @@ -1,10 +1,9 @@ import { Post } from "@/models/post"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import Link from "next/link"; import Image from "next/image"; -import { Calendar, Clock, Eye, Heart, MessageCircle, Lock } from "lucide-react"; +import { Calendar, Eye, Heart, MessageCircle, Lock } from "lucide-react"; import { cn } from "@/lib/utils"; import config from "@/config"; @@ -25,30 +24,30 @@ export function BlogCard({ post, className }: BlogCardProps) { }; // 计算阅读时间(估算) - const getReadingTime = (content: string) => { - const wordsPerMinute = 200; - const wordCount = content.length; - const minutes = Math.ceil(wordCount / wordsPerMinute); - return `${minutes} 分钟阅读`; - }; + // const getReadingTime = (content: string) => { + // const wordsPerMinute = 200; + // const wordCount = content.length; + // const minutes = Math.ceil(wordCount / wordsPerMinute); + // return `${minutes} 分钟阅读`; + // }; - // 根据内容类型获取图标 - const getContentTypeIcon = (type: Post['type']) => { - switch (type) { - case 'markdown': - return '📝'; - case 'html': - return '🌐'; - case 'text': - return '📄'; - default: - return '📝'; - } - }; + // // 根据内容类型获取图标 + // const getContentTypeIcon = (type: Post['type']) => { + // switch (type) { + // case 'markdown': + // return '📝'; + // case 'html': + // return '🌐'; + // case 'text': + // return '📄'; + // default: + // return '📝'; + // } + // }; return ( {/* 封面图片区域 */} @@ -65,44 +64,62 @@ export function BlogCard({ post, className }: BlogCardProps) { /> ) : ( // 默认渐变背景 - 基于热度生成颜色 -
80 ? "from-red-400 via-pink-500 to-orange-500" : - post.heat > 60 ? "from-orange-400 via-yellow-500 to-red-500" : - post.heat > 40 ? "from-blue-400 via-purple-500 to-pink-500" : - post.heat > 20 ? "from-green-400 via-blue-500 to-purple-500" : - "from-gray-400 via-slate-500 to-gray-600" + post.heat > 60 ? "from-orange-400 via-yellow-500 to-red-500" : + post.heat > 40 ? "from-blue-400 via-purple-500 to-pink-500" : + post.heat > 20 ? "from-green-400 via-blue-500 to-purple-500" : + "from-gray-400 via-slate-500 to-gray-600" )} /> )} - + {/* 覆盖层 */}
- + {/* 私有文章标识 */} {post.isPrivate && ( - 私有 )} - - {/* 内容类型标签 */} - - {getContentTypeIcon(post.type)} {post.type.toUpperCase()} - + + {/* 统计信息 */} +
+ + {/* 统计信息 */} +
+
+ {/* 点赞数 */} +
+ + {post.likeCount} +
+ {/* 评论数 */} +
+ + {post.commentCount} +
+ {/* 阅读量 */} +
+ + {post.viewCount} +
+
+
+
+
{/* 热度指示器 */} {post.heat > 50 && ( -
- +
+ 🔥 {post.heat}
@@ -110,95 +127,32 @@ export function BlogCard({ post, className }: BlogCardProps) {
{/* Card Header - 标题区域 */} - + {post.title} + + + {/* Card Content - 主要内容 */} + {post.content.replace(/[#*`]/g, '').substring(0, 150)} {post.content.length > 150 ? '...' : ''} - - - {/* Card Content - 主要内容 */} - - {/* 标签列表 */} - {post.labels && post.labels.length > 0 && ( -
- {post.labels.slice(0, 3).map((label) => ( - - {label.key} - - ))} - {post.labels.length > 3 && ( - - +{post.labels.length - 3} - - )} -
- )} - - {/* 统计信息 */} -
-
- {/* 点赞数 */} -
- - {post.likeCount} -
- - {/* 评论数 */} -
- - {post.commentCount} -
-
- -
- {/* 阅读量 */} -
- - {post.viewCount} -
- - {/* 阅读时间 */} -
- - {getReadingTime(post.content)} -
-
-
{/* Card Footer - 日期和操作区域 */} - - {/* 创建日期 */} + + {/* 左侧:最新日期 */}
-
- - {/* 更新日期(如果与创建日期不同)或阅读提示 */} - {post.updatedAt !== post.createdAt ? ( -
- 更新于 {formatDate(post.updatedAt)} -
- ) : ( -
- 阅读更多 → -
- )}
+ + ); } @@ -209,7 +163,7 @@ export function BlogCardSkeleton() { {/* 封面图片骨架 */}
- + {/* Header 骨架 */}
@@ -219,7 +173,7 @@ export function BlogCardSkeleton() {
- + {/* Content 骨架 */}
@@ -232,7 +186,7 @@ export function BlogCardSkeleton() {
- + {/* Footer 骨架 */}
@@ -243,12 +197,12 @@ export function BlogCardSkeleton() { } // 网格布局的博客卡片列表 -export function BlogCardGrid({ - posts, +export function BlogCardGrid({ + posts, isLoading, showPrivate = false -}: { - posts: Post[]; +}: { + posts: Post[]; isLoading?: boolean; showPrivate?: boolean; }) { diff --git a/web/src/components/blog-home.tsx b/web/src/components/blog-home.tsx new file mode 100644 index 0000000..df67d87 --- /dev/null +++ b/web/src/components/blog-home.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { BlogCardGrid } from "@/components/blog-card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Search, TrendingUp, Clock, Heart, Eye } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import config from '@/config'; +import type { Label } from "@/models/label"; +import type { Post } from "@/models/post"; +import { listPosts } from "@/api/post"; + +import { useEffect, useState } from "react"; +import { useStoredState } from '@/hooks/use-storage-state'; +import { listLabels } from "@/api/label"; +import GravatarAvatar from "./gravatar"; +import { POST_SORT_TYPE } from "@/localstore"; + +// 定义排序类型 +type SortType = 'latest' | 'popular'; + +export default function BlogHome() { + const [labels, setLabels] = useState([]); + const [posts, setPosts] = useState([]); + const [loading, setLoading] = useState(false); + const [sortType, setSortType, sortTypeLoaded] = useStoredState(POST_SORT_TYPE, 'latest'); + + // 根据排序类型获取文章 + useEffect(() => { + if (!sortTypeLoaded) return; // 等待从 localStorage 加载完成 + + const fetchPosts = async () => { + try { + setLoading(true); + let orderedBy: string; + let reverse: boolean; + switch (sortType) { + case 'latest': + orderedBy = 'updated_at'; + reverse = false; + break; + case 'popular': + orderedBy = 'heat'; + reverse = false; + break; + default: + orderedBy = 'updated_at'; + reverse = false; + } + const data = await listPosts({ + page: 1, + size: 10, + orderedBy, + reverse + }); + setPosts(data.data); + console.log(`${sortType} posts:`, data.data); + } catch (error) { + console.error("Failed to fetch posts:", error); + } finally { + setLoading(false); + } + }; + fetchPosts(); + }, [sortType, sortTypeLoaded]); + + // 获取标签 + useEffect(() => { + listLabels().then(data => { + setLabels(data.data); + console.log("Labels:", data.data); + }).catch(error => { + console.error("Failed to fetch labels:", error); + }); + }, []); + + // + + // 处理排序切换 + const handleSortChange = (type: SortType) => { + if (sortType !== type) { + setSortType(type); + } + }; + + return ( +
+ {/* Hero Section */} +
+ {/* 背景装饰 */} +
+ + {/* 容器 - 关键布局 */} +
+
+

+ {config.metadata.name} +

+

+ {config.metadata.description} +

+ + {/* 搜索框 */} +
+ + +
+ + {/* 热门标签 */} + {/*
+ {labels.map(label => ( + + {label.key} + + ))} +
*/} +
+
+
+ + {/* 主内容区域 */} +
+ {/* 容器 - 关键布局 */} +
+
+ {/* 主要内容区域 */} +
+ {/* 文章列表标题 */} +
+

+ {sortType === 'latest' ? '最新文章' : '热门文章'} + {posts.length > 0 && ( + + ({posts.length} 篇) + + )} +

+ + {/* 排序按钮组 */} +
+ + +
+
+ + {/* 博客卡片网格 */} + + + {/* 加载更多按钮 */} + {!loading && posts.length > 0 && ( +
+ +
+ )} + + {/* 加载状态指示器 */} + {loading && ( +
+
+
+ 正在加载{sortType === 'latest' ? '最新' : '热门'}文章... +
+
+ )} +
+ + {/* 侧边栏 */} +
+ {/* 关于我 */} + + + + + 关于我 + + + +
+
+ +
+

{config.owner.name}

+

{config.owner.motto}

+
+

+ {config.owner.description} +

+
+
+ + {/* 热门文章 */} + {posts.length > 0 && ( + + + + + {sortType === 'latest' ? '热门文章' : '最新文章'} + + + + {posts.slice(0, 3).map((post, index) => ( +
+ + {index + 1} + +
+

+ {post.title} +

+
+ + + {post.viewCount} + + + + {post.likeCount} + +
+
+
+ ))} +
+
+ )} + + {/* 标签云 */} + + + 标签云 + + +
+ {labels.map((label) => ( + + {label.key} + + ))} +
+
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/web/src/views/BlogHome.tsx b/web/src/components/post-page.tsx similarity index 100% rename from web/src/views/BlogHome.tsx rename to web/src/components/post-page.tsx diff --git a/web/src/config.ts b/web/src/config.ts index 4414728..5a65559 100644 --- a/web/src/config.ts +++ b/web/src/config.ts @@ -8,7 +8,9 @@ const config = { owner: { name: "Snowykami", description: "全栈开发工程师,喜欢分享技术心得和生活感悟。", - motto: "And now that story unfolds into a journey that, alone, I set out to" + motto: "And now that story unfolds into a journey that, alone, I set out to", + avatar: "https://cdn.liteyuki.org/snowykami/avatar.jpg", + gravatarEmail: "snowykami@outlook.com" } } diff --git a/web/src/hooks/use-storage-state.tsx b/web/src/hooks/use-storage-state.tsx new file mode 100644 index 0000000..6b98150 --- /dev/null +++ b/web/src/hooks/use-storage-state.tsx @@ -0,0 +1,35 @@ +import { useState, useEffect, useCallback } from 'react'; + +export function useStoredState(key: string, defaultValue: T) { + const [value, setValue] = useState(defaultValue); + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { + try { + const stored = localStorage.getItem(key); + if (stored) { + try { + setValue(JSON.parse(stored)); + } catch { + setValue(stored as T); + } + } + } catch (error) { + console.error('Error reading from localStorage:', error); + } finally { + setIsLoaded(true); + } + }, [key]); + + // 使用 useCallback 确保 setter 函数引用稳定 + const setStoredValue = useCallback((newValue: T) => { + setValue(newValue); + try { + localStorage.setItem(key, typeof newValue === 'string' ? newValue : JSON.stringify(newValue)); + } catch (error) { + console.error('Error writing to localStorage:', error); + } + }, [key]); + + return [value, setStoredValue, isLoaded] as const; +} \ No newline at end of file diff --git a/web/src/localstore.ts b/web/src/localstore.ts new file mode 100644 index 0000000..128d385 --- /dev/null +++ b/web/src/localstore.ts @@ -0,0 +1,2 @@ +export const POST_SORT_TYPE = "post_sort_type"; +export const POST_SORT_TYPE_DEFAULT = "latest"; \ No newline at end of file