Spaces:
Running
Running
| {# File: templates/_ai_side_nav.html #} | |
| <style> | |
| .ai-nav-card { | |
| border: 1px solid rgba(255,255,255,0.10); | |
| border-radius: 18px; | |
| background: rgba(255,255,255,0.04); | |
| padding: 14px; | |
| margin-top: 14px; | |
| } | |
| .ai-nav-title { | |
| font-weight: 900; | |
| letter-spacing: 0.5px; | |
| font-size: 13px; | |
| opacity: 0.9; | |
| margin-bottom: 10px; | |
| text-transform: uppercase; | |
| } | |
| .ai-nav-btn { | |
| width: 100%; | |
| text-align: left; | |
| border: 1px solid rgba(255,255,255,0.10); | |
| background: rgba(0,0,0,0.18); | |
| color: inherit; | |
| border-radius: 14px; | |
| padding: 10px 12px; | |
| cursor: pointer; | |
| font-weight: 800; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-top: 8px; | |
| } | |
| .ai-nav-btn.active { | |
| border-color: rgba(123,220,59,0.35); | |
| background: rgba(123,220,59,0.08); | |
| } | |
| .ai-nav-panel { | |
| margin-top: 10px; | |
| display: none; | |
| gap: 10px; | |
| flex-direction: column; | |
| } | |
| .ai-nav-panel.active { display: flex; } | |
| .ai-muted { opacity: .75; font-size: 12px; } | |
| .ai-error { color: #ff6b6b; font-size: 12px; margin-top: 8px; } | |
| .ai-ok { color: rgba(123,220,59,0.95); font-size: 12px; margin-top: 8px; } | |
| .ai-swatch { width: 14px; height: 14px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.20); display:inline-block; } | |
| .ai-chip { | |
| border: 1px solid rgba(255,255,255,0.12); | |
| background: rgba(0,0,0,0.18); | |
| color: inherit; | |
| border-radius: 999px; | |
| padding: 7px 10px; | |
| cursor: pointer; | |
| font-weight: 800; | |
| font-size: 12px; | |
| } | |
| .ai-chip.active { | |
| border-color: rgba(123,220,59,0.35); | |
| background: rgba(123,220,59,0.08); | |
| } | |
| .ai-mini-card { | |
| border: 1px solid rgba(255,255,255,0.10); | |
| border-radius: 16px; | |
| padding: 10px; | |
| background: rgba(0,0,0,0.12); | |
| } | |
| .ai-loading { | |
| opacity: .85; | |
| border: 1px dashed rgba(255,255,255,0.18); | |
| background: rgba(0,0,0,0.10); | |
| } | |
| .ai-row { | |
| display:flex; | |
| gap:10px; | |
| align-items:center; | |
| } | |
| </style> | |
| <form method="POST" enctype="multipart/form-data"> | |
| <h3>{{ product.product_name }}</h3> | |
| <!-- Cloth Image (dataset se) --> | |
| <img src="{{ product.image_src }}" width="200"> | |
| <br><br> | |
| <!-- Person Image Upload --> | |
| <input type="file" name="person_image" id="person_image" required> | |
| <br><br> | |
| <button type="submit">Try On</button> | |
| </form> | |
| <!-- Result Image --> | |
| {% if result_image_url %} | |
| <h3>Result:</h3> | |
| <img src="{{ result_image_url }}" width="300"> | |
| {% endif %} | |
| {# Unique wrapper so this partial can be included on multiple pages safely #} | |
| <div class="ai-nav-card" data-ai-stylist> | |
| <div class="ai-nav-title">AI Stylist</div> | |
| <button class="ai-nav-btn active" type="button" data-ai-tab="palette"> | |
| <span>🎨 Color Palette</span><span>›</span> | |
| </button> | |
| <button class="ai-nav-btn" type="button" data-ai-tab="style"> | |
| <span>🧥 Style Recs</span><span>›</span> | |
| </button> | |
| <div class="ai-error" data-ai-error></div> | |
| <div class="ai-ok" data-ai-ok></div> | |
| <div class="ai-nav-panel active" data-ai-panel="palette"> | |
| <div class="ai-mini-card"> | |
| <div style="font-weight:900; font-size:13px;">Skin tone</div> | |
| <div class="ai-muted"> | |
| On Try-On page, it uses the same uploaded photo automatically (<code>#person_image</code>). | |
| Otherwise upload here. | |
| </div> | |
| <input type="file" accept="image/*" style="margin-top:10px;" data-ai-selfie /> | |
| <div class="ai-muted" style="margin-top:10px;" data-ai-skin></div> | |
| <div class="ai-muted" style="margin-top:6px;" data-ai-hint></div> | |
| </div> | |
| <div style="display:flex; flex-direction:column; gap:10px;" data-ai-palettes></div> | |
| </div> | |
| <div class="ai-nav-panel" data-ai-panel="style"> | |
| <div class="ai-mini-card"> | |
| <div style="font-weight:900; font-size:13px;">Vibe</div> | |
| <div style="display:flex; gap:8px; flex-wrap:wrap; margin-top:10px;"> | |
| <button class="ai-chip active" data-ai-vibe="minimal" type="button">Minimal</button> | |
| <button class="ai-chip" data-ai-vibe="streetwear" type="button">Streetwear</button> | |
| <button class="ai-chip" data-ai-vibe="formal" type="button">Formal</button> | |
| <button class="ai-chip" data-ai-vibe="smart-casual" type="button">Smart</button> | |
| </div> | |
| <button class="ai-nav-btn" type="button" style="margin-top:10px;" data-ai-style-run> | |
| <span>Refresh</span><span>↻</span> | |
| </button> | |
| <div class="ai-muted">Upload a photo above for better results.</div> | |
| </div> | |
| <div style="display:flex; flex-direction:column; gap:10px;" data-ai-style></div> | |
| </div> | |
| </div> | |
| <script> | |
| (() => { | |
| const root = document.currentScript?.previousElementSibling; | |
| if (!root || !root.matches("[data-ai-stylist]")) return; | |
| const tabButtons = root.querySelectorAll("[data-ai-tab]"); | |
| const panels = root.querySelectorAll("[data-ai-panel]"); | |
| const selfieInput = root.querySelector("[data-ai-selfie]"); | |
| const errBox = root.querySelector("[data-ai-error]"); | |
| const okBox = root.querySelector("[data-ai-ok]"); | |
| const skinBox = root.querySelector("[data-ai-skin]"); | |
| const hintBox = root.querySelector("[data-ai-hint]"); | |
| const palettesBox = root.querySelector("[data-ai-palettes]"); | |
| const styleBox = root.querySelector("[data-ai-style]"); | |
| const runStyleBtn = root.querySelector("[data-ai-style-run]"); | |
| let vibe = "minimal"; | |
| let busy = false; | |
| function setError(msg) { | |
| if (errBox) errBox.textContent = msg || ""; | |
| if (okBox) okBox.textContent = ""; | |
| } | |
| function setOk(msg) { | |
| if (okBox) okBox.textContent = msg || ""; | |
| if (errBox) errBox.textContent = ""; | |
| } | |
| function setTab(which) { | |
| tabButtons.forEach((b) => b.classList.toggle("active", b.getAttribute("data-ai-tab") === which)); | |
| panels.forEach((p) => p.classList.toggle("active", p.getAttribute("data-ai-panel") === which)); | |
| } | |
| tabButtons.forEach((b) => b.addEventListener("click", () => setTab(b.getAttribute("data-ai-tab")))); | |
| root.querySelectorAll("[data-ai-vibe]").forEach((btn) => { | |
| btn.addEventListener("click", () => { | |
| root.querySelectorAll("[data-ai-vibe]").forEach((b) => b.classList.remove("active")); | |
| btn.classList.add("active"); | |
| vibe = btn.getAttribute("data-ai-vibe") || "minimal"; | |
| setOk(`Vibe set to: ${vibe}`); | |
| }); | |
| }); | |
| function getTryonFileIfExists() { | |
| const tryonInput = document.getElementById("person_image"); | |
| if (tryonInput && tryonInput.files && tryonInput.files[0]) return tryonInput.files[0]; | |
| return null; | |
| } | |
| function getSelfieFile() { | |
| const f1 = getTryonFileIfExists(); | |
| if (f1) return f1; | |
| if (selfieInput && selfieInput.files && selfieInput.files[0]) return selfieInput.files[0]; | |
| return null; | |
| } | |
| function setLoading(targetBox, on) { | |
| if (!targetBox) return; | |
| targetBox.classList.toggle("ai-loading", !!on); | |
| } | |
| function renderEmptyHint() { | |
| if (!hintBox) return; | |
| const hasTryon = !!document.getElementById("person_image"); | |
| const hasFile = !!getSelfieFile(); | |
| if (hasFile) { | |
| hintBox.textContent = ""; | |
| return; | |
| } | |
| hintBox.textContent = hasTryon | |
| ? "Choose a photo in the Try-On upload to auto-detect." | |
| : "Upload a selfie here to get palette + recommendations."; | |
| } | |
| async function fetchPalette() { | |
| if (busy) return; | |
| busy = true; | |
| setError(""); | |
| setOk(""); | |
| if (skinBox) skinBox.textContent = ""; | |
| if (palettesBox) palettesBox.innerHTML = ""; | |
| renderEmptyHint(); | |
| const f = getSelfieFile(); | |
| if (!f) { busy = false; return; } | |
| setLoading(palettesBox, true); | |
| setOk("Analyzing skin tone…"); | |
| try { | |
| const fd = new FormData(); | |
| fd.append("selfie", f); | |
| const res = await fetch("/api/ai/palette", { method: "POST", body: fd }); | |
| const data = await res.json(); | |
| if (!data.ok) { setError(data.error || "Palette failed."); return; } | |
| const rgb = data.skin.rgb; | |
| if (skinBox) { | |
| skinBox.innerHTML = | |
| `Detected skin: <span class="ai-swatch" style="background: rgb(${rgb[0]},${rgb[1]},${rgb[2]});"></span> | |
| <span class="ai-muted"> rgb(${rgb.join(",")})</span>`; | |
| } | |
| (data.palettes || []).forEach((p) => { | |
| const card = document.createElement("div"); | |
| card.className = "ai-mini-card"; | |
| card.innerHTML = ` | |
| <div style="font-weight:900; font-size:13px;">${p.name}</div> | |
| <div class="ai-muted">${p.note}</div> | |
| <div style="display:flex; gap:6px; flex-wrap:wrap; margin-top:10px;"> | |
| ${(p.swatches || []).map(hex => `<span class="ai-swatch" title="${hex}" style="background:${hex};"></span>`).join("")} | |
| </div> | |
| `; | |
| palettesBox.appendChild(card); | |
| }); | |
| setOk("Palette ready."); | |
| } catch (e) { | |
| setError("Palette request failed."); | |
| } finally { | |
| setLoading(palettesBox, false); | |
| busy = false; | |
| } | |
| } | |
| async function fetchStyle() { | |
| if (busy) return; | |
| busy = true; | |
| setError(""); | |
| setOk(""); | |
| if (styleBox) styleBox.innerHTML = ""; | |
| renderEmptyHint(); | |
| setLoading(styleBox, true); | |
| setOk("Finding best matches…"); | |
| try { | |
| const fd = new FormData(); | |
| fd.append("vibe", vibe); | |
| const f = getSelfieFile(); | |
| if (f) fd.append("selfie", f); | |
| const res = await fetch("/api/ai/style", { method: "POST", body: fd }); | |
| const data = await res.json(); | |
| if (!data.ok) { setError(data.error || "Style failed."); return; } | |
| (data.recs || []).forEach((r) => { | |
| const p = r.product; | |
| const div = document.createElement("div"); | |
| div.className = "ai-mini-card"; | |
| div.innerHTML = ` | |
| <div class="ai-row"> | |
| <img src="${p.image_src}" style="width:52px; height:52px; border-radius:14px; object-fit:cover; border:1px solid rgba(255,255,255,.12);" /> | |
| <div style="min-width:0; flex:1;"> | |
| <div style="font-weight:900; font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${p.product_name}</div> | |
| <div class="ai-muted">Score: ${Number(r.score || 0).toFixed(3)} · ${p.category}</div> | |
| <a href="/tryon/${p.product_id}" class="ai-muted" style="display:inline-block; margin-top:4px;">Try →</a> | |
| </div> | |
| </div> | |
| `; | |
| styleBox.appendChild(div); | |
| }); | |
| setOk("Recommendations ready."); | |
| } catch (e) { | |
| setError("Style request failed."); | |
| } finally { | |
| setLoading(styleBox, false); | |
| busy = false; | |
| } | |
| } | |
| if (selfieInput) { | |
| selfieInput.addEventListener("change", async () => { | |
| await fetchPalette(); | |
| await fetchStyle(); | |
| }); | |
| } | |
| const tryonInput = document.getElementById("person_image"); | |
| if (tryonInput) { | |
| tryonInput.addEventListener("change", async () => { | |
| await fetchPalette(); | |
| await fetchStyle(); | |
| }); | |
| } | |
| if (runStyleBtn) runStyleBtn.addEventListener("click", fetchStyle); | |
| renderEmptyHint(); | |
| setTimeout(() => { fetchPalette(); fetchStyle(); }, 300); | |
| })(); | |
| </script> |