diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx new file mode 100644 index 0000000..53ab86f --- /dev/null +++ b/src/app/admin/AdminSidebar.tsx @@ -0,0 +1,41 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +const navItems = [ + { href: "/admin/menus", label: "메뉴 관리" }, + { href: "/admin/boards", label: "게시판" }, + { href: "/admin/users", label: "사용자" }, + { href: "/admin/logs", label: "로그" }, + { href: "/admin/banners", label: "배너" }, +]; + +export default function AdminSidebar() { + const pathname = usePathname(); + return ( + + ); +} + + diff --git a/src/app/admin/boards/page.tsx b/src/app/admin/boards/page.tsx index 3f8b9ce..5f3a833 100644 --- a/src/app/admin/boards/page.tsx +++ b/src/app/admin/boards/page.tsx @@ -1,44 +1,109 @@ "use client"; import useSWR from "swr"; -import { useState } from "react"; +import { useMemo, useState } from "react"; const fetcher = (url: string) => fetch(url).then((r) => r.json()); export default function AdminBoardsPage() { - const { data, mutate } = useSWR<{ boards: any[] }>("/api/admin/boards", fetcher); - const boards = data?.boards ?? []; + const { data: boardsResp, mutate: mutateBoards } = useSWR<{ boards: any[] }>("/api/admin/boards", fetcher); + const { data: catsResp, mutate: mutateCats } = useSWR<{ categories: any[] }>("/api/admin/categories", fetcher); + const boards = boardsResp?.boards ?? []; + const categories = (catsResp?.categories ?? []).sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); + const groups = useMemo(() => { + const map: Record = {}; + for (const b of boards) { + const cid = b.categoryId ?? "uncat"; + if (!map[cid]) map[cid] = []; + map[cid].push(b); + } + return categories.map((c: any) => ({ ...c, items: (map[c.id] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) })); + }, [boards, categories]); + const [savingId, setSavingId] = useState(null); async function save(b: any) { setSavingId(b.id); await fetch(`/api/admin/boards/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(b) }); setSavingId(null); - mutate(); + mutateBoards(); } + + // DnD: 카테고리 순서 변경 + async function reorderCategories(next: any[]) { + // optimistic update + await Promise.all(next.map((c, idx) => fetch(`/api/admin/categories/${c.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: idx + 1 }) }))); + mutateCats(); + } + + // DnD: 보드 순서 변경 (카테고리 내부) + async function reorderBoards(categoryId: string, nextItems: any[]) { + await Promise.all(nextItems.map((b, idx) => fetch(`/api/admin/boards/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: idx + 1, categoryId }) }))); + mutateBoards(); + } + return ( - - 게시판 설정 - - - - 이름 - slug - 읽기 - 쓰기 - 익명 - 비밀댓 - 승인 - 유형 - 성인 - 정렬 - - - - - {boards.map((b) => ( - + + 게시판 관리 + {/* 대분류 리스트 (드래그로 순서 변경) */} + + 대분류 + + {groups.map((g, idx) => ( + { + const arr = [...groups]; + const [moved] = arr.splice(from, 1); + arr.splice(to, 0, moved); + reorderCategories(arr); + }} onSave={async (payload) => { + await fetch(`/api/admin/categories/${g.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) }); + mutateCats(); + }} /> ))} - - + + + + {groups.map((g) => ( + + + 대분류: {g.name} + slug: {g.slug} + + + + + + 이름 + slug + 읽기 + 쓰기 + 익명 + 비밀댓 + 승인 + 유형 + 성인 + 정렬 + + + + + {g.items.map((b, i) => ( + { + const list = [...g.items]; + const [mv] = list.splice(from, 1); + list.splice(to, 0, mv); + reorderBoards(g.id, list); + }} + > + + + ))} + + + + + ))} ); } @@ -46,39 +111,88 @@ export default function AdminBoardsPage() { function Row({ b, onSave, saving }: { b: any; onSave: (b: any) => void; saving: boolean }) { const [edit, setEdit] = useState(b); return ( - - setEdit({ ...edit, name: e.target.value })} /> - setEdit({ ...edit, slug: e.target.value })} /> - - setEdit({ ...edit, readLevel: e.target.value })}> + + setEdit({ ...edit, name: e.target.value })} /> + setEdit({ ...edit, slug: e.target.value })} /> + + setEdit({ ...edit, readLevel: e.target.value })}> public member moderator admin - - setEdit({ ...edit, writeLevel: e.target.value })}> + + setEdit({ ...edit, writeLevel: e.target.value })}> public member moderator admin - setEdit({ ...edit, allowAnonymousPost: e.target.checked })} /> - setEdit({ ...edit, allowSecretComment: e.target.checked })} /> - setEdit({ ...edit, requiresApproval: e.target.checked })} /> - - setEdit({ ...edit, type: e.target.value })}> + setEdit({ ...edit, allowAnonymousPost: e.target.checked })} /> + setEdit({ ...edit, allowSecretComment: e.target.checked })} /> + setEdit({ ...edit, requiresApproval: e.target.checked })} /> + + setEdit({ ...edit, type: e.target.value })}> general special - setEdit({ ...edit, isAdultOnly: e.target.checked })} /> - setEdit({ ...edit, sortOrder: Number(e.target.value) })} style={{ width: 80 }} /> - onSave(edit)} disabled={saving}>{saving ? "저장중" : "저장"} + setEdit({ ...edit, isAdultOnly: e.target.checked })} /> + setEdit({ ...edit, sortOrder: Number(e.target.value) })} /> + onSave(edit)} disabled={saving}>{saving ? "저장중" : "저장"} ); } +function DraggableRow({ index, onMove, children }: { index: number; onMove: (from: number, to: number) => void; children: React.ReactNode }) { + return ( + { + e.dataTransfer.setData("text/plain", String(index)); + e.dataTransfer.effectAllowed = "move"; + }} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const from = Number(e.dataTransfer.getData("text/plain")); + const to = index; + if (!Number.isNaN(from) && from !== to) onMove(from, to); + }} + className="align-middle" + > + {children} + + ); +} + +function CategoryRow({ idx, g, onMove, onSave }: { idx: number; g: any; onMove: (from: number, to: number) => void; onSave: (payload: any) => void }) { + const [edit, setEdit] = useState({ name: g.name, slug: g.slug }); + return ( + { + e.dataTransfer.setData("text/plain", String(idx)); + e.dataTransfer.effectAllowed = "move"; + }} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const from = Number(e.dataTransfer.getData("text/plain")); + const to = idx; + if (!Number.isNaN(from) && from !== to) onMove(from, to); + }} + > + ≡ + setEdit({ ...edit, name: e.target.value })} /> + setEdit({ ...edit, slug: e.target.value })} /> + + onSave(edit)}>수정 + + ); +} + diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..c9d88d8 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import AdminSidebar from "@/app/admin/AdminSidebar"; + +export const metadata: Metadata = { + title: "Admin | ASSM", +}; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + {children} + + + + ); +} + + diff --git a/src/app/admin/menus/page.tsx b/src/app/admin/menus/page.tsx new file mode 100644 index 0000000..efb5768 --- /dev/null +++ b/src/app/admin/menus/page.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useState } from "react"; + +type MenuItem = { id: string; label: string; path: string; visible: boolean; order: number }; + +const initialMenus: MenuItem[] = [ + { id: "1", label: "홈", path: "/", visible: true, order: 1 }, + { id: "2", label: "게시판", path: "/boards", visible: true, order: 2 }, + { id: "3", label: "쿠폰", path: "/coupons", visible: false, order: 3 }, +]; + +export default function AdminMenusPage() { + const [menus, setMenus] = useState(initialMenus); + const [form, setForm] = useState<{ label: string; path: string; visible: boolean }>({ label: "", path: "", visible: true }); + + function addMenu() { + if (!form.label.trim() || !form.path.trim()) return; + const next: MenuItem = { id: crypto.randomUUID(), label: form.label, path: form.path, visible: form.visible, order: menus.length + 1 }; + setMenus((m) => [...m, next]); + setForm({ label: "", path: "", visible: true }); + } + + function removeMenu(id: string) { + setMenus((m) => m.filter((x) => x.id !== id)); + } + + function toggleVisible(id: string) { + setMenus((m) => m.map((x) => (x.id === id ? { ...x, visible: !x.visible } : x))); + } + + return ( + + + 메뉴 관리 + + + {/* 추가 폼 */} + + 메뉴 추가 + + setForm({ ...form, label: e.target.value })} /> + setForm({ ...form, path: e.target.value })} /> + + + setForm({ ...form, visible: e.target.checked })} /> 표시 + + 추가 + + + + + {/* 목록 */} + + + # + 이름 + 경로 + 표시 + 관리 + + + {menus.sort((a, b) => a.order - b.order).map((m, idx) => ( + + {idx + 1} + {m.label} + {m.path} + + toggleVisible(m.id)} className={`h-7 px-3 rounded-full text-xs border ${m.visible ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"}`}>{m.visible ? "표시" : "숨김"} + + + removeMenu(m.id)} className="h-7 px-3 rounded-md border border-neutral-300 text-xs hover:bg-neutral-100">삭제 + + + ))} + + + + ); +} + + diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index ee5c5ed..456e766 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -7,9 +7,9 @@ export default function AdminDashboardPage() { const { data } = useSWR<{ users: number; posts: number; comments: number; reportsOpen: number; pendingReviews: number }>("/api/admin/dashboard", fetcher); const m = data ?? { users: 0, posts: 0, comments: 0, reportsOpen: 0, pendingReviews: 0 }; return ( - - 관리자 대시보드 - + + 관리자 대시보드 + @@ -22,9 +22,9 @@ export default function AdminDashboardPage() { function Card({ label, value }: { label: string; value: number }) { return ( - - {label} - {value} + + {label} + {value} ); } diff --git a/src/app/components/HeroBanner.tsx b/src/app/components/HeroBanner.tsx index ae5d09b..f3eaefe 100644 --- a/src/app/components/HeroBanner.tsx +++ b/src/app/components/HeroBanner.tsx @@ -128,7 +128,7 @@ export function HeroBanner() { {/* Pagination */} {numSlides > 1 && ( - + {banners.map((_, i) => (