From 2c86f2d515c923815e162f849ce2f926c42432ec Mon Sep 17 00:00:00 2001 From: mota Date: Sun, 2 Nov 2025 12:07:11 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A9=94=EC=9D=B8=ED=8E=98=EC=9D=B4=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/.prompt/1101.md | 4 +- prisma/seed.js | 33 ++- src/app/components/BoardPanelClient.tsx | 269 ++++++++++++++++++++ src/app/components/ImagePlaceholderIcon.tsx | 16 ++ src/app/components/SinglePageLogo.tsx | 2 +- src/app/components/UserAvatar.tsx | 29 +++ src/app/globals.css | 31 +++ src/app/page.tsx | 261 ++++++------------- 8 files changed, 458 insertions(+), 187 deletions(-) create mode 100644 src/app/components/BoardPanelClient.tsx create mode 100644 src/app/components/ImagePlaceholderIcon.tsx create mode 100644 src/app/components/UserAvatar.tsx 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 ? ( + {post.title} + ) : ( +
+ +
+ )} +
+
+
+
+ {board.name} +
+
+
+
+
n
+
+ {post.title} +
+
+ + {formatDateYmd(post.createdAt)} + +
+
+
+ + ); + })} +
+
+
+
+
+ ); + } + + // 텍스트 메인 타입 또는 기본 타입 렌더링 + return ( +
+
+
+
{categoryName || board.name}
+
+
+ {siblingBoards.map((sb) => ( + + ))} +
+
+
+
+ {board.name} + 더보기 +
+
+ {isTextMain && selectedBoardData.textPosts ? ( +
+
    + {selectedBoardData.textPosts.map((p) => ( +
  • +
    +
    +
    +
    +
    n
    +
    + {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 (
- + diff --git a/src/app/components/UserAvatar.tsx b/src/app/components/UserAvatar.tsx new file mode 100644 index 0000000..e515c6c --- /dev/null +++ b/src/app/components/UserAvatar.tsx @@ -0,0 +1,29 @@ +export function UserAvatar({ src, alt, width = 120, height = 120, className }: { src?: string | null; alt?: string; width?: number; height?: number; className?: string }) { + if (src) { + return ( + {alt + ); + } + + // 프로필 이미지가 없을 때 표시할 기본 아바타 SVG + return ( +
+ + + + + +
+ ); +} + diff --git a/src/app/globals.css b/src/app/globals.css index 8e99d55..a1bb6df 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -40,3 +40,34 @@ html { scrollbar-gutter: stable both-edges; } .scrollbar-hidden::-webkit-scrollbar { display: none; /* Chrome, Safari, Opera */ } + +/* 커스텀 가로 스크롤바 - 얇고 예쁜 스타일, 컨텐츠를 밀어내지 않음 */ +.scrollbar-thin-x { + scrollbar-width: thin; /* Firefox */ + scrollbar-color: rgba(92, 92, 92, 0.3) transparent; /* Firefox */ +} + +.scrollbar-thin-x::-webkit-scrollbar { + height: 6px; /* Chrome, Safari, Opera */ +} + +.scrollbar-thin-x::-webkit-scrollbar-track { + background: transparent; + border-radius: 10px; +} + +.scrollbar-thin-x::-webkit-scrollbar-thumb { + background: rgba(92, 92, 92, 0.3); + border-radius: 10px; + transition: background 0.2s ease; +} + +.scrollbar-thin-x::-webkit-scrollbar-thumb:hover { + background: rgba(92, 92, 92, 0.5); +} + +/* 스크롤바가 아래 컨텐츠를 밀어내지 않도록 하는 클래스 */ +.scrollbar-overlay { + margin-bottom: -6px; /* 스크롤바 높이만큼 아래 마진 조정 */ + padding-bottom: 6px; /* 스크롤바 공간 확보 */ +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index f2e7a91..f434d96 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,6 +7,9 @@ import SearchIcon from "@/app/svgs/SearchIcon"; import { RankIcon1st } from "@/app/components/RankIcon1st"; import { RankIcon2nd } from "@/app/components/RankIcon2nd"; import { RankIcon3rd } from "@/app/components/RankIcon3rd"; +import { UserAvatar } from "@/app/components/UserAvatar"; +import { ImagePlaceholderIcon } from "@/app/components/ImagePlaceholderIcon"; +import { BoardPanelClient } from "@/app/components/BoardPanelClient"; import prisma from "@/lib/prisma"; export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: "recent" | "popular" } | undefined> }) { @@ -60,191 +63,85 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s return `${yyyy}.${mm}.${dd}`; } - const renderBoardPanel = async (board: { id: string; name: string; slug: string; categoryId: string | null; categoryName: string; mainTypeKey?: string }) => { + // 게시판 패널 데이터 수집 함수 (모든 sibling boards 포함) + const prepareBoardPanelData = 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 }, + select: { id: true, name: true, slug: true, mainPageViewType: { select: { key: true } } }, orderBy: [{ sortOrder: "asc" }, { name: "asc" }], }) : []; - const isTextMain = board.mainTypeKey === "main_text"; - const isSpecialRank = board.mainTypeKey === "main_special_rank"; + + const boardsData = []; - // 특수 랭킹 타입 처리 - 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, + for (const sb of siblingBoards) { + const isTextMain = sb.mainPageViewType?.key === "main_text"; + const isSpecialRank = sb.mainPageViewType?.key === "main_special_rank"; + const isPreview = sb.mainPageViewType?.key === "main_preview"; + + const boardMeta = { + id: sb.id, + name: sb.name, + slug: sb.slug, + mainTypeKey: sb.mainPageViewType?.key, + }; + + let specialRankUsers; + let previewPosts; + let textPosts; + + if (isSpecialRank) { + specialRankUsers = await prisma.user.findMany({ + select: { userId: true, nickname: true, points: true, profileImage: true }, + where: { status: "active" }, + orderBy: { points: "desc" }, + take: 3, + }); + } else if (isPreview) { + previewPosts = await prisma.post.findMany({ + where: { boardId: sb.id, status: "published" }, + select: { + id: true, + title: true, + createdAt: true, + attachments: { + where: { type: "image" }, + orderBy: { sortOrder: "asc" }, + take: 1, + select: { url: true }, + }, + }, + orderBy: { createdAt: "desc" }, + take: 3, + }); + } else if (isTextMain) { + textPosts = await prisma.post.findMany({ + where: { boardId: sb.id, status: "published" }, + select: { id: true, title: true, createdAt: true, stat: { select: { recommendCount: true } } }, + orderBy: { createdAt: "desc" }, + take: 7, + }); + } + // 기본 타입은 PostList가 자체적으로 API를 호출하므로 데이터 미리 가져오지 않음 + + boardsData.push({ + board: boardMeta, + categoryName: board.categoryName || board.name, + siblingBoards: siblingBoards.map(sb => ({ + id: sb.id, + name: sb.name, + slug: sb.slug, + mainTypeKey: sb.mainPageViewType?.key, + })), + specialRankUsers, + previewPosts, + textPosts, }); - - return ( -
-
-
-
{board.categoryName || board.name}
-
-
- {siblingBoards.map((sb) => ( - - {sb.name} - - ))} -
-
-
-
-
-
- {topUsers.map((user, idx) => { - const rank = idx + 1; - return ( -
-
- {user.nickname -
-
- 뱃지 -
-
-
-
-
-
-
- {rank === 1 && } - {rank === 2 && } - {rank === 3 && } -
-
- {rank}위 -
-
-
- {user.nickname || "익명"} -
-
-
- - - - {user.points.toLocaleString()} -
-
-
- ); - })} -
-
-
-
-
- ); - } - - if (!isTextMain) { - return ( -
-
-
-
{board.categoryName || board.name}
-
-
- {siblingBoards.map((sb) => ( - - {sb.name} - - ))} -
-
-
-
- {board.name} - 더보기 -
-
- -
-
-
- ); } - 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" }, - take: 7, - }); - - return ( -
-
-
-
{board.categoryName || board.name}
-
-
- {siblingBoards.map((sb) => ( - - {sb.name} - - ))} -
-
-
-
- {board.name} - 더보기 -
-
-
-
    - {posts.map((p) => ( -
  • -
    -
    -
    -
    -
    n
    -
    - {p.title} - +{p.stat?.recommendCount ?? 0} -
    - {formatDateYmd(new Date(p.createdAt))} -
    -
  • - ))} -
-
-
-
-
- ); + return { initialBoardId: board.id, boardsData }; }; return ( @@ -289,7 +186,13 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
- 프로필 +
Lv
@@ -350,9 +253,9 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
- {(await Promise.all(firstTwo.map((b) => renderBoardPanel(b)))).map((panel, idx) => ( + {(await Promise.all(firstTwo.map((b) => prepareBoardPanelData(b)))).map((panelData, idx) => (
- {panel} +
))}
@@ -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} +
))}