Skip to content

Commit 5b282c0

Browse files
committed
feat: add guestbook page
1 parent 17436d0 commit 5b282c0

3 files changed

Lines changed: 250 additions & 0 deletions

File tree

src/app/guestbook/page.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { Metadata } from 'next';
2+
import GuestBookClient from '@/components/guestbook/GuestBookClient';
3+
4+
export const metadata: Metadata = {
5+
title: 'Guestbook | Dechive',
6+
description: 'Dechive를 지나간 사람들이 남긴 짧은 흔적들.',
7+
};
8+
9+
export default function GuestBookPage() {
10+
return <GuestBookClient />;
11+
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useMemo, useState } from 'react';
4+
import { ChevronLeft, ChevronRight, Feather } from 'lucide-react';
5+
import { useLang } from '@/components/layout/LangProvider';
6+
import i18n from '@/lib/i18n';
7+
8+
interface GuestMessage {
9+
id: string;
10+
name: string;
11+
message: string;
12+
created_at: string;
13+
}
14+
15+
interface GuestBookResponse {
16+
messages?: GuestMessage[];
17+
error?: string;
18+
}
19+
20+
const PER_PAGE = 5;
21+
22+
export default function GuestBookClient() {
23+
const { lang } = useLang();
24+
const t = i18n[lang];
25+
const [messages, setMessages] = useState<GuestMessage[]>([]);
26+
const [page, setPage] = useState(0);
27+
const [name, setName] = useState('');
28+
const [password, setPassword] = useState('');
29+
const [message, setMessage] = useState('');
30+
const [loading, setLoading] = useState(true);
31+
const [submitting, setSubmitting] = useState(false);
32+
const [error, setError] = useState('');
33+
34+
const totalPages = Math.max(1, Math.ceil(messages.length / PER_PAGE));
35+
const pageMessages = useMemo(
36+
() => messages.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE),
37+
[messages, page],
38+
);
39+
40+
const fetchMessages = useCallback(async () => {
41+
setLoading(true);
42+
setError('');
43+
44+
try {
45+
const response = await fetch('/api/guestbook');
46+
const data = await response.json() as GuestBookResponse;
47+
48+
if (!response.ok) {
49+
setError(data.error ?? (lang === 'en' ? 'Unable to load messages.' : '방명록을 불러오지 못했습니다.'));
50+
return;
51+
}
52+
53+
setMessages(data.messages ?? []);
54+
} catch {
55+
setError(lang === 'en' ? 'Unable to load messages.' : '방명록을 불러오지 못했습니다.');
56+
} finally {
57+
setLoading(false);
58+
}
59+
}, [lang]);
60+
61+
useEffect(() => {
62+
fetchMessages();
63+
}, [fetchMessages]);
64+
65+
const handleSubmit = async () => {
66+
if (!name.trim() || !password.trim() || !message.trim()) {
67+
setError(lang === 'en' ? 'Please fill in every field.' : '모든 항목을 입력해주세요.');
68+
return;
69+
}
70+
71+
setSubmitting(true);
72+
setError('');
73+
74+
try {
75+
const response = await fetch('/api/guestbook', {
76+
method: 'POST',
77+
headers: { 'Content-Type': 'application/json' },
78+
body: JSON.stringify({
79+
name,
80+
password,
81+
message,
82+
}),
83+
});
84+
const data = await response.json() as GuestBookResponse;
85+
86+
if (!response.ok) {
87+
setError(data.error ?? (lang === 'en' ? 'Unable to leave a message.' : '방명록을 남기지 못했습니다.'));
88+
return;
89+
}
90+
91+
setName('');
92+
setPassword('');
93+
setMessage('');
94+
setPage(0);
95+
await fetchMessages();
96+
} catch {
97+
setError(lang === 'en' ? 'Unable to leave a message.' : '방명록을 남기지 못했습니다.');
98+
} finally {
99+
setSubmitting(false);
100+
}
101+
};
102+
103+
return (
104+
<main className="flex flex-1 items-center justify-center px-6 py-16">
105+
<section className="w-full max-w-3xl">
106+
<div className="mb-10 text-center">
107+
<p className="mb-3 text-xs font-medium uppercase tracking-[0.34em] text-white/45">
108+
Dechive
109+
</p>
110+
<h1 className="text-4xl font-semibold text-white sm:text-5xl">
111+
{lang === 'en' ? 'Guestbook' : '방명록'}
112+
</h1>
113+
<p className="mx-auto mt-5 max-w-xl text-sm leading-relaxed text-zinc-300">
114+
{lang === 'en'
115+
? 'If you passed through this library, leave a small trace.'
116+
: '이 도서관을 지나갔다면, 작은 흔적을 남겨주세요.'}
117+
</p>
118+
</div>
119+
120+
<div className="rounded-md border border-white/12 bg-black/35 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] backdrop-blur-md sm:p-6">
121+
<div className="grid gap-3 sm:grid-cols-2">
122+
<input
123+
type="text"
124+
autoComplete="nickname"
125+
placeholder={t.guestBookNickname}
126+
value={name}
127+
onChange={(event) => setName(event.target.value)}
128+
className="rounded-md border border-white/12 bg-black/25 px-4 py-3 text-sm text-white outline-none placeholder:text-white/45 focus:border-white/30"
129+
/>
130+
<input
131+
type="password"
132+
autoComplete="off"
133+
placeholder={t.guestBookPassword}
134+
value={password}
135+
onChange={(event) => setPassword(event.target.value)}
136+
className="rounded-md border border-white/12 bg-black/25 px-4 py-3 text-sm text-white outline-none placeholder:text-white/45 focus:border-white/30"
137+
/>
138+
</div>
139+
140+
<textarea
141+
rows={4}
142+
placeholder={t.guestBookMessage}
143+
value={message}
144+
onChange={(event) => setMessage(event.target.value)}
145+
className="mt-3 w-full resize-none rounded-md border border-white/12 bg-black/25 px-4 py-3 text-sm leading-relaxed text-white outline-none placeholder:text-white/45 focus:border-white/30"
146+
/>
147+
148+
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
149+
<p className="min-h-5 text-xs text-amber-100/70">
150+
{error}
151+
</p>
152+
<button
153+
type="button"
154+
onClick={handleSubmit}
155+
disabled={submitting}
156+
className="inline-flex items-center justify-center gap-2 rounded-md border border-amber-200/30 bg-amber-200/10 px-5 py-2.5 text-sm font-medium text-amber-50 transition-colors hover:bg-amber-200/15 disabled:cursor-not-allowed disabled:opacity-45"
157+
>
158+
<Feather size={15} className="text-amber-100/80" />
159+
{submitting ? (lang === 'en' ? 'Leaving...' : '남기는 중...') : t.guestBookSubmit}
160+
</button>
161+
</div>
162+
</div>
163+
164+
<div className="mt-10 rounded-md border border-white/10 bg-black/25 p-5 backdrop-blur-md sm:p-6">
165+
<div className="mb-5 flex items-center justify-between gap-4">
166+
<h2 className="text-sm font-medium tracking-[0.18em] text-white/75">
167+
{lang === 'en' ? 'TRACES LEFT BEHIND' : '남겨진 흔적들'}
168+
</h2>
169+
<span className="text-xs text-zinc-400">
170+
{messages.length}
171+
</span>
172+
</div>
173+
174+
{loading ? (
175+
<p className="py-8 text-center text-sm text-zinc-400">
176+
{lang === 'en' ? 'Opening the guestbook...' : '방명록을 여는 중입니다...'}
177+
</p>
178+
) : pageMessages.length === 0 ? (
179+
<p className="py-8 text-center text-sm text-zinc-400">
180+
{lang === 'en' ? 'No traces have been left yet.' : '아직 남겨진 흔적이 없습니다.'}
181+
</p>
182+
) : (
183+
<div className="flex flex-col gap-5">
184+
{pageMessages.map((guestMessage) => (
185+
<article key={guestMessage.id} className="border-b border-white/10 pb-5 last:border-b-0 last:pb-0">
186+
<div className="mb-2 flex items-center gap-2">
187+
<span className="text-sm font-medium text-zinc-100">
188+
{guestMessage.name}
189+
</span>
190+
<span className="text-[11px] text-zinc-500">
191+
{new Date(guestMessage.created_at).toLocaleDateString(lang === 'en' ? 'en-US' : 'ko-KR', {
192+
year: '2-digit',
193+
month: '2-digit',
194+
day: '2-digit',
195+
})}
196+
</span>
197+
</div>
198+
<p className="whitespace-pre-line text-sm leading-relaxed text-zinc-200">
199+
{guestMessage.message}
200+
</p>
201+
</article>
202+
))}
203+
</div>
204+
)}
205+
206+
{messages.length > PER_PAGE && (
207+
<div className="mt-6 flex items-center justify-end gap-3">
208+
<button
209+
type="button"
210+
onClick={() => setPage((currentPage) => Math.max(0, currentPage - 1))}
211+
disabled={page === 0}
212+
className="rounded-md border border-white/10 p-2 text-zinc-300 transition-colors hover:text-white disabled:opacity-35"
213+
>
214+
<ChevronLeft size={16} />
215+
</button>
216+
<span className="text-xs text-zinc-400">
217+
{page + 1} / {totalPages}
218+
</span>
219+
<button
220+
type="button"
221+
onClick={() => setPage((currentPage) => Math.min(totalPages - 1, currentPage + 1))}
222+
disabled={page >= totalPages - 1}
223+
className="rounded-md border border-white/10 p-2 text-zinc-300 transition-colors hover:text-white disabled:opacity-35"
224+
>
225+
<ChevronRight size={16} />
226+
</button>
227+
</div>
228+
)}
229+
</div>
230+
</section>
231+
</main>
232+
);
233+
}

src/components/layout/Footer.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ export default function Footer() {
1414
>
1515
About
1616
</Link>
17+
<Link
18+
href="/guestbook"
19+
className="text-xs text-zinc-400 transition-colors hover:text-zinc-200"
20+
>
21+
Guestbook
22+
</Link>
1723
<Link
1824
href="/privacy-policy"
1925
className="text-xs text-zinc-400 transition-colors hover:text-zinc-200"

0 commit comments

Comments
 (0)