← back to Animals

public/dogpark/avatars.html

219 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Make your dog's avatar · AnimalsDirectory</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
  :root { --bg:#0e0e10; --rise:#161618; --ink:#e8e6e0; --ink-mute:#7a7873; --rule:#26252a; --accent:#d4a04a; --accent-hi:#e6b86a; --good:#34d399; --bad:#d87a7a; }
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 -apple-system,system-ui,sans-serif;font-weight:300}
  header{padding:28px 40px;border-bottom:1px solid var(--rule);display:flex;align-items:baseline;justify-content:space-between}
  header h1{margin:0;font-weight:300;font-size:24px;letter-spacing:-.01em}
  header h1 em{font-style:normal;color:var(--accent)}
  header nav a{color:var(--ink-mute);font-size:11px;letter-spacing:.18em;text-transform:uppercase;text-decoration:none;margin-left:24px}
  header nav a:hover{color:var(--accent)}
  main{max-width:1280px;margin:0 auto;padding:40px}
  .lede{color:var(--ink-mute);max-width:60ch;margin:0 0 32px}

  .drop{border:2px dashed var(--rule);border-radius:12px;padding:56px 32px;text-align:center;cursor:pointer;transition:border-color 180ms,background 180ms}
  .drop:hover, .drop.over{border-color:var(--accent);background:rgba(212,160,74,.05)}
  .drop p{margin:0;color:var(--ink-mute)}
  .drop strong{color:var(--ink);font-weight:400}
  .drop input{display:none}

  .preview{display:none;margin-top:32px;display:none;gap:24px;align-items:flex-start;flex-wrap:wrap}
  .preview.show{display:flex}
  .preview img{width:200px;height:200px;border-radius:8px;border:1px solid var(--rule);object-fit:cover}
  .preview .meta{flex:1;min-width:240px}
  .preview h3{margin:0 0 4px;font-size:18px;font-weight:400}
  .preview .filename{color:var(--ink-mute);font-size:13px;margin:0 0 16px}
  .hint-input{width:100%;background:var(--rise);color:var(--ink);border:1px solid var(--rule);padding:10px 12px;border-radius:6px;font:inherit;margin-bottom:12px;resize:vertical;font-family:inherit}
  .hint-input:focus{outline:none;border-color:var(--accent)}
  .hint-label{display:block;font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-mute);margin:0 0 6px}
  .btn{background:var(--accent);color:#0e0e10;border:none;padding:12px 22px;font:inherit;font-weight:500;font-size:13px;letter-spacing:.14em;text-transform:uppercase;border-radius:6px;cursor:pointer;transition:background 180ms}
  .btn:hover{background:var(--accent-hi)}
  .btn:disabled{opacity:.5;cursor:not-allowed}
  .btn.ghost{background:transparent;color:var(--accent);border:1px solid var(--rule)}

  .status{margin:24px 0;padding:14px 18px;border-radius:6px;font-size:13px;border:1px solid var(--rule);display:none}
  .status.show{display:block}
  .status.error{color:var(--bad);border-color:#5a2025}
  .status.success{color:var(--good);border-color:#1a4a30}
  .spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--rule);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;vertical-align:-2px;margin-right:8px}
  @keyframes spin{to{transform:rotate(360deg)}}

  .results{display:none;margin-top:40px}
  .results.show{display:block}
  .results h2{font-weight:300;font-size:22px;margin:0 0 4px}
  .results .desc{color:var(--ink-mute);font-style:italic;margin:0 0 24px;max-width:64ch}

  .grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
  @media (max-width:900px){.grid{grid-template-columns:repeat(2,1fr)}}
  @media (max-width:560px){.grid{grid-template-columns:1fr}}
  .card{background:var(--rise);border:1px solid var(--rule);border-radius:10px;overflow:hidden;cursor:pointer;transition:border-color 180ms,transform 180ms;position:relative}
  .card:hover{border-color:var(--accent);transform:translateY(-2px)}
  .card.selected{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent)}
  .card.selected::after{content:'✓';position:absolute;top:8px;right:10px;background:var(--accent);color:#0e0e10;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:600}
  .card img{display:block;width:100%;aspect-ratio:1;object-fit:cover}
  .card .label{padding:10px 14px;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute);border-top:1px solid var(--rule)}

  .pick-bar{margin-top:24px;display:none;align-items:center;gap:16px;padding:18px 20px;background:var(--rise);border:1px solid var(--accent);border-radius:8px}
  .pick-bar.show{display:flex}
  .pick-bar p{margin:0;flex:1}
  .pick-bar strong{color:var(--accent)}
</style>
</head>
<body>
<header>
  <h1>Make your dog's <em>avatar</em></h1>
  <nav>
    <a href="/dog-park">← Dog park</a>
    <a href="/static/dogpark/avatars-gallery.html">Test gallery</a>
  </nav>
</header>
<main>
  <p class="lede">Upload a clear close-up of your dog. We'll generate 6 stylized avatar variants for you to pick from. Best results: face visible, single dog in frame, decent lighting.</p>

  <label class="drop" id="drop">
    <input type="file" id="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif">
    <p><strong>Drop a photo here</strong> or click to choose</p>
    <p style="margin-top:8px;font-size:13px">JPEG, PNG, WebP, HEIC · max 8 MB</p>
  </label>

  <section class="preview" id="preview">
    <img id="preview-img" alt="">
    <div class="meta">
      <h3 id="preview-name">your dog</h3>
      <p class="filename" id="preview-filename"></p>
      <span class="hint-label">Optional hint</span>
      <textarea id="hint" class="hint-input" rows="2" placeholder="e.g. 'My dog is the brown Lab on the right' (helpful only when multiple dogs in the photo)"></textarea>
      <button class="btn" id="generate">Generate 6 avatars</button>
      <button class="btn ghost" id="clear" style="margin-left:8px">Choose different photo</button>
    </div>
  </section>

  <div class="status" id="status"></div>

  <section class="results" id="results">
    <h2>Pick your favorite</h2>
    <p class="desc" id="subject"></p>
    <div class="grid" id="grid"></div>
    <div class="pick-bar" id="pick-bar">
      <p>Selected: <strong id="picked-label">—</strong></p>
      <button class="btn" id="confirm">Use in dog park</button>
    </div>
  </section>
</main>

<script>
const $ = (id) => document.getElementById(id);
const drop = $('drop'), file = $('file'), preview = $('preview'), previewImg = $('preview-img'),
      previewName = $('preview-name'), previewFilename = $('preview-filename'),
      genBtn = $('generate'), clearBtn = $('clear'), hintEl = $('hint'),
      statusEl = $('status'), resultsEl = $('results'), grid = $('grid'),
      subjectEl = $('subject'), pickBar = $('pick-bar'), pickedLabel = $('picked-label'), confirmBtn = $('confirm');

let currentFile = null, currentToken = null, currentVariant = null;

function setStatus(msg, kind = '') {
  statusEl.className = 'status show ' + kind;
  statusEl.innerHTML = msg;
}
function clearStatus() { statusEl.className = 'status'; statusEl.innerHTML = ''; }

function setFile(f) {
  if (!f) return;
  if (!/^image\//.test(f.type)) { setStatus('Not an image file.', 'error'); return; }
  if (f.size > 8 * 1024 * 1024) { setStatus('Image is over 8 MB.', 'error'); return; }
  clearStatus();
  currentFile = f;
  previewImg.src = URL.createObjectURL(f);
  previewFilename.textContent = `${f.name} · ${(f.size/1024/1024).toFixed(2)} MB`;
  preview.classList.add('show');
  resultsEl.classList.remove('show');
  pickBar.classList.remove('show');
}

drop.addEventListener('click', () => file.click());
file.addEventListener('change', () => setFile(file.files?.[0]));
['dragenter','dragover'].forEach(e => drop.addEventListener(e, ev => { ev.preventDefault(); drop.classList.add('over'); }));
['dragleave','drop'].forEach(e => drop.addEventListener(e, ev => { ev.preventDefault(); drop.classList.remove('over'); }));
drop.addEventListener('drop', ev => setFile(ev.dataTransfer?.files?.[0]));

clearBtn.addEventListener('click', () => {
  currentFile = null; currentToken = null; currentVariant = null;
  preview.classList.remove('show'); resultsEl.classList.remove('show'); pickBar.classList.remove('show');
  file.value = ''; clearStatus();
});

genBtn.addEventListener('click', async () => {
  if (!currentFile) return;
  genBtn.disabled = true;
  setStatus('<span class="spinner"></span>Analyzing photo and generating 6 avatars… (~60s)', '');

  const fd = new FormData();
  fd.append('photo', currentFile);
  if (hintEl.value.trim()) fd.append('hint', hintEl.value.trim());

  try {
    const r = await fetch('/dogpark/avatars/generate', { method: 'POST', body: fd });
    const data = await r.json();
    if (!r.ok) {
      setStatus('Failed: ' + (data.error || r.status), 'error');
      genBtn.disabled = false;
      return;
    }
    currentToken = data.token;
    subjectEl.textContent = '“' + (data.subject_text || '') + '”';
    grid.innerHTML = '';
    for (const v of data.variants) {
      const card = document.createElement('div');
      card.className = 'card';
      card.dataset.variant = v.id;
      card.innerHTML = v.ok
        ? `<img src="${v.url}" loading="lazy" alt="${v.label}"><div class="label">${v.label}</div>`
        : `<div style="padding:80px 20px;text-align:center;color:var(--bad);font-size:12px">${v.error || 'failed'}</div><div class="label">${v.label}</div>`;
      if (v.ok) {
        card.addEventListener('click', () => {
          [...grid.querySelectorAll('.card')].forEach(c => c.classList.remove('selected'));
          card.classList.add('selected');
          currentVariant = v.id;
          pickedLabel.textContent = v.label;
          pickBar.classList.add('show');
        });
      }
      grid.appendChild(card);
    }
    resultsEl.classList.add('show');
    setStatus(`Done in ${(data.elapsed_ms/1000).toFixed(0)}s. ${data.rate_remaining} generations remaining this hour.`, 'success');
    genBtn.disabled = false;
  } catch (e) {
    setStatus('Network error: ' + e.message, 'error');
    genBtn.disabled = false;
  }
});

confirmBtn.addEventListener('click', async () => {
  if (!currentToken || !currentVariant) return;
  confirmBtn.disabled = true;
  try {
    const r = await fetch('/dogpark/avatars/select', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token: currentToken, variant_id: currentVariant })
    });
    const data = await r.json();
    if (!r.ok) { setStatus('Select failed: ' + (data.error || r.status), 'error'); confirmBtn.disabled = false; return; }
    localStorage.setItem('dogpark_avatar_url', data.url);
    setStatus(`Saved. Heading to the dog park…`, 'success');
    setTimeout(() => { window.location.href = '/dog-park'; }, 800);
  } catch (e) {
    setStatus('Network error: ' + e.message, 'error');
    confirmBtn.disabled = false;
  }
});
</script>
</body>
</html>