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; }
|
: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, "사진 요청");
|
||||||
|
|||||||
Reference in New Issue
Block a user