DB(메타데이터)와 MinIO(바이트)를 함께 실제로 쓰는 PoC. 신규 런타임 의존성 없음. - src/files.js: files 테이블 + saveFile/list/get/rename/delete + getObjectStream - src/server.js: POST/GET/PATCH/DELETE /files, 정적 UI, 부팅 시 테이블 보장 · 업로드는 express.raw(멀티파트 파서 불필요), 다운로드는 S3 스트림 파이프 · 텍스트 charset=utf-8 + RFC5987 파일명으로 한글 깨짐 방지 - public/index.html: 드래그앤드롭 업로드 + 목록/열기/이름변경/삭제 UI - scripts/check-files.js(files:check): DB+S3 라운드트립 자가 검증 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
298 lines
11 KiB
HTML
298 lines
11 KiB
HTML
<!doctype html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>파일 갤러리 — DB + MinIO PoC</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0f172a;
|
|
--panel: #ffffff;
|
|
--ink: #0f172a;
|
|
--muted: #64748b;
|
|
--line: #e2e8f0;
|
|
--brand: #6366f1;
|
|
--brand-600: #4f46e5;
|
|
--ok: #10b981;
|
|
--err: #ef4444;
|
|
--radius: 14px;
|
|
--shadow: 0 10px 30px -12px rgba(2, 6, 23, 0.25);
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
font-family: -apple-system, system-ui, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
|
color: var(--ink);
|
|
background:
|
|
radial-gradient(1200px 600px at 100% -10%, #312e81 0%, transparent 55%),
|
|
radial-gradient(1000px 500px at -10% 110%, #0e7490 0%, transparent 50%),
|
|
var(--bg);
|
|
}
|
|
.wrap { max-width: 760px; margin: 0 auto; padding: 3rem 1.25rem 4rem; }
|
|
|
|
header { color: #e2e8f0; margin-bottom: 1.75rem; }
|
|
.badge {
|
|
display: inline-flex; align-items: center; gap: .4rem;
|
|
font-size: .72rem; font-weight: 600; letter-spacing: .02em;
|
|
color: #c7d2fe; background: rgba(99,102,241,.18);
|
|
border: 1px solid rgba(99,102,241,.35);
|
|
padding: .3rem .6rem; border-radius: 999px;
|
|
}
|
|
.badge::before {
|
|
content: ""; width: 7px; height: 7px; border-radius: 50%;
|
|
background: var(--ok); box-shadow: 0 0 0 3px rgba(16,185,129,.25);
|
|
}
|
|
h1 { font-size: 1.6rem; margin: .8rem 0 .3rem; letter-spacing: -.01em; }
|
|
header p { margin: 0; color: #94a3b8; font-size: .92rem; }
|
|
|
|
.card {
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius);
|
|
box-shadow: var(--shadow);
|
|
padding: 1.25rem;
|
|
}
|
|
|
|
/* 드롭존 */
|
|
.drop {
|
|
display: flex; flex-direction: column; align-items: center; gap: .75rem;
|
|
padding: 2rem 1rem; text-align: center;
|
|
border: 2px dashed var(--line); border-radius: var(--radius);
|
|
background: #f8fafc; cursor: pointer; transition: .15s ease;
|
|
}
|
|
.drop:hover, .drop.over { border-color: var(--brand); background: #eef2ff; }
|
|
.drop svg { width: 40px; height: 40px; color: var(--brand); }
|
|
.drop strong { color: var(--brand-600); }
|
|
.drop small { color: var(--muted); }
|
|
#file { display: none; }
|
|
|
|
.actionbar {
|
|
display: flex; align-items: center; gap: .75rem; margin-top: 1rem;
|
|
}
|
|
.filename {
|
|
flex: 1; min-width: 0; font-size: .9rem; color: var(--muted);
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
button {
|
|
font: inherit; font-weight: 600; cursor: pointer;
|
|
color: #fff; background: var(--brand-600); border: 0;
|
|
padding: .6rem 1.1rem; border-radius: 10px; transition: .15s ease;
|
|
}
|
|
button:hover { background: var(--brand); transform: translateY(-1px); }
|
|
button:disabled { opacity: .5; cursor: default; transform: none; }
|
|
|
|
#status { margin-top: .85rem; font-size: .88rem; min-height: 1.2em; }
|
|
#status.ok { color: var(--ok); }
|
|
#status.err { color: var(--err); }
|
|
#status.busy { color: var(--muted); }
|
|
|
|
.list-head {
|
|
display: flex; align-items: baseline; justify-content: space-between;
|
|
margin: 2rem .25rem .75rem; color: #cbd5e1;
|
|
}
|
|
.list-head h2 { font-size: 1rem; margin: 0; color: #e2e8f0; }
|
|
.count { font-size: .8rem; color: #94a3b8; }
|
|
|
|
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .6rem; }
|
|
li {
|
|
display: flex; align-items: center; gap: .85rem;
|
|
background: var(--panel); border: 1px solid var(--line);
|
|
border-radius: 12px; padding: .8rem .95rem;
|
|
transition: .15s ease;
|
|
}
|
|
li:hover { border-color: #c7d2fe; box-shadow: var(--shadow); }
|
|
.ico {
|
|
flex: none; width: 38px; height: 38px; border-radius: 9px;
|
|
display: grid; place-items: center; font-weight: 700; font-size: .7rem;
|
|
color: var(--brand-600); background: #eef2ff;
|
|
text-transform: uppercase;
|
|
}
|
|
.meta { flex: 1; min-width: 0; }
|
|
.meta a {
|
|
font-weight: 600; color: var(--ink); text-decoration: none;
|
|
display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.meta a:hover { color: var(--brand-600); text-decoration: underline; }
|
|
.meta small { color: var(--muted); font-size: .78rem; }
|
|
.actions { flex: none; display: flex; gap: .4rem; }
|
|
.btn-sm {
|
|
color: var(--muted); background: #fff; text-decoration: none;
|
|
font: inherit; font-size: .82rem; font-weight: 600; cursor: pointer;
|
|
padding: .35rem .6rem; border: 1px solid var(--line); border-radius: 8px;
|
|
transition: .15s ease;
|
|
}
|
|
.btn-sm:hover { color: var(--brand-600); border-color: #c7d2fe; transform: none; }
|
|
.btn-sm.danger:hover { color: var(--err); border-color: #fecaca; background: #fef2f2; }
|
|
.empty { color: #94a3b8; font-size: .9rem; text-align: center; padding: 1.5rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<header>
|
|
<span class="badge">DB + MinIO 연결됨</span>
|
|
<h1>파일 갤러리</h1>
|
|
<p>업로드하면 MinIO(S3)에 저장되고 메타데이터는 Postgres에 기록됩니다.</p>
|
|
</header>
|
|
|
|
<div class="card">
|
|
<label class="drop" id="drop" for="file">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
|
stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
<polyline points="17 8 12 3 7 8" />
|
|
<line x1="12" y1="3" x2="12" y2="15" />
|
|
</svg>
|
|
<div><strong>클릭</strong>하거나 파일을 여기로 끌어다 놓으세요</div>
|
|
<small>최대 25MB</small>
|
|
</label>
|
|
<input type="file" id="file" />
|
|
<div class="actionbar">
|
|
<span class="filename" id="filename">선택된 파일 없음</span>
|
|
<button id="upload" disabled>업로드</button>
|
|
</div>
|
|
<div id="status"></div>
|
|
</div>
|
|
|
|
<div class="list-head">
|
|
<h2>파일 목록</h2>
|
|
<span class="count" id="count"></span>
|
|
</div>
|
|
<ul id="list"></ul>
|
|
</div>
|
|
|
|
<script>
|
|
const $ = (s) => document.querySelector(s);
|
|
const fileInput = $("#file");
|
|
const drop = $("#drop");
|
|
const statusEl = $("#status");
|
|
const uploadBtn = $("#upload");
|
|
|
|
function setStatus(msg, kind) {
|
|
statusEl.textContent = msg || "";
|
|
statusEl.className = kind || "";
|
|
}
|
|
function fmtSize(n) {
|
|
n = Number(n);
|
|
if (n < 1024) return n + " B";
|
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
|
return (n / 1024 / 1024).toFixed(1) + " MB";
|
|
}
|
|
function ext(name) {
|
|
const m = /\.([a-z0-9]+)$/i.exec(name || "");
|
|
return m ? m[1].slice(0, 4) : "file";
|
|
}
|
|
|
|
function pick(file) {
|
|
fileInput._picked = file || null;
|
|
$("#filename").textContent = file ? file.name : "선택된 파일 없음";
|
|
uploadBtn.disabled = !file;
|
|
setStatus("");
|
|
}
|
|
fileInput.onchange = () => pick(fileInput.files[0]);
|
|
|
|
["dragenter", "dragover"].forEach((e) =>
|
|
drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.add("over"); })
|
|
);
|
|
["dragleave", "drop"].forEach((e) =>
|
|
drop.addEventListener(e, (ev) => { ev.preventDefault(); drop.classList.remove("over"); })
|
|
);
|
|
drop.addEventListener("drop", (ev) => pick(ev.dataTransfer.files[0]));
|
|
|
|
async function refresh() {
|
|
try {
|
|
const r = await fetch("/files");
|
|
const { files } = await r.json();
|
|
$("#count").textContent = (files || []).length + "개";
|
|
if (!files || !files.length) {
|
|
$("#list").innerHTML = '<div class="empty">아직 업로드된 파일이 없습니다.</div>';
|
|
return;
|
|
}
|
|
$("#list").innerHTML = files
|
|
.map(
|
|
(f) => `
|
|
<li data-id="${f.id}">
|
|
<div class="ico">${ext(f.name)}</div>
|
|
<div class="meta">
|
|
<a href="/files/${f.id}" target="_blank">${f.name}</a>
|
|
<small>${fmtSize(f.size)} · ${new Date(f.created_at).toLocaleString("ko-KR")}</small>
|
|
</div>
|
|
<div class="actions">
|
|
<a class="btn-sm" href="/files/${f.id}" target="_blank">열기</a>
|
|
<button class="btn-sm" data-act="rename">이름변경</button>
|
|
<button class="btn-sm danger" data-act="delete">삭제</button>
|
|
</div>
|
|
</li>`
|
|
)
|
|
.join("");
|
|
} catch (e) {
|
|
$("#list").innerHTML = '<div class="empty">목록을 불러오지 못했습니다.</div>';
|
|
}
|
|
}
|
|
|
|
// 목록의 이름변경/삭제 (이벤트 위임)
|
|
$("#list").addEventListener("click", async (ev) => {
|
|
const btn = ev.target.closest("button[data-act]");
|
|
if (!btn) return;
|
|
const li = btn.closest("li");
|
|
const id = li.dataset.id;
|
|
const cur = li.querySelector(".meta a").textContent;
|
|
|
|
if (btn.dataset.act === "delete") {
|
|
if (!confirm(`"${cur}" 을(를) 삭제할까요? (MinIO 객체도 함께 삭제됩니다)`)) return;
|
|
try {
|
|
const r = await fetch("/files/" + id, { method: "DELETE" });
|
|
const j = await r.json();
|
|
if (!r.ok) throw new Error(j.error || r.status);
|
|
setStatus("삭제됨 — #" + id, "ok");
|
|
refresh();
|
|
} catch (e) {
|
|
setStatus("삭제 실패: " + e.message, "err");
|
|
}
|
|
} else if (btn.dataset.act === "rename") {
|
|
const name = prompt("새 이름", cur);
|
|
if (!name || name.trim() === cur) return;
|
|
try {
|
|
const r = await fetch("/files/" + id, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name: name.trim() }),
|
|
});
|
|
const j = await r.json();
|
|
if (!r.ok) throw new Error(j.error || r.status);
|
|
setStatus("이름 변경됨 — #" + id, "ok");
|
|
refresh();
|
|
} catch (e) {
|
|
setStatus("이름 변경 실패: " + e.message, "err");
|
|
}
|
|
}
|
|
});
|
|
|
|
uploadBtn.onclick = async () => {
|
|
const file = fileInput._picked;
|
|
if (!file) return;
|
|
uploadBtn.disabled = true;
|
|
setStatus("업로드 중…", "busy");
|
|
try {
|
|
const r = await fetch("/files?name=" + encodeURIComponent(file.name), {
|
|
method: "POST",
|
|
headers: { "Content-Type": file.type || "application/octet-stream" },
|
|
body: file,
|
|
});
|
|
const j = await r.json();
|
|
if (!r.ok) throw new Error(j.error || r.status);
|
|
setStatus("완료 — #" + j.id + " 저장됨", "ok");
|
|
fileInput.value = "";
|
|
pick(null);
|
|
refresh();
|
|
} catch (e) {
|
|
setStatus("실패: " + e.message, "err");
|
|
uploadBtn.disabled = false;
|
|
}
|
|
};
|
|
|
|
refresh();
|
|
</script>
|
|
</body>
|
|
</html>
|