全项目扫描修复: Docker数据卷修复+安全requireAdmin+12页SEO+API白名单+脚本超时+常量提取+假数据删除

This commit is contained in:
ZhuiGuangAI Dev
2026-06-12 17:27:47 +08:00
parent 464924aea0
commit 4c325c8699
454 changed files with 33647 additions and 980 deletions
+365
View File
@@ -0,0 +1,365 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
MessageSquare,
ThumbsUp,
Award,
TrendingUp,
Calendar,
CheckCircle2,
Heart,
Bookmark,
Eye,
} from "lucide-react";
import UserActivityCard from "@/components/forum/UserActivityCard";
import UserBadges from "@/components/forum/UserBadges";
import MemberBadge from "@/components/common/MemberBadge";
import LevelProgress from "@/components/common/LevelProgress";
type Tab = "topics" | "posts" | "comments" | "bookmarks";
interface ProfileData {
id: number;
name: string | null;
email: string;
bio: string | null;
avatarUrl: string | null;
points: number;
level: string;
role: string;
isBot: boolean;
createdAt: string;
levelInfo: {
level: string;
label: string;
icon: string;
color: string;
bgColor: string;
progress: number;
nextLevel: { label: string; minPoints: number; remaining: number } | null;
};
stats: {
topicCount: number;
postCount: number;
commentCount: number;
favoriteCount: number;
bookmarkCount: number;
followedCategoryCount: number;
bestAnswerCount: number;
likesReceived: number;
checkInStreak: number;
lastCheckInDate: string | null;
};
achievements: Array<{ key: string; name: string; icon: string; tier: string; unlockedAt: string | null }>;
}
function getTimeAgo(date: string): string {
const now = Date.now();
const t = new Date(date).getTime();
const seconds = Math.floor((now - t) / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "刚刚";
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 30) return `${days}天前`;
return new Date(date).toLocaleDateString("zh-CN");
}
const statCards = (stats: ProfileData["stats"]) => [
{ label: "积分", value: stats ? 0 : 0, icon: Award, color: "from-amber-400 to-amber-600" },
];
export default function UserPublicProfile({ userId, initialData }: { userId: number; initialData?: any }) {
const [profile, setProfile] = useState<ProfileData | null>(initialData || null);
const [tab, setTab] = useState<Tab>("topics");
const [tabData, setTabData] = useState<any[]>([]);
const [tabLoading, setTabLoading] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (initialData) return;
let mounted = true;
fetch(`/api/user/${userId}/profile`)
.then(r => r.json())
.then(d => { if (mounted && !d.error) setProfile(d); })
.catch(() => {});
return () => { mounted = false; };
}, [userId, initialData]);
useEffect(() => {
if (!profile) return;
let mounted = true;
setTabLoading(true);
setTabData([]);
const urls: Record<Tab, string> = {
topics: `/api/user/${userId}/topics`,
posts: `/api/user/${userId}/posts`,
comments: `/api/user/${userId}/comments`,
bookmarks: `/api/user/${userId}/bookmarks`,
};
fetch(urls[tab])
.then(r => r.json())
.then(d => { if (mounted) setTabData(d.items || d.topics || d.posts || d.comments || d.bookmarks || []); })
.catch(() => {})
.finally(() => { if (mounted) setTabLoading(false); });
return () => { mounted = false; };
}, [tab, userId, profile]);
if (!profile) {
return (
<div className="min-h-screen bg-[#f8fafc] flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
const displayName = profile.name || profile.email.split("@")[0];
const initial = (profile.name || profile.email).charAt(0).toUpperCase();
const isOwner = false;
const copyShareLink = () => {
const url = `${window.location.origin}/user/${userId}`;
navigator.clipboard?.writeText(url).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const tabs: Array<{ key: Tab; label: string; count: number; icon: any }> = [
{ key: "topics", label: "话题", count: profile.stats.topicCount, icon: TrendingUp },
{ key: "posts", label: "回复", count: profile.stats.postCount, icon: MessageSquare },
{ key: "comments", label: "评价", count: profile.stats.commentCount, icon: MessageSquare },
{ key: "bookmarks", label: "收藏", count: profile.stats.bookmarkCount, icon: Bookmark },
];
return (
<div className="min-h-screen bg-gradient-to-b from-[#f8fafc] to-white">
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="mb-4">
<Link href="/community" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
← 返回社区
</Link>
</div>
<div className="rounded-2xl border border-border/40 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden mb-5">
<div className="h-36 bg-gradient-to-br from-violet-500 via-purple-500 to-indigo-500 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,rgba(255,255,255,0.15),transparent_60%)]" />
<div className="absolute top-4 right-4 flex items-center gap-2">
<button
onClick={copyShareLink}
className="px-3 py-1.5 bg-white/20 backdrop-blur text-white text-xs rounded-lg hover:bg-white/30 transition-colors flex items-center gap-1"
>
{copied ? (
<>
<CheckCircle2 className="w-3 h-3" /> 已复制
</>
) : (
<>
<Eye className="w-3 h-3" /> 分享
</>
)}
</button>
</div>
</div>
<div className="px-6 pb-6">
<div className="flex items-end justify-between -mt-12 mb-4 flex-wrap gap-4">
<div className="flex items-end gap-4">
<div className="relative">
{profile.avatarUrl ? (
<img
src={profile.avatarUrl}
alt=""
className="h-24 w-24 rounded-2xl border-4 border-white object-cover shadow-lg"
/>
) : (
<div className="flex h-24 w-24 items-center justify-center rounded-2xl bg-white border-4 border-white shadow-lg text-3xl font-bold text-primary">
{initial}
</div>
)}
{profile.isBot && (
<span className="absolute -bottom-1 -right-1 px-1.5 py-0.5 rounded-md text-[10px] font-medium bg-violet-600 text-white">
Bot
</span>
)}
</div>
<div className="pb-2">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-bold">{displayName}</h1>
<MemberBadge level={profile.level as any} points={profile.points} size="md" />
</div>
<p className="text-xs text-muted-foreground/60 mt-0.5">
{profile.email}
</p>
</div>
</div>
</div>
{profile.bio && (
<p className="text-sm text-muted-foreground/80 mb-4 bg-muted/30 rounded-lg p-3">
{profile.bio}
</p>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground/60 mb-4">
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
加入于 {new Date(profile.createdAt).toLocaleDateString("zh-CN")}
</span>
{profile.stats.lastCheckInDate && (
<span className="flex items-center gap-1">
🔥 连续签到 {profile.stats.checkInStreak} 天
</span>
)}
</div>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2 sm:gap-3 mb-5">
{[
{ label: "话题", value: profile.stats.topicCount, icon: TrendingUp, color: "text-violet-600 bg-violet-50" },
{ label: "回复", value: profile.stats.postCount, icon: MessageSquare, color: "text-blue-600 bg-blue-50" },
{ label: "评价", value: profile.stats.commentCount, icon: MessageSquare, color: "text-amber-600 bg-amber-50" },
{ label: "获赞", value: profile.stats.likesReceived, icon: ThumbsUp, color: "text-pink-600 bg-pink-50" },
{ label: "最佳", value: profile.stats.bestAnswerCount, icon: CheckCircle2, color: "text-emerald-600 bg-emerald-50" },
{ label: "收藏", value: profile.stats.bookmarkCount, icon: Bookmark, color: "text-indigo-600 bg-indigo-50" },
{ label: "关注板块", value: profile.stats.followedCategoryCount, icon: Heart, color: "text-rose-600 bg-rose-50" },
{ label: "成就", value: profile.achievements.length, icon: Award, color: "text-yellow-600 bg-yellow-50" },
].map(stat => (
<div key={stat.label} className="text-center p-2 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
<div className={`mx-auto mb-1 flex h-7 w-7 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="w-3.5 h-3.5" />
</div>
<p className="text-base font-bold">{stat.value}</p>
<p className="text-[10px] text-muted-foreground/70">{stat.label}</p>
</div>
))}
</div>
<LevelProgress level={profile.level as any} points={profile.points} />
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-5">
<div className="lg:col-span-2">
<UserActivityCard userId={userId} />
</div>
<div>
<UserBadges userId={userId} />
</div>
</div>
<div className="rounded-2xl border border-border/40 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] overflow-hidden">
<div className="flex border-b border-border/40">
{tabs.map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${
tab === t.key ? "text-primary" : "text-muted-foreground hover:text-foreground"
}`}
>
<span className="inline-flex items-center gap-1.5">
<t.icon className="w-3.5 h-3.5" />
{t.label}
<span className="text-xs text-muted-foreground/50">({t.count})</span>
</span>
{tab === t.key && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
))}
</div>
<div className="p-5 min-h-[200px]">
{tabLoading ? (
<div className="text-center py-8 text-sm text-muted-foreground/50">加载中...</div>
) : tabData.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground/50">暂无内容</div>
) : (
<div className="space-y-3">
{tabData.slice(0, 15).map((item: any) => (
<ActivityItem key={item.id} item={item} type={tab} />
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
function ActivityItem({ item, type }: { item: any; type: Tab }) {
if (type === "topics") {
return (
<Link href={`/community/topic/${item.id}`} className="block">
<div className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors">
<div className="flex items-center gap-2 mb-1">
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-violet-50 text-violet-700">
{item.category?.name || "话题"}
</span>
{item.isPinned && <span className="text-[10px] text-amber-600">📌 置顶</span>}
{item.isFeatured && <span className="text-[10px] text-rose-600">🌟 精华</span>}
</div>
<h3 className="text-sm font-medium">{item.title}</h3>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>👁 {item.viewCount || 0}</span>
<span>💬 {item.replyCount || 0}</span>
<span>👍 {item.likeCount || 0}</span>
<span>·</span>
<span>{getTimeAgo(item.createdAt)}</span>
</div>
</div>
</Link>
);
}
if (type === "posts") {
return (
<Link href={`/community/topic/${item.topic?.id || ""}`} className="block">
<div className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors">
{item.isAnswer && (
<span className="inline-block mb-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-50 text-emerald-700">
✓ 最佳答案
</span>
)}
<p className="text-sm text-muted-foreground/80 line-clamp-2">{item.content}</p>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>回复于:{item.topic?.title || "—"}</span>
<span>·</span>
<span>{getTimeAgo(item.createdAt)}</span>
</div>
</div>
</Link>
);
}
if (type === "comments") {
const target = item.tool || item.skill;
return (
<Link href={target ? `/${item.tool ? "tools" : "skills"}/${target.slug}` : "#"} className="block">
<div className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors">
<p className="text-sm text-muted-foreground/80 line-clamp-2">{item.content}</p>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>评价于:{target?.name || "—"}</span>
{item.rating && <span>· ⭐ {item.rating}/5</span>}
<span>·</span>
<span>{getTimeAgo(item.createdAt)}</span>
</div>
</div>
</Link>
);
}
// bookmarks
return (
<Link href={`/community/topic/${item.topic?.id || ""}`} className="block">
<div className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors">
<h3 className="text-sm font-medium">{item.topic?.title || "—"}</h3>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>收藏于 {getTimeAgo(item.createdAt)}</span>
</div>
</div>
</Link>
);
}
+88 -152
View File
@@ -1,12 +1,10 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import Link from "next/link";
import MemberBadge from "@/components/common/MemberBadge";
import LevelProgress from "@/components/common/LevelProgress";
import { getLevelInfo } from "@/lib/level-config";
import { MessageSquare, TrendingUp, Star, Award, Calendar, Share2, Edit3, Check } from "lucide-react";
import UserPublicProfile from "./UserPublicProfile";
export const dynamic = "force-dynamic";
@@ -14,167 +12,105 @@ interface Props {
params: { id: string };
}
export const metadata: Metadata = {
title: "用户资料 - 追光AI",
};
export default async function UserProfilePage({ params }: Props) {
const userId = parseInt(params.id);
if (isNaN(userId)) notFound();
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
forumTopics: {
take: 10,
orderBy: { createdAt: "desc" },
include: { category: { select: { name: true, slug: true, icon: true } } },
},
forumPosts: {
take: 10,
orderBy: { createdAt: "desc" },
include: { topic: { select: { id: true, title: true } } },
},
comments: {
take: 10,
orderBy: { createdAt: "desc" },
include: {
tool: { select: { name: true, slug: true } },
skill: { select: { name: true, slug: true } },
select: { id: true },
});
if (!user) notFound();
const [bestAnswerCount, totalLikesReceived, checkInStreak, followCount, achievements] = await Promise.all([
prisma.forumPost.count({ where: { userId, isAnswer: true } }),
prisma.forumPost.aggregate({ where: { userId }, _sum: { likeCount: true } }),
prisma.forumCheckIn.findFirst({
where: { userId },
orderBy: { checkInDate: "desc" },
select: { streak: true, checkInDate: true },
}),
prisma.forumCategoryFollow.count({ where: { userId } }),
prisma.userAchievement.findMany({
where: { userId },
include: { achievement: { select: { key: true, name: true, icon: true, tier: true } } },
orderBy: { unlockedAt: "desc" },
take: 6,
}),
]);
const counts = await prisma.user.findUnique({
where: { id: userId },
select: {
_count: {
select: {
forumTopics: { where: { isHidden: false } },
forumPosts: { where: { isHidden: false } },
comments: { where: { status: "ACTIVE" } },
favorites: true,
topicBookmarks: true,
},
},
},
});
if (!user) notFound();
const fullUser = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
bio: true,
avatarUrl: true,
points: true,
level: true,
role: true,
isBot: true,
createdAt: true,
},
});
const session = await getServerSession(authOptions);
const isOwner = session?.user?.id === userId.toString();
if (!fullUser) notFound();
const levelInfo = getLevelInfo(user.points);
const shareUrl = `${process.env.NEXTAUTH_URL || "https://www.zhuig.com"}/user/${userId}`;
const levelInfo = getLevelInfo(fullUser.points);
return (
<div className="min-h-screen bg-[#f8fafc]">
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="mb-4">
<Link href="/community" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
← 返回社区
</Link>
</div>
const initialData = {
id: fullUser.id,
name: fullUser.name,
email: fullUser.email,
bio: fullUser.bio,
avatarUrl: fullUser.avatarUrl,
points: fullUser.points,
level: fullUser.level,
role: fullUser.role,
isBot: fullUser.isBot,
createdAt: fullUser.createdAt,
levelInfo,
stats: {
topicCount: counts?._count.forumTopics || 0,
postCount: counts?._count.forumPosts || 0,
commentCount: counts?._count.comments || 0,
favoriteCount: counts?._count.favorites || 0,
bookmarkCount: counts?._count.topicBookmarks || 0,
followedCategoryCount: followCount,
bestAnswerCount,
likesReceived: totalLikesReceived._sum.likeCount || 0,
checkInStreak: checkInStreak?.streak || 0,
lastCheckInDate: checkInStreak?.checkInDate?.toISOString() || null,
},
achievements: achievements.map(a => ({
key: a.achievement.key,
name: a.achievement.name,
icon: a.achievement.icon,
tier: a.achievement.tier,
progress: a.progress,
unlockedAt: a.unlockedAt?.toISOString() || null,
})),
};
<div className="rounded-xl border border-border/50 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)] overflow-hidden mb-6">
<div className="h-32 bg-gradient-to-r from-violet-500 via-purple-500 to-indigo-500 relative">
<div className="absolute -bottom-12 left-6">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="h-24 w-24 rounded-full border-4 border-white object-cover shadow-lg" />
) : (
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white border-4 border-white shadow-lg text-3xl font-bold text-primary">
{(user.name || user.email).charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="absolute top-4 right-4 flex items-center gap-2">
{isOwner ? (
<Link href="/user" className="px-3 py-1.5 bg-white/20 text-white text-xs rounded-lg hover:bg-white/30 transition-colors flex items-center gap-1">
<Edit3 className="w-3 h-3" /> 编辑资料
</Link>
) : (
<a href={shareUrl} className="px-3 py-1.5 bg-white/20 text-white text-xs rounded-lg hover:bg-white/30 transition-colors flex items-center gap-1"
onClick={(e) => {
e.preventDefault();
navigator.clipboard?.writeText(shareUrl).then(() => alert("已复制分享链接!"));
}}>
<Share2 className="w-3 h-3" /> 分享
</a>
)}
</div>
</div>
<div className="pt-16 px-6 pb-6">
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h1 className="text-xl font-bold">{user.name || user.email?.split("@")[0]}</h1>
<p className="text-sm text-muted-foreground/60 mt-1">{user.email}</p>
{user.bio && <p className="text-sm mt-2 text-muted-foreground/70">{user.bio}</p>}
<div className="mt-2 flex items-center gap-2 flex-wrap">
<MemberBadge level={user.level} points={user.points} size="md" />
<span className="text-xs text-muted-foreground/50 flex items-center gap-1">
<Calendar className="w-3 h-3" />
注册于 {user.createdAt?.toLocaleDateString("zh-CN")}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 gap-4 mt-6">
{[
{ label: "积分", value: user.points, icon: Award, color: "text-emerald-600 bg-emerald-50" },
{ label: "话题", value: user.forumTopics.length, icon: TrendingUp, color: "text-violet-600 bg-violet-50" },
{ label: "回复", value: user.forumPosts.length, icon: MessageSquare, color: "text-blue-600 bg-blue-50" },
{ label: "评论", value: user.comments.length, icon: Star, color: "text-amber-600 bg-amber-50" },
].map((stat) => (
<div key={stat.label} className="text-center p-3 rounded-lg bg-muted/30">
<div className={`mx-auto mb-1 flex h-7 w-7 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="w-3.5 h-3.5" />
</div>
<p className="text-lg font-bold">{stat.value}</p>
<p className="text-xs text-muted-foreground">{stat.label}</p>
</div>
))}
</div>
<div className="mt-6">
<LevelProgress level={user.level} points={user.points} />
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h2 className="text-lg font-semibold mb-4">发布的话题</h2>
{user.forumTopics.length === 0 ? (
<p className="text-sm text-muted-foreground/50 text-center py-6 rounded-xl border border-border/40 bg-white">暂无话题</p>
) : (
<div className="space-y-3">
{user.forumTopics.map((topic) => (
<Link key={topic.id} href={`/community/topic/${topic.id}`} className="block">
<div className="p-4 rounded-xl border border-border/40 bg-white hover:border-border/60 transition-colors shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<h3 className="text-sm font-medium">{topic.title}</h3>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground/50">
<span>{topic.category.name}</span>
<span>{topic.createdAt?.toLocaleDateString("zh-CN")}</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
<div>
<h2 className="text-lg font-semibold mb-4">参与的评论</h2>
{user.comments.length === 0 ? (
<p className="text-sm text-muted-foreground/50 text-center py-6 rounded-xl border border-border/40 bg-white">暂无评论</p>
) : (
<div className="space-y-3">
{user.comments.map((comment) => {
const target = comment.tool || comment.skill;
return (
<Link key={comment.id} href={target ? `/${comment.tool ? "tools" : "skills"}/${target.slug}` : "#"} className="block">
<div className="p-4 rounded-xl border border-border/40 bg-white hover:border-border/60 transition-colors shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<p className="text-sm text-muted-foreground/80 truncate">{comment.content}</p>
{target && (
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground/50">
<span>于 {comment.tool ? "工具" : "技能"}: {target.name}</span>
<span>{comment.createdAt?.toLocaleDateString("zh-CN")}</span>
</div>
)}
</div>
</Link>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
);
return <UserPublicProfile userId={userId} initialData={initialData} />;
}
+5
View File
@@ -1,5 +1,6 @@
"use client";
import type { Metadata } from "next";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
@@ -14,6 +15,10 @@ interface Collection {
_count: { items: number };
}
export const metadata: Metadata = {
title: "我的收藏夹 - 追光AI",
};
export default function CollectionsPage() {
const { data: session, status } = useSession();
const router = useRouter();
+8 -16
View File
@@ -1,10 +1,12 @@
"use client";
import type { Metadata } from "next";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import { PRICING_LABEL, PRICING_COLOR } from "@/lib/constants";
interface FavoriteItem {
id: number;
@@ -33,20 +35,6 @@ interface FavoriteItem {
} | null;
}
const pricingLabel: Record<string, string> = {
free: "免费",
freemium: "免费增值",
paid: "付费",
subscription: "订阅",
};
const pricingColor: Record<string, string> = {
free: "bg-emerald-50 text-emerald-700 border-emerald-200/60",
freemium: "bg-blue-50 text-blue-700 border-blue-200/60",
paid: "bg-amber-50 text-amber-700 border-amber-200/60",
subscription: "bg-purple-50 text-purple-700 border-purple-200/60",
};
const sourceTypeLabel: Record<string, string> = {
github: "GitHub",
gitlab: "GitLab",
@@ -63,6 +51,10 @@ const sourceTypeColor: Record<string, string> = {
type FilterType = "all" | "tool" | "skill";
export const metadata: Metadata = {
title: "我的收藏 - 追光AI",
};
export default function FavoritesPage() {
const { data: session, status } = useSession();
const router = useRouter();
@@ -274,8 +266,8 @@ export default function FavoritesPage() {
</p>
<div className="mt-1 flex items-center gap-2">
{isTool && fav.tool ? (
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${pricingColor[fav.tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{pricingLabel[fav.tool.pricingModel] || fav.tool.pricingModel}
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${PRICING_COLOR[fav.tool.pricingModel] || "bg-secondary text-muted-foreground border-border"}`}>
{PRICING_LABEL[fav.tool.pricingModel] || fav.tool.pricingModel}
</span>
) : !isTool && fav.skill ? (
<span className={`inline-flex rounded-md px-1.5 py-0.5 text-[10px] font-medium ${sourceTypeColor[fav.skill.sourceType] || "bg-secondary text-muted-foreground"}`}>
+334 -98
View File
@@ -1,18 +1,40 @@
"use client";
import type { Metadata } from "next";
import { useState, useEffect } from "react";
import { useSession, signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Heart, Wrench, Zap, LogOut, Edit3, Check, X, Clock, MessageCircle, TrendingUp, Award, Star } from "lucide-react";
import {
Heart,
LogOut,
Edit3,
Check,
X,
Clock,
MessageCircle,
TrendingUp,
Award,
Star,
Bookmark,
CheckCircle2,
ThumbsUp,
Bell,
Loader2,
Activity,
Trophy,
} from "lucide-react";
import MemberBadge from "@/components/common/MemberBadge";
import LevelProgress from "@/components/common/LevelProgress";
import ActivityHeatmap from "@/components/forum/ActivityHeatmap";
import { UserLevel } from "@prisma/client";
function getTimeAgo(date: Date): string {
type Tab = "overview" | "topics" | "posts" | "bookmarks" | "achievements";
function getTimeAgo(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diff = now.getTime() - date.getTime();
const diff = now.getTime() - d.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
@@ -22,9 +44,14 @@ function getTimeAgo(date: Date): string {
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 30) return `${days}天前`;
return date.toLocaleDateString("zh-CN");
return d.toLocaleDateString("zh-CN");
}
export const metadata: Metadata = {
title: "个人中心 - 追光AI",
description: "管理你的追光AI个人资料、收藏与成就。",
};
export default function UserProfilePage() {
const { data: session, status } = useSession();
const router = useRouter();
@@ -43,6 +70,12 @@ export default function UserProfilePage() {
const [userLevel, setUserLevel] = useState<UserLevel>(UserLevel.LV01);
const [pointsHistory, setPointsHistory] = useState<any[]>([]);
const [userStats, setUserStats] = useState<any>(null);
const [tab, setTab] = useState<Tab>("overview");
const [tabData, setTabData] = useState<any[]>([]);
const [tabLoading, setTabLoading] = useState(false);
const [activity, setActivity] = useState<any>(null);
const [achievements, setAchievements] = useState<any[]>([]);
const [unreadNotifs, setUnreadNotifs] = useState(0);
useEffect(() => {
if (status === "loading") return;
@@ -84,12 +117,65 @@ export default function UserProfilePage() {
})
.catch(() => {});
fetch("/api/user/points?type=history&pageSize=5")
fetch("/api/user/points?type=history&pageSize=8")
.then((r) => r.json())
.then((data) => setPointsHistory(data.history || []))
.catch(() => {});
if (session?.user?.id) {
const userId = parseInt(session.user.id);
fetch(`/api/user/${userId}/activity`)
.then(r => r.json())
.then(d => { if (d.heatmap) setActivity(d); })
.catch(() => {});
fetch(`/api/forum/achievements`)
.then(r => r.json())
.then(d => {
const list = (d.unlocked || []).filter((a: any) => a.unlockedAt);
setAchievements(list);
})
.catch(() => {});
fetch(`/api/notifications?pageSize=1`)
.then(r => r.json())
.then(d => setUnreadNotifs(d.unreadCount || 0))
.catch(() => {});
}
}, [status, session]);
useEffect(() => {
if (!session?.user?.id) return;
if (tab === "overview") {
setTabData([]);
return;
}
let mounted = true;
setTabLoading(true);
setTabData([]);
const userId = session.user.id;
const urls: Record<Exclude<Tab, "overview">, string> = {
topics: `/api/user/${userId}/topics`,
posts: `/api/user/${userId}/posts`,
bookmarks: `/api/user/${userId}/bookmarks`,
achievements: `/api/forum/achievements`,
};
fetch(urls[tab as Exclude<Tab, "overview">])
.then(r => r.json())
.then(d => {
if (!mounted) return;
if (tab === "achievements") {
const list = (d.unlocked || []).filter((a: any) => a.unlockedAt);
setAchievements(list);
} else {
setTabData(d.items || d.topics || d.posts || d.bookmarks || []);
}
})
.catch(() => {})
.finally(() => { if (mounted) setTabLoading(false); });
return () => { mounted = false; };
}, [tab, session]);
if (status === "loading") {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -153,14 +239,22 @@ export default function UserProfilePage() {
const shortcuts = [
{ icon: Heart, label: "我的收藏", desc: `${favoriteTotal} 个收藏`, href: "/user/favorites", color: "text-red-600 bg-red-50 border-red-200/60" },
{ icon: Wrench, label: "浏览工具", desc: "发现AI工具", href: "/tools", color: "text-primary bg-primary/10 border-primary/20" },
{ icon: Zap, label: "浏览技能", desc: "探索AI技能", href: "/skills", color: "text-violet-600 bg-violet-50 border-violet-200/60" },
{ icon: Bookmark, label: "话题收藏", desc: "论坛收藏的话题", href: "/user?tab=bookmarks", color: "text-amber-600 bg-amber-50 border-amber-200/60" },
{ icon: Bell, label: "消息通知", desc: `${unreadNotifs} 条未读`, href: "/notifications", color: "text-violet-600 bg-violet-50 border-violet-200/60" },
];
const initial = currentName
? currentName.charAt(0).toUpperCase()
: session?.user?.email?.charAt(0).toUpperCase() || "?";
const tabs: Array<{ key: Tab; label: string }> = [
{ key: "overview", label: "概览" },
{ key: "topics", label: "话题" },
{ key: "posts", label: "回复" },
{ key: "bookmarks", label: "收藏" },
{ key: "achievements", label: "成就" },
];
return (
<div className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
@@ -168,8 +262,8 @@ export default function UserProfilePage() {
<div className="absolute top-60 -right-20 h-[300px] w-[400px] rounded-full bg-purple-500/3 blur-[100px]" />
</div>
<div className="relative mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 animate-fade-in">
<div className="relative mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-6 animate-fade-in">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={() => router.push("/")} className="transition-colors hover:text-foreground">首页</button>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
@@ -218,11 +312,16 @@ export default function UserProfilePage() {
<p className="mt-1 text-sm text-muted-foreground truncate">
{session?.user?.email}
</p>
<div className="mt-2 flex items-center gap-2 justify-center sm:justify-start">
<div className="mt-2 flex items-center gap-2 justify-center sm:justify-start flex-wrap">
<MemberBadge level={userLevel} points={userPoints} size="md" />
<span className="text-xs text-muted-foreground/60">
{favoriteTotal} 个收藏
</span>
{userStats?.checkInStreak !== undefined && userStats.checkInStreak > 0 && (
<span className="text-xs text-orange-600 flex items-center gap-0.5">
🔥 连续{userStats.checkInStreak}天
</span>
)}
{createdAt && (
<>
<span className="text-xs text-muted-foreground/40">·</span>
@@ -275,118 +374,255 @@ export default function UserProfilePage() {
</div>
</Card>
<div className="grid gap-4 sm:grid-cols-2 mb-6">
<LevelProgress level={userLevel} points={userPoints} />
<Card className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<h3 className="text-sm font-semibold mb-3">积分明细</h3>
{pointsHistory.length === 0 ? (
<p className="text-xs text-muted-foreground/50 text-center py-4">暂无积分记录</p>
) : (
<div className="space-y-2">
{pointsHistory.map((ph: any) => (
<div key={ph.id} className="flex items-center justify-between text-xs">
<div>
<p className="font-medium text-foreground/80">{ph.reason}</p>
{ph.detail && <p className="text-muted-foreground/50 truncate max-w-[150px]">{ph.detail}</p>}
</div>
<div className="text-right">
<p className="font-semibold text-emerald-600">+{ph.points}</p>
<p className="text-muted-foreground/40">{new Date(ph.createdAt).toLocaleDateString("zh-CN")}</p>
</div>
</div>
))}
</div>
)}
</Card>
</div>
{userStats && (
<div className="grid gap-4 grid-cols-4 mb-6">
<div className="grid gap-3 grid-cols-4 sm:grid-cols-8 mb-6">
{[
{ label: "评论", value: userStats.commentCount || 0, icon: MessageCircle, color: "text-blue-600 bg-blue-50" },
{ label: "话题", value: userStats.topicCount || 0, icon: TrendingUp, color: "text-violet-600 bg-violet-50" },
{ label: "回复", value: userStats.postCount || 0, icon: MessageCircle, color: "text-blue-600 bg-blue-50" },
{ label: "评价", value: userStats.reviewCount || 0, icon: Star, color: "text-amber-600 bg-amber-50" },
{ label: "积分", value: userStats.points || 0, icon: Award, color: "text-emerald-600 bg-emerald-50" },
{ label: "评论", value: userStats.commentCount || 0, icon: MessageCircle, color: "text-cyan-600 bg-cyan-50" },
{ label: "最佳", value: userStats.bestAnswerCount || 0, icon: CheckCircle2, color: "text-green-600 bg-green-50" },
{ label: "获赞", value: userStats.likesReceived || 0, icon: ThumbsUp, color: "text-pink-600 bg-pink-50" },
{ label: "成就", value: achievements.length, icon: Trophy, color: "text-yellow-600 bg-yellow-50" },
].map((stat) => (
<div key={stat.label} className="rounded-xl border border-border/40 bg-white p-4 text-center shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className={`mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="w-4 h-4" />
<div key={stat.label} className="rounded-xl border border-border/40 bg-white p-2 sm:p-3 text-center shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className={`mx-auto mb-1 flex h-7 w-7 items-center justify-center rounded-lg ${stat.color}`}>
<stat.icon className="w-3.5 h-3.5" />
</div>
<p className="text-lg font-bold">{stat.value}</p>
<p className="text-xs text-muted-foreground">{stat.label}</p>
<p className="text-base font-bold">{stat.value}</p>
<p className="text-[10px] text-muted-foreground">{stat.label}</p>
</div>
))}
</div>
)}
<div className="grid gap-4 sm:grid-cols-3 mb-6">
{shortcuts.map((s) => (
<Card
key={s.label}
className="group cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_4px_12px_rgba(0,0,0,0.06)] hover:-translate-y-0.5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
onClick={() => router.push(s.href)}
<div className="flex gap-2 mb-4 border-b border-border/40 overflow-x-auto">
{tabs.map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2.5 text-sm font-medium transition-colors whitespace-nowrap relative ${
tab === t.key ? "text-primary" : "text-muted-foreground hover:text-foreground"
}`}
>
<CardContent className="p-5 text-center">
<div className={`mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-xl border ${s.color}`}>
<s.icon className="w-5 h-5" />
</div>
<p className="font-medium text-sm">{s.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{s.desc}</p>
</CardContent>
</Card>
{t.label}
{tab === t.key && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />}
</button>
))}
</div>
{browsingHistory.length > 0 && (
<Card className="border-border/60 bg-white mb-6 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<Clock className="w-4 h-4 text-muted-foreground" />
<h2 className="font-semibold text-sm">最近浏览</h2>
</div>
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide">
{browsingHistory.map((item: any) => {
const isTool = !!item.tool;
const entity = item.tool || item.skill;
if (!entity) return null;
{tab === "overview" && (
<>
<div className="grid gap-4 sm:grid-cols-2 mb-6">
<LevelProgress level={userLevel} points={userPoints} />
const href = isTool ? `/tools/${entity.slug}` : `/skills/${entity.slug}`;
const timeAgo = getTimeAgo(new Date(item.viewedAt));
return (
<div
key={item.id}
className="flex-shrink-0 w-[180px] cursor-pointer rounded-xl border border-border/50 bg-white p-3 transition-all duration-200 hover:border-border hover:shadow-sm shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
onClick={() => router.push(href)}
>
<div className="flex items-center gap-2.5">
{entity.logoUrl ? (
<img src={entity.logoUrl} referrerPolicy="no-referrer" alt="" className="h-8 w-8 rounded-lg border border-border/40 object-contain p-0.5" />
) : (
<div className={`flex h-8 w-8 items-center justify-center rounded-lg text-xs font-semibold ${isTool ? "bg-primary/10 text-primary" : "bg-violet-100 text-violet-700"}`}>
{entity.name.charAt(0)}
</div>
)}
<Card className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<h3 className="text-sm font-semibold mb-3">最近积分</h3>
{pointsHistory.length === 0 ? (
<p className="text-xs text-muted-foreground/50 text-center py-4">暂无积分记录</p>
) : (
<div className="space-y-2 max-h-[160px] overflow-y-auto pr-1">
{pointsHistory.map((ph: any) => (
<div key={ph.id} className="flex items-center justify-between text-xs">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-foreground">{entity.name}</p>
<div className="flex items-center gap-1 mt-0.5">
<span className={`rounded px-1 py-0 text-[8px] font-medium ${isTool ? "bg-primary/10 text-primary" : "bg-violet-100 text-violet-700"}`}>
{isTool ? "工具" : "技能"}
</span>
<span className="text-[8px] text-muted-foreground/50">{timeAgo}</span>
</div>
<p className="font-medium text-foreground/80 truncate">{ph.reason}</p>
{ph.detail && <p className="text-muted-foreground/50 truncate">{ph.detail}</p>}
</div>
<div className="text-right shrink-0 ml-2">
<p className="font-semibold text-emerald-600">+{ph.points}</p>
<p className="text-muted-foreground/40">{new Date(ph.createdAt).toLocaleDateString("zh-CN")}</p>
</div>
</div>
))}
</div>
)}
</Card>
</div>
{activity && (
<Card className="rounded-xl border border-border/50 bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04)] mb-6">
<div className="flex items-center gap-2 mb-4">
<Activity className="w-4 h-4 text-emerald-600" />
<h3 className="text-sm font-semibold">活跃足迹</h3>
<span className="text-xs text-muted-foreground/60 ml-2">最近 {activity.summary.totalDays} 天 · 活跃 {activity.summary.activeDays} 天</span>
</div>
<ActivityHeatmap data={activity.heatmap} />
</Card>
)}
<div className="grid gap-4 sm:grid-cols-3 mb-6">
{shortcuts.map((s) => (
<Card
key={s.label}
className="group cursor-pointer border-border/50 bg-white transition-all duration-300 hover:border-primary/30 hover:shadow-[0_4px_12px_rgba(0,0,0,0.06)] hover:-translate-y-0.5 shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
onClick={() => {
if (s.href.startsWith("/user?tab=")) setTab("bookmarks");
else router.push(s.href);
}}
>
<CardContent className="p-5 text-center">
<div className={`mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-xl border ${s.color}`}>
<s.icon className="w-5 h-5" />
</div>
);
})}
</div>
<p className="font-medium text-sm">{s.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{s.desc}</p>
</CardContent>
</Card>
))}
</div>
{browsingHistory.length > 0 && (
<Card className="border-border/60 bg-white mb-6 shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<Clock className="w-4 h-4 text-muted-foreground" />
<h2 className="font-semibold text-sm">最近浏览</h2>
</div>
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide">
{browsingHistory.slice(0, 12).map((item: any) => {
const isTool = !!item.tool;
const entity = item.tool || item.skill;
if (!entity) return null;
const href = isTool ? `/tools/${entity.slug}` : `/skills/${entity.slug}`;
const timeAgo = getTimeAgo(item.viewedAt);
return (
<div
key={item.id}
className="flex-shrink-0 w-[180px] cursor-pointer rounded-xl border border-border/50 bg-white p-3 transition-all duration-200 hover:border-border hover:shadow-sm shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
onClick={() => router.push(href)}
>
<div className="flex items-center gap-2.5">
{entity.logoUrl ? (
<img src={entity.logoUrl} referrerPolicy="no-referrer" alt="" className="h-8 w-8 rounded-lg border border-border/40 object-contain p-0.5" />
) : (
<div className={`flex h-8 w-8 items-center justify-center rounded-lg text-xs font-semibold ${isTool ? "bg-primary/10 text-primary" : "bg-violet-100 text-violet-700"}`}>
{entity.name.charAt(0)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-foreground">{entity.name}</p>
<div className="flex items-center gap-1 mt-0.5">
<span className={`rounded px-1 py-0 text-[8px] font-medium ${isTool ? "bg-primary/10 text-primary" : "bg-violet-100 text-violet-700"}`}>
{isTool ? "工具" : "技能"}
</span>
<span className="text-[8px] text-muted-foreground/50">{timeAgo}</span>
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</>
)}
{(tab === "topics" || tab === "posts" || tab === "bookmarks") && (
<Card className="rounded-xl border border-border/40 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
{tabLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground/40" />
</div>
) : tabData.length === 0 ? (
<div className="text-center py-12 text-sm text-muted-foreground/50">
暂无{tab === "topics" ? "话题" : tab === "posts" ? "回复" : "收藏"}
</div>
) : (
<div className="space-y-3">
{tabData.slice(0, 15).map((item: any) => {
if (tab === "topics") {
return (
<div key={item.id} onClick={() => router.push(`/community/topic/${item.id}`)} className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors cursor-pointer">
<div className="flex items-center gap-2 mb-1">
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-violet-50 text-violet-700">
{item.category?.name || "话题"}
</span>
</div>
<h3 className="text-sm font-medium">{item.title}</h3>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>👁 {item.viewCount || 0}</span>
<span>💬 {item.replyCount || 0}</span>
<span>👍 {item.likeCount || 0}</span>
<span>·</span>
<span>{getTimeAgo(item.createdAt)}</span>
</div>
</div>
);
}
if (tab === "posts") {
return (
<div key={item.id} onClick={() => router.push(`/community/topic/${item.topic?.id || ""}`)} className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors cursor-pointer">
{item.isAnswer && (
<span className="inline-block mb-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-emerald-50 text-emerald-700">✓ 最佳答案</span>
)}
<p className="text-sm text-muted-foreground/80 line-clamp-2">{item.content}</p>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>回复于:{item.topic?.title || "—"}</span>
<span>·</span>
<span>{getTimeAgo(item.createdAt)}</span>
</div>
</div>
);
}
// bookmarks
return (
<div key={item.id} onClick={() => router.push(`/community/topic/${item.topic?.id || ""}`)} className="p-3 rounded-lg border border-border/30 hover:border-border/60 hover:bg-muted/20 transition-colors cursor-pointer">
<h3 className="text-sm font-medium">{item.topic?.title || "—"}</h3>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground/50">
<span>收藏于 {getTimeAgo(item.createdAt)}</span>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
)}
<Card className="border-border/60 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
{tab === "achievements" && (
<Card className="rounded-xl border border-border/40 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
{tabLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground/40" />
</div>
) : achievements.length === 0 ? (
<div className="text-center py-12 text-sm text-muted-foreground/50">
<Trophy className="w-10 h-10 text-muted-foreground/30 mx-auto mb-2" />
尚未解锁任何成就
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{achievements.map((a: any) => {
const tierColors: Record<string, string> = {
bronze: "from-amber-100 to-amber-50 text-amber-700",
silver: "from-slate-100 to-slate-50 text-slate-700",
gold: "from-yellow-100 to-yellow-50 text-yellow-700",
diamond: "from-violet-100 to-violet-50 text-violet-700",
};
const cls = tierColors[a.tier] || tierColors.bronze;
return (
<div key={a.key} className={`rounded-xl border border-border/40 bg-gradient-to-br ${cls} p-3 text-center`}>
<div className="text-3xl mb-1">{a.icon}</div>
<p className="text-xs font-semibold truncate">{a.name}</p>
{a.unlockedAt && (
<p className="text-[10px] opacity-60 mt-0.5">{getTimeAgo(a.unlockedAt)}</p>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
)}
<Card className="mt-6 border-border/60 bg-white shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]">
<CardContent className="p-5">
<button
onClick={() => signOut({ callbackUrl: "/" })}