Compare commits

...

10 Commits

22 changed files with 764 additions and 53 deletions
+23
View File
@@ -511,3 +511,26 @@
# Use the same badge keys as BK_BADGE_ORDER_GRID
# Default: theme,tag,year,parts,instance_count,total_minifigures,total_missing,total_damaged,owner,storage,purchase_date,purchase_location,purchase_price,instructions,rebrickable,bricklink
# BK_BADGE_ORDER_DETAIL=theme,tag,year,parts,owner,storage,purchase_date,rebrickable,bricklink
# Optional: Show/hide individual BrickData (sidecar) elements on the set detail page
# All default to true (shown). Badges are controlled separately via BK_BADGE_ORDER_DETAIL.
# These take effect live (no restart). Set to false to hide.
# BK_SIDECAR_SHOW_DESIGNER: the set designer line
# BK_SIDECAR_SHOW_DESCRIPTION: the "About this set" description
# BK_SIDECAR_SHOW_NOTES: the Brickset notes
# BK_SIDECAR_SHOW_PRICE_PAID: price comparison "Paid" row (and "Change vs paid")
# BK_SIDECAR_SHOW_PRICE_MSRP: price comparison retail/MSRP rows (and "Saved vs retail")
# BK_SIDECAR_SHOW_PRICE_MARKET: price comparison "Worth now" market value rows
# Default: true
# BK_SIDECAR_SHOW_DESIGNER=false
# BK_SIDECAR_SHOW_DESCRIPTION=false
# BK_SIDECAR_SHOW_NOTES=false
# BK_SIDECAR_SHOW_PRICE_PAID=false
# BK_SIDECAR_SHOW_PRICE_MSRP=false
# BK_SIDECAR_SHOW_PRICE_MARKET=false
# Optional: which BrickLink market value the set-detail "Change vs paid" compares
# against: used or new (falls back to the other when one is missing). The statistics
# page shows both regardless of this setting. Live-changeable.
# Default: used
# BK_SIDECAR_PRICE_BASIS=new
+9
View File
@@ -8,11 +8,20 @@
- Added BrickData settings with an admin UI and a live/restart split so most options apply without a container restart
- **Box art cover override**: Browse a set's additional images from BrickData and pick any of them (including official box art) as the cover image
- **Admin-defined custom fields per set** (#146): Admins can define typed custom fields that then appear on each set, with a mass-edit screen to apply metadata across multiple selected sets at once
- **Retail (MSRP) price and savings** (#144): Set detail shows the lego.com retail price for a chosen region (US/UK/CA/DE) as a badge and in the price comparison, plus a "saved vs retail" figure (MSRP minus what you paid) per set and summed on the Statistics page
- **BrickLink market value** (#160): Set detail shows the current BrickLink market value (new/used average, min/max on hover) pulled on demand from BrickData, with a collection-value estimate on the Statistics page
- **Instructions badge and stats** (#154): Sets with downloaded instruction files now show an "instructions" badge (respects `BK_HIDE_SET_INSTRUCTIONS` and the badge order), and the Statistics page counts how many sets have instructions and the total number of instruction files
- **Statistics: price and instructions stats**: Added a paid / retail / market price comparison and instruction-related statistics
- **Retirement dates for already-retired sets** (#37): The wishlist now falls back to BrickData for sets that the upcoming-retirements CSV does not cover, so long-retired sets show a retirement date instead of "Not found". Dates are colour-coded (yellow for upcoming, red for already retired), and the set detail "Retired" badge shows the full exit date when available
- **New grid filters** (#141): Added parts-range, year-range and "None" filters to the set grid
- **Audit mode for parts tables**: A new "Start audit" entry in the parts table header menu opens a focused, keyboard-driven popup that walks the visible parts one at a time (respecting the active filter and sort), showing a large image, the part name, color and needed quantity. Arrow keys move between parts, Enter/Space save and advance, and a Missing/Found toggle records the missing count (Found mode works out the missing count from what you found). Optimized for phones with a numeric keypad and the image kept in view
### Bug Fixes
- **Fixed instructions never linking to sets with alphanumeric ids** (#166): Instructions for "Pick a Brick" / promotional builds whose set id begins with letters (e.g. `EG00029-1`) were orphaned and never associated with the set; the matching now handles alphanumeric set ids
- **Fixed font size on the landing page** (#167): Item titles in the "Latest added parts" section on the homepage now match the smaller size used by the sets and minifigures sections
- **Fixed sets with unmappable BrickLink parts never clearing the "need refresh" list** (#163): Sets containing parts that Rebrickable has no BrickLink mapping for (e.g. length-specific pneumatic hoses) kept reappearing on the Refresh admin page right after being refreshed; these unmappable parts no longer flag a set as needing a refresh
- **Fixed `BK_HIDE_SPARE_PARTS` being ignored on set detail pages**: Spare parts were still shown in the parts inventory on a set's detail page (and in per-minifigure part tables) even with the setting enabled. The set-specific and minifigure part queries now honor the flag like the other part lists do
- **Fixed price coverage exceeding sensible bounds** (#156): Price coverage is now divided by all priced item types and clamped to 100%
- **Fixed per-color counts on parts sub-cards** (#159): Set and minifigure counts are now computed per color
+2
View File
@@ -41,6 +41,7 @@ from bricktracker.views.minifigure import minifigure_page
from bricktracker.views.part import part_page
from bricktracker.views.purchase_location import purchase_location_page
from bricktracker.views.set import set_page
from bricktracker.views.sidecar import sidecar_page
from bricktracker.views.statistics import statistics_page
from bricktracker.views.storage import storage_page
from bricktracker.views.wish import wish_page
@@ -151,6 +152,7 @@ def setup_app(app: Flask) -> None:
app.register_blueprint(part_page)
app.register_blueprint(purchase_location_page)
app.register_blueprint(set_page)
app.register_blueprint(sidecar_page)
app.register_blueprint(statistics_page)
app.register_blueprint(storage_page)
app.register_blueprint(wish_page)
+13 -1
View File
@@ -117,11 +117,23 @@ CONFIG: Final[list[dict[str, Any]]] = [
# Opt-in browsing of every BrickLink image the sidecar exposes for a set
# (a carousel on the detail page + extra cover sources). Off keeps the
# plain single cover behaviour.
{'n': 'SIDECAR_ADDITIONAL_IMAGES', 'c': bool},
{'n': 'SIDECAR_ADDITIONAL_IMAGES', 'd': False, 'c': bool},
{'n': 'SIDECAR_RETAIL_REGION', 'd': 'US'},
{'n': 'SIDECAR_AUTO_FETCH_PRICE', 'c': bool},
# Currency to request BrickLink market values in (e.g. EUR). Empty lets the
# sidecar use its default. Sent as a query parameter only when set; sidecars
# that do not support it simply ignore it.
{'n': 'SIDECAR_CURRENCY', 'd': ''},
# Per-element show/hide toggles for the BrickData enrichment on the set
# detail page. All default on so behaviour is unchanged; badges are handled
# separately via BADGE_ORDER_DETAIL.
{'n': 'SIDECAR_SHOW_DESIGNER', 'd': True, 'c': bool},
{'n': 'SIDECAR_SHOW_DESCRIPTION', 'd': True, 'c': bool},
{'n': 'SIDECAR_SHOW_NOTES', 'd': True, 'c': bool},
{'n': 'SIDECAR_SHOW_PRICE_PAID', 'd': True, 'c': bool},
{'n': 'SIDECAR_SHOW_PRICE_MSRP', 'd': True, 'c': bool},
{'n': 'SIDECAR_SHOW_PRICE_MARKET', 'd': True, 'c': bool},
# Which BrickLink market value the set-detail "Change vs paid" compares
# against: 'used' or 'new'. Statistics shows both regardless.
{'n': 'SIDECAR_PRICE_BASIS', 'd': 'used'},
]
+19 -2
View File
@@ -93,7 +93,16 @@ LIVE_CHANGEABLE_VARS: Final[List[str]] = [
'BK_SIDECAR_DEFAULT_COVER',
'BK_SIDECAR_RETAIL_REGION',
'BK_SIDECAR_AUTO_FETCH_PRICE',
'BK_SIDECAR_CURRENCY'
'BK_SIDECAR_ADDITIONAL_IMAGES',
'BK_SIDECAR_CURRENCY',
# Per-element show/hide toggles for the BrickData set-detail enrichment
'BK_SIDECAR_SHOW_DESIGNER',
'BK_SIDECAR_SHOW_DESCRIPTION',
'BK_SIDECAR_SHOW_NOTES',
'BK_SIDECAR_SHOW_PRICE_PAID',
'BK_SIDECAR_SHOW_PRICE_MSRP',
'BK_SIDECAR_SHOW_PRICE_MARKET',
'BK_SIDECAR_PRICE_BASIS'
]
# Environment variables that require restart
@@ -354,6 +363,14 @@ class ConfigManager:
'BK_SIDECAR_DEFAULT_COVER': 'Default cover image source for bulk add: rebrickable, box, or set',
'BK_SIDECAR_RETAIL_REGION': 'LEGO.com retail price region for MSRP: US, UK, CA, or DE',
'BK_SIDECAR_AUTO_FETCH_PRICE': 'Automatically fetch BrickLink market value when viewing a set (respects the cache TTL), so you do not have to press "Fetch value". Off by default as it is the slower live path.',
'BK_SIDECAR_CURRENCY': 'Currency to request BrickLink market values in, e.g. EUR. Empty uses the sidecar default. (The sidecar must support the currency query parameter.)'
'BK_SIDECAR_ADDITIONAL_IMAGES': 'Browse every BrickLink image the sidecar exposes for a set: adds a carousel on the set detail page and extra cover sources. Off keeps the plain single cover.',
'BK_SIDECAR_CURRENCY': 'Currency to request BrickLink market values in, e.g. EUR. Empty uses the sidecar default. (The sidecar must support the currency query parameter.)',
'BK_SIDECAR_SHOW_DESIGNER': 'Show the BrickData set designer on the set detail page',
'BK_SIDECAR_SHOW_DESCRIPTION': 'Show the BrickData "About this set" description on the set detail page',
'BK_SIDECAR_SHOW_NOTES': 'Show the BrickData Brickset notes on the set detail page',
'BK_SIDECAR_SHOW_PRICE_PAID': 'Show the "Paid" row (and "Change vs paid") in the BrickData price comparison',
'BK_SIDECAR_SHOW_PRICE_MSRP': 'Show the retail/MSRP rows (and "Saved vs retail") in the BrickData price comparison',
'BK_SIDECAR_SHOW_PRICE_MARKET': 'Show the "Worth now" market value rows in the BrickData price comparison',
'BK_SIDECAR_PRICE_BASIS': 'Which BrickLink market value the set-detail "Change vs paid" compares against: used or new (falls back to the other when one is missing). Statistics shows both regardless.'
}
return help_text.get(var_name, 'No help available for this variable')
+8 -2
View File
@@ -181,8 +181,14 @@ class RebrickablePart(BrickRecord):
'color_rgb': data['color']['rgb'],
'color_transparent': data['color']['is_trans'],
'bricklink_color_id': None,
'bricklink_color_name': None,
'bricklink_part_num': None,
# Empty-string sentinel (not NULL) means "fetched from Rebrickable,
# but no BrickLink mapping exists upstream". need_refresh.sql flags
# only NULL (never fetched), so confirmed-missing parts/colors stop
# reappearing on the refresh list forever (#163). color_id stays
# NULL since it is an INTEGER column and is excluded from the
# refresh trigger instead.
'bricklink_color_name': '',
'bricklink_part_num': '',
'name': data['part']['name'],
'category': data['part']['part_cat_id'],
'image': data['part']['part_img_url'],
+50 -10
View File
@@ -4,7 +4,7 @@ import os
import time
from typing import Any, Final
from flask import current_app
from flask import current_app, url_for
import requests
logger = logging.getLogger(__name__)
@@ -363,23 +363,34 @@ class BrickSidecar(object):
def get_images(ref: str, /) -> dict[str, Any] | None:
return BrickSidecar._get_json('/sets/{ref}/images'.format(ref=ref))
# Build the image-proxy URL for direct use in an <img src> (no server
# round-trip needed to preview). Does not hit the network.
# Build a BrickTracker-served proxy URL for an <img src>. The browser hits
# BrickTracker (same origin), which fetches the image from the sidecar
# server-side. This works even when the sidecar is only reachable on the
# internal Docker network (e.g. http://brickdata:3335), which the browser
# cannot resolve. Returns None when disabled or the type is unknown.
@staticmethod
def image_proxy_url(image_type: str, ref: str, /) -> str | None:
if image_type not in IMAGE_TYPES or not BrickSidecar.enabled():
return None
return url_for('sidecar.image', image_type=image_type, ref=ref)
# The real sidecar image URL, used server-side to fetch the bytes.
@staticmethod
def _remote_image_url(image_type: str, ref: str, /) -> str | None:
if image_type not in IMAGE_TYPES or not BrickSidecar.enabled():
return None
return '{base}/images/bricklink/{type}/{ref}.png'.format(
base=BrickSidecar.base_url(),
type=image_type,
ref=ref,
)
# Build the URL of one Brickset additional image (0-indexed) for direct use
# in an <img src>. Unlike box/instruction art these come from Brickset, not
# the BrickLink image proxy. Pass thumbnail=True for the smaller variant.
# Does not hit the network. Returns None when the sidecar is disabled.
# Build a BrickTracker-served proxy URL for one Brickset additional image
# (0-indexed) for use in an <img src>. Like image_proxy_url, the browser
# talks only to BrickTracker, which fetches from the sidecar server-side.
# Pass thumbnail=True for the smaller variant. Returns None when disabled.
@staticmethod
def additional_image_url(
ref: str,
@@ -391,6 +402,27 @@ class BrickSidecar(object):
if not BrickSidecar.enabled():
return None
return url_for(
'sidecar.additional_image',
ref=ref,
index=index,
thumbnail=1 if thumbnail else None,
)
# The real sidecar additional-image URL, used server-side to fetch bytes.
# Unlike box/instruction art these come from Brickset, not the BrickLink
# image proxy.
@staticmethod
def _remote_additional_image_url(
ref: str,
index: int,
/,
*,
thumbnail: bool = False,
) -> str | None:
if not BrickSidecar.enabled():
return None
url = '{base}/sets/{ref}/additional-images/{index}'.format(
base=BrickSidecar.base_url(),
ref=ref,
@@ -405,8 +437,16 @@ class BrickSidecar(object):
# Fetch the raw bytes of one Brickset additional image (used when saving it
# as the cover). Returns None on any failure or a 404.
@staticmethod
def fetch_additional_image_bytes(ref: str, index: int, /) -> bytes | None:
url = BrickSidecar.additional_image_url(ref, index)
def fetch_additional_image_bytes(
ref: str,
index: int,
/,
*,
thumbnail: bool = False,
) -> bytes | None:
url = BrickSidecar._remote_additional_image_url(
ref, index, thumbnail=thumbnail,
)
if url is None:
return None
@@ -416,7 +456,7 @@ class BrickSidecar(object):
# Returns None on any failure or a 404 (BrickLink has no such image).
@staticmethod
def fetch_image_bytes(image_type: str, ref: str, /) -> bytes | None:
url = BrickSidecar.image_proxy_url(image_type, ref)
url = BrickSidecar._remote_image_url(image_type, ref)
if url is None:
return None
+56 -4
View File
@@ -67,10 +67,13 @@ def summarize(
'notes': _description(data.get('notes')),
}
# Retired status from the exit date.
# Retired status from the exit date. exit_date is the full formatted date
# (BrickData/Brickset has it even for sets retired long ago, which the
# upcoming-retirements CSV does not cover, see #37).
retired, exit_year = _retired(data.get('exitDate'))
summary['retired'] = retired
summary['exit_year'] = exit_year
summary['exit_date'] = _format_date(data.get('exitDate'))
summary['launch_year'] = _year(data.get('launchDate'))
# --- Prices: paid / retail / market --------------------------------
@@ -111,9 +114,15 @@ def summarize(
if msrp is not None and paid is not None:
prices['savings_vs_msrp'] = round(msrp - paid, 2)
# Value movement vs what was paid (using the sealed/new market average,
# falling back to used).
market_ref = market_new if market_new is not None else market_used
# Value movement vs what was paid. SIDECAR_PRICE_BASIS picks which market
# average to compare against ('used' or 'new'); fall back to the other when
# the preferred one is missing.
basis = str(current_app.config.get('SIDECAR_PRICE_BASIS', 'used')).lower()
if basis == 'new':
market_ref = market_new if market_new is not None else market_used
else:
market_ref = market_used if market_used is not None else market_new
prices['market_basis'] = basis
if market_ref is not None and paid is not None:
prices['gain_vs_paid'] = round(market_ref - paid, 2)
@@ -235,8 +244,51 @@ def _year(value: Any) -> int | None:
return parsed.year if parsed is not None else None
def _format_date(value: Any) -> str | None:
parsed = _parse_date(value)
if parsed is None:
return None
return parsed.strftime(current_app.config['PURCHASE_DATE_FORMAT'])
def _retired(exit_date: Any) -> tuple[bool, int | None]:
parsed = _parse_date(exit_date)
if parsed is None:
return False, None
return parsed < datetime.now(timezone.utc), parsed.year
# Bulk retirement lookup for a list of set refs (e.g. the whole wishlist) in a
# single cached BrickData call. Returns {ref: {'date': str|None,
# 'retired': bool}} keyed by both "number-variant" and the bare number so the
# caller can match whichever form it stores. Empty dict when the sidecar is
# disabled or unreachable, so callers degrade to the CSV (see #37).
def retirement_dates(
set_refs: Any,
/,
) -> dict[str, dict[str, Any]]:
if not BrickSidecar.enabled():
return {}
refs = list({str(ref) for ref in set_refs if ref})
if not refs:
return {}
try:
bulk = BrickSidecar.get_sets_bulk(refs, price=False)
except Exception as exception:
logger.debug('sidecar retirement bulk fetch failed: %s', exception)
return {}
out: dict[str, dict[str, Any]] = {}
for ref, data in bulk.items():
retired, _ = _retired(data.get('exitDate'))
info = {
'date': _format_date(data.get('exitDate')),
'retired': retired,
}
out[ref] = info
number, _, _ = ref.partition('-')
out.setdefault(number, info)
return out
@@ -58,10 +58,14 @@ INNER JOIN (
) "null_sums"
-- bricklink_color_id is intentionally NOT a trigger: it is an INTEGER
-- column so it cannot carry the empty-string "confirmed missing" sentinel,
-- and BrickLink mappings can be absent upstream forever. color_name and
-- part_num still trigger because NULL there means "never fetched"; once a
-- refresh confirms no mapping they are written as '' and stop matching (#163).
WHERE "null_rgb" > 0
OR "null_transparent" > 0
OR "null_url" > 0
OR "null_bricklink_color_id" > 0
OR "null_bricklink_color_name" > 0
OR "null_bricklink_part_num" > 0
) "null_join"
+53
View File
@@ -0,0 +1,53 @@
from flask import Blueprint, Response, abort, request
from ..sidecar import BrickSidecar
# Proxies sidecar (BrickData) images through BrickTracker so the browser only
# ever talks to BrickTracker. This is what makes box art, the additional-images
# carousel and the cover previews work when the sidecar is only reachable on
# the internal Docker network (e.g. http://brickdata:3335), which the browser
# itself cannot resolve. Public (no login) because these images appear on pages
# anonymous users can already see.
sidecar_page = Blueprint('sidecar', __name__, url_prefix='/sidecar')
def _image_response(data: bytes | None, mimetype: str, /) -> Response:
if data is None:
abort(404)
response = Response(data, mimetype=mimetype)
# Cache in the browser so viewing a set does not re-proxy every image.
response.headers['Cache-Control'] = 'public, max-age=86400'
return response
# Box / set art (BrickLink image proxy), e.g. /sidecar/image/box/8250-1.png
@sidecar_page.route('/image/<image_type>/<ref>.png', methods=['GET'])
def image(*, image_type: str, ref: str) -> Response:
if not BrickSidecar.enabled():
abort(404)
return _image_response(
BrickSidecar.fetch_image_bytes(image_type, ref), 'image/png',
)
# One Brickset additional image, e.g. /sidecar/additional-images/8250-1/0.
# Path kept plural ("additional-images") so it matches the BaguetteBox gallery
# filter in base.html, which keys on that string for extensionless image URLs.
@sidecar_page.route(
'/additional-images/<ref>/<int:index>', methods=['GET'],
)
def additional_image(*, ref: str, index: int) -> Response:
if not BrickSidecar.enabled():
abort(404)
thumbnail = request.args.get('thumbnail') is not None
return _image_response(
BrickSidecar.fetch_additional_image_bytes(
ref, index, thumbnail=thumbnail,
),
'image/jpeg',
)
+8 -1
View File
@@ -11,6 +11,7 @@ from werkzeug.wrappers.response import Response
from .exceptions import exception_handler
from ..retired_list import BrickRetiredList
from ..sidecar_set import retirement_dates
from ..wish import BrickWish
from ..wish_list import BrickWishList
from ..wish_owner_list import BrickWishOwnerList
@@ -23,10 +24,16 @@ wish_page = Blueprint('wish', __name__, url_prefix='/wishes')
@wish_page.route('/', methods=['GET'])
@exception_handler(__file__)
def list() -> str:
table_collection = BrickWishList().all()
return render_template(
'wishes.html',
table_collection=BrickWishList().all(),
table_collection=table_collection,
retired=BrickRetiredList(),
# BrickData fallback for already-retired sets the CSV does not cover.
sidecar_retired=retirement_dates(
[item.fields.set for item in table_collection]
),
error=request.args.get('error'),
owners=BrickWishOwnerList.list(),
)
+369
View File
@@ -0,0 +1,369 @@
// Audit mode: a focused, keyboard-driven modal that walks the visible parts of
// one accordion parts table, letting you check each part off and record missing
// counts without hunting for tiny checkboxes.
//
// Saving reuses the existing BrickChanger auto-save: set the value on the row's
// missing input / checked checkbox and dispatch a "change" event, exactly like
// parts-bulk-operations.js does.
class PartsAuditMode {
constructor(accordionId) {
this.accordionId = accordionId;
this.trigger = document.getElementById(`audit-mode-${accordionId}`);
if (!this.trigger) {
return;
}
this.rows = [];
this.index = 0;
this.mode = 'missing'; // 'missing' or 'found'
this.setupModal();
this.cacheElements();
this.setupEventListeners();
}
// Build the shared modal once and reuse it across every accordion.
setupModal() {
if (document.getElementById('partsAuditModal')) {
return;
}
const modalHTML = `
<div class="modal fade" id="partsAuditModal" tabindex="-1" aria-labelledby="partsAuditModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header d-block">
<div class="d-flex align-items-center">
<h5 class="modal-title mb-0" id="partsAuditModalLabel"><i class="ri-eye-line me-2"></i>Audit parts</h5>
<span class="ms-auto me-3 text-secondary"><span id="audit-position">0 / 0</span></span>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="progress mt-2" role="progressbar" style="height: 6px;">
<div id="audit-progress-bar" class="progress-bar" style="width: 0%;"></div>
</div>
<small class="text-secondary"><span id="audit-checked-count">0</span> checked</small>
</div>
<div class="modal-body text-center">
<div class="mb-2">
<img id="audit-image" class="img-fluid border rounded audit-image" alt="">
</div>
<div class="mb-1">Quantity <span id="audit-qty">0</span></div>
<div class="mb-3"><span id="audit-name" class="fs-6"></span> <span id="audit-color" class="text-secondary ms-1"></span></div>
<div id="audit-input-area">
<div class="btn-group mb-2" role="group" aria-label="Audit mode">
<button type="button" class="btn btn-sm" id="audit-mode-missing">Missing</button>
<button type="button" class="btn btn-sm" id="audit-mode-found">Found</button>
</div>
<div class="row justify-content-center mb-1">
<div class="col-auto">
<div class="input-group">
<span class="input-group-text" id="audit-input-label">Missing</span>
<input type="number" inputmode="numeric" pattern="[0-9]*" min="0" class="form-control text-center" id="audit-number" style="max-width: 6rem;" autocomplete="off">
</div>
</div>
</div>
<small class="text-secondary">type a number</small>
</div>
<div id="audit-no-input" class="text-secondary d-none">This section only tracks checked status.</div>
</div>
<div class="modal-footer d-flex">
<button type="button" class="btn btn-outline-secondary" id="audit-prev"><i class="ri-arrow-left-line"></i> Prev</button>
<button type="button" class="btn btn-primary ms-auto" id="audit-next">Check &amp; Next <i class="ri-arrow-right-line"></i></button>
</div>
<div class="modal-footer py-1">
<small class="text-secondary w-100 text-center">
←/→ move · type number · Enter / Space save+next
</small>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
}
cacheElements() {
this.modalEl = document.getElementById('partsAuditModal');
this.modal = bootstrap.Modal.getOrCreateInstance(this.modalEl);
this.el = {
position: document.getElementById('audit-position'),
progress: document.getElementById('audit-progress-bar'),
checkedCount: document.getElementById('audit-checked-count'),
image: document.getElementById('audit-image'),
name: document.getElementById('audit-name'),
color: document.getElementById('audit-color'),
qty: document.getElementById('audit-qty'),
inputArea: document.getElementById('audit-input-area'),
noInput: document.getElementById('audit-no-input'),
modeMissing: document.getElementById('audit-mode-missing'),
modeFound: document.getElementById('audit-mode-found'),
inputLabel: document.getElementById('audit-input-label'),
number: document.getElementById('audit-number'),
prev: document.getElementById('audit-prev'),
next: document.getElementById('audit-next'),
};
}
setupEventListeners() {
this.trigger.addEventListener('click', (e) => {
e.preventDefault();
this.start();
});
// These shared-modal controls get wired by whichever instance is created
// first; subsequent instances just re-point the handlers at the active one
// via the `activeInstance` lookup performed inside start().
if (this.modalEl.dataset.auditWired === 'true') {
return;
}
this.modalEl.dataset.auditWired = 'true';
this.el.prev.addEventListener('click', () => PartsAuditMode.active && PartsAuditMode.active.go(-1));
this.el.next.addEventListener('click', () => PartsAuditMode.active && PartsAuditMode.active.commitAndNext());
this.el.modeMissing.addEventListener('click', () => PartsAuditMode.active && PartsAuditMode.active.setMode('missing'));
this.el.modeFound.addEventListener('click', () => PartsAuditMode.active && PartsAuditMode.active.setMode('found'));
// Keyboard handling in the capture phase so navigation keys win over the
// focused number input's default cursor movement.
this.modalEl.addEventListener('keydown', (e) => {
const active = PartsAuditMode.active;
if (!active) {
return;
}
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
active.go(-1);
break;
case 'ArrowRight':
e.preventDefault();
active.go(1);
break;
case 'Enter':
e.preventDefault();
active.commitAndNext();
break;
case ' ':
case 'Spacebar':
e.preventDefault();
active.commitAndNext();
break;
// digits / Backspace fall through to the number input
}
}, true);
// Focus the number box whenever the modal opens (desktop only).
this.modalEl.addEventListener('shown.bs.modal', () => {
const active = PartsAuditMode.active;
if (active) {
active.focusInput();
}
});
}
// On phones we never auto-focus the number box: doing so pops the keyboard
// and scrolls the part image off-screen. The audit flow there is touch
// driven (tap the field only when entering a count, then Check & Next), so
// the image stays visible while you identify the part.
isMobileLayout() {
return window.matchMedia('(max-width: 575.98px)').matches;
}
focusInput() {
if (this.isMobileLayout()) {
return;
}
if (this.el.number && !this.el.inputArea.classList.contains('d-none')) {
this.el.number.focus();
this.el.number.select();
}
}
start() {
const accordion = document.getElementById(this.accordionId);
if (!accordion) {
return;
}
// Snapshot the currently visible rows in DOM (sort) order. Using a
// snapshot means saving (which re-runs the header filter) can't drop the
// row we are sitting on.
this.rows = Array.from(accordion.querySelectorAll('tbody tr')).filter(
row => !row.classList.contains('parts-filtered-out') && row.offsetParent !== null
);
if (!this.rows.length) {
return;
}
PartsAuditMode.active = this;
this.index = 0;
this.render();
this.modal.show();
}
setMode(mode) {
this.mode = mode;
this.render();
this.focusInput();
}
currentRow() {
return this.rows[this.index];
}
neededQty(row) {
const cell = row.querySelector('[data-col="quantity"]');
const value = cell ? parseInt(cell.textContent.trim(), 10) : NaN;
return Number.isNaN(value) ? 1 : value;
}
missingInput(row) {
// ids look like "part-missing-...-{id}" / "individual-part-missing-...",
// so match the same "-missing-" fragment parts-bulk-operations.js uses.
return row.querySelector('input[id*="-missing-"]');
}
checkedBox(row) {
return row.querySelector('input[id*="-checked-"][type="checkbox"]');
}
render() {
const row = this.currentRow();
if (!row) {
return;
}
// Image: prefer the full-size lightbox target, fall back to the thumbnail.
const link = row.querySelector('a[data-lightbox]');
const img = row.querySelector('td img');
this.el.image.src = (link && link.getAttribute('href')) || (img && img.getAttribute('src')) || '';
const nameLink = row.querySelector('[data-col="name"] a');
this.el.name.textContent = nameLink ? nameLink.textContent.trim() : '';
const colorCell = row.querySelector('[data-col="color"]');
this.el.color.innerHTML = colorCell ? colorCell.innerHTML : '';
const needed = this.neededQty(row);
this.el.qty.textContent = needed;
// Toggle the input area depending on whether this table tracks missing.
const missing = this.missingInput(row);
if (missing) {
this.el.inputArea.classList.remove('d-none');
this.el.noInput.classList.add('d-none');
// Mode button styling.
this.el.modeMissing.className = 'btn btn-sm ' + (this.mode === 'missing' ? 'btn-primary' : 'btn-outline-secondary');
this.el.modeFound.className = 'btn btn-sm ' + (this.mode === 'found' ? 'btn-primary' : 'btn-outline-secondary');
this.el.inputLabel.textContent = this.mode === 'found' ? 'Found' : 'Missing';
// Prefill: missing mode shows the current missing value, found starts empty.
this.el.number.value = this.mode === 'missing' ? (missing.value || '') : '';
// Remember what the box started with so a commit that didn't change
// the number leaves the missing field (and the database) untouched.
this.originalNumber = this.el.number.value.trim();
} else {
this.el.inputArea.classList.add('d-none');
this.el.noInput.classList.remove('d-none');
this.originalNumber = '';
}
this.updateProgress();
}
updateProgress() {
const total = this.rows.length;
this.el.position.textContent = `${this.index + 1} / ${total}`;
this.el.progress.style.width = total ? `${Math.round(((this.index + 1) / total) * 100)}%` : '0%';
const checked = this.rows.filter(row => {
const box = this.checkedBox(row);
return box && box.checked;
}).length;
this.el.checkedCount.textContent = checked;
}
// Plain navigation (arrows / Prev) — clamps, never saves or closes.
go(delta) {
const next = this.index + delta;
if (next < 0 || next >= this.rows.length) {
return;
}
this.index = next;
this.render();
this.focusInput();
}
setMissingValue(input, value) {
if (input.value !== value) {
input.value = value;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
check(row) {
const box = this.checkedBox(row);
if (box && !box.checked) {
box.checked = true;
box.dispatchEvent(new Event('change', { bubbles: true }));
}
}
// Enter / Space: record the typed number per mode, tick checked, advance.
// If the number box is unchanged from what the part loaded with, we leave the
// missing field (and the database) completely alone and only tick checked, so
// glancing past a part never triggers a redundant POST.
commitAndNext() {
const row = this.currentRow();
if (!row) {
return;
}
const input = this.missingInput(row);
if (input) {
const typed = this.el.number.value.trim();
if (typed !== this.originalNumber) {
const number = parseInt(typed, 10) || 0;
let missing;
if (this.mode === 'found') {
missing = Math.max(0, this.neededQty(row) - number);
} else {
missing = Math.max(0, number);
}
// Empty string clears the field (0 missing), matching the
// "clear all missing" convention in parts-bulk-operations.js.
this.setMissingValue(input, missing > 0 ? String(missing) : '');
}
}
this.check(row);
this.advance();
}
// Move forward after a commit; close the modal once the last part is done.
advance() {
this.updateProgress();
if (this.index + 1 >= this.rows.length) {
this.modal.hide();
return;
}
this.index += 1;
this.render();
this.focusInput();
}
}
PartsAuditMode.active = null;
// Wire up an instance per accordion that exposes an audit menu item.
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[id^="audit-mode-"]').forEach(item => {
const accordionId = item.id.replace('audit-mode-', '');
new PartsAuditMode(accordionId);
});
});
+11 -7
View File
@@ -150,14 +150,18 @@ class PartsTableFilter {
if (!this.body) {
return;
}
if (!this.emptyRow) {
const columns = this.table.querySelectorAll('thead tr:first-child th').length || 1;
this.emptyRow = document.createElement('tr');
this.emptyRow.className = 'parts-filter-empty no-sort';
this.emptyRow.innerHTML = `<td colspan="${columns}" class="text-center text-body-secondary py-3">No matching parts</td>`;
this.body.appendChild(this.emptyRow);
// Render the empty-state message as a sibling of the table rather than a
// tbody row. The sortable library iterates every <tr> in the tbody and
// reads row.cells[columnIndex], so a single colspan cell would make it
// crash with "can't access property dataset" when sorting any column
// past the first.
if (!this.emptyMessage) {
this.emptyMessage = document.createElement('div');
this.emptyMessage.className = 'parts-filter-empty text-center text-body-secondary py-3';
this.emptyMessage.textContent = 'No matching parts';
this.table.insertAdjacentElement('afterend', this.emptyMessage);
}
this.emptyRow.style.display = visibleCount === 0 ? '' : 'none';
this.emptyMessage.style.display = visibleCount === 0 ? '' : 'none';
}
clear() {
+12
View File
@@ -53,6 +53,18 @@
object-fit:contain;
}
/* Audit mode part image: large on desktop, compact on phones so the part
image, needed quantity and input all stay visible above the keyboard. */
.audit-image {
max-height: 220px;
}
@media (max-width: 575.98px) {
.audit-image {
max-height: 130px;
}
}
.table-td-input {
max-width: 150px;
}
+3 -3
View File
@@ -72,7 +72,7 @@
{% if not bulk %}
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="add-check-box-art"
data-sidecar-base="{{ sidecar.base_url() }}"
data-proxy-url="{{ sidecar.image_proxy_url('box', '__REF__') }}"
data-bs-toggle="modal" data-bs-target="#addBoxArtModal">
<i class="ri-eye-line"></i> Check box art
</button>
@@ -98,7 +98,7 @@
var modal = document.getElementById('addBoxArtModal');
if (!modal) { return; }
modal.addEventListener('show.bs.modal', function () {
var base = (document.getElementById('add-check-box-art').getAttribute('data-sidecar-base') || '').replace(/\/$/, '');
var proxy = document.getElementById('add-check-box-art').getAttribute('data-proxy-url') || '';
var ref = (document.getElementById('add-set').value || '').trim().split(',')[0].trim();
// BrickTracker appends "-1" to a bare set number; match that so
// the sidecar lookup works (e.g. "10179" -> "10179-1").
@@ -115,7 +115,7 @@
return;
}
empty.classList.add('d-none');
image.src = base + '/images/bricklink/box/' + encodeURIComponent(ref) + '.png';
image.src = proxy.replace('__REF__', encodeURIComponent(ref));
image.classList.remove('d-none');
});
})();
+75 -7
View File
@@ -554,13 +554,6 @@
</label>
<input type="number" class="form-control config-number" id="BK_SIDECAR_TIMEOUT" data-var="BK_SIDECAR_TIMEOUT" min="1" max="120">
</div>
<div class="col-md-6">
<label for="BK_SIDECAR_PRICE_CACHE_HOURS" class="form-label">
BK_SIDECAR_PRICE_CACHE_HOURS {{ config_badges('BK_SIDECAR_PRICE_CACHE_HOURS') }}
<div class="text-muted small">Hours to cache BrickLink market prices before refetching</div>
</label>
<input type="number" class="form-control config-number" id="BK_SIDECAR_PRICE_CACHE_HOURS" data-var="BK_SIDECAR_PRICE_CACHE_HOURS" min="0" max="8760">
</div>
<div class="col-md-6">
<label for="BK_SIDECAR_RETAIL_REGION" class="form-label">
BK_SIDECAR_RETAIL_REGION {{ config_badges('BK_SIDECAR_RETAIL_REGION') }}
@@ -582,6 +575,13 @@
</label>
<input type="text" class="form-control config-text" id="BK_SIDECAR_DEFAULT_COVER" data-var="BK_SIDECAR_DEFAULT_COVER" {{ is_locked('BK_SIDECAR_DEFAULT_COVER') }}>
</div>
<div class="col-md-6">
<label for="BK_SIDECAR_PRICE_BASIS" class="form-label">
BK_SIDECAR_PRICE_BASIS {{ config_badges('BK_SIDECAR_PRICE_BASIS') }}
<div class="text-muted small">Market value the set-detail "Change vs paid" uses: used or new</div>
</label>
<input type="text" class="form-control config-text" id="BK_SIDECAR_PRICE_BASIS" data-var="BK_SIDECAR_PRICE_BASIS" {{ is_locked('BK_SIDECAR_PRICE_BASIS') }}>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_AUTO_FETCH_PRICE" data-var="BK_SIDECAR_AUTO_FETCH_PRICE" {{ is_locked('BK_SIDECAR_AUTO_FETCH_PRICE') }}>
@@ -591,6 +591,74 @@
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_ADDITIONAL_IMAGES" data-var="BK_SIDECAR_ADDITIONAL_IMAGES" {{ is_locked('BK_SIDECAR_ADDITIONAL_IMAGES') }}>
<label class="form-check-label" for="BK_SIDECAR_ADDITIONAL_IMAGES">
BK_SIDECAR_ADDITIONAL_IMAGES {{ config_badges('BK_SIDECAR_ADDITIONAL_IMAGES') }}
<div class="text-muted small">Show additional BrickLink images (detail-page carousel + extra cover sources)</div>
</label>
</div>
</div>
</div>
<h6 class="fw-bold text-secondary mb-3 mt-3">Set detail display</h6>
<p class="text-muted small">Show or hide individual BrickData elements on the set detail page. Badges are controlled separately via BK_BADGE_ORDER_DETAIL.</p>
<div class="row g-3">
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_DESIGNER" data-var="BK_SIDECAR_SHOW_DESIGNER" {{ is_locked('BK_SIDECAR_SHOW_DESIGNER') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_DESIGNER">
BK_SIDECAR_SHOW_DESIGNER {{ config_badges('BK_SIDECAR_SHOW_DESIGNER') }}
<div class="text-muted small">Show the set designer</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_DESCRIPTION" data-var="BK_SIDECAR_SHOW_DESCRIPTION" {{ is_locked('BK_SIDECAR_SHOW_DESCRIPTION') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_DESCRIPTION">
BK_SIDECAR_SHOW_DESCRIPTION {{ config_badges('BK_SIDECAR_SHOW_DESCRIPTION') }}
<div class="text-muted small">Show the "About this set" description</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_NOTES" data-var="BK_SIDECAR_SHOW_NOTES" {{ is_locked('BK_SIDECAR_SHOW_NOTES') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_NOTES">
BK_SIDECAR_SHOW_NOTES {{ config_badges('BK_SIDECAR_SHOW_NOTES') }}
<div class="text-muted small">Show the Brickset notes</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_PRICE_PAID" data-var="BK_SIDECAR_SHOW_PRICE_PAID" {{ is_locked('BK_SIDECAR_SHOW_PRICE_PAID') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_PRICE_PAID">
BK_SIDECAR_SHOW_PRICE_PAID {{ config_badges('BK_SIDECAR_SHOW_PRICE_PAID') }}
<div class="text-muted small">Price comparison: "Paid" row (and "Change vs paid")</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_PRICE_MSRP" data-var="BK_SIDECAR_SHOW_PRICE_MSRP" {{ is_locked('BK_SIDECAR_SHOW_PRICE_MSRP') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_PRICE_MSRP">
BK_SIDECAR_SHOW_PRICE_MSRP {{ config_badges('BK_SIDECAR_SHOW_PRICE_MSRP') }}
<div class="text-muted small">Price comparison: retail/MSRP rows (and "Saved vs retail")</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_PRICE_MARKET" data-var="BK_SIDECAR_SHOW_PRICE_MARKET" {{ is_locked('BK_SIDECAR_SHOW_PRICE_MARKET') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_PRICE_MARKET">
BK_SIDECAR_SHOW_PRICE_MARKET {{ config_badges('BK_SIDECAR_SHOW_PRICE_MARKET') }}
<div class="text-muted small">Price comparison: "Worth now" market value rows</div>
</label>
</div>
</div>
</div>
<!-- Advanced Settings -->
+3
View File
@@ -197,6 +197,7 @@
{% if request.endpoint == 'set.details' %}
<script src="{{ url_for('static', filename='scripts/parts-table-filter.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts-audit-mode.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/set-details.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/quick-add-individual-part.js') }}"></script>
{% endif %}
@@ -205,9 +206,11 @@
{% endif %}
{% if request.endpoint == 'individual_part.lot_details' %}
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts-audit-mode.js') }}"></script>
{% endif %}
{% if request.endpoint == 'individual_minifigure.details' %}
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts-audit-mode.js') }}"></script>
{% endif %}
{% if request.endpoint == 'statistics.overview' %}
<script src="{{ url_for('static', filename='scripts/statistics.js') }}"></script>
+1 -1
View File
@@ -229,7 +229,7 @@
when the data is absent (so they are safe to put anywhere in BADGE_ORDER_*). #}
{% macro retired(summary, solo=false, last=false) %}
{% if summary and summary.retired %}
{{ badge(check=true, solo=solo, last=last, color='light text-danger-emphasis bg-danger-subtle border border-danger-subtle', icon='time-line', collapsible='Retired:', text=(summary.exit_year or 'Yes'), alt='Retired') }}
{{ badge(check=true, solo=solo, last=last, color='light text-danger-emphasis bg-danger-subtle border border-danger-subtle', icon='time-line', collapsible='Retired:', text=(summary.exit_date or summary.exit_year or 'Yes'), alt='Retired') }}
{% endif %}
{% endmacro %}
+5
View File
@@ -53,6 +53,11 @@
<li><a class="dropdown-item" href="#" id="check-all-{{ accordion_id }}"><i class="ri-checkbox-line me-2"></i>Check all</a></li>
<li><a class="dropdown-item" href="#" id="uncheck-all-{{ accordion_id }}"><i class="ri-checkbox-blank-line me-2"></i>Uncheck all</a></li>
{% endif %}
{% if show_missing_menu or show_checked_menu %}
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Audit</h6></li>
<li><a class="dropdown-item" href="#" id="audit-mode-{{ accordion_id }}"><i class="ri-eye-line me-2"></i>Start audit</a></li>
{% endif %}
</ul>
</div>
</th>
+2 -2
View File
@@ -22,9 +22,9 @@
</td>
{% if not no_quantity %}
{% if all %}
<td>{{ item.fields.total_quantity }}</td>
<td data-col="quantity">{{ item.fields.total_quantity }}</td>
{% else %}
<td>{% if quantity %}{{ item.fields.quantity * quantity }}{% else %}{{ item.fields.quantity }}{% endif %}</td>
<td data-col="quantity">{% if quantity %}{{ item.fields.quantity * quantity }}{% else %}{{ item.fields.quantity }}{% endif %}</td>
{% endif %}
{% endif %}
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
+20 -10
View File
@@ -8,23 +8,30 @@
by macro/badge.html via BADGE_ORDER_DETAIL, so they are not repeated here. #}
{# --- Designer (web-scraped; the API does not expose it) --- #}
{% if s.designer %}
{% if s.designer and config['SIDECAR_SHOW_DESIGNER'] %}
<div class="card-body border-bottom-0 pt-0">
<div class="small text-muted"><i class="ri-pencil-ruler-2-line me-1"></i>Designer: {{ s.designer }}</div>
</div>
{% endif %}
{# --- Price comparison: paid / retail / worth now --- #}
{% if p.paid is not none or p.msrp is not none or p.msrp_inflated is not none or p.has_market %}
{# --- Price comparison: paid / retail / worth now. Each row group has its own
live toggle; the whole card hides when all three are off. --- #}
{% set show_paid = config['SIDECAR_SHOW_PRICE_PAID'] %}
{% set show_msrp = config['SIDECAR_SHOW_PRICE_MSRP'] %}
{% set show_market = config['SIDECAR_SHOW_PRICE_MARKET'] %}
{% if (show_paid or show_msrp or show_market) and (p.paid is not none or p.msrp is not none or p.msrp_inflated is not none or p.has_market) %}
<div class="card-body border-bottom-0 pt-0">
<div class="border rounded p-2">
<div class="fw-bold small mb-1"><i class="ri-scales-line me-1"></i>Price comparison</div>
<table class="table table-sm mb-1 small">
<tbody>
{% if show_paid %}
<tr>
<td>Paid</td>
<td class="text-end">{% if p.paid is not none %}{{ '%.2f' | format(p.paid) }} {{ config['PURCHASE_CURRENCY'] }}{% else %}<span class="text-muted"></span>{% endif %}</td>
</tr>
{% endif %}
{% if show_msrp %}
<tr>
<td>Retail (MSRP)</td>
<td class="text-end">{% if p.msrp is not none %}{{ '%.2f' | format(p.msrp) }} {{ p.msrp_currency }}{% else %}<span class="text-muted"></span>{% endif %}</td>
@@ -35,23 +42,26 @@
<td class="text-end text-muted">{{ '%.2f' | format(p.msrp_inflated) }} {{ p.msrp_inflated_currency }}</td>
</tr>
{% endif %}
{% endif %}
{% if show_market %}
<tr>
<td>Worth now (new)</td>
<td class="text-end">{% if p.market_new is not none %}{{ '%.2f' | format(p.market_new) }} {{ p.market_currency }}{% else %}<span class="text-muted"></span>{% endif %}</td>
<td class="text-end">{% if p.market_new is not none %}{{ '%.2f' | format(p.market_new) }} {{ p.market_currency }}{% if p.market_min is not none and p.market_max is not none %}<i class="ri-information-line ms-1 text-muted" title="Min {{ '%.2f' | format(p.market_min) }} / Max {{ '%.2f' | format(p.market_max) }} {{ p.market_currency }}"></i>{% endif %}{% else %}<span class="text-muted"></span>{% endif %}</td>
</tr>
<tr>
<td>Worth now (used)</td>
<td class="text-end">{% if p.market_used is not none %}{{ '%.2f' | format(p.market_used) }} {{ p.market_currency }}{% else %}<span class="text-muted"></span>{% endif %}</td>
<td class="text-end">{% if p.market_used is not none %}{{ '%.2f' | format(p.market_used) }} {{ p.market_currency }}{% if p.market_used_min is not none and p.market_used_max is not none %}<i class="ri-information-line ms-1 text-muted" title="Min {{ '%.2f' | format(p.market_used_min) }} / Max {{ '%.2f' | format(p.market_used_max) }} {{ p.market_currency }}"></i>{% endif %}{% else %}<span class="text-muted"></span>{% endif %}</td>
</tr>
{% if p.savings_vs_msrp is defined %}
{% endif %}
{% if show_msrp and p.savings_vs_msrp is defined %}
<tr class="border-top">
<td>Saved vs retail</td>
<td class="text-end {% if p.savings_vs_msrp >= 0 %}text-success{% else %}text-danger{% endif %}">{{ '%+.2f' | format(p.savings_vs_msrp) }}</td>
</tr>
{% endif %}
{% if p.gain_vs_paid is defined %}
{% if show_paid and p.gain_vs_paid is defined %}
<tr>
<td>Change vs paid</td>
<td>Change vs paid <span class="text-muted">({{ p.market_basis or 'used' }})</span></td>
<td class="text-end {% if p.gain_vs_paid >= 0 %}text-success{% else %}text-danger{% endif %}">{{ '%+.2f' | format(p.gain_vs_paid) }}</td>
</tr>
{% endif %}
@@ -73,7 +83,7 @@
{# --- Sidecar description, styled like notes. This is the official set blurb,
distinct from the user's own notes, so it is always shown when present. --- #}
{% if s.description %}
{% if s.description and config['SIDECAR_SHOW_DESCRIPTION'] %}
<div class="card-body border-bottom-0 pt-0">
<div class="alert alert-secondary mb-0" role="alert">
<div class="fw-bold small mb-1"><i class="ri-information-line me-1"></i>About this set</div>
@@ -83,7 +93,7 @@
{% endif %}
{# --- Brickset Notes (web-scraped; trivia / variants / packaging quirks). --- #}
{% if s.notes %}
{% if s.notes and config['SIDECAR_SHOW_NOTES'] %}
<div class="card-body border-bottom-0 pt-0">
<div class="alert alert-light border mb-0" role="alert">
<div class="fw-bold small mb-1"><i class="ri-sticky-note-line me-1"></i>Notes</div>
+17 -2
View File
@@ -20,7 +20,7 @@
</thead>
<tbody>
{% for item in table_collection %}
{% with retirement_date = retired.get(item.fields.set) %}
{% with retirement_date = retired.get(item.fields.set), bd_retired = (sidecar_retired.get(item.fields.set) if sidecar_retired is defined and sidecar_retired else none) %}
<tr>
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.set) }}
<td>{{ item.fields.set }} {{ table.rebrickable(item) }}</td>
@@ -28,7 +28,22 @@
<td>{{ item.theme.name }}</td>
<td>{{ item.fields.year }}</td>
<td>{{ item.fields.number_of_parts }}</td>
<td>{% if retirement_date %}{{ retirement_date }}{% else %}<span class="badge rounded-pill text-bg-light border">Not found</span>{% endif %}</td>
<td>
{% if retirement_date %}
{# Upcoming-retirements CSV: these are future dates. #}
<span class="badge rounded-pill text-warning-emphasis bg-warning-subtle border border-warning-subtle"><i class="ri-calendar-close-line"></i> {{ retirement_date }}</span>
{% elif bd_retired and bd_retired.date %}
{# BrickData fallback for sets the CSV does not cover (#37):
red when already retired, yellow when still upcoming. #}
{% if bd_retired.retired %}
<span class="badge rounded-pill text-danger-emphasis bg-danger-subtle border border-danger-subtle"><i class="ri-time-line"></i> Retired {{ bd_retired.date }}</span>
{% else %}
<span class="badge rounded-pill text-warning-emphasis bg-warning-subtle border border-warning-subtle"><i class="ri-calendar-close-line"></i> {{ bd_retired.date }}</span>
{% endif %}
{% else %}
<span class="badge rounded-pill text-bg-light border">Not found</span>
{% endif %}
</td>
<td>
{% for owner in owners %}
{{ badge.owner(item, owner) }}