Spaces:
Runtime error
Runtime error
feat(ui): garment detail panel and real-time load logs
Browse files- Clicking a wardrobe card opens an overlay with the full garment
attributes (type, color, material, pattern, season, formality) and
the AI-generated natural-language description.
- api_load_dataset converted from a blocking call to a streaming
generator that yields progress per garment (count, log lines,
preview URL).
- loadDataset() in the frontend now uses gradioClient.submit() to
iterate SSE events; a live log console (dark monospace) and a row
of thumbnail previews update in real time as each garment is
processed.
- Wardrobe is refreshed automatically once loading completes.
Co-authored-by: Cursor <cursoragent@cursor.com>
- app.py +36 -6
- src/ui/index.html +104 -8
- src/ui/style.css +186 -0
app.py
CHANGED
|
@@ -863,7 +863,12 @@ def _build_custom_server():
|
|
| 863 |
return ask(question.strip())
|
| 864 |
|
| 865 |
@server.api(name="load_dataset")
|
| 866 |
-
def api_load_dataset(dataset_key: str)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 867 |
from datasets import load_dataset as hf_load
|
| 868 |
|
| 869 |
ds_configs = {
|
|
@@ -872,10 +877,22 @@ def _build_custom_server():
|
|
| 872 |
}
|
| 873 |
config = ds_configs.get(dataset_key)
|
| 874 |
if not config:
|
| 875 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 876 |
|
| 877 |
-
|
| 878 |
-
|
|
|
|
|
|
|
| 879 |
step = max(1, len(ds) // (target * 2))
|
| 880 |
indices = list(range(0, len(ds), step))[:target * 3]
|
| 881 |
garments_processed: list[tuple[dict, bytes]] = []
|
|
@@ -910,6 +927,11 @@ def _build_custom_server():
|
|
| 910 |
garment = _extract_single_garment(crop_bytes)
|
| 911 |
if garment:
|
| 912 |
garments_processed.append((garment, crop_bytes))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 913 |
else:
|
| 914 |
if max(image.size) > 512:
|
| 915 |
image.thumbnail((512, 512), Image.LANCZOS)
|
|
@@ -919,11 +941,19 @@ def _build_custom_server():
|
|
| 919 |
garment = _extract_single_garment(crop_bytes)
|
| 920 |
if garment:
|
| 921 |
garments_processed.append((garment, crop_bytes))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 922 |
|
| 923 |
if not garments_processed:
|
| 924 |
-
|
|
|
|
|
|
|
| 925 |
added = add_garments(garments_processed)
|
| 926 |
-
|
|
|
|
| 927 |
|
| 928 |
@server.get("/")
|
| 929 |
async def homepage():
|
|
|
|
| 863 |
return ask(question.strip())
|
| 864 |
|
| 865 |
@server.api(name="load_dataset")
|
| 866 |
+
def api_load_dataset(dataset_key: str):
|
| 867 |
+
"""Stream dataset processing progress as each garment is analyzed.
|
| 868 |
+
|
| 869 |
+
Yields progress dicts with: done, count, log[], preview_url.
|
| 870 |
+
The final yield has done=True with the total count.
|
| 871 |
+
"""
|
| 872 |
from datasets import load_dataset as hf_load
|
| 873 |
|
| 874 |
ds_configs = {
|
|
|
|
| 877 |
}
|
| 878 |
config = ds_configs.get(dataset_key)
|
| 879 |
if not config:
|
| 880 |
+
yield {"done": True, "error": "Unknown dataset", "count": 0, "log": ["Error: dataset not recognized."], "preview_url": None}
|
| 881 |
+
return
|
| 882 |
+
|
| 883 |
+
log_lines: list[str] = [f"Downloading {config['hf_id']}..."]
|
| 884 |
+
yield {"done": False, "count": 0, "log": log_lines[:], "preview_url": None}
|
| 885 |
+
|
| 886 |
+
try:
|
| 887 |
+
ds = hf_load(config["hf_id"], split="train")
|
| 888 |
+
except Exception as e:
|
| 889 |
+
yield {"done": True, "error": str(e), "count": 0, "log": [f"Error downloading dataset: {e}"], "preview_url": None}
|
| 890 |
+
return
|
| 891 |
|
| 892 |
+
log_lines.append(f"Dataset loaded: {len(ds)} images. Starting processing...")
|
| 893 |
+
yield {"done": False, "count": 0, "log": log_lines[:], "preview_url": None}
|
| 894 |
+
|
| 895 |
+
target = TARGET_GARMENTS
|
| 896 |
step = max(1, len(ds) // (target * 2))
|
| 897 |
indices = list(range(0, len(ds), step))[:target * 3]
|
| 898 |
garments_processed: list[tuple[dict, bytes]] = []
|
|
|
|
| 927 |
garment = _extract_single_garment(crop_bytes)
|
| 928 |
if garment:
|
| 929 |
garments_processed.append((garment, crop_bytes))
|
| 930 |
+
n = len(garments_processed)
|
| 931 |
+
preview_path = _save_temp_preview(crop_bytes, n)
|
| 932 |
+
preview_url = f"/garments/_preview_{n:03d}.jpg" if preview_path else None
|
| 933 |
+
log_lines.append(f"{n}/{target} — {garment.get('color', '?')} {garment.get('type', '?')}")
|
| 934 |
+
yield {"done": False, "count": n, "log": log_lines[-12:], "preview_url": preview_url}
|
| 935 |
else:
|
| 936 |
if max(image.size) > 512:
|
| 937 |
image.thumbnail((512, 512), Image.LANCZOS)
|
|
|
|
| 941 |
garment = _extract_single_garment(crop_bytes)
|
| 942 |
if garment:
|
| 943 |
garments_processed.append((garment, crop_bytes))
|
| 944 |
+
n = len(garments_processed)
|
| 945 |
+
preview_path = _save_temp_preview(crop_bytes, n)
|
| 946 |
+
preview_url = f"/garments/_preview_{n:03d}.jpg" if preview_path else None
|
| 947 |
+
log_lines.append(f"{n}/{target} — {garment.get('color', '?')} {garment.get('type', '?')}")
|
| 948 |
+
yield {"done": False, "count": n, "log": log_lines[-12:], "preview_url": preview_url}
|
| 949 |
|
| 950 |
if not garments_processed:
|
| 951 |
+
yield {"done": True, "error": "No garments extracted", "count": 0, "log": log_lines + ["No garments could be extracted."], "preview_url": None}
|
| 952 |
+
return
|
| 953 |
+
|
| 954 |
added = add_garments(garments_processed)
|
| 955 |
+
log_lines.append(f"Done: {len(added)} garments added to your wardrobe.")
|
| 956 |
+
yield {"done": True, "count": len(added), "log": log_lines[-12:], "preview_url": None}
|
| 957 |
|
| 958 |
@server.get("/")
|
| 959 |
async def homepage():
|
src/ui/index.html
CHANGED
|
@@ -33,7 +33,7 @@
|
|
| 33 |
|
| 34 |
<div class="garment-grid" x-show="garments.length > 0">
|
| 35 |
<template x-for="g in garments" :key="g.id">
|
| 36 |
-
<div class="garment-card">
|
| 37 |
<img :src="g.image_url" :alt="g.type" loading="lazy">
|
| 38 |
<div class="info">
|
| 39 |
<div class="type" x-text="g.type || 'garment'"></div>
|
|
@@ -49,6 +49,46 @@
|
|
| 49 |
</div>
|
| 50 |
</section>
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
<!-- Add Clothes -->
|
| 53 |
<section x-show="tab === 'add'">
|
| 54 |
<h2>Add Clothes</h2>
|
|
@@ -79,6 +119,18 @@
|
|
| 79 |
</button>
|
| 80 |
</div>
|
| 81 |
<div class="status" :class="datasetStatus.type" x-show="datasetStatus.message" x-text="datasetStatus.message"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
</div>
|
| 83 |
</section>
|
| 84 |
|
|
@@ -149,6 +201,12 @@
|
|
| 149 |
datasetKey: 'second-hand',
|
| 150 |
datasetLoading: false,
|
| 151 |
datasetStatus: { type: '', message: '' },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
// Outfits
|
| 154 |
context: '',
|
|
@@ -200,14 +258,44 @@
|
|
| 200 |
|
| 201 |
async loadDataset() {
|
| 202 |
this.datasetLoading = true;
|
| 203 |
-
this.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
try {
|
| 205 |
-
const
|
| 206 |
-
const
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
}
|
| 212 |
} catch (e) {
|
| 213 |
this.datasetStatus = { type: 'error', message: 'Failed to load dataset.' };
|
|
@@ -282,6 +370,14 @@
|
|
| 282 |
div.textContent = str;
|
| 283 |
return div.innerHTML;
|
| 284 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
};
|
| 286 |
}
|
| 287 |
</script>
|
|
|
|
| 33 |
|
| 34 |
<div class="garment-grid" x-show="garments.length > 0">
|
| 35 |
<template x-for="g in garments" :key="g.id">
|
| 36 |
+
<div class="garment-card" @click="selectGarment(g)">
|
| 37 |
<img :src="g.image_url" :alt="g.type" loading="lazy">
|
| 38 |
<div class="info">
|
| 39 |
<div class="type" x-text="g.type || 'garment'"></div>
|
|
|
|
| 49 |
</div>
|
| 50 |
</section>
|
| 51 |
|
| 52 |
+
<!-- Garment Detail Overlay -->
|
| 53 |
+
<div class="detail-overlay" x-show="selectedGarment" @click.self="closeDetail()" @keydown.escape.window="closeDetail()">
|
| 54 |
+
<div class="detail-panel" x-show="selectedGarment">
|
| 55 |
+
<button class="detail-close" @click="closeDetail()" aria-label="Close">✕</button>
|
| 56 |
+
<div class="detail-image">
|
| 57 |
+
<img :src="selectedGarment?.image_url" :alt="selectedGarment?.type" loading="lazy">
|
| 58 |
+
</div>
|
| 59 |
+
<div class="detail-info">
|
| 60 |
+
<h3 class="detail-title" x-text="selectedGarment ? `${selectedGarment.color} ${selectedGarment.type}` : ''"></h3>
|
| 61 |
+
<p class="detail-description" x-show="selectedGarment?.description" x-text="selectedGarment?.description"></p>
|
| 62 |
+
<div class="attr-list">
|
| 63 |
+
<div class="attr-row">
|
| 64 |
+
<span class="attr-key">Type</span>
|
| 65 |
+
<span class="attr-val" x-text="selectedGarment?.type || '—'"></span>
|
| 66 |
+
</div>
|
| 67 |
+
<div class="attr-row">
|
| 68 |
+
<span class="attr-key">Color</span>
|
| 69 |
+
<span class="attr-val" x-text="selectedGarment?.color || '—'"></span>
|
| 70 |
+
</div>
|
| 71 |
+
<div class="attr-row">
|
| 72 |
+
<span class="attr-key">Material</span>
|
| 73 |
+
<span class="attr-val" x-text="selectedGarment?.material || '—'"></span>
|
| 74 |
+
</div>
|
| 75 |
+
<div class="attr-row">
|
| 76 |
+
<span class="attr-key">Pattern</span>
|
| 77 |
+
<span class="attr-val" x-text="selectedGarment?.pattern || '—'"></span>
|
| 78 |
+
</div>
|
| 79 |
+
<div class="attr-row">
|
| 80 |
+
<span class="attr-key">Season</span>
|
| 81 |
+
<span class="attr-val" x-text="selectedGarment?.season || '—'"></span>
|
| 82 |
+
</div>
|
| 83 |
+
<div class="attr-row">
|
| 84 |
+
<span class="attr-key">Formality</span>
|
| 85 |
+
<span class="attr-val" x-text="selectedGarment?.formality || '—'"></span>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
<!-- Add Clothes -->
|
| 93 |
<section x-show="tab === 'add'">
|
| 94 |
<h2>Add Clothes</h2>
|
|
|
|
| 119 |
</button>
|
| 120 |
</div>
|
| 121 |
<div class="status" :class="datasetStatus.type" x-show="datasetStatus.message" x-text="datasetStatus.message"></div>
|
| 122 |
+
|
| 123 |
+
<div class="log-console" x-show="datasetLog.length > 0" x-ref="logConsole">
|
| 124 |
+
<template x-for="(line, i) in datasetLog" :key="i">
|
| 125 |
+
<div class="log-line" x-text="line"></div>
|
| 126 |
+
</template>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<div class="dataset-previews" x-show="datasetPreviews.length > 0">
|
| 130 |
+
<template x-for="(url, i) in datasetPreviews" :key="i">
|
| 131 |
+
<img :src="url" :alt="`Garment ${i + 1}`" loading="lazy">
|
| 132 |
+
</template>
|
| 133 |
+
</div>
|
| 134 |
</div>
|
| 135 |
</section>
|
| 136 |
|
|
|
|
| 201 |
datasetKey: 'second-hand',
|
| 202 |
datasetLoading: false,
|
| 203 |
datasetStatus: { type: '', message: '' },
|
| 204 |
+
datasetLog: [],
|
| 205 |
+
datasetPreviews: [],
|
| 206 |
+
datasetCount: 0,
|
| 207 |
+
|
| 208 |
+
// Garment detail
|
| 209 |
+
selectedGarment: null,
|
| 210 |
|
| 211 |
// Outfits
|
| 212 |
context: '',
|
|
|
|
| 258 |
|
| 259 |
async loadDataset() {
|
| 260 |
this.datasetLoading = true;
|
| 261 |
+
this.datasetLog = [];
|
| 262 |
+
this.datasetPreviews = [];
|
| 263 |
+
this.datasetCount = 0;
|
| 264 |
+
this.datasetStatus = { type: 'loading', message: 'Starting...' };
|
| 265 |
+
|
| 266 |
try {
|
| 267 |
+
const job = window.gradioClient.submit("/load_dataset", { dataset_key: this.datasetKey });
|
| 268 |
+
for await (const event of job) {
|
| 269 |
+
if (event.type !== "data") continue;
|
| 270 |
+
const data = event.data[0];
|
| 271 |
+
if (!data) continue;
|
| 272 |
+
|
| 273 |
+
if (Array.isArray(data.log) && data.log.length) {
|
| 274 |
+
this.datasetLog = data.log;
|
| 275 |
+
this.$nextTick(() => {
|
| 276 |
+
const el = this.$refs.logConsole;
|
| 277 |
+
if (el) el.scrollTop = el.scrollHeight;
|
| 278 |
+
});
|
| 279 |
+
}
|
| 280 |
+
if (typeof data.count === "number") this.datasetCount = data.count;
|
| 281 |
+
if (data.preview_url && !this.datasetPreviews.includes(data.preview_url)) {
|
| 282 |
+
this.datasetPreviews.push(data.preview_url);
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
if (data.done) {
|
| 286 |
+
if (data.error) {
|
| 287 |
+
this.datasetStatus = { type: 'error', message: data.error };
|
| 288 |
+
} else {
|
| 289 |
+
this.datasetStatus = { type: 'success', message: `Loaded ${data.count} garments into your wardrobe!` };
|
| 290 |
+
await this.loadWardrobe();
|
| 291 |
+
}
|
| 292 |
+
break;
|
| 293 |
+
} else {
|
| 294 |
+
this.datasetStatus = {
|
| 295 |
+
type: 'loading',
|
| 296 |
+
message: data.count > 0 ? `Processing garments... ${data.count}/50` : 'Downloading dataset...',
|
| 297 |
+
};
|
| 298 |
+
}
|
| 299 |
}
|
| 300 |
} catch (e) {
|
| 301 |
this.datasetStatus = { type: 'error', message: 'Failed to load dataset.' };
|
|
|
|
| 370 |
div.textContent = str;
|
| 371 |
return div.innerHTML;
|
| 372 |
},
|
| 373 |
+
|
| 374 |
+
selectGarment(g) {
|
| 375 |
+
this.selectedGarment = g;
|
| 376 |
+
},
|
| 377 |
+
|
| 378 |
+
closeDetail() {
|
| 379 |
+
this.selectedGarment = null;
|
| 380 |
+
},
|
| 381 |
};
|
| 382 |
}
|
| 383 |
</script>
|
src/ui/style.css
CHANGED
|
@@ -137,6 +137,10 @@ section h2 .count {
|
|
| 137 |
transition: transform var(--transition), box-shadow var(--transition);
|
| 138 |
}
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
.garment-card:hover {
|
| 141 |
transform: translateY(-2px);
|
| 142 |
box-shadow: var(--shadow-lg);
|
|
@@ -509,6 +513,165 @@ nav button.active {
|
|
| 509 |
display: none !important;
|
| 510 |
}
|
| 511 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
@media (max-width: 640px) {
|
| 513 |
.app {
|
| 514 |
padding: 1.5rem 1rem 3rem;
|
|
@@ -525,4 +688,27 @@ nav button.active {
|
|
| 525 |
.context-input {
|
| 526 |
flex-direction: column;
|
| 527 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
}
|
|
|
|
| 137 |
transition: transform var(--transition), box-shadow var(--transition);
|
| 138 |
}
|
| 139 |
|
| 140 |
+
.garment-card {
|
| 141 |
+
cursor: pointer;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
.garment-card:hover {
|
| 145 |
transform: translateY(-2px);
|
| 146 |
box-shadow: var(--shadow-lg);
|
|
|
|
| 513 |
display: none !important;
|
| 514 |
}
|
| 515 |
|
| 516 |
+
/* Garment detail overlay */
|
| 517 |
+
.detail-overlay {
|
| 518 |
+
position: fixed;
|
| 519 |
+
inset: 0;
|
| 520 |
+
background: rgba(0, 0, 0, 0.5);
|
| 521 |
+
z-index: 100;
|
| 522 |
+
display: flex;
|
| 523 |
+
align-items: center;
|
| 524 |
+
justify-content: center;
|
| 525 |
+
padding: 1.5rem;
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
.detail-panel {
|
| 529 |
+
background: var(--surface);
|
| 530 |
+
border-radius: var(--radius);
|
| 531 |
+
box-shadow: var(--shadow-lg);
|
| 532 |
+
max-width: 600px;
|
| 533 |
+
width: 100%;
|
| 534 |
+
max-height: 90vh;
|
| 535 |
+
overflow-y: auto;
|
| 536 |
+
display: grid;
|
| 537 |
+
grid-template-columns: 1fr 1fr;
|
| 538 |
+
grid-template-rows: auto 1fr;
|
| 539 |
+
position: relative;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
.detail-close {
|
| 543 |
+
position: absolute;
|
| 544 |
+
top: 0.75rem;
|
| 545 |
+
right: 0.75rem;
|
| 546 |
+
background: none;
|
| 547 |
+
border: 1px solid var(--border);
|
| 548 |
+
border-radius: 50%;
|
| 549 |
+
width: 32px;
|
| 550 |
+
height: 32px;
|
| 551 |
+
cursor: pointer;
|
| 552 |
+
font-size: 0.85rem;
|
| 553 |
+
color: var(--text-muted);
|
| 554 |
+
display: flex;
|
| 555 |
+
align-items: center;
|
| 556 |
+
justify-content: center;
|
| 557 |
+
transition: all var(--transition);
|
| 558 |
+
z-index: 1;
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
.detail-close:hover {
|
| 562 |
+
background: #f3f4f6;
|
| 563 |
+
color: var(--text);
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
.detail-image {
|
| 567 |
+
grid-column: 1;
|
| 568 |
+
grid-row: 1 / 3;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
.detail-image img {
|
| 572 |
+
width: 100%;
|
| 573 |
+
height: 100%;
|
| 574 |
+
object-fit: cover;
|
| 575 |
+
border-radius: var(--radius) 0 0 var(--radius);
|
| 576 |
+
background: #f3f4f6;
|
| 577 |
+
display: block;
|
| 578 |
+
min-height: 260px;
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
.detail-info {
|
| 582 |
+
grid-column: 2;
|
| 583 |
+
grid-row: 1 / 3;
|
| 584 |
+
padding: 1.5rem 1.5rem 1.5rem 1.25rem;
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
.detail-title {
|
| 588 |
+
font-size: 1rem;
|
| 589 |
+
font-weight: 600;
|
| 590 |
+
text-transform: capitalize;
|
| 591 |
+
margin-bottom: 0.5rem;
|
| 592 |
+
padding-right: 2rem;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
.detail-description {
|
| 596 |
+
font-size: 0.82rem;
|
| 597 |
+
color: var(--text-muted);
|
| 598 |
+
line-height: 1.5;
|
| 599 |
+
margin-bottom: 1rem;
|
| 600 |
+
font-style: italic;
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
.attr-list {
|
| 604 |
+
display: flex;
|
| 605 |
+
flex-direction: column;
|
| 606 |
+
gap: 0.4rem;
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
.attr-row {
|
| 610 |
+
display: flex;
|
| 611 |
+
justify-content: space-between;
|
| 612 |
+
align-items: baseline;
|
| 613 |
+
font-size: 0.82rem;
|
| 614 |
+
padding: 0.35rem 0;
|
| 615 |
+
border-bottom: 1px solid var(--border);
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
.attr-row:last-child {
|
| 619 |
+
border-bottom: none;
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
.attr-key {
|
| 623 |
+
color: var(--text-muted);
|
| 624 |
+
font-weight: 500;
|
| 625 |
+
flex-shrink: 0;
|
| 626 |
+
margin-right: 0.75rem;
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
.attr-val {
|
| 630 |
+
text-transform: capitalize;
|
| 631 |
+
text-align: right;
|
| 632 |
+
font-weight: 500;
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
/* Log console */
|
| 636 |
+
.log-console {
|
| 637 |
+
background: #1e1e2e;
|
| 638 |
+
border-radius: var(--radius-sm);
|
| 639 |
+
padding: 0.75rem 1rem;
|
| 640 |
+
margin-top: 0.75rem;
|
| 641 |
+
max-height: 180px;
|
| 642 |
+
overflow-y: auto;
|
| 643 |
+
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
| 644 |
+
font-size: 0.78rem;
|
| 645 |
+
line-height: 1.6;
|
| 646 |
+
scroll-behavior: smooth;
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
.log-line {
|
| 650 |
+
color: #a6e3a1;
|
| 651 |
+
white-space: pre-wrap;
|
| 652 |
+
word-break: break-all;
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
.log-line:first-child {
|
| 656 |
+
color: #89b4fa;
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
/* Dataset previews */
|
| 660 |
+
.dataset-previews {
|
| 661 |
+
display: flex;
|
| 662 |
+
flex-wrap: wrap;
|
| 663 |
+
gap: 0.5rem;
|
| 664 |
+
margin-top: 1rem;
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
.dataset-previews img {
|
| 668 |
+
width: 72px;
|
| 669 |
+
height: 72px;
|
| 670 |
+
object-fit: cover;
|
| 671 |
+
border-radius: var(--radius-sm);
|
| 672 |
+
background: #f3f4f6;
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
@media (max-width: 640px) {
|
| 676 |
.app {
|
| 677 |
padding: 1.5rem 1rem 3rem;
|
|
|
|
| 688 |
.context-input {
|
| 689 |
flex-direction: column;
|
| 690 |
}
|
| 691 |
+
|
| 692 |
+
.detail-panel {
|
| 693 |
+
grid-template-columns: 1fr;
|
| 694 |
+
grid-template-rows: auto auto;
|
| 695 |
+
max-height: 85vh;
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
.detail-image {
|
| 699 |
+
grid-column: 1;
|
| 700 |
+
grid-row: 1;
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
.detail-image img {
|
| 704 |
+
border-radius: var(--radius) var(--radius) 0 0;
|
| 705 |
+
min-height: 200px;
|
| 706 |
+
max-height: 220px;
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
.detail-info {
|
| 710 |
+
grid-column: 1;
|
| 711 |
+
grid-row: 2;
|
| 712 |
+
padding: 1rem;
|
| 713 |
+
}
|
| 714 |
}
|