Files
msgapp/src/app/my-page/page.tsx
koreacomp5 b579b32138
Some checks failed
deploy-on-main / deploy (push) Failing after 23s
컴잇
2025-11-10 01:39:44 +09:00

352 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { headers } from "next/headers";
import { redirect } from "next/navigation";
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";
import { ProfileEditTrigger } from "@/app/components/ProfileEditTrigger";
import { SendMessageForm } from "@/app/components/SendMessageForm";
export default async function MyPage({ searchParams }: { searchParams: Promise<{ tab?: string; page?: string; sort?: string; q?: string; to?: 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 || "";
const toUserId = sp?.to || "";
// 현재 로그인한 사용자 정보 가져오기
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") || "";
let isAdmin = false;
const uid = cookieHeader
.split(";")
.map((s) => s.trim())
.find((pair) => pair.startsWith("uid="))
?.split("=")[1];
const isAdminVal = cookieHeader
.split(";")
.map((s) => s.trim())
.find((pair) => pair.startsWith("isAdmin="))
?.split("=")[1];
isAdmin = isAdminVal === "1";
if (!uid) {
redirect(`/login?next=/my-page`);
}
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 },
});
if (user) {
currentUser = { ...user, isAdmin } as any;
} else {
redirect(`/login?next=/my-page`);
}
}
} catch (e) {
// 에러 무시
}
if (!currentUser) redirect(`/login?next=/my-page`);
// 통계 정보 가져오기
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 },
{ key: "points", label: "포인트 히스토리", count: await prisma.pointTransaction.count({ where: { userId: currentUser.userId } }) },
{ key: "messages-received", label: "받은 쪽지함", count: receivedMessagesCount },
{ key: "messages-sent", label: "보낸 쪽지함", count: sentMessagesCount },
];
return (
<div className="space-y-6">
{/* 히어로 배너 */}
<section>
<HeroBanner
subItems={[
{ id: "my-page", name: "마이페이지", href: "/my-page" },
]}
activeSubId="my-page"
/>
</section>
{/* 프로필 섹션 (모바일 대응) */}
<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">
{/* 좌측: 프로필 이미지, 닉네임, 레벨/등급/포인트 */}
<div className="flex flex-col gap-[16px] items-center">
<div className="flex flex-col gap-[16px] items-center">
{/* 프로필 이미지 */}
<div className="relative w-[112px] h-[112px] md:w-[161px] md:h-[162px]">
<div className="absolute inset-0 flex items-center justify-center">
<svg className="w-full h-full" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="80" cy="80" r="79" stroke="#8c8c8c" strokeWidth="2" fill="none" />
</svg>
</div>
<div className="absolute inset-[8px] md:inset-[10px] flex items-center justify-center">
<UserAvatar
src={currentUser.profileImage || null}
alt={currentUser.nickname || "프로필"}
width={140}
height={140}
className="rounded-full w-full h-full object-cover"
/>
</div>
</div>
{/* 닉네임 */}
<div className="flex items-center gap-[4px]">
<span className="text-[20px] font-bold text-[#5c5c5c] truncate max-w-[200px]">{currentUser.nickname || "사용자"}</span>
<ProfileEditTrigger initialProfileImageUrl={currentUser.profileImage || null} />
</div>
{/* 관리자 설정 버튼 */}
{"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>
)}
</div>
{/* 프로필 수정은 연필 아이콘(모달)에서 처리 */}
{/* 레벨/등급/포인트 정보 - 가로 배치 */}
<div className="flex gap-3 md:gap-[16px] items-center justify-center">
<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>
<span className="text-[13px] md:text-[14px] text-[#5c5c5c] font-[700] min-w-0 truncate">Lv.{currentUser.level || 1}</span>
</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>
<span className="text-[13px] md:text-[14px] text-[#5c5c5c] font-[700] min-w-0 truncate">{getGradeName(currentUser.grade)}</span>
</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>
<span className="text-[13px] md:text-[14px] text-[#5c5c5c] font-[700] min-w-0 truncate">{currentUser.points.toLocaleString()}</span>
</div>
</div>
</div>
{/* 구분선 (모바일 숨김) */}
<div className="hidden md:block w-[8px] h-[162px] bg-[#d5d5d5] shrink-0"></div>
{/* 우측: 등급 배지 및 포인트 진행 상황 */}
<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]">
<GradeIcon grade={currentUser.grade} width={125} height={125} />
</div>
<div className="flex flex-col gap-[16px] items-center">
<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">
<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>
{/* 탭 버튼 (모바일 가로 스크롤) */}
<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">
{tabs.map((tab) => (
<Link
key={tab.key}
href={`/my-page?tab=${tab.key}`}
className={`shrink-0 md:flex-1 h-10 md:h-[46px] rounded-[16px] flex items-center justify-center gap-[8px] px-3 ${
activeTab === tab.key
? "bg-[#5c5c5c] text-white"
: "bg-transparent text-[#5c5c5c]"
}`}
>
<span className={`text-[14px] md:text-[16px] ${activeTab === tab.key ? "font-medium" : "font-normal"}`}>
{tab.label}
</span>
<div
className={`inline-flex items-center justify-center h-[20px] px-[8px] rounded-[14px] ${
activeTab === tab.key
? "bg-white"
: "border border-[#707070]"
}`}
>
<span
className={`text-[10px] leading-none font-semibold ${
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"
/>
)}
{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>
);
})()}
{activeTab === "messages-received" && (async () => {
const pageSize = 20;
// 받은 쪽지함 진입 시 미읽은 메시지를 읽음 처리
await prisma.message.updateMany({
where: { receiverId: currentUser.userId, readAt: null },
data: { readAt: new Date() },
});
const messages = await prisma.message.findMany({
where: { receiverId: currentUser.userId },
orderBy: { createdAt: "desc" },
take: pageSize,
skip: (page - 1) * pageSize,
select: { id: true, body: true, createdAt: true, sender: { select: { userId: true, nickname: true } } },
});
return (
<div className="bg-white rounded-[24px] p-4">
<ul className="divide-y divide-neutral-200">
{messages.map((m) => (
<li key={m.id} className="py-3 flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="text-sm text-neutral-800">
<Link href={`/users/${m.sender.userId}`} className="font-semibold hover:underline">{m.sender.nickname || "알 수 없음"}</Link>
<span className="text-xs text-neutral-500 ml-2">{new Date(m.createdAt).toLocaleString()}</span>
</div>
<div className="text-sm text-neutral-700 mt-1 whitespace-pre-wrap break-words">{m.body}</div>
</div>
</li>
))}
</ul>
</div>
);
})()}
{activeTab === "messages-sent" && (async () => {
const pageSize = 20;
const receiver = toUserId ? await prisma.user.findUnique({ where: { userId: toUserId }, select: { userId: true, nickname: true } }) : null;
const messages = await prisma.message.findMany({
where: { senderId: currentUser.userId },
orderBy: { createdAt: "desc" },
take: pageSize,
skip: (page - 1) * pageSize,
select: { id: true, body: true, createdAt: true, receiver: { select: { userId: true, nickname: true } } },
});
return (
<div className="bg-white rounded-[24px] p-4 space-y-6">
{receiver ? (
<div className="border border-neutral-200 rounded-md p-3">
<SendMessageForm receiverId={receiver.userId} receiverNickname={receiver.nickname} />
</div>
) : (
<div className="text-sm text-neutral-500"> .</div>
)}
<div>
<h3 className="text-sm font-semibold text-neutral-900 mb-2"> </h3>
<ul className="divide-y divide-neutral-200">
{messages.map((m) => (
<li key={m.id} className="py-3 flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="text-sm text-neutral-800">
<Link href={`/users/${m.receiver.userId}`} className="font-semibold hover:underline">{m.receiver.nickname || "알 수 없음"}</Link>
<span className="text-xs text-neutral-500 ml-2">{new Date(m.createdAt).toLocaleString()}</span>
</div>
<div className="text-sm text-neutral-700 mt-1 whitespace-pre-wrap break-words">{m.body}</div>
</div>
</li>
))}
</ul>
</div>
</div>
);
})()}
</section>
</div>
);
}