Scenedrobe / web /admin.html
Emin AkkocΜ§
CLIP
e4b1ae8
Raw History Blame Contribute Delete
17.7 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenedrobe Admin</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
min-height: 100vh;
padding: 1.5rem;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.top-bar h1 { font-size: 1.5rem; color: #fff; }
.top-bar a {
color: #60a5fa;
text-decoration: none;
font-size: 0.9rem;
}
.top-bar a:hover { text-decoration: underline; }
.layout {
display: grid;
grid-template-columns: 360px 1fr;
gap: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
/* ── Upload panel ── */
.upload-panel {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 10px;
padding: 1.2rem;
height: fit-content;
position: sticky;
top: 1.5rem;
}
.upload-panel h2 {
font-size: 1rem;
color: #fff;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 0.8rem;
}
.form-group label {
display: block;
font-size: 0.8rem;
color: #aaa;
margin-bottom: 0.3rem;
}
.form-group input[type="text"] {
width: 100%;
padding: 0.55rem 0.8rem;
border: 1px solid #333;
border-radius: 6px;
background: #111;
color: #e0e0e0;
font-size: 0.85rem;
}
.form-group input[type="text"]::placeholder { color: #555; }
.slider-group label {
display: flex;
justify-content: space-between;
align-items: center;
}
.slider-group label span {
font-weight: 600;
color: #e0e0e0;
font-size: 0.8rem;
min-width: 2.5em;
text-align: right;
}
.slider-group input[type="range"] {
width: 100%;
margin-top: 0.2rem;
accent-color: #2563eb;
cursor: pointer;
}
.drop-zone {
border: 2px dashed #333;
border-radius: 8px;
padding: 1.5rem;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
margin-bottom: 0.8rem;
}
.drop-zone:hover, .drop-zone.dragover {
border-color: #2563eb;
background: rgba(37, 99, 235, 0.05);
}
.drop-zone.has-file {
border-color: #4ade80;
border-style: solid;
}
.drop-zone input { display: none; }
.drop-zone-text {
color: #666;
font-size: 0.85rem;
}
.drop-zone-text strong { color: #aaa; }
.drop-zone-preview {
display: none;
max-width: 100%;
max-height: 180px;
border-radius: 6px;
margin-top: 0.5rem;
}
.btn {
padding: 0.6rem 1.2rem;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a1a;
color: #e0e0e0;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s;
}
.btn:hover { background: #2a2a2a; }
.btn-primary {
background: #2563eb;
border-color: #2563eb;
color: #fff;
width: 100%;
font-size: 0.9rem;
font-weight: 500;
}
.btn-primary:hover { background: #1d4ed8; }
.btn-primary:disabled {
background: #1e3a5f;
cursor: not-allowed;
opacity: 0.6;
}
.btn-danger {
background: transparent;
border-color: #444;
color: #f87171;
padding: 0.35rem 0.6rem;
font-size: 0.75rem;
}
.btn-danger:hover {
background: rgba(248, 113, 113, 0.1);
border-color: #f87171;
}
.upload-status {
margin-top: 0.8rem;
font-size: 0.8rem;
min-height: 1.2em;
text-align: center;
}
.upload-status.success { color: #4ade80; }
.upload-status.error { color: #f87171; }
.extraction-result {
margin-top: 0.8rem;
border: 1px solid #2a2a2a;
border-radius: 8px;
overflow: hidden;
display: none;
}
.extraction-result.visible { display: block; }
.extraction-result-header {
font-size: 0.75rem;
color: #aaa;
padding: 0.5rem 0.7rem 0.3rem;
background: #111;
}
.extraction-result img {
display: block;
width: 100%;
max-height: 180px;
object-fit: contain;
background: #fff;
padding: 0.3rem;
}
/* ── Catalog grid ── */
.catalog-panel h2 {
font-size: 1rem;
color: #fff;
margin-bottom: 0.3rem;
}
.catalog-count {
font-size: 0.8rem;
color: #888;
margin-bottom: 1rem;
}
.catalog-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 0.8rem;
}
.catalog-card {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 8px;
overflow: hidden;
transition: border-color 0.2s;
}
.catalog-card:hover { border-color: #444; }
.catalog-card-images {
display: grid;
grid-template-columns: 1fr 1fr;
}
.catalog-card-images img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
}
.catalog-card-images img.original { background: #111; }
.catalog-card-images img.extracted { background: #fff; }
.catalog-card-body {
padding: 0.6rem 0.7rem;
}
.catalog-card-label {
font-size: 0.85rem;
font-weight: 500;
color: #e0e0e0;
margin-bottom: 0.15rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.catalog-card-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.3rem;
}
.catalog-card-price {
font-size: 0.85rem;
color: #4ade80;
font-weight: 600;
}
.catalog-empty {
grid-column: 1 / -1;
text-align: center;
color: #555;
padding: 3rem 0;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.layout {
grid-template-columns: 1fr;
}
.upload-panel { position: static; }
.catalog-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
}
</style>
</head>
<body>
<div class="top-bar">
<h1>Catalog Admin</h1>
<a href="/">&larr; Back to Scenedrobe</a>
</div>
<div class="layout">
<div class="upload-panel">
<h2>Add Product</h2>
<form id="uploadForm">
<div class="drop-zone" id="dropZone">
<input type="file" id="fileInput" accept="image/*">
<div class="drop-zone-text" id="dropText">
<strong>Drop image here</strong> or click to browse
</div>
<img class="drop-zone-preview" id="dropPreview">
</div>
<div class="form-group">
<label for="labelInput">Label *</label>
<input type="text" id="labelInput" placeholder="e.g. Blue Denim Jacket">
</div>
<div class="form-group slider-group">
<label>Mask Erosion <span id="erosionVal">0</span></label>
<input type="range" id="maskErosion" min="0" max="30" step="1" value="0">
</div>
<button type="button" class="btn btn-primary" id="extractBtn" disabled>
Extract Garment
</button>
<div class="upload-status" id="extractStatus"></div>
<div class="extraction-result" id="extractionResult">
<div class="extraction-result-header">Extracted garment <span id="extractScore"></span></div>
<img id="extractionImg">
</div>
<div class="form-group">
<label for="priceInput">Price</label>
<input type="text" id="priceInput" placeholder="e.g. 49.99">
</div>
<div class="form-group">
<label for="urlInput">Product URL</label>
<input type="text" id="urlInput" placeholder="https://...">
</div>
<button type="submit" class="btn btn-primary" id="saveBtn" disabled style="background:#16a34a;border-color:#16a34a;">
Save to Catalog
</button>
<div class="upload-status" id="saveStatus"></div>
</form>
</div>
<div class="catalog-panel">
<h2>Catalog</h2>
<div class="catalog-count" id="catalogCount">Loading...</div>
<div class="catalog-grid" id="catalogGrid">
<div class="catalog-empty">Loading catalog...</div>
</div>
</div>
</div>
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const dropText = document.getElementById('dropText');
const dropPreview = document.getElementById('dropPreview');
const uploadForm = document.getElementById('uploadForm');
const extractBtn = document.getElementById('extractBtn');
const extractStatus = document.getElementById('extractStatus');
const extractionResult = document.getElementById('extractionResult');
const extractionImg = document.getElementById('extractionImg');
const extractScore = document.getElementById('extractScore');
const saveBtn = document.getElementById('saveBtn');
const saveStatus = document.getElementById('saveStatus');
const labelInput = document.getElementById('labelInput');
const priceInput = document.getElementById('priceInput');
const urlInput = document.getElementById('urlInput');
const catalogGrid = document.getElementById('catalogGrid');
const catalogCount = document.getElementById('catalogCount');
const maskErosion = document.getElementById('maskErosion');
const erosionVal = document.getElementById('erosionVal');
maskErosion.addEventListener('input', () => { erosionVal.textContent = maskErosion.value; });
maskErosion.addEventListener('change', () => { if (selectedFile && labelInput.value.trim()) runExtraction(); });
let selectedFile = null;
let previewData = null; // stores embedding + thumbnails from preview
// ── Drop zone ──
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length) handleFile(e.target.files[0]);
});
function handleFile(file) {
if (!file.type.startsWith('image/')) return;
selectedFile = file;
previewData = null;
dropZone.classList.add('has-file');
dropText.style.display = 'none';
dropPreview.style.display = 'block';
dropPreview.src = URL.createObjectURL(file);
clearPreview();
checkExtractReady();
// auto-trigger extraction if label is already filled
if (labelInput.value.trim()) runExtraction();
}
function clearPreview() {
previewData = null;
extractionResult.classList.remove('visible');
extractStatus.textContent = '';
extractStatus.className = 'upload-status';
saveBtn.disabled = true;
saveStatus.textContent = '';
}
function checkExtractReady() {
extractBtn.disabled = !(selectedFile && labelInput.value.trim());
}
labelInput.addEventListener('input', () => {
checkExtractReady();
clearPreview();
});
// ── Step 1: Extract (preview) ──
extractBtn.addEventListener('click', runExtraction);
async function runExtraction() {
if (!selectedFile || !labelInput.value.trim()) return;
extractBtn.disabled = true;
extractStatus.textContent = 'Detecting & extracting garment...';
extractStatus.className = 'upload-status';
clearPreview();
const formData = new FormData();
formData.append('image', selectedFile);
formData.append('label', labelInput.value.trim());
formData.append('mask_erosion', maskErosion.value);
try {
const resp = await fetch('/catalog/preview', { method: 'POST', body: formData });
const data = await resp.json();
if (data.status === 'ok') {
previewData = data;
extractionImg.src = `data:image/jpeg;base64,${data.extracted_thumbnail}`;
extractScore.textContent = `(${Math.round(data.detection_score * 100)}% confidence)`;
extractionResult.classList.add('visible');
extractStatus.textContent = `Extraction successful β€” review below then save.`;
extractStatus.className = 'upload-status success';
saveBtn.disabled = false;
} else {
extractStatus.textContent = data.message || 'Extraction failed.';
extractStatus.className = 'upload-status error';
}
} catch (err) {
extractStatus.textContent = `Error: ${err.message}`;
extractStatus.className = 'upload-status error';
} finally {
extractBtn.disabled = false;
checkExtractReady();
}
}
// ── Step 2: Save ──
uploadForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (!previewData) return;
saveBtn.disabled = true;
saveStatus.textContent = 'Saving to catalog...';
saveStatus.className = 'upload-status';
const formData = new FormData();
formData.append('label', labelInput.value.trim());
formData.append('price', priceInput.value.trim());
formData.append('product_url', urlInput.value.trim());
formData.append('embedding', JSON.stringify(previewData.embedding));
formData.append('original_thumbnail', previewData.original_thumbnail);
formData.append('extracted_thumbnail', previewData.extracted_thumbnail);
formData.append('prepared_thumbnail', previewData.prepared_thumbnail || '');
try {
const resp = await fetch('/catalog/add', { method: 'POST', body: formData });
const data = await resp.json();
if (data.status === 'added') {
saveStatus.textContent = `Saved "${data.label}" to catalog`;
saveStatus.className = 'upload-status success';
resetForm();
loadCatalog();
} else {
throw new Error(data.message || 'Unexpected response');
}
} catch (err) {
saveStatus.textContent = `Error: ${err.message}`;
saveStatus.className = 'upload-status error';
saveBtn.disabled = false;
}
});
function resetForm() {
selectedFile = null;
previewData = null;
fileInput.value = '';
labelInput.value = '';
priceInput.value = '';
urlInput.value = '';
dropZone.classList.remove('has-file');
dropText.style.display = '';
dropPreview.style.display = 'none';
dropPreview.src = '';
extractBtn.disabled = true;
saveBtn.disabled = true;
extractionResult.classList.remove('visible');
extractStatus.textContent = '';
saveStatus.textContent = '';
}
// ── Catalog list ──
async function loadCatalog() {
try {
const resp = await fetch('/catalog/list?limit=200');
const data = await resp.json();
catalogCount.textContent = `${data.items.length} item(s)`;
if (data.items.length === 0) {
catalogGrid.innerHTML = '<div class="catalog-empty">No items in catalog yet. Upload some products to get started.</div>';
return;
}
catalogGrid.innerHTML = '';
data.items.forEach(item => {
const card = document.createElement('div');
card.className = 'catalog-card';
const origSrc = item.original_thumbnail
? `data:image/jpeg;base64,${item.original_thumbnail}` : '';
const extSrc = item.extracted_thumbnail
? `data:image/jpeg;base64,${item.extracted_thumbnail}` : '';
card.innerHTML = `
<div class="catalog-card-images">
${origSrc
? `<img class="original" src="${origSrc}" alt="${item.label}" title="Original">`
: `<div style="aspect-ratio:1;background:#222;"></div>`
}
${extSrc
? `<img class="extracted" src="${extSrc}" alt="${item.label} (extracted)" title="Extracted">`
: `<div style="aspect-ratio:1;background:#222;"></div>`
}
</div>
<div class="catalog-card-body">
<div class="catalog-card-label" title="${item.label}">${item.label}</div>
<div class="catalog-card-meta">
${item.price ? `<span class="catalog-card-price">$${item.price}</span>` : '<span></span>'}
<button class="btn btn-danger" onclick="deleteItem('${item.id}')">Delete</button>
</div>
</div>
`;
catalogGrid.appendChild(card);
});
} catch (err) {
catalogGrid.innerHTML = `<div class="catalog-empty">Error loading catalog: ${err.message}</div>`;
}
}
async function deleteItem(id) {
if (!confirm('Delete this item from the catalog?')) return;
try {
await fetch(`/catalog/${id}`, { method: 'DELETE' });
loadCatalog();
} catch (err) {
alert(`Error deleting: ${err.message}`);
}
}
// initial load
loadCatalog();
</script>
</body>
</html>