diff --git a/.cursor/.prompt/1101.md b/.cursor/.prompt/1101.md
index f676852..3a3440d 100644
--- a/.cursor/.prompt/1101.md
+++ b/.cursor/.prompt/1101.md
@@ -1,8 +1,8 @@
-메인 게시판 일반
+x 메인 게시판 일반
메인 게시판 프리뷰
-메인 게시판 스페셜랭크
+x 메인 게시판 스페셜랭크
기본 리스트
스페셜_랭크
diff --git a/prisma/seed.js b/prisma/seed.js
index 2143f36..eba5007 100644
--- a/prisma/seed.js
+++ b/prisma/seed.js
@@ -1,7 +1,12 @@
const { PrismaClient } = require("@prisma/client");
+const { createHash } = require("crypto");
const prisma = new PrismaClient();
+function hashPassword(plain) {
+ return createHash("sha256").update(plain, "utf8").digest("hex");
+}
+
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
@@ -60,12 +65,19 @@ async function createRandomUsers(count = 100) {
name,
birth,
phone,
+ passwordHash: hashPassword("1234"),
agreementTermsAt: new Date(),
authLevel: "USER",
isAdultVerified: Math.random() < 0.6,
lastLoginAt: Math.random() < 0.8 ? new Date() : null,
},
});
+ } else {
+ // 기존 사용자도 패스워드를 1234로 업데이트
+ await prisma.user.update({
+ where: { userId: user.userId },
+ data: { passwordHash: hashPassword("1234") },
+ });
}
if (roleUser && user) {
await prisma.userRole.upsert({
@@ -83,6 +95,7 @@ async function upsertCategories() {
{ name: "메인", slug: "main", sortOrder: 1, status: "active" },
{ name: "명예의 전당", slug: "hall-of-fame", sortOrder: 2, status: "active" },
{ name: "소통방", slug: "community", sortOrder: 3, status: "active" },
+ { name: "TEST", slug: "test", sortOrder: 4, status: "active" },
];
const map = {};
for (const c of categories) {
@@ -155,12 +168,13 @@ async function upsertRoles() {
async function upsertAdmin() {
const admin = await prisma.user.upsert({
where: { nickname: "admin" },
- update: {},
+ update: { passwordHash: hashPassword("1234") },
create: {
nickname: "admin",
name: "Administrator",
birth: new Date("1990-01-01"),
phone: "010-0000-0001",
+ passwordHash: hashPassword("1234"),
agreementTermsAt: new Date(),
authLevel: "ADMIN",
},
@@ -195,14 +209,17 @@ async function upsertBoards(admin, categoryMap) {
{ name: "회원랭킹", slug: "ranking", description: "랭킹", sortOrder: 14 },
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", sortOrder: 15 },
{ name: "월간집계", slug: "monthly-stats", description: "월간 통계", sortOrder: 16 },
+ // TEST
+ { name: "TEST", slug: "test", description: "테스트 게시판", sortOrder: 20 },
// 광고/제휴 계열 게시판은 제거(메인/명예의전당/소통방 외)
];
const created = [];
- // 특수 랭킹/텍스트 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
+ // 특수 랭킹/텍스트/미리보기 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
const mainSpecial = await prisma.boardViewType.findUnique({ where: { key: "main_special_rank" } });
const listSpecial = await prisma.boardViewType.findUnique({ where: { key: "list_special_rank" } });
const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } });
+ const mainPreview = await prisma.boardViewType.findUnique({ where: { key: "main_preview" } });
for (const b of boards) {
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
@@ -222,6 +239,8 @@ async function upsertBoards(admin, categoryMap) {
qna: "community",
tips: "community",
anonymous: "community",
+ // TEST
+ test: "test",
// 광고/제휴
};
const categorySlug = mapBySlug[b.slug] || "community";
@@ -234,9 +253,11 @@ async function upsertBoards(admin, categoryMap) {
allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined,
categoryId: category ? category.id : undefined,
- // 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지
+ // 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지, TEST는 미리보기
...(b.slug === "ranking"
? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {})
+ : b.slug === "test"
+ ? (mainPreview ? { mainPageViewTypeId: mainPreview.id } : {})
: (mainText ? { mainPageViewTypeId: mainText.id } : {})),
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
},
@@ -248,9 +269,11 @@ async function upsertBoards(admin, categoryMap) {
allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined,
categoryId: category ? category.id : undefined,
- // 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지
+ // 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지, TEST는 미리보기
...(b.slug === "ranking"
? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {})
+ : b.slug === "test"
+ ? (mainPreview ? { mainPageViewTypeId: mainPreview.id } : {})
: (mainText ? { mainPageViewTypeId: mainText.id } : {})),
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
},
@@ -393,7 +416,7 @@ async function seedMainpageVisibleBoards(boards) {
const SETTINGS_KEY = "mainpage_settings";
const setting = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
const current = setting ? JSON.parse(setting.value) : {};
- const wantSlugs = new Set(["notice", "free", "ranking"]);
+ const wantSlugs = new Set(["notice", "free", "ranking", "test"]);
const visibleBoardIds = boards.filter((b) => wantSlugs.has(b.slug)).map((b) => b.id);
const next = { ...current, visibleBoardIds };
await prisma.setting.upsert({
diff --git a/src/app/components/BoardPanelClient.tsx b/src/app/components/BoardPanelClient.tsx
new file mode 100644
index 0000000..a9bad6c
--- /dev/null
+++ b/src/app/components/BoardPanelClient.tsx
@@ -0,0 +1,269 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { RankIcon1st } from "./RankIcon1st";
+import { RankIcon2nd } from "./RankIcon2nd";
+import { RankIcon3rd } from "./RankIcon3rd";
+import { UserAvatar } from "./UserAvatar";
+import { ImagePlaceholderIcon } from "./ImagePlaceholderIcon";
+import { PostList } from "./PostList";
+
+type BoardMeta = {
+ id: string;
+ name: string;
+ slug: string;
+ mainTypeKey?: string;
+};
+
+type UserData = {
+ userId: string;
+ nickname: string | null;
+ points: number;
+ profileImage: string | null;
+};
+
+type PostData = {
+ id: string;
+ title: string;
+ createdAt: Date;
+ attachments?: { url: string }[];
+ stat?: { recommendCount: number | null };
+};
+
+type BoardPanelData = {
+ board: BoardMeta;
+ categoryName: string;
+ siblingBoards: BoardMeta[];
+ specialRankUsers?: UserData[];
+ previewPosts?: PostData[];
+ textPosts?: PostData[];
+};
+
+export function BoardPanelClient({
+ initialBoardId,
+ boardsData
+}: {
+ initialBoardId: string;
+ boardsData: BoardPanelData[];
+}) {
+ const [selectedBoardId, setSelectedBoardId] = useState(initialBoardId);
+
+ // 선택된 게시판 데이터 찾기
+ const selectedBoardData = boardsData.find(bd => bd.board.id === selectedBoardId) || boardsData[0];
+ const { board, categoryName, siblingBoards } = selectedBoardData;
+
+ function formatDateYmd(d: Date) {
+ const date = new Date(d);
+ const yyyy = date.getFullYear();
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
+ const dd = String(date.getDate()).padStart(2, "0");
+ return `${yyyy}.${mm}.${dd}`;
+ }
+
+ const isTextMain = board.mainTypeKey === "main_text";
+ const isSpecialRank = board.mainTypeKey === "main_special_rank";
+ const isPreview = board.mainTypeKey === "main_preview";
+
+ // 특수 랭킹 타입 렌더링
+ if (isSpecialRank && selectedBoardData.specialRankUsers) {
+ return (
+
+
+
+
{categoryName || board.name}
+
+
+ {siblingBoards.map((sb) => (
+
+ ))}
+
+
+
+
+
+
+ {selectedBoardData.specialRankUsers.map((user, idx) => {
+ const rank = idx + 1;
+ return (
+
+
+
+
+
+
+
+
+ {rank === 1 && }
+ {rank === 2 && }
+ {rank === 3 && }
+
+
+ {rank}위
+
+
+
+ {user.nickname || "익명"}
+
+
+
+
+
{user.points.toLocaleString()}
+
+
+
+ );
+ })}
+
+
+
+
+
+ );
+ }
+
+ // 미리보기 타입 렌더링
+ if (isPreview && selectedBoardData.previewPosts) {
+ return (
+
+
+
+
{categoryName || board.name}
+
+
+ {siblingBoards.map((sb) => (
+
+ ))}
+
+
+
+
+
+
+ {selectedBoardData.previewPosts.map((post) => {
+ const firstImage = post.attachments?.[0]?.url;
+ return (
+
+
+ {firstImage ? (
+

+ ) : (
+
+
+
+ )}
+
+
+
+
+ {board.name}
+
+
+
+
+ {formatDateYmd(post.createdAt)}
+
+
+
+
+
+ );
+ })}
+
+
+
+
+
+ );
+ }
+
+ // 텍스트 메인 타입 또는 기본 타입 렌더링
+ return (
+
+
+
+
{categoryName || board.name}
+
+
+ {siblingBoards.map((sb) => (
+
+ ))}
+
+
+
+
+ {board.name}
+ 더보기
+
+
+ {isTextMain && selectedBoardData.textPosts ? (
+
+
+ {selectedBoardData.textPosts.map((p) => (
+ -
+
+
+
+
{p.title}
+
+{p.stat?.recommendCount ?? 0}
+
+
{formatDateYmd(p.createdAt)}
+
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
diff --git a/src/app/components/ImagePlaceholderIcon.tsx b/src/app/components/ImagePlaceholderIcon.tsx
new file mode 100644
index 0000000..a5d214e
--- /dev/null
+++ b/src/app/components/ImagePlaceholderIcon.tsx
@@ -0,0 +1,16 @@
+export function ImagePlaceholderIcon({ width = 32, height = 32, className }: { width?: number; height?: number; className?: string }) {
+ return (
+
+ );
+}
+
diff --git a/src/app/components/SinglePageLogo.tsx b/src/app/components/SinglePageLogo.tsx
index 55e34e7..f9de5fc 100644
--- a/src/app/components/SinglePageLogo.tsx
+++ b/src/app/components/SinglePageLogo.tsx
@@ -4,7 +4,7 @@ import React from "react";
export function SinglePageLogo({ width = 132, height = 32, className }: { width?: number; height?: number; className?: string }) {
return (
-
@@ -367,9 +270,9 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
return (
- {(await Promise.all(pair.map((b) => renderBoardPanel(b)))).map((panel, idx) => (
+ {(await Promise.all(pair.map((b) => prepareBoardPanelData(b)))).map((panelData, idx) => (
- {panel}
+
))}