Compare commits
2 Commits
16b01a2c51
...
d4aab34e43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4aab34e43 | ||
|
|
293e4a20b9 |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 795 KiB After Width: | Height: | Size: 795 KiB |
@@ -1,14 +1,39 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
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 { 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: catsResp, mutate: mutateCats } = useSWR<{ categories: any[] }>("/api/admin/categories", fetcher);
|
const { data: catsResp, mutate: mutateCats } = useSWR<{ categories: any[] }>("/api/admin/categories", fetcher);
|
||||||
const boards = boardsResp?.boards ?? [];
|
const rawBoards = boardsResp?.boards ?? [];
|
||||||
const categories = (catsResp?.categories ?? []).sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
const rawCategories = catsResp?.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);
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
const dirtyCount = Object.keys(dirtyBoards).length + Object.keys(dirtyCats).length;
|
||||||
|
const [catOrder, setCatOrder] = useState<string[]>([]);
|
||||||
|
const [draggingCatIndex, setDraggingCatIndex] = useState<number | null>(null);
|
||||||
|
const catRefs = useRef<Record<string, HTMLLIElement | null>>({});
|
||||||
|
const boards = useMemo(() => {
|
||||||
|
return rawBoards.map((b: any) => ({ ...b, ...(dirtyBoards[b.id] ?? {}) }));
|
||||||
|
}, [rawBoards, dirtyBoards]);
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) }));
|
||||||
|
return merged.sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||||
|
}, [rawCategories, dirtyCats]);
|
||||||
|
const orderedCats = useMemo(() => {
|
||||||
|
if (!catOrder.length) return categories;
|
||||||
|
const map = new Map(categories.map((c: any) => [c.id, c]));
|
||||||
|
const ordered = catOrder.map((id) => map.get(id)).filter(Boolean) as any[];
|
||||||
|
// 새로 생긴 카테고리(id 미포함)는 뒤에 추가
|
||||||
|
const missing = categories.filter((c: any) => !catOrder.includes(c.id));
|
||||||
|
return [...ordered, ...missing];
|
||||||
|
}, [categories, catOrder]);
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const map: Record<string, any[]> = {};
|
const map: Record<string, any[]> = {};
|
||||||
for (const b of boards) {
|
for (const b of boards) {
|
||||||
@@ -16,13 +41,20 @@ export default function AdminBoardsPage() {
|
|||||||
if (!map[cid]) map[cid] = [];
|
if (!map[cid]) map[cid] = [];
|
||||||
map[cid].push(b);
|
map[cid].push(b);
|
||||||
}
|
}
|
||||||
return categories.map((c: any) => ({ ...c, items: (map[c.id] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) }));
|
return orderedCats.map((c: any) => ({ ...c, items: (map[c.id] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) }));
|
||||||
}, [boards, categories]);
|
}, [boards, orderedCats]);
|
||||||
|
|
||||||
const [savingId, setSavingId] = useState<string | null>(null);
|
// 최초/데이터 변경 시 표시용 카테고리 순서를 초기화
|
||||||
const [dirtyBoards, setDirtyBoards] = useState<Record<string, any>>({});
|
// 서버 sortOrder에 맞춰 초기 catOrder 설정
|
||||||
const [dirtyCats, setDirtyCats] = useState<Record<string, any>>({});
|
// categories가 바뀔 때만 동기화
|
||||||
const [savingAll, setSavingAll] = useState(false);
|
// 사용자가 드래그로 순서를 바꾸면 catOrder가 우선됨
|
||||||
|
useEffect(() => {
|
||||||
|
if (draggingCatIndex !== null) return; // 드래그 중에는 catOrder를 리셋하지 않음
|
||||||
|
const next = categories.map((c: any) => c.id);
|
||||||
|
if (next.length && (next.length !== catOrder.length || next.some((id, i) => id !== catOrder[i]))) {
|
||||||
|
setCatOrder(next);
|
||||||
|
}
|
||||||
|
}, [categories, draggingCatIndex]);
|
||||||
async function save(b: any) {
|
async function save(b: any) {
|
||||||
setSavingId(b.id);
|
setSavingId(b.id);
|
||||||
await fetch(`/api/admin/boards/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
|
await fetch(`/api/admin/boards/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
|
||||||
@@ -30,17 +62,59 @@ export default function AdminBoardsPage() {
|
|||||||
mutateBoards();
|
mutateBoards();
|
||||||
}
|
}
|
||||||
|
|
||||||
// DnD: 카테고리 순서 변경
|
// DnD: 카테고리 순서 변경 (저장 시 반영)
|
||||||
async function reorderCategories(next: any[]) {
|
function reorderCategories(next: any[]) {
|
||||||
// optimistic update
|
setDirtyCats((prev) => {
|
||||||
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 }) })));
|
// 서버 기준(또는 이전 dirty 오버라이드) sortOrder 맵 구성
|
||||||
mutateCats();
|
const baseSort = new Map<string, number>();
|
||||||
|
// rawCategories에는 서버 값이 들어있음
|
||||||
|
// prev에 sortOrder가 있으면 우선 적용
|
||||||
|
for (const c of rawCategories) {
|
||||||
|
const prevOverride = prev[c.id]?.sortOrder;
|
||||||
|
baseSort.set(c.id, prevOverride ?? (c.sortOrder ?? 0));
|
||||||
|
}
|
||||||
|
const updated: Record<string, any> = { ...prev };
|
||||||
|
next.forEach((c, idx) => {
|
||||||
|
const target = idx + 1;
|
||||||
|
const current = baseSort.get(c.id) ?? 0;
|
||||||
|
if (target !== current) {
|
||||||
|
updated[c.id] = { ...(updated[c.id] ?? {}), sortOrder: target };
|
||||||
|
} else if (updated[c.id]?.sortOrder !== undefined) {
|
||||||
|
// 정렬값이 동일해졌다면 해당 키만 제거 (다른 수정값은 유지)
|
||||||
|
const { sortOrder, ...rest } = updated[c.id];
|
||||||
|
if (Object.keys(rest).length === 0) delete updated[c.id]; else updated[c.id] = rest;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// DnD: 보드 순서 변경 (카테고리 내부)
|
// DnD: 보드 순서 변경 (저장 시 반영)
|
||||||
async function reorderBoards(categoryId: string, nextItems: any[]) {
|
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 }) })));
|
setDirtyBoards((prev) => {
|
||||||
mutateBoards();
|
// 서버 기준(또는 이전 dirty) 정렬/카테고리 맵 구성
|
||||||
|
const base = new Map<string, { sortOrder: number; categoryId: string | null | undefined }>();
|
||||||
|
for (const b of rawBoards) {
|
||||||
|
const prevB = prev[b.id] ?? {};
|
||||||
|
base.set(b.id, {
|
||||||
|
sortOrder: prevB.sortOrder ?? (b.sortOrder ?? 0),
|
||||||
|
categoryId: prevB.categoryId ?? b.categoryId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const updated: Record<string, any> = { ...prev };
|
||||||
|
nextItems.forEach((b, idx) => {
|
||||||
|
const targetSort = idx + 1;
|
||||||
|
const targetCat = categoryId;
|
||||||
|
const baseVal = base.get(b.id) ?? { sortOrder: b.sortOrder ?? 0, categoryId: b.categoryId };
|
||||||
|
if (baseVal.sortOrder !== targetSort || baseVal.categoryId !== targetCat) {
|
||||||
|
updated[b.id] = { ...(updated[b.id] ?? {}), sortOrder: targetSort, categoryId: targetCat };
|
||||||
|
} else if (updated[b.id]) {
|
||||||
|
const { sortOrder, categoryId: catId, ...rest } = updated[b.id];
|
||||||
|
if (Object.keys(rest).length === 0) delete updated[b.id]; else updated[b.id] = rest;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function markBoardDirty(id: string, draft: any) {
|
function markBoardDirty(id: string, draft: any) {
|
||||||
@@ -49,19 +123,41 @@ export default function AdminBoardsPage() {
|
|||||||
function markCatDirty(id: string, draft: any) {
|
function markCatDirty(id: string, draft: any) {
|
||||||
setDirtyCats((prev) => ({ ...prev, [id]: draft }));
|
setDirtyCats((prev) => ({ ...prev, [id]: draft }));
|
||||||
}
|
}
|
||||||
|
function toggleCat(id: string) {
|
||||||
|
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
}
|
||||||
async function saveAll() {
|
async function saveAll() {
|
||||||
|
const prevDirtyBoards = dirtyBoards;
|
||||||
|
const prevDirtyCats = dirtyCats;
|
||||||
try {
|
try {
|
||||||
setSavingAll(true);
|
setSavingAll(true);
|
||||||
const boardEntries = Object.entries(dirtyBoards);
|
const boardEntries = Object.entries(dirtyBoards);
|
||||||
const catEntries = Object.entries(dirtyCats);
|
const catEntries = Object.entries(dirtyCats);
|
||||||
await Promise.all([
|
// 1) 서버 저장 (병렬) - 실패 시 아래 catch로 이동
|
||||||
|
const resps = await Promise.all([
|
||||||
...boardEntries.map(([id, payload]) => fetch(`/api/admin/boards/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) })),
|
...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) })),
|
...catEntries.map(([id, payload]) => fetch(`/api/admin/categories/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) })),
|
||||||
]);
|
]);
|
||||||
|
const anyFail = resps.some((r) => !r.ok);
|
||||||
|
if (anyFail) throw new Error("save_failed");
|
||||||
|
// 2) 성공 시: 먼저 서버 데이터로 최신화 → 그 다음 dirty 초기화
|
||||||
|
await Promise.all([
|
||||||
|
mutateBoards(undefined, { revalidate: true }),
|
||||||
|
mutateCats(undefined, { revalidate: true }),
|
||||||
|
]);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new Event("categories:reload"));
|
||||||
|
}
|
||||||
setDirtyBoards({});
|
setDirtyBoards({});
|
||||||
setDirtyCats({});
|
setDirtyCats({});
|
||||||
mutateBoards();
|
} catch (e) {
|
||||||
mutateCats();
|
// 실패 시: 변경 사항 취소하고 서버 상태로 되돌림
|
||||||
|
setDirtyBoards({});
|
||||||
|
setDirtyCats({});
|
||||||
|
await Promise.all([
|
||||||
|
mutateBoards(undefined, { revalidate: true }),
|
||||||
|
mutateCats(undefined, { revalidate: true }),
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingAll(false);
|
setSavingAll(false);
|
||||||
}
|
}
|
||||||
@@ -70,42 +166,87 @@ export default function AdminBoardsPage() {
|
|||||||
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>
|
||||||
{/* 변경사항 저장 바 */}
|
|
||||||
{(Object.keys(dirtyBoards).length + Object.keys(dirtyCats).length) > 0 && (
|
|
||||||
<div className="sticky top-2 z-10 flex justify-end">
|
<div className="sticky top-2 z-10 flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={saveAll}
|
onClick={saveAll}
|
||||||
disabled={savingAll}
|
disabled={savingAll || dirtyCount === 0}
|
||||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow disabled:opacity-60"
|
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>
|
>{savingAll ? "저장 중..." : `변경사항 저장 (${dirtyCount})`}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 대분류 리스트 (드래그로 순서 변경) */}
|
{/* 대분류(헤더)와 소분류(보드 테이블)를 하나의 리스트로 통합, 대분류별 토글 */}
|
||||||
<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">대분류 및 소분류</div>
|
||||||
<ul className="divide-y divide-neutral-100">
|
<ul
|
||||||
|
className="divide-y-2 divide-neutral-100"
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
if (draggingCatIndex === null) return;
|
||||||
|
const ids = (catOrder.length ? catOrder : categories.map((c: any) => c.id));
|
||||||
|
// 현재 마우스 Y에 해당하는 행 인덱스 계산
|
||||||
|
let overIdx = -1;
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
|
const el = catRefs.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 === draggingCatIndex) return;
|
||||||
|
setCatOrder((order) => {
|
||||||
|
const base = order.length ? order : categories.map((c: any) => c.id);
|
||||||
|
const next = [...base];
|
||||||
|
const [moved] = next.splice(draggingCatIndex, 1);
|
||||||
|
next.splice(overIdx, 0, moved);
|
||||||
|
setDraggingCatIndex(overIdx);
|
||||||
|
const nextCats = next.map((id) => categories.find((c: any) => c.id === id)).filter(Boolean) as any[];
|
||||||
|
reorderCategories(nextCats);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onDragEnter={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const ids = (catOrder.length ? catOrder : categories.map((c: any) => c.id));
|
||||||
|
const nextCats = ids.map((id) => categories.find((c: any) => c.id === id)).filter(Boolean) as any[];
|
||||||
|
reorderCategories(nextCats);
|
||||||
|
setDraggingCatIndex(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{groups.map((g, idx) => (
|
{groups.map((g, idx) => (
|
||||||
<CategoryRow key={g.id} idx={idx} g={g} onMove={(from, to) => {
|
<li
|
||||||
const arr = [...groups];
|
key={g.id}
|
||||||
const [moved] = arr.splice(from, 1);
|
className="select-none"
|
||||||
arr.splice(to, 0, moved);
|
ref={(el) => { catRefs.current[g.id] = el; }}
|
||||||
reorderCategories(arr);
|
|
||||||
}} onDirty={(payload) => markCatDirty(g.id, { ...payload })} />
|
onDrop={(e) => {
|
||||||
))}
|
e.preventDefault();
|
||||||
</ul>
|
setDraggingCatIndex(null);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => { setDraggingCatIndex(null); }}
|
||||||
|
>
|
||||||
|
<div className="px-4 py-3 flex items-center gap-3">
|
||||||
|
<div className="w-8 text-sm text-neutral-500 select-none">{idx + 1}</div>
|
||||||
|
<CategoryHeaderContent g={g} onDirty={(payload) => markCatDirty(g.id, { ...payload })} onDragStart={() => {
|
||||||
|
setDraggingCatIndex(idx);
|
||||||
|
}} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="toggle"
|
||||||
|
className="w-8 text-2xl leading-none text-neutral-700"
|
||||||
|
onClick={() => toggleCat(g.id)}
|
||||||
|
>{expanded[g.id] ? '▾' : '▸'}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{groups.map((g) => (
|
{expanded[g.id] && (
|
||||||
<section key={g.id} className="rounded-xl border border-neutral-200 overflow-hidden bg-white">
|
<div className="overflow-x-auto border-t border-neutral-100 ml-8">
|
||||||
<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">
|
<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>
|
||||||
|
<th className="px-2 py-2 w-8"></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>
|
||||||
@@ -115,7 +256,6 @@ 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>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -137,8 +277,11 @@ export default function AdminBoardsPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
)}
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -175,7 +318,7 @@ 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>
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,56 +326,6 @@ function BoardRowCells({ b, onDirty }: { b: any; onDirty: (id: string, draft: an
|
|||||||
function DraggableRow({ index, onMove, children }: { index: number; onMove: (from: number, to: number) => void; children: React.ReactNode }) {
|
function DraggableRow({ index, onMove, children }: { index: number; onMove: (from: number, to: number) => void; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<tr
|
<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) => {
|
onDragOver={(e) => {
|
||||||
// 수직만 허용: 기본 동작만 유지하고 수평 제스처는 무시
|
// 수직만 허용: 기본 동작만 유지하고 수평 제스처는 무시
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -275,93 +368,81 @@ function DraggableRow({ index, onMove, children }: { index: number; onMove: (fro
|
|||||||
}
|
}
|
||||||
if (!Number.isNaN(from) && from !== to) onMove(from, to);
|
if (!Number.isNaN(from) && from !== to) onMove(from, to);
|
||||||
}}
|
}}
|
||||||
className="align-middle cursor-ns-resize select-none"
|
className="align-middle select-none"
|
||||||
>
|
>
|
||||||
|
<td
|
||||||
|
className="px-2 py-2 w-8 text-xl text-neutral-500 cursor-grab"
|
||||||
|
title="드래그하여 순서 변경"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData("text/plain", String(index));
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
const row = (e.currentTarget as HTMLElement).closest('tr') as HTMLTableRowElement;
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const table = row.closest('table') as HTMLElement | null;
|
||||||
|
const tableRect = table?.getBoundingClientRect();
|
||||||
|
const placeholder = document.createElement('tr');
|
||||||
|
placeholder.style.height = `${rect.height}px`;
|
||||||
|
(row.parentNode as HTMLElement).insertBefore(placeholder, row);
|
||||||
|
(window as any).__adminDnd = { placeholder, dragging: row, target: null, before: false, rAF: 0 };
|
||||||
|
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
|
||||||
|
const offsetY = e.clientY - rect.top;
|
||||||
|
row.style.position = 'fixed';
|
||||||
|
row.style.left = `${tableRect ? tableRect.left : rect.left}px`;
|
||||||
|
row.style.width = `${tableRect ? tableRect.width : rect.width}px`;
|
||||||
|
row.style.zIndex = '9999';
|
||||||
|
row.classList.add('bg-white');
|
||||||
|
const updatePos = (clientY: number) => {
|
||||||
|
const top = clamp(clientY - offsetY, (tableRect?.top ?? 0), (tableRect?.bottom ?? (rect.top + rect.height)) - rect.height);
|
||||||
|
row.style.top = `${top}px`;
|
||||||
|
};
|
||||||
|
updatePos(e.clientY);
|
||||||
|
const img = document.createElement('canvas');
|
||||||
|
img.width = 1; img.height = 1; const ctx = img.getContext('2d'); ctx?.clearRect(0,0,1,1);
|
||||||
|
e.dataTransfer.setDragImage(img, 0, 0);
|
||||||
|
const onDragOver = (ev: DragEvent) => { if (typeof ev.clientY === 'number') updatePos(ev.clientY); };
|
||||||
|
const cleanup = () => {
|
||||||
|
row.style.position = '';
|
||||||
|
row.style.left = '';
|
||||||
|
row.style.top = '';
|
||||||
|
row.style.width = '';
|
||||||
|
row.style.zIndex = '';
|
||||||
|
row.classList.remove('bg-white');
|
||||||
|
placeholder.remove();
|
||||||
|
window.removeEventListener('dragover', onDragOver, true);
|
||||||
|
window.removeEventListener('dragend', cleanup, true);
|
||||||
|
const st = (window as any).__adminDnd; if (st?.rAF) cancelAnimationFrame(st.rAF);
|
||||||
|
(window as any).__adminDnd = undefined;
|
||||||
|
};
|
||||||
|
window.addEventListener('dragover', onDragOver, true);
|
||||||
|
window.addEventListener('dragend', cleanup, true);
|
||||||
|
}}
|
||||||
|
>≡</td>
|
||||||
|
<td className="px-2 py-2 w-8 text-center text-neutral-500">{index + 1}</td>
|
||||||
{children}
|
{children}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryRow({ idx, g, onMove, onDirty }: { idx: number; g: any; onMove: (from: number, to: number) => void; onDirty: (payload: any) => void }) {
|
function CategoryHeaderContent({ g, onDirty, onDragStart }: { g: any; onDirty: (payload: any) => void; onDragStart?: () => void }) {
|
||||||
const [edit, setEdit] = useState({ name: g.name, slug: g.slug });
|
const [edit, setEdit] = useState({ name: g.name, slug: g.slug });
|
||||||
return (
|
return (
|
||||||
<li
|
<>
|
||||||
className="px-4 py-3 flex items-center gap-3 cursor-ns-resize select-none"
|
<div
|
||||||
|
className="w-10 text-xl text-neutral-500 cursor-grab select-none"
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
e.dataTransfer.setData("text/plain", String(idx));
|
e.dataTransfer.setData("text/plain", "category-drag");
|
||||||
e.dataTransfer.effectAllowed = "move";
|
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 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); };
|
onDragStart && onDragStart();
|
||||||
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) => {
|
title="드래그하여 순서 변경"
|
||||||
e.preventDefault();
|
>≡</div>
|
||||||
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.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); }} />
|
<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" />
|
<div className="flex-1" />
|
||||||
</li>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getUserIdFromRequest } from "@/lib/auth";
|
|
||||||
import { requirePermission } from "@/lib/rbac";
|
|
||||||
|
|
||||||
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const userId = getUserIdFromRequest(req);
|
|
||||||
await requirePermission({ userId, resource: "ADMIN", action: "MODERATE" });
|
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const data: any = {};
|
const data: any = {};
|
||||||
for (const k of ["name", "slug", "sortOrder", "status"]) {
|
for (const k of ["name", "slug", "sortOrder", "status"]) {
|
||||||
@@ -18,8 +14,6 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
|
|||||||
|
|
||||||
export async function DELETE(req: Request, context: { params: Promise<{ id: string }> }) {
|
export async function DELETE(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const userId = getUserIdFromRequest(req);
|
|
||||||
await requirePermission({ userId, resource: "ADMIN", action: "MODERATE" });
|
|
||||||
await prisma.boardCategory.delete({ where: { id } });
|
await prisma.boardCategory.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getUserIdFromRequest } from "@/lib/auth";
|
|
||||||
import { requirePermission } from "@/lib/rbac";
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const categories = await prisma.boardCategory.findMany({
|
const categories = await prisma.boardCategory.findMany({
|
||||||
@@ -19,8 +17,6 @@ const createSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const userId = getUserIdFromRequest(req);
|
|
||||||
await requirePermission({ userId, resource: "ADMIN", action: "MODERATE" });
|
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const parsed = createSchema.safeParse(body);
|
const parsed = createSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ const updateSchema = z.object({
|
|||||||
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const userId = getUserIdFromRequest(req);
|
const userId = getUserIdFromRequest(req);
|
||||||
|
try {
|
||||||
await requirePermission({ userId, resource: "POST", action: "UPDATE" });
|
await requirePermission({ userId, resource: "POST", action: "UPDATE" });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const parsed = updateSchema.safeParse(body);
|
const parsed = updateSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
@@ -35,7 +39,11 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
|
|||||||
export async function DELETE(req: Request, context: { params: Promise<{ id: string }> }) {
|
export async function DELETE(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const userId = getUserIdFromRequest(req);
|
const userId = getUserIdFromRequest(req);
|
||||||
|
try {
|
||||||
await requirePermission({ userId, resource: "POST", action: "DELETE" });
|
await requirePermission({ userId, resource: "POST", action: "DELETE" });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
const post = await prisma.post.update({ where: { id }, data: { status: "deleted" } });
|
const post = await prisma.post.update({ where: { id }, data: { status: "deleted" } });
|
||||||
return NextResponse.json({ post });
|
return NextResponse.json({ post });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,13 +56,19 @@ export function AppHeader() {
|
|||||||
setOpenSlug(null);
|
setOpenSlug(null);
|
||||||
}, 150);
|
}, 150);
|
||||||
}, [cancelClose]);
|
}, [cancelClose]);
|
||||||
// 카테고리 로드
|
// 카테고리 로드 + 외부에서 새로고침 트리거 지원
|
||||||
React.useEffect(() => {
|
const reloadCategories = React.useCallback(() => {
|
||||||
fetch("/api/categories", { cache: "no-store" })
|
fetch("/api/categories", { cache: "no-store" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setCategories(d?.categories || []))
|
.then((d) => setCategories(d?.categories || []))
|
||||||
.catch(() => setCategories([]));
|
.catch(() => setCategories([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
React.useEffect(() => {
|
||||||
|
reloadCategories();
|
||||||
|
const onRefresh = () => reloadCategories();
|
||||||
|
window.addEventListener("categories:reload", onRefresh);
|
||||||
|
return () => window.removeEventListener("categories:reload", onRefresh);
|
||||||
|
}, [reloadCategories]);
|
||||||
|
|
||||||
// ESC로 메가메뉴 닫기
|
// ESC로 메가메뉴 닫기
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user