to-do first
This commit is contained in:
132
public/index.html
Normal file
132
public/index.html
Normal file
@@ -0,0 +1,132 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user