wardrobe-us / src /ui /index.html
Ox1's picture
fix(ui): restore garment images and descriptions in Ask chat
74e743a
Raw
History Blame Contribute Delete
23.3 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wardrobe AI</title>
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@annotorious/annotorious@3.8.6/dist/annotorious.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<script type="module">
import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
import { createImageAnnotator } from "https://cdn.jsdelivr.net/npm/@annotorious/annotorious@3.8.6/+esm";
window.gradioClient = null;
window.annotoriousFactory = createImageAnnotator;
Client.connect(window.location.origin).then(c => { window.gradioClient = c; });
</script>
</head>
<body>
<div class="app" x-data="wardrobe()" x-init="init()">
<header>
<h1>Wardrobe AI</h1>
<p>Your clothes, organized. Upload photos, get outfit ideas, ask anything.</p>
</header>
<nav>
<button :class="{ active: tab === 'wardrobe' }" @click="tab = 'wardrobe'; loadWardrobe()">My Wardrobe</button>
<button :class="{ active: tab === 'add' }" @click="tab = 'add'">Add Clothes</button>
<button :class="{ active: tab === 'outfits' }" @click="tab = 'outfits'">Get Dressed</button>
<button :class="{ active: tab === 'ask' }" @click="tab = 'ask'; loadWardrobe()">Ask</button>
</nav>
<!-- My Wardrobe -->
<section x-show="tab === 'wardrobe'">
<h2>My Wardrobe <span class="count" x-text="garments.length ? `(${garments.length})` : ''"></span></h2>
<div class="garment-grid" x-show="garments.length > 0">
<template x-for="g in garments" :key="g.id">
<div class="garment-card" @click="selectGarment(g)">
<img :src="g.image_url" :alt="g.type" loading="lazy">
<div class="info">
<div class="type" x-text="g.type || 'garment'"></div>
<div class="color" x-text="g.color || ''"></div>
</div>
</div>
</template>
</div>
<div class="empty-state" x-show="garments.length === 0 && !loading">
<p>Your wardrobe is empty.</p>
<button class="btn btn-primary" @click="tab = 'add'">Add your first clothes</button>
</div>
</section>
<!-- Garment Detail Overlay -->
<div class="detail-overlay" x-show="selectedGarment" @click.self="closeDetail()" @keydown.escape.window="closeDetail()">
<div class="detail-panel" x-show="selectedGarment">
<button class="detail-close" @click="closeDetail()" aria-label="Close"></button>
<div class="detail-image">
<img :src="selectedGarment?.image_url" :alt="selectedGarment?.type" loading="lazy">
</div>
<div class="detail-info">
<h3 class="detail-title" x-text="selectedGarment ? `${selectedGarment.color} ${selectedGarment.type}` : ''"></h3>
<p class="detail-description" x-show="selectedGarment?.description" x-text="selectedGarment?.description"></p>
<div class="attr-list">
<div class="attr-row">
<span class="attr-key">Type</span>
<span class="attr-val" x-text="selectedGarment?.type || '—'"></span>
</div>
<div class="attr-row">
<span class="attr-key">Color</span>
<span class="attr-val" x-text="selectedGarment?.color || '—'"></span>
</div>
<div class="attr-row">
<span class="attr-key">Material</span>
<span class="attr-val" x-text="selectedGarment?.material || '—'"></span>
</div>
<div class="attr-row">
<span class="attr-key">Pattern</span>
<span class="attr-val" x-text="selectedGarment?.pattern || '—'"></span>
</div>
<div class="attr-row">
<span class="attr-key">Season</span>
<span class="attr-val" x-text="selectedGarment?.season || '—'"></span>
</div>
<div class="attr-row">
<span class="attr-key">Formality</span>
<span class="attr-val" x-text="selectedGarment?.formality || '—'"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Add Clothes -->
<section x-show="tab === 'add'">
<h2>Add Clothes</h2>
<div class="upload-area"
@click="$refs.fileInput.click()"
@dragover.prevent="dragover = true"
@dragleave="dragover = false"
@drop.prevent="dragover = false; handleFile($event.dataTransfer.files[0])"
:class="{ dragover }">
<div class="icon">+</div>
<p>Tap to upload a photo of your clothes</p>
<p style="font-size:0.8rem; margin-top:0.5rem; color:#9ca3af">or drag and drop an image here</p>
<input type="file" accept="image/*" x-ref="fileInput" @change="handleFile($refs.fileInput.files[0])" style="display:none">
</div>
<div class="status" :class="uploadStatus.type" x-show="uploadStatus.message" x-text="uploadStatus.message"></div>
<!-- Bounding box editor (shown after upload) -->
<div class="editor-area" x-show="editorActive">
<p class="editor-hint">
<strong x-text="editorAutoBoxCount > 0 ? `${editorAutoBoxCount} garment${editorAutoBoxCount > 1 ? 's' : ''} auto-detected.` : 'No garments auto-detected.'"></strong>
Draw rectangles over any missing ones, adjust or delete existing boxes, then click Analyse.
</p>
<div class="editor-canvas-wrap">
<img id="annoImage" :src="editorImageUrl" alt="Garment photo">
</div>
<div class="editor-actions">
<button class="btn btn-primary" @click="analyzeBoxes()" :disabled="analyzing || editorBoxCount === 0">
<span x-text="analyzing ? 'Analysing…' : `Analyse ${editorBoxCount} garment${editorBoxCount !== 1 ? 's' : ''}`"></span>
</button>
<button class="btn btn-secondary" @click="cancelEditor()" :disabled="analyzing">Cancel</button>
</div>
<div class="status" :class="analyzeStatus.type" x-show="analyzeStatus.message" x-text="analyzeStatus.message"></div>
</div>
<div style="margin-top:2rem">
<h2>Or load a sample dataset</h2>
<div class="dataset-row">
<select x-model="datasetKey">
<option value="second-hand">Second-hand (individual garments)</option>
<option value="fashion-1k">Fashion-1K (multi-garment, slower)</option>
</select>
<button class="btn btn-secondary" @click="loadDataset()" :disabled="datasetLoading">
<span x-text="datasetLoading ? 'Loading...' : 'Load Dataset'"></span>
</button>
</div>
<div class="status" :class="datasetStatus.type" x-show="datasetStatus.message" x-text="datasetStatus.message"></div>
</div>
</section>
<!-- Get Dressed -->
<section x-show="tab === 'outfits'">
<h2>Get Dressed</h2>
<div class="context-input">
<input type="text" x-model="context" placeholder="What's the occasion? (e.g. dinner with friends, casual Friday...)" @keydown.enter="generateOutfits()">
<button class="btn btn-primary" @click="generateOutfits()" :disabled="outfitsLoading">
<span x-text="outfitsLoading ? 'Generating...' : 'Generate'"></span>
</button>
</div>
<div class="outfit-grid" x-show="outfits.length > 0">
<template x-for="combo in outfits" :key="combo.id">
<div class="outfit-card" :style="combo.rated ? 'opacity:0.4; transform:scale(0.97)' : ''">
<div class="outfit-images">
<img :src="combo.top.image_url" :alt="combo.top.type" loading="lazy">
<img :src="combo.bottom.image_url" :alt="combo.bottom.type" loading="lazy">
</div>
<div class="outfit-info">
<span class="label" x-text="`${combo.top.color} ${combo.top.type} + ${combo.bottom.color} ${combo.bottom.type}`"></span>
<div class="outfit-actions">
<button class="dislike" @click="rateOutfit(combo, false)">-</button>
<button class="like" @click="rateOutfit(combo, true)">+</button>
</div>
</div>
</div>
</template>
</div>
<div class="empty-state" x-show="outfits.length === 0 && !outfitsLoading">
<p>No combinations yet. Add at least one top and one bottom to your wardrobe, then hit Generate.</p>
</div>
</section>
<!-- Ask -->
<section x-show="tab === 'ask'">
<h2>Ask about your wardrobe</h2>
<div class="chat-container">
<div class="chat-messages" x-ref="chatMessages">
<template x-for="(msg, i) in messages" :key="i">
<div class="chat-message" :class="msg.role">
<div class="bubble" x-html="msg.role === 'assistant' ? renderMessage(msg.text) : escapeHtml(msg.text)"></div>
</div>
</template>
</div>
<div class="chat-input">
<input type="text" x-model="question" placeholder="What should I wear for..." @keydown.enter="askQuestion()">
<button @click="askQuestion()" :disabled="asking">Send</button>
</div>
</div>
</section>
<!-- Log Dock: fixed bottom panel shown during dataset loading -->
<div class="log-dock" x-show="datasetLog.length > 0" :class="{ minimized: logMinimized }">
<div class="log-dock-header" @click="logMinimized = !logMinimized">
<span class="log-dock-title" x-text="datasetLoading ? `Loading dataset — ${datasetCount}/50` : `Done — ${datasetCount} garments added`"></span>
<div class="log-dock-actions">
<button class="log-dock-btn" :title="logMinimized ? 'Expand' : 'Minimize'" @click.stop="logMinimized = !logMinimized" x-text="logMinimized ? '▲' : '▼'"></button>
<button class="log-dock-btn" title="Close" @click.stop="datasetLog = []"></button>
</div>
</div>
<div class="log-dock-body" x-show="!logMinimized">
<div class="log-console" x-ref="logConsole">
<template x-for="(line, i) in datasetLog" :key="i">
<div class="log-line" x-text="line"></div>
</template>
</div>
<div class="dataset-previews" x-show="datasetPreviews.length > 0">
<template x-for="(url, i) in datasetPreviews" :key="i">
<img :src="url" :alt="`Garment ${i + 1}`" loading="lazy">
</template>
</div>
</div>
</div>
</div>
<script>
function wardrobe() {
return {
tab: 'wardrobe',
loading: false,
garments: [],
garmentsById: {},
dragover: false,
// Upload
uploadStatus: { type: '', message: '' },
// Dataset
datasetKey: 'second-hand',
datasetLoading: false,
datasetStatus: { type: '', message: '' },
datasetLog: [],
datasetPreviews: [],
datasetCount: 0,
logMinimized: false,
// Garment detail
selectedGarment: null,
// Bounding-box editor
editorActive: false,
editorImageUrl: '',
editorToken: '',
editorBoxCount: 0,
editorAutoBoxCount: 0,
analyzing: false,
analyzeStatus: { type: '', message: '' },
anno: null,
// Outfits
context: '',
outfits: [],
outfitsLoading: false,
// Chat
messages: [{ role: 'assistant', text: 'Hi! Ask me anything about your wardrobe — outfit ideas, what to wear for an event, or care tips.' }],
question: '',
asking: false,
async init() {
await this.waitForClient();
await this.loadWardrobe();
},
async waitForClient() {
while (!window.gradioClient) {
await new Promise(r => setTimeout(r, 100));
}
},
async loadWardrobe() {
this.loading = true;
try {
const result = await window.gradioClient.predict("/get_wardrobe", {});
this.garments = result.data[0].garments || [];
this.garmentsById = Object.fromEntries(this.garments.map(g => [g.id, g]));
} catch (e) { console.error(e); }
this.loading = false;
},
async handleFile(file) {
if (!file) return;
this.uploadStatus = { type: 'loading', message: 'Detecting garments…' };
this.editorActive = false;
this.analyzeStatus = { type: '', message: '' };
try {
const { handle_file } = await import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js");
const result = await window.gradioClient.predict("/prepare_image", { image_path: handle_file(file) });
const data = result.data[0];
if (data.error) {
this.uploadStatus = { type: 'error', message: data.error };
return;
}
this.uploadStatus = { type: '', message: '' };
this.editorToken = data.token;
this.editorImageUrl = data.image_url;
this.editorAutoBoxCount = data.boxes.length;
this.editorBoxCount = data.boxes.length;
this.editorActive = true;
this.$nextTick(() => this.initAnnotator(data.boxes, data.width, data.height));
} catch (e) {
this.uploadStatus = { type: 'error', message: 'Something went wrong. Please try again.' };
console.error(e);
}
},
async loadDataset() {
this.datasetLoading = true;
this.datasetLog = [];
this.datasetPreviews = [];
this.datasetCount = 0;
this.logMinimized = false;
this.datasetStatus = { type: 'loading', message: 'Starting...' };
try {
const job = window.gradioClient.submit("/load_dataset", { dataset_key: this.datasetKey });
for await (const event of job) {
if (event.type !== "data") continue;
const data = event.data[0];
if (!data) continue;
if (Array.isArray(data.log) && data.log.length) {
this.datasetLog = data.log;
this.$nextTick(() => {
const el = this.$refs.logConsole;
if (el) el.scrollTop = el.scrollHeight;
});
}
if (typeof data.count === "number") this.datasetCount = data.count;
if (data.preview_url && !this.datasetPreviews.includes(data.preview_url)) {
this.datasetPreviews.push(data.preview_url);
}
if (data.done) {
if (data.error) {
this.datasetStatus = { type: 'error', message: data.error };
} else {
this.datasetStatus = { type: 'success', message: `Loaded ${data.count} garments into your wardrobe!` };
await this.loadWardrobe();
}
break;
} else {
this.datasetStatus = {
type: 'loading',
message: data.count > 0 ? `Processing garments... ${data.count}/50` : 'Downloading dataset...',
};
}
}
} catch (e) {
this.datasetStatus = { type: 'error', message: 'Failed to load dataset.' };
console.error(e);
}
this.datasetLoading = false;
},
async generateOutfits() {
this.outfitsLoading = true;
this.outfits = [];
try {
const result = await window.gradioClient.predict("/get_combinations", { context: this.context });
const data = result.data[0];
this.outfits = (data.combinations || []).map(c => ({ ...c, rated: false }));
} catch (e) { console.error(e); }
this.outfitsLoading = false;
},
async rateOutfit(combo, liked) {
combo.rated = true;
try {
await window.gradioClient.predict("/rate_outfit", {
top_id: combo.top.id,
bottom_id: combo.bottom.id,
liked,
});
} catch (e) { console.error(e); }
},
async askQuestion() {
const q = this.question.trim();
if (!q) return;
this.messages.push({ role: 'user', text: q });
this.question = '';
this.asking = true;
this.$nextTick(() => {
this.$refs.chatMessages.scrollTop = this.$refs.chatMessages.scrollHeight;
});
try {
if (!this.garments.length) await this.loadWardrobe();
const result = await window.gradioClient.predict("/ask_question", { question: q });
this.messages.push({ role: 'assistant', text: result.data[0] });
} catch (e) {
this.messages.push({ role: 'assistant', text: 'Sorry, something went wrong. Please try again.' });
console.error(e);
}
this.asking = false;
this.$nextTick(() => {
this.$refs.chatMessages.scrollTop = this.$refs.chatMessages.scrollHeight;
});
},
normalizeGarmentId(num) {
const n = parseInt(num, 10);
return Number.isNaN(n) ? `garment_${num}` : `garment_${String(n).padStart(3, '0')}`;
},
buildGarmentChip(id) {
const g = this.garmentsById[id];
if (!g) return `<span class="garment-ref">${id}</span>`;
const label = g.description
? (g.description.length > 72 ? g.description.slice(0, 72) + '…' : g.description)
: `${g.color} ${g.type}`;
return `<span class="garment-inline">
<img src="${g.image_url}" alt="${this.escapeHtml(g.type)}">
<span>${this.escapeHtml(label)}</span>
</span>`;
},
renderMessage(text) {
const chips = [];
const toPlaceholder = (id) => {
const idx = chips.length;
chips.push(this.buildGarmentChip(id));
return `GARMENTCHIP${idx}END`;
};
let raw = text.replace(/\[garment_(\d+)\]/gi, (_, num) => toPlaceholder(this.normalizeGarmentId(num)));
raw = raw.replace(/\bgarment_(\d+)\b/gi, (_, num) => toPlaceholder(this.normalizeGarmentId(num)));
let html = marked.parse(raw);
chips.forEach((chip, i) => {
html = html.replaceAll(`GARMENTCHIP${i}END`, chip);
html = html.replaceAll(`<p>GARMENTCHIP${i}END</p>`, `<p>${chip}</p>`);
});
return html;
},
escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
initAnnotator(boxes, imgW, imgH) {
// Destroy any previous instance
if (this.anno) {
try { this.anno.destroy(); } catch (_) {}
this.anno = null;
}
const factory = window.annotoriousFactory;
if (!factory) {
console.error('Annotorious not loaded yet');
return;
}
const el = document.getElementById('annoImage');
if (!el) return;
const start = () => {
this.anno = factory(el, {
drawingEnabled: true,
style: { fill: '#3b82f6', fillOpacity: 0.15, stroke: '#3b82f6', strokeWidth: 2 },
});
this.anno.setDrawingTool('rectangle');
// Load auto-detected boxes using Annotorious v3 internal format
if (boxes.length > 0) {
const annotations = boxes.map((b, i) => ({
id: `auto-${i}`,
bodies: [],
target: {
selector: {
type: 'RECTANGLE',
geometry: {
x: b.x, y: b.y, w: b.w, h: b.h,
bounds: { minX: b.x, minY: b.y, maxX: b.x + b.w, maxY: b.y + b.h },
},
},
},
}));
this.anno.setAnnotations(annotations);
}
// Keep editorBoxCount in sync
const updateCount = () => {
this.editorBoxCount = this.anno.getAnnotations().length;
};
this.anno.on('createAnnotation', updateCount);
this.anno.on('deleteAnnotation', updateCount);
this.anno.on('updateAnnotation', updateCount);
};
// Wait for the image to be loaded before creating the annotator
if (el.complete && el.naturalWidth > 0) {
start();
} else {
el.addEventListener('load', start, { once: true });
}
},
async analyzeBoxes() {
if (!this.anno || this.editorBoxCount === 0) return;
this.analyzing = true;
this.analyzeStatus = { type: 'loading', message: 'Extracting garment attributes…' };
// Map Annotorious v3 RECTANGLE geometry to {x,y,w,h} pixel boxes
const annotations = this.anno.getAnnotations();
const boxes = annotations.map(ann => {
const geom = ann.target?.selector?.geometry;
if (geom && geom.w > 0 && geom.h > 0) {
return { x: Math.round(geom.x), y: Math.round(geom.y), w: Math.round(geom.w), h: Math.round(geom.h) };
}
return null;
}).filter(Boolean);
if (boxes.length === 0) {
this.analyzeStatus = { type: 'error', message: 'No valid boxes to analyze.' };
this.analyzing = false;
return;
}
try {
const result = await window.gradioClient.predict("/analyze_boxes", {
token: this.editorToken,
boxes: JSON.stringify(boxes),
});
const data = result.data[0];
if (data.error) {
this.analyzeStatus = { type: 'error', message: data.error };
} else {
this.analyzeStatus = { type: 'success', message: `Added ${data.count} garment${data.count !== 1 ? 's' : ''} to your wardrobe!` };
await this.loadWardrobe();
this.cancelEditor();
}
} catch (e) {
this.analyzeStatus = { type: 'error', message: 'Something went wrong. Please try again.' };
console.error(e);
}
this.analyzing = false;
},
cancelEditor() {
if (this.anno) {
try { this.anno.destroy(); } catch (_) {}
this.anno = null;
}
this.editorActive = false;
this.editorToken = '';
this.editorImageUrl = '';
this.editorBoxCount = 0;
this.editorAutoBoxCount = 0;
},
selectGarment(g) {
this.selectedGarment = g;
},
closeDetail() {
this.selectedGarment = null;
},
};
}
</script>
</body>
</html>