133 lines
5.2 KiB
HTML
133 lines
5.2 KiB
HTML
<!doctype html>
|
|
<!-- Design Ref: §2 public/index.html — fetch 기반 단일 페이지 UI. Plan SC: SC-01 -->
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>To-Do</title>
|
|
<style>
|
|
:root { --bg:#f6f7f9; --card:#fff; --line:#e5e7eb; --muted:#6b7280; --accent:#2563eb; }
|
|
* { box-sizing: border-box; }
|
|
body { margin:0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
background: var(--bg); color:#111827; }
|
|
.wrap { max-width: 640px; margin: 40px auto; padding: 0 16px; }
|
|
h1 { font-size: 1.5rem; margin: 0 0 16px; }
|
|
form { display:flex; gap:8px; flex-wrap:wrap; background:var(--card);
|
|
padding:16px; border:1px solid var(--line); border-radius:12px; }
|
|
input[type=text] { flex:1 1 240px; padding:10px 12px; border:1px solid var(--line);
|
|
border-radius:8px; font-size:1rem; }
|
|
input[type=file] { flex:1 1 200px; font-size:.85rem; }
|
|
button { padding:10px 16px; border:0; border-radius:8px; background:var(--accent);
|
|
color:#fff; font-size:.95rem; cursor:pointer; }
|
|
button.secondary { background:transparent; color:var(--muted); padding:6px 8px; }
|
|
button:disabled { opacity:.5; cursor:default; }
|
|
ul { list-style:none; padding:0; margin:16px 0 0; }
|
|
li { display:flex; align-items:center; gap:10px; background:var(--card);
|
|
padding:12px 14px; border:1px solid var(--line); border-radius:10px; margin-bottom:8px; }
|
|
li.done .title { text-decoration: line-through; color: var(--muted); }
|
|
.title { flex:1; word-break: break-word; }
|
|
.meta { font-size:.8rem; }
|
|
a.dl { color: var(--accent); text-decoration:none; font-size:.8rem; }
|
|
.empty, .err { color: var(--muted); text-align:center; padding:24px 0; }
|
|
.err { color:#b91c1c; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<h1>📝 To-Do</h1>
|
|
|
|
<form id="add-form">
|
|
<input type="text" id="title" placeholder="할 일을 입력하세요" autocomplete="off" required />
|
|
<input type="file" id="file" />
|
|
<button type="submit" id="add-btn">추가</button>
|
|
</form>
|
|
|
|
<ul id="list"></ul>
|
|
<div id="status" class="empty"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const listEl = document.getElementById("list");
|
|
const statusEl = document.getElementById("status");
|
|
const form = document.getElementById("add-form");
|
|
const titleEl = document.getElementById("title");
|
|
const fileEl = document.getElementById("file");
|
|
const addBtn = document.getElementById("add-btn");
|
|
|
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g,
|
|
(c) => ({ "&":"&", "<":"<", ">":">", '"':""", "'":"'" }[c]));
|
|
|
|
async function api(path, opts) {
|
|
const res = await fetch(path, opts);
|
|
if (res.status === 204) return null;
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
|
|
return body.data;
|
|
}
|
|
|
|
function render(todos) {
|
|
listEl.innerHTML = "";
|
|
if (!todos.length) { statusEl.textContent = "할 일이 없습니다."; statusEl.className = "empty"; return; }
|
|
statusEl.textContent = "";
|
|
for (const t of todos) {
|
|
const li = document.createElement("li");
|
|
if (t.done) li.classList.add("done");
|
|
|
|
const cb = document.createElement("input");
|
|
cb.type = "checkbox"; cb.checked = t.done;
|
|
cb.addEventListener("change", () => toggle(t.id, cb.checked));
|
|
|
|
const span = document.createElement("span");
|
|
span.className = "title";
|
|
span.innerHTML = esc(t.title) +
|
|
(t.hasAttachment
|
|
? ` <a class="dl" href="/api/todos/${t.id}/attachment">📎 ${esc(t.attachment_name || "첨부")}</a>`
|
|
: "");
|
|
|
|
const del = document.createElement("button");
|
|
del.className = "secondary"; del.textContent = "삭제";
|
|
del.addEventListener("click", () => remove(t.id));
|
|
|
|
li.append(cb, span, del);
|
|
listEl.appendChild(li);
|
|
}
|
|
}
|
|
|
|
async function load() {
|
|
try { render(await api("/api/todos")); }
|
|
catch (e) { statusEl.textContent = "불러오기 실패: " + e.message; statusEl.className = "err"; }
|
|
}
|
|
|
|
async function toggle(id, done) {
|
|
try { await api(`/api/todos/${id}`, {
|
|
method: "PATCH", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ done }) }); await load(); }
|
|
catch (e) { alert("변경 실패: " + e.message); await load(); }
|
|
}
|
|
|
|
async function remove(id) {
|
|
try { await api(`/api/todos/${id}`, { method: "DELETE" }); await load(); }
|
|
catch (e) { alert("삭제 실패: " + e.message); }
|
|
}
|
|
|
|
form.addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
const title = titleEl.value.trim();
|
|
if (!title) return;
|
|
const fd = new FormData();
|
|
fd.append("title", title);
|
|
if (fileEl.files[0]) fd.append("file", fileEl.files[0]);
|
|
addBtn.disabled = true;
|
|
try {
|
|
await api("/api/todos", { method: "POST", body: fd });
|
|
titleEl.value = ""; fileEl.value = "";
|
|
await load();
|
|
} catch (e) { alert("추가 실패: " + e.message); }
|
|
finally { addBtn.disabled = false; }
|
|
});
|
|
|
|
load();
|
|
</script>
|
|
</body>
|
|
</html>
|