업로드 연락처 입력과 저장 폴더 정리

This commit is contained in:
DESKTOP-KSVGT20\shkim
2026-05-13 23:03:44 +09:00
parent 648974960e
commit 735c05ed1e
3 changed files with 50 additions and 76 deletions
+9 -18
View File
@@ -64,18 +64,6 @@ body {
object-position: center top; object-position: center top;
} }
.poster__plate {
position: absolute;
z-index: 5;
left: 8.5%;
right: 8.5%;
bottom: 10.5%;
padding: clamp(16px, 3.5vw, 25px) 12px clamp(13px, 2.6vw, 20px);
background: rgba(255, 255, 255, 0.9);
border: 0;
backdrop-filter: none;
}
h1 { h1 {
margin: 0; margin: 0;
color: #565656; color: #565656;
@@ -160,6 +148,15 @@ h1 {
transform: scaleX(-1); transform: scaleX(-1);
} }
.upload-panel__notice {
margin: -6px auto 16px;
color: #5f6f6b;
font-family: "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif;
font-size: 12px;
line-height: 1.55;
word-break: keep-all;
}
.select-button, .select-button,
.upload-button, .upload-button,
.upload-again { .upload-again {
@@ -485,12 +482,6 @@ h1 {
content: none; content: none;
} }
.poster__plate {
left: 8%;
right: 8%;
bottom: 10.5%;
}
.upload-panel { .upload-panel {
width: min(calc(100% - 48px), 340px); width: min(calc(100% - 48px), 340px);
margin-top: -54px; margin-top: -54px;
+3 -19
View File
@@ -8,28 +8,19 @@
</head> </head>
<body> <body>
<main class="upload-page"> <main class="upload-page">
<section class="wedding-card" aria-labelledby="archive-title"> <section class="wedding-card" aria-label="{{title}}">
<div class="poster"> <div class="poster">
<img class="poster__art" src="/assets/wedding-illustration.png?v=single-illustration-1" alt=""> <img class="poster__art" src="/assets/wedding-illustration.png?v=single-illustration-1" alt="">
<div class="poster__plate">
<h1 id="archive-title">{{title}}</h1>
<p class="poster__date">2026.05.24 SUN</p>
<p class="poster__subtitle">OUR WEDDING ARCHIVE</p>
</div>
</div> </div>
<form class="upload-panel" method="post" action="/r/{{slug}}" enctype="multipart/form-data"> <form class="upload-panel" method="post" action="/r/{{slug}}" enctype="multipart/form-data">
<p class="upload-panel__today">오늘의 순간</p> <p class="upload-panel__today">오늘의 순간</p>
<p class="upload-panel__notice">※ 입력해주신 연락처는 이벤트 상품 전달 용도로만 사용되며,<br>행사 종료 후 삭제됩니다.</p>
<div class="guest-fields" aria-label="업로드하시는 분 정보"> <div class="guest-fields" aria-label="업로드하시는 분 정보">
<label>
<span>성함</span>
<input type="text" name="guestName" autocomplete="name" placeholder="성함을 입력해 주세요." required>
</label>
<label> <label>
<span>연락처</span> <span>연락처</span>
<input type="tel" name="guestPhone" autocomplete="tel" inputmode="tel" placeholder="연락처를 입력해 주세요." required> <input type="tel" name="guestPhone" autocomplete="tel" inputmode="tel" placeholder="연락처를 입력해 주세요.">
</label> </label>
</div> </div>
@@ -63,8 +54,6 @@
<script> <script>
document.querySelectorAll(".upload-panel").forEach((form) => { document.querySelectorAll(".upload-panel").forEach((form) => {
const input = form.querySelector('input[type="file"][name="files"]'); const input = form.querySelector('input[type="file"][name="files"]');
const guestName = form.querySelector('input[name="guestName"]');
const guestPhone = form.querySelector('input[name="guestPhone"]');
const submit = form.querySelector('button[type="submit"][aria-label="업로드"]'); const submit = form.querySelector('button[type="submit"][aria-label="업로드"]');
const summary = form.querySelector(".file-summary"); const summary = form.querySelector(".file-summary");
const empty = form.querySelector(".file-summary__empty"); const empty = form.querySelector(".file-summary__empty");
@@ -161,11 +150,6 @@
setStatus("먼저 사진을 선택해 주세요.", "error"); setStatus("먼저 사진을 선택해 주세요.", "error");
return; return;
} }
if (!guestName.value.trim() || !guestPhone.value.trim()) {
setStatus("성함과 연락처를 모두 입력해 주세요.", "error");
return;
}
submit.disabled = true; submit.disabled = true;
form.dataset.uploading = "true"; form.dataset.uploading = "true";
setStatus("업로드 중이에요. 화면을 닫지 말아 주세요."); setStatus("업로드 중이에요. 화면을 닫지 말아 주세요.");
+36 -37
View File
@@ -64,6 +64,11 @@ function cleanGuestField(value) {
.slice(0, 80); .slice(0, 80);
} }
function submissionFolderName(guestPhone) {
const phone = cleanGuestField(guestPhone).replace(/[^\d]/g, "");
return phone || "익명";
}
function normalizeExts(value) { function normalizeExts(value) {
return String(value || "") return String(value || "")
.split(/[\s,;]+/) .split(/[\s,;]+/)
@@ -171,7 +176,6 @@ function wantsJson(req) {
} }
function uploadResultPage(request, { heading, message, isError = false }) { function uploadResultPage(request, { heading, message, isError = false }) {
const title = escapeHtml(request.title);
const slug = escapeHtml(request.slug); const slug = escapeHtml(request.slug);
return `<!doctype html> return `<!doctype html>
<html lang="ko"> <html lang="ko">
@@ -183,14 +187,9 @@ function uploadResultPage(request, { heading, message, isError = false }) {
</head> </head>
<body> <body>
<main class="upload-page"> <main class="upload-page">
<section class="wedding-card" aria-labelledby="archive-title"> <section class="wedding-card" aria-label="${escapeHtml(request.title)}">
<div class="poster"> <div class="poster">
<img class="poster__art" src="/assets/wedding-illustration.png?v=single-illustration-1" alt=""> <img class="poster__art" src="/assets/wedding-illustration.png?v=single-illustration-1" alt="">
<div class="poster__plate">
<h1 id="archive-title">${title}</h1>
<p class="poster__date">2026.05.24 SUN</p>
<p class="poster__subtitle">OUR WEDDING ARCHIVE</p>
</div>
</div> </div>
<section class="thank-you${isError ? " thank-you--error" : ""}"> <section class="thank-you${isError ? " thank-you--error" : ""}">
<p class="thank-you__eyebrow">${isError ? "PLEASE TRY AGAIN" : "THANK YOU"}</p> <p class="thank-you__eyebrow">${isError ? "PLEASE TRY AGAIN" : "THANK YOU"}</p>
@@ -221,10 +220,8 @@ async function uploadPage(request) {
<section class="panel"> <section class="panel">
<h1>${values.title}</h1> <h1>${values.title}</h1>
<form method="post" action="/r/${values.slug}" enctype="multipart/form-data"> <form method="post" action="/r/${values.slug}" enctype="multipart/form-data">
<label>성함</label>
<input type="text" name="guestName" autocomplete="name" required>
<label>연락처</label> <label>연락처</label>
<input type="tel" name="guestPhone" autocomplete="tel" required> <input type="tel" name="guestPhone" autocomplete="tel">
<div class="upload-box"> <div class="upload-box">
<label>사진/영상 선택</label> <label>사진/영상 선택</label>
<input type="file" name="files" accept="${values.accept}" multiple required> <input type="file" name="files" accept="${values.accept}" multiple required>
@@ -265,11 +262,11 @@ async function moveUploadedFiles(files, targetDir) {
} }
} }
async function appendUploadMetadata(targetDir, { guestName, guestPhone, files }) { async function appendUploadMetadata(targetDir, { guestPhone, folderName, files }) {
const entry = { const entry = {
uploadedAt: new Date().toISOString(), uploadedAt: new Date().toISOString(),
guestName,
guestPhone, guestPhone,
folderName,
files: files.map((file) => ({ files: files.map((file) => ({
storedName: file.filename, storedName: file.filename,
originalName: Buffer.from(file.originalname, "latin1").toString("utf8"), originalName: Buffer.from(file.originalname, "latin1").toString("utf8"),
@@ -280,22 +277,33 @@ async function appendUploadMetadata(targetDir, { guestName, guestPhone, files })
} }
async function readUploadMetadata(folderName) { async function readUploadMetadata(folderName) {
const metadataPath = path.join(uploadsDir, folderName, "upload-metadata.jsonl"); const requestDir = path.join(uploadsDir, folderName);
const paths = [path.join(requestDir, "upload-metadata.jsonl")];
const entries = await fsp.readdir(requestDir, { withFileTypes: true }).catch((error) => {
if (error.code === "ENOENT") return [];
throw error;
});
for (const entry of entries) {
if (entry.isDirectory()) {
paths.push(path.join(requestDir, entry.name, "upload-metadata.jsonl"));
}
}
const submissions = [];
for (const metadataPath of paths) {
const content = await fsp.readFile(metadataPath, "utf8").catch((error) => { const content = await fsp.readFile(metadataPath, "utf8").catch((error) => {
if (error.code === "ENOENT") return ""; if (error.code === "ENOENT") return "";
throw error; throw error;
}); });
return content for (const line of content.split(/\r?\n/).filter(Boolean)) {
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
try { try {
return JSON.parse(line); submissions.push(JSON.parse(line));
} catch { } catch {
return null; // Ignore malformed legacy lines so one bad record does not hide the list.
} }
}) }
.filter(Boolean); }
return submissions.sort((a, b) => String(a.uploadedAt || "").localeCompare(String(b.uploadedAt || "")));
} }
function formatDateTime(value) { function formatDateTime(value) {
@@ -414,8 +422,8 @@ app.get("/admin/requests/:slug/submissions", requireAdmin, async (req, res) => {
return `<article class="submission"> return `<article class="submission">
<h3>제출 ${index + 1}</h3> <h3>제출 ${index + 1}</h3>
<dl> <dl>
<dt>성함</dt><dd>${escapeHtml(submission.guestName || "-")}</dd>
<dt>연락처</dt><dd>${escapeHtml(submission.guestPhone || "-")}</dd> <dt>연락처</dt><dd>${escapeHtml(submission.guestPhone || "-")}</dd>
<dt>저장 폴더</dt><dd>${escapeHtml(submission.folderName || "-")}</dd>
<dt>제출 시간</dt><dd>${escapeHtml(formatDateTime(submission.uploadedAt))}</dd> <dt>제출 시간</dt><dd>${escapeHtml(formatDateTime(submission.uploadedAt))}</dd>
<dt>파일 수</dt><dd>${files.length}개</dd> <dt>파일 수</dt><dd>${files.length}개</dd>
</dl> </dl>
@@ -431,7 +439,7 @@ app.get("/admin/requests/:slug/submissions", requireAdmin, async (req, res) => {
</div> </div>
<h1>${escapeHtml(item.title)} 제출 내역</h1> <h1>${escapeHtml(item.title)} 제출 내역</h1>
<p class="muted">저장 위치: ${escapeHtml(path.join(uploadsDir, item.folderName))}</p> <p class="muted">저장 위치: ${escapeHtml(path.join(uploadsDir, item.folderName))}</p>
<p class="muted">제출 정보 파일: ${escapeHtml(path.join(uploadsDir, item.folderName, "upload-metadata.jsonl"))}</p> <p class="muted">제출 정보 파일: 각 연락처/익명 폴더의 upload-metadata.jsonl</p>
${rows || `<div class="notice">아직 이 링크로 제출된 내역이 없어. 예전 업로드는 메타데이터가 없으면 여기에는 표시되지 않을 수 있어.</div>`} ${rows || `<div class="notice">아직 이 링크로 제출된 내역이 없어. 예전 업로드는 메타데이터가 없으면 여기에는 표시되지 않을 수 있어.</div>`}
</section> </section>
`)); `));
@@ -530,20 +538,11 @@ app.post("/r/:slug", async (req, res) => {
} }
try { try {
const files = req.files || []; const files = req.files || [];
const guestName = cleanGuestField(req.body.guestName);
const guestPhone = cleanGuestField(req.body.guestPhone); const guestPhone = cleanGuestField(req.body.guestPhone);
if (!guestName || !guestPhone) { const requestDir = path.join(uploadsDir, request.folderName);
await removeFiles(files); const folderName = submissionFolderName(guestPhone);
const message = "성함과 연락처를 모두 입력해 주세요."; const targetDir = path.join(requestDir, folderName);
if (wantsJson(req)) { const existingBytes = await directorySize(requestDir);
res.status(400).json({ ok: false, message });
return;
}
res.status(400).send(uploadResultPage(request, { heading: "업로드 실패", message, isError: true }));
return;
}
const targetDir = path.join(uploadsDir, request.folderName);
const existingBytes = await directorySize(targetDir);
const incomingBytes = files.reduce((total, file) => total + file.size, 0); const incomingBytes = files.reduce((total, file) => total + file.size, 0);
if (existingBytes + incomingBytes > request.maxBytes) { if (existingBytes + incomingBytes > request.maxBytes) {
await removeFiles(files); await removeFiles(files);
@@ -556,7 +555,7 @@ app.post("/r/:slug", async (req, res) => {
return; return;
} }
await moveUploadedFiles(files, targetDir); await moveUploadedFiles(files, targetDir);
await appendUploadMetadata(targetDir, { guestName, guestPhone, files }); await appendUploadMetadata(targetDir, { guestPhone, folderName, files });
if (wantsJson(req)) { if (wantsJson(req)) {
res.json({ ok: true, count: files.length }); res.json({ ok: true, count: files.length });
return; return;