; defaultMainTypeId?: string | null; defaultListTypeId?: string | null }) {
const [edit, setEdit] = useState(b);
+ const effectiveMainTypeId = edit.mainPageViewTypeId ?? defaultMainTypeId ?? '';
+ const effectiveListTypeId = edit.listViewTypeId ?? defaultListTypeId ?? '';
return (
<>
| { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /> |
{ const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /> |
+
+
+ |
+
+
+ |
|
{ const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /> |
-
+ {allowMove && categories && onMove ? (
+
+
+ |
+ ) : null}
+ { const v = { ...edit, status: e.target.checked ? 'active' : 'hidden' }; setEdit(v); onDirty(b.id, v); }} /> |
+
+
+ |
>
);
}
-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 (
- {
- // 수직만 허용: 기본 동작만 유지하고 수평 제스처는 무시
- 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"
- >
+
| {
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(); }}
>≡ |
{index + 1} |
{children}
diff --git a/src/app/admin/mainpage-settings/page.tsx b/src/app/admin/mainpage-settings/page.tsx
new file mode 100644
index 0000000..0aec83a
--- /dev/null
+++ b/src/app/admin/mainpage-settings/page.tsx
@@ -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 (
+
+
+
메인페이지 설정
+
+
+
+
+ {/* 배너 표시 */}
+
+
+
+
+ {/* 제휴 샾 목록 표시 */}
+
+
+
+
+ {/* 보일 게시판 선택 */}
+
+
+
+
+
+
+ {draft.visibleBoardIds.length === 0 ? (
+
선택된 게시판이 없습니다.
+ ) : (
+ 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 (
+
+
+ {board.name}
+ {category && (
+ ({category.name})
+ )}
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ {/* 게시판 선택 모달 */}
+
setShowBoardModal(false)}>
+
+
+
게시판 선택
+
+ {boards
+ .filter((b: any) => !draft.visibleBoardIds.includes(b.id))
+ .map((b: any) => {
+ const category = categories.find((c: any) => c.id === b.categoryId);
+ return (
+
+ );
+ })}
+ {boards.filter((b: any) => !draft.visibleBoardIds.includes(b.id)).length === 0 && (
+
추가할 수 있는 게시판이 없습니다.
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
diff --git a/src/app/api/admin/boards/[id]/route.ts b/src/app/api/admin/boards/[id]/route.ts
index dc87a7f..2111141 100644
--- a/src/app/api/admin/boards/[id]/route.ts
+++ b/src/app/api/admin/boards/[id]/route.ts
@@ -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 });
+}
+
diff --git a/src/app/api/admin/boards/route.ts b/src/app/api/admin/boards/route.ts
index a5014a2..8e2c63f 100644
--- a/src/app/api/admin/boards/route.ts
+++ b/src/app/api/admin/boards/route.ts
@@ -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 });
+}
+
diff --git a/src/app/api/admin/mainpage-settings/route.ts b/src/app/api/admin/mainpage-settings/route.ts
new file mode 100644
index 0000000..b301d80
--- /dev/null
+++ b/src/app/api/admin/mainpage-settings/route.ts
@@ -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 });
+}
+
diff --git a/src/app/api/admin/view-types/[id]/route.ts b/src/app/api/admin/view-types/[id]/route.ts
new file mode 100644
index 0000000..38b0868
--- /dev/null
+++ b/src/app/api/admin/view-types/[id]/route.ts
@@ -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 });
+}
+
+
diff --git a/src/app/api/admin/view-types/route.ts b/src/app/api/admin/view-types/route.ts
new file mode 100644
index 0000000..084daac
--- /dev/null
+++ b/src/app/api/admin/view-types/route.ts
@@ -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 });
+}
+
+
diff --git a/src/app/api/categories/route.ts b/src/app/api/categories/route.ts
index 55fe64e..d74c8b9 100644
--- a/src/app/api/categories/route.ts
+++ b/src/app/api/categories/route.ts
@@ -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 },
},