관리자페이지 작업
This commit is contained in:
55
prisma/migrations/20251031063728_/migration.sql
Normal file
55
prisma/migrations/20251031063728_/migration.sql
Normal 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");
|
||||||
11
prisma/migrations/20251031070529_/migration.sql
Normal file
11
prisma/migrations/20251031070529_/migration.sql
Normal 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");
|
||||||
@@ -141,6 +141,12 @@ model Board {
|
|||||||
categoryId String?
|
categoryId String?
|
||||||
category BoardCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
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[]
|
posts Post[]
|
||||||
moderators BoardModerator[]
|
moderators BoardModerator[]
|
||||||
|
|
||||||
@@ -150,9 +156,29 @@ model Board {
|
|||||||
@@index([status, sortOrder])
|
@@index([status, sortOrder])
|
||||||
@@index([type, requiresApproval])
|
@@index([type, requiresApproval])
|
||||||
@@index([categoryId])
|
@@index([categoryId])
|
||||||
|
@@index([mainPageViewTypeId])
|
||||||
|
@@index([listViewTypeId])
|
||||||
@@map("boards")
|
@@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 {
|
model BoardModerator {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -703,3 +729,14 @@ model PartnerRequest {
|
|||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
@@map("partner_requests")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -207,6 +207,28 @@ async function upsertBoards(admin, categoryMap) {
|
|||||||
return created;
|
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() {
|
async function seedPolicies() {
|
||||||
// 금칙어 예시
|
// 금칙어 예시
|
||||||
const banned = [
|
const banned = [
|
||||||
@@ -241,6 +263,7 @@ async function main() {
|
|||||||
await upsertRoles();
|
await upsertRoles();
|
||||||
const admin = await upsertAdmin();
|
const admin = await upsertAdmin();
|
||||||
const categoryMap = await upsertCategories();
|
const categoryMap = await upsertCategories();
|
||||||
|
await upsertViewTypes();
|
||||||
const boards = await upsertBoards(admin, categoryMap);
|
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 |
@@ -9,6 +9,7 @@ const navItems = [
|
|||||||
{ href: "/admin/users", label: "사용자" },
|
{ href: "/admin/users", label: "사용자" },
|
||||||
{ href: "/admin/logs", label: "로그" },
|
{ href: "/admin/logs", label: "로그" },
|
||||||
{ href: "/admin/banners", label: "배너" },
|
{ href: "/admin/banners", label: "배너" },
|
||||||
|
{ href: "/admin/mainpage-settings", label: "메인페이지 설정" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminSidebar() {
|
export default function AdminSidebar() {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useMemo, useState, useEffect, useRef } from "react";
|
import { useMemo, useState, useEffect, useRef } from "react";
|
||||||
|
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||||
|
|
||||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
export default function AdminBoardsPage() {
|
export default function AdminBoardsPage() {
|
||||||
|
const { show } = useToast();
|
||||||
const { data: boardsResp, mutate: mutateBoards } = useSWR<{ boards: any[] }>("/api/admin/boards", fetcher);
|
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 { data: catsResp, mutate: mutateCats } = useSWR<{ categories: any[] }>("/api/admin/categories", fetcher);
|
||||||
const rawBoards = boardsResp?.boards ?? [];
|
const rawBoards = boardsResp?.boards ?? [];
|
||||||
const rawCategories = catsResp?.categories ?? [];
|
const rawCategories = catsResp?.categories ?? [];
|
||||||
@@ -19,9 +22,17 @@ export default function AdminBoardsPage() {
|
|||||||
const [catOrder, setCatOrder] = useState<string[]>([]);
|
const [catOrder, setCatOrder] = useState<string[]>([]);
|
||||||
const [draggingCatIndex, setDraggingCatIndex] = useState<number | null>(null);
|
const [draggingCatIndex, setDraggingCatIndex] = useState<number | null>(null);
|
||||||
const catRefs = useRef<Record<string, HTMLLIElement | 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(() => {
|
const boards = useMemo(() => {
|
||||||
return rawBoards.map((b: any) => ({ ...b, ...(dirtyBoards[b.id] ?? {}) }));
|
return rawBoards.map((b: any) => ({ ...b, ...(dirtyBoards[b.id] ?? {}) }));
|
||||||
}, [rawBoards, dirtyBoards]);
|
}, [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 categories = useMemo(() => {
|
||||||
const merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) }));
|
const merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) }));
|
||||||
return merged.sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
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] = [];
|
if (!map[cid]) map[cid] = [];
|
||||||
map[cid].push(b);
|
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]);
|
}, [boards, orderedCats]);
|
||||||
|
|
||||||
// 최초/데이터 변경 시 표시용 카테고리 순서를 초기화
|
// 최초/데이터 변경 시 표시용 카테고리 순서를 초기화
|
||||||
@@ -104,7 +129,7 @@ export default function AdminBoardsPage() {
|
|||||||
const updated: Record<string, any> = { ...prev };
|
const updated: Record<string, any> = { ...prev };
|
||||||
nextItems.forEach((b, idx) => {
|
nextItems.forEach((b, idx) => {
|
||||||
const targetSort = idx + 1;
|
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 };
|
const baseVal = base.get(b.id) ?? { sortOrder: b.sortOrder ?? 0, categoryId: b.categoryId };
|
||||||
if (baseVal.sortOrder !== targetSort || baseVal.categoryId !== targetCat) {
|
if (baseVal.sortOrder !== targetSort || baseVal.categoryId !== targetCat) {
|
||||||
updated[b.id] = { ...(updated[b.id] ?? {}), sortOrder: targetSort, 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-xl md:text-2xl font-bold text-neutral-900">게시판 관리</h1>
|
<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="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
|
<ul
|
||||||
className="divide-y-2 divide-neutral-100"
|
className="divide-y-2 divide-neutral-100"
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
@@ -229,19 +327,50 @@ export default function AdminBoardsPage() {
|
|||||||
>
|
>
|
||||||
<div className="px-4 py-3 flex items-center gap-3">
|
<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>
|
<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 })} onDragStart={() => {
|
<CategoryHeaderContent g={g} onDirty={(payload) => markCatDirty(g.id, { ...payload })} onDragStart={() => {
|
||||||
setDraggingCatIndex(idx);
|
setDraggingCatIndex(idx);
|
||||||
}} />
|
}} />
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="toggle"
|
aria-label="toggle"
|
||||||
className="w-8 text-2xl leading-none text-neutral-700"
|
className="w-8 text-2xl leading-none text-neutral-700"
|
||||||
onClick={() => toggleCat(g.id)}
|
onClick={() => toggleCat(g.id)}
|
||||||
>{expanded[g.id] ? '▾' : '▸'}</button>
|
>{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>
|
</div>
|
||||||
|
|
||||||
{expanded[g.id] && (
|
{expanded[g.id] && (
|
||||||
<div className="overflow-x-auto border-t border-neutral-100 ml-8">
|
<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">
|
<table className="min-w-full text-sm">
|
||||||
<thead className="text-xs text-neutral-500 border-b border-neutral-200">
|
<thead className="text-xs text-neutral-500 border-b border-neutral-200">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -249,6 +378,8 @@ export default function AdminBoardsPage() {
|
|||||||
<th className="px-2 py-2 w-8 text-center">#</th>
|
<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">이름</th>
|
||||||
<th className="px-3 py-2 text-left">slug</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>
|
||||||
@@ -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>
|
<th className="px-3 py-2">대분류 이동</th>
|
||||||
|
<th className="px-3 py-2">활성</th>
|
||||||
|
<th className="px-3 py-2">삭제</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</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) => (
|
{g.items.map((b, i) => (
|
||||||
<DraggableRow
|
<DraggableRow
|
||||||
key={b.id}
|
key={b.id}
|
||||||
|
catId={g.id}
|
||||||
|
boardId={b.id}
|
||||||
index={i}
|
index={i}
|
||||||
onMove={(from, to) => {
|
setRef={(el) => { boardRefs.current[b.id] = el; }}
|
||||||
const list = [...g.items];
|
onStart={() => setDraggingBoard({ catId: g.id, index: i })}
|
||||||
const [mv] = list.splice(from, 1);
|
onEnd={() => setDraggingBoard(null)}
|
||||||
list.splice(to, 0, mv);
|
|
||||||
reorderBoards(g.id, list);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<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>
|
</DraggableRow>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</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 [edit, setEdit] = useState(b);
|
||||||
|
const effectiveMainTypeId = edit.mainPageViewTypeId ?? defaultMainTypeId ?? '';
|
||||||
|
const effectiveListTypeId = edit.listViewTypeId ?? defaultListTypeId ?? '';
|
||||||
return (
|
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.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"><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">
|
<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); }}>
|
<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="public">public</option>
|
||||||
@@ -318,105 +550,38 @@ function BoardRowCells({ b, onDirty }: { b: any; onDirty: (id: string, draft: an
|
|||||||
</select>
|
</select>
|
||||||
</td>
|
</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>
|
<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 (
|
return (
|
||||||
<tr
|
<tr ref={setRef} className="align-middle select-none">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<td
|
<td
|
||||||
className="px-2 py-2 w-8 text-xl text-neutral-500 cursor-grab"
|
className="px-2 py-2 w-8 text-xl text-neutral-500 cursor-grab"
|
||||||
title="드래그하여 순서 변경"
|
title="드래그하여 순서 변경"
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
e.dataTransfer.setData("text/plain", String(index));
|
e.dataTransfer.setData("text/plain", String(index));
|
||||||
e.dataTransfer.effectAllowed = "move";
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
const row = (e.currentTarget as HTMLElement).closest('tr') as HTMLTableRowElement;
|
const img = document.createElement('canvas'); img.width = 1; img.height = 1; e.dataTransfer.setDragImage(img, 0, 0);
|
||||||
const rect = row.getBoundingClientRect();
|
onStart();
|
||||||
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);
|
|
||||||
}}
|
}}
|
||||||
|
onDragEnd={() => { onEnd(); }}
|
||||||
>≡</td>
|
>≡</td>
|
||||||
<td className="px-2 py-2 w-8 text-center text-neutral-500">{index + 1}</td>
|
<td className="px-2 py-2 w-8 text-center text-neutral-500">{index + 1}</td>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
190
src/app/admin/mainpage-settings/page.tsx
Normal file
190
src/app/admin/mainpage-settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,7 +5,7 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
|
|||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const data: any = {};
|
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 (k in body) data[k] = body[k];
|
||||||
}
|
}
|
||||||
if ("requiredTags" in body) {
|
if ("requiredTags" in body) {
|
||||||
@@ -18,4 +18,11 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
|
|||||||
return NextResponse.json({ board: updated });
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const boards = await prisma.board.findMany({
|
const boards = await prisma.board.findMany({
|
||||||
|
where: { NOT: { status: 'archived' } },
|
||||||
orderBy: { sortOrder: "asc" },
|
orderBy: { sortOrder: "asc" },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -18,10 +20,46 @@ export async function GET() {
|
|||||||
type: true,
|
type: true,
|
||||||
status: true,
|
status: true,
|
||||||
categoryId: true,
|
categoryId: true,
|
||||||
|
mainPageViewTypeId: true,
|
||||||
|
listViewTypeId: true,
|
||||||
category: { select: { id: true, name: true, slug: true } },
|
category: { select: { id: true, name: true, slug: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json({ boards });
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
25
src/app/api/admin/mainpage-settings/route.ts
Normal file
25
src/app/api/admin/mainpage-settings/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
||||||
19
src/app/api/admin/view-types/[id]/route.ts
Normal file
19
src/app/api/admin/view-types/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
26
src/app/api/admin/view-types/route.ts
Normal file
26
src/app/api/admin/view-types/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4,9 +4,11 @@ import prisma from "@/lib/prisma";
|
|||||||
// 대분류(BoardCategory)와 소분류(Board)를 함께 반환
|
// 대분류(BoardCategory)와 소분류(Board)를 함께 반환
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const categories = await prisma.boardCategory.findMany({
|
const categories = await prisma.boardCategory.findMany({
|
||||||
|
where: { status: "active" },
|
||||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
include: {
|
include: {
|
||||||
boards: {
|
boards: {
|
||||||
|
where: { status: "active" },
|
||||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
select: { id: true, name: true, slug: true, requiresApproval: true, type: true },
|
select: { id: true, name: true, slug: true, requiresApproval: true, type: true },
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user