| |
| |
| |
| |
| |
| const BRAND = window.__BRAND__ || { wordmark: "SalonView AI", slug: "salonview", nameKo: "μ΄λ‘±λ·° AI" }; |
| const API = "/api/v1/photos"; |
| const $ = (s) => document.querySelector(s); |
|
|
| const state = { |
| file: null, |
| originalUrl: null, |
| taskId: null, |
| resultUrl: null, |
| shareUrl: null, |
| deliveryAvailable: true, |
| accessCode: "", |
| gender: "auto", |
| retryTo: "capture", |
| }; |
| let CFG = { access_required: false, video_enabled: true }; |
|
|
| |
| const STEPS = ["landing", "consent", "capture", "loading", "result", "qr", "done"]; |
|
|
| function buildStepbar() { |
| const bar = $("#stepbar"); |
| bar.innerHTML = STEPS.map(() => "<i></i>").join(""); |
| } |
| function setStep(name) { |
| const idx = STEPS.indexOf(name); |
| const items = $("#stepbar").children; |
| for (let i = 0; i < items.length; i++) items[i].classList.toggle("on", idx >= 0 && i <= idx); |
| $("#stepbar").style.display = idx >= 0 ? "flex" : "none"; |
| } |
| let currentScreen = null; |
| let firstNav = true; |
| function showScreen(name, opts = {}) { |
| document.querySelectorAll(".screen").forEach((s) => s.classList.toggle("active", s.id === `screen-${name}`)); |
| setStep(name); |
| const screen = document.getElementById(`screen-${name}`); |
| const top = screen && screen.querySelector(".screen-body"); |
| if (top) top.scrollTop = 0; |
| |
| if (screen) { |
| |
| const target = name === "access" |
| ? screen.querySelector("#access-input") |
| : screen.querySelector(".title, .brand-logo, .btn-primary"); |
| if (target) { |
| if (!target.hasAttribute("tabindex") && name !== "access") target.setAttribute("tabindex", "-1"); |
| try { target.focus({ preventScroll: true }); } catch (e) { } |
| } |
| } |
| |
| |
| |
| const prev = currentScreen; |
| currentScreen = name; |
| if (!opts.fromPop && name !== prev) { |
| try { |
| const url = location.pathname + location.search; |
| if (firstNav) history.replaceState({ screen: name }, "", url); |
| else history.pushState({ screen: name }, "", url); |
| } catch (e) { } |
| firstNav = false; |
| } |
| } |
|
|
| |
| |
| |
| const _BACK_GUARDS = { |
| result: () => !!state.resultUrl, |
| qr: () => !!state.shareUrl, |
| done: () => !!state.resultUrl, |
| loading: () => false, |
| capture: () => $("#c-ai").checked && $("#c-qr").checked, |
| "recent-view": () => !!recentViewItem, |
| }; |
| window.addEventListener("popstate", (e) => { |
| |
| if (currentScreen === "loading") { |
| try { history.pushState({ screen: "loading" }, "", location.pathname + location.search); } catch (err) { } |
| showToast("보μ μ€μ΄μμ. μ μλ§ κΈ°λ€λ € μ£ΌμΈμ."); |
| return; |
| } |
| let target = (e.state && e.state.screen) || null; |
| if (!target) target = (CFG.access_required && !state.accessCode) ? "access" : "landing"; |
| |
| |
| if (target === "loading") target = state.resultUrl ? "result" : "landing"; |
| const guard = _BACK_GUARDS[target]; |
| if (guard && !guard()) target = "landing"; |
| |
| |
| if (target === "capture" && currentScreen === "result") resetCapture(); |
| showScreen(target, { fromPop: true }); |
| |
| |
| try { history.replaceState({ screen: target }, "", location.pathname + location.search); } catch (err) { } |
| }); |
|
|
| let toastTimer = null; |
| function showToast(msg) { |
| const t = $("#toast"); |
| t.textContent = msg; |
| t.classList.add("show"); |
| if (toastTimer) clearTimeout(toastTimer); |
| toastTimer = setTimeout(() => t.classList.remove("show"), 2600); |
| } |
|
|
| async function api(url, opts) { |
| const res = await fetch(url, opts); |
| if (res.status === 410) { const e = new Error("expired"); e.expired = true; throw e; } |
| if (!res.ok) throw new Error((await res.text()) || `μμ² μ€ν¨: ${res.status}`); |
| return res.json(); |
| } |
|
|
| |
| |
| |
| function sameOriginPath(u) { |
| if (!u) return u; |
| try { const x = new URL(u, location.origin); return x.pathname + x.search; } |
| catch (e) { return u; } |
| } |
|
|
| function showError(title, msg, retryTo) { |
| $("#error-title").textContent = title; |
| $("#error-msg").textContent = msg; |
| state.retryTo = retryTo || "capture"; |
| showScreen("error"); |
| } |
|
|
| |
| document.addEventListener("click", (e) => { |
| const b = e.target.closest("[data-go]"); |
| if (!b || b.disabled) return; |
| if (b.dataset.go === "done") $("#done-thumb").src = state.resultUrl || ""; |
| |
| |
| if (b.dataset.go === "capture" && currentScreen === "result") resetCapture(); |
| showScreen(b.dataset.go); |
| }); |
|
|
| |
| function syncConsent() { |
| $("#consent-go").disabled = !($("#c-ai").checked && $("#c-qr").checked); |
| } |
| ["c-ai", "c-qr"].forEach((id) => $("#" + id).addEventListener("change", syncConsent)); |
|
|
| |
| const input = $("#photo-input"); |
| $("#btn-shoot").addEventListener("click", () => { input.setAttribute("capture", "environment"); input.click(); }); |
| $("#btn-gallery").addEventListener("click", () => { input.removeAttribute("capture"); input.click(); }); |
| $("#btn-retake").addEventListener("click", resetCapture); |
|
|
| |
| document.querySelectorAll("#gender-pick .gender-opt").forEach((b) => { |
| b.addEventListener("click", () => { |
| document.querySelectorAll("#gender-pick .gender-opt").forEach((o) => o.classList.remove("active")); |
| b.classList.add("active"); |
| state.gender = b.dataset.gender || "auto"; |
| }); |
| }); |
|
|
| function resetGenderPick() { |
| state.gender = "auto"; |
| document.querySelectorAll("#gender-pick .gender-opt").forEach((o) => |
| o.classList.toggle("active", o.dataset.gender === "auto")); |
| } |
|
|
| const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]; |
| const MAX_MB = 20; |
| input.addEventListener("change", (ev) => { |
| const f = ev.target.files && ev.target.files[0]; |
| if (!f) return; |
| |
| |
| if (f.type && !ALLOWED_TYPES.includes(f.type)) { |
| showToast("JPGΒ·PNGΒ·WEBP μ¬μ§λ§ μ¬λ¦΄ μ μμ΄μ. (μμ΄ν° HEICλ λ³ν ν μ¬λ € μ£ΌμΈμ)"); |
| input.value = ""; |
| return; |
| } |
| if (f.size > MAX_MB * 1024 * 1024) { |
| showToast(`μ¬μ§μ΄ λ무 컀μ (μ΅λ ${MAX_MB}MB). λ μμ μ¬μ§μΌλ‘ μ¬λ € μ£ΌμΈμ.`); |
| input.value = ""; |
| return; |
| } |
| state.file = f; |
| if (state.originalUrl) URL.revokeObjectURL(state.originalUrl); |
| state.originalUrl = URL.createObjectURL(f); |
| const prev = $("#capture-preview"); |
| prev.src = state.originalUrl; |
| prev.hidden = false; |
| $("#face-guide").style.display = "none"; |
| $("#capture-hint").textContent = "μ΄ μ¬μ§μΌλ‘ μ§νν κΉμ?"; |
| $("#capture-actions").hidden = true; |
| $("#capture-confirm").hidden = false; |
| }); |
|
|
| function resetCapture() { |
| state.file = null; |
| $("#capture-preview").hidden = true; |
| $("#face-guide").style.display = ""; |
| $("#capture-hint").textContent = "μΌκ΅΄μ΄ μ 보μ΄κ² μ λ©΄μΌλ‘ λ§μΆ° μ£ΌμΈμ"; |
| $("#capture-actions").hidden = false; |
| $("#capture-confirm").hidden = true; |
| resetGenderPick(); |
| input.value = ""; |
| } |
|
|
| |
| $("#btn-use").addEventListener("click", startProcessing); |
|
|
| async function startProcessing() { |
| if (!state.file) { showToast("μ¬μ§μ λ¨Όμ μ νν΄ μ£ΌμΈμ."); return; } |
| $("#loading-bg").style.backgroundImage = `url(${state.originalUrl})`; |
| showScreen("loading"); |
| startLoadingTimer(); |
|
|
| const data = new FormData(); |
| data.append("file", state.file); |
| |
| |
| data.append("salon_id", "leechard_pro"); |
| data.append("ai_consent", $("#c-ai").checked ? "true" : "false"); |
| data.append("qr_delivery_allowed", $("#c-qr").checked ? "true" : "false"); |
| data.append("marketing_allowed", $("#c-mkt").checked ? "true" : "false"); |
| if (state.gender && state.gender !== "auto") data.append("gender", state.gender); |
| data.append("access_code", state.accessCode || ""); |
| try { |
| const up = await api(`${API}/upload`, { method: "POST", body: data }); |
| state.taskId = up.task_id; |
| statusFailures = 0; |
| await pollStatus(); |
| } catch (err) { |
| stopLoadingTimer(); |
| |
| const msg = String((err && err.message) || ""); |
| if (msg.includes("access code")) { |
| showToast("μ μ μ½λλ₯Ό λ€μ μ
λ ₯ν΄ μ£ΌμΈμ."); |
| state.accessCode = ""; |
| showScreen("access"); |
| } else if (msg.includes("consent")) { |
| showError("λμκ° νμν΄μ", "νμ λμ νλͺ©μ 체ν¬ν λ€ λ€μ μλν΄ μ£ΌμΈμ.", "consent"); |
| } else { |
| showError("μ
λ‘λμ μ€ν¨νμ΄μ", "λ€νΈμν¬λ₯Ό νμΈνκ³ λ€μ μλν΄ μ£ΌμΈμ.", "capture"); |
| } |
| } |
| } |
|
|
| let loadingT0 = 0, loadingTimer = null; |
| function startLoadingTimer() { |
| loadingT0 = Date.now(); |
| const tick = () => { |
| const s = (Date.now() - loadingT0) / 1000; |
| |
| const pct = Math.min(92, Math.round(100 * (1 - Math.exp(-s / 15)))); |
| $("#progress-fill").style.width = pct + "%"; |
| $("#loading-pct").textContent = pct + "%"; |
| $("#loading-time").textContent = s < 30 |
| ? "λ³΄ν΅ 15μ΄, κΈΈλ©΄ 1λΆ μ λ κ±Έλ €μ" |
| : `${Math.round(s)}μ΄μ§Έ μ²λ¦¬ μ€β¦ (μ²μ ν μ₯μ λ κ±Έλ €μ)`; |
| }; |
| tick(); |
| loadingTimer = setInterval(tick, 500); |
| startNotices(); |
| } |
| function setProgressComplete() { |
| $("#progress-fill").style.width = "100%"; |
| $("#loading-pct").textContent = "100%"; |
| } |
| function stopLoadingTimer() { if (loadingTimer) { clearInterval(loadingTimer); loadingTimer = null; } stopNotices(); } |
|
|
| |
| |
| |
| const NOTICES = [ |
| "ν€μ΄Β·μμΒ·λ°°κ²½μ κ·Έλλ‘,\nμΌκ΅΄λ§ λ λ©μ§κ² 보μ λΌμ.", |
| "κ²°κ³Όλ QRλ‘ μλ ν΄λν°μ\nλ°λ‘ μ λ¬λΌμ (7μΌκ° μ ν¨)", |
| "AIλ‘ μμ±Β·νΈμ§λ\nκ°μ μ΄λ―Έμ§μμ.", |
| "μλ³Έ μ¬μ§μ μλ²μ\nμ μ₯λμ§ μμμ.", |
| "λ³΄ν΅ 15μ΄, κΈΈλ©΄ 1λΆ κ±Έλ €μ.\nμ μλ§ κΈ°λ€λ € μ£ΌμΈμ.", |
| ]; |
| let noticeTimer = null, noticeIdx = 0; |
| function startNotices() { |
| const el = $("#notice-text"); |
| if (!el) return; |
| noticeIdx = 0; |
| el.textContent = NOTICES[0]; |
| el.style.opacity = "1"; |
| if (noticeTimer) clearInterval(noticeTimer); |
| if (REDUCE_MOTION) return; |
| noticeTimer = setInterval(() => { |
| noticeIdx = (noticeIdx + 1) % NOTICES.length; |
| el.style.opacity = "0"; |
| setTimeout(() => { el.textContent = NOTICES[noticeIdx]; el.style.opacity = "1"; }, 350); |
| }, 3600); |
| } |
| function stopNotices() { if (noticeTimer) { clearInterval(noticeTimer); noticeTimer = null; } } |
|
|
| const FAIL_MSG = { |
| QC_FAILED: ["μΌκ΅΄μ΄ μ μ 보μ¬μ", "λ°μ κ³³μμ μΌκ΅΄μ΄ μ λ©΄μΌλ‘ 보μ΄κ² λ€μ μ°μ΄ μ£ΌμΈμ."], |
| AI_FAILED: ["μ§κΈ 보μ μ΄ μ΄λ €μμ", "μ μ ν λ€μ μλν΄ μ£ΌμΈμ."], |
| QA_FAILED: ["κ²°κ³Ό κ²μλ₯Ό ν΅κ³Όνμ§ λͺ»νμ΄μ", "λ€μ ν λ² μλν΄ μ£ΌμΈμ."], |
| }; |
|
|
| let statusFailures = 0; |
| async function pollStatus() { |
| let st; |
| try { |
| st = await api(`${API}/status/${state.taskId}`); |
| statusFailures = 0; |
| } catch (e) { |
| |
| |
| statusFailures++; |
| if (statusFailures <= 8) { setTimeout(pollStatus, 1600); return; } |
| stopLoadingTimer(); |
| showError("μ²λ¦¬ μνλ₯Ό νμΈνμ§ λͺ»νμ΄μ", |
| "λ€νΈμν¬κ° λΆμμ ν΄μ. κ²°κ³Όλ 'μμ
λ΄μ'μ μ μ₯λλ μ μ ν νμΈν΄ μ£ΌμΈμ.", "capture"); |
| return; |
| } |
|
|
| if (st.status === "SUCCEEDED") { |
| stopLoadingTimer(); |
| setProgressComplete(); |
| state.resultUrl = sameOriginPath(st.result_url); |
| |
| state.deliveryAvailable = st.delivery_available !== false; |
| showResult(); |
| return; |
| } |
| if (st.is_terminal) { |
| stopLoadingTimer(); |
| const [t, m] = FAIL_MSG[st.error_code] || FAIL_MSG[st.status] || ["보μ μ μ€ν¨νμ΄μ", "λ€μ μλν΄ μ£ΌμΈμ."]; |
| showError(t, m, "capture"); |
| return; |
| } |
| setTimeout(pollStatus, 1100); |
| } |
|
|
| |
| function showResult() { |
| $("#ba-after").src = state.resultUrl; |
| $("#ba-before").src = state.originalUrl || state.resultUrl; |
| const range = $("#ba-range"); |
| const apply = () => $("#ba").style.setProperty("--p", range.value + "%"); |
| range.oninput = apply; |
| range.value = 50; apply(); |
| $("#result-go").disabled = !state.deliveryAvailable; |
| showScreen("result"); |
| addRecent(tokenFromResultUrl(state.resultUrl)); |
| } |
|
|
| |
| |
| |
| $("#btn-save").addEventListener("click", () => { |
| if (state.resultUrl) saveMediaToGallery(state.resultUrl, `${BRAND.slug}_${Date.now()}.png`, "μ¬μ§"); |
| else showToast("κ²°κ³Όκ° μμ§ μμ΄μ."); |
| }); |
|
|
| $("#result-go").addEventListener("click", loadQr); |
|
|
| async function loadQr() { |
| try { |
| const qr = await api(`${API}/qr/${state.taskId}`); |
| $("#qr-img").src = `data:image/png;base64,${qr.qr_png_base64}`; |
| state.shareUrl = qr.share_url || ""; |
| $("#share-link").textContent = state.shareUrl; |
| $("#done-thumb").src = state.resultUrl || ""; |
| showScreen("qr"); |
| } catch (e) { |
| showError("QR μμ±μ μ€ν¨νμ΄μ", "κ²°κ³Όκ° μ€λΉλμλμ§ νμΈνκ³ λ€μ μλν΄ μ£ΌμΈμ.", "result"); |
| } |
| } |
|
|
| $("#btn-copy").addEventListener("click", async () => { |
| try { await navigator.clipboard.writeText(state.shareUrl || ""); showToast("λ§ν¬λ₯Ό 볡μ¬νμ΄μ."); } |
| catch { showToast("볡μ¬νμ§ λͺ»νμ΄μ."); } |
| }); |
|
|
| |
| $("#error-retry").addEventListener("click", () => { |
| if (state.retryTo === "capture") resetCapture(); |
| showScreen(state.retryTo || "capture"); |
| }); |
| $("#done-home").addEventListener("click", () => { |
| resetCapture(); |
| state.taskId = null; state.resultUrl = null; state.shareUrl = null; |
| recentViewItem = null; |
| $("#c-ai").checked = $("#c-qr").checked = $("#c-mkt").checked = false; |
| syncConsent(); |
| showScreen("landing"); |
| }); |
|
|
| |
| |
| |
| |
| let shareData = { token: null, imageUrl: null }; |
| async function loadSharedResult(token) { |
| |
| |
| for (let attempt = 0; attempt < 20; attempt++) { |
| try { |
| const share = await api(`${API}/share/${encodeURIComponent(token)}`); |
| shareData = { token, imageUrl: sameOriginPath(share.result_image_url) }; |
| $("#share-img").src = shareData.imageUrl; |
| prefetchMedia(shareData.imageUrl); |
| showScreen("share"); |
| return; |
| } catch (e) { |
| if (e && e.expired) { showScreen("expired"); return; } |
| if (attempt === 0) showToast("λΆλ¬μ€λ μ€μ΄μμβ¦ μ μλ§ κΈ°λ€λ € μ£ΌμΈμ."); |
| await new Promise((r) => setTimeout(r, 3000)); |
| } |
| } |
| |
| $("#screen-expired .title").textContent = "μ°κ²°μ΄ μννμ§ μμμ"; |
| $("#screen-expired .error-msg").textContent = "μ μ ν λ§ν¬λ₯Ό λ€μ μ΄μ΄ μ£ΌμΈμ."; |
| showScreen("expired"); |
| } |
|
|
| $("#share-save").addEventListener("click", () => { |
| if (shareData.imageUrl) { |
| saveMediaToGallery(shareData.imageUrl, `${BRAND.slug}_${shareData.token}.png`, "μ¬μ§"); |
| } |
| }); |
|
|
| |
| |
| |
| const RECENT_KEY = "leechard_recent_v1"; |
| const RECENT_MAX = 60; |
| let recentViewItem = null; |
|
|
| function loadRecent() { |
| try { const l = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]"); return Array.isArray(l) ? l : []; } |
| catch { return []; } |
| } |
| function saveRecent(list) { |
| try { localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX))); } catch { } |
| } |
| function tokenFromResultUrl(u) { |
| const m = /\/share\/([^/]+)\/image/.exec(u || ""); |
| return m ? m[1] : null; |
| } |
| function addRecent(token) { |
| if (!token) return; |
| const list = loadRecent().filter((e) => e.token !== token); |
| list.unshift({ token, at: Date.now() }); |
| saveRecent(list); |
| } |
| function recentImageUrl(token) { return `${API}/share/${encodeURIComponent(token)}/image`; } |
| function recentShareUrl(token) { return `${location.origin}/result/${encodeURIComponent(token)}`; } |
|
|
| async function openRecent() { |
| recentViewItem = null; |
| showScreen("recent"); |
| await loadHistory(); |
| } |
|
|
| async function loadHistory() { |
| const grid = $("#recent-grid"), empty = $("#recent-empty"); |
| grid.innerHTML = ""; |
| empty.hidden = true; |
| let items = null; |
| try { |
| const r = await fetch(`${API}/history`, { headers: { "X-Access-Code": state.accessCode || "" } }); |
| if (r.status === 403) { |
| showToast("μ μ μ½λκ° νμν΄μ."); |
| state.accessCode = ""; |
| showScreen("access"); |
| return; |
| } |
| if (!r.ok) throw new Error(String(r.status)); |
| items = await r.json(); |
| } catch (e) { |
| |
| items = loadRecent().map((e2) => ({ token: e2.token, image_url: recentImageUrl(e2.token), has_video: false })); |
| } |
| empty.hidden = items.length > 0; |
| for (const it of items) renderHistoryCell(grid, empty, it); |
| } |
| function renderHistoryCell(grid, empty, it) { |
| const cell = document.createElement("button"); |
| cell.className = "recent-item"; cell.type = "button"; |
| const img = document.createElement("img"); img.alt = "AI κ²°κ³Ό"; |
| img.onerror = () => { cell.classList.add("offline"); cell.title = "μ°κ²° ν λ€μ νμλΌμ"; }; |
| img.src = sameOriginPath(it.image_url) || recentImageUrl(it.token); |
| cell.appendChild(img); |
| if (it.has_video) { |
| const b = document.createElement("span"); b.className = "recent-badge"; b.textContent = "βΆ"; |
| cell.appendChild(b); |
| } |
| cell.addEventListener("click", () => openRecentView(it)); |
| grid.appendChild(cell); |
| } |
|
|
| |
| |
| |
| let rvVideoPollTimer = null; |
| async function fetchOperatorVideoBlob(taskId) { |
| const r = await fetch(`${API}/video-file/${encodeURIComponent(taskId)}`, |
| { headers: { "X-Access-Code": state.accessCode || "" } }); |
| if (!r.ok) throw new Error(String(r.status)); |
| return r.blob(); |
| } |
| async function loadRecentVideo(taskId) { |
| try { |
| const blob = await fetchOperatorVideoBlob(taskId); |
| if (!recentViewItem || recentViewItem.task_id !== taskId) return false; |
| if (recentViewItem._videoObjUrl) URL.revokeObjectURL(recentViewItem._videoObjUrl); |
| const url = URL.createObjectURL(blob); |
| recentViewItem._videoObjUrl = url; |
| $("#rv-video").src = url; |
| $("#rv-tabs").hidden = false; |
| return true; |
| } catch (e) { return false; } |
| } |
|
|
| function openRecentView(it) { |
| recentViewItem = it; |
| if (rvVideoPollTimer) { clearTimeout(rvVideoPollTimer); rvVideoPollTimer = null; } |
| const imgUrl = sameOriginPath(it.image_url) || recentImageUrl(it.token); |
| $("#rv-img").src = imgUrl; |
| prefetchMedia(imgUrl); |
| $("#rv-video").removeAttribute("src"); |
| $("#rv-tabs").hidden = true; |
| const hasVideo = !!(it.has_video && it.task_id); |
| |
| const mk = $("#rv-make-video"), st = $("#rv-video-status"); |
| st.hidden = true; st.textContent = ""; |
| mk.disabled = false; mk.textContent = "β¨ μ΄ μ¬μ§μΌλ‘ ν보 μμ λ§λ€κΈ°"; |
| mk.hidden = !(CFG.video_enabled && it.task_id && !hasVideo); |
| rvShow("photo"); |
| $("#recent-link").textContent = recentShareUrl(it.token); |
| showScreen("recent-view"); |
| if (hasVideo) loadRecentVideo(it.task_id); |
| } |
| function rvShow(which) { |
| const photo = which === "photo"; |
| $("#rv-photo-wrap").hidden = !photo; |
| $("#rv-video-wrap").hidden = photo; |
| $("#rv-tab-photo").classList.toggle("active", photo); |
| $("#rv-tab-video").classList.toggle("active", !photo); |
| const v = $("#rv-video"); |
| if (photo) { try { v.pause(); } catch (e) { } } |
| else { try { v.play(); } catch (e) { } } |
| $("#rv-save").textContent = photo ? "π₯ μ¬μ§ μ μ₯" : "π₯ μμ μ μ₯"; |
| } |
|
|
| |
| $("#rv-make-video").addEventListener("click", startRecentVideo); |
| async function startRecentVideo() { |
| const it = recentViewItem; |
| if (!it || !it.task_id) return; |
| const tid = it.task_id; |
| const mk = $("#rv-make-video"), st = $("#rv-video-status"); |
| mk.disabled = true; |
| st.hidden = false; |
| st.textContent = "ν보 μμμ λ§λ€κ³ μμ΄μβ¦ λΉ λ₯΄λ©΄ 30μ΄, λ³΄ν΅ 1λΆ μ λμμ"; |
| const data = new FormData(); |
| data.append("access_code", state.accessCode || ""); |
| try { |
| const r = await api(`${API}/video/${encodeURIComponent(tid)}`, { method: "POST", body: data }); |
| if (!recentViewItem || recentViewItem.task_id !== tid) return; |
| if (r.video_status === "SUCCEEDED") { recentVideoReady(tid); return; } |
| if (r.video_status === "FAILED") { rvVideoFailed(); return; } |
| pollRecentVideo(tid); |
| } catch (e) { |
| if (!recentViewItem || recentViewItem.task_id !== tid) return; |
| rvVideoFailed("μμ μμ±μ μμνμ§ λͺ»νμ΄μ. λ€μ μλν΄ μ£ΌμΈμ."); |
| } |
| } |
| async function pollRecentVideo(tid) { |
| try { |
| const r = await fetch(`${API}/video/${encodeURIComponent(tid)}`, |
| { headers: { "X-Access-Code": state.accessCode || "" } }); |
| if (!r.ok) throw new Error(String(r.status)); |
| const j = await r.json(); |
| if (!recentViewItem || recentViewItem.task_id !== tid) return; |
| if (j.video_status === "SUCCEEDED") { recentVideoReady(tid); return; } |
| if (j.video_status === "FAILED") { rvVideoFailed(); return; } |
| } catch (e) { } |
| rvVideoPollTimer = setTimeout(() => pollRecentVideo(tid), 5000); |
| } |
| async function recentVideoReady(tid) { |
| const mk = $("#rv-make-video"), st = $("#rv-video-status"); |
| const ok = await loadRecentVideo(tid); |
| if (recentViewItem && recentViewItem.task_id === tid) recentViewItem.has_video = true; |
| mk.hidden = true; |
| st.hidden = false; |
| st.textContent = ok |
| ? "ν보 μμμ΄ μμ±λμ΄μ! 'μμ' νμμ νμΈνκ³ μ μ₯νμΈμ." |
| : "μμμ΄ μμ±λμ΄μ. 'μμ' νμμ νμΈνμΈμ."; |
| if (ok) rvShow("video"); |
| showToast("ν보 μμμ΄ μμ±λμ΄μ!"); |
| } |
| function rvVideoFailed(msg) { |
| const mk = $("#rv-make-video"), st = $("#rv-video-status"); |
| mk.disabled = false; |
| st.hidden = false; |
| st.textContent = msg || "μμ μμ±μ μ€ν¨νμ΄μ. λ€μ μλν΄ μ£ΌμΈμ."; |
| } |
| |
| |
| |
| |
| |
| const _mediaCache = {}; |
| function prefetchMedia(url) { |
| if (url && !_mediaCache[url]) { |
| _mediaCache[url] = fetch(url).then((r) => { if (!r.ok) throw 0; return r.blob(); }) |
| .catch(() => { delete _mediaCache[url]; return null; }); |
| } |
| return _mediaCache[url]; |
| } |
| async function saveMediaToGallery(url, filename, label) { |
| let blob; |
| try { blob = await (prefetchMedia(url) || fetch(url).then((r) => r.blob())); if (!blob) throw 0; } |
| catch (e) { showToast("λΆλ¬μ€μ§ λͺ»νμ΄μ. μ°κ²° νμΈ ν λ€μ μλν΄ μ£ΌμΈμ."); return; } |
| const file = new File([blob], filename, { type: blob.type || "application/octet-stream" }); |
| if (navigator.canShare && navigator.canShare({ files: [file] })) { |
| try { await navigator.share({ files: [file], title: BRAND.wordmark }); return; } |
| catch (e) { if (e && e.name === "AbortError") return; } |
| } |
| const objUrl = URL.createObjectURL(blob); |
| const a = document.createElement("a"); a.href = objUrl; a.download = filename; |
| document.body.appendChild(a); a.click(); a.remove(); |
| setTimeout(() => URL.revokeObjectURL(objUrl), 5000); |
| showToast(`${label} μ μ₯μ μμνμ΄μ. λ€μ΄λ‘λλ₯Ό νμΈν΄ μ£ΌμΈμ.`); |
| } |
| $("#rv-save").addEventListener("click", () => { |
| if (!recentViewItem) return; |
| const showingVideo = !$("#rv-video-wrap").hidden; |
| if (showingVideo && recentViewItem._videoObjUrl) { |
| |
| saveMediaToGallery(recentViewItem._videoObjUrl, `${BRAND.slug}_${recentViewItem.token}.mp4`, "μμ"); |
| } else { |
| saveMediaToGallery(sameOriginPath(recentViewItem.image_url) || recentImageUrl(recentViewItem.token), |
| `${BRAND.slug}_${recentViewItem.token}.png`, "μ¬μ§"); |
| } |
| }); |
| $("#rv-tab-photo").addEventListener("click", () => rvShow("photo")); |
| $("#rv-tab-video").addEventListener("click", () => rvShow("video")); |
| $("#btn-recent").addEventListener("click", openRecent); |
| $("#recent-refresh").addEventListener("click", loadHistory); |
| $("#recent-copy").addEventListener("click", async () => { |
| try { await navigator.clipboard.writeText($("#recent-link").textContent || ""); showToast("λ§ν¬λ₯Ό 볡μ¬νμ΄μ."); } |
| catch { showToast("볡μ¬νμ§ λͺ»νμ΄μ."); } |
| }); |
|
|
| |
| $("#access-go").addEventListener("click", submitAccessCode); |
| $("#access-input").addEventListener("keydown", (e) => { if (e.key === "Enter") submitAccessCode(); }); |
| async function submitAccessCode() { |
| const code = $("#access-input").value.trim(); |
| if (!code) return; |
| const data = new FormData(); |
| data.append("code", code); |
| try { |
| await api(`${API}/access`, { method: "POST", body: data }); |
| state.accessCode = code; |
| $("#access-error").hidden = true; |
| showScreen("landing"); |
| } catch (e) { |
| $("#access-error").hidden = false; |
| } |
| } |
|
|
| |
| |
| |
| const REDUCE_MOTION = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches); |
| if (REDUCE_MOTION) { |
| const sv = document.querySelector(".bg-waves svg"); |
| if (sv && sv.pauseAnimations) sv.pauseAnimations(); |
| } |
| buildStepbar(); |
| const sharePath = location.pathname.match(/^\/result\/([^/]+)$/); |
| if (sharePath) { |
| |
| |
| showScreen("share", {}); |
| loadSharedResult(sharePath[1]); |
| } else { |
| boot(); |
| } |
| async function boot() { |
| |
| |
| |
| for (let attempt = 0; attempt < 30; attempt++) { |
| try { |
| const cfg = await api(`${API}/config`); |
| CFG = Object.assign(CFG, cfg); |
| |
| |
| if (currentScreen === null || currentScreen === "landing") { |
| showScreen(cfg.access_required ? "access" : "landing"); |
| } |
| return; |
| } catch (e) { |
| if (attempt === 0) showToast("μλ²λ₯Ό κΉ¨μ°λ μ€μ΄μμβ¦ μ μλ§ κΈ°λ€λ € μ£ΌμΈμ. (μ΅λ 1~2λΆ)"); |
| await new Promise((r) => setTimeout(r, 3000)); |
| } |
| } |
| if (currentScreen === null) showScreen("landing"); |
| } |
|
|