adminpage

This commit is contained in:
koreacomp5
2025-10-30 10:03:12 +09:00
parent da6b396acc
commit 108a07ab9d

View File

@@ -20,6 +20,9 @@ export default function AdminBoardsPage() {
}, [boards, categories]); }, [boards, categories]);
const [savingId, setSavingId] = useState<string | null>(null); 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) { 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) });
@@ -40,9 +43,44 @@ export default function AdminBoardsPage() {
mutateBoards(); 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 ( 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">
<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="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>
@@ -53,10 +91,7 @@ export default function AdminBoardsPage() {
const [moved] = arr.splice(from, 1); const [moved] = arr.splice(from, 1);
arr.splice(to, 0, moved); arr.splice(to, 0, moved);
reorderCategories(arr); reorderCategories(arr);
}} onSave={async (payload) => { }} onDirty={(payload) => markCatDirty(g.id, { ...payload })} />
await fetch(`/api/admin/categories/${g.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
mutateCats();
}} />
))} ))}
</ul> </ul>
</div> </div>
@@ -96,7 +131,7 @@ export default function AdminBoardsPage() {
reorderBoards(g.id, list); reorderBoards(g.id, list);
}} }}
> >
<Row b={b} onSave={save} saving={savingId === b.id} /> <BoardRowCells b={b} onDirty={(id, draft) => markBoardDirty(id, draft)} />
</DraggableRow> </DraggableRow>
))} ))}
</tbody> </tbody>
@@ -108,14 +143,14 @@ export default function AdminBoardsPage() {
); );
} }
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); const [edit, setEdit] = useState(b);
return ( return (
<tr className="align-middle"> <>
<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) => setEdit({ ...edit, name: e.target.value })} /></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) => setEdit({ ...edit, slug: e.target.value })} /></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"> <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) => setEdit({ ...edit, readLevel: e.target.value })}> <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>
<option value="member">member</option> <option value="member">member</option>
<option value="moderator">moderator</option> <option value="moderator">moderator</option>
@@ -123,26 +158,25 @@ function Row({ b, onSave, saving }: { b: any; onSave: (b: any) => void; saving:
</select> </select>
</td> </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.writeLevel} onChange={(e) => setEdit({ ...edit, writeLevel: e.target.value })}> <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="public">public</option>
<option value="member">member</option> <option value="member">member</option>
<option value="moderator">moderator</option> <option value="moderator">moderator</option>
<option value="admin">admin</option> <option value="admin">admin</option>
</select> </select>
</td> </td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => setEdit({ ...edit, allowAnonymousPost: e.target.checked })} /></td> <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) => setEdit({ ...edit, allowSecretComment: e.target.checked })} /></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) => setEdit({ ...edit, requiresApproval: e.target.checked })} /></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"> <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) => setEdit({ ...edit, type: e.target.value })}> <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="general">general</option>
<option value="special">special</option> <option value="special">special</option>
</select> </select>
</td> </td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => setEdit({ ...edit, isAdultOnly: e.target.checked })} /></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) => setEdit({ ...edit, sortOrder: Number(e.target.value) })} /></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>
<td className="px-3 py-2 text-right"><button className="h-9 px-3 rounded-md bg-neutral-900 text-white text-sm disabled:opacity-60" onClick={() => onSave(edit)} disabled={saving}>{saving ? "저장중" : "저장"}</button></td> </>
</tr>
); );
} }
@@ -153,44 +187,180 @@ function DraggableRow({ index, onMove, children }: { index: number; onMove: (fro
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";
// 수직만 보이게: 실제 행을 고정 포지션으로 띄워 따라오게 함 + 전역 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;
});
}
}} }}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { onDrop={(e) => {
e.preventDefault(); e.preventDefault();
const from = Number(e.dataTransfer.getData("text/plain")); const from = Number(e.dataTransfer.getData("text/plain"));
const to = index; 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); if (!Number.isNaN(from) && from !== to) onMove(from, to);
}} }}
className="align-middle" className="align-middle cursor-ns-resize select-none"
> >
{children} {children}
</tr> </tr>
); );
} }
function CategoryRow({ idx, g, onMove, onSave }: { idx: number; g: any; onMove: (from: number, to: number) => void; onSave: (payload: any) => void }) { 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 }); const [edit, setEdit] = useState({ name: g.name, slug: g.slug });
return ( return (
<li <li
className="px-4 py-3 flex items-center gap-3" className="px-4 py-3 flex items-center gap-3 cursor-ns-resize select-none"
draggable draggable
onDragStart={(e) => { onDragStart={(e) => {
e.dataTransfer.setData("text/plain", String(idx)); e.dataTransfer.setData("text/plain", String(idx));
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 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;
});
}
}} }}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { onDrop={(e) => {
e.preventDefault(); e.preventDefault();
const from = Number(e.dataTransfer.getData("text/plain")); const from = Number(e.dataTransfer.getData("text/plain"));
const to = idx; 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); if (!Number.isNaN(from) && from !== to) onMove(from, to);
}} }}
> >
<div className="w-6 text-xs text-neutral-500"></div> <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) => setEdit({ ...edit, name: e.target.value })} /> <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) => setEdit({ ...edit, slug: e.target.value })} /> <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" />
<button className="h-8 px-3 rounded-md border border-neutral-300 text-sm hover:bg-neutral-100" onClick={() => onSave(edit)}></button>
</li> </li>
); );
} }