first commit

This commit is contained in:
2025-09-07 22:57:43 +00:00
commit 3bd542adbf
122 changed files with 45056 additions and 0 deletions

View 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
View 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>
);
}