mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 11:06:23 +00:00
feat: Refactor comment section to correctly handle API response structure
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 9s
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 9s
fix: Update Gravatar URL size and improve avatar rendering logic style: Adjust footer margin for better layout consistency refactor: Remove old navbar component and integrate new layout structure feat: Enhance user profile page with user header component chore: Remove unused user profile component fix: Update posts per page configuration for better pagination feat: Extend device context to support system theme mode refactor: Remove unused device hook fix: Improve storage state hook for better error handling i18n: Add new translations for blog home page feat: Implement pagination component for better navigation feat: Create theme toggle component for improved user experience feat: Introduce responsive navbar or side layout with theme toggle feat: Develop custom select component for better UI consistency feat: Create user header component to display user information chore: Add query key constants for better code maintainability
This commit is contained in:
@ -7,15 +7,14 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
import config from '@/config'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getPostHref } from '@/utils/common/post'
|
||||
import { motion } from 'framer-motion'
|
||||
import { motion } from 'motion/react'
|
||||
import { deceleration } from '@/motion/curve'
|
||||
|
||||
interface BlogCardProps {
|
||||
|
||||
export function BlogCard({ post, className }: {
|
||||
post: Post
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BlogCard({ post, className }: BlogCardProps) {
|
||||
}) {
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
@ -57,16 +56,16 @@ export function BlogCard({ post, className }: BlogCardProps) {
|
||||
// 默认渐变背景 - 基于热度生成颜色
|
||||
<div
|
||||
className={cn(
|
||||
'w-full h-full bg-gradient-to-br',
|
||||
post.heat > 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',
|
||||
'w-full h-full bg-gradient-to-br',
|
||||
post.heat > 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',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@ -210,7 +209,7 @@ export function BlogCardGrid({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
{Array.from({ length: config.postsPerPage }).map((_, index) => (
|
||||
<BlogCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
|
@ -1,85 +1,84 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { BlogCardGrid } from "@/components/blog-home/blog-home-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrendingUp, Clock, } from "lucide-react";
|
||||
import Sidebar, { SidebarAbout, SidebarHotPosts, SidebarMisskeyIframe, SidebarTags } from "../blog/blog-sidebar-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 { POST_SORT_TYPE } from "@/localstore";
|
||||
import { motion } from "framer-motion";
|
||||
import { useDevice } from "@/hooks/use-device";
|
||||
import { checkIsMobile } from "@/utils/client/device";
|
||||
import { motion } from "motion/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { OrderBy } from "@/models/common";
|
||||
import { PaginationController } from "@/components/common/pagination";
|
||||
import { QueryKey } from "@/constant";
|
||||
import { useStoredState } from "@/hooks/use-storage-state";
|
||||
|
||||
// 定义排序类型
|
||||
type SortType = 'latest' | 'popular';
|
||||
enum SortBy {
|
||||
Latest = 'latest',
|
||||
Hottest = 'hottest',
|
||||
}
|
||||
|
||||
const DEFAULT_SORTBY: SortBy = SortBy.Latest;
|
||||
|
||||
export default function BlogHome() {
|
||||
const [labels, setLabels] = useState<Label[]>([]);
|
||||
// 从路由查询参数中获取页码和标签们
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations("BlogHome");
|
||||
|
||||
const [labels, setLabels] = useState<string[]>([]);
|
||||
const [keywords, setKeywords] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [totalPosts, setTotalPosts] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
|
||||
const [sortBy, setSortBy, isSortByLoaded] = useStoredState<SortBy>(QueryKey.SortBy, DEFAULT_SORTBY);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sortTypeLoaded) return;
|
||||
const fetchPosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
let orderBy: string;
|
||||
let desc: boolean;
|
||||
switch (sortType) {
|
||||
case 'latest':
|
||||
orderBy = 'updated_at';
|
||||
desc = true;
|
||||
break;
|
||||
case 'popular':
|
||||
orderBy = 'heat';
|
||||
desc = true;
|
||||
break;
|
||||
default:
|
||||
orderBy = 'updated_at';
|
||||
desc = true;
|
||||
}
|
||||
// 处理关键词,空格分割转逗号
|
||||
const keywords = ""?.trim() ? ""?.trim().split(/\s+/).join(",") : undefined;
|
||||
const data = await listPosts({
|
||||
page: 1,
|
||||
size: 10,
|
||||
orderBy: orderBy,
|
||||
desc: desc,
|
||||
keywords
|
||||
});
|
||||
setPosts(data.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch posts:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!isSortByLoaded) return; // wait for stored state loaded
|
||||
setLoading(true);
|
||||
listPosts(
|
||||
{
|
||||
page: currentPage,
|
||||
size: config.postsPerPage,
|
||||
orderBy: sortBy === SortBy.Latest ? OrderBy.CreatedAt : OrderBy.Heat,
|
||||
desc: true,
|
||||
keywords: keywords.join(",") || undefined,
|
||||
labels: labels.join(",") || undefined,
|
||||
}
|
||||
};
|
||||
fetchPosts();
|
||||
}, [sortType, sortTypeLoaded]);
|
||||
|
||||
// 获取标签
|
||||
useEffect(() => {
|
||||
listLabels().then(data => {
|
||||
setLabels(data.data || []);
|
||||
}).catch(error => {
|
||||
console.error("Failed to fetch labels:", error);
|
||||
).then(res => {
|
||||
setPosts(res.data.posts);
|
||||
setTotalPosts(res.data.total);
|
||||
setLoading(false);
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
}, [keywords, labels, currentPage, sortBy, isSortByLoaded]);
|
||||
|
||||
// 处理排序切换
|
||||
const handleSortChange = (type: SortType) => {
|
||||
if (sortType !== type) {
|
||||
setSortType(type);
|
||||
const handleSortChange = (type: SortBy) => {
|
||||
if (sortBy !== type) {
|
||||
setSortBy(type);
|
||||
setCurrentPage(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
// 修改查询参数和状态
|
||||
setCurrentPage(page);
|
||||
// 不滚动到顶部,用户可能在阅读侧边栏
|
||||
// window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
// 修改查询参数
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('page', page.toString());
|
||||
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 主内容区域 */}
|
||||
@ -90,80 +89,75 @@ export default function BlogHome() {
|
||||
{/* 主要内容区域 */}
|
||||
<motion.div
|
||||
className="lg:col-span-3 self-start"
|
||||
initial={{ y: checkIsMobile() ? 30 : 60, opacity: 0 }}
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
|
||||
{/* 文章列表标题 */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{sortType === 'latest' ? '最新文章' : '热门文章'}
|
||||
{sortBy === 'latest' ? t("latest_posts") : t("hottest_posts")}
|
||||
{posts.length > 0 && (
|
||||
<span className="text-sm font-normal text-slate-500 ml-2">
|
||||
({posts.length} 篇)
|
||||
<span className="text-xl font-normal text-slate-500 ml-2">
|
||||
({posts.length})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{/* 排序按钮组 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isSortByLoaded && <div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={sortType === 'latest' ? 'default' : 'outline'}
|
||||
variant={sortBy === SortBy.Latest ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('latest')}
|
||||
onClick={() => handleSortChange(SortBy.Latest)}
|
||||
disabled={loading}
|
||||
className="transition-all duration-200"
|
||||
>
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
最新
|
||||
{t("latest")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortType === 'popular' ? 'default' : 'outline'}
|
||||
variant={sortBy === 'hottest' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('popular')}
|
||||
onClick={() => handleSortChange(SortBy.Hottest)}
|
||||
disabled={loading}
|
||||
className="transition-all duration-200"
|
||||
>
|
||||
<TrendingUp className="w-4 h-4 mr-2" />
|
||||
热门
|
||||
{t("hottest")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* 博客卡片网格 */}
|
||||
<BlogCardGrid posts={posts} isLoading={loading} showPrivate={true} />
|
||||
|
||||
{/* 加载更多按钮 */}
|
||||
{!loading && posts.length > 0 && (
|
||||
<div className="text-center mt-12">
|
||||
<Button size="lg" className="px-8">
|
||||
加载更多文章
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页控制器 */}
|
||||
<div className="mt-8">
|
||||
<PaginationController
|
||||
className="pt-4 flex justify-center"
|
||||
initialPage={currentPage}
|
||||
totalPages={Math.ceil(totalPosts / config.postsPerPage)}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
{/* 加载状态指示器 */}
|
||||
{loading && (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-flex items-center gap-2 text-slate-600">
|
||||
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
正在加载{sortType === 'latest' ? '最新' : '热门'}文章...
|
||||
<span>{t("loading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<motion.div
|
||||
initial={checkIsMobile() ? { y: 30, opacity: 0 } : { x: 80, opacity: 0 }}
|
||||
initial={{ x: 80, opacity: 0 }}
|
||||
animate={{ x: 0, y: 0, opacity: 1 }}
|
||||
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}
|
||||
>
|
||||
<Sidebar
|
||||
cards={[
|
||||
<SidebarAbout key="about" config={config} />,
|
||||
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortType} /> : null,
|
||||
<SidebarTags key="tags" labels={labels} />,
|
||||
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortBy} /> : null,
|
||||
<SidebarTags key="tags" labels={[]} />,
|
||||
<SidebarMisskeyIframe key="misskey" />,
|
||||
].filter(Boolean)}
|
||||
/>
|
||||
|
@ -34,7 +34,7 @@ export function SidebarAbout({ config }: { config: typeof configType }) {
|
||||
<CardContent>
|
||||
<div className="text-center mb-4">
|
||||
<div className="w-20 h-20 mx-auto bg-gradient-to-br from-blue-400 to-purple-500 rounded-full flex items-center justify-center text-white text-2xl font-bold overflow-hidden">
|
||||
<GravatarAvatar email={config.owner.gravatarEmail} className="w-full h-full object-cover" />
|
||||
<GravatarAvatar email={config.owner.gravatarEmail} className="w-full h-full object-cover" size={200} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg">{config.owner.name}</h3>
|
||||
<p className="text-sm text-slate-600">{config.owner.motto}</p>
|
||||
|
@ -60,7 +60,7 @@ export function CommentInput(
|
||||
<div className="fade-in-up">
|
||||
<div className="flex py-4 fade-in">
|
||||
<div onClick={user ? () => clickToUserProfile(user.username) : clickToLogin} className="cursor-pointer flex-shrink-0 w-10 h-10 fade-in">
|
||||
{user && <GravatarAvatar url={user.avatarUrl} email={user.email} size={100}/>}
|
||||
{user && <GravatarAvatar className="w-full h-full" url={user.avatarUrl} email={user.email} size={100}/>}
|
||||
{!user && <CircleUser className="w-full h-full fade-in" />}
|
||||
</div>
|
||||
<div className="flex-1 pl-2 fade-in-up">
|
||||
|
@ -101,7 +101,7 @@ export function CommentItem(
|
||||
commentId: comment.id
|
||||
}
|
||||
).then(response => {
|
||||
setReplies(response.data);
|
||||
setReplies(response.data.comments);
|
||||
setRepliesLoaded(true);
|
||||
});
|
||||
}
|
||||
@ -159,7 +159,7 @@ export function CommentItem(
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in w-12 h-12">
|
||||
<GravatarAvatar email={comment.user.email} size={120}/>
|
||||
<GravatarAvatar className="w-full h-full" url={comment.user.avatarUrl} email={comment.user.email} size={100}/>
|
||||
</div>
|
||||
<div className="flex-1 pl-2 fade-in-up">
|
||||
<div className="flex gap-2 md:gap-4 items-center">
|
||||
|
@ -17,8 +17,6 @@ import config from "@/config";
|
||||
|
||||
import "./style.css";
|
||||
|
||||
|
||||
|
||||
export function CommentSection(
|
||||
{
|
||||
targetType,
|
||||
@ -59,7 +57,7 @@ export function CommentSection(
|
||||
size: config.commentsPerPage,
|
||||
commentId: 0
|
||||
}).then(response => {
|
||||
setComments(response.data);
|
||||
setComments(response.data.comments);
|
||||
});
|
||||
}, [])
|
||||
|
||||
@ -108,10 +106,10 @@ export function CommentSection(
|
||||
size: config.commentsPerPage,
|
||||
commentId: 0
|
||||
}).then(response => {
|
||||
if (response.data.length < config.commentsPerPage) {
|
||||
if (response.data.comments.length < config.commentsPerPage) {
|
||||
setNeedLoadMore(false);
|
||||
}
|
||||
setComments(prevComments => [...prevComments, ...response.data]);
|
||||
setComments(prevComments => [...prevComments, ...response.data.comments]);
|
||||
setPage(nextPage);
|
||||
});
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import Image from "next/image";
|
||||
import crypto from "crypto";
|
||||
|
||||
// 生成 Gravatar URL 的函数
|
||||
function getGravatarUrl(email: string, size: number = 40, defaultType: string = "identicon"): string {
|
||||
function getGravatarUrl(email: string, size: number = 200, defaultType: string = "identicon"): string {
|
||||
const hash = crypto.createHash('md5').update(email.toLowerCase().trim()).digest('hex');
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=${defaultType}`;
|
||||
}
|
||||
@ -21,35 +21,26 @@ interface GravatarAvatarProps {
|
||||
|
||||
const GravatarAvatar: React.FC<GravatarAvatarProps> = ({
|
||||
email,
|
||||
size = 40,
|
||||
size = 200,
|
||||
className = "",
|
||||
alt = "avatar",
|
||||
url,
|
||||
defaultType = "identicon"
|
||||
}) => {
|
||||
// 如果有自定义URL,使用自定义URL
|
||||
if (url && url.trim() !== "") {
|
||||
return (
|
||||
// 把尺寸控制交给父组件的 wrapper(父组件通过 tailwind 的 w-.. h-.. 控制)
|
||||
const gravatarUrl = url && url.trim() !== "" ? url : getGravatarUrl(email, size , defaultType);
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden ${className}`}>
|
||||
<Image
|
||||
src={url}
|
||||
width={size}
|
||||
height={size}
|
||||
className={`rounded-full object-cover w-full h-full ${className}`}
|
||||
src={gravatarUrl}
|
||||
alt={alt}
|
||||
fill
|
||||
sizes="(max-width: 640px) 64px, 200px"
|
||||
className="rounded-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const gravatarUrl = getGravatarUrl(email, size * 10, defaultType);
|
||||
return (
|
||||
<Image
|
||||
src={gravatarUrl}
|
||||
width={size}
|
||||
height={size}
|
||||
className={`rounded-full object-cover w-full h-full ${className}`}
|
||||
alt={alt}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
144
web/src/components/common/pagination.tsx
Normal file
144
web/src/components/common/pagination.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
|
||||
export function PaginationController({
|
||||
initialPage = 1,
|
||||
totalPages = 10,
|
||||
buttons = 7, // recommended odd number >=5
|
||||
onPageChange,
|
||||
...props
|
||||
}: {
|
||||
initialPage?: number
|
||||
totalPages: number
|
||||
buttons?: number
|
||||
onPageChange?: (page: number) => void
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
// normalize buttons
|
||||
const btns = Math.max(5, buttons ?? 7);
|
||||
const buttonsToShow = totalPages < btns ? totalPages : btns;
|
||||
// rely on shadcn buttonVariants and PaginationLink's isActive prop for styling
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(() => Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages)));
|
||||
const [direction, setDirection] = useState(0) // 1 = forward (right->left), -1 = backward
|
||||
|
||||
// sync when initialPage or totalPages props change
|
||||
useEffect(() => {
|
||||
const p = Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages));
|
||||
setCurrentPage(p);
|
||||
}, [initialPage, totalPages]);
|
||||
|
||||
const handleSetPage = useCallback((p: number) => {
|
||||
const next = Math.min(Math.max(1, Math.floor(p)), Math.max(1, totalPages));
|
||||
setDirection(next > currentPage ? 1 : next < currentPage ? -1 : 0);
|
||||
setCurrentPage(next);
|
||||
if (typeof onPageChange === 'function') onPageChange(next);
|
||||
}, [onPageChange, totalPages, currentPage]);
|
||||
|
||||
// helper to render page link
|
||||
const renderPage = (pageNum: number) => (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
isActive={pageNum === currentPage}
|
||||
aria-current={pageNum === currentPage ? 'page' : undefined}
|
||||
onClick={() => handleSetPage(pageNum)}
|
||||
type="button"
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
|
||||
// if totalPages small, render all
|
||||
if (totalPages <= buttonsToShow) {
|
||||
return (
|
||||
<Pagination>
|
||||
<PaginationContent className="select-none">
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
aria-disabled={currentPage === 1}
|
||||
onClick={() => currentPage > 1 && handleSetPage(currentPage - 1)}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: totalPages }).map((_, i) => renderPage(i + 1))}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
aria-disabled={currentPage === totalPages}
|
||||
onClick={() => currentPage < totalPages && handleSetPage(currentPage + 1)}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
);
|
||||
}
|
||||
|
||||
// for larger totalPages, show: 1, 2 or ellipsis, center range, ellipsis or N-1, N
|
||||
const centerCount = buttonsToShow - 4; // slots for center pages
|
||||
let start = currentPage - Math.floor(centerCount / 2);
|
||||
let end = start + centerCount - 1;
|
||||
if (start < 3) {
|
||||
start = 3;
|
||||
end = start + centerCount - 1;
|
||||
}
|
||||
if (end > totalPages - 2) {
|
||||
end = totalPages - 2;
|
||||
start = end - (centerCount - 1);
|
||||
}
|
||||
|
||||
const centerPages = [] as number[];
|
||||
for (let i = start; i <= end; i++) centerPages.push(i);
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<Pagination >
|
||||
<PaginationContent className="select-none">
|
||||
<PaginationItem>
|
||||
<PaginationPrevious aria-disabled={currentPage === 1} onClick={() => currentPage > 1 && handleSetPage(currentPage - 1)} />
|
||||
</PaginationItem>
|
||||
|
||||
{renderPage(1)}
|
||||
|
||||
{/* second slot: either page 2 or ellipsis if center starts later */}
|
||||
{start > 3 ? (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : renderPage(2)}
|
||||
|
||||
{/* center pages */}
|
||||
{centerPages.map((p) => (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink
|
||||
isActive={p === currentPage}
|
||||
aria-current={p === currentPage ? 'page' : undefined}
|
||||
onClick={() => handleSetPage(p)}
|
||||
type="button"
|
||||
>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
|
||||
{end < totalPages - 2 ? (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : renderPage(totalPages - 1)}
|
||||
|
||||
{renderPage(totalPages)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext aria-disabled={currentPage === totalPages} onClick={() => currentPage < totalPages && handleSetPage(currentPage + 1)} />
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
);
|
||||
}
|
88
web/src/components/common/theme-toggle.tsx
Normal file
88
web/src/components/common/theme-toggle.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
|
||||
import { useDevice } from "@/contexts/device-context";
|
||||
import { Sun, Moon, Monitor } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ThemeMode = "light" | "dark" | "system";
|
||||
|
||||
// PC端:三状态轮换按钮
|
||||
export function ThemeModeCycleButton(props: React.ButtonHTMLAttributes<HTMLButtonElement> & { mode: ThemeMode; setMode: (m: ThemeMode) => void }) {
|
||||
const { mode, setMode, className, style, onClick, ...rest } = props;
|
||||
const nextMode = (mode: ThemeMode): ThemeMode => {
|
||||
if (mode === "light") return "dark";
|
||||
if (mode === "dark") return "system";
|
||||
return "light";
|
||||
};
|
||||
const icon = mode === "light" ? <Sun className="w-4 h-4" /> : mode === "dark" ? <Moon className="w-4 h-4" /> : <Monitor className="w-4 h-4" />;
|
||||
const label = mode.charAt(0).toUpperCase() + mode.slice(1);
|
||||
|
||||
const baseCls = "flex items-center gap-2 px-2 py-2 rounded-full bg-muted hover:bg-accent border border-input text-sm font-medium transition-all";
|
||||
const mergedClassName = cn(baseCls, className);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={mergedClassName}
|
||||
style={style}
|
||||
onClick={(e) => {
|
||||
setMode(nextMode(mode));
|
||||
onClick?.(e);
|
||||
}}
|
||||
title={`切换主题(当前:${label})`}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 移动端:横向按钮组
|
||||
export function ThemeModeSegmented(props: React.HTMLAttributes<HTMLDivElement> & { mode: ThemeMode; setMode: (m: ThemeMode) => void }) {
|
||||
const { mode, setMode, className, style, ...rest } = props;
|
||||
const modes: { value: ThemeMode; icon: React.ReactNode; label: string }[] = [
|
||||
{ value: "light", icon: <Sun className="w-4 h-4" />, label: "Light" },
|
||||
{ value: "system", icon: <Monitor className="w-4 h-4" />, label: "System" },
|
||||
{ value: "dark", icon: <Moon className="w-4 h-4" />, label: "Dark" },
|
||||
];
|
||||
const activeIndex = modes.findIndex((m) => m.value === mode);
|
||||
const baseCls = "relative inline-flex bg-muted rounded-full p-1 gap-1 overflow-hidden";
|
||||
|
||||
return (
|
||||
<div className={cn("theme-mode-segmented-wrapper", className)} style={style} {...rest}>
|
||||
<div className={baseCls}>
|
||||
{/* 滑动高亮块 */}
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
className="absolute w-12 h-8 rounded-full bg-white/70 shadow-sm z-1 top-1"
|
||||
style={{
|
||||
left: `calc(0.25rem + ${activeIndex} * (3rem + 0.25rem))`,
|
||||
}}
|
||||
/>
|
||||
{modes.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-12 h-8 rounded-full text-sm font-medium transition-all z-10",
|
||||
mode === m.value ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setMode(m.value)}
|
||||
type="button"
|
||||
>
|
||||
{m.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 总组件:根据设备类型渲染
|
||||
export function ThemeModeToggle(props: React.HTMLAttributes<HTMLElement> = {}) {
|
||||
const { isMobile, mode, setMode } = useDevice();
|
||||
const Comp: React.ElementType = isMobile ? ThemeModeSegmented : ThemeModeCycleButton;
|
||||
const { className, style } = props;
|
||||
// 仅转发 className / style,避免复杂的 prop 类型不匹配
|
||||
return <Comp mode={mode} setMode={setMode} className={className} style={style} />;
|
||||
}
|
@ -3,7 +3,7 @@ import React from "react";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="w-full py-6 text-center text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 mt-12">
|
||||
<footer className="w-full py-6 text-center text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700">
|
||||
© {new Date().getFullYear()} {config.metadata.name} · Powered by {config.owner.name} · {config.footer.text}
|
||||
</footer>
|
||||
);
|
||||
|
@ -12,13 +12,13 @@ import {
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
} from "@/components/ui/navigation-menu"
|
||||
import GravatarAvatar from "@/components/common/gravatar"
|
||||
import { useDevice } from "@/contexts/device-context"
|
||||
import config from "@/config"
|
||||
import { useState } from "react"
|
||||
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
||||
import { Menu } from "lucide-react"
|
||||
import { Switch } from "../ui/switch"
|
||||
import { ThemeModeToggle } from "../common/theme-toggle"
|
||||
|
||||
const navbarMenuComponents = [
|
||||
{
|
||||
@ -55,7 +55,7 @@ export function Navbar() {
|
||||
<NavMenuCenter />
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Switch checked={mode === "dark"} onCheckedChange={(checked) => setMode(checked ? "dark" : "light")} />
|
||||
<ThemeModeToggle className="hidden md:block" />
|
||||
<SidebarMenuClientOnly />
|
||||
</div>
|
||||
</nav>
|
||||
@ -169,8 +169,11 @@ function SidebarMenu() {
|
||||
) : null
|
||||
)}
|
||||
</nav>
|
||||
<div className="flex items-center justify-center p-4 border-t border-border">
|
||||
<ThemeModeToggle/>
|
||||
</div>
|
||||
|
||||
</SheetContent>
|
||||
</Sheet></div>
|
||||
|
||||
)
|
||||
}
|
185
web/src/components/ui/select.tsx
Normal file
185
web/src/components/ui/select.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
@ -1,5 +1,8 @@
|
||||
import { User } from "@/models/user";
|
||||
import { UserHeader } from "./user-header";
|
||||
|
||||
export function UserPage({user}: {user: User}) {
|
||||
return <div>User: {user.username}</div>;
|
||||
}
|
||||
return <div>
|
||||
<UserHeader user={user} />
|
||||
</div>;
|
||||
}
|
||||
|
40
web/src/components/user/user-header.tsx
Normal file
40
web/src/components/user/user-header.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
import { User } from "@/models/user";
|
||||
import GravatarAvatar from "@/components/common/gravatar";
|
||||
import { Mail, User as UserIcon, Shield } from 'lucide-react';
|
||||
|
||||
export function UserHeader({ user }: { user: User }) {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row items-center md:items-center h-auto md:h-60">
|
||||
{/* 左侧 30%(头像容器) */}
|
||||
<div className="md:basis-[20%] flex justify-center items-center p-4">
|
||||
{/* wrapper 控制显示大小,父组件给具体 w/h */}
|
||||
<div className="w-40 h-40 md:w-48 md:h-48 relative">
|
||||
<GravatarAvatar className="rounded-full w-full h-full" url={user.avatarUrl} email={user.email} size={200} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧 70%(信息区) */}
|
||||
<div className="md:basis-[70%] p-4 flex flex-col justify-center space-y-2">
|
||||
<h2 className="text-2xl font-bold mt-0">{user.nickname}</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">@{user.username}</p>
|
||||
|
||||
<div className="flex items-center text-sm text-slate-600 dark:text-slate-400">
|
||||
<UserIcon className="w-4 h-4 mr-2" />
|
||||
<span>{user.gender || '未填写'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-slate-600 dark:text-slate-400">
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
<span>{user.email || '未填写'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-slate-600 dark:text-slate-400">
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
<span>{user.role || '访客'}</span>
|
||||
</div>
|
||||
{/* 其他简介、按钮等放这里 */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
"use client"
|
||||
import { User } from "@/models/user";
|
||||
import GravatarAvatar from "@/components/common/gravatar";
|
||||
|
||||
export function UserProfile({ user }: { user: User }) {
|
||||
return (
|
||||
<div className="flex">
|
||||
<GravatarAvatar email={user.email} size={120}/>
|
||||
</div>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user