From 3d850188fd503b22e766fb30476e67ca6a4d7629 Mon Sep 17 00:00:00 2001 From: koreacomp5 Date: Fri, 24 Oct 2025 21:24:51 +0900 Subject: [PATCH] =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/.prompt/1024memo.md | 5 +- .../{ => done}/_101101_메인페이지구성.md | 0 .../{ => done}/_header_navigation작업.md | 0 .cursor/.prompt/{ => done}/_todolist.md | 0 .../.prompt/{ => done}/_게시판 대분류작업.md | 0 src/app/api/posts/route.ts | 1 + src/app/boards/[id]/page.tsx | 50 ++++- src/app/components/CategoryBoardBrowser.tsx | 177 ++++++++++++++++++ src/app/components/HeroBanner.tsx | 2 +- src/app/components/HorizontalCardScroller.tsx | 153 +++++++++++++++ src/app/components/PostList.tsx | 70 ++++--- src/app/globals.css | 9 + src/app/layout.tsx | 4 +- src/app/page.tsx | 81 +++++--- 14 files changed, 497 insertions(+), 55 deletions(-) rename .cursor/.prompt/{ => done}/_101101_메인페이지구성.md (100%) rename .cursor/.prompt/{ => done}/_header_navigation작업.md (100%) rename .cursor/.prompt/{ => done}/_todolist.md (100%) rename .cursor/.prompt/{ => done}/_게시판 대분류작업.md (100%) create mode 100644 src/app/components/CategoryBoardBrowser.tsx create mode 100644 src/app/components/HorizontalCardScroller.tsx diff --git a/.cursor/.prompt/1024memo.md b/.cursor/.prompt/1024memo.md index ca4ff44..2e36509 100644 --- a/.cursor/.prompt/1024memo.md +++ b/.cursor/.prompt/1024memo.md @@ -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. diff --git a/.cursor/.prompt/_101101_메인페이지구성.md b/.cursor/.prompt/done/_101101_메인페이지구성.md similarity index 100% rename from .cursor/.prompt/_101101_메인페이지구성.md rename to .cursor/.prompt/done/_101101_메인페이지구성.md diff --git a/.cursor/.prompt/_header_navigation작업.md b/.cursor/.prompt/done/_header_navigation작업.md similarity index 100% rename from .cursor/.prompt/_header_navigation작업.md rename to .cursor/.prompt/done/_header_navigation작업.md diff --git a/.cursor/.prompt/_todolist.md b/.cursor/.prompt/done/_todolist.md similarity index 100% rename from .cursor/.prompt/_todolist.md rename to .cursor/.prompt/done/_todolist.md diff --git a/.cursor/.prompt/_게시판 대분류작업.md b/.cursor/.prompt/done/_게시판 대분류작업.md similarity index 100% rename from .cursor/.prompt/_게시판 대분류작업.md rename to .cursor/.prompt/done/_게시판 대분류작업.md diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 0303448..97230ef 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -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 } } } }, }, diff --git a/src/app/boards/[id]/page.tsx b/src/app/boards/[id]/page.tsx index 2dd8501..537def0 100644 --- a/src/app/boards/[id]/page.tsx +++ b/src/app/boards/[id]/page.tsx @@ -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,13 +16,50 @@ 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 ( -
-
-

게시판

- -
- +
+ {/* 상단 배너 (홈과 동일) */} +
+ +
+ + {/* 보드 탭 + 리스트 카드 */} +
+ {/* 상단 탭 영역 */} +
+
+
+ {categoryName} + {siblingBoards.map((b: any) => ( + + {b.name} + + ))} +
+ + + +
+
+ + {/* 리스트 */} +
+ +
+
); } diff --git a/src/app/components/CategoryBoardBrowser.tsx b/src/app/components/CategoryBoardBrowser.tsx new file mode 100644 index 0000000..b35dc51 --- /dev/null +++ b/src/app/components/CategoryBoardBrowser.tsx @@ -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(null); + const [selectedBoardId, setSelectedBoardId] = useState(null); + const [posts, setPosts] = useState(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(); + 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 ( +
+ {/* 상단: 한 줄(row)로 카테고리 + 화살표 + 보드 pill 버튼들 */} +
+
+ + + {selectedCategory?.boards?.map((b) => ( + + ))} +
+
+ + {/* 2행: 게시글 리스트 */} +
+ {!selectedBoardId && ( +
보드를 선택해 주세요.
+ )} + {selectedBoardId && isLoadingPosts && ( +
불러오는 중…
+ )} + {selectedBoardId && !isLoadingPosts && posts && posts.length === 0 && ( +
게시글이 없습니다.
+ )} + +
+ {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 ( +
+
+
+ 썸네일 +
+
+
{boardName}
+
{p.title}
+
{dateStr}
+
+
+
+ ); + })} +
+
+
+ ); +} + + diff --git a/src/app/components/HeroBanner.tsx b/src/app/components/HeroBanner.tsx index 5a31053..ae5d09b 100644 --- a/src/app/components/HeroBanner.tsx +++ b/src/app/components/HeroBanner.tsx @@ -74,7 +74,7 @@ export function HeroBanner() { onMouseLeave={() => setIsHovered(false)} aria-roledescription="carousel" > -
+
(null); + const trackRef = useRef(null); + const [thumbWidth, setThumbWidth] = useState(60); + const [thumbLeft, setThumbLeft] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const dragOffsetRef = useRef(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) => { + 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 ( +
+
+
+ {items.map((card) => ( +
+
+
+ {card.name} +
+
+

{card.region}

+

{card.name}

+

{card.address}

+
+
+
+ ))} +
+
+ +
+ +
+
+
+ +
+
+ ); +} + + diff --git a/src/app/components/PostList.tsx b/src/app/components/PostList.tsx index ee7390b..173a6d4 100644 --- a/src/app/components/PostList.tsx +++ b/src/app/components/PostList.tsx @@ -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 ( -
-
- 정렬: +
+ {/* 정렬 스위치 */} + -
    + + {/* 리스트 테이블 헤더 */} +
    +
    제목
    +
    작성자
    +
    지표
    +
    작성일
    +
    + + {/* 아이템들 */} +
      {items.map((p) => ( -
    • -
      - {p.title} - {new Date(p.createdAt).toLocaleString()} -
      -
      - 추천 {p.stat?.recommendCount ?? 0} · 조회 {p.stat?.views ?? 0} · 댓글 {p.stat?.commentsCount ?? 0} -
      - {!!p.postTags?.length && ( -
      - {p.postTags?.map((pt) => ( - #{pt.tag.name} - ))} +
    • +
      +
      + + {p.isPinned && 공지} + {p.title} + + {!!p.postTags?.length && ( +
      + {p.postTags?.map((pt) => ( + #{pt.tag.name} + ))} +
      + )}
      - )} +
      {p.author?.nickname ?? "익명"}
      +
      + 👍 {p.stat?.recommendCount ?? 0} + 👁️ {p.stat?.views ?? 0} + 💬 {p.stat?.commentsCount ?? 0} +
      +
      {new Date(p.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
      +
    • ))}
    -
    -
    diff --git a/src/app/globals.css b/src/app/globals.css index fd8e9cc..9a9efbf 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 */ +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1efe4bc..ea24eb1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -28,12 +28,12 @@ export default function RootLayout({
-
+
{children}
-
+
diff --git a/src/app/page.tsx b/src/app/page.tsx index 3eb86f7..8d24db6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 - {/* 메인 그리드: 좌 대형 카드, 우 2열 카드 등 타깃 사이트 구조를 단순화한 12그리드 */} -
-
-
-
-
-
+ {/* 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 ; + })()} + + {/* 2행: 최소 높이(모바일), md+에서 고정 높이 620px로 내부 스크롤 */} +
+
+
+ {/* 1행: 프로필 사진 영역 */} +
+ 프로필 +
+ {/* 2행: 정보 영역 (4행 그리드) */} +
+
홍길동
+
레벨 : Lv. 79
+
등급 : Iron
+
포인트 : 1,600,000
+
+ {/* 3행: 버튼들 영역 (4개 버튼, 세로) */} +
+ + + + +
-
-
-
-
+
+ +
+
+
-
- {/* 하단 롤링 배너/뉴스 영역 유사 섹션 */} -
-
-
-
+ {/* 3행: 최소/최대 높이 + 내부 스크롤 가능 */} +
+
+
+ +
+
+ +
+
);