디자인디테일

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

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