This commit is contained in:
koreacomp5
2025-10-24 21:24:51 +09:00
parent 2668ade05f
commit 3d850188fd
14 changed files with 497 additions and 55 deletions

View File

@@ -1,7 +1,6 @@
# home main 화면 구성
[] 1. 상단엔 네비게이션 바가 있고 하단엔 footer가 있음 (이미 만들어져있음)
[] 2. 메인 컨테이너 top마진 48 bottom 마진140 x마진 60 (또는 패딩으로 자연스럽게)
[] 3. 메인 컨테이너는 위부터 높이가 264, 448, 594, 592 (패딩 또는 마진 포함해서 자연스럽게)
[] 1. 상단엔 네비게이션 바가 있고 하단엔 footer가 있음 (이미 만들어져있음) 메인 컨테이너 top마진 48 bottom 마진140 x마진 60 (또는 패딩으로 자연스럽게)
[] 2. 메인 컨테이너는 내부 컨텐츠는 위부터 높이가 264, 448, 594, 592 인 플랙스로 구성 (패딩 또는 마진 포함해서 자연스럽게)
[] 3.
[] 3.
[] 3.

View File

@@ -108,6 +108,7 @@ export async function GET(req: Request) {
boardId: true,
isPinned: true,
status: true,
author: { select: { nickname: true } },
stat: { select: { recommendCount: true, views: true, commentsCount: true } },
postTags: { select: { tag: { select: { name: true, slug: true } } } },
},

View File

@@ -1,4 +1,5 @@
import { PostList } from "@/app/components/PostList";
import { HeroBanner } from "@/app/components/HeroBanner";
import { headers } from "next/headers";
// Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다.
@@ -15,14 +16,51 @@ export default async function BoardDetail({ params, searchParams }: { params: an
const res = await fetch(new URL("/api/boards", base).toString(), { cache: "no-store" });
const { boards } = await res.json();
const board = (boards || []).find((b: any) => b.id === id);
const siblingBoards = (boards || []).filter((b: any) => b.category?.id && b.category.id === board?.category?.id);
const categoryName = board?.category?.name ?? "";
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h1></h1>
<a href={`/posts/new?boardId=${id}${board?.slug ? `&boardSlug=${board.slug}` : ""}`}><button> </button></a>
<div className="space-y-6">
{/* 상단 배너 (홈과 동일) */}
<section>
<HeroBanner />
</section>
{/* 보드 탭 + 리스트 카드 */}
<section className="rounded-xl overflow-hidden bg-white">
{/* 상단 탭 영역 */}
<div className="px-4 py-2 border-b border-neutral-200">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 overflow-x-auto flex-1">
<span className="shrink-0 text-sm text-neutral-500">{categoryName}</span>
{siblingBoards.map((b: any) => (
<a
key={b.id}
href={`/boards/${b.id}`}
className={`shrink-0 whitespace-nowrap text-xs px-3 py-1 rounded-full border transition-colors ${
b.id === id
? "bg-neutral-900 text-white border-neutral-900"
: "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
}`}
>
{b.name}
</a>
))}
</div>
<a
href={`/posts/new?boardId=${id}${board?.slug ? `&boardSlug=${board.slug}` : ""}`}
className="shrink-0"
>
<button className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800"> </button>
</a>
</div>
</div>
{/* 리스트 */}
<div className="p-0">
<PostList boardId={id} sort={sort} />
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,177 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
type ApiCategory = {
id: string;
name: string;
slug: string;
boards: { id: string; name: string; slug: string; type: string; requiresApproval: boolean }[];
};
type PostItem = {
id: string;
title: string;
createdAt: string;
boardId: string;
};
interface Props {
categoryName?: string;
categorySlug?: string;
}
export default function CategoryBoardBrowser({ categoryName, categorySlug }: Props) {
const router = useRouter();
const [categories, setCategories] = useState<ApiCategory[] | null>(null);
const [selectedBoardId, setSelectedBoardId] = useState<string | null>(null);
const [posts, setPosts] = useState<PostItem[] | null>(null);
const [isLoadingPosts, setIsLoadingPosts] = useState(false);
useEffect(() => {
let isMounted = true;
(async () => {
const res = await fetch("/api/categories", { cache: "no-store" });
const data = await res.json();
if (!isMounted) return;
setCategories(data.categories ?? []);
})();
return () => {
isMounted = false;
};
}, []);
const selectedCategory = useMemo(() => {
if (!categories) return null;
// 우선순위: 전달받은 카테고리명 -> 전달받은 슬러그 -> 기본(암실소문/main)
const byName = categoryName ? categories.find((c) => c.name === categoryName) : null;
if (byName) return byName;
const bySlug = categorySlug ? categories.find((c) => c.slug === categorySlug) : null;
if (bySlug) return bySlug;
return (
categories.find((c) => c.name === "암실소문") ||
categories.find((c) => c.slug === "main") ||
null
);
}, [categories, categoryName, categorySlug]);
const boardIdToName = useMemo(() => {
const map = new Map<string, string>();
if (selectedCategory) {
for (const b of selectedCategory.boards) map.set(b.id, b.name);
}
return map;
}, [selectedCategory]);
// 기본 보드 자동 선택: 선택된 카테고리의 첫 번째 보드
useEffect(() => {
if (!selectedBoardId && selectedCategory?.boards?.length) {
setSelectedBoardId(selectedCategory.boards[0].id);
}
}, [selectedCategory, selectedBoardId]);
useEffect(() => {
if (!selectedBoardId) return;
let isMounted = true;
(async () => {
try {
setIsLoadingPosts(true);
const params = new URLSearchParams({ pageSize: String(10), boardId: selectedBoardId });
const res = await fetch(`/api/posts?${params.toString()}`, { cache: "no-store" });
const data = await res.json();
if (!isMounted) return;
setPosts(data.items ?? []);
} finally {
if (isMounted) setIsLoadingPosts(false);
}
})();
return () => {
isMounted = false;
};
}, [selectedBoardId]);
return (
<div className="w-full h-full min-h-0 flex flex-col bg-white rounded-xl overflow-hidden">
{/* 상단: 한 줄(row)로 카테고리 + 화살표 + 보드 pill 버튼들 */}
<div className="px-3 py-2 border-b border-neutral-200">
<div className="flex items-center gap-2 overflow-x-auto flex-nowrap">
<button
type="button"
className="shrink-0 text-lg md:text-xl font-bold text-neutral-800 truncate"
onClick={() => {
const first = selectedCategory?.boards?.[0];
if (first?.id) router.push(`/boards/${first.id}`);
}}
title={(selectedCategory?.name ?? categoryName ?? "").toString()}
>
{selectedCategory?.name ?? categoryName ?? ""}
</button>
<button
type="button"
aria-label="카테고리 첫 게시판 이동"
className="shrink-0 w-6 h-6 rounded-full border border-neutral-300 text-neutral-500 hover:bg-neutral-50 flex items-center justify-center"
onClick={() => {
const first = selectedCategory?.boards?.[0];
if (first?.id) router.push(`/boards/${first.id}`);
}}
>
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M7 5l5 5-5 5" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{selectedCategory?.boards?.map((b) => (
<button
key={b.id}
className={`shrink-0 whitespace-nowrap text-xs px-3 py-1 rounded-full border transition-colors ${
selectedBoardId === b.id
? "bg-neutral-800 text-white border-neutral-800"
: "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
}`}
onClick={() => setSelectedBoardId(b.id)}
>
{b.name}
</button>
))}
</div>
</div>
{/* 2행: 게시글 리스트 */}
<div className="flex-1 min-h-0 overflow-y-auto p-3">
{!selectedBoardId && (
<div className="text-sm text-neutral-500"> .</div>
)}
{selectedBoardId && isLoadingPosts && (
<div className="text-sm text-neutral-500"> </div>
)}
{selectedBoardId && !isLoadingPosts && posts && posts.length === 0 && (
<div className="text-sm text-neutral-500"> .</div>
)}
<div className="grid grid-cols-1 gap-3">
{posts?.map((p) => {
const boardName = boardIdToName.get(p.boardId) ?? "";
const dateStr = new Date(p.createdAt).toLocaleDateString();
const imgSrc = `https://picsum.photos/seed/${p.id}/200/140`;
return (
<article key={p.id} className="w-full h-[140px] rounded-lg border border-neutral-200 overflow-hidden bg-white">
<div className="h-full w-full grid grid-cols-[200px_1fr]">
<div className="w-[200px] h-full bg-neutral-100">
<img src={imgSrc} alt="썸네일" className="w-full h-full object-cover" />
</div>
<div className="flex flex-col justify-center gap-1 px-3">
<div className="text-[11px] text-neutral-500">{boardName}</div>
<div className="text-sm font-semibold line-clamp-2">{p.title}</div>
<div className="text-xs text-neutral-500">{dateStr}</div>
</div>
</div>
</article>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -74,7 +74,7 @@ export function HeroBanner() {
onMouseLeave={() => setIsHovered(false)}
aria-roledescription="carousel"
>
<div className="relative h-56 sm:h-72 md:h-[420px]">
<div className="relative h-56 sm:h-72 md:h-[264px]">
<div
className="flex h-full w-full transition-transform duration-500 ease-out"
style={{ transform: `translate3d(${translatePercent}%, 0, 0)`, width: `${numSlides * 100}%` }}

View File

@@ -0,0 +1,153 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
type CardItem = {
id: number;
region: string;
name: string;
address: string;
image: string;
};
interface HorizontalCardScrollerProps {
items: CardItem[];
}
export default function HorizontalCardScroller({ items }: HorizontalCardScrollerProps) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const [thumbWidth, setThumbWidth] = useState<number>(60);
const [thumbLeft, setThumbLeft] = useState<number>(0);
const [isDragging, setIsDragging] = useState<boolean>(false);
const dragOffsetRef = useRef<number>(0);
const CARD_WIDTH = 384;
const CARD_GAP = 16; // Tailwind gap-4
const SCROLL_STEP = CARD_WIDTH + CARD_GAP;
const updateThumb = useCallback(() => {
const scroller = scrollRef.current;
const track = trackRef.current;
if (!scroller || !track) return;
const { scrollWidth, clientWidth, scrollLeft } = scroller;
const trackWidth = track.clientWidth;
if (scrollWidth <= 0 || trackWidth <= 0) return;
const ratioVisible = Math.max(0, Math.min(1, clientWidth / scrollWidth));
const newThumbWidth = Math.max(40, Math.round(trackWidth * ratioVisible));
const maxThumbLeft = Math.max(0, trackWidth - newThumbWidth);
const ratioPosition = scrollWidth === clientWidth ? 0 : scrollLeft / (scrollWidth - clientWidth);
const newThumbLeft = Math.round(maxThumbLeft * ratioPosition);
setThumbWidth(newThumbWidth);
setThumbLeft(newThumbLeft);
}, []);
useEffect(() => {
updateThumb();
const el = scrollRef.current;
if (!el) return;
const onScroll = () => updateThumb();
const onResize = () => updateThumb();
el.addEventListener("scroll", onScroll);
window.addEventListener("resize", onResize);
return () => {
el.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onResize);
};
}, [updateThumb]);
useEffect(() => {
if (!isDragging) return;
const onMove = (e: MouseEvent) => {
const el = scrollRef.current;
const track = trackRef.current;
if (!el || !track) return;
const rect = track.getBoundingClientRect();
let x = e.clientX - rect.left - dragOffsetRef.current;
x = Math.max(0, Math.min(x, rect.width - thumbWidth));
setThumbLeft(x);
const ratio = rect.width === thumbWidth ? 0 : x / (rect.width - thumbWidth);
const targetScrollLeft = ratio * (el.scrollWidth - el.clientWidth);
el.scrollLeft = targetScrollLeft;
};
const onUp = () => setIsDragging(false);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [isDragging, thumbWidth]);
const handleThumbMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect) return;
dragOffsetRef.current = e.clientX - rect.left - thumbLeft;
setIsDragging(true);
e.preventDefault();
};
const scrollByStep = (direction: 1 | -1) => {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: direction * SCROLL_STEP, behavior: "smooth" });
};
return (
<div className="relative h-[400px]">
<div ref={scrollRef} className="scrollbar-hidden h-full overflow-x-auto overflow-y-hidden">
<div className="flex h-full items-center gap-4">
{items.map((card) => (
<article key={card.id} className="flex-shrink-0 w-[384px] h-[324px] rounded-xl bg-white overflow-hidden shadow-sm p-2">
<div className="grid grid-rows-[192px_116px] h-full">
<div className="w-full h-[192px] overflow-hidden rounded-lg">
<img src={card.image} alt={card.name} className="w-full h-full object-cover" />
</div>
<div className="h-[116px] flex flex-col justify-center px-2">
<p className="text-sm text-neutral-600">{card.region}</p>
<p className="text-base font-semibold">{card.name}</p>
<p className="text-sm text-neutral-600">{card.address}</p>
</div>
</div>
</article>
))}
</div>
</div>
<div className="pointer-events-none absolute bottom-[20px] left-1/2 -translate-x-1/2 z-20 flex items-center gap-3">
<button
type="button"
aria-label="이전"
className="pointer-events-auto p-0 m-0 bg-transparent text-orange-500 hover:text-orange-600 focus:outline-none"
onClick={() => scrollByStep(-1)}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
<polygon points="10,0 0,6 10,12" />
</svg>
</button>
<div ref={trackRef} className="pointer-events-auto relative h-2 w-[30vw] rounded-full bg-orange-200/50">
<div
className="absolute top-0 h-2 rounded-full bg-orange-500"
style={{ width: `${thumbWidth}px`, left: `${thumbLeft}px` }}
onMouseDown={handleThumbMouseDown}
/>
</div>
<button
type="button"
aria-label="다음"
className="pointer-events-auto p-0 m-0 bg-transparent text-orange-500 hover:text-orange-600 focus:outline-none"
onClick={() => scrollByStep(1)}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
<polygon points="0,0 10,6 0,12" />
</svg>
</button>
</div>
</div>
);
}

View File

@@ -9,6 +9,7 @@ type Item = {
status: string;
stat?: { recommendCount: number; views: number; commentsCount: number } | null;
postTags?: { tag: { name: string; slug: string } }[];
author?: { nickname: string } | null;
};
type Resp = {
@@ -40,44 +41,69 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end
const canLoadMore = (data?.at(-1)?.items.length ?? 0) === pageSize;
return (
<div>
<div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
<span>:</span>
<div className="w-full">
{/* 정렬 스위치 */}
<div className="mb-2 flex items-center gap-3 text-sm">
<span className="text-neutral-500"></span>
<a
className={sort === "recent" ? "underline" : "hover:underline"}
href={`${q ? "/search" : "/"}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); if (boardId) p.set("boardId", boardId); p.set("sort", "recent"); return p.toString(); })()}`}
style={{ textDecoration: sort === "recent" ? "underline" : "none" }}
>
</a>
<a
className={sort === "popular" ? "underline" : "hover:underline"}
href={`${q ? "/search" : "/"}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); if (boardId) p.set("boardId", boardId); p.set("sort", "popular"); return p.toString(); })()}`}
style={{ textDecoration: sort === "popular" ? "underline" : "none" }}
>
</a>
</div>
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{/* 리스트 테이블 헤더 */}
<div className="hidden md:grid grid-cols-[1fr_auto_auto_auto] items-center px-4 py-2 text-xs text-neutral-500 border-b border-neutral-200">
<div></div>
<div className="w-28 text-center"></div>
<div className="w-24 text-center"></div>
<div className="w-24 text-right"></div>
</div>
{/* 아이템들 */}
<ul className="divide-y divide-neutral-100">
{items.map((p) => (
<li key={p.id} style={{ padding: 12, border: "1px solid #eee", borderRadius: 8 }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<strong><a href={`/posts/${p.id}`}>{p.title}</a></strong>
<span style={{ opacity: 0.7 }}>{new Date(p.createdAt).toLocaleString()}</span>
</div>
<div style={{ fontSize: 12, opacity: 0.8 }}>
{p.stat?.recommendCount ?? 0} · {p.stat?.views ?? 0} · {p.stat?.commentsCount ?? 0}
</div>
<li key={p.id} className="px-4 py-3 hover:bg-neutral-50 transition-colors">
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_auto_auto] items-center gap-2">
<div className="min-w-0">
<a href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900">
{p.isPinned && <span className="mr-2 inline-flex items-center rounded bg-orange-100 text-orange-700 px-1.5 py-0.5 text-[11px]"></span>}
{p.title}
</a>
{!!p.postTags?.length && (
<div style={{ marginTop: 6, display: "flex", gap: 6, flexWrap: "wrap", fontSize: 12 }}>
<div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500">
{p.postTags?.map((pt) => (
<a key={pt.tag.slug} href={`/search?tag=${pt.tag.slug}`}>#{pt.tag.name}</a>
<a key={pt.tag.slug} href={`/search?tag=${pt.tag.slug}`} className="hover:underline">#{pt.tag.name}</a>
))}
</div>
)}
</div>
<div className="md:w-28 text-xs text-neutral-600 text-center">{p.author?.nickname ?? "익명"}</div>
<div className="md:w-24 text-[11px] text-neutral-600 text-center flex md:block gap-3 md:gap-0">
<span>👍 {p.stat?.recommendCount ?? 0}</span>
<span>👁 {p.stat?.views ?? 0}</span>
<span>💬 {p.stat?.commentsCount ?? 0}</span>
</div>
<div className="md:w-24 text-xs text-neutral-500 text-right">{new Date(p.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
</div>
</li>
))}
</ul>
<div style={{ marginTop: 12 }}>
<button disabled={!canLoadMore || isLoading} onClick={() => setSize(size + 1)}>
{/* 페이지 더보기 */}
<div className="mt-3 flex justify-center">
<button
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100 disabled:opacity-50"
disabled={!canLoadMore || isLoading}
onClick={() => setSize(size + 1)}
>
{isLoading ? "로딩 중..." : canLoadMore ? "더 보기" : "끝"}
</button>
</div>

View File

@@ -27,3 +27,12 @@ body {
/* 유틸: 카드 스켈레톤 색상 헬퍼 (타깃 사이트 톤 유사) */
.bg-neutral-100 { background-color: #f5f5f7; }
/* 커스텀 스크롤 구현을 위한 기본 스크롤 숨김 */
.scrollbar-hidden {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hidden::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}

View File

@@ -28,12 +28,12 @@ export default function RootLayout({
</div>
</div>
<main className="flex-1 bg-[#F2F2F2]">
<div className="max-w-7xl mx-auto px-4 py-6">
<div className="max-w-[1920px] mx-auto px-4 py-6">
{children}
</div>
</main>
<div className="">
<div className="max-w-7xl mx-auto px-4">
<div className="max-w-[1920px] mx-auto px-4">
<AppFooter />
</div>
</div>

View File

@@ -1,4 +1,6 @@
import { HeroBanner } from "@/app/components/HeroBanner";
import HorizontalCardScroller from "@/app/components/HorizontalCardScroller";
import CategoryBoardBrowser from "@/app/components/CategoryBoardBrowser";
export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: "recent" | "popular" } | undefined> }) {
const sp = await searchParams;
@@ -10,32 +12,69 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
<HeroBanner />
</section>
{/* 메인 그리드: 좌 대형 카드, 우 2열 카드 등 타깃 사이트 구조를 단순화한 12그리드 */}
<section className="grid grid-cols-1 md:grid-cols-12 gap-4">
<div className="md:col-span-8 grid grid-cols-1 gap-4">
<div className="aspect-[16/9] rounded-xl bg-neutral-100 overflow-hidden" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="h-40 rounded-xl bg-neutral-100" />
<div className="h-40 rounded-xl bg-neutral-100" />
{/* 1행: 커스텀 중앙 50vw 주황 스크롤바 */}
{(() => {
const items = [
{ id: 1, region: "경기도", name: "라온마사지샾", address: "수원시 팔달구 매산로 45", image: "/sample.jpg" },
{ id: 2, region: "강원도", name: "휴앤힐링마사지샾", address: "춘천시 중앙로 112", image: "/sample.jpg" },
{ id: 3, region: "충청북도", name: "소담마사지샾", address: "청주시 상당구 상당로 88", image: "/sample.jpg" },
{ id: 4, region: "충청남도", name: "아늑마사지샾", address: "천안시 동남구 시민로 21", image: "/sample.jpg" },
{ id: 5, region: "전라북도", name: "편안한마사지샾", address: "전주시 완산구 풍남문로 77", image: "/sample.jpg" },
{ id: 6, region: "전라남도", name: "바른마사지샾", address: "여수시 중앙로 9", image: "/sample.jpg" },
{ id: 7, region: "경상북도", name: "늘봄마사지샾", address: "대구시 중구 동성로3길 12", image: "/sample.jpg" },
{ id: 8, region: "경상남도", name: "편히쉬다마사지샾", address: "창원시 성산구 중앙대로 150", image: "/sample.jpg" },
{ id: 9, region: "제주특별자치도", name: "제주소풍마사지샾", address: "제주시 중앙로 230", image: "/sample.jpg" },
{ id: 10, region: "서울특별시", name: "도심휴식마사지샾", address: "강남구 테헤란로 427", image: "/sample.jpg" },
];
return <HorizontalCardScroller items={items} />;
})()}
{/* 2행: 최소 높이(모바일), md+에서 고정 높이 620px로 내부 스크롤 */}
<section className="min-h-[514px] md:h-[620px] overflow-hidden">
<div className="grid grid-cols-1 md:[grid-template-columns:1fr_2fr] xl:[grid-template-columns:1fr_2fr_2fr] gap-4 h-full min-h-0">
<div className="rounded-xl bg-white p-4 md:p-6 flex flex-col h-full w-full md:min-w-[350px] space-y-6">
{/* 1행: 프로필 사진 영역 */}
<div className="flex items-center justify-center">
<img
src="https://picsum.photos/seed/profile/200/200"
alt="프로필"
className="w-40 h-40 rounded-full object-cover"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="h-28 rounded-lg bg-neutral-100" />
<div className="h-28 rounded-lg bg-neutral-100" />
<div className="h-28 rounded-lg bg-neutral-100" />
{/* 2행: 정보 영역 (4행 그리드) */}
<div className="grid grid-rows-4 gap-1">
<div className="text-lg md:text-xl font-bold truncate"></div>
<div className="text-sm text-neutral-700">레벨 : Lv. 79</div>
<div className="text-sm text-neutral-700">등급 : Iron</div>
<div className="text-sm text-neutral-700 mb-[20px]">포인트 : 1,600,000</div>
</div>
{/* 3행: 버튼들 영역 (4개 버튼, 세로) */}
<div className="grid grid-cols-1 gap-2">
<button className="h-10 rounded-md bg-neutral-100 hover:bg-neutral-200 text-sm font-medium text-neutral-900 text-left px-3"></button>
<button className="h-10 rounded-md bg-neutral-100 hover:bg-neutral-200 text-sm font-medium text-neutral-900 text-left px-3"></button>
<button className="h-10 rounded-md bg-neutral-100 hover:bg-neutral-200 text-sm font-medium text-neutral-900 text-left px-3"> </button>
<button className="h-10 rounded-md bg-neutral-100 hover:bg-neutral-200 text-sm font-medium text-neutral-900 text-left px-3"> </button>
</div>
</div>
<div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
<CategoryBoardBrowser />
</div>
<div className="hidden xl:flex xl:flex-col rounded-xl overflow-hidden h-full min-h-0">
<CategoryBoardBrowser categoryName="명예의 전당" categorySlug="hall-of-fame" />
</div>
</div>
<aside className="md:col-span-4 space-y-4">
<div className="h-40 rounded-xl bg-neutral-100" />
<div className="h-40 rounded-xl bg-neutral-100" />
<div className="h-40 rounded-xl bg-neutral-100" />
</aside>
</section>
{/* 하단 롤링 배너/뉴스 영역 유사 섹션 */}
<section className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="h-28 rounded-lg bg-neutral-100" />
<div className="h-28 rounded-lg bg-neutral-100" />
<div className="h-28 rounded-lg bg-neutral-100" />
{/* 3행: 최소/최대 높이 + 내부 스크롤 가능 */}
<section className="min-h-[514px] md:h-[620px] overflow-hidden">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 h-full min-h-0">
<div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
<CategoryBoardBrowser categoryName="소통방" categorySlug="community" />
</div>
<div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
<CategoryBoardBrowser categoryName="제휴업소 정보" categorySlug="partner-info" />
</div>
</div>
</section>
</div>
);