"use client"; import useSWR from "swr"; import { useMemo, useState, useEffect, useRef } from "react"; import { useToast } from "@/app/components/ui/ToastProvider"; const fetcher = (url: string) => fetch(url).then((r) => r.json()); export default function AdminBoardsPage() { const { show } = useToast(); const { data: boardsResp, mutate: mutateBoards } = useSWR<{ boards: any[] }>("/api/admin/boards", fetcher); const { data: vtResp, mutate: mutateVt } = useSWR<{ items: any[] }>("/api/admin/view-types", 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 [boardOrderByCat, setBoardOrderByCat] = useState>({}); const [draggingBoard, setDraggingBoard] = useState<{ catId: string; index: number } | null>(null); const boardRefs = useRef>({}); const boards = useMemo(() => { return rawBoards.map((b: any) => ({ ...b, ...(dirtyBoards[b.id] ?? {}) })); }, [rawBoards, dirtyBoards]); const viewTypes = vtResp?.items ?? []; const mainTypes = useMemo(() => viewTypes.filter((v: any) => v.scope === 'main'), [viewTypes]); const listTypes = useMemo(() => viewTypes.filter((v: any) => v.scope === 'list'), [viewTypes]); const defaultMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_default' || t.key === 'default')?.id ?? null), [mainTypes]); const defaultListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_default' || t.key === 'default')?.id ?? null), [listTypes]); 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); } const result = orderedCats.map((c: any) => { const itemsSorted = (map[c.id] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); const custom = boardOrderByCat[c.id]; if (!custom || custom.length === 0) return { ...c, items: itemsSorted }; const byId = new Map(itemsSorted.map((x: any) => [x.id, x])); const ordered = custom.map((id) => byId.get(id)).filter(Boolean) as any[]; const missing = itemsSorted.filter((x: any) => !custom.includes(x.id)); return { ...c, items: [...ordered, ...missing] }; }); // 미분류(카테고리 없음) 그룹을 마지막에 추가 const uncat = (map["uncat"] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); if (uncat.length) { result.push({ id: "uncat", name: "미분류", slug: "uncategorized", items: uncat }); } return result; }, [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(); } // 버튼으로 카테고리 순서 이동 (↑/↓) 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) => { // 서버 기준(또는 이전 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 === "uncat" ? null : 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); } } // 생성/삭제 액션들 async function createCategory() { const name = prompt("대분류 이름을 입력하세요"); if (!name) return; const slug = prompt("대분류 slug을 입력하세요(영문)"); if (!slug) return; const dupName = categories.some((c: any) => c.name === name); const dupSlug = categories.some((c: any) => c.slug === slug); if (dupName || dupSlug) { show(dupName ? "대분류 이름이 중복입니다." : "대분류 slug가 중복입니다."); return; } await fetch(`/api/admin/categories`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, sortOrder: categories.length + 1, status: "active" }) }); await mutateCats(); } async function deleteCategory(id: string) { if (!confirm("대분류를 삭제하시겠습니까? 소분류의 카테고리는 해제됩니다.")) return; await fetch(`/api/admin/categories/${id}`, { method: "DELETE" }); await mutateCats(); } async function createBoard(catId: string, currentItems: any[]) { const name = prompt("소분류(게시판) 이름을 입력하세요"); if (!name) return; const slug = prompt("소분류 slug을 입력하세요(영문)"); if (!slug) return; const dupName = boards.some((b: any) => b.name === name); const dupSlug = boards.some((b: any) => b.slug === slug); if (dupName || dupSlug) { show(dupName ? "게시판 이름이 중복입니다." : "게시판 slug가 중복입니다."); return; } const sortOrder = (currentItems?.length ?? 0) + 1; await fetch(`/api/admin/boards`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, categoryId: catId === "uncat" ? null : catId, sortOrder, status: "active", mainPageViewTypeId: defaultMainTypeId, listViewTypeId: defaultListTypeId }) }); await mutateBoards(); } async function deleteBoard(id: string) { if (!confirm("해당 소분류(게시판)를 삭제하시겠습니까?")) return; await fetch(`/api/admin/boards/${id}`, { method: "DELETE" }); await mutateBoards(); } async function moveBoardToCategory(boardId: string, toCategoryId: string) { try { const target = boards.find((x: any) => x.id === boardId); if (!target) return; const targetGroup = groups.find((g: any) => g.id === toCategoryId); // 미분류로 이동 시 uncat 그룹 기준으로 정렬 순서 계산 const nextOrder = (toCategoryId === 'uncat' ? (groups.find((g: any) => g.id === 'uncat')?.items?.length ?? 0) : (targetGroup?.items?.length ?? 0)) + 1; const res = await fetch(`/api/admin/boards/${boardId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ categoryId: toCategoryId === 'uncat' ? null : toCategoryId, sortOrder: nextOrder }), }); if (!res.ok) throw new Error("move_failed"); await mutateBoards(); show("이동되었습니다."); } catch { show("이동 중 오류가 발생했습니다."); } } return (

게시판 관리

{/* 대분류(헤더)와 소분류(보드 테이블)를 하나의 리스트로 통합, 대분류별 토글 */}
게시판 리스트
    {groups.map((g, idx) => (
  • {idx + 1}
    {g.id === 'uncat' ? (
    미분류 (카테고리 없음)
    ) : ( markCatDirty(g.id, { ...payload })} /> )} {g.id !== 'uncat' && (
    )} {g.id !== 'uncat' && ( <> )}
    {expanded[g.id] && (
    {g.items.map((b, i) => ( { 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); }} > markBoardDirty(id, draft)} onDelete={() => deleteBoard(b.id)} allowMove={true} categories={g.id === 'uncat' ? orderedCats : [{ id: 'uncat', name: '미분류' }, ...orderedCats.filter((c: any) => c.id !== g.id)]} onMove={(toId) => moveBoardToCategory(b.id, toId)} mainTypes={mainTypes} listTypes={listTypes} defaultMainTypeId={defaultMainTypeId} defaultListTypeId={defaultListTypeId} onAddType={async (scope: 'main'|'list') => { const key = prompt(`${scope === 'main' ? '메인뷰' : '리스트뷰'} key (예: preview)`); if (!key) return null; const name = prompt(`${scope === 'main' ? '메인뷰' : '리스트뷰'} 표시 이름`); if (!name) return null; const res = await fetch('/api/admin/view-types', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key, name, scope }) }); if (!res.ok) { show('타입 추가 실패'); return null; } await mutateVt(); const data = await res.json().catch(() => ({})); return data?.item?.id ?? null; }} /> ))}
    # 정렬 이름 slug 메인뷰 리스트뷰 읽기 쓰기 익명 비밀댓 성인 대분류 이동 활성 삭제
    )}
  • ))}
); } function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, mainTypes, listTypes, onAddType, defaultMainTypeId, defaultListTypeId }: { b: any; onDirty: (id: string, draft: any) => void; onDelete: () => void; allowMove?: boolean; categories?: any[]; onMove?: (toId: string) => void; mainTypes?: any[]; listTypes?: any[]; onAddType?: (scope: 'main'|'list') => Promise; defaultMainTypeId?: string | null; defaultListTypeId?: string | null }) { const [edit, setEdit] = useState(b); const effectiveMainTypeId = edit.mainPageViewTypeId ?? defaultMainTypeId ?? ''; const effectiveListTypeId = edit.listViewTypeId ?? defaultListTypeId ?? ''; 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, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /> {allowMove && categories && onMove ? ( ) : null} { const v = { ...edit, status: e.target.checked ? 'active' : 'hidden' }; setEdit(v); onDirty(b.id, v); }} /> ); } function DraggableRow({ catId, boardId, index, totalCount, children, onMoveIndex }: { catId: string; boardId: string; index: number; totalCount: number; children: React.ReactNode; onMoveIndex: (delta: number) => void }) { return ( {index + 1}
{children} ); } function CategoryHeaderContent({ g, onDirty }: { g: any; onDirty: (payload: any) => void }) { const [edit, setEdit] = useState({ name: g.name, slug: g.slug }); return ( <>
{ const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} /> { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
); }