From 108a07ab9d6800e1c636921b0eb4bcce11244a2c Mon Sep 17 00:00:00 2001 From: koreacomp5 Date: Thu, 30 Oct 2025 10:03:12 +0900 Subject: [PATCH] adminpage --- src/app/admin/boards/page.tsx | 228 +++++++++++++++++++++++++++++----- 1 file changed, 199 insertions(+), 29 deletions(-) diff --git a/src/app/admin/boards/page.tsx b/src/app/admin/boards/page.tsx index 5f3a833..09bbc53 100644 --- a/src/app/admin/boards/page.tsx +++ b/src/app/admin/boards/page.tsx @@ -20,6 +20,9 @@ export default function AdminBoardsPage() { }, [boards, categories]); const [savingId, setSavingId] = useState(null); + const [dirtyBoards, setDirtyBoards] = useState>({}); + const [dirtyCats, setDirtyCats] = useState>({}); + const [savingAll, setSavingAll] = useState(false); async function save(b: any) { setSavingId(b.id); await fetch(`/api/admin/boards/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(b) }); @@ -40,9 +43,44 @@ export default function AdminBoardsPage() { mutateBoards(); } + function markBoardDirty(id: string, draft: any) { + setDirtyBoards((prev) => ({ ...prev, [id]: draft })); + } + function markCatDirty(id: string, draft: any) { + setDirtyCats((prev) => ({ ...prev, [id]: draft })); + } + async function saveAll() { + try { + setSavingAll(true); + const boardEntries = Object.entries(dirtyBoards); + const catEntries = Object.entries(dirtyCats); + await Promise.all([ + ...boardEntries.map(([id, payload]) => fetch(`/api/admin/boards/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) })), + ...catEntries.map(([id, payload]) => fetch(`/api/admin/categories/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) })), + ]); + setDirtyBoards({}); + setDirtyCats({}); + mutateBoards(); + mutateCats(); + } finally { + setSavingAll(false); + } + } + return (

게시판 관리

+ {/* 변경사항 저장 바 */} + {(Object.keys(dirtyBoards).length + Object.keys(dirtyCats).length) > 0 && ( +
+ +
+ )} + {/* 대분류 리스트 (드래그로 순서 변경) */}
대분류
@@ -53,10 +91,7 @@ export default function AdminBoardsPage() { const [moved] = arr.splice(from, 1); arr.splice(to, 0, moved); reorderCategories(arr); - }} onSave={async (payload) => { - await fetch(`/api/admin/categories/${g.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) }); - mutateCats(); - }} /> + }} onDirty={(payload) => markCatDirty(g.id, { ...payload })} /> ))}
@@ -96,7 +131,7 @@ export default function AdminBoardsPage() { reorderBoards(g.id, list); }} > - + markBoardDirty(id, draft)} /> ))} @@ -108,14 +143,14 @@ export default function AdminBoardsPage() { ); } -function Row({ b, onSave, saving }: { b: any; onSave: (b: any) => void; saving: boolean }) { +function BoardRowCells({ b, onDirty }: { b: any; onDirty: (id: string, draft: any) => void }) { const [edit, setEdit] = useState(b); return ( - - setEdit({ ...edit, name: e.target.value })} /> - setEdit({ ...edit, slug: e.target.value })} /> + <> + { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /> + { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /> - { const v = { ...edit, readLevel: e.target.value }; setEdit(v); onDirty(b.id, v); }}> @@ -123,26 +158,25 @@ function Row({ b, onSave, saving }: { b: any; onSave: (b: any) => void; saving: - { const v = { ...edit, writeLevel: e.target.value }; setEdit(v); onDirty(b.id, v); }}> - setEdit({ ...edit, allowAnonymousPost: e.target.checked })} /> - setEdit({ ...edit, allowSecretComment: e.target.checked })} /> - setEdit({ ...edit, requiresApproval: e.target.checked })} /> + { const v = { ...edit, allowAnonymousPost: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /> + { const v = { ...edit, allowSecretComment: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /> + { const v = { ...edit, requiresApproval: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /> - { const v = { ...edit, type: e.target.value }; setEdit(v); onDirty(b.id, v); }}> - setEdit({ ...edit, isAdultOnly: e.target.checked })} /> - setEdit({ ...edit, sortOrder: Number(e.target.value) })} /> - - + { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /> + { const v = { ...edit, sortOrder: Number(e.target.value) }; setEdit(v); onDirty(b.id, v); }} /> + ); } @@ -153,44 +187,180 @@ function DraggableRow({ index, onMove, children }: { index: number; onMove: (fro onDragStart={(e) => { e.dataTransfer.setData("text/plain", String(index)); e.dataTransfer.effectAllowed = "move"; + // 수직만 보이게: 실제 행을 고정 포지션으로 띄워 따라오게 함 + 전역 placeholder 등록 + const row = e.currentTarget as HTMLTableRowElement; + const rect = row.getBoundingClientRect(); + const table = row.closest('table') as HTMLElement | null; + const tableRect = table?.getBoundingClientRect(); + // placeholder로 자리를 유지 (전역으로 참조) + const placeholder = document.createElement('tr'); + placeholder.style.height = `${rect.height}px`; + (row.parentNode as HTMLElement).insertBefore(placeholder, row); + (window as any).__adminDnd = { placeholder, dragging: row, target: null, before: false, rAF: 0 }; + // 행을 고정 배치 + const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)); + const offsetY = e.clientY - rect.top; + row.style.position = 'fixed'; + row.style.left = `${tableRect ? tableRect.left : rect.left}px`; + row.style.width = `${tableRect ? tableRect.width : rect.width}px`; + row.style.zIndex = '9999'; + row.classList.add('bg-white'); + const updatePos = (clientY: number) => { + const top = clamp(clientY - offsetY, (tableRect?.top ?? 0), (tableRect?.bottom ?? (rect.top + rect.height)) - rect.height); + row.style.top = `${top}px`; + }; + updatePos(e.clientY); + // 기본 드래그 이미지는 투명 1x1로 숨김 + const img = document.createElement('canvas'); + img.width = 1; img.height = 1; const ctx = img.getContext('2d'); ctx?.clearRect(0,0,1,1); + e.dataTransfer.setDragImage(img, 0, 0); + const onDragOver = (ev: DragEvent) => { + if (typeof ev.clientY === 'number') updatePos(ev.clientY); + }; + const cleanup = () => { + row.style.position = ''; + row.style.left = ''; + row.style.top = ''; + row.style.width = ''; + row.style.zIndex = ''; + row.classList.remove('bg-white'); + placeholder.remove(); + window.removeEventListener('dragover', onDragOver, true); + window.removeEventListener('dragend', cleanup, true); + const st = (window as any).__adminDnd; if (st?.rAF) cancelAnimationFrame(st.rAF); + (window as any).__adminDnd = undefined; + }; + window.addEventListener('dragover', onDragOver, true); + window.addEventListener('dragend', cleanup, true); + }} + onDragOver={(e) => { + // 수직만 허용: 기본 동작만 유지하고 수평 제스처는 무시 + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const current = e.currentTarget as HTMLTableRowElement; + const state = (window as any).__adminDnd || {}; + const ph: HTMLElement | undefined = state.placeholder; + if (!ph || !current.parentElement) return; + const r = current.getBoundingClientRect(); + const before = e.clientY < r.top + r.height / 2; + // 목표만 저장하고 DOM 조작은 프레임당 1회 수행 + state.target = current; + state.before = before; + if (!state.rAF) { + state.rAF = requestAnimationFrame(() => { + const st = (window as any).__adminDnd || {}; + if (!st.placeholder || !st.target || !st.target.parentElement) { st.rAF = 0; return; } + const parent = st.target.parentElement as HTMLElement; + const desiredNode = st.before ? st.target : (st.target.nextSibling as any); + if (desiredNode !== st.placeholder) { + parent.insertBefore(st.placeholder, desiredNode || null); + if (st.dragging) { + const pr = st.placeholder.getBoundingClientRect(); + (st.dragging as HTMLElement).style.top = `${pr.top}px`; + } + } + st.rAF = 0; + }); + } }} - onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); const from = Number(e.dataTransfer.getData("text/plain")); - const to = index; + const state = (window as any).__adminDnd || {}; + const ph: HTMLElement | undefined = state.placeholder; + let to = index; + if (ph && ph.parentElement) { + to = Array.from(ph.parentElement.children).indexOf(ph); + ph.remove(); + } if (!Number.isNaN(from) && from !== to) onMove(from, to); }} - className="align-middle" + className="align-middle cursor-ns-resize select-none" > {children} ); } -function CategoryRow({ idx, g, onMove, onSave }: { idx: number; g: any; onMove: (from: number, to: number) => void; onSave: (payload: any) => void }) { +function CategoryRow({ idx, g, onMove, onDirty }: { idx: number; g: any; onMove: (from: number, to: number) => void; onDirty: (payload: any) => void }) { const [edit, setEdit] = useState({ name: g.name, slug: g.slug }); return (
  • { e.dataTransfer.setData("text/plain", String(idx)); e.dataTransfer.effectAllowed = "move"; + const item = e.currentTarget as HTMLLIElement; + const rect = item.getBoundingClientRect(); + const listRect = item.parentElement?.getBoundingClientRect(); + // placeholder (전역 등록) + const placeholder = document.createElement('div'); + placeholder.style.height = `${rect.height}px`; + placeholder.style.border = '1px dashed rgba(0,0,0,0.1)'; + item.parentElement?.insertBefore(placeholder, item); + (window as any).__adminDnd = { placeholder, dragging: item, target: null, before: false, rAF: 0 }; + // fix item position + const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)); + const offsetY = e.clientY - rect.top; + item.style.position = 'fixed'; + item.style.left = `${listRect ? listRect.left : rect.left}px`; + item.style.width = `${listRect ? listRect.width : rect.width}px`; + item.style.zIndex = '9999'; + const updatePos = (y: number) => { + const top = clamp(y - offsetY, (listRect?.top ?? 0), (listRect?.bottom ?? (rect.top + rect.height)) - rect.height); + item.style.top = `${top}px`; + }; + updatePos(e.clientY); + // hide default drag image + const img = document.createElement('canvas'); img.width = 1; img.height = 1; e.dataTransfer.setDragImage(img, 0, 0); + const onDragOver = (ev: DragEvent) => { if (typeof ev.clientY === 'number') updatePos(ev.clientY); }; + const cleanup = () => { const st = (window as any).__adminDnd; if (st?.rAF) cancelAnimationFrame(st.rAF); item.style.position=''; item.style.left=''; item.style.top=''; item.style.width=''; item.style.zIndex=''; placeholder.remove(); window.removeEventListener('dragover', onDragOver, true); window.removeEventListener('dragend', cleanup, true); (window as any).__adminDnd = undefined; }; + window.addEventListener('dragover', onDragOver, true); + window.addEventListener('dragend', cleanup, true); + }} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const state = (window as any).__adminDnd || {}; + const current = e.currentTarget as HTMLElement; + const r = current.getBoundingClientRect(); + state.target = current; + state.before = e.clientY < r.top + r.height / 2; + if (!state.rAF) { + state.rAF = requestAnimationFrame(() => { + const st = (window as any).__adminDnd || {}; + if (!st.placeholder || !st.target || !st.target.parentElement) { st.rAF = 0; return; } + const parent = st.target.parentElement as HTMLElement; + const desiredNode = st.before ? st.target : (st.target.nextSibling as any); + if (desiredNode !== st.placeholder) { + parent.insertBefore(st.placeholder, desiredNode || null); + if (st.dragging) { + const pr = st.placeholder.getBoundingClientRect(); + (st.dragging as HTMLElement).style.top = `${pr.top}px`; + } + } + st.rAF = 0; + }); + } }} - onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); const from = Number(e.dataTransfer.getData("text/plain")); - const to = idx; + const state = (window as any).__adminDnd || {}; + const ph: HTMLElement | undefined = state.placeholder; + let to = idx; + if (ph && ph.parentElement) { + to = Array.from(ph.parentElement.children).indexOf(ph); + ph.remove(); + } if (!Number.isNaN(from) && from !== to) onMove(from, to); }} >
    - setEdit({ ...edit, name: e.target.value })} /> - setEdit({ ...edit, slug: e.target.value })} /> + { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} /> + { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
    -
  • ); }