디자인디테일

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

View File

@@ -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() {

View File

@@ -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" />

View File

@@ -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>
);
})

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>
);
}