디자인디테일
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user