Compare commits
10 Commits
4b1c60a9bf
...
53b6376966
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53b6376966 | ||
|
|
3d850188fd | ||
|
|
2668ade05f | ||
|
|
70613eff52 | ||
|
|
d3efaba6f7 | ||
|
|
329537e3ca | ||
|
|
a1e4f76b0b | ||
|
|
96aaca4678 | ||
|
|
ebd740f346 | ||
|
|
30ffadec5e |
9
.cursor/.prompt/1024memo.md
Normal file
9
.cursor/.prompt/1024memo.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# home main 화면 구성
|
||||
[] 1. 상단엔 네비게이션 바가 있고 하단엔 footer가 있음 (이미 만들어져있음) 메인 컨테이너 top마진 48 bottom 마진140 x마진 60 (또는 패딩으로 자연스럽게)
|
||||
[] 2. 메인 컨테이너는 내부 컨텐츠는 위부터 높이가 264, 448, 594, 592 인 플랙스로 구성 (패딩 또는 마진 포함해서 자연스럽게)
|
||||
[] 3.
|
||||
[] 3.
|
||||
[] 3.
|
||||
[] 3.
|
||||
|
||||
2. 게시판 화면 구성
|
||||
@@ -2,6 +2,25 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "c.pxhere.com",
|
||||
pathname: "/images/**",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "aromatica.co",
|
||||
pathname: "/**",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "cafe24img.poxo.com",
|
||||
pathname: "/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -5,7 +5,7 @@ const prisma = new PrismaClient();
|
||||
async function upsertCategories() {
|
||||
// 카테고리 트리 (projectmemo 기준 상위 그룹)
|
||||
const categories = [
|
||||
{ name: "암실소문 (메인)", slug: "main", sortOrder: 1, status: "active" },
|
||||
{ name: "암실소문", slug: "main", sortOrder: 1, status: "active" },
|
||||
{ name: "명예의 전당", slug: "hall-of-fame", sortOrder: 2, status: "active" },
|
||||
{ name: "주변 제휴업체", slug: "nearby-partners", sortOrder: 3, status: "active" },
|
||||
{ name: "제휴업소 정보", slug: "partner-info", sortOrder: 4, status: "active" },
|
||||
@@ -128,8 +128,11 @@ async function upsertBoards(admin, categoryMap) {
|
||||
{ name: "회원랭킹", slug: "ranking", description: "랭킹", type: "special", sortOrder: 14 },
|
||||
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", type: "special", sortOrder: 15 },
|
||||
{ name: "월간집계", slug: "monthly-stats", description: "월간 통계", type: "special", sortOrder: 16 },
|
||||
// 제휴업소 일반(사진)
|
||||
{ name: "제휴업소 일반(사진)", slug: "partners-photos", description: "사진 전용 게시판", type: "general", sortOrder: 17, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
|
||||
// 제휴업소 일반
|
||||
{ name: "제휴업소", slug: "partners-photos", description: "사진 전용 게시판", type: "general", sortOrder: 17, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
|
||||
// 광고/제휴
|
||||
{ name: "제휴문의", slug: "partner-contact", description: "제휴문의", type: "general", sortOrder: 18, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
|
||||
{ name: "제휴업소 요청", slug: "partner-req", description: "제휴업소 요청", type: "general", sortOrder: 19, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
|
||||
];
|
||||
|
||||
const created = [];
|
||||
@@ -159,6 +162,9 @@ async function upsertBoards(admin, categoryMap) {
|
||||
anonymous: "community",
|
||||
"find-therapist": "community",
|
||||
"blue-house": "community",
|
||||
// 광고/제휴
|
||||
"partner-contact": "ads-affiliates",
|
||||
"partner-req": "ads-affiliates",
|
||||
};
|
||||
const categorySlug = mapBySlug[b.slug] || "community";
|
||||
const category = categoryMap[categorySlug];
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 795 KiB After Width: | Height: | Size: 795 KiB |
BIN
public/sample.jpg
Normal file
BIN
public/sample.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
@@ -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 } } } },
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
export function AppFooter() {
|
||||
return (
|
||||
<footer style={{ padding: 12, borderTop: "1px solid #eee", marginTop: 24 }}>
|
||||
© msg App
|
||||
<footer className="py-[72px]">
|
||||
<div className="text-[#626262] text-[16px] leading-[14px] flex flex-row mb-[30px]">
|
||||
<div className="flex-1"></div>
|
||||
<div className="border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]">개인정보처리방침</div>
|
||||
<div className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]">이메일 무단수집거부</div>
|
||||
<div className=" border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]">책임의 한계와 법적고지</div>
|
||||
<div className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]">이용안내</div>
|
||||
<div className="px-[8px] cursor-pointer hover:text-[#2BAF7E]">문의하기</div>
|
||||
<div className="flex-1"></div>
|
||||
</div>
|
||||
<div className="text-[#888] text-center font-[Pretendard] text-[16px] font-normal leading-[100%] mt-[24px]">
|
||||
본 내용에는 청소년 유해매체물이 포함될 수 있어 성인인증을 거친 분만 회원 가입을 하실 수 있습니다 19세 미만 청소년은 이용할 수 없습니다.
|
||||
</div>
|
||||
<div className="text-center mt-[24px]">
|
||||
<span className="text-[#525252] font-[Pretendard] text-[14px] font-normal leading-[12px] mr-[8px]">
|
||||
암실소문ⓒ
|
||||
</span>
|
||||
<span className="text-[#888] font-[Pretendard] text-[14px] font-normal leading-[12px]">
|
||||
All Rights Reserved.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SearchBar } from "@/app/components/SearchBar";
|
||||
import React from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
export function AppHeader() {
|
||||
const [categories, setCategories] = React.useState<Array<{ id: string; name: string; slug: string; boards: Array<{ id: string; name: string; slug: string }> }>>([]);
|
||||
@@ -15,9 +16,46 @@ export function AppHeader() {
|
||||
const navRefs = React.useRef<Record<string, HTMLAnchorElement | null>>({});
|
||||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const blockRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const navItemRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const [leftPositions, setLeftPositions] = React.useState<Record<string, number>>({});
|
||||
const [panelHeight, setPanelHeight] = React.useState<number>(0);
|
||||
const [blockWidths, setBlockWidths] = React.useState<Record<string, number>>({});
|
||||
const closeTimer = React.useRef<number | null>(null);
|
||||
|
||||
// 현재 경로 기반 활성 보드/카테고리 계산
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const activeBoardId = React.useMemo(() => {
|
||||
if (!pathname) return null;
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
if (parts[0] === "boards" && parts[1]) return parts[1];
|
||||
return null;
|
||||
}, [pathname]);
|
||||
const activeCategorySlug = React.useMemo(() => {
|
||||
if (activeBoardId) {
|
||||
const found = categories.find((c) => c.boards.some((b) => b.id === activeBoardId));
|
||||
return found?.slug ?? null;
|
||||
}
|
||||
if (pathname === "/boards") {
|
||||
return searchParams?.get("category") ?? null;
|
||||
}
|
||||
return null;
|
||||
}, [activeBoardId, pathname, searchParams, categories]);
|
||||
|
||||
const cancelClose = React.useCallback(() => {
|
||||
if (closeTimer.current) {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleClose = React.useCallback(() => {
|
||||
cancelClose();
|
||||
closeTimer.current = window.setTimeout(() => {
|
||||
setMegaOpen(false);
|
||||
setOpenSlug(null);
|
||||
}, 150);
|
||||
}, [cancelClose]);
|
||||
// 카테고리 로드
|
||||
React.useEffect(() => {
|
||||
fetch("/api/categories", { cache: "no-store" })
|
||||
@@ -61,27 +99,33 @@ export function AppHeader() {
|
||||
if (!container) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const nextPositions: Record<string, number> = {};
|
||||
const desiredWidths: Record<string, number> = {};
|
||||
const minWidthsBySlug: Record<string, number> = { community: 200 };
|
||||
categories.forEach((cat) => {
|
||||
const a = navRefs.current[cat.slug];
|
||||
if (a) {
|
||||
const r = a.getBoundingClientRect();
|
||||
const itemEl = navItemRefs.current[cat.slug];
|
||||
if (!itemEl) return;
|
||||
const r = itemEl.getBoundingClientRect();
|
||||
nextPositions[cat.slug] = Math.max(0, r.left - containerRect.left);
|
||||
}
|
||||
const baseWidth = r.width; // 각 네비 항목의 실제 폭을 기본값으로 사용
|
||||
const minWidth = minWidthsBySlug[cat.slug] ?? baseWidth;
|
||||
desiredWidths[cat.slug] = Math.max(baseWidth, minWidth);
|
||||
});
|
||||
setLeftPositions(nextPositions);
|
||||
|
||||
// 각 블록의 가로 폭 = 다음 항목의 left까지 남은 거리 (마지막은 컨테이너 오른쪽 끝)
|
||||
const nextWidths: Record<string, number> = {};
|
||||
// 원하는 최소 폭을 기준으로 하되, 다음 항목의 시작점까지를 넘지 않도록 클램프
|
||||
const finalWidths: Record<string, number> = {};
|
||||
const ordered = categories
|
||||
.filter((c) => typeof nextPositions[c.slug] === "number")
|
||||
.sort((a, b) => (nextPositions[a.slug]! - nextPositions[b.slug]!));
|
||||
ordered.forEach((cat, idx) => {
|
||||
const currentLeft = nextPositions[cat.slug]!;
|
||||
const nextLeft = idx < ordered.length - 1 ? nextPositions[ordered[idx + 1].slug]! : containerRect.width;
|
||||
const width = Math.max(0, nextLeft - currentLeft);
|
||||
nextWidths[cat.slug] = width;
|
||||
const desired = desiredWidths[cat.slug] ?? 0;
|
||||
const maxAvail = idx < ordered.length - 1
|
||||
? Math.max(0, nextPositions[ordered[idx + 1].slug]! - currentLeft)
|
||||
: Math.max(0, containerRect.width - currentLeft);
|
||||
finalWidths[cat.slug] = Math.min(desired, maxAvail);
|
||||
});
|
||||
setBlockWidths(nextWidths);
|
||||
setBlockWidths(finalWidths);
|
||||
|
||||
// 패널 높이 = 블록들 중 최대 높이
|
||||
let maxH = 0;
|
||||
@@ -120,8 +164,8 @@ export function AppHeader() {
|
||||
{/* 데스크톱 메가메뉴 */}
|
||||
<div
|
||||
className="relative hidden xl:block pl-10"
|
||||
onMouseEnter={() => setMegaOpen(true)}
|
||||
onMouseLeave={() => setMegaOpen(false)}
|
||||
onMouseEnter={() => { cancelClose(); setMegaOpen(true); }}
|
||||
onMouseLeave={() => { scheduleClose(); }}
|
||||
onFocusCapture={() => setMegaOpen(true)}
|
||||
onBlurCapture={(e) => {
|
||||
const next = (e as unknown as React.FocusEvent<HTMLDivElement>).relatedTarget as Node | null;
|
||||
@@ -129,11 +173,21 @@ export function AppHeader() {
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-8">
|
||||
{categories.map((cat) => (
|
||||
<div key={cat.id} className="relative group" onMouseEnter={() => setOpenSlug(cat.slug)}>
|
||||
{categories.map((cat, idx) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="relative group min-w-[80px] text-center"
|
||||
onMouseEnter={() => setOpenSlug(cat.slug)}
|
||||
ref={(el) => {
|
||||
navItemRefs.current[cat.slug] = el;
|
||||
}}
|
||||
style={{ minWidth: idx === categories.length - 1 ? 120 : undefined }}
|
||||
>
|
||||
<Link
|
||||
href={`/boards?category=${cat.slug}`}
|
||||
className="px-2 py-2 text-sm font-medium text-neutral-700 transition-colors duration-200 hover:text-neutral-900"
|
||||
href={cat.boards?.[0]?.id ? `/boards/${cat.boards[0].id}` : `/boards?category=${cat.slug}`}
|
||||
className={`px-2 py-2 text-sm font-medium transition-colors duration-200 hover:text-neutral-900 whitespace-nowrap ${
|
||||
activeCategorySlug === cat.slug ? "text-neutral-900" : "text-neutral-700"
|
||||
}`}
|
||||
ref={(el) => {
|
||||
navRefs.current[cat.slug] = el;
|
||||
}}
|
||||
@@ -142,7 +196,9 @@ export function AppHeader() {
|
||||
</Link>
|
||||
<span
|
||||
className={`pointer-events-none absolute left-1 right-1 -bottom-0.5 h-0.5 origin-left rounded bg-neutral-900 transition-all duration-200 ${
|
||||
openSlug === cat.slug ? "scale-x-100 opacity-100" : "scale-x-0 opacity-0 group-hover:opacity-100 group-hover:scale-x-100"
|
||||
(megaOpen && openSlug === cat.slug) || activeCategorySlug === cat.slug
|
||||
? "scale-x-100 opacity-100"
|
||||
: "scale-x-0 opacity-0 group-hover:opacity-100 group-hover:scale-x-100"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
@@ -153,8 +209,10 @@ export function AppHeader() {
|
||||
megaOpen ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
style={{ top: headerBottom }}
|
||||
onMouseEnter={() => { cancelClose(); setMegaOpen(true); }}
|
||||
onMouseLeave={() => { scheduleClose(); }}
|
||||
>
|
||||
<div className="px-4 py-4 w-screen">
|
||||
<div className="px-4 py-4 w-full mx-auto overflow-x-hidden">
|
||||
<div ref={panelRef} className="relative">
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
@@ -163,15 +221,18 @@ export function AppHeader() {
|
||||
blockRefs.current[cat.slug] = el;
|
||||
}}
|
||||
className="absolute top-0"
|
||||
style={{ left: (leftPositions[cat.slug] ?? 0) + 0, width: blockWidths[cat.slug] ?? undefined }}
|
||||
style={{ left: (leftPositions[cat.slug] ?? 0), width: blockWidths[cat.slug] ?? undefined }}
|
||||
>
|
||||
{/* <div className="mb-2 font-semibold text-neutral-800">{cat.name}</div> */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="mx-auto w-max flex flex-col items-center gap-3">
|
||||
{cat.boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={`/boards/${b.id}`}
|
||||
className="rounded px-2 py-1 text-sm text-neutral-700 transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-900"
|
||||
className={`rounded px-2 py-1 text-sm transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-900 text-center whitespace-nowrap ${
|
||||
activeBoardId === b.id ? "bg-neutral-100 text-neutral-900 font-medium" : "text-neutral-700"
|
||||
}`}
|
||||
aria-current={activeBoardId === b.id ? "page" : undefined}
|
||||
>
|
||||
{b.name}
|
||||
</Link>
|
||||
|
||||
177
src/app/components/CategoryBoardBrowser.tsx
Normal file
177
src/app/components/CategoryBoardBrowser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,145 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null };
|
||||
|
||||
export function HeroBanner() {
|
||||
const [banners, setBanners] = useState<Banner[]>([]);
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [progress, setProgress] = useState(0); // 0..1
|
||||
const rotationMs = 5000;
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
const startedAtRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/banners").then((r) => r.json()).then((d) => setBanners(d.banners ?? []));
|
||||
}, []);
|
||||
|
||||
const numSlides = banners.length;
|
||||
const canAutoPlay = numSlides > 1 && !isHovered;
|
||||
|
||||
const startTicker = useCallback(() => {
|
||||
if (!canAutoPlay) return;
|
||||
startedAtRef.current = performance.now();
|
||||
const tick = () => {
|
||||
const now = performance.now();
|
||||
const elapsed = now - startedAtRef.current;
|
||||
const nextProgress = Math.min(1, elapsed / rotationMs);
|
||||
setProgress(nextProgress);
|
||||
if (nextProgress >= 1) {
|
||||
setActiveIndex((i) => (i + 1) % Math.max(1, numSlides));
|
||||
startedAtRef.current = performance.now();
|
||||
setProgress(0);
|
||||
}
|
||||
rafIdRef.current = window.requestAnimationFrame(tick);
|
||||
};
|
||||
rafIdRef.current = window.requestAnimationFrame(tick);
|
||||
}, [canAutoPlay, numSlides, rotationMs]);
|
||||
|
||||
const stopTicker = useCallback(() => {
|
||||
if (rafIdRef.current) {
|
||||
window.cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if ((banners?.length ?? 0) < 2) return;
|
||||
const t = setInterval(() => setIdx((i) => (i + 1) % banners.length), 3000);
|
||||
return () => clearInterval(t);
|
||||
}, [banners]);
|
||||
const slide = banners[idx];
|
||||
if (!slide) return null;
|
||||
const content = (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<img src={slide.imageUrl} alt={slide.title} style={{ width: 72, height: 48, objectFit: "cover", borderRadius: 6 }} />
|
||||
<h1 style={{ margin: 0 }}>{slide.title}</h1>
|
||||
</div>
|
||||
);
|
||||
stopTicker();
|
||||
setProgress(0);
|
||||
startTicker();
|
||||
return () => stopTicker();
|
||||
}, [startTicker, stopTicker, activeIndex, isHovered, numSlides]);
|
||||
|
||||
const goTo = useCallback((index: number) => {
|
||||
if (numSlides === 0) return;
|
||||
const next = (index + numSlides) % numSlides;
|
||||
setActiveIndex(next);
|
||||
setProgress(0);
|
||||
startedAtRef.current = performance.now();
|
||||
}, [numSlides]);
|
||||
|
||||
const goPrev = useCallback(() => goTo(activeIndex - 1), [activeIndex, goTo]);
|
||||
const goNext = useCallback(() => goTo(activeIndex + 1), [activeIndex, goTo]);
|
||||
|
||||
const translatePercent = useMemo(() => (numSlides > 0 ? -(activeIndex * 100) : 0), [activeIndex, numSlides]);
|
||||
|
||||
if (numSlides === 0) return null;
|
||||
|
||||
return (
|
||||
<section style={{ padding: 16, background: "#f5f5f5", borderRadius: 12, marginBottom: 16 }}>
|
||||
{slide.linkUrl ? <a href={slide.linkUrl}>{content}</a> : content}
|
||||
<section
|
||||
className="relative w-full overflow-hidden rounded-xl bg-neutral-900 text-white"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
aria-roledescription="carousel"
|
||||
>
|
||||
<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}%` }}
|
||||
>
|
||||
{banners.map((banner) => (
|
||||
<div key={banner.id} className="relative h-full w-full shrink-0">
|
||||
{banner.linkUrl ? (
|
||||
<a href={banner.linkUrl} className="block h-full w-full focus:outline-none focus:ring-2 focus:ring-white/60" aria-label={banner.title}>
|
||||
<Image src={banner.imageUrl} alt={banner.title} fill priority className="object-cover" />
|
||||
</a>
|
||||
) : (
|
||||
<Image src={banner.imageUrl} alt={banner.title} fill priority className="object-cover" />
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-black/10 to-transparent" />
|
||||
<div className="absolute bottom-3 left-4 right-4 md:bottom-5 md:left-6 md:right-6">
|
||||
<h2 className="line-clamp-2 text-lg font-semibold md:text-2xl">{banner.title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
{numSlides > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous slide"
|
||||
onClick={goPrev}
|
||||
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white backdrop-blur transition hover:bg-black/60 focus:outline-none focus:ring-2 focus:ring-white/60"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5"><path d="M15.75 19.5 8.25 12l7.5-7.5" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next slide"
|
||||
onClick={goNext}
|
||||
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white backdrop-blur transition hover:bg-black/60 focus:outline-none focus:ring-2 focus:ring-white/60"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5"><path d="M8.25 4.5 15.75 12l-7.5 7.5" /></svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar
|
||||
{numSlides > 1 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 h-1 bg-white/20">
|
||||
<div className="h-full bg-white transition-[width] duration-100" style={{ width: `${progress * 100}%` }} />
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{/* Pagination */}
|
||||
{numSlides > 1 && (
|
||||
<div className="absolute bottom-3 right-3 z-10 flex gap-2">
|
||||
{banners.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
aria-current={activeIndex === i ? "true" : undefined}
|
||||
onClick={() => goTo(i)}
|
||||
className={`h-2 w-2 rounded-full transition ${activeIndex === i ? "bg-white" : "bg-white/40 hover:bg-white/70"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
153
src/app/components/HorizontalCardScroller.tsx
Normal file
153
src/app/components/HorizontalCardScroller.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,73 @@ 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="px-4 py-2 border-b border-neutral-200 mb-2 flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-500 mr-1">정렬</span>
|
||||
<a
|
||||
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||
sort === "recent" ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
|
||||
}`}
|
||||
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={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||
sort === "popular" ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
|
||||
}`}
|
||||
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>
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -22,18 +22,18 @@ export default function RootLayout({
|
||||
<QueryProvider>
|
||||
<ToastProvider>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div className="sticky top-0 z-50 border-b bg-white/80 backdrop-blur">
|
||||
<div className="mx-auto">
|
||||
<div className="sticky top-0 z-50 bg-white/80 backdrop-blur">
|
||||
<div className="mx-auto w-full">
|
||||
<AppHeader />
|
||||
</div>
|
||||
</div>
|
||||
<main className="flex-1">
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
<main className="flex-1 bg-[#F2F2F2]">
|
||||
<div className="max-w-[1920px] mx-auto px-4 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<div className="border-t">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="">
|
||||
<div className="max-w-[1920px] mx-auto px-4">
|
||||
<AppFooter />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||
import HorizontalCardScroller from "@/app/components/HorizontalCardScroller";
|
||||
import CategoryBoardBrowser from "@/app/components/CategoryBoardBrowser";
|
||||
|
||||
export default function Home({ searchParams }: { searchParams?: { sort?: "recent" | "popular" } }) {
|
||||
const sort = searchParams?.sort ?? "recent";
|
||||
export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: "recent" | "popular" } | undefined> }) {
|
||||
const sp = await searchParams;
|
||||
const sort = sp?.sort ?? "recent";
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 히어로 섹션: 상단 대형 비주얼 영역 */}
|
||||
@@ -9,32 +12,69 @@ export default function Home({ searchParams }: { searchParams?: { sort?: "recent
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useParams, useRouter } from "next/navigation";
|
||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
import { UploadButton } from "@/app/components/UploadButton";
|
||||
import { Editor } from "@/app/components/Editor";
|
||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||
|
||||
export default function EditPostPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
@@ -39,14 +40,48 @@ export default function EditPostPage() {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
if (!form) return <div>불러오는 중...</div>;
|
||||
if (!form) return <div className="text-sm text-neutral-600">불러오는 중...</div>;
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<h1>글 수정</h1>
|
||||
<input placeholder="제목" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||
<div className="space-y-6">
|
||||
{/* 상단 배너 */}
|
||||
<section>
|
||||
<HeroBanner />
|
||||
</section>
|
||||
|
||||
{/* 수정 카드 */}
|
||||
<section className="rounded-xl overflow-hidden bg-white">
|
||||
<header className="px-4 py-3 border-b border-neutral-200">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-neutral-900">글 수정</h1>
|
||||
</header>
|
||||
<div className="p-4 md:p-6 space-y-3">
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-neutral-300 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-neutral-400"
|
||||
placeholder="제목"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
/>
|
||||
<Editor value={form.content} onChange={(v) => setForm({ ...form, content: v })} placeholder="내용을 입력하세요" />
|
||||
<div className="pt-1">
|
||||
<UploadButton onUploaded={(url) => setForm((f) => (!f ? f : { ...f, content: `${f.content}\n` }))} />
|
||||
<button disabled={loading} onClick={submit}>{loading ? "저장 중..." : "저장"}</button>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="px-4 py-3 border-t border-neutral-200 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={submit}
|
||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800 disabled:opacity-60"
|
||||
>
|
||||
{loading ? "저장 중..." : "저장"}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||
|
||||
// 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다.
|
||||
export default async function PostDetail({ params }: { params: any }) {
|
||||
@@ -12,18 +13,34 @@ export default async function PostDetail({ params }: { params: any }) {
|
||||
const res = await fetch(new URL(`/api/posts/${id}`, base).toString(), { cache: "no-store" });
|
||||
if (!res.ok) return notFound();
|
||||
const { post } = await res.json();
|
||||
const createdAt = post?.createdAt ? new Date(post.createdAt) : null;
|
||||
return (
|
||||
<div>
|
||||
<h1>{post.title}</h1>
|
||||
<div style={{ display: "flex", gap: 8, margin: "8px 0" }}>
|
||||
<div className="space-y-6">
|
||||
{/* 상단 배너 */}
|
||||
<section>
|
||||
<HeroBanner />
|
||||
</section>
|
||||
|
||||
{/* 본문 카드 */}
|
||||
<section className="rounded-xl overflow-hidden bg-white">
|
||||
<header className="px-4 py-3 border-b border-neutral-200">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-neutral-900 break-words">{post.title}</h1>
|
||||
{createdAt && (
|
||||
<p className="mt-1 text-xs text-neutral-500">{createdAt.toLocaleString()}</p>
|
||||
)}
|
||||
</header>
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="prose max-w-none whitespace-pre-wrap break-words">{post.content}</div>
|
||||
</div>
|
||||
<footer className="px-4 py-3 border-t border-neutral-200 flex gap-2">
|
||||
<form action={`/api/posts/${post.id}/recommend`} method="post">
|
||||
<button type="submit">추천</button>
|
||||
<button type="submit" className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800">추천</button>
|
||||
</form>
|
||||
<form action={`/api/posts/${post.id}/report`} method="post">
|
||||
<button type="submit">신고</button>
|
||||
<button type="submit" className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100">신고</button>
|
||||
</form>
|
||||
</div>
|
||||
<p style={{ whiteSpace: "pre-wrap" }}>{post.content}</p>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
import { UploadButton } from "@/app/components/UploadButton";
|
||||
import { Editor } from "@/app/components/Editor";
|
||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||
|
||||
export default function NewPostPage() {
|
||||
const router = useRouter();
|
||||
@@ -49,17 +50,56 @@ export default function NewPostPage() {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<h1>새 글</h1>
|
||||
<input placeholder="boardId" value={form.boardId} onChange={(e) => setForm({ ...form, boardId: e.target.value })} />
|
||||
<input placeholder="제목" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||
<div className="space-y-6">
|
||||
{/* 상단 배너 */}
|
||||
<section>
|
||||
<HeroBanner />
|
||||
</section>
|
||||
|
||||
{/* 작성 카드 */}
|
||||
<section className="rounded-xl overflow-hidden bg-white">
|
||||
<header className="px-4 py-3 border-b border-neutral-200">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-neutral-900">새 글</h1>
|
||||
</header>
|
||||
<div className="p-4 md:p-6 space-y-3">
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-neutral-300 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-neutral-400"
|
||||
placeholder="boardId"
|
||||
value={form.boardId}
|
||||
onChange={(e) => setForm({ ...form, boardId: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-neutral-300 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-neutral-400"
|
||||
placeholder="제목"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
/>
|
||||
<Editor value={form.content} onChange={(v) => setForm({ ...form, content: v })} placeholder="내용을 입력하세요" />
|
||||
<div className="pt-1">
|
||||
<UploadButton
|
||||
multiple
|
||||
onUploaded={(url) => setForm((f) => ({ ...f, content: `${f.content}\n` }))}
|
||||
{...(boardSlug ? require("@/lib/photoPresets").getPhotoPresetBySlug(boardSlug) : {})}
|
||||
/>
|
||||
<button disabled={loading} onClick={submit}>{loading ? "저장 중..." : "등록"}</button>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="px-4 py-3 border-t border-neutral-200 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={submit}
|
||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800 disabled:opacity-60"
|
||||
>
|
||||
{loading ? "저장 중..." : "등록"}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user