313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
"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>
|
|
);
|
|
}
|
|
|
|
|