Compare commits
10 Commits
0f73567db6
...
d56e25ce88
| Author | SHA1 | Date | |
|---|---|---|---|
| d56e25ce88 | |||
| 7b77888812 | |||
| 23564953da | |||
| 63aedfa2eb | |||
| f9a68235c7 | |||
| 655ddfd609 | |||
| 67c9a73ebf | |||
| 7350205c7a | |||
| fced5374f5 | |||
| 75c1906e63 |
+23
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'},
|
||||
]
|
||||
|
||||
@@ -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')
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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 & 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);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
Reference in New Issue
Block a user