메인페이지
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
|
||||
|
||||
메인 게시판 일반
|
||||
x 메인 게시판 일반
|
||||
메인 게시판 프리뷰
|
||||
메인 게시판 스페셜랭크
|
||||
x 메인 게시판 스페셜랭크
|
||||
|
||||
기본 리스트
|
||||
스페셜_랭크
|
||||
|
||||
@@ -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({
|
||||
|
||||
269
src/app/components/BoardPanelClient.tsx
Normal file
269
src/app/components/BoardPanelClient.tsx
Normal file
@@ -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 (
|
||||
<div 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] shrink-0">
|
||||
<div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 scrollbar-thin-x">
|
||||
{siblingBoards.map((sb) => (
|
||||
<button
|
||||
key={sb.id}
|
||||
onClick={() => setSelectedBoardId(sb.id)}
|
||||
className={`px-[16px] py-[8px] rounded-[14px] text-[14px] shrink-0 cursor-pointer ${
|
||||
sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5]"
|
||||
}`}
|
||||
>
|
||||
{sb.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl overflow-hidden 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]">
|
||||
{selectedBoardData.specialRankUsers.map((user, idx) => {
|
||||
const rank = idx + 1;
|
||||
return (
|
||||
<div key={user.userId} className="flex h-[150px] items-center rounded-[16px] overflow-hidden bg-white">
|
||||
<div className="h-[150px] w-[160px] relative shrink-0 bg-[#d5d5d5] overflow-hidden">
|
||||
<UserAvatar
|
||||
src={user.profileImage}
|
||||
alt={user.nickname || "프로필"}
|
||||
width={160}
|
||||
height={150}
|
||||
className="w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
</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">
|
||||
{rank === 1 && <RankIcon1st />}
|
||||
{rank === 2 && <RankIcon2nd />}
|
||||
{rank === 3 && <RankIcon3rd />}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 미리보기 타입 렌더링
|
||||
if (isPreview && selectedBoardData.previewPosts) {
|
||||
return (
|
||||
<div 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] shrink-0">
|
||||
<div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 scrollbar-thin-x">
|
||||
{siblingBoards.map((sb) => (
|
||||
<button
|
||||
key={sb.id}
|
||||
onClick={() => setSelectedBoardId(sb.id)}
|
||||
className={`px-[16px] py-[8px] rounded-[14px] text-[14px] shrink-0 cursor-pointer ${
|
||||
sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5]"
|
||||
}`}
|
||||
>
|
||||
{sb.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl overflow-hidden 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]">
|
||||
{selectedBoardData.previewPosts.map((post) => {
|
||||
const firstImage = post.attachments?.[0]?.url;
|
||||
return (
|
||||
<Link key={post.id} href={`/posts/${post.id}`} className="flex h-[150px] items-start rounded-[16px] overflow-hidden bg-white">
|
||||
<div className="h-[150px] w-[214px] relative shrink-0 bg-[#ededed] overflow-hidden">
|
||||
{firstImage ? (
|
||||
<img
|
||||
src={firstImage}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] flex items-center justify-center">
|
||||
<ImagePlaceholderIcon width={32} height={32} />
|
||||
</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="bg-white border border-[#d5d5d5] px-[16px] py-[5px] rounded-[12px] text-[14px] text-[#5c5c5c] shrink-0 w-fit">
|
||||
{board.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-[4px] 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">{post.title}</span>
|
||||
</div>
|
||||
<div className="h-[16px] relative">
|
||||
<span className="absolute top-1/2 translate-y-[-50%] text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">
|
||||
{formatDateYmd(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 텍스트 메인 타입 또는 기본 타입 렌더링
|
||||
return (
|
||||
<div 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] shrink-0">
|
||||
<div className="text-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 scrollbar-thin-x">
|
||||
{siblingBoards.map((sb) => (
|
||||
<button
|
||||
key={sb.id}
|
||||
onClick={() => setSelectedBoardId(sb.id)}
|
||||
className={`px-[16px] py-[8px] rounded-[14px] text-[14px] shrink-0 cursor-pointer ${
|
||||
sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5]"
|
||||
}`}
|
||||
>
|
||||
{sb.name}
|
||||
</button>
|
||||
))}
|
||||
</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">
|
||||
{isTextMain && selectedBoardData.textPosts ? (
|
||||
<div className="bg-white px-[24px] pt-[8px] pb-[16px]">
|
||||
<ul className="min-h-[326px]">
|
||||
{selectedBoardData.textPosts.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(p.createdAt)}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<PostList key={board.id} boardId={board.id} sort="recent" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
16
src/app/components/ImagePlaceholderIcon.tsx
Normal file
16
src/app/components/ImagePlaceholderIcon.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export function ImagePlaceholderIcon({ width = 32, height = 32, className }: { width?: number; height?: number; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={String(width)}
|
||||
height={String(height)}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M19 14C19.5933 14 20.1734 13.8241 20.6667 13.4944C21.1601 13.1648 21.5446 12.6962 21.7716 12.1481C21.9987 11.5999 22.0581 10.9967 21.9424 10.4147C21.8266 9.83279 21.5409 9.29824 21.1213 8.87868C20.7018 8.45912 20.1672 8.1734 19.5853 8.05764C19.0033 7.94189 18.4001 8.0013 17.8519 8.22836C17.3038 8.45542 16.8352 8.83994 16.5056 9.33329C16.1759 9.82664 16 10.4067 16 11C16 11.7956 16.3161 12.5587 16.8787 13.1213C17.4413 13.6839 18.2044 14 19 14ZM19 10C19.1978 10 19.3911 10.0586 19.5556 10.1685C19.72 10.2784 19.8482 10.4346 19.9239 10.6173C19.9996 10.8 20.0194 11.0011 19.9808 11.1951C19.9422 11.3891 19.847 11.5673 19.7071 11.7071C19.5673 11.847 19.3891 11.9422 19.1951 11.9808C19.0011 12.0194 18.8 11.9996 18.6173 11.9239C18.4346 11.8482 18.2784 11.72 18.1685 11.5556C18.0586 11.3911 18 11.1978 18 11C18 10.7348 18.1054 10.4804 18.2929 10.2929C18.4804 10.1054 18.7348 10 19 10Z" fill="#707070"/>
|
||||
<path d="M26 4L6 4C5.46957 4 4.96086 4.21071 4.58579 4.58579C4.21071 4.96086 4 5.46957 4 6L4 26C4 26.5304 4.21071 27.0391 4.58579 27.4142C4.96086 27.7893 5.46957 28 6 28L26 28C26.5304 28 27.0391 27.7893 27.4142 27.4142C27.7893 27.0391 28 26.5304 28 26L28 6C28 5.46957 27.7893 4.96086 27.4142 4.58579C27.0391 4.21071 26.5304 4 26 4ZM26 26L6 26L6 20L11 15L16.59 20.59C16.9647 20.9625 17.4716 21.1716 18 21.1716C18.5284 21.1716 19.0353 20.9625 19.41 20.59L21 19L26 24V26ZM26 21.17L22.41 17.58C22.0353 17.2075 21.5284 16.9984 21 16.9984C20.4716 16.9984 19.9647 17.2075 19.59 17.58L18 19.17L12.41 13.58C12.0353 13.2075 11.5284 12.9984 11 12.9984C10.4716 12.9984 9.96473 13.2075 9.59 13.58L6 17.17L6 6L26 6L26 21.17Z" fill="#707070"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
export function SinglePageLogo({ width = 132, height = 32, className }: { width?: number; height?: number; className?: string }) {
|
||||
return (
|
||||
<div className={className} style={{ position: "relative", width, height, display: "inline-flex", alignItems: "center", justifyContent: "flex-start" }} aria-label="logo">
|
||||
<svg width={width} height={height} viewBox="0 0 132 32" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-hidden={false}>
|
||||
<svg width={String(width)} height={String(height)} viewBox="0 0 132 32" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-hidden={false}>
|
||||
<g clipPath="url(#clip0_9808_5898)">
|
||||
<path d="M23.2494 0H8.74625C3.91583 0 0 3.91583 0 8.74625V23.2494C0 28.0798 3.91583 31.9956 8.74625 31.9956H23.2494C28.0798 31.9956 31.9956 28.0798 31.9956 23.2494V8.74625C31.9956 3.91583 28.0798 0 23.2494 0Z" fill="#5C5C5C"/>
|
||||
<path d="M8.86328 25.895L13.8752 32H23.2539C27.3314 32 30.7577 29.2094 31.7264 25.4337C31.9052 24.6919 32.0008 23.9087 32.0008 23.0931V16.4506L25.1495 8.5575L25.8189 16.1581L24.8608 19.67L20.7739 21.0112L16.5977 21.2031H11.7058L10.7477 21.5225L9.66203 22.9275L8.86328 25.895Z" fill="#333333"/>
|
||||
|
||||
29
src/app/components/UserAvatar.tsx
Normal file
29
src/app/components/UserAvatar.tsx
Normal file
@@ -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 (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || "프로필"}
|
||||
width={width}
|
||||
height={height}
|
||||
className={className || "rounded-full object-cover"}
|
||||
style={{ width, height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 프로필 이미지가 없을 때 표시할 기본 아바타 SVG
|
||||
return (
|
||||
<div
|
||||
className={className || "rounded-full bg-[#E0E0E0] flex items-center justify-center overflow-hidden"}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<svg width={width} height={height} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="120" height="120" rx="60" fill="#E0E0E0"/>
|
||||
<circle cx="60" cy="40" r="20" fill="white"/>
|
||||
<path d="M30 100C30 80 45 70 60 70C75 70 90 80 90 100" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; /* 스크롤바 공간 확보 */
|
||||
}
|
||||
261
src/app/page.tsx
261
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 (
|
||||
<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">
|
||||
<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-[150px] items-center rounded-[16px] overflow-hidden bg-white">
|
||||
<div className="h-[150px] 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">
|
||||
{rank === 1 && <RankIcon1st />}
|
||||
{rank === 2 && <RankIcon2nd />}
|
||||
{rank === 3 && <RankIcon3rd />}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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" },
|
||||
take: 7,
|
||||
});
|
||||
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
return { initialBoardId: board.id, boardsData };
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -289,7 +186,13 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
||||
<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" />
|
||||
<UserAvatar
|
||||
src={null}
|
||||
alt="프로필"
|
||||
width={120}
|
||||
height={120}
|
||||
className="rounded-full"
|
||||
/>
|
||||
<div className="w-[62px] h-[62px] rounded-full bg-neutral-200 flex items-center justify-center text-[11px] text-neutral-700">
|
||||
Lv
|
||||
</div>
|
||||
@@ -350,9 +253,9 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{(await Promise.all(firstTwo.map((b) => renderBoardPanel(b)))).map((panel, idx) => (
|
||||
{(await Promise.all(firstTwo.map((b) => prepareBoardPanelData(b)))).map((panelData, idx) => (
|
||||
<div key={firstTwo[idx].id} className="rounded-xl overflow-hidden xl:h-[514px] h-full min-h-0 flex flex-col flex-1">
|
||||
{panel}
|
||||
<BoardPanelClient initialBoardId={panelData.initialBoardId} boardsData={panelData.boardsData} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -367,9 +270,9 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
||||
return (
|
||||
<section key={`rest-${i}`} className="min-h-[395px] md:h-[540px] overflow-hidden">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 h-full min-h-0">
|
||||
{(await Promise.all(pair.map((b) => renderBoardPanel(b)))).map((panel, idx) => (
|
||||
{(await Promise.all(pair.map((b) => prepareBoardPanelData(b)))).map((panelData, idx) => (
|
||||
<div key={pair[idx].id} className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
|
||||
{panel}
|
||||
<BoardPanelClient initialBoardId={panelData.initialBoardId} boardsData={panelData.boardsData} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user