Files
calc/public/index.html
2026-06-25 18:51:24 +09:00

329 lines
13 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>계산기 & 메모장</title>
<style>
:root {
--bg: #0f172a; --panel: #1e293b; --panel2: #334155;
--txt: #e2e8f0; --muted: #94a3b8; --accent: #38bdf8; --accent2: #f59e0b;
--danger: #ef4444; --border: #334155;
}
* { box-sizing: border-box; }
body {
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg); color: var(--txt); line-height: 1.5;
}
header { padding: 18px 24px; border-bottom: 1px solid var(--border); }
header h1 { margin: 0; font-size: 20px; }
header p { margin: 4px 0 0; color: var(--muted); font-size: 13px; }
.grid {
display: grid; gap: 18px; padding: 24px; max-width: 1100px; margin: 0 auto;
grid-template-columns: 320px 1fr;
}
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
.card {
background: var(--panel); border: 1px solid var(--border);
border-radius: 12px; padding: 16px;
}
.card h2 { margin: 0 0 12px; font-size: 15px; color: var(--accent); }
/* 계산기 */
.calc-display {
background: #0b1220; border: 1px solid var(--border); border-radius: 8px;
padding: 12px 14px; text-align: right; font-size: 28px; min-height: 48px;
overflow-x: auto; white-space: nowrap; margin-bottom: 12px;
}
.calc-keys { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.calc-keys button {
padding: 14px 0; font-size: 18px; border: none; border-radius: 8px;
background: var(--panel2); color: var(--txt); cursor: pointer;
}
.calc-keys button:hover { filter: brightness(1.2); }
.calc-keys .op { background: #1d4ed8; }
.calc-keys .eq { background: var(--accent); color: #06283d; font-weight: 700; }
.calc-keys .fn { background: #475569; color: var(--muted); }
.calc-keys .span2 { grid-column: span 2; }
/* 메모장 */
.row { display: flex; gap: 8px; margin-bottom: 8px; }
input[type=text], textarea {
width: 100%; background: #0b1220; color: var(--txt);
border: 1px solid var(--border); border-radius: 8px; padding: 9px 11px;
font-size: 14px; font-family: inherit;
}
textarea { min-height: 140px; resize: vertical; }
button.btn {
border: none; border-radius: 8px; padding: 9px 14px; font-size: 14px;
cursor: pointer; background: var(--accent); color: #06283d; font-weight: 600;
}
button.btn.ghost { background: var(--panel2); color: var(--txt); font-weight: 500; }
button.btn.danger { background: var(--danger); color: #fff; }
button.btn:hover { filter: brightness(1.1); }
.list { list-style: none; margin: 12px 0 0; padding: 0; }
.list li {
border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px;
margin-bottom: 8px; cursor: pointer; background: #0b1220;
}
.list li:hover { border-color: var(--accent); }
.list li.active { border-color: var(--accent); background: #102132; }
.list .t { font-weight: 600; }
.list .m { color: var(--muted); font-size: 12px; margin-top: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-row {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; border: 1px solid var(--border); border-radius: 8px;
padding: 8px 12px; margin-bottom: 8px; background: #0b1220;
}
.file-row .meta { color: var(--muted); font-size: 12px; }
.file-row a { color: var(--accent); text-decoration: none; }
.toast {
position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%);
background: var(--panel2); border: 1px solid var(--border); color: var(--txt);
padding: 10px 16px; border-radius: 8px; opacity: 0; transition: opacity .2s;
pointer-events: none; font-size: 14px;
}
.toast.show { opacity: 1; }
.muted { color: var(--muted); font-size: 12px; }
</style>
</head>
<body>
<header>
<h1>🧮 계산기 &amp; 📝 메모장</h1>
<p>계산기 · 메모는 DB · 파일은 오브젝트 스토리지에 저장됩니다.</p>
</header>
<div class="grid">
<!-- 계산기 -->
<section class="card" aria-label="계산기">
<h2>계산기</h2>
<div id="display" class="calc-display">0</div>
<div class="calc-keys" id="keys">
<button class="fn" data-act="clear">C</button>
<button class="fn" data-act="back"></button>
<button class="fn" data-val="%">%</button>
<button class="op" data-val="/">÷</button>
<button data-val="7">7</button>
<button data-val="8">8</button>
<button data-val="9">9</button>
<button class="op" data-val="*">×</button>
<button data-val="4">4</button>
<button data-val="5">5</button>
<button data-val="6">6</button>
<button class="op" data-val="-"></button>
<button data-val="1">1</button>
<button data-val="2">2</button>
<button data-val="3">3</button>
<button class="op" data-val="+">+</button>
<button class="span2" data-val="0">0</button>
<button data-val=".">.</button>
<button class="eq" data-act="eq">=</button>
</div>
<p class="muted" style="margin-top:10px">키보드 입력도 가능합니다 (Enter = , Esc = C).</p>
</section>
<div>
<!-- 메모장 -->
<section class="card" aria-label="메모장" style="margin-bottom:18px">
<h2>메모장</h2>
<div class="row">
<input type="text" id="noteTitle" placeholder="제목" />
</div>
<textarea id="noteBody" placeholder="메모 내용을 입력하세요…"></textarea>
<div class="row" style="margin-top:8px">
<button class="btn" id="saveNote">저장</button>
<button class="btn ghost" id="newNote">새 메모</button>
<button class="btn danger" id="delNote" style="margin-left:auto">삭제</button>
</div>
<ul class="list" id="noteList"></ul>
</section>
<!-- 파일 저장 -->
<section class="card" aria-label="파일">
<h2>파일 저장 (오브젝트 스토리지)</h2>
<div class="row">
<input type="file" id="fileInput" />
<button class="btn" id="uploadBtn">업로드</button>
</div>
<div id="fileList"></div>
</section>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
const toast = (msg) => {
const t = $("toast"); t.textContent = msg; t.classList.add("show");
clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.remove("show"), 2200);
};
/* ---------------- 계산기 ---------------- */
const display = $("display");
let expr = "";
const render = () => (display.textContent = expr || "0");
function inputVal(v) {
// 연산자 중복 방지
if (/[+\-*/%]/.test(v) && /[+\-*/%]$/.test(expr)) expr = expr.slice(0, -1);
expr += v; render();
}
function evaluate() {
if (!expr) return;
try {
// 숫자/연산자/소수점/괄호만 허용 후 평가
if (!/^[0-9+\-*/%.() ]+$/.test(expr)) throw new Error("invalid");
const result = Function('"use strict"; return (' + expr + ")")();
if (result === undefined || Number.isNaN(result) || !Number.isFinite(result))
throw new Error("invalid");
expr = String(result); render();
} catch {
toast("계산할 수 없는 식입니다");
}
}
$("keys").addEventListener("click", (e) => {
const b = e.target.closest("button"); if (!b) return;
if (b.dataset.val != null) inputVal(b.dataset.val);
else if (b.dataset.act === "clear") { expr = ""; render(); }
else if (b.dataset.act === "back") { expr = expr.slice(0, -1); render(); }
else if (b.dataset.act === "eq") evaluate();
});
document.addEventListener("keydown", (e) => {
if (document.activeElement && /INPUT|TEXTAREA/.test(document.activeElement.tagName)) return;
if (/[0-9+\-*/%.]/.test(e.key)) inputVal(e.key);
else if (e.key === "Enter" || e.key === "=") { e.preventDefault(); evaluate(); }
else if (e.key === "Escape") { expr = ""; render(); }
else if (e.key === "Backspace") { expr = expr.slice(0, -1); render(); }
});
render();
/* ---------------- 메모장 ---------------- */
let notes = [];
let activeId = null;
async function api(path, opts) {
const res = await fetch(path, opts);
const ct = res.headers.get("content-type") || "";
const data = ct.includes("json") ? await res.json() : null;
if (!res.ok) throw new Error((data && data.error) || res.statusText);
return data;
}
async function loadNotes() {
try {
notes = await api("/api/notes");
renderNotes();
} catch (e) { toast("메모 불러오기 실패: " + e.message); }
}
function renderNotes() {
const ul = $("noteList"); ul.innerHTML = "";
for (const n of notes) {
const li = document.createElement("li");
if (n.id === activeId) li.classList.add("active");
li.innerHTML = `<div class="t"></div><div class="m"></div>`;
li.querySelector(".t").textContent = n.title || "(제목 없음)";
li.querySelector(".m").textContent = (n.body || "").slice(0, 60) || " ";
li.onclick = () => selectNote(n.id);
ul.appendChild(li);
}
}
function selectNote(id) {
const n = notes.find((x) => x.id === id); if (!n) return;
activeId = id; $("noteTitle").value = n.title || ""; $("noteBody").value = n.body || "";
renderNotes();
}
$("newNote").onclick = () => {
activeId = null; $("noteTitle").value = ""; $("noteBody").value = "";
renderNotes(); $("noteTitle").focus();
};
$("saveNote").onclick = async () => {
const title = $("noteTitle").value, body = $("noteBody").value;
try {
if (activeId == null) {
const n = await api("/api/notes", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ title, body }),
});
activeId = n.id;
} else {
await api("/api/notes/" + activeId, {
method: "PUT", headers: { "content-type": "application/json" },
body: JSON.stringify({ title, body }),
});
}
await loadNotes(); toast("메모를 저장했습니다");
} catch (e) { toast("저장 실패: " + e.message); }
};
$("delNote").onclick = async () => {
if (activeId == null) { $("newNote").click(); return; }
try {
await api("/api/notes/" + activeId, { method: "DELETE" });
activeId = null; $("noteTitle").value = ""; $("noteBody").value = "";
await loadNotes(); toast("메모를 삭제했습니다");
} catch (e) { toast("삭제 실패: " + e.message); }
};
/* ---------------- 파일 ---------------- */
async function loadFiles() {
try {
const files = await api("/api/files");
const box = $("fileList"); box.innerHTML = "";
if (!files.length) { box.innerHTML = '<p class="muted">저장된 파일이 없습니다.</p>'; return; }
for (const f of files) {
const row = document.createElement("div");
row.className = "file-row";
const left = document.createElement("div");
const a = document.createElement("a");
a.href = "/api/files/" + encodeURIComponent(f.name);
a.textContent = f.name; a.download = f.name;
const meta = document.createElement("div");
meta.className = "meta";
meta.textContent = humanSize(f.size) + " · " + new Date(f.lastModified).toLocaleString();
left.appendChild(a); left.appendChild(meta);
const del = document.createElement("button");
del.className = "btn danger"; del.textContent = "삭제";
del.onclick = () => deleteFile(f.name);
row.appendChild(left); row.appendChild(del);
box.appendChild(row);
}
} catch (e) { toast("파일 목록 실패: " + e.message); }
}
function humanSize(n) {
if (n == null) return "";
const u = ["B", "KB", "MB", "GB"]; let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return (i ? n.toFixed(1) : n) + u[i];
}
$("uploadBtn").onclick = async () => {
const input = $("fileInput");
const file = input.files && input.files[0];
if (!file) { toast("업로드할 파일을 선택하세요"); return; }
try {
await api("/api/files/" + encodeURIComponent(file.name), {
method: "PUT",
headers: { "content-type": file.type || "application/octet-stream" },
body: file,
});
input.value = ""; await loadFiles(); toast("파일을 업로드했습니다");
} catch (e) { toast("업로드 실패: " + e.message); }
};
async function deleteFile(name) {
try {
await api("/api/files/" + encodeURIComponent(name), { method: "DELETE" });
await loadFiles(); toast("파일을 삭제했습니다");
} catch (e) { toast("삭제 실패: " + e.message); }
}
loadNotes();
loadFiles();
</script>
</body>
</html>