first commit
This commit is contained in:
312
app/admin/page.tsx
Normal file
312
app/admin/page.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const router = useRouter();
|
||||
const noticeSectionRef = useRef<HTMLDivElement>(null);
|
||||
const [value, setValue] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [saving, setSaving] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [okMsg, setOkMsg] = useState<string>("");
|
||||
const [handles, setHandles] = useState<Array<{
|
||||
id: string;
|
||||
email: string;
|
||||
handle: string;
|
||||
icon: string;
|
||||
isApproved: boolean;
|
||||
createtime: string;
|
||||
}>>([]);
|
||||
const [loadingHandles, setLoadingHandles] = useState<boolean>(false);
|
||||
const [pendingIds, setPendingIds] = useState<Set<string>>(new Set());
|
||||
const [notices, setNotices] = useState<Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tag: string;
|
||||
pubDate: string;
|
||||
}>>([]);
|
||||
const [loadingNotices, setLoadingNotices] = useState<boolean>(false);
|
||||
const [pendingNoticeIds, setPendingNoticeIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadHandles = async () => {
|
||||
setLoadingHandles(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/user_handles', { cache: 'no-store' });
|
||||
const data = await res.json();
|
||||
if (res.ok) setHandles(data.items ?? []);
|
||||
} finally {
|
||||
setLoadingHandles(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch('/api/cost_per_view', { cache: 'no-store' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error ?? '로드 실패');
|
||||
if (typeof data.value === 'number') setValue(String(data.value));
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? '로드 실패');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 최초 진입 시 핸들 목록 로드
|
||||
loadHandles();
|
||||
loadNotices();
|
||||
}, []);
|
||||
|
||||
const loadNotices = async () => {
|
||||
setLoadingNotices(true);
|
||||
try {
|
||||
const res = await fetch('/api/notice', { cache: 'no-store' });
|
||||
const data = await res.json();
|
||||
if (res.ok) setNotices(data ?? []);
|
||||
} finally {
|
||||
setLoadingNotices(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNotice = async (id: string) => {
|
||||
if (!confirm('이 공지글을 삭제하시겠습니까? 삭제 후 되돌릴 수 없습니다.')) return;
|
||||
setPendingNoticeIds(prev => new Set(prev).add(id));
|
||||
try {
|
||||
const res = await fetch(`/api/notice?id=${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error ?? '삭제 실패');
|
||||
}
|
||||
setNotices(prev => prev.filter(n => n.id !== id));
|
||||
} catch (e) {
|
||||
alert('삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setPendingNoticeIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setOkMsg("");
|
||||
try {
|
||||
const res = await fetch('/api/cost_per_view', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.ok) throw new Error(data?.error ?? '저장 실패');
|
||||
setOkMsg('저장되었습니다');
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? '저장 실패');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleApprove = async (id: string, approve: boolean) => {
|
||||
setPendingIds(prev => new Set(prev).add(id));
|
||||
try {
|
||||
const res = await fetch('/api/admin/user_handles/approve', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, approve }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.ok) throw new Error(data?.error ?? '변경 실패');
|
||||
setHandles(prev => prev.map(h => h.id === id ? { ...h, isApproved: approve } : h));
|
||||
} catch (e) {
|
||||
alert('변경 실패');
|
||||
} finally {
|
||||
setPendingIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[900px] mx-auto p-4">
|
||||
<div className="w-full flex justify-between mb-3">
|
||||
<button
|
||||
className="px-3 py-1 rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] text-sm"
|
||||
onClick={() => router.push('/usr/1_dashboard')}
|
||||
>
|
||||
대시보드로
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-4">관리자 설정</h1>
|
||||
<div className="border-1 border-[#e6e9ef] rounded-lg bg-white p-4">
|
||||
<div className="mb-2 font-semibold">Cost Per View</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
className="border-1 border-border-pale rounded-md px-2 py-1 w-[220px]"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<button
|
||||
className="px-4 py-2 rounded-md text-white bg-[#F94B37] disabled:opacity-60"
|
||||
onClick={onSave}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
{saving ? '저장 중...' : '적용'}
|
||||
</button>
|
||||
</div>
|
||||
{loading && <div className="text-sm text-[#848484] mt-2">불러오는 중...</div>}
|
||||
{error && <div className="text-sm text-red-600 mt-2">{error}</div>}
|
||||
{okMsg && <div className="text-sm text-green-600 mt-2">{okMsg}</div>}
|
||||
</div>
|
||||
|
||||
<div className="border-1 border-[#e6e9ef] rounded-lg bg-white p-4 mt-6">
|
||||
<div className="mb-2 font-semibold flex items-center justify-between">
|
||||
<span>User Handle 목록</span>
|
||||
<button
|
||||
className="px-3 py-1 rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] text-sm"
|
||||
onClick={loadHandles}
|
||||
>새로고침</button>
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full min-w-[680px] table-fixed border-separate border-spacing-y-1">
|
||||
<colgroup>
|
||||
<col className="w-[48px]" />
|
||||
<col className="w-[200px]" />
|
||||
<col className="w-[220px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[160px]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="h-[40px]">
|
||||
<th className="text-left">아이콘</th>
|
||||
<th className="text-left">핸들</th>
|
||||
<th className="text-left">이메일</th>
|
||||
<th className="text-left">승인</th>
|
||||
<th className="text-left">등록일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{handles.map((h) => (
|
||||
<tr key={h.id} className="h-[48px] bg-white border-1 border-[#e6e9ef] rounded-lg">
|
||||
<td className="px-1">
|
||||
<div className="w-[36px] h-[36px] rounded-full border-1 border-[#e6e9ef] overflow-hidden">
|
||||
{h.icon ? (
|
||||
<img src={h.icon} alt="icon" className="w-[36px] h-[36px] rounded-full" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-[#f0f0f0]" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 font-semibold">{h.handle}</td>
|
||||
<td className="px-2 text-sm text-[#555]">{h.email}</td>
|
||||
<td className="px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${h.isApproved ? '' : 'text-[#F94B37]'}`}>{h.isApproved ? '승인' : '미승인'}</span>
|
||||
<button
|
||||
className="px-2 py-1 text-xs rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] disabled:opacity-60"
|
||||
onClick={() => toggleApprove(h.id, !h.isApproved)}
|
||||
disabled={pendingIds.has(h.id)}
|
||||
>
|
||||
{h.isApproved ? '미승인으로' : '승인'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 text-sm">{h.createtime?.split('T')[0]}</td>
|
||||
</tr>
|
||||
))}
|
||||
{handles.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-4 text-[#848484]">
|
||||
{loadingHandles ? '불러오는 중...' : '데이터가 없습니다'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={noticeSectionRef} className="border-1 border-[#e6e9ef] rounded-lg bg-white p-4 mt-6">
|
||||
<div className="mb-2 font-semibold flex items-center justify-between">
|
||||
<span>NoticeBoard 목록</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="px-3 py-1 rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] text-sm"
|
||||
onClick={() => router.push('/usr/4_noticeboard')}
|
||||
>게시글 목록</button>
|
||||
<button
|
||||
className="px-3 py-1 rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] text-sm"
|
||||
onClick={() => router.push('/admin/noticeboard/write')}
|
||||
>글쓰기</button>
|
||||
<button
|
||||
className="px-3 py-1 rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] text-sm"
|
||||
onClick={loadNotices}
|
||||
>새로고침</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full min-w-[680px] table-fixed border-separate border-spacing-y-1">
|
||||
<colgroup>
|
||||
<col className="w-[220px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col />
|
||||
<col className="w-[140px]" />
|
||||
<col className="w-[100px]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="h-[40px]">
|
||||
<th className="text-left">제목</th>
|
||||
<th className="text-left">태그</th>
|
||||
<th className="text-left">내용</th>
|
||||
<th className="text-left">게시일</th>
|
||||
<th className="text-left">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{notices.map((n) => (
|
||||
<tr key={n.id} className="h-[48px] bg-white border-1 border-[#e6e9ef] rounded-lg">
|
||||
<td className="px-2 font-semibold truncate">{n.title}</td>
|
||||
<td className="px-2 text-sm text-[#555]">{n.tag}</td>
|
||||
<td className="px-2 text-sm truncate">{n.content}</td>
|
||||
<td className="px-2 text-sm">{new Date(n.pubDate).toISOString().split('T')[0]}</td>
|
||||
<td className="px-2 text-sm">
|
||||
<button
|
||||
className="px-2 py-1 text-xs rounded-md border-1 border-border-pale hover:border-[#F94B37] hover:text-[#F94B37] disabled:opacity-60"
|
||||
onClick={() => deleteNotice(n.id)}
|
||||
disabled={pendingNoticeIds.has(n.id)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{notices.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-4 text-[#848484]">
|
||||
{loadingNotices ? '불러오는 중...' : '데이터가 없습니다'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user