Compare commits
2 Commits
53b6376966
...
108a07ab9d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
108a07ab9d | ||
|
|
da6b396acc |
41
src/app/admin/AdminSidebar.tsx
Normal file
41
src/app/admin/AdminSidebar.tsx
Normal file
@@ -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 (
|
||||
<aside className="w-56 shrink-0 border-r border-neutral-200 bg-white/80 backdrop-blur h-full">
|
||||
<div className="px-4 py-4 border-b border-neutral-200">
|
||||
<Link href="/admin" className="block text-lg font-bold text-neutral-900">관리자</Link>
|
||||
</div>
|
||||
<nav className="p-2 space-y-1">
|
||||
{navItems.map((it) => {
|
||||
const active = pathname === it.href;
|
||||
return (
|
||||
<Link
|
||||
key={it.href}
|
||||
href={it.href}
|
||||
className={`flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors ${
|
||||
active ? "bg-neutral-900 text-white" : "text-neutral-800 hover:bg-neutral-100"
|
||||
}`}
|
||||
>
|
||||
{it.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,84 +1,368 @@
|
||||
"use client";
|
||||
import useSWR from "swr";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export default function AdminBoardsPage() {
|
||||
const { data, mutate } = useSWR<{ boards: any[] }>("/api/admin/boards", fetcher);
|
||||
const boards = data?.boards ?? [];
|
||||
const { data: boardsResp, mutate: mutateBoards } = useSWR<{ boards: any[] }>("/api/admin/boards", fetcher);
|
||||
const { data: catsResp, mutate: mutateCats } = useSWR<{ categories: any[] }>("/api/admin/categories", fetcher);
|
||||
const boards = boardsResp?.boards ?? [];
|
||||
const categories = (catsResp?.categories ?? []).sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
const groups = useMemo(() => {
|
||||
const map: Record<string, any[]> = {};
|
||||
for (const b of boards) {
|
||||
const cid = b.categoryId ?? "uncat";
|
||||
if (!map[cid]) map[cid] = [];
|
||||
map[cid].push(b);
|
||||
}
|
||||
return categories.map((c: any) => ({ ...c, items: (map[c.id] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) }));
|
||||
}, [boards, categories]);
|
||||
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [dirtyBoards, setDirtyBoards] = useState<Record<string, any>>({});
|
||||
const [dirtyCats, setDirtyCats] = useState<Record<string, any>>({});
|
||||
const [savingAll, setSavingAll] = useState(false);
|
||||
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();
|
||||
}
|
||||
|
||||
function markBoardDirty(id: string, draft: any) {
|
||||
setDirtyBoards((prev) => ({ ...prev, [id]: draft }));
|
||||
}
|
||||
function markCatDirty(id: string, draft: any) {
|
||||
setDirtyCats((prev) => ({ ...prev, [id]: draft }));
|
||||
}
|
||||
async function saveAll() {
|
||||
try {
|
||||
setSavingAll(true);
|
||||
const boardEntries = Object.entries(dirtyBoards);
|
||||
const catEntries = Object.entries(dirtyCats);
|
||||
await Promise.all([
|
||||
...boardEntries.map(([id, payload]) => fetch(`/api/admin/boards/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) })),
|
||||
...catEntries.map(([id, payload]) => fetch(`/api/admin/categories/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) })),
|
||||
]);
|
||||
setDirtyBoards({});
|
||||
setDirtyCats({});
|
||||
mutateBoards();
|
||||
mutateCats();
|
||||
} finally {
|
||||
setSavingAll(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>게시판 설정</h1>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
<th>slug</th>
|
||||
<th>읽기</th>
|
||||
<th>쓰기</th>
|
||||
<th>익명</th>
|
||||
<th>비밀댓</th>
|
||||
<th>승인</th>
|
||||
<th>유형</th>
|
||||
<th>성인</th>
|
||||
<th>정렬</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{boards.map((b) => (
|
||||
<Row key={b.id} b={b} onSave={save} saving={savingId === b.id} />
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-neutral-900">게시판 관리</h1>
|
||||
{/* 변경사항 저장 바 */}
|
||||
{(Object.keys(dirtyBoards).length + Object.keys(dirtyCats).length) > 0 && (
|
||||
<div className="sticky top-2 z-10 flex justify-end">
|
||||
<button
|
||||
onClick={saveAll}
|
||||
disabled={savingAll}
|
||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow disabled:opacity-60"
|
||||
>{savingAll ? "저장 중..." : `변경사항 저장 (${Object.keys(dirtyBoards).length + Object.keys(dirtyCats).length})`}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 대분류 리스트 (드래그로 순서 변경) */}
|
||||
<div className="rounded-xl border border-neutral-200 overflow-hidden bg-white">
|
||||
<div className="px-4 py-2 border-b border-neutral-200 text-sm font-semibold">대분류</div>
|
||||
<ul className="divide-y divide-neutral-100">
|
||||
{groups.map((g, idx) => (
|
||||
<CategoryRow key={g.id} idx={idx} g={g} onMove={(from, to) => {
|
||||
const arr = [...groups];
|
||||
const [moved] = arr.splice(from, 1);
|
||||
arr.splice(to, 0, moved);
|
||||
reorderCategories(arr);
|
||||
}} onDirty={(payload) => markCatDirty(g.id, { ...payload })} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{groups.map((g) => (
|
||||
<section key={g.id} className="rounded-xl border border-neutral-200 overflow-hidden bg-white">
|
||||
<div className="px-4 py-2 border-b border-neutral-200 flex items-center justify-between">
|
||||
<div className="text-sm font-semibold">대분류: {g.name}</div>
|
||||
<div className="text-xs text-neutral-500">slug: {g.slug}</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-xs text-neutral-500 border-b border-neutral-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">이름</th>
|
||||
<th className="px-3 py-2 text-left">slug</th>
|
||||
<th className="px-3 py-2">읽기</th>
|
||||
<th className="px-3 py-2">쓰기</th>
|
||||
<th className="px-3 py-2">익명</th>
|
||||
<th className="px-3 py-2">비밀댓</th>
|
||||
<th className="px-3 py-2">승인</th>
|
||||
<th className="px-3 py-2">유형</th>
|
||||
<th className="px-3 py-2">성인</th>
|
||||
<th className="px-3 py-2">정렬</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{g.items.map((b, i) => (
|
||||
<DraggableRow
|
||||
key={b.id}
|
||||
index={i}
|
||||
onMove={(from, to) => {
|
||||
const list = [...g.items];
|
||||
const [mv] = list.splice(from, 1);
|
||||
list.splice(to, 0, mv);
|
||||
reorderBoards(g.id, list);
|
||||
}}
|
||||
>
|
||||
<BoardRowCells b={b} onDirty={(id, draft) => markBoardDirty(id, draft)} />
|
||||
</DraggableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ b, onSave, saving }: { b: any; onSave: (b: any) => void; saving: boolean }) {
|
||||
function BoardRowCells({ b, onDirty }: { b: any; onDirty: (id: string, draft: any) => void }) {
|
||||
const [edit, setEdit] = useState(b);
|
||||
return (
|
||||
<tr>
|
||||
<td><input value={edit.name} onChange={(e) => setEdit({ ...edit, name: e.target.value })} /></td>
|
||||
<td><input value={edit.slug} onChange={(e) => setEdit({ ...edit, slug: e.target.value })} /></td>
|
||||
<td>
|
||||
<select value={edit.readLevel} onChange={(e) => setEdit({ ...edit, readLevel: e.target.value })}>
|
||||
<>
|
||||
<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={edit.readLevel} onChange={(e) => { const v = { ...edit, readLevel: e.target.value }; setEdit(v); onDirty(b.id, v); }}>
|
||||
<option value="public">public</option>
|
||||
<option value="member">member</option>
|
||||
<option value="moderator">moderator</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select value={edit.writeLevel} onChange={(e) => setEdit({ ...edit, writeLevel: e.target.value })}>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<select className="h-9 rounded-md border border-neutral-300 px-2 text-sm" value={edit.writeLevel} onChange={(e) => { const v = { ...edit, writeLevel: e.target.value }; setEdit(v); onDirty(b.id, v); }}>
|
||||
<option value="public">public</option>
|
||||
<option value="member">member</option>
|
||||
<option value="moderator">moderator</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => setEdit({ ...edit, allowAnonymousPost: e.target.checked })} /></td>
|
||||
<td><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => setEdit({ ...edit, allowSecretComment: e.target.checked })} /></td>
|
||||
<td><input type="checkbox" checked={edit.requiresApproval} onChange={(e) => setEdit({ ...edit, requiresApproval: e.target.checked })} /></td>
|
||||
<td>
|
||||
<select value={edit.type} onChange={(e) => setEdit({ ...edit, type: e.target.value })}>
|
||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => { const v = { ...edit, allowAnonymousPost: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => { const v = { ...edit, allowSecretComment: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.requiresApproval} onChange={(e) => { const v = { ...edit, requiresApproval: e.target.checked }; 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={edit.type} onChange={(e) => { const v = { ...edit, type: e.target.value }; setEdit(v); onDirty(b.id, v); }}>
|
||||
<option value="general">general</option>
|
||||
<option value="special">special</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => setEdit({ ...edit, isAdultOnly: e.target.checked })} /></td>
|
||||
<td><input type="number" value={edit.sortOrder} onChange={(e) => setEdit({ ...edit, sortOrder: Number(e.target.value) })} style={{ width: 80 }} /></td>
|
||||
<td><button onClick={() => onSave(edit)} disabled={saving}>{saving ? "저장중" : "저장"}</button></td>
|
||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
<td className="px-3 py-2 text-center"><input className="h-9 w-20 rounded-md border border-neutral-300 px-2 text-sm" type="number" value={edit.sortOrder} onChange={(e) => { const v = { ...edit, sortOrder: Number(e.target.value) }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DraggableRow({ index, onMove, children }: { index: number; onMove: (from: number, to: number) => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<tr
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
// 수직만 보이게: 실제 행을 고정 포지션으로 띄워 따라오게 함 + 전역 placeholder 등록
|
||||
const row = e.currentTarget as HTMLTableRowElement;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const table = row.closest('table') as HTMLElement | null;
|
||||
const tableRect = table?.getBoundingClientRect();
|
||||
// placeholder로 자리를 유지 (전역으로 참조)
|
||||
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);
|
||||
// 기본 드래그 이미지는 투명 1x1로 숨김
|
||||
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);
|
||||
}}
|
||||
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 cursor-ns-resize select-none"
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryRow({ idx, g, onMove, onDirty }: { idx: number; g: any; onMove: (from: number, to: number) => void; onDirty: (payload: any) => void }) {
|
||||
const [edit, setEdit] = useState({ name: g.name, slug: g.slug });
|
||||
return (
|
||||
<li
|
||||
className="px-4 py-3 flex items-center gap-3 cursor-ns-resize select-none"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", String(idx));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
const item = e.currentTarget as HTMLLIElement;
|
||||
const rect = item.getBoundingClientRect();
|
||||
const listRect = item.parentElement?.getBoundingClientRect();
|
||||
// placeholder (전역 등록)
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.style.height = `${rect.height}px`;
|
||||
placeholder.style.border = '1px dashed rgba(0,0,0,0.1)';
|
||||
item.parentElement?.insertBefore(placeholder, item);
|
||||
(window as any).__adminDnd = { placeholder, dragging: item, target: null, before: false, rAF: 0 };
|
||||
// fix item position
|
||||
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
|
||||
const offsetY = e.clientY - rect.top;
|
||||
item.style.position = 'fixed';
|
||||
item.style.left = `${listRect ? listRect.left : rect.left}px`;
|
||||
item.style.width = `${listRect ? listRect.width : rect.width}px`;
|
||||
item.style.zIndex = '9999';
|
||||
const updatePos = (y: number) => {
|
||||
const top = clamp(y - offsetY, (listRect?.top ?? 0), (listRect?.bottom ?? (rect.top + rect.height)) - rect.height);
|
||||
item.style.top = `${top}px`;
|
||||
};
|
||||
updatePos(e.clientY);
|
||||
// hide default drag image
|
||||
const img = document.createElement('canvas'); img.width = 1; img.height = 1; e.dataTransfer.setDragImage(img, 0, 0);
|
||||
const onDragOver = (ev: DragEvent) => { if (typeof ev.clientY === 'number') updatePos(ev.clientY); };
|
||||
const cleanup = () => { const st = (window as any).__adminDnd; if (st?.rAF) cancelAnimationFrame(st.rAF); item.style.position=''; item.style.left=''; item.style.top=''; item.style.width=''; item.style.zIndex=''; placeholder.remove(); window.removeEventListener('dragover', onDragOver, true); window.removeEventListener('dragend', cleanup, true); (window as any).__adminDnd = undefined; };
|
||||
window.addEventListener('dragover', onDragOver, true);
|
||||
window.addEventListener('dragend', cleanup, true);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const state = (window as any).__adminDnd || {};
|
||||
const current = e.currentTarget as HTMLElement;
|
||||
const r = current.getBoundingClientRect();
|
||||
state.target = current;
|
||||
state.before = e.clientY < r.top + r.height / 2;
|
||||
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 = idx;
|
||||
if (ph && ph.parentElement) {
|
||||
to = Array.from(ph.parentElement.children).indexOf(ph);
|
||||
ph.remove();
|
||||
}
|
||||
if (!Number.isNaN(from) && from !== to) onMove(from, to);
|
||||
}}
|
||||
>
|
||||
<div className="w-6 text-xs text-neutral-500">≡</div>
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||
<div className="flex-1" />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
21
src/app/admin/layout.tsx
Normal file
21
src/app/admin/layout.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-[calc(100vh-0px)] flex">
|
||||
<AdminSidebar />
|
||||
<main className="flex-1 min-w-0 bg-[#F7F7F7]">
|
||||
<div className="max-w-[1920px] mx-auto px-4 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
82
src/app/admin/menus/page.tsx
Normal file
82
src/app/admin/menus/page.tsx
Normal file
@@ -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<MenuItem[]>(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 (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-neutral-900">메뉴 관리</h1>
|
||||
</header>
|
||||
|
||||
{/* 추가 폼 */}
|
||||
<section className="rounded-xl bg-white border border-neutral-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-neutral-200 text-sm font-medium">메뉴 추가</div>
|
||||
<div className="p-4 grid grid-cols-1 md:grid-cols-[240px_1fr_auto] gap-3 items-center">
|
||||
<input className="h-10 rounded-md border border-neutral-300 px-3 text-sm" placeholder="이름" value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} />
|
||||
<input className="h-10 rounded-md border border-neutral-300 px-3 text-sm" placeholder="경로 (/path)" value={form.path} onChange={(e) => setForm({ ...form, path: e.target.value })} />
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1 text-sm text-neutral-700">
|
||||
<input type="checkbox" checked={form.visible} onChange={(e) => setForm({ ...form, visible: e.target.checked })} /> 표시
|
||||
</label>
|
||||
<button className="h-10 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800" onClick={addMenu}>추가</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 목록 */}
|
||||
<section className="rounded-xl bg-white border border-neutral-200 overflow-hidden">
|
||||
<div className="px-4 py-2 text-xs text-neutral-500 border-b border-neutral-200 grid grid-cols-[60px_1fr_1fr_120px_120px]">
|
||||
<div>#</div>
|
||||
<div>이름</div>
|
||||
<div>경로</div>
|
||||
<div className="text-center">표시</div>
|
||||
<div className="text-right">관리</div>
|
||||
</div>
|
||||
<ul className="divide-y divide-neutral-100">
|
||||
{menus.sort((a, b) => a.order - b.order).map((m, idx) => (
|
||||
<li key={m.id} className="px-4 py-3 grid grid-cols-[60px_1fr_1fr_120px_120px] items-center">
|
||||
<div className="text-sm text-neutral-500">{idx + 1}</div>
|
||||
<div className="truncate text-sm">{m.label}</div>
|
||||
<div className="truncate text-sm text-neutral-700">{m.path}</div>
|
||||
<div className="text-center">
|
||||
<button onClick={() => 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 ? "표시" : "숨김"}</button>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<button onClick={() => removeMenu(m.id)} className="h-7 px-3 rounded-md border border-neutral-300 text-xs hover:bg-neutral-100">삭제</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<h1>관리자 대시보드</h1>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, minmax(0,1fr))", gap: 12 }}>
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl md:text-2xl font-bold">관리자 대시보드</h1>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<Card label="사용자" value={m.users} />
|
||||
<Card label="게시글" value={m.posts} />
|
||||
<Card label="댓글" value={m.comments} />
|
||||
@@ -22,9 +22,9 @@ export default function AdminDashboardPage() {
|
||||
|
||||
function Card({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div style={{ border: "1px solid #eee", borderRadius: 8, padding: 16 }}>
|
||||
<div style={{ fontSize: 12, opacity: 0.7 }}>{label}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700 }}>{value}</div>
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-4">
|
||||
<div className="text-xs text-neutral-500">{label}</div>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export function HeroBanner() {
|
||||
|
||||
{/* Pagination */}
|
||||
{numSlides > 1 && (
|
||||
<div className="absolute bottom-3 right-3 z-10 flex gap-2">
|
||||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex gap-2">
|
||||
{banners.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
|
||||
Reference in New Issue
Block a user