Compare commits

...

54 Commits

Author SHA1 Message Date
FrederikBaerentsen 146f3706a5 Update version to 1.3.1 2025-12-20 15:42:47 -05:00
FrederikBaerentsen 951e662113 fix(changelog): updated changelog 2025-12-20 15:24:57 -05:00
FrederikBaerentsen 1184f9bf48 fix(add): fixed #199, foreign key constraint failed 2025-12-20 15:22:45 -05:00
FrederikBaerentsen ede8d996e2 fix(debug): fixed debug log not shown 2025-12-19 18:16:08 -05:00
FrederikBaerentsen 45f74848d2 fix(changelog): updated with post-1.3 fixes 2025-12-18 22:45:46 -05:00
FrederikBaerentsen 417bbd178b fix(meta): fixed an issue where owner, status and tag didn't save on sets detail page 2025-12-18 22:16:14 -05:00
FrederikBaerentsen 349648969c fix(minifigures): fix filter on client side pagination 2025-12-18 21:53:19 -05:00
FrederikBaerentsen 7f9a7a2afe fix(error): fixed error message paths 2025-12-18 13:44:53 -05:00
FrederikBaerentsen 451b8e14a1 fix(admin): nil images now uses correct folder. 2025-12-18 13:20:26 -05:00
FrederikBaerentsen cca5b6d88e fix(readme): updated readme 2025-12-18 11:35:02 -05:00
FrederikBaerentsen 678499a9f2 Merge pull request '.gitignore update' (#117) from release/1.3 into master
Reviewed-on: FrederikBaerentsen/BrickTracker#117
2025-12-18 17:25:47 +01:00
FrederikBaerentsen 8fab57d55a .gitignore update 2025-12-18 10:17:07 -05:00
FrederikBaerentsen b1c32ea5aa Merge pull request 'release/1.3' (#116) from release/1.3 into master
Reviewed-on: FrederikBaerentsen/BrickTracker#116
2025-12-18 01:41:28 +01:00
FrederikBaerentsen 577f9a566d feat(migration): added documentation links to migration page 2025-12-17 19:33:34 -05:00
FrederikBaerentsen 1263f775c3 feat(readme): updated readme with logo 2025-12-17 19:22:53 -05:00
FrederikBaerentsen 3f95f49e31 feat(readme): updated readme with links to new documentation 2025-12-17 19:19:28 -05:00
FrederikBaerentsen d134974b84 feat(logo): Image updated to own design 2025-12-17 17:56:51 -05:00
FrederikBaerentsen 728b030ee1 feat(docs): new images for documentation 2025-12-17 14:00:36 -05:00
FrederikBaerentsen bcbeff8a3c fix(sets): filters now uses two rows on sets page 2025-12-17 13:07:54 -05:00
FrederikBaerentsen 01a5114bb0 fix(admin): fixed link to migration guide 2025-12-17 10:27:13 -05:00
FrederikBaerentsen 6003419069 fix(git): updated gitignore 2025-12-17 10:26:29 -05:00
FrederikBaerentsen e32b82b961 feat(env): updated examples 2025-12-17 10:26:10 -05:00
FrederikBaerentsen c45d696a48 fix(docs): updated migration guide with backup warning. 2025-12-15 22:27:13 -05:00
FrederikBaerentsen a98f4faaeb fix(docker): updated compose files for v1.3 changes 2025-12-15 22:26:45 -05:00
FrederikBaerentsen 343f2f2fe9 fix(changelog): updated changelog formatting 2025-12-15 22:15:05 -05:00
FrederikBaerentsen 41b5f60e0a fix(changelog): updated changelog for 1.3 2025-12-15 22:11:40 -05:00
FrederikBaerentsen 41aed75b37 fix(docs): updated migration guide 2025-12-15 21:32:36 -05:00
FrederikBaerentsen 7651ac187d fix(env): create folder if doesn't exist, when saving .env file 2025-12-15 21:20:56 -05:00
FrederikBaerentsen 7cc8de596e fix(docker): changes Dockerfile command order to use pip cache 2025-12-15 19:53:00 -05:00
FrederikBaerentsen d207f22990 fix(docker): fixing exit code 137 when stopping container 2025-12-15 19:50:29 -05:00
FrederikBaerentsen 2cc23b5ffa feat(darkmode): updated changelog with darkmode info 2025-12-15 19:34:43 -05:00
FrederikBaerentsen b2e4597ab5 feat(darkmode): added darkmode with env var setting and live settings on admin page 2025-12-15 17:52:05 -05:00
FrederikBaerentsen 7369d0babf feat(parts): Added option to hide spare parts but still save them to db 2025-12-07 20:41:13 +01:00
FrederikBaerentsen d6d0a70116 fix(socket): added better debug logging and added polling as priority over websocket, for better iOS connection 2025-12-07 20:38:37 +01:00
FrederikBaerentsen 91ef4158b7 fix(env): settings are not locked after save anymore 2025-12-06 21:04:04 +01:00
FrederikBaerentsen e1eea7295d fix(env): moved .env to data folder. admin page, now correctly works with changes to variables 2025-12-06 20:48:30 +01:00
FrederikBaerentsen bc8864ab2a fix(inst): removed cloudscraper 2025-12-06 15:41:05 +01:00
FrederikBaerentsen 7860b71ccd fix(sets): adding sets now works after migration 20 2025-12-06 15:40:44 +01:00
FrederikBaerentsen 60e4fe8037 fix(inst): removed cloudscraper as it caused issues with rebrickable instructions 2025-12-05 23:51:09 +01:00
FrederikBaerentsen 85728e2d68 fix(inst): fixed folder path for instructions 2025-12-05 22:38:38 +01:00
FrederikBaerentsen 00ca611217 fix(inst): download from rebrickable work again. fixed folder path and rebrickable connection 2025-12-05 22:31:19 +01:00
FrederikBaerentsen 1e17185114 fix(sets): if no set image exists, use nil image 2025-12-05 20:51:41 +01:00
FrederikBaerentsen 41e61a2f41 fix(sets): set number can now be alphanumerical 2025-12-05 19:33:25 +01:00
FrederikBaerentsen 4d4a1aa9f9 feat: new user data structure. see docs/migration_guide 2025-12-05 17:59:56 +01:00
FrederikBaerentsen 29c5d81160 feat(stats): statistics page now requires authentication if enabled 2025-11-06 21:57:05 +01:00
FrederikBaerentsen 891a55ee9e fix: moved clear filter button 2025-11-06 21:39:25 +01:00
FrederikBaerentsen 0fedd430b3 fix: removed ?page=1 on client-side pagination 2025-11-06 21:08:16 +01:00
FrederikBaerentsen 346f8e9908 feat: added clear filter button to sets/parts/problems/minifigures 2025-11-06 18:53:19 +01:00
FrederikBaerentsen 7567cb51af feat(prob): added filter for tag and storage 2025-11-06 18:06:27 +01:00
FrederikBaerentsen 61450312ff feat: added filters to /parts, /problems, /minifigures 2025-11-06 17:51:43 +01:00
FrederikBaerentsen 22cdb713d7 fix(admin): changed accordion style on settings 2025-11-06 09:16:12 +01:00
FrederikBaerentsen 81b7ebf1a6 fix(sql): set will now be deleted correctly 2025-11-06 09:08:53 +01:00
FrederikBaerentsen 7445666f25 fix(statistics): statistics will now load correctly if no sets are found 2025-11-06 09:08:36 +01:00
FrederikBaerentsen e65a9454a8 Updated gitignore 2025-11-06 08:29:32 +01:00
82 changed files with 2335 additions and 518 deletions
+110 -47
View File
@@ -1,3 +1,23 @@
# ================================================================================================
# BrickTracker Configuration File
# ================================================================================================
#
# FILE LOCATION (v1.3+):
# ----------------------
# This file can be placed in two locations:
# 1. data/.env (RECOMMENDED) - Included in data volume backup, settings persist via admin panel
# 2. .env (root) - Backward compatible
#
# Priority: data/.env > .env (root)
#
# The application automatically detects and uses the correct location at runtime.
#
# For Docker:
# - Recommended: Place this file as data/.env (included in data volume)
# - Backward compatible: Keep as .env in root (add "env_file: .env" to compose.yaml)
#
# ================================================================================================
#
# Note on *_DEFAULT_ORDER # Note on *_DEFAULT_ORDER
# If set, it will append a direct ORDER BY <whatever you set> to the SQL query # If set, it will append a direct ORDER BY <whatever you set> to the SQL query
# while listing objects. You can look at the structure of the SQLite database to # while listing objects. You can look at the structure of the SQLite database to
@@ -41,11 +61,11 @@
# Default: false # Default: false
# BK_BRICKLINK_LINKS=true # BK_BRICKLINK_LINKS=true
# Optional: Path to the database. # Optional: Path to the database, relative to '/app/' folder
# Useful if you need it mounted in a Docker volume. Keep in mind that it will not # Useful if you need it mounted in a Docker volume. Keep in mind that it will not
# do any check on the existence of the path, or if it is dangerous. # do any check on the existence of the path, or if it is dangerous.
# Default: ./app.db # Default: data/app.db
# BK_DATABASE_PATH=/var/lib/bricktracker/app.db # BK_DATABASE_PATH=data/app.db
# Optional: Format of the timestamp added to the database file when downloading it # Optional: Format of the timestamp added to the database file when downloading it
# Check https://docs.python.org/3/library/time.html#time.strftime for format details # Check https://docs.python.org/3/library/time.html#time.strftime for format details
@@ -86,9 +106,9 @@
# Default: .pdf # Default: .pdf
# BK_INSTRUCTIONS_ALLOWED_EXTENSIONS=.pdf, .docx, .png # BK_INSTRUCTIONS_ALLOWED_EXTENSIONS=.pdf, .docx, .png
# Optional: Folder where to store the instructions, relative to the '/app/static/' folder # Optional: Folder where to store the instructions, relative to '/app/' folder
# Default: instructions # Default: data/instructions
# BK_INSTRUCTIONS_FOLDER=/var/lib/bricktracker/instructions/ # BK_INSTRUCTIONS_FOLDER=data/instructions
# Optional: Hide the 'Add' entry from the menu. Does not disable the route. # Optional: Hide the 'Add' entry from the menu. Does not disable the route.
# Default: false # Default: false
@@ -161,15 +181,23 @@
# Optional: Change the default order of minifigures. By default ordered by insertion order. # Optional: Change the default order of minifigures. By default ordered by insertion order.
# Useful column names for this option are: # Useful column names for this option are:
# - "rebrickable_minifigures"."figure": minifigure ID (fig-xxxxx) # - "rebrickable_minifigures"."figure": minifigure ID (e.g., "fig-001234")
# - "rebrickable_minifigures"."number": minifigure ID as an integer (xxxxx) # - "rebrickable_minifigures"."number": minifigure ID as an integer (e.g., 1234)
# - "rebrickable_minifigures"."name": minifigure name # - "rebrickable_minifigures"."name": minifigure name
# - "rebrickable_minifigures"."number_of_parts": number of parts in the minifigure
# - "bricktracker_minifigures"."quantity": quantity owned
# - "total_missing": number of missing parts (composite field)
# - "total_damaged": number of damaged parts (composite field)
# - "total_quantity": total quantity across all sets (composite field)
# - "total_sets": number of sets containing this minifigure (composite field)
# Default: "rebrickable_minifigures"."name" ASC # Default: "rebrickable_minifigures"."name" ASC
# BK_MINIFIGURES_DEFAULT_ORDER="rebrickable_minifigures"."name" ASC # Examples:
# BK_MINIFIGURES_DEFAULT_ORDER="rebrickable_minifigures"."number" DESC
# BK_MINIFIGURES_DEFAULT_ORDER="total_missing" DESC, "rebrickable_minifigures"."name" ASC
# Optional: Folder where to store the minifigures images, relative to the '/app/static/' folder # Optional: Folder where to store the minifigures images, relative to '/app/' folder
# Default: minifigs # Default: data/minifigures
# BK_MINIFIGURES_FOLDER=minifigures # BK_MINIFIGURES_FOLDER=data/minifigures
# Optional: Disable threading on the task executed by the socket. # Optional: Disable threading on the task executed by the socket.
# You should not need to change this parameter unless you are debugging something with the # You should not need to change this parameter unless you are debugging something with the
@@ -179,17 +207,26 @@
# Optional: Change the default order of parts. By default ordered by insertion order. # Optional: Change the default order of parts. By default ordered by insertion order.
# Useful column names for this option are: # Useful column names for this option are:
# - "bricktracker_parts"."part": part number # - "bricktracker_parts"."part": part number (e.g., "3001")
# - "bricktracker_parts"."spare": part is a spare part # - "bricktracker_parts"."spare": part is a spare part (0 or 1)
# - "bricktracker_parts"."quantity": quantity of this part
# - "bricktracker_parts"."missing": number of missing parts
# - "bricktracker_parts"."damaged": number of damaged parts
# - "rebrickable_parts"."name": part name # - "rebrickable_parts"."name": part name
# - "rebrickable_parts"."color_name": part color name # - "rebrickable_parts"."color_name": part color name
# - "total_missing": number of missing parts # - "total_missing": total missing across all sets (composite field)
# - "total_damaged": total damaged across all sets (composite field)
# - "total_quantity": total quantity across all sets (composite field)
# - "total_sets": number of sets containing this part (composite field)
# - "total_minifigures": number of minifigures with this part (composite field)
# Default: "rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC # Default: "rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC
# BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "rebrickable_parts"."name"."name" ASC # Examples:
# BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "rebrickable_parts"."name" ASC
# BK_PARTS_DEFAULT_ORDER="rebrickable_parts"."color_name" ASC, "rebrickable_parts"."name" ASC
# Optional: Folder where to store the parts images, relative to the '/app/static/' folder # Optional: Folder where to store the parts images, relative to '/app/' folder
# Default: parts # Default: data/parts
# BK_PARTS_FOLDER=parts # BK_PARTS_FOLDER=data/parts
# Optional: Enable server-side pagination for individual pages (recommended for large collections) # Optional: Enable server-side pagination for individual pages (recommended for large collections)
# When enabled, pages use server-side pagination with configurable page sizes # When enabled, pages use server-side pagination with configurable page sizes
@@ -247,9 +284,12 @@
# Optional: Change the default order of purchase locations. By default ordered by insertion order. # Optional: Change the default order of purchase locations. By default ordered by insertion order.
# Useful column names for this option are: # Useful column names for this option are:
# - "bricktracker_metadata_purchase_locations"."name" ASC: storage name # - "bricktracker_metadata_purchase_locations"."name": purchase location name
# - "bricktracker_metadata_purchase_locations"."rowid": insertion order (special column)
# Default: "bricktracker_metadata_purchase_locations"."name" ASC # Default: "bricktracker_metadata_purchase_locations"."name" ASC
# BK_PURCHASE_LOCATION_DEFAULT_ORDER="bricktracker_metadata_purchase_locations"."name" ASC # Examples:
# BK_PURCHASE_LOCATION_DEFAULT_ORDER="bricktracker_metadata_purchase_locations"."name" DESC
# BK_PURCHASE_LOCATION_DEFAULT_ORDER="bricktracker_metadata_purchase_locations"."rowid" DESC
# Optional: Shuffle the lists on the front page. # Optional: Shuffle the lists on the front page.
# Default: false # Default: false
@@ -265,23 +305,23 @@
# Optional: URL of the image representing a missing image in Rebrickable # Optional: URL of the image representing a missing image in Rebrickable
# Default: https://rebrickable.com/static/img/nil.png # Default: https://rebrickable.com/static/img/nil.png
# BK_REBRICKABLE_IMAGE_NIL= # BK_REBRICKABLE_IMAGE_NIL=https://rebrickable.com/static/img/nil.png
# Optional: URL of the image representing a missing minifigure image in Rebrickable # Optional: URL of the image representing a missing minifigure image in Rebrickable
# Default: https://rebrickable.com/static/img/nil_mf.jpg # Default: https://rebrickable.com/static/img/nil_mf.jpg
# BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE= # BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE=https://rebrickable.com/static/img/nil_mf.jpg
# Optional: Pattern of the link to Rebrickable for a minifigure. Will be passed to Python .format() # Optional: Pattern of the link to Rebrickable for a minifigure. Will be passed to Python .format()
# Default: https://rebrickable.com/minifigs/{figure} # Default: https://rebrickable.com/minifigs/{figure}
# BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN= # BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN=https://rebrickable.com/minifigs/{figure}
# Optional: Pattern of the link to Rebrickable for a part. Will be passed to Python .format() # Optional: Pattern of the link to Rebrickable for a part. Will be passed to Python .format()
# Default: https://rebrickable.com/parts/{part}/_/{color} # Default: https://rebrickable.com/parts/{part}/_/{color}
# BK_REBRICKABLE_LINK_PART_PATTERN= # BK_REBRICKABLE_LINK_PART_PATTERN=https://rebrickable.com/parts/{part}/_/{color}
# Optional: Pattern of the link to Rebrickable for instructions. Will be passed to Python .format() # Optional: Pattern of the link to Rebrickable for instructions. Will be passed to Python .format()
# Default: https://rebrickable.com/instructions/{path} # Default: https://rebrickable.com/instructions/{path}
# BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN= # BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN=https://rebrickable.com/instructions/{path}
# Optional: User-Agent to use when querying Rebrickable and Peeron outside of the Rebrick python library # Optional: User-Agent to use when querying Rebrickable and Peeron outside of the Rebrick python library
# Default: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 # Default: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36
@@ -327,27 +367,33 @@
# Default: https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date # Default: https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date
# BK_RETIRED_SETS_FILE_URL= # BK_RETIRED_SETS_FILE_URL=
# Optional: Path to the unofficial retired sets lists # Optional: Path to the unofficial retired sets lists, relative to '/app/' folder
# You can name it whatever you want, but content has to be a CSV # You can name it whatever you want, but content has to be a CSV
# Default: ./retired_sets.csv # Default: data/retired_sets.csv
# BK_RETIRED_SETS_PATH=/var/lib/bricktracker/retired_sets.csv # BK_RETIRED_SETS_PATH=data/retired_sets.csv
# Optional: Change the default order of sets. By default ordered by insertion order. # Optional: Change the default order of sets. By default ordered by insertion order.
# Useful column names for this option are: # Useful column names for this option are:
# - "rebrickable_sets"."set": set number as a string # - "rebrickable_sets"."set": set number as a string (e.g., "10255-1")
# - "rebrickable_sets"."number": the number part of set as an integer # - "rebrickable_sets"."number": the number part of set as text (e.g., "10255")
# - "rebrickable_sets"."version": the version part of set as an integer # - "rebrickable_sets"."version": the version part of set as an integer (e.g., 1)
# - "rebrickable_sets"."name": set name # - "rebrickable_sets"."name": set name
# - "rebrickable_sets"."year": set release year # - "rebrickable_sets"."year": set release year
# - "rebrickable_sets"."number_of_parts": set number of parts # - "rebrickable_sets"."number_of_parts": set number of parts
# - "total_missing": number of missing parts # - "bricktracker_sets"."purchase_date": purchase date (as REAL/Julian day)
# - "total_minifigures": number of minifigures # - "bricktracker_sets"."purchase_price": purchase price
# - "total_missing": number of missing parts (composite field)
# - "total_damaged": number of damaged parts (composite field)
# - "total_minifigures": number of minifigures (composite field)
# Default: "rebrickable_sets"."number" DESC, "rebrickable_sets"."version" ASC # Default: "rebrickable_sets"."number" DESC, "rebrickable_sets"."version" ASC
# BK_SETS_DEFAULT_ORDER="rebrickable_sets"."year" ASC # Examples:
# BK_SETS_DEFAULT_ORDER="rebrickable_sets"."year" DESC, "rebrickable_sets"."name" ASC
# BK_SETS_DEFAULT_ORDER="rebrickable_sets"."number_of_parts" DESC
# BK_SETS_DEFAULT_ORDER="total_missing" DESC, "rebrickable_sets"."year" ASC
# Optional: Folder where to store the sets images, relative to the '/app/static/' folder # Optional: Folder where to store the sets images, relative to '/app/' folder
# Default: sets # Default: data/sets
# BK_SETS_FOLDER=sets # BK_SETS_FOLDER=data/sets
# Optional: Enable set consolidation/grouping on the main sets page # Optional: Enable set consolidation/grouping on the main sets page
# When enabled, multiple copies of the same set are grouped together showing instance count # When enabled, multiple copies of the same set are grouped together showing instance count
@@ -367,10 +413,14 @@
# Default: true # Default: true
# BK_SHOW_SETS_DUPLICATE_FILTER=true # BK_SHOW_SETS_DUPLICATE_FILTER=true
# Optional: Skip saving or displaying spare parts # Optional: Skip importing spare parts when downloading sets from Rebrickable
# Default: false # Default: false
# BK_SKIP_SPARE_PARTS=true # BK_SKIP_SPARE_PARTS=true
# Optional: Hide spare parts from parts lists (spare parts must still be in database)
# Default: false
# BK_HIDE_SPARE_PARTS=true
# Optional: Namespace of the Socket.IO socket # Optional: Namespace of the Socket.IO socket
# Default: bricksocket # Default: bricksocket
# BK_SOCKET_NAMESPACE=customsocket # BK_SOCKET_NAMESPACE=customsocket
@@ -381,18 +431,21 @@
# Optional: Change the default order of storages. By default ordered by insertion order. # Optional: Change the default order of storages. By default ordered by insertion order.
# Useful column names for this option are: # Useful column names for this option are:
# - "bricktracker_metadata_storages"."name" ASC: storage name # - "bricktracker_metadata_storages"."name": storage name
# - "bricktracker_metadata_storages"."rowid": insertion order (special column)
# Default: "bricktracker_metadata_storages"."name" ASC # Default: "bricktracker_metadata_storages"."name" ASC
# BK_STORAGE_DEFAULT_ORDER="bricktracker_metadata_storages"."name" ASC # Examples:
# BK_STORAGE_DEFAULT_ORDER="bricktracker_metadata_storages"."name" DESC
# BK_STORAGE_DEFAULT_ORDER="bricktracker_metadata_storages"."rowid" DESC
# Optional: URL to the themes.csv.gz on Rebrickable # Optional: URL to the themes.csv.gz on Rebrickable
# Default: https://cdn.rebrickable.com/media/downloads/themes.csv.gz # Default: https://cdn.rebrickable.com/media/downloads/themes.csv.gz
# BK_THEMES_FILE_URL= # BK_THEMES_FILE_URL=
# Optional: Path to the themes file # Optional: Path to the themes file, relative to '/app/' folder
# You can name it whatever you want, but content has to be a CSV # You can name it whatever you want, but content has to be a CSV
# Default: ./themes.csv # Default: data/themes.csv
# BK_THEMES_PATH=/var/lib/bricktracker/themes.csv # BK_THEMES_PATH=data/themes.csv
# Optional: Timezone to use to display datetimes # Optional: Timezone to use to display datetimes
# Check your system for available timezone/TZ values # Check your system for available timezone/TZ values
@@ -404,14 +457,19 @@
# Default: false # Default: false
# BK_USE_REMOTE_IMAGES=true # BK_USE_REMOTE_IMAGES=true
# Optional: Change the default order of sets. By default ordered by insertion order. # Optional: Change the default order of wishlist sets. By default ordered by insertion order.
# Useful column names for this option are: # Useful column names for this option are:
# - "bricktracker_wishes"."set": set number as a string # - "bricktracker_wishes"."set": set number as a string (e.g., "10255-1")
# - "bricktracker_wishes"."name": set name # - "bricktracker_wishes"."name": set name
# - "bricktracker_wishes"."year": set release year # - "bricktracker_wishes"."year": set release year
# - "bricktracker_wishes"."number_of_parts": set number of parts # - "bricktracker_wishes"."number_of_parts": set number of parts
# - "bricktracker_wishes"."theme_id": theme ID
# - "bricktracker_wishes"."rowid": insertion order (special column)
# Default: "bricktracker_wishes"."rowid" DESC # Default: "bricktracker_wishes"."rowid" DESC
# BK_WISHES_DEFAULT_ORDER="bricktracker_wishes"."set" DESC # Examples:
# BK_WISHES_DEFAULT_ORDER="bricktracker_wishes"."year" DESC, "bricktracker_wishes"."name" ASC
# BK_WISHES_DEFAULT_ORDER="bricktracker_wishes"."number_of_parts" DESC
# BK_WISHES_DEFAULT_ORDER="bricktracker_wishes"."set" ASC
# Optional: Show collection growth charts on the statistics page # Optional: Show collection growth charts on the statistics page
# Default: true # Default: true
@@ -421,3 +479,8 @@
# When true, all sections start expanded. When false, all sections start collapsed. # When true, all sections start expanded. When false, all sections start collapsed.
# Default: true # Default: true
# BK_STATISTICS_DEFAULT_EXPANDED=false # BK_STATISTICS_DEFAULT_EXPANDED=false
# Optional: Enable dark mode by default
# When true, the application starts in dark mode.
# Default: false
# BK_DARK_MODE=true
+3
View File
@@ -17,12 +17,14 @@ static/sets/
# IDE # IDE
.vscode/ .vscode/
*.code-workspace
# Temporary # Temporary
*.csv *.csv
/local/ /local/
run_local.sh run_local.sh
settings.local.json settings.local.json
/offline/
# Apple idiocy # Apple idiocy
.DS_Store .DS_Store
@@ -33,3 +35,4 @@ vitepress/
# Local data # Local data
offline/ offline/
data/
+195 -81
View File
@@ -1,17 +1,179 @@
# Changelog # Changelog
## Unreleased ## 1.3.1
### 1.3 ### Bug Fixes
- Add individual pagination control system per entity type - **Fixed foreign key constraint errors during set imports**: Resolved `FOREIGN KEY constraint failed` errors when importing sets with parts and minifigures
- `BK_SETS_SERVER_SIDE_PAGINATION`: Enable/disable pagination for sets - Fixed insertion order in `bricktracker/part.py`: Parent records (`rebrickable_parts`) now inserted before child records (`bricktracker_parts`)
- `BK_PARTS_SERVER_SIDE_PAGINATION`: Enable/disable pagination for parts - Fixed insertion order in `bricktracker/minifigure.py`: Parent records (`rebrickable_minifigures`) now inserted before child records (`bricktracker_minifigures`)
- `BK_MINIFIGURES_SERVER_SIDE_PAGINATION`: Enable/disable pagination for minifigures - Ensures foreign key references are valid when SQLite checks constraints
- Device-specific pagination sizes (desktop/mobile) for each entity type - **Fixed set metadata updates**: Owner, status, and tag checkboxes now properly persist changes on set details page
- Supports search, filtering, and sorting in both server-side and client-side modes - Fixed `update_set_state()` method to commit database transactions (was using deferred execution without commit)
- Consolidated duplicate code across parts.js, problems.js, and minifigures.js - All metadata updates (owner, status, tags, storage, purchase info) now work consistently
- Created shared functions in collapsible-state.js for common operations - **Fixed nil image downloads**: Placeholder images for parts and minifigures without images now download correctly
- Removed early returns that prevented nil image downloads
- Nil images now properly saved to configured folders (e.g., `/app/data/parts/nil.jpg`)
- **Fixed error logging for missing files**: File not found errors now show actual configured folder paths instead of just URL paths
- Added detailed logging showing both file path and configured folder for easier debugging
- **Fixed minifigure filters in client-side pagination mode**: Owner and other filters now work correctly when server-side pagination is disabled
- Aligned filter behavior with parts page (applies filters server-side, then loads filtered data for client-side search)
## 1.3
### Breaking Changes
#### Data Folder Consolidation
> **Warning**
> **BREAKING CHANGE**: Version 1.3 consolidates all user data into a single `data/` folder for easier backup and volume mapping.
- **Path handling**: All relative paths are now resolved relative to the application root (`/app` in Docker)
- Example: `data/app.db``/app/data/app.db`
- **New default paths** (automatically used for new installations):
- Database: `data/app.db` (was: `app.db` in root)
- Configuration: `data/.env` (was: `.env` in root) - *optional, backward compatible*
- CSV files: `data/*.csv` (was: `*.csv` in root)
- Images/PDFs: `data/{sets,parts,minifigures,instructions}/` (was: `static/*`)
- **Configuration file (.env) location**:
- New recommended location: `data/.env` (included in data volume, settings persist)
- Backward compatible: `.env` in root still works (requires volume mount for admin panel persistence)
- Priority: `data/.env` > `.env` (automatic detection, no migration required)
- **Migration options**:
1. **Migrate to new structure** (recommended - single volume for all data including .env)
2. **Keep current setup** (backward compatible - old paths continue to work)
See [Migration Guide](docs/migration_guide.md) for detailed instructions
#### Default Minifigures Folder Change
> **Warning**
> **BREAKING CHANGE**: Default minifigures folder path changed from `minifigs` to `minifigures`
- **Impact**: Users who relied on the default `BK_MINIFIGURES_FOLDER` value (without explicitly setting it) will need to either:
1. Set `BK_MINIFIGURES_FOLDER=minifigs` in their environment to maintain existing behavior, or
2. Rename their existing `minifigs` folder to `minifigures`
- **No impact**: Users who already have `BK_MINIFIGURES_FOLDER` explicitly configured
- Improved consistency across documentation and Docker configurations
### New Features
- **Live Settings changes**
- Added live environment variable configuration management system
- Configuration Management interface in admin panel with live preview and badge system
- **Live settings**: Can be changed without application restart (menu visibility, table display, pagination, features)
- **Static settings**: Require restart but can be edited and saved to .env file (authentication, server, database, API keys)
- Advanced badge system showing value status: True/False for booleans, Set/Default/Unset for other values, Changed indicator
- Live API endpoints: `/admin/api/config/update` for immediate changes, `/admin/api/config/update-static` for .env updates
- Form pre-population with current values and automatic page reload after successful live updates
- Fixed environment variable lock detection in admin configuration panel
- Resolved bug where all variables appeared "locked" after saving live settings
- Lock detection now correctly identifies only Docker environment variables set before .env loading
- Variables set via Docker's `environment:` directive remain properly locked
- Variables from data/.env or root .env are correctly shown as editable
- Added configuration persistence warning in admin panel
- Warning banner shows when using .env in root (non-persistent)
- Success banner shows when using data/.env (persistent)
- Provides migration instructions directly in the UI
- **Spare Parts**
- Added spare parts control options
- `BK_SKIP_SPARE_PARTS`: Skip importing spare parts when downloading sets from Rebrickable (parts not saved to database)
- `BK_HIDE_SPARE_PARTS`: Hide spare parts from all parts lists (parts must still be in database)
- Both options are live-changeable in admin configuration panel
- Options can be used independently or together for flexible spare parts management
- Affects all parts displays: /parts page, set details accordion, minifigure parts, and problem parts
- **Pagination**
- Added 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
- **Peeron Instructions**
- Added Peeron instructions integration
- Full image caching system with automatic thumbnail generation
- Optimized HTTP calls by downloading full images once and generating thumbnails locally
- Automatic cache cleanup after PDF generation to save disk space
- **Parts checkmark**
- Added parts checking/inventory system
- New "Checked" column in parts tables for tracking inventory progress
- Checkboxes to mark parts as verified during set walkthrough
- `BK_HIDE_TABLE_CHECKED_PARTS`: Environment variable to hide checked column
- **Set Consolidation**
- Added set consolidation/grouping functionality
- Automatic grouping of duplicate sets on main sets page
- Shows instance count with stack icon badge (e.g., "3 copies")
- Expandable drawer interface to view all set copies individually
- Full set cards for each instance with all badges, statuses, and functionality
- `BK_SETS_CONSOLIDATION`: Environment variable to enable/disable consolidation (default: false)
- Backwards compatible - when disabled, behaves exactly like original individual view
- Improved theme filtering: handles duplicate theme names correctly
- Fixed set number sorting: proper numeric sorting in both ascending and descending order
- Mixed status indicators for consolidated sets: three-state checkboxes (unchecked/partial/checked) with count badges
- Template logic handles three states: none (0/2), all (2/2), partial (1/2) with visual indicators
- Purple overlay styling for partial states, disabled checkboxes for read-only consolidated status display
- Individual sets maintain full interactive checkbox functionality
- **Statistics**
- Added comprehensive statistics system (#91)
- New Statistics page with collection analytics
- Financial overview: total cost, average price, price range, investment tracking
- Collection metrics: total sets, unique sets, parts count, minifigures count
- Theme distribution statistics with clickable drill-down to filtered sets
- Storage location statistics showing sets per location with value calculations
- Purchase location analytics with spending patterns and date ranges
- Problem tracking: missing and damaged parts statistics
- Clickable numbers throughout statistics that filter to relevant sets
- `BK_HIDE_STATISTICS`: Environment variable to hide statistics menu item
- Year-based analytics: Sets by release year and purchases by year
- Sets by Release Year: Shows collection distribution across LEGO release years
- Purchases by Year: Tracks spending patterns and acquisition timeline
- Year summary with peak collection/spending years and timeline insights
- Enhanced statistics interface and functionality
- Collapsible sections: All statistics sections have clickable headers to expand/collapse
- Collection growth charts: Line charts showing sets, parts, and minifigures over time
- Configuration options: `BK_STATISTICS_SHOW_CHARTS` and `BK_STATISTICS_DEFAULT_EXPANDED` environment variables
- **Admin Page Section Expansion**
- Added configurable admin page section expansion
- `BK_ADMIN_DEFAULT_EXPANDED_SECTIONS`: Environment variable to specify which sections expand by default
- Accepts comma-separated list of section names (e.g., "database,theme,instructions")
- Valid sections: authentication, instructions, image, theme, retired, metadata, owner, purchase_location, status, storage, tag, database
- URL parameters take priority over configuration (e.g., `?open_database=1`)
- Database section expanded by default to maintain original behavior
- Smart metadata handling: sub-section expansion automatically expands parent metadata section
- **Duplicate Sets filter**
- Added duplicate sets filter functionality
- New filter button on Sets page to show only duplicate/consolidated sets
- `BK_SHOW_SETS_DUPLICATE_FILTER`: Environment variable to show/hide the filter button (default: true)
- Works with both server-side and client-side pagination modes
- Consolidated mode: Shows sets that have multiple instances
- Non-consolidated mode: Shows sets that appear multiple times in collection
- **Bricklink Links**
- Added BrickLink links for sets
- BrickLink badge links now appear on set cards and set details pages alongside Rebrickable links
- `BK_BRICKLINK_LINK_SET_PATTERN`: New environment variable for BrickLink set URL pattern (default: https://www.bricklink.com/v2/catalog/catalogitem.page?S={set_num})
- Controlled by existing `BK_BRICKLINK_LINKS` environment variable
- **Dark Mode**
- Added dark mode support
- `BK_DARK_MODE`: Environment variable to enable dark mode theme (default: false)
- Uses Bootstrap 5.3's native dark mode with `data-bs-theme` attribute
- Live-changeable via Admin > Live Settings
- Setting persists across sessions via .env file
- **Alphanumetic Set Number**
- Added alphanumeric set number support
- Database schema change: Set number column changed from INTEGER to TEXT
- Supports LEGO promotional and special edition sets with letters in their numbers
- Examples: "McDR6US-1", "COMCON035-1", "EG00021-1"
### Improvements
- Improved WebSocket/Socket.IO reliability for mobile devices
- Changed connection strategy to polling-first with automatic WebSocket upgrade
- Increased connection timeout to 30 seconds for slow mobile networks
- Added ping/pong keepalive settings (30s timeout, 25s interval)
- Improved server-side connection logging with user agent and transport details
- Fixed dynamic sort icons across all pages - Fixed dynamic sort icons across all pages
- Sort icons now properly toggle between ascending/descending states - Sort icons now properly toggle between ascending/descending states
- Improved DataTable integration - Improved DataTable integration
@@ -23,76 +185,7 @@
- Preserves selection state during dropdown consolidation - Preserves selection state during dropdown consolidation
- Consistent search behavior (instant for client-side, Enter key for server-side) - Consistent search behavior (instant for client-side, Enter key for server-side)
- Mobile-friendly pagination navigation - Mobile-friendly pagination navigation
- Add Peeron instructions integration - Added performance optimization
- Full image caching system with automatic thumbnail generation
- Optimized HTTP calls by downloading full images once and generating thumbnails locally
- Automatic cache cleanup after PDF generation to save disk space
- Add parts checking/inventory system
- New "Checked" column in parts tables for tracking inventory progress
- Checkboxes to mark parts as verified during set walkthrough
- `BK_HIDE_TABLE_CHECKED_PARTS`: Environment variable to hide checked column
- Add set consolidation/grouping functionality
- Automatic grouping of duplicate sets on main sets page
- Shows instance count with stack icon badge (e.g., "3 copies")
- Expandable drawer interface to view all set copies individually
- Full set cards for each instance with all badges, statuses, and functionality
- `BK_SETS_CONSOLIDATION`: Environment variable to enable/disable consolidation (default: false)
- Backwards compatible - when disabled, behaves exactly like original individual view
- Improved theme filtering: handles duplicate theme names correctly
- Fixed set number sorting: proper numeric sorting in both ascending and descending order
- Mixed status indicators for consolidated sets: three-state checkboxes (unchecked/partial/checked) with count badges
- Template logic handles three states: none (0/2), all (2/2), partial (1/2) with visual indicators
- Purple overlay styling for partial states, disabled checkboxes for read-only consolidated status display
- Individual sets maintain full interactive checkbox functionality
- Add comprehensive statistics system (#91)
- New Statistics page with collection analytics
- Financial overview: total cost, average price, price range, investment tracking
- Collection metrics: total sets, unique sets, parts count, minifigures count
- Theme distribution statistics with clickable drill-down to filtered sets
- Storage location statistics showing sets per location with value calculations
- Purchase location analytics with spending patterns and date ranges
- Problem tracking: missing and damaged parts statistics
- Clickable numbers throughout statistics that filter to relevant sets
- `BK_HIDE_STATISTICS`: Environment variable to hide statistics menu item
- Year-based analytics: Sets by release year and purchases by year
- Sets by Release Year: Shows collection distribution across LEGO release years
- Purchases by Year: Tracks spending patterns and acquisition timeline
- Year summary with peak collection/spending years and timeline insights
- Enhanced statistics interface and functionality
- Collapsible sections: All statistics sections have clickable headers to expand/collapse
- Collection growth charts: Line charts showing sets, parts, and minifigures over time
- Configuration options: `BK_STATISTICS_SHOW_CHARTS` and `BK_STATISTICS_DEFAULT_EXPANDED` environment variables
- Add configurable admin page section expansion
- `BK_ADMIN_DEFAULT_EXPANDED_SECTIONS`: Environment variable to specify which sections expand by default
- Accepts comma-separated list of section names (e.g., "database,theme,instructions")
- Valid sections: authentication, instructions, image, theme, retired, metadata, owner, purchase_location, status, storage, tag, database
- URL parameters take priority over configuration (e.g., `?open_database=1`)
- Database section expanded by default to maintain original behavior
- Smart metadata handling: sub-section expansion automatically expands parent metadata section
- Add duplicate sets filter functionality
- New filter button on Sets page to show only duplicate/consolidated sets
- `BK_SHOW_SETS_DUPLICATE_FILTER`: Environment variable to show/hide the filter button (default: true)
- Works with both server-side and client-side pagination modes
- Consolidated mode: Shows sets that have multiple instances
- Non-consolidated mode: Shows sets that appear multiple times in collection
- Add BrickLink links for sets
- BrickLink badge links now appear on set cards and set details pages alongside Rebrickable links
- `BK_BRICKLINK_LINK_SET_PATTERN`: New environment variable for BrickLink set URL pattern (default: https://www.bricklink.com/v2/catalog/catalogitem.page?S={set_num})
- Controlled by existing `BK_BRICKLINK_LINKS` environment variable
- Add live environment variable configuration management system
- Configuration Management interface in admin panel with live preview and badge system
- Live settings: Can be changed without application restart (menu visibility, table display, pagination, features)
- Static settings: Require restart but can be edited and saved to .env file (authentication, server, database, API keys)
- Advanced badge system showing value status: True/False for booleans, Set/Default/Unset for other values, Changed indicator
- Live API endpoints: `/admin/api/config/update` for immediate changes, `/admin/api/config/update-static` for .env updates
- Form pre-population with current values and automatic page reload after successful live updates
- **BREAKING CHANGE**: Default minifigures folder path changed from `minifigs` to `minifigures`
- Impact: Users who relied on the default `BK_MINIFIGURES_FOLDER` value (without explicitly setting it) will need to either:
1. Set `BK_MINIFIGURES_FOLDER=minifigs` in their environment to maintain existing behavior, or
2. Rename their existing `minifigs` folder to `minifigures`
- No impact: Users who already have `BK_MINIFIGURES_FOLDER` explicitly configured
- Improved consistency across documentation and Docker configurations
- Add performance optimization
- SQLite WAL Mode: - SQLite WAL Mode:
- Increased cache size to 10,000 pages (~40MB) for faster query execution - Increased cache size to 10,000 pages (~40MB) for faster query execution
- Set temp_store to memory for accelerated temporary operations - Set temp_store to memory for accelerated temporary operations
@@ -108,8 +201,29 @@
- Statistics Query Optimization: - Statistics Query Optimization:
- Replaced separate subqueries with efficient CTEs (Common Table Expressions) - Replaced separate subqueries with efficient CTEs (Common Table Expressions)
- Consolidated aggregations for set, part, minifigure, and financial statistics - Consolidated aggregations for set, part, minifigure, and financial statistics
- Added default image handling for sets without images
- Sets with null/missing images from Rebrickable API now display placeholder image
- Automatic fallback to nil.png from parts folder for set previews
- Copy of nil placeholder saved as set image for consistent display across all routes
- Prevents errors when downloading sets that have no set_img_url in API response
- Fixed instructions download from Rebrickable
- Replaced cloudscraper with standard requests library
- Resolves 403 Forbidden errors when downloading instruction PDFs
- Fixed instructions display and URL generation
- Fixed "Open PDF" button links to use correct data route
- Corrected path resolution for data/instructions folder
- Fixed instruction listing page to scan correct folder location
- Fixed Peeron PDF creation to use correct data folder path
- Fixed foreign key constraint error when adding sets
- Rebrickable set is now inserted before BrickTracker set to satisfy FK constraints
- Resolves "FOREIGN KEY constraint failed" error when adding sets
- Fixed atomic transaction handling for set downloads
- All database operations during set addition now use deferred execution
- Ensures all-or-nothing behavior: if any part fails (set info, parts, minifigs), nothing is committed
- Prevents partial set additions that would leave the database in an inconsistent state
- Metadata updates (owners, tags) now defer until final commit
### 1.2.4 ## 1.2.4
> **Warning** > **Warning**
> To use the new BrickLink color parameter in URLs, update your `.env` file: > To use the new BrickLink color parameter in URLs, update your `.env` file:
+8 -5
View File
@@ -2,13 +2,16 @@ FROM python:3-slim
WORKDIR /app WORKDIR /app
# Copy requirements first (so pip install can be cached)
COPY requirements.txt .
# Python library requirements
RUN pip install --no-cache-dir -r requirements.txt
# Bricktracker # Bricktracker
COPY . . COPY . .
# Fix line endings and set executable permissions for entrypoint script # Set executable permissions for entrypoint script
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh RUN chmod +x entrypoint.sh
# Python library requirements
RUN pip --no-cache-dir install -r requirements.txt
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]
+6 -10
View File
@@ -1,3 +1,5 @@
<img src="static/brick.png" height="100" width="100">
# BrickTracker # BrickTracker
A web application for organizing and tracking LEGO sets, parts, and minifigures. Uses the Rebrickable API to fetch LEGO data and allows users to track missing pieces and collection status. A web application for organizing and tracking LEGO sets, parts, and minifigures. Uses the Rebrickable API to fetch LEGO data and allows users to track missing pieces and collection status.
@@ -16,19 +18,13 @@ A web application for organizing and tracking LEGO sets, parts, and minifigures.
## Prefered setup: pre-build docker image ## Prefered setup: pre-build docker image
Use the provided [compose.yaml](compose.yaml) file. See [Quick Start](https://bricktracker.baerentsen.space/quick-start) to get up and running right away.
See [Quickstart](docs/quickstart.md) to get up and running right away. See [Walk Through](https://bricktracker.baerentsen.space/tutorial-first-steps) for a more detailed guide.
See [Setup](docs/setup.md) for a more setup guide.
## Usage
See [first steps](docs/first-steps.md).
## Documentation ## Documentation
Most of the pages should be self explanatory to use. Most of the pages should be self explanatory to use.
However, you can find more specific documentation in the [documentation](docs/DOCS.md). However, you can find more specific documentation in the [documentation](https://bricktracker.baerentsen.space/whatis).
You can find screenshots of the application in the [overview](docs/overview.md) documentation file. You can find screenshots of the application in the [overview](https://bricktracker.baerentsen.space/overview) documentation.
+59
View File
@@ -1,6 +1,8 @@
import logging import logging
import os
import sys import sys
import time import time
from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from flask import current_app, Flask, g from flask import current_app, Flask, g
@@ -25,6 +27,7 @@ from bricktracker.views.admin.status import admin_status_page
from bricktracker.views.admin.storage import admin_storage_page from bricktracker.views.admin.storage import admin_storage_page
from bricktracker.views.admin.tag import admin_tag_page from bricktracker.views.admin.tag import admin_tag_page
from bricktracker.views.admin.theme import admin_theme_page from bricktracker.views.admin.theme import admin_theme_page
from bricktracker.views.data import data_page
from bricktracker.views.error import error_404 from bricktracker.views.error import error_404
from bricktracker.views.index import index_page from bricktracker.views.index import index_page
from bricktracker.views.instructions import instructions_page from bricktracker.views.instructions import instructions_page
@@ -37,7 +40,60 @@ from bricktracker.views.storage import storage_page
from bricktracker.views.wish import wish_page from bricktracker.views.wish import wish_page
def load_env_file() -> None:
"""Load .env file into os.environ with priority: data/.env > .env (root)
Also stores which BK_ variables were set via Docker environment (before loading .env)
so we can detect locked variables in the admin panel.
"""
import json
data_env = Path('data/.env')
root_env = Path('.env')
# Store which BK_ variables were already in environment BEFORE loading .env
# These are "locked" (set via Docker's environment: directive)
docker_env_vars = {k: v for k, v in os.environ.items() if k.startswith('BK_')}
# Store this in a way the admin panel can access it
# We'll use an environment variable to store the JSON list of locked var names
os.environ['_BK_DOCKER_ENV_VARS'] = json.dumps(list(docker_env_vars.keys()))
env_file = None
if data_env.exists():
env_file = data_env
logging.info(f"Loading environment from: {data_env}")
elif root_env.exists():
env_file = root_env
logging.info(f"Loading environment from: {root_env} (consider migrating to data/.env)")
if env_file:
# Simple .env parser (no external dependencies needed)
with open(env_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
# Skip comments and empty lines
if not line or line.startswith('#'):
continue
# Parse key=value
if '=' in line:
key, value = line.split('=', 1)
key = key.strip()
value = value.strip()
# Remove quotes if present
if value.startswith('"') and value.endswith('"'):
value = value[1:-1]
elif value.startswith("'") and value.endswith("'"):
value = value[1:-1]
# Only set if not already in environment (environment variables take precedence)
if key not in os.environ:
os.environ[key] = value
def setup_app(app: Flask) -> None: def setup_app(app: Flask) -> None:
# Load .env file before configuration (if not already loaded by Docker Compose)
load_env_file()
# Load the configuration # Load the configuration
BrickConfigurationList(app) BrickConfigurationList(app)
@@ -48,12 +104,14 @@ def setup_app(app: Flask) -> None:
level=logging.DEBUG, level=logging.DEBUG,
format='[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s', # noqa: E501 format='[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s', # noqa: E501
) )
logging.getLogger().setLevel(logging.DEBUG)
else: else:
logging.basicConfig( logging.basicConfig(
stream=sys.stdout, stream=sys.stdout,
level=logging.INFO, level=logging.INFO,
format='[%(asctime)s] %(levelname)s - %(message)s', format='[%(asctime)s] %(levelname)s - %(message)s',
) )
logging.getLogger().setLevel(logging.INFO)
# Load the navbar # Load the navbar
Navbar(app) Navbar(app)
@@ -77,6 +135,7 @@ def setup_app(app: Flask) -> None:
# Register app routes # Register app routes
app.register_blueprint(add_page) app.register_blueprint(add_page)
app.register_blueprint(data_page)
app.register_blueprint(index_page) app.register_blueprint(index_page)
app.register_blueprint(instructions_page) app.register_blueprint(instructions_page)
app.register_blueprint(login_page) app.register_blueprint(login_page)
+9 -7
View File
@@ -13,7 +13,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'BRICKLINK_LINK_PART_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}&C={color}'}, # noqa: E501 {'n': 'BRICKLINK_LINK_PART_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}&C={color}'}, # noqa: E501
{'n': 'BRICKLINK_LINK_SET_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?S={set_num}'}, # noqa: E501 {'n': 'BRICKLINK_LINK_SET_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?S={set_num}'}, # noqa: E501
{'n': 'BRICKLINK_LINKS', 'c': bool}, {'n': 'BRICKLINK_LINKS', 'c': bool},
{'n': 'DATABASE_PATH', 'd': './app.db'}, {'n': 'DATABASE_PATH', 'd': 'data/app.db'},
{'n': 'DATABASE_TIMESTAMP_FORMAT', 'd': '%Y-%m-%d-%H-%M-%S'}, {'n': 'DATABASE_TIMESTAMP_FORMAT', 'd': '%Y-%m-%d-%H-%M-%S'},
{'n': 'DEBUG', 'c': bool}, {'n': 'DEBUG', 'c': bool},
{'n': 'DEFAULT_TABLE_PER_PAGE', 'd': 25, 'c': int}, {'n': 'DEFAULT_TABLE_PER_PAGE', 'd': 25, 'c': int},
@@ -22,7 +22,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'HOST', 'd': '0.0.0.0'}, {'n': 'HOST', 'd': '0.0.0.0'},
{'n': 'INDEPENDENT_ACCORDIONS', 'c': bool}, {'n': 'INDEPENDENT_ACCORDIONS', 'c': bool},
{'n': 'INSTRUCTIONS_ALLOWED_EXTENSIONS', 'd': ['.pdf'], 'c': list}, # noqa: E501 {'n': 'INSTRUCTIONS_ALLOWED_EXTENSIONS', 'd': ['.pdf'], 'c': list}, # noqa: E501
{'n': 'INSTRUCTIONS_FOLDER', 'd': 'instructions', 's': True}, {'n': 'INSTRUCTIONS_FOLDER', 'd': 'data/instructions'},
{'n': 'HIDE_ADD_SET', 'c': bool}, {'n': 'HIDE_ADD_SET', 'c': bool},
{'n': 'HIDE_ADD_BULK_SET', 'c': bool}, {'n': 'HIDE_ADD_BULK_SET', 'c': bool},
{'n': 'HIDE_ADMIN', 'c': bool}, {'n': 'HIDE_ADMIN', 'c': bool},
@@ -40,7 +40,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'HIDE_TABLE_CHECKED_PARTS', 'c': bool}, {'n': 'HIDE_TABLE_CHECKED_PARTS', 'c': bool},
{'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': 'minifigures', 's': True}, {'n': 'MINIFIGURES_FOLDER', 'd': 'data/minifigures'},
{'n': 'MINIFIGURES_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int}, {'n': 'MINIFIGURES_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int},
{'n': 'MINIFIGURES_PAGINATION_SIZE_MOBILE', 'd': 5, 'c': int}, {'n': 'MINIFIGURES_PAGINATION_SIZE_MOBILE', 'd': 5, 'c': int},
{'n': 'MINIFIGURES_SERVER_SIDE_PAGINATION', 'c': bool}, {'n': 'MINIFIGURES_SERVER_SIDE_PAGINATION', 'c': bool},
@@ -48,7 +48,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'PARTS_SERVER_SIDE_PAGINATION', 'c': bool}, {'n': 'PARTS_SERVER_SIDE_PAGINATION', 'c': bool},
{'n': 'SETS_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': 'data/parts'},
{'n': 'PARTS_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int}, {'n': 'PARTS_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int},
{'n': 'PARTS_PAGINATION_SIZE_MOBILE', 'd': 5, 'c': int}, {'n': 'PARTS_PAGINATION_SIZE_MOBILE', 'd': 5, 'c': int},
{'n': 'PROBLEMS_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int}, {'n': 'PROBLEMS_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int},
@@ -77,22 +77,24 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'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
{'n': 'RETIRED_SETS_PATH', 'd': './retired_sets.csv'}, {'n': 'RETIRED_SETS_PATH', 'd': 'data/retired_sets.csv'},
{'n': 'SETS_DEFAULT_ORDER', 'd': '"rebrickable_sets"."number" DESC, "rebrickable_sets"."version" ASC'}, # noqa: E501 {'n': 'SETS_DEFAULT_ORDER', 'd': '"rebrickable_sets"."number" DESC, "rebrickable_sets"."version" ASC'}, # noqa: E501
{'n': 'SETS_FOLDER', 'd': 'sets', 's': True}, {'n': 'SETS_FOLDER', 'd': 'data/sets'},
{'n': 'SETS_CONSOLIDATION', 'd': False, 'c': bool}, {'n': 'SETS_CONSOLIDATION', 'd': False, 'c': bool},
{'n': 'SHOW_GRID_FILTERS', 'c': bool}, {'n': 'SHOW_GRID_FILTERS', 'c': bool},
{'n': 'SHOW_GRID_SORT', 'c': bool}, {'n': 'SHOW_GRID_SORT', 'c': bool},
{'n': 'SHOW_SETS_DUPLICATE_FILTER', 'd': True, 'c': bool}, {'n': 'SHOW_SETS_DUPLICATE_FILTER', 'd': True, 'c': bool},
{'n': 'SKIP_SPARE_PARTS', 'c': bool}, {'n': 'SKIP_SPARE_PARTS', 'c': bool},
{'n': 'HIDE_SPARE_PARTS', 'c': bool},
{'n': 'SOCKET_NAMESPACE', 'd': 'bricksocket'}, {'n': 'SOCKET_NAMESPACE', 'd': 'bricksocket'},
{'n': 'SOCKET_PATH', 'd': '/bricksocket/'}, {'n': 'SOCKET_PATH', 'd': '/bricksocket/'},
{'n': 'STORAGE_DEFAULT_ORDER', 'd': '"bricktracker_metadata_storages"."name" ASC'}, # noqa: E501 {'n': 'STORAGE_DEFAULT_ORDER', 'd': '"bricktracker_metadata_storages"."name" ASC'}, # noqa: E501
{'n': 'THEMES_FILE_URL', 'd': 'https://cdn.rebrickable.com/media/downloads/themes.csv.gz'}, # noqa: E501 {'n': 'THEMES_FILE_URL', 'd': 'https://cdn.rebrickable.com/media/downloads/themes.csv.gz'}, # noqa: E501
{'n': 'THEMES_PATH', 'd': './themes.csv'}, {'n': 'THEMES_PATH', 'd': 'data/themes.csv'},
{'n': 'TIMEZONE', 'd': 'Etc/UTC'}, {'n': 'TIMEZONE', 'd': 'Etc/UTC'},
{'n': 'USE_REMOTE_IMAGES', 'c': bool}, {'n': 'USE_REMOTE_IMAGES', 'c': bool},
{'n': 'WISHES_DEFAULT_ORDER', 'd': '"bricktracker_wishes"."rowid" DESC'}, {'n': 'WISHES_DEFAULT_ORDER', 'd': '"bricktracker_wishes"."rowid" DESC'},
{'n': 'STATISTICS_SHOW_CHARTS', 'd': True, 'c': bool}, {'n': 'STATISTICS_SHOW_CHARTS', 'd': True, 'c': bool},
{'n': 'STATISTICS_DEFAULT_EXPANDED', 'd': True, 'c': bool}, {'n': 'STATISTICS_DEFAULT_EXPANDED', 'd': True, 'c': bool},
{'n': 'DARK_MODE', 'c': bool},
] ]
+22 -3
View File
@@ -46,12 +46,14 @@ LIVE_CHANGEABLE_VARS: Final[List[str]] = [
'BK_SHOW_GRID_SORT', 'BK_SHOW_GRID_SORT',
'BK_SHOW_SETS_DUPLICATE_FILTER', 'BK_SHOW_SETS_DUPLICATE_FILTER',
'BK_SKIP_SPARE_PARTS', 'BK_SKIP_SPARE_PARTS',
'BK_HIDE_SPARE_PARTS',
'BK_USE_REMOTE_IMAGES', 'BK_USE_REMOTE_IMAGES',
'BK_PEERON_DOWNLOAD_DELAY', 'BK_PEERON_DOWNLOAD_DELAY',
'BK_PEERON_MIN_IMAGE_SIZE', 'BK_PEERON_MIN_IMAGE_SIZE',
'BK_REBRICKABLE_PAGE_SIZE', 'BK_REBRICKABLE_PAGE_SIZE',
'BK_STATISTICS_SHOW_CHARTS', 'BK_STATISTICS_SHOW_CHARTS',
'BK_STATISTICS_DEFAULT_EXPANDED', 'BK_STATISTICS_DEFAULT_EXPANDED',
'BK_DARK_MODE',
# Default ordering and formatting # Default ordering and formatting
'BK_INSTRUCTIONS_ALLOWED_EXTENSIONS', 'BK_INSTRUCTIONS_ALLOWED_EXTENSIONS',
'BK_MINIFIGURES_DEFAULT_ORDER', 'BK_MINIFIGURES_DEFAULT_ORDER',
@@ -107,7 +109,20 @@ class ConfigManager:
"""Manages live configuration updates for BrickTracker""" """Manages live configuration updates for BrickTracker"""
def __init__(self): def __init__(self):
self.env_file_path = Path('.env') # Check for .env in data folder first (v1.3+), fallback to root (backward compatibility)
data_env = Path('data/.env')
root_env = Path('.env')
if data_env.exists():
self.env_file_path = data_env
logger.info("Using configuration file: data/.env")
elif root_env.exists():
self.env_file_path = root_env
logger.info("Using configuration file: .env (consider migrating to data/.env)")
else:
# Default to data/.env for new installations
self.env_file_path = data_env
logger.info("Configuration file will be created at: data/.env")
def get_current_config(self) -> Dict[str, Any]: def get_current_config(self) -> Dict[str, Any]:
"""Get current configuration values for live-changeable variables""" """Get current configuration values for live-changeable variables"""
@@ -202,6 +217,8 @@ class ConfigManager:
def _update_env_file(self, var_name: str, value: Any) -> None: def _update_env_file(self, var_name: str, value: Any) -> None:
"""Update the .env file with new value""" """Update the .env file with new value"""
if not self.env_file_path.exists(): if not self.env_file_path.exists():
# Ensure parent directory exists
self.env_file_path.parent.mkdir(parents=True, exist_ok=True)
self.env_file_path.touch() self.env_file_path.touch()
# Read current .env content # Read current .env content
@@ -304,9 +321,11 @@ class ConfigManager:
'BK_SETS_CONSOLIDATION': 'Enable set consolidation/grouping functionality', 'BK_SETS_CONSOLIDATION': 'Enable set consolidation/grouping functionality',
'BK_SHOW_GRID_FILTERS': 'Show filter options on grids by default', 'BK_SHOW_GRID_FILTERS': 'Show filter options on grids by default',
'BK_SHOW_GRID_SORT': 'Show sort options on grids by default', 'BK_SHOW_GRID_SORT': 'Show sort options on grids by default',
'BK_SKIP_SPARE_PARTS': 'Skip spare parts when importing sets', 'BK_SKIP_SPARE_PARTS': 'Skip importing spare parts when downloading sets from Rebrickable',
'BK_HIDE_SPARE_PARTS': 'Hide spare parts from parts lists (spare parts must still be in database)',
'BK_USE_REMOTE_IMAGES': 'Use remote images from Rebrickable CDN instead of local', 'BK_USE_REMOTE_IMAGES': 'Use remote images from Rebrickable CDN instead of local',
'BK_STATISTICS_SHOW_CHARTS': 'Show collection growth charts on statistics page', 'BK_STATISTICS_SHOW_CHARTS': 'Show collection growth charts on statistics page',
'BK_STATISTICS_DEFAULT_EXPANDED': 'Expand all statistics sections by default' 'BK_STATISTICS_DEFAULT_EXPANDED': 'Expand all statistics sections by default',
'BK_DARK_MODE': 'Enable dark mode theme'
} }
return help_text.get(var_name, 'No help available for this variable') return help_text.get(var_name, 'No help available for this variable')
+5 -1
View File
@@ -60,7 +60,7 @@ class BrickConfiguration(object):
if self.cast == bool and isinstance(value, str): if self.cast == bool and isinstance(value, str):
value = value.lower() in ('true', 'yes', '1') value = value.lower() in ('true', 'yes', '1')
# Static path fixup # Static path fixup (legacy - only for paths with s: True flag)
if self.static_path and isinstance(value, str): if self.static_path and isinstance(value, str):
value = os.path.normpath(value) value = os.path.normpath(value)
@@ -70,6 +70,10 @@ class BrickConfiguration(object):
# Remove static prefix # Remove static prefix
value = value.removeprefix('static/') value = value.removeprefix('static/')
# Normalize regular paths (not marked as static)
elif not self.static_path and isinstance(value, str) and ('FOLDER' in self.name or 'PATH' in self.name):
value = os.path.normpath(value)
# Type casting # Type casting
if self.cast is not None: if self.cast is not None:
self.value = self.cast(value) self.value = self.cast(value)
+68 -21
View File
@@ -13,7 +13,6 @@ 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 re
import cloudscraper
from .exceptions import ErrorException, DownloadException from .exceptions import ErrorException, DownloadException
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -106,12 +105,34 @@ class BrickInstructions(object):
message=f'File {self.filename} already exists, skipped - <a href="{pdf_url}" target="_blank" class="btn btn-sm btn-primary ms-2"><i class="ri-external-link-line"></i> Open PDF</a>' message=f'File {self.filename} already exists, skipped - <a href="{pdf_url}" target="_blank" class="btn btn-sm btn-primary ms-2"><i class="ri-external-link-line"></i> Open PDF</a>'
) )
# Fetch PDF via cloudscraper (to bypass Cloudflare) # Use plain requests instead of cloudscraper
scraper = cloudscraper.create_scraper() session = requests.Session()
scraper.headers.update({ session.headers.update({
"User-Agent": current_app.config['REBRICKABLE_USER_AGENT'] 'User-Agent': current_app.config['REBRICKABLE_USER_AGENT'],
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'DNT': '1',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'same-origin',
'Cache-Control': 'max-age=0'
}) })
resp = scraper.get(path, stream=True)
# Visit the set's instructions listing page first to establish session cookies
set_number = None
if self.rebrickable:
set_number = self.rebrickable.fields.set
elif self.set:
set_number = self.set
if set_number:
instructions_page = f"https://rebrickable.com/instructions/{set_number}/"
session.get(instructions_page)
session.headers.update({"Referer": instructions_page})
resp = session.get(path, stream=True, allow_redirects=True)
if not resp.ok: if not resp.ok:
raise DownloadException(f"Failed to download: HTTP {resp.status_code}") raise DownloadException(f"Failed to download: HTTP {resp.status_code}")
@@ -172,11 +193,16 @@ class BrickInstructions(object):
if filename is None: if filename is None:
filename = self.filename filename = self.filename
return os.path.join( folder = current_app.config['INSTRUCTIONS_FOLDER']
current_app.static_folder, # type: ignore
current_app.config['INSTRUCTIONS_FOLDER'], # If folder is absolute, use it directly
filename # Otherwise, make it relative to app root (not static folder)
) if os.path.isabs(folder):
base_path = folder
else:
base_path = os.path.join(current_app.root_path, folder)
return os.path.join(base_path, filename)
# Rename an instructions file # Rename an instructions file
def rename(self, filename: str, /) -> None: def rename(self, filename: str, /) -> None:
@@ -217,10 +243,16 @@ class BrickInstructions(object):
folder: str = current_app.config['INSTRUCTIONS_FOLDER'] folder: str = current_app.config['INSTRUCTIONS_FOLDER']
# Compute the path # Determine which route to use based on folder path
path = os.path.join(folder, self.filename) # If folder contains 'data' (new structure), use data route
# Otherwise use static route (legacy)
return url_for('static', filename=path) if 'data' in folder:
return url_for('data.serve_data_file', folder='instructions', filename=self.filename)
else:
# Legacy: folder is relative to static/
folder_clean = folder.removeprefix('static/')
path = os.path.join(folder_clean, self.filename)
return url_for('static', filename=path)
# Return the icon depending on the extension # Return the icon depending on the extension
def icon(self, /) -> str: def icon(self, /) -> str:
@@ -237,20 +269,33 @@ class BrickInstructions(object):
@staticmethod @staticmethod
def find_instructions(set: str, /) -> list[Tuple[str, str]]: def find_instructions(set: str, /) -> list[Tuple[str, str]]:
""" """
Scrape Rebrickables HTML and return a list of Scrape Rebrickable's HTML and return a list of
(filename_slug, download_url). Duplicate slugs get _1, _2, … (filename_slug, download_url). Duplicate slugs get _1, _2, …
""" """
page_url = f"https://rebrickable.com/instructions/{set}/" page_url = f"https://rebrickable.com/instructions/{set}/"
logger.debug(f"[find_instructions] fetching HTML from {page_url!r}") logger.debug(f"[find_instructions] fetching HTML from {page_url!r}")
# Solve Cloudflares challenge # Use plain requests instead of cloudscraper
scraper = cloudscraper.create_scraper() session = requests.Session()
scraper.headers.update({'User-Agent': current_app.config['REBRICKABLE_USER_AGENT']}) session.headers.update({
resp = scraper.get(page_url) 'User-Agent': current_app.config['REBRICKABLE_USER_AGENT'],
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'DNT': '1',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Cache-Control': 'max-age=0'
})
resp = session.get(page_url)
if not resp.ok: if not resp.ok:
raise ErrorException(f'Failed to load instructions page for {set}. HTTP {resp.status_code}') raise ErrorException(f'Failed to load instructions page for {set}. HTTP {resp.status_code}')
soup = BeautifulSoup(resp.content, 'html.parser') soup = BeautifulSoup(resp.content, 'html.parser')
# Match download links with or without query parameters (e.g., ?cfe=timestamp&cfk=key)
link_re = re.compile(r'^/instructions/\d+/.+/download/') link_re = re.compile(r'^/instructions/\d+/.+/download/')
raw: list[tuple[str, str]] = [] raw: list[tuple[str, str]] = []
@@ -263,8 +308,10 @@ class BrickInstructions(object):
alt_text = img['alt'].removeprefix('LEGO Building Instructions for ') # type: ignore alt_text = img['alt'].removeprefix('LEGO Building Instructions for ') # type: ignore
slug = re.sub(r'[^A-Za-z0-9]+', '-', alt_text).strip('-') slug = re.sub(r'[^A-Za-z0-9]+', '-', alt_text).strip('-')
# Build the absolute download URL # Build the absolute download URL - this preserves query parameters
# BeautifulSoup's a['href'] includes the full href with ?cfe=...&cfk=... params
download_url = urljoin('https://rebrickable.com', a['href']) # type: ignore download_url = urljoin('https://rebrickable.com', a['href']) # type: ignore
logger.debug(f"[find_instructions] Found download link: {download_url}")
raw.append((slug, download_url)) raw.append((slug, download_url))
if not raw: if not raw:
+8 -5
View File
@@ -36,11 +36,14 @@ class BrickInstructionsList(object):
# Try to list the files in the instruction folder # Try to list the files in the instruction folder
try: try:
# Make a folder relative to static folder_config: str = current_app.config['INSTRUCTIONS_FOLDER']
folder: str = os.path.join(
current_app.static_folder, # type: ignore # If folder is absolute, use it directly
current_app.config['INSTRUCTIONS_FOLDER'], # Otherwise, make it relative to app root (not static folder)
) if os.path.isabs(folder_config):
folder = folder_config
else:
folder = os.path.join(current_app.root_path, folder_config)
for file in os.scandir(folder): for file in os.scandir(folder):
instruction = BrickInstructions(file) instruction = BrickInstructions(file)
+1 -2
View File
@@ -198,9 +198,8 @@ class BrickMetadata(BrickRecord):
) )
if rows != 1: if rows != 1:
raise DatabaseException('Could not update the {kind} "{name}" state for set {set} ({id})'.format( # noqa: E501 raise DatabaseException('Could not update the {kind} state for set {set} ({id})'.format(
kind=self.kind, kind=self.kind,
name=self.fields.name,
set=brickset.fields.set, set=brickset.fields.set,
id=brickset.fields.id, id=brickset.fields.id,
)) ))
+7 -6
View File
@@ -33,11 +33,7 @@ class BrickMinifigure(RebrickableMinifigure):
) )
) )
if not refresh: # Load the inventory (needed to count parts for rebrickable record)
# Insert into database
self.insert(commit=False)
# Load the inventory
if not BrickPartList.download( if not BrickPartList.download(
socket, socket,
self.brickset, self.brickset,
@@ -46,9 +42,14 @@ class BrickMinifigure(RebrickableMinifigure):
): ):
return False return False
# Insert the rebrickable set into database (after counting parts) # Insert the rebrickable minifigure into database first (parent record)
# This must happen before inserting into bricktracker_minifigures due to FK constraint
self.insert_rebrickable() self.insert_rebrickable()
if not refresh:
# Insert into bricktracker_minifigures database (child record)
self.insert(commit=False)
except Exception as e: except Exception as e:
socket.fail( socket.fail(
message='Error while importing minifigure {figure} from {set}: {error}'.format( # noqa: E501 message='Error while importing minifigure {figure} from {set}: {error}'.format( # noqa: E501
+53
View File
@@ -43,6 +43,29 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
return self return self
# Load all minifigures with problems filter
def all_filtered(self, /, owner_id: str | None = None, problems_filter: str = 'all', theme_id: str = 'all', year: str = 'all') -> Self:
# Save the owner_id parameter
if owner_id is not None:
self.fields.owner_id = owner_id
context = {}
if problems_filter and problems_filter != 'all':
context['problems_filter'] = problems_filter
if theme_id and theme_id != 'all':
context['theme_id'] = theme_id
if year and year != 'all':
context['year'] = year
# 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
self.list(override_query=query, **context)
return self
# Load all minifigures by owner # Load all minifigures by owner
def all_by_owner(self, owner_id: str | None = None, /) -> Self: def all_by_owner(self, owner_id: str | None = None, /) -> Self:
# Save the owner_id parameter # Save the owner_id parameter
@@ -53,10 +76,31 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
return self return self
# Load all minifigures by owner with problems filter
def all_by_owner_filtered(self, /, owner_id: str | None = None, problems_filter: str = 'all', theme_id: str = 'all', year: str = 'all') -> Self:
# Save the owner_id parameter
self.fields.owner_id = owner_id
context = {}
if problems_filter and problems_filter != 'all':
context['problems_filter'] = problems_filter
if theme_id and theme_id != 'all':
context['theme_id'] = theme_id
if year and year != 'all':
context['year'] = year
# Load the minifigures from the database
self.list(override_query=self.all_by_owner_query, **context)
return self
# Load minifigures with pagination support # Load minifigures with pagination support
def all_filtered_paginated( def all_filtered_paginated(
self, self,
owner_id: str | None = None, owner_id: str | None = None,
problems_filter: str = 'all',
theme_id: str = 'all',
year: str = 'all',
search_query: str | None = None, search_query: str | None = None,
page: int = 1, page: int = 1,
per_page: int = 50, per_page: int = 50,
@@ -74,6 +118,15 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
if search_query: if search_query:
filter_context['search_query'] = search_query filter_context['search_query'] = search_query
if problems_filter and problems_filter != 'all':
filter_context['problems_filter'] = problems_filter
if theme_id and theme_id != 'all':
filter_context['theme_id'] = theme_id
if year and year != 'all':
filter_context['year'] = year
# Field mapping for sorting # Field mapping for sorting
field_mapping = { field_mapping = {
'name': '"rebrickable_minifigures"."name"', 'name': '"rebrickable_minifigures"."name"',
+15 -19
View File
@@ -5,33 +5,29 @@ from .exceptions import ErrorException
def parse_set(set: str, /) -> str: def parse_set(set: str, /) -> str:
number, _, version = set.partition('-') number, _, version = set.partition('-')
# Making sure both are integers # Set number can be alphanumeric (e.g., "McDR6US", "10312", "COMCON035")
# Just validate it's not empty
if not number or number.strip() == '':
raise ErrorException('Set number cannot be empty')
# Clean up the number (trim whitespace)
number = number.strip()
# Version defaults to 1 if not provided
if version == '': if version == '':
version = 1 version = '1'
# Version must be a positive integer
try: try:
number = int(number) version_int = int(version)
except Exception:
raise ErrorException('Number "{number}" is not a number'.format(
number=number,
))
try:
version = int(version)
except Exception: except Exception:
raise ErrorException('Version "{version}" is not a number'.format( raise ErrorException('Version "{version}" is not a number'.format(
version=version, version=version,
)) ))
# Make sure both are positive if version_int < 0:
if number < 0: raise ErrorException('Version "{version}" should be positive'.format(
raise ErrorException('Number "{number}" should be positive'.format(
number=number,
))
if version < 0:
raise ErrorException('Version "{version}" should be positive'.format( # noqa: E501
version=version, version=version,
)) ))
return '{number}-{version}'.format(number=number, version=version) return '{number}-{version}'.format(number=number, version=version_int)
+6 -5
View File
@@ -62,13 +62,14 @@ class BrickPart(RebrickablePart):
) )
) )
if not refresh: # Insert the rebrickable part into database first (parent record)
# Insert into database # This must happen before inserting into bricktracker_parts due to FK constraint
self.insert(commit=False)
# Insert the rebrickable set into database
self.insert_rebrickable() self.insert_rebrickable()
if not refresh:
# Insert into bricktracker_parts database (child record)
self.insert(commit=False)
except Exception as e: except Exception as e:
socket.fail( socket.fail(
message='Error while importing part {part} from {kind} {identifier}: {error}'.format( # noqa: E501 message='Error while importing part {part} from {kind} {identifier}: {error}'.format( # noqa: E501
+59 -9
View File
@@ -57,8 +57,8 @@ class BrickPartList(BrickRecordList[BrickPart]):
return self return self
# Load all parts with filters (owner and/or color) # Load all parts with filters (owner, color, theme, year)
def all_filtered(self, owner_id: str | None = None, color_id: str | None = None, /) -> Self: def all_filtered(self, owner_id: str | None = None, color_id: str | None = None, theme_id: str | None = None, year: str | None = None, /) -> Self:
# Save the filter parameters # Save the filter parameters
if owner_id is not None: if owner_id is not None:
self.fields.owner_id = owner_id self.fields.owner_id = owner_id
@@ -73,8 +73,13 @@ class BrickPartList(BrickRecordList[BrickPart]):
# Prepare context for query # Prepare context for query
context = {} context = {}
if current_app.config.get('SKIP_SPARE_PARTS', False): # Hide spare parts from display if configured
if current_app.config.get('HIDE_SPARE_PARTS', False):
context['skip_spare_parts'] = True context['skip_spare_parts'] = True
if theme_id and theme_id != 'all':
context['theme_id'] = theme_id
if year and year != 'all':
context['year'] = year
# Load the parts from the database # Load the parts from the database
self.list(override_query=query, **context) self.list(override_query=query, **context)
@@ -86,6 +91,8 @@ class BrickPartList(BrickRecordList[BrickPart]):
self, self,
owner_id: str | None = None, owner_id: str | None = None,
color_id: str | None = None, color_id: str | None = None,
theme_id: str | None = None,
year: str | None = None,
search_query: str | None = None, search_query: str | None = None,
page: int = 1, page: int = 1,
per_page: int = 50, per_page: int = 50,
@@ -102,9 +109,14 @@ class BrickPartList(BrickRecordList[BrickPart]):
if color_id and color_id != 'all': if color_id and color_id != 'all':
filter_context['color_id'] = color_id filter_context['color_id'] = color_id
if theme_id and theme_id != 'all':
filter_context['theme_id'] = theme_id
if year and year != 'all':
filter_context['year'] = year
if search_query: if search_query:
filter_context['search_query'] = search_query filter_context['search_query'] = search_query
if current_app.config.get('SKIP_SPARE_PARTS', False): # Hide spare parts from display if configured
if current_app.config.get('HIDE_SPARE_PARTS', False):
filter_context['skip_spare_parts'] = True filter_context['skip_spare_parts'] = True
# Field mapping for sorting # Field mapping for sorting
@@ -193,8 +205,13 @@ class BrickPartList(BrickRecordList[BrickPart]):
self.brickset = brickset self.brickset = brickset
self.minifigure = minifigure self.minifigure = minifigure
# Prepare context for hiding spare parts if configured
context = {}
if current_app.config.get('HIDE_SPARE_PARTS', False):
context['skip_spare_parts'] = True
# Load the parts from the database # Load the parts from the database
self.list() self.list(**context)
return self return self
@@ -207,8 +224,13 @@ class BrickPartList(BrickRecordList[BrickPart]):
# Save the minifigure # Save the minifigure
self.minifigure = minifigure self.minifigure = minifigure
# Prepare context for hiding spare parts if configured
context = {}
if current_app.config.get('HIDE_SPARE_PARTS', False):
context['skip_spare_parts'] = True
# Load the parts from the database # Load the parts from the database
self.list(override_query=self.minifigure_query) self.list(override_query=self.minifigure_query, **context)
return self return self
@@ -238,7 +260,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
return self return self
def problem_filtered(self, owner_id: str | None = None, color_id: str | None = None, /) -> Self: def problem_filtered(self, owner_id: str | None = None, color_id: str | None = None, theme_id: str | None = None, year: str | None = None, storage_id: str | None = None, tag_id: str | None = None, /) -> Self:
# Save the filter parameters for client-side filtering # Save the filter parameters for client-side filtering
if owner_id is not None: if owner_id is not None:
self.fields.owner_id = owner_id self.fields.owner_id = owner_id
@@ -251,7 +273,16 @@ class BrickPartList(BrickRecordList[BrickPart]):
context['owner_id'] = owner_id context['owner_id'] = owner_id
if color_id and color_id != 'all': if color_id and color_id != 'all':
context['color_id'] = color_id context['color_id'] = color_id
if current_app.config.get('SKIP_SPARE_PARTS', False): if theme_id and theme_id != 'all':
context['theme_id'] = theme_id
if year and year != 'all':
context['year'] = year
if storage_id and storage_id != 'all':
context['storage_id'] = storage_id
if tag_id and tag_id != 'all':
context['tag_id'] = tag_id
# Hide spare parts from display if configured
if current_app.config.get('HIDE_SPARE_PARTS', False):
context['skip_spare_parts'] = True context['skip_spare_parts'] = True
# Load the problematic parts from the database # Load the problematic parts from the database
@@ -263,6 +294,10 @@ class BrickPartList(BrickRecordList[BrickPart]):
self, self,
owner_id: str | None = None, owner_id: str | None = None,
color_id: str | None = None, color_id: str | None = None,
theme_id: str | None = None,
year: str | None = None,
storage_id: str | None = None,
tag_id: str | None = None,
search_query: str | None = None, search_query: str | None = None,
page: int = 1, page: int = 1,
per_page: int = 50, per_page: int = 50,
@@ -275,9 +310,18 @@ class BrickPartList(BrickRecordList[BrickPart]):
filter_context['owner_id'] = owner_id filter_context['owner_id'] = owner_id
if color_id and color_id != 'all': if color_id and color_id != 'all':
filter_context['color_id'] = color_id filter_context['color_id'] = color_id
if theme_id and theme_id != 'all':
filter_context['theme_id'] = theme_id
if year and year != 'all':
filter_context['year'] = year
if storage_id and storage_id != 'all':
filter_context['storage_id'] = storage_id
if tag_id and tag_id != 'all':
filter_context['tag_id'] = tag_id
if search_query: if search_query:
filter_context['search_query'] = search_query filter_context['search_query'] = search_query
if current_app.config.get('SKIP_SPARE_PARTS', False): # Hide spare parts from display if configured
if current_app.config.get('HIDE_SPARE_PARTS', False):
filter_context['skip_spare_parts'] = True filter_context['skip_spare_parts'] = True
# Field mapping for sorting # Field mapping for sorting
@@ -377,7 +421,13 @@ class BrickPartList(BrickRecordList[BrickPart]):
# Process each part # Process each part
number_of_parts: int = 0 number_of_parts: int = 0
skip_spares = current_app.config.get('SKIP_SPARE_PARTS', False)
for part in inventory: for part in inventory:
# Skip spare parts if configured
if skip_spares and part.fields.spare:
continue
# Count the number of parts for minifigures # Count the number of parts for minifigures
if minifigure is not None: if minifigure is not None:
number_of_parts += part.fields.quantity number_of_parts += part.fields.quantity
+4 -5
View File
@@ -7,7 +7,6 @@ from typing import Any, NamedTuple, TYPE_CHECKING
from urllib.parse import urljoin from urllib.parse import urljoin
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import cloudscraper
from flask import current_app, url_for from flask import current_app, url_for
import requests import requests
@@ -53,12 +52,12 @@ def get_peeron_scan_url(set_number: str, version_number: str):
def create_peeron_scraper(): def create_peeron_scraper():
"""Create a cloudscraper instance configured for Peeron""" """Create a requests session configured for Peeron"""
scraper = cloudscraper.create_scraper() session = requests.Session()
scraper.headers.update({ session.headers.update({
"User-Agent": get_peeron_user_agent() "User-Agent": get_peeron_user_agent()
}) })
return scraper return session
def get_peeron_cache_dir(): def get_peeron_cache_dir():
+9 -5
View File
@@ -4,7 +4,6 @@ import tempfile
import time import time
from typing import Any, TYPE_CHECKING from typing import Any, TYPE_CHECKING
import cloudscraper
from flask import current_app from flask import current_app
from PIL import Image from PIL import Image
@@ -188,10 +187,15 @@ class PeeronPDF(object):
# Get target file path # Get target file path
def _get_target_path(self, /) -> str: def _get_target_path(self, /) -> str:
"""Get the full path where the PDF should be saved""" """Get the full path where the PDF should be saved"""
instructions_folder = os.path.join( folder = current_app.config['INSTRUCTIONS_FOLDER']
current_app.static_folder, # type: ignore
current_app.config['INSTRUCTIONS_FOLDER'] # If folder is absolute, use it directly
) # Otherwise, make it relative to app root (not static folder)
if os.path.isabs(folder):
instructions_folder = folder
else:
instructions_folder = os.path.join(current_app.root_path, folder)
return os.path.join(instructions_folder, self.filename) return os.path.join(instructions_folder, self.filename)
# Create BrickInstructions instance for the generated PDF # Create BrickInstructions instance for the generated PDF
+33 -10
View File
@@ -53,6 +53,7 @@ class RebrickableImage(object):
if os.path.exists(path): if os.path.exists(path):
return return
# Get the URL (this handles nil images via url() method)
url = self.url() url = self.url()
if url is None: if url is None:
return return
@@ -96,9 +97,16 @@ class RebrickableImage(object):
# Return the path depending on the objects provided # Return the path depending on the objects provided
def path(self, /) -> str: def path(self, /) -> str:
folder = self.folder()
# If folder is an absolute path (starts with /), use it directly
# Otherwise, make it relative to app root (current_app.root_path)
if folder.startswith('/'):
base_path = folder
else:
base_path = os.path.join(current_app.root_path, folder)
return os.path.join( return os.path.join(
current_app.static_folder, # type: ignore base_path,
self.folder(),
'{id}.{ext}'.format(id=self.id(), ext=self.extension), '{id}.{ext}'.format(id=self.id(), ext=self.extension),
) )
@@ -116,7 +124,11 @@ class RebrickableImage(object):
else: else:
return self.minifigure.fields.image return self.minifigure.fields.image
return self.set.fields.image # Handle set images - use nil placeholder if image is null
if self.set.fields.image is None:
return current_app.config['REBRICKABLE_IMAGE_NIL']
else:
return self.set.fields.image
# Return the name of the nil image file # Return the name of the nil image file
@staticmethod @staticmethod
@@ -152,10 +164,21 @@ class RebrickableImage(object):
# _, extension = os.path.splitext(self.part_img_url) # _, extension = os.path.splitext(self.part_img_url)
extension = '.jpg' extension = '.jpg'
# Compute the path # Determine which route to use based on folder path
path = os.path.join(folder, '{name}{ext}'.format( # If folder contains 'data' (new structure), use data route
name=name, # Otherwise use static route (legacy - relative paths like 'parts', 'sets')
ext=extension, if 'data' in folder:
)) # Extract the folder type from the folder_name config key
# E.g., 'PARTS_FOLDER' -> 'parts', 'SETS_FOLDER' -> 'sets'
return url_for('static', filename=path) folder_type = folder_name.replace('_FOLDER', '').lower()
filename = '{name}{ext}'.format(name=name, ext=extension)
return url_for('data.serve_data_file', folder=folder_type, filename=filename)
else:
# Legacy: folder is relative to static/ (e.g., 'parts' or 'static/parts')
# Strip 'static/' prefix if present to avoid double /static/ in URL
folder_clean = folder.removeprefix('static/')
path = os.path.join(folder_clean, '{name}{ext}'.format(
name=name,
ext=extension,
))
return url_for('static', filename=path)
+13 -3
View File
@@ -155,9 +155,18 @@ class RebrickableSet(BrickRecord):
# Return a short form of the Rebrickable set # Return a short form of the Rebrickable set
def short(self, /, *, from_download: bool = False) -> dict[str, Any]: def short(self, /, *, from_download: bool = False) -> dict[str, Any]:
# Use nil image URL if set image is null
image_url = self.fields.image
if image_url is None:
# Return path to nil.png from parts folder
image_url = RebrickableImage.static_url(
RebrickableImage.nil_name(),
'PARTS_FOLDER'
)
return { return {
'download': from_download, 'download': from_download,
'image': self.fields.image, 'image': image_url,
'name': self.fields.name, 'name': self.fields.name,
'set': self.fields.set, 'set': self.fields.set,
} }
@@ -196,17 +205,18 @@ class RebrickableSet(BrickRecord):
@staticmethod @staticmethod
def from_rebrickable(data: dict[str, Any], /, **_) -> dict[str, Any]: def from_rebrickable(data: dict[str, Any], /, **_) -> dict[str, Any]:
# Extracting version and number # Extracting version and number
# Note: number can be alphanumeric (e.g., "McDR6US", "COMCON035")
number, _, version = str(data['set_num']).partition('-') number, _, version = str(data['set_num']).partition('-')
return { return {
'set': str(data['set_num']), 'set': str(data['set_num']),
'number': int(number), 'number': str(number), # Keep as string to support alphanumeric sets
'version': int(version), 'version': int(version),
'name': str(data['name']), 'name': str(data['name']),
'year': int(data['year']), 'year': int(data['year']),
'theme_id': int(data['theme_id']), 'theme_id': int(data['theme_id']),
'number_of_parts': int(data['num_parts']), 'number_of_parts': int(data['num_parts']),
'image': str(data['set_img_url']), 'image': str(data['set_img_url']) if data['set_img_url'] is not None else None,
'url': str(data['set_url']), 'url': str(data['set_url']),
'last_modified': str(data['last_modified_dt']), 'last_modified': str(data['last_modified_dt']),
} }
+6 -4
View File
@@ -59,6 +59,10 @@ class BrickSet(RebrickableSet):
# Generate an UUID for self # Generate an UUID for self
self.fields.id = str(uuid4()) self.fields.id = str(uuid4())
# Insert the rebrickable set into database FIRST
# This must happen before inserting bricktracker_sets due to FK constraint
self.insert_rebrickable()
if not refresh: if not refresh:
# Save the storage # Save the storage
storage = BrickSetStorageList.get( storage = BrickSetStorageList.get(
@@ -74,7 +78,8 @@ class BrickSet(RebrickableSet):
) )
self.fields.purchase_location = purchase_location.fields.id self.fields.purchase_location = purchase_location.fields.id
# Insert into database # Insert into database (deferred - will execute at final commit)
# All operations are atomic - if anything fails, nothing is committed
self.insert(commit=False) self.insert(commit=False)
# Save the owners # Save the owners
@@ -91,9 +96,6 @@ class BrickSet(RebrickableSet):
tag = BrickSetTagList.get(id) tag = BrickSetTagList.get(id)
tag.update_set_state(self, state=True) tag.update_set_state(self, state=True)
# Insert the rebrickable set into database
self.insert_rebrickable()
# Load the inventory # Load the inventory
if not BrickPartList.download(socket, self, refresh=refresh): if not BrickPartList.download(socket, self, refresh=refresh):
return False return False
+39 -3
View File
@@ -75,6 +75,9 @@ class BrickSocket(object):
**kwargs, **kwargs,
path=app.config['SOCKET_PATH'], path=app.config['SOCKET_PATH'],
async_mode='gevent', async_mode='gevent',
# Ping/pong settings for mobile network resilience
ping_timeout=30, # Wait 30s for pong response before disconnecting
ping_interval=25, # Send ping every 25s to keep connection alive
) )
# Store the socket in the app config # Store the socket in the app config
@@ -86,9 +89,23 @@ class BrickSocket(object):
self.connected() self.connected()
@self.socket.on(MESSAGES['DISCONNECT'], namespace=self.namespace) @self.socket.on(MESSAGES['DISCONNECT'], namespace=self.namespace)
def disconnect() -> None: def disconnect(reason=None) -> None:
self.disconnected() self.disconnected()
@self.socket.on('connect_error', namespace=self.namespace)
def connect_error(data) -> None:
logger.error(f'Socket CONNECT_ERROR: {data}')
@self.socket.on_error(namespace=self.namespace)
def error_handler(e) -> None:
logger.error(f'Socket ERROR: {e}')
try:
user_agent = request.headers.get('User-Agent', 'unknown')
remote_addr = request.remote_addr
logger.error(f'Socket ERROR details: ip={remote_addr}, ua={user_agent[:80]}...')
except Exception:
pass
@self.socket.on(MESSAGES['DOWNLOAD_INSTRUCTIONS'], namespace=self.namespace) # noqa: E501 @self.socket.on(MESSAGES['DOWNLOAD_INSTRUCTIONS'], namespace=self.namespace) # noqa: E501
@authenticated_socket(self) @authenticated_socket(self)
def download_instructions(data: dict[str, Any], /) -> None: def download_instructions(data: dict[str, Any], /) -> None:
@@ -232,13 +249,32 @@ class BrickSocket(object):
# Socket is connected # Socket is connected
def connected(self, /) -> Tuple[str, int]: def connected(self, /) -> Tuple[str, int]:
logger.debug('Socket: client connected') # Get detailed connection info for debugging
try:
sid = request.sid # type: ignore
transport = request.environ.get('HTTP_UPGRADE', 'polling')
user_agent = request.headers.get('User-Agent', 'unknown')
remote_addr = request.remote_addr
# Check if it's likely a mobile device
is_mobile = any(x in user_agent.lower() for x in ['iphone', 'ipad', 'android', 'mobile'])
logger.info(
f'Socket CONNECTED: sid={sid}, transport={transport}, '
f'ip={remote_addr}, mobile={is_mobile}, ua={user_agent[:80]}...'
)
except Exception as e:
logger.warning(f'Socket connected but failed to get details: {e}')
return '', 301 return '', 301
# Socket is disconnected # Socket is disconnected
def disconnected(self, /) -> None: def disconnected(self, /) -> None:
logger.debug('Socket: client disconnected') try:
sid = request.sid # type: ignore
logger.info(f'Socket DISCONNECTED: sid={sid}')
except Exception as e:
logger.info(f'Socket disconnected (sid unavailable): {e}')
# Emit a message through the socket # Emit a message through the socket
def emit(self, name: str, *arg, all=False) -> None: def emit(self, name: str, *arg, all=False) -> None:
+1 -1
View File
@@ -1,4 +1,4 @@
-- Migration 0019: Performance optimization indexes -- description: Performance optimization indexes
-- High-impact composite index for problem parts aggregation -- High-impact composite index for problem parts aggregation
-- Used in set listings, statistics, and problem reports -- Used in set listings, statistics, and problem reports
+58
View File
@@ -0,0 +1,58 @@
-- description: Change set number column from INTEGER to TEXT to support alphanumeric set numbers
-- Temporarily disable foreign key constraints for this migration
-- This is necessary because we're recreating a table that other tables reference
-- We verify integrity at the end to ensure safety
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
-- Create new table with TEXT number column
CREATE TABLE "rebrickable_sets_new" (
"set" TEXT NOT NULL,
"number" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"year" INTEGER NOT NULL,
"theme_id" INTEGER NOT NULL,
"number_of_parts" INTEGER NOT NULL,
"image" TEXT,
"url" TEXT,
"last_modified" TEXT,
PRIMARY KEY("set")
);
-- Copy all data from old table to new table
-- Cast INTEGER number to TEXT explicitly
INSERT INTO "rebrickable_sets_new"
SELECT
"set",
CAST("number" AS TEXT),
"version",
"name",
"year",
"theme_id",
"number_of_parts",
"image",
"url",
"last_modified"
FROM "rebrickable_sets";
-- Drop old table
DROP TABLE "rebrickable_sets";
-- Rename new table to original name
ALTER TABLE "rebrickable_sets_new" RENAME TO "rebrickable_sets";
-- Recreate the index
CREATE INDEX IF NOT EXISTS idx_rebrickable_sets_number_version
ON rebrickable_sets(number, version);
-- Verify foreign key integrity before committing
-- This ensures we haven't broken any references
PRAGMA foreign_key_check;
COMMIT;
-- Re-enable foreign key constraints
PRAGMA foreign_keys=ON;
@@ -28,6 +28,8 @@ ON "bricktracker_minifigures"."figure" IS NOT DISTINCT FROM "rebrickable_minifig
{% block group %}{% endblock %} {% block group %}{% endblock %}
{% block having %}{% endblock %}
{% if order %} {% if order %}
ORDER BY {{ order }} ORDER BY {{ order }}
{% endif %} {% endif %}
+29 -1
View File
@@ -17,6 +17,14 @@ IFNULL(COUNT("bricktracker_minifigures"."id"), 0) AS "total_sets"
{% endblock %} {% endblock %}
{% block join %} {% block join %}
{% if theme_id or year %}
-- Join with sets for theme/year filtering
INNER JOIN "bricktracker_sets" AS "filter_sets"
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "filter_sets"."id"
INNER JOIN "rebrickable_sets" AS "filter_rs"
ON "filter_sets"."set" IS NOT DISTINCT FROM "filter_rs"."set"
{% endif %}
-- LEFT JOIN + SELECT to avoid messing the total -- LEFT JOIN + SELECT to avoid messing the total
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
@@ -35,8 +43,28 @@ AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figu
{% endblock %} {% endblock %}
{% block where %} {% block where %}
WHERE 1=1
{% if theme_id and theme_id != 'all' %}
AND "filter_rs"."theme_id" = {{ theme_id }}
{% endif %}
{% if year and year != 'all' %}
AND "filter_rs"."year" = {{ year }}
{% endif %}
{% if search_query %} {% if search_query %}
WHERE (LOWER("rebrickable_minifigures"."name") LIKE LOWER('%{{ search_query }}%')) AND (LOWER("rebrickable_minifigures"."name") LIKE LOWER('%{{ search_query }}%'))
{% endif %}
{% endblock %}
{% block having %}
{% if problems_filter %}
HAVING 1=1
{% if problems_filter == 'missing' %}
AND SUM(IFNULL("problem_join"."total_missing", 0)) > 0
{% elif problems_filter == 'damaged' %}
AND SUM(IFNULL("problem_join"."total_damaged", 0)) > 0
{% elif problems_filter == 'both' %}
AND SUM(IFNULL("problem_join"."total_missing", 0)) > 0 AND SUM(IFNULL("problem_join"."total_damaged", 0)) > 0
{% endif %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -29,6 +29,10 @@ COUNT("bricktracker_minifigures"."id") AS "total_sets"
INNER JOIN "bricktracker_sets" INNER JOIN "bricktracker_sets"
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id" ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- Join with rebrickable sets for theme/year filtering
INNER JOIN "rebrickable_sets"
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
-- Left join with set owners (using dynamic columns) -- Left join with set owners (using dynamic columns)
LEFT JOIN "bricktracker_set_owners" LEFT JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id" ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
@@ -64,6 +68,12 @@ AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figu
{% if owner_id and owner_id != 'all' %} {% if owner_id and owner_id != 'all' %}
{% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %} {% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% endif %} {% endif %}
{% if theme_id and theme_id != 'all' %}
{% set _ = conditions.append('"rebrickable_sets"."theme_id" = ' ~ theme_id) %}
{% endif %}
{% if year and year != 'all' %}
{% set _ = conditions.append('"rebrickable_sets"."year" = ' ~ year) %}
{% endif %}
{% if search_query %} {% if search_query %}
{% set _ = conditions.append('(LOWER("rebrickable_minifigures"."name") LIKE LOWER(\'%' ~ search_query ~ '%\'))') %} {% set _ = conditions.append('(LOWER("rebrickable_minifigures"."name") LIKE LOWER(\'%' ~ search_query ~ '%\'))') %}
{% endif %} {% endif %}
@@ -75,4 +85,17 @@ WHERE {{ conditions | join(' AND ') }}
{% block group %} {% block group %}
GROUP BY GROUP BY
"rebrickable_minifigures"."figure" "rebrickable_minifigures"."figure"
{% endblock %}
{% block having %}
{% if problems_filter %}
HAVING 1=1
{% if problems_filter == 'missing' %}
AND SUM(IFNULL("problem_join"."total_missing", 0)) > 0
{% elif problems_filter == 'damaged' %}
AND SUM(IFNULL("problem_join"."total_damaged", 0)) > 0
{% elif problems_filter == 'both' %}
AND SUM(IFNULL("problem_join"."total_missing", 0)) > 0 AND SUM(IFNULL("problem_join"."total_damaged", 0)) > 0
{% endif %}
{% endif %}
{% endblock %} {% endblock %}
@@ -0,0 +1,16 @@
-- Get distinct themes from minifigures' sets
SELECT DISTINCT
"rebrickable_sets"."theme_id",
COUNT(DISTINCT "bricktracker_minifigures"."figure") as "minifigure_count"
FROM "bricktracker_minifigures"
INNER JOIN "bricktracker_sets"
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "rebrickable_sets"
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
{% if owner_id and owner_id != 'all' %}
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 %}
GROUP BY "rebrickable_sets"."theme_id"
ORDER BY "rebrickable_sets"."theme_id" ASC
@@ -0,0 +1,16 @@
-- Get distinct years from minifigures' sets
SELECT DISTINCT
"rebrickable_sets"."year",
COUNT(DISTINCT "bricktracker_minifigures"."figure") as "minifigure_count"
FROM "bricktracker_minifigures"
INNER JOIN "bricktracker_sets"
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "rebrickable_sets"
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
{% if owner_id and owner_id != 'all' %}
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 %}
GROUP BY "rebrickable_sets"."year"
ORDER BY "rebrickable_sets"."year" DESC
+13
View File
@@ -24,6 +24,13 @@ SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_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"
{% if theme_id or year %}
INNER JOIN "bricktracker_sets" AS "filter_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "filter_sets"."id"
INNER JOIN "rebrickable_sets" AS "filter_rs"
ON "filter_sets"."set" IS NOT DISTINCT FROM "filter_rs"."set"
{% endif %}
{% endblock %} {% endblock %}
{% block where %} {% block where %}
@@ -31,6 +38,12 @@ AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures
{% if color_id and color_id != 'all' %} {% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %} {% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %}
{% endif %} {% endif %}
{% if theme_id and theme_id != 'all' %}
{% set _ = conditions.append('"filter_rs"."theme_id" = ' ~ theme_id) %}
{% endif %}
{% if year and year != 'all' %}
{% set _ = conditions.append('"filter_rs"."year" = ' ~ year) %}
{% endif %}
{% if search_query %} {% 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 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) %} {% set _ = conditions.append(search_condition) %}
@@ -10,7 +10,12 @@ SUM("bricktracker_parts"."damaged") AS "total_damaged",
{% endblock %} {% endblock %}
{% block where %} {% block where %}
WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure {% set conditions = [] %}
{% set _ = conditions.append('"bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure') %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %}
{% endif %}
WHERE {{ conditions | join(' AND ') }}
{% endblock %} {% endblock %}
{% block group %} {% block group %}
+22
View File
@@ -45,10 +45,20 @@ SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures"
INNER JOIN "bricktracker_sets" INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id" ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- Join with rebrickable sets for theme/year filtering
INNER JOIN "rebrickable_sets"
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
-- Left join with set owners (using dynamic columns) -- Left join with set owners (using dynamic columns)
LEFT JOIN "bricktracker_set_owners" LEFT JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id" ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
-- Left join with set tags (for tag filtering)
{% if tag_id and tag_id != 'all' %}
LEFT JOIN "bricktracker_set_tags"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id"
{% endif %}
-- Left join with minifigures -- 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"
@@ -65,6 +75,18 @@ AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures
{% if color_id and color_id != 'all' %} {% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %} {% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %}
{% endif %} {% endif %}
{% if theme_id and theme_id != 'all' %}
{% set _ = conditions.append('"rebrickable_sets"."theme_id" = ' ~ theme_id) %}
{% endif %}
{% if year and year != 'all' %}
{% set _ = conditions.append('"rebrickable_sets"."year" = ' ~ year) %}
{% endif %}
{% if storage_id and storage_id != 'all' %}
{% set _ = conditions.append('"bricktracker_sets"."storage" = \'' ~ storage_id ~ '\'') %}
{% endif %}
{% if tag_id and tag_id != 'all' %}
{% set _ = conditions.append('"bricktracker_set_tags"."tag_' ~ tag_id ~ '" = 1') %}
{% endif %}
{% if search_query %} {% 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 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) %} {% set _ = conditions.append(search_condition) %}
+7 -2
View File
@@ -10,6 +10,11 @@ IFNULL("bricktracker_parts"."damaged", 0) AS "total_damaged",
{% endblock %} {% endblock %}
{% block where %} {% block where %}
WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id {% set conditions = [] %}
AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure {% set _ = conditions.append('"bricktracker_parts"."id" IS NOT DISTINCT FROM :id') %}
{% set _ = conditions.append('"bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure') %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %}
{% endif %}
WHERE {{ conditions | join(' AND ') }}
{% endblock %} {% endblock %}
@@ -0,0 +1,21 @@
-- Get distinct storages from problem parts' sets
SELECT DISTINCT
"bricktracker_sets"."storage" AS "storage_id",
"bricktracker_metadata_storages"."name" AS "storage_name",
COUNT(DISTINCT "bricktracker_parts"."part") as "part_count"
FROM "bricktracker_parts"
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
LEFT JOIN "bricktracker_metadata_storages"
ON "bricktracker_sets"."storage" IS NOT DISTINCT FROM "bricktracker_metadata_storages"."id"
{% if owner_id and owner_id != 'all' %}
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)
AND "bricktracker_sets"."storage" IS NOT NULL
{% if owner_id and owner_id != 'all' %}
AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% endif %}
GROUP BY "bricktracker_sets"."storage", "bricktracker_metadata_storages"."name"
ORDER BY "bricktracker_metadata_storages"."name" ASC
@@ -0,0 +1,7 @@
-- Get list of all tags (simplified - filtering happens at application level)
-- Tags use dynamic columns in bricktracker_set_tags, making direct SQL filtering complex
SELECT
"bricktracker_metadata_tags"."id" AS "tag_id",
"bricktracker_metadata_tags"."name" AS "tag_name"
FROM "bricktracker_metadata_tags"
ORDER BY "bricktracker_metadata_tags"."name" ASC
+16
View File
@@ -0,0 +1,16 @@
-- Get distinct themes from parts' sets
SELECT DISTINCT
"rebrickable_sets"."theme_id",
COUNT(DISTINCT "bricktracker_parts"."part") as "part_count"
FROM "bricktracker_parts"
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "rebrickable_sets"
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
{% if owner_id and owner_id != 'all' %}
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 %}
GROUP BY "rebrickable_sets"."theme_id"
ORDER BY "rebrickable_sets"."theme_id" ASC
@@ -0,0 +1,19 @@
-- Get distinct themes from problem parts' sets
SELECT DISTINCT
"rebrickable_sets"."theme_id",
COUNT(DISTINCT "bricktracker_parts"."part") as "part_count"
FROM "bricktracker_parts"
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "rebrickable_sets"
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
{% if owner_id and owner_id != 'all' %}
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 %}
GROUP BY "rebrickable_sets"."theme_id"
ORDER BY "rebrickable_sets"."theme_id" ASC
+16
View File
@@ -0,0 +1,16 @@
-- Get distinct years from parts' sets
SELECT DISTINCT
"rebrickable_sets"."year",
COUNT(DISTINCT "bricktracker_parts"."part") as "part_count"
FROM "bricktracker_parts"
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "rebrickable_sets"
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
{% if owner_id and owner_id != 'all' %}
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 %}
GROUP BY "rebrickable_sets"."year"
ORDER BY "rebrickable_sets"."year" DESC
@@ -0,0 +1,19 @@
-- Get distinct years from problem parts' sets
SELECT DISTINCT
"rebrickable_sets"."year",
COUNT(DISTINCT "bricktracker_parts"."part") as "part_count"
FROM "bricktracker_parts"
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "rebrickable_sets"
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
{% if owner_id and owner_id != 'all' %}
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 %}
GROUP BY "rebrickable_sets"."year"
ORDER BY "rebrickable_sets"."year" DESC
+15 -13
View File
@@ -3,22 +3,24 @@
BEGIN TRANSACTION; BEGIN TRANSACTION;
DELETE FROM "bricktracker_sets" -- Delete child records first (those with foreign keys referencing bricktracker_sets)
WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM '{{ id }}'; DELETE FROM "bricktracker_parts"
WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM '{{ id }}';
DELETE FROM "bricktracker_set_owners"
WHERE "bricktracker_set_owners"."id" IS NOT DISTINCT FROM '{{ id }}';
DELETE FROM "bricktracker_set_statuses"
WHERE "bricktracker_set_statuses"."id" IS NOT DISTINCT FROM '{{ id }}';
DELETE FROM "bricktracker_set_tags"
WHERE "bricktracker_set_tags"."id" IS NOT DISTINCT FROM '{{ id }}';
DELETE FROM "bricktracker_minifigures" DELETE FROM "bricktracker_minifigures"
WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM '{{ id }}'; WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM '{{ id }}';
DELETE FROM "bricktracker_parts" DELETE FROM "bricktracker_set_tags"
WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM '{{ id }}'; WHERE "bricktracker_set_tags"."id" IS NOT DISTINCT FROM '{{ id }}';
DELETE FROM "bricktracker_set_statuses"
WHERE "bricktracker_set_statuses"."id" IS NOT DISTINCT FROM '{{ id }}';
DELETE FROM "bricktracker_set_owners"
WHERE "bricktracker_set_owners"."id" IS NOT DISTINCT FROM '{{ id }}';
-- Delete the parent record last
DELETE FROM "bricktracker_sets"
WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM '{{ id }}';
COMMIT; COMMIT;
+23 -23
View File
@@ -56,14 +56,14 @@ class BrickStatistics:
"""Get financial summary from overview statistics""" """Get financial summary from overview statistics"""
overview = self.get_overview() overview = self.get_overview()
return { return {
'total_cost': overview.get('total_cost', 0), 'total_cost': overview.get('total_cost') or 0,
'average_cost': overview.get('average_cost', 0), 'average_cost': overview.get('average_cost') or 0,
'minimum_cost': overview.get('minimum_cost', 0), 'minimum_cost': overview.get('minimum_cost') or 0,
'maximum_cost': overview.get('maximum_cost', 0), 'maximum_cost': overview.get('maximum_cost') or 0,
'sets_with_price': overview.get('sets_with_price', 0), 'sets_with_price': overview.get('sets_with_price') or 0,
'total_sets': overview.get('total_sets', 0), 'total_sets': overview.get('total_sets') or 0,
'percentage_with_price': round( 'percentage_with_price': round(
(overview.get('sets_with_price', 0) / max(overview.get('total_sets', 1), 1)) * 100, 1 ((overview.get('sets_with_price') or 0) / max((overview.get('total_sets') or 0), 1)) * 100, 1
) )
} }
@@ -71,16 +71,16 @@ class BrickStatistics:
"""Get collection summary from overview statistics""" """Get collection summary from overview statistics"""
overview = self.get_overview() overview = self.get_overview()
return { return {
'total_sets': overview.get('total_sets', 0), 'total_sets': overview.get('total_sets') or 0,
'unique_sets': overview.get('unique_sets', 0), 'unique_sets': overview.get('unique_sets') or 0,
'total_parts_count': overview.get('total_parts_count', 0), 'total_parts_count': overview.get('total_parts_count') or 0,
'unique_parts': overview.get('unique_parts', 0), 'unique_parts': overview.get('unique_parts') or 0,
'total_minifigures_count': overview.get('total_minifigures_count', 0), 'total_minifigures_count': overview.get('total_minifigures_count') or 0,
'unique_minifigures': overview.get('unique_minifigures', 0), 'unique_minifigures': overview.get('unique_minifigures') or 0,
'total_missing_parts': overview.get('total_missing_parts', 0), 'total_missing_parts': overview.get('total_missing_parts') or 0,
'total_damaged_parts': overview.get('total_damaged_parts', 0), 'total_damaged_parts': overview.get('total_damaged_parts') or 0,
'storage_locations_used': overview.get('storage_locations_used', 0), 'storage_locations_used': overview.get('storage_locations_used') or 0,
'purchase_locations_used': overview.get('purchase_locations_used', 0) 'purchase_locations_used': overview.get('purchase_locations_used') or 0
} }
def get_sets_by_year_statistics(self) -> list[dict[str, Any]]: def get_sets_by_year_statistics(self) -> list[dict[str, Any]]:
@@ -106,9 +106,9 @@ class BrickStatistics:
peak_collection_year = None peak_collection_year = None
max_sets_in_year = 0 max_sets_in_year = 0
if sets_by_year: if sets_by_year:
peak_year_data = max(sets_by_year, key=lambda x: x['total_sets']) peak_year_data = max(sets_by_year, key=lambda x: x.get('total_sets') or 0)
peak_collection_year = peak_year_data['year'] peak_collection_year = peak_year_data.get('year')
max_sets_in_year = peak_year_data['total_sets'] max_sets_in_year = peak_year_data.get('total_sets') or 0
# Find peak spending year # Find peak spending year
peak_spending_year = None peak_spending_year = None
@@ -116,9 +116,9 @@ class BrickStatistics:
if purchases_by_year: if purchases_by_year:
spending_years = [y for y in purchases_by_year if y.get('total_spent')] spending_years = [y for y in purchases_by_year if y.get('total_spent')]
if spending_years: if spending_years:
peak_spending_data = max(spending_years, key=lambda x: x['total_spent'] or 0) peak_spending_data = max(spending_years, key=lambda x: x.get('total_spent') or 0)
peak_spending_year = peak_spending_data['purchase_year'] peak_spending_year = peak_spending_data.get('purchase_year')
max_spending = peak_spending_data['total_spent'] max_spending = peak_spending_data.get('total_spent') or 0
return { return {
'years_represented': years_represented, 'years_represented': years_represented,
+2 -2
View File
@@ -1,4 +1,4 @@
from typing import Final from typing import Final
__version__: Final[str] = '1.3.0' __version__: Final[str] = '1.3.1'
__database_version__: Final[int] = 19 __database_version__: Final[int] = 20
+56 -6
View File
@@ -1,4 +1,5 @@
import logging import logging
import os
from flask import Blueprint, request, render_template, current_app, jsonify from flask import Blueprint, request, render_template, current_app, jsonify
from flask_login import login_required from flask_login import login_required
@@ -31,22 +32,43 @@ admin_page = Blueprint('admin', __name__, url_prefix='/admin')
def get_env_values(): def get_env_values():
"""Get current environment values, using defaults from config when not set""" """Get current environment values, using defaults from config when not set"""
import os import json
from pathlib import Path from pathlib import Path
env_values = {} env_values = {}
config_defaults = {} config_defaults = {}
env_explicit_values = {} # Track which values are explicitly set env_explicit_values = {} # Track which values are explicitly set
env_locked_values = {} # Track which values are set via Docker environment (locked)
# Get list of variables that were set via Docker environment (before .env was loaded)
# This was stored by load_env_file() in app.py
docker_env_vars = set()
if '_BK_DOCKER_ENV_VARS' in os.environ:
try:
docker_env_vars = set(json.loads(os.environ['_BK_DOCKER_ENV_VARS']))
except (json.JSONDecodeError, TypeError):
pass
# Read .env file if it exists (check both locations)
env_file = None
if Path('data/.env').exists():
env_file = Path('data/.env')
elif Path('.env').exists():
env_file = Path('.env')
# Read .env file if it exists
env_file = Path('.env')
env_from_file = {} env_from_file = {}
if env_file.exists(): if env_file:
with open(env_file, 'r', encoding='utf-8') as f: with open(env_file, 'r', encoding='utf-8') as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
if line and not line.startswith('#') and '=' in line: if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1) key, value = line.split('=', 1)
# Strip quotes from value when reading
value = value.strip()
if value.startswith('"') and value.endswith('"'):
value = value[1:-1]
elif value.startswith("'") and value.endswith("'"):
value = value[1:-1]
env_from_file[key] = value env_from_file[key] = value
# Process each config item # Process each config item
@@ -64,6 +86,11 @@ def get_env_values():
# For int/other types, keep the original default value # For int/other types, keep the original default value
config_defaults[env_name] = default_value config_defaults[env_name] = default_value
# Check if value is set via Docker environment directive
# A variable is "locked" if it was in os.environ BEFORE our app loaded .env
is_locked = env_name in docker_env_vars
env_locked_values[env_name] = is_locked
# Check if value is explicitly set in .env file or environment # Check if value is explicitly set in .env file or environment
is_explicitly_set = env_name in env_from_file or env_name in os.environ is_explicitly_set = env_name in env_from_file or env_name in os.environ
env_explicit_values[env_name] = is_explicitly_set env_explicit_values[env_name] = is_explicitly_set
@@ -88,7 +115,7 @@ def get_env_values():
env_values[env_name] = value env_values[env_name] = value
return env_values, config_defaults, env_explicit_values return env_values, config_defaults, env_explicit_values, env_locked_values
# Admin # Admin
@@ -202,13 +229,36 @@ def admin() -> str:
open_tag open_tag
) )
env_values, config_defaults, env_explicit_values = get_env_values() env_values, config_defaults, env_explicit_values, env_locked_values = get_env_values()
# Check .env file location and set warnings
env_file_location = None
env_file_warning = False
env_file_missing = False
if os.path.exists('data/.env'):
env_file_location = 'data/.env'
env_file_warning = False
env_file_missing = False
elif os.path.exists('.env'):
env_file_location = '.env'
env_file_warning = True # Warn: changes won't persist without volume mount
env_file_missing = False
else:
env_file_location = None
env_file_warning = False
env_file_missing = True # Warn: no .env file found
return render_template( return render_template(
'admin.html', 'admin.html',
configuration=BrickConfigurationList.list(), configuration=BrickConfigurationList.list(),
env_values=env_values, env_values=env_values,
config_defaults=config_defaults, config_defaults=config_defaults,
env_explicit_values=env_explicit_values, env_explicit_values=env_explicit_values,
env_locked_values=env_locked_values,
env_file_location=env_file_location,
env_file_warning=env_file_warning,
env_file_missing=env_file_missing,
database_counters=database_counters, database_counters=database_counters,
database_error=request.args.get('database_error'), database_error=request.args.get('database_error'),
database_exception=database_exception, database_exception=database_exception,
+60
View File
@@ -0,0 +1,60 @@
import os
import logging
from flask import Blueprint, current_app, send_from_directory, abort
from werkzeug.utils import secure_filename
logger = logging.getLogger(__name__)
data_page = Blueprint(
'data',
__name__,
url_prefix='/data'
)
@data_page.route('/<path:folder>/<filename>')
def serve_data_file(folder: str, filename: str):
"""
Serve files from the data folder (images, PDFs, etc.)
This replaces serving these files from static/ folder.
Security:
- Only allows serving files from configured data folders
- Uses secure_filename to prevent path traversal
- Returns 404 if file doesn't exist or folder not allowed
"""
# Secure the filename to prevent path traversal attacks
safe_filename = secure_filename(filename)
# Get the configured data folders
allowed_folders = {
'sets': current_app.config.get('SETS_FOLDER', './data/sets'),
'parts': current_app.config.get('PARTS_FOLDER', './data/parts'),
'minifigures': current_app.config.get('MINIFIGURES_FOLDER', './data/minifigures'),
'instructions': current_app.config.get('INSTRUCTIONS_FOLDER', './data/instructions'),
}
# Check if the requested folder is allowed
if folder not in allowed_folders:
logger.warning(f"Attempt to access unauthorized folder: {folder}")
abort(404)
# Get the actual folder path
folder_path = allowed_folders[folder]
# If folder_path is relative (not absolute), make it relative to app root
if not os.path.isabs(folder_path):
folder_path = os.path.join(current_app.root_path, folder_path)
# Check if file exists
file_path = os.path.join(folder_path, safe_filename)
if not os.path.isfile(file_path):
logger.warning(f"File not found: {file_path} (configured folder: {folder_path})")
abort(404)
# Verify the resolved path is still within the allowed folder (security check)
if not os.path.abspath(file_path).startswith(os.path.abspath(folder_path)):
logger.warning(f"Path traversal attempt detected: {filename}")
abort(404)
return send_from_directory(folder_path, safe_filename)
+33 -4
View File
@@ -16,6 +16,9 @@ minifigure_page = Blueprint('minifigure', __name__, url_prefix='/minifigures')
def list() -> str: def list() -> str:
# Get filter parameters from request # Get filter parameters from request
owner_id = request.args.get('owner', 'all') owner_id = request.args.get('owner', 'all')
problems_filter = request.args.get('problems', 'all')
theme_id = request.args.get('theme', 'all')
year = request.args.get('year', 'all')
search_query, sort_field, sort_order, page = get_request_params() search_query, sort_field, sort_order, page = get_request_params()
# Get pagination configuration # Get pagination configuration
@@ -26,6 +29,9 @@ def list() -> str:
# PAGINATION MODE - Server-side pagination with search # PAGINATION MODE - Server-side pagination with search
minifigures, total_count = BrickMinifigureList().all_filtered_paginated( minifigures, total_count = BrickMinifigureList().all_filtered_paginated(
owner_id=owner_id, owner_id=owner_id,
problems_filter=problems_filter,
theme_id=theme_id,
year=year,
search_query=search_query, search_query=search_query,
page=page, page=page,
per_page=per_page, per_page=per_page,
@@ -36,20 +42,43 @@ def list() -> str:
pagination_context = build_pagination_context(page, per_page, total_count, is_mobile) pagination_context = build_pagination_context(page, per_page, total_count, is_mobile)
else: else:
# ORIGINAL MODE - Single page with all data for client-side search # 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_filtered(owner_id=owner_id, problems_filter=problems_filter, theme_id=theme_id, year=year)
minifigures = BrickMinifigureList().all()
else:
minifigures = BrickMinifigureList().all_by_owner(owner_id)
pagination_context = None pagination_context = None
# Get list of owners for filter dropdown # Get list of owners for filter dropdown
owners = BrickSetOwnerList.list() owners = BrickSetOwnerList.list()
# Prepare context for dependent filters
filter_context = {}
if owner_id != 'all' and owner_id:
filter_context['owner_id'] = owner_id
# Get list of themes for filter dropdown
from ..theme_list import BrickThemeList
from ..sql import BrickSQL
theme_list = BrickThemeList()
themes_data = BrickSQL().fetchall('minifigure/themes/list', **filter_context)
themes = []
for theme_data in themes_data:
theme = theme_list.get(theme_data['theme_id'])
themes.append({
'theme_id': theme_data['theme_id'],
'theme_name': theme.name if theme else f"Theme {theme_data['theme_id']}"
})
# Get list of years for filter dropdown
years = BrickSQL().fetchall('minifigure/years/list', **filter_context)
template_context = { template_context = {
'table_collection': minifigures, 'table_collection': minifigures,
'owners': owners, 'owners': owners,
'selected_owner': owner_id, 'selected_owner': owner_id,
'selected_problems': problems_filter,
'themes': themes,
'selected_theme': theme_id,
'years': years,
'selected_year': year,
'search_query': search_query, 'search_query': search_query,
'use_pagination': use_pagination, 'use_pagination': use_pagination,
'current_sort': sort_field, 'current_sort': sort_field,
+74 -15
View File
@@ -19,6 +19,8 @@ def list() -> str:
# Get filter parameters from request # Get filter parameters from request
owner_id = request.args.get('owner', 'all') owner_id = request.args.get('owner', 'all')
color_id = request.args.get('color', 'all') color_id = request.args.get('color', 'all')
theme_id = request.args.get('theme', 'all')
year = request.args.get('year', 'all')
search_query, sort_field, sort_order, page = get_request_params() search_query, sort_field, sort_order, page = get_request_params()
# Get pagination configuration # Get pagination configuration
@@ -30,6 +32,8 @@ def list() -> str:
parts, total_count = BrickPartList().all_filtered_paginated( parts, total_count = BrickPartList().all_filtered_paginated(
owner_id=owner_id, owner_id=owner_id,
color_id=color_id, color_id=color_id,
theme_id=theme_id,
year=year,
search_query=search_query, search_query=search_query,
page=page, page=page,
per_page=per_page, per_page=per_page,
@@ -40,19 +44,34 @@ def list() -> str:
pagination_context = build_pagination_context(page, per_page, total_count, is_mobile) pagination_context = build_pagination_context(page, per_page, total_count, is_mobile)
else: else:
# ORIGINAL MODE - Single page with all data for client-side search # ORIGINAL MODE - Single page with all data for client-side search
parts = BrickPartList().all_filtered(owner_id, color_id) parts = BrickPartList().all_filtered(owner_id, color_id, theme_id, year)
pagination_context = None pagination_context = None
# Get list of owners for filter dropdown # Get list of owners for filter dropdown
owners = BrickSetOwnerList.list() owners = BrickSetOwnerList.list()
# Get list of colors for filter dropdown # Prepare context for dependent filters
# Prepare context for color query (filter by owner if selected) filter_context = {}
color_context = {}
if owner_id != 'all' and owner_id: if owner_id != 'all' and owner_id:
color_context['owner_id'] = owner_id filter_context['owner_id'] = owner_id
colors = BrickSQL().fetchall('part/colors/list', **color_context) # Get list of colors for filter dropdown
colors = BrickSQL().fetchall('part/colors/list', **filter_context)
# Get list of themes for filter dropdown
from ..theme_list import BrickThemeList
theme_list = BrickThemeList()
themes_data = BrickSQL().fetchall('part/themes/list', **filter_context)
themes = []
for theme_data in themes_data:
theme = theme_list.get(theme_data['theme_id'])
themes.append({
'theme_id': theme_data['theme_id'],
'theme_name': theme.name if theme else f"Theme {theme_data['theme_id']}"
})
# Get list of years for filter dropdown
years = BrickSQL().fetchall('part/years/list', **filter_context)
template_context = { template_context = {
'table_collection': parts, 'table_collection': parts,
@@ -60,6 +79,10 @@ def list() -> str:
'selected_owner': owner_id, 'selected_owner': owner_id,
'colors': colors, 'colors': colors,
'selected_color': color_id, 'selected_color': color_id,
'themes': themes,
'selected_theme': theme_id,
'years': years,
'selected_year': year,
'search_query': search_query, 'search_query': search_query,
'use_pagination': use_pagination, 'use_pagination': use_pagination,
'current_sort': sort_field, 'current_sort': sort_field,
@@ -80,6 +103,10 @@ def problem() -> str:
# Get filter parameters from request # Get filter parameters from request
owner_id = request.args.get('owner', 'all') owner_id = request.args.get('owner', 'all')
color_id = request.args.get('color', 'all') color_id = request.args.get('color', 'all')
theme_id = request.args.get('theme', 'all')
year = request.args.get('year', 'all')
storage_id = request.args.get('storage', 'all')
tag_id = request.args.get('tag', 'all')
search_query, sort_field, sort_order, page = get_request_params() search_query, sort_field, sort_order, page = get_request_params()
# Get pagination configuration # Get pagination configuration
@@ -91,6 +118,10 @@ def problem() -> str:
parts, total_count = BrickPartList().problem_paginated( parts, total_count = BrickPartList().problem_paginated(
owner_id=owner_id, owner_id=owner_id,
color_id=color_id, color_id=color_id,
theme_id=theme_id,
year=year,
storage_id=storage_id,
tag_id=tag_id,
search_query=search_query, search_query=search_query,
page=page, page=page,
per_page=per_page, per_page=per_page,
@@ -101,20 +132,40 @@ def problem() -> str:
pagination_context = build_pagination_context(page, per_page, total_count, is_mobile) pagination_context = build_pagination_context(page, per_page, total_count, is_mobile)
else: else:
# ORIGINAL MODE - Single page with all data for client-side search # ORIGINAL MODE - Single page with all data for client-side search
parts = BrickPartList().problem_filtered(owner_id, color_id) parts = BrickPartList().problem_filtered(owner_id, color_id, theme_id, year, storage_id, tag_id)
pagination_context = None pagination_context = None
# Get list of owners for filter dropdown # Get list of owners for filter dropdown
owners = BrickSetOwnerList.list() owners = BrickSetOwnerList.list()
# Get list of colors for filter dropdown # Prepare context for dependent filters
# Prepare context for color query (filter by owner if selected) filter_context = {}
color_context = {} if owner_id != 'all' and owner_id:
if owner_id != 'all': filter_context['owner_id'] = owner_id
color_context['owner_id'] = owner_id
# Get colors from problem parts (following same pattern as parts page) # Get list of colors for filter dropdown (problem parts only)
colors = BrickSQL().fetchall('part/colors/list_problem', **color_context) colors = BrickSQL().fetchall('part/colors/list_problem', **filter_context)
# Get list of themes for filter dropdown (problem parts only)
from ..theme_list import BrickThemeList
theme_list = BrickThemeList()
themes_data = BrickSQL().fetchall('part/themes/list_problem', **filter_context)
themes = []
for theme_data in themes_data:
theme = theme_list.get(theme_data['theme_id'])
themes.append({
'theme_id': theme_data['theme_id'],
'theme_name': theme.name if theme else f"Theme {theme_data['theme_id']}"
})
# Get list of years for filter dropdown (problem parts only)
years = BrickSQL().fetchall('part/years/list_problem', **filter_context)
# Get list of storages for filter dropdown (problem parts only)
storages = BrickSQL().fetchall('part/storages/list_problem', **filter_context)
# Get list of tags for filter dropdown (problem parts only)
tags = BrickSQL().fetchall('part/tags/list_problem', **filter_context)
return render_template( return render_template(
'problem.html', 'problem.html',
@@ -127,7 +178,15 @@ def problem() -> str:
owners=owners, owners=owners,
colors=colors, colors=colors,
selected_owner=owner_id, selected_owner=owner_id,
selected_color=color_id selected_color=color_id,
themes=themes,
selected_theme=theme_id,
years=years,
selected_year=year,
storages=storages,
selected_storage=storage_id,
tags=tags,
selected_tag=tag_id
) )
+8 -3
View File
@@ -6,6 +6,7 @@ Provides statistics and analytics pages
import logging import logging
from flask import Blueprint, render_template, request, url_for, redirect, current_app from flask import Blueprint, render_template, request, url_for, redirect, current_app
from flask_login import login_required
from werkzeug.wrappers.response import Response from werkzeug.wrappers.response import Response
from .exceptions import exception_handler from .exceptions import exception_handler
@@ -17,6 +18,7 @@ statistics_page = Blueprint('statistics', __name__, url_prefix='/statistics')
@statistics_page.route('/', methods=['GET']) @statistics_page.route('/', methods=['GET'])
@login_required
@exception_handler(__file__) @exception_handler(__file__)
def overview() -> str: def overview() -> str:
"""Statistics overview page with metrics""" """Statistics overview page with metrics"""
@@ -89,6 +91,7 @@ def redirect_to_filtered_sets(filter_type: str, filter_value: str) -> Response:
@statistics_page.route('/themes', methods=['GET']) @statistics_page.route('/themes', methods=['GET'])
@login_required
@exception_handler(__file__) @exception_handler(__file__)
def themes() -> str: def themes() -> str:
"""Detailed theme statistics page""" """Detailed theme statistics page"""
@@ -104,6 +107,7 @@ def themes() -> str:
@statistics_page.route('/storage', methods=['GET']) @statistics_page.route('/storage', methods=['GET'])
@login_required
@exception_handler(__file__) @exception_handler(__file__)
def storage() -> str: def storage() -> str:
"""Detailed storage statistics page""" """Detailed storage statistics page"""
@@ -119,6 +123,7 @@ def storage() -> str:
@statistics_page.route('/purchase-locations', methods=['GET']) @statistics_page.route('/purchase-locations', methods=['GET'])
@login_required
@exception_handler(__file__) @exception_handler(__file__)
def purchase_locations() -> str: def purchase_locations() -> str:
"""Detailed purchase location statistics page""" """Detailed purchase location statistics page"""
@@ -172,10 +177,10 @@ def prepare_chart_data(sets_by_year_stats, purchases_by_year_stats):
# Get sets and parts data from sets_by_year # Get sets and parts data from sets_by_year
year_data = sets_by_year_lookup.get(year) year_data = sets_by_year_lookup.get(year)
if year_data: if year_data:
sets_data.append(year_data.get('total_sets', 0)) sets_data.append(year_data.get('total_sets') or 0)
parts_data.append(year_data.get('total_parts', 0)) parts_data.append(year_data.get('total_parts') or 0)
# Use actual minifigure count from the database # Use actual minifigure count from the database
minifigs_data.append(year_data.get('total_minifigures', 0)) minifigs_data.append(year_data.get('total_minifigures') or 0)
else: else:
sets_data.append(0) sets_data.append(0)
parts_data.append(0) parts_data.append(0)
+7 -13
View File
@@ -2,21 +2,15 @@ services:
bricktracker: bricktracker:
container_name: BrickTracker container_name: BrickTracker
restart: unless-stopped restart: unless-stopped
# image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:dev
build: . build: .
ports: ports:
- "3334:3333" - "3334:3333"
volumes: volumes:
- ./local:/local - ./local:/app/data # Changed from ./local to ./data for consistency
- ./local/instructions:/app/static/instructions/
- ./local/minifigures:/app/static/minifigures/
- ./local/parts:/app/static/parts/
- ./local/sets:/app/static/sets/
environment: environment:
BK_DEBUG: true - BK_DEBUG=true
BK_DATABASE_PATH: /local/app.db # For local development, place .env in data/ folder
BK_INSTRUCTIONS_FOLDER: instructions # The app automatically detects and uses data/.env (no env_file needed)
BK_MINIFIGURES_FOLDER: minifigures # Uncomment below only if you keep .env in root for backward compatibility
BK_PARTS_FOLDER: parts # env_file: .env
BK_RETIRED_SETS_PATH: /local/retired_sets.csv
BK_SETS_FOLDER: sets
BK_THEMES_PATH: /local/themes.csv
+7 -19
View File
@@ -2,26 +2,14 @@ services:
bricktracker: bricktracker:
container_name: BrickTracker container_name: BrickTracker
restart: unless-stopped restart: unless-stopped
image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:1.2.2 image: gitea.baerentsen.space/frederikbaerentsen/bricktracker:latest
ports: ports:
- "3333:3333" - "3333:3333"
volumes: volumes:
- data:/data/ - ./data:/app/data/
- instructions:/app/static/instructions/ # Configuration can be done via .env file
- minifigures:/app/static/minifigures/ # For new installations, place .env in data/ folder for persistence
- parts:/app/static/parts/ # For backward compatibility, .env in root is also supported
- sets:/app/static/sets/ # The app automatically detects and uses data/.env (priority) or .env (fallback)
# Or define those in your .env file # env_file: ".env" # Optional: Only needed if keeping .env in root for backward compatibility
environment:
BK_DATABASE_PATH: /data/app.db
BK_MINIFIGURES_FOLDER: minifigures
BK_RETIRED_SETS_PATH: /data/retired_sets.csv
BK_THEMES_PATH: /data/themes.csv
env_file: ".env"
volumes:
data:
instructions:
minifigures:
parts:
sets:
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

+353
View File
@@ -0,0 +1,353 @@
# Data Folder Migration Guide (Docker Compose)
## Overview
Starting with version 1.3, BrickTracker consolidates all user data into a single `data/` folder for easier backup, persistence, and volume mapping.
**This guide assumes you are running BrickTracker using Docker Compose with bind mounts.**
> **Note:** If you're using Docker named volumes instead of bind mounts, you'll need to manually copy data between volumes. The commands below are specific to bind mount setups.
**Backup your data before to making any changes!**
## What Changed?
### New Default Structure (v1.3+)
**All relative paths are resolved relative to `/app` inside the container.** Previously all paths were relative to `/app/static`.
For example: `data/app.db``/app/data/app.db`
```
Container (/app/):
├── data/ # NEW: Single volume mount for all user data
│ ├── .env # Configuration (recommended location)
│ ├── app.db # Database
│ ├── retired_sets.csv # Downloaded CSV files
│ ├── themes.csv
│ ├── sets/ # Set images
│ ├── parts/ # Part images
│ ├── minifigures/ # Minifigure images
│ └── instructions/ # PDF instructions
└── static/ # App assets
├── brick.png
├── styles.css
└── scripts/
```
**Docker Compose volume:** Single mount `./data:/app/data/`
### Previous Structure (v1.2 and earlier)
```
Container (/app/):
├── app.db # Mounted from ./data/ on host
├── retired_sets.csv # Mounted from ./data/ on host
├── themes.csv
└── static/
├── instructions/ # Separate bind mount
├── minifigs/ # Separate bind mount
├── parts/ # Separate bind mount
├── sets/ # Separate bind mount
```
**Docker Compose bind mounts:** 5 separate mounts
```yaml
volumes:
- ./data:/app/
- ./instructions:/app/static/instructions/
- ./minifigs:/app/static/minifigs/
- ./parts:/app/static/parts/
- ./sets:/app/static/sets/
```
## Migration Options
> **Warning**
> Backup your data before making any changes!
### Option 1: Migrate to New Data Folder Structure (Recommended)
This is the recommended approach for cleaner backups and simpler bind mount management.
1. **Stop the container:**
```bash
docker compose down
```
2. **Create new consolidated data directory on host:**
```bash
mkdir -p ./bricktracker-data/{sets,parts,minifigures,instructions}
```
3. **Move data from old bind mount locations to new structure:**
Assuming your old `compose.yaml` had:
- `./data:/app/` (contains app.db, retired_sets.csv, themes.csv)
- `./instructions:/app/static/instructions/`
- `./minifigs:/app/static/minifigs/`
- `./parts:/app/static/parts/`
- `./sets:/app/static/sets/`
> Default location for minifigures changed from `minifigs` to `minifigures`
Run:
```bash
# Move configuration file (optional but recommended)
mv .env ./bricktracker-data/.env
# Move database and CSV files
mv ./data/app.db ./bricktracker-data/
mv ./data/retired_sets.csv ./bricktracker-data/
mv ./data/themes.csv ./bricktracker-data/
# Move image and instruction folders
mv ./instructions/* ./bricktracker-data/instructions/
mv ./minifigs/* ./bricktracker-data/minifigures/
mv ./parts/* ./bricktracker-data/parts/
mv ./sets/* ./bricktracker-data/sets/
```
4. **Update `compose.yaml` to use single bind mount:**
```yaml
services:
bricktracker:
volumes:
- ./bricktracker-data:/app/data/
# Remove old volume mounts and env_file (if .env was moved to data/)
```
5. **Remove old path overrides from `.env` (if present):**
Delete any lines starting with:
- `BK_DATABASE_PATH=`
- `BK_INSTRUCTIONS_FOLDER=`
- `BK_MINIFIGURES_FOLDER=`
- `BK_PARTS_FOLDER=`
- `BK_SETS_FOLDER=`
- `BK_RETIRED_SETS_PATH=`
- `BK_THEMES_PATH=`
6. **Start the container:**
```bash
docker compose up -d
```
7. **Verify everything works:**
```bash
docker compose logs -f bricktracker
# Check the web interface to ensure images/data are loading
```
8. **Clean up old directories (after verification):**
```bash
rm -r ./data ./instructions ./minifigs ./parts ./sets
```
### Option 2: Keep Current Setup (No Data Migration)
If you want to keep your current volume structure without moving any files:
1. **Add these environment variables to your `.env` file:**
```env
# Keep database and CSV files in /data volume (old location)
BK_DATABASE_PATH=app.db
BK_RETIRED_SETS_PATH=retired_sets.csv
BK_THEMES_PATH=themes.csv
# Keep image/instruction folders in static/ (old location)
BK_INSTRUCTIONS_FOLDER=static/instructions
BK_MINIFIGURES_FOLDER=static/minifigures
BK_PARTS_FOLDER=static/parts
BK_SETS_FOLDER=static/sets
```
2. **Keep your existing volume mounts in `compose.yaml`:**
```yaml
volumes:
- ./data:/app/
- ./instructions:/app/static/instructions/
- ./minifigures:/app/static/minifigures/
- ./parts:/app/static/parts/
- ./sets:/app/static/sets/
```
3. **Update to v1.3 and restart:**
```bash
docker compose pull
docker compose up -d
```
That's it! Your existing setup will continue to work.
## Configuration File (.env) Location
### New Behavior (v1.3+)
BrickTracker now supports `.env` in two locations with automatic detection:
1. **data/.env** (recommended - new location)
- Included in data volume backup
- Settings persist when changed via admin panel
- Priority location (checked first)
- **No `env_file` needed** - app reads it directly from `/app/data/.env`
2. **.env** (backward compatibility - root)
- Continues to work for existing installations
- Requires `env_file: .env` in compose.yaml for Docker to load it at startup
- Not included in data volume (unless you add `.env` to `data/`)
### Migration Steps for .env
**Option A: Move to data folder (recommended)**
```bash
# Move .env to data folder
mv .env data/.env
# Update compose.yaml - remove or comment out env_file
# The app will automatically find and use /app/data/.env
```
**Option B: Keep in root (backward compatible)**
```bash
# No changes needed
# Keep env_file: .env in compose.yaml
# App will use .env from root as fallback
```
**Note:** The application automatically detects which location has the .env file at runtime. No Docker Compose `env_file` directive is needed for `data/.env` because the app reads it directly from the mounted volume.
## Configuration Reference
### New Default Paths (v1.3+)
All paths are relative to `/app` inside the container.
| Config Variable | Default Value | Resolves To (Container) | Description |
|----------------|---------------|------------------------|-------------|
| `BK_DATABASE_PATH` | `data/app.db` | `/app/data/app.db` | Database file |
| `BK_RETIRED_SETS_PATH` | `data/retired_sets.csv` | `/app/data/retired_sets.csv` | Retired sets CSV |
| `BK_THEMES_PATH` | `data/themes.csv` | `/app/data/themes.csv` | Themes CSV |
| `BK_INSTRUCTIONS_FOLDER` | `data/instructions` | `/app/data/instructions` | PDF instructions |
| `BK_MINIFIGURES_FOLDER` | `data/minifigures` | `/app/data/minifigures` | Minifigure images |
| `BK_PARTS_FOLDER` | `data/parts` | `/app/data/parts` | Part images |
| `BK_SETS_FOLDER` | `data/sets` | `/app/data/sets` | Set images |
**Docker Compose bind mount:** `./bricktracker-data:/app/data/` (single mount)
### Old Paths (v1.2 and earlier)
To preserve old volume structure without migration, add to `.env`:
| Config Variable | Value to Preserve Old Behavior | Resolves To (Container) |
|----------------|-------------------------------|------------------------|
| `BK_DATABASE_PATH` | `app.db` | `/app/app.db` |
| `BK_RETIRED_SETS_PATH` | `retired_sets.csv` | `/app/retired_sets.csv` |
| `BK_THEMES_PATH` | `themes.csv` | `/app/themes.csv` |
| `BK_INSTRUCTIONS_FOLDER` | `static/instructions` | `/app/static/instructions` |
| `BK_MINIFIGURES_FOLDER` | `static/minifigs` | `/app/static/minifigs` |
| `BK_PARTS_FOLDER` | `static/parts` | `/app/static/parts` |
| `BK_SETS_FOLDER` | `static/sets` | `/app/static/sets` |
## Benefits of New Structure
1. **Single Bind Mount**: One `./bricktracker-data:/app/data/` mount instead of five separate mounts
2. **Easier Backups**: All user data in one location - just backup the `bricktracker-data` directory
3. **Cleaner Separation**: User data separated from application assets
4. **Better Portability**: Migrate between systems by copying/moving single directory
## Troubleshooting
### Images/Instructions Not Loading After Migration
1. **Check if data was copied correctly:**
```bash
docker compose exec bricktracker ls -la /app/data/
docker compose exec bricktracker ls -la /app/data/sets/
docker compose exec bricktracker ls -la /app/data/instructions/
```
2. **Verify bind mount:**
```bash
docker compose config
# Should show: volumes: - ./bricktracker-data:/app/data/
```
3. **Check logs for path errors:**
```bash
docker compose logs -f
```
4. **Verify no old environment overrides:**
```bash
cat .env | grep BK_
```
### Database Not Found
1. **Check database file location in container:**
```bash
docker compose exec bricktracker ls -la /app/data/app.db
```
2. **If using old setup, verify environment variables:**
```bash
docker compose exec bricktracker env | grep BK_DATABASE_PATH
```
3. **Check host directory contains database:**
```bash
ls -la ./bricktracker-data/
# Should show: app.db, retired_sets.csv, themes.csv, and subdirectories
```
### Settings Don't Persist After Restart
**Error:** Admin panel changes revert after `docker compose restart`
**Solution:**
This happens when `.env` is not in a volume. Choose one:
**Option A: Move .env to data folder**
```bash
mv .env data/.env
# Update compose.yaml - remove or comment out env_file
# The app will automatically find and use /app/data/.env
```
**Option B: Mount .env as volume**
```yaml
volumes:
- ./.env:/app/.env
```
### Permission Errors
If you see permission errors after migration:
```bash
# Fix permissions on bind-mounted directory
sudo chown -R $(id -u):$(id -g) ./bricktracker-data
```
**Permission denied writing .env:**
If the admin panel shows an error when saving settings:
1. Ensure .env file is writable by container user
2. If using volume mount, check host file permissions
3. In container: `docker exec BrickTracker ls -la /app/.env` or `/app/data/.env`
### Reverting Migration
If you need to revert to the old structure:
1. Stop the container: `docker compose down`
2. Restore old `compose.yaml` with 5 volume mounts
3. Add old path environment variables to `.env` (see Option 1)
4. Start: `docker compose up -d`
+1 -1
View File
@@ -13,4 +13,4 @@ then
fi fi
# Execute the WSGI server # Execute the WSGI server
gunicorn --bind "${BK_HOST}:${BK_PORT}" "wsgi:application" --worker-class "gevent" --workers 1 "$@" exec gunicorn --bind "${BK_HOST}:${BK_PORT}" "wsgi:application" --worker-class "gevent" --workers 1 "$@"
-1
View File
@@ -10,6 +10,5 @@ rebrick
requests requests
tzdata tzdata
bs4 bs4
cloudscraper
fpdf2 fpdf2
pillow pillow
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 65 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+75 -2
View File
@@ -314,10 +314,15 @@ window.updateUrlParams = function(params, resetPage = true) {
window.location.href = currentUrl.toString(); window.location.href = currentUrl.toString();
}; };
// Shared filter application (supports owner and color filters) // Shared filter application (supports owner, color, theme, year, storage, tag, and problems filters)
window.applyPageFilters = function(tableId) { window.applyPageFilters = function(tableId) {
const ownerSelect = document.getElementById('filter-owner'); const ownerSelect = document.getElementById('filter-owner');
const colorSelect = document.getElementById('filter-color'); const colorSelect = document.getElementById('filter-color');
const themeSelect = document.getElementById('filter-theme');
const yearSelect = document.getElementById('filter-year');
const storageSelect = document.getElementById('filter-storage');
const tagSelect = document.getElementById('filter-tag');
const problemsSelect = document.getElementById('filter-problems');
const params = {}; const params = {};
// Handle owner filter // Handle owner filter
@@ -330,8 +335,37 @@ window.applyPageFilters = function(tableId) {
params.color = colorSelect.value; params.color = colorSelect.value;
} }
// Handle theme filter
if (themeSelect) {
params.theme = themeSelect.value;
}
// Handle year filter
if (yearSelect) {
params.year = yearSelect.value;
}
// Handle storage filter
if (storageSelect) {
params.storage = storageSelect.value;
}
// Handle tag filter
if (tagSelect) {
params.tag = tagSelect.value;
}
// Handle problems filter (for minifigures page)
if (problemsSelect) {
params.problems = problemsSelect.value;
}
// Check if we're in pagination mode
const isPaginationMode = window.isPaginationModeForPage(tableId);
// Update URL with new parameters // Update URL with new parameters
window.updateUrlParams(params, true); // Only reset to page 1 if in server-side pagination mode
window.updateUrlParams(params, isPaginationMode);
}; };
// Shared search setup for both pagination and client-side modes // Shared search setup for both pagination and client-side modes
@@ -404,6 +438,45 @@ window.applyFiltersAndKeepState = function(tableId, storageKey) {
} }
}; };
// Shared function to clear all filters for a page (works in both pagination and client-side modes)
window.clearPageFilters = function(tableId, filterParams) {
const isPaginationMode = window.isPaginationModeForPage(tableId);
if (isPaginationMode) {
// SERVER-SIDE PAGINATION MODE: Remove all filter parameters and redirect to base URL
const currentUrl = new URL(window.location);
// Remove all filter parameters
filterParams.forEach(param => {
currentUrl.searchParams.delete(param);
});
// Also remove page parameter to go back to clean base URL
currentUrl.searchParams.delete('page');
// Navigate to cleaned URL (will be just /xxx if no other params)
window.location.href = currentUrl.toString();
} else {
// CLIENT-SIDE MODE: Reset all filter dropdowns to "all"
filterParams.forEach(param => {
const select = document.getElementById(`filter-${param}`);
if (select) {
select.value = 'all';
}
});
// Remove page parameter from URL if present (without reloading)
const currentUrl = new URL(window.location);
if (currentUrl.searchParams.has('page')) {
currentUrl.searchParams.delete('page');
window.history.replaceState({}, '', currentUrl.toString());
}
// Trigger filter application (will use existing filter logic)
window.applyPageFilters(tableId);
}
};
// Shared initialization for table pages (parts, problems, minifigures) // Shared initialization for table pages (parts, problems, minifigures)
window.initializeTablePage = function(config) { window.initializeTablePage = function(config) {
const { const {
+67 -10
View File
@@ -6,34 +6,83 @@ function isPaginationMode() {
return tableElement && tableElement.getAttribute('data-table') === 'false'; return tableElement && tableElement.getAttribute('data-table') === 'false';
} }
function filterByOwner() { function applyFilters() {
const select = document.getElementById('filter-owner'); const ownerSelect = document.getElementById('filter-owner');
const selectedOwner = select.value; const problemsSelect = document.getElementById('filter-problems');
const themeSelect = document.getElementById('filter-theme');
const yearSelect = document.getElementById('filter-year');
const currentUrl = new URL(window.location); const currentUrl = new URL(window.location);
if (selectedOwner === 'all') { // Apply owner filter
currentUrl.searchParams.delete('owner'); if (ownerSelect) {
} else { const selectedOwner = ownerSelect.value;
currentUrl.searchParams.set('owner', selectedOwner); if (selectedOwner === 'all') {
currentUrl.searchParams.delete('owner');
} else {
currentUrl.searchParams.set('owner', selectedOwner);
}
} }
// Reset to page 1 when filtering // Apply problems filter
if (problemsSelect) {
const selectedProblems = problemsSelect.value;
if (selectedProblems === 'all') {
currentUrl.searchParams.delete('problems');
} else {
currentUrl.searchParams.set('problems', selectedProblems);
}
}
// Apply theme filter
if (themeSelect) {
const selectedTheme = themeSelect.value;
if (selectedTheme === 'all') {
currentUrl.searchParams.delete('theme');
} else {
currentUrl.searchParams.set('theme', selectedTheme);
}
}
// Apply year filter
if (yearSelect) {
const selectedYear = yearSelect.value;
if (selectedYear === 'all') {
currentUrl.searchParams.delete('year');
} else {
currentUrl.searchParams.set('year', selectedYear);
}
}
// Reset to page 1 when filtering in server-side pagination mode
if (isPaginationMode()) { if (isPaginationMode()) {
currentUrl.searchParams.set('page', '1'); currentUrl.searchParams.set('page', '1');
} }
// Navigate to updated URL (reload page with new filters)
// This works for both pagination and client-side modes
// because backend applies filters even in client-side mode
window.location.href = currentUrl.toString(); window.location.href = currentUrl.toString();
} }
// Legacy function for compatibility
function filterByOwner() {
applyFilters();
}
// Initialize filter and sort states for minifigures page // Initialize filter and sort states for minifigures page
function initializeCollapsibleStates() { function initializeCollapsibleStates() {
initializePageCollapsibleStates('minifigures'); initializePageCollapsibleStates('minifigures');
} }
// Keep filters expanded after selection // Keep filters expanded after selection
function filterByOwnerAndKeepOpen() { function applyFiltersAndKeepOpen() {
preserveCollapsibleStateOnChange('table-filter', 'minifigures-filter-state'); preserveCollapsibleStateOnChange('table-filter', 'minifigures-filter-state');
filterByOwner(); applyFilters();
}
// Legacy function for compatibility
function filterByOwnerAndKeepOpen() {
applyFiltersAndKeepOpen();
} }
// Setup table search and sort functionality // Setup table search and sort functionality
@@ -120,6 +169,14 @@ document.addEventListener("DOMContentLoaded", () => {
const currentOrder = urlParams.get('order'); const currentOrder = urlParams.get('order');
window.initializeSortButtonStates(currentSort, currentOrder); window.initializeSortButtonStates(currentSort, currentOrder);
} }
// Initialize clear filters button
const clearButton = document.getElementById('table-filter-clear');
if (clearButton) {
clearButton.addEventListener('click', () => {
window.clearPageFilters('minifigures', ['owner', 'problems', 'theme', 'year']);
});
}
}); });
function setupSortButtons() { function setupSortButtons() {
+8
View File
@@ -23,5 +23,13 @@ document.addEventListener("DOMContentLoaded", () => {
}, },
hasColorDropdown: true hasColorDropdown: true
}); });
// Initialize clear filters button
const clearButton = document.getElementById('table-filter-clear');
if (clearButton) {
clearButton.addEventListener('click', () => {
window.clearPageFilters('parts', ['owner', 'color', 'theme', 'year']);
});
}
}); });
+8
View File
@@ -23,4 +23,12 @@ document.addEventListener("DOMContentLoaded", () => {
}, },
hasColorDropdown: true hasColorDropdown: true
}); });
// Initialize clear filters button
const clearButton = document.getElementById('table-filter-clear');
if (clearButton) {
clearButton.addEventListener('click', () => {
window.clearPageFilters('problems', ['owner', 'color', 'theme', 'year', 'storage', 'tag']);
});
}
}); });
+64
View File
@@ -22,6 +22,9 @@ document.addEventListener("DOMContentLoaded", () => {
// Initialize duplicate filter functionality // Initialize duplicate filter functionality
initializeDuplicateFilter(); initializeDuplicateFilter();
// Initialize clear filters button
initializeClearFiltersButton();
if (searchInput && searchClear) { if (searchInput && searchClear) {
if (isPaginationMode()) { if (isPaginationMode()) {
// PAGINATION MODE - Server-side search // PAGINATION MODE - Server-side search
@@ -704,4 +707,65 @@ function triggerGridRefresh() {
} }
} }
} }
}
// Initialize clear filters button functionality
function initializeClearFiltersButton() {
const clearFiltersButton = document.getElementById('grid-filter-clear');
if (!clearFiltersButton) return;
clearFiltersButton.addEventListener('click', () => {
if (isPaginationMode()) {
// SERVER-SIDE PAGINATION MODE: Remove all filter parameters and redirect to base URL
const currentUrl = new URL(window.location);
// Remove all filter parameters
const filterParams = ['status', 'theme', 'year', 'owner', 'purchase_location', 'storage', 'tag', 'duplicate'];
filterParams.forEach(param => {
currentUrl.searchParams.delete(param);
});
// Also remove page parameter to go back to clean base URL
currentUrl.searchParams.delete('page');
// Navigate to cleaned URL (will be just /sets if no other params)
window.location.href = currentUrl.toString();
} else {
// CLIENT-SIDE MODE: Reset all filter dropdowns to empty string
const filterDropdowns = [
'grid-status',
'grid-theme',
'grid-year',
'grid-owner',
'grid-purchase-location',
'grid-storage',
'grid-tag'
];
filterDropdowns.forEach(dropdownId => {
const dropdown = document.getElementById(dropdownId);
if (dropdown) {
dropdown.value = '';
}
});
// Clear duplicate filter if active
const duplicateButton = document.getElementById('duplicate-filter-toggle');
if (duplicateButton && duplicateButton.classList.contains('btn-secondary')) {
duplicateButton.classList.remove('btn-secondary');
duplicateButton.classList.add('btn-outline-secondary');
applyDuplicateFilter(false);
}
// Remove page parameter from URL if present (without reloading)
const currentUrl = new URL(window.location);
if (currentUrl.searchParams.has('page')) {
currentUrl.searchParams.delete('page');
window.history.replaceState({}, '', currentUrl.toString());
}
// Trigger filtering if grid instance exists
triggerGridRefresh();
}
});
} }
+9 -1
View File
@@ -186,7 +186,15 @@ class BrickSocket {
if (this.socket === undefined) { if (this.socket === undefined) {
this.socket = io.connect(`${window.location.origin}/${this.namespace}`, { this.socket = io.connect(`${window.location.origin}/${this.namespace}`, {
path: this.path, path: this.path,
transports: ["websocket"], // Adding Polling as iOS is having issues with websockets
transports: ["polling", "websocket"],
// Increase timeout for slow mobile connections
timeout: 30000,
// Reconnection settings
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
// Allow upgrade from polling to websocket
upgrade: true,
}); });
// Complete // Complete
+23 -6
View File
@@ -11,6 +11,11 @@ document.addEventListener('DOMContentLoaded', function() {
// Debug: Log chart data to console // Debug: Log chart data to console
console.log('Chart Data:', window.chartData); console.log('Chart Data:', window.chartData);
// Get current theme colors
const isDarkMode = document.documentElement.getAttribute('data-bs-theme') === 'dark';
const textColor = isDarkMode ? '#f8f9fa' : '#212529';
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
// Common chart configuration // Common chart configuration
const commonConfig = { const commonConfig = {
type: 'line', type: 'line',
@@ -25,20 +30,30 @@ document.addEventListener('DOMContentLoaded', function() {
x: { x: {
title: { title: {
display: true, display: true,
text: 'Release Year' text: 'Release Year',
color: textColor
},
ticks: {
color: textColor
}, },
grid: { grid: {
display: false display: false,
color: gridColor
} }
}, },
y: { y: {
beginAtZero: true, beginAtZero: true,
title: { title: {
display: true, display: true,
text: 'Count' text: 'Count',
color: textColor
}, },
ticks: { ticks: {
precision: 0 precision: 0,
color: textColor
},
grid: {
color: gridColor
} }
} }
}, },
@@ -110,7 +125,8 @@ document.addEventListener('DOMContentLoaded', function() {
...commonConfig.options.scales.y, ...commonConfig.options.scales.y,
title: { title: {
display: true, display: true,
text: 'Parts Count' text: 'Parts Count',
color: textColor
} }
} }
} }
@@ -143,7 +159,8 @@ document.addEventListener('DOMContentLoaded', function() {
...commonConfig.options.scales.y, ...commonConfig.options.scales.y,
title: { title: {
display: true, display: true,
text: 'Minifigures Count' text: 'Minifigures Count',
color: textColor
} }
} }
} }
+92
View File
@@ -87,6 +87,11 @@
border-color: #6c757d; border-color: #6c757d;
} }
[data-bs-theme="dark"] .table th .dropdown-toggle:hover {
background-color: #2b3035;
border-color: #6c757d;
}
/* Style dropdown items */ /* Style dropdown items */
.dropdown-menu .dropdown-header { .dropdown-menu .dropdown-header {
font-size: 0.75rem; font-size: 0.75rem;
@@ -106,6 +111,11 @@
color: #212529; color: #212529;
} }
[data-bs-theme="dark"] .dropdown-menu .dropdown-item:hover {
background-color: #2b3035;
color: #fff;
}
.dropdown-menu .dropdown-item i { .dropdown-menu .dropdown-item i {
width: 1.25rem; width: 1.25rem;
text-align: center; text-align: center;
@@ -117,6 +127,11 @@
--th-bg:#fff !important; --th-bg:#fff !important;
} }
[data-bs-theme="dark"] .sortable {
--th-color: #fff !important;
--th-bg: #212529 !important;
}
.sortable thead th { .sortable thead th {
font-weight: bold !important; font-weight: bold !important;
} }
@@ -180,3 +195,80 @@
pointer-events: none; pointer-events: none;
}/* Duplicate filter support */ }/* Duplicate filter support */
.duplicate-filter-hidden { display: none !important; } .duplicate-filter-hidden { display: none !important; }
/* Dark mode link and text fixes */
[data-bs-theme="dark"] .text-reset {
color: var(--bs-body-color) !important;
}
[data-bs-theme="dark"] a.text-reset:hover {
color: var(--bs-link-hover-color) !important;
}
[data-bs-theme="dark"] .text-dark {
color: var(--bs-body-color) !important;
}
/* Dark mode border fixes */
[data-bs-theme="dark"] .border-black {
border-color: var(--bs-border-color) !important;
}
/* Dark mode badge fixes */
[data-bs-theme="dark"] .text-bg-light {
background-color: #343a40 !important;
color: #f8f9fa !important;
}
[data-bs-theme="dark"] .bg-white {
background-color: #212529 !important;
}
[data-bs-theme="dark"] .text-black {
color: #f8f9fa !important;
}
/* Dark mode button fixes */
[data-bs-theme="dark"] .btn-outline-dark {
border-color: #f8f9fa;
color: #f8f9fa;
}
[data-bs-theme="dark"] .btn-outline-dark:hover {
background-color: #f8f9fa;
border-color: #f8f9fa;
color: #212529;
}
[data-bs-theme="dark"] .btn-light {
background-color: #343a40;
border-color: #495057;
color: #f8f9fa;
}
[data-bs-theme="dark"] .btn-light:hover {
background-color: #495057;
border-color: #6c757d;
color: #fff;
}
/* Dark mode table fixes */
[data-bs-theme="dark"] .table-light {
--bs-table-bg: #343a40;
--bs-table-color: #f8f9fa;
}
/* Dark mode for card header links */
[data-bs-theme="dark"] .text-decoration-none.text-dark {
color: var(--bs-body-color) !important;
}
/* Dark mode table text fix - ensure all table text is visible */
[data-bs-theme="dark"] .table {
--bs-table-color: var(--bs-body-color);
}
[data-bs-theme="dark"] .table tbody td,
[data-bs-theme="dark"] .table tbody th {
color: var(--bs-body-color);
}
+174 -137
View File
@@ -1,10 +1,21 @@
{% import 'macro/accordion.html' as accordion %} {% import 'macro/accordion.html' as accordion %}
<!-- Helper macro to check if variable is locked -->
{% macro is_locked(var_name) %}{{ 'disabled' if env_locked_values[var_name] else '' }}{% endmacro %}
<!-- Helper macro for config badges with truncation --> <!-- Helper macro for config badges with truncation -->
{% macro config_badges(var_name) %} {% macro config_badges(var_name) %}
{% set current_value = env_values[var_name] %} {% set current_value = env_values[var_name] %}
{% set default_value = config_defaults[var_name] %} {% set default_value = config_defaults[var_name] %}
{% set is_explicitly_set = env_explicit_values[var_name] %} {% set is_explicitly_set = env_explicit_values[var_name] %}
{% set is_locked = env_locked_values[var_name] %}
<!-- Locked badge (Docker environment variable) -->
{% if is_locked %}
<span class="badge rounded-pill text-bg-dark ms-2" data-badge-var="{{ var_name }}" data-badge-type="locked" title="Set via Docker environment variable">
<i class="ri-lock-line"></i> Locked
</span>
{% endif %}
<!-- Value badge --> <!-- Value badge -->
{% if not is_explicitly_set %} {% if not is_explicitly_set %}
@@ -24,51 +35,75 @@
{% endif %} {% endif %}
<!-- Changed badge --> <!-- Changed badge -->
{% if current_value != default_value %} {% if current_value != default_value and not is_locked %}
<span class="badge rounded-pill text-bg-warning ms-1" data-badge-var="{{ var_name }}" data-badge-type="changed">Changed</span> <span class="badge rounded-pill text-bg-warning ms-1" data-badge-var="{{ var_name }}" data-badge-type="changed">Changed</span>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{{ accordion.header('Configuration Management', 'configuration-management', 'admin', icon='settings-4-line') }} <!-- Configuration Status -->
<div class="p-3"> <div id="config-status" class="mb-3 px-3 pt-3">
<!-- Configuration Status --> <!-- Status indicators -->
<div id="config-status" class="mb-3"> </div>
<!-- Status indicators -->
</div>
<!-- Badge Legend --> <!-- Configuration File Location Warning -->
<div class="alert alert-info mb-3"> {% if env_file_warning %}
<h6 class="mb-2"><i class="ri-information-line"></i> Badge Legend</h6> <div class="alert alert-warning mb-3 mx-3">
<div class="row g-2"> <h6 class="mb-2"><i class="ri-alert-line"></i> Configuration Persistence Warning</h6>
<div class="col-md-6"> <p class="mb-2">
<small> <strong>Configuration file location:</strong> <code>{{ env_file_location }}</code>
<span class="badge rounded-pill text-bg-success">True</span> Boolean setting enabled<br> </p>
<span class="badge rounded-pill text-bg-danger">False</span> Boolean setting disabled<br> <p class="mb-0">
<span class="badge rounded-pill text-bg-primary">Set</span> Custom value configured <small>
</small> Settings changes will <strong>not persist</strong> across container restarts.
</div> To persist configuration changes, either:
<div class="col-md-6"> <ul class="mb-0 mt-1">
<small> <li>Move <code>.env</code> to <code>data/.env</code> folder (recommended), or</li>
<span class="badge rounded-pill text-bg-secondary">Unset</span> Not in .env file<br> <li>Add volume mount: <code>- ./.env:/app/.env</code> to your compose.yaml</li>
<span class="badge rounded-pill text-bg-light text-dark">Default Value</span> Using default value<br> </ul>
<span class="badge rounded-pill text-bg-warning">Changed</span> Modified from default See <a href="https://bricktracker.baerentsen.space/migration-guide" target="_blank" class="alert-link">Migration Guide</a> for details.
</small> </small>
</div> </p>
</div>
{% elif env_file_missing %}
<div class="alert alert-danger mb-3 mx-3">
<h6 class="mb-2"><i class="ri-information-line"></i> No Configuration File Found</h6>
<p class="mb-0">
<small>
No <code>.env</code> file was found at <code>data/.env</code>. Currently using default values and Docker environment variables.
If you save changes, a new configuration file will be created at <code>data/.env</code> (recommended location).
See <a href="https://bricktracker.baerentsen.space/migration_guide.html" target="_blank" class="alert-link">Migration Guide</a> for details.
</small>
</p>
</div>
{% endif %}
<!-- Badge Legend -->
<div class="alert alert-info mb-3 mx-3">
<h6 class="mb-2"><i class="ri-information-line"></i> Badge Legend</h6>
<div class="row g-2">
<div class="col-md-6">
<small>
<span class="badge rounded-pill text-bg-success">True</span> Boolean setting enabled<br>
<span class="badge rounded-pill text-bg-danger">False</span> Boolean setting disabled<br>
<span class="badge rounded-pill text-bg-primary">Set</span> Custom value configured<br>
<span class="badge rounded-pill text-bg-dark"><i class="ri-lock-line"></i> Locked</span> Set via Docker environment (cannot be changed)
</small>
</div>
<div class="col-md-6">
<small>
<span class="badge rounded-pill text-bg-secondary">Unset</span> Not in .env file<br>
<span class="badge rounded-pill text-bg-light text-dark">Default Value</span> Using default value<br>
<span class="badge rounded-pill text-bg-warning">Changed</span> Modified from default
</small>
</div> </div>
</div> </div>
</div>
<!-- Sub-Drawers --> <!-- Live Settings Accordion -->
<div class="accordion" id="configuration-accordion"> {{ accordion.header('Live Settings', 'live-settings', 'admin', expanded=true, icon='settings-4-line', class='p-3') }}
<!-- Live Settings Sub-Drawer --> <div class="mb-3">
<div class="accordion-item"> <span class="badge text-bg-success">Changes Applied On Save</span>
<h2 class="accordion-header" id="live-settings-heading"> </div>
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#live-settings-collapse" aria-expanded="true" aria-controls="live-settings-collapse">
<i class="ri-settings-4-line me-2"></i> Live Settings
<span class="badge text-bg-success ms-2">Changes Applied On Save</span>
</button>
</h2>
<div id="live-settings-collapse" class="accordion-collapse collapse show" aria-labelledby="live-settings-heading" data-bs-parent="#configuration-accordion">
<div class="accordion-body">
<!-- Action buttons --> <!-- Action buttons -->
<div class="d-flex gap-2 justify-content-end mb-4"> <div class="d-flex gap-2 justify-content-end mb-4">
<button id="config-save-all" class="btn btn-success"> <button id="config-save-all" class="btn btn-success">
@@ -90,7 +125,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ADD_SET" data-var="BK_HIDE_ADD_SET"> <input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ADD_SET" data-var="BK_HIDE_ADD_SET" {{ is_locked('BK_HIDE_ADD_SET') }}>
<label class="form-check-label" for="BK_HIDE_ADD_SET"> <label class="form-check-label" for="BK_HIDE_ADD_SET">
BK_HIDE_ADD_SET {{ config_badges('BK_HIDE_ADD_SET') }} BK_HIDE_ADD_SET {{ config_badges('BK_HIDE_ADD_SET') }}
<div class="text-muted small">Hide the "Add Set" menu entry</div> <div class="text-muted small">Hide the "Add Set" menu entry</div>
@@ -98,7 +133,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ADD_BULK_SET" data-var="BK_HIDE_ADD_BULK_SET"> <input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ADD_BULK_SET" data-var="BK_HIDE_ADD_BULK_SET" {{ is_locked('BK_HIDE_ADD_BULK_SET') }}>
<label class="form-check-label" for="BK_HIDE_ADD_BULK_SET"> <label class="form-check-label" for="BK_HIDE_ADD_BULK_SET">
BK_HIDE_ADD_BULK_SET {{ config_badges('BK_HIDE_ADD_BULK_SET') }} BK_HIDE_ADD_BULK_SET {{ config_badges('BK_HIDE_ADD_BULK_SET') }}
<div class="text-muted small">Hide the "Add Bulk Set" menu entry</div> <div class="text-muted small">Hide the "Add Bulk Set" menu entry</div>
@@ -106,7 +141,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ADMIN" data-var="BK_HIDE_ADMIN"> <input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ADMIN" data-var="BK_HIDE_ADMIN" {{ is_locked('BK_HIDE_ADMIN') }}>
<label class="form-check-label" for="BK_HIDE_ADMIN"> <label class="form-check-label" for="BK_HIDE_ADMIN">
BK_HIDE_ADMIN {{ config_badges('BK_HIDE_ADMIN') }} BK_HIDE_ADMIN {{ config_badges('BK_HIDE_ADMIN') }}
<div class="text-muted small">Hide the "Admin" menu entry</div> <div class="text-muted small">Hide the "Admin" menu entry</div>
@@ -114,7 +149,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_INSTRUCTIONS" data-var="BK_HIDE_ALL_INSTRUCTIONS"> <input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_INSTRUCTIONS" data-var="BK_HIDE_ALL_INSTRUCTIONS" {{ is_locked('BK_HIDE_ALL_INSTRUCTIONS') }}>
<label class="form-check-label" for="BK_HIDE_ALL_INSTRUCTIONS"> <label class="form-check-label" for="BK_HIDE_ALL_INSTRUCTIONS">
BK_HIDE_ALL_INSTRUCTIONS {{ config_badges('BK_HIDE_ALL_INSTRUCTIONS') }} BK_HIDE_ALL_INSTRUCTIONS {{ config_badges('BK_HIDE_ALL_INSTRUCTIONS') }}
<div class="text-muted small">Hide the "Instructions" menu entry</div> <div class="text-muted small">Hide the "Instructions" menu entry</div>
@@ -122,7 +157,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_MINIFIGURES" data-var="BK_HIDE_ALL_MINIFIGURES"> <input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_MINIFIGURES" data-var="BK_HIDE_ALL_MINIFIGURES" {{ is_locked('BK_HIDE_ALL_MINIFIGURES') }}>
<label class="form-check-label" for="BK_HIDE_ALL_MINIFIGURES"> <label class="form-check-label" for="BK_HIDE_ALL_MINIFIGURES">
BK_HIDE_ALL_MINIFIGURES {{ config_badges('BK_HIDE_ALL_MINIFIGURES') }} BK_HIDE_ALL_MINIFIGURES {{ config_badges('BK_HIDE_ALL_MINIFIGURES') }}
<div class="text-muted small">Hide the "Minifigures" menu entry</div> <div class="text-muted small">Hide the "Minifigures" menu entry</div>
@@ -130,7 +165,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_PARTS" data-var="BK_HIDE_ALL_PARTS"> <input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_PARTS" data-var="BK_HIDE_ALL_PARTS" {{ is_locked('BK_HIDE_ALL_PARTS') }}>
<label class="form-check-label" for="BK_HIDE_ALL_PARTS"> <label class="form-check-label" for="BK_HIDE_ALL_PARTS">
BK_HIDE_ALL_PARTS {{ config_badges('BK_HIDE_ALL_PARTS') }} BK_HIDE_ALL_PARTS {{ config_badges('BK_HIDE_ALL_PARTS') }}
<div class="text-muted small">Hide the "Parts" menu entry</div> <div class="text-muted small">Hide the "Parts" menu entry</div>
@@ -140,7 +175,7 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_PROBLEMS_PARTS" data-var="BK_HIDE_ALL_PROBLEMS_PARTS"> <input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_PROBLEMS_PARTS" data-var="BK_HIDE_ALL_PROBLEMS_PARTS" {{ is_locked('BK_HIDE_ALL_PROBLEMS_PARTS') }}>
<label class="form-check-label" for="BK_HIDE_ALL_PROBLEMS_PARTS"> <label class="form-check-label" for="BK_HIDE_ALL_PROBLEMS_PARTS">
BK_HIDE_ALL_PROBLEMS_PARTS {{ config_badges('BK_HIDE_ALL_PROBLEMS_PARTS') }} BK_HIDE_ALL_PROBLEMS_PARTS {{ config_badges('BK_HIDE_ALL_PROBLEMS_PARTS') }}
<div class="text-muted small">Hide the "Problems" menu entry</div> <div class="text-muted small">Hide the "Problems" menu entry</div>
@@ -148,7 +183,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_SETS" data-var="BK_HIDE_ALL_SETS"> <input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_SETS" data-var="BK_HIDE_ALL_SETS" {{ is_locked('BK_HIDE_ALL_SETS') }}>
<label class="form-check-label" for="BK_HIDE_ALL_SETS"> <label class="form-check-label" for="BK_HIDE_ALL_SETS">
BK_HIDE_ALL_SETS {{ config_badges('BK_HIDE_ALL_SETS') }} BK_HIDE_ALL_SETS {{ config_badges('BK_HIDE_ALL_SETS') }}
<div class="text-muted small">Hide the "Sets" menu entry</div> <div class="text-muted small">Hide the "Sets" menu entry</div>
@@ -156,7 +191,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_STORAGES" data-var="BK_HIDE_ALL_STORAGES"> <input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_STORAGES" data-var="BK_HIDE_ALL_STORAGES" {{ is_locked('BK_HIDE_ALL_STORAGES') }}>
<label class="form-check-label" for="BK_HIDE_ALL_STORAGES"> <label class="form-check-label" for="BK_HIDE_ALL_STORAGES">
BK_HIDE_ALL_STORAGES {{ config_badges('BK_HIDE_ALL_STORAGES') }} BK_HIDE_ALL_STORAGES {{ config_badges('BK_HIDE_ALL_STORAGES') }}
<div class="text-muted small">Hide the "Storages" menu entry</div> <div class="text-muted small">Hide the "Storages" menu entry</div>
@@ -164,7 +199,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_STATISTICS" data-var="BK_HIDE_STATISTICS"> <input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_STATISTICS" data-var="BK_HIDE_STATISTICS" {{ is_locked('BK_HIDE_STATISTICS') }}>
<label class="form-check-label" for="BK_HIDE_STATISTICS"> <label class="form-check-label" for="BK_HIDE_STATISTICS">
BK_HIDE_STATISTICS {{ config_badges('BK_HIDE_STATISTICS') }} BK_HIDE_STATISTICS {{ config_badges('BK_HIDE_STATISTICS') }}
<div class="text-muted small">Hide the "Statistics" menu entry</div> <div class="text-muted small">Hide the "Statistics" menu entry</div>
@@ -172,7 +207,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_WISHES" data-var="BK_HIDE_WISHES"> <input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_WISHES" data-var="BK_HIDE_WISHES" {{ is_locked('BK_HIDE_WISHES') }}>
<label class="form-check-label" for="BK_HIDE_WISHES"> <label class="form-check-label" for="BK_HIDE_WISHES">
BK_HIDE_WISHES {{ config_badges('BK_HIDE_WISHES') }} BK_HIDE_WISHES {{ config_badges('BK_HIDE_WISHES') }}
<div class="text-muted small">Hide the "Wishes" menu entry</div> <div class="text-muted small">Hide the "Wishes" menu entry</div>
@@ -187,7 +222,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_SET_INSTRUCTIONS" data-var="BK_HIDE_SET_INSTRUCTIONS"> <input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_SET_INSTRUCTIONS" data-var="BK_HIDE_SET_INSTRUCTIONS" {{ is_locked('BK_HIDE_SET_INSTRUCTIONS') }}>
<label class="form-check-label" for="BK_HIDE_SET_INSTRUCTIONS"> <label class="form-check-label" for="BK_HIDE_SET_INSTRUCTIONS">
BK_HIDE_SET_INSTRUCTIONS {{ config_badges('BK_HIDE_SET_INSTRUCTIONS') }} BK_HIDE_SET_INSTRUCTIONS {{ config_badges('BK_HIDE_SET_INSTRUCTIONS') }}
<div class="text-muted small">Hide instructions section in set details</div> <div class="text-muted small">Hide instructions section in set details</div>
@@ -195,7 +230,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_TABLE_DAMAGED_PARTS" data-var="BK_HIDE_TABLE_DAMAGED_PARTS"> <input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_TABLE_DAMAGED_PARTS" data-var="BK_HIDE_TABLE_DAMAGED_PARTS" {{ is_locked('BK_HIDE_TABLE_DAMAGED_PARTS') }}>
<label class="form-check-label" for="BK_HIDE_TABLE_DAMAGED_PARTS"> <label class="form-check-label" for="BK_HIDE_TABLE_DAMAGED_PARTS">
BK_HIDE_TABLE_DAMAGED_PARTS {{ config_badges('BK_HIDE_TABLE_DAMAGED_PARTS') }} BK_HIDE_TABLE_DAMAGED_PARTS {{ config_badges('BK_HIDE_TABLE_DAMAGED_PARTS') }}
<div class="text-muted small">Hide the "Damaged" column in parts tables</div> <div class="text-muted small">Hide the "Damaged" column in parts tables</div>
@@ -203,7 +238,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_TABLE_MISSING_PARTS" data-var="BK_HIDE_TABLE_MISSING_PARTS"> <input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_TABLE_MISSING_PARTS" data-var="BK_HIDE_TABLE_MISSING_PARTS" {{ is_locked('BK_HIDE_TABLE_MISSING_PARTS') }}>
<label class="form-check-label" for="BK_HIDE_TABLE_MISSING_PARTS"> <label class="form-check-label" for="BK_HIDE_TABLE_MISSING_PARTS">
BK_HIDE_TABLE_MISSING_PARTS {{ config_badges('BK_HIDE_TABLE_MISSING_PARTS') }} BK_HIDE_TABLE_MISSING_PARTS {{ config_badges('BK_HIDE_TABLE_MISSING_PARTS') }}
<div class="text-muted small">Hide the "Missing" column in parts tables</div> <div class="text-muted small">Hide the "Missing" column in parts tables</div>
@@ -211,7 +246,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_TABLE_CHECKED_PARTS" data-var="BK_HIDE_TABLE_CHECKED_PARTS"> <input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_TABLE_CHECKED_PARTS" data-var="BK_HIDE_TABLE_CHECKED_PARTS" {{ is_locked('BK_HIDE_TABLE_CHECKED_PARTS') }}>
<label class="form-check-label" for="BK_HIDE_TABLE_CHECKED_PARTS"> <label class="form-check-label" for="BK_HIDE_TABLE_CHECKED_PARTS">
BK_HIDE_TABLE_CHECKED_PARTS {{ config_badges('BK_HIDE_TABLE_CHECKED_PARTS') }} BK_HIDE_TABLE_CHECKED_PARTS {{ config_badges('BK_HIDE_TABLE_CHECKED_PARTS') }}
<div class="text-muted small">Hide the "Checked" column in parts tables</div> <div class="text-muted small">Hide the "Checked" column in parts tables</div>
@@ -221,7 +256,7 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SHOW_GRID_FILTERS" data-var="BK_SHOW_GRID_FILTERS"> <input class="form-check-input config-toggle" type="checkbox" id="BK_SHOW_GRID_FILTERS" data-var="BK_SHOW_GRID_FILTERS" {{ is_locked('BK_SHOW_GRID_FILTERS') }}>
<label class="form-check-label" for="BK_SHOW_GRID_FILTERS"> <label class="form-check-label" for="BK_SHOW_GRID_FILTERS">
BK_SHOW_GRID_FILTERS {{ config_badges('BK_SHOW_GRID_FILTERS') }} BK_SHOW_GRID_FILTERS {{ config_badges('BK_SHOW_GRID_FILTERS') }}
<div class="text-muted small">Show filter controls on grid views by default</div> <div class="text-muted small">Show filter controls on grid views by default</div>
@@ -229,7 +264,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SHOW_GRID_SORT" data-var="BK_SHOW_GRID_SORT"> <input class="form-check-input config-toggle" type="checkbox" id="BK_SHOW_GRID_SORT" data-var="BK_SHOW_GRID_SORT" {{ is_locked('BK_SHOW_GRID_SORT') }}>
<label class="form-check-label" for="BK_SHOW_GRID_SORT"> <label class="form-check-label" for="BK_SHOW_GRID_SORT">
BK_SHOW_GRID_SORT {{ config_badges('BK_SHOW_GRID_SORT') }} BK_SHOW_GRID_SORT {{ config_badges('BK_SHOW_GRID_SORT') }}
<div class="text-muted small">Show sort options on grids by default</div> <div class="text-muted small">Show sort options on grids by default</div>
@@ -237,7 +272,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SHOW_SETS_DUPLICATE_FILTER" data-var="BK_SHOW_SETS_DUPLICATE_FILTER"> <input class="form-check-input config-toggle" type="checkbox" id="BK_SHOW_SETS_DUPLICATE_FILTER" data-var="BK_SHOW_SETS_DUPLICATE_FILTER" {{ is_locked('BK_SHOW_SETS_DUPLICATE_FILTER') }}>
<label class="form-check-label" for="BK_SHOW_SETS_DUPLICATE_FILTER"> <label class="form-check-label" for="BK_SHOW_SETS_DUPLICATE_FILTER">
BK_SHOW_SETS_DUPLICATE_FILTER {{ config_badges('BK_SHOW_SETS_DUPLICATE_FILTER') }} BK_SHOW_SETS_DUPLICATE_FILTER {{ config_badges('BK_SHOW_SETS_DUPLICATE_FILTER') }}
<div class="text-muted small">Show duplicate/consolidated filter button on sets page</div> <div class="text-muted small">Show duplicate/consolidated filter button on sets page</div>
@@ -245,7 +280,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_INDEPENDENT_ACCORDIONS" data-var="BK_INDEPENDENT_ACCORDIONS"> <input class="form-check-input config-toggle" type="checkbox" id="BK_INDEPENDENT_ACCORDIONS" data-var="BK_INDEPENDENT_ACCORDIONS" {{ is_locked('BK_INDEPENDENT_ACCORDIONS') }}>
<label class="form-check-label" for="BK_INDEPENDENT_ACCORDIONS"> <label class="form-check-label" for="BK_INDEPENDENT_ACCORDIONS">
BK_INDEPENDENT_ACCORDIONS {{ config_badges('BK_INDEPENDENT_ACCORDIONS') }} BK_INDEPENDENT_ACCORDIONS {{ config_badges('BK_INDEPENDENT_ACCORDIONS') }}
<div class="text-muted small">Make accordion sections independent (can open multiple)</div> <div class="text-muted small">Make accordion sections independent (can open multiple)</div>
@@ -253,7 +288,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SETS_CONSOLIDATION" data-var="BK_SETS_CONSOLIDATION"> <input class="form-check-input config-toggle" type="checkbox" id="BK_SETS_CONSOLIDATION" data-var="BK_SETS_CONSOLIDATION" {{ is_locked('BK_SETS_CONSOLIDATION') }}>
<label class="form-check-label" for="BK_SETS_CONSOLIDATION"> <label class="form-check-label" for="BK_SETS_CONSOLIDATION">
BK_SETS_CONSOLIDATION {{ config_badges('BK_SETS_CONSOLIDATION') }} BK_SETS_CONSOLIDATION {{ config_badges('BK_SETS_CONSOLIDATION') }}
<div class="text-muted small">Enable set consolidation/grouping functionality</div> <div class="text-muted small">Enable set consolidation/grouping functionality</div>
@@ -273,7 +308,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-12"> <div class="col-12">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SETS_SERVER_SIDE_PAGINATION" data-var="BK_SETS_SERVER_SIDE_PAGINATION"> <input class="form-check-input config-toggle" type="checkbox" id="BK_SETS_SERVER_SIDE_PAGINATION" data-var="BK_SETS_SERVER_SIDE_PAGINATION" {{ is_locked('BK_SETS_SERVER_SIDE_PAGINATION') }}>
<label class="form-check-label" for="BK_SETS_SERVER_SIDE_PAGINATION"> <label class="form-check-label" for="BK_SETS_SERVER_SIDE_PAGINATION">
BK_SETS_SERVER_SIDE_PAGINATION {{ config_badges('BK_SETS_SERVER_SIDE_PAGINATION') }} BK_SETS_SERVER_SIDE_PAGINATION {{ config_badges('BK_SETS_SERVER_SIDE_PAGINATION') }}
<div class="text-muted small">Enable/disable pagination for sets</div> <div class="text-muted small">Enable/disable pagination for sets</div>
@@ -303,7 +338,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-12"> <div class="col-12">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_PARTS_SERVER_SIDE_PAGINATION" data-var="BK_PARTS_SERVER_SIDE_PAGINATION"> <input class="form-check-input config-toggle" type="checkbox" id="BK_PARTS_SERVER_SIDE_PAGINATION" data-var="BK_PARTS_SERVER_SIDE_PAGINATION" {{ is_locked('BK_PARTS_SERVER_SIDE_PAGINATION') }}>
<label class="form-check-label" for="BK_PARTS_SERVER_SIDE_PAGINATION"> <label class="form-check-label" for="BK_PARTS_SERVER_SIDE_PAGINATION">
BK_PARTS_SERVER_SIDE_PAGINATION {{ config_badges('BK_PARTS_SERVER_SIDE_PAGINATION') }} BK_PARTS_SERVER_SIDE_PAGINATION {{ config_badges('BK_PARTS_SERVER_SIDE_PAGINATION') }}
<div class="text-muted small">Enable/disable pagination for parts</div> <div class="text-muted small">Enable/disable pagination for parts</div>
@@ -336,7 +371,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-12"> <div class="col-12">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_MINIFIGURES_SERVER_SIDE_PAGINATION" data-var="BK_MINIFIGURES_SERVER_SIDE_PAGINATION"> <input class="form-check-input config-toggle" type="checkbox" id="BK_MINIFIGURES_SERVER_SIDE_PAGINATION" data-var="BK_MINIFIGURES_SERVER_SIDE_PAGINATION" {{ is_locked('BK_MINIFIGURES_SERVER_SIDE_PAGINATION') }}>
<label class="form-check-label" for="BK_MINIFIGURES_SERVER_SIDE_PAGINATION"> <label class="form-check-label" for="BK_MINIFIGURES_SERVER_SIDE_PAGINATION">
BK_MINIFIGURES_SERVER_SIDE_PAGINATION {{ config_badges('BK_MINIFIGURES_SERVER_SIDE_PAGINATION') }} BK_MINIFIGURES_SERVER_SIDE_PAGINATION {{ config_badges('BK_MINIFIGURES_SERVER_SIDE_PAGINATION') }}
<div class="text-muted small">Enable/disable pagination for minifigures</div> <div class="text-muted small">Enable/disable pagination for minifigures</div>
@@ -366,7 +401,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-12"> <div class="col-12">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_PROBLEMS_SERVER_SIDE_PAGINATION" data-var="BK_PROBLEMS_SERVER_SIDE_PAGINATION"> <input class="form-check-input config-toggle" type="checkbox" id="BK_PROBLEMS_SERVER_SIDE_PAGINATION" data-var="BK_PROBLEMS_SERVER_SIDE_PAGINATION" {{ is_locked('BK_PROBLEMS_SERVER_SIDE_PAGINATION') }}>
<label class="form-check-label" for="BK_PROBLEMS_SERVER_SIDE_PAGINATION"> <label class="form-check-label" for="BK_PROBLEMS_SERVER_SIDE_PAGINATION">
BK_PROBLEMS_SERVER_SIDE_PAGINATION {{ config_badges('BK_PROBLEMS_SERVER_SIDE_PAGINATION') }} BK_PROBLEMS_SERVER_SIDE_PAGINATION {{ config_badges('BK_PROBLEMS_SERVER_SIDE_PAGINATION') }}
<div class="text-muted small">Enable/disable pagination for problems</div> <div class="text-muted small">Enable/disable pagination for problems</div>
@@ -409,7 +444,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_RANDOM" data-var="BK_RANDOM"> <input class="form-check-input config-toggle" type="checkbox" id="BK_RANDOM" data-var="BK_RANDOM" {{ is_locked('BK_RANDOM') }}>
<label class="form-check-label" for="BK_RANDOM"> <label class="form-check-label" for="BK_RANDOM">
BK_RANDOM {{ config_badges('BK_RANDOM') }} BK_RANDOM {{ config_badges('BK_RANDOM') }}
<div class="text-muted small">Shuffle the lists on the front page</div> <div class="text-muted small">Shuffle the lists on the front page</div>
@@ -417,7 +452,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_BRICKLINK_LINKS" data-var="BK_BRICKLINK_LINKS"> <input class="form-check-input config-toggle" type="checkbox" id="BK_BRICKLINK_LINKS" data-var="BK_BRICKLINK_LINKS" {{ is_locked('BK_BRICKLINK_LINKS') }}>
<label class="form-check-label" for="BK_BRICKLINK_LINKS"> <label class="form-check-label" for="BK_BRICKLINK_LINKS">
BK_BRICKLINK_LINKS {{ config_badges('BK_BRICKLINK_LINKS') }} BK_BRICKLINK_LINKS {{ config_badges('BK_BRICKLINK_LINKS') }}
<div class="text-muted small">Display BrickLink links wherever applicable</div> <div class="text-muted small">Display BrickLink links wherever applicable</div>
@@ -425,7 +460,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_REBRICKABLE_LINKS" data-var="BK_REBRICKABLE_LINKS"> <input class="form-check-input config-toggle" type="checkbox" id="BK_REBRICKABLE_LINKS" data-var="BK_REBRICKABLE_LINKS" {{ is_locked('BK_REBRICKABLE_LINKS') }}>
<label class="form-check-label" for="BK_REBRICKABLE_LINKS"> <label class="form-check-label" for="BK_REBRICKABLE_LINKS">
BK_REBRICKABLE_LINKS {{ config_badges('BK_REBRICKABLE_LINKS') }} BK_REBRICKABLE_LINKS {{ config_badges('BK_REBRICKABLE_LINKS') }}
<div class="text-muted small">Display Rebrickable links wherever applicable</div> <div class="text-muted small">Display Rebrickable links wherever applicable</div>
@@ -433,17 +468,25 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SKIP_SPARE_PARTS" data-var="BK_SKIP_SPARE_PARTS"> <input class="form-check-input config-toggle" type="checkbox" id="BK_SKIP_SPARE_PARTS" data-var="BK_SKIP_SPARE_PARTS" {{ is_locked('BK_SKIP_SPARE_PARTS') }}>
<label class="form-check-label" for="BK_SKIP_SPARE_PARTS"> <label class="form-check-label" for="BK_SKIP_SPARE_PARTS">
BK_SKIP_SPARE_PARTS {{ config_badges('BK_SKIP_SPARE_PARTS') }} BK_SKIP_SPARE_PARTS {{ config_badges('BK_SKIP_SPARE_PARTS') }}
<div class="text-muted small">Skip spare parts when importing sets</div> <div class="text-muted small">Skip importing spare parts when downloading sets from Rebrickable</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_SPARE_PARTS" data-var="BK_HIDE_SPARE_PARTS" {{ is_locked('BK_HIDE_SPARE_PARTS') }}>
<label class="form-check-label" for="BK_HIDE_SPARE_PARTS">
BK_HIDE_SPARE_PARTS {{ config_badges('BK_HIDE_SPARE_PARTS') }}
<div class="text-muted small">Hide spare parts from parts lists (spare parts must still be in database)</div>
</label> </label>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_USE_REMOTE_IMAGES" data-var="BK_USE_REMOTE_IMAGES"> <input class="form-check-input config-toggle" type="checkbox" id="BK_USE_REMOTE_IMAGES" data-var="BK_USE_REMOTE_IMAGES" {{ is_locked('BK_USE_REMOTE_IMAGES') }}>
<label class="form-check-label" for="BK_USE_REMOTE_IMAGES"> <label class="form-check-label" for="BK_USE_REMOTE_IMAGES">
BK_USE_REMOTE_IMAGES {{ config_badges('BK_USE_REMOTE_IMAGES') }} BK_USE_REMOTE_IMAGES {{ config_badges('BK_USE_REMOTE_IMAGES') }}
<div class="text-muted small">Use remote images from Rebrickable CDN instead of local storage</div> <div class="text-muted small">Use remote images from Rebrickable CDN instead of local storage</div>
@@ -451,7 +494,7 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_STATISTICS_SHOW_CHARTS" data-var="BK_STATISTICS_SHOW_CHARTS"> <input class="form-check-input config-toggle" type="checkbox" id="BK_STATISTICS_SHOW_CHARTS" data-var="BK_STATISTICS_SHOW_CHARTS" {{ is_locked('BK_STATISTICS_SHOW_CHARTS') }}>
<label class="form-check-label" for="BK_STATISTICS_SHOW_CHARTS"> <label class="form-check-label" for="BK_STATISTICS_SHOW_CHARTS">
BK_STATISTICS_SHOW_CHARTS {{ config_badges('BK_STATISTICS_SHOW_CHARTS') }} BK_STATISTICS_SHOW_CHARTS {{ config_badges('BK_STATISTICS_SHOW_CHARTS') }}
<div class="text-muted small">Show collection growth charts on statistics page</div> <div class="text-muted small">Show collection growth charts on statistics page</div>
@@ -459,12 +502,20 @@
</div> </div>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_STATISTICS_DEFAULT_EXPANDED" data-var="BK_STATISTICS_DEFAULT_EXPANDED"> <input class="form-check-input config-toggle" type="checkbox" id="BK_STATISTICS_DEFAULT_EXPANDED" data-var="BK_STATISTICS_DEFAULT_EXPANDED" {{ is_locked('BK_STATISTICS_DEFAULT_EXPANDED') }}>
<label class="form-check-label" for="BK_STATISTICS_DEFAULT_EXPANDED"> <label class="form-check-label" for="BK_STATISTICS_DEFAULT_EXPANDED">
BK_STATISTICS_DEFAULT_EXPANDED {{ config_badges('BK_STATISTICS_DEFAULT_EXPANDED') }} BK_STATISTICS_DEFAULT_EXPANDED {{ config_badges('BK_STATISTICS_DEFAULT_EXPANDED') }}
<div class="text-muted small">Expand all statistics sections by default</div> <div class="text-muted small">Expand all statistics sections by default</div>
</label> </label>
</div> </div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_DARK_MODE" data-var="BK_DARK_MODE" {{ is_locked('BK_DARK_MODE') }}>
<label class="form-check-label" for="BK_DARK_MODE">
BK_DARK_MODE {{ config_badges('BK_DARK_MODE') }}
<div class="text-muted small">Enable dark mode theme</div>
</label>
</div>
</div> </div>
</div> </div>
@@ -501,7 +552,7 @@
BK_ADMIN_DEFAULT_EXPANDED_SECTIONS {{ config_badges('BK_ADMIN_DEFAULT_EXPANDED_SECTIONS') }} BK_ADMIN_DEFAULT_EXPANDED_SECTIONS {{ config_badges('BK_ADMIN_DEFAULT_EXPANDED_SECTIONS') }}
<div class="text-muted small">Admin sections to expand by default (comma-separated)</div> <div class="text-muted small">Admin sections to expand by default (comma-separated)</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_ADMIN_DEFAULT_EXPANDED_SECTIONS" data-var="BK_ADMIN_DEFAULT_EXPANDED_SECTIONS"> <input type="text" class="form-control config-text" id="BK_ADMIN_DEFAULT_EXPANDED_SECTIONS" data-var="BK_ADMIN_DEFAULT_EXPANDED_SECTIONS" {{ is_locked('BK_ADMIN_DEFAULT_EXPANDED_SECTIONS') }}>
</div> </div>
</div> </div>
@@ -514,7 +565,7 @@
BK_INSTRUCTIONS_ALLOWED_EXTENSIONS {{ config_badges('BK_INSTRUCTIONS_ALLOWED_EXTENSIONS') }} BK_INSTRUCTIONS_ALLOWED_EXTENSIONS {{ config_badges('BK_INSTRUCTIONS_ALLOWED_EXTENSIONS') }}
<div class="text-muted small">Allowed file extensions for instructions (comma-separated)</div> <div class="text-muted small">Allowed file extensions for instructions (comma-separated)</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_INSTRUCTIONS_ALLOWED_EXTENSIONS" data-var="BK_INSTRUCTIONS_ALLOWED_EXTENSIONS"> <input type="text" class="form-control config-text" id="BK_INSTRUCTIONS_ALLOWED_EXTENSIONS" data-var="BK_INSTRUCTIONS_ALLOWED_EXTENSIONS" {{ is_locked('BK_INSTRUCTIONS_ALLOWED_EXTENSIONS') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -522,7 +573,7 @@
BK_MINIFIGURES_DEFAULT_ORDER {{ config_badges('BK_MINIFIGURES_DEFAULT_ORDER') }} BK_MINIFIGURES_DEFAULT_ORDER {{ config_badges('BK_MINIFIGURES_DEFAULT_ORDER') }}
<div class="text-muted small">SQL ORDER BY clause for minifigures listing</div> <div class="text-muted small">SQL ORDER BY clause for minifigures listing</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_MINIFIGURES_DEFAULT_ORDER" data-var="BK_MINIFIGURES_DEFAULT_ORDER"> <input type="text" class="form-control config-text" id="BK_MINIFIGURES_DEFAULT_ORDER" data-var="BK_MINIFIGURES_DEFAULT_ORDER" {{ is_locked('BK_MINIFIGURES_DEFAULT_ORDER') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -530,7 +581,7 @@
BK_PARTS_DEFAULT_ORDER {{ config_badges('BK_PARTS_DEFAULT_ORDER') }} BK_PARTS_DEFAULT_ORDER {{ config_badges('BK_PARTS_DEFAULT_ORDER') }}
<div class="text-muted small">SQL ORDER BY clause for parts listing</div> <div class="text-muted small">SQL ORDER BY clause for parts listing</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_PARTS_DEFAULT_ORDER" data-var="BK_PARTS_DEFAULT_ORDER"> <input type="text" class="form-control config-text" id="BK_PARTS_DEFAULT_ORDER" data-var="BK_PARTS_DEFAULT_ORDER" {{ is_locked('BK_PARTS_DEFAULT_ORDER') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -538,7 +589,7 @@
BK_SETS_DEFAULT_ORDER {{ config_badges('BK_SETS_DEFAULT_ORDER') }} BK_SETS_DEFAULT_ORDER {{ config_badges('BK_SETS_DEFAULT_ORDER') }}
<div class="text-muted small">SQL ORDER BY clause for sets listing</div> <div class="text-muted small">SQL ORDER BY clause for sets listing</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_SETS_DEFAULT_ORDER" data-var="BK_SETS_DEFAULT_ORDER"> <input type="text" class="form-control config-text" id="BK_SETS_DEFAULT_ORDER" data-var="BK_SETS_DEFAULT_ORDER" {{ is_locked('BK_SETS_DEFAULT_ORDER') }}>
</div> </div>
@@ -547,7 +598,7 @@
BK_PURCHASE_LOCATION_DEFAULT_ORDER {{ config_badges('BK_PURCHASE_LOCATION_DEFAULT_ORDER') }} BK_PURCHASE_LOCATION_DEFAULT_ORDER {{ config_badges('BK_PURCHASE_LOCATION_DEFAULT_ORDER') }}
<div class="text-muted small">SQL ORDER BY clause for purchase locations</div> <div class="text-muted small">SQL ORDER BY clause for purchase locations</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_PURCHASE_LOCATION_DEFAULT_ORDER" data-var="BK_PURCHASE_LOCATION_DEFAULT_ORDER"> <input type="text" class="form-control config-text" id="BK_PURCHASE_LOCATION_DEFAULT_ORDER" data-var="BK_PURCHASE_LOCATION_DEFAULT_ORDER" {{ is_locked('BK_PURCHASE_LOCATION_DEFAULT_ORDER') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -555,7 +606,7 @@
BK_STORAGE_DEFAULT_ORDER {{ config_badges('BK_STORAGE_DEFAULT_ORDER') }} BK_STORAGE_DEFAULT_ORDER {{ config_badges('BK_STORAGE_DEFAULT_ORDER') }}
<div class="text-muted small">SQL ORDER BY clause for storage locations</div> <div class="text-muted small">SQL ORDER BY clause for storage locations</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_STORAGE_DEFAULT_ORDER" data-var="BK_STORAGE_DEFAULT_ORDER"> <input type="text" class="form-control config-text" id="BK_STORAGE_DEFAULT_ORDER" data-var="BK_STORAGE_DEFAULT_ORDER" {{ is_locked('BK_STORAGE_DEFAULT_ORDER') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -563,7 +614,7 @@
BK_WISHES_DEFAULT_ORDER {{ config_badges('BK_WISHES_DEFAULT_ORDER') }} BK_WISHES_DEFAULT_ORDER {{ config_badges('BK_WISHES_DEFAULT_ORDER') }}
<div class="text-muted small">SQL ORDER BY clause for wishes listing</div> <div class="text-muted small">SQL ORDER BY clause for wishes listing</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_WISHES_DEFAULT_ORDER" data-var="BK_WISHES_DEFAULT_ORDER"> <input type="text" class="form-control config-text" id="BK_WISHES_DEFAULT_ORDER" data-var="BK_WISHES_DEFAULT_ORDER" {{ is_locked('BK_WISHES_DEFAULT_ORDER') }}>
</div> </div>
</div> </div>
@@ -576,7 +627,7 @@
BK_BRICKLINK_LINK_PART_PATTERN {{ config_badges('BK_BRICKLINK_LINK_PART_PATTERN') }} BK_BRICKLINK_LINK_PART_PATTERN {{ config_badges('BK_BRICKLINK_LINK_PART_PATTERN') }}
<div class="text-muted small">Pattern for BrickLink part links (supports {part} and {color})</div> <div class="text-muted small">Pattern for BrickLink part links (supports {part} and {color})</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_BRICKLINK_LINK_PART_PATTERN" data-var="BK_BRICKLINK_LINK_PART_PATTERN"> <input type="text" class="form-control config-text" id="BK_BRICKLINK_LINK_PART_PATTERN" data-var="BK_BRICKLINK_LINK_PART_PATTERN" {{ is_locked('BK_BRICKLINK_LINK_PART_PATTERN') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -584,7 +635,7 @@
BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN {{ config_badges('BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN') }} BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN {{ config_badges('BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN') }}
<div class="text-muted small">Pattern for Rebrickable minifigure links (supports {figure})</div> <div class="text-muted small">Pattern for Rebrickable minifigure links (supports {figure})</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN" data-var="BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN"> <input type="text" class="form-control config-text" id="BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN" data-var="BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN" {{ is_locked('BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -592,7 +643,7 @@
BK_REBRICKABLE_LINK_PART_PATTERN {{ config_badges('BK_REBRICKABLE_LINK_PART_PATTERN') }} BK_REBRICKABLE_LINK_PART_PATTERN {{ config_badges('BK_REBRICKABLE_LINK_PART_PATTERN') }}
<div class="text-muted small">Pattern for Rebrickable part links (supports {part} and {color})</div> <div class="text-muted small">Pattern for Rebrickable part links (supports {part} and {color})</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_REBRICKABLE_LINK_PART_PATTERN" data-var="BK_REBRICKABLE_LINK_PART_PATTERN"> <input type="text" class="form-control config-text" id="BK_REBRICKABLE_LINK_PART_PATTERN" data-var="BK_REBRICKABLE_LINK_PART_PATTERN" {{ is_locked('BK_REBRICKABLE_LINK_PART_PATTERN') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -600,7 +651,7 @@
BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN {{ config_badges('BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN') }} BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN {{ config_badges('BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN') }}
<div class="text-muted small">Pattern for Rebrickable instruction links (supports {path})</div> <div class="text-muted small">Pattern for Rebrickable instruction links (supports {path})</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN" data-var="BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN"> <input type="text" class="form-control config-text" id="BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN" data-var="BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN" {{ is_locked('BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -608,7 +659,7 @@
BK_PEERON_INSTRUCTION_PATTERN {{ config_badges('BK_PEERON_INSTRUCTION_PATTERN') }} BK_PEERON_INSTRUCTION_PATTERN {{ config_badges('BK_PEERON_INSTRUCTION_PATTERN') }}
<div class="text-muted small">Pattern for Peeron instruction URLs (supports {set_number} and {version_number})</div> <div class="text-muted small">Pattern for Peeron instruction URLs (supports {set_number} and {version_number})</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_PEERON_INSTRUCTION_PATTERN" data-var="BK_PEERON_INSTRUCTION_PATTERN"> <input type="text" class="form-control config-text" id="BK_PEERON_INSTRUCTION_PATTERN" data-var="BK_PEERON_INSTRUCTION_PATTERN" {{ is_locked('BK_PEERON_INSTRUCTION_PATTERN') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -616,7 +667,7 @@
BK_PEERON_SCAN_PATTERN {{ config_badges('BK_PEERON_SCAN_PATTERN') }} BK_PEERON_SCAN_PATTERN {{ config_badges('BK_PEERON_SCAN_PATTERN') }}
<div class="text-muted small">Pattern for Peeron scan URLs (supports {set_number} and {version_number})</div> <div class="text-muted small">Pattern for Peeron scan URLs (supports {set_number} and {version_number})</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_PEERON_SCAN_PATTERN" data-var="BK_PEERON_SCAN_PATTERN"> <input type="text" class="form-control config-text" id="BK_PEERON_SCAN_PATTERN" data-var="BK_PEERON_SCAN_PATTERN" {{ is_locked('BK_PEERON_SCAN_PATTERN') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -624,7 +675,7 @@
BK_PEERON_THUMBNAIL_PATTERN {{ config_badges('BK_PEERON_THUMBNAIL_PATTERN') }} BK_PEERON_THUMBNAIL_PATTERN {{ config_badges('BK_PEERON_THUMBNAIL_PATTERN') }}
<div class="text-muted small">Pattern for Peeron thumbnail URLs (supports {set_number} and {version_number})</div> <div class="text-muted small">Pattern for Peeron thumbnail URLs (supports {set_number} and {version_number})</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_PEERON_THUMBNAIL_PATTERN" data-var="BK_PEERON_THUMBNAIL_PATTERN"> <input type="text" class="form-control config-text" id="BK_PEERON_THUMBNAIL_PATTERN" data-var="BK_PEERON_THUMBNAIL_PATTERN" {{ is_locked('BK_PEERON_THUMBNAIL_PATTERN') }}>
</div> </div>
</div> </div>
@@ -637,7 +688,7 @@
BK_REBRICKABLE_IMAGE_NIL {{ config_badges('BK_REBRICKABLE_IMAGE_NIL') }} BK_REBRICKABLE_IMAGE_NIL {{ config_badges('BK_REBRICKABLE_IMAGE_NIL') }}
<div class="text-muted small">URL for missing image placeholder</div> <div class="text-muted small">URL for missing image placeholder</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_REBRICKABLE_IMAGE_NIL" data-var="BK_REBRICKABLE_IMAGE_NIL"> <input type="text" class="form-control config-text" id="BK_REBRICKABLE_IMAGE_NIL" data-var="BK_REBRICKABLE_IMAGE_NIL" {{ is_locked('BK_REBRICKABLE_IMAGE_NIL') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -645,7 +696,7 @@
BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE {{ config_badges('BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE') }} BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE {{ config_badges('BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE') }}
<div class="text-muted small">URL for missing minifigure image placeholder</div> <div class="text-muted small">URL for missing minifigure image placeholder</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE" data-var="BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE"> <input type="text" class="form-control config-text" id="BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE" data-var="BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE" {{ is_locked('BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -653,7 +704,7 @@
BK_RETIRED_SETS_FILE_URL {{ config_badges('BK_RETIRED_SETS_FILE_URL') }} BK_RETIRED_SETS_FILE_URL {{ config_badges('BK_RETIRED_SETS_FILE_URL') }}
<div class="text-muted small">URL to the retired sets CSV file</div> <div class="text-muted small">URL to the retired sets CSV file</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_RETIRED_SETS_FILE_URL" data-var="BK_RETIRED_SETS_FILE_URL"> <input type="text" class="form-control config-text" id="BK_RETIRED_SETS_FILE_URL" data-var="BK_RETIRED_SETS_FILE_URL" {{ is_locked('BK_RETIRED_SETS_FILE_URL') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -661,7 +712,7 @@
BK_RETIRED_SETS_PATH {{ config_badges('BK_RETIRED_SETS_PATH') }} BK_RETIRED_SETS_PATH {{ config_badges('BK_RETIRED_SETS_PATH') }}
<div class="text-muted small">Local path to store retired sets CSV</div> <div class="text-muted small">Local path to store retired sets CSV</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_RETIRED_SETS_PATH" data-var="BK_RETIRED_SETS_PATH"> <input type="text" class="form-control config-text" id="BK_RETIRED_SETS_PATH" data-var="BK_RETIRED_SETS_PATH" {{ is_locked('BK_RETIRED_SETS_PATH') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -669,7 +720,7 @@
BK_THEMES_FILE_URL {{ config_badges('BK_THEMES_FILE_URL') }} BK_THEMES_FILE_URL {{ config_badges('BK_THEMES_FILE_URL') }}
<div class="text-muted small">URL to the Rebrickable themes CSV file</div> <div class="text-muted small">URL to the Rebrickable themes CSV file</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_THEMES_FILE_URL" data-var="BK_THEMES_FILE_URL"> <input type="text" class="form-control config-text" id="BK_THEMES_FILE_URL" data-var="BK_THEMES_FILE_URL" {{ is_locked('BK_THEMES_FILE_URL') }}>
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -677,26 +728,17 @@
BK_THEMES_PATH {{ config_badges('BK_THEMES_PATH') }} BK_THEMES_PATH {{ config_badges('BK_THEMES_PATH') }}
<div class="text-muted small">Local path to store themes CSV</div> <div class="text-muted small">Local path to store themes CSV</div>
</label> </label>
<input type="text" class="form-control config-text" id="BK_THEMES_PATH" data-var="BK_THEMES_PATH"> <input type="text" class="form-control config-text" id="BK_THEMES_PATH" data-var="BK_THEMES_PATH" {{ is_locked('BK_THEMES_PATH') }}>
</div> </div>
</div> </div>
{{ accordion.footer() }}
<!-- Static Settings Accordion -->
</div> {{ accordion.header('Static Settings', 'static-settings', 'admin', expanded=false, icon='database-2-line', class='p-3') }}
</div> <div class="mb-3">
</div> <span class="badge text-bg-warning">Requires Restart</span>
</div>
<!-- Static Settings Sub-Drawer --> <!-- Static configuration with editable fields -->
<div class="accordion-item">
<h2 class="accordion-header" id="static-settings-heading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#static-settings-collapse" aria-expanded="false" aria-controls="static-settings-collapse">
<i class="ri-database-2-line me-2"></i> Static Settings
<span class="badge text-bg-warning ms-2">Requires Restart</span>
</button>
</h2>
<div id="static-settings-collapse" class="accordion-collapse collapse" aria-labelledby="static-settings-heading" data-bs-parent="#configuration-accordion">
<div class="accordion-body">
<!-- Static configuration with editable fields -->
<div class="alert alert-warning"> <div class="alert alert-warning">
<h6><i class="ri-warning-line"></i> Restart Required</h6> <h6><i class="ri-warning-line"></i> Restart Required</h6>
@@ -711,14 +753,14 @@
BK_AUTHENTICATION_PASSWORD {{ config_badges('BK_AUTHENTICATION_PASSWORD') }} BK_AUTHENTICATION_PASSWORD {{ config_badges('BK_AUTHENTICATION_PASSWORD') }}
<div class="text-muted small">Password for authentication system</div> <div class="text-muted small">Password for authentication system</div>
</label> </label>
<input type="password" class="form-control config-static" id="static-BK_AUTHENTICATION_PASSWORD" data-var="BK_AUTHENTICATION_PASSWORD"> <input type="password" class="form-control config-static" id="static-BK_AUTHENTICATION_PASSWORD" data-var="BK_AUTHENTICATION_PASSWORD" {{ is_locked('BK_AUTHENTICATION_PASSWORD') }}>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="static-BK_AUTHENTICATION_KEY" class="form-label"> <label for="static-BK_AUTHENTICATION_KEY" class="form-label">
BK_AUTHENTICATION_KEY {{ config_badges('BK_AUTHENTICATION_KEY') }} BK_AUTHENTICATION_KEY {{ config_badges('BK_AUTHENTICATION_KEY') }}
<div class="text-muted small">Secret key for session signing</div> <div class="text-muted small">Secret key for session signing</div>
</label> </label>
<input type="password" class="form-control config-static" id="static-BK_AUTHENTICATION_KEY" data-var="BK_AUTHENTICATION_KEY"> <input type="password" class="form-control config-static" id="static-BK_AUTHENTICATION_KEY" data-var="BK_AUTHENTICATION_KEY" {{ is_locked('BK_AUTHENTICATION_KEY') }}>
</div> </div>
</div> </div>
@@ -730,7 +772,7 @@
BK_HOST {{ config_badges('BK_HOST') }} BK_HOST {{ config_badges('BK_HOST') }}
<div class="text-muted small">Server host address to bind to</div> <div class="text-muted small">Server host address to bind to</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_HOST" data-var="BK_HOST"> <input type="text" class="form-control config-static" id="static-BK_HOST" data-var="BK_HOST" {{ is_locked('BK_HOST') }}>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="static-BK_PORT" class="form-label"> <label for="static-BK_PORT" class="form-label">
@@ -741,7 +783,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-static-toggle" type="checkbox" id="static-BK_DEBUG" data-var="BK_DEBUG"> <input class="form-check-input config-static-toggle" type="checkbox" id="static-BK_DEBUG" data-var="BK_DEBUG" {{ is_locked('BK_DEBUG') }}>
<label class="form-check-label" for="static-BK_DEBUG"> <label class="form-check-label" for="static-BK_DEBUG">
BK_DEBUG {{ config_badges('BK_DEBUG') }} BK_DEBUG {{ config_badges('BK_DEBUG') }}
<div class="text-muted small">Enable debug mode</div> <div class="text-muted small">Enable debug mode</div>
@@ -753,14 +795,14 @@
BK_DOMAIN_NAME {{ config_badges('BK_DOMAIN_NAME') }} BK_DOMAIN_NAME {{ config_badges('BK_DOMAIN_NAME') }}
<div class="text-muted small">Domain name for CORS configuration</div> <div class="text-muted small">Domain name for CORS configuration</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_DOMAIN_NAME" data-var="BK_DOMAIN_NAME"> <input type="text" class="form-control config-static" id="static-BK_DOMAIN_NAME" data-var="BK_DOMAIN_NAME" {{ is_locked('BK_DOMAIN_NAME') }}>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="static-BK_TIMEZONE" class="form-label"> <label for="static-BK_TIMEZONE" class="form-label">
BK_TIMEZONE {{ config_badges('BK_TIMEZONE') }} BK_TIMEZONE {{ config_badges('BK_TIMEZONE') }}
<div class="text-muted small">Application timezone</div> <div class="text-muted small">Application timezone</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_TIMEZONE" data-var="BK_TIMEZONE"> <input type="text" class="form-control config-static" id="static-BK_TIMEZONE" data-var="BK_TIMEZONE" {{ is_locked('BK_TIMEZONE') }}>
</div> </div>
</div> </div>
@@ -772,35 +814,35 @@
BK_DATABASE_PATH {{ config_badges('BK_DATABASE_PATH') }} BK_DATABASE_PATH {{ config_badges('BK_DATABASE_PATH') }}
<div class="text-muted small">Path to the SQLite database file</div> <div class="text-muted small">Path to the SQLite database file</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_DATABASE_PATH" data-var="BK_DATABASE_PATH"> <input type="text" class="form-control config-static" id="static-BK_DATABASE_PATH" data-var="BK_DATABASE_PATH" {{ is_locked('BK_DATABASE_PATH') }}>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="static-BK_INSTRUCTIONS_FOLDER" class="form-label"> <label for="static-BK_INSTRUCTIONS_FOLDER" class="form-label">
BK_INSTRUCTIONS_FOLDER {{ config_badges('BK_INSTRUCTIONS_FOLDER') }} BK_INSTRUCTIONS_FOLDER {{ config_badges('BK_INSTRUCTIONS_FOLDER') }}
<div class="text-muted small">Folder for instruction files</div> <div class="text-muted small">Folder for instruction files</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_INSTRUCTIONS_FOLDER" data-var="BK_INSTRUCTIONS_FOLDER"> <input type="text" class="form-control config-static" id="static-BK_INSTRUCTIONS_FOLDER" data-var="BK_INSTRUCTIONS_FOLDER" {{ is_locked('BK_INSTRUCTIONS_FOLDER') }}>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="static-BK_PARTS_FOLDER" class="form-label"> <label for="static-BK_PARTS_FOLDER" class="form-label">
BK_PARTS_FOLDER {{ config_badges('BK_PARTS_FOLDER') }} BK_PARTS_FOLDER {{ config_badges('BK_PARTS_FOLDER') }}
<div class="text-muted small">Folder for part images</div> <div class="text-muted small">Folder for part images</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_PARTS_FOLDER" data-var="BK_PARTS_FOLDER"> <input type="text" class="form-control config-static" id="static-BK_PARTS_FOLDER" data-var="BK_PARTS_FOLDER" {{ is_locked('BK_PARTS_FOLDER') }}>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="static-BK_SETS_FOLDER" class="form-label"> <label for="static-BK_SETS_FOLDER" class="form-label">
BK_SETS_FOLDER {{ config_badges('BK_SETS_FOLDER') }} BK_SETS_FOLDER {{ config_badges('BK_SETS_FOLDER') }}
<div class="text-muted small">Folder for set images</div> <div class="text-muted small">Folder for set images</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_SETS_FOLDER" data-var="BK_SETS_FOLDER"> <input type="text" class="form-control config-static" id="static-BK_SETS_FOLDER" data-var="BK_SETS_FOLDER" {{ is_locked('BK_SETS_FOLDER') }}>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="static-BK_MINIFIGURES_FOLDER" class="form-label"> <label for="static-BK_MINIFIGURES_FOLDER" class="form-label">
BK_MINIFIGURES_FOLDER {{ config_badges('BK_MINIFIGURES_FOLDER') }} BK_MINIFIGURES_FOLDER {{ config_badges('BK_MINIFIGURES_FOLDER') }}
<div class="text-muted small">Folder for minifigure images</div> <div class="text-muted small">Folder for minifigure images</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_MINIFIGURES_FOLDER" data-var="BK_MINIFIGURES_FOLDER"> <input type="text" class="form-control config-static" id="static-BK_MINIFIGURES_FOLDER" data-var="BK_MINIFIGURES_FOLDER" {{ is_locked('BK_MINIFIGURES_FOLDER') }}>
</div> </div>
</div> </div>
@@ -812,21 +854,21 @@
BK_REBRICKABLE_API_KEY {{ config_badges('BK_REBRICKABLE_API_KEY') }} BK_REBRICKABLE_API_KEY {{ config_badges('BK_REBRICKABLE_API_KEY') }}
<div class="text-muted small">API key for Rebrickable integration</div> <div class="text-muted small">API key for Rebrickable integration</div>
</label> </label>
<input type="password" class="form-control config-static" id="static-BK_REBRICKABLE_API_KEY" data-var="BK_REBRICKABLE_API_KEY"> <input type="password" class="form-control config-static" id="static-BK_REBRICKABLE_API_KEY" data-var="BK_REBRICKABLE_API_KEY" {{ is_locked('BK_REBRICKABLE_API_KEY') }}>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="static-BK_USER_AGENT" class="form-label"> <label for="static-BK_USER_AGENT" class="form-label">
BK_USER_AGENT {{ config_badges('BK_USER_AGENT') }} BK_USER_AGENT {{ config_badges('BK_USER_AGENT') }}
<div class="text-muted small">User agent string for HTTP requests</div> <div class="text-muted small">User agent string for HTTP requests</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_USER_AGENT" data-var="BK_USER_AGENT"> <input type="text" class="form-control config-static" id="static-BK_USER_AGENT" data-var="BK_USER_AGENT" {{ is_locked('BK_USER_AGENT') }}>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="static-BK_REBRICKABLE_USER_AGENT" class="form-label"> <label for="static-BK_REBRICKABLE_USER_AGENT" class="form-label">
BK_REBRICKABLE_USER_AGENT {{ config_badges('BK_REBRICKABLE_USER_AGENT') }} BK_REBRICKABLE_USER_AGENT {{ config_badges('BK_REBRICKABLE_USER_AGENT') }}
<div class="text-muted small">User agent for Rebrickable API requests</div> <div class="text-muted small">User agent for Rebrickable API requests</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_REBRICKABLE_USER_AGENT" data-var="BK_REBRICKABLE_USER_AGENT"> <input type="text" class="form-control config-static" id="static-BK_REBRICKABLE_USER_AGENT" data-var="BK_REBRICKABLE_USER_AGENT" {{ is_locked('BK_REBRICKABLE_USER_AGENT') }}>
</div> </div>
</div> </div>
@@ -838,28 +880,28 @@
BK_DATABASE_TIMESTAMP_FORMAT {{ config_badges('BK_DATABASE_TIMESTAMP_FORMAT') }} BK_DATABASE_TIMESTAMP_FORMAT {{ config_badges('BK_DATABASE_TIMESTAMP_FORMAT') }}
<div class="text-muted small">Database timestamp format</div> <div class="text-muted small">Database timestamp format</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_DATABASE_TIMESTAMP_FORMAT" data-var="BK_DATABASE_TIMESTAMP_FORMAT"> <input type="text" class="form-control config-static" id="static-BK_DATABASE_TIMESTAMP_FORMAT" data-var="BK_DATABASE_TIMESTAMP_FORMAT" {{ is_locked('BK_DATABASE_TIMESTAMP_FORMAT') }}>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="static-BK_FILE_DATETIME_FORMAT" class="form-label"> <label for="static-BK_FILE_DATETIME_FORMAT" class="form-label">
BK_FILE_DATETIME_FORMAT {{ config_badges('BK_FILE_DATETIME_FORMAT') }} BK_FILE_DATETIME_FORMAT {{ config_badges('BK_FILE_DATETIME_FORMAT') }}
<div class="text-muted small">File datetime format</div> <div class="text-muted small">File datetime format</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_FILE_DATETIME_FORMAT" data-var="BK_FILE_DATETIME_FORMAT"> <input type="text" class="form-control config-static" id="static-BK_FILE_DATETIME_FORMAT" data-var="BK_FILE_DATETIME_FORMAT" {{ is_locked('BK_FILE_DATETIME_FORMAT') }}>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="static-BK_PURCHASE_DATE_FORMAT" class="form-label"> <label for="static-BK_PURCHASE_DATE_FORMAT" class="form-label">
BK_PURCHASE_DATE_FORMAT {{ config_badges('BK_PURCHASE_DATE_FORMAT') }} BK_PURCHASE_DATE_FORMAT {{ config_badges('BK_PURCHASE_DATE_FORMAT') }}
<div class="text-muted small">Purchase date format</div> <div class="text-muted small">Purchase date format</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_PURCHASE_DATE_FORMAT" data-var="BK_PURCHASE_DATE_FORMAT"> <input type="text" class="form-control config-static" id="static-BK_PURCHASE_DATE_FORMAT" data-var="BK_PURCHASE_DATE_FORMAT" {{ is_locked('BK_PURCHASE_DATE_FORMAT') }}>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="static-BK_PURCHASE_CURRENCY" class="form-label"> <label for="static-BK_PURCHASE_CURRENCY" class="form-label">
BK_PURCHASE_CURRENCY {{ config_badges('BK_PURCHASE_CURRENCY') }} BK_PURCHASE_CURRENCY {{ config_badges('BK_PURCHASE_CURRENCY') }}
<div class="text-muted small">Purchase currency</div> <div class="text-muted small">Purchase currency</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_PURCHASE_CURRENCY" data-var="BK_PURCHASE_CURRENCY"> <input type="text" class="form-control config-static" id="static-BK_PURCHASE_CURRENCY" data-var="BK_PURCHASE_CURRENCY" {{ is_locked('BK_PURCHASE_CURRENCY') }}>
</div> </div>
</div> </div>
@@ -871,18 +913,18 @@
BK_SOCKET_NAMESPACE {{ config_badges('BK_SOCKET_NAMESPACE') }} BK_SOCKET_NAMESPACE {{ config_badges('BK_SOCKET_NAMESPACE') }}
<div class="text-muted small">Socket.IO namespace</div> <div class="text-muted small">Socket.IO namespace</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_SOCKET_NAMESPACE" data-var="BK_SOCKET_NAMESPACE"> <input type="text" class="form-control config-static" id="static-BK_SOCKET_NAMESPACE" data-var="BK_SOCKET_NAMESPACE" {{ is_locked('BK_SOCKET_NAMESPACE') }}>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="static-BK_SOCKET_PATH" class="form-label"> <label for="static-BK_SOCKET_PATH" class="form-label">
BK_SOCKET_PATH {{ config_badges('BK_SOCKET_PATH') }} BK_SOCKET_PATH {{ config_badges('BK_SOCKET_PATH') }}
<div class="text-muted small">Socket.IO path</div> <div class="text-muted small">Socket.IO path</div>
</label> </label>
<input type="text" class="form-control config-static" id="static-BK_SOCKET_PATH" data-var="BK_SOCKET_PATH"> <input type="text" class="form-control config-static" id="static-BK_SOCKET_PATH" data-var="BK_SOCKET_PATH" {{ is_locked('BK_SOCKET_PATH') }}>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input config-static-toggle" type="checkbox" id="static-BK_NO_THREADED_SOCKET" data-var="BK_NO_THREADED_SOCKET"> <input class="form-check-input config-static-toggle" type="checkbox" id="static-BK_NO_THREADED_SOCKET" data-var="BK_NO_THREADED_SOCKET" {{ is_locked('BK_NO_THREADED_SOCKET') }}>
<label class="form-check-label" for="static-BK_NO_THREADED_SOCKET"> <label class="form-check-label" for="static-BK_NO_THREADED_SOCKET">
BK_NO_THREADED_SOCKET {{ config_badges('BK_NO_THREADED_SOCKET') }} BK_NO_THREADED_SOCKET {{ config_badges('BK_NO_THREADED_SOCKET') }}
<div class="text-muted small">Disable threaded socket mode</div> <div class="text-muted small">Disable threaded socket mode</div>
@@ -891,17 +933,12 @@
</div> </div>
</div> </div>
<!-- Save Button for Static Settings --> <!-- Save Button for Static Settings -->
<div class="d-flex gap-2 justify-content-end mt-4"> <div class="d-flex gap-2 justify-content-end mt-4">
<button id="config-save-static" class="btn btn-warning"> <button id="config-save-static" class="btn btn-warning">
<i class="ri-save-line"></i> Save Static Settings to .env <i class="ri-save-line"></i> Save Static Settings to .env
</button> </button>
</div>
</div>
</div>
</div>
</div> </div>
</div>
{{ accordion.footer() }} {{ accordion.footer() }}
<!-- Initialize Configuration Data --> <!-- Initialize Configuration Data -->
+4
View File
@@ -6,6 +6,10 @@
<div class="alert alert-warning text-center" role="alert"> <div class="alert alert-warning text-center" role="alert">
You are about to <strong>upgrade your database file</strong>. This action is irreversible.<br> You are about to <strong>upgrade your database file</strong>. This action is irreversible.<br>
The process shold be lossless, but just to be sure, grab a copy of your database before proceeding.<br> The process shold be lossless, but just to be sure, grab a copy of your database before proceeding.<br>
Read about database migration in the <a href="https://bricktracker.baerentsen.space/upgrade-database">Upgrade your database</a> documentation<br>
</div>
<div class="alert alert-danger text-center" role="alert">
Please read <a href="https://bricktracker.baerentsen.space/migration_guide">Migration Guide</a> if you are upgrading to BrickTracker version 1.3<br>
</div> </div>
<h5 class="border-bottom">Upgrades</h5> <h5 class="border-bottom">Upgrades</h5>
<ul> <ul>
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" data-bs-theme="{{ 'dark' if config.get('DARK_MODE') in [True, 'True', 'true'] else 'light' }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
+43 -1
View File
@@ -3,7 +3,7 @@
<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"><i class="ri-user-line"></i><span class="ms-1 d-none d-md-inline"> Owner</span></span> <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"> <select id="filter-owner" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_owner == 'all' %}selected{% endif %}>All owners</option> <option value="all" {% if selected_owner == 'all' %}selected{% endif %}>All owners</option>
{% for owner in owners %} {% for owner in owners %}
<option value="{{ owner.fields.id }}" {% if selected_owner == owner.fields.id %}selected{% endif %}>{{ owner.fields.name }}</option> <option value="{{ owner.fields.id }}" {% if selected_owner == owner.fields.id %}selected{% endif %}>{{ owner.fields.name }}</option>
@@ -12,4 +12,46 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-error-warning-line"></i><span class="ms-1 d-none d-md-inline"> Problems</span></span>
<select id="filter-problems" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_problems == 'all' %}selected{% endif %}>All minifigures</option>
<option value="missing" {% if selected_problems == 'missing' %}selected{% endif %}>With missing parts</option>
<option value="damaged" {% if selected_problems == 'damaged' %}selected{% endif %}>With damaged parts</option>
<option value="both" {% if selected_problems == 'both' %}selected{% endif %}>With missing and damaged parts</option>
</select>
</div>
</div>
{% if themes | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-price-tag-3-line"></i><span class="ms-1 d-none d-md-inline"> Theme</span></span>
<select id="filter-theme" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_theme == 'all' %}selected{% endif %}>All themes</option>
{% for theme in themes %}
<option value="{{ theme.theme_id }}" {% if selected_theme == theme.theme_id|string %}selected{% endif %}>{{ theme.theme_name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if years | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-calendar-line"></i><span class="ms-1 d-none d-md-inline"> Year</span></span>
<select id="filter-year" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_year == 'all' %}selected{% endif %}>All years</option>
{% for year in years %}
<option value="{{ year.year }}" {% if selected_year == year.year|string %}selected{% endif %}>{{ year.year }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
<div class="col-12 col-lg-auto">
<button id="table-filter-clear" class="btn btn-outline-danger w-100" type="button" title="Clear all filters">
<i class="ri-filter-off-line"></i> <span class="d-none d-md-inline">Clear</span>
</button>
</div>
</div> </div>
+33 -2
View File
@@ -1,6 +1,6 @@
<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"> <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 %} {% if owners | length %}
<div class="col-12 col-md-6 flex-grow-1"> <div class="col-12 flex-grow-1">
<div class="input-group"> <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> <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"> <select id="filter-owner" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
@@ -13,7 +13,7 @@
</div> </div>
{% endif %} {% endif %}
{% if colors | length %} {% if colors | length %}
<div class="col-12 col-md-6 flex-grow-1"> <div class="col-12 flex-grow-1">
<div class="input-group"> <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> <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"> <select id="filter-color" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
@@ -27,4 +27,35 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if themes | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-price-tag-3-line"></i><span class="ms-1 d-none d-md-inline"> Theme</span></span>
<select id="filter-theme" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_theme == 'all' %}selected{% endif %}>All themes</option>
{% for theme in themes %}
<option value="{{ theme.theme_id }}" {% if selected_theme == theme.theme_id|string %}selected{% endif %}>{{ theme.theme_name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if years | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-calendar-line"></i><span class="ms-1 d-none d-md-inline"> Year</span></span>
<select id="filter-year" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_year == 'all' %}selected{% endif %}>All years</option>
{% for year in years %}
<option value="{{ year.year }}" {% if selected_year == year.year|string %}selected{% endif %}>{{ year.year }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
<div class="col-12 col-lg-auto">
<button id="table-filter-clear" class="btn btn-outline-danger w-100" type="button" title="Clear all filters">
<i class="ri-filter-off-line"></i> <span class="d-none d-md-inline">Clear</span>
</button>
</div>
</div> </div>
+59 -2
View File
@@ -1,6 +1,6 @@
<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"> <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 %} {% if owners | length %}
<div class="col-12 col-md-6 flex-grow-1"> <div class="col-12 flex-grow-1">
<div class="input-group"> <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> <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"> <select id="filter-owner" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
@@ -13,7 +13,7 @@
</div> </div>
{% endif %} {% endif %}
{% if colors | length %} {% if colors | length %}
<div class="col-12 col-md-6 flex-grow-1"> <div class="col-12 flex-grow-1">
<div class="input-group"> <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> <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"> <select id="filter-color" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
@@ -27,4 +27,61 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if themes | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-price-tag-3-line"></i><span class="ms-1 d-none d-md-inline"> Theme</span></span>
<select id="filter-theme" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_theme == 'all' %}selected{% endif %}>All themes</option>
{% for theme in themes %}
<option value="{{ theme.theme_id }}" {% if selected_theme == theme.theme_id|string %}selected{% endif %}>{{ theme.theme_name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if years | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-calendar-line"></i><span class="ms-1 d-none d-md-inline"> Year</span></span>
<select id="filter-year" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_year == 'all' %}selected{% endif %}>All years</option>
{% for year in years %}
<option value="{{ year.year }}" {% if selected_year == year.year|string %}selected{% endif %}>{{ year.year }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if storages | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-inbox-line"></i><span class="ms-1 d-none d-md-inline"> Storage</span></span>
<select id="filter-storage" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_storage == 'all' %}selected{% endif %}>All storages</option>
{% for storage in storages %}
<option value="{{ storage.storage_id }}" {% if selected_storage == storage.storage_id %}selected{% endif %}>{{ storage.storage_name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if tags | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-price-tag-line"></i><span class="ms-1 d-none d-md-inline"> Tag</span></span>
<select id="filter-tag" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_tag == 'all' %}selected{% endif %}>All tags</option>
{% for tag in tags %}
<option value="{{ tag.tag_id }}" {% if selected_tag == tag.tag_id %}selected{% endif %}>{{ tag.tag_name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
<div class="col-12 col-lg-auto">
<button id="table-filter-clear" class="btn btn-outline-danger w-100" type="button" title="Clear all filters">
<i class="ri-filter-off-line"></i> <span class="d-none d-md-inline">Clear</span>
</button>
</div>
</div> </div>
+7
View File
@@ -76,6 +76,8 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Force line break here to always have 2 rows: status/theme/owner/purchase_location on row 1, storage/tag/year/clear on row 2 -->
<div class="w-100"></div>
{% if brickset_storages | length %} {% if brickset_storages | length %}
<div class="col-12 flex-grow-1"> <div class="col-12 flex-grow-1">
<label class="visually-hidden" for="grid-owner">Storage</label> <label class="visually-hidden" for="grid-owner">Storage</label>
@@ -122,4 +124,9 @@
</select> </select>
</div> </div>
</div> </div>
<div class="col-12 col-lg-auto">
<button id="grid-filter-clear" class="btn btn-outline-danger w-100" type="button" title="Clear all filters">
<i class="ri-filter-off-line"></i> <span class="d-none d-md-inline">Clear</span>
</button>
</div>
</div> </div>