Improve guest upload flow
This commit is contained in:
+159
-8
@@ -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
@@ -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
@@ -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 }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user