From 19c6e03c70a84f975b421f64227ece9f0270381c Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 22 Jun 2026 23:40:06 +1000 Subject: [PATCH] =?UTF-8?q?feat(search):=20clickable=20crate=20contents=20?= =?UTF-8?q?=E2=86=92=20Release=20Info=20panel=20(cover/label/format/countr?= =?UTF-8?q?y/year/genre/style=20+=20all=20copies=20+=20locate);=20crate=20?= =?UTF-8?q?genre/style=20+=20last-scanned=20+=20compress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- app/navigator_routes.py | 33 ++++++++++++++++++++++++--- site/search.html | 49 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/app/navigator_routes.py b/app/navigator_routes.py index 5fa790c..57c8546 100644 --- a/app/navigator_routes.py +++ b/app/navigator_routes.py @@ -87,15 +87,19 @@ async def crate_contents(crate_id: int, ident=Depends(require_token), db=Depends crate = (await db.execute(text(""" SELECT c.id, c.name, c.label_text, c.rack_id, c.rack_level_index::int AS level, c.slot_number::int AS slot, c.direction, c.crate_purpose, - r.name AS rack_name + c.updated_at AS last_scanned, c.updated_by, r.name AS rack_name, rt.type AS rack_type, + (SELECT count(*) FROM inventory i WHERE i.crate_id = c.id AND i.in_stock) AS items_count FROM virtual_crate c LEFT JOIN virtual_rack r ON r.id = c.rack_id + LEFT JOIN virtual_rack_type rt ON rt.id = r.rack_type_id WHERE c.id = :id """), {"id": crate_id})).mappings().first() if not crate: raise HTTPException(404, "crate not found") items = [dict(r) for r in (await db.execute(text(""" - SELECT i.sku, i.slot_number::int AS slot, coalesce(i.title, dc.title) AS title, - dc.artist, i.price::float AS price, i.condition, i.in_stock + SELECT i.sku, i.release_id, i.slot_number::int AS slot, coalesce(i.title, dc.title) AS title, + dc.artist, i.price::float AS price, i.condition, i.in_stock, + (SELECT string_agg(DISTINCT g.genre_name, ', ') FROM disc_release_genre g WHERE g.release_id = i.release_id) AS genre, + (SELECT string_agg(DISTINCT s.style_name, ', ') FROM disc_release_style s WHERE s.release_id = i.release_id) AS style FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id WHERE i.crate_id = :id ORDER BY i.slot_number NULLS LAST, i.sku @@ -103,6 +107,29 @@ async def crate_contents(crate_id: int, ident=Depends(require_token), db=Depends return {"ok": True, "crate": dict(crate), "items": items} +@router.get("/release/{release_id}") +async def release_info(release_id: int, ident=Depends(require_token), db=Depends(get_db)): + """Full release metadata (from the disc_release mirror) + every inventory copy + where each lives.""" + dr = (await db.execute(text(""" + SELECT dr.id, dr.title, dr.artists_sort AS artist, dr.country, dr.year, dr.thumb, dr.notes, dr.master_id, + (SELECT string_agg(label_name || coalesce(' ('||catno||')',''), ', ') FROM disc_release_label WHERE release_id=dr.id) AS label, + (SELECT string_agg(name || coalesce(' '||descriptions,''), ', ') FROM disc_release_format WHERE release_id=dr.id) AS format, + (SELECT string_agg(DISTINCT genre_name, ', ') FROM disc_release_genre WHERE release_id=dr.id) AS genre, + (SELECT string_agg(DISTINCT style_name, ', ') FROM disc_release_style WHERE release_id=dr.id) AS style, + (SELECT value FROM disc_release_identifier WHERE release_id=dr.id AND type ILIKE 'barcode' LIMIT 1) AS barcode + FROM disc_release dr WHERE dr.id = :r + """), {"r": release_id})).mappings().first() + items = [dict(r) for r in (await db.execute(text(""" + SELECT i.sku, i.price::float AS price, i.condition, i.sleeve_cond, i.in_stock, + i.crate_id, i.slot_number::int AS slot, c.label_text AS crate, r.name AS rack, + c.rack_level_index::int AS level + FROM inventory i LEFT JOIN virtual_crate c ON c.id = i.crate_id + LEFT JOIN virtual_rack r ON r.id = c.rack_id + WHERE i.release_id = :r ORDER BY i.in_stock DESC, i.sku + """), {"r": release_id})).mappings()] + return {"ok": True, "release": dict(dr) if dr else None, "items": items} + + # ── Reorganize — A-Z re-file planner (server scans + applies; client plans the moves) ────────── class ScanIn(BaseModel): crate_ids: list[int] diff --git a/site/search.html b/site/search.html index 61fd340..3b7e63d 100644 --- a/site/search.html +++ b/site/search.html @@ -57,7 +57,12 @@
Crate contents
-
pick a crate on the map →
+
pick a crate on the map →
+ +
+
💿 Release Info +
+
click a record in a crate, or look one up →
📚 Collections @@ -249,11 +254,47 @@ async function loadCrate(id){ const d=await get('/nav/crate/'+id); const c=d.crate; $('#ccName').innerHTML='📦 '+esc(c.label_text||c.name||('Crate '+c.id)); $('#ccName').className='pink'; - $('#ccMeta').textContent=`· ${c.rack_name||''} L${c.level??'?'} slot ${c.slot??'?'} · ${d.items.length} items`; - $('#ccList').innerHTML=d.items.length? ''+ - d.items.map(i=>``).join('')+'
SlotTitleArtistPrice
${i.slot??'—'}${esc(i.title||i.sku)}${i.in_stock?'':' sold'}${esc(i.artist||'')}${money(i.price)}
' + const ls = c.last_scanned ? ` · scanned ${String(c.last_scanned).slice(0,10)}${c.updated_by?' by '+esc(c.updated_by):''}` : ''; + $('#ccMeta').innerHTML=`· ${esc(c.rack_name||'')} L${c.level??'?'} · ${c.items_count??d.items.length} items${ls} · compress`; + $('#ccList').innerHTML=d.items.length? ''+ + d.items.map(i=>` + + + `).join('')+'
SlotTitleArtistGenre/StylePrice
${i.slot??'—'}${esc(i.title||i.sku)}${i.in_stock?'':' sold'}${esc(i.artist||'')}${esc(i.genre||'')}${i.style?' · '+esc(i.style):''}${money(i.price)}
' : '
empty crate
'; } +async function compressCrate(id){ + if(!confirm('Renumber this crate\'s slots 1..N (close gaps)?')) return; + await fetch('/nav/reorg/compress/'+id,{method:'POST',headers:hdr()}); loadCrate(id); +} +async function relLookup(){ + const q=$('#relQ').value.trim(); if(!q) return; + if(/^\d+$/.test(q)) return showRelease(parseInt(q)); + const d=await get('/nav/locate?sku='+encodeURIComponent(q)); // sku → release_id + if(d.release_id) showRelease(d.release_id); + else $('#relBody').innerHTML='
no release found for that SKU
'; +} +function relItems(items){ + return items.length ? items.map(i=>`
+
${esc(i.sku)} · ${money(i.price)}${i.in_stock?'':' sold'}
+
${esc(i.condition||'')}${i.sleeve_cond?' / '+esc(i.sleeve_cond):''} ${i.crate?'· 📍 '+esc(i.crate)+' L'+(i.level??'?')+' slot '+(i.slot??'?'):'· unfiled'}
+ ${i.crate_id?'go →':''}
`).join('') : '
no inventory copies
'; +} +async function showRelease(rid){ + if(!rid){ $('#relBody').innerHTML='
that item has no release id
'; return; } + $('#relQ').value=rid; + const d=await get('/nav/release/'+rid); const r=d.release; + if(!r){ $('#relBody').innerHTML=`
release ${rid} isn't in the local mirror
`+relItems(d.items); return; } + $('#relBody').innerHTML=` +
+ +
${esc(r.title||'')}
${esc(r.artist||'')}
+
${esc(r.label||'')}${r.format?' · '+esc(r.format):''}
+
${esc(r.country||'')} ${r.year||''}${r.genre?' · '+esc(r.genre):''}${r.style?' / '+esc(r.style):''}
+ ${r.barcode?`
📷 ${esc(r.barcode)}
`:''}
+
+
Inventory copies (${d.items.length})
${relItems(d.items)}`; +} // ── collection editor ── function showEditor(){ $('#colPanel').style.display='none'; $('#editPanel').style.display=''; }