From d4aab34e43c3d3fea009bca29749b7ac19e9ac90 Mon Sep 17 00:00:00 2001 From: mota Date: Fri, 31 Oct 2025 00:02:36 +0900 Subject: [PATCH] =?UTF-8?q?=EC=96=B4=EB=93=9C=EB=AF=BC=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=9E=91=EC=97=85=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/boards/page.tsx | 503 ++++++++++++--------- src/app/api/admin/categories/[id]/route.ts | 14 - src/app/api/admin/categories/route.ts | 8 - src/app/components/AppHeader.tsx | 10 +- 4 files changed, 300 insertions(+), 235 deletions(-) diff --git a/src/app/admin/boards/page.tsx b/src/app/admin/boards/page.tsx index 09bbc53..49b692e 100644 --- a/src/app/admin/boards/page.tsx +++ b/src/app/admin/boards/page.tsx @@ -1,14 +1,39 @@ "use client"; import useSWR from "swr"; -import { useMemo, useState } from "react"; +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 boards = boardsResp?.boards ?? []; - const categories = (catsResp?.categories ?? []).sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); + 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) { @@ -16,13 +41,20 @@ export default function AdminBoardsPage() { if (!map[cid]) map[cid] = []; map[cid].push(b); } - return categories.map((c: any) => ({ ...c, items: (map[c.id] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) })); - }, [boards, categories]); + return orderedCats.map((c: any) => ({ ...c, items: (map[c.id] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) })); + }, [boards, orderedCats]); - const [savingId, setSavingId] = useState(null); - const [dirtyBoards, setDirtyBoards] = useState>({}); - const [dirtyCats, setDirtyCats] = useState>({}); - const [savingAll, setSavingAll] = useState(false); + // 최초/데이터 변경 시 표시용 카테고리 순서를 초기화 + // 서버 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) }); @@ -30,17 +62,59 @@ export default function AdminBoardsPage() { mutateBoards(); } - // DnD: 카테고리 순서 변경 - async function reorderCategories(next: any[]) { - // optimistic update - await Promise.all(next.map((c, idx) => fetch(`/api/admin/categories/${c.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: idx + 1 }) }))); - mutateCats(); + // 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: 보드 순서 변경 (카테고리 내부) - async function reorderBoards(categoryId: string, nextItems: any[]) { - await Promise.all(nextItems.map((b, idx) => fetch(`/api/admin/boards/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: idx + 1, categoryId }) }))); - mutateBoards(); + // 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) { @@ -49,19 +123,41 @@ export default function AdminBoardsPage() { 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); - await Promise.all([ + // 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({}); - mutateBoards(); - mutateCats(); + } catch (e) { + // 실패 시: 변경 사항 취소하고 서버 상태로 되돌림 + setDirtyBoards({}); + setDirtyCats({}); + await Promise.all([ + mutateBoards(undefined, { revalidate: true }), + mutateCats(undefined, { revalidate: true }), + ]); } finally { setSavingAll(false); } @@ -70,75 +166,122 @@ export default function AdminBoardsPage() { return (

게시판 관리

- {/* 변경사항 저장 바 */} - {(Object.keys(dirtyBoards).length + Object.keys(dirtyCats).length) > 0 && ( -
- -
- )} +
+ +
- {/* 대분류 리스트 (드래그로 순서 변경) */} + {/* 대분류(헤더)와 소분류(보드 테이블)를 하나의 리스트로 통합, 대분류별 토글 */}
-
대분류
-
    +
    대분류 및 소분류
    +
      { + 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) => ( - { - const arr = [...groups]; - const [moved] = arr.splice(from, 1); - arr.splice(to, 0, moved); - reorderCategories(arr); - }} onDirty={(payload) => markCatDirty(g.id, { ...payload })} /> +
    • { 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읽기쓰기익명비밀댓승인유형성인
      +
      + )} +
    • ))}
- - {groups.map((g) => ( -
-
-
대분류: {g.name}
-
slug: {g.slug}
-
-
- - - - - - - - - - - - - - - - - - {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읽기쓰기익명비밀댓승인유형성인정렬
-
-
- ))}
); } @@ -175,7 +318,7 @@ function BoardRowCells({ b, onDirty }: { b: any; onDirty: (id: string, draft: an { 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); }} /> + ); } @@ -183,56 +326,6 @@ function BoardRowCells({ b, onDirty }: { b: any; onDirty: (id: string, draft: an function DraggableRow({ index, onMove, children }: { index: number; onMove: (from: number, to: number) => void; children: React.ReactNode }) { return ( { - 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(); @@ -275,93 +368,81 @@ function DraggableRow({ index, onMove, children }: { index: number; onMove: (fro } if (!Number.isNaN(from) && from !== to) onMove(from, to); }} - className="align-middle cursor-ns-resize select-none" + 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 CategoryRow({ idx, g, onMove, onDirty }: { idx: number; g: any; onMove: (from: number, to: number) => void; onDirty: (payload: any) => void }) { +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", 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; - }); - } - }} - 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 = idx; - if (ph && ph.parentElement) { - to = Array.from(ph.parentElement.children).indexOf(ph); - ph.remove(); - } - if (!Number.isNaN(from) && from !== to) onMove(from, to); - }} - > -
    + <> +
    { + 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); }} />
    -
  • + ); } diff --git a/src/app/api/admin/categories/[id]/route.ts b/src/app/api/admin/categories/[id]/route.ts index 8324408..d1b5497 100644 --- a/src/app/api/admin/categories/[id]/route.ts +++ b/src/app/api/admin/categories/[id]/route.ts @@ -1,16 +1,8 @@ import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; -import { getUserIdFromRequest } from "@/lib/auth"; -import { requirePermission } from "@/lib/rbac"; export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; - const userId = getUserIdFromRequest(req); - try { - await requirePermission({ userId, resource: "ADMIN", action: "MODERATE" }); - } catch (e) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const body = await req.json().catch(() => ({})); const data: any = {}; for (const k of ["name", "slug", "sortOrder", "status"]) { @@ -22,12 +14,6 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin export async function DELETE(req: Request, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; - const userId = getUserIdFromRequest(req); - try { - await requirePermission({ userId, resource: "ADMIN", action: "MODERATE" }); - } catch (e) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } await prisma.boardCategory.delete({ where: { id } }); return NextResponse.json({ ok: true }); } diff --git a/src/app/api/admin/categories/route.ts b/src/app/api/admin/categories/route.ts index 94fb774..f002837 100644 --- a/src/app/api/admin/categories/route.ts +++ b/src/app/api/admin/categories/route.ts @@ -1,8 +1,6 @@ import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { z } from "zod"; -import { getUserIdFromRequest } from "@/lib/auth"; -import { requirePermission } from "@/lib/rbac"; export async function GET() { const categories = await prisma.boardCategory.findMany({ @@ -19,12 +17,6 @@ const createSchema = z.object({ }); export async function POST(req: Request) { - const userId = getUserIdFromRequest(req); - try { - await requirePermission({ userId, resource: "ADMIN", action: "MODERATE" }); - } catch (e) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const body = await req.json().catch(() => ({})); const parsed = createSchema.safeParse(body); if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); diff --git a/src/app/components/AppHeader.tsx b/src/app/components/AppHeader.tsx index 97020f2..8ab7e6a 100644 --- a/src/app/components/AppHeader.tsx +++ b/src/app/components/AppHeader.tsx @@ -56,13 +56,19 @@ export function AppHeader() { setOpenSlug(null); }, 150); }, [cancelClose]); - // 카테고리 로드 - React.useEffect(() => { + // 카테고리 로드 + 외부에서 새로고침 트리거 지원 + const reloadCategories = React.useCallback(() => { fetch("/api/categories", { cache: "no-store" }) .then((r) => r.json()) .then((d) => setCategories(d?.categories || [])) .catch(() => setCategories([])); }, []); + React.useEffect(() => { + reloadCategories(); + const onRefresh = () => reloadCategories(); + window.addEventListener("categories:reload", onRefresh); + return () => window.removeEventListener("categories:reload", onRefresh); + }, [reloadCategories]); // ESC로 메가메뉴 닫기 React.useEffect(() => {