← 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>