feat: 방명록(guestbook) 웹사이트 추가

- src/db.js: initGuestbook/listMessages/addMessage (본인 schema에 테이블 생성)
- src/server.js: express.json + public 정적 서빙, GET/POST /api/messages, 시작 시 테이블 자동 생성
- public/index.html: 작성 폼 + 목록 UI (XSS 이스케이프, 길이 검증)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 14:04:13 +09:00
parent 179226b59a
commit 9026a47119
3 changed files with 192 additions and 8 deletions

122
public/index.html Normal file
View File

@@ -0,0 +1,122 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>방명록</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", "Apple SD Gothic Neo", sans-serif;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
color: #1e293b;
display: flex; justify-content: center; padding: 32px 16px;
}
.card {
width: 100%; max-width: 640px;
background: rgba(255,255,255,0.96);
border-radius: 20px; padding: 28px 26px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
}
h1 { margin: 0 0 4px; font-size: 1.6rem; }
p.sub { margin: 0 0 20px; color: #64748b; font-size: .9rem; }
form { display: grid; gap: 10px; margin-bottom: 22px; }
input, textarea {
width: 100%; padding: 11px 13px; border: 1px solid #d1d5db;
border-radius: 11px; font-size: .95rem; font-family: inherit; background: #fff;
}
textarea { resize: vertical; min-height: 76px; }
button {
justify-self: end; padding: 10px 22px; border: 0; border-radius: 11px;
background: #6366f1; color: #fff; font-weight: 600; font-size: .95rem; cursor: pointer;
}
button:hover { background: #4f46e5; }
button:disabled { opacity: .6; cursor: default; }
.msg { border-top: 1px solid #eef; padding: 13px 2px; }
.msg:first-child { border-top: 0; }
.msg .head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.msg .name { font-weight: 700; }
.msg .time { color: #94a3b8; font-size: .78rem; }
.msg .body { margin: 5px 0 0; white-space: pre-wrap; word-break: break-word; }
.empty { color: #94a3b8; text-align: center; padding: 24px 0; }
.error { color: #dc2626; font-size: .85rem; min-height: 1.2em; }
</style>
</head>
<body>
<div class="card">
<h1>📖 방명록</h1>
<p class="sub">한 줄 남기고 가세요. (Postgres 에 저장됩니다)</p>
<form id="form">
<input id="name" name="name" placeholder="이름" maxlength="40" required autocomplete="off" />
<textarea id="message" name="message" placeholder="메시지를 남겨주세요" maxlength="500" required></textarea>
<div class="error" id="error"></div>
<button type="submit" id="submit">남기기</button>
</form>
<div id="list"><div class="empty">불러오는 중…</div></div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const list = $("list"), errorEl = $("error");
const esc = (s) => s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const fmt = (iso) => {
const d = new Date(iso);
return d.toLocaleString("ko-KR", { dateStyle: "medium", timeStyle: "short" });
};
function render(messages) {
if (!messages.length) {
list.innerHTML = '<div class="empty">아직 글이 없어요. 첫 글을 남겨보세요!</div>';
return;
}
list.innerHTML = messages.map((m) => `
<div class="msg">
<div class="head">
<span class="name">${esc(m.name)}</span>
<span class="time">${fmt(m.created_at)}</span>
</div>
<p class="body">${esc(m.message)}</p>
</div>`).join("");
}
async function load() {
try {
const r = await fetch("/api/messages");
const data = await r.json();
if (!data.ok) throw new Error(data.error);
render(data.messages);
} catch (e) {
list.innerHTML = `<div class="empty">불러오기 실패: ${esc(String(e.message || e))}</div>`;
}
}
$("form").addEventListener("submit", async (ev) => {
ev.preventDefault();
errorEl.textContent = "";
$("submit").disabled = true;
try {
const r = await fetch("/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: $("name").value, message: $("message").value }),
});
const data = await r.json();
if (!data.ok) throw new Error(data.error);
$("message").value = "";
await load();
} catch (e) {
errorEl.textContent = String(e.message || e);
} finally {
$("submit").disabled = false;
}
});
load();
</script>
</body>
</html>