mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-04-27 15:07:43 +00:00
feat: Better URL rendering
This commit is contained in:
parent
ef0a081977
commit
2f708a9092
3 changed files with 83 additions and 95 deletions
|
|
@ -91,7 +91,6 @@ function storesOverlap(aItem, bItem) {
|
||||||
const b = bItem?.stores;
|
const b = bItem?.stores;
|
||||||
if (!a || !b) return false;
|
if (!a || !b) return false;
|
||||||
|
|
||||||
// stores are Set(storeLabel). Exact-label overlap is the intended rule.
|
|
||||||
for (const s of a) {
|
for (const s of a) {
|
||||||
if (b.has(s)) return true;
|
if (b.has(s)) return true;
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +115,6 @@ function isBCStoreLabel(label) {
|
||||||
return s.includes("bcl") || s.includes("strath");
|
return s.includes("bcl") || s.includes("strath");
|
||||||
}
|
}
|
||||||
|
|
||||||
// infer BC-ness by checking any row for that skuKey in current index
|
|
||||||
function skuIsBC(allRows, skuKey) {
|
function skuIsBC(allRows, skuKey) {
|
||||||
for (const r of allRows) {
|
for (const r of allRows) {
|
||||||
if (keySkuForRow(r) !== skuKey) continue;
|
if (keySkuForRow(r) !== skuKey) continue;
|
||||||
|
|
@ -145,16 +143,8 @@ function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) {
|
||||||
return scored.slice(0, limit).map((x) => x.it);
|
return scored.slice(0, limit).map((x) => x.it);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recommendSimilar(
|
function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isIgnoredPairFn) {
|
||||||
allAgg,
|
if (!pinned || !pinned.name) return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus);
|
||||||
pinned,
|
|
||||||
limit,
|
|
||||||
otherPinnedSku,
|
|
||||||
mappedSkus,
|
|
||||||
isIgnoredPairFn
|
|
||||||
) {
|
|
||||||
if (!pinned || !pinned.name)
|
|
||||||
return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus);
|
|
||||||
|
|
||||||
const base = String(pinned.name || "");
|
const base = String(pinned.name || "");
|
||||||
const pinnedSku = String(pinned.sku || "");
|
const pinnedSku = String(pinned.sku || "");
|
||||||
|
|
@ -167,11 +157,8 @@ function recommendSimilar(
|
||||||
if (it.sku === pinned.sku) continue;
|
if (it.sku === pinned.sku) continue;
|
||||||
if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue;
|
if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue;
|
||||||
|
|
||||||
// NEW: never suggest same-store pairs
|
|
||||||
if (storesOverlap(pinned, it)) continue;
|
if (storesOverlap(pinned, it)) continue;
|
||||||
|
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, String(it.sku || ""))) continue;
|
||||||
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, String(it.sku || "")))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
const s = similarityScore(base, it.name || "");
|
const s = similarityScore(base, it.name || "");
|
||||||
if (s > 0) scored.push({ it, s });
|
if (s > 0) scored.push({ it, s });
|
||||||
|
|
@ -180,7 +167,6 @@ function recommendSimilar(
|
||||||
return scored.slice(0, limit).map((x) => x.it);
|
return scored.slice(0, limit).map((x) => x.it);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FAST initial pairing (approx) with ignore-pair exclusion + same-store exclusion
|
|
||||||
function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn) {
|
function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn) {
|
||||||
const items = allAgg.filter((it) => {
|
const items = allAgg.filter((it) => {
|
||||||
if (!it) return false;
|
if (!it) return false;
|
||||||
|
|
@ -197,9 +183,7 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
const itemNormName = new Map();
|
const itemNormName = new Map();
|
||||||
|
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
const toks = Array.from(new Set(tokenizeQuery(it.name || "")))
|
const toks = Array.from(new Set(tokenizeQuery(it.name || ""))).filter(Boolean).slice(0, 10);
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 10);
|
|
||||||
itemTokens.set(it.sku, toks);
|
itemTokens.set(it.sku, toks);
|
||||||
itemNormName.set(it.sku, normSearchText(it.name || ""));
|
itemNormName.set(it.sku, normSearchText(it.name || ""));
|
||||||
for (const t of toks) {
|
for (const t of toks) {
|
||||||
|
|
@ -232,8 +216,6 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
if (isUnknownSkuKey(bSku)) continue;
|
if (isUnknownSkuKey(bSku)) continue;
|
||||||
|
|
||||||
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue;
|
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue;
|
||||||
|
|
||||||
// NEW: never suggest same-store pairs
|
|
||||||
if (storesOverlap(a, b)) continue;
|
if (storesOverlap(a, b)) continue;
|
||||||
|
|
||||||
cand.set(bSku, b);
|
cand.set(bSku, b);
|
||||||
|
|
@ -282,8 +264,6 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
const bSku = String(p.b.sku || "");
|
const bSku = String(p.b.sku || "");
|
||||||
if (!aSku || !bSku || aSku === bSku) continue;
|
if (!aSku || !bSku || aSku === bSku) continue;
|
||||||
if (used.has(aSku) || used.has(bSku)) continue;
|
if (used.has(aSku) || used.has(bSku)) continue;
|
||||||
|
|
||||||
// NEW: extra guard
|
|
||||||
if (storesOverlap(p.a, p.b)) continue;
|
if (storesOverlap(p.a, p.b)) continue;
|
||||||
|
|
||||||
used.add(aSku);
|
used.add(aSku);
|
||||||
|
|
@ -359,20 +339,21 @@ export async function renderSkuLinker($app) {
|
||||||
if (!r || r.removed) continue;
|
if (!r || r.removed) continue;
|
||||||
const skuKey = String(keySkuForRow(r) || "").trim();
|
const skuKey = String(keySkuForRow(r) || "").trim();
|
||||||
if (!skuKey) continue;
|
if (!skuKey) continue;
|
||||||
|
|
||||||
const storeLabel = String(r.storeLabel || r.store || "").trim();
|
const storeLabel = String(r.storeLabel || r.store || "").trim();
|
||||||
const url = String(r.url || "").trim();
|
const url = String(r.url || "").trim();
|
||||||
if (!storeLabel || !url) continue;
|
if (!storeLabel || !url) continue;
|
||||||
|
|
||||||
let m = URL_BY_SKU_STORE.get(skuKey);
|
let m = URL_BY_SKU_STORE.get(skuKey);
|
||||||
if (!m) URL_BY_SKU_STORE.set(skuKey, (m = new Map()));
|
if (!m) URL_BY_SKU_STORE.set(skuKey, (m = new Map()));
|
||||||
if (!m.has(storeLabel)) m.set(storeLabel, url);
|
if (!m.has(storeLabel)) m.set(storeLabel, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// candidates for this page (hide unknown u: entirely)
|
|
||||||
const allAgg = aggregateBySku(allRows, (x) => x).filter((it) => !isUnknownSkuKey(it.sku));
|
const allAgg = aggregateBySku(allRows, (x) => x).filter((it) => !isUnknownSkuKey(it.sku));
|
||||||
|
|
||||||
const meta = await loadSkuMetaBestEffort();
|
const meta = await loadSkuMetaBestEffort();
|
||||||
const mappedSkus = buildMappedSkuSet(meta.links || []);
|
const mappedSkus = buildMappedSkuSet(meta.links || []);
|
||||||
const ignoreSet = rules.ignoreSet; // already canonicalized as "a|b"
|
const ignoreSet = rules.ignoreSet;
|
||||||
|
|
||||||
function isIgnoredPair(a, b) {
|
function isIgnoredPair(a, b) {
|
||||||
return rules.isIgnoredPair(String(a || ""), String(b || ""));
|
return rules.isIgnoredPair(String(a || ""), String(b || ""));
|
||||||
|
|
@ -389,7 +370,6 @@ export async function renderSkuLinker($app) {
|
||||||
const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)";
|
const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)";
|
||||||
const store = it.cheapestStoreLabel || ([...it.stores][0] || "Store");
|
const store = it.cheapestStoreLabel || ([...it.stores][0] || "Store");
|
||||||
|
|
||||||
// IMPORTANT: link must match displayed store label
|
|
||||||
const href =
|
const href =
|
||||||
URL_BY_SKU_STORE.get(String(it.sku || ""))?.get(String(store || "")) ||
|
URL_BY_SKU_STORE.get(String(it.sku || ""))?.get(String(store || "")) ||
|
||||||
String(it.sampleUrl || "").trim() ||
|
String(it.sampleUrl || "").trim() ||
|
||||||
|
|
@ -407,21 +387,18 @@ export async function renderSkuLinker($app) {
|
||||||
<div class="item ${pinned ? "pinnedItem" : ""}" data-sku="${esc(it.sku)}">
|
<div class="item ${pinned ? "pinnedItem" : ""}" data-sku="${esc(it.sku)}">
|
||||||
<div class="itemRow">
|
<div class="itemRow">
|
||||||
<div class="thumbBox">${renderThumbHtml(it.img)}</div>
|
<div class="thumbBox">${renderThumbHtml(it.img)}</div>
|
||||||
|
|
||||||
<div class="itemBody">
|
<div class="itemBody">
|
||||||
<div class="itemTop">
|
<div class="itemMain">
|
||||||
<div class="itemName">${esc(it.name || "(no name)")}</div>
|
<div class="itemName">${esc(it.name || "(no name)")}</div>
|
||||||
|
${pinned ? `<div class="small">Pinned (click again to unpin)</div>` : ``}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="itemFacts">
|
||||||
|
<div class="mono priceBig">${esc(price)}</div>
|
||||||
|
${storeBadge}
|
||||||
<span class="badge mono">${esc(displaySku(it.sku))}</span>
|
<span class="badge mono">${esc(displaySku(it.sku))}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- spacing: price row then store row -->
|
|
||||||
<div class="meta">
|
|
||||||
<span class="mono">${esc(price)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta">
|
|
||||||
${storeBadge}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${pinned ? `<div class="small">Pinned (click again to unpin)</div>` : ``}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -446,7 +423,6 @@ export async function renderSkuLinker($app) {
|
||||||
if (otherPinned) {
|
if (otherPinned) {
|
||||||
const oSku = String(otherPinned.sku || "");
|
const oSku = String(otherPinned.sku || "");
|
||||||
out = out.filter((it) => !isIgnoredPair(oSku, String(it.sku || "")));
|
out = out.filter((it) => !isIgnoredPair(oSku, String(it.sku || "")));
|
||||||
// NEW: never show same-store pairs when other side pinned
|
|
||||||
out = out.filter((it) => !storesOverlap(otherPinned, it));
|
out = out.filter((it) => !storesOverlap(otherPinned, it));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|
@ -483,7 +459,6 @@ export async function renderSkuLinker($app) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: enforce same-store rule even on manual pinning
|
|
||||||
if (other && storesOverlap(other, it)) {
|
if (other && storesOverlap(other, it)) {
|
||||||
$status.textContent = "Not allowed: both items belong to the same store.";
|
$status.textContent = "Not allowed: both items belong to the same store.";
|
||||||
return;
|
return;
|
||||||
|
|
@ -520,8 +495,7 @@ export async function renderSkuLinker($app) {
|
||||||
if (!localWrite) {
|
if (!localWrite) {
|
||||||
$linkBtn.disabled = true;
|
$linkBtn.disabled = true;
|
||||||
$ignoreBtn.disabled = true;
|
$ignoreBtn.disabled = true;
|
||||||
$status.textContent =
|
$status.textContent = "Write disabled on GitHub Pages. Use: node viz/serve.js and open 127.0.0.1.";
|
||||||
"Write disabled on GitHub Pages. Use: node viz/serve.js and open 127.0.0.1.";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -542,7 +516,6 @@ export async function renderSkuLinker($app) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: same-store rule
|
|
||||||
if (storesOverlap(pinnedL, pinnedR)) {
|
if (storesOverlap(pinnedL, pinnedR)) {
|
||||||
$linkBtn.disabled = true;
|
$linkBtn.disabled = true;
|
||||||
$ignoreBtn.disabled = true;
|
$ignoreBtn.disabled = true;
|
||||||
|
|
@ -558,11 +531,8 @@ export async function renderSkuLinker($app) {
|
||||||
$ignoreBtn.disabled = false;
|
$ignoreBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isIgnoredPair(a, b)) {
|
if (isIgnoredPair(a, b)) $status.textContent = "This pair is already ignored.";
|
||||||
$status.textContent = "This pair is already ignored.";
|
else if ($status.textContent === "Pin one item on each side to enable linking / ignoring.") $status.textContent = "";
|
||||||
} else if ($status.textContent === "Pin one item on each side to enable linking / ignoring.") {
|
|
||||||
$status.textContent = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAll() {
|
function updateAll() {
|
||||||
|
|
@ -571,8 +541,7 @@ export async function renderSkuLinker($app) {
|
||||||
updateButtons();
|
updateButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
let tL = null,
|
let tL = null, tR = null;
|
||||||
tR = null;
|
|
||||||
$qL.addEventListener("input", () => {
|
$qL.addEventListener("input", () => {
|
||||||
if (tL) clearTimeout(tL);
|
if (tL) clearTimeout(tL);
|
||||||
tL = setTimeout(() => {
|
tL = setTimeout(() => {
|
||||||
|
|
@ -615,19 +584,12 @@ export async function renderSkuLinker($app) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direction: if either is BC-based, FROM is BC sku.
|
|
||||||
const aBC = skuIsBC(allRows, a);
|
const aBC = skuIsBC(allRows, a);
|
||||||
const bBC = skuIsBC(allRows, b);
|
const bBC = skuIsBC(allRows, b);
|
||||||
|
|
||||||
let fromSku = a,
|
let fromSku = a, toSku = b;
|
||||||
toSku = b;
|
if (aBC && !bBC) { fromSku = a; toSku = b; }
|
||||||
if (aBC && !bBC) {
|
else if (bBC && !aBC) { fromSku = b; toSku = a; }
|
||||||
fromSku = a;
|
|
||||||
toSku = b;
|
|
||||||
} else if (bBC && !aBC) {
|
|
||||||
fromSku = b;
|
|
||||||
toSku = a;
|
|
||||||
}
|
|
||||||
|
|
||||||
$status.textContent = `Writing: ${displaySku(fromSku)} → ${displaySku(toSku)} …`;
|
$status.textContent = `Writing: ${displaySku(fromSku)} → ${displaySku(toSku)} …`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export function renderSearch($app) {
|
||||||
const out = new Map();
|
const out = new Map();
|
||||||
for (const r of Array.isArray(listings) ? listings : []) {
|
for (const r of Array.isArray(listings) ? listings : []) {
|
||||||
if (!r || r.removed) continue;
|
if (!r || r.removed) continue;
|
||||||
|
|
||||||
const skuKey = String(keySkuForRow(r) || "").trim();
|
const skuKey = String(keySkuForRow(r) || "").trim();
|
||||||
if (!skuKey) continue;
|
if (!skuKey) continue;
|
||||||
|
|
||||||
|
|
@ -80,7 +81,7 @@ export function renderSearch($app) {
|
||||||
const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)";
|
const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)";
|
||||||
const store = it.cheapestStoreLabel || ([...it.stores][0] || "Store");
|
const store = it.cheapestStoreLabel || ([...it.stores][0] || "Store");
|
||||||
|
|
||||||
// IMPORTANT: link must match the displayed store label
|
// link must match displayed store label
|
||||||
const href = urlForAgg(it, store);
|
const href = urlForAgg(it, store);
|
||||||
const storeBadge = href
|
const storeBadge = href
|
||||||
? `<a class="badge" href="${esc(
|
? `<a class="badge" href="${esc(
|
||||||
|
|
@ -93,21 +94,17 @@ export function renderSearch($app) {
|
||||||
return `
|
return `
|
||||||
<div class="item" data-sku="${esc(it.sku)}">
|
<div class="item" data-sku="${esc(it.sku)}">
|
||||||
<div class="itemRow">
|
<div class="itemRow">
|
||||||
<div class="thumbBox">
|
<div class="thumbBox">${renderThumbHtml(it.img)}</div>
|
||||||
${renderThumbHtml(it.img)}
|
|
||||||
</div>
|
|
||||||
<div class="itemBody">
|
<div class="itemBody">
|
||||||
<div class="itemTop">
|
<div class="itemMain">
|
||||||
<div class="itemName">${esc(it.name || "(no name)")}</div>
|
<div class="itemName">${esc(it.name || "(no name)")}</div>
|
||||||
<span class="badge mono">${esc(displaySku(it.sku))}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- spacing: price row then store row -->
|
<div class="itemFacts">
|
||||||
<div class="meta">
|
<div class="mono priceBig">${esc(price)}</div>
|
||||||
<span class="mono">${esc(price)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta">
|
|
||||||
${storeBadge}
|
${storeBadge}
|
||||||
|
<span class="badge mono">${esc(displaySku(it.sku))}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -181,25 +178,21 @@ export function renderSearch($app) {
|
||||||
return `
|
return `
|
||||||
<div class="item" data-sku="${esc(sku)}">
|
<div class="item" data-sku="${esc(sku)}">
|
||||||
<div class="itemRow">
|
<div class="itemRow">
|
||||||
<div class="thumbBox">
|
<div class="thumbBox">${renderThumbHtml(img)}</div>
|
||||||
${renderThumbHtml(img)}
|
|
||||||
</div>
|
|
||||||
<div class="itemBody">
|
<div class="itemBody">
|
||||||
<div class="itemTop">
|
<div class="itemMain">
|
||||||
<div class="itemName">${esc(r.name || "(no name)")}</div>
|
<div class="itemName">${esc(r.name || "(no name)")}</div>
|
||||||
<span class="badge mono">${esc(displaySku(sku))}</span>
|
<div class="meta">
|
||||||
|
<span class="badge">${esc(kind)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- spacing: kind/store row then price row -->
|
<div class="itemFacts">
|
||||||
<div class="meta">
|
<div class="mono priceBig">${esc(priceLine)}</div>
|
||||||
<span class="badge">${esc(kind)}</span>
|
|
||||||
${storeBadge}
|
${storeBadge}
|
||||||
</div>
|
<div class="small mono">${esc(when)}</div>
|
||||||
<div class="meta">
|
<span class="badge mono">${esc(displaySku(sku))}</span>
|
||||||
<span class="mono">${esc(priceLine)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta">
|
|
||||||
<span class="mono">${esc(when)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -222,10 +215,7 @@ export function renderSearch($app) {
|
||||||
if (!indexReady) return;
|
if (!indexReady) return;
|
||||||
|
|
||||||
const tokens = tokenizeQuery($q.value);
|
const tokens = tokenizeQuery($q.value);
|
||||||
if (!tokens.length) {
|
if (!tokens.length) return;
|
||||||
// recent gets rendered later after rules load
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches = allAgg.filter((it) => matchesAllTokens(it.searchText, tokens));
|
const matches = allAgg.filter((it) => matchesAllTokens(it.searchText, tokens));
|
||||||
renderAggregates(matches);
|
renderAggregates(matches);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ body {
|
||||||
a { color: var(--accent); text-decoration: none; }
|
a { color: var(--accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* Make badge-links look like badges (not accent-blue) + clearly clickable */
|
/* badge links should look like badges, but clearly clickable */
|
||||||
a.badge { color: var(--muted); }
|
a.badge { color: var(--muted); }
|
||||||
a.badge:hover { text-decoration: underline; cursor: pointer; }
|
a.badge:hover { text-decoration: underline; cursor: pointer; }
|
||||||
|
|
||||||
|
|
@ -82,20 +82,21 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
|
||||||
.itemRow {
|
.itemRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: flex-start;
|
align-items: stretch; /* key: let thumb fill height */
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumbBox {
|
.thumbBox {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #0b0d10;
|
background: #0b0d10;
|
||||||
flex: 0 0 64px;
|
flex: 0 0 64px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
height: auto;
|
||||||
|
min-height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb {
|
.thumb {
|
||||||
|
|
@ -114,6 +115,26 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
|
||||||
.itemBody {
|
.itemBody {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemMain {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemFacts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemTop {
|
.itemTop {
|
||||||
|
|
@ -141,7 +162,6 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
margin-top: 8px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -151,6 +171,12 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
|
||||||
|
|
||||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||||
|
|
||||||
|
.priceBig {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -232,9 +258,19 @@ a.badge:hover { text-decoration: underline; cursor: pointer; }
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.container { padding: 14px; }
|
.container { padding: 14px; }
|
||||||
.thumbBox { width: 56px; height: 56px; flex: 0 0 56px; }
|
.thumbBox { width: 56px; flex: 0 0 56px; min-height: 56px; }
|
||||||
.detailThumbBox { width: 84px; height: 84px; flex: 0 0 84px; }
|
.detailThumbBox { width: 84px; height: 84px; flex: 0 0 84px; }
|
||||||
|
|
||||||
|
/* on mobile, keep the "facts" inline so cards don't get super tall */
|
||||||
|
.itemBody { flex-direction: column; gap: 10px; }
|
||||||
|
.itemFacts {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.chartBox {
|
.chartBox {
|
||||||
height: 58vh;
|
height: 58vh;
|
||||||
min-height: 260px;
|
min-height: 260px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue