"use client"; import useSWR from "swr"; import { useMemo, useState, useEffect, useRef } from "react"; const fetcher = (url: string) => fetch(url).then((r) => r.json()); export default function AdminBoardsPage() { const { data: boardsResp, mutate: mutateBoards } = useSWR<{ boards: any[] }>("/api/admin/boards", fetcher); const { data: catsResp, mutate: mutateCats } = useSWR<{ categories: any[] }>("/api/admin/categories", fetcher); const rawBoards = boardsResp?.boards ?? []; const rawCategories = catsResp?.categories ?? []; const [savingId, setSavingId] = useState(null); const [dirtyBoards, setDirtyBoards] = useState>({}); const [dirtyCats, setDirtyCats] = useState>({}); const [savingAll, setSavingAll] = useState(false); const [expanded, setExpanded] = useState>({}); const dirtyCount = Object.keys(dirtyBoards).length + Object.keys(dirtyCats).length; const [catOrder, setCatOrder] = useState([]); const [draggingCatIndex, setDraggingCatIndex] = useState(null); const catRefs = useRef>({}); const boards = useMemo(() => { return rawBoards.map((b: any) => ({ ...b, ...(dirtyBoards[b.id] ?? {}) })); }, [rawBoards, dirtyBoards]); const categories = useMemo(() => { const merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) })); return merged.sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); }, [rawCategories, dirtyCats]); const orderedCats = useMemo(() => { if (!catOrder.length) return categories; const map = new Map(categories.map((c: any) => [c.id, c])); const ordered = catOrder.map((id) => map.get(id)).filter(Boolean) as any[]; // 새로 생긴 카테고리(id 미포함)는 뒤에 추가 const missing = categories.filter((c: any) => !catOrder.includes(c.id)); return [...ordered, ...missing]; }, [categories, catOrder]); const groups = useMemo(() => { const map: Record = {}; for (const b of boards) { const cid = b.categoryId ?? "uncat"; if (!map[cid]) map[cid] = []; map[cid].push(b); } return orderedCats.map((c: any) => ({ ...c, items: (map[c.id] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) })); }, [boards, orderedCats]); // 최초/데이터 변경 시 표시용 카테고리 순서를 초기화 // 서버 sortOrder에 맞춰 초기 catOrder 설정 // categories가 바뀔 때만 동기화 // 사용자가 드래그로 순서를 바꾸면 catOrder가 우선됨 useEffect(() => { if (draggingCatIndex !== null) return; // 드래그 중에는 catOrder를 리셋하지 않음 const next = categories.map((c: any) => c.id); if (next.length && (next.length !== catOrder.length || next.some((id, i) => id !== catOrder[i]))) { setCatOrder(next); } }, [categories, draggingCatIndex]); 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) }); setSavingId(null); mutateBoards(); } // DnD: 카테고리 순서 변경 (저장 시 반영) function reorderCategories(next: any[]) { setDirtyCats((prev) => { // 서버 기준(또는 이전 dirty 오버라이드) sortOrder 맵 구성 const baseSort = new Map(); // rawCategories에는 서버 값이 들어있음 // prev에 sortOrder가 있으면 우선 적용 for (const c of rawCategories) { const prevOverride = prev[c.id]?.sortOrder; baseSort.set(c.id, prevOverride ?? (c.sortOrder ?? 0)); } const updated: Record = { ...prev }; next.forEach((c, idx) => { const target = idx + 1; const current = baseSort.get(c.id) ?? 0; if (target !== current) { updated[c.id] = { ...(updated[c.id] ?? {}), sortOrder: target }; } else if (updated[c.id]?.sortOrder !== undefined) { // 정렬값이 동일해졌다면 해당 키만 제거 (다른 수정값은 유지) const { sortOrder, ...rest } = updated[c.id]; if (Object.keys(rest).length === 0) delete updated[c.id]; else updated[c.id] = rest; } }); return updated; }); } // DnD: 보드 순서 변경 (저장 시 반영) function reorderBoards(categoryId: string, nextItems: any[]) { setDirtyBoards((prev) => { // 서버 기준(또는 이전 dirty) 정렬/카테고리 맵 구성 const base = new Map(); for (const b of rawBoards) { const prevB = prev[b.id] ?? {}; base.set(b.id, { sortOrder: prevB.sortOrder ?? (b.sortOrder ?? 0), categoryId: prevB.categoryId ?? b.categoryId, }); } const updated: Record = { ...prev }; nextItems.forEach((b, idx) => { const targetSort = idx + 1; const targetCat = categoryId; const baseVal = base.get(b.id) ?? { sortOrder: b.sortOrder ?? 0, categoryId: b.categoryId }; if (baseVal.sortOrder !== targetSort || baseVal.categoryId !== targetCat) { updated[b.id] = { ...(updated[b.id] ?? {}), sortOrder: targetSort, categoryId: targetCat }; } else if (updated[b.id]) { const { sortOrder, categoryId: catId, ...rest } = updated[b.id]; if (Object.keys(rest).length === 0) delete updated[b.id]; else updated[b.id] = rest; } }); return updated; }); } function markBoardDirty(id: string, draft: any) { setDirtyBoards((prev) => ({ ...prev, [id]: draft })); } function markCatDirty(id: string, draft: any) { setDirtyCats((prev) => ({ ...prev, [id]: draft })); } function toggleCat(id: string) { setExpanded((prev) => ({ ...prev, [id]: !prev[id] })); } async function saveAll() { const prevDirtyBoards = dirtyBoards; const prevDirtyCats = dirtyCats; try { setSavingAll(true); const boardEntries = Object.entries(dirtyBoards); const catEntries = Object.entries(dirtyCats); // 1) 서버 저장 (병렬) - 실패 시 아래 catch로 이동 const resps = 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) })), ]); const anyFail = resps.some((r) => !r.ok); if (anyFail) throw new Error("save_failed"); // 2) 성공 시: 먼저 서버 데이터로 최신화 → 그 다음 dirty 초기화 await Promise.all([ mutateBoards(undefined, { revalidate: true }), mutateCats(undefined, { revalidate: true }), ]); if (typeof window !== "undefined") { window.dispatchEvent(new Event("categories:reload")); } setDirtyBoards({}); setDirtyCats({}); } catch (e) { // 실패 시: 변경 사항 취소하고 서버 상태로 되돌림 setDirtyBoards({}); setDirtyCats({}); await Promise.all([ mutateBoards(undefined, { revalidate: true }), mutateCats(undefined, { revalidate: true }), ]); } finally { setSavingAll(false); } } return (

게시판 관리

{/* 대분류(헤더)와 소분류(보드 테이블)를 하나의 리스트로 통합, 대분류별 토글 */}
대분류 및 소분류
    { 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); }} > {groups.map((g, idx) => (
  • { catRefs.current[g.id] = el; }} onDrop={(e) => { e.preventDefault(); setDraggingCatIndex(null); }} onDragEnd={() => { setDraggingCatIndex(null); }} >
    {idx + 1}
    markCatDirty(g.id, { ...payload })} onDragStart={() => { setDraggingCatIndex(idx); }} />
    {expanded[g.id] && (
    {g.items.map((b, i) => ( { const list = [...g.items]; const [mv] = list.splice(from, 1); list.splice(to, 0, mv); reorderBoards(g.id, list); }} > markBoardDirty(id, draft)} /> ))}
    # 이름 slug 읽기 쓰기 익명 비밀댓 승인 유형 성인
    )}
  • ))}
); } function BoardRowCells({ b, onDirty }: { b: any; onDirty: (id: string, draft: any) => void }) { const [edit, setEdit] = useState(b); return ( <> { 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, 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, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /> ); } function DraggableRow({ index, onMove, children }: { index: number; onMove: (from: number, to: number) => void; children: React.ReactNode }) { return ( { // 수직만 허용: 기본 동작만 유지하고 수평 제스처는 무시 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; }); } }} onDrop={(e) => { e.preventDefault(); const from = Number(e.dataTransfer.getData("text/plain")); 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 select-none" > { e.dataTransfer.setData("text/plain", String(index)); e.dataTransfer.effectAllowed = "move"; const row = (e.currentTarget as HTMLElement).closest('tr') as HTMLTableRowElement; const rect = row.getBoundingClientRect(); const table = row.closest('table') as HTMLElement | null; const tableRect = table?.getBoundingClientRect(); 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); 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); }} >≡ {index + 1} {children} ); } function CategoryHeaderContent({ g, onDirty, onDragStart }: { g: any; onDirty: (payload: any) => void; onDragStart?: () => void }) { const [edit, setEdit] = useState({ name: g.name, slug: g.slug }); return ( <>
{ 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="드래그하여 순서 변경" >≡
{ const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} /> { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
); }