Compare commits
10 Commits
| 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
|
# 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
|
# 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
|
# 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
|
- 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
|
- **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
|
- **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
|
- **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
|
- **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
|
### 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 `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 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
|
- **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.part import part_page
|
||||||
from bricktracker.views.purchase_location import purchase_location_page
|
from bricktracker.views.purchase_location import purchase_location_page
|
||||||
from bricktracker.views.set import set_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.statistics import statistics_page
|
||||||
from bricktracker.views.storage import storage_page
|
from bricktracker.views.storage import storage_page
|
||||||
from bricktracker.views.wish import wish_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(part_page)
|
||||||
app.register_blueprint(purchase_location_page)
|
app.register_blueprint(purchase_location_page)
|
||||||
app.register_blueprint(set_page)
|
app.register_blueprint(set_page)
|
||||||
|
app.register_blueprint(sidecar_page)
|
||||||
app.register_blueprint(statistics_page)
|
app.register_blueprint(statistics_page)
|
||||||
app.register_blueprint(storage_page)
|
app.register_blueprint(storage_page)
|
||||||
app.register_blueprint(wish_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
|
# 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
|
# (a carousel on the detail page + extra cover sources). Off keeps the
|
||||||
# plain single cover behaviour.
|
# 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_RETAIL_REGION', 'd': 'US'},
|
||||||
{'n': 'SIDECAR_AUTO_FETCH_PRICE', 'c': bool},
|
{'n': 'SIDECAR_AUTO_FETCH_PRICE', 'c': bool},
|
||||||
# Currency to request BrickLink market values in (e.g. EUR). Empty lets the
|
# 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
|
# sidecar use its default. Sent as a query parameter only when set; sidecars
|
||||||
# that do not support it simply ignore it.
|
# that do not support it simply ignore it.
|
||||||
{'n': 'SIDECAR_CURRENCY', 'd': ''},
|
{'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_DEFAULT_COVER',
|
||||||
'BK_SIDECAR_RETAIL_REGION',
|
'BK_SIDECAR_RETAIL_REGION',
|
||||||
'BK_SIDECAR_AUTO_FETCH_PRICE',
|
'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
|
# 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_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_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_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')
|
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_rgb': data['color']['rgb'],
|
||||||
'color_transparent': data['color']['is_trans'],
|
'color_transparent': data['color']['is_trans'],
|
||||||
'bricklink_color_id': None,
|
'bricklink_color_id': None,
|
||||||
'bricklink_color_name': None,
|
# Empty-string sentinel (not NULL) means "fetched from Rebrickable,
|
||||||
'bricklink_part_num': None,
|
# 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'],
|
'name': data['part']['name'],
|
||||||
'category': data['part']['part_cat_id'],
|
'category': data['part']['part_cat_id'],
|
||||||
'image': data['part']['part_img_url'],
|
'image': data['part']['part_img_url'],
|
||||||
|
|||||||
+50
-10
@@ -4,7 +4,7 @@ import os
|
|||||||
import time
|
import time
|
||||||
from typing import Any, Final
|
from typing import Any, Final
|
||||||
|
|
||||||
from flask import current_app
|
from flask import current_app, url_for
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -363,23 +363,34 @@ class BrickSidecar(object):
|
|||||||
def get_images(ref: str, /) -> dict[str, Any] | None:
|
def get_images(ref: str, /) -> dict[str, Any] | None:
|
||||||
return BrickSidecar._get_json('/sets/{ref}/images'.format(ref=ref))
|
return BrickSidecar._get_json('/sets/{ref}/images'.format(ref=ref))
|
||||||
|
|
||||||
# Build the image-proxy URL for direct use in an <img src> (no server
|
# Build a BrickTracker-served proxy URL for an <img src>. The browser hits
|
||||||
# round-trip needed to preview). Does not hit the network.
|
# 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
|
@staticmethod
|
||||||
def image_proxy_url(image_type: str, ref: str, /) -> str | None:
|
def image_proxy_url(image_type: str, ref: str, /) -> str | None:
|
||||||
if image_type not in IMAGE_TYPES or not BrickSidecar.enabled():
|
if image_type not in IMAGE_TYPES or not BrickSidecar.enabled():
|
||||||
return None
|
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(
|
return '{base}/images/bricklink/{type}/{ref}.png'.format(
|
||||||
base=BrickSidecar.base_url(),
|
base=BrickSidecar.base_url(),
|
||||||
type=image_type,
|
type=image_type,
|
||||||
ref=ref,
|
ref=ref,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build the URL of one Brickset additional image (0-indexed) for direct use
|
# Build a BrickTracker-served proxy URL for one Brickset additional image
|
||||||
# in an <img src>. Unlike box/instruction art these come from Brickset, not
|
# (0-indexed) for use in an <img src>. Like image_proxy_url, the browser
|
||||||
# the BrickLink image proxy. Pass thumbnail=True for the smaller variant.
|
# talks only to BrickTracker, which fetches from the sidecar server-side.
|
||||||
# Does not hit the network. Returns None when the sidecar is disabled.
|
# Pass thumbnail=True for the smaller variant. Returns None when disabled.
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def additional_image_url(
|
def additional_image_url(
|
||||||
ref: str,
|
ref: str,
|
||||||
@@ -391,6 +402,27 @@ class BrickSidecar(object):
|
|||||||
if not BrickSidecar.enabled():
|
if not BrickSidecar.enabled():
|
||||||
return None
|
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(
|
url = '{base}/sets/{ref}/additional-images/{index}'.format(
|
||||||
base=BrickSidecar.base_url(),
|
base=BrickSidecar.base_url(),
|
||||||
ref=ref,
|
ref=ref,
|
||||||
@@ -405,8 +437,16 @@ class BrickSidecar(object):
|
|||||||
# Fetch the raw bytes of one Brickset additional image (used when saving it
|
# Fetch the raw bytes of one Brickset additional image (used when saving it
|
||||||
# as the cover). Returns None on any failure or a 404.
|
# as the cover). Returns None on any failure or a 404.
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def fetch_additional_image_bytes(ref: str, index: int, /) -> bytes | None:
|
def fetch_additional_image_bytes(
|
||||||
url = BrickSidecar.additional_image_url(ref, index)
|
ref: str,
|
||||||
|
index: int,
|
||||||
|
/,
|
||||||
|
*,
|
||||||
|
thumbnail: bool = False,
|
||||||
|
) -> bytes | None:
|
||||||
|
url = BrickSidecar._remote_additional_image_url(
|
||||||
|
ref, index, thumbnail=thumbnail,
|
||||||
|
)
|
||||||
if url is None:
|
if url is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -416,7 +456,7 @@ class BrickSidecar(object):
|
|||||||
# Returns None on any failure or a 404 (BrickLink has no such image).
|
# Returns None on any failure or a 404 (BrickLink has no such image).
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def fetch_image_bytes(image_type: str, ref: str, /) -> bytes | None:
|
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:
|
if url is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -67,10 +67,13 @@ def summarize(
|
|||||||
'notes': _description(data.get('notes')),
|
'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'))
|
retired, exit_year = _retired(data.get('exitDate'))
|
||||||
summary['retired'] = retired
|
summary['retired'] = retired
|
||||||
summary['exit_year'] = exit_year
|
summary['exit_year'] = exit_year
|
||||||
|
summary['exit_date'] = _format_date(data.get('exitDate'))
|
||||||
summary['launch_year'] = _year(data.get('launchDate'))
|
summary['launch_year'] = _year(data.get('launchDate'))
|
||||||
|
|
||||||
# --- Prices: paid / retail / market --------------------------------
|
# --- Prices: paid / retail / market --------------------------------
|
||||||
@@ -111,9 +114,15 @@ def summarize(
|
|||||||
if msrp is not None and paid is not None:
|
if msrp is not None and paid is not None:
|
||||||
prices['savings_vs_msrp'] = round(msrp - paid, 2)
|
prices['savings_vs_msrp'] = round(msrp - paid, 2)
|
||||||
|
|
||||||
# Value movement vs what was paid (using the sealed/new market average,
|
# Value movement vs what was paid. SIDECAR_PRICE_BASIS picks which market
|
||||||
# falling back to used).
|
# average to compare against ('used' or 'new'); fall back to the other when
|
||||||
market_ref = market_new if market_new is not None else market_used
|
# 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:
|
if market_ref is not None and paid is not None:
|
||||||
prices['gain_vs_paid'] = round(market_ref - paid, 2)
|
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
|
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]:
|
def _retired(exit_date: Any) -> tuple[bool, int | None]:
|
||||||
parsed = _parse_date(exit_date)
|
parsed = _parse_date(exit_date)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
return False, None
|
return False, None
|
||||||
return parsed < datetime.now(timezone.utc), parsed.year
|
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"
|
) "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
|
WHERE "null_rgb" > 0
|
||||||
OR "null_transparent" > 0
|
OR "null_transparent" > 0
|
||||||
OR "null_url" > 0
|
OR "null_url" > 0
|
||||||
OR "null_bricklink_color_id" > 0
|
|
||||||
OR "null_bricklink_color_name" > 0
|
OR "null_bricklink_color_name" > 0
|
||||||
OR "null_bricklink_part_num" > 0
|
OR "null_bricklink_part_num" > 0
|
||||||
) "null_join"
|
) "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 .exceptions import exception_handler
|
||||||
from ..retired_list import BrickRetiredList
|
from ..retired_list import BrickRetiredList
|
||||||
|
from ..sidecar_set import retirement_dates
|
||||||
from ..wish import BrickWish
|
from ..wish import BrickWish
|
||||||
from ..wish_list import BrickWishList
|
from ..wish_list import BrickWishList
|
||||||
from ..wish_owner_list import BrickWishOwnerList
|
from ..wish_owner_list import BrickWishOwnerList
|
||||||
@@ -23,10 +24,16 @@ wish_page = Blueprint('wish', __name__, url_prefix='/wishes')
|
|||||||
@wish_page.route('/', methods=['GET'])
|
@wish_page.route('/', methods=['GET'])
|
||||||
@exception_handler(__file__)
|
@exception_handler(__file__)
|
||||||
def list() -> str:
|
def list() -> str:
|
||||||
|
table_collection = BrickWishList().all()
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'wishes.html',
|
'wishes.html',
|
||||||
table_collection=BrickWishList().all(),
|
table_collection=table_collection,
|
||||||
retired=BrickRetiredList(),
|
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'),
|
error=request.args.get('error'),
|
||||||
owners=BrickWishOwnerList.list(),
|
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) {
|
if (!this.body) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.emptyRow) {
|
// Render the empty-state message as a sibling of the table rather than a
|
||||||
const columns = this.table.querySelectorAll('thead tr:first-child th').length || 1;
|
// tbody row. The sortable library iterates every <tr> in the tbody and
|
||||||
this.emptyRow = document.createElement('tr');
|
// reads row.cells[columnIndex], so a single colspan cell would make it
|
||||||
this.emptyRow.className = 'parts-filter-empty no-sort';
|
// crash with "can't access property dataset" when sorting any column
|
||||||
this.emptyRow.innerHTML = `<td colspan="${columns}" class="text-center text-body-secondary py-3">No matching parts</td>`;
|
// past the first.
|
||||||
this.body.appendChild(this.emptyRow);
|
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() {
|
clear() {
|
||||||
|
|||||||
@@ -53,6 +53,18 @@
|
|||||||
object-fit:contain;
|
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 {
|
.table-td-input {
|
||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -72,7 +72,7 @@
|
|||||||
{% if not bulk %}
|
{% if not bulk %}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="add-check-box-art"
|
<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">
|
data-bs-toggle="modal" data-bs-target="#addBoxArtModal">
|
||||||
<i class="ri-eye-line"></i> Check box art
|
<i class="ri-eye-line"></i> Check box art
|
||||||
</button>
|
</button>
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
var modal = document.getElementById('addBoxArtModal');
|
var modal = document.getElementById('addBoxArtModal');
|
||||||
if (!modal) { return; }
|
if (!modal) { return; }
|
||||||
modal.addEventListener('show.bs.modal', function () {
|
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();
|
var ref = (document.getElementById('add-set').value || '').trim().split(',')[0].trim();
|
||||||
// BrickTracker appends "-1" to a bare set number; match that so
|
// BrickTracker appends "-1" to a bare set number; match that so
|
||||||
// the sidecar lookup works (e.g. "10179" -> "10179-1").
|
// the sidecar lookup works (e.g. "10179" -> "10179-1").
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
empty.classList.add('d-none');
|
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');
|
image.classList.remove('d-none');
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -554,13 +554,6 @@
|
|||||||
</label>
|
</label>
|
||||||
<input type="number" class="form-control config-number" id="BK_SIDECAR_TIMEOUT" data-var="BK_SIDECAR_TIMEOUT" min="1" max="120">
|
<input type="number" class="form-control config-number" id="BK_SIDECAR_TIMEOUT" data-var="BK_SIDECAR_TIMEOUT" min="1" max="120">
|
||||||
</div>
|
</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">
|
<div class="col-md-6">
|
||||||
<label for="BK_SIDECAR_RETAIL_REGION" class="form-label">
|
<label for="BK_SIDECAR_RETAIL_REGION" class="form-label">
|
||||||
BK_SIDECAR_RETAIL_REGION {{ config_badges('BK_SIDECAR_RETAIL_REGION') }}
|
BK_SIDECAR_RETAIL_REGION {{ config_badges('BK_SIDECAR_RETAIL_REGION') }}
|
||||||
@@ -582,6 +575,13 @@
|
|||||||
</label>
|
</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') }}>
|
<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>
|
||||||
|
<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="col-md-6 d-flex align-items-center">
|
||||||
<div class="form-check form-switch">
|
<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') }}>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Advanced Settings -->
|
<!-- Advanced Settings -->
|
||||||
|
|||||||
@@ -197,6 +197,7 @@
|
|||||||
{% if request.endpoint == 'set.details' %}
|
{% 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-table-filter.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.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/set-details.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='scripts/quick-add-individual-part.js') }}"></script>
|
<script src="{{ url_for('static', filename='scripts/quick-add-individual-part.js') }}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -205,9 +206,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if request.endpoint == 'individual_part.lot_details' %}
|
{% 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-bulk-operations.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='scripts/parts-audit-mode.js') }}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if request.endpoint == 'individual_minifigure.details' %}
|
{% 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-bulk-operations.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='scripts/parts-audit-mode.js') }}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if request.endpoint == 'statistics.overview' %}
|
{% if request.endpoint == 'statistics.overview' %}
|
||||||
<script src="{{ url_for('static', filename='scripts/statistics.js') }}"></script>
|
<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_*). #}
|
when the data is absent (so they are safe to put anywhere in BADGE_ORDER_*). #}
|
||||||
{% macro retired(summary, solo=false, last=false) %}
|
{% macro retired(summary, solo=false, last=false) %}
|
||||||
{% if summary and summary.retired %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endmacro %}
|
{% 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="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>
|
<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 %}
|
{% 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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
|
|||||||
@@ -22,9 +22,9 @@
|
|||||||
</td>
|
</td>
|
||||||
{% if not no_quantity %}
|
{% if not no_quantity %}
|
||||||
{% if all %}
|
{% if all %}
|
||||||
<td>{{ item.fields.total_quantity }}</td>
|
<td data-col="quantity">{{ item.fields.total_quantity }}</td>
|
||||||
{% else %}
|
{% 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 %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
|
{% 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. #}
|
by macro/badge.html via BADGE_ORDER_DETAIL, so they are not repeated here. #}
|
||||||
|
|
||||||
{# --- Designer (web-scraped; the API does not expose it) --- #}
|
{# --- 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="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 class="small text-muted"><i class="ri-pencil-ruler-2-line me-1"></i>Designer: {{ s.designer }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# --- Price comparison: paid / retail / worth now --- #}
|
{# --- Price comparison: paid / retail / worth now. Each row group has its own
|
||||||
{% if p.paid is not none or p.msrp is not none or p.msrp_inflated is not none or p.has_market %}
|
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="card-body border-bottom-0 pt-0">
|
||||||
<div class="border rounded p-2">
|
<div class="border rounded p-2">
|
||||||
<div class="fw-bold small mb-1"><i class="ri-scales-line me-1"></i>Price comparison</div>
|
<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">
|
<table class="table table-sm mb-1 small">
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{% if show_paid %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Paid</td>
|
<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>
|
<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>
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if show_msrp %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Retail (MSRP)</td>
|
<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>
|
<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>
|
<td class="text-end text-muted">{{ '%.2f' | format(p.msrp_inflated) }} {{ p.msrp_inflated_currency }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if show_market %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Worth now (new)</td>
|
<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>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Worth now (used)</td>
|
<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>
|
</tr>
|
||||||
{% if p.savings_vs_msrp is defined %}
|
{% endif %}
|
||||||
|
{% if show_msrp and p.savings_vs_msrp is defined %}
|
||||||
<tr class="border-top">
|
<tr class="border-top">
|
||||||
<td>Saved vs retail</td>
|
<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>
|
<td class="text-end {% if p.savings_vs_msrp >= 0 %}text-success{% else %}text-danger{% endif %}">{{ '%+.2f' | format(p.savings_vs_msrp) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if p.gain_vs_paid is defined %}
|
{% if show_paid and p.gain_vs_paid is defined %}
|
||||||
<tr>
|
<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>
|
<td class="text-end {% if p.gain_vs_paid >= 0 %}text-success{% else %}text-danger{% endif %}">{{ '%+.2f' | format(p.gain_vs_paid) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -73,7 +83,7 @@
|
|||||||
|
|
||||||
{# --- Sidecar description, styled like notes. This is the official set blurb,
|
{# --- 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. --- #}
|
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="card-body border-bottom-0 pt-0">
|
||||||
<div class="alert alert-secondary mb-0" role="alert">
|
<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>
|
<div class="fw-bold small mb-1"><i class="ri-information-line me-1"></i>About this set</div>
|
||||||
@@ -83,7 +93,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# --- Brickset Notes (web-scraped; trivia / variants / packaging quirks). --- #}
|
{# --- 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="card-body border-bottom-0 pt-0">
|
||||||
<div class="alert alert-light border mb-0" role="alert">
|
<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>
|
<div class="fw-bold small mb-1"><i class="ri-sticky-note-line me-1"></i>Notes</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for item in table_collection %}
|
{% 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>
|
<tr>
|
||||||
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.set) }}
|
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.set) }}
|
||||||
<td>{{ item.fields.set }} {{ table.rebrickable(item) }}</td>
|
<td>{{ item.fields.set }} {{ table.rebrickable(item) }}</td>
|
||||||
@@ -28,7 +28,22 @@
|
|||||||
<td>{{ item.theme.name }}</td>
|
<td>{{ item.theme.name }}</td>
|
||||||
<td>{{ item.fields.year }}</td>
|
<td>{{ item.fields.year }}</td>
|
||||||
<td>{{ item.fields.number_of_parts }}</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>
|
<td>
|
||||||
{% for owner in owners %}
|
{% for owner in owners %}
|
||||||
{{ badge.owner(item, owner) }}
|
{{ badge.owner(item, owner) }}
|
||||||
|
|||||||
Reference in New Issue
Block a user