Compare commits

..

15 Commits

Author SHA1 Message Date
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 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: #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: #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: #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
FrederikBaerentsen c6b9f1c61a Merge pull request '1.2.2: Fix orphaned parts blocking database upgrade' (#65) from gregoo/BrickTracker:master into master
Reviewed-on: #65
2025-02-13 19:23:07 +01:00
gregoo e28ad8b32c Fix orphaned parts blocking database upgrade 2025-02-10 16:42:23 +01:00
FrederikBaerentsen 6d70dbdf8b Merge pull request '1.2.1: Fix add set with no metadata' (#63) from gregoo/BrickTracker:master into master
Reviewed-on: #63
2025-02-08 10:49:06 +01:00
gregoo 1dee03fbea Update changelog and version 2025-02-08 10:29:08 +01:00
gregoo bb05fbdd22 Warn users if there is no metadata configured 2025-02-08 10:27:55 +01:00
gregoo 3496143962 Fix None being submitted to a metadata get() 2025-02-08 10:15:54 +01:00
45 changed files with 1148 additions and 196 deletions
+2 -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
+31
View File
@@ -1,5 +1,36 @@
# Changelog # Changelog
## Unreleased
### Current PR
- Added search/filter/sort options to `parts` and `minifigures`.
### Next PR
> **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.2
Fix legibility of "Damaged" and "Missing" fields for tiny screen by reducing horizontal padding
Fixed instructions download from Rebrickable
## 1.2.2:
This release fixes a bug where orphaned parts in the `inventory` table are blocking the database upgrade.
## 1.2.1:
This release fixes a bug where you could not add a set if no metadata was configured.
## 1.2.0: ## 1.2.0:
> **Warning** > **Warning**
+1 -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'},
+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)
for a_tag in soup.find_all('a', href=True): if not img or set not in img['alt']:
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 ')
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'])
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
+8 -3
View File
@@ -3,7 +3,7 @@ from typing import List, overload, Self, Type, TypeVar
from flask import url_for from flask import url_for
from .exceptions import NotFoundException from .exceptions import ErrorException, NotFoundException
from .fields import BrickRecordFields from .fields import BrickRecordFields
from .record_list import BrickRecordList from .record_list import BrickRecordList
from .set_owner import BrickSetOwner from .set_owner import BrickSetOwner
@@ -113,12 +113,17 @@ class BrickMetadataList(BrickRecordList[T]):
# Grab a specific status # Grab a specific status
@classmethod @classmethod
def get(cls, id: str, /, *, allow_none: bool = False) -> T: def get(cls, id: str | None, /, *, allow_none: bool = False) -> T:
new = cls.new() new = cls.new()
if allow_none and id == '': if allow_none and (id == '' or id is None):
return new.model() return new.model()
if id is None:
raise ErrorException('Cannot get {kind} with no ID'.format(
kind=new.kind.capitalize()
))
if id not in new.mapping: if id not in new.mapping:
raise NotFoundException( raise NotFoundException(
'{kind} with ID {id} was not found in the database'.format( '{kind} with ID {id} was not found in the database'.format(
+21
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,16 @@ 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
# 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 +94,17 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
else: else:
brickset = None brickset = None
# Prepare template context for owner filtering
context = {}
if hasattr(self.fields, 'owner_id') and self.fields.owner_id is not None:
context['owner_id'] = self.fields.owner_id
# 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
): ):
minifigure = BrickMinifigure(brickset=brickset, record=record) minifigure = BrickMinifigure(brickset=brickset, record=record)
@@ -132,6 +149,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
+38
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,35 @@ 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
# Load the parts from the database
self.list(override_query=query)
return self
# Base part list # Base part list
def list( def list(
self, self,
@@ -69,11 +99,19 @@ 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
# 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
): ):
part = BrickPart( part = BrickPart(
brickset=brickset, brickset=brickset,
+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
+13
View File
@@ -4,6 +4,19 @@ PRAGMA foreign_keys = ON;
BEGIN TRANSACTION; BEGIN TRANSACTION;
-- Fix: somehow a deletion bug was introduced in an older release?
DELETE FROM "inventory"
WHERE "inventory"."u_id" NOT IN (
SELECT "bricktracker_sets"."id"
FROM "bricktracker_sets"
);
DELETE FROM "missing"
WHERE "missing"."u_id" NOT IN (
SELECT "bricktracker_sets"."id"
FROM "bricktracker_sets"
);
-- Create a Bricktracker parts table: an amount of parts linked to a Bricktracker set -- Create a Bricktracker parts table: an amount of parts linked to a Bricktracker set
CREATE TABLE "bricktracker_parts" ( CREATE TABLE "bricktracker_parts" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
+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;
@@ -0,0 +1,71 @@
{% 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 %}
{% if owner_id and owner_id != 'all' %}
WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% endif %}
{% endblock %}
{% block group %}
GROUP BY
"rebrickable_minifigures"."figure"
{% endblock %}
+3
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",
+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
+6
View File
@@ -26,6 +26,12 @@ 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 %}
{% if color_id and color_id != 'all' %}
WHERE "bricktracker_parts"."color" = {{ color_id }}
{% endif %}
{% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
"bricktracker_parts"."part", "bricktracker_parts"."part",
@@ -0,0 +1,78 @@
{% 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 has_where = false %}
{% if owner_id and owner_id != 'all' %}
WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% set has_where = true %}
{% endif %}
{% if color_id and color_id != 'all' %}
{% if has_where %}
AND "bricktracker_parts"."color" = {{ color_id }}
{% else %}
WHERE "bricktracker_parts"."color" = {{ color_id }}
{% endif %}
{% endif %}
{% endblock %}
{% block group %}
GROUP BY
"bricktracker_parts"."part",
"bricktracker_parts"."color",
"bricktracker_parts"."spare"
{% endblock %}
@@ -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"
+2 -2
View File
@@ -1,4 +1,4 @@
from typing import Final from typing import Final
__version__: Final[str] = '1.2.0' __version__: Final[str] = '1.2.2'
__database_version__: Final[int] = 15 __database_version__: Final[int] = 17
+2
View File
@@ -84,6 +84,7 @@ def admin() -> str:
open_image = request.args.get('open_image', None) open_image = request.args.get('open_image', None)
open_instructions = request.args.get('open_instructions', None) open_instructions = request.args.get('open_instructions', None)
open_logout = request.args.get('open_logout', None) open_logout = request.args.get('open_logout', None)
open_metadata = request.args.get('open_metadata', None)
open_owner = request.args.get('open_owner', None) open_owner = request.args.get('open_owner', None)
open_purchase_location = request.args.get('open_purchase_location', None) open_purchase_location = request.args.get('open_purchase_location', None)
open_retired = request.args.get('open_retired', None) open_retired = request.args.get('open_retired', None)
@@ -93,6 +94,7 @@ def admin() -> str:
open_theme = request.args.get('open_theme', None) open_theme = request.args.get('open_theme', None)
open_metadata = ( open_metadata = (
open_metadata or
open_owner or open_owner or
open_purchase_location or open_purchase_location or
open_status or open_status or
+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__)
+17 -2
View File
@@ -1,9 +1,10 @@
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 import BrickMinifigure from ..minifigure import BrickMinifigure
from ..minifigure_list import BrickMinifigureList from ..minifigure_list import BrickMinifigureList
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,9 +13,23 @@ 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:
# Get owner filter from request
owner_id = request.args.get('owner', 'all')
# Get minifigures filtered by owner
if owner_id == 'all' or owner_id is None or owner_id == '':
minifigures = BrickMinifigureList().all()
else:
minifigures = BrickMinifigureList().all_by_owner(owner_id)
# Get list of owners for filter dropdown
owners = BrickSetOwnerList.list()
return render_template( return render_template(
'minifigures.html', 'minifigures.html',
table_collection=BrickMinifigureList().all(), table_collection=minifigures,
owners=owners,
selected_owner=owner_id,
) )
+27 -2
View File
@@ -1,10 +1,12 @@
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 ..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,9 +15,32 @@ 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:
# Get filter parameters from request
owner_id = request.args.get('owner', 'all')
color_id = request.args.get('color', 'all')
# Get parts with filters applied
parts = BrickPartList().all_filtered(owner_id, color_id)
# 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)
return render_template( return render_template(
'parts.html', 'parts.html',
table_collection=BrickPartList().all() table_collection=parts,
owners=owners,
selected_owner=owner_id,
colors=colors,
selected_color=color_id,
) )
+1 -1
View File
@@ -2,7 +2,7 @@ services:
bricktracker: bricktracker:
container_name: BrickTracker container_name: BrickTracker
restart: unless-stopped restart: unless-stopped
image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.2.0 image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.2.2
ports: ports:
- "3333:3333" - "3333:3333"
volumes: volumes:
+1 -1
View File
@@ -2,7 +2,7 @@ services:
bricktracker: bricktracker:
container_name: BrickTracker container_name: BrickTracker
restart: unless-stopped restart: unless-stopped
image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.2.0 image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.2.2
ports: ports:
- "3333:3333" - "3333:3333"
volumes: volumes:
+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
View File
@@ -9,3 +9,4 @@ rebrick
requests requests
tzdata tzdata
bs4 bs4
cloudscraper
+148
View File
@@ -0,0 +1,148 @@
// Minifigures page functionality
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);
}
window.location.href = currentUrl.toString();
}
// Keep filters expanded after selection
function filterByOwnerAndKeepOpen() {
// Remember if filters were open
const filterSection = document.getElementById('table-filter');
const wasOpen = filterSection && filterSection.classList.contains('show');
filterByOwner();
// Store the state to restore after page reload
if (wasOpen) {
sessionStorage.setItem('keepFiltersOpen', 'true');
}
}
// Setup table search and sort functionality
document.addEventListener("DOMContentLoaded", () => {
const searchInput = document.getElementById('table-search');
const searchClear = document.getElementById('table-search-clear');
// Restore filter state after page load
if (sessionStorage.getItem('keepFiltersOpen') === 'true') {
const filterSection = document.getElementById('table-filter');
const filterButton = document.querySelector('[data-bs-target="#table-filter"]');
if (filterSection && filterButton) {
filterSection.classList.add('show');
filterButton.setAttribute('aria-expanded', 'true');
}
sessionStorage.removeItem('keepFiltersOpen');
}
if (searchInput && searchClear) {
// Wait for table to be initialized by setup_tables
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('');
});
// Setup sort buttons
setupSortButtons();
}
}, 100);
}
});
function setupSortButtons() {
// Sort button functionality
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';
// Get column index based on attribute
const columnMap = {
'name': 1,
'parts': 2,
'quantity': 3,
'missing': 4,
'damaged': 5,
'sets': 6
};
const columnIndex = columnMap[attribute];
if (columnIndex !== undefined && window.brickTableInstance) {
// 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
window.brickTableInstance.table.columns.sort(columnIndex, newDirection);
}
});
});
if (clearButton) {
clearButton.addEventListener('click', () => {
// Clear all sort buttons
sortButtons.forEach(btn => {
btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-primary');
btn.removeAttribute('data-current-direction');
});
// Reset table sort - remove all sorting
if (window.brickTableInstance) {
// Destroy and recreate to clear sorting
const tableElement = document.querySelector('#minifigures');
const currentPerPage = window.brickTableInstance.table.options.perPage;
window.brickTableInstance.table.destroy();
setTimeout(() => {
// Create new instance using the globally available BrickTable class
const newInstance = new window.BrickTable(tableElement, currentPerPage);
window.brickTableInstance = newInstance;
// Re-enable search functionality
newInstance.table.searchable = true;
}, 50);
}
});
}
}
+190
View File
@@ -0,0 +1,190 @@
// Parts page functionality
function applyFilters() {
const ownerSelect = document.getElementById('filter-owner');
const colorSelect = document.getElementById('filter-color');
const currentUrl = new URL(window.location);
// Handle owner filter
if (ownerSelect) {
const selectedOwner = ownerSelect.value;
if (selectedOwner === 'all') {
currentUrl.searchParams.delete('owner');
} else {
currentUrl.searchParams.set('owner', selectedOwner);
}
}
// Handle color filter
if (colorSelect) {
const selectedColor = colorSelect.value;
if (selectedColor === 'all') {
currentUrl.searchParams.delete('color');
} else {
currentUrl.searchParams.set('color', selectedColor);
}
}
window.location.href = currentUrl.toString();
}
function setupColorDropdown() {
const colorSelect = document.getElementById('filter-color');
if (!colorSelect) return;
// Add color squares to option text
const options = colorSelect.querySelectorAll('option[data-color-rgb]');
options.forEach(option => {
const colorRgb = option.dataset.colorRgb;
const colorId = option.dataset.colorId;
const colorName = option.textContent.trim();
if (colorRgb && colorId !== '9999') {
// Create a visual indicator (using Unicode square)
option.textContent = `${colorName}`; //■
//option.style.color = `#${colorRgb}`;
}
});
}
// Keep filters expanded after selection
function applyFiltersAndKeepOpen() {
// Remember if filters were open
const filterSection = document.getElementById('table-filter');
const wasOpen = filterSection && filterSection.classList.contains('show');
applyFilters();
// Store the state to restore after page reload
if (wasOpen) {
sessionStorage.setItem('keepFiltersOpen', 'true');
}
}
function setupSortButtons() {
// Sort button functionality
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';
// Get column index based on attribute
const columnMap = {
'name': 1,
'color': 2,
'quantity': 3,
'missing': 4,
'damaged': 5,
'sets': 6,
'minifigures': 7
};
const columnIndex = columnMap[attribute];
if (columnIndex !== undefined && window.partsTableInstance) {
// 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
window.partsTableInstance.table.columns.sort(columnIndex, newDirection);
}
});
});
if (clearButton) {
clearButton.addEventListener('click', () => {
// Clear all sort buttons
sortButtons.forEach(btn => {
btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-primary');
btn.removeAttribute('data-current-direction');
});
// Reset table sort - remove all sorting
if (window.partsTableInstance) {
// Destroy and recreate to clear sorting
const tableElement = document.querySelector('#parts');
const currentPerPage = window.partsTableInstance.table.options.perPage;
window.partsTableInstance.table.destroy();
setTimeout(() => {
// Create new instance using the globally available BrickTable class
const newInstance = new window.BrickTable(tableElement, currentPerPage);
window.partsTableInstance = newInstance;
// Re-enable search functionality
newInstance.table.searchable = true;
}, 50);
}
});
}
}
// Setup table search and sort functionality
document.addEventListener("DOMContentLoaded", () => {
const searchInput = document.getElementById('table-search');
const searchClear = document.getElementById('table-search-clear');
// Setup color dropdown with color squares
setupColorDropdown();
// Restore filter state after page load
if (sessionStorage.getItem('keepFiltersOpen') === 'true') {
const filterSection = document.getElementById('table-filter');
const filterButton = document.querySelector('[data-bs-target="#table-filter"]');
if (filterSection && filterButton) {
filterSection.classList.add('show');
filterButton.setAttribute('aria-expanded', 'true');
}
sessionStorage.removeItem('keepFiltersOpen');
}
if (searchInput && searchClear) {
// Wait for table to be initialized by setup_tables
const setupSearch = () => {
const tableElement = document.querySelector('table[data-table="true"]');
if (tableElement && window.partsTableInstance) {
// Enable custom search for parts table
window.partsTableInstance.table.searchable = true;
// Connect search input to table
searchInput.addEventListener('input', (e) => {
window.partsTableInstance.table.search(e.target.value);
});
// Clear search
searchClear.addEventListener('click', () => {
searchInput.value = '';
window.partsTableInstance.table.search('');
});
// Setup sort buttons
setupSortButtons();
} else {
// If table instance not ready, try again
setTimeout(setupSearch, 100);
}
};
setTimeout(setupSearch, 100);
}
});
+17 -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,17 @@ 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 hasCustomInterface = isMinifiguresTable || isPartsTable;
this.table = new simpleDatatables.DataTable(`#${table.id}`, { this.table = new simpleDatatables.DataTable(`#${table.id}`, {
columns: columns, columns: columns,
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: !hasCustomInterface, // Disable built-in search for tables with custom interface
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 +98,13 @@ 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;
}
}
); );
+59 -52
View File
@@ -37,59 +37,66 @@
</div> </div>
<h6 class="border-bottom mt-2">Metadata</h6> <h6 class="border-bottom mt-2">Metadata</h6>
<div class="accordion accordion" id="metadata"> <div class="accordion accordion" id="metadata">
{% if brickset_owners | length %} {% if not (brickset_owners | length) and not (brickset_purchase_locations | length) and not (brickset_storages | length) and not (brickset_tags | length) %}
{{ accordion.header('Owners', 'owners', 'metadata', icon='user-line') }} <div class="alert alert-warning" role="alert">
<div id="add-owners"> You have no metadata configured.
{% for owner in brickset_owners %} You can add entries in the <a href="{{ url_for('admin.admin', open_metadata=true) }}" class="btn btn-warning" role="button"><i class="ri-profile-line"></i> Set metadata management</a> section of the Admin panel.
{% with id=owner.as_dataset() %}
<div class="form-check">
<input class="form-check-input" type="checkbox" value="{{ owner.fields.id }}" id="{{ id }}" autocomplete="off">
<label class="form-check-label" for="{{ id }}">{{ owner.fields.name }}</label>
</div>
{% endwith %}
{% endfor %}
</div> </div>
{{ accordion.footer() }} {% else %}
{% endif %} {% if brickset_owners | length %}
{% if brickset_purchase_locations | length %} {{ accordion.header('Owners', 'owners', 'metadata', icon='user-line') }}
{{ accordion.header('Purchase location', 'purchase-location', 'metadata', icon='building-line') }} <div id="add-owners">
<label class="visually-hidden" for="add-purchase-location">{{ name }}</label> {% for owner in brickset_owners %}
<div class="input-group"> {% with id=owner.as_dataset() %}
<select id="add-purchase-location" class="form-select" autocomplete="off"> <div class="form-check">
<option value="" selected><i>None</i></option> <input class="form-check-input" type="checkbox" value="{{ owner.fields.id }}" id="{{ id }}" autocomplete="off">
{% for purchase_location in brickset_purchase_locations %} <label class="form-check-label" for="{{ id }}">{{ owner.fields.name }}</label>
<option value="{{ purchase_location.fields.id }}">{{ purchase_location.fields.name }}</option> </div>
{% endfor %} {% endwith %}
</select> {% endfor %}
</div> </div>
{{ accordion.footer() }} {{ accordion.footer() }}
{% endif %} {% endif %}
{% if brickset_storages | length %} {% if brickset_purchase_locations | length %}
{{ accordion.header('Storage', 'storage', 'metadata', icon='archive-2-line') }} {{ accordion.header('Purchase location', 'purchase-location', 'metadata', icon='building-line') }}
<label class="visually-hidden" for="add-storage">{{ name }}</label> <label class="visually-hidden" for="add-purchase-location">{{ name }}</label>
<div class="input-group"> <div class="input-group">
<select id="add-storage" class="form-select" autocomplete="off"> <select id="add-purchase-location" class="form-select" autocomplete="off">
<option value="" selected><i>None</i></option> <option value="" selected><i>None</i></option>
{% for storage in brickset_storages %} {% for purchase_location in brickset_purchase_locations %}
<option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option> <option value="{{ purchase_location.fields.id }}">{{ purchase_location.fields.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
{{ accordion.footer() }} {{ accordion.footer() }}
{% endif %} {% endif %}
{% if brickset_tags | length %} {% if brickset_storages | length %}
{{ accordion.header('Tags', 'tags', 'metadata', icon='price-tag-2-line') }} {{ accordion.header('Storage', 'storage', 'metadata', icon='archive-2-line') }}
<div id="add-tags"> <label class="visually-hidden" for="add-storage">{{ name }}</label>
{% for tag in brickset_tags %} <div class="input-group">
{% with id=tag.as_dataset() %} <select id="add-storage" class="form-select" autocomplete="off">
<div class="form-check"> <option value="" selected><i>None</i></option>
<input class="form-check-input" type="checkbox" value="{{ tag.fields.id }}" id="{{ id }}" autocomplete="off"> {% for storage in brickset_storages %}
<label class="form-check-label" for="{{ id }}">{{ tag.fields.name }}</label> <option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
</div> {% endfor %}
{% endwith %} </select>
{% endfor %} </div>
</div> {{ accordion.footer() }}
{{ accordion.footer() }} {% endif %}
{% if brickset_tags | length %}
{{ accordion.header('Tags', 'tags', 'metadata', icon='price-tag-2-line') }}
<div id="add-tags">
{% for tag in brickset_tags %}
{% with id=tag.as_dataset() %}
<div class="form-check">
<input class="form-check-input" type="checkbox" value="{{ tag.fields.id }}" id="{{ id }}" autocomplete="off">
<label class="form-check-label" for="{{ id }}">{{ tag.fields.name }}</label>
</div>
{% endwith %}
{% endfor %}
</div>
{{ accordion.footer() }}
{% endif %}
{% endif %} {% endif %}
</div> </div>
<hr> <hr>
+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 %}
+2
View File
@@ -91,6 +91,8 @@
<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>
<script src="{{ url_for('static', filename='scripts/minifigures.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts.js') }}"></script>
<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>
+45 -1
View File
@@ -3,9 +3,53 @@
{% block title %} - All minifigures{% endblock %} {% block title %} - All minifigures{% endblock %}
{% block main %} {% block main %}
<div class="container-fluid px-0"> {% if table_collection | length %}
<div class="container-fluid">
<div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
<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="">
<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>
</div>
</div>
{% if owners | length > 1 %}
<div class="col-12">
<div class="input-group">
<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>
{% endif %}
</div>
{% include 'minifigure/sort.html' %}
{% include 'minifigure/filter.html' %}
{% with all=true %} {% with all=true %}
{% include 'minifigure/table.html' %} {% include 'minifigure/table.html' %}
{% endwith %} {% endwith %}
</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-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 %} {% 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>
+43 -1
View File
@@ -3,9 +3,51 @@
{% block title %} - All parts{% endblock %} {% block title %} - All parts{% endblock %}
{% block main %} {% block main %}
<div class="container-fluid px-0"> {% if table_collection | length %}
<div class="container-fluid">
<div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
<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="">
<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>
</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-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' %}
{% with all=true %} {% with all=true %}
{% include 'part/table.html' %} {% include 'part/table.html' %}
{% endwith %} {% endwith %}
</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-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 %} {% endblock %}
+1 -1
View File
@@ -10,7 +10,7 @@
<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" data-search-exact="name,number,parts,searchPurchaseLocation,searchStorage,theme,year" data-search-list="searchOwner,searchTag" class="form-control form-control-sm px-1" type="text" placeholder="Set, name, number of parts, theme, year, owner, purchase location, storage, tag" value="">
<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>