관리자페이지 작업

This commit is contained in:
mota
2025-10-31 16:27:04 +09:00
parent f444888f2d
commit 0827352e6b
14 changed files with 704 additions and 105 deletions

View File

@@ -1,11 +1,14 @@
"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 ?? [];
@@ -19,9 +22,17 @@ export default function AdminBoardsPage() {
const [catOrder, setCatOrder] = useState<string[]>([]);
const [draggingCatIndex, setDraggingCatIndex] = useState<number | null>(null);
const catRefs = useRef<Record<string, HTMLLIElement | null>>({});
const [boardOrderByCat, setBoardOrderByCat] = useState<Record<string, string[]>>({});
const [draggingBoard, setDraggingBoard] = useState<{ catId: string; index: number } | null>(null);
const boardRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
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));
@@ -41,7 +52,21 @@ export default function AdminBoardsPage() {
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)) }));
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]);
// 최초/데이터 변경 시 표시용 카테고리 순서를 초기화
@@ -104,7 +129,7 @@ export default function AdminBoardsPage() {
const updated: Record<string, any> = { ...prev };
nextItems.forEach((b, idx) => {
const targetSort = idx + 1;
const targetCat = categoryId;
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 };
@@ -163,6 +188,72 @@ export default function AdminBoardsPage() {
}
}
// 생성/삭제 액션들
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 (
<div className="space-y-6">
<h1 className="text-xl md:text-2xl font-bold text-neutral-900"> </h1>
@@ -176,7 +267,14 @@ export default function AdminBoardsPage() {
{/* 대분류(헤더)와 소분류(보드 테이블)를 하나의 리스트로 통합, 대분류별 토글 */}
<div className="rounded-xl border border-neutral-200 overflow-hidden bg-white">
<div className="px-4 py-2 border-b border-neutral-200 text-sm font-semibold"> </div>
<div className="px-4 py-2 border-b border-neutral-200 text-sm font-semibold flex items-center justify-between">
<span> </span>
<button
type="button"
className="h-8 px-3 rounded-md border border-neutral-300 text-sm hover:bg-neutral-50"
onClick={createCategory}
> </button>
</div>
<ul
className="divide-y-2 divide-neutral-100"
onDragOver={(e) => {
@@ -229,19 +327,50 @@ export default function AdminBoardsPage() {
>
<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>
<CategoryHeaderContent g={g} onDirty={(payload) => markCatDirty(g.id, { ...payload })} onDragStart={() => {
setDraggingCatIndex(idx);
}} />
{g.id === 'uncat' ? (
<div className="flex items-center gap-3">
<div className="w-10 text-xl text-neutral-400 select-none"></div>
<div className="text-sm font-medium text-neutral-800"> ( )</div>
</div>
) : (
<CategoryHeaderContent g={g} onDirty={(payload) => markCatDirty(g.id, { ...payload })} onDragStart={() => {
setDraggingCatIndex(idx);
}} />
)}
<button
type="button"
aria-label="toggle"
className="w-8 text-2xl leading-none text-neutral-700"
onClick={() => toggleCat(g.id)}
>{expanded[g.id] ? '▾' : '▸'}</button>
{g.id !== 'uncat' && (
<>
<label className="ml-auto flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
checked={(g.status ?? 'active') !== 'hidden'}
onChange={(e) => markCatDirty(g.id, { status: e.target.checked ? 'active' : 'hidden' })}
/>
</label>
<button
type="button"
className="h-8 px-3 rounded-md border border-red-300 text-sm text-red-700 hover:bg-red-50"
onClick={() => deleteCategory(g.id)}
> </button>
</>
)}
</div>
{expanded[g.id] && (
<div className="overflow-x-auto border-t border-neutral-100 ml-8">
<div className="flex items-center justify-end p-2">
<button
type="button"
className="h-8 px-3 rounded-md border border-neutral-300 text-sm hover:bg-neutral-50"
onClick={() => createBoard(g.id, g.items)}
> </button>
</div>
<table className="min-w-full text-sm">
<thead className="text-xs text-neutral-500 border-b border-neutral-200">
<tr>
@@ -249,6 +378,8 @@ export default function AdminBoardsPage() {
<th className="px-2 py-2 w-8 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>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
@@ -256,22 +387,81 @@ export default function AdminBoardsPage() {
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"> </th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
<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);
}}
>
{g.items.map((b, i) => (
<DraggableRow
key={b.id}
catId={g.id}
boardId={b.id}
index={i}
onMove={(from, to) => {
const list = [...g.items];
const [mv] = list.splice(from, 1);
list.splice(to, 0, mv);
reorderBoards(g.id, list);
}}
setRef={(el) => { boardRefs.current[b.id] = el; }}
onStart={() => setDraggingBoard({ catId: g.id, index: i })}
onEnd={() => setDraggingBoard(null)}
>
<BoardRowCells b={b} onDirty={(id, draft) => markBoardDirty(id, draft)} />
<BoardRowCells
b={b}
onDirty={(id, draft) => 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;
}}
/>
</DraggableRow>
))}
</tbody>
@@ -286,12 +476,54 @@ export default function AdminBoardsPage() {
);
}
function BoardRowCells({ b, onDirty }: { b: any; onDirty: (id: string, draft: any) => void }) {
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<string | null>; defaultMainTypeId?: string | null; defaultListTypeId?: string | null }) {
const [edit, setEdit] = useState(b);
const effectiveMainTypeId = edit.mainPageViewTypeId ?? defaultMainTypeId ?? '';
const effectiveListTypeId = edit.listViewTypeId ?? defaultListTypeId ?? '';
return (
<>
<td className="px-3 py-2"><input className="h-9 w-full rounded-md border border-neutral-300 px-2 text-sm" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2"><input className="h-9 w-full rounded-md border border-neutral-300 px-2 text-sm" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center">
<select
className="h-9 rounded-md border border-neutral-300 px-2 text-sm"
value={effectiveMainTypeId}
onChange={async (e) => {
if (e.target.value === '__add__') {
const id = (await onAddType?.('main')) ?? null;
if (id) { const v = { ...edit, mainPageViewTypeId: id }; setEdit(v); onDirty(b.id, v); }
e.currentTarget.value = id ?? '';
return;
}
const v = { ...edit, mainPageViewTypeId: e.target.value || null };
setEdit(v); onDirty(b.id, v);
}}
>
<option value="">()</option>
{(mainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
<option value="__add__">+ </option>
</select>
</td>
<td className="px-3 py-2 text-center">
<select
className="h-9 rounded-md border border-neutral-300 px-2 text-sm"
value={effectiveListTypeId}
onChange={async (e) => {
if (e.target.value === '__add__') {
const id = (await onAddType?.('list')) ?? null;
if (id) { const v = { ...edit, listViewTypeId: id }; setEdit(v); onDirty(b.id, v); }
e.currentTarget.value = id ?? '';
return;
}
const v = { ...edit, listViewTypeId: e.target.value || null };
setEdit(v); onDirty(b.id, v);
}}
>
<option value="">()</option>
{(listTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
<option value="__add__">+ </option>
</select>
</td>
<td className="px-3 py-2 text-center">
<select className="h-9 rounded-md border border-neutral-300 px-2 text-sm" value={edit.readLevel} onChange={(e) => { const v = { ...edit, readLevel: e.target.value }; setEdit(v); onDirty(b.id, v); }}>
<option value="public">public</option>
@@ -318,105 +550,38 @@ function BoardRowCells({ b, onDirty }: { b: any; onDirty: (id: string, draft: an
</select>
</td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
{allowMove && categories && onMove ? (
<td className="px-3 py-2 text-center">
<select className="h-9 rounded-md border border-neutral-300 px-2 text-sm" defaultValue="" onChange={(e) => { if (e.target.value) onMove(e.target.value); }}>
<option value="" disabled> </option>
{categories.map((c: any) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</td>
) : null}
<td className="px-3 py-2 text-center"><input type="checkbox" checked={(edit.status ?? 'active') !== 'hidden'} onChange={(e) => { const v = { ...edit, status: e.target.checked ? 'active' : 'hidden' }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center">
<button type="button" className="h-8 px-3 rounded-md border border-red-300 text-sm text-red-700 hover:bg-red-50" onClick={onDelete}></button>
</td>
</>
);
}
function DraggableRow({ index, onMove, children }: { index: number; onMove: (from: number, to: number) => void; children: React.ReactNode }) {
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 }) {
return (
<tr
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;
});
}
}}
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"
>
<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 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);
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>
<td className="px-2 py-2 w-8 text-center text-neutral-500">{index + 1}</td>
{children}