디자인디테일
This commit is contained in:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user