디자인디테일
This commit is contained in:
@@ -4,12 +4,11 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/admin/menus", label: "메뉴 관리" },
|
||||
{ href: "/admin/mainpage-settings", label: "메인페이지 설정" },
|
||||
{ href: "/admin/boards", label: "게시판" },
|
||||
{ href: "/admin/banners", label: "배너" },
|
||||
{ href: "/admin/users", label: "사용자" },
|
||||
{ href: "/admin/logs", label: "로그" },
|
||||
{ href: "/admin/banners", label: "배너" },
|
||||
{ href: "/admin/mainpage-settings", label: "메인페이지 설정" },
|
||||
];
|
||||
|
||||
export default function AdminSidebar() {
|
||||
|
||||
@@ -87,6 +87,21 @@ export default function AdminBoardsPage() {
|
||||
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: 카테고리 순서 변경 (저장 시 반영)
|
||||
function reorderCategories(next: any[]) {
|
||||
setDirtyCats((prev) => {
|
||||
@@ -275,56 +290,9 @@ export default function AdminBoardsPage() {
|
||||
onClick={createCategory}
|
||||
>대분류 추가</button>
|
||||
</div>
|
||||
<ul
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<ul className="divide-y-2 divide-neutral-100">
|
||||
{groups.map((g, idx) => (
|
||||
<li
|
||||
key={g.id}
|
||||
className="select-none"
|
||||
ref={(el) => { catRefs.current[g.id] = el; }}
|
||||
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDraggingCatIndex(null);
|
||||
}}
|
||||
onDragEnd={() => { setDraggingCatIndex(null); }}
|
||||
>
|
||||
<li key={g.id} className="select-none">
|
||||
<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>
|
||||
{g.id === 'uncat' ? (
|
||||
@@ -333,9 +301,25 @@ export default function AdminBoardsPage() {
|
||||
<div className="text-sm font-medium text-neutral-800">미분류 (카테고리 없음)</div>
|
||||
</div>
|
||||
) : (
|
||||
<CategoryHeaderContent g={g} onDirty={(payload) => markCatDirty(g.id, { ...payload })} onDragStart={() => {
|
||||
setDraggingCatIndex(idx);
|
||||
}} />
|
||||
<CategoryHeaderContent g={g} onDirty={(payload) => markCatDirty(g.id, { ...payload })} />
|
||||
)}
|
||||
{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
|
||||
type="button"
|
||||
@@ -374,8 +358,8 @@ export default function AdminBoardsPage() {
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-xs text-neutral-500 border-b border-neutral-200">
|
||||
<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-16 text-center">정렬</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">메인뷰</th>
|
||||
@@ -392,50 +376,26 @@ export default function AdminBoardsPage() {
|
||||
<th className="px-3 py-2">삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{g.items.map((b, i) => (
|
||||
<DraggableRow
|
||||
key={b.id}
|
||||
catId={g.id}
|
||||
boardId={b.id}
|
||||
index={i}
|
||||
setRef={(el) => { boardRefs.current[b.id] = el; }}
|
||||
onStart={() => setDraggingBoard({ catId: g.id, index: i })}
|
||||
onEnd={() => setDraggingBoard(null)}
|
||||
totalCount={g.items.length}
|
||||
onMoveIndex={(delta) => {
|
||||
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
|
||||
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 (
|
||||
<tr ref={setRef} 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>
|
||||
<tr className="align-middle select-none">
|
||||
<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}
|
||||
</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 });
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
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>
|
||||
<div className="w-10" />
|
||||
<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); }} />
|
||||
<div className="flex-1" />
|
||||
|
||||
@@ -24,6 +24,17 @@ export default function MainPageSettingsPage() {
|
||||
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 동기화
|
||||
useEffect(() => {
|
||||
if (settingsResp?.settings) {
|
||||
@@ -110,7 +121,7 @@ export default function MainPageSettingsPage() {
|
||||
{draft.visibleBoardIds.length === 0 ? (
|
||||
<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);
|
||||
if (!board) return null;
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDraft({ ...draft, visibleBoardIds: draft.visibleBoardIds.filter((id: string) => id !== boardId) });
|
||||
}}
|
||||
className="text-xs text-red-600 hover:text-red-800"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveVisibleBoard(idx, -1)}
|
||||
disabled={idx === 0}
|
||||
className="h-7 px-2 rounded-md border border-neutral-300 text-xs disabled:opacity-40"
|
||||
aria-label="위로"
|
||||
>
|
||||
↑
|
||||
</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>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PostList } from "@/app/components/PostList";
|
||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||
import { BoardToolbar } from "@/app/components/BoardToolbar";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
// Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다.
|
||||
@@ -20,44 +21,24 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
||||
const categoryName = board?.category?.name ?? "";
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 상단 배너 (홈과 동일) */}
|
||||
{/* 상단 배너 (서브카테고리 표시) */}
|
||||
<section>
|
||||
<HeroBanner />
|
||||
<HeroBanner
|
||||
subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.id}` }))}
|
||||
activeSubId={id}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 리스트 */}
|
||||
{/* 검색/필터 툴바 + 리스트 */}
|
||||
<section>
|
||||
<BoardToolbar boardId={id} />
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { SearchBar } from "@/app/components/SearchBar";
|
||||
import React from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { SinglePageLogo } from "@/app/components/SinglePageLogo";
|
||||
|
||||
export function AppHeader() {
|
||||
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>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
<nav className="flex flex-1 items-center gap-4 justify-between">
|
||||
|
||||
75
src/app/components/BoardToolbar.tsx
Normal file
75
src/app/components/BoardToolbar.tsx
Normal 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;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useEffect, useRef, useCallback, useMemo } from "react";
|
||||
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> {
|
||||
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));
|
||||
}
|
||||
|
||||
export function Editor({ value, onChange, placeholder }: Props) {
|
||||
export function Editor({ value, onChange, placeholder, withToolbar = true }: Props) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const { show } = useToast();
|
||||
|
||||
@@ -99,23 +99,107 @@ export function Editor({ value, onChange, placeholder }: Props) {
|
||||
[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="코드"></></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 (
|
||||
<div
|
||||
ref={ref}
|
||||
contentEditable
|
||||
onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
|
||||
onPaste={onPaste}
|
||||
onDrop={onDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
data-placeholder={placeholder}
|
||||
style={{
|
||||
minHeight: 160,
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
}}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
<div>
|
||||
{toolbar && (
|
||||
<div className="mb-3">{toolbar}</div>
|
||||
)}
|
||||
<div
|
||||
ref={ref}
|
||||
contentEditable
|
||||
onInput={(e) => onChange((e.target as HTMLDivElement).innerHTML)}
|
||||
onPaste={onPaste}
|
||||
onDrop={onDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
data-placeholder={placeholder}
|
||||
style={{
|
||||
minHeight: 160,
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
}}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { SelectedBanner } from "@/app/components/SelectedBanner";
|
||||
|
||||
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 [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -65,7 +67,7 @@ export function HeroBanner() {
|
||||
|
||||
const translatePercent = useMemo(() => (numSlides > 0 ? -(activeIndex * 100) : 0), [activeIndex, numSlides]);
|
||||
|
||||
if (numSlides === 0) return null;
|
||||
if (numSlides === 0) return <SelectedBanner height={224} />;
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -88,7 +90,7 @@ export function HeroBanner() {
|
||||
) : (
|
||||
<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">
|
||||
<h2 className="line-clamp-2 text-lg font-semibold md:text-2xl">{banner.title}</h2>
|
||||
</div>
|
||||
@@ -96,26 +98,44 @@ export function HeroBanner() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
{/* Pagination - Figma 스타일: 활성은 주황 바, 비활성은 회색 점 (배너 위에 오버랩) */}
|
||||
{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 className="pointer-events-auto absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-[6px]">
|
||||
{banners.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
aria-current={activeIndex === i ? "true" : undefined}
|
||||
onClick={() => goTo(i)}
|
||||
className={
|
||||
activeIndex === i
|
||||
? "h-[4px] w-[18px] rounded-full bg-[#F94B37]"
|
||||
: "h-[6px] w-[6px] rounded-full bg-[rgba(255,255,255,0.6)] hover:bg-white"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -126,20 +146,7 @@ export function HeroBanner() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
"use client";
|
||||
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 = {
|
||||
id: string;
|
||||
@@ -22,8 +28,11 @@ type Resp = {
|
||||
|
||||
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 router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
|
||||
const getKey = (index: number, prev: Resp | null) => {
|
||||
if (prev && prev.items.length === 0) return null;
|
||||
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);
|
||||
return `/api/posts?${sp.toString()}`;
|
||||
};
|
||||
const { data, size, setSize, isLoading } = useSWRInfinite<Resp>(getKey, fetcher);
|
||||
const items = data?.flatMap((d) => d.items) ?? [];
|
||||
// default(무한 스크롤 형태)
|
||||
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 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 (
|
||||
<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(); })()}`}
|
||||
>
|
||||
최신
|
||||
</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(); })()}`}
|
||||
>
|
||||
인기
|
||||
</a>
|
||||
</div>
|
||||
{/* 정렬 스위치 (board 변형에서는 상단 툴바를 숨김) */}
|
||||
{variant !== "board" && (
|
||||
<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(); })()}`}
|
||||
>
|
||||
최신
|
||||
</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(); })()}`}
|
||||
>
|
||||
인기
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 리스트 테이블 헤더 */}
|
||||
<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>
|
||||
{/* 리스트 테이블 헤더 (board 변형에서는 숨김) */}
|
||||
{variant !== "board" && (
|
||||
<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 />
|
||||
<div>제목</div>
|
||||
<div className="text-center">작성자</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) => (
|
||||
<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">
|
||||
<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-[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">
|
||||
<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>}
|
||||
@@ -89,28 +141,109 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end
|
||||
</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 className="md:w-[120px] text-xs text-neutral-600 text-center hidden md:flex items-center justify-center gap-2">
|
||||
<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 className="truncate max-w-[84px]">{p.author?.nickname ?? "익명"}</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 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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* 페이지 더보기 */}
|
||||
<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>
|
||||
{/* 페이지네이션 */}
|
||||
{!isEmpty && (
|
||||
variant === "board" ? (
|
||||
<div className="mt-4 flex items-center justify-between px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Previous */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = Math.max(1, page - 1);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
29
src/app/components/SelectedBanner.tsx
Normal file
29
src/app/components/SelectedBanner.tsx
Normal 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;
|
||||
|
||||
|
||||
29
src/app/components/SinglePageLogo.tsx
Normal file
29
src/app/components/SinglePageLogo.tsx
Normal 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;
|
||||
|
||||
|
||||
@@ -79,7 +79,8 @@ export function UploadButton({ onUploaded, multiple = false, maxWidth = 1600, ma
|
||||
const { show } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
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;
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -99,7 +100,8 @@ export function UploadButton({ onUploaded, multiple = false, maxWidth = 1600, ma
|
||||
show("업로드 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
e.currentTarget.value = "";
|
||||
// 선택값 초기화(같은 파일 재선택 가능하도록)
|
||||
if (input) input.value = "";
|
||||
}
|
||||
}
|
||||
return <label style={{ display: "inline-block" }}>
|
||||
|
||||
15
src/app/icon.svg
Normal file
15
src/app/icon.svg
Normal 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 |
@@ -14,6 +14,7 @@ export default function NewPostPage() {
|
||||
const initialBoardId = sp.get("boardId") ?? "";
|
||||
const boardSlug = sp.get("boardSlug") ?? undefined;
|
||||
const [form, setForm] = useState({ boardId: initialBoardId, title: "", content: "" });
|
||||
const [isSecret, setIsSecret] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
async function submit() {
|
||||
try {
|
||||
@@ -49,56 +50,90 @@ export default function NewPostPage() {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const plainLength = (form.content || "").replace(/<[^>]*>/g, "").length;
|
||||
const MAX_LEN = 10000;
|
||||
return (
|
||||
<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">
|
||||
<section className="mx-auto max-w-5xl bg-white rounded-2xl border border-neutral-300 px-6 sm:px-8 pt-6 pb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-[22px] md:text-[26px] font-semibold text-neutral-900 leading-none">게시글 작성</h1>
|
||||
<button
|
||||
aria-label="닫기"
|
||||
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
|
||||
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="제목"
|
||||
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="제목을 작성해주세요"
|
||||
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) : {})}
|
||||
/>
|
||||
</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()}
|
||||
className="h-16 rounded-2xl border border-neutral-300 px-6 text-base text-neutral-900 bg-white hover:bg-neutral-50"
|
||||
onClick={() => {/* 태그 선택 자리표시 */}}
|
||||
>
|
||||
취소
|
||||
테그선택
|
||||
</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` }))}
|
||||
{...(boardSlug ? require("@/lib/photoPresets").getPhotoPresetBySlug(boardSlug) : {})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<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"
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
12
src/app/svgs/CommentIcon.tsx
Normal file
12
src/app/svgs/CommentIcon.tsx
Normal 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
12
src/app/svgs/LikeIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
13
src/app/svgs/ViewsIcon.tsx
Normal file
13
src/app/svgs/ViewsIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user