Add admin upload submissions view
This commit is contained in:
+88
-2
@@ -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; }
|
||||
* { box-sizing: border-box; }
|
||||
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; }
|
||||
h1 { font-size:24px; margin:0 0 18px; }
|
||||
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[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.secondary { background:#475467; }
|
||||
button.danger { background:var(--danger); }
|
||||
.row { display:grid; grid-template-columns: 1fr 1fr; gap:14px; }
|
||||
.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; }
|
||||
.error { border-color:#fecaca; background:#fff1f2; }
|
||||
.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; } }
|
||||
</style>
|
||||
</head>
|
||||
@@ -264,13 +272,42 @@ async function appendUploadMetadata(targetDir, { guestName, guestPhone, files })
|
||||
guestPhone,
|
||||
files: files.map((file) => ({
|
||||
storedName: file.filename,
|
||||
originalName: file.originalname,
|
||||
originalName: Buffer.from(file.originalname, "latin1").toString("utf8"),
|
||||
size: file.size
|
||||
}))
|
||||
};
|
||||
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("/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>${escapeHtml(item.allowedExts.join(", "))}</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('링크를 삭제할까? 업로드된 파일은 유지돼.');">
|
||||
<button class="danger" type="submit">삭제</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).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) => {
|
||||
const db = await readDb();
|
||||
const title = sanitizeName(req.body.title, "사진 요청");
|
||||
|
||||
Reference in New Issue
Block a user