Skip to content

Commit 12d137c

Browse files
Aprasaksclaude
andcommitted
feat: 방명록 기능 추가 및 Supabase 연동 (#59)
- 메인 페이지 방명록 모달 UI (파피루스 텍스처, 페이지네이션) - GET/POST /api/guestbook API 라우트 구현 - bcryptjs로 비밀번호 해싱 - Supabase guestbook 테이블 연동 - i18n enterArchive 텍스트 업데이트 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9d4d8d8 commit 12d137c

5 files changed

Lines changed: 242 additions & 2 deletions

File tree

package-lock.json

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@google/generative-ai": "^0.24.1",
1717
"@supabase/supabase-js": "^2.103.2",
1818
"@tailwindcss/typography": "^0.5.19",
19+
"bcryptjs": "^3.0.3",
1920
"class-variance-authority": "^0.7.1",
2021
"clsx": "^2.1.1",
2122
"cohere-ai": "^8.0.0",
@@ -37,6 +38,7 @@
3738
"devDependencies": {
3839
"@anthropic-ai/sdk": "^0.82.0",
3940
"@tailwindcss/postcss": "^4",
41+
"@types/bcryptjs": "^2.4.6",
4042
"@types/github-slugger": "^1.3.0",
4143
"@types/node": "^20",
4244
"@types/react": "^19",

src/app/api/guestbook/route.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { createClient } from '@supabase/supabase-js';
3+
import bcrypt from 'bcryptjs';
4+
5+
const supabase = createClient(
6+
process.env.SUPABASE_URL!,
7+
process.env.SUPABASE_ANON_KEY!,
8+
);
9+
10+
// GET — 메시지 목록
11+
export async function GET() {
12+
const { data, error } = await supabase
13+
.from('guestbook')
14+
.select('id, name, message, created_at')
15+
.order('created_at', { ascending: false });
16+
17+
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
18+
return NextResponse.json({ messages: data });
19+
}
20+
21+
// POST — 메시지 등록
22+
export async function POST(req: NextRequest) {
23+
const { name, password, message } = await req.json() as {
24+
name: string;
25+
password: string;
26+
message: string;
27+
};
28+
29+
if (!name?.trim() || !password?.trim() || !message?.trim()) {
30+
return NextResponse.json({ error: '모든 항목을 입력해주세요.' }, { status: 400 });
31+
}
32+
33+
const hashedPassword = await bcrypt.hash(password, 10);
34+
35+
const { error } = await supabase.from('guestbook').insert({
36+
name: name.trim(),
37+
password: hashedPassword,
38+
message: message.trim(),
39+
});
40+
41+
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
42+
return NextResponse.json({ ok: true });
43+
}

src/components/home/HomeClient.tsx

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,179 @@
11
'use client';
22

3+
import { useState, useEffect, useCallback } from 'react';
34
import Link from 'next/link';
5+
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
46
import { useLang } from '@/components/layout/LangProvider';
57
import i18n from '@/lib/i18n';
68

9+
interface GuestMessage {
10+
id: string;
11+
name: string;
12+
message: string;
13+
created_at: string;
14+
}
15+
16+
const PER_PAGE = 3;
17+
18+
type T = typeof i18n['ko'] | typeof i18n['en'];
19+
20+
function GuestBookModal({ t, onClose }: { t: T; onClose: () => void }) {
21+
const [page, setPage] = useState(0);
22+
const [messages, setMessages] = useState<GuestMessage[]>([]);
23+
const [name, setName] = useState('');
24+
const [password, setPassword] = useState('');
25+
const [message, setMessage] = useState('');
26+
const [submitting, setSubmitting] = useState(false);
27+
28+
const totalPages = Math.ceil(messages.length / PER_PAGE);
29+
const pageMessages = messages.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE);
30+
31+
const fetchMessages = useCallback(async () => {
32+
const res = await fetch('/api/guestbook');
33+
const data = await res.json() as { messages: GuestMessage[] };
34+
setMessages(data.messages ?? []);
35+
}, []);
36+
37+
useEffect(() => { fetchMessages(); }, [fetchMessages]);
38+
39+
const handleSubmit = async () => {
40+
if (!name.trim() || !password.trim() || !message.trim()) return;
41+
setSubmitting(true);
42+
await fetch('/api/guestbook', {
43+
method: 'POST',
44+
headers: { 'Content-Type': 'application/json' },
45+
body: JSON.stringify({ name, password, message }),
46+
});
47+
setName(''); setPassword(''); setMessage('');
48+
await fetchMessages();
49+
setPage(0);
50+
setSubmitting(false);
51+
};
52+
53+
return (
54+
<>
55+
{/* 백드롭 */}
56+
<div
57+
className="fixed inset-0 z-40 bg-black/30 backdrop-blur-[2px]"
58+
onClick={onClose}
59+
/>
60+
61+
{/* 모달 */}
62+
<div
63+
className="fixed left-1/2 top-1/2 z-50 w-full max-w-xl -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-2xl"
64+
style={{
65+
background: 'rgba(20,12,4,0.6)',
66+
backdropFilter: 'blur(24px)',
67+
maskImage: 'linear-gradient(to bottom, transparent, black 15%, black 85%, transparent), linear-gradient(to right, transparent, black 10%, black 90%, transparent)',
68+
WebkitMaskImage: 'linear-gradient(to bottom, transparent, black 15%, black 85%, transparent), linear-gradient(to right, transparent, black 10%, black 90%, transparent)',
69+
maskComposite: 'intersect',
70+
WebkitMaskComposite: 'source-in',
71+
}}
72+
>
73+
{/* 파피루스 텍스처 */}
74+
<div
75+
className="pointer-events-none absolute inset-0"
76+
style={{
77+
backgroundImage: 'url(/images/background-image.svg)',
78+
backgroundSize: 'cover',
79+
opacity: 0.5,
80+
}}
81+
/>
82+
83+
<div className="relative px-6 py-6">
84+
{/* 헤더 */}
85+
<div className="mb-5 flex items-center justify-between">
86+
<p className="text-xs font-semibold tracking-widest uppercase text-white">
87+
{t.guestBookTitle}
88+
</p>
89+
<button
90+
onClick={onClose}
91+
className="rounded-lg p-1 text-zinc-300 transition-colors hover:text-white"
92+
>
93+
<X size={15} />
94+
</button>
95+
</div>
96+
97+
{/* 입력 */}
98+
<div className="mb-5 flex flex-col gap-2">
99+
<div className="flex gap-2">
100+
<input
101+
type="text"
102+
placeholder={t.guestBookNickname}
103+
value={name}
104+
onChange={(e) => setName(e.target.value)}
105+
className="flex-1 rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-sm text-zinc-100 outline-none placeholder:text-zinc-300 focus:border-white/40"
106+
/>
107+
<input
108+
type="password"
109+
placeholder={t.guestBookPassword}
110+
value={password}
111+
onChange={(e) => setPassword(e.target.value)}
112+
className="flex-1 rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-sm text-zinc-100 outline-none placeholder:text-zinc-300 focus:border-white/40"
113+
/>
114+
</div>
115+
<div className="flex gap-2">
116+
<input
117+
type="text"
118+
placeholder={t.guestBookMessage}
119+
value={message}
120+
onChange={(e) => setMessage(e.target.value)}
121+
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
122+
className="flex-1 rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-sm text-zinc-100 outline-none placeholder:text-zinc-300 focus:border-white/40"
123+
/>
124+
<button
125+
onClick={handleSubmit}
126+
disabled={submitting}
127+
className="shrink-0 rounded-lg bg-white/20 px-4 py-2 text-sm font-semibold text-zinc-100 transition-colors hover:bg-white/30 disabled:opacity-40"
128+
>
129+
{t.guestBookSubmit}
130+
</button>
131+
</div>
132+
</div>
133+
134+
{/* 구분선 */}
135+
<div className="mb-4 h-px bg-white/10" />
136+
137+
{/* 메시지 */}
138+
<div className="flex flex-col gap-4">
139+
{pageMessages.map((msg) => (
140+
<div key={msg.id} className="flex flex-col gap-0.5">
141+
<div className="flex items-center gap-2">
142+
<span className="text-sm font-semibold text-zinc-100">{msg.name}</span>
143+
<span className="text-[11px] text-zinc-300">{new Date(msg.created_at).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' })}</span>
144+
</div>
145+
<p className="text-sm leading-relaxed text-white">{msg.message}</p>
146+
</div>
147+
))}
148+
</div>
149+
150+
{/* 페이지네이션 */}
151+
<div className="mt-4 flex items-center justify-end gap-2">
152+
<button
153+
onClick={() => setPage((p) => Math.max(0, p - 1))}
154+
disabled={page === 0}
155+
className="text-amber-300 transition-opacity disabled:opacity-20"
156+
>
157+
<ChevronLeft size={18} />
158+
</button>
159+
<button
160+
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
161+
disabled={page === totalPages - 1}
162+
className="text-amber-300 transition-opacity disabled:opacity-20"
163+
>
164+
<ChevronRight size={18} />
165+
</button>
166+
</div>
167+
</div>
168+
</div>
169+
</>
170+
);
171+
}
172+
7173
export default function HomeClient() {
8174
const { lang } = useLang();
9175
const t = i18n[lang];
176+
const [guestBookOpen, setGuestBookOpen] = useState(false);
10177

11178
return (
12179
<main className="flex flex-1 flex-col items-center justify-center px-6 pb-20 text-center">
@@ -23,7 +190,17 @@ export default function HomeClient() {
23190
>
24191
{t.enterArchive}
25192
</Link>
193+
<button
194+
onClick={() => setGuestBookOpen(true)}
195+
className="rounded-full border border-zinc-600 px-8 py-2.5 text-sm font-medium text-zinc-300 backdrop-blur-sm transition-all hover:border-zinc-400 hover:text-white active:scale-95"
196+
>
197+
{t.guestBookTitle}
198+
</button>
26199
</div>
200+
201+
{guestBookOpen && (
202+
<GuestBookModal t={t} onClose={() => setGuestBookOpen(false)} />
203+
)}
27204
</main>
28205
);
29206
}

src/lib/i18n.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const i18n = {
55
// Home
66
homeTagline1: '생각이 기록이 되는 순간',
77
homeTagline2: '의미를 가진다.',
8-
enterArchive: '서고 입장',
8+
enterArchive: '아카이브 입장',
99

1010
// About
1111
aboutLabel: 'About Demian',
@@ -79,7 +79,7 @@ const i18n = {
7979
// Home
8080
homeTagline1: 'When thoughts become records,',
8181
homeTagline2: 'meaning is born.',
82-
enterArchive: 'Enter Archive →',
82+
enterArchive: 'Enter Library',
8383

8484
// About
8585
aboutLabel: 'About Demian',

0 commit comments

Comments
 (0)