Fixed consolidating sets.
This commit is contained in:
@@ -332,6 +332,12 @@
|
||||
# Default: sets
|
||||
# BK_SETS_FOLDER=sets
|
||||
|
||||
# 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 disabled, each set copy is displayed individually (original behavior)
|
||||
# Default: false
|
||||
# BK_SETS_CONSOLIDATION=true
|
||||
|
||||
# Optional: Make the grid filters displayed by default, rather than collapsed
|
||||
# Default: false
|
||||
# BK_SHOW_GRID_FILTERS=true
|
||||
|
||||
@@ -77,6 +77,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
|
||||
{'n': 'RETIRED_SETS_PATH', 'd': './retired_sets.csv'},
|
||||
{'n': 'SETS_DEFAULT_ORDER', 'd': '"rebrickable_sets"."number" DESC, "rebrickable_sets"."version" ASC'}, # noqa: E501
|
||||
{'n': 'SETS_FOLDER', 'd': 'sets', 's': True},
|
||||
{'n': 'SETS_CONSOLIDATION', 'd': False, 'c': bool},
|
||||
{'n': 'SHOW_GRID_FILTERS', 'c': bool},
|
||||
{'n': 'SHOW_GRID_SORT', 'c': bool},
|
||||
{'n': 'SKIP_SPARE_PARTS', 'c': bool},
|
||||
|
||||
@@ -111,6 +111,16 @@ class BrickMetadataList(BrickRecordList[T]):
|
||||
in new.filter(**kwargs)
|
||||
])
|
||||
|
||||
# Return the items as a dictionary mapping column names to UUIDs
|
||||
@classmethod
|
||||
def as_column_mapping(cls, /, **kwargs) -> dict:
|
||||
new = cls.new()
|
||||
|
||||
return {
|
||||
record.as_column(): record.fields.id
|
||||
for record in new.filter(**kwargs)
|
||||
}
|
||||
|
||||
# Grab a specific status
|
||||
@classmethod
|
||||
def get(cls, id: str | None, /, *, allow_none: bool = False) -> T:
|
||||
|
||||
@@ -24,6 +24,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
|
||||
|
||||
# Queries
|
||||
all_query: str = 'set/list/all'
|
||||
consolidated_query: str = 'set/list/consolidated'
|
||||
damaged_minifigure_query: str = 'set/list/damaged_minifigure'
|
||||
damaged_part_query: str = 'set/list/damaged_part'
|
||||
generic_query: str = 'set/list/generic'
|
||||
@@ -46,8 +47,25 @@ class BrickSetList(BrickRecordList[BrickSet]):
|
||||
|
||||
# All the sets
|
||||
def all(self, /) -> Self:
|
||||
# Load the sets from the database
|
||||
self.list(do_theme=True)
|
||||
# Load the sets from the database with metadata context for filtering
|
||||
filter_context = {
|
||||
'owners': BrickSetOwnerList.as_columns(),
|
||||
'statuses': BrickSetStatusList.as_columns(),
|
||||
'tags': BrickSetTagList.as_columns(),
|
||||
}
|
||||
self.list(do_theme=True, **filter_context)
|
||||
|
||||
return self
|
||||
|
||||
# All sets in consolidated/grouped view
|
||||
def all_consolidated(self, /) -> Self:
|
||||
# Load the sets from the database using consolidated query with metadata context
|
||||
filter_context = {
|
||||
'owners_dict': BrickSetOwnerList.as_column_mapping(),
|
||||
'statuses_dict': BrickSetStatusList.as_column_mapping(),
|
||||
'tags_dict': BrickSetTagList.as_column_mapping(),
|
||||
}
|
||||
self.list(override_query=self.consolidated_query, do_theme=True, **filter_context)
|
||||
|
||||
return self
|
||||
|
||||
@@ -64,7 +82,8 @@ class BrickSetList(BrickRecordList[BrickSet]):
|
||||
owner_filter: str | None = None,
|
||||
purchase_location_filter: str | None = None,
|
||||
storage_filter: str | None = None,
|
||||
tag_filter: str | None = None
|
||||
tag_filter: str | None = None,
|
||||
use_consolidated: bool = True
|
||||
) -> tuple[Self, int]:
|
||||
# Convert theme name to theme ID for filtering
|
||||
theme_id_filter = None
|
||||
@@ -86,24 +105,51 @@ class BrickSetList(BrickRecordList[BrickSet]):
|
||||
'owners': BrickSetOwnerList.as_columns(),
|
||||
'statuses': BrickSetStatusList.as_columns(),
|
||||
'tags': BrickSetTagList.as_columns(),
|
||||
'owners_dict': BrickSetOwnerList.as_column_mapping(),
|
||||
'statuses_dict': BrickSetStatusList.as_column_mapping(),
|
||||
'tags_dict': BrickSetTagList.as_column_mapping(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Field mapping for sorting
|
||||
field_mapping = {
|
||||
'set': '"rebrickable_sets"."set"',
|
||||
'name': '"rebrickable_sets"."name"',
|
||||
'year': '"rebrickable_sets"."year"',
|
||||
'parts': '"rebrickable_sets"."number_of_parts"',
|
||||
'theme': '"rebrickable_sets"."theme_id"',
|
||||
'minifigures': '"total_minifigures"', # Use the alias from the SQL query
|
||||
'missing': '"total_missing"', # Use the alias from the SQL query
|
||||
'damaged': '"total_damaged"', # Use the alias from the SQL query
|
||||
'purchase-date': '"bricktracker_sets"."purchase_date"',
|
||||
'purchase-price': '"bricktracker_sets"."purchase_price"'
|
||||
}
|
||||
if use_consolidated:
|
||||
field_mapping = {
|
||||
'set': '"rebrickable_sets"."number", "rebrickable_sets"."version"',
|
||||
'name': '"rebrickable_sets"."name"',
|
||||
'year': '"rebrickable_sets"."year"',
|
||||
'parts': '"rebrickable_sets"."number_of_parts"',
|
||||
'theme': '"rebrickable_sets"."theme_id"',
|
||||
'minifigures': '"total_minifigures"',
|
||||
'missing': '"total_missing"',
|
||||
'damaged': '"total_damaged"',
|
||||
'instances': '"instance_count"', # New field for consolidated view
|
||||
'purchase-date': '"purchase_date"', # Use the MIN aggregated value
|
||||
'purchase-price': '"purchase_price"' # Use the MIN aggregated value
|
||||
}
|
||||
else:
|
||||
field_mapping = {
|
||||
'set': '"rebrickable_sets"."number", "rebrickable_sets"."version"',
|
||||
'name': '"rebrickable_sets"."name"',
|
||||
'year': '"rebrickable_sets"."year"',
|
||||
'parts': '"rebrickable_sets"."number_of_parts"',
|
||||
'theme': '"rebrickable_sets"."theme_id"',
|
||||
'minifigures': '"total_minifigures"', # Use the alias from the SQL query
|
||||
'missing': '"total_missing"', # Use the alias from the SQL query
|
||||
'damaged': '"total_damaged"', # Use the alias from the SQL query
|
||||
'purchase-date': '"bricktracker_sets"."purchase_date"',
|
||||
'purchase-price': '"bricktracker_sets"."purchase_price"'
|
||||
}
|
||||
|
||||
# Choose query based on whether filters are applied
|
||||
query_to_use = 'set/list/all_filtered' if has_filters else self.all_query
|
||||
# Choose query based on consolidation preference and filter complexity
|
||||
# Owner/tag filters still need to fall back to non-consolidated for now
|
||||
# due to complex aggregation requirements
|
||||
complex_filters = [owner_filter, tag_filter]
|
||||
if use_consolidated and not any(complex_filters):
|
||||
query_to_use = self.consolidated_query
|
||||
else:
|
||||
# Use filtered query when consolidation is disabled or complex filters applied
|
||||
query_to_use = 'set/list/all_filtered'
|
||||
|
||||
# Handle instructions filtering
|
||||
if status_filter in ['has-missing-instructions', '-has-missing-instructions']:
|
||||
@@ -114,6 +160,18 @@ class BrickSetList(BrickRecordList[BrickSet]):
|
||||
purchase_location_filter, storage_filter, tag_filter
|
||||
)
|
||||
|
||||
# Handle special case for set sorting with multiple columns
|
||||
if sort_field == 'set' and field_mapping:
|
||||
# Create custom order clause for set sorting
|
||||
direction = 'DESC' if sort_order.lower() == 'desc' else 'ASC'
|
||||
custom_order = f'"rebrickable_sets"."number" {direction}, "rebrickable_sets"."version" {direction}'
|
||||
filter_context['order'] = custom_order
|
||||
# Remove set from field mapping to avoid double-processing
|
||||
field_mapping_copy = field_mapping.copy()
|
||||
field_mapping_copy.pop('set', None)
|
||||
field_mapping = field_mapping_copy
|
||||
sort_field = None # Disable automatic ORDER BY construction
|
||||
|
||||
# Normal SQL-based filtering and pagination
|
||||
result, total_count = self.paginate(
|
||||
page=page,
|
||||
@@ -125,8 +183,11 @@ class BrickSetList(BrickRecordList[BrickSet]):
|
||||
**filter_context
|
||||
)
|
||||
|
||||
# Populate themes for filter dropdown from ALL sets, not just current page
|
||||
result._populate_themes_global()
|
||||
# Populate themes for filter dropdown from filtered dataset (not just current page)
|
||||
result._populate_themes_from_filtered_dataset(
|
||||
query_to_use,
|
||||
**filter_context
|
||||
)
|
||||
|
||||
return result, total_count
|
||||
|
||||
@@ -143,11 +204,36 @@ class BrickSetList(BrickRecordList[BrickSet]):
|
||||
def _theme_name_to_id(self, theme_name: str) -> str | None:
|
||||
"""Convert a theme name to theme ID for filtering"""
|
||||
try:
|
||||
from .sql import BrickSQL
|
||||
theme_list = BrickThemeList()
|
||||
|
||||
# Find all theme IDs that match the name
|
||||
matching_theme_ids = []
|
||||
for theme_id, theme in theme_list.themes.items():
|
||||
if theme.name.lower() == theme_name.lower():
|
||||
return str(theme_id)
|
||||
return None
|
||||
matching_theme_ids.append(str(theme_id))
|
||||
|
||||
if not matching_theme_ids:
|
||||
return None
|
||||
|
||||
# If only one match, return it
|
||||
if len(matching_theme_ids) == 1:
|
||||
return matching_theme_ids[0]
|
||||
|
||||
# Multiple matches - check which theme ID actually has sets in the user's collection
|
||||
sql = BrickSQL()
|
||||
for theme_id in matching_theme_ids:
|
||||
result = sql.fetchone(
|
||||
'set/check_theme_exists',
|
||||
theme_id=theme_id
|
||||
)
|
||||
count = result['count'] if result else 0
|
||||
if count > 0:
|
||||
return theme_id
|
||||
|
||||
# If none have sets, return the first match (fallback)
|
||||
return matching_theme_ids[0]
|
||||
|
||||
except Exception:
|
||||
# If themes can't be loaded, return None to disable theme filtering
|
||||
return None
|
||||
@@ -240,21 +326,53 @@ class BrickSetList(BrickRecordList[BrickSet]):
|
||||
purchase_location_filter, storage_filter, tag_filter
|
||||
)
|
||||
|
||||
def _populate_themes_global(self) -> None:
|
||||
"""Populate themes list from ALL sets, not just current page"""
|
||||
def _populate_themes_from_filtered_dataset(self, query_name: str, **filter_context) -> None:
|
||||
"""Populate themes list from filtered dataset (all pages, not just current page)"""
|
||||
try:
|
||||
# Load all sets to get all possible themes
|
||||
all_sets = BrickSetList().all()
|
||||
from .theme_list import BrickThemeList
|
||||
|
||||
# Use a simplified query to get just distinct theme_ids
|
||||
theme_context = dict(filter_context)
|
||||
theme_context.pop('limit', None)
|
||||
theme_context.pop('offset', None)
|
||||
|
||||
# Use a special lightweight query for themes
|
||||
theme_records = super().select(
|
||||
override_query='set/list/themes_only',
|
||||
**theme_context
|
||||
)
|
||||
|
||||
# Convert to theme names
|
||||
theme_list = BrickThemeList()
|
||||
themes = set()
|
||||
for record in all_sets.records:
|
||||
if hasattr(record, 'theme') and hasattr(record.theme, 'name'):
|
||||
themes.add(record.theme.name)
|
||||
for record in theme_records:
|
||||
theme_id = record.get('theme_id')
|
||||
if theme_id:
|
||||
theme = theme_list.get(theme_id)
|
||||
if theme and hasattr(theme, 'name'):
|
||||
themes.add(theme.name)
|
||||
|
||||
self.themes = list(themes)
|
||||
self.themes.sort()
|
||||
|
||||
except Exception:
|
||||
# Fall back to current page themes
|
||||
self._populate_themes()
|
||||
# Fall back to simpler approach: get themes from ALL sets (ignoring filters)
|
||||
# This is better than showing only current page themes
|
||||
try:
|
||||
from .theme_list import BrickThemeList
|
||||
all_sets = BrickSetList()
|
||||
all_sets.list(do_theme=True)
|
||||
|
||||
themes = set()
|
||||
for record in all_sets.records:
|
||||
if hasattr(record, 'theme') and hasattr(record.theme, 'name'):
|
||||
themes.add(record.theme.name)
|
||||
|
||||
self.themes = list(themes)
|
||||
self.themes.sort()
|
||||
except Exception:
|
||||
# Final fallback to current page themes
|
||||
self._populate_themes()
|
||||
|
||||
def _matches_search(self, record, search_query: str) -> bool:
|
||||
"""Check if record matches search query"""
|
||||
@@ -301,7 +419,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
|
||||
reverse = sort_order == 'desc'
|
||||
|
||||
if sort_field == 'set':
|
||||
return sorted(records, key=lambda r: r.fields.set, reverse=reverse)
|
||||
return sorted(records, key=lambda r: self._set_sort_key(r.fields.set), reverse=reverse)
|
||||
elif sort_field == 'name':
|
||||
return sorted(records, key=lambda r: r.fields.name, reverse=reverse)
|
||||
elif sort_field == 'year':
|
||||
@@ -312,6 +430,19 @@ class BrickSetList(BrickRecordList[BrickSet]):
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def _set_sort_key(self, set_number: str) -> tuple:
|
||||
"""Generate sort key for set numbers like '10121-1' -> (10121, 1)"""
|
||||
try:
|
||||
if '-' in set_number:
|
||||
main_part, version_part = set_number.split('-', 1)
|
||||
return (int(main_part), int(version_part))
|
||||
else:
|
||||
return (int(set_number), 0)
|
||||
except (ValueError, TypeError):
|
||||
# Fallback to string sorting if parsing fails
|
||||
return (float('inf'), set_number)
|
||||
|
||||
# Sets with a minifigure part damaged
|
||||
def damaged_minifigure(self, figure: str, /) -> Self:
|
||||
# Save the parameters to the fields
|
||||
|
||||
4
bricktracker/sql/set/check_theme_exists.sql
Normal file
4
bricktracker/sql/set/check_theme_exists.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
SELECT COUNT(*) as count
|
||||
FROM "bricktracker_sets"
|
||||
INNER JOIN "rebrickable_sets" ON "bricktracker_sets"."set" = "rebrickable_sets"."set"
|
||||
WHERE "rebrickable_sets"."theme_id" = {{ theme_id }}
|
||||
@@ -8,7 +8,7 @@ AND (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%')
|
||||
{% endif %}
|
||||
|
||||
{% if theme_filter %}
|
||||
AND "rebrickable_sets"."theme_id" = '{{ theme_filter }}'
|
||||
AND "rebrickable_sets"."theme_id" = {{ theme_filter }}
|
||||
{% endif %}
|
||||
|
||||
{% if storage_filter %}
|
||||
|
||||
166
bricktracker/sql/set/list/consolidated.sql
Normal file
166
bricktracker/sql/set/list/consolidated.sql
Normal file
@@ -0,0 +1,166 @@
|
||||
SELECT
|
||||
(SELECT MIN("id") FROM "bricktracker_sets" WHERE "set" = "rebrickable_sets"."set") AS "id",
|
||||
"rebrickable_sets"."set",
|
||||
"rebrickable_sets"."number",
|
||||
"rebrickable_sets"."version",
|
||||
"rebrickable_sets"."name",
|
||||
"rebrickable_sets"."year",
|
||||
"rebrickable_sets"."theme_id",
|
||||
"rebrickable_sets"."number_of_parts",
|
||||
"rebrickable_sets"."image",
|
||||
"rebrickable_sets"."url",
|
||||
COUNT("bricktracker_sets"."id") AS "instance_count",
|
||||
IFNULL(SUM("problem_join"."total_missing"), 0) AS "total_missing",
|
||||
IFNULL(SUM("problem_join"."total_damaged"), 0) AS "total_damaged",
|
||||
IFNULL(SUM("minifigures_join"."total"), 0) AS "total_minifigures",
|
||||
-- Keep one representative instance for display purposes
|
||||
GROUP_CONCAT("bricktracker_sets"."id", '|') AS "instance_ids",
|
||||
REPLACE(GROUP_CONCAT(DISTINCT "bricktracker_sets"."storage"), ',', '|') AS "storage",
|
||||
MIN("bricktracker_sets"."purchase_date") AS "purchase_date",
|
||||
REPLACE(GROUP_CONCAT(DISTINCT "bricktracker_sets"."purchase_location"), ',', '|') AS "purchase_location",
|
||||
MIN("bricktracker_sets"."purchase_price") AS "purchase_price"
|
||||
{% block owners %}
|
||||
{% if owners_dict %}
|
||||
{% for column, uuid in owners_dict.items() %}
|
||||
, MAX("bricktracker_set_owners"."{{ column }}") AS "{{ column }}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block tags %}
|
||||
{% if tags_dict %}
|
||||
{% for column, uuid in tags_dict.items() %}
|
||||
, MAX("bricktracker_set_tags"."{{ column }}") AS "{{ column }}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block statuses %}
|
||||
{% if statuses_dict %}
|
||||
{% for column, uuid in statuses_dict.items() %}
|
||||
, MAX("bricktracker_set_statuses"."{{ column }}") AS "{{ column }}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
FROM "bricktracker_sets"
|
||||
|
||||
INNER JOIN "rebrickable_sets"
|
||||
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
|
||||
|
||||
-- LEFT JOIN + SELECT to avoid messing the total
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
"bricktracker_parts"."id",
|
||||
SUM("bricktracker_parts"."missing") AS "total_missing",
|
||||
SUM("bricktracker_parts"."damaged") AS "total_damaged"
|
||||
FROM "bricktracker_parts"
|
||||
GROUP BY "bricktracker_parts"."id"
|
||||
) "problem_join"
|
||||
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "problem_join"."id"
|
||||
|
||||
-- LEFT JOIN + SELECT to avoid messing the total
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
"bricktracker_minifigures"."id",
|
||||
SUM("bricktracker_minifigures"."quantity") AS "total"
|
||||
FROM "bricktracker_minifigures"
|
||||
GROUP BY "bricktracker_minifigures"."id"
|
||||
) "minifigures_join"
|
||||
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "minifigures_join"."id"
|
||||
|
||||
{% if owners_dict %}
|
||||
LEFT JOIN "bricktracker_set_owners"
|
||||
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
|
||||
{% endif %}
|
||||
|
||||
{% if statuses_dict %}
|
||||
LEFT JOIN "bricktracker_set_statuses"
|
||||
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_statuses"."id"
|
||||
{% endif %}
|
||||
|
||||
{% if tags_dict %}
|
||||
LEFT JOIN "bricktracker_set_tags"
|
||||
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id"
|
||||
{% endif %}
|
||||
|
||||
{% block where %}
|
||||
WHERE 1=1
|
||||
{% if search_query %}
|
||||
AND (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%')
|
||||
OR LOWER("rebrickable_sets"."set") LIKE LOWER('%{{ search_query }}%'))
|
||||
{% endif %}
|
||||
|
||||
{% if theme_filter %}
|
||||
AND "rebrickable_sets"."theme_id" = {{ theme_filter }}
|
||||
{% endif %}
|
||||
|
||||
{% if storage_filter %}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND bs_filter."storage" = '{{ storage_filter }}'
|
||||
)
|
||||
{% endif %}
|
||||
|
||||
{% if purchase_location_filter %}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND bs_filter."purchase_location" = '{{ purchase_location_filter }}'
|
||||
)
|
||||
{% endif %}
|
||||
|
||||
{% if status_filter %}
|
||||
{% if status_filter == 'has-storage' %}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND bs_filter."storage" IS NOT NULL AND bs_filter."storage" != ''
|
||||
)
|
||||
{% elif status_filter == '-has-storage' %}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND bs_filter."storage" IS NOT NULL AND bs_filter."storage" != ''
|
||||
)
|
||||
{% elif status_filter.startswith('status-') %}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
JOIN "bricktracker_set_statuses" ON bs_filter."id" = "bricktracker_set_statuses"."id"
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND "bricktracker_set_statuses"."{{ status_filter.replace('-', '_') }}" = 1
|
||||
)
|
||||
{% elif status_filter.startswith('-status-') %}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
JOIN "bricktracker_set_statuses" ON bs_filter."id" = "bricktracker_set_statuses"."id"
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND "bricktracker_set_statuses"."{{ status_filter[1:].replace('-', '_') }}" = 1
|
||||
)
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
GROUP BY "rebrickable_sets"."set"
|
||||
|
||||
{% if status_filter %}
|
||||
{% if status_filter == 'has-missing' %}
|
||||
HAVING IFNULL(SUM("problem_join"."total_missing"), 0) > 0
|
||||
{% elif status_filter == '-has-missing' %}
|
||||
HAVING IFNULL(SUM("problem_join"."total_missing"), 0) = 0
|
||||
{% elif status_filter == 'has-damaged' %}
|
||||
HAVING IFNULL(SUM("problem_join"."total_damaged"), 0) > 0
|
||||
{% elif status_filter == '-has-damaged' %}
|
||||
HAVING IFNULL(SUM("problem_join"."total_damaged"), 0) = 0
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if order %}
|
||||
ORDER BY {{ order }}
|
||||
{% endif %}
|
||||
|
||||
{% if limit %}
|
||||
LIMIT {{ limit }}
|
||||
{% endif %}
|
||||
|
||||
{% if offset %}
|
||||
OFFSET {{ offset }}
|
||||
{% endif %}
|
||||
87
bricktracker/sql/set/list/themes_only.sql
Normal file
87
bricktracker/sql/set/list/themes_only.sql
Normal file
@@ -0,0 +1,87 @@
|
||||
SELECT DISTINCT "rebrickable_sets"."theme_id"
|
||||
FROM "bricktracker_sets"
|
||||
|
||||
INNER JOIN "rebrickable_sets"
|
||||
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
|
||||
|
||||
{% block where %}
|
||||
WHERE 1=1
|
||||
{% if search_query %}
|
||||
AND (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%')
|
||||
OR LOWER("rebrickable_sets"."set") LIKE LOWER('%{{ search_query }}%'))
|
||||
{% endif %}
|
||||
|
||||
{% if storage_filter %}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND bs_filter."storage" = '{{ storage_filter }}'
|
||||
)
|
||||
{% endif %}
|
||||
|
||||
{% if purchase_location_filter %}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND bs_filter."purchase_location" = '{{ purchase_location_filter }}'
|
||||
)
|
||||
{% endif %}
|
||||
|
||||
{% if status_filter %}
|
||||
{% if status_filter == 'has-storage' %}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND bs_filter."storage" IS NOT NULL AND bs_filter."storage" != ''
|
||||
)
|
||||
{% elif status_filter == '-has-storage' %}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND bs_filter."storage" IS NOT NULL AND bs_filter."storage" != ''
|
||||
)
|
||||
{% elif status_filter.startswith('status-') %}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
JOIN "bricktracker_set_statuses" ON bs_filter."id" = "bricktracker_set_statuses"."id"
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND "bricktracker_set_statuses"."{{ status_filter.replace('-', '_') }}" = 1
|
||||
)
|
||||
{% elif status_filter.startswith('-status-') %}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
JOIN "bricktracker_set_statuses" ON bs_filter."id" = "bricktracker_set_statuses"."id"
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND "bricktracker_set_statuses"."{{ status_filter[1:].replace('-', '_') }}" = 1
|
||||
)
|
||||
{% elif status_filter == 'has-missing' %}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
JOIN "bricktracker_parts" ON bs_filter."id" = "bricktracker_parts"."id"
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND "bricktracker_parts"."missing" > 0
|
||||
)
|
||||
{% elif status_filter == '-has-missing' %}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
JOIN "bricktracker_parts" ON bs_filter."id" = "bricktracker_parts"."id"
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND "bricktracker_parts"."missing" > 0
|
||||
)
|
||||
{% elif status_filter == 'has-damaged' %}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
JOIN "bricktracker_parts" ON bs_filter."id" = "bricktracker_parts"."id"
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND "bricktracker_parts"."damaged" > 0
|
||||
)
|
||||
{% elif status_filter == '-has-damaged' %}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
JOIN "bricktracker_parts" ON bs_filter."id" = "bricktracker_parts"."id"
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND "bricktracker_parts"."damaged" > 0
|
||||
)
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -64,13 +64,17 @@ def list() -> str:
|
||||
owner_filter=owner_filter,
|
||||
purchase_location_filter=purchase_location_filter,
|
||||
storage_filter=storage_filter,
|
||||
tag_filter=tag_filter
|
||||
tag_filter=tag_filter,
|
||||
use_consolidated=current_app.config['SETS_CONSOLIDATION']
|
||||
)
|
||||
|
||||
pagination_context = build_pagination_context(page, per_page, total_count, is_mobile)
|
||||
else:
|
||||
# ORIGINAL MODE - Single page with all data for client-side search
|
||||
sets = BrickSetList().all()
|
||||
if current_app.config['SETS_CONSOLIDATION']:
|
||||
sets = BrickSetList().all_consolidated()
|
||||
else:
|
||||
sets = BrickSetList().all()
|
||||
pagination_context = None
|
||||
|
||||
template_context = {
|
||||
@@ -239,13 +243,44 @@ def deleted(*, id: str) -> str:
|
||||
@set_page.route('/<id>/details', methods=['GET'])
|
||||
@exception_handler(__file__)
|
||||
def details(*, id: str) -> str:
|
||||
return render_template(
|
||||
'set.html',
|
||||
item=BrickSet().select_specific(id),
|
||||
open_instructions=request.args.get('open_instructions'),
|
||||
brickset_statuses=BrickSetStatusList.list(all=True),
|
||||
**set_metadata_lists(as_class=True)
|
||||
)
|
||||
# Load the specific set
|
||||
item = BrickSet().select_specific(id)
|
||||
|
||||
# Check if there are multiple instances of this set
|
||||
all_instances = BrickSetList()
|
||||
# Load all sets with metadata context for tags, owners, etc.
|
||||
filter_context = {
|
||||
'owners': BrickSetOwnerList.as_columns(),
|
||||
'statuses': BrickSetStatusList.as_columns(),
|
||||
'tags': BrickSetTagList.as_columns(),
|
||||
}
|
||||
all_instances.list(do_theme=True, **filter_context)
|
||||
|
||||
# Find all instances with the same set number
|
||||
same_set_instances = [
|
||||
record for record in all_instances.records
|
||||
if record.fields.set == item.fields.set
|
||||
]
|
||||
|
||||
# If consolidation is enabled and multiple instances exist, show consolidated view
|
||||
if current_app.config['SETS_CONSOLIDATION'] and len(same_set_instances) > 1:
|
||||
return render_template(
|
||||
'set.html',
|
||||
item=item,
|
||||
all_instances=same_set_instances,
|
||||
open_instructions=request.args.get('open_instructions'),
|
||||
brickset_statuses=BrickSetStatusList.list(all=True),
|
||||
**set_metadata_lists(as_class=True)
|
||||
)
|
||||
else:
|
||||
# Single instance or consolidation disabled, show normal view
|
||||
return render_template(
|
||||
'set.html',
|
||||
item=item,
|
||||
open_instructions=request.args.get('open_instructions'),
|
||||
brickset_statuses=BrickSetStatusList.list(all=True),
|
||||
**set_metadata_lists(as_class=True)
|
||||
)
|
||||
|
||||
|
||||
# Update problematic pieces of a set
|
||||
|
||||
@@ -91,7 +91,7 @@ class BrickGridFilter {
|
||||
attribute: select.value,
|
||||
bool: true,
|
||||
value: "1"
|
||||
})
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -106,19 +106,48 @@ class BrickGridFilter {
|
||||
const attribute = current.getAttribute(`data-${filter.attribute}`);
|
||||
|
||||
// Bool check
|
||||
// Attribute not equal value, or undefined and value is truthy
|
||||
// For boolean attributes (like owner/tag filtering)
|
||||
if (filter.bool) {
|
||||
if ((attribute != null && attribute != filter.value) || (attribute == null && filter.value == "1")) {
|
||||
current.parentElement.classList.add("d-none");
|
||||
return;
|
||||
if (filter.value == "1") {
|
||||
// Looking for sets WITH this attribute
|
||||
// Hide if attribute is missing (null) or explicitly "0"
|
||||
// For owner/tag attributes: missing = doesn't have this owner/tag
|
||||
if (attribute == null || attribute == "0") {
|
||||
current.parentElement.classList.add("d-none");
|
||||
return;
|
||||
}
|
||||
} else if (filter.value == "0") {
|
||||
// Looking for sets WITHOUT this attribute
|
||||
// Hide if attribute is present and "1"
|
||||
// For owner/tag attributes: present and "1" = has this owner/tag
|
||||
if (attribute == "1") {
|
||||
current.parentElement.classList.add("d-none");
|
||||
return;
|
||||
}
|
||||
// Note: null (missing) is treated as "doesn't have" which is what we want for value="0"
|
||||
}
|
||||
}
|
||||
|
||||
// Value check
|
||||
// Attribute not equal value, or attribute undefined
|
||||
else if ((attribute != null && attribute != filter.value) || attribute == null) {
|
||||
// For consolidated cards, attributes may be comma or pipe-separated (e.g., "storage1,storage2" or "storage1|storage2")
|
||||
else if (attribute == null) {
|
||||
// Hide if attribute is missing
|
||||
current.parentElement.classList.add("d-none");
|
||||
return;
|
||||
} else if (attribute.includes(',') || attribute.includes('|')) {
|
||||
// Handle comma or pipe-separated values (consolidated cards)
|
||||
const separator = attribute.includes('|') ? '|' : ',';
|
||||
const values = attribute.split(separator).map(v => v.trim());
|
||||
if (!values.includes(filter.value)) {
|
||||
current.parentElement.classList.add("d-none");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Handle single values (regular cards)
|
||||
if (attribute != filter.value) {
|
||||
current.parentElement.classList.add("d-none");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
static/scripts/set-details.js
Normal file
15
static/scripts/set-details.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// Set details page functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const collapseElement = document.getElementById('all-instances');
|
||||
const toggleIcon = document.getElementById('copies-toggle-icon');
|
||||
|
||||
if (collapseElement && toggleIcon) {
|
||||
collapseElement.addEventListener('shown.bs.collapse', function() {
|
||||
toggleIcon.className = 'ri-arrow-up-s-line fs-4';
|
||||
});
|
||||
|
||||
collapseElement.addEventListener('hidden.bs.collapse', function() {
|
||||
toggleIcon.className = 'ri-arrow-down-s-line fs-4';
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -193,4 +193,170 @@ function setupPaginationFilterDropdowns() {
|
||||
currentUrl.searchParams.set('page', '1');
|
||||
window.location.href = currentUrl.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// Set grouping functionality
|
||||
function initializeSetGrouping() {
|
||||
const groupToggle = document.getElementById('group-identical-sets');
|
||||
if (!groupToggle) return;
|
||||
|
||||
// Load saved state from localStorage
|
||||
const savedState = localStorage.getItem('groupIdenticalSets') === 'true';
|
||||
groupToggle.checked = savedState;
|
||||
|
||||
// Apply grouping on page load if enabled
|
||||
if (savedState) {
|
||||
applySetGrouping();
|
||||
}
|
||||
|
||||
// Listen for toggle changes
|
||||
groupToggle.addEventListener('change', function() {
|
||||
// Save state to localStorage
|
||||
localStorage.setItem('groupIdenticalSets', this.checked);
|
||||
|
||||
if (this.checked) {
|
||||
applySetGrouping();
|
||||
} else {
|
||||
removeSetGrouping();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applySetGrouping() {
|
||||
const grid = document.getElementById('grid');
|
||||
if (!grid) return;
|
||||
|
||||
const setCards = Array.from(grid.children);
|
||||
const groupedSets = {};
|
||||
|
||||
// Group sets by rebrickable_set_id
|
||||
setCards.forEach(cardCol => {
|
||||
const setCard = cardCol.querySelector('.card[data-set-id]');
|
||||
if (!setCard) return;
|
||||
|
||||
const setId = setCard.getAttribute('data-set-id');
|
||||
const rebrickableId = setCard.getAttribute('data-rebrickable-id');
|
||||
|
||||
if (!rebrickableId) return;
|
||||
|
||||
if (!groupedSets[rebrickableId]) {
|
||||
groupedSets[rebrickableId] = [];
|
||||
}
|
||||
|
||||
groupedSets[rebrickableId].push({
|
||||
cardCol: cardCol,
|
||||
setId: setId,
|
||||
rebrickableId: rebrickableId
|
||||
});
|
||||
});
|
||||
|
||||
// Process each group
|
||||
Object.keys(groupedSets).forEach(rebrickableId => {
|
||||
const group = groupedSets[rebrickableId];
|
||||
|
||||
if (group.length > 1) {
|
||||
createGroupedSetDisplay(group);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createGroupedSetDisplay(setGroup) {
|
||||
const firstSet = setGroup[0];
|
||||
const firstCard = firstSet.cardCol.querySelector('.card');
|
||||
|
||||
if (!firstCard) return;
|
||||
|
||||
// Calculate aggregate stats
|
||||
let totalMissing = 0;
|
||||
let totalDamaged = 0;
|
||||
let allSetIds = [];
|
||||
|
||||
setGroup.forEach(set => {
|
||||
const card = set.cardCol.querySelector('.card');
|
||||
|
||||
// Get missing and damaged counts from existing data attributes
|
||||
const missingCount = parseInt(card.getAttribute('data-missing') || '0');
|
||||
const damagedCount = parseInt(card.getAttribute('data-damaged') || '0');
|
||||
|
||||
totalMissing += missingCount;
|
||||
totalDamaged += damagedCount;
|
||||
allSetIds.push(set.setId);
|
||||
});
|
||||
|
||||
// Create grouped card container
|
||||
const groupContainer = document.createElement('div');
|
||||
groupContainer.className = firstSet.cardCol.className + ' set-group-container';
|
||||
groupContainer.innerHTML = `
|
||||
<div class="card set-group-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center">
|
||||
<button class="btn btn-sm btn-outline-primary me-2 group-toggle-btn"
|
||||
type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#group-${setGroup[0].rebrickableId}"
|
||||
aria-expanded="false">
|
||||
<i class="ri-arrow-right-line"></i>
|
||||
</button>
|
||||
<span class="fw-bold">${firstCard.querySelector('.card-title')?.textContent || 'Set'}</span>
|
||||
<span class="badge bg-secondary ms-2">${setGroup.length} sets</span>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
${totalMissing > 0 ? `<span class="badge bg-warning text-dark">${totalMissing} missing</span>` : ''}
|
||||
${totalDamaged > 0 ? `<span class="badge bg-danger">${totalDamaged} damaged</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse" id="group-${setGroup[0].rebrickableId}">
|
||||
<div class="card-body">
|
||||
<div class="row set-group-items"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add individual set cards to the group
|
||||
const groupItems = groupContainer.querySelector('.set-group-items');
|
||||
setGroup.forEach(set => {
|
||||
const itemContainer = document.createElement('div');
|
||||
itemContainer.className = 'col-12 mb-2';
|
||||
|
||||
// Clone the original card but make it smaller
|
||||
const clonedCard = set.cardCol.querySelector('.card').cloneNode(true);
|
||||
clonedCard.classList.add('set-group-item');
|
||||
|
||||
itemContainer.appendChild(clonedCard);
|
||||
groupItems.appendChild(itemContainer);
|
||||
|
||||
// Hide the original card
|
||||
set.cardCol.style.display = 'none';
|
||||
set.cardCol.classList.add('grouped-set-hidden');
|
||||
});
|
||||
|
||||
// Insert the grouped container before the first hidden set
|
||||
firstSet.cardCol.parentNode.insertBefore(groupContainer, firstSet.cardCol);
|
||||
|
||||
// Add event listener to toggle arrow icon
|
||||
const toggleBtn = groupContainer.querySelector('.group-toggle-btn');
|
||||
const collapseElement = groupContainer.querySelector('.collapse');
|
||||
|
||||
collapseElement.addEventListener('shown.bs.collapse', () => {
|
||||
toggleBtn.querySelector('i').className = 'ri-arrow-down-line';
|
||||
});
|
||||
|
||||
collapseElement.addEventListener('hidden.bs.collapse', () => {
|
||||
toggleBtn.querySelector('i').className = 'ri-arrow-right-line';
|
||||
});
|
||||
}
|
||||
|
||||
function removeSetGrouping() {
|
||||
// Show all hidden sets
|
||||
const hiddenSets = document.querySelectorAll('.grouped-set-hidden');
|
||||
hiddenSets.forEach(setCol => {
|
||||
setCol.style.display = '';
|
||||
setCol.classList.remove('grouped-set-hidden');
|
||||
});
|
||||
|
||||
// Remove all group containers
|
||||
const groupContainers = document.querySelectorAll('.set-group-container');
|
||||
groupContainers.forEach(container => {
|
||||
container.remove();
|
||||
});
|
||||
}
|
||||
@@ -107,6 +107,7 @@
|
||||
{% endif %}
|
||||
{% if request.endpoint == 'set.details' %}
|
||||
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='scripts/set-details.js') }}"></script>
|
||||
{% endif %}
|
||||
{% if request.endpoint == 'instructions.download' or request.endpoint == 'instructions.do_download' %}
|
||||
<script src="{{ url_for('static', filename='scripts/socket/peeron.js') }}"></script>
|
||||
|
||||
@@ -75,14 +75,34 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% macro purchase_location(item, purchase_locations, solo=false, last=false) %}
|
||||
{% if purchase_locations and item.fields.purchase_location in purchase_locations.mapping %}
|
||||
{% set purchase_location = purchase_locations.mapping[item.fields.purchase_location] %}
|
||||
{% if last %}
|
||||
{% set tooltip=purchase_location.fields.name %}
|
||||
{% if purchase_locations and item.fields.purchase_location %}
|
||||
{% if '|' in item.fields.purchase_location or ',' in item.fields.purchase_location %}
|
||||
{# Consolidated mode - multiple purchase locations #}
|
||||
{% set separator = '|' if '|' in item.fields.purchase_location else ',' %}
|
||||
{% set location_list = item.fields.purchase_location.split(separator) %}
|
||||
{% for location_id in location_list %}
|
||||
{% if location_id and location_id.strip() and location_id.strip() in purchase_locations.mapping %}
|
||||
{% set purchase_location = purchase_locations.mapping[location_id.strip()] %}
|
||||
{% if last %}
|
||||
{% set tooltip=purchase_location.fields.name %}
|
||||
{% else %}
|
||||
{% set text=purchase_location.fields.name %}
|
||||
{% endif %}
|
||||
{{ badge(url=purchase_location.url(), solo=solo, last=last, color='light border', icon='building-line', text=text, tooltip=tooltip, collapsible='Location:') }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% set text=purchase_location.fields.name %}
|
||||
{# Single purchase location #}
|
||||
{% if item.fields.purchase_location.strip() and item.fields.purchase_location.strip() in purchase_locations.mapping %}
|
||||
{% set purchase_location = purchase_locations.mapping[item.fields.purchase_location.strip()] %}
|
||||
{% if last %}
|
||||
{% set tooltip=purchase_location.fields.name %}
|
||||
{% else %}
|
||||
{% set text=purchase_location.fields.name %}
|
||||
{% endif %}
|
||||
{{ badge(url=purchase_location.url(), solo=solo, last=last, color='light border', icon='building-line', text=text, tooltip=tooltip, collapsible='Location:') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{{ badge(check=purchase_location, solo=solo, last=last, color='light border', icon='building-line', text=text, tooltip=tooltip, collapsible='Location:') }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
@@ -103,14 +123,34 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% macro storage(item, storages, solo=false, last=false) %}
|
||||
{% if storages and item.fields.storage in storages.mapping %}
|
||||
{% set storage = storages.mapping[item.fields.storage] %}
|
||||
{% if last %}
|
||||
{% set tooltip=storage.fields.name %}
|
||||
{% if storages and item.fields.storage %}
|
||||
{% if '|' in item.fields.storage or ',' in item.fields.storage %}
|
||||
{# Consolidated mode - multiple storage locations #}
|
||||
{% set separator = '|' if '|' in item.fields.storage else ',' %}
|
||||
{% set storage_list = item.fields.storage.split(separator) %}
|
||||
{% for storage_id in storage_list %}
|
||||
{% if storage_id and storage_id.strip() and storage_id.strip() in storages.mapping %}
|
||||
{% set storage = storages.mapping[storage_id.strip()] %}
|
||||
{% if last %}
|
||||
{% set tooltip=storage.fields.name %}
|
||||
{% else %}
|
||||
{% set text=storage.fields.name %}
|
||||
{% endif %}
|
||||
{{ badge(url=storage.url(), solo=solo, last=last, color='light text-warning-emphasis bg-warning-subtle border border-warning-subtle', icon='archive-2-line', text=text, alt='Storage', tooltip=tooltip) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% set text=storage.fields.name %}
|
||||
{# Single storage location #}
|
||||
{% if item.fields.storage.strip() and item.fields.storage.strip() in storages.mapping %}
|
||||
{% set storage = storages.mapping[item.fields.storage.strip()] %}
|
||||
{% if last %}
|
||||
{% set tooltip=storage.fields.name %}
|
||||
{% else %}
|
||||
{% set text=storage.fields.name %}
|
||||
{% endif %}
|
||||
{{ badge(url=storage.url(), solo=solo, last=last, color='light text-warning-emphasis bg-warning-subtle border border-warning-subtle', icon='archive-2-line', text=text, alt='Storage', tooltip=tooltip) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{{ badge(url=item.url_for_storage(), solo=solo, last=last, color='light text-warning-emphasis bg-warning-subtle border border-warning-subtle', icon='archive-2-line', text=text, alt='Storage', tooltip=tooltip) }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
@@ -6,9 +6,92 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% with solo=true, delete=delete %}
|
||||
{% include 'set/card.html' %}
|
||||
{% endwith %}
|
||||
{% if all_instances and all_instances | length > 1 %}
|
||||
<!-- Multiple instances view with drawer-style header -->
|
||||
<div class="alert alert-info p-0">
|
||||
<!-- Clickable drawer header -->
|
||||
<div class="alert alert-info mb-0" style="cursor: pointer;" data-bs-toggle="collapse"
|
||||
data-bs-target="#all-instances" aria-expanded="false" aria-controls="all-instances">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h4 class="mb-1">
|
||||
<i class="ri-stack-line me-2"></i>Multiple Copies Available
|
||||
<span class="badge bg-primary ms-2">{{ all_instances | length }}</span>
|
||||
</h4>
|
||||
<p class="mb-0">This set has {{ all_instances | length }} copies in your collection. Click to view all copies individually.</p>
|
||||
</div>
|
||||
<i class="ri-arrow-down-s-line fs-4" id="copies-toggle-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible instances section -->
|
||||
<div class="collapse" id="all-instances">
|
||||
<div class="px-3 pb-3">
|
||||
<div class="row">
|
||||
{% set current_item_id = item.fields.id %}
|
||||
{% for instance in all_instances %}
|
||||
<div class="col-md-6 col-xl-4 d-flex align-items-stretch mb-3">
|
||||
{% with item=instance, index=loop.index0, tiny=false %}
|
||||
<div class="position-relative w-100">
|
||||
{% include 'set/card.html' %}
|
||||
<!-- Custom label overlay for distinguishing instances -->
|
||||
{% set instance_name = [] %}
|
||||
{% if instance.fields.storage %}
|
||||
{% set _ = instance_name.append(instance.fields.storage) %}
|
||||
{% endif %}
|
||||
{% if instance.fields.purchase_location %}
|
||||
{% set _ = instance_name.append("from " + instance.fields.purchase_location) %}
|
||||
{% endif %}
|
||||
{% set instance_tags = [] %}
|
||||
{% for tag in brickset_tags %}
|
||||
{% if instance.fields[tag.as_column()] %}
|
||||
{% set _ = instance_tags.append(tag.fields.name) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if instance_tags %}
|
||||
{% set _ = instance_name.append("[" + instance_tags | join(", ") + "]") %}
|
||||
{% endif %}
|
||||
{% if instance.fields.purchase_date %}
|
||||
{% set _ = instance_name.append("(" + instance.purchase_date() + ")") %}
|
||||
{% endif %}
|
||||
{% if instance_name %}
|
||||
<div class="position-absolute top-0 end-0 m-2">
|
||||
<span class="badge bg-dark bg-opacity-75 text-wrap text-start" style="max-width: 150px;">
|
||||
{{ instance_name | join(" ") }}
|
||||
{% if instance.fields.id == current_item_id %}
|
||||
<br><small class="text-info">Currently Viewing</small>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="position-absolute top-0 end-0 m-2">
|
||||
<span class="badge bg-secondary">
|
||||
Copy #{{ loop.index }}
|
||||
{% if instance.fields.id == current_item_id %}
|
||||
<br><small class="text-info">Current</small>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% with solo=true, delete=delete %}
|
||||
{% include 'set/card.html' %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<!-- Single instance view -->
|
||||
{% with solo=true, delete=delete %}
|
||||
{% include 'set/card.html' %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
{% import 'macro/card.html' as card %}
|
||||
{% import 'macro/form.html' as form %}
|
||||
|
||||
<div {% if not solo %}id="set-{{ item.fields.id }}"{% endif %} class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}"
|
||||
<div {% if not solo %}id="set-{{ item.fields.consolidated_id or item.fields.id }}"{% endif %} class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}"
|
||||
data-set-id="{{ item.fields.consolidated_id or item.fields.id }}" data-rebrickable-id="{{ item.fields.set }}"
|
||||
{% if item.fields.instance_count is defined %}data-instance-count="{{ item.fields.instance_count }}" data-instance-ids="{{ item.fields.instance_ids }}"{% endif %}
|
||||
{% if not solo and not tiny %}
|
||||
data-index="{{ index }}" data-number="{{ item.fields.set }}" data-name="{{ item.fields.name | lower }}" data-parts="{{ item.fields.number_of_parts }}"
|
||||
data-index="{{ index }}" data-number="{{ item.fields.set }}" data-set="{{ item.fields.set.split('-')[0] | int }}{{ '%05d' | format(item.fields.set.split('-')[1] | int) }}" data-name="{{ item.fields.name | lower }}" data-parts="{{ item.fields.number_of_parts }}"
|
||||
data-year="{{ item.fields.year }}" data-theme="{{ item.theme.name | lower }}"
|
||||
{% if not config['HIDE_SET_INSTRUCTIONS'] %}
|
||||
data-has-missing-instructions="{{ (item.instructions | length == 0) | int }}"
|
||||
@@ -63,6 +65,9 @@
|
||||
{{ badge.year(item.fields.year, solo=solo, last=last) }}
|
||||
{% endif %}
|
||||
{{ badge.parts(item.fields.number_of_parts, solo=solo, last=last) }}
|
||||
{% if item.fields.instance_count is defined and item.fields.instance_count > 1 %}
|
||||
<span class="badge bg-primary"><i class="ri-stack-line"></i> {{ item.fields.instance_count }} copies</span>
|
||||
{% endif %}
|
||||
{{ badge.total_minifigures(item.fields.total_minifigures, solo=solo, last=last) }}
|
||||
{{ badge.total_missing(item.fields.total_missing, solo=solo, last=last) }}
|
||||
{{ badge.total_damaged(item.fields.total_damaged, solo=solo, last=last) }}
|
||||
|
||||
Reference in New Issue
Block a user