Compare commits

..

53 Commits

Author SHA1 Message Date
FrederikBaerentsen d2d388b142 Merge branch 'release/1.3' into feature/pagination 2025-09-24 21:47:54 +02:00
FrederikBaerentsen acf06e1955 Updated change log 2025-09-24 21:36:40 +02:00
FrederikBaerentsen c465e9814c Fixed duplicate color in parts dropdown 2025-09-24 21:24:51 +02:00
FrederikBaerentsen 046493294f Moved sort/filter buttons 2025-09-24 20:44:50 +02:00
FrederikBaerentsen 1096fbdef6 Fixed sorting icon on sets page 2025-09-24 20:40:46 +02:00
FrederikBaerentsen fc405e0832 Consolidated parts.js, problems.js and minifigures.js 2025-09-24 20:18:30 +02:00
FrederikBaerentsen cce96af09b Consolidate duplicate collapsible state management 2025-09-24 19:53:01 +02:00
FrederikBaerentsen f953a44593 Disabled table sort using headers, if server-side pagination is enabled. 2025-09-24 19:08:34 +02:00
FrederikBaerentsen e87cb90e20 Updated gitignore 2025-09-23 18:07:42 +02:00
FrederikBaerentsen f3fada9dd8 Updated gitignore 2025-09-23 17:58:15 +02:00
FrederikBaerentsen 4eae6b19dc Updated gitignore 2025-09-23 17:55:26 +02:00
FrederikBaerentsen 064b79bf9e Merge remote-tracking branch 'origin/master' into feature/pagination 2025-09-23 17:24:58 +02:00
FrederikBaerentsen 7c1cb66f67 Merge pull request 'hotfix/pagination-bug' (#99) from hotfix/pagination-bug into master
Reviewed-on: FrederikBaerentsen/BrickTracker#99
2025-09-23 17:16:30 +02:00
FrederikBaerentsen 5641b3e740 Merge branch 'master' into hotfix/pagination-bug 2025-09-23 17:12:37 +02:00
FrederikBaerentsen 9317a1baae Removed code for another feature 2025-09-23 17:10:13 +02:00
FrederikBaerentsen 6f6d90aa60 fix(pagination): Fixed socket gevent (#95) 2025-09-23 17:06:18 +02:00
FrederikBaerentsen 83a45795c3 Merge pull request 'fix(pagination): Fix #95. Switch from eventlet to gevent' (#98) from hotfix/pagination-bug into master
Reviewed-on: FrederikBaerentsen/BrickTracker#98
2025-09-23 16:51:27 +02:00
FrederikBaerentsen 572c52dada fix(pagination): Added requirements.txt 2025-09-23 16:46:43 +02:00
FrederikBaerentsen 909655c10a fix(pagination): Fix #95. Switch from eventlet to gevent 2025-09-23 16:42:03 +02:00
FrederikBaerentsen d1b79de411 Updated .env.sample with new variables 2025-09-23 16:41:38 +02:00
FrederikBaerentsen 1e767537b9 fix(pagination): Fix #95. Switch from eventlet to gevent 2025-09-23 16:36:22 +02:00
FrederikBaerentsen 8ee0d144be Updated gitignore 2025-09-23 15:16:51 +02:00
FrederikBaerentsen f7963b4723 Removed datatable-search field from minifigures page 2025-09-22 10:08:41 +02:00
FrederikBaerentsen 52b6c94483 Fixed problems pagination 2025-09-22 10:01:16 +02:00
FrederikBaerentsen b5236fae51 Added filter/search/pagination to 'Problems' 2025-09-22 09:36:25 +02:00
FrederikBaerentsen 9d0a48ee2a Fixed gitignore 2025-09-21 19:03:08 +02:00
FrederikBaerentsen 5677d731e4 Updated gitignore 2025-09-21 18:56:56 +02:00
FrederikBaerentsen fcdcd12184 Updated .env sample file 2025-09-21 18:21:29 +02:00
FrederikBaerentsen e1891e8bd6 Added more pagination options 2025-09-21 18:18:26 +02:00
FrederikBaerentsen af53b29818 Removed print log spam 2025-09-21 17:32:11 +02:00
FrederikBaerentsen 8a0a7837dc Fixed filtering on /sets page. 2025-09-21 17:26:57 +02:00
FrederikBaerentsen 4b3aef577a Fixed sorting and filtering on /sets. 2025-09-21 15:58:32 +02:00
FrederikBaerentsen 9a32a3f193 Merge remote-tracking branch 'origin/master' into feature/pagination 2025-09-17 18:32:53 +02:00
FrederikBaerentsen c71667cd41 Fix: #80, default images not downloading (also present in feature/pagination) 2025-09-17 18:07:28 +02:00
FrederikBaerentsen 421d635dd3 Moved import and added ignore to BeautifulSoup type annotation issues 2025-09-17 17:03:24 +02:00
FrederikBaerentsen 6bc406b70d Fixed broken wishlist page 2025-09-17 16:49:53 +02:00
FrederikBaerentsen 5fa145a9d7 Fixed pagination button size 2025-09-17 16:34:29 +02:00
FrederikBaerentsen 3bfd1c17dd Sets, Parts and Minifigures have pagination now 2025-09-17 07:06:34 +02:00
FrederikBaerentsen 46dada312a Added page size option 2025-09-16 18:26:21 +02:00
FrederikBaerentsen c876e1e3a4 Added pagination to /parts page. 2025-09-16 15:30:54 +02:00
FrederikBaerentsen 787a376553 Fixed sorting issue on minifigs, updated change log and version. 2025-09-16 10:44:47 +02:00
FrederikBaerentsen 7f4be9da36 Updated gitignore 2025-09-16 10:43:52 +02:00
FrederikBaerentsen d6f69bca9d Merge pull request 'Added migration to get new bricklink data fields, fixed bricklink links, added set refresh based on missing bricklink data' (#88) from feature/bricklink-data into master
Reviewed-on: FrederikBaerentsen/BrickTracker#88

Fixes #87 and #55
2025-09-16 10:34:44 +02:00
FrederikBaerentsen 3adeef086b Added migration to get new bricklink data fields, fixed bricklink links, added set refresh based on missing bricklink data 2025-09-16 09:43:01 +02:00
FrederikBaerentsen 40b63fff6a Merge pull request 'Added filter/sort/search to /minifigures and /parts' (#86) from feature/sort-search-filter into master
Reviewed-on: FrederikBaerentsen/BrickTracker#86
2025-09-16 09:37:33 +02:00
FrederikBaerentsen 1cac17a420 Added filter/sort/search to /minifigures and /parts 2025-09-15 20:29:26 +02:00
FrederikBaerentsen 7bfbbbf298 Merge pull request 'Fixed the rebrickable scraping to deal with changes' (#81) from hiddenside/BrickTracker:fix-instructions-download into master
Reviewed-on: FrederikBaerentsen/BrickTracker#81
2025-08-08 19:47:14 +02:00
hiddenside 79f348178c Tweaks to get the progress bar working as expected. 2025-08-02 12:01:01 -07:00
jl 07be7b6004 Fixed the rebrickable scraping to deal with changes
Created a common naming schema for the instructions when downloaded
	setnumber-set-name-rebrickable-name
so set 3816-1 Glove World would end up
	3816-1-Glove-World-BI-3004-32-3816-V-29-39
If there is ever a duplicate name it appends _1+++
2025-08-02 12:01:01 -07:00
FrederikBaerentsen cb24cfc014 Merge pull request 'Fix legibility of "Damaged" and "Missing" fields for tiny screen by reducing horizontal padding' (#74) from gregoo/BrickTracker:master into master
Reviewed-on: FrederikBaerentsen/BrickTracker#74
2025-08-02 13:20:37 +02:00
FrederikBaerentsen 418bd5cd9d Merge pull request 'Fixed broken URLs in quickstart.md and setup.md' (#75) from KingColton1/BrickTracker:master into master
Reviewed-on: FrederikBaerentsen/BrickTracker#75
2025-08-02 13:20:17 +02:00
kingcolton 9953e3921a Fixed broken URLs in quickstart.md and setup.md 2025-04-08 19:10:59 -04:00
gregoo 2d0fa7bf89 Fix legibility of "Damaged" and "Missing" fields for tiny screen by reducing horizontal padding 2025-03-31 16:08:53 +02:00
72 changed files with 3450 additions and 222 deletions
+43 -1
View File
@@ -28,7 +28,8 @@
# BK_AUTHENTICATION_KEY=change-this-to-something-random # BK_AUTHENTICATION_KEY=change-this-to-something-random
# Optional: Pattern of the link to Bricklink for a part. Will be passed to Python .format() # Optional: Pattern of the link to Bricklink for a part. Will be passed to Python .format()
# Default: https://www.bricklink.com/v2/catalog/catalogitem.page?P={part} # Supports {part} and {color} parameters. BrickLink part numbers and color IDs are used when available.
# Default: https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}&C={color}
# BK_BRICKLINK_LINK_PART_PATTERN= # BK_BRICKLINK_LINK_PART_PATTERN=
# Optional: Display Bricklink links wherever applicable # Optional: Display Bricklink links wherever applicable
@@ -169,6 +170,47 @@
# Default: parts # Default: parts
# BK_PARTS_FOLDER=parts # BK_PARTS_FOLDER=parts
# Optional: Enable server-side pagination for individual pages (recommended for large collections)
# When enabled, pages use server-side pagination with configurable page sizes
# When disabled, pages load all data at once with instant client-side search
# Default: false for all
# BK_SETS_SERVER_SIDE_PAGINATION=true
# BK_PARTS_SERVER_SIDE_PAGINATION=true
# BK_MINIFIGURES_SERVER_SIDE_PAGINATION=true
# BK_PROBLEMS_SERVER_SIDE_PAGINATION=true
# Optional: Number of parts to show per page on desktop devices (when server-side pagination is enabled)
# Default: 10
# BK_PARTS_PAGINATION_SIZE_DESKTOP=10
# Optional: Number of parts to show per page on mobile devices (when server-side pagination is enabled)
# Default: 5
# BK_PARTS_PAGINATION_SIZE_MOBILE=5
# Optional: Number of sets to show per page on desktop devices (when server-side pagination is enabled)
# Should be divisible by 4 for grid layout. Default: 12
# BK_SETS_PAGINATION_SIZE_DESKTOP=12
# Optional: Number of sets to show per page on mobile devices (when server-side pagination is enabled)
# Default: 4
# BK_SETS_PAGINATION_SIZE_MOBILE=4
# Optional: Number of minifigures to show per page on desktop devices (when server-side pagination is enabled)
# Default: 10
# BK_MINIFIGURES_PAGINATION_SIZE_DESKTOP=10
# Optional: Number of minifigures to show per page on mobile devices (when server-side pagination is enabled)
# Default: 5
# BK_MINIFIGURES_PAGINATION_SIZE_MOBILE=5
# Optional: Number of problems to show per page on desktop devices (when server-side pagination is enabled)
# Default: 10
# BK_PROBLEMS_PAGINATION_SIZE_DESKTOP=10
# Optional: Number of problems to show per page on mobile devices (when server-side pagination is enabled)
# Default: 5
# BK_PROBLEMS_PAGINATION_SIZE_MOBILE=5
# Optional: Port the server will listen on. # Optional: Port the server will listen on.
# Default: 3333 # Default: 3333
# BK_PORT=3333 # BK_PORT=3333
+9
View File
@@ -21,6 +21,15 @@ static/sets/
# Temporary # Temporary
*.csv *.csv
/local/ /local/
run_local.sh
settings.local.json
# Apple idiocy # Apple idiocy
.DS_Store .DS_Store
# Documentation
docusaurus/
vitepress/
# Local data
offline/
+45
View File
@@ -1,5 +1,50 @@
# Changelog # Changelog
## Unreleased
### 1.3
- Add individual pagination control system per entity type
- `BK_SETS_SERVER_SIDE_PAGINATION`: Enable/disable pagination for sets
- `BK_PARTS_SERVER_SIDE_PAGINATION`: Enable/disable pagination for parts
- `BK_MINIFIGURES_SERVER_SIDE_PAGINATION`: Enable/disable pagination for minifigures
- Device-specific pagination sizes (desktop/mobile) for each entity type
- Supports search, filtering, and sorting in both server-side and client-side modes
- Consolidated duplicate code across parts.js, problems.js, and minifigures.js
- Created shared functions in collapsible-state.js for common operations
- Fixed dynamic sort icons across all pages
- Sort icons now properly toggle between ascending/descending states
- Improved DataTable integration
- Disabled column header sorting when server-side pagination is enabled
- Prevents conflicting sort mechanisms between DataTable and server-side sorting
- Enhanced color dropdown functionality
- Automatic merging of duplicate color entries with same color_id
- Keeps entries with valid RGB data, removes entries with None/empty RGB
- Preserves selection state during dropdown consolidation
- Consistent search behavior (instant for client-side, Enter key for server-side)
- Mobile-friendly pagination navigation
### 1.2.4
> **Warning**
> To use the new BrickLink color parameter in URLs, update your `.env` file:
> `BK_BRICKLINK_LINK_PART_PATTERN=https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}&C={color}`
- Add BrickLink color and part number support for accurate BrickLink URLs
- Database migrations to store BrickLink color ID, color name, and part number
- Updated Rebrickable API integration to extract BrickLink data from external_ids
- Enhanced BrickLink URL generation with proper part number fallback
- Extended admin set refresh to detect and track missing BrickLink data
## 1.2.3
Added search/filter/sort options to `parts` and `minifigures`.
## 1.2.2
Fix legibility of "Damaged" and "Missing" fields for tiny screen by reducing horizontal padding
Fixed instructions download from Rebrickable
## 1.2.2: ## 1.2.2:
This release fixes a bug where orphaned parts in the `inventory` table are blocking the database upgrade. This release fixes a bug where orphaned parts in the `inventory` table are blocking the database upgrade.
+3
View File
@@ -5,6 +5,9 @@ WORKDIR /app
# Bricktracker # Bricktracker
COPY . . COPY . .
# Fix line endings and set executable permissions for entrypoint script
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh
# Python library requirements # Python library requirements
RUN pip --no-cache-dir install -r requirements.txt RUN pip --no-cache-dir install -r requirements.txt
+2 -2
View File
@@ -1,6 +1,6 @@
# This need to be first # This need to be first
import eventlet import gevent.monkey
eventlet.monkey_patch() gevent.monkey.patch_all()
import logging # noqa: E402 import logging # noqa: E402
+4
View File
@@ -10,6 +10,7 @@ from bricktracker.configuration_list import BrickConfigurationList
from bricktracker.login import LoginManager from bricktracker.login import LoginManager
from bricktracker.navbar import Navbar from bricktracker.navbar import Navbar
from bricktracker.sql import close from bricktracker.sql import close
from bricktracker.template_filters import replace_query_filter
from bricktracker.version import __version__ from bricktracker.version import __version__
from bricktracker.views.add import add_page from bricktracker.views.add import add_page
from bricktracker.views.admin.admin import admin_page from bricktracker.views.admin.admin import admin_page
@@ -121,6 +122,9 @@ def setup_app(app: Flask) -> None:
# Version # Version
g.version = __version__ g.version = __version__
# Register custom Jinja2 filters
app.jinja_env.filters['replace_query'] = replace_query_filter
# Make sure all connections are closed at the end # Make sure all connections are closed at the end
@app.teardown_request @app.teardown_request
def teardown_request(_: BaseException | None) -> None: def teardown_request(_: BaseException | None) -> None:
+19 -1
View File
@@ -10,7 +10,7 @@ from typing import Any, Final
CONFIG: Final[list[dict[str, Any]]] = [ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'AUTHENTICATION_PASSWORD', 'd': ''}, {'n': 'AUTHENTICATION_PASSWORD', 'd': ''},
{'n': 'AUTHENTICATION_KEY', 'd': ''}, {'n': 'AUTHENTICATION_KEY', 'd': ''},
{'n': 'BRICKLINK_LINK_PART_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}'}, # noqa: E501 {'n': 'BRICKLINK_LINK_PART_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}&C={color}'}, # noqa: E501
{'n': 'BRICKLINK_LINKS', 'c': bool}, {'n': 'BRICKLINK_LINKS', 'c': bool},
{'n': 'DATABASE_PATH', 'd': './app.db'}, {'n': 'DATABASE_PATH', 'd': './app.db'},
{'n': 'DATABASE_TIMESTAMP_FORMAT', 'd': '%Y-%m-%d-%H-%M-%S'}, {'n': 'DATABASE_TIMESTAMP_FORMAT', 'd': '%Y-%m-%d-%H-%M-%S'},
@@ -37,9 +37,21 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'HIDE_WISHES', 'c': bool}, {'n': 'HIDE_WISHES', 'c': bool},
{'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"rebrickable_minifigures"."name" ASC'}, # noqa: E501 {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"rebrickable_minifigures"."name" ASC'}, # noqa: E501
{'n': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True}, {'n': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True},
{'n': 'MINIFIGURES_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int},
{'n': 'MINIFIGURES_PAGINATION_SIZE_MOBILE', 'd': 5, 'c': int},
{'n': 'MINIFIGURES_SERVER_SIDE_PAGINATION', 'c': bool},
{'n': 'NO_THREADED_SOCKET', 'c': bool}, {'n': 'NO_THREADED_SOCKET', 'c': bool},
{'n': 'PARTS_SERVER_SIDE_PAGINATION', 'c': bool},
{'n': 'SETS_SERVER_SIDE_PAGINATION', 'c': bool},
{'n': 'PARTS_DEFAULT_ORDER', 'd': '"rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC'}, # noqa: E501 {'n': 'PARTS_DEFAULT_ORDER', 'd': '"rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC'}, # noqa: E501
{'n': 'PARTS_FOLDER', 'd': 'parts', 's': True}, {'n': 'PARTS_FOLDER', 'd': 'parts', 's': True},
{'n': 'PARTS_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int},
{'n': 'PARTS_PAGINATION_SIZE_MOBILE', 'd': 5, 'c': int},
{'n': 'PROBLEMS_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int},
{'n': 'PROBLEMS_PAGINATION_SIZE_MOBILE', 'd': 10, 'c': int},
{'n': 'PROBLEMS_SERVER_SIDE_PAGINATION', 'c': bool},
{'n': 'SETS_PAGINATION_SIZE_DESKTOP', 'd': 12, 'c': int},
{'n': 'SETS_PAGINATION_SIZE_MOBILE', 'd': 4, 'c': int},
{'n': 'PORT', 'd': 3333, 'c': int}, {'n': 'PORT', 'd': 3333, 'c': int},
{'n': 'PURCHASE_DATE_FORMAT', 'd': '%d/%m/%Y'}, {'n': 'PURCHASE_DATE_FORMAT', 'd': '%d/%m/%Y'},
{'n': 'PURCHASE_CURRENCY', 'd': ''}, {'n': 'PURCHASE_CURRENCY', 'd': ''},
@@ -52,6 +64,12 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{part}/_/{color}'}, # noqa: E501 {'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{part}/_/{color}'}, # noqa: E501
{'n': 'REBRICKABLE_LINK_INSTRUCTIONS_PATTERN', 'd': 'https://rebrickable.com/instructions/{path}'}, # noqa: E501 {'n': 'REBRICKABLE_LINK_INSTRUCTIONS_PATTERN', 'd': 'https://rebrickable.com/instructions/{path}'}, # noqa: E501
{'n': 'REBRICKABLE_USER_AGENT', 'd': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}, # noqa: E501 {'n': 'REBRICKABLE_USER_AGENT', 'd': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}, # noqa: E501
{'n': 'USER_AGENT', 'd': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}, # noqa: E501
{'n': 'PEERON_DOWNLOAD_DELAY', 'd': 1000, 'c': int},
{'n': 'PEERON_INSTRUCTION_PATTERN', 'd': 'http://peeron.com/scans/{set_number}-{version_number}'},
{'n': 'PEERON_MIN_IMAGE_SIZE', 'd': 100, 'c': int},
{'n': 'PEERON_SCAN_PATTERN', 'd': 'http://belay.peeron.com/scans/{set_number}-{version_number}/'},
{'n': 'PEERON_THUMBNAIL_PATTERN', 'd': 'http://belay.peeron.com/thumbs/{set_number}-{version_number}/'},
{'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool}, {'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool},
{'n': 'REBRICKABLE_PAGE_SIZE', 'd': 100, 'c': int}, {'n': 'REBRICKABLE_PAGE_SIZE', 'd': 100, 'c': int},
{'n': 'RETIRED_SETS_FILE_URL', 'd': 'https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date'}, # noqa: E501 {'n': 'RETIRED_SETS_FILE_URL', 'd': 'https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date'}, # noqa: E501
+93 -97
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timezone from datetime import datetime, timezone
import logging import logging
import os import os
from urllib.parse import urljoin
from shutil import copyfileobj from shutil import copyfileobj
import traceback import traceback
from typing import Tuple, TYPE_CHECKING from typing import Tuple, TYPE_CHECKING
@@ -11,6 +12,8 @@ import humanize
import requests import requests
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
import re
import cloudscraper
from .exceptions import ErrorException, DownloadException from .exceptions import ErrorException, DownloadException
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -89,91 +92,72 @@ class BrickInstructions(object):
# Download an instruction file # Download an instruction file
def download(self, path: str, /) -> None: def download(self, path: str, /) -> None:
"""
Streams the PDF in chunks and uses self.socket.update_total
+ self.socket.progress_count to drive a determinate bar.
"""
try: try:
# Just to make sure that the progress is initiated
self.socket.progress(
message='Downloading {file}'.format(
file=self.filename,
)
)
target = self.path(filename=secure_filename(self.filename)) target = self.path(filename=secure_filename(self.filename))
# Skipping rather than failing here # Skip if we already have it
if os.path.isfile(target): if os.path.isfile(target):
self.socket.complete( return self.socket.complete(
message='File {file} already exists, skipped'.format( message=f"File {self.filename} already exists, skipped"
file=self.filename,
)
) )
else: # Fetch PDF via cloudscraper (to bypass Cloudflare)
url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501 scraper = cloudscraper.create_scraper()
path=path scraper.headers.update({
) "User-Agent": current_app.config['REBRICKABLE_USER_AGENT']
trimmed_url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501 })
path=path.partition('/')[0] resp = scraper.get(path, stream=True)
) if not resp.ok:
raise DownloadException(f"Failed to download: HTTP {resp.status_code}")
# Request the file # Tell the socket how many bytes in total
self.socket.progress( total = int(resp.headers.get("Content-Length", 0))
message='Requesting {url}'.format( self.socket.update_total(total)
url=trimmed_url,
)
)
response = requests.get(url, stream=True) # Reset the counter and kick off at 0%
if response.ok: self.socket.progress_count = 0
self.socket.progress(message=f"Starting download {self.filename}")
# Store the content header as size # Write out in 8 KiB chunks and update the counter
try: with open(target, "wb") as f:
self.size = int( for chunk in resp.iter_content(chunk_size=8192):
response.headers.get('Content-length', 0) if not chunk:
) continue
except Exception: f.write(chunk)
self.size = 0
# Downloading the file # Bump the internal counter and emit
self.socket.progress_count += len(chunk)
self.socket.progress( self.socket.progress(
message='Downloading {url} ({size})'.format( message=(
url=trimmed_url, f"Downloading {self.filename} "
size=self.human_size(), f"({humanize.naturalsize(self.socket.progress_count)}/"
f"{humanize.naturalsize(self.socket.progress_total)})"
) )
) )
with open(target, 'wb') as f: # Done!
copyfileobj(response.raw, f) logger.info(f"Downloaded {self.filename}")
else: self.socket.complete(
raise DownloadException('failed to download: {code}'.format( # noqa: E501 message=f"File {self.filename} downloaded ({self.human_size()})"
code=response.status_code
))
# Info
logger.info('The instruction file {file} has been downloaded'.format( # noqa: E501
file=self.filename
))
# Complete
self.socket.complete(
message='File {file} downloaded ({size})'.format( # noqa: E501
file=self.filename,
size=self.human_size()
)
)
except Exception as e:
self.socket.fail(
message='Error while downloading instruction {file}: {error}'.format( # noqa: E501
file=self.filename,
error=e,
)
) )
except Exception as e:
logger.debug(traceback.format_exc()) logger.debug(traceback.format_exc())
self.socket.fail(
message=f"Error downloading {self.filename}: {e}"
)
# Display the size in a human format # Display the size in a human format
def human_size(self) -> str: def human_size(self) -> str:
return humanize.naturalsize(self.size) try:
size = self.size
except AttributeError:
size = os.path.getsize(self.path())
return humanize.naturalsize(size)
# Display the time in a human format # Display the time in a human format
def human_time(self) -> str: def human_time(self) -> str:
@@ -250,40 +234,52 @@ class BrickInstructions(object):
# Find the instructions for a set # Find the instructions for a set
@staticmethod @staticmethod
def find_instructions(set: str, /) -> list[Tuple[str, str]]: def find_instructions(set: str, /) -> list[Tuple[str, str]]:
response = requests.get( """
current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( Scrape Rebrickables HTML and return a list of
path=set, (filename_slug, download_url). Duplicate slugs get _1, _2, …
), """
headers={ page_url = f"https://rebrickable.com/instructions/{set}/"
'User-Agent': current_app.config['REBRICKABLE_USER_AGENT'] logger.debug(f"[find_instructions] fetching HTML from {page_url!r}")
}
)
if not response.ok: # Solve Cloudflares challenge
raise ErrorException('Failed to load the Rebrickable instructions page. Status code: {code}'.format( # noqa: E501 scraper = cloudscraper.create_scraper()
code=response.status_code scraper.headers.update({'User-Agent': current_app.config['REBRICKABLE_USER_AGENT']})
)) resp = scraper.get(page_url)
if not resp.ok:
raise ErrorException(f'Failed to load instructions page for {set}. HTTP {resp.status_code}')
# Parse the HTML content soup = BeautifulSoup(resp.content, 'html.parser')
soup = BeautifulSoup(response.content, 'html.parser') link_re = re.compile(r'^/instructions/\d+/.+/download/')
# Collect all <img> tags with "LEGO Building Instructions" in the raw: list[tuple[str, str]] = []
# alt attribute for a in soup.find_all('a', href=link_re):
found_tags: list[Tuple[str, str]] = [] img = a.find('img', alt=True) # type: ignore
for a_tag in soup.find_all('a', href=True): if not img or set not in img['alt']: # type: ignore
img_tag = a_tag.find('img', alt=True) continue
if img_tag and "LEGO Building Instructions" in img_tag['alt']:
found_tags.append(
(
img_tag['alt'].removeprefix('LEGO Building Instructions for '), # noqa: E501
a_tag['href']
)
) # Save alt and href
# Raise an error if nothing found # Turn the alt text into a slug
if not len(found_tags): alt_text = img['alt'].removeprefix('LEGO Building Instructions for ') # type: ignore
raise ErrorException('No instruction found for set {set}'.format( slug = re.sub(r'[^A-Za-z0-9]+', '-', alt_text).strip('-')
set=set
))
return found_tags # Build the absolute download URL
download_url = urljoin('https://rebrickable.com', a['href']) # type: ignore
raw.append((slug, download_url))
if not raw:
raise ErrorException(f'No download links found on instructions page for {set}')
# Disambiguate duplicate slugs by appending _1, _2, …
from collections import Counter, defaultdict
counts = Counter(name for name, _ in raw)
seen: dict[str, int] = defaultdict(int)
unique: list[tuple[str, str]] = []
for name, url in raw:
idx = seen[name]
if counts[name] > 1 and idx > 0:
final_name = f"{name}_{idx}"
else:
final_name = name
seen[name] += 1
unique.append((final_name, url))
return unique
+66
View File
@@ -21,6 +21,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
# Queries # Queries
all_query: str = 'minifigure/list/all' all_query: str = 'minifigure/list/all'
all_by_owner_query: str = 'minifigure/list/all_by_owner'
damaged_part_query: str = 'minifigure/list/damaged_part' damaged_part_query: str = 'minifigure/list/damaged_part'
last_query: str = 'minifigure/list/last' last_query: str = 'minifigure/list/last'
missing_part_query: str = 'minifigure/list/missing_part' missing_part_query: str = 'minifigure/list/missing_part'
@@ -42,6 +43,58 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
return self return self
# Load all minifigures by owner
def all_by_owner(self, owner_id: str | None = None, /) -> Self:
# Save the owner_id parameter
self.fields.owner_id = owner_id
# Load the minifigures from the database
self.list(override_query=self.all_by_owner_query)
return self
# Load minifigures with pagination support
def all_filtered_paginated(
self,
owner_id: str | None = None,
search_query: str | None = None,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc'
) -> tuple[Self, int]:
# Prepare filter context
filter_context = {}
if owner_id and owner_id != 'all':
filter_context['owner_id'] = owner_id
list_query = self.all_by_owner_query
else:
list_query = self.all_query
if search_query:
filter_context['search_query'] = search_query
# Field mapping for sorting
field_mapping = {
'name': '"rebrickable_minifigures"."name"',
'parts': '"rebrickable_minifigures"."number_of_parts"',
'quantity': '"total_quantity"',
'missing': '"total_missing"',
'damaged': '"total_damaged"',
'sets': '"total_sets"'
}
# Use the base pagination method
return self.paginate(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=list_query,
field_mapping=field_mapping,
**filter_context
)
# Minifigures with a part damaged part # Minifigures with a part damaged part
def damaged_part(self, part: str, color: int, /) -> Self: def damaged_part(self, part: str, color: int, /) -> Self:
# Save the parameters to the fields # Save the parameters to the fields
@@ -83,11 +136,20 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
else: else:
brickset = None brickset = None
# Prepare template context for owner filtering
context_vars = {}
if hasattr(self.fields, 'owner_id') and self.fields.owner_id is not None:
context_vars['owner_id'] = self.fields.owner_id
# Merge with any additional context passed in
context_vars.update(context)
# Load the sets from the database # Load the sets from the database
for record in super().select( for record in super().select(
override_query=override_query, override_query=override_query,
order=order, order=order,
limit=limit, limit=limit,
**context_vars
): ):
minifigure = BrickMinifigure(brickset=brickset, record=record) minifigure = BrickMinifigure(brickset=brickset, record=record)
@@ -132,6 +194,10 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
if self.brickset is not None: if self.brickset is not None:
parameters['id'] = self.brickset.fields.id parameters['id'] = self.brickset.fields.id
# Add owner_id parameter for owner filtering
if hasattr(self.fields, 'owner_id') and self.fields.owner_id is not None:
parameters['owner_id'] = self.fields.owner_id
return parameters return parameters
# Import the minifigures from Rebrickable # Import the minifigures from Rebrickable
+52
View File
@@ -0,0 +1,52 @@
from flask import current_app, request
from typing import Any, Dict, Tuple
def get_pagination_config(entity_type: str) -> Tuple[int, bool]:
"""Get pagination configuration for an entity type (sets, parts, minifigures)"""
# Check if pagination is enabled for this specific entity type
pagination_key = f'{entity_type.upper()}_SERVER_SIDE_PAGINATION'
use_pagination = current_app.config.get(pagination_key, False)
if not use_pagination:
return 0, False
# Determine page size based on device type and entity
user_agent = request.headers.get('User-Agent', '').lower()
is_mobile = any(device in user_agent for device in ['mobile', 'android', 'iphone', 'ipad'])
# Get appropriate config keys based on entity type
entity_upper = entity_type.upper()
desktop_key = f'{entity_upper}_PAGINATION_SIZE_DESKTOP'
mobile_key = f'{entity_upper}_PAGINATION_SIZE_MOBILE'
per_page = current_app.config[mobile_key] if is_mobile else current_app.config[desktop_key]
return per_page, is_mobile
def build_pagination_context(page: int, per_page: int, total_count: int, is_mobile: bool) -> Dict[str, Any]:
"""Build pagination context for templates"""
total_pages = (total_count + per_page - 1) // per_page if total_count > 0 else 1
has_prev = page > 1
has_next = page < total_pages
return {
'page': page,
'per_page': per_page,
'total_count': total_count,
'total_pages': total_pages,
'has_prev': has_prev,
'has_next': has_next,
'is_mobile': is_mobile
}
def get_request_params() -> Tuple[str, str, str, int]:
"""Extract common request parameters for pagination"""
search_query = request.args.get('search', '').strip()
sort_field = request.args.get('sort', '')
sort_order = request.args.get('order', 'asc')
page = int(request.args.get('page', 1))
return search_query, sort_field, sort_order, page
+162 -3
View File
@@ -23,6 +23,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
# Queries # Queries
all_query: str = 'part/list/all' all_query: str = 'part/list/all'
all_by_owner_query: str = 'part/list/all_by_owner'
different_color_query = 'part/list/with_different_color' different_color_query = 'part/list/with_different_color'
last_query: str = 'part/list/last' last_query: str = 'part/list/last'
minifigure_query: str = 'part/list/from_minifigure' minifigure_query: str = 'part/list/from_minifigure'
@@ -46,6 +47,88 @@ class BrickPartList(BrickRecordList[BrickPart]):
return self return self
# Load all parts by owner
def all_by_owner(self, owner_id: str | None = None, /) -> Self:
# Save the owner_id parameter
self.fields.owner_id = owner_id
# Load the parts from the database
self.list(override_query=self.all_by_owner_query)
return self
# Load all parts with filters (owner and/or color)
def all_filtered(self, owner_id: str | None = None, color_id: str | None = None, /) -> Self:
# Save the filter parameters
if owner_id is not None:
self.fields.owner_id = owner_id
if color_id is not None:
self.fields.color_id = color_id
# Choose query based on whether owner filtering is needed
if owner_id and owner_id != 'all':
query = self.all_by_owner_query
else:
query = self.all_query
# Prepare context for query
context = {}
if current_app.config.get('SKIP_SPARE_PARTS', False):
context['skip_spare_parts'] = True
# Load the parts from the database
self.list(override_query=query, **context)
return self
# Load parts with pagination support
def all_filtered_paginated(
self,
owner_id: str | None = None,
color_id: str | None = None,
search_query: str | None = None,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc'
) -> tuple[Self, int]:
# Prepare filter context
filter_context = {}
if owner_id and owner_id != 'all':
filter_context['owner_id'] = owner_id
list_query = self.all_by_owner_query
else:
list_query = self.all_query
if color_id and color_id != 'all':
filter_context['color_id'] = color_id
if search_query:
filter_context['search_query'] = search_query
if current_app.config.get('SKIP_SPARE_PARTS', False):
filter_context['skip_spare_parts'] = True
# Field mapping for sorting
field_mapping = {
'name': '"rebrickable_parts"."name"',
'color': '"rebrickable_parts"."color_name"',
'quantity': '"total_quantity"',
'missing': '"total_missing"',
'damaged': '"total_damaged"',
'sets': '"total_sets"',
'minifigures': '"total_minifigures"'
}
# Use the base pagination method
return self.paginate(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=list_query,
field_mapping=field_mapping,
**filter_context
)
# Base part list # Base part list
def list( def list(
self, self,
@@ -54,6 +137,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
override_query: str | None = None, override_query: str | None = None,
order: str | None = None, order: str | None = None,
limit: int | None = None, limit: int | None = None,
offset: int | None = None,
**context: Any, **context: Any,
) -> None: ) -> None:
if order is None: if order is None:
@@ -69,11 +153,25 @@ class BrickPartList(BrickRecordList[BrickPart]):
else: else:
minifigure = None minifigure = None
# Prepare template context for filtering
context_vars = {}
if hasattr(self.fields, 'owner_id') and self.fields.owner_id is not None:
context_vars['owner_id'] = self.fields.owner_id
if hasattr(self.fields, 'color_id') and self.fields.color_id is not None:
context_vars['color_id'] = self.fields.color_id
if hasattr(self.fields, 'search_query') and self.fields.search_query:
context_vars['search_query'] = self.fields.search_query
# Merge with any additional context passed in
context_vars.update(context)
# Load the sets from the database # Load the sets from the database
for record in super().select( for record in super().select(
override_query=override_query, override_query=override_query,
order=order, order=order,
limit=limit, limit=limit,
offset=offset,
**context_vars
): ):
part = BrickPart( part = BrickPart(
brickset=brickset, brickset=brickset,
@@ -81,9 +179,6 @@ class BrickPartList(BrickRecordList[BrickPart]):
record=record, record=record,
) )
if current_app.config['SKIP_SPARE_PARTS'] and part.fields.spare:
continue
self.records.append(part) self.records.append(part)
# List specific parts from a brickset or minifigure # List specific parts from a brickset or minifigure
@@ -143,6 +238,70 @@ class BrickPartList(BrickRecordList[BrickPart]):
return self return self
def problem_filtered(self, owner_id: str | None = None, color_id: str | None = None, /) -> Self:
# Save the filter parameters for client-side filtering
if owner_id is not None:
self.fields.owner_id = owner_id
if color_id is not None:
self.fields.color_id = color_id
# Prepare context for query
context = {}
if owner_id and owner_id != 'all':
context['owner_id'] = owner_id
if color_id and color_id != 'all':
context['color_id'] = color_id
if current_app.config.get('SKIP_SPARE_PARTS', False):
context['skip_spare_parts'] = True
# Load the problematic parts from the database
self.list(override_query=self.problem_query, **context)
return self
def problem_paginated(
self,
owner_id: str | None = None,
color_id: str | None = None,
search_query: str | None = None,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc'
) -> tuple[Self, int]:
# Prepare filter context
filter_context = {}
if owner_id and owner_id != 'all':
filter_context['owner_id'] = owner_id
if color_id and color_id != 'all':
filter_context['color_id'] = color_id
if search_query:
filter_context['search_query'] = search_query
if current_app.config.get('SKIP_SPARE_PARTS', False):
filter_context['skip_spare_parts'] = True
# Field mapping for sorting
field_mapping = {
'name': '"rebrickable_parts"."name"',
'color': '"rebrickable_parts"."color_name"',
'quantity': '"total_quantity"',
'missing': '"total_missing"',
'damaged': '"total_damaged"',
'sets': '"total_sets"',
'minifigures': '"total_minifigures"'
}
# Use the base pagination method with problem query
return self.paginate(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=self.problem_query,
field_mapping=field_mapping,
**filter_context
)
# Return a dict with common SQL parameters for a parts list # Return a dict with common SQL parameters for a parts list
def sql_parameters(self, /) -> dict[str, Any]: def sql_parameters(self, /) -> dict[str, Any]:
parameters: dict[str, Any] = super().sql_parameters() parameters: dict[str, Any] = super().sql_parameters()
+37 -1
View File
@@ -91,8 +91,17 @@ class RebrickablePart(BrickRecord):
def url_for_bricklink(self, /) -> str: def url_for_bricklink(self, /) -> str:
if current_app.config['BRICKLINK_LINKS']: if current_app.config['BRICKLINK_LINKS']:
try: try:
# Use BrickLink part number if available and not None/empty, otherwise fall back to Rebrickable part
bricklink_part = getattr(self.fields, 'bricklink_part_num', None)
part_param = bricklink_part if bricklink_part else self.fields.part
# Use BrickLink color ID if available and not None, otherwise fall back to Rebrickable color
bricklink_color = getattr(self.fields, 'bricklink_color_id', None)
color_param = bricklink_color if bricklink_color is not None else self.fields.color
# print(f'BrickLink URL parameters: part={part_param}, color={color_param}') # Debugging line, can be removed later
return current_app.config['BRICKLINK_LINK_PART_PATTERN'].format( # noqa: E501 return current_app.config['BRICKLINK_LINK_PART_PATTERN'].format( # noqa: E501
part=self.fields.part, part=part_param,
color=color_param,
) )
except Exception: except Exception:
pass pass
@@ -168,6 +177,9 @@ class RebrickablePart(BrickRecord):
'color_name': data['color']['name'], 'color_name': data['color']['name'],
'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_name': None,
'bricklink_part_num': None,
'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'],
@@ -176,6 +188,30 @@ class RebrickablePart(BrickRecord):
'print': data['part']['print_of'] 'print': data['part']['print_of']
} }
# Extract BrickLink color info if available in external_ids
if 'color' in data and 'external_ids' in data['color']:
external_ids = data['color']['external_ids']
if 'BrickLink' in external_ids and external_ids['BrickLink']:
bricklink_data = external_ids['BrickLink']
# Extract BrickLink color ID and name from the nested structure
if isinstance(bricklink_data, dict):
if 'ext_ids' in bricklink_data and bricklink_data['ext_ids']:
record['bricklink_color_id'] = bricklink_data['ext_ids'][0]
if 'ext_descrs' in bricklink_data and bricklink_data['ext_descrs']:
# ext_descrs is a list of lists, get the first description from the first list
if len(bricklink_data['ext_descrs']) > 0 and len(bricklink_data['ext_descrs'][0]) > 0:
record['bricklink_color_name'] = bricklink_data['ext_descrs'][0][0]
# Extract BrickLink part number if available
if 'part' in data and 'external_ids' in data['part']:
part_external_ids = data['part']['external_ids']
if 'BrickLink' in part_external_ids and part_external_ids['BrickLink']:
bricklink_parts = part_external_ids['BrickLink']
if isinstance(bricklink_parts, list) and len(bricklink_parts) > 0:
record['bricklink_part_num'] = bricklink_parts[0]
if brickset is not None: if brickset is not None:
record['id'] = brickset.fields.id record['id'] = brickset.fields.id
+7 -12
View File
@@ -11,24 +11,19 @@ class RebrickableSetList(BrickRecordList[RebrickableSet]):
select_query: str = 'rebrickable/set/list' select_query: str = 'rebrickable/set/list'
refresh_query: str = 'rebrickable/set/need_refresh' refresh_query: str = 'rebrickable/set/need_refresh'
# All the sets # Implementation of abstract list method
def all(self, /) -> Self: def list(self, /, *, override_query: str | None = None, **context) -> None:
# Load the sets from the database # Load the sets from the database
for record in self.select(): for record in self.select(override_query=override_query, **context):
rebrickable_set = RebrickableSet(record=record) rebrickable_set = RebrickableSet(record=record)
self.records.append(rebrickable_set) self.records.append(rebrickable_set)
# All the sets
def all(self, /) -> Self:
self.list()
return self return self
# Sets needing refresh # Sets needing refresh
def need_refresh(self, /) -> Self: def need_refresh(self, /) -> Self:
# Load the sets from the database self.list(override_query=self.refresh_query)
for record in self.select(
override_query=self.refresh_query
):
rebrickable_set = RebrickableSet(record=record)
self.records.append(rebrickable_set)
return self return self
+86 -1
View File
@@ -1,5 +1,6 @@
import re
from sqlite3 import Row from sqlite3 import Row
from typing import Any, Generator, Generic, ItemsView, TypeVar, TYPE_CHECKING from typing import Any, Generator, Generic, ItemsView, Self, TypeVar, TYPE_CHECKING
from .fields import BrickRecordFields from .fields import BrickRecordFields
from .sql import BrickSQL from .sql import BrickSQL
@@ -72,6 +73,90 @@ class BrickRecordList(Generic[T]):
**context **context
) )
# Generic pagination method for all record lists
def paginate(
self,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc',
count_query: str | None = None,
list_query: str | None = None,
field_mapping: dict[str, str] | None = None,
**filter_context: Any
) -> tuple['Self', int]:
"""Generic pagination implementation for all record lists"""
from .sql import BrickSQL
# Use provided queries or fall back to defaults
list_query = list_query or getattr(self, 'all_query', None)
if not list_query:
raise NotImplementedError("Subclass must define all_query")
# Calculate offset
offset = (page - 1) * per_page
# Get total count by wrapping the main query
if count_query:
# Use provided count query
count_result = BrickSQL().fetchone(count_query, **filter_context)
total_count = count_result['total_count'] if count_result else 0
else:
# Generate count by wrapping the main query (without ORDER BY, LIMIT, OFFSET)
count_context = {k: v for k, v in filter_context.items()
if k not in ['order', 'limit', 'offset']}
# Get the main query SQL without pagination clauses
main_sql = BrickSQL().load_query(list_query, **count_context)
# Remove ORDER BY, LIMIT, OFFSET clauses for counting
# Remove ORDER BY clause and everything after it that's not part of subqueries
count_sql = re.sub(r'\s+ORDER\s+BY\s+[^)]*?(\s+LIMIT|\s+OFFSET|$)', r'\1', main_sql, flags=re.IGNORECASE)
# Remove LIMIT and OFFSET
count_sql = re.sub(r'\s+LIMIT\s+\d+', '', count_sql, flags=re.IGNORECASE)
count_sql = re.sub(r'\s+OFFSET\s+\d+', '', count_sql, flags=re.IGNORECASE)
# Wrap in COUNT(*)
wrapped_sql = f"SELECT COUNT(*) as total_count FROM ({count_sql.strip()})"
count_result = BrickSQL().raw_execute(wrapped_sql, {}).fetchone()
total_count = count_result['total_count'] if count_result else 0
# Prepare sort order
order_clause = None
if sort_field and field_mapping and sort_field in field_mapping:
sql_field = field_mapping[sort_field]
direction = 'DESC' if sort_order.lower() == 'desc' else 'ASC'
order_clause = f'{sql_field} {direction}'
# Build pagination context
pagination_context = {
'limit': per_page,
'offset': offset,
'order': order_clause or getattr(self, 'order', None),
**filter_context
}
# Load paginated results using the existing list() method
# Check if this is a set list that needs do_theme parameter
if hasattr(self, 'themes'): # Only BrickSetList has this attribute
self.list(override_query=list_query, do_theme=True, **pagination_context)
else:
self.list(override_query=list_query, **pagination_context)
return self, total_count
# Base method that subclasses can override
def list(
self,
/,
*,
override_query: str | None = None,
**context: Any,
) -> None:
"""Load records from database - should be implemented by subclasses that use pagination"""
raise NotImplementedError("Subclass must implement list() method")
# Generic SQL parameters from fields # Generic SQL parameters from fields
def sql_parameters(self, /) -> dict[str, Any]: def sql_parameters(self, /) -> dict[str, Any]:
parameters: dict[str, Any] = {} parameters: dict[str, Any] = {}
+265 -3
View File
@@ -13,6 +13,8 @@ from .set_storage_list import BrickSetStorageList
from .set_tag import BrickSetTag from .set_tag import BrickSetTag
from .set_tag_list import BrickSetTagList from .set_tag_list import BrickSetTagList
from .set import BrickSet from .set import BrickSet
from .theme_list import BrickThemeList
from .instructions_list import BrickInstructionsList
# All the sets from the database # All the sets from the database
@@ -21,6 +23,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
order: str order: str
# Queries # Queries
all_query: str = 'set/list/all'
damaged_minifigure_query: str = 'set/list/damaged_minifigure' damaged_minifigure_query: str = 'set/list/damaged_minifigure'
damaged_part_query: str = 'set/list/damaged_part' damaged_part_query: str = 'set/list/damaged_part'
generic_query: str = 'set/list/generic' generic_query: str = 'set/list/generic'
@@ -48,6 +51,267 @@ class BrickSetList(BrickRecordList[BrickSet]):
return self return self
# All sets with pagination and filtering
def all_filtered_paginated(
self,
search_query: str | None = None,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc',
status_filter: str | None = None,
theme_filter: str | None = None,
owner_filter: str | None = None,
purchase_location_filter: str | None = None,
storage_filter: str | None = None,
tag_filter: str | None = None
) -> tuple[Self, int]:
# Convert theme name to theme ID for filtering
theme_id_filter = None
if theme_filter:
theme_id_filter = self._theme_name_to_id(theme_filter)
# Check if any filters are applied
has_filters = any([status_filter, theme_id_filter, owner_filter, purchase_location_filter, storage_filter, tag_filter])
# Prepare filter context
filter_context = {
'search_query': search_query,
'status_filter': status_filter,
'theme_filter': theme_id_filter, # Use converted theme ID
'owner_filter': owner_filter,
'purchase_location_filter': purchase_location_filter,
'storage_filter': storage_filter,
'tag_filter': tag_filter,
'owners': BrickSetOwnerList.as_columns(),
'statuses': BrickSetStatusList.as_columns(),
'tags': BrickSetTagList.as_columns(),
}
# Field mapping for sorting
field_mapping = {
'set': '"rebrickable_sets"."set"',
'name': '"rebrickable_sets"."name"',
'year': '"rebrickable_sets"."year"',
'parts': '"rebrickable_sets"."number_of_parts"',
'theme': '"rebrickable_sets"."theme_id"',
'minifigures': '"total_minifigures"', # Use the alias from the SQL query
'missing': '"total_missing"', # Use the alias from the SQL query
'damaged': '"total_damaged"', # Use the alias from the SQL query
'purchase-date': '"bricktracker_sets"."purchase_date"',
'purchase-price': '"bricktracker_sets"."purchase_price"'
}
# Choose query based on whether filters are applied
query_to_use = 'set/list/all_filtered' if has_filters else self.all_query
# Handle instructions filtering
if status_filter in ['has-missing-instructions', '-has-missing-instructions']:
# For instructions filter, we need to load all sets first, then filter and paginate
return self._all_filtered_paginated_with_instructions(
search_query, page, per_page, sort_field, sort_order,
status_filter, theme_id_filter, owner_filter,
purchase_location_filter, storage_filter, tag_filter
)
# Normal SQL-based filtering and pagination
result, total_count = self.paginate(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=query_to_use,
field_mapping=field_mapping,
**filter_context
)
# Populate themes for filter dropdown from ALL sets, not just current page
result._populate_themes_global()
return result, total_count
def _populate_themes(self) -> None:
"""Populate themes list from the current records"""
themes = set()
for record in self.records:
if hasattr(record, 'theme') and hasattr(record.theme, 'name'):
themes.add(record.theme.name)
self.themes = list(themes)
self.themes.sort()
def _theme_name_to_id(self, theme_name: str) -> str | None:
"""Convert a theme name to theme ID for filtering"""
try:
theme_list = BrickThemeList()
for theme_id, theme in theme_list.themes.items():
if theme.name.lower() == theme_name.lower():
return str(theme_id)
return None
except Exception:
# If themes can't be loaded, return None to disable theme filtering
return None
def _all_filtered_paginated_with_instructions(
self,
search_query: str | None,
page: int,
per_page: int,
sort_field: str | None,
sort_order: str,
status_filter: str,
theme_id_filter: str | None,
owner_filter: str | None,
purchase_location_filter: str | None,
storage_filter: str | None,
tag_filter: str | None
) -> tuple[Self, int]:
"""Handle filtering when instructions filter is involved"""
try:
# Load all sets first (without pagination) with full metadata
all_sets = BrickSetList()
filter_context = {
'owners': BrickSetOwnerList.as_columns(),
'statuses': BrickSetStatusList.as_columns(),
'tags': BrickSetTagList.as_columns(),
}
all_sets.list(do_theme=True, **filter_context)
# Load instructions list
instructions_list = BrickInstructionsList()
instruction_sets = set(instructions_list.sets.keys())
# Apply all filters manually
filtered_records = []
for record in all_sets.records:
# Apply instructions filter
set_id = record.fields.set
has_instructions = set_id in instruction_sets
if status_filter == 'has-missing-instructions' and has_instructions:
continue # Skip sets that have instructions
elif status_filter == '-has-missing-instructions' and not has_instructions:
continue # Skip sets that don't have instructions
# Apply other filters manually
if search_query and not self._matches_search(record, search_query):
continue
if theme_id_filter and not self._matches_theme(record, theme_id_filter):
continue
if owner_filter and not self._matches_owner(record, owner_filter):
continue
if purchase_location_filter and not self._matches_purchase_location(record, purchase_location_filter):
continue
if storage_filter and not self._matches_storage(record, storage_filter):
continue
if tag_filter and not self._matches_tag(record, tag_filter):
continue
filtered_records.append(record)
# Apply sorting
if sort_field:
filtered_records = self._sort_records(filtered_records, sort_field, sort_order)
# Calculate pagination
total_count = len(filtered_records)
start_index = (page - 1) * per_page
end_index = start_index + per_page
paginated_records = filtered_records[start_index:end_index]
# Create result
result = BrickSetList()
result.records = paginated_records
# Copy themes from the source that has all sets
result.themes = all_sets.themes if hasattr(all_sets, 'themes') else []
# If themes weren't populated, populate them globally
if not result.themes:
result._populate_themes_global()
return result, total_count
except Exception:
# Fall back to normal pagination without instructions filter
return self.all_filtered_paginated(
search_query, page, per_page, sort_field, sort_order,
None, theme_id_filter, owner_filter,
purchase_location_filter, storage_filter, tag_filter
)
def _populate_themes_global(self) -> None:
"""Populate themes list from ALL sets, not just current page"""
try:
# Load all sets to get all possible themes
all_sets = BrickSetList().all()
themes = set()
for record in all_sets.records:
if hasattr(record, 'theme') and hasattr(record.theme, 'name'):
themes.add(record.theme.name)
self.themes = list(themes)
self.themes.sort()
except Exception:
# Fall back to current page themes
self._populate_themes()
def _matches_search(self, record, search_query: str) -> bool:
"""Check if record matches search query"""
search_lower = search_query.lower()
return (search_lower in record.fields.name.lower() or
search_lower in record.fields.set.lower())
def _matches_theme(self, record, theme_id: str) -> bool:
"""Check if record matches theme filter"""
return str(record.fields.theme_id) == theme_id
def _matches_owner(self, record, owner_filter: str) -> bool:
"""Check if record matches owner filter"""
if not owner_filter.startswith('owner-'):
return True
# Convert owner-uuid format to owner_uuid column name
owner_column = owner_filter.replace('-', '_')
# Check if record has this owner attribute set to 1
return hasattr(record.fields, owner_column) and getattr(record.fields, owner_column) == 1
def _matches_purchase_location(self, record, location_filter: str) -> bool:
"""Check if record matches purchase location filter"""
return record.fields.purchase_location == location_filter
def _matches_storage(self, record, storage_filter: str) -> bool:
"""Check if record matches storage filter"""
return record.fields.storage == storage_filter
def _matches_tag(self, record, tag_filter: str) -> bool:
"""Check if record matches tag filter"""
if not tag_filter.startswith('tag-'):
return True
# Convert tag-uuid format to tag_uuid column name
tag_column = tag_filter.replace('-', '_')
# Check if record has this tag attribute set to 1
return hasattr(record.fields, tag_column) and getattr(record.fields, tag_column) == 1
def _sort_records(self, records, sort_field: str, sort_order: str):
"""Sort records manually"""
reverse = sort_order == 'desc'
if sort_field == 'set':
return sorted(records, key=lambda r: r.fields.set, reverse=reverse)
elif sort_field == 'name':
return sorted(records, key=lambda r: r.fields.name, reverse=reverse)
elif sort_field == 'year':
return sorted(records, key=lambda r: r.fields.year, reverse=reverse)
elif sort_field == 'parts':
return sorted(records, key=lambda r: r.fields.number_of_parts, reverse=reverse)
# Add more sort fields as needed
return records
# Sets with a minifigure part damaged # Sets with a minifigure part damaged
def damaged_minifigure(self, figure: str, /) -> Self: def damaged_minifigure(self, figure: str, /) -> Self:
# Save the parameters to the fields # Save the parameters to the fields
@@ -102,9 +366,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
override_query=override_query, override_query=override_query,
order=order, order=order,
limit=limit, limit=limit,
owners=BrickSetOwnerList.as_columns(), **context
statuses=BrickSetStatusList.as_columns(),
tags=BrickSetTagList.as_columns(),
): ):
brickset = BrickSet(record=record) brickset = BrickSet(record=record)
+1 -1
View File
@@ -70,7 +70,7 @@ class BrickSocket(object):
*args, *args,
**kwargs, **kwargs,
path=app.config['SOCKET_PATH'], path=app.config['SOCKET_PATH'],
async_mode='eventlet', async_mode='gevent',
) )
# Store the socket in the app config # Store the socket in the app config
+9
View File
@@ -0,0 +1,9 @@
-- description: Add BrickLink color fields to rebrickable_parts table
BEGIN TRANSACTION;
-- Add BrickLink color fields to the rebrickable_parts table
ALTER TABLE "rebrickable_parts" ADD COLUMN "bricklink_color_id" INTEGER;
ALTER TABLE "rebrickable_parts" ADD COLUMN "bricklink_color_name" TEXT;
COMMIT;
+8
View File
@@ -0,0 +1,8 @@
-- description: Add BrickLink part number field to rebrickable_parts table
BEGIN TRANSACTION;
-- Add BrickLink part number field to the rebrickable_parts table
ALTER TABLE "rebrickable_parts" ADD COLUMN "bricklink_part_num" TEXT;
COMMIT;
@@ -35,3 +35,7 @@ ORDER BY {{ order }}
{% if limit %} {% if limit %}
LIMIT {{ limit }} LIMIT {{ limit }}
{% endif %} {% endif %}
{% if offset %}
OFFSET {{ offset }}
{% endif %}
+6
View File
@@ -34,6 +34,12 @@ ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "problem_join"."id"
AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figure" AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figure"
{% endblock %} {% endblock %}
{% block where %}
{% if search_query %}
WHERE (LOWER("rebrickable_minifigures"."name") LIKE LOWER('%{{ search_query }}%'))
{% endif %}
{% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
"rebrickable_minifigures"."figure" "rebrickable_minifigures"."figure"
@@ -0,0 +1,78 @@
{% extends 'minifigure/base/base.sql' %}
{% block total_missing %}
SUM(IFNULL("problem_join"."total_missing", 0)) AS "total_missing",
{% endblock %}
{% block total_damaged %}
SUM(IFNULL("problem_join"."total_damaged", 0)) AS "total_damaged",
{% endblock %}
{% block total_quantity %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0) ELSE 0 END) AS "total_quantity",
{% else %}
SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity",
{% endif %}
{% endblock %}
{% block total_sets %}
{% if owner_id and owner_id != 'all' %}
COUNT(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_minifigures"."id" ELSE NULL END) AS "total_sets"
{% else %}
COUNT("bricktracker_minifigures"."id") AS "total_sets"
{% endif %}
{% endblock %}
{% block join %}
-- Join with sets to get owner information
INNER JOIN "bricktracker_sets"
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- Left join with set owners (using dynamic columns)
LEFT JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
-- LEFT JOIN + SELECT to avoid messing the total
LEFT JOIN (
SELECT
"bricktracker_parts"."id",
"bricktracker_parts"."figure",
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "owner_parts"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."missing" ELSE 0 END) AS "total_missing",
SUM(CASE WHEN "owner_parts"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."damaged" ELSE 0 END) AS "total_damaged"
{% else %}
SUM("bricktracker_parts"."missing") AS "total_missing",
SUM("bricktracker_parts"."damaged") AS "total_damaged"
{% endif %}
FROM "bricktracker_parts"
INNER JOIN "bricktracker_sets" AS "parts_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "parts_sets"."id"
LEFT JOIN "bricktracker_set_owners" AS "owner_parts"
ON "parts_sets"."id" IS NOT DISTINCT FROM "owner_parts"."id"
WHERE "bricktracker_parts"."figure" IS NOT NULL
GROUP BY
"bricktracker_parts"."id",
"bricktracker_parts"."figure"
) "problem_join"
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "problem_join"."id"
AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figure"
{% endblock %}
{% block where %}
{% set conditions = [] %}
{% if owner_id and owner_id != 'all' %}
{% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% endif %}
{% if search_query %}
{% set _ = conditions.append('(LOWER("rebrickable_minifigures"."name") LIKE LOWER(\'%' ~ search_query ~ '%\'))') %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
{% endif %}
{% endblock %}
{% block group %}
GROUP BY
"rebrickable_minifigures"."figure"
{% endblock %}
+7
View File
@@ -14,6 +14,9 @@ SELECT
"rebrickable_parts"."color_name", "rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb", "rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent", "rebrickable_parts"."color_transparent",
"rebrickable_parts"."bricklink_color_id",
"rebrickable_parts"."bricklink_color_name",
"rebrickable_parts"."bricklink_part_num",
"rebrickable_parts"."name", "rebrickable_parts"."name",
--"rebrickable_parts"."category", --"rebrickable_parts"."category",
"rebrickable_parts"."image", "rebrickable_parts"."image",
@@ -57,3 +60,7 @@ ORDER BY {{ order }}
{% if limit %} {% if limit %}
LIMIT {{ limit }} LIMIT {{ limit }}
{% endif %} {% endif %}
{% if offset %}
OFFSET {{ offset }}
{% endif %}
+16
View File
@@ -0,0 +1,16 @@
SELECT DISTINCT
"rebrickable_parts"."color_id" AS "color_id",
"rebrickable_parts"."color_name" AS "color_name",
"rebrickable_parts"."color_rgb" AS "color_rgb"
FROM "rebrickable_parts"
INNER JOIN "bricktracker_parts"
ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
{% if owner_id and owner_id != 'all' %}
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% endif %}
ORDER BY "rebrickable_parts"."color_name" ASC
@@ -0,0 +1,19 @@
SELECT DISTINCT
"rebrickable_parts"."color_id" AS "color_id",
"rebrickable_parts"."color_name" AS "color_name",
"rebrickable_parts"."color_rgb" AS "color_rgb"
FROM "rebrickable_parts"
INNER JOIN "bricktracker_parts"
ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
{% if owner_id and owner_id != 'all' %}
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
{% endif %}
WHERE ("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)
{% if owner_id and owner_id != 'all' %}
AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% endif %}
ORDER BY "rebrickable_parts"."color_name" ASC
+17
View File
@@ -26,6 +26,23 @@ ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id
AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure" AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
{% endblock %} {% endblock %}
{% block where %}
{% set conditions = [] %}
{% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %}
{% endif %}
{% if search_query %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("bricktracker_parts"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set _ = conditions.append(search_condition) %}
{% endif %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
{% endif %}
{% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
"bricktracker_parts"."part", "bricktracker_parts"."part",
@@ -0,0 +1,83 @@
{% extends 'part/base/base.sql' %}
{% block total_missing %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."missing" ELSE 0 END) AS "total_missing",
{% else %}
SUM("bricktracker_parts"."missing") AS "total_missing",
{% endif %}
{% endblock %}
{% block total_damaged %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."damaged" ELSE 0 END) AS "total_damaged",
{% else %}
SUM("bricktracker_parts"."damaged") AS "total_damaged",
{% endif %}
{% endblock %}
{% block total_quantity %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1) ELSE 0 END) AS "total_quantity",
{% else %}
SUM("bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
{% endif %}
{% endblock %}
{% block total_sets %}
{% if owner_id and owner_id != 'all' %}
COUNT(DISTINCT CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."id" ELSE NULL END) AS "total_sets",
{% else %}
COUNT(DISTINCT "bricktracker_parts"."id") AS "total_sets",
{% endif %}
{% endblock %}
{% block total_minifigures %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0) ELSE 0 END) AS "total_minifigures"
{% else %}
SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures"
{% endif %}
{% endblock %}
{% block join %}
-- Join with sets to get owner information
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- Left join with set owners (using dynamic columns)
LEFT JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
-- Left join with minifigures
LEFT JOIN "bricktracker_minifigures"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
{% endblock %}
{% block where %}
{% set conditions = [] %}
{% if owner_id and owner_id != 'all' %}
{% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% endif %}
{% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %}
{% endif %}
{% if search_query %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("bricktracker_parts"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set _ = conditions.append(search_condition) %}
{% endif %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
{% endif %}
{% endblock %}
{% block group %}
GROUP BY
"bricktracker_parts"."part",
"bricktracker_parts"."color",
"bricktracker_parts"."spare"
{% endblock %}
+51 -3
View File
@@ -1,30 +1,78 @@
{% extends 'part/base/base.sql' %} {% extends 'part/base/base.sql' %}
{% block total_missing %} {% block total_missing %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."missing" ELSE 0 END) AS "total_missing",
{% else %}
SUM("bricktracker_parts"."missing") AS "total_missing", SUM("bricktracker_parts"."missing") AS "total_missing",
{% endif %}
{% endblock %} {% endblock %}
{% block total_damaged %} {% block total_damaged %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."damaged" ELSE 0 END) AS "total_damaged",
{% else %}
SUM("bricktracker_parts"."damaged") AS "total_damaged", SUM("bricktracker_parts"."damaged") AS "total_damaged",
{% endif %}
{% endblock %}
{% block total_quantity %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1) ELSE 0 END) AS "total_quantity",
{% else %}
SUM("bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
{% endif %}
{% endblock %} {% endblock %}
{% block total_sets %} {% block total_sets %}
IFNULL(COUNT("bricktracker_parts"."id"), 0) - IFNULL(COUNT("bricktracker_parts"."figure"), 0) AS "total_sets", {% if owner_id and owner_id != 'all' %}
COUNT(DISTINCT CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."id" ELSE NULL END) AS "total_sets",
{% else %}
COUNT(DISTINCT "bricktracker_parts"."id") AS "total_sets",
{% endif %}
{% endblock %} {% endblock %}
{% block total_minifigures %} {% block total_minifigures %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0) ELSE 0 END) AS "total_minifigures"
{% else %}
SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures" SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures"
{% endif %}
{% endblock %} {% endblock %}
{% block join %} {% block join %}
-- Join with sets to get owner information
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- Left join with set owners (using dynamic columns)
LEFT JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
-- Left join with minifigures
LEFT JOIN "bricktracker_minifigures" LEFT JOIN "bricktracker_minifigures"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id" ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure" AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
{% endblock %} {% endblock %}
{% block where %} {% block where %}
WHERE "bricktracker_parts"."missing" > 0 {% set conditions = [] %}
OR "bricktracker_parts"."damaged" > 0 -- Always filter for problematic parts
{% set _ = conditions.append('("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)') %}
{% if owner_id and owner_id != 'all' %}
{% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% endif %}
{% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %}
{% endif %}
{% if search_query %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("bricktracker_parts"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set _ = conditions.append(search_condition) %}
{% endif %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %}
{% endif %}
WHERE {{ conditions | join(' AND ') }}
{% endblock %} {% endblock %}
{% block group %} {% block group %}
@@ -4,6 +4,9 @@ INSERT OR IGNORE INTO "rebrickable_parts" (
"color_name", "color_name",
"color_rgb", "color_rgb",
"color_transparent", "color_transparent",
"bricklink_color_id",
"bricklink_color_name",
"bricklink_part_num",
"name", "name",
"category", "category",
"image", "image",
@@ -16,6 +19,9 @@ INSERT OR IGNORE INTO "rebrickable_parts" (
:color_name, :color_name,
:color_rgb, :color_rgb,
:color_transparent, :color_transparent,
:bricklink_color_id,
:bricklink_color_name,
:bricklink_part_num,
:name, :name,
:category, :category,
:image, :image,
@@ -28,6 +34,9 @@ DO UPDATE SET
"color_name" = :color_name, "color_name" = :color_name,
"color_rgb" = :color_rgb, "color_rgb" = :color_rgb,
"color_transparent" = :color_transparent, "color_transparent" = :color_transparent,
"bricklink_color_id" = :bricklink_color_id,
"bricklink_color_name" = :bricklink_color_name,
"bricklink_part_num" = :bricklink_part_num,
"name" = :name, "name" = :name,
"category" = :category, "category" = :category,
"image" = :image, "image" = :image,
@@ -4,6 +4,9 @@ SELECT
"rebrickable_parts"."color_name", "rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb", "rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent", "rebrickable_parts"."color_transparent",
"rebrickable_parts"."bricklink_color_id",
"rebrickable_parts"."bricklink_color_name",
"rebrickable_parts"."bricklink_part_num",
"rebrickable_parts"."name", "rebrickable_parts"."name",
"rebrickable_parts"."category", "rebrickable_parts"."category",
"rebrickable_parts"."image", "rebrickable_parts"."image",
@@ -4,6 +4,9 @@ SELECT
"rebrickable_parts"."color_name", "rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb", "rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent", "rebrickable_parts"."color_transparent",
"rebrickable_parts"."bricklink_color_id",
"rebrickable_parts"."bricklink_color_name",
"rebrickable_parts"."bricklink_part_num",
"rebrickable_parts"."name", "rebrickable_parts"."name",
"rebrickable_parts"."category", "rebrickable_parts"."category",
"rebrickable_parts"."image", "rebrickable_parts"."image",
@@ -6,7 +6,10 @@ SELECT
"rebrickable_sets"."url", "rebrickable_sets"."url",
"null_join"."null_rgb", "null_join"."null_rgb",
"null_join"."null_transparent", "null_join"."null_transparent",
"null_join"."null_url" "null_join"."null_url",
"null_join"."null_bricklink_color_id",
"null_join"."null_bricklink_color_name",
"null_join"."null_bricklink_part_num"
FROM "rebrickable_sets" FROM "rebrickable_sets"
INNER JOIN ( INNER JOIN (
@@ -14,19 +17,28 @@ INNER JOIN (
"null_sums"."set", "null_sums"."set",
"null_sums"."null_rgb", "null_sums"."null_rgb",
"null_sums"."null_transparent", "null_sums"."null_transparent",
"null_sums"."null_url" "null_sums"."null_url",
"null_sums"."null_bricklink_color_id",
"null_sums"."null_bricklink_color_name",
"null_sums"."null_bricklink_part_num"
FROM ( FROM (
SELECT SELECT
"unique_set_parts"."set", "unique_set_parts"."set",
SUM(CASE WHEN "unique_set_parts"."color_rgb" IS NULL THEN 1 ELSE 0 END) AS "null_rgb", SUM(CASE WHEN "unique_set_parts"."color_rgb" IS NULL THEN 1 ELSE 0 END) AS "null_rgb",
SUM(CASE WHEN "unique_set_parts"."color_transparent" IS NULL THEN 1 ELSE 0 END) AS "null_transparent", SUM(CASE WHEN "unique_set_parts"."color_transparent" IS NULL THEN 1 ELSE 0 END) AS "null_transparent",
SUM(CASE WHEN "unique_set_parts"."url" IS NULL THEN 1 ELSE 0 END) AS "null_url" SUM(CASE WHEN "unique_set_parts"."url" IS NULL THEN 1 ELSE 0 END) AS "null_url",
SUM(CASE WHEN "unique_set_parts"."bricklink_color_id" IS NULL THEN 1 ELSE 0 END) AS "null_bricklink_color_id",
SUM(CASE WHEN "unique_set_parts"."bricklink_color_name" IS NULL THEN 1 ELSE 0 END) AS "null_bricklink_color_name",
SUM(CASE WHEN "unique_set_parts"."bricklink_part_num" IS NULL THEN 1 ELSE 0 END) AS "null_bricklink_part_num"
FROM ( FROM (
SELECT SELECT
"bricktracker_sets"."set", "bricktracker_sets"."set",
"rebrickable_parts"."color_rgb", "rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent", "rebrickable_parts"."color_transparent",
"rebrickable_parts"."url" "rebrickable_parts"."url",
"rebrickable_parts"."bricklink_color_id",
"rebrickable_parts"."bricklink_color_name",
"rebrickable_parts"."bricklink_part_num"
FROM "bricktracker_sets" FROM "bricktracker_sets"
INNER JOIN "bricktracker_parts" INNER JOIN "bricktracker_parts"
@@ -49,5 +61,8 @@ INNER JOIN (
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_part_num" > 0
) "null_join" ) "null_join"
ON "rebrickable_sets"."set" IS NOT DISTINCT FROM "null_join"."set" ON "rebrickable_sets"."set" IS NOT DISTINCT FROM "null_join"."set"
+4
View File
@@ -49,3 +49,7 @@ ORDER BY {{ order }}
{% if limit %} {% if limit %}
LIMIT {{ limit }} LIMIT {{ limit }}
{% endif %} {% endif %}
{% if offset %}
OFFSET {{ offset }}
{% endif %}
+7
View File
@@ -1 +1,8 @@
{% extends 'set/base/full.sql' %} {% extends 'set/base/full.sql' %}
{% block where %}
{% if search_query %}
WHERE (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%')
OR LOWER("rebrickable_sets"."set") LIKE LOWER('%{{ search_query }}%'))
{% endif %}
{% endblock %}
@@ -0,0 +1,69 @@
{% extends 'set/base/full.sql' %}
{% block where %}
WHERE 1=1
{% if search_query %}
AND (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%')
OR LOWER("rebrickable_sets"."set") LIKE LOWER('%{{ search_query }}%'))
{% endif %}
{% if theme_filter %}
AND "rebrickable_sets"."theme_id" = '{{ theme_filter }}'
{% endif %}
{% if storage_filter %}
AND "bricktracker_sets"."storage" = '{{ storage_filter }}'
{% endif %}
{% if purchase_location_filter %}
AND "bricktracker_sets"."purchase_location" = '{{ purchase_location_filter }}'
{% endif %}
{% if status_filter %}
{% if status_filter == 'has-missing' %}
AND IFNULL("problem_join"."total_missing", 0) > 0
{% elif status_filter == '-has-missing' %}
AND IFNULL("problem_join"."total_missing", 0) = 0
{% elif status_filter == 'has-damaged' %}
AND IFNULL("problem_join"."total_damaged", 0) > 0
{% elif status_filter == '-has-damaged' %}
AND IFNULL("problem_join"."total_damaged", 0) = 0
{% elif status_filter == 'has-storage' %}
AND "bricktracker_sets"."storage" IS NOT NULL AND "bricktracker_sets"."storage" != ''
{% elif status_filter == '-has-storage' %}
AND ("bricktracker_sets"."storage" IS NULL OR "bricktracker_sets"."storage" = '')
{% elif status_filter.startswith('status-') %}
AND EXISTS (
SELECT 1 FROM "bricktracker_set_statuses"
WHERE "bricktracker_set_statuses"."id" = "bricktracker_sets"."id"
AND "bricktracker_set_statuses"."{{ status_filter.replace('-', '_') }}" = 1
)
{% elif status_filter.startswith('-status-') %}
AND NOT EXISTS (
SELECT 1 FROM "bricktracker_set_statuses"
WHERE "bricktracker_set_statuses"."id" = "bricktracker_sets"."id"
AND "bricktracker_set_statuses"."{{ status_filter[1:].replace('-', '_') }}" = 1
)
{% endif %}
{% endif %}
{% if owner_filter %}
{% if owner_filter.startswith('owner-') %}
AND EXISTS (
SELECT 1 FROM "bricktracker_set_owners"
WHERE "bricktracker_set_owners"."id" = "bricktracker_sets"."id"
AND "bricktracker_set_owners"."{{ owner_filter.replace('-', '_') }}" = 1
)
{% endif %}
{% endif %}
{% if tag_filter %}
{% if tag_filter.startswith('tag-') %}
AND EXISTS (
SELECT 1 FROM "bricktracker_set_tags"
WHERE "bricktracker_set_tags"."id" = "bricktracker_sets"."id"
AND "bricktracker_set_tags"."{{ tag_filter.replace('-', '_') }}" = 1
)
{% endif %}
{% endif %}
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
"""Custom Jinja2 template filters for BrickTracker."""
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
def replace_query_filter(url, key, value):
"""Replace or add a query parameter in a URL"""
parsed = urlparse(url)
query_dict = parse_qs(parsed.query, keep_blank_values=True)
query_dict[key] = [str(value)]
new_query = urlencode(query_dict, doseq=True)
return urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, new_query, parsed.fragment))
+2 -2
View File
@@ -1,4 +1,4 @@
from typing import Final from typing import Final
__version__: Final[str] = '1.2.2' __version__: Final[str] = '1.2.5'
__database_version__: Final[int] = 15 __database_version__: Final[int] = 17
+4 -1
View File
@@ -25,6 +25,7 @@ def update() -> Response:
BrickSet(), BrickSet(),
minifigure=BrickMinifigure(record={ minifigure=BrickMinifigure(record={
'set_img_url': None, 'set_img_url': None,
'image': None,
}) })
).download() ).download()
@@ -33,7 +34,9 @@ def update() -> Response:
BrickSet(), BrickSet(),
part=BrickPart(record={ part=BrickPart(record={
'part_img_url': None, 'part_img_url': None,
'part_img_url_id': None 'part_img_url_id': None,
'image_id': None,
'image': None,
}) })
).download() ).download()
+1 -1
View File
@@ -7,7 +7,7 @@ from ...rebrickable_set_list import RebrickableSetList
admin_set_page = Blueprint('admin_set', __name__, url_prefix='/admin/set') admin_set_page = Blueprint('admin_set', __name__, url_prefix='/admin/set')
# Sets that need o be refreshed # Sets that need to be refreshed
@admin_set_page.route('/refresh', methods=['GET']) @admin_set_page.route('/refresh', methods=['GET'])
@login_required @login_required
@exception_handler(__file__) @exception_handler(__file__)
+49 -5
View File
@@ -1,9 +1,11 @@
from flask import Blueprint, render_template from flask import Blueprint, current_app, render_template, request
from .exceptions import exception_handler from .exceptions import exception_handler
from ..minifigure import BrickMinifigure from ..minifigure import BrickMinifigure
from ..minifigure_list import BrickMinifigureList from ..minifigure_list import BrickMinifigureList
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
from ..set_list import BrickSetList, set_metadata_lists from ..set_list import BrickSetList, set_metadata_lists
from ..set_owner_list import BrickSetOwnerList
minifigure_page = Blueprint('minifigure', __name__, url_prefix='/minifigures') minifigure_page = Blueprint('minifigure', __name__, url_prefix='/minifigures')
@@ -12,10 +14,52 @@ minifigure_page = Blueprint('minifigure', __name__, url_prefix='/minifigures')
@minifigure_page.route('/', methods=['GET']) @minifigure_page.route('/', methods=['GET'])
@exception_handler(__file__) @exception_handler(__file__)
def list() -> str: def list() -> str:
return render_template( # Get filter parameters from request
'minifigures.html', owner_id = request.args.get('owner', 'all')
table_collection=BrickMinifigureList().all(), search_query, sort_field, sort_order, page = get_request_params()
)
# Get pagination configuration
per_page, is_mobile = get_pagination_config('minifigures')
use_pagination = per_page > 0
if use_pagination:
# PAGINATION MODE - Server-side pagination with search
minifigures, total_count = BrickMinifigureList().all_filtered_paginated(
owner_id=owner_id,
search_query=search_query,
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order
)
pagination_context = build_pagination_context(page, per_page, total_count, is_mobile)
else:
# ORIGINAL MODE - Single page with all data for client-side search
if owner_id == 'all' or owner_id is None or owner_id == '':
minifigures = BrickMinifigureList().all()
else:
minifigures = BrickMinifigureList().all_by_owner(owner_id)
pagination_context = None
# Get list of owners for filter dropdown
owners = BrickSetOwnerList.list()
template_context = {
'table_collection': minifigures,
'owners': owners,
'selected_owner': owner_id,
'search_query': search_query,
'use_pagination': use_pagination,
'current_sort': sort_field,
'current_order': sort_order
}
if pagination_context:
template_context['pagination'] = pagination_context
return render_template('minifigures.html', **template_context)
# Minifigure details # Minifigure details
+108 -6
View File
@@ -1,10 +1,13 @@
from flask import Blueprint, render_template from flask import Blueprint, render_template, request
from .exceptions import exception_handler from .exceptions import exception_handler
from ..minifigure_list import BrickMinifigureList from ..minifigure_list import BrickMinifigureList
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
from ..part import BrickPart from ..part import BrickPart
from ..part_list import BrickPartList from ..part_list import BrickPartList
from ..set_list import BrickSetList, set_metadata_lists from ..set_list import BrickSetList, set_metadata_lists
from ..set_owner_list import BrickSetOwnerList
from ..sql import BrickSQL
part_page = Blueprint('part', __name__, url_prefix='/parts') part_page = Blueprint('part', __name__, url_prefix='/parts')
@@ -13,19 +16,118 @@ part_page = Blueprint('part', __name__, url_prefix='/parts')
@part_page.route('/', methods=['GET']) @part_page.route('/', methods=['GET'])
@exception_handler(__file__) @exception_handler(__file__)
def list() -> str: def list() -> str:
return render_template( # Get filter parameters from request
'parts.html', owner_id = request.args.get('owner', 'all')
table_collection=BrickPartList().all() color_id = request.args.get('color', 'all')
) search_query, sort_field, sort_order, page = get_request_params()
# Get pagination configuration
per_page, is_mobile = get_pagination_config('parts')
use_pagination = per_page > 0
if use_pagination:
# PAGINATION MODE - Server-side pagination with search
parts, total_count = BrickPartList().all_filtered_paginated(
owner_id=owner_id,
color_id=color_id,
search_query=search_query,
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order
)
pagination_context = build_pagination_context(page, per_page, total_count, is_mobile)
else:
# ORIGINAL MODE - Single page with all data for client-side search
parts = BrickPartList().all_filtered(owner_id, color_id)
pagination_context = None
# Get list of owners for filter dropdown
owners = BrickSetOwnerList.list()
# Get list of colors for filter dropdown
# Prepare context for color query (filter by owner if selected)
color_context = {}
if owner_id != 'all' and owner_id:
color_context['owner_id'] = owner_id
colors = BrickSQL().fetchall('part/colors/list', **color_context)
template_context = {
'table_collection': parts,
'owners': owners,
'selected_owner': owner_id,
'colors': colors,
'selected_color': color_id,
'search_query': search_query,
'use_pagination': use_pagination,
'current_sort': sort_field,
'current_order': sort_order
}
if pagination_context:
template_context['pagination'] = pagination_context
return render_template('parts.html', **template_context)
# Problem # Problem
@part_page.route('/problem', methods=['GET']) @part_page.route('/problem', methods=['GET'])
@exception_handler(__file__) @exception_handler(__file__)
def problem() -> str: def problem() -> str:
# Get filter parameters from request
owner_id = request.args.get('owner', 'all')
color_id = request.args.get('color', 'all')
search_query, sort_field, sort_order, page = get_request_params()
# Get pagination configuration
per_page, is_mobile = get_pagination_config('problems')
use_pagination = per_page > 0
if use_pagination:
# PAGINATION MODE - Server-side pagination with search and filters
parts, total_count = BrickPartList().problem_paginated(
owner_id=owner_id,
color_id=color_id,
search_query=search_query,
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order
)
pagination_context = build_pagination_context(page, per_page, total_count, is_mobile)
else:
# ORIGINAL MODE - Single page with all data for client-side search
parts = BrickPartList().problem_filtered(owner_id, color_id)
pagination_context = None
# Get list of owners for filter dropdown
owners = BrickSetOwnerList.list()
# Get list of colors for filter dropdown
# Prepare context for color query (filter by owner if selected)
color_context = {}
if owner_id != 'all':
color_context['owner_id'] = owner_id
# Get colors from problem parts (following same pattern as parts page)
colors = BrickSQL().fetchall('part/colors/list_problem', **color_context)
return render_template( return render_template(
'problem.html', 'problem.html',
table_collection=BrickPartList().problem() table_collection=parts,
pagination=pagination_context,
search_query=search_query,
sort_field=sort_field,
sort_order=sort_order,
use_pagination=use_pagination,
owners=owners,
colors=colors,
selected_owner=owner_id,
selected_color=color_id
) )
+57 -5
View File
@@ -15,6 +15,7 @@ from werkzeug.wrappers.response import Response
from .exceptions import exception_handler from .exceptions import exception_handler
from ..exceptions import ErrorException from ..exceptions import ErrorException
from ..minifigure import BrickMinifigure from ..minifigure import BrickMinifigure
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
from ..part import BrickPart from ..part import BrickPart
from ..rebrickable_set import RebrickableSet from ..rebrickable_set import RebrickableSet
from ..set import BrickSet from ..set import BrickSet
@@ -35,12 +36,63 @@ set_page = Blueprint('set', __name__, url_prefix='/sets')
@set_page.route('/', methods=['GET']) @set_page.route('/', methods=['GET'])
@exception_handler(__file__) @exception_handler(__file__)
def list() -> str: def list() -> str:
return render_template( # Get filter parameters from request
'sets.html', search_query, sort_field, sort_order, page = get_request_params()
collection=BrickSetList().all(),
brickset_statuses=BrickSetStatusList.list(), # Get filter parameters
status_filter = request.args.get('status')
theme_filter = request.args.get('theme')
owner_filter = request.args.get('owner')
purchase_location_filter = request.args.get('purchase_location')
storage_filter = request.args.get('storage')
tag_filter = request.args.get('tag')
# Get pagination configuration
per_page, is_mobile = get_pagination_config('sets')
use_pagination = per_page > 0
if use_pagination:
# PAGINATION MODE - Server-side pagination with search and filters
sets, total_count = BrickSetList().all_filtered_paginated(
search_query=search_query,
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
status_filter=status_filter,
theme_filter=theme_filter,
owner_filter=owner_filter,
purchase_location_filter=purchase_location_filter,
storage_filter=storage_filter,
tag_filter=tag_filter
)
pagination_context = build_pagination_context(page, per_page, total_count, is_mobile)
else:
# ORIGINAL MODE - Single page with all data for client-side search
sets = BrickSetList().all()
pagination_context = None
template_context = {
'collection': sets,
'search_query': search_query,
'use_pagination': use_pagination,
'current_sort': sort_field,
'current_order': sort_order,
'current_status_filter': status_filter,
'current_theme_filter': theme_filter,
'current_owner_filter': owner_filter,
'current_purchase_location_filter': purchase_location_filter,
'current_storage_filter': storage_filter,
'current_tag_filter': tag_filter,
'brickset_statuses': BrickSetStatusList.list(),
**set_metadata_lists(as_class=True) **set_metadata_lists(as_class=True)
) }
if pagination_context:
template_context['pagination'] = pagination_context
return render_template('sets.html', **template_context)
# Change the value of purchase date # Change the value of purchase date
+11 -4
View File
@@ -19,17 +19,24 @@ class BrickWishList(BrickRecordList[BrickWish]):
# Queries # Queries
select_query: str = 'wish/list/all' select_query: str = 'wish/list/all'
# All the wished sets # Implementation of abstract list method
def all(self, /) -> Self: def list(self, /, *, override_query: str | None = None, **context) -> None:
# Use provided order or default
order = context.pop('order', current_app.config['WISHES_DEFAULT_ORDER'])
# Load the wished sets from the database # Load the wished sets from the database
for record in self.select( for record in self.select(
order=current_app.config['WISHES_DEFAULT_ORDER'], override_query=override_query,
order=order,
owners=BrickWishOwnerList.as_columns(), owners=BrickWishOwnerList.as_columns(),
**context
): ):
brickwish = BrickWish(record=record) brickwish = BrickWish(record=record)
self.records.append(brickwish) self.records.append(brickwish)
# All the wished sets
def all(self, /) -> Self:
self.list()
return self return self
# Add a set to the wishlist # Add a set to the wishlist
+4 -4
View File
@@ -61,9 +61,9 @@ docker compose up -d
2. Access BrickTracker at `http://localhost:3333` 2. Access BrickTracker at `http://localhost:3333`
Please refer to [Environment Variables Reference](docs/env.md) for a list of available variables. Please refer to [Environment Variables Reference](env.md) for a list of available variables.
3. Read more in [First steps](docs/first-steps.md) 3. Read more in [First steps](first-steps.md)
## Troubleshooting ## Troubleshooting
@@ -85,6 +85,6 @@ Please refer to [Environment Variables Reference](docs/env.md) for a list of ava
- Check for any syntax errors in `.env` file - Check for any syntax errors in `.env` file
- Verify no conflicting environment variables are set in the shell - Verify no conflicting environment variables are set in the shell
For more troubleshooting, take a look at [Common Errors](docs/common-errors.md) For more troubleshooting, take a look at [Common Errors](common-errors.md)
Please refer to [Setup](docs/setup.md) for more information. Please refer to [Setup](setup.md) for more information.
+1 -1
View File
@@ -53,7 +53,7 @@ services:
The [.env.sample](../.env.sample) file provides ample documentation on all the configurable options. Have a look at it. The [.env.sample](../.env.sample) file provides ample documentation on all the configurable options. Have a look at it.
You can make a copy of `.env.sample` as `.env` with your options or create an `.env` file from scratch. You can make a copy of `.env.sample` as `.env` with your options or create an `.env` file from scratch.
[Environment Variables Reference](docs/env.md) contains a table of the available variables. [Environment Variables Reference](env.md) contains a table of the available variables.
## Database file ## Database file
+1 -1
View File
@@ -13,4 +13,4 @@ then
fi fi
# Execute the WSGI server # Execute the WSGI server
gunicorn --bind "${BK_SERVER}:${BK_PORT}" "app:create_app()" --worker-class "eventlet" "$@" gunicorn --bind "${BK_HOST}:${BK_PORT}" "wsgi:application" --worker-class "gevent" --workers 1 "$@"
+2
View File
@@ -2,6 +2,7 @@ eventlet
flask flask
flask_socketio flask_socketio
flask-login flask-login
gevent
gunicorn gunicorn
humanize humanize
jinja2 jinja2
@@ -9,3 +10,4 @@ rebrick
requests requests
tzdata tzdata
bs4 bs4
cloudscraper
+442
View File
@@ -0,0 +1,442 @@
/**
* Shared collapsible state management for filters and sort sections
* Handles BK_SHOW_GRID_FILTERS and BK_SHOW_GRID_SORT configuration with user preferences
*/
// Generic state management for collapsible sections (filter and sort)
function initializeCollapsibleState(elementId, storageKey) {
const element = document.getElementById(elementId);
const toggleButton = document.querySelector(`[data-bs-target="#${elementId}"]`);
if (!element || !toggleButton) return;
// Restore state on page load
const savedState = sessionStorage.getItem(storageKey);
if (savedState === 'open') {
// User explicitly opened it
element.classList.add('show');
toggleButton.setAttribute('aria-expanded', 'true');
} else if (savedState === 'closed') {
// User explicitly closed it, override template state
element.classList.remove('show');
toggleButton.setAttribute('aria-expanded', 'false');
}
// If no saved state, keep the template state (respects BK_SHOW_GRID_FILTERS/BK_SHOW_GRID_SORT)
// Listen for toggle events
element.addEventListener('show.bs.collapse', () => {
sessionStorage.setItem(storageKey, 'open');
});
element.addEventListener('hide.bs.collapse', () => {
sessionStorage.setItem(storageKey, 'closed');
});
}
// Initialize filter and sort states for a specific page
function initializePageCollapsibleStates(pagePrefix, filterElementId = 'table-filter', sortElementId = 'table-sort') {
initializeCollapsibleState(filterElementId, `${pagePrefix}-filter-state`);
initializeCollapsibleState(sortElementId, `${pagePrefix}-sort-state`);
// Initialize sort icons based on current URL parameters (for all pages)
const urlParams = new URLSearchParams(window.location.search);
const currentSort = urlParams.get('sort');
const currentOrder = urlParams.get('order');
if (currentSort || currentOrder) {
updateSortIcon(currentOrder);
}
}
// Shared function to preserve filter state during filter changes
function preserveCollapsibleStateOnChange(elementId, storageKey) {
const element = document.getElementById(elementId);
const wasOpen = element && element.classList.contains('show');
// Store the state to restore after page reload
if (wasOpen) {
sessionStorage.setItem(storageKey, 'open');
}
}
// Setup color dropdown with visual indicators (shared implementation)
function setupColorDropdown() {
const colorSelect = document.getElementById('filter-color');
if (!colorSelect) return;
// Merge duplicate color options where one has color_rgb and the other is None
const colorMap = new Map();
const allOptions = colorSelect.querySelectorAll('option[data-color-id]');
// First pass: collect all options by color_id
allOptions.forEach(option => {
const colorId = option.dataset.colorId;
const colorRgb = option.dataset.colorRgb;
const colorName = option.textContent.trim();
if (!colorMap.has(colorId)) {
colorMap.set(colorId, []);
}
colorMap.get(colorId).push({
element: option,
colorRgb: colorRgb,
colorName: colorName,
selected: option.selected
});
});
// Second pass: merge duplicates, keeping the one with color_rgb
colorMap.forEach((options, colorId) => {
if (options.length > 1) {
// Find option with color_rgb (not empty/null/undefined)
const withRgb = options.find(opt => opt.colorRgb && opt.colorRgb !== 'None' && opt.colorRgb !== '');
const withoutRgb = options.find(opt => !opt.colorRgb || opt.colorRgb === 'None' || opt.colorRgb === '');
if (withRgb && withoutRgb) {
// Keep the selected state from either option
const wasSelected = withRgb.selected || withoutRgb.selected;
// Update the option with RGB to be selected if either was selected
if (wasSelected) {
withRgb.element.selected = true;
}
// Remove the option without RGB
withoutRgb.element.remove();
}
}
});
// Add color squares to remaining option text
const remainingOptions = colorSelect.querySelectorAll('option[data-color-rgb]');
remainingOptions.forEach(option => {
const colorRgb = option.dataset.colorRgb;
const colorId = option.dataset.colorId;
const colorName = option.textContent.trim();
if (colorRgb && colorRgb !== 'None' && colorRgb !== '' && colorId !== '9999') {
// Create a visual indicator (using Unicode square)
option.textContent = `${colorName}`; //■
//option.style.color = `#${colorRgb}`;
}
});
}
// Check if pagination mode is enabled for a specific table
function isPaginationModeForTable(tableId) {
const tableElement = document.querySelector(`#${tableId}`);
return tableElement && tableElement.getAttribute('data-table') === 'false';
}
// Update sort icon based on current sort direction
function updateSortIcon(sortDirection = null) {
// Find the main sort icon (could be in grid-sort or table-sort)
const sortIcon = document.querySelector('#grid-sort .ri-sort-asc, #grid-sort .ri-sort-desc, #table-sort .ri-sort-asc, #table-sort .ri-sort-desc');
if (!sortIcon) return;
// Remove existing sort classes
sortIcon.classList.remove('ri-sort-asc', 'ri-sort-desc');
// Add appropriate class based on sort direction
if (sortDirection === 'desc') {
sortIcon.classList.add('ri-sort-desc');
} else {
sortIcon.classList.add('ri-sort-asc');
}
}
// Initialize sort button states and icons for pagination mode
window.initializeSortButtonStates = function(currentSort, currentOrder) {
const sortButtons = document.querySelectorAll('[data-sort-attribute]');
// Update main sort icon
updateSortIcon(currentOrder);
if (currentSort) {
sortButtons.forEach(btn => {
// Clear all buttons first
btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-primary');
btn.removeAttribute('data-current-direction');
// Set active state for current sort
if (btn.dataset.sortAttribute === currentSort) {
btn.classList.remove('btn-outline-primary');
btn.classList.add('btn-primary');
btn.dataset.currentDirection = currentOrder || 'asc';
}
});
}
};
// Shared sort button setup function
window.setupSharedSortButtons = function(tableId, tableInstanceGlobal, columnMap) {
const sortButtons = document.querySelectorAll('[data-sort-attribute]');
const clearButton = document.querySelector('[data-sort-clear]');
const isPaginationMode = isPaginationModeForTable(tableId);
sortButtons.forEach(button => {
button.addEventListener('click', () => {
const attribute = button.dataset.sortAttribute;
const isDesc = button.dataset.sortDesc === 'true';
if (isPaginationMode) {
// PAGINATION MODE - Server-side sorting via URL parameters
const currentUrl = new URL(window.location);
const currentSort = currentUrl.searchParams.get('sort');
const currentOrder = currentUrl.searchParams.get('order');
const isCurrentlyActive = currentSort === attribute;
let newDirection;
if (isCurrentlyActive) {
// Toggle direction if same attribute
newDirection = currentOrder === 'asc' ? 'desc' : 'asc';
} else {
// Use default direction for new attribute
newDirection = isDesc ? 'desc' : 'asc';
}
// Set sort parameters and reset to first page
currentUrl.searchParams.set('sort', attribute);
currentUrl.searchParams.set('order', newDirection);
currentUrl.searchParams.set('page', '1');
// Navigate to sorted results
window.location.href = currentUrl.toString();
} else {
// ORIGINAL MODE - Client-side sorting via Simple DataTables
const columnIndex = columnMap[attribute];
const tableInstance = window[tableInstanceGlobal];
if (columnIndex !== undefined && tableInstance) {
// Determine sort direction
const isCurrentlyActive = button.classList.contains('btn-primary');
const currentDirection = button.dataset.currentDirection || (isDesc ? 'desc' : 'asc');
const newDirection = isCurrentlyActive ?
(currentDirection === 'asc' ? 'desc' : 'asc') :
(isDesc ? 'desc' : 'asc');
// Clear other active buttons
sortButtons.forEach(btn => {
btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-primary');
btn.removeAttribute('data-current-direction');
});
// Mark this button as active
button.classList.remove('btn-outline-primary');
button.classList.add('btn-primary');
button.dataset.currentDirection = newDirection;
// Apply sort using Simple DataTables API
tableInstance.table.columns.sort(columnIndex, newDirection);
// Update sort icon to reflect new direction
updateSortIcon(newDirection);
}
}
});
});
if (clearButton) {
clearButton.addEventListener('click', () => {
if (isPaginationMode) {
// PAGINATION MODE - Clear server-side sorting via URL parameters
const currentUrl = new URL(window.location);
currentUrl.searchParams.delete('sort');
currentUrl.searchParams.delete('order');
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
} else {
// ORIGINAL MODE - Clear client-side sorting
// Clear all sort buttons
sortButtons.forEach(btn => {
btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-primary');
btn.removeAttribute('data-current-direction');
});
// Reset sort icon to default ascending
updateSortIcon('asc');
// Reset table sort - remove all sorting
const tableInstance = window[tableInstanceGlobal];
if (tableInstance) {
const tableElement = document.querySelector(`#${tableId}`);
const currentPerPage = tableInstance.table.options.perPage;
tableInstance.table.destroy();
setTimeout(() => {
// Create new instance using the globally available BrickTable class
const newInstance = new window.BrickTable(tableElement, currentPerPage);
window[tableInstanceGlobal] = newInstance;
// Re-enable search functionality
newInstance.table.searchable = true;
}, 50);
}
}
});
}
};
// =================================================================
// SHARED FUNCTIONS FOR PAGE-SPECIFIC OPERATIONS
// =================================================================
// Shared pagination mode detection (works for any table/grid ID)
window.isPaginationModeForPage = function(elementId, attributeName = 'data-table') {
const element = document.querySelector(`#${elementId}`);
return element && element.getAttribute(attributeName) === 'false';
};
// Shared URL parameter update helper
window.updateUrlParams = function(params, resetPage = true) {
const currentUrl = new URL(window.location);
// Apply parameter updates
Object.entries(params).forEach(([key, value]) => {
if (value === null || value === undefined || value === '' || value === 'all') {
currentUrl.searchParams.delete(key);
} else {
currentUrl.searchParams.set(key, value);
}
});
// Reset to page 1 if requested
if (resetPage) {
currentUrl.searchParams.set('page', '1');
}
// Navigate to updated URL
window.location.href = currentUrl.toString();
};
// Shared filter application (supports owner and color filters)
window.applyPageFilters = function(tableId) {
const ownerSelect = document.getElementById('filter-owner');
const colorSelect = document.getElementById('filter-color');
const params = {};
// Handle owner filter
if (ownerSelect) {
params.owner = ownerSelect.value;
}
// Handle color filter
if (colorSelect) {
params.color = colorSelect.value;
}
// Update URL with new parameters
window.updateUrlParams(params, true);
};
// Shared search setup for both pagination and client-side modes
window.setupPageSearch = function(tableId, searchInputId, clearButtonId, tableInstanceGlobal) {
const searchInput = document.getElementById(searchInputId);
const searchClear = document.getElementById(clearButtonId);
if (!searchInput || !searchClear) return;
const isPaginationMode = window.isPaginationModeForPage(tableId);
if (isPaginationMode) {
// PAGINATION MODE - Server-side search with Enter key
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const searchValue = e.target.value.trim();
window.updateUrlParams({ search: searchValue }, true);
}
});
// Clear search
searchClear.addEventListener('click', () => {
searchInput.value = '';
window.updateUrlParams({ search: null }, true);
});
} else {
// ORIGINAL MODE - Client-side instant search via Simple DataTables
const setupClientSearch = () => {
const tableElement = document.querySelector(`table[data-table="true"]#${tableId}`);
const tableInstance = window[tableInstanceGlobal];
if (tableElement && tableInstance) {
// Enable search functionality
tableInstance.table.searchable = true;
// Instant search as user types
searchInput.addEventListener('input', (e) => {
const searchValue = e.target.value.trim();
tableInstance.table.search(searchValue);
});
// Clear search
searchClear.addEventListener('click', () => {
searchInput.value = '';
tableInstance.table.search('');
});
} else {
// If table instance not ready, try again
setTimeout(setupClientSearch, 100);
}
};
setTimeout(setupClientSearch, 100);
}
};
// Shared function to preserve filter state and apply filters
window.applyFiltersAndKeepState = function(tableId, storageKey) {
const filterElement = document.getElementById('table-filter');
const wasOpen = filterElement && filterElement.classList.contains('show');
// Apply the filters
window.applyPageFilters(tableId);
// Store the state to restore after page reload
if (wasOpen) {
sessionStorage.setItem(storageKey, 'open');
}
};
// Shared initialization for table pages (parts, problems, minifigures)
window.initializeTablePage = function(config) {
const {
pagePrefix, // e.g., 'parts', 'problems', 'minifigures'
tableId, // e.g., 'parts', 'problems', 'minifigures'
searchInputId = 'table-search',
clearButtonId = 'table-search-clear',
tableInstanceGlobal, // e.g., 'partsTableInstance', 'problemsTableInstance'
sortColumnMap, // Column mapping for sort buttons
hasColorDropdown = true
} = config;
// Initialize collapsible states (filter and sort)
initializePageCollapsibleStates(pagePrefix);
// Setup search functionality
window.setupPageSearch(tableId, searchInputId, clearButtonId, tableInstanceGlobal);
// Setup color dropdown if needed
if (hasColorDropdown) {
setupColorDropdown();
}
// Setup sort buttons with shared functionality
if (sortColumnMap) {
window.setupSharedSortButtons(tableId, tableInstanceGlobal, sortColumnMap);
}
// Initialize sort button states and icons for pagination mode
if (window.isPaginationModeForPage(tableId)) {
const urlParams = new URLSearchParams(window.location.search);
const currentSort = urlParams.get('sort');
const currentOrder = urlParams.get('order');
window.initializeSortButtonStates(currentSort, currentOrder);
}
};
+136
View File
@@ -0,0 +1,136 @@
// Minifigures page functionality
// Check if we're in pagination mode (server-side) or original mode (client-side)
function isPaginationMode() {
const tableElement = document.querySelector('#minifigures');
return tableElement && tableElement.getAttribute('data-table') === 'false';
}
function filterByOwner() {
const select = document.getElementById('filter-owner');
const selectedOwner = select.value;
const currentUrl = new URL(window.location);
if (selectedOwner === 'all') {
currentUrl.searchParams.delete('owner');
} else {
currentUrl.searchParams.set('owner', selectedOwner);
}
// Reset to page 1 when filtering
if (isPaginationMode()) {
currentUrl.searchParams.set('page', '1');
}
window.location.href = currentUrl.toString();
}
// Initialize filter and sort states for minifigures page
function initializeCollapsibleStates() {
initializePageCollapsibleStates('minifigures');
}
// Keep filters expanded after selection
function filterByOwnerAndKeepOpen() {
preserveCollapsibleStateOnChange('table-filter', 'minifigures-filter-state');
filterByOwner();
}
// Setup table search and sort functionality
document.addEventListener("DOMContentLoaded", () => {
const searchInput = document.getElementById('table-search');
const searchClear = document.getElementById('table-search-clear');
// Initialize collapsible states (filter and sort)
initializeCollapsibleStates();
if (searchInput && searchClear) {
if (isPaginationMode()) {
// PAGINATION MODE - Server-side search
const searchForm = document.createElement('form');
searchForm.style.display = 'none';
searchInput.parentNode.appendChild(searchForm);
searchForm.appendChild(searchInput.cloneNode(true));
// Handle Enter key for search
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
performServerSearch();
}
});
// Handle search button click (if exists)
const searchButton = document.querySelector('[data-search-trigger]');
if (searchButton) {
searchButton.addEventListener('click', performServerSearch);
}
// Clear search
searchClear.addEventListener('click', () => {
searchInput.value = '';
performServerSearch();
});
function performServerSearch() {
const currentUrl = new URL(window.location);
const searchQuery = searchInput.value.trim();
if (searchQuery) {
currentUrl.searchParams.set('search', searchQuery);
} else {
currentUrl.searchParams.delete('search');
}
// Reset to page 1 when searching
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
}
} else {
// ORIGINAL MODE - Client-side search with Simple DataTables
setTimeout(() => {
const tableElement = document.querySelector('table[data-table="true"]');
if (tableElement && window.brickTableInstance) {
// Enable custom search for minifigures table
window.brickTableInstance.table.searchable = true;
// Connect search input to table
searchInput.addEventListener('input', (e) => {
window.brickTableInstance.table.search(e.target.value);
});
// Clear search
searchClear.addEventListener('click', () => {
searchInput.value = '';
window.brickTableInstance.table.search('');
});
}
}, 100);
}
}
// Setup sort buttons for both modes
setupSortButtons();
// Initialize sort button states and icons for pagination mode
if (isPaginationMode()) {
const urlParams = new URLSearchParams(window.location.search);
const currentSort = urlParams.get('sort');
const currentOrder = urlParams.get('order');
window.initializeSortButtonStates(currentSort, currentOrder);
}
});
function setupSortButtons() {
const columnMap = {
'name': 1,
'parts': 2,
'quantity': 3,
'missing': 4,
'damaged': 5,
'sets': 6
};
// Use shared sort buttons setup from collapsible-state.js
window.setupSharedSortButtons('minifigures', 'brickTableInstance', columnMap);
}
+27
View File
@@ -0,0 +1,27 @@
// Parts page functionality - now uses shared functions
// Keep filters expanded after selection
function applyFiltersAndKeepOpen() {
window.applyFiltersAndKeepState('parts', 'parts-filter-state');
}
// Initialize parts page
document.addEventListener("DOMContentLoaded", () => {
// Use shared table page initialization
window.initializeTablePage({
pagePrefix: 'parts',
tableId: 'parts',
tableInstanceGlobal: 'partsTableInstance',
sortColumnMap: {
'name': 1,
'color': 2,
'quantity': 3,
'missing': 4,
'damaged': 5,
'sets': 6,
'minifigures': 7
},
hasColorDropdown: true
});
});
+26
View File
@@ -0,0 +1,26 @@
// Problems page functionality - now uses shared functions
// Keep filters expanded after selection
function applyFiltersAndKeepOpen() {
window.applyFiltersAndKeepState('problems', 'problems-filter-state');
}
// Initialize problems page
document.addEventListener("DOMContentLoaded", () => {
// Use shared table page initialization
window.initializeTablePage({
pagePrefix: 'problems',
tableId: 'problems',
tableInstanceGlobal: 'problemsTableInstance',
sortColumnMap: {
'name': 1,
'color': 2,
'quantity': 3,
'missing': 4,
'damaged': 5,
'sets': 6,
'minifigures': 7
},
hasColorDropdown: true
});
});
+196
View File
@@ -0,0 +1,196 @@
// Sets page functionality
// Check if we're in pagination mode (server-side) or original mode (client-side)
function isPaginationMode() {
const gridElement = document.querySelector('#grid');
return gridElement && gridElement.getAttribute('data-grid') === 'false';
}
// Initialize filter and sort states for sets page
function initializeCollapsibleStates() {
initializePageCollapsibleStates('sets', 'grid-filter', 'grid-sort');
}
// Setup page functionality
document.addEventListener("DOMContentLoaded", () => {
// Initialize collapsible states (filter and sort)
initializeCollapsibleStates();
const searchInput = document.getElementById('grid-search');
const searchClear = document.getElementById('grid-search-clear');
if (searchInput && searchClear) {
if (isPaginationMode()) {
// PAGINATION MODE - Server-side search
const searchForm = document.createElement('form');
searchForm.style.display = 'none';
searchInput.parentNode.appendChild(searchForm);
searchForm.appendChild(searchInput.cloneNode(true));
// Handle Enter key for search
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
performServerSearch();
}
});
// Handle search button click (if exists)
const searchButton = document.querySelector('[data-search-trigger]');
if (searchButton) {
searchButton.addEventListener('click', performServerSearch);
}
// Clear search
searchClear.addEventListener('click', () => {
searchInput.value = '';
performServerSearch();
});
function performServerSearch() {
const currentUrl = new URL(window.location);
const searchQuery = searchInput.value.trim();
if (searchQuery) {
currentUrl.searchParams.set('search', searchQuery);
} else {
currentUrl.searchParams.delete('search');
}
// Reset to page 1 when searching
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
}
// Setup sort buttons for pagination mode
setupPaginationSortButtons();
// Setup filter dropdowns for pagination mode
setupPaginationFilterDropdowns();
// Initialize sort button states and icons for pagination mode
const urlParams = new URLSearchParams(window.location.search);
const currentSort = urlParams.get('sort');
const currentOrder = urlParams.get('order');
window.initializeSortButtonStates(currentSort, currentOrder);
} else {
// ORIGINAL MODE - Grid search functionality is handled by existing grid scripts
// No additional setup needed here
}
}
});
function setupPaginationSortButtons() {
// Sort button functionality for pagination mode
const sortButtons = document.querySelectorAll('[data-sort-attribute]');
const clearButton = document.querySelector('[data-sort-clear]');
sortButtons.forEach(button => {
button.addEventListener('click', () => {
const attribute = button.dataset.sortAttribute;
const isDesc = button.dataset.sortDesc === 'true';
// PAGINATION MODE - Server-side sorting
const currentUrl = new URL(window.location);
const currentSort = currentUrl.searchParams.get('sort');
const currentOrder = currentUrl.searchParams.get('order');
// Determine new sort direction
let newOrder = isDesc ? 'desc' : 'asc';
if (currentSort === attribute) {
// Toggle direction if clicking the same column
newOrder = currentOrder === 'asc' ? 'desc' : 'asc';
}
currentUrl.searchParams.set('sort', attribute);
currentUrl.searchParams.set('order', newOrder);
// Update sort icon immediately before navigation
updateSortIcon(newOrder);
// Reset to page 1 when sorting
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
});
});
if (clearButton) {
clearButton.addEventListener('click', () => {
// PAGINATION MODE - Clear server-side sorting
const currentUrl = new URL(window.location);
currentUrl.searchParams.delete('sort');
currentUrl.searchParams.delete('order');
// Reset sort icon to default ascending
updateSortIcon('asc');
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
});
}
}
function setupPaginationFilterDropdowns() {
// Filter dropdown functionality for pagination mode
const filterDropdowns = document.querySelectorAll('#grid-filter select');
filterDropdowns.forEach(dropdown => {
dropdown.addEventListener('change', () => {
performServerFilter();
});
});
function performServerFilter() {
const currentUrl = new URL(window.location);
// Get all filter values
const statusFilter = document.getElementById('grid-status')?.value || '';
const themeFilter = document.getElementById('grid-theme')?.value || '';
const ownerFilter = document.getElementById('grid-owner')?.value || '';
const purchaseLocationFilter = document.getElementById('grid-purchase-location')?.value || '';
const storageFilter = document.getElementById('grid-storage')?.value || '';
const tagFilter = document.getElementById('grid-tag')?.value || '';
// Update URL parameters
if (statusFilter) {
currentUrl.searchParams.set('status', statusFilter);
} else {
currentUrl.searchParams.delete('status');
}
if (themeFilter) {
currentUrl.searchParams.set('theme', themeFilter);
} else {
currentUrl.searchParams.delete('theme');
}
if (ownerFilter) {
currentUrl.searchParams.set('owner', ownerFilter);
} else {
currentUrl.searchParams.delete('owner');
}
if (purchaseLocationFilter) {
currentUrl.searchParams.set('purchase_location', purchaseLocationFilter);
} else {
currentUrl.searchParams.delete('purchase_location');
}
if (storageFilter) {
currentUrl.searchParams.set('storage', storageFilter);
} else {
currentUrl.searchParams.delete('storage');
}
if (tagFilter) {
currentUrl.searchParams.set('tag', tagFilter);
} else {
currentUrl.searchParams.delete('tag');
}
// Reset to page 1 when filtering
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
}
}
+24 -3
View File
@@ -1,4 +1,5 @@
class BrickTable { // Make BrickTable globally accessible
window.BrickTable = class BrickTable {
constructor(table, per_page) { constructor(table, per_page) {
const columns = []; const columns = [];
const no_sort_and_filter = []; const no_sort_and_filter = [];
@@ -32,12 +33,22 @@ class BrickTable {
columns.push({ select: number, type: "number", searchable: false }); columns.push({ select: number, type: "number", searchable: false });
} }
// Special configuration for tables with custom search/sort
const isMinifiguresTable = table.id === 'minifigures';
const isPartsTable = table.id === 'parts';
const isProblemsTable = table.id === 'problems';
const isPartsTablePaginationMode = isPartsTable && table.getAttribute('data-table') === 'false';
const isProblemsTablePaginationMode = isProblemsTable && table.getAttribute('data-table') === 'false';
const hasCustomInterface = isMinifiguresTable || isPartsTablePaginationMode || isProblemsTablePaginationMode;
const hasCustomSearch = isPartsTable || isProblemsTable || isMinifiguresTable; // Parts, problems, and minifigures always have custom search
this.table = new simpleDatatables.DataTable(`#${table.id}`, { this.table = new simpleDatatables.DataTable(`#${table.id}`, {
columns: columns, columns: columns,
paging: !(isPartsTablePaginationMode || isProblemsTablePaginationMode), // Disable built-in pagination for tables in pagination mode
pagerDelta: 1, pagerDelta: 1,
perPage: per_page, perPage: per_page,
perPageSelect: [10, 25, 50, 100, 500, 1000], perPageSelect: [10, 25, 50, 100, 500, 1000],
searchable: true, searchable: !hasCustomSearch, // Disable built-in search for tables with custom search
searchMethod: (table => (terms, cell, row, column, source) => table.search(terms, cell, row, column, source))(this), searchMethod: (table => (terms, cell, row, column, source) => table.search(terms, cell, row, column, source))(this),
searchQuerySeparator: "", searchQuerySeparator: "",
tableRender: () => { tableRender: () => {
@@ -92,5 +103,15 @@ class BrickTable {
// Helper to setup the tables // Helper to setup the tables
const setup_tables = (per_page) => document.querySelectorAll('table[data-table="true"]').forEach( const setup_tables = (per_page) => document.querySelectorAll('table[data-table="true"]').forEach(
el => new BrickTable(el, per_page) el => {
const brickTable = new window.BrickTable(el, per_page);
// Store the instance globally for external access
if (el.id === 'minifigures') {
window.brickTableInstance = brickTable;
} else if (el.id === 'parts') {
window.partsTableInstance = brickTable;
} else if (el.id === 'problems') {
window.problemsTableInstance = brickTable;
}
}
); );
+22
View File
@@ -77,4 +77,26 @@
linear-gradient(127deg, rgb(0 255 0 / 80%), rgb(0 255 0 / 0%) 70.71%), linear-gradient(127deg, rgb(0 255 0 / 80%), rgb(0 255 0 / 0%) 70.71%),
linear-gradient(336deg, rgb(0 0 255 / 80%), rgb(0 0 255 / 0%) 70.71%) linear-gradient(336deg, rgb(0 0 255 / 80%), rgb(0 0 255 / 0%) 70.71%)
; ;
}
/* Mobile Pagination Fixes */
.mobile-pagination {
display: flex !important;
width: 100% !important;
gap: 2px;
}
.mobile-pagination .btn:first-child,
.mobile-pagination .btn:last-child {
flex: 0 0 auto;
min-width: 90px;
max-width: 120px;
}
.mobile-pagination .btn:nth-child(2) {
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
} }
+3
View File
@@ -1,5 +1,6 @@
{% import 'macro/table.html' as table %} {% import 'macro/table.html' as table %}
{% import 'macro/badge.html' as badge %} {% import 'macro/badge.html' as badge %}
{% import 'macro/form.html' as form %}
<div class="alert alert-info m-2" role="alert">This page lists the sets that may need a refresh because they have some of their newer fields containing empty values.</div> <div class="alert alert-info m-2" role="alert">This page lists the sets that may need a refresh because they have some of their newer fields containing empty values.</div>
<div class="table-responsive-sm"> <div class="table-responsive-sm">
@@ -13,6 +14,7 @@
<th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Empty RGB</th> <th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Empty RGB</th>
<th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Empty transparent</th> <th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Empty transparent</th>
<th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Empty URL</th> <th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Empty URL</th>
<th data-table-number="true" scope="col"><i class="ri-palette-line fw-normal"></i>BrickLink Issues</th>
<th data-table-no-sort-and-search="true" class="no-sort" scope="col"><i class="ri-settings-4-line fw-normal"></i> Actions</th> <th data-table-no-sort-and-search="true" class="no-sort" scope="col"><i class="ri-settings-4-line fw-normal"></i> Actions</th>
</tr> </tr>
</thead> </thead>
@@ -26,6 +28,7 @@
<td>{{ item.fields.null_rgb }}</td> <td>{{ item.fields.null_rgb }}</td>
<td>{{ item.fields.null_transparent }}</td> <td>{{ item.fields.null_transparent }}</td>
<td>{{ item.fields.null_url }}</td> <td>{{ item.fields.null_url }}</td>
<td>{{ item.fields.null_bricklink_color_id + item.fields.null_bricklink_color_name + item.fields.null_bricklink_part_num }}</td>
<td><a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh</a></td> <td><a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh</a></td>
</tr> </tr>
{% endfor %} {% endfor %}
+14
View File
@@ -81,6 +81,7 @@
<script src="https://cdn.jsdelivr.net/npm/simple-datatables@9.2.1/dist/umd/simple-datatables.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/simple-datatables@9.2.1/dist/umd/simple-datatables.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vanillajs-datepicker@1.3.4/dist/js/datepicker-full.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/vanillajs-datepicker@1.3.4/dist/js/datepicker-full.min.js"></script>
<!-- BrickTracker scripts --> <!-- BrickTracker scripts -->
<script src="{{ url_for('static', filename='scripts/collapsible-state.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/changer.js') }}"></script> <script src="{{ url_for('static', filename='scripts/changer.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/grid/filter.js') }}"></script> <script src="{{ url_for('static', filename='scripts/grid/filter.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/grid/grid.js') }}"></script> <script src="{{ url_for('static', filename='scripts/grid/grid.js') }}"></script>
@@ -91,6 +92,19 @@
<script src="{{ url_for('static', filename='scripts/socket/instructions.js') }}"></script> <script src="{{ url_for('static', filename='scripts/socket/instructions.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/socket/set.js') }}"></script> <script src="{{ url_for('static', filename='scripts/socket/set.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/table.js') }}"></script> <script src="{{ url_for('static', filename='scripts/table.js') }}"></script>
{% if request.endpoint == 'minifigure.list' %}
<script src="{{ url_for('static', filename='scripts/minifigures.js') }}"></script>
{% endif %}
{% if request.endpoint == 'part.list' %}
<script src="{{ url_for('static', filename='scripts/parts.js') }}"></script>
{% endif %}
{% if request.endpoint == 'part.problem' %}
<script src="{{ url_for('static', filename='scripts/parts.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/problems.js') }}"></script>
{% endif %}
{% if request.endpoint == 'set.list' %}
<script src="{{ url_for('static', filename='scripts/sets.js') }}"></script>
{% endif %}
<script type="text/javascript"> <script type="text/javascript">
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
setup_grids(); setup_grids();
+11 -11
View File
@@ -1,6 +1,6 @@
{% macro checkbox(name, id, prefix, url, checked, parent=none, delete=false) %} {% macro checkbox(name, id, prefix, url, checked, parent=none, delete=false) %}
{% if g.login.is_authenticated() %} {% if g.login.is_authenticated() %}
<input class="form-check-input" type="checkbox" id="{{ prefix }}-{{ id }}" {% if checked %}checked{% endif %} <input class="form-check-input px-1" type="checkbox" id="{{ prefix }}-{{ id }}" {% if checked %}checked{% endif %}
{% if not delete %} {% if not delete %}
data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}" {% if parent %}data-changer-parent="{{ parent }}"{% endif %} data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}" {% if parent %}data-changer-parent="{{ parent }}"{% endif %}
{% else %} {% else %}
@@ -22,8 +22,8 @@
{% else %} {% else %}
<label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label> <label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label>
<div class="input-group"> <div class="input-group">
{% if icon %}<span class="input-group-text"><i class="ri-{{ icon }} me-1"></i><span class="ms-1 d-none d-md-inline"> {{ name }}</span></span>{% endif %} {% if icon %}<span class="input-group-text px-1"><i class="ri-{{ icon }} me-1"></i><span class="ms-1 d-none d-md-inline"> {{ name }}</span></span>{% endif %}
<input class="form-control form-control-sm flex-shrink-1" type="text" id="{{ prefix }}-{{ id }}" value="{% if value %}{{ value }}{% endif %}" <input class="form-control form-control-sm flex-shrink-1 px-1" type="text" id="{{ prefix }}-{{ id }}" value="{% if value %}{{ value }}{% endif %}"
{% if g.login.is_authenticated() %} {% if g.login.is_authenticated() %}
data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}" data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}"
{% if date %}data-changer-date="true"{% endif %} {% if date %}data-changer-date="true"{% endif %}
@@ -31,12 +31,12 @@
disabled disabled
{% endif %} {% endif %}
autocomplete="off"> autocomplete="off">
{% if suffix %}<span class="input-group-text d-none d-md-inline">{{ suffix }}</span>{% endif %} {% if suffix %}<span class="input-group-text d-none d-md-inline px-1">{{ suffix }}</span>{% endif %}
{% if g.login.is_authenticated() %} {% if g.login.is_authenticated() %}
<span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line"></span> <span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line px-1"></span>
<button id="clear-{{ prefix }}-{{ id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button> <button id="clear-{{ prefix }}-{{ id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border px-1"><i class="ri-eraser-line"></i></button>
{% else %} {% else %}
<span class="input-group-text ri-prohibited-line"></span> <span class="input-group-text ri-prohibited-line px-1"></span>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -46,8 +46,8 @@
{% if g.login.is_authenticated() %} {% if g.login.is_authenticated() %}
<label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label> <label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label>
<div class="input-group"> <div class="input-group">
{% if icon %}<span class="input-group-text"><i class="ri-{{ icon }} me-1"></i><span class="ms-1 d-none d-md-inline"> {{ name }}</span></span>{% endif %} {% if icon %}<span class="input-group-text px-1"><i class="ri-{{ icon }} me-1"></i><span class="ms-1 d-none d-md-inline"> {{ name }}</span></span>{% endif %}
<select id="{{ prefix }}-{{ id }}" class="form-select" <select id="{{ prefix }}-{{ id }}" class="form-select px-1"
{% if not delete %} {% if not delete %}
data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}" data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}"
{% else %} {% else %}
@@ -59,8 +59,8 @@
<option value="{{ metadata.fields.id }}" {% if metadata.fields.id == value %}selected{% endif %}>{{ metadata.fields.name }}</option> <option value="{{ metadata.fields.id }}" {% if metadata.fields.id == value %}selected{% endif %}>{{ metadata.fields.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
<span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line"></span> <span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line px-1"></span>
<button id="clear-{{ prefix }}-{{ id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button> <button id="clear-{{ prefix }}-{{ id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border px-1"><i class="ri-eraser-line"></i></button>
</div> </div>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
+15
View File
@@ -0,0 +1,15 @@
<div id="table-filter" class="collapse {% if config['SHOW_GRID_FILTERS'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center">
{% if owners | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-user-line"></i><span class="ms-1 d-none d-md-inline"> Owner</span></span>
<select id="filter-owner" class="form-select" onchange="filterByOwnerAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_owner == 'all' %}selected{% endif %}>All owners</option>
{% for owner in owners %}
<option value="{{ owner.fields.id }}" {% if selected_owner == owner.fields.id %}selected{% endif %}>{{ owner.fields.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
</div>
+25
View File
@@ -0,0 +1,25 @@
<div id="table-sort" class="collapse {% if config['SHOW_GRID_SORT'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center">
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text mb-2"><i class="ri-sort-asc"></i><span class="ms-1 d-none d-md-inline"> Sort</span></span>
<button id="sort-name" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="name"><i class="ri-pencil-line"></i><span class="d-none d-md-inline"> Name</span></button>
<button id="sort-parts" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="parts" data-sort-desc="true"><i class="ri-shapes-line"></i><span class="d-none d-xl-inline"> Parts</span></button>
<button id="sort-quantity" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="quantity" data-sort-desc="true"><i class="ri-functions"></i><span class="d-none d-xl-inline"> Quantity</span></button>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<button id="sort-missing" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="missing" data-sort-desc="true"><i class="ri-question-line"></i><span class="d-none d-xl-inline"> Missing</span></button>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<button id="sort-damaged" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="damaged" data-sort-desc="true"><i class="ri-error-warning-line"></i><span class="d-none d-xl-inline"> Damaged</span></button>
{% endif %}
<button id="sort-sets" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="sets" data-sort-desc="true"><i class="ri-hashtag"></i><span class="d-none d-xl-inline"> Sets</span></button>
<button id="sort-clear" type="button" class="btn btn-outline-dark mb-2"
data-sort-clear="true"><i class="ri-close-circle-line"></i><span class="d-none d-xl-inline"> Clear</span></button>
</div>
</div>
</div>
+6 -6
View File
@@ -7,17 +7,17 @@
{% for item in table_collection %} {% for item in table_collection %}
<tr> <tr>
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.figure) }} {{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.figure) }}
<td > <td data-sort="name">
<a class="text-reset" href="{{ item.url() }}" style="max-width:auto">{{ item.fields.name }}</a> <a class="text-reset" href="{{ item.url() }}" style="max-width:auto">{{ item.fields.name }}</a>
{% if all %} {% if all %}
{{ table.rebrickable(item) }} {{ table.rebrickable(item) }}
{% endif %} {% endif %}
</td> </td>
<td>{{ item.fields.number_of_parts }}</td> <td data-sort="parts">{{ item.fields.number_of_parts }}</td>
<td>{{ item.fields.total_quantity }}</td> <td data-sort="quantity">{{ item.fields.total_quantity }}</td>
<td>{{ item.fields.total_missing }}</td> <td data-sort="missing">{{ item.fields.total_missing }}</td>
<td>{{ item.fields.total_damaged }}</td> <td data-sort="damaged">{{ item.fields.total_damaged }}</td>
<td>{{ item.fields.total_sets }}</td> <td data-sort="sets">{{ item.fields.total_sets }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
+22
View File
@@ -0,0 +1,22 @@
{% import 'macro/table.html' as table %}
<tbody>
{% for minifigure in table_collection %}
<tr>
{{ table.image(minifigure.url_for_image(), caption=minifigure.fields.name, alt=minifigure.fields.figure) }}
<td data-sort="{{ minifigure.fields.name }}">
<a class="text-reset" href="{{ minifigure.url() }}">{{ minifigure.fields.name }}</a>
{{ table.rebrickable(minifigure) }}
</td>
<td data-sort="{{ minifigure.fields.number_of_parts }}">{{ minifigure.fields.number_of_parts }}</td>
<td data-sort="{{ minifigure.fields.total_quantity }}">{{ minifigure.fields.total_quantity }}</td>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<td data-sort="{{ minifigure.fields.total_missing }}">{{ minifigure.fields.total_missing }}</td>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<td data-sort="{{ minifigure.fields.total_damaged }}">{{ minifigure.fields.total_damaged }}</td>
{% endif %}
<td data-sort="{{ minifigure.fields.total_sets }}">{{ minifigure.fields.total_sets }}</td>
</tr>
{% endfor %}
</tbody>
+185 -5
View File
@@ -1,11 +1,191 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% import 'macro/table.html' as table %}
{% block title %} - All minifigures{% endblock %} {% block title %} - All minifigures{% endblock %}
{% block main %} {% block main %}
<div class="container-fluid px-0"> {% if table_collection | length %}
{% with all=true %} <div class="container-fluid">
{% include 'minifigure/table.html' %} <div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
{% endwith %} <div class="col-12 flex-grow-1">
<label class="visually-hidden" for="table-search">Search</label>
<div class="input-group">
<span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-md-inline"> Search</span></span>
<input id="table-search" class="form-control form-control-sm px-1" type="text" placeholder="Figure name, parts count, sets" value="{{ search_query or '' }}">
<button id="table-search-clear" type="button" class="btn btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
</div>
</div>
<div class="col-12">
<div class="input-group">
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#table-sort" aria-expanded="{% if config['SHOW_GRID_SORT'] %}true{% else %}false{% endif %}" aria-controls="table-sort">
<i class="ri-sort-asc"></i> Sort
</button>
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#table-filter" aria-expanded="{% if config['SHOW_GRID_FILTERS'] %}true{% else %}false{% endif %}" aria-controls="table-filter">
<i class="ri-filter-line"></i> Filters
</button>
</div>
</div>
</div>
{% include 'minifigure/sort.html' %}
{% include 'minifigure/filter.html' %}
{% if use_pagination %}
<!-- PAGINATION MODE -->
<div class="table-responsive-sm">
<table data-table="false" class="table table-striped align-middle mb-0" id="minifigures">
{{ table.header(parts=true, quantity=true, missing=true, damaged=true, sets=true, minifigures=false) }}
{% include 'minifigure/table_body.html' %}
</table>
</div>
<!-- Pagination -->
<div>
{% if pagination and pagination.total_pages > 1 %}
<div class="row mt-4">
<div class="col-12">
<!-- Desktop Pagination -->
<div class="d-none d-md-block">
<nav aria-label="Minifigures pagination">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page - 1) }}">
<i class="ri-arrow-left-line"></i> Previous
</a>
</li>
{% endif %}
<!-- Show page numbers (with smart truncation) -->
{% set start_page = [1, pagination.page - 2] | max %}
{% set end_page = [pagination.total_pages, pagination.page + 2] | min %}
{% if start_page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', 1) }}">1</a>
</li>
{% if start_page > 2 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endif %}
{% for page_num in range(start_page, end_page + 1) %}
{% if page_num == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', page_num) }}">{{ page_num }}</a>
</li>
{% endif %}
{% endfor %}
{% if end_page < pagination.total_pages %}
{% if end_page < pagination.total_pages - 1 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.total_pages) }}">{{ pagination.total_pages }}</a>
</li>
{% endif %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page + 1) }}">
Next <i class="ri-arrow-right-line"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
<!-- Mobile Pagination -->
<div class="d-md-none">
<div class="mobile-pagination" role="group" aria-label="Mobile pagination">
{% if pagination.has_prev %}
<a href="{{ request.url | replace_query('page', pagination.page - 1) }}"
class="btn btn-outline-primary">
<i class="ri-arrow-left-line"></i> Previous
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
<i class="ri-arrow-left-line"></i> Previous
</button>
{% endif %}
<span class="btn btn-light">
Page {{ pagination.page }} of {{ pagination.total_pages }}
</span>
{% if pagination.has_next %}
<a href="{{ request.url | replace_query('page', pagination.page + 1) }}"
class="btn btn-outline-primary">
Next <i class="ri-arrow-right-line"></i>
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
Next <i class="ri-arrow-right-line"></i>
</button>
{% endif %}
</div>
</div>
<!-- Results Info -->
<div class="text-center mt-3">
<small class="text-muted">
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} to
{{ [pagination.page * pagination.per_page, pagination.total_count] | min }}
of {{ pagination.total_count }} minifigures
</small>
</div>
</div>
</div>
{% endif %}
</div>
{% else %}
<!-- ORIGINAL MODE - Single page with client-side search -->
<div class="table-responsive-sm">
<table data-table="true" class="table table-striped align-middle {% if not all %}sortable mb-0{% endif %}" id="minifigures">
{{ table.header(parts=true, quantity=true, missing=true, damaged=true, sets=true, minifigures=false) }}
<tbody>
{% for minifigure in table_collection %}
<tr>
{{ table.image(minifigure.url_for_image(), caption=minifigure.fields.name, alt=minifigure.fields.figure) }}
<td data-sort="{{ minifigure.fields.name }}">
<a class="text-reset" href="{{ minifigure.url() }}">{{ minifigure.fields.name }}</a>
{{ table.rebrickable(minifigure) }}
</td>
<td data-sort="{{ minifigure.fields.number_of_parts }}">{{ minifigure.fields.number_of_parts }}</td>
<td data-sort="{{ minifigure.fields.total_quantity }}">{{ minifigure.fields.total_quantity }}</td>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<td data-sort="{{ minifigure.fields.total_missing }}">{{ minifigure.fields.total_missing }}</td>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<td data-sort="{{ minifigure.fields.total_damaged }}">{{ minifigure.fields.total_damaged }}</td>
{% endif %}
<td data-sort="{{ minifigure.fields.total_sets }}">{{ minifigure.fields.total_sets }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div> </div>
{% endblock %} {% else %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="text-center">
<i class="ri-group-line" style="font-size: 4rem; color: #6c757d;"></i>
<h3 class="mt-3">No minifigures found</h3>
<p class="text-muted">No minifigures are available for the selected owner.</p>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
<div id="table-filter" class="collapse {% if config['SHOW_GRID_FILTERS'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center">
{% if owners | length %}
<div class="col-12 col-md-6 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-user-line"></i><span class="ms-1 d-none d-md-inline"> Owner</span></span>
<select id="filter-owner" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_owner == 'all' %}selected{% endif %}>All owners</option>
{% for owner in owners %}
<option value="{{ owner.fields.id }}" {% if selected_owner == owner.fields.id %}selected{% endif %}>{{ owner.fields.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if colors | length %}
<div class="col-12 col-md-6 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-palette-line"></i><span class="ms-1 d-none d-md-inline"> Color</span></span>
<select id="filter-color" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_color == 'all' %}selected{% endif %}>All colors</option>
{% for color in colors %}
<option value="{{ color.color_id }}" {% if selected_color == color.color_id|string %}selected{% endif %} data-color-rgb="{{ color.color_rgb }}" data-color-id="{{ color.color_id }}">
{{ color.color_name }}
</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
</div>
+27
View File
@@ -0,0 +1,27 @@
<div id="table-sort" class="collapse {% if config['SHOW_GRID_SORT'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center">
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text mb-2"><i class="ri-sort-asc"></i><span class="ms-1 d-none d-md-inline"> Sort</span></span>
<button id="sort-name" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="name"><i class="ri-pencil-line"></i><span class="d-none d-md-inline"> Name</span></button>
<button id="sort-color" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="color"><i class="ri-palette-line"></i><span class="d-none d-xl-inline"> Color</span></button>
<button id="sort-quantity" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="quantity" data-sort-desc="true"><i class="ri-functions"></i><span class="d-none d-xl-inline"> Quantity</span></button>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<button id="sort-missing" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="missing" data-sort-desc="true"><i class="ri-question-line"></i><span class="d-none d-xl-inline"> Missing</span></button>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<button id="sort-damaged" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="damaged" data-sort-desc="true"><i class="ri-error-warning-line"></i><span class="d-none d-xl-inline"> Damaged</span></button>
{% endif %}
<button id="sort-sets" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="sets" data-sort-desc="true"><i class="ri-hashtag"></i><span class="d-none d-xl-inline"> Sets</span></button>
<button id="sort-minifigures" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="minifigures" data-sort-desc="true"><i class="ri-group-line"></i><span class="d-none d-xl-inline"> Figures</span></button>
<button id="sort-clear" type="button" class="btn btn-outline-dark mb-2"
data-sort-clear="true"><i class="ri-close-circle-line"></i><span class="d-none d-xl-inline"> Clear</span></button>
</div>
</div>
</div>
+35
View File
@@ -0,0 +1,35 @@
{% import 'macro/form.html' as form %}
{% import 'macro/table.html' as table %}
<tbody>
{% for item in table_collection %}
<tr>
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part, accordion=solo) }}
<td data-sort="{{ item.fields.name }}">
<a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a>
{% if item.fields.spare %}<span class="badge rounded-pill text-bg-warning fw-normal"><i class="ri-loop-left-line"></i> Spare</span>{% endif %}
{% if all %}
{{ table.rebrickable(item) }}
{{ table.bricklink(item) }}
{% endif %}
</td>
<td data-sort="{{ item.fields.color_name }}">
{% if item.fields.color_rgb %}<span class="color-rgb color-rgb-table {% if item.fields.color == 9999 %}color-any{% endif %} align-middle border border-black" {% if item.fields.color != 9999 %}style="background-color: #{{ item.fields.color_rgb }};"{% endif %}></span>{% endif %}
<span class="align-middle">{{ item.fields.color_name }}</span>
</td>
<td>{{ item.fields.total_quantity }}</td>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<td data-sort="{{ item.fields.total_missing }}" class="table-td-input">
{{ form.input('Missing', item.fields.id, item.html_id('missing'), item.url_for_problem('missing'), item.fields.total_missing, all=all, read_only=read_only) }}
</td>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<td data-sort="{{ item.fields.total_damaged }}" class="table-td-input">
{{ form.input('Damaged', item.fields.id, item.html_id('damaged'), item.url_for_problem('damaged'), item.fields.total_damaged, all=all, read_only=read_only) }}
</td>
{% endif %}
<td>{{ item.fields.total_sets }}</td>
<td>{{ item.fields.total_minifigures }}</td>
</tr>
{% endfor %}
</tbody>
+198 -5
View File
@@ -1,11 +1,204 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% import 'macro/form.html' as form %}
{% import 'macro/table.html' as table %}
{% block title %} - All parts{% endblock %} {% block title %} - All parts{% endblock %}
{% block main %} {% block main %}
<div class="container-fluid px-0"> {% if table_collection | length %}
{% with all=true %} <div class="container-fluid">
{% include 'part/table.html' %} <div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
{% endwith %} <div class="col-12 flex-grow-1">
<label class="visually-hidden" for="table-search">Search</label>
<div class="input-group">
<span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-md-inline"> Search</span></span>
<input id="table-search" class="form-control form-control-sm px-1" type="text" placeholder="Part name, color, quantity, sets" value="{{ search_query or '' }}">
<button id="table-search-clear" type="button" class="btn btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
</div>
</div>
<div class="col-12">
<div class="input-group">
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#table-sort" aria-expanded="{% if config['SHOW_GRID_SORT'] %}true{% else %}false{% endif %}" aria-controls="table-sort">
<i class="ri-sort-asc"></i> Sort
</button>
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#table-filter" aria-expanded="{% if config['SHOW_GRID_FILTERS'] %}true{% else %}false{% endif %}" aria-controls="table-filter">
<i class="ri-filter-line"></i> Filters
</button>
</div>
</div>
</div>
{% include 'part/sort.html' %}
{% include 'part/filter.html' %}
{% if use_pagination %}
<!-- PAGINATION MODE -->
<div class="table-responsive-sm">
<table data-table="false" class="table table-striped align-middle mb-0" id="parts">
{{ table.header(color=true, quantity=not no_quantity, sets=true, minifigures=true) }}
{% with all=true %}
{% include 'part/table_body.html' %}
{% endwith %}
</table>
</div>
<!-- Pagination -->
<div>
{% if pagination and pagination.total_pages > 1 %}
<div class="row mt-4">
<div class="col-12">
<!-- Desktop Pagination -->
<div class="d-none d-md-block">
<nav aria-label="Parts pagination">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page - 1) }}">
<i class="ri-arrow-left-line"></i> Previous
</a>
</li>
{% endif %}
<!-- Show page numbers (with smart truncation) -->
{% set start_page = [1, pagination.page - 2] | max %}
{% set end_page = [pagination.total_pages, pagination.page + 2] | min %}
{% if start_page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', 1) }}">1</a>
</li>
{% if start_page > 2 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endif %}
{% for page_num in range(start_page, end_page + 1) %}
{% if page_num == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', page_num) }}">{{ page_num }}</a>
</li>
{% endif %}
{% endfor %}
{% if end_page < pagination.total_pages %}
{% if end_page < pagination.total_pages - 1 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.total_pages) }}">{{ pagination.total_pages }}</a>
</li>
{% endif %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page + 1) }}">
Next <i class="ri-arrow-right-line"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
<!-- Mobile Pagination -->
<div class="d-md-none">
<div class="mobile-pagination" role="group" aria-label="Mobile pagination">
{% if pagination.has_prev %}
<a href="{{ request.url | replace_query('page', pagination.page - 1) }}"
class="btn btn-outline-primary">
<i class="ri-arrow-left-line"></i> Previous
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
<i class="ri-arrow-left-line"></i> Previous
</button>
{% endif %}
<span class="btn btn-light">
Page {{ pagination.page }} of {{ pagination.total_pages }}
</span>
{% if pagination.has_next %}
<a href="{{ request.url | replace_query('page', pagination.page + 1) }}"
class="btn btn-outline-primary">
Next <i class="ri-arrow-right-line"></i>
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
Next <i class="ri-arrow-right-line"></i>
</button>
{% endif %}
</div>
</div>
<!-- Results Info -->
<div class="text-center mt-3">
<small class="text-muted">
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} to
{{ [pagination.page * pagination.per_page, pagination.total_count] | min }}
of {{ pagination.total_count }} parts
</small>
</div>
</div>
</div>
{% endif %}
</div>
{% else %}
<!-- ORIGINAL MODE - Single page with client-side search -->
<div class="table-responsive-sm">
<table data-table="true" class="table table-striped align-middle {% if not all %}sortable mb-0{% endif %}" id="parts">
{{ table.header(color=true, quantity=not no_quantity, sets=true, minifigures=true) }}
<tbody>
{% for item in table_collection %}
<tr>
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part, accordion=solo) }}
<td data-sort="{{ item.fields.name }}">
<a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a>
{% if item.fields.spare %}<span class="badge rounded-pill text-bg-warning fw-normal"><i class="ri-loop-left-line"></i> Spare</span>{% endif %}
{{ table.rebrickable(item) }}
{{ table.bricklink(item) }}
</td>
<td data-sort="{{ item.fields.color_name }}">
{% if item.fields.color_rgb %}<span class="color-rgb color-rgb-table {% if item.fields.color == 9999 %}color-any{% endif %} align-middle border border-black" {% if item.fields.color != 9999 %}style="background-color: #{{ item.fields.color_rgb }};"{% endif %}></span>{% endif %}
<span class="align-middle">{{ item.fields.color_name }}</span>
</td>
<td>{{ item.fields.total_quantity }}</td>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<td data-sort="{{ item.fields.total_missing }}" class="table-td-input">
{{ form.input('Missing', item.fields.id, item.html_id('missing'), item.url_for_problem('missing'), item.fields.total_missing, all=true, read_only=read_only) }}
</td>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<td data-sort="{{ item.fields.total_damaged }}" class="table-td-input">
{{ form.input('Damaged', item.fields.id, item.html_id('damaged'), item.url_for_problem('damaged'), item.fields.total_damaged, all=true, read_only=read_only) }}
</td>
{% endif %}
<td>{{ item.fields.total_sets }}</td>
<td>{{ item.fields.total_minifigures }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div> </div>
{% endblock %} {% else %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="text-center">
<i class="ri-shapes-line" style="font-size: 4rem; color: #6c757d;"></i>
<h3 class="mt-3">No parts found</h3>
<p class="text-muted">No parts are available for the selected owner.</p>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
+222 -4
View File
@@ -1,11 +1,229 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% import 'macro/form.html' as form %}
{% import 'macro/table.html' as table %}
{% block title %} - Problematic parts{% endblock %} {% block title %} - Problematic parts{% endblock %}
{% block main %} {% block main %}
<div class="container-fluid px-0"> {% if table_collection | length %}
{% with all=true, no_quantity=true %} <div class="container-fluid">
{% include 'part/table.html' %} <div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
{% endwith %} <div class="col-12 flex-grow-1">
<label class="visually-hidden" for="table-search">Search</label>
<div class="input-group">
<span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-md-inline"> Search</span></span>
<input id="table-search" class="form-control form-control-sm px-1" type="text" placeholder="Part name, color" value="{{ search_query or '' }}">
<button id="table-search-clear" type="button" class="btn btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
</div>
</div>
<div class="col-12">
<div class="input-group">
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#table-sort" aria-expanded="{% if config['SHOW_GRID_SORT'] %}true{% else %}false{% endif %}" aria-controls="table-sort">
<i class="ri-sort-asc"></i> Sort
</button>
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#table-filter" aria-expanded="{% if config['SHOW_GRID_FILTERS'] %}true{% else %}false{% endif %}" aria-controls="table-filter">
<i class="ri-filter-line"></i> Filters
</button>
</div>
</div>
</div>
{% include 'problem/sort.html' %}
{% include 'problem/filter.html' %}
{% if use_pagination %}
<!-- PAGINATION MODE -->
<div class="table-responsive-sm">
<table data-table="false" class="table table-striped align-middle mb-0" id="problems">
{{ table.header(color=true, quantity=false, sets=true, minifigures=true) }}
<tbody>
{% for item in table_collection %}
<tr>
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part) }}
<td data-sort="{{ item.fields.name }}">
<a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a>
{% if item.fields.spare %}<span class="badge rounded-pill text-bg-warning fw-normal"><i class="ri-loop-left-line"></i> Spare</span>{% endif %}
{{ table.rebrickable(item) }}
{{ table.bricklink(item) }}
</td>
<td data-sort="{{ item.fields.color_name }}">
{% if item.fields.color_rgb %}<span class="color-rgb color-rgb-table {% if item.fields.color == 9999 %}color-any{% endif %} align-middle border border-black" {% if item.fields.color != 9999 %}style="background-color: #{{ item.fields.color_rgb }};"{% endif %}></span>{% endif %}
<span class="align-middle">{{ item.fields.color_name }}</span>
</td>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<td data-sort="{{ item.fields.total_missing }}" class="table-td-input">
{{ form.input('Missing', item.fields.id, item.html_id('missing'), item.url_for_problem('missing'), item.fields.total_missing, all=true, read_only=read_only) }}
</td>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<td data-sort="{{ item.fields.total_damaged }}" class="table-td-input">
{{ form.input('Damaged', item.fields.id, item.html_id('damaged'), item.url_for_problem('damaged'), item.fields.total_damaged, all=true, read_only=read_only) }}
</td>
{% endif %}
<td>{{ item.fields.total_sets }}</td>
<td>{{ item.fields.total_minifigures }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
<div>
{% if pagination and pagination.total_pages > 1 %}
<div class="row mt-4">
<div class="col-12">
<!-- Desktop Pagination -->
<div class="d-none d-md-block">
<nav aria-label="Problems pagination">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page - 1) }}">
<i class="ri-arrow-left-line"></i> Previous
</a>
</li>
{% endif %}
<!-- Show page numbers (with smart truncation) -->
{% set start_page = [1, pagination.page - 2] | max %}
{% set end_page = [pagination.total_pages, pagination.page + 2] | min %}
{% if start_page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', 1) }}">1</a>
</li>
{% if start_page > 2 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endif %}
{% for page_num in range(start_page, end_page + 1) %}
{% if page_num == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', page_num) }}">{{ page_num }}</a>
</li>
{% endif %}
{% endfor %}
{% if end_page < pagination.total_pages %}
{% if end_page < pagination.total_pages - 1 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.total_pages) }}">{{ pagination.total_pages }}</a>
</li>
{% endif %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page + 1) }}">
Next <i class="ri-arrow-right-line"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
<!-- Mobile Pagination -->
<div class="d-md-none">
<div class="mobile-pagination" role="group" aria-label="Mobile pagination">
{% if pagination.has_prev %}
<a href="{{ request.url | replace_query('page', pagination.page - 1) }}"
class="btn btn-outline-primary">
<i class="ri-arrow-left-line"></i> Previous
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
<i class="ri-arrow-left-line"></i> Previous
</button>
{% endif %}
<span class="btn btn-light">
Page {{ pagination.page }} of {{ pagination.total_pages }}
</span>
{% if pagination.has_next %}
<a href="{{ request.url | replace_query('page', pagination.page + 1) }}"
class="btn btn-outline-primary">
Next <i class="ri-arrow-right-line"></i>
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
Next <i class="ri-arrow-right-line"></i>
</button>
{% endif %}
</div>
</div>
<!-- Results Info -->
<div class="text-center mt-3">
<small class="text-muted">
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} to
{{ [pagination.page * pagination.per_page, pagination.total_count] | min }}
of {{ pagination.total_count }} problematic parts
</small>
</div>
</div>
</div>
{% endif %}
</div>
{% else %}
<!-- ORIGINAL MODE - Single page with client-side search -->
<div class="table-responsive-sm">
<table data-table="true" class="table table-striped align-middle sortable mb-0" id="problems">
{{ table.header(color=true, quantity=false, sets=true, minifigures=true) }}
<tbody>
{% for item in table_collection %}
<tr>
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part) }}
<td data-sort="{{ item.fields.name }}">
<a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a>
{% if item.fields.spare %}<span class="badge rounded-pill text-bg-warning fw-normal"><i class="ri-loop-left-line"></i> Spare</span>{% endif %}
{{ table.rebrickable(item) }}
{{ table.bricklink(item) }}
</td>
<td data-sort="{{ item.fields.color_name }}">
{% if item.fields.color_rgb %}<span class="color-rgb color-rgb-table {% if item.fields.color == 9999 %}color-any{% endif %} align-middle border border-black" {% if item.fields.color != 9999 %}style="background-color: #{{ item.fields.color_rgb }};"{% endif %}></span>{% endif %}
<span class="align-middle">{{ item.fields.color_name }}</span>
</td>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<td data-sort="{{ item.fields.total_missing }}" class="table-td-input">
{{ form.input('Missing', item.fields.id, item.html_id('missing'), item.url_for_problem('missing'), item.fields.total_missing, all=true, read_only=read_only) }}
</td>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<td data-sort="{{ item.fields.total_damaged }}" class="table-td-input">
{{ form.input('Damaged', item.fields.id, item.html_id('damaged'), item.url_for_problem('damaged'), item.fields.total_damaged, all=true, read_only=read_only) }}
</td>
{% endif %}
<td>{{ item.fields.total_sets }}</td>
<td>{{ item.fields.total_minifigures }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div> </div>
{% else %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="text-center">
<i class="ri-error-warning-line" style="font-size: 4rem; color: #6c757d;"></i>
<h3 class="mt-3">No problematic parts found</h3>
<p class="text-muted">Great! All your parts are in perfect condition.</p>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %} {% endblock %}
+30
View File
@@ -0,0 +1,30 @@
<div id="table-filter" class="collapse {% if config['SHOW_GRID_FILTERS'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center">
{% if owners | length %}
<div class="col-12 col-md-6 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-user-line"></i><span class="ms-1 d-none d-md-inline"> Owner</span></span>
<select id="filter-owner" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_owner == 'all' %}selected{% endif %}>All owners</option>
{% for owner in owners %}
<option value="{{ owner.fields.id }}" {% if selected_owner == owner.fields.id %}selected{% endif %}>{{ owner.fields.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if colors | length %}
<div class="col-12 col-md-6 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-palette-line"></i><span class="ms-1 d-none d-md-inline"> Color</span></span>
<select id="filter-color" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_color == 'all' %}selected{% endif %}>All colors</option>
{% for color in colors %}
<option value="{{ color.color_id }}" {% if selected_color == color.color_id|string %}selected{% endif %} data-color-rgb="{{ color.color_rgb }}" data-color-id="{{ color.color_id }}">
{{ color.color_name }}
</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
</div>
+25
View File
@@ -0,0 +1,25 @@
<div id="table-sort" class="collapse {% if config['SHOW_GRID_SORT'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center">
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text mb-2"><i class="ri-sort-asc"></i><span class="ms-1 d-none d-md-inline"> Sort</span></span>
<button id="sort-name" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="name"><i class="ri-pencil-line"></i><span class="d-none d-md-inline"> Name</span></button>
<button id="sort-color" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="color"><i class="ri-palette-line"></i><span class="d-none d-xl-inline"> Color</span></button>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<button id="sort-missing" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="missing" data-sort-desc="true"><i class="ri-question-line"></i><span class="d-none d-xl-inline"> Missing</span></button>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<button id="sort-damaged" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="damaged" data-sort-desc="true"><i class="ri-error-warning-line"></i><span class="d-none d-xl-inline"> Damaged</span></button>
{% endif %}
<button id="sort-sets" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="sets" data-sort-desc="true"><i class="ri-hashtag"></i><span class="d-none d-xl-inline"> Sets</span></button>
<button id="sort-minifigures" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="minifigures" data-sort-desc="true"><i class="ri-group-line"></i><span class="d-none d-xl-inline"> Figures</span></button>
<button id="sort-clear" type="button" class="btn btn-outline-dark mb-2"
data-sort-clear="true"><i class="ri-close-circle-line"></i><span class="d-none d-xl-inline"> Clear</span></button>
</div>
</div>
</div>
+21 -21
View File
@@ -6,26 +6,26 @@
<select id="grid-status" class="form-select" <select id="grid-status" class="form-select"
data-filter="metadata" data-filter="metadata"
autocomplete="off"> autocomplete="off">
<option value="" selected>All</option> <option value="" {% if not current_status_filter %}selected{% endif %}>All</option>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %} {% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<option value="has-missing">Has missing pieces</option> <option value="has-missing" {% if current_status_filter == 'has-missing' %}selected{% endif %}>Has missing pieces</option>
<option value="-has-missing">Has NO missing pieces</option> <option value="-has-missing" {% if current_status_filter == '-has-missing' %}selected{% endif %}>Has NO missing pieces</option>
{% endif %} {% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %} {% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<option value="has-damaged">Has damaged pieces</option> <option value="has-damaged" {% if current_status_filter == 'has-damaged' %}selected{% endif %}>Has damaged pieces</option>
<option value="-has-damaged">Has NO damaged pieces</option> <option value="-has-damaged" {% if current_status_filter == '-has-damaged' %}selected{% endif %}>Has NO damaged pieces</option>
{% endif %} {% endif %}
{% if not config['HIDE_SET_INSTRUCTIONS'] %} {% if not config['HIDE_SET_INSTRUCTIONS'] %}
<option value="-has-missing-instructions">Has instructions</option> <option value="-has-missing-instructions" {% if current_status_filter == '-has-missing-instructions' %}selected{% endif %}>Has instructions</option>
<option value="has-missing-instructions">Is MISSING instructions</option> <option value="has-missing-instructions" {% if current_status_filter == 'has-missing-instructions' %}selected{% endif %}>Is MISSING instructions</option>
{% endif %} {% endif %}
{% if brickset_storages | length %} {% if brickset_storages | length %}
<option value="has-storage">Is in storage</option> <option value="has-storage" {% if current_status_filter == 'has-storage' %}selected{% endif %}>Is in storage</option>
<option value="-has-storage">Is NOT in storage</option> <option value="-has-storage" {% if current_status_filter == '-has-storage' %}selected{% endif %}>Is NOT in storage</option>
{% endif %} {% endif %}
{% for status in brickset_statuses %} {% for status in brickset_statuses %}
<option value="{{ status.as_dataset() }}">{{ status.fields.name }}</option> <option value="{{ status.as_dataset() }}" {% if current_status_filter == status.as_dataset() %}selected{% endif %}>{{ status.fields.name }}</option>
<option value="-{{ status.as_dataset() }}">NOT: {{ status.fields.name }}</option> <option value="-{{ status.as_dataset() }}" {% if current_status_filter == ('-' + status.as_dataset()) %}selected{% endif %}>NOT: {{ status.fields.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -37,9 +37,9 @@
<select id="grid-theme" class="form-select" <select id="grid-theme" class="form-select"
data-filter="value" data-filter-attribute="theme" data-filter="value" data-filter-attribute="theme"
autocomplete="off"> autocomplete="off">
<option value="" selected>All</option> <option value="" {% if not current_theme_filter %}selected{% endif %}>All</option>
{% for theme in collection.themes %} {% for theme in collection.themes %}
<option value="{{ theme | lower }}">{{ theme }}</option> <option value="{{ theme | lower }}" {% if current_theme_filter == (theme | lower) %}selected{% endif %}>{{ theme }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -52,9 +52,9 @@
<select id="grid-owner" class="form-select" <select id="grid-owner" class="form-select"
data-filter="metadata" data-filter="metadata"
autocomplete="off"> autocomplete="off">
<option value="" selected>All</option> <option value="" {% if not current_owner_filter %}selected{% endif %}>All</option>
{% for owner in brickset_owners %} {% for owner in brickset_owners %}
<option value="{{ owner.as_dataset() }}">{{ owner.fields.name }}</option> <option value="{{ owner.as_dataset() }}" {% if current_owner_filter == owner.as_dataset() %}selected{% endif %}>{{ owner.fields.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -68,9 +68,9 @@
<select id="grid-purchase-location" class="form-select" <select id="grid-purchase-location" class="form-select"
data-filter="value" data-filter-attribute="purchase-location" data-filter="value" data-filter-attribute="purchase-location"
autocomplete="off"> autocomplete="off">
<option value="" selected>All</option> <option value="" {% if not current_purchase_location_filter %}selected{% endif %}>All</option>
{% for purchase_location in brickset_purchase_locations %} {% for purchase_location in brickset_purchase_locations %}
<option value="{{ purchase_location.fields.id }}">{{ purchase_location.fields.name }}</option> <option value="{{ purchase_location.fields.id }}" {% if current_purchase_location_filter == purchase_location.fields.id %}selected{% endif %}>{{ purchase_location.fields.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -84,9 +84,9 @@
<select id="grid-storage" class="form-select" <select id="grid-storage" class="form-select"
data-filter="value" data-filter-attribute="storage" data-filter="value" data-filter-attribute="storage"
autocomplete="off"> autocomplete="off">
<option value="" selected>All</option> <option value="" {% if not current_storage_filter %}selected{% endif %}>All</option>
{% for storage in brickset_storages %} {% for storage in brickset_storages %}
<option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option> <option value="{{ storage.fields.id }}" {% if current_storage_filter == storage.fields.id %}selected{% endif %}>{{ storage.fields.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -100,9 +100,9 @@
<select id="grid-tag" class="form-select" <select id="grid-tag" class="form-select"
data-filter="metadata" data-filter="metadata"
autocomplete="off"> autocomplete="off">
<option value="" selected>All</option> <option value="" {% if not current_tag_filter %}selected{% endif %}>All</option>
{% for tag in brickset_tags %} {% for tag in brickset_tags %}
<option value="{{ tag.as_dataset() }}">{{ tag.fields.name }}</option> <option value="{{ tag.as_dataset() }}" {% if current_tag_filter == tag.as_dataset() %}selected{% endif %}>{{ tag.fields.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
+2 -2
View File
@@ -2,8 +2,8 @@
<div class="col-12 flex-grow-1"> <div class="col-12 flex-grow-1">
<div class="input-group"> <div class="input-group">
<span class="input-group-text mb-2"><i class="ri-sort-asc"></i><span class="ms-1 d-none d-md-inline"> Sort</span></span> <span class="input-group-text mb-2"><i class="ri-sort-asc"></i><span class="ms-1 d-none d-md-inline"> Sort</span></span>
<button id="sort-number" type="button" class="btn btn-outline-primary mb-2" <button id="sort-set" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="number" data-sort-natural="true"><i class="ri-hashtag"></i><span class="d-none d-md-inline"> Set</span></button> data-sort-attribute="set" data-sort-natural="true"><i class="ri-hashtag"></i><span class="d-none d-md-inline"> Set</span></button>
<button id="sort-name" type="button" class="btn btn-outline-primary mb-2" <button id="sort-name" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="name"><i class="ri-pencil-line"></i><span class="d-none d-md-inline"> Name</span></button> data-sort-attribute="name"><i class="ri-pencil-line"></i><span class="d-none d-md-inline"> Name</span></button>
<button id="sort-theme" type="button" class="btn btn-outline-primary mb-2" <button id="sort-theme" type="button" class="btn btn-outline-primary mb-2"
+129 -6
View File
@@ -3,14 +3,14 @@
{% block title %} - All sets{% endblock %} {% block title %} - All sets{% endblock %}
{% block main %} {% block main %}
{% if collection | length %} {% if collection | length or use_pagination %}
<div class="container-fluid"> <div class="container-fluid">
<div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2"> <div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
<div class="col-12 flex-grow-1"> <div class="col-12 flex-grow-1">
<label class="visually-hidden" for="grid-search">Search</label> <label class="visually-hidden" for="grid-search">Search</label>
<div class="input-group"> <div class="input-group">
<span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-md-inline"> Search</span></span> <span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-md-inline"> Search</span></span>
<input id="grid-search" data-search-exact="name,number,parts,searchPurchaseLocation,searchStorage,theme,year" data-search-list="searchOwner,searchTag" class="form-control form-control-sm" type="text" placeholder="Set, name, number of parts, theme, year, owner, purchase location, storage, tag" value=""> <input id="grid-search" {% if not use_pagination %}data-search-exact="name,number,parts,searchPurchaseLocation,searchStorage,theme,year" data-search-list="searchOwner,searchTag"{% endif %} class="form-control form-control-sm px-1" type="text" placeholder="Set, name, number of parts, theme, year{% if not use_pagination %}, owner, purchase location, storage, tag{% endif %}" value="{{ search_query or '' }}">
<button id="grid-search-clear" type="button" class="btn btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button> <button id="grid-search-clear" type="button" class="btn btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
</div> </div>
</div> </div>
@@ -19,10 +19,6 @@
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#grid-sort" aria-expanded="{% if config['SHOW_GRID_SORT'] %}true{% else %}false{% endif %}" aria-controls="grid-sort"> <button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#grid-sort" aria-expanded="{% if config['SHOW_GRID_SORT'] %}true{% else %}false{% endif %}" aria-controls="grid-sort">
<i class="ri-sort-asc"></i> Sort <i class="ri-sort-asc"></i> Sort
</button> </button>
</div>
</div>
<div class="col-12">
<div class="input-group">
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#grid-filter" aria-expanded="{% if config['SHOW_GRID_FILTERS'] %}true{% else %}false{% endif %}" aria-controls="grid-filter"> <button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#grid-filter" aria-expanded="{% if config['SHOW_GRID_FILTERS'] %}true{% else %}false{% endif %}" aria-controls="grid-filter">
<i class="ri-filter-line"></i> Filters <i class="ri-filter-line"></i> Filters
</button> </button>
@@ -31,6 +27,131 @@
</div> </div>
{% include 'set/sort.html' %} {% include 'set/sort.html' %}
{% include 'set/filter.html' %} {% include 'set/filter.html' %}
{% if use_pagination %}
<!-- PAGINATION MODE -->
<div class="row" data-grid="false" id="grid">
{% for item in collection %}
<div class="col-md-6 col-xl-3 d-flex align-items-stretch">
{% with index=loop.index0 %}
{% include 'set/card.html' %}
{% endwith %}
</div>
{% endfor %}
</div>
<!-- Pagination -->
<div>
{% if pagination and pagination.total_pages > 1 %}
<div class="row mt-4">
<div class="col-12">
<!-- Desktop Pagination -->
<div class="d-none d-md-block">
<nav aria-label="Sets pagination">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page - 1) }}">
<i class="ri-arrow-left-line"></i> Previous
</a>
</li>
{% endif %}
<!-- Show page numbers (with smart truncation) -->
{% set start_page = [1, pagination.page - 2] | max %}
{% set end_page = [pagination.total_pages, pagination.page + 2] | min %}
{% if start_page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', 1) }}">1</a>
</li>
{% if start_page > 2 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endif %}
{% for page_num in range(start_page, end_page + 1) %}
{% if page_num == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', page_num) }}">{{ page_num }}</a>
</li>
{% endif %}
{% endfor %}
{% if end_page < pagination.total_pages %}
{% if end_page < pagination.total_pages - 1 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.total_pages) }}">{{ pagination.total_pages }}</a>
</li>
{% endif %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page + 1) }}">
Next <i class="ri-arrow-right-line"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
<!-- Mobile Pagination -->
<div class="d-md-none">
<div class="mobile-pagination" role="group" aria-label="Mobile pagination">
{% if pagination.has_prev %}
<a href="{{ request.url | replace_query('page', pagination.page - 1) }}"
class="btn btn-outline-primary">
<i class="ri-arrow-left-line"></i> Previous
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
<i class="ri-arrow-left-line"></i> Previous
</button>
{% endif %}
<span class="btn btn-light">
Page {{ pagination.page }} of {{ pagination.total_pages }}
</span>
{% if pagination.has_next %}
<a href="{{ request.url | replace_query('page', pagination.page + 1) }}"
class="btn btn-outline-primary">
Next <i class="ri-arrow-right-line"></i>
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
Next <i class="ri-arrow-right-line"></i>
</button>
{% endif %}
</div>
</div>
<!-- Results Info -->
<div class="text-center mt-3">
<small class="text-muted">
{% if pagination.total_count > 0 %}
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} to
{{ [pagination.page * pagination.per_page, pagination.total_count] | min }}
of {{ pagination.total_count }} sets
{% else %}
No sets found
{% endif %}
</small>
</div>
</div>
</div>
{% endif %}
</div>
{% else %}
<!-- ORIGINAL MODE - Single page with client-side search and filters -->
<div class="row" data-grid="true" id="grid"> <div class="row" data-grid="true" id="grid">
{% for item in collection %} {% for item in collection %}
<div class="col-md-6 col-xl-3 d-flex align-items-stretch"> <div class="col-md-6 col-xl-3 d-flex align-items-stretch">
@@ -40,6 +161,8 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %}
</div> </div>
{% else %} {% else %}
{% include 'set/empty.html' %} {% include 'set/empty.html' %}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env python3
"""
WSGI entry point for BrickTracker - Production Docker deployment
This ensures proper gevent monkey patching before any imports
"""
# CRITICAL: Monkey patch must be first, before ANY other imports
import gevent.monkey
gevent.monkey.patch_all()
# Now import the regular app factory
from app import create_app
# Create the application - this will be a BrickSocket instance
app_instance = create_app()
# For gunicorn, we need the Flask app, not the BrickSocket wrapper
application = app_instance.app if hasattr(app_instance, 'app') else app_instance