first commit
This commit is contained in:
112
app/admin/noticeboard/write/page.tsx
Normal file
112
app/admin/noticeboard/write/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
import { Crepe } from "@milkdown/crepe";
|
||||
import { MilkdownProvider } from "@milkdown/react";
|
||||
import { useCallback, useState,useRef } from "react";
|
||||
import { MilkdownEditor } from "@/app/components/editor";
|
||||
|
||||
export default function Page() {
|
||||
const titleRef = useRef<HTMLInputElement>(null!);
|
||||
const tagRef = useRef<HTMLSelectElement>(null!);
|
||||
|
||||
const crepeRef = useRef<Crepe>(null!);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
|
||||
const handlePost = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/notice', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: titleRef.current?.value,
|
||||
content: crepeRef.current?.getMarkdown(),
|
||||
tag: tagRef.current?.value,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setToastMessage('Post successful!');
|
||||
setTimeout(() => {
|
||||
setToastMessage('');
|
||||
window.location.href = '/usr/4_noticeboard'; // Redirect after success
|
||||
}, 1000); // 3 seconds delay
|
||||
} else {
|
||||
setToastMessage('Failed to post.');
|
||||
}
|
||||
} catch (error) {
|
||||
setToastMessage('An error occurred.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className=" bg-white h-full min-h-[760px]">
|
||||
<div className="h-full grid grid-rows-[20px_74px_minmax(300px,auto)_240px] grid-cols-[minmax(500px,880px)] justify-center py-8 gap-5
|
||||
2xl:grid-cols-[minmax(500px,880px)_auto] 2xl:grid-rows-[20px_74px_minmax(300px,1fr)_auto]
|
||||
">
|
||||
<div className="ml-1 text-xl leading-8 font-extrabold 2xl:col-[1/3] 2xl:row-[1/2]">게시글작성</div>
|
||||
<div className="flex flex-col justify-between 2xl:col-[1/2] 2xl:row-[2/3]">
|
||||
<div className="h-[14px] text-sm leading-4 text-[#3f2929] ml-1">
|
||||
제목
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="h-[48px] border-1 border-[#d5d5d5] rounded-lg px-2 mx-1"
|
||||
placeholder="제목을 입력하세요"
|
||||
ref={titleRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between 2xl:col-[1/2] 2xl:row-[3/4]">
|
||||
<div className="h-[14px] text-sm leading-4 text-[#3f2929] ml-1"> 내용 </div>
|
||||
<div className="h-[13px]"></div>
|
||||
|
||||
<div className="grow-1 border-1 border-[#d5d5d5] rounded-lg px-2 mx-1 overflow-y-auto">
|
||||
<MilkdownProvider>
|
||||
<MilkdownEditor editorRef={crepeRef} />
|
||||
</MilkdownProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center 2xl:col-[2/3] 2xl:row-[2/4] 2xl:justify-start">
|
||||
<div className="flex flex-col ">
|
||||
<div className="h-[14px] text-sm leading-4 text-[#3f2929] ml-1">
|
||||
작업 태그 설정
|
||||
</div>
|
||||
<div className="h-[13px]"></div>
|
||||
<select
|
||||
className="h-[48px] mx-1 border-1 border-[#d5d5d5] rounded-lg px-2"
|
||||
ref={tagRef}
|
||||
>
|
||||
<option value="중요">중요</option>
|
||||
<option value="0">없음</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="h-[48px]"></div>
|
||||
|
||||
<div className="flex flex-col justify-between h-[116px] items-center">
|
||||
<button onClick={handlePost} disabled={isLoading} className="w-[324px] h-[48px] border-1 border-[#D73B29] rounded-lg flex items-center justify-center text-white bg-[#F94B37] font-extrabold text-xl cursor-pointer hover:bg-[#D73B29]">
|
||||
{isLoading ? 'Posting...' : '게시'}
|
||||
</button>
|
||||
<div
|
||||
className="w-[324px] h-[48px] border-1 border-[#D5D5D5] rounded-lg flex items-center justify-center font-medium text-xl cursor-pointer hover:bg-[#E5E5E5]"
|
||||
onClick={() => window.location.href = '/usr/4_noticeboard'}
|
||||
>
|
||||
취소
|
||||
</div>
|
||||
</div>
|
||||
{toastMessage && (
|
||||
<div className="fixed bottom-4 right-4 bg-gray-800 text-white p-4 rounded shadow-lg">
|
||||
{toastMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
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