570 lines
29 KiB
TypeScript
570 lines
29 KiB
TypeScript
"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<string | null>(null);
|
|
const [dirtyBoards, setDirtyBoards] = useState<Record<string, any>>({});
|
|
const [dirtyCats, setDirtyCats] = useState<Record<string, any>>({});
|
|
const [savingAll, setSavingAll] = useState(false);
|
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
const dirtyCount = Object.keys(dirtyBoards).length + Object.keys(dirtyCats).length;
|
|
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 textMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_text')?.id ?? null), [mainTypes]);
|
|
const textListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_text')?.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<string, any[]> = {};
|
|
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<string, number>();
|
|
// 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<string, any> = { ...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<string, { sortOrder: number; categoryId: string | null | undefined }>();
|
|
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<string, any> = { ...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: textMainTypeId, listViewTypeId: textListTypeId }) });
|
|
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>
|
|
<div className="sticky top-2 z-10 flex justify-end">
|
|
<button
|
|
onClick={saveAll}
|
|
disabled={savingAll || dirtyCount === 0}
|
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow disabled:opacity-60"
|
|
>{savingAll ? "저장 중..." : `변경사항 저장 (${dirtyCount})`}</button>
|
|
</div>
|
|
|
|
{/* 대분류(헤더)와 소분류(보드 테이블)를 하나의 리스트로 통합, 대분류별 토글 */}
|
|
<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 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">
|
|
{groups.map((g, idx) => (
|
|
<li key={g.id} className="select-none">
|
|
<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>
|
|
{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 })} />
|
|
)}
|
|
{g.id !== 'uncat' && (
|
|
<div className="flex items-center gap-1 ml-2">
|
|
<button
|
|
type="button"
|
|
className="h-7 w-7 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
|
|
aria-label="대분류 위로"
|
|
disabled={(catOrder.length ? catOrder.indexOf(g.id) : categories.map((c:any)=>c.id).indexOf(g.id)) === 0}
|
|
onClick={() => moveCategory(g.id, -1)}
|
|
>↑</button>
|
|
<button
|
|
type="button"
|
|
className="h-7 w-7 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
|
|
aria-label="대분류 아래로"
|
|
disabled={(catOrder.length ? catOrder.indexOf(g.id) : categories.map((c:any)=>c.id).indexOf(g.id)) === ( (catOrder.length ? catOrder.length : categories.length) - 1)}
|
|
onClick={() => moveCategory(g.id, 1)}
|
|
>↓</button>
|
|
</div>
|
|
)}
|
|
<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>
|
|
<th className="px-2 py-2 w-8 text-center">#</th>
|
|
<th className="px-2 py-2 w-16 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>
|
|
<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">
|
|
{g.items.map((b, i) => (
|
|
<DraggableRow
|
|
key={b.id}
|
|
catId={g.id}
|
|
boardId={b.id}
|
|
index={i}
|
|
totalCount={g.items.length}
|
|
onMoveIndex={(delta) => {
|
|
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);
|
|
}}
|
|
>
|
|
<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>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 selectableMainTypes = (mainTypes ?? []).filter((t: any) => t.id !== defaultMainTypeId);
|
|
const selectableListTypes = (listTypes ?? []).filter((t: any) => t.id !== defaultListTypeId);
|
|
// 표시 값: 현재 값이 선택 가능 목록에 없으면 첫 번째 항목을 사용
|
|
const effectiveMainTypeId = selectableMainTypes.some((t: any) => t.id === edit.mainPageViewTypeId)
|
|
? edit.mainPageViewTypeId
|
|
: (selectableMainTypes[0]?.id ?? '');
|
|
const effectiveListTypeId = selectableListTypes.some((t: any) => t.id === edit.listViewTypeId)
|
|
? edit.listViewTypeId
|
|
: (selectableListTypes[0]?.id ?? '');
|
|
return (
|
|
<>
|
|
<td className="px-3 py-2"><input className="h-9 w-full min-w-[160px] 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 min-w-[200px] 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 };
|
|
setEdit(v); onDirty(b.id, v);
|
|
}}
|
|
>
|
|
{(selectableMainTypes ?? []).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 };
|
|
setEdit(v); onDirty(b.id, v);
|
|
}}
|
|
>
|
|
{(selectableListTypes ?? []).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>
|
|
<option value="member">member</option>
|
|
<option value="moderator">moderator</option>
|
|
<option value="admin">admin</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.writeLevel} onChange={(e) => { const v = { ...edit, writeLevel: e.target.value }; setEdit(v); onDirty(b.id, v); }}>
|
|
<option value="public">public</option>
|
|
<option value="member">member</option>
|
|
<option value="moderator">moderator</option>
|
|
<option value="admin">admin</option>
|
|
</select>
|
|
</td>
|
|
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => { const v = { ...edit, allowAnonymousPost: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
|
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => { const v = { ...edit, allowSecretComment: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></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({ catId, boardId, index, totalCount, children, onMoveIndex }: { catId: string; boardId: string; index: number; totalCount: number; children: React.ReactNode; onMoveIndex: (delta: number) => void }) {
|
|
return (
|
|
<tr className="align-middle select-none">
|
|
<td className="px-2 py-2 w-8 text-center text-neutral-500">{index + 1}</td>
|
|
<td className="px-2 py-2 w-16 text-center">
|
|
<div className="inline-flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
className="h-6 w-6 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
|
|
aria-label="위로"
|
|
onClick={() => onMoveIndex(-1)}
|
|
disabled={index === 0}
|
|
>↑</button>
|
|
<button
|
|
type="button"
|
|
className="h-6 w-6 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
|
|
aria-label="아래로"
|
|
onClick={() => onMoveIndex(1)}
|
|
disabled={index === (totalCount - 1)}
|
|
>↓</button>
|
|
</div>
|
|
</td>
|
|
{children}
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
function CategoryHeaderContent({ g, onDirty }: { g: any; onDirty: (payload: any) => void }) {
|
|
const [edit, setEdit] = useState({ name: g.name, slug: g.slug });
|
|
return (
|
|
<>
|
|
<div className="w-10" />
|
|
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48 min-w-[160px]" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} />
|
|
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48 min-w-[200px]" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
|
|
<div className="flex-1" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
|