Files
msgapp/src/app/my-page/page.tsx

295 lines
14 KiB
TypeScript
Raw Normal View History

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>
);
}