- 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>
123 lines
4.4 KiB
HTML
123 lines
4.4 KiB
HTML
<!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) =>
|
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|