Added filter/search/pagination to 'Problems'

This commit is contained in:
2025-09-22 09:36:25 +02:00
parent 9d0a48ee2a
commit b5236fae51
10 changed files with 502 additions and 9 deletions
+10 -1
View File
@@ -46,7 +46,10 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'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_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int},
{'n': 'PARTS_PAGINATION_SIZE_MOBILE', 'd': 10, 'c': int},
{'n': 'PARTS_PAGINATION_SIZE_MOBILE', 'd': 5, 'c': int},
{'n': 'PROBLEMS_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int},
{'n': 'PROBLEMS_PAGINATION_SIZE_MOBILE', 'd': 10, 'c': int},
{'n': 'PROBLEMS_SERVER_SIDE_PAGINATION', 'c': bool},
{'n': 'SETS_PAGINATION_SIZE_DESKTOP', 'd': 12, 'c': int},
{'n': 'SETS_PAGINATION_SIZE_MOBILE', 'd': 4, 'c': int},
{'n': 'PORT', 'd': 3333, 'c': int},
@@ -61,6 +64,12 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{part}/_/{color}'}, # noqa: E501
{'n': 'REBRICKABLE_LINK_INSTRUCTIONS_PATTERN', 'd': 'https://rebrickable.com/instructions/{path}'}, # noqa: E501
{'n': 'REBRICKABLE_USER_AGENT', 'd': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}, # noqa: E501
{'n': 'USER_AGENT', 'd': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}, # noqa: E501
{'n': 'PEERON_DOWNLOAD_DELAY', 'd': 1000, 'c': int},
{'n': 'PEERON_INSTRUCTION_PATTERN', 'd': 'http://peeron.com/scans/{set_number}-{version_number}'},
{'n': 'PEERON_MIN_IMAGE_SIZE', 'd': 100, 'c': int},
{'n': 'PEERON_SCAN_PATTERN', 'd': 'http://belay.peeron.com/scans/{set_number}-{version_number}/'},
{'n': 'PEERON_THUMBNAIL_PATTERN', 'd': 'http://belay.peeron.com/thumbs/{set_number}-{version_number}/'},
{'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool},
{'n': 'REBRICKABLE_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
+43
View File
@@ -238,6 +238,49 @@ class BrickPartList(BrickRecordList[BrickPart]):
return self
def problem_paginated(
self,
owner_id: str | None = None,
color_id: str | None = None,
search_query: str | None = None,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc'
) -> tuple[Self, int]:
# Prepare filter context
filter_context = {}
if owner_id and owner_id != 'all':
filter_context['owner_id'] = owner_id
if color_id and color_id != 'all':
filter_context['color_id'] = color_id
if search_query:
filter_context['search_query'] = search_query
if current_app.config.get('SKIP_SPARE_PARTS', False):
filter_context['skip_spare_parts'] = True
# Field mapping for sorting
field_mapping = {
'name': '"rebrickable_parts"."name"',
'color': '"rebrickable_parts"."color_name"',
'quantity': '"total_quantity"',
'missing': '"total_missing"',
'damaged': '"total_damaged"',
'sets': '"total_sets"',
'minifigures': '"total_minifigures"'
}
# Use the base pagination method with problem query
return self.paginate(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=self.problem_query,
field_mapping=field_mapping,
**filter_context
)
# Return a dict with common SQL parameters for a parts list
def sql_parameters(self, /) -> dict[str, Any]:
parameters: dict[str, Any] = super().sql_parameters()
@@ -0,0 +1,19 @@
SELECT DISTINCT
"rebrickable_parts"."color_id" AS "color_id",
"rebrickable_parts"."color_name" AS "color_name",
"rebrickable_parts"."color_rgb" AS "color_rgb"
FROM "rebrickable_parts"
INNER JOIN "bricktracker_parts"
ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
{% if owner_id and owner_id != 'all' %}
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
{% endif %}
WHERE ("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)
{% if owner_id and owner_id != 'all' %}
AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% endif %}
ORDER BY "rebrickable_parts"."color_name" ASC
+51 -3
View File
@@ -1,30 +1,78 @@
{% extends 'part/base/base.sql' %}
{% block total_missing %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."missing" ELSE 0 END) AS "total_missing",
{% else %}
SUM("bricktracker_parts"."missing") AS "total_missing",
{% endif %}
{% endblock %}
{% block total_damaged %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."damaged" ELSE 0 END) AS "total_damaged",
{% else %}
SUM("bricktracker_parts"."damaged") AS "total_damaged",
{% endif %}
{% endblock %}
{% block total_quantity %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1) ELSE 0 END) AS "total_quantity",
{% else %}
SUM("bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
{% endif %}
{% endblock %}
{% block total_sets %}
IFNULL(COUNT("bricktracker_parts"."id"), 0) - IFNULL(COUNT("bricktracker_parts"."figure"), 0) AS "total_sets",
{% if owner_id and owner_id != 'all' %}
COUNT(DISTINCT CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."id" ELSE NULL END) AS "total_sets",
{% else %}
COUNT(DISTINCT "bricktracker_parts"."id") AS "total_sets",
{% endif %}
{% endblock %}
{% block total_minifigures %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0) ELSE 0 END) AS "total_minifigures"
{% else %}
SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures"
{% endif %}
{% endblock %}
{% block join %}
-- Join with sets to get owner information
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- Left join with set owners (using dynamic columns)
LEFT JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
-- Left join with minifigures
LEFT JOIN "bricktracker_minifigures"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
{% endblock %}
{% block where %}
WHERE "bricktracker_parts"."missing" > 0
OR "bricktracker_parts"."damaged" > 0
{% set conditions = [] %}
-- Always filter for problematic parts
{% set _ = conditions.append('("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)') %}
{% if owner_id and owner_id != 'all' %}
{% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% endif %}
{% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %}
{% endif %}
{% if search_query %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("bricktracker_parts"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set _ = conditions.append(search_condition) %}
{% endif %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %}
{% endif %}
WHERE {{ conditions | join(' AND ') }}
{% endblock %}
{% block group %}
+49 -1
View File
@@ -77,9 +77,57 @@ def list() -> str:
@part_page.route('/problem', methods=['GET'])
@exception_handler(__file__)
def problem() -> str:
# Get filter parameters from request
owner_id = request.args.get('owner', 'all')
color_id = request.args.get('color', 'all')
search_query, sort_field, sort_order, page = get_request_params()
# Get pagination configuration
per_page, is_mobile = get_pagination_config('problems')
use_pagination = per_page > 0
if use_pagination:
# PAGINATION MODE - Server-side pagination with search and filters
parts, total_count = BrickPartList().problem_paginated(
owner_id=owner_id,
color_id=color_id,
search_query=search_query,
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order
)
pagination_context = build_pagination_context(page, per_page, total_count, is_mobile)
else:
# ORIGINAL MODE - Single page with all data for client-side search
parts = BrickPartList().problem()
pagination_context = None
# Get list of owners for filter dropdown
owners = BrickSetOwnerList.list()
# Get list of colors for filter dropdown
# Prepare context for color query (filter by owner if selected)
color_context = {}
if owner_id != 'all':
color_context['owner_id'] = owner_id
# Get colors from problem parts (following same pattern as parts page)
colors = BrickSQL().fetchall('part/colors/list_problem', **color_context)
return render_template(
'problem.html',
table_collection=BrickPartList().problem()
table_collection=parts,
pagination=pagination_context,
search_query=search_query,
sort_field=sort_field,
sort_order=sort_order,
use_pagination=use_pagination,
owners=owners,
colors=colors,
selected_owner=owner_id,
selected_color=color_id
)
+74
View File
@@ -0,0 +1,74 @@
// Problems page functionality
function setupProblemsPage() {
// Handle search input
const searchInput = document.getElementById('table-search');
const clearButton = document.getElementById('table-search-clear');
if (searchInput && clearButton) {
let searchTimeout;
// Search on input
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const url = new URL(window.location);
if (this.value.trim()) {
url.searchParams.set('search', this.value.trim());
} else {
url.searchParams.delete('search');
}
url.searchParams.delete('page'); // Reset to first page
window.location.href = url.toString();
}, 500);
});
// Clear search
clearButton.addEventListener('click', function() {
searchInput.value = '';
const url = new URL(window.location);
url.searchParams.delete('search');
url.searchParams.delete('page');
window.location.href = url.toString();
});
}
// Setup sort and filter functionality (from parts.js)
setupSortButtons();
setupColorDropdown();
// Restore filter state if needed
const keepFiltersOpen = sessionStorage.getItem('keepFiltersOpen');
if (keepFiltersOpen === 'true') {
const filterSection = document.getElementById('table-filter');
if (filterSection && !filterSection.classList.contains('show')) {
filterSection.classList.add('show');
}
sessionStorage.removeItem('keepFiltersOpen');
}
// Update active sort button based on current URL parameters
const urlParams = new URLSearchParams(window.location.search);
const currentSort = urlParams.get('sort');
const currentOrder = urlParams.get('order');
if (currentSort) {
const activeButton = document.querySelector(`[data-sort-attribute="${currentSort}"]`);
if (activeButton) {
activeButton.classList.add('active');
// Add direction indicator
if (currentOrder === 'desc') {
activeButton.classList.add('btn-primary');
activeButton.classList.remove('btn-outline-primary');
}
}
}
}
// Helper function to check if we're in pagination mode
function isPaginationMode() {
const tableElement = document.querySelector('#problems');
return tableElement && tableElement.getAttribute('data-table') === 'false';
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', setupProblemsPage);
+4
View File
@@ -97,6 +97,10 @@
{% if request.endpoint == 'part.list' %}
<script src="{{ url_for('static', filename='scripts/parts.js') }}"></script>
{% endif %}
{% if request.endpoint == 'part.problem' %}
<script src="{{ url_for('static', filename='scripts/parts.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/problems.js') }}"></script>
{% endif %}
{% if request.endpoint == 'set.list' %}
<script src="{{ url_for('static', filename='scripts/sets.js') }}"></script>
{% endif %}
+197 -4
View File
@@ -1,11 +1,204 @@
{% extends 'base.html' %}
{% import 'macro/form.html' as form %}
{% import 'macro/table.html' as table %}
{% block title %} - Problematic parts{% endblock %}
{% block main %}
<div class="container-fluid px-0">
{% with all=true, no_quantity=true %}
{% include 'part/table.html' %}
{% endwith %}
{% if table_collection | length %}
<div class="container-fluid">
<div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
<div class="col-12 flex-grow-1">
<label class="visually-hidden" for="table-search">Search</label>
<div class="input-group">
<span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-md-inline"> Search</span></span>
<input id="table-search" class="form-control form-control-sm px-1" type="text" placeholder="Part name, color" value="{{ search_query or '' }}">
<button id="table-search-clear" type="button" class="btn btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
</div>
</div>
<div class="col-12">
<div class="input-group">
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#table-sort" aria-expanded="{% if config['SHOW_GRID_SORT'] %}true{% else %}false{% endif %}" aria-controls="table-sort">
<i class="ri-sort-asc"></i> Sort
</button>
</div>
</div>
<div class="col-12">
<div class="input-group">
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#table-filter" aria-expanded="{% if config['SHOW_GRID_FILTERS'] %}true{% else %}false{% endif %}" aria-controls="table-filter">
<i class="ri-filter-line"></i> Filters
</button>
</div>
</div>
</div>
{% include 'problem/sort.html' %}
{% include 'problem/filter.html' %}
{% if use_pagination %}
<!-- PAGINATION MODE -->
<div class="table-responsive-sm">
<table data-table="false" class="table table-striped align-middle sortable mb-0" id="problems">
{{ table.header(color=true, quantity=false, sets=true, minifigures=true) }}
<tbody>
{% for item in table_collection %}
<tr>
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part) }}
<td data-sort="{{ item.fields.name }}">
<a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a>
{% if item.fields.spare %}<span class="badge rounded-pill text-bg-warning fw-normal"><i class="ri-loop-left-line"></i> Spare</span>{% endif %}
{{ table.rebrickable(item) }}
{{ table.bricklink(item) }}
</td>
<td data-sort="{{ item.fields.color_name }}">
{% if item.fields.color_rgb %}<span class="color-rgb color-rgb-table {% if item.fields.color == 9999 %}color-any{% endif %} align-middle border border-black" {% if item.fields.color != 9999 %}style="background-color: #{{ item.fields.color_rgb }};"{% endif %}></span>{% endif %}
<span class="align-middle">{{ item.fields.color_name }}</span>
</td>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<td data-sort="{{ item.fields.total_missing }}" class="table-td-input">
{{ form.input('Missing', item.fields.id, item.html_id('missing'), item.url_for_problem('missing'), item.fields.total_missing, all=true, read_only=read_only) }}
</td>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<td data-sort="{{ item.fields.total_damaged }}" class="table-td-input">
{{ form.input('Damaged', item.fields.id, item.html_id('damaged'), item.url_for_problem('damaged'), item.fields.total_damaged, all=true, read_only=read_only) }}
</td>
{% endif %}
<td>{{ item.fields.total_sets }}</td>
<td>{{ item.fields.total_minifigures }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
<div>
{% if pagination and pagination.total_pages > 1 %}
<div class="row mt-4">
<div class="col-12">
<!-- Desktop Pagination -->
<div class="d-none d-md-block">
<nav aria-label="Problems pagination">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page - 1) }}">
<i class="ri-arrow-left-line"></i> Previous
</a>
</li>
{% endif %}
<!-- Show page numbers (with smart truncation) -->
{% set start_page = [1, pagination.page - 2] | max %}
{% set end_page = [pagination.total_pages, pagination.page + 2] | min %}
{% if start_page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', 1) }}">1</a>
</li>
{% if start_page > 2 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endif %}
{% for page_num in range(start_page, end_page + 1) %}
{% if page_num == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', page_num) }}">{{ page_num }}</a>
</li>
{% endif %}
{% endfor %}
{% if end_page < pagination.total_pages %}
{% if end_page < pagination.total_pages - 1 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.total_pages) }}">{{ pagination.total_pages }}</a>
</li>
{% endif %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page + 1) }}">
Next <i class="ri-arrow-right-line"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
<!-- Mobile Pagination -->
<div class="d-md-none">
<div class="mobile-pagination" role="group" aria-label="Mobile pagination">
{% if pagination.has_prev %}
<a href="{{ request.url | replace_query('page', pagination.page - 1) }}"
class="btn btn-outline-primary">
<i class="ri-arrow-left-line"></i> Previous
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
<i class="ri-arrow-left-line"></i> Previous
</button>
{% endif %}
<span class="btn btn-light">
Page {{ pagination.page }} of {{ pagination.total_pages }}
</span>
{% if pagination.has_next %}
<a href="{{ request.url | replace_query('page', pagination.page + 1) }}"
class="btn btn-outline-primary">
Next <i class="ri-arrow-right-line"></i>
</a>
{% else %}
<button class="btn btn-outline-secondary" disabled>
Next <i class="ri-arrow-right-line"></i>
</button>
{% endif %}
</div>
</div>
<!-- Results Info -->
<div class="text-center mt-3">
<small class="text-muted">
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} to
{{ [pagination.page * pagination.per_page, pagination.total_count] | min }}
of {{ pagination.total_count }} problematic parts
</small>
</div>
</div>
</div>
{% endif %}
</div>
{% else %}
<!-- ORIGINAL MODE - Single page with all data -->
<div class="container-fluid px-0">
{% with all=true, no_quantity=true %}
{% include 'part/table.html' %}
{% endwith %}
</div>
{% endif %}
</div>
{% else %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="text-center">
<i class="ri-error-warning-line" style="font-size: 4rem; color: #6c757d;"></i>
<h3 class="mt-3">No problematic parts found</h3>
<p class="text-muted">Great! All your parts are in perfect condition.</p>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
<div id="table-filter" class="collapse {% if config['SHOW_GRID_FILTERS'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center">
{% if owners | length %}
<div class="col-12 col-md-6 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-user-line"></i><span class="ms-1 d-none d-md-inline"> Owner</span></span>
<select id="filter-owner" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_owner == 'all' %}selected{% endif %}>All owners</option>
{% for owner in owners %}
<option value="{{ owner.fields.id }}" {% if selected_owner == owner.fields.id %}selected{% endif %}>{{ owner.fields.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if colors | length %}
<div class="col-12 col-md-6 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-palette-line"></i><span class="ms-1 d-none d-md-inline"> Color</span></span>
<select id="filter-color" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_color == 'all' %}selected{% endif %}>All colors</option>
{% for color in colors %}
<option value="{{ color.color_id }}" {% if selected_color == color.color_id|string %}selected{% endif %} data-color-rgb="{{ color.color_rgb }}" data-color-id="{{ color.color_id }}">
{{ color.color_name }}
</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
</div>
+25
View File
@@ -0,0 +1,25 @@
<div id="table-sort" class="collapse {% if config['SHOW_GRID_SORT'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center">
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text mb-2"><i class="ri-sort-asc"></i><span class="ms-1 d-none d-md-inline"> Sort</span></span>
<button id="sort-name" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="name"><i class="ri-pencil-line"></i><span class="d-none d-md-inline"> Name</span></button>
<button id="sort-color" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="color"><i class="ri-palette-line"></i><span class="d-none d-xl-inline"> Color</span></button>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<button id="sort-missing" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="missing" data-sort-desc="true"><i class="ri-question-line"></i><span class="d-none d-xl-inline"> Missing</span></button>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<button id="sort-damaged" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="damaged" data-sort-desc="true"><i class="ri-error-warning-line"></i><span class="d-none d-xl-inline"> Damaged</span></button>
{% endif %}
<button id="sort-sets" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="sets" data-sort-desc="true"><i class="ri-hashtag"></i><span class="d-none d-xl-inline"> Sets</span></button>
<button id="sort-minifigures" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="minifigures" data-sort-desc="true"><i class="ri-group-line"></i><span class="d-none d-xl-inline"> Figures</span></button>
<button id="sort-clear" type="button" class="btn btn-outline-dark mb-2"
data-sort-clear="true"><i class="ri-close-circle-line"></i><span class="d-none d-xl-inline"> Clear</span></button>
</div>
</div>
</div>