관리자페이지 작업

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

@@ -0,0 +1,55 @@
-- CreateTable
CREATE TABLE "board_view_types" (
"id" TEXT NOT NULL PRIMARY KEY,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_boards" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'active',
"type" TEXT NOT NULL DEFAULT 'general',
"requiresApproval" BOOLEAN NOT NULL DEFAULT false,
"allowAnonymousPost" BOOLEAN NOT NULL DEFAULT false,
"allowSecretComment" BOOLEAN NOT NULL DEFAULT false,
"isAdultOnly" BOOLEAN NOT NULL DEFAULT false,
"requiredTags" JSONB,
"requiredFields" JSONB,
"readLevel" TEXT NOT NULL DEFAULT 'public',
"writeLevel" TEXT NOT NULL DEFAULT 'member',
"categoryId" TEXT,
"mainPageViewTypeId" TEXT,
"listViewTypeId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "boards_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "board_categories" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "boards_mainPageViewTypeId_fkey" FOREIGN KEY ("mainPageViewTypeId") REFERENCES "board_view_types" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "boards_listViewTypeId_fkey" FOREIGN KEY ("listViewTypeId") REFERENCES "board_view_types" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_boards" ("allowAnonymousPost", "allowSecretComment", "categoryId", "createdAt", "description", "id", "isAdultOnly", "name", "readLevel", "requiredFields", "requiredTags", "requiresApproval", "slug", "sortOrder", "status", "type", "updatedAt", "writeLevel") SELECT "allowAnonymousPost", "allowSecretComment", "categoryId", "createdAt", "description", "id", "isAdultOnly", "name", "readLevel", "requiredFields", "requiredTags", "requiresApproval", "slug", "sortOrder", "status", "type", "updatedAt", "writeLevel" FROM "boards";
DROP TABLE "boards";
ALTER TABLE "new_boards" RENAME TO "boards";
CREATE UNIQUE INDEX "boards_slug_key" ON "boards"("slug");
CREATE INDEX "boards_status_sortOrder_idx" ON "boards"("status", "sortOrder");
CREATE INDEX "boards_type_requiresApproval_idx" ON "boards"("type", "requiresApproval");
CREATE INDEX "boards_categoryId_idx" ON "boards"("categoryId");
CREATE INDEX "boards_mainPageViewTypeId_idx" ON "boards"("mainPageViewTypeId");
CREATE INDEX "boards_listViewTypeId_idx" ON "boards"("listViewTypeId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "board_view_types_key_key" ON "board_view_types"("key");
-- CreateIndex
CREATE INDEX "board_view_types_scope_idx" ON "board_view_types"("scope");

View File

@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "settings" (
"id" TEXT NOT NULL PRIMARY KEY,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "settings_key_key" ON "settings"("key");

View File

@@ -141,6 +141,12 @@ model Board {
categoryId String?
category BoardCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
// 뷰 타입 설정 (동적 테이블 참조)
mainPageViewTypeId String?
mainPageViewType BoardViewType? @relation("MainPageViewType", fields: [mainPageViewTypeId], references: [id], onDelete: SetNull)
listViewTypeId String?
listViewType BoardViewType? @relation("ListViewType", fields: [listViewTypeId], references: [id], onDelete: SetNull)
posts Post[]
moderators BoardModerator[]
@@ -150,9 +156,29 @@ model Board {
@@index([status, sortOrder])
@@index([type, requiresApproval])
@@index([categoryId])
@@index([mainPageViewTypeId])
@@index([listViewTypeId])
@@map("boards")
}
// 게시판 뷰 타입 정의 (enum 대신 데이터 테이블)
model BoardViewType {
id String @id @default(cuid())
key String @unique // 예: preview, text, special_rank
name String // 표시용 이름
scope String // 용도 구분: 'main' | 'list' 등 자유 텍스트
// 역참조
mainBoards Board[] @relation("MainPageViewType")
listBoards Board[] @relation("ListViewType")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([scope])
@@map("board_view_types")
}
// 게시판 운영진 매핑
model BoardModerator {
id String @id @default(cuid())
@@ -703,3 +729,14 @@ model PartnerRequest {
@@index([status, createdAt])
@@map("partner_requests")
}
// 시스템 설정 (key-value 형태)
model Setting {
id String @id @default(cuid())
key String @unique
value String // JSON 문자열로 저장
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("settings")
}

View File

@@ -207,6 +207,28 @@ async function upsertBoards(admin, categoryMap) {
return created;
}
async function upsertViewTypes() {
const viewTypes = [
// main scope
{ key: "main_default", name: "기본", scope: "main" },
{ key: "main_text", name: "텍스트", scope: "main" },
{ key: "main_preview", name: "미리보기", scope: "main" },
{ key: "main_special_rank", name: "특수랭킹", scope: "main" },
// list scope
{ key: "list_default", name: "기본", scope: "list" },
{ key: "list_text", name: "텍스트", scope: "list" },
{ key: "list_preview", name: "미리보기", scope: "list" },
{ key: "list_special_rank", name: "특수랭킹", scope: "list" },
];
for (const vt of viewTypes) {
await prisma.boardViewType.upsert({
where: { key: vt.key },
update: { name: vt.name, scope: vt.scope },
create: vt,
});
}
}
async function seedPolicies() {
// 금칙어 예시
const banned = [
@@ -241,6 +263,7 @@ async function main() {
await upsertRoles();
const admin = await upsertAdmin();
const categoryMap = await upsertCategories();
await upsertViewTypes();
const boards = await upsertBoards(admin, categoryMap);
// 샘플 글 하나

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 795 KiB

After

Width:  |  Height:  |  Size: 829 KiB

View File

@@ -9,6 +9,7 @@ const navItems = [
{ href: "/admin/users", label: "사용자" },
{ href: "/admin/logs", label: "로그" },
{ href: "/admin/banners", label: "배너" },
{ href: "/admin/mainpage-settings", label: "메인페이지 설정" },
];
export default function AdminSidebar() {

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}

View File

@@ -0,0 +1,190 @@
"use client";
import useSWR from "swr";
import { useState, useEffect } from "react";
import { useToast } from "@/app/components/ui/ToastProvider";
import { Modal } from "@/app/components/ui/Modal";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function MainPageSettingsPage() {
const { show } = useToast();
const { data: settingsResp, mutate: mutateSettings } = useSWR<{ settings: any }>("/api/admin/mainpage-settings", fetcher);
const { data: boardsResp } = useSWR<{ boards: any[] }>("/api/admin/boards", fetcher);
const { data: catsResp } = useSWR<{ categories: any[] }>("/api/admin/categories", fetcher);
const settings = settingsResp?.settings ?? {};
const boards = boardsResp?.boards ?? [];
const categories = catsResp?.categories ?? [];
const [saving, setSaving] = useState(false);
const [showBoardModal, setShowBoardModal] = useState(false);
const [draft, setDraft] = useState({
showBanner: settings.showBanner ?? true,
showPartnerShops: settings.showPartnerShops ?? true,
visibleBoardIds: settings.visibleBoardIds ?? [],
});
// settings가 로드되면 draft 동기화
useEffect(() => {
if (settingsResp?.settings) {
setDraft({
showBanner: settings.showBanner ?? true,
showPartnerShops: settings.showPartnerShops ?? true,
visibleBoardIds: settings.visibleBoardIds ?? [],
});
}
}, [settingsResp, settings]);
async function save() {
setSaving(true);
try {
const res = await fetch("/api/admin/mainpage-settings", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(draft),
});
if (!res.ok) throw new Error("save_failed");
const data = await res.json();
// 저장된 설정으로 즉시 업데이트
await mutateSettings({ settings: data.settings }, { revalidate: false });
show("저장되었습니다.");
} catch (e) {
console.error(e);
show("저장 중 오류가 발생했습니다.");
} finally {
setSaving(false);
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl md:text-2xl font-bold text-neutral-900"> </h1>
<button
onClick={save}
disabled={saving}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow disabled:opacity-60"
>
{saving ? "저장 중..." : "저장"}
</button>
</div>
<div className="space-y-4">
{/* 배너 표시 */}
<div className="rounded-xl border border-neutral-200 bg-white p-4">
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
checked={draft.showBanner}
onChange={(e) => setDraft({ ...draft, showBanner: e.target.checked })}
/>
</label>
</div>
{/* 제휴 샾 목록 표시 */}
<div className="rounded-xl border border-neutral-200 bg-white p-4">
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
checked={draft.showPartnerShops}
onChange={(e) => setDraft({ ...draft, showPartnerShops: e.target.checked })}
/>
</label>
</div>
{/* 보일 게시판 선택 */}
<div className="rounded-xl border border-neutral-200 bg-white p-4">
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-medium"> </label>
<button
type="button"
onClick={() => setShowBoardModal(true)}
className="h-8 px-3 rounded-md border border-neutral-300 text-sm hover:bg-neutral-50"
>
</button>
</div>
<div className="space-y-2 min-h-[60px]">
{draft.visibleBoardIds.length === 0 ? (
<p className="text-sm text-neutral-400 py-4 text-center"> .</p>
) : (
draft.visibleBoardIds.map((boardId: string) => {
const board = boards.find((b: any) => b.id === boardId);
if (!board) return null;
const category = categories.find((c: any) => c.id === board.categoryId);
return (
<div key={boardId} className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-md">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{board.name}</span>
{category && (
<span className="text-xs text-neutral-500">({category.name})</span>
)}
</div>
<button
type="button"
onClick={() => {
setDraft({ ...draft, visibleBoardIds: draft.visibleBoardIds.filter((id: string) => id !== boardId) });
}}
className="text-xs text-red-600 hover:text-red-800"
>
</button>
</div>
);
})
)}
</div>
</div>
</div>
{/* 게시판 선택 모달 */}
<Modal open={showBoardModal} onClose={() => setShowBoardModal(false)}>
<div className="w-[600px] max-w-[90vw] max-h-[80vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-lg font-bold mb-4"> </h2>
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{boards
.filter((b: any) => !draft.visibleBoardIds.includes(b.id))
.map((b: any) => {
const category = categories.find((c: any) => c.id === b.categoryId);
return (
<button
key={b.id}
type="button"
onClick={() => {
setDraft({ ...draft, visibleBoardIds: [...draft.visibleBoardIds, b.id] });
setShowBoardModal(false);
}}
className="w-full text-left px-3 py-2 rounded-md border border-neutral-200 hover:bg-neutral-50 transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{b.name}</span>
{category && (
<span className="text-xs text-neutral-500">({category.name})</span>
)}
</div>
</button>
);
})}
{boards.filter((b: any) => !draft.visibleBoardIds.includes(b.id)).length === 0 && (
<p className="text-sm text-neutral-400 py-4 text-center"> .</p>
)}
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={() => setShowBoardModal(false)}
className="h-9 px-4 rounded-md border border-neutral-300 text-sm hover:bg-neutral-50"
>
</button>
</div>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -5,7 +5,7 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
const { id } = await context.params;
const body = await req.json().catch(() => ({}));
const data: any = {};
for (const k of ["name", "slug", "description", "sortOrder", "readLevel", "writeLevel", "allowAnonymousPost", "allowSecretComment", "requiresApproval", "status", "type", "isAdultOnly", "categoryId"]) {
for (const k of ["name", "slug", "description", "sortOrder", "readLevel", "writeLevel", "allowAnonymousPost", "allowSecretComment", "requiresApproval", "status", "type", "isAdultOnly", "categoryId", "mainPageViewTypeId", "listViewTypeId"]) {
if (k in body) data[k] = body[k];
}
if ("requiredTags" in body) {
@@ -18,4 +18,11 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
return NextResponse.json({ board: updated });
}
export async function DELETE(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
// Soft delete: mark as archived instead of physical deletion
const updated = await prisma.board.update({ where: { id }, data: { status: 'archived' } });
return NextResponse.json({ board: updated });
}

View File

@@ -1,8 +1,10 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
export async function GET() {
const boards = await prisma.board.findMany({
where: { NOT: { status: 'archived' } },
orderBy: { sortOrder: "asc" },
select: {
id: true,
@@ -18,10 +20,46 @@ export async function GET() {
type: true,
status: true,
categoryId: true,
mainPageViewTypeId: true,
listViewTypeId: true,
category: { select: { id: true, name: true, slug: true } },
},
});
return NextResponse.json({ boards });
}
const createSchema = z.object({
name: z.string().min(1),
slug: z.string().min(1),
description: z.string().optional(),
sortOrder: z.coerce.number().int().optional(),
readLevel: z.string().optional(),
writeLevel: z.string().optional(),
allowAnonymousPost: z.boolean().optional(),
allowSecretComment: z.boolean().optional(),
requiresApproval: z.boolean().optional(),
status: z.string().optional(),
type: z.string().optional(),
isAdultOnly: z.boolean().optional(),
categoryId: z.string().nullable().optional(),
});
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const data = parsed.data;
// sortOrder 기본: 같은 카테고리 내 마지막 다음 순서
let sortOrder = data.sortOrder;
if (!sortOrder) {
const max = await prisma.board.aggregate({
_max: { sortOrder: true },
where: { categoryId: data.categoryId ?? undefined },
});
sortOrder = (max._max.sortOrder ?? 0) + 1;
}
const created = await prisma.board.create({ data: { ...data, sortOrder } });
return NextResponse.json({ board: created }, { status: 201 });
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
const SETTINGS_KEY = "mainpage_settings";
export async function GET() {
const setting = await prisma.setting.findUnique({
where: { key: SETTINGS_KEY },
});
const settings = setting ? JSON.parse(setting.value) : {};
return NextResponse.json({ settings }, { headers: { "Cache-Control": "no-store" } });
}
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const value = JSON.stringify(body);
const updated = await prisma.setting.upsert({
where: { key: SETTINGS_KEY },
update: { value },
create: { key: SETTINGS_KEY, value },
});
const settings = JSON.parse(updated.value);
return NextResponse.json({ settings, ok: true });
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
const body = await req.json().catch(() => ({}));
const data: any = {};
for (const k of ['key', 'name', 'scope']) { if (k in body) data[k] = body[k]; }
const updated = await prisma.boardViewType.update({ where: { id }, data });
return NextResponse.json({ item: updated });
}
export async function DELETE(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
await prisma.boardViewType.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
export async function GET() {
const items = await prisma.boardViewType.findMany({ orderBy: [{ scope: 'asc' }, { name: 'asc' }] });
return NextResponse.json({ items });
}
const createSchema = z.object({
key: z.string().min(1),
name: z.string().min(1),
scope: z.string().min(1), // 'main' | 'list' 등 자유 텍스트
});
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const exists = await prisma.boardViewType.findFirst({ where: { key: parsed.data.key } });
if (exists) return NextResponse.json({ error: 'duplicate_key' }, { status: 409 });
const created = await prisma.boardViewType.create({ data: parsed.data });
return NextResponse.json({ item: created }, { status: 201 });
}

View File

@@ -4,9 +4,11 @@ import prisma from "@/lib/prisma";
// 대분류(BoardCategory)와 소분류(Board)를 함께 반환
export async function GET() {
const categories = await prisma.boardCategory.findMany({
where: { status: "active" },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
include: {
boards: {
where: { status: "active" },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: { id: true, name: true, slug: true, requiresApproval: true, type: true },
},