Files
msgapp/src/app/page.tsx

362 lines
19 KiB
TypeScript
Raw Normal View History

import { HeroBanner } from "@/app/components/HeroBanner";
2025-11-02 02:46:20 +09:00
import Link from "next/link";
2025-10-24 21:24:51 +09:00
import HorizontalCardScroller from "@/app/components/HorizontalCardScroller";
2025-10-31 20:05:09 +09:00
import { PostList } from "@/app/components/PostList";
2025-10-31 00:02:17 +09:00
import ProfileLabelIcon from "@/app/svgs/profilelableicon";
import SearchIcon from "@/app/svgs/SearchIcon";
2025-10-31 20:05:09 +09:00
import prisma from "@/lib/prisma";
2025-10-08 20:55:43 +09:00
2025-10-13 18:06:46 +09:00
export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: "recent" | "popular" } | undefined> }) {
const sp = await searchParams;
const sort = sp?.sort ?? "recent";
2025-10-31 20:05:09 +09:00
// 메인페이지 설정 불러오기
const SETTINGS_KEY = "mainpage_settings" as const;
const settingRow = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
const parsed = settingRow ? JSON.parse(settingRow.value as string) : {};
const showBanner: boolean = parsed.showBanner ?? true;
const showPartnerShops: boolean = parsed.showPartnerShops ?? true;
const visibleBoardIds: string[] = Array.isArray(parsed.visibleBoardIds) ? parsed.visibleBoardIds : [];
2025-11-02 07:01:42 +09:00
// 보드 메타데이터 (메인뷰 타입 포함)
2025-10-31 20:05:09 +09:00
const boardsMeta = visibleBoardIds.length
2025-11-02 07:01:42 +09:00
? await prisma.board.findMany({
where: { id: { in: visibleBoardIds } },
select: {
id: true,
name: true,
slug: true,
category: { select: { id: true, name: true } },
mainPageViewType: { select: { key: true } },
},
})
2025-10-31 20:05:09 +09:00
: [];
2025-11-02 07:01:42 +09:00
const idToMeta = new Map(
boardsMeta.map((b) => [
b.id,
{
id: b.id,
name: b.name,
slug: b.slug,
categoryId: b.category?.id ?? null,
categoryName: b.category?.name ?? "",
mainTypeKey: b.mainPageViewType?.key,
},
] as const)
);
2025-10-31 20:05:09 +09:00
const orderedBoards = visibleBoardIds
.map((id) => idToMeta.get(id))
2025-11-02 07:01:42 +09:00
.filter((v): v is any => Boolean(v));
2025-10-31 20:05:09 +09:00
const firstTwo = orderedBoards.slice(0, 2);
const restBoards = orderedBoards.slice(2);
2025-11-02 07:01:42 +09:00
function formatDateYmd(d: Date) {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}.${mm}.${dd}`;
}
const renderBoardPanel = async (board: { id: string; name: string; slug: string; categoryId: string | null; categoryName: string; mainTypeKey?: string }) => {
// 같은 카테고리의 소분류(게시판) 탭 목록
const siblingBoards = board.categoryId
? await prisma.board.findMany({
where: { categoryId: board.categoryId },
select: { id: true, name: true, slug: true },
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
})
: [];
const isTextMain = board.mainTypeKey === "main_text";
2025-11-02 11:01:18 +09:00
const isSpecialRank = board.mainTypeKey === "main_special_rank";
// 특수 랭킹 타입 처리
if (isSpecialRank) {
const topUsers = await prisma.user.findMany({
select: { userId: true, nickname: true, points: true, profileImage: true },
where: { status: "active" },
orderBy: { points: "desc" },
take: 3,
});
return (
<div key={board.id} className="h-full min-h-0 flex flex-col">
<div className="flex-1 min-h-0 overflow-hidden p-0">
<div className="px-[24px] pt-[8px] pb-[16px]">
<div className="flex flex-col gap-[16px]">
{topUsers.map((user, idx) => {
const rank = idx + 1;
return (
<div key={user.userId} className="flex h-[140px] items-center rounded-[16px] overflow-hidden">
<div className="h-[140px] w-[160px] relative shrink-0 bg-[#d5d5d5] overflow-hidden">
<img
src={user.profileImage || "https://picsum.photos/seed/profile/200/200"}
alt={user.nickname || "프로필"}
className="w-full h-full object-cover"
/>
<div className="absolute left-[calc(50%+49.25px)] top-0 translate-x-[-50%] w-[31.25px] h-[31.25px] flex items-center justify-center pointer-events-none">
<div className="w-[62.5px] h-[62.5px] flex items-center justify-center">
<img src="https://picsum.photos/seed/badge/200/200" alt="뱃지" className="w-[51px] h-[53px] object-contain" />
</div>
</div>
</div>
<div className="flex-1 flex items-center gap-[10px] px-[30px] py-[24px] min-w-0">
<div className="flex flex-col gap-[12px] min-w-0 flex-1">
<div className="flex items-center gap-[12px]">
<div className="relative w-[20px] h-[20px] shrink-0">
{/* 순위 배지 아이콘 자리 - 피그마 디자인에 맞게 이미지로 대체 필요 */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[12px]"></span>
</div>
</div>
<div className="bg-white border border-[#d5d5d5] px-[16px] py-[5px] rounded-[12px] text-[14px] text-[#5c5c5c] shrink-0">
{rank}
</div>
</div>
<div className="text-[24px] font-medium text-[#5c5c5c] truncate leading-[22px]">
{user.nickname || "익명"}
</div>
</div>
<div className="h-[24px] flex items-center gap-[4px] shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0">
<path d="M8.17377 0.0481988C8.1834 0.0326271 8.19596 0.0201252 8.21037 0.0117499C8.22478 0.00337451 8.24062 -0.000628204 8.25656 7.99922e-05C8.2725 0.000788189 8.28806 0.0061865 8.30194 0.0158187C8.31582 0.0254509 8.3276 0.0390338 8.33629 0.0554196L11.3323 5.55973C11.3575 5.60512 11.4038 5.62472 11.4468 5.60718L15.8683 3.80198C15.9441 3.77104 16.0174 3.85666 15.9963 3.95053L14.2584 16H1.74226L0.00344328 3.95156C-0.00125237 3.93019 -0.0011435 3.90765 0.00375838 3.88635C0.00866025 3.86505 0.0181727 3.84576 0.0312889 3.83054C0.0444051 3.81532 0.0606369 3.80472 0.0782661 3.79988C0.0958953 3.79503 0.114266 3.79612 0.131434 3.80302L4.55889 5.61131C4.59846 5.62678 4.64309 5.61131 4.66835 5.57005L8.17461 0.0481988H8.17377Z" fill="#FFB625"/>
</svg>
<span className="text-[20px] font-semibold text-[#5c5c5c] leading-[22px]">{user.points.toLocaleString()}</span>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
2025-11-02 07:01:42 +09:00
if (!isTextMain) {
return (
<div key={board.id} className="h-full min-h-0 flex flex-col">
<div className="content-stretch flex gap-[30px] items-start w-full mb-2">
<div className="flex items-center gap-[8px]">
<div className="text-[30px] text-[#5c5c5c] leading-[30px]">{board.categoryName || board.name}</div>
</div>
<div className="flex items-center gap-[8px]">
{siblingBoards.map((sb) => (
<Link
key={sb.id}
href={`/boards/${sb.slug}`}
className={`px-[16px] py-[8px] rounded-[14px] text-[14px] ${
sb.id === board.id ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5]"
}`}
>
{sb.name}
</Link>
))}
</div>
</div>
<div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col bg-white">
<div className="px-3 py-2 border-b border-neutral-200 flex items-center justify-between">
<Link href={`/boards/${board.slug}`} className="text-lg md:text-xl font-bold text-neutral-800 truncate">{board.name}</Link>
<Link href={`/boards/${board.slug}`} className="text-xs px-3 py-1 rounded-full border border-neutral-300 text-neutral-700 hover:bg-neutral-100"></Link>
</div>
<div className="flex-1 min-h-0 overflow-hidden p-0">
<PostList boardId={board.id} sort={sort} />
</div>
</div>
</div>
);
}
const posts = await prisma.post.findMany({
where: { boardId: board.id, status: "published" },
select: { id: true, title: true, createdAt: true, stat: { select: { recommendCount: true } } },
orderBy: { createdAt: "desc" },
2025-11-02 11:01:18 +09:00
take: 7,
2025-11-02 07:01:42 +09:00
});
return (
<div key={board.id} className="h-full min-h-0 flex flex-col">
<div className="content-stretch flex gap-[30px] items-start w-full mb-2">
<div className="flex items-center gap-[8px]">
<div className="text-[30px] text-[#5c5c5c] leading-[30px]">{board.categoryName || board.name}</div>
</div>
<div className="flex items-center gap-[8px]">
{siblingBoards.map((sb) => (
<Link
key={sb.id}
href={`/boards/${sb.slug}`}
className={`px-[16px] py-[8px] rounded-[14px] text-[14px] ${
sb.id === board.id ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5]"
}`}
>
{sb.name}
</Link>
))}
</div>
</div>
<div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col bg-white">
<div className="px-3 py-2 border-b border-neutral-200 flex items-center justify-between">
<Link href={`/boards/${board.slug}`} className="text-lg md:text-xl font-bold text-neutral-800 truncate">{board.name}</Link>
<Link href={`/boards/${board.slug}`} className="text-xs px-3 py-1 rounded-full border border-neutral-300 text-neutral-700 hover:bg-neutral-100"></Link>
</div>
<div className="flex-1 min-h-0 overflow-hidden p-0">
<div className="bg-white px-[24px] pt-[8px] pb-[16px]">
<ul className="min-h-[326px]">
{posts.map((p) => (
<li key={p.id} className="border-b border-[#ededed] h-[56px] pl-0 pr-[24px] pt-[16px] pb-[16px]">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-[4px] h-[24px] overflow-hidden">
<div className="relative w-[16px] h-[16px] shrink-0">
<div className="absolute inset-0 rounded-full bg-[#f45f00] opacity-0" />
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
</div>
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate max-w-[calc(100vw-280px)]">{p.title}</span>
<span className="text-[16px] text-[#f45f00] font-bold">+{p.stat?.recommendCount ?? 0}</span>
</div>
<span className="text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">{formatDateYmd(new Date(p.createdAt))}</span>
</div>
</li>
))}
</ul>
</div>
</div>
</div>
2025-10-31 20:05:09 +09:00
</div>
2025-11-02 07:01:42 +09:00
);
};
2025-10-31 20:05:09 +09:00
2025-10-08 20:55:43 +09:00
return (
<div className="space-y-8">
2025-10-31 20:05:09 +09:00
{/* 히어로 섹션: 상단 대형 비주얼 영역 (설정 온오프) */}
{showBanner && (
<section>
<HeroBanner />
</section>
)}
2025-11-02 02:46:20 +09:00
{/* ( , DB )
- partners ( )
- partner_shops로 */}
{showPartnerShops && (async () => {
// 우선순위: partners(관리자 관리) → partner_shops(폴백)
let partners: any[] = [];
try {
partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], take: 10 });
} catch (_) {
partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" }, take: 10 });
}
const items = partners.map((p: any) => ({
id: p.id,
region: p.address ? String(p.address).split(" ")[0] : p.category,
name: p.name,
address: p.address || "",
image: p.imageUrl || "/sample.jpg",
}));
if (items.length > 0) return <HorizontalCardScroller items={items} />;
const shops = await (prisma as any).partnerShop.findMany({ where: { active: true }, orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] });
const shopItems = shops.map((s: any) => ({ id: s.id, region: s.region, name: s.name, address: s.address, image: s.imageUrl }));
return <HorizontalCardScroller items={shopItems} />;
2025-10-24 21:24:51 +09:00
})()}
2025-10-31 20:05:09 +09:00
{/* 1행: 프로필 + 선택된 보드 2개 (최대 2개) */}
{(firstTwo.length > 0) && (
2025-11-02 11:01:18 +09:00
<section className="min-h-[395px] overflow-hidden">
<div className="grid grid-cols-1 md:grid-cols-2 xl:flex xl:gap-[23px] gap-4 h-full min-h-0">
<div className="hidden xl:grid relative overflow-hidden rounded-xl bg-white px-[25px] py-[34px] grid-rows-[120px_120px_1fr] gap-y-[32px] h-[514px] w-[350px] shrink-0">
2025-10-31 20:05:09 +09:00
<div className="absolute inset-x-0 top-0 h-[56px] bg-[#d5d5d5] z-0" />
<div className="h-[120px] flex items-center justify-center relative z-10">
<div className="flex items-center justify-center gap-[8px]">
<img src="https://picsum.photos/seed/profile/200/200" alt="프로필" className="w-[120px] h-[120px] rounded-full object-cover" />
<div className="w-[62px] h-[62px] rounded-full bg-neutral-200 flex items-center justify-center text-[11px] text-neutral-700">
Lv
</div>
2025-10-30 20:47:34 +09:00
</div>
</div>
2025-10-31 20:05:09 +09:00
<div className="h-[120px] flex flex-col items-center relative z-10">
<div className="text-[18px] text-[#5c5c5c] font-[700] truncate text-center mb-[20px]"></div>
<div className="w-[300px] pl-[67px] flex flex-col gap-[12px]">
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
<div className="w-[64px] flex items-center">
<ProfileLabelIcon width={16} height={16} />
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]"></span>
</div>
<div className="text-[16px] text-[#5c5c5c] font-[700]">Lv. 79</div>
2025-10-30 20:47:34 +09:00
</div>
2025-10-31 20:05:09 +09:00
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
<div className="w-[64px] flex items-center">
<ProfileLabelIcon width={16} height={16} />
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]"></span>
</div>
<div className="text-[16px] text-[#5c5c5c] font-[700]">Iron</div>
2025-10-30 20:47:34 +09:00
</div>
2025-10-31 20:05:09 +09:00
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
<div className="w-[64px] flex items-center">
<ProfileLabelIcon width={16} height={16} />
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]"></span>
</div>
<div className="text-[16px] text-[#5c5c5c] font-[700]">1,600,000</div>
2025-10-30 20:47:34 +09:00
</div>
</div>
</div>
2025-10-31 20:05:09 +09:00
<div className="flex flex-col gap-[12px] relative z-10">
<button className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center">
<span className="absolute left-[100px] inline-flex items-center">
<SearchIcon width={16} height={16} />
<span className="ml-[8px]"> </span>
</span>
</button>
<button className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center">
<span className="absolute left-[100px] inline-flex items-center">
<SearchIcon width={16} height={16} />
<span className="ml-[8px]"> </span>
</span>
</button>
<button className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center">
<span className="absolute left-[100px] inline-flex items-center">
<SearchIcon width={16} height={16} />
<span className="ml-[8px]"> </span>
</span>
<span className="absolute right-[8px] w-[47px] h-[18px] rounded-full bg-white text-[#707070] text-[10px] font-[600] leading-[18px] flex items-center justify-end pr-[6px]">12 </span>
</button>
<button className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center">
<span className="absolute left-[100px] inline-flex items-center">
<SearchIcon width={16} height={16} />
<span className="ml-[8px]"> </span>
</span>
<span className="absolute right-[8px] w-[47px] h-[18px] rounded-full bg-white text-[#707070] text-[10px] font-[600] leading-[18px] flex items-center justify-end pr-[6px]">7 </span>
</button>
</div>
2025-10-24 21:24:51 +09:00
</div>
2025-11-02 07:01:42 +09:00
{(await Promise.all(firstTwo.map((b) => renderBoardPanel(b)))).map((panel, idx) => (
2025-11-02 11:01:18 +09:00
<div key={firstTwo[idx].id} className="rounded-xl overflow-hidden xl:h-[514px] h-full min-h-0 flex flex-col flex-1">
2025-11-02 07:01:42 +09:00
{panel}
2025-10-31 20:05:09 +09:00
</div>
))}
</div>
2025-10-31 20:05:09 +09:00
</section>
)}
2025-10-31 20:05:09 +09:00
{/* 나머지 보드: 2개씩 다음 열로 렌더링 */}
{restBoards.length > 0 && (
<>
2025-11-02 07:01:42 +09:00
{Array.from({ length: Math.ceil(restBoards.length / 2) }).map(async (_, i) => {
2025-10-31 20:05:09 +09:00
const pair = restBoards.slice(i * 2, i * 2 + 2);
return (
2025-11-02 11:01:18 +09:00
<section key={`rest-${i}`} className="min-h-[395px] md:h-[495px] overflow-hidden">
2025-10-31 20:05:09 +09:00
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 h-full min-h-0">
2025-11-02 07:01:42 +09:00
{(await Promise.all(pair.map((b) => renderBoardPanel(b)))).map((panel, idx) => (
<div key={pair[idx].id} className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
{panel}
2025-10-31 20:05:09 +09:00
</div>
))}
</div>
</section>
);
})}
</>
)}
2025-10-08 20:55:43 +09:00
</div>
);
}