업로드 연락처 입력과 저장 폴더 정리
This commit is contained in:
+9
-18
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user