Add admin upload submissions view

This commit is contained in:
DESKTOP-KSVGT20\shkim
2026-05-11 23:03:15 +09:00
parent 7f4e814d94
commit 648974960e
+88 -2
View File
@@ -129,7 +129,7 @@ function page(title, body) {
:root { color-scheme: light; --ink:#17202a; --muted:#667085; --line:#d6dde7; --bg:#f6f8fb; --panel:#fff; --accent:#0f766e; --danger:#b42318; } :root { color-scheme: light; --ink:#17202a; --muted:#667085; --line:#d6dde7; --bg:#f6f8fb; --panel:#fff; --accent:#0f766e; --danger:#b42318; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--ink); } body { margin:0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--ink); }
main { width:min(960px, calc(100% - 32px)); margin:32px auto; } main { width:min(1100px, calc(100% - 32px)); margin:32px auto; }
.panel { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:20px; } .panel { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:20px; }
h1 { font-size:24px; margin:0 0 18px; } h1 { font-size:24px; margin:0 0 18px; }
h2 { font-size:18px; margin:26px 0 12px; } h2 { font-size:18px; margin:26px 0 12px; }
@@ -137,6 +137,7 @@ function page(title, body) {
input, textarea, select { width:100%; border:1px solid var(--line); border-radius:6px; padding:10px 12px; font:inherit; background:#fff; } input, textarea, select { width:100%; border:1px solid var(--line); border-radius:6px; padding:10px 12px; font:inherit; background:#fff; }
input[type=file] { padding:9px; } input[type=file] { padding:9px; }
button, .button { display:inline-flex; align-items:center; justify-content:center; gap:6px; border:0; border-radius:6px; padding:10px 14px; background:var(--accent); color:#fff; font-weight:650; cursor:pointer; text-decoration:none; } button, .button { display:inline-flex; align-items:center; justify-content:center; gap:6px; border:0; border-radius:6px; padding:10px 14px; background:var(--accent); color:#fff; font-weight:650; cursor:pointer; text-decoration:none; }
.button.secondary { background:#475467; }
button.danger { background:var(--danger); } button.danger { background:var(--danger); }
.row { display:grid; grid-template-columns: 1fr 1fr; gap:14px; } .row { display:grid; grid-template-columns: 1fr 1fr; gap:14px; }
.actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:16px; } .actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:16px; }
@@ -147,6 +148,13 @@ function page(title, body) {
.notice { border:1px solid #bae6fd; background:#ecfeff; padding:12px; border-radius:6px; margin:12px 0; } .notice { border:1px solid #bae6fd; background:#ecfeff; padding:12px; border-radius:6px; margin:12px 0; }
.error { border-color:#fecaca; background:#fff1f2; } .error { border-color:#fecaca; background:#fff1f2; }
.upload-box { border:2px dashed var(--line); border-radius:8px; background:#fff; padding:18px; } .upload-box { border:2px dashed var(--line); border-radius:8px; background:#fff; padding:18px; }
.stack { display:flex; flex-direction:column; gap:8px; }
.submission { border:1px solid var(--line); border-radius:8px; padding:14px; margin-top:12px; background:#fff; }
.submission h3 { margin:0 0 8px; font-size:16px; }
.submission dl { display:grid; grid-template-columns:90px 1fr; gap:6px 12px; margin:0; }
.submission dt { color:var(--muted); }
.submission dd { margin:0; }
.file-list-admin { margin:10px 0 0; padding-left:18px; color:var(--muted); }
@media (max-width:720px) { main { width:calc(100% - 20px); margin:16px auto; } .row { grid-template-columns:1fr; } .table { font-size:14px; } } @media (max-width:720px) { main { width:calc(100% - 20px); margin:16px auto; } .row { grid-template-columns:1fr; } .table { font-size:14px; } }
</style> </style>
</head> </head>
@@ -264,13 +272,42 @@ async function appendUploadMetadata(targetDir, { guestName, guestPhone, files })
guestPhone, guestPhone,
files: files.map((file) => ({ files: files.map((file) => ({
storedName: file.filename, storedName: file.filename,
originalName: file.originalname, originalName: Buffer.from(file.originalname, "latin1").toString("utf8"),
size: file.size size: file.size
})) }))
}; };
await fsp.appendFile(path.join(targetDir, "upload-metadata.jsonl"), `${JSON.stringify(entry)}\n`, "utf8"); await fsp.appendFile(path.join(targetDir, "upload-metadata.jsonl"), `${JSON.stringify(entry)}\n`, "utf8");
} }
async function readUploadMetadata(folderName) {
const metadataPath = path.join(uploadsDir, folderName, "upload-metadata.jsonl");
const content = await fsp.readFile(metadataPath, "utf8").catch((error) => {
if (error.code === "ENOENT") return "";
throw error;
});
return content
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
}
function formatDateTime(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return new Intl.DateTimeFormat("ko-KR", {
timeZone: "Asia/Seoul",
dateStyle: "medium",
timeStyle: "short"
}).format(date);
}
app.get("/", (req, res) => res.redirect("/admin")); app.get("/", (req, res) => res.redirect("/admin"));
app.get("/admin/login", (req, res) => { app.get("/admin/login", (req, res) => {
@@ -316,9 +353,12 @@ app.get("/admin", requireAdmin, async (req, res) => {
<td>${Math.round(item.maxBytes / 1024 / 1024)} MB</td> <td>${Math.round(item.maxBytes / 1024 / 1024)} MB</td>
<td>${escapeHtml(item.allowedExts.join(", "))}</td> <td>${escapeHtml(item.allowedExts.join(", "))}</td>
<td> <td>
<div class="stack">
<a class="button secondary" href="/admin/requests/${escapeHtml(item.slug)}/submissions">제출 내역</a>
<form method="post" action="/admin/requests/${escapeHtml(item.slug)}/delete" onsubmit="return confirm('링크를 삭제할까? 업로드된 파일은 유지돼.');"> <form method="post" action="/admin/requests/${escapeHtml(item.slug)}/delete" onsubmit="return confirm('링크를 삭제할까? 업로드된 파일은 유지돼.');">
<button class="danger" type="submit">삭제</button> <button class="danger" type="submit">삭제</button>
</form> </form>
</div>
</td> </td>
</tr>`; </tr>`;
}).join(""); }).join("");
@@ -351,6 +391,52 @@ app.get("/admin", requireAdmin, async (req, res) => {
`)); `));
}); });
app.get("/admin/requests/:slug/submissions", requireAdmin, async (req, res) => {
const db = await readDb();
const item = db.requests.find((request) => request.slug === req.params.slug);
if (!item) {
res.status(404).send(page("제출 내역 없음", `<section class="panel"><h1>제출 내역 없음</h1><p class="muted">해당 링크를 찾을 수 없어.</p><a class="button" href="/admin">돌아가기</a></section>`));
return;
}
const submissions = await readUploadMetadata(item.folderName);
const rows = submissions
.map((submission, index) => {
const files = Array.isArray(submission.files) ? submission.files : [];
const fileItems = files
.map((file) => {
const name = file.originalName || file.storedName || "파일";
const size = Number(file.size || 0);
const sizeText = size > 0 ? ` (${Math.round(size / 1024)} KB)` : "";
return `<li>${escapeHtml(name)}${escapeHtml(sizeText)}</li>`;
})
.join("");
return `<article class="submission">
<h3>제출 ${index + 1}</h3>
<dl>
<dt>성함</dt><dd>${escapeHtml(submission.guestName || "-")}</dd>
<dt>연락처</dt><dd>${escapeHtml(submission.guestPhone || "-")}</dd>
<dt>제출 시간</dt><dd>${escapeHtml(formatDateTime(submission.uploadedAt))}</dd>
<dt>파일 수</dt><dd>${files.length}개</dd>
</dl>
<ul class="file-list-admin">${fileItems || `<li class="muted">파일 기록이 없어.</li>`}</ul>
</article>`;
})
.join("");
res.send(page(`${item.title} 제출 내역`, `
<section class="panel">
<div class="actions" style="float:right">
<a class="button secondary" href="/admin">관리자 홈</a>
</div>
<h1>${escapeHtml(item.title)} 제출 내역</h1>
<p class="muted">저장 위치: ${escapeHtml(path.join(uploadsDir, item.folderName))}</p>
<p class="muted">제출 정보 파일: ${escapeHtml(path.join(uploadsDir, item.folderName, "upload-metadata.jsonl"))}</p>
${rows || `<div class="notice">아직 이 링크로 제출된 내역이 없어. 예전 업로드는 메타데이터가 없으면 여기에는 표시되지 않을 수 있어.</div>`}
</section>
`));
});
app.post("/admin/requests", requireAdmin, async (req, res) => { app.post("/admin/requests", requireAdmin, async (req, res) => {
const db = await readDb(); const db = await readDb();
const title = sanitizeName(req.body.title, "사진 요청"); const title = sanitizeName(req.body.title, "사진 요청");