2025-11-02 13:32:19 +09:00
|
|
|
import { headers } from "next/headers";
|
2025-11-09 22:05:22 +09:00
|
|
|
import { redirect } from "next/navigation";
|
2025-11-02 13:32:19 +09:00
|
|
|
import prisma from "@/lib/prisma";
|
|
|
|
|
import Link from "next/link";
|
|
|
|
|
import { UserAvatar } from "@/app/components/UserAvatar";
|
|
|
|
|
import { GradeIcon, getGradeName } from "@/app/components/GradeIcon";
|
|
|
|
|
import { HeroBanner } from "@/app/components/HeroBanner";
|
|
|
|
|
import { PostList } from "@/app/components/PostList";
|
|
|
|
|
import ProfileLabelIcon from "@/app/svgs/profilelableicon";
|
2025-11-09 22:05:22 +09:00
|
|
|
import { ProfileImageEditor } from "@/app/components/ProfileImageEditor";
|
2025-11-02 13:32:19 +09:00
|
|
|
export default async function MyPage({ searchParams }: { searchParams: Promise<{ tab?: string; page?: string; sort?: string; q?: string }> }) {
|
|
|
|
|
const sp = await searchParams;
|
|
|
|
|
const activeTab = sp?.tab || "posts";
|
|
|
|
|
const page = parseInt(sp?.page || "1", 10);
|
|
|
|
|
const sort = sp?.sort || "recent";
|
|
|
|
|
const q = sp?.q || "";
|
|
|
|
|
|
|
|
|
|
// 현재 로그인한 사용자 정보 가져오기
|
|
|
|
|
let currentUser: {
|
|
|
|
|
userId: string;
|
|
|
|
|
nickname: string;
|
|
|
|
|
profileImage: string | null;
|
|
|
|
|
points: number;
|
|
|
|
|
level: number;
|
|
|
|
|
grade: number;
|
|
|
|
|
} | null = null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const h = await headers();
|
|
|
|
|
const cookieHeader = h.get("cookie") || "";
|
2025-11-09 22:05:22 +09:00
|
|
|
let isAdmin = false;
|
2025-11-02 13:32:19 +09:00
|
|
|
const uid = cookieHeader
|
|
|
|
|
.split(";")
|
|
|
|
|
.map((s) => s.trim())
|
|
|
|
|
.find((pair) => pair.startsWith("uid="))
|
|
|
|
|
?.split("=")[1];
|
2025-11-09 22:05:22 +09:00
|
|
|
const isAdminVal = cookieHeader
|
|
|
|
|
.split(";")
|
|
|
|
|
.map((s) => s.trim())
|
|
|
|
|
.find((pair) => pair.startsWith("isAdmin="))
|
|
|
|
|
?.split("=")[1];
|
|
|
|
|
isAdmin = isAdminVal === "1";
|
2025-11-02 13:32:19 +09:00
|
|
|
|
2025-11-09 22:05:22 +09:00
|
|
|
if (!uid) {
|
|
|
|
|
redirect(`/login?next=/my-page`);
|
|
|
|
|
}
|
2025-11-02 13:32:19 +09:00
|
|
|
if (uid) {
|
|
|
|
|
const user = await prisma.user.findUnique({
|
|
|
|
|
where: { userId: decodeURIComponent(uid) },
|
|
|
|
|
select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true },
|
|
|
|
|
});
|
2025-11-09 22:05:22 +09:00
|
|
|
if (user) {
|
|
|
|
|
currentUser = { ...user, isAdmin } as any;
|
|
|
|
|
} else {
|
|
|
|
|
redirect(`/login?next=/my-page`);
|
|
|
|
|
}
|
2025-11-02 13:32:19 +09:00
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// 에러 무시
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-09 22:05:22 +09:00
|
|
|
if (!currentUser) redirect(`/login?next=/my-page`);
|
2025-11-02 13:32:19 +09:00
|
|
|
|
|
|
|
|
// 통계 정보 가져오기
|
|
|
|
|
const [postsCount, commentsCount, receivedMessagesCount, sentMessagesCount] = await Promise.all([
|
|
|
|
|
prisma.post.count({ where: { authorId: currentUser.userId } }),
|
|
|
|
|
prisma.comment.count({ where: { authorId: currentUser.userId } }),
|
|
|
|
|
prisma.message.count({ where: { receiverId: currentUser.userId } }),
|
|
|
|
|
prisma.message.count({ where: { senderId: currentUser.userId } }),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// 등급 업그레이드에 필요한 포인트 계산 (예시: 다음 등급까지)
|
|
|
|
|
const nextGradePoints = (() => {
|
|
|
|
|
// 등급별 포인트 기준을 여기에 설정 (임시로 2M)
|
|
|
|
|
const gradeThresholds = [0, 200000, 400000, 800000, 1600000, 3200000, 6400000, 10000000];
|
|
|
|
|
const currentGradeIndex = Math.min(7, Math.max(0, currentUser.grade));
|
|
|
|
|
return gradeThresholds[currentGradeIndex + 1] || 2000000;
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
const tabs = [
|
|
|
|
|
{ key: "posts", label: "내가 쓴 게시글", count: postsCount },
|
|
|
|
|
{ key: "comments", label: "내가 쓴 댓글", count: commentsCount },
|
2025-11-02 15:13:03 +09:00
|
|
|
{ key: "points", label: "포인트 히스토리", count: await prisma.pointTransaction.count({ where: { userId: currentUser.userId } }) },
|
2025-11-02 13:32:19 +09:00
|
|
|
{ key: "messages-received", label: "받은 쪽지함", count: receivedMessagesCount },
|
|
|
|
|
{ key: "messages-sent", label: "보낸 쪽지함", count: sentMessagesCount },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{/* 히어로 배너 */}
|
|
|
|
|
<section>
|
2025-11-09 22:05:22 +09:00
|
|
|
<HeroBanner
|
|
|
|
|
subItems={[
|
|
|
|
|
{ id: "my-page", name: "마이페이지", href: "/my-page" },
|
|
|
|
|
]}
|
|
|
|
|
activeSubId="my-page"
|
|
|
|
|
/>
|
2025-11-02 13:32:19 +09:00
|
|
|
</section>
|
|
|
|
|
|
2025-11-02 15:13:03 +09:00
|
|
|
{/* 프로필 섹션 (모바일 대응) */}
|
|
|
|
|
<section className="bg-white rounded-[16px] px-4 py-4 md:rounded-[24px] md:px-[108px] md:py-[23px]">
|
|
|
|
|
<div className="flex flex-col md:flex-row gap-4 md:gap-[64px] items-center justify-center">
|
2025-11-02 13:32:19 +09:00
|
|
|
{/* 좌측: 프로필 이미지, 닉네임, 레벨/등급/포인트 */}
|
|
|
|
|
<div className="flex flex-col gap-[16px] items-center">
|
|
|
|
|
<div className="flex flex-col gap-[16px] items-center">
|
|
|
|
|
{/* 프로필 이미지 */}
|
2025-11-02 15:13:03 +09:00
|
|
|
<div className="relative w-[112px] h-[112px] md:w-[161px] md:h-[162px]">
|
2025-11-02 13:32:19 +09:00
|
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
2025-11-02 15:13:03 +09:00
|
|
|
<svg className="w-full h-full" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
|
2025-11-02 13:32:19 +09:00
|
|
|
<circle cx="80" cy="80" r="79" stroke="#8c8c8c" strokeWidth="2" fill="none" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
2025-11-02 15:13:03 +09:00
|
|
|
<div className="absolute inset-[8px] md:inset-[10px] flex items-center justify-center">
|
2025-11-02 13:32:19 +09:00
|
|
|
<UserAvatar
|
|
|
|
|
src={currentUser.profileImage || null}
|
|
|
|
|
alt={currentUser.nickname || "프로필"}
|
|
|
|
|
width={140}
|
|
|
|
|
height={140}
|
2025-11-02 15:13:03 +09:00
|
|
|
className="rounded-full w-full h-full object-cover"
|
2025-11-02 13:32:19 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/* 닉네임 */}
|
|
|
|
|
<div className="flex items-center gap-[4px]">
|
|
|
|
|
<span className="text-[20px] font-bold text-[#5c5c5c] truncate max-w-[200px]">{currentUser.nickname || "사용자"}</span>
|
|
|
|
|
<button className="w-[20px] h-[20px] shrink-0">
|
2025-11-09 22:05:22 +09:00
|
|
|
<svg width="20" height="20" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
|
|
|
<path d="M1.05882 10.9412H1.94929L9.17506 3.71541L8.28459 2.82494L1.05882 10.0507V10.9412ZM0.638118 12C0.457294 12 0.305765 11.9388 0.183529 11.8165C0.0611764 11.6942 0 11.5427 0 11.3619V10.1389C0 9.96682 0.0330587 9.80277 0.0991764 9.64677C0.165176 9.49077 0.256118 9.35483 0.372 9.23894L9.31094 0.304058C9.41765 0.207117 9.53547 0.132235 9.66441 0.0794118C9.79347 0.0264706 9.92877 0 10.0703 0C10.2118 0 10.3489 0.025118 10.4815 0.0753533C10.6142 0.125589 10.7316 0.20547 10.8339 0.315L11.6959 1.18782C11.8055 1.29006 11.8835 1.40771 11.9301 1.54076C11.9767 1.67382 12 1.80688 12 1.93994C12 2.08194 11.9758 2.21741 11.9273 2.34635C11.8788 2.47541 11.8017 2.59329 11.6959 2.7L2.76106 11.628C2.64518 11.7439 2.50924 11.8348 2.35324 11.9008C2.19724 11.9669 2.03318 12 1.86106 12H0.638118ZM8.72206 3.27794L8.28459 2.82494L9.17506 3.71541L8.72206 3.27794Z" fill="#707070"/>
|
2025-11-02 13:32:19 +09:00
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2025-11-09 22:05:22 +09:00
|
|
|
{/* 관리자 설정 버튼 */}
|
|
|
|
|
{"isAdmin" in (currentUser as any) && (currentUser as any).isAdmin && (
|
|
|
|
|
<div className="mt-1">
|
|
|
|
|
<Link
|
|
|
|
|
href="/admin"
|
|
|
|
|
className="inline-flex items-center h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800"
|
|
|
|
|
>
|
|
|
|
|
관리자설정
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-11-02 13:32:19 +09:00
|
|
|
</div>
|
2025-11-09 22:05:22 +09:00
|
|
|
{/* 프로필 이미지 편집 */}
|
|
|
|
|
<ProfileImageEditor initialUrl={currentUser.profileImage || null} />
|
2025-11-02 13:32:19 +09:00
|
|
|
{/* 레벨/등급/포인트 정보 - 가로 배치 */}
|
2025-11-02 15:13:03 +09:00
|
|
|
<div className="flex gap-3 md:gap-[16px] items-center justify-center">
|
2025-11-02 13:32:19 +09:00
|
|
|
<div className="flex items-center gap-[4px] min-w-0">
|
|
|
|
|
<ProfileLabelIcon width={14} height={14} className="shrink-0" />
|
|
|
|
|
<span className="text-[11px] text-[#8c8c8c] font-[700] shrink-0">레벨</span>
|
2025-11-02 15:13:03 +09:00
|
|
|
<span className="text-[13px] md:text-[14px] text-[#5c5c5c] font-[700] min-w-0 truncate">Lv.{currentUser.level || 1}</span>
|
2025-11-02 13:32:19 +09:00
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-[4px] min-w-0">
|
|
|
|
|
<ProfileLabelIcon width={14} height={14} className="shrink-0" />
|
|
|
|
|
<span className="text-[11px] text-[#8c8c8c] font-[700] shrink-0">등급</span>
|
2025-11-02 15:13:03 +09:00
|
|
|
<span className="text-[13px] md:text-[14px] text-[#5c5c5c] font-[700] min-w-0 truncate">{getGradeName(currentUser.grade)}</span>
|
2025-11-02 13:32:19 +09:00
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-[4px] min-w-0">
|
|
|
|
|
<ProfileLabelIcon width={14} height={14} className="shrink-0" />
|
|
|
|
|
<span className="text-[11px] text-[#8c8c8c] font-[700] shrink-0">포인트</span>
|
2025-11-02 15:13:03 +09:00
|
|
|
<span className="text-[13px] md:text-[14px] text-[#5c5c5c] font-[700] min-w-0 truncate">{currentUser.points.toLocaleString()}</span>
|
2025-11-02 13:32:19 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-02 15:13:03 +09:00
|
|
|
{/* 구분선 (모바일 숨김) */}
|
|
|
|
|
<div className="hidden md:block w-[8px] h-[162px] bg-[#d5d5d5] shrink-0"></div>
|
2025-11-02 13:32:19 +09:00
|
|
|
|
|
|
|
|
{/* 우측: 등급 배지 및 포인트 진행 상황 */}
|
2025-11-02 15:13:03 +09:00
|
|
|
<div className="flex flex-col items-center justify-center w-[120px] md:w-[161px] shrink-0">
|
|
|
|
|
<div className="w-[96px] h-[96px] md:w-[125px] md:h-[125px] flex items-center justify-center mb-[16px]">
|
2025-11-02 13:32:19 +09:00
|
|
|
<GradeIcon grade={currentUser.grade} width={125} height={125} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col gap-[16px] items-center">
|
2025-11-02 15:13:03 +09:00
|
|
|
<div className="text-[16px] md:text-[20px] font-bold text-[#5c5c5c]">{getGradeName(currentUser.grade)}</div>
|
|
|
|
|
<div className="flex items-start gap-[8px] text-[16px] md:text-[20px] font-bold">
|
2025-11-02 13:32:19 +09:00
|
|
|
<span className="text-[#5c5c5c] truncate">{(currentUser.points / 1000000).toFixed(1)}M</span>
|
|
|
|
|
<span className="text-[#8c8c8c] shrink-0">/ {(nextGradePoints / 1000000).toFixed(1)}M</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
2025-11-02 15:13:03 +09:00
|
|
|
{/* 탭 버튼 (모바일 가로 스크롤) */}
|
|
|
|
|
<section className="mt-4">
|
|
|
|
|
<div className="bg-white rounded-[16px] p-2 md:rounded-[24px] md:p-[10px] flex gap-2 md:gap-[10px] items-center overflow-x-auto scrollbar-thin-x">
|
2025-11-02 13:32:19 +09:00
|
|
|
{tabs.map((tab) => (
|
|
|
|
|
<Link
|
|
|
|
|
key={tab.key}
|
|
|
|
|
href={`/my-page?tab=${tab.key}`}
|
2025-11-02 15:13:03 +09:00
|
|
|
className={`shrink-0 md:flex-1 h-10 md:h-[46px] rounded-[16px] flex items-center justify-center gap-[8px] px-3 ${
|
2025-11-02 13:32:19 +09:00
|
|
|
activeTab === tab.key
|
|
|
|
|
? "bg-[#5c5c5c] text-white"
|
|
|
|
|
: "bg-transparent text-[#5c5c5c]"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
2025-11-02 15:13:03 +09:00
|
|
|
<span className={`text-[14px] md:text-[16px] ${activeTab === tab.key ? "font-medium" : "font-normal"}`}>
|
2025-11-02 13:32:19 +09:00
|
|
|
{tab.label}
|
|
|
|
|
</span>
|
|
|
|
|
<div
|
2025-11-02 15:13:03 +09:00
|
|
|
className={`inline-flex items-center justify-center h-[20px] px-[8px] rounded-[14px] ${
|
2025-11-02 13:32:19 +09:00
|
|
|
activeTab === tab.key
|
|
|
|
|
? "bg-white"
|
|
|
|
|
: "border border-[#707070]"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<span
|
2025-11-02 15:13:03 +09:00
|
|
|
className={`text-[10px] leading-none font-semibold ${
|
2025-11-02 13:32:19 +09:00
|
|
|
activeTab === tab.key ? "text-[#707070]" : "text-[#707070]"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{tab.count.toLocaleString()}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* 컨텐츠 영역 */}
|
|
|
|
|
<section>
|
|
|
|
|
{activeTab === "posts" && (
|
|
|
|
|
<PostList
|
|
|
|
|
authorId={currentUser.userId}
|
|
|
|
|
sort={sort as "recent" | "popular"}
|
|
|
|
|
q={q}
|
|
|
|
|
variant="board"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-11-02 15:13:03 +09:00
|
|
|
{activeTab === "comments" && (async () => {
|
|
|
|
|
const pageSize = 20;
|
|
|
|
|
const comments = await prisma.comment.findMany({
|
|
|
|
|
where: { authorId: currentUser.userId },
|
|
|
|
|
orderBy: { createdAt: "desc" },
|
|
|
|
|
take: pageSize,
|
|
|
|
|
skip: (page - 1) * pageSize,
|
|
|
|
|
select: { id: true, postId: true, content: true, createdAt: true },
|
|
|
|
|
});
|
|
|
|
|
return (
|
|
|
|
|
<div className="bg-white rounded-[24px] p-4">
|
|
|
|
|
<ul className="divide-y divide-neutral-200">
|
|
|
|
|
{comments.map((c) => (
|
|
|
|
|
<li key={c.id} className="py-3 flex items-center justify-between gap-4">
|
|
|
|
|
<Link href={`/posts/${c.postId}`} className="text-sm text-neutral-800 hover:underline truncate max-w-[70%]">
|
|
|
|
|
{c.content.slice(0, 80)}{c.content.length > 80 ? "…" : ""}
|
|
|
|
|
</Link>
|
|
|
|
|
<span className="text-xs text-neutral-500 shrink-0">{new Date(c.createdAt).toLocaleString()}</span>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
{activeTab === "points" && (async () => {
|
|
|
|
|
const pageSize = 20;
|
|
|
|
|
const txns = await prisma.pointTransaction.findMany({
|
|
|
|
|
where: { userId: currentUser.userId },
|
|
|
|
|
orderBy: { createdAt: "desc" },
|
|
|
|
|
take: pageSize,
|
|
|
|
|
skip: (page - 1) * pageSize,
|
|
|
|
|
select: { id: true, amount: true, reason: true, createdAt: true },
|
|
|
|
|
});
|
|
|
|
|
return (
|
|
|
|
|
<div className="bg-white rounded-[24px] p-4">
|
|
|
|
|
<ul className="divide-y divide-neutral-200">
|
|
|
|
|
{txns.map((t) => (
|
|
|
|
|
<li key={t.id} className="py-3 flex items-center justify-between gap-4">
|
|
|
|
|
<div className="text-sm text-neutral-800 truncate max-w-[60%]">{t.reason}</div>
|
|
|
|
|
<div className={`text-sm font-semibold ${t.amount >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
|
|
|
|
{t.amount >= 0 ? `+${t.amount}` : `${t.amount}`}
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-xs text-neutral-500 shrink-0">{new Date(t.createdAt).toLocaleString()}</span>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
2025-11-02 13:32:19 +09:00
|
|
|
{activeTab === "messages-received" && (
|
|
|
|
|
<div className="bg-white rounded-[24px] p-4">
|
|
|
|
|
<div className="text-center text-neutral-500 py-10">받은 쪽지함 기능은 준비 중입니다.</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{activeTab === "messages-sent" && (
|
|
|
|
|
<div className="bg-white rounded-[24px] p-4">
|
|
|
|
|
<div className="text-center text-neutral-500 py-10">보낸 쪽지함 기능은 준비 중입니다.</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|