디자인디테일

This commit is contained in:
koreacomp5
2025-11-01 23:16:22 +09:00
parent f84111b9cc
commit 27cf98eef2
20 changed files with 735 additions and 384 deletions

17
.cursor/.prompt/1101.md Normal file
View File

@@ -0,0 +1,17 @@
배너 디테일
카드 디테일
메인 디테일 프리뷰, 글, 스페셜_랭크
기본 리스트 , 글이 없습니다.
글쓰기
글뷰, 댓글 +리스트
스페셜_랭크
스페셜_출석
스페셜_제휴업체
스페셜_제휴업체지도
로그인관련
회원쪽지
링크로들어오면 보이고 거기서 페이지이동하면안보이게

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

@@ -4,12 +4,11 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
const navItems = [ const navItems = [
{ href: "/admin/menus", label: "메뉴 관리" }, { href: "/admin/mainpage-settings", label: "메인페이지 설정" },
{ href: "/admin/boards", label: "게시판" }, { href: "/admin/boards", label: "게시판" },
{ href: "/admin/banners", label: "배너" },
{ href: "/admin/users", label: "사용자" }, { href: "/admin/users", label: "사용자" },
{ href: "/admin/logs", label: "로그" }, { href: "/admin/logs", label: "로그" },
{ href: "/admin/banners", label: "배너" },
{ href: "/admin/mainpage-settings", label: "메인페이지 설정" },
]; ];
export default function AdminSidebar() { export default function AdminSidebar() {

View File

@@ -87,6 +87,21 @@ export default function AdminBoardsPage() {
mutateBoards(); mutateBoards();
} }
// 버튼으로 카테고리 순서 이동 (↑/↓)
function moveCategory(catId: string, delta: number) {
const baseIds = (catOrder.length ? catOrder : categories.map((c: any) => c.id));
const idx = baseIds.indexOf(catId);
if (idx === -1) return;
const to = idx + delta;
if (to < 0 || to >= baseIds.length) return;
const nextIds = [...baseIds];
const [moved] = nextIds.splice(idx, 1);
nextIds.splice(to, 0, moved);
setCatOrder(nextIds);
const nextCats = nextIds.map((id) => categories.find((c: any) => c.id === id)).filter(Boolean) as any[];
reorderCategories(nextCats);
}
// DnD: 카테고리 순서 변경 (저장 시 반영) // DnD: 카테고리 순서 변경 (저장 시 반영)
function reorderCategories(next: any[]) { function reorderCategories(next: any[]) {
setDirtyCats((prev) => { setDirtyCats((prev) => {
@@ -275,56 +290,9 @@ export default function AdminBoardsPage() {
onClick={createCategory} onClick={createCategory}
> </button> > </button>
</div> </div>
<ul <ul className="divide-y-2 divide-neutral-100">
className="divide-y-2 divide-neutral-100"
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggingCatIndex === null) return;
const ids = (catOrder.length ? catOrder : categories.map((c: any) => c.id));
// 현재 마우스 Y에 해당하는 행 인덱스 계산
let overIdx = -1;
for (let i = 0; i < ids.length; i++) {
const el = catRefs.current[ids[i]];
if (!el) continue;
const rect = el.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (e.clientY < mid) { overIdx = i; break; }
}
if (overIdx === -1) overIdx = ids.length - 1;
if (overIdx === draggingCatIndex) return;
setCatOrder((order) => {
const base = order.length ? order : categories.map((c: any) => c.id);
const next = [...base];
const [moved] = next.splice(draggingCatIndex, 1);
next.splice(overIdx, 0, moved);
setDraggingCatIndex(overIdx);
const nextCats = next.map((id) => categories.find((c: any) => c.id === id)).filter(Boolean) as any[];
reorderCategories(nextCats);
return next;
});
}}
onDragEnter={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
onDrop={(e) => {
e.preventDefault();
const ids = (catOrder.length ? catOrder : categories.map((c: any) => c.id));
const nextCats = ids.map((id) => categories.find((c: any) => c.id === id)).filter(Boolean) as any[];
reorderCategories(nextCats);
setDraggingCatIndex(null);
}}
>
{groups.map((g, idx) => ( {groups.map((g, idx) => (
<li <li key={g.id} className="select-none">
key={g.id}
className="select-none"
ref={(el) => { catRefs.current[g.id] = el; }}
onDrop={(e) => {
e.preventDefault();
setDraggingCatIndex(null);
}}
onDragEnd={() => { setDraggingCatIndex(null); }}
>
<div className="px-4 py-3 flex items-center gap-3"> <div className="px-4 py-3 flex items-center gap-3">
<div className="w-8 text-sm text-neutral-500 select-none">{idx + 1}</div> <div className="w-8 text-sm text-neutral-500 select-none">{idx + 1}</div>
{g.id === 'uncat' ? ( {g.id === 'uncat' ? (
@@ -333,9 +301,25 @@ export default function AdminBoardsPage() {
<div className="text-sm font-medium text-neutral-800"> ( )</div> <div className="text-sm font-medium text-neutral-800"> ( )</div>
</div> </div>
) : ( ) : (
<CategoryHeaderContent g={g} onDirty={(payload) => markCatDirty(g.id, { ...payload })} onDragStart={() => { <CategoryHeaderContent g={g} onDirty={(payload) => markCatDirty(g.id, { ...payload })} />
setDraggingCatIndex(idx); )}
}} /> {g.id !== 'uncat' && (
<div className="flex items-center gap-1 ml-2">
<button
type="button"
className="h-7 w-7 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
aria-label="대분류 위로"
disabled={(catOrder.length ? catOrder.indexOf(g.id) : categories.map((c:any)=>c.id).indexOf(g.id)) === 0}
onClick={() => moveCategory(g.id, -1)}
></button>
<button
type="button"
className="h-7 w-7 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
aria-label="대분류 아래로"
disabled={(catOrder.length ? catOrder.indexOf(g.id) : categories.map((c:any)=>c.id).indexOf(g.id)) === ( (catOrder.length ? catOrder.length : categories.length) - 1)}
onClick={() => moveCategory(g.id, 1)}
></button>
</div>
)} )}
<button <button
type="button" type="button"
@@ -374,8 +358,8 @@ export default function AdminBoardsPage() {
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="text-xs text-neutral-500 border-b border-neutral-200"> <thead className="text-xs text-neutral-500 border-b border-neutral-200">
<tr> <tr>
<th className="px-2 py-2 w-8"></th>
<th className="px-2 py-2 w-8 text-center">#</th> <th className="px-2 py-2 w-8 text-center">#</th>
<th className="px-2 py-2 w-16 text-center"></th>
<th className="px-3 py-2 text-left"></th> <th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left">slug</th> <th className="px-3 py-2 text-left">slug</th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
@@ -392,50 +376,26 @@ export default function AdminBoardsPage() {
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
</tr> </tr>
</thead> </thead>
<tbody <tbody className="divide-y divide-neutral-100">
className="divide-y divide-neutral-100"
onDragOver={(e) => {
e.preventDefault();
if (!draggingBoard || draggingBoard.catId !== g.id) return;
const ids = (boardOrderByCat[g.id]?.length ? boardOrderByCat[g.id] : g.items.map((x: any) => x.id));
let overIdx = -1;
for (let i = 0; i < ids.length; i++) {
const el = boardRefs.current[ids[i]];
if (!el) continue;
const rect = el.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (e.clientY < mid) { overIdx = i; break; }
}
if (overIdx === -1) overIdx = ids.length - 1;
if (overIdx === draggingBoard.index) return;
setBoardOrderByCat((prev) => {
const base = (prev[g.id]?.length ? prev[g.id] : ids);
const next = [...base];
const [moved] = next.splice(draggingBoard.index, 1);
next.splice(overIdx, 0, moved);
setDraggingBoard({ catId: g.id, index: overIdx });
const nextItems = next.map((id) => g.items.find((x: any) => x.id === id)).filter(Boolean) as any[];
reorderBoards(g.id, nextItems);
return { ...prev, [g.id]: next };
});
}}
onDrop={(e) => {
e.preventDefault();
const ids = (boardOrderByCat[g.id]?.length ? boardOrderByCat[g.id] : g.items.map((x: any) => x.id));
const nextItems = ids.map((id) => g.items.find((x: any) => x.id === id)).filter(Boolean) as any[];
reorderBoards(g.id, nextItems);
setDraggingBoard(null);
}}
>
{g.items.map((b, i) => ( {g.items.map((b, i) => (
<DraggableRow <DraggableRow
key={b.id} key={b.id}
catId={g.id} catId={g.id}
boardId={b.id} boardId={b.id}
index={i} index={i}
setRef={(el) => { boardRefs.current[b.id] = el; }} totalCount={g.items.length}
onStart={() => setDraggingBoard({ catId: g.id, index: i })} onMoveIndex={(delta) => {
onEnd={() => setDraggingBoard(null)} const ids = (boardOrderByCat[g.id]?.length ? boardOrderByCat[g.id] : g.items.map((x: any) => x.id));
const from = i;
const to = from + delta;
if (to < 0 || to >= ids.length) return;
const next = [...ids];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
setBoardOrderByCat((prev) => ({ ...prev, [g.id]: next }));
const nextItems = next.map((id) => g.items.find((x: any) => x.id === id)).filter(Boolean) as any[];
reorderBoards(g.id, nextItems);
}}
> >
<BoardRowCells <BoardRowCells
b={b} b={b}
@@ -568,42 +528,38 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
); );
} }
function DraggableRow({ catId, boardId, index, children, setRef, onStart, onEnd }: { catId: string; boardId: string; index: number; children: React.ReactNode; setRef: (el: HTMLTableRowElement | null) => void; onStart: () => void; onEnd: () => void }) { function DraggableRow({ catId, boardId, index, totalCount, children, onMoveIndex }: { catId: string; boardId: string; index: number; totalCount: number; children: React.ReactNode; onMoveIndex: (delta: number) => void }) {
return ( return (
<tr ref={setRef} className="align-middle select-none"> <tr className="align-middle select-none">
<td
className="px-2 py-2 w-8 text-xl text-neutral-500 cursor-grab"
title="드래그하여 순서 변경"
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", String(index));
e.dataTransfer.effectAllowed = 'move';
const img = document.createElement('canvas'); img.width = 1; img.height = 1; e.dataTransfer.setDragImage(img, 0, 0);
onStart();
}}
onDragEnd={() => { onEnd(); }}
></td>
<td className="px-2 py-2 w-8 text-center text-neutral-500">{index + 1}</td> <td className="px-2 py-2 w-8 text-center text-neutral-500">{index + 1}</td>
<td className="px-2 py-2 w-16 text-center">
<div className="inline-flex items-center gap-1">
<button
type="button"
className="h-6 w-6 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
aria-label="위로"
onClick={() => onMoveIndex(-1)}
disabled={index === 0}
></button>
<button
type="button"
className="h-6 w-6 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
aria-label="아래로"
onClick={() => onMoveIndex(1)}
disabled={index === (totalCount - 1)}
></button>
</div>
</td>
{children} {children}
</tr> </tr>
); );
} }
function CategoryHeaderContent({ g, onDirty, onDragStart }: { g: any; onDirty: (payload: any) => void; onDragStart?: () => void }) { function CategoryHeaderContent({ g, onDirty }: { g: any; onDirty: (payload: any) => void }) {
const [edit, setEdit] = useState({ name: g.name, slug: g.slug }); const [edit, setEdit] = useState({ name: g.name, slug: g.slug });
return ( return (
<> <>
<div <div className="w-10" />
className="w-10 text-xl text-neutral-500 cursor-grab select-none"
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", "category-drag");
e.dataTransfer.effectAllowed = 'move';
const img = document.createElement('canvas'); img.width = 1; img.height = 1; e.dataTransfer.setDragImage(img, 0, 0);
onDragStart && onDragStart();
}}
title="드래그하여 순서 변경"
></div>
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} /> <input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} />
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} /> <input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
<div className="flex-1" /> <div className="flex-1" />

View File

@@ -24,6 +24,17 @@ export default function MainPageSettingsPage() {
visibleBoardIds: settings.visibleBoardIds ?? [], visibleBoardIds: settings.visibleBoardIds ?? [],
}); });
function moveVisibleBoard(index: number, delta: number) {
setDraft((d) => {
const arr = [...(d.visibleBoardIds as string[])];
const nextIndex = index + delta;
if (nextIndex < 0 || nextIndex >= arr.length) return d;
const [item] = arr.splice(index, 1);
arr.splice(nextIndex, 0, item);
return { ...d, visibleBoardIds: arr };
});
}
// settings가 로드되면 draft 동기화 // settings가 로드되면 draft 동기화
useEffect(() => { useEffect(() => {
if (settingsResp?.settings) { if (settingsResp?.settings) {
@@ -110,7 +121,7 @@ export default function MainPageSettingsPage() {
{draft.visibleBoardIds.length === 0 ? ( {draft.visibleBoardIds.length === 0 ? (
<p className="text-sm text-neutral-400 py-4 text-center"> .</p> <p className="text-sm text-neutral-400 py-4 text-center"> .</p>
) : ( ) : (
draft.visibleBoardIds.map((boardId: string) => { draft.visibleBoardIds.map((boardId: string, idx: number) => {
const board = boards.find((b: any) => b.id === boardId); const board = boards.find((b: any) => b.id === boardId);
if (!board) return null; if (!board) return null;
const category = categories.find((c: any) => c.id === board.categoryId); const category = categories.find((c: any) => c.id === board.categoryId);
@@ -122,15 +133,37 @@ export default function MainPageSettingsPage() {
<span className="text-xs text-neutral-500">({category.name})</span> <span className="text-xs text-neutral-500">({category.name})</span>
)} )}
</div> </div>
<button <div className="flex items-center gap-2">
type="button" <div className="flex items-center gap-1">
onClick={() => { <button
setDraft({ ...draft, visibleBoardIds: draft.visibleBoardIds.filter((id: string) => id !== boardId) }); type="button"
}} onClick={() => moveVisibleBoard(idx, -1)}
className="text-xs text-red-600 hover:text-red-800" disabled={idx === 0}
> className="h-7 px-2 rounded-md border border-neutral-300 text-xs disabled:opacity-40"
aria-label="위로"
</button> >
</button>
<button
type="button"
onClick={() => moveVisibleBoard(idx, 1)}
disabled={idx === (draft.visibleBoardIds.length - 1)}
className="h-7 px-2 rounded-md border border-neutral-300 text-xs disabled:opacity-40"
aria-label="아래로"
>
</button>
</div>
<button
type="button"
onClick={() => {
setDraft({ ...draft, visibleBoardIds: draft.visibleBoardIds.filter((id: string) => id !== boardId) });
}}
className="h-7 px-3 rounded-md border border-red-200 text-xs text-red-600 hover:bg-red-50"
>
</button>
</div>
</div> </div>
); );
}) })

View File

@@ -1,82 +0,0 @@
"use client";
import { useState } from "react";
type MenuItem = { id: string; label: string; path: string; visible: boolean; order: number };
const initialMenus: MenuItem[] = [
{ id: "1", label: "홈", path: "/", visible: true, order: 1 },
{ id: "2", label: "게시판", path: "/boards", visible: true, order: 2 },
{ id: "3", label: "쿠폰", path: "/coupons", visible: false, order: 3 },
];
export default function AdminMenusPage() {
const [menus, setMenus] = useState<MenuItem[]>(initialMenus);
const [form, setForm] = useState<{ label: string; path: string; visible: boolean }>({ label: "", path: "", visible: true });
function addMenu() {
if (!form.label.trim() || !form.path.trim()) return;
const next: MenuItem = { id: crypto.randomUUID(), label: form.label, path: form.path, visible: form.visible, order: menus.length + 1 };
setMenus((m) => [...m, next]);
setForm({ label: "", path: "", visible: true });
}
function removeMenu(id: string) {
setMenus((m) => m.filter((x) => x.id !== id));
}
function toggleVisible(id: string) {
setMenus((m) => m.map((x) => (x.id === id ? { ...x, visible: !x.visible } : x)));
}
return (
<div className="space-y-6">
<header className="flex items-center justify-between">
<h1 className="text-xl md:text-2xl font-bold text-neutral-900"> </h1>
</header>
{/* 추가 폼 */}
<section className="rounded-xl bg-white border border-neutral-200 overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 text-sm font-medium"> </div>
<div className="p-4 grid grid-cols-1 md:grid-cols-[240px_1fr_auto] gap-3 items-center">
<input className="h-10 rounded-md border border-neutral-300 px-3 text-sm" placeholder="이름" value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} />
<input className="h-10 rounded-md border border-neutral-300 px-3 text-sm" placeholder="경로 (/path)" value={form.path} onChange={(e) => setForm({ ...form, path: e.target.value })} />
<div className="flex items-center gap-3">
<label className="flex items-center gap-1 text-sm text-neutral-700">
<input type="checkbox" checked={form.visible} onChange={(e) => setForm({ ...form, visible: e.target.checked })} />
</label>
<button className="h-10 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800" onClick={addMenu}></button>
</div>
</div>
</section>
{/* 목록 */}
<section className="rounded-xl bg-white border border-neutral-200 overflow-hidden">
<div className="px-4 py-2 text-xs text-neutral-500 border-b border-neutral-200 grid grid-cols-[60px_1fr_1fr_120px_120px]">
<div>#</div>
<div></div>
<div></div>
<div className="text-center"></div>
<div className="text-right"></div>
</div>
<ul className="divide-y divide-neutral-100">
{menus.sort((a, b) => a.order - b.order).map((m, idx) => (
<li key={m.id} className="px-4 py-3 grid grid-cols-[60px_1fr_1fr_120px_120px] items-center">
<div className="text-sm text-neutral-500">{idx + 1}</div>
<div className="truncate text-sm">{m.label}</div>
<div className="truncate text-sm text-neutral-700">{m.path}</div>
<div className="text-center">
<button onClick={() => toggleVisible(m.id)} className={`h-7 px-3 rounded-full text-xs border ${m.visible ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"}`}>{m.visible ? "표시" : "숨김"}</button>
</div>
<div className="text-right">
<button onClick={() => removeMenu(m.id)} className="h-7 px-3 rounded-md border border-neutral-300 text-xs hover:bg-neutral-100"></button>
</div>
</li>
))}
</ul>
</section>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { PostList } from "@/app/components/PostList"; import { PostList } from "@/app/components/PostList";
import { HeroBanner } from "@/app/components/HeroBanner"; import { HeroBanner } from "@/app/components/HeroBanner";
import { BoardToolbar } from "@/app/components/BoardToolbar";
import { headers } from "next/headers"; import { headers } from "next/headers";
// Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다. // Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다.
@@ -20,44 +21,24 @@ export default async function BoardDetail({ params, searchParams }: { params: an
const categoryName = board?.category?.name ?? ""; const categoryName = board?.category?.name ?? "";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 상단 배너 (홈과 동일) */} {/* 상단 배너 (서브카테고리 표시) */}
<section> <section>
<HeroBanner /> <HeroBanner
subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.id}` }))}
activeSubId={id}
/>
</section> </section>
{/* 보드 탭 + 리스트 카드 */} {/* 검색/필터 툴바 + 리스트 */}
<section className="rounded-xl overflow-hidden bg-white"> <section>
{/* 상단 탭 영역 */} <BoardToolbar boardId={id} />
<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"> <div className="p-0">
<PostList boardId={id} sort={sort} /> <PostList
boardId={id}
sort={sort}
variant="board"
newPostHref={`/posts/new?boardId=${id}${board?.slug ? `&boardSlug=${board.slug}` : ""}`}
/>
</div> </div>
</section> </section>
</div> </div>

View File

@@ -5,6 +5,7 @@ import Link from "next/link";
import { SearchBar } from "@/app/components/SearchBar"; import { SearchBar } from "@/app/components/SearchBar";
import React from "react"; import React from "react";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import { SinglePageLogo } from "@/app/components/SinglePageLogo";
export function AppHeader() { export function AppHeader() {
const [categories, setCategories] = React.useState<Array<{ id: string; name: string; slug: string; boards: Array<{ id: string; name: string; slug: string }> }>>([]); const [categories, setCategories] = React.useState<Array<{ id: string; name: string; slug: string; boards: Array<{ id: string; name: string; slug: string }> }>>([]);
@@ -309,7 +310,7 @@ export function AppHeader() {
</span> </span>
</button> </button>
<Link href="/" aria-label="홈" className="shrink-0"> <Link href="/" aria-label="홈" className="shrink-0">
<Image src="/logo.png" alt="logo" width={120} height={28} priority className="w-20 xl:w-[120px] h-auto" /> <SinglePageLogo width={120} height={28} className="w-20 xl:w-[120px] h-auto" />
</Link> </Link>
</div> </div>
<nav className="flex flex-1 items-center gap-4 justify-between"> <nav className="flex flex-1 items-center gap-4 justify-between">

View File

@@ -0,0 +1,75 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import React from "react";
export function BoardToolbar({ boardId }: { boardId: string }) {
const router = useRouter();
const sp = useSearchParams();
const sort = (sp.get("sort") as "recent" | "popular" | null) ?? "recent";
const scope = (sp.get("scope") as "q" | "author" | null) ?? "q"; // q: 제목+내용, author: 작성자
const defaultText = scope === "author" ? sp.get("author") ?? "" : sp.get("q") ?? "";
const period = sp.get("period") ?? "all"; // all | 1d | 1w | 1m
const onChangeSort = (e: React.ChangeEvent<HTMLSelectElement>) => {
const next = new URLSearchParams(sp.toString());
next.set("sort", e.target.value);
router.push(`/boards/${boardId}?${next.toString()}`);
};
const onChangePeriod = (e: React.ChangeEvent<HTMLSelectElement>) => {
const next = new URLSearchParams(sp.toString());
const v = e.target.value;
next.set("period", v);
// 계산된 start 적용
const now = new Date();
if (v === "1d") now.setDate(now.getDate() - 1);
if (v === "1w") now.setDate(now.getDate() - 7);
if (v === "1m") now.setMonth(now.getMonth() - 1);
if (v === "all") next.delete("start"); else next.set("start", now.toISOString());
router.push(`/boards/${boardId}?${next.toString()}`);
};
const onSubmit = (formData: FormData) => {
const next = new URLSearchParams(sp.toString());
const scopeSel = String(formData.get("scope") || "q");
const text = String(formData.get("text") || "");
next.set("scope", scopeSel);
if (scopeSel === "author") {
next.delete("q");
if (text) next.set("author", text); else next.delete("author");
} else {
next.delete("author");
if (text) next.set("q", text); else next.delete("q");
}
router.push(`/boards/${boardId}?${next.toString()}`);
};
return (
<div className="flex items-center justify-between px-0 py-2">
<div className="flex items-center gap-2">
<select aria-label="정렬" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={sort} onChange={onChangeSort}>
<option value="recent"></option>
<option value="popular"></option>
</select>
<select aria-label="기간" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={period} onChange={onChangePeriod}>
<option value="all"></option>
<option value="1d">24</option>
<option value="1w">1</option>
<option value="1m">1</option>
</select>
</div>
<form action={onSubmit} className="flex items-center gap-2">
<select name="scope" aria-label="검색대상" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={scope}>
<option value="q">+</option>
<option value="author"></option>
</select>
<input name="text" defaultValue={defaultText} placeholder="검색어를 입력해 주세요." className="h-8 w-56 md:w-72 px-3 rounded-md border border-neutral-300 text-sm placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-300" />
<button type="submit" className="h-8 px-3 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800"></button>
</form>
</div>
);
}
export default BoardToolbar;

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { useEffect, useRef, useCallback } from "react"; import { useEffect, useRef, useCallback, useMemo } from "react";
import { useToast } from "@/app/components/ui/ToastProvider"; import { useToast } from "@/app/components/ui/ToastProvider";
type Props = { value: string; onChange: (v: string) => void; placeholder?: string }; type Props = { value: string; onChange: (v: string) => void; placeholder?: string; withToolbar?: boolean };
async function readFileAsDataUrl(file: File): Promise<string> { async function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -36,7 +36,7 @@ async function resizeImageToBlob(dataUrl: string, opts: { maxWidth: number; maxH
return await new Promise((resolve) => canvas.toBlob((b) => resolve(b as Blob), "image/webp", opts.quality)); return await new Promise((resolve) => canvas.toBlob((b) => resolve(b as Blob), "image/webp", opts.quality));
} }
export function Editor({ value, onChange, placeholder }: Props) { export function Editor({ value, onChange, placeholder, withToolbar = true }: Props) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const { show } = useToast(); const { show } = useToast();
@@ -99,23 +99,107 @@ export function Editor({ value, onChange, placeholder }: Props) {
[uploadAndInsert] [uploadAndInsert]
); );
const sync = useCallback(() => {
const el = ref.current;
if (el) onChange(el.innerHTML);
}, [onChange]);
const exec = useCallback(
(command: string, value?: string) => {
const el = ref.current;
if (!el) return;
el.focus();
try {
// eslint-disable-next-line deprecation/deprecation
document.execCommand(command, false, value ?? "");
} catch {
/* no-op */
}
sync();
},
[sync]
);
const wrapSelectionWithHtml = useCallback(
(prefix: string, suffix: string) => {
const selection = window.getSelection();
const text = selection?.toString() ?? "";
if (!text) return;
// eslint-disable-next-line deprecation/deprecation
document.execCommand("insertHTML", false, `${prefix}${text}${suffix}`);
sync();
},
[sync]
);
const toolbar = useMemo(() => {
if (!withToolbar) return null;
return (
<div className="flex flex-wrap items-center gap-1">
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("bold")} aria-label="굵게">B</button>
<button type="button" className="px-2 py-1 text-sm italic rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("italic")} aria-label="기울임">I</button>
<button type="button" className="px-2 py-1 text-sm line-through rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("strikeThrough")} aria-label="취소선">S</button>
<button type="button" className="px-2 py-1 text-sm underline rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("underline")} aria-label="밑줄">U</button>
<button
type="button"
className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100"
onClick={() => {
const url = window.prompt("링크 URL 입력 (예: https://example.com)")?.trim();
if (url) exec("createLink", url);
}}
aria-label="링크"
>
link
</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("unlink")} aria-label="링크 제거">unlink</button>
<span className="mx-2 h-4 w-px bg-neutral-300" />
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "H1")} aria-label="H1">H1</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "H2")} aria-label="H2">H2</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "H3")} aria-label="H3">H3</button>
<span className="mx-2 h-4 w-px bg-neutral-300" />
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertOrderedList")} aria-label="번호 목록">1.</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertUnorderedList")} aria-label="글머리 목록"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => wrapSelectionWithHtml("<code>", "</code>")} aria-label="코드">&lt;/&gt;</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "BLOCKQUOTE")} aria-label="인용구"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertHorizontalRule")} aria-label="구분선"></button>
<span className="mx-2 h-4 w-px bg-neutral-300" />
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("justifyLeft")} aria-label="왼쪽 정렬"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("justifyCenter")} aria-label="가운데 정렬"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("justifyRight")} aria-label="오른쪽 정렬"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("justifyFull")} aria-label="양쪽 정렬"></button>
<span className="mx-2 h-4 w-px bg-neutral-300" />
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("indent")} aria-label="들여쓰기"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("outdent")} aria-label="내어쓰기"></button>
<span className="mx-2 h-4 w-px bg-neutral-300" />
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("removeFormat")} aria-label="서식 제거">clear</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("undo")} aria-label="되돌리기"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("redo")} aria-label="다시하기"></button>
</div>
);
}, [exec, withToolbar, wrapSelectionWithHtml]);
return ( return (
<div <div>
ref={ref} {toolbar && (
contentEditable <div className="mb-3">{toolbar}</div>
onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)} )}
onPaste={onPaste} <div
onDrop={onDrop} ref={ref}
onDragOver={(e) => e.preventDefault()} contentEditable
data-placeholder={placeholder} onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
style={{ onPaste={onPaste}
minHeight: 160, onDrop={onDrop}
border: "1px solid #ddd", onDragOver={(e) => e.preventDefault()}
borderRadius: 6, data-placeholder={placeholder}
padding: 12, style={{
}} minHeight: 160,
suppressContentEditableWarning border: "1px solid #ddd",
/> borderRadius: 6,
padding: 12,
}}
suppressContentEditableWarning
/>
</div>
); );
} }

View File

@@ -1,10 +1,12 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { SelectedBanner } from "@/app/components/SelectedBanner";
type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null }; type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null };
type SubItem = { id: string; name: string; href: string };
export function HeroBanner() { export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; activeSubId?: string }) {
const [banners, setBanners] = useState<Banner[]>([]); const [banners, setBanners] = useState<Banner[]>([]);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@@ -65,7 +67,7 @@ export function HeroBanner() {
const translatePercent = useMemo(() => (numSlides > 0 ? -(activeIndex * 100) : 0), [activeIndex, numSlides]); const translatePercent = useMemo(() => (numSlides > 0 ? -(activeIndex * 100) : 0), [activeIndex, numSlides]);
if (numSlides === 0) return null; if (numSlides === 0) return <SelectedBanner height={224} />;
return ( return (
<section <section
@@ -88,7 +90,7 @@ export function HeroBanner() {
) : ( ) : (
<Image src={banner.imageUrl} alt={banner.title} fill priority className="object-cover" /> <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" /> {/* Figma 스타일: 오버레이 제거 */}
<div className="absolute bottom-3 left-4 right-4 md:bottom-5 md:left-6 md:right-6"> <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> <h2 className="line-clamp-2 text-lg font-semibold md:text-2xl">{banner.title}</h2>
</div> </div>
@@ -96,26 +98,44 @@ export function HeroBanner() {
))} ))}
</div> </div>
{/* Controls */} {/* Pagination - Figma 스타일: 활성은 주황 바, 비활성은 회색 점 (배너 위에 오버랩) */}
{numSlides > 1 && ( {numSlides > 1 && (
<> <div className="pointer-events-auto absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-[6px]">
<button {banners.map((_, i) => (
type="button" <button
aria-label="Previous slide" key={i}
onClick={goPrev} aria-label={`Go to slide ${i + 1}`}
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" aria-current={activeIndex === i ? "true" : undefined}
> onClick={() => goTo(i)}
<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> className={
</button> activeIndex === i
<button ? "h-[4px] w-[18px] rounded-full bg-[#F94B37]"
type="button" : "h-[6px] w-[6px] rounded-full bg-[rgba(255,255,255,0.6)] hover:bg-white"
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" ))}
> </div>
<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>
</>
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */}
<div className="mt-2 h-[58px] bg-black rounded-xl flex items-center justify-center px-2">
{Array.isArray(subItems) && subItems.length > 0 && (
<div className="flex items-center gap-[8px] overflow-x-auto no-scrollbar">
{subItems.map((s) => (
<a
key={s.id}
href={s.href}
className={
s.id === activeSubId
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:text-white border border-white/30 text-[12px] leading-[28px] whitespace-nowrap"
}
>
{s.name}
</a>
))}
</div>
)} )}
</div> </div>
@@ -126,20 +146,7 @@ export function HeroBanner() {
</div> </div>
)} */} )} */}
{/* Pagination */}
{numSlides > 1 && (
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 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> </section>
); );
} }

View File

@@ -1,5 +1,11 @@
"use client"; "use client";
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
import useSWR from "swr";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import ViewsIcon from "@/app/svgs/ViewsIcon";
import LikeIcon from "@/app/svgs/LikeIcon";
import CommentIcon from "@/app/svgs/CommentIcon";
type Item = { type Item = {
id: string; id: string;
@@ -22,8 +28,11 @@ type Resp = {
const fetcher = (url: string) => fetch(url).then((r) => r.json()); const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function PostList({ boardId, sort = "recent", q, tag, author, start, end }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; start?: string; end?: string }) { export function PostList({ boardId, sort = "recent", q, tag, author, start, end, variant = "default", newPostHref }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string }) {
const pageSize = 10; const pageSize = 10;
const router = useRouter();
const sp = useSearchParams();
const getKey = (index: number, prev: Resp | null) => { const getKey = (index: number, prev: Resp | null) => {
if (prev && prev.items.length === 0) return null; if (prev && prev.items.length === 0) return null;
const page = index + 1; const page = index + 1;
@@ -36,46 +45,89 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end
if (end) sp.set("end", end); if (end) sp.set("end", end);
return `/api/posts?${sp.toString()}`; return `/api/posts?${sp.toString()}`;
}; };
const { data, size, setSize, isLoading } = useSWRInfinite<Resp>(getKey, fetcher); // default(무한 스크롤 형태)
const items = data?.flatMap((d) => d.items) ?? []; const { data, size, setSize, isLoading } = useSWRInfinite<Resp>(getKey, fetcher, { revalidateFirstPage: false });
const itemsInfinite = data?.flatMap((d) => d.items) ?? [];
const canLoadMore = (data?.at(-1)?.items.length ?? 0) === pageSize; const canLoadMore = (data?.at(-1)?.items.length ?? 0) === pageSize;
const isEmptyInfinite = !isLoading && itemsInfinite.length === 0;
// board 변형: 번호 페이지네이션
const initialPage = useMemo(() => Math.max(1, parseInt(sp.get("page") || "1", 10)), [sp]);
const [page, setPage] = useState(initialPage);
useEffect(() => { setPage(initialPage); }, [initialPage]);
const singleKey = useMemo(() => {
if (variant !== "board") return null;
const usp = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sort });
if (boardId) usp.set("boardId", boardId);
if (q) usp.set("q", q);
if (tag) usp.set("tag", tag);
if (author) usp.set("author", author);
if (start) usp.set("start", start);
if (end) usp.set("end", end);
return `/api/posts?${usp.toString()}`;
}, [variant, page, pageSize, sort, boardId, q, tag, author, start, end]);
const { data: singlePageResp, isLoading: isLoadingSingle } = useSWR<Resp>(singleKey, fetcher);
const itemsSingle = singlePageResp?.items ?? [];
const totalSingle = singlePageResp?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(totalSingle / pageSize));
const isEmptySingle = !isLoadingSingle && itemsSingle.length === 0;
const items = variant === "board" ? itemsSingle : itemsInfinite;
const isEmpty = variant === "board" ? isEmptySingle : isEmptyInfinite;
const initials = (name?: string | null) => (name ? name.trim().slice(0, 1) : "익");
return ( return (
<div className="w-full"> <div className="w-full">
{/* 정렬 스위치 */} {/* 정렬 스위치 (board 변형에서는 상단 툴바를 숨김) */}
<div className="px-4 py-2 border-b border-neutral-200 mb-2 flex items-center gap-2"> {variant !== "board" && (
<span className="text-xs text-neutral-500 mr-1"></span> <div className="px-4 py-2 border-b border-neutral-200 mb-2 flex items-center gap-2">
<a <span className="text-xs text-neutral-500 mr-1"></span>
className={`text-xs px-3 py-1 rounded-full border transition-colors ${ <a
sort === "recent" ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100" 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(); })()}`} }`}
> 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(); })()}`}
>
</a>
<a </a>
className={`text-xs px-3 py-1 rounded-full border transition-colors ${ <a
sort === "popular" ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100" 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(); })()}`} }`}
> 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(); })()}`}
>
</a>
</div> </a>
</div>
)}
{/* 리스트 테이블 헤더 */} {/* 리스트 테이블 헤더 (board 변형에서는 숨김) */}
<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"> {variant !== "board" && (
<div></div> <div className="hidden md:grid grid-cols-[20px_1fr_120px_120px_80px] items-center px-4 py-2 text-[12px] text-[#8c8c8c] bg-[#f6f4f4] border-b border-[#e6e6e6] rounded-t-xl">
<div className="w-28 text-center"></div> <div />
<div className="w-24 text-center"></div> <div></div>
<div className="w-24 text-right"></div> <div className="text-center"></div>
</div> <div className="text-center"></div>
<div className="text-right"></div>
</div>
)}
{/* 빈 상태 */}
{isEmpty && (
<div className="h-[400px] flex items-center justify-center border-t border-b border-[#8c8c8c]">
<p className="text-[20px] leading-[20px] text-[#161616]"> .</p>
</div>
)}
{/* 아이템들 */} {/* 아이템들 */}
<ul className="divide-y divide-neutral-100"> <ul className="divide-y divide-[#ececec]">
{items.map((p) => ( {items.map((p) => (
<li key={p.id} className="px-4 py-3 hover:bg-neutral-50 transition-colors"> <li key={p.id} className={`px-4 ${variant === "board" ? "py-2.5" : "py-3 md: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="grid grid-cols-1 md:grid-cols-[20px_1fr_120px_120px_80px] items-center gap-2">
{/* bullet/공지 아이콘 자리 */}
<div className="hidden md:flex items-center justify-center text-[#f94b37]">{p.isPinned ? "★" : "•"}</div>
<div className="min-w-0"> <div className="min-w-0">
<a href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900"> <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.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>}
@@ -89,28 +141,109 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end
</div> </div>
)} )}
</div> </div>
<div className="md:w-28 text-xs text-neutral-600 text-center">{p.author?.nickname ?? "익명"}</div> <div className="md:w-[120px] text-xs text-neutral-600 text-center hidden md:flex items-center justify-center gap-2">
<div className="md:w-24 text-[11px] text-neutral-600 text-center flex md:block gap-3 md:gap-0"> <span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-200 text-[11px] text-neutral-700">{initials(p.author?.nickname)}</span>
<span>👍 {p.stat?.recommendCount ?? 0}</span> <span className="truncate max-w-[84px]">{p.author?.nickname ?? "익명"}</span>
<span>👁 {p.stat?.views ?? 0}</span>
<span>💬 {p.stat?.commentsCount ?? 0}</span>
</div> </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 className="md:w-[120px] text-[11px] text-[#8c8c8c] text-center flex items-center justify-center gap-3">
<span className="inline-flex items-center gap-1"><ViewsIcon width={16} height={16} />{p.stat?.views ?? 0}</span>
<span className="inline-flex items-center gap-1"><LikeIcon width={16} height={16} />{p.stat?.recommendCount ?? 0}</span>
<span className="inline-flex items-center gap-1"><CommentIcon width={16} height={16} />{p.stat?.commentsCount ?? 0}</span>
</div>
<div className="md:w-[80px] text-xs text-neutral-500 text-right">{new Date(p.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
</div> </div>
</li> </li>
))} ))}
</ul> </ul>
{/* 페이지 더보기 */} {/* 페이지네이션 */}
<div className="mt-3 flex justify-center"> {!isEmpty && (
<button variant === "board" ? (
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100 disabled:opacity-50" <div className="mt-4 flex items-center justify-between px-4">
disabled={!canLoadMore || isLoading} <div className="flex items-center gap-2">
onClick={() => setSize(size + 1)} {/* Previous */}
> <button
{isLoading ? "로딩 중..." : canLoadMore ? "더 보기" : "끝"} onClick={() => {
</button> const next = Math.max(1, page - 1);
</div> setPage(next);
const nextSp = new URLSearchParams(Array.from(sp.entries()));
nextSp.set("page", String(next));
router.push(`?${nextSp.toString()}`);
}}
disabled={page <= 1}
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50"
>
Previous
</button>
{/* Numbers with ellipsis */}
<div className="flex items-center gap-1">
{(() => {
const nodes: (number | string)[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) nodes.push(i);
} else {
nodes.push(1);
const start = Math.max(2, page - 1);
const end = Math.min(totalPages - 1, page + 1);
if (start > 2) nodes.push("...");
for (let i = start; i <= end; i++) nodes.push(i);
if (end < totalPages - 1) nodes.push("...");
nodes.push(totalPages);
}
return nodes.map((n, idx) =>
typeof n === "number" ? (
<button
key={`p-${n}-${idx}`}
onClick={() => {
setPage(n);
const nextSp = new URLSearchParams(Array.from(sp.entries()));
nextSp.set("page", String(n));
router.push(`?${nextSp.toString()}`);
}}
aria-current={n === page ? "page" : undefined}
className={`h-9 w-9 rounded-md border ${n === page ? "border-neutral-300 text-[#f94b37] font-semibold" : "border-neutral-300 text-neutral-900"}`}
>
{n}
</button>
) : (
<span key={`e-${idx}`} className="h-9 w-9 inline-flex items-center justify-center text-neutral-900"></span>
)
);
})()}
</div>
{/* Next */}
<button
onClick={() => {
const next = Math.min(totalPages, page + 1);
setPage(next);
const nextSp = new URLSearchParams(Array.from(sp.entries()));
nextSp.set("page", String(next));
router.push(`?${nextSp.toString()}`);
}}
disabled={page >= totalPages}
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50"
>
Next
</button>
</div>
{newPostHref && (
<a href={newPostHref} className="shrink-0">
<button className="h-9 px-4 rounded-[10px] bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95"></button>
</a>
)}
</div>
) : (
<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>
)
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,29 @@
"use client";
import React from "react";
type Props = {
height?: number | string; // ex) 224 or '14rem'
className?: string;
};
// Figma 선택 요소(Container_Event)를 그대로 옮긴 플레이스홀더형 배너
export function SelectedBanner({ height = 122, className }: Props) {
return (
<section
className={`relative w-full overflow-hidden rounded-[12px] bg-[#D9D9D9] ${className ?? ""}`}
style={{ height }}
aria-label="banner"
>
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-[6px]" style={{ bottom: 12 }}>
<span className="block h-[4px] w-[18px] rounded-full bg-[#F94B37]" aria-hidden />
{Array.from({ length: 12 }).map((_, i) => (
<span key={i} className="block h-[6px] w-[6px] rounded-full bg-[#B9B9B9]" aria-hidden />
))}
</div>
</section>
);
}
export default SelectedBanner;

View File

@@ -0,0 +1,29 @@
"use client";
import React from "react";
export function SinglePageLogo({ width = 120, height = 28, className }: { width?: number; height?: number; className?: string }) {
// 원본 SVG는 512x512 정사각형입니다. 주어진 width/height 중 높이를 기준으로 정사각형 로고를 맞추고,
// 가로 폭은 전달받은 width 박스 안에서 가운데 정렬합니다.
const squareSize = height; // 로고 자체는 정사각형
return (
<div className={className} style={{ position: "relative", width, height, display: "inline-flex", alignItems: "center", justifyContent: "flex-start" }} aria-label="logo">
<svg width={squareSize} height={squareSize} viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-hidden={false}>
<g clipPath="url(#clip0_9739_16177)">
<path d="M371.99 0H139.94C62.6533 0 0 62.6533 0 139.94V371.99C0 449.277 62.6533 511.93 139.94 511.93H371.99C449.277 511.93 511.93 449.277 511.93 371.99V139.94C511.93 62.6533 449.277 0 371.99 0Z" fill="#5C5C5C"/>
<path d="M141.801 414.32L221.991 512H372.051C437.291 512 492.111 467.35 507.611 406.94C510.471 395.07 512.001 382.54 512.001 369.49V263.21L402.381 136.92L413.091 258.53L397.761 314.72L332.371 336.18L265.551 339.25H187.281L171.951 344.36L154.581 366.84L141.801 414.32Z" fill="#333333"/>
<path d="M247.849 74.16C294.009 71.07 344.459 85.39 381.369 116.03C455.159 177.27 451.759 279.02 379.519 339.81C365.789 351.36 351.499 358.08 337.619 367.97C313.749 384.98 335.579 425.85 319.879 442.83C313.839 449.36 304.699 450.21 298.049 444.33C282.919 430.94 298.229 402.44 283.699 388.06C272.859 377.33 254.969 383.45 250.369 398.22C247.149 408.57 251.369 429.43 251.769 441.22C252.209 454.15 254.029 480.74 238.219 483.24C223.489 485.56 217.279 476.88 216.879 461.71C216.329 441.16 221.509 415.25 219.329 395.63C217.049 375.09 180.939 361.76 175.979 381.71C172.449 395.94 180.599 424.11 157.669 423.6C128.939 422.96 139.289 374.75 137.799 354.07C136.349 333.98 128.279 331.17 117.829 318.14C60.8193 247.04 79.0293 156.62 149.889 107.9C178.259 88.4 214.349 76.4 247.839 74.16H247.849Z" fill="white"/>
<path d="M339.518 242.97H189.838C173.858 242.97 160.898 231.58 160.898 217.54C160.898 203.5 173.848 192.11 189.838 192.11H339.518C355.498 192.11 368.458 203.5 368.458 217.54C368.458 231.58 355.508 242.97 339.518 242.97Z" fill="#333333"/>
</g>
<defs>
<clipPath id="clip0_9739_16177">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>
</div>
);
}
export default SinglePageLogo;

View File

@@ -79,7 +79,8 @@ export function UploadButton({ onUploaded, multiple = false, maxWidth = 1600, ma
const { show } = useToast(); const { show } = useToast();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
async function onChange(e: React.ChangeEvent<HTMLInputElement>) { async function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []); const input = e.currentTarget; // React 이벤트 풀링 대비: 참조 보관
const files = Array.from(input.files ?? []);
if (files.length === 0) return; if (files.length === 0) return;
try { try {
setLoading(true); setLoading(true);
@@ -99,7 +100,8 @@ export function UploadButton({ onUploaded, multiple = false, maxWidth = 1600, ma
show("업로드 실패"); show("업로드 실패");
} finally { } finally {
setLoading(false); setLoading(false);
e.currentTarget.value = ""; // 선택값 초기화(같은 파일 재선택 가능하도록)
if (input) input.value = "";
} }
} }
return <label style={{ display: "inline-block" }}> return <label style={{ display: "inline-block" }}>

15
src/app/icon.svg Normal file
View File

@@ -0,0 +1,15 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_9739_16177)">
<path d="M371.99 0H139.94C62.6533 0 0 62.6533 0 139.94V371.99C0 449.277 62.6533 511.93 139.94 511.93H371.99C449.277 511.93 511.93 449.277 511.93 371.99V139.94C511.93 62.6533 449.277 0 371.99 0Z" fill="#5C5C5C"/>
<path d="M141.801 414.32L221.991 512H372.051C437.291 512 492.111 467.35 507.611 406.94C510.471 395.07 512.001 382.54 512.001 369.49V263.21L402.381 136.92L413.091 258.53L397.761 314.72L332.371 336.18L265.551 339.25H187.281L171.951 344.36L154.581 366.84L141.801 414.32Z" fill="#333333"/>
<path d="M247.849 74.16C294.009 71.07 344.459 85.39 381.369 116.03C455.159 177.27 451.759 279.02 379.519 339.81C365.789 351.36 351.499 358.08 337.619 367.97C313.749 384.98 335.579 425.85 319.879 442.83C313.839 449.36 304.699 450.21 298.049 444.33C282.919 430.94 298.229 402.44 283.699 388.06C272.859 377.33 254.969 383.45 250.369 398.22C247.149 408.57 251.369 429.43 251.769 441.22C252.209 454.15 254.029 480.74 238.219 483.24C223.489 485.56 217.279 476.88 216.879 461.71C216.329 441.16 221.509 415.25 219.329 395.63C217.049 375.09 180.939 361.76 175.979 381.71C172.449 395.94 180.599 424.11 157.669 423.6C128.939 422.96 139.289 374.75 137.799 354.07C136.349 333.98 128.279 331.17 117.829 318.14C60.8193 247.04 79.0293 156.62 149.889 107.9C178.259 88.4 214.349 76.4 247.839 74.16H247.849Z" fill="white"/>
<path d="M339.518 242.97H189.838C173.858 242.97 160.898 231.58 160.898 217.54C160.898 203.5 173.848 192.11 189.838 192.11H339.518C355.498 192.11 368.458 203.5 368.458 217.54C368.458 231.58 355.508 242.97 339.518 242.97Z" fill="#333333"/>
</g>
<defs>
<clipPath id="clip0_9739_16177">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -14,6 +14,7 @@ export default function NewPostPage() {
const initialBoardId = sp.get("boardId") ?? ""; const initialBoardId = sp.get("boardId") ?? "";
const boardSlug = sp.get("boardSlug") ?? undefined; const boardSlug = sp.get("boardSlug") ?? undefined;
const [form, setForm] = useState({ boardId: initialBoardId, title: "", content: "" }); const [form, setForm] = useState({ boardId: initialBoardId, title: "", content: "" });
const [isSecret, setIsSecret] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
async function submit() { async function submit() {
try { try {
@@ -49,56 +50,90 @@ export default function NewPostPage() {
setLoading(false); setLoading(false);
} }
} }
const plainLength = (form.content || "").replace(/<[^>]*>/g, "").length;
const MAX_LEN = 10000;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 상단 배너 */}
<section> <section>
<HeroBanner /> <HeroBanner />
</section> </section>
{/* 작성 카드 */} <section className="mx-auto max-w-5xl bg-white rounded-2xl border border-neutral-300 px-6 sm:px-8 pt-6 pb-8">
<section className="rounded-xl overflow-hidden bg-white"> <div className="flex items-center justify-between">
<header className="px-4 py-3 border-b border-neutral-200"> <h1 className="text-[22px] md:text-[26px] font-semibold text-neutral-900 leading-none"> </h1>
<h1 className="text-xl md:text-2xl font-bold text-neutral-900"> </h1> <button
</header> aria-label="닫기"
<div className="p-4 md:p-6 space-y-3"> onClick={() => router.back()}
className="inline-flex items-center justify-center rounded-xl p-2 hover:bg-neutral-100"
>
</button>
</div>
<div className="mt-5 grid grid-cols-1 sm:grid-cols-[1fr_162px] gap-3">
<input <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" className="h-16 w-full rounded-2xl border border-neutral-300 px-6 text-lg placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-neutral-400"
placeholder="boardId" placeholder="제목을 작성해주세요"
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} value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })} 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![image](${url})` }))}
{...(boardSlug ? require("@/lib/photoPresets").getPhotoPresetBySlug(boardSlug) : {})}
/>
</div>
</div>
<footer className="px-4 py-3 border-t border-neutral-200 flex items-center justify-end gap-2">
<button <button
type="button" type="button"
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100" className="h-16 rounded-2xl border border-neutral-300 px-6 text-base text-neutral-900 bg-white hover:bg-neutral-50"
onClick={() => router.back()} onClick={() => {/* 태그 선택 자리표시 */}}
> >
</button> </button>
</div>
<div className="mt-3 rounded-2xl border border-neutral-300">
<div className="p-5">
<Editor value={form.content} onChange={(v) => setForm({ ...form, content: v })} placeholder="내용을 작성해주세요" />
</div>
<div className="px-4 py-3 flex items-center justify-between bg-neutral-100 rounded-b-2xl">
<label className="inline-flex items-center gap-2 bg-white px-2.5 py-1.5 rounded-lg border border-red-300">
<input
type="checkbox"
className="size-4 accent-[#f94b37]"
checked={isSecret}
onChange={(e) => setIsSecret(e.target.checked)}
/>
<span className="text-sm text-[#f94b37]"></span>
</label>
<div className="flex items-center gap-3 text-[13px]">
<span className="text-orange-600">{plainLength}</span>
<span className="text-neutral-900">/ {MAX_LEN.toLocaleString()}</span>
<span aria-hidden>🙂</span>
<UploadButton
multiple
onUploaded={(url) => setForm((f) => ({ ...f, content: `${f.content}\n![image](${url})` }))}
{...(boardSlug ? require("@/lib/photoPresets").getPhotoPresetBySlug(boardSlug) : {})}
/>
</div>
</div>
</div>
<div className="mt-4">
<button <button
disabled={loading} disabled={loading}
onClick={submit} onClick={submit}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800 disabled:opacity-60" className="w-full h-14 rounded-2xl bg-[#f94b37] text-white text-[20px] font-semibold hover:opacity-95 disabled:opacity-60 border border-[#d73b29]"
> >
{loading ? "저장 중..." : "등록"} {loading ? "저장 중..." : "게시하기"}
</button> </button>
</footer> </div>
<div className="sr-only">
<input
className="hidden"
placeholder="boardId"
value={form.boardId}
onChange={(e) => setForm({ ...form, boardId: e.target.value })}
aria-hidden
readOnly
/>
</div>
</section> </section>
</div> </div>
); );

View File

@@ -0,0 +1,12 @@
"use client";
import React from "react";
export default function CommentIcon({ width = 20, height = 20, fill = "#8C8C8C", className }: { width?: number; height?: number; fill?: string; className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 20 20" fill="none" className={className} aria-hidden>
<path fillRule="evenodd" clipRule="evenodd" d="M4.5 2C3.67157 2 3 2.67157 3 3.5V16.5C3 17.3284 3.67157 18 4.5 18H15.5C16.3284 18 17 17.3284 17 16.5V7.62132C17 7.2235 16.842 6.84197 16.5607 6.56066L12.4393 2.43934C12.158 2.15804 11.7765 2 11.3787 2H4.5ZM6.75 10.5C6.33579 10.5 6 10.8358 6 11.25C6 11.6642 6.33579 12 6.75 12H13.25C13.6642 12 14 11.6642 14 11.25C14 10.8358 13.6642 10.5 13.25 10.5H6.75ZM6.75 13.5C6.33579 13.5 6 13.8358 6 14.25C6 14.6642 6.33579 15 6.75 15H13.25C13.6642 15 14 14.6642 14 14.25C14 13.8358 13.6642 13.5 13.25 13.5H6.75Z" fill={fill}/>
</svg>
);
}

12
src/app/svgs/LikeIcon.tsx Normal file
View File

@@ -0,0 +1,12 @@
"use client";
import React from "react";
export default function LikeIcon({ width = 20, height = 20, fill = "#8C8C8C", className }: { width?: number; height?: number; fill?: string; className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 20 20" fill="none" className={className} aria-hidden>
<path d="M9.65298 16.9149L9.6476 16.9121L9.62912 16.9024C9.61341 16.8941 9.59102 16.8822 9.56238 16.8667C9.50511 16.8358 9.42281 16.7907 9.31906 16.732C9.11164 16.6146 8.81794 16.4425 8.46663 16.2206C7.76556 15.7777 6.82731 15.1314 5.88539 14.3197C4.04447 12.7332 2 10.3523 2 7.5C2 5.01472 4.01472 3 6.5 3C7.9144 3 9.17542 3.65238 10 4.67158C10.8246 3.65238 12.0856 3 13.5 3C15.9853 3 18 5.01472 18 7.5C18 10.3523 15.9555 12.7332 14.1146 14.3197C13.1727 15.1314 12.2344 15.7777 11.5334 16.2206C11.1821 16.4425 10.8884 16.6146 10.6809 16.732C10.5772 16.7907 10.4949 16.8358 10.4376 16.8667C10.409 16.8822 10.3866 16.8941 10.3709 16.9024L10.3524 16.9121L10.347 16.9149L10.3453 16.9158C10.13 17.03 9.87 17.03 9.65529 16.9161L9.65298 16.9149Z" fill={fill}/>
</svg>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import React from "react";
export default function ViewsIcon({ width = 20, height = 20, fill = "#8C8C8C", className }: { width?: number; height?: number; fill?: string; className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 20 20" fill="none" className={className} aria-hidden>
<path d="M10.0005 12.5C11.3812 12.5 12.5005 11.3807 12.5005 10C12.5005 8.61929 11.3812 7.5 10.0005 7.5C8.61978 7.5 7.50049 8.61929 7.50049 10C7.50049 11.3807 8.61978 12.5 10.0005 12.5Z" fill={fill}/>
<path fillRule="evenodd" clipRule="evenodd" d="M0.664743 10.5904C0.51788 10.2087 0.518007 9.78563 0.665098 9.40408C2.10927 5.65788 5.74378 3 9.99908 3C14.2565 3 17.8925 5.66051 19.3352 9.40962C19.4821 9.79127 19.4819 10.2144 19.3348 10.5959C17.8907 14.3421 14.2562 17 10.0009 17C5.74346 17 2.10746 14.3395 0.664743 10.5904ZM14.0009 10C14.0009 12.2091 12.21 14 10.0009 14C7.79172 14 6.00086 12.2091 6.00086 10C6.00086 7.79086 7.79172 6 10.0009 6C12.21 6 14.0009 7.79086 14.0009 10Z" fill={fill}/>
</svg>
);
}