admin page
This commit is contained in:
@@ -1,44 +1,109 @@
|
||||
"use client";
|
||||
import useSWR from "swr";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export default function AdminBoardsPage() {
|
||||
const { data, mutate } = useSWR<{ boards: any[] }>("/api/admin/boards", fetcher);
|
||||
const boards = data?.boards ?? [];
|
||||
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 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);
|
||||
}
|
||||
return categories.map((c: any) => ({ ...c, items: (map[c.id] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) }));
|
||||
}, [boards, categories]);
|
||||
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
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);
|
||||
mutate();
|
||||
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: 보드 순서 변경 (카테고리 내부)
|
||||
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();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>게시판 설정</h1>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
<th>slug</th>
|
||||
<th>읽기</th>
|
||||
<th>쓰기</th>
|
||||
<th>익명</th>
|
||||
<th>비밀댓</th>
|
||||
<th>승인</th>
|
||||
<th>유형</th>
|
||||
<th>성인</th>
|
||||
<th>정렬</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{boards.map((b) => (
|
||||
<Row key={b.id} b={b} onSave={save} saving={savingId === b.id} />
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-neutral-900">게시판 관리</h1>
|
||||
{/* 대분류 리스트 (드래그로 순서 변경) */}
|
||||
<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>
|
||||
<ul className="divide-y divide-neutral-100">
|
||||
{groups.map((g, idx) => (
|
||||
<CategoryRow key={g.id} idx={idx} g={g} onMove={(from, to) => {
|
||||
const arr = [...groups];
|
||||
const [moved] = arr.splice(from, 1);
|
||||
arr.splice(to, 0, moved);
|
||||
reorderCategories(arr);
|
||||
}} onSave={async (payload) => {
|
||||
await fetch(`/api/admin/categories/${g.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
||||
mutateCats();
|
||||
}} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{groups.map((g) => (
|
||||
<section key={g.id} className="rounded-xl border border-neutral-200 overflow-hidden bg-white">
|
||||
<div className="px-4 py-2 border-b border-neutral-200 flex items-center justify-between">
|
||||
<div className="text-sm font-semibold">대분류: {g.name}</div>
|
||||
<div className="text-xs text-neutral-500">slug: {g.slug}</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-xs text-neutral-500 border-b border-neutral-200">
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{g.items.map((b, i) => (
|
||||
<DraggableRow
|
||||
key={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);
|
||||
}}
|
||||
>
|
||||
<Row b={b} onSave={save} saving={savingId === b.id} />
|
||||
</DraggableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,39 +111,88 @@ export default function AdminBoardsPage() {
|
||||
function Row({ b, onSave, saving }: { b: any; onSave: (b: any) => void; saving: boolean }) {
|
||||
const [edit, setEdit] = useState(b);
|
||||
return (
|
||||
<tr>
|
||||
<td><input value={edit.name} onChange={(e) => setEdit({ ...edit, name: e.target.value })} /></td>
|
||||
<td><input value={edit.slug} onChange={(e) => setEdit({ ...edit, slug: e.target.value })} /></td>
|
||||
<td>
|
||||
<select value={edit.readLevel} onChange={(e) => setEdit({ ...edit, readLevel: e.target.value })}>
|
||||
<tr className="align-middle">
|
||||
<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) => setEdit({ ...edit, name: e.target.value })} /></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) => setEdit({ ...edit, slug: e.target.value })} /></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) => setEdit({ ...edit, readLevel: e.target.value })}>
|
||||
<option value="public">public</option>
|
||||
<option value="member">member</option>
|
||||
<option value="moderator">moderator</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select value={edit.writeLevel} onChange={(e) => setEdit({ ...edit, writeLevel: e.target.value })}>
|
||||
<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) => setEdit({ ...edit, writeLevel: e.target.value })}>
|
||||
<option value="public">public</option>
|
||||
<option value="member">member</option>
|
||||
<option value="moderator">moderator</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => setEdit({ ...edit, allowAnonymousPost: e.target.checked })} /></td>
|
||||
<td><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => setEdit({ ...edit, allowSecretComment: e.target.checked })} /></td>
|
||||
<td><input type="checkbox" checked={edit.requiresApproval} onChange={(e) => setEdit({ ...edit, requiresApproval: e.target.checked })} /></td>
|
||||
<td>
|
||||
<select value={edit.type} onChange={(e) => setEdit({ ...edit, type: e.target.value })}>
|
||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => setEdit({ ...edit, allowAnonymousPost: e.target.checked })} /></td>
|
||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => setEdit({ ...edit, allowSecretComment: e.target.checked })} /></td>
|
||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.requiresApproval} onChange={(e) => setEdit({ ...edit, requiresApproval: e.target.checked })} /></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.type} onChange={(e) => setEdit({ ...edit, type: e.target.value })}>
|
||||
<option value="general">general</option>
|
||||
<option value="special">special</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => setEdit({ ...edit, isAdultOnly: e.target.checked })} /></td>
|
||||
<td><input type="number" value={edit.sortOrder} onChange={(e) => setEdit({ ...edit, sortOrder: Number(e.target.value) })} style={{ width: 80 }} /></td>
|
||||
<td><button onClick={() => onSave(edit)} disabled={saving}>{saving ? "저장중" : "저장"}</button></td>
|
||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => setEdit({ ...edit, isAdultOnly: e.target.checked })} /></td>
|
||||
<td className="px-3 py-2 text-center"><input className="h-9 w-20 rounded-md border border-neutral-300 px-2 text-sm" type="number" value={edit.sortOrder} onChange={(e) => setEdit({ ...edit, sortOrder: Number(e.target.value) })} /></td>
|
||||
<td className="px-3 py-2 text-right"><button className="h-9 px-3 rounded-md bg-neutral-900 text-white text-sm disabled:opacity-60" onClick={() => onSave(edit)} disabled={saving}>{saving ? "저장중" : "저장"}</button></td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function DraggableRow({ index, onMove, children }: { index: number; onMove: (from: number, to: number) => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<tr
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const from = Number(e.dataTransfer.getData("text/plain"));
|
||||
const to = index;
|
||||
if (!Number.isNaN(from) && from !== to) onMove(from, to);
|
||||
}}
|
||||
className="align-middle"
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryRow({ idx, g, onMove, onSave }: { idx: number; g: any; onMove: (from: number, to: number) => void; onSave: (payload: any) => void }) {
|
||||
const [edit, setEdit] = useState({ name: g.name, slug: g.slug });
|
||||
return (
|
||||
<li
|
||||
className="px-4 py-3 flex items-center gap-3"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", String(idx));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const from = Number(e.dataTransfer.getData("text/plain"));
|
||||
const to = idx;
|
||||
if (!Number.isNaN(from) && from !== to) onMove(from, to);
|
||||
}}
|
||||
>
|
||||
<div className="w-6 text-xs text-neutral-500">≡</div>
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.name} onChange={(e) => setEdit({ ...edit, name: e.target.value })} />
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.slug} onChange={(e) => setEdit({ ...edit, slug: e.target.value })} />
|
||||
<div className="flex-1" />
|
||||
<button className="h-8 px-3 rounded-md border border-neutral-300 text-sm hover:bg-neutral-100" onClick={() => onSave(edit)}>수정</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user