Improve guest upload flow

This commit is contained in:
DESKTOP-KSVGT20\shkim
2026-05-10 21:23:17 +09:00
parent 328065db18
commit 59bdfbc76f
3 changed files with 308 additions and 19 deletions
+159 -8
View File
@@ -123,7 +123,9 @@ h1 {
transform: scaleX(-1);
}
.upload-button {
.select-button,
.upload-button,
.upload-again {
position: relative;
display: grid;
place-items: center;
@@ -147,6 +149,19 @@ h1 {
font-weight: 400;
letter-spacing: 0.08em;
cursor: pointer;
text-decoration: none;
}
.select-button {
margin-bottom: 14px;
background:
radial-gradient(ellipse at 50% 0%, rgba(255, 255, 255, 0.2), transparent 58%),
linear-gradient(180deg, #f8f8f6 0%, #eeeee9 100%);
box-shadow:
0 9px 18px rgba(85, 85, 85, 0.11),
inset 0 0 0 1px rgba(83, 83, 83, 0.14);
color: #565656;
font-size: clamp(24px, 4.6vw, 32px);
}
.file-picker input[type="file"] {
@@ -158,16 +173,64 @@ h1 {
cursor: pointer;
}
.submit-fallback {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
.file-summary {
margin: 0 0 14px;
padding: 16px 18px;
border: 1px solid rgba(137, 166, 158, 0.35);
border-radius: 8px;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 8px 18px rgba(55, 55, 55, 0.06);
text-align: left;
}
.file-summary__title {
margin: 0 0 10px;
color: #45615c;
font-family: "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif;
font-size: 14px;
font-weight: 700;
}
.file-list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.file-list li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
color: #4f4f4f;
font-family: "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif;
font-size: 13px;
}
.file-list span:first-child {
overflow: hidden;
clip: rect(0 0 0 0);
text-overflow: ellipsis;
white-space: nowrap;
border: 0;
}
.file-list span:last-child {
color: #87928f;
font-size: 12px;
}
.upload-status {
min-height: 22px;
margin: 12px 0 0;
color: #64736f;
font-family: "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif;
font-size: 13px;
line-height: 1.45;
}
.upload-status[data-type="error"] {
color: #a64635;
}
.upload-button:hover {
@@ -177,12 +240,96 @@ h1 {
linear-gradient(180deg, #216d65 0%, var(--deep-dark) 100%);
}
.select-button:hover {
background:
radial-gradient(ellipse at 50% 0%, rgba(255, 255, 255, 0.26), transparent 58%),
linear-gradient(180deg, #ffffff 0%, #e9e9e3 100%);
}
.upload-button:disabled {
opacity: 0.48;
cursor: default;
}
.upload-panel[data-uploading="true"] .upload-button span {
visibility: hidden;
}
.upload-panel[data-uploading="true"] .upload-button::after {
content: "";
width: 28px;
height: 28px;
border: 3px solid rgba(255, 255, 255, 0.35);
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
.upload-button:focus-visible,
.upload-again:focus-visible,
.file-picker:focus-within {
outline: 3px solid #89aaa3;
outline-offset: 3px;
}
.thank-you {
width: min(100%, 420px);
margin: clamp(18px, 4vw, 28px) auto 0;
padding: 26px 24px 28px;
border: 1px solid rgba(137, 166, 158, 0.32);
border-radius: 10px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 14px 30px rgba(55, 55, 55, 0.08);
}
.thank-you__eyebrow {
margin: 0 0 10px;
color: #8aa69f;
font-family: "Times New Roman", "Batang", serif;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.22em;
}
.thank-you h2 {
margin: 0;
color: #4f4f4f;
font-size: clamp(31px, 6vw, 42px);
line-height: 1.15;
font-weight: 400;
letter-spacing: 0.08em;
}
.thank-you p:last-of-type {
margin: 14px 0 22px;
color: #626262;
font-family: "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif;
font-size: 15px;
line-height: 1.6;
word-break: keep-all;
}
.thank-you--error {
border-color: rgba(166, 70, 53, 0.28);
}
.thank-you--error .thank-you__eyebrow,
.thank-you--error h2 {
color: #8c4d43;
}
.upload-again {
min-height: 58px;
margin: 0 auto;
font-size: clamp(22px, 4.4vw, 29px);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 430px) {
.upload-page {
padding-left: 7px;
@@ -205,6 +352,10 @@ h1 {
width: min(100%, 300px);
}
.thank-you {
width: min(100%, 300px);
}
.upload-panel__today {
font-size: clamp(23px, 6.5vw, 29px);
}
+91 -5
View File
@@ -22,21 +22,107 @@
<form class="upload-panel" method="post" action="/r/{{slug}}" enctype="multipart/form-data">
<p class="upload-panel__today">오늘의 순간</p>
<label class="upload-button file-picker" aria-label="업로드">
<span>사진 올리</span>
<label class="select-button file-picker">
<span>사진 선택하</span>
<input type="file" name="files" accept="{{accept}}" multiple required>
</label>
<button class="submit-fallback" type="submit" aria-label="업로드">사진 올리기</button>
<div class="file-summary" hidden>
<p class="file-summary__title">선택한 파일</p>
<ul class="file-list"></ul>
</div>
<button class="upload-button" type="submit" aria-label="업로드">
<span>업로드하기</span>
</button>
<p class="upload-status" aria-live="polite"></p>
</form>
<section class="thank-you" hidden>
<p class="thank-you__eyebrow">THANK YOU</p>
<h2>감사합니다</h2>
<p>소중한 오늘을 함께 남겨주셔서 고마워요.</p>
<a class="upload-again" href="/r/{{slug}}">사진 더 올리기</a>
</section>
</section>
</main>
<script>
document.querySelectorAll(".upload-panel").forEach((form) => {
const input = form.querySelector('input[type="file"][name="files"]');
const submit = form.querySelector('button[type="submit"]');
const submit = form.querySelector('button[type="submit"][aria-label="업로드"]');
const summary = form.querySelector(".file-summary");
const list = form.querySelector(".file-list");
const status = form.querySelector(".upload-status");
const thanks = document.querySelector(".thank-you");
submit.disabled = true;
const formatBytes = (bytes) => {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
return `${bytes} B`;
};
const setStatus = (message, type = "") => {
status.textContent = message;
status.dataset.type = type;
};
input?.addEventListener("change", () => {
if (input.files && input.files.length > 0) submit?.click();
const files = Array.from(input.files || []);
list.replaceChildren();
if (files.length === 0) {
summary.hidden = true;
submit.disabled = true;
setStatus("");
return;
}
files.forEach((file) => {
const item = document.createElement("li");
const name = document.createElement("span");
const size = document.createElement("span");
name.textContent = file.name;
size.textContent = formatBytes(file.size);
item.append(name, size);
list.append(item);
});
summary.hidden = false;
submit.disabled = false;
setStatus(`${files.length}개 파일을 선택했어요.`);
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (!input.files || input.files.length === 0) {
setStatus("먼저 사진을 선택해 주세요.", "error");
return;
}
submit.disabled = true;
form.dataset.uploading = "true";
setStatus("업로드 중이에요. 화면을 닫지 말아 주세요.");
try {
const response = await fetch(form.action, {
method: "POST",
body: new FormData(form),
headers: { "Accept": "application/json" }
});
const result = await response.json().catch(() => ({}));
if (!response.ok || !result.ok) throw new Error(result.message || "업로드 중 오류가 났어요.");
form.hidden = true;
thanks.hidden = false;
thanks.querySelector("p:last-of-type").textContent =
`${result.count || input.files.length}개 파일을 잘 받았어요. 소중한 오늘을 함께 남겨주셔서 고마워요.`;
} catch (error) {
submit.disabled = false;
form.dataset.uploading = "false";
setStatus(error.message || "업로드 중 오류가 났어요. 다시 시도해 주세요.", "error");
}
});
});
</script>
+58 -6
View File
@@ -149,6 +149,44 @@ function renderTemplate(template, values) {
return template.replaceAll(/\{\{(\w+)\}\}/g, (match, key) => values[key] ?? match);
}
function wantsJson(req) {
return String(req.get("accept") || "").includes("application/json");
}
function uploadResultPage(request, { heading, message, isError = false }) {
const title = escapeHtml(request.title);
const slug = escapeHtml(request.slug);
return `<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(heading)}</title>
<link rel="stylesheet" href="/assets/upload.css">
</head>
<body>
<main class="upload-page">
<section class="wedding-card" aria-labelledby="archive-title">
<div class="poster">
<img class="poster__image" src="/assets/wedding-poster.png" 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>
<section class="thank-you${isError ? " thank-you--error" : ""}">
<p class="thank-you__eyebrow">${isError ? "PLEASE TRY AGAIN" : "THANK YOU"}</p>
<h2>${escapeHtml(heading)}</h2>
<p>${escapeHtml(message)}</p>
<a class="upload-again" href="/r/${slug}">${isError ? "다시 올리기" : "사진 더 올리기"}</a>
</section>
</section>
</main>
</body>
</html>`;
}
async function uploadPage(request) {
const accept = request.allowedExts.map((ext) => `.${ext}`).join(",");
const templatePath = path.join(publicDir, "upload.html");
@@ -165,12 +203,10 @@ async function uploadPage(request) {
return page(request.title, `
<section class="panel">
<h1>${values.title}</h1>
<p class="muted">이 링크 전체에서 총 ${values.maxMb} MB까지 업로드할 수 있어.</p>
<form method="post" action="/r/${values.slug}" enctype="multipart/form-data">
<div class="upload-box">
<label>사진/영상 선택</label>
<input type="file" name="files" accept="${values.accept}" multiple required>
<p class="muted">허용 형식: ${values.allowedExts}</p>
</div>
<div class="actions"><button type="submit">업로드</button></div>
</form>
@@ -372,7 +408,11 @@ app.post("/r/:slug", async (req, res) => {
if (error) {
await removeFiles(req.files);
const message = "업로드 중 오류가 났어. 파일 형식과 용량을 확인해줘.";
res.status(400).send(page("업로드 실패", `<section class="panel"><h1>업로드 실패</h1><div class="notice error">${escapeHtml(message)}</div><a class="button" href="/r/${escapeHtml(request.slug)}">다시 올리기</a></section>`));
if (wantsJson(req)) {
res.status(400).json({ ok: false, message });
return;
}
res.status(400).send(uploadResultPage(request, { heading: "업로드 실패", message, isError: true }));
return;
}
try {
@@ -383,14 +423,26 @@ app.post("/r/:slug", async (req, res) => {
if (existingBytes + incomingBytes > request.maxBytes) {
await removeFiles(files);
const remainMb = Math.max(0, Math.floor((request.maxBytes - existingBytes) / 1024 / 1024));
res.status(400).send(page("업로드 실패", `<section class="panel"><h1>업로드 실패</h1><div class="notice error">이 링크의 총 업로드 용량 제한을 넘었어. 남은 용량은 약 ${escapeHtml(remainMb)} MB야.</div><a class="button" href="/r/${escapeHtml(request.slug)}">다시 올리기</a></section>`));
if (wantsJson(req)) {
res.status(400).json({ ok: false, message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.` });
return;
}
res.status(400).send(uploadResultPage(request, { heading: "업로드 실패", message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.`, isError: true }));
return;
}
await moveUploadedFiles(files, targetDir);
res.send(page("업로드 완료", `<section class="panel"><h1>업로드 완료</h1><div class="notice">${escapeHtml(files.length)}개 파일을 받았어.</div><a class="button" href="/r/${escapeHtml(request.slug)}">더 올리기</a></section>`));
if (wantsJson(req)) {
res.json({ ok: true, count: files.length });
return;
}
res.send(uploadResultPage(request, { heading: "감사합니다", message: `${files.length}개 파일을 잘 받았어요. 소중한 오늘을 함께 남겨주셔서 고마워요.` }));
} catch {
await removeFiles(req.files);
res.status(500).send(page("업로드 실패", `<section class="panel"><h1>업로드 실패</h1><div class="notice error">파일을 저장하는 중 오류가 났어.</div><a class="button" href="/r/${escapeHtml(request.slug)}">다시 올리기</a></section>`));
if (wantsJson(req)) {
res.status(500).json({ ok: false, message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요." });
return;
}
res.status(500).send(uploadResultPage(request, { heading: "업로드 실패", message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요.", isError: true }));
}
});
});