Skip to content

Commit 924b1be

Browse files
junzero741claude
andcommitted
feat(frontend): add report modal and admin page
- 글 열람 후 하단에 신고 버튼 노출 - ReportModal: reason 선택 + 선택적 description 입력 - /admin: 비밀키 로그인 후 신고 목록 조회, 글 삭제/신고 기각 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0da3288 commit 924b1be

6 files changed

Lines changed: 368 additions & 6 deletions

File tree

apps/frontend/src/app/[slug]/post-unlock.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
'use client';
22

33
import { useState } from 'react';
4-
import { viewPost } from '../../lib/api';
5-
import { sanitizeHtml } from '../../lib/sanitize';
4+
import { viewPost } from '@/lib/api';
5+
import { sanitizeHtml } from '@/lib/sanitize';
6+
import ReportModal from '@/components/ReportModal';
67

78
type Step = 'auth' | 'view';
89

@@ -12,6 +13,7 @@ export default function PostUnlock({ slug }: { slug: string }) {
1213
const [post, setPost] = useState<{ title: string; content: string } | null>(null);
1314
const [error, setError] = useState('');
1415
const [loading, setLoading] = useState(false);
16+
const [reportOpen, setReportOpen] = useState(false);
1517

1618
async function handleSubmit(e: React.FormEvent) {
1719
e.preventDefault();
@@ -38,6 +40,19 @@ export default function PostUnlock({ slug }: { slug: string }) {
3840
className="prose prose-slate max-w-none prose-headings:text-text-primary prose-p:text-text-primary prose-a:text-brand-600 prose-strong:text-text-primary prose-img:rounded-lg"
3941
dangerouslySetInnerHTML={{ __html: sanitizeHtml(post.content) }}
4042
/>
43+
<div className="mt-12 flex justify-end">
44+
<button
45+
onClick={() => setReportOpen(true)}
46+
className="flex items-center gap-1.5 text-xs text-text-secondary hover:text-error transition-colors"
47+
>
48+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-3.5 w-3.5">
49+
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
50+
<line x1="4" x2="4" y1="22" y2="15" />
51+
</svg>
52+
신고하기
53+
</button>
54+
</div>
55+
{reportOpen && <ReportModal slug={slug} onClose={() => setReportOpen(false)} />}
4156
</section>
4257
);
4358
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
'use client';
2+
3+
import { useState, useEffect, useCallback } from 'react';
4+
import { ReportReason } from '@private-board/shared';
5+
import { REPORT_REASON_LABELS } from '@/lib/constants';
6+
import { AdminReport, getAdminReports, adminDeletePost, adminDismissReport } from '@/lib/api';
7+
8+
export default function AdminPage() {
9+
const [secret, setSecret] = useState('');
10+
const [input, setInput] = useState('');
11+
const [reports, setReports] = useState<AdminReport[]>([]);
12+
const [loading, setLoading] = useState(false);
13+
const [error, setError] = useState('');
14+
15+
const fetchReports = useCallback(async (s: string) => {
16+
setLoading(true);
17+
setError('');
18+
try {
19+
const data = await getAdminReports(s);
20+
setReports(data);
21+
} catch (err) {
22+
if (err instanceof Error && err.message === 'UNAUTHORIZED') {
23+
setSecret('');
24+
sessionStorage.removeItem('adminSecret');
25+
setError('인증에 실패했습니다.');
26+
} else {
27+
setError(err instanceof Error ? err.message : '오류가 발생했습니다.');
28+
}
29+
} finally {
30+
setLoading(false);
31+
}
32+
}, []);
33+
34+
useEffect(() => {
35+
const saved = sessionStorage.getItem('adminSecret');
36+
if (saved) {
37+
setSecret(saved);
38+
fetchReports(saved);
39+
}
40+
}, [fetchReports]);
41+
42+
async function handleLogin(e: React.FormEvent) {
43+
e.preventDefault();
44+
setError('');
45+
sessionStorage.setItem('adminSecret', input);
46+
setSecret(input);
47+
await fetchReports(input);
48+
}
49+
50+
function handleLogout() {
51+
sessionStorage.removeItem('adminSecret');
52+
setSecret('');
53+
setReports([]);
54+
setInput('');
55+
}
56+
57+
async function handleDelete(slug: string) {
58+
if (!confirm(`"${slug}" 게시글을 삭제하시겠습니까?`)) return;
59+
try {
60+
await adminDeletePost(secret, slug);
61+
setReports((prev) => prev.filter((r) => r.post.slug !== slug));
62+
} catch (err) {
63+
alert(err instanceof Error ? err.message : '오류가 발생했습니다.');
64+
}
65+
}
66+
67+
async function handleDismiss(id: string) {
68+
try {
69+
await adminDismissReport(secret, id);
70+
setReports((prev) => prev.filter((r) => r.id !== id));
71+
} catch (err) {
72+
alert(err instanceof Error ? err.message : '오류가 발생했습니다.');
73+
}
74+
}
75+
76+
if (!secret) {
77+
return (
78+
<section className="mx-auto max-w-sm px-4 py-16">
79+
<div className="rounded-xl border border-border bg-surface-card p-8 shadow-md">
80+
<h1 className="mb-6 text-lg font-semibold text-text-primary">관리자</h1>
81+
<form onSubmit={handleLogin} className="flex flex-col gap-4">
82+
<input
83+
type="password"
84+
placeholder="관리자 비밀키"
85+
value={input}
86+
onChange={(e) => setInput(e.target.value)}
87+
autoFocus
88+
className="rounded-lg border border-border bg-surface px-3 py-2.5 text-sm shadow-sm focus:border-border-focus focus:outline-none"
89+
/>
90+
{error && <p className="text-xs text-error">{error}</p>}
91+
<button
92+
type="submit"
93+
disabled={!input}
94+
className="rounded-lg bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
95+
>
96+
로그인
97+
</button>
98+
</form>
99+
</div>
100+
</section>
101+
);
102+
}
103+
104+
return (
105+
<section className="mx-auto max-w-3xl px-4 py-10">
106+
<div className="mb-6 flex items-center justify-between">
107+
<h1 className="text-xl font-semibold text-text-primary">신고 관리</h1>
108+
<div className="flex items-center gap-3">
109+
<button
110+
onClick={() => fetchReports(secret)}
111+
className="text-sm text-text-secondary hover:text-text-primary"
112+
>
113+
새로고침
114+
</button>
115+
<button
116+
onClick={handleLogout}
117+
className="text-sm text-text-secondary hover:text-text-primary"
118+
>
119+
로그아웃
120+
</button>
121+
</div>
122+
</div>
123+
124+
{loading && (
125+
<p className="text-sm text-text-secondary">불러오는 중...</p>
126+
)}
127+
128+
{error && (
129+
<p className="text-sm text-error">{error}</p>
130+
)}
131+
132+
{!loading && reports.length === 0 && (
133+
<div className="rounded-xl border border-border bg-surface-card px-6 py-12 text-center">
134+
<p className="text-sm text-text-secondary">처리 대기 중인 신고가 없습니다.</p>
135+
</div>
136+
)}
137+
138+
<ul className="flex flex-col gap-4">
139+
{reports.map((report) => (
140+
<li key={report.id} className="rounded-xl border border-border bg-surface-card p-5">
141+
<div className="mb-3 flex items-start justify-between gap-4">
142+
<div>
143+
<p className="font-medium text-text-primary">{report.post.title}</p>
144+
<p className="mt-0.5 text-xs text-text-secondary">/{report.post.slug}</p>
145+
</div>
146+
<span className="shrink-0 rounded-full bg-brand-100 px-2.5 py-0.5 text-xs font-medium text-brand-600">
147+
{REPORT_REASON_LABELS[report.reason]}
148+
</span>
149+
</div>
150+
151+
{report.description && (
152+
<p className="mb-3 text-sm text-text-secondary">{report.description}</p>
153+
)}
154+
155+
<div className="flex items-center justify-between">
156+
<p className="text-xs text-text-muted">
157+
{report.reporterIp} · {new Date(report.createdAt).toLocaleString('ko-KR')}
158+
</p>
159+
<div className="flex gap-2">
160+
<button
161+
onClick={() => handleDismiss(report.id)}
162+
className="rounded-lg border border-border px-3 py-1.5 text-xs text-text-secondary hover:bg-surface transition-colors"
163+
>
164+
신고 기각
165+
</button>
166+
<button
167+
onClick={() => handleDelete(report.post.slug)}
168+
className="rounded-lg bg-error px-3 py-1.5 text-xs font-medium text-white hover:opacity-90 transition-opacity"
169+
>
170+
글 삭제
171+
</button>
172+
</div>
173+
</div>
174+
</li>
175+
))}
176+
</ul>
177+
</section>
178+
);
179+
}

apps/frontend/src/app/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
'use client';
22

33
import { useState, useEffect, useRef, useCallback } from 'react';
4-
import Editor from '../components/Editor';
5-
import { createPost } from '../lib/api';
6-
import { replaceBlobsWithUrls } from '../lib/image';
4+
import Editor from '@/components/Editor';
5+
import { createPost } from '@/lib/api';
6+
import { replaceBlobsWithUrls } from '@/lib/image';
77
import { FEATURE_FLAGS } from '@private-board/shared';
88

99
type Step = 'write' | 'done';
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { ReportReason } from '@private-board/shared';
5+
import { REPORT_REASON_LABELS } from '@/lib/constants';
6+
import { reportPost } from '@/lib/api';
7+
8+
const REASONS = Object.keys(REPORT_REASON_LABELS) as ReportReason[];
9+
10+
export default function ReportModal({ slug, onClose }: { slug: string; onClose: () => void }) {
11+
const [reason, setReason] = useState<ReportReason>('ILLEGAL_CONTENT');
12+
const [description, setDescription] = useState('');
13+
const [loading, setLoading] = useState(false);
14+
const [error, setError] = useState('');
15+
const [done, setDone] = useState(false);
16+
17+
async function handleSubmit(e: React.FormEvent) {
18+
e.preventDefault();
19+
setError('');
20+
setLoading(true);
21+
try {
22+
await reportPost(slug, reason, description.trim() || undefined);
23+
setDone(true);
24+
} catch (err) {
25+
setError(err instanceof Error ? err.message : '오류가 발생했습니다.');
26+
} finally {
27+
setLoading(false);
28+
}
29+
}
30+
31+
return (
32+
<div
33+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
34+
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
35+
>
36+
<div className="w-full max-w-sm rounded-xl border border-border bg-surface-card p-6 shadow-lg">
37+
{done ? (
38+
<div className="flex flex-col items-center gap-3 py-4 text-center">
39+
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-brand-100">
40+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5 text-brand-600">
41+
<polyline points="20 6 9 17 4 12" />
42+
</svg>
43+
</div>
44+
<p className="text-sm font-medium text-text-primary">신고가 접수되었습니다.</p>
45+
<p className="text-xs text-text-secondary">검토 후 조치하겠습니다.</p>
46+
<button onClick={onClose} className="mt-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
47+
닫기
48+
</button>
49+
</div>
50+
) : (
51+
<>
52+
<div className="mb-4 flex items-center justify-between">
53+
<h2 className="text-base font-semibold text-text-primary">게시글 신고</h2>
54+
<button onClick={onClose} className="text-text-secondary hover:text-text-primary">
55+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
56+
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
57+
</svg>
58+
</button>
59+
</div>
60+
61+
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
62+
<fieldset className="flex flex-col gap-2">
63+
<legend className="mb-1 text-xs font-medium text-text-secondary">신고 사유</legend>
64+
{REASONS.map((r) => (
65+
<label key={r} className="flex cursor-pointer items-center gap-2.5 text-sm text-text-primary">
66+
<input
67+
type="radio"
68+
name="reason"
69+
value={r}
70+
checked={reason === r}
71+
onChange={() => setReason(r)}
72+
className="accent-brand-600"
73+
/>
74+
{REPORT_REASON_LABELS[r]}
75+
</label>
76+
))}
77+
</fieldset>
78+
79+
<div className="flex flex-col gap-1">
80+
<label className="text-xs font-medium text-text-secondary">상세 설명 (선택)</label>
81+
<textarea
82+
value={description}
83+
onChange={(e) => setDescription(e.target.value)}
84+
rows={3}
85+
maxLength={500}
86+
placeholder="추가 설명을 입력해주세요."
87+
className="resize-none rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:border-border-focus focus:outline-none"
88+
/>
89+
</div>
90+
91+
{error && (
92+
<p className="text-xs text-error">{error}</p>
93+
)}
94+
95+
<button
96+
type="submit"
97+
disabled={loading}
98+
className="rounded-lg bg-brand-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-brand-700 disabled:opacity-50"
99+
>
100+
{loading ? '제출 중...' : '신고하기'}
101+
</button>
102+
</form>
103+
</>
104+
)}
105+
</div>
106+
</div>
107+
);
108+
}

0 commit comments

Comments
 (0)