Added pagination to /parts page.
This commit is contained in:
+14
-1
@@ -1,6 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## Unreleased
|
||||
|
||||
### 1.2.5
|
||||
|
||||
- Add configurable pagination system with `SERVER_SIDE_PAGINATION` environment variable
|
||||
- `BK_SERVER_SIDE_PAGINATION=true`: Server-side pagination with mobile-responsive page sizes (25/50 items)
|
||||
- `BK_SERVER_SIDE_PAGINATION=false`: Original single-page mode with live instant search
|
||||
- Supports search, filtering, and sorting in both modes
|
||||
- Optimized for large datasets (100k+ parts)
|
||||
- Enhance parts page functionality
|
||||
- Server-side search triggered by Enter key
|
||||
- Server-side sorting
|
||||
- Color filtering
|
||||
- Mobile-friendly pagination navigation
|
||||
|
||||
### 1.2.4
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from bricktracker.configuration_list import BrickConfigurationList
|
||||
from bricktracker.login import LoginManager
|
||||
from bricktracker.navbar import Navbar
|
||||
from bricktracker.sql import close
|
||||
from bricktracker.template_filters import replace_query_filter
|
||||
from bricktracker.version import __version__
|
||||
from bricktracker.views.add import add_page
|
||||
from bricktracker.views.admin.admin import admin_page
|
||||
@@ -121,6 +122,9 @@ def setup_app(app: Flask) -> None:
|
||||
# Version
|
||||
g.version = __version__
|
||||
|
||||
# Register custom Jinja2 filters
|
||||
app.jinja_env.filters['replace_query'] = replace_query_filter
|
||||
|
||||
# Make sure all connections are closed at the end
|
||||
@app.teardown_request
|
||||
def teardown_request(_: BaseException | None) -> None:
|
||||
|
||||
@@ -38,6 +38,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
|
||||
{'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"rebrickable_minifigures"."name" ASC'}, # noqa: E501
|
||||
{'n': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True},
|
||||
{'n': 'NO_THREADED_SOCKET', 'c': bool},
|
||||
{'n': '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_FOLDER', 'd': 'parts', 's': True},
|
||||
{'n': 'PORT', 'd': 3333, 'c': int},
|
||||
|
||||
@@ -24,6 +24,8 @@ class BrickPartList(BrickRecordList[BrickPart]):
|
||||
# Queries
|
||||
all_query: str = 'part/list/all'
|
||||
all_by_owner_query: str = 'part/list/all_by_owner'
|
||||
all_count_query: str = 'part/count/all'
|
||||
all_by_owner_count_query: str = 'part/count/all_by_owner'
|
||||
different_color_query = 'part/list/with_different_color'
|
||||
last_query: str = 'part/list/last'
|
||||
minifigure_query: str = 'part/list/from_minifigure'
|
||||
@@ -76,6 +78,76 @@ class BrickPartList(BrickRecordList[BrickPart]):
|
||||
|
||||
return self
|
||||
|
||||
# Load parts with pagination support
|
||||
def all_filtered_paginated(
|
||||
self,
|
||||
owner_id: str | None = None,
|
||||
color_id: str | None = None,
|
||||
search_query: str | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
sort_field: str | None = None,
|
||||
sort_order: str = 'asc'
|
||||
) -> tuple[Self, int]:
|
||||
from .sql import BrickSQL
|
||||
|
||||
# Save the filter parameters
|
||||
if owner_id is not None:
|
||||
self.fields.owner_id = owner_id
|
||||
if color_id is not None:
|
||||
self.fields.color_id = color_id
|
||||
if search_query:
|
||||
self.fields.search_query = search_query
|
||||
if sort_field:
|
||||
self.fields.sort_field = sort_field
|
||||
self.fields.sort_order = sort_order
|
||||
|
||||
# Calculate offset
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
# Get total count first
|
||||
count_context = {}
|
||||
if owner_id and owner_id != 'all':
|
||||
count_context['owner_id'] = owner_id
|
||||
count_query = self.all_by_owner_count_query
|
||||
query = self.all_by_owner_query
|
||||
else:
|
||||
count_query = self.all_count_query
|
||||
query = self.all_query
|
||||
|
||||
if color_id and color_id != 'all':
|
||||
count_context['color_id'] = color_id
|
||||
if search_query:
|
||||
count_context['search_query'] = search_query
|
||||
|
||||
# Execute count query
|
||||
count_result = BrickSQL().fetchone(count_query, **count_context)
|
||||
total_count = count_result['total_count'] if count_result else 0
|
||||
|
||||
# Prepare sort order
|
||||
order_clause = None
|
||||
if sort_field:
|
||||
# Map frontend sort field names to SQL column names
|
||||
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"'
|
||||
}
|
||||
|
||||
if sort_field in field_mapping:
|
||||
sql_field = field_mapping[sort_field]
|
||||
direction = 'DESC' if sort_order.lower() == 'desc' else 'ASC'
|
||||
order_clause = f'{sql_field} {direction}'
|
||||
|
||||
# Load paginated parts
|
||||
self.list(override_query=query, limit=per_page, offset=offset, order=order_clause)
|
||||
|
||||
return self, total_count
|
||||
|
||||
# Base part list
|
||||
def list(
|
||||
self,
|
||||
@@ -84,6 +156,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
|
||||
override_query: str | None = None,
|
||||
order: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
**context: Any,
|
||||
) -> None:
|
||||
if order is None:
|
||||
@@ -105,12 +178,15 @@ class BrickPartList(BrickRecordList[BrickPart]):
|
||||
context_vars['owner_id'] = self.fields.owner_id
|
||||
if hasattr(self.fields, 'color_id') and self.fields.color_id is not None:
|
||||
context_vars['color_id'] = self.fields.color_id
|
||||
if hasattr(self.fields, 'search_query') and self.fields.search_query:
|
||||
context_vars['search_query'] = self.fields.search_query
|
||||
|
||||
# Load the sets from the database
|
||||
for record in super().select(
|
||||
override_query=override_query,
|
||||
order=order,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
**context_vars
|
||||
):
|
||||
part = BrickPart(
|
||||
|
||||
@@ -60,3 +60,7 @@ ORDER BY {{ order }}
|
||||
{% if limit %}
|
||||
LIMIT {{ limit }}
|
||||
{% endif %}
|
||||
|
||||
{% if offset %}
|
||||
OFFSET {{ offset }}
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
SELECT COUNT(*) as "total_count"
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
"bricktracker_parts"."part",
|
||||
"bricktracker_parts"."color",
|
||||
"bricktracker_parts"."spare"
|
||||
FROM "bricktracker_parts"
|
||||
|
||||
INNER JOIN "rebrickable_parts"
|
||||
ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
|
||||
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
|
||||
|
||||
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"
|
||||
|
||||
{% set conditions = [] %}
|
||||
{% if color_id and color_id != 'all' %}
|
||||
{% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %}
|
||||
{% endif %}
|
||||
{% if search_query %}
|
||||
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("bricktracker_parts"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
|
||||
{% set _ = conditions.append(search_condition) %}
|
||||
{% endif %}
|
||||
{% if conditions %}
|
||||
WHERE {{ conditions | join(' AND ') }}
|
||||
{% endif %}
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
SELECT COUNT(*) as "total_count"
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
"bricktracker_parts"."part",
|
||||
"bricktracker_parts"."color",
|
||||
"bricktracker_parts"."spare"
|
||||
FROM "bricktracker_parts"
|
||||
|
||||
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"
|
||||
|
||||
INNER JOIN "rebrickable_parts"
|
||||
ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
|
||||
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
|
||||
|
||||
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"
|
||||
|
||||
{% set conditions = [] %}
|
||||
{% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
|
||||
{% 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 %}
|
||||
WHERE {{ conditions | join(' AND ') }}
|
||||
)
|
||||
@@ -27,8 +27,16 @@ AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures
|
||||
{% endblock %}
|
||||
|
||||
{% block where %}
|
||||
{% set conditions = [] %}
|
||||
{% if color_id and color_id != 'all' %}
|
||||
WHERE "bricktracker_parts"."color" = {{ color_id }}
|
||||
{% 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 conditions %}
|
||||
WHERE {{ conditions | join(' AND ') }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -56,17 +56,19 @@ AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures
|
||||
{% endblock %}
|
||||
|
||||
{% block where %}
|
||||
{% set has_where = false %}
|
||||
{% set conditions = [] %}
|
||||
{% if owner_id and owner_id != 'all' %}
|
||||
WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
|
||||
{% set has_where = true %}
|
||||
{% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
|
||||
{% endif %}
|
||||
{% if color_id and color_id != 'all' %}
|
||||
{% if has_where %}
|
||||
AND "bricktracker_parts"."color" = {{ color_id }}
|
||||
{% else %}
|
||||
WHERE "bricktracker_parts"."color" = {{ color_id }}
|
||||
{% 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 conditions %}
|
||||
WHERE {{ conditions | join(' AND ') }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Custom Jinja2 template filters for BrickTracker."""
|
||||
|
||||
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
||||
|
||||
|
||||
def replace_query_filter(url, key, value):
|
||||
"""Replace or add a query parameter in a URL"""
|
||||
parsed = urlparse(url)
|
||||
query_dict = parse_qs(parsed.query, keep_blank_values=True)
|
||||
query_dict[key] = [str(value)]
|
||||
|
||||
new_query = urlencode(query_dict, doseq=True)
|
||||
return urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, new_query, parsed.fragment))
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Final
|
||||
|
||||
__version__: Final[str] = '1.2.4'
|
||||
__version__: Final[str] = '1.2.5'
|
||||
__database_version__: Final[int] = 17
|
||||
|
||||
+66
-12
@@ -1,4 +1,4 @@
|
||||
from flask import Blueprint, render_template, request
|
||||
from flask import Blueprint, current_app, render_template, request
|
||||
|
||||
from .exceptions import exception_handler
|
||||
from ..minifigure_list import BrickMinifigureList
|
||||
@@ -15,13 +15,58 @@ part_page = Blueprint('part', __name__, url_prefix='/parts')
|
||||
@part_page.route('/', methods=['GET'])
|
||||
@exception_handler(__file__)
|
||||
def list() -> str:
|
||||
|
||||
|
||||
# Get filter parameters from request
|
||||
owner_id = request.args.get('owner', 'all')
|
||||
color_id = request.args.get('color', 'all')
|
||||
search_query = request.args.get('search', '').strip()
|
||||
sort_field = request.args.get('sort', '')
|
||||
sort_order = request.args.get('order', 'asc')
|
||||
|
||||
# Get parts with filters applied
|
||||
parts = BrickPartList().all_filtered(owner_id, color_id)
|
||||
# Check if server-side pagination is enabled
|
||||
use_pagination = current_app.config.get('SERVER_SIDE_PAGINATION', False)
|
||||
|
||||
if use_pagination:
|
||||
# PAGINATION MODE - Server-side pagination with search
|
||||
# Get pagination parameters
|
||||
page = int(request.args.get('page', 1))
|
||||
|
||||
# Determine page size based on device type
|
||||
user_agent = request.headers.get('User-Agent', '').lower()
|
||||
is_mobile = any(device in user_agent for device in ['mobile', 'android', 'iphone', 'ipad'])
|
||||
per_page = 25 if is_mobile else 50
|
||||
|
||||
# Get parts with pagination
|
||||
parts, total_count = BrickPartList().all_filtered_paginated(
|
||||
owner_id=owner_id,
|
||||
color_id=color_id,
|
||||
search_query=search_query,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
sort_field=sort_field,
|
||||
sort_order=sort_order
|
||||
)
|
||||
|
||||
# Calculate pagination info
|
||||
total_pages = (total_count + per_page - 1) // per_page if total_count > 0 else 1
|
||||
has_prev = page > 1
|
||||
has_next = page < total_pages
|
||||
|
||||
pagination_context = {
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'total_count': total_count,
|
||||
'total_pages': total_pages,
|
||||
'has_prev': has_prev,
|
||||
'has_next': has_next,
|
||||
'is_mobile': is_mobile
|
||||
}
|
||||
else:
|
||||
# ORIGINAL MODE - Single page with all data for client-side search
|
||||
# Get all parts without pagination but with filters applied
|
||||
parts = BrickPartList().all_filtered(owner_id, color_id)
|
||||
|
||||
pagination_context = None
|
||||
|
||||
# Get list of owners for filter dropdown
|
||||
owners = BrickSetOwnerList.list()
|
||||
@@ -34,14 +79,23 @@ def list() -> str:
|
||||
|
||||
colors = BrickSQL().fetchall('part/colors/list', **color_context)
|
||||
|
||||
return render_template(
|
||||
'parts.html',
|
||||
table_collection=parts,
|
||||
owners=owners,
|
||||
selected_owner=owner_id,
|
||||
colors=colors,
|
||||
selected_color=color_id,
|
||||
)
|
||||
template_context = {
|
||||
'table_collection': parts,
|
||||
'owners': owners,
|
||||
'selected_owner': owner_id,
|
||||
'colors': colors,
|
||||
'selected_color': color_id,
|
||||
'search_query': search_query,
|
||||
'use_pagination': use_pagination,
|
||||
'current_sort': sort_field,
|
||||
'current_order': sort_order
|
||||
}
|
||||
|
||||
if pagination_context:
|
||||
template_context['pagination'] = pagination_context
|
||||
|
||||
return render_template('parts.html', **template_context)
|
||||
|
||||
|
||||
|
||||
# Problem
|
||||
|
||||
+189
-72
@@ -4,6 +4,12 @@ function applyFilters() {
|
||||
const colorSelect = document.getElementById('filter-color');
|
||||
const currentUrl = new URL(window.location);
|
||||
|
||||
// Reset to first page when filters change (only for pagination mode)
|
||||
const tableElement = document.querySelector('#parts');
|
||||
if (tableElement && tableElement.getAttribute('data-table') === 'false') {
|
||||
currentUrl.searchParams.set('page', '1');
|
||||
}
|
||||
|
||||
// Handle owner filter
|
||||
if (ownerSelect) {
|
||||
const selectedOwner = ownerSelect.value;
|
||||
@@ -70,68 +76,140 @@ function setupSortButtons() {
|
||||
const attribute = button.dataset.sortAttribute;
|
||||
const isDesc = button.dataset.sortDesc === 'true';
|
||||
|
||||
// Get column index based on attribute
|
||||
const columnMap = {
|
||||
'name': 1,
|
||||
'color': 2,
|
||||
'quantity': 3,
|
||||
'missing': 4,
|
||||
'damaged': 5,
|
||||
'sets': 6,
|
||||
'minifigures': 7
|
||||
};
|
||||
if (isPaginationMode()) {
|
||||
// PAGINATION MODE - Server-side sorting via URL parameters
|
||||
const currentUrl = new URL(window.location);
|
||||
|
||||
const columnIndex = columnMap[attribute];
|
||||
if (columnIndex !== undefined && window.partsTableInstance) {
|
||||
// Determine sort direction
|
||||
const isCurrentlyActive = button.classList.contains('btn-primary');
|
||||
const currentDirection = button.dataset.currentDirection || (isDesc ? 'desc' : 'asc');
|
||||
const newDirection = isCurrentlyActive ?
|
||||
(currentDirection === 'asc' ? 'desc' : 'asc') :
|
||||
(isDesc ? 'desc' : 'asc');
|
||||
const currentSort = currentUrl.searchParams.get('sort');
|
||||
const currentOrder = currentUrl.searchParams.get('order');
|
||||
const isCurrentlyActive = currentSort === attribute;
|
||||
|
||||
// Clear other active buttons
|
||||
sortButtons.forEach(btn => {
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-outline-primary');
|
||||
btn.removeAttribute('data-current-direction');
|
||||
});
|
||||
let newDirection;
|
||||
if (isCurrentlyActive) {
|
||||
// Toggle direction if same attribute
|
||||
newDirection = currentOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
// Use default direction for new attribute
|
||||
newDirection = isDesc ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
// Mark this button as active
|
||||
button.classList.remove('btn-outline-primary');
|
||||
button.classList.add('btn-primary');
|
||||
button.dataset.currentDirection = newDirection;
|
||||
// Set sort parameters and reset to first page
|
||||
currentUrl.searchParams.set('sort', attribute);
|
||||
currentUrl.searchParams.set('order', newDirection);
|
||||
currentUrl.searchParams.set('page', '1');
|
||||
|
||||
// Apply sort using Simple DataTables API
|
||||
window.partsTableInstance.table.columns.sort(columnIndex, newDirection);
|
||||
// Navigate to sorted results
|
||||
window.location.href = currentUrl.toString();
|
||||
|
||||
} else {
|
||||
// ORIGINAL MODE - Client-side sorting via Simple DataTables
|
||||
// Get column index based on attribute
|
||||
const columnMap = {
|
||||
'name': 1,
|
||||
'color': 2,
|
||||
'quantity': 3,
|
||||
'missing': 4,
|
||||
'damaged': 5,
|
||||
'sets': 6,
|
||||
'minifigures': 7
|
||||
};
|
||||
|
||||
const columnIndex = columnMap[attribute];
|
||||
if (columnIndex !== undefined && window.partsTableInstance) {
|
||||
// Determine sort direction
|
||||
const isCurrentlyActive = button.classList.contains('btn-primary');
|
||||
const currentDirection = button.dataset.currentDirection || (isDesc ? 'desc' : 'asc');
|
||||
const newDirection = isCurrentlyActive ?
|
||||
(currentDirection === 'asc' ? 'desc' : 'asc') :
|
||||
(isDesc ? 'desc' : 'asc');
|
||||
|
||||
// Clear other active buttons
|
||||
sortButtons.forEach(btn => {
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-outline-primary');
|
||||
btn.removeAttribute('data-current-direction');
|
||||
});
|
||||
|
||||
// Mark this button as active
|
||||
button.classList.remove('btn-outline-primary');
|
||||
button.classList.add('btn-primary');
|
||||
button.dataset.currentDirection = newDirection;
|
||||
|
||||
// Apply sort using Simple DataTables API
|
||||
window.partsTableInstance.table.columns.sort(columnIndex, newDirection);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (clearButton) {
|
||||
clearButton.addEventListener('click', () => {
|
||||
// Clear all sort buttons
|
||||
sortButtons.forEach(btn => {
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-outline-primary');
|
||||
btn.removeAttribute('data-current-direction');
|
||||
});
|
||||
if (isPaginationMode()) {
|
||||
// PAGINATION MODE - Clear server-side sorting via URL parameters
|
||||
const currentUrl = new URL(window.location);
|
||||
currentUrl.searchParams.delete('sort');
|
||||
currentUrl.searchParams.delete('order');
|
||||
currentUrl.searchParams.set('page', '1');
|
||||
window.location.href = currentUrl.toString();
|
||||
|
||||
// Reset table sort - remove all sorting
|
||||
if (window.partsTableInstance) {
|
||||
// Destroy and recreate to clear sorting
|
||||
const tableElement = document.querySelector('#parts');
|
||||
const currentPerPage = window.partsTableInstance.table.options.perPage;
|
||||
window.partsTableInstance.table.destroy();
|
||||
} else {
|
||||
// ORIGINAL MODE - Clear client-side sorting
|
||||
// Clear all sort buttons
|
||||
sortButtons.forEach(btn => {
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-outline-primary');
|
||||
btn.removeAttribute('data-current-direction');
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
// Create new instance using the globally available BrickTable class
|
||||
const newInstance = new window.BrickTable(tableElement, currentPerPage);
|
||||
window.partsTableInstance = newInstance;
|
||||
// Reset table sort - remove all sorting
|
||||
if (window.partsTableInstance) {
|
||||
// Destroy and recreate to clear sorting
|
||||
const tableElement = document.querySelector('#parts');
|
||||
const currentPerPage = window.partsTableInstance.table.options.perPage;
|
||||
window.partsTableInstance.table.destroy();
|
||||
|
||||
// Re-enable search functionality
|
||||
newInstance.table.searchable = true;
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
// Create new instance using the globally available BrickTable class
|
||||
const newInstance = new window.BrickTable(tableElement, currentPerPage);
|
||||
window.partsTableInstance = newInstance;
|
||||
|
||||
// Re-enable search functionality
|
||||
newInstance.table.searchable = true;
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if pagination mode is enabled
|
||||
function isPaginationMode() {
|
||||
const tableElement = document.querySelector('#parts');
|
||||
// In pagination mode, table has data-table="false"
|
||||
// In original mode, table has data-table="true"
|
||||
return tableElement && tableElement.getAttribute('data-table') === 'false';
|
||||
}
|
||||
|
||||
// Initialize sort button states for pagination mode
|
||||
function initializeSortButtonStates() {
|
||||
const currentUrl = new URL(window.location);
|
||||
const currentSort = currentUrl.searchParams.get('sort');
|
||||
const currentOrder = currentUrl.searchParams.get('order');
|
||||
|
||||
if (currentSort) {
|
||||
const sortButtons = document.querySelectorAll('[data-sort-attribute]');
|
||||
sortButtons.forEach(btn => {
|
||||
// Clear all buttons first
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-outline-primary');
|
||||
btn.removeAttribute('data-current-direction');
|
||||
|
||||
// Set active state for current sort
|
||||
if (btn.dataset.sortAttribute === currentSort) {
|
||||
btn.classList.remove('btn-outline-primary');
|
||||
btn.classList.add('btn-primary');
|
||||
btn.dataset.currentDirection = currentOrder || 'asc';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -159,32 +237,71 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
if (searchInput && searchClear) {
|
||||
// Wait for table to be initialized by setup_tables
|
||||
const setupSearch = () => {
|
||||
const tableElement = document.querySelector('table[data-table="true"]');
|
||||
if (tableElement && window.partsTableInstance) {
|
||||
// Enable custom search for parts table
|
||||
window.partsTableInstance.table.searchable = true;
|
||||
if (isPaginationMode()) {
|
||||
// PAGINATION MODE - Server-side search with Enter key
|
||||
// Search on Enter key press
|
||||
searchInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const currentUrl = new URL(window.location);
|
||||
const searchValue = e.target.value.trim();
|
||||
|
||||
// Connect search input to table
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
window.partsTableInstance.table.search(e.target.value);
|
||||
});
|
||||
// Reset to first page when searching
|
||||
currentUrl.searchParams.set('page', '1');
|
||||
|
||||
// Clear search
|
||||
searchClear.addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
window.partsTableInstance.table.search('');
|
||||
});
|
||||
if (searchValue) {
|
||||
currentUrl.searchParams.set('search', searchValue);
|
||||
} else {
|
||||
currentUrl.searchParams.delete('search');
|
||||
}
|
||||
|
||||
// Setup sort buttons
|
||||
setupSortButtons();
|
||||
} else {
|
||||
// If table instance not ready, try again
|
||||
setTimeout(setupSearch, 100);
|
||||
}
|
||||
};
|
||||
window.location.href = currentUrl.toString();
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(setupSearch, 100);
|
||||
// Clear search
|
||||
searchClear.addEventListener('click', () => {
|
||||
const currentUrl = new URL(window.location);
|
||||
currentUrl.searchParams.delete('search');
|
||||
currentUrl.searchParams.set('page', '1');
|
||||
window.location.href = currentUrl.toString();
|
||||
});
|
||||
} else {
|
||||
// ORIGINAL MODE - Client-side instant search via Simple DataTables
|
||||
// Wait for table to be initialized
|
||||
const setupClientSearch = () => {
|
||||
const tableElement = document.querySelector('table[data-table="true"]');
|
||||
if (tableElement && window.partsTableInstance) {
|
||||
// Enable search functionality
|
||||
window.partsTableInstance.table.searchable = true;
|
||||
|
||||
// Instant search as user types
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const searchValue = e.target.value.trim();
|
||||
window.partsTableInstance.table.search(searchValue);
|
||||
});
|
||||
|
||||
// Clear search
|
||||
searchClear.addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
window.partsTableInstance.table.search('');
|
||||
});
|
||||
} else {
|
||||
// If table instance not ready, try again
|
||||
setTimeout(setupClientSearch, 100);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(setupClientSearch, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Setup sort buttons
|
||||
setupSortButtons();
|
||||
|
||||
// Initialize sort button states for pagination mode
|
||||
if (isPaginationMode()) {
|
||||
initializeSortButtonStates();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -36,10 +36,12 @@ window.BrickTable = class BrickTable {
|
||||
// Special configuration for tables with custom search/sort
|
||||
const isMinifiguresTable = table.id === 'minifigures';
|
||||
const isPartsTable = table.id === 'parts';
|
||||
const hasCustomInterface = isMinifiguresTable || isPartsTable;
|
||||
const isPartsTablePaginationMode = isPartsTable && table.getAttribute('data-table') === 'false';
|
||||
const hasCustomInterface = isMinifiguresTable || isPartsTablePaginationMode;
|
||||
|
||||
this.table = new simpleDatatables.DataTable(`#${table.id}`, {
|
||||
columns: columns,
|
||||
paging: !isPartsTablePaginationMode, // Disable built-in pagination only for parts table in pagination mode
|
||||
pagerDelta: 1,
|
||||
perPage: per_page,
|
||||
perPageSelect: [10, 25, 50, 100, 500, 1000],
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{% import 'macro/form.html' as form %}
|
||||
{% import 'macro/table.html' as table %}
|
||||
|
||||
<tbody>
|
||||
{% for item in table_collection %}
|
||||
<tr>
|
||||
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part, accordion=solo) }}
|
||||
<td data-sort="{{ item.fields.name }}">
|
||||
<a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a>
|
||||
{% if item.fields.spare %}<span class="badge rounded-pill text-bg-warning fw-normal"><i class="ri-loop-left-line"></i> Spare</span>{% endif %}
|
||||
{% if all %}
|
||||
{{ table.rebrickable(item) }}
|
||||
{{ table.bricklink(item) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-sort="{{ item.fields.color_name }}">
|
||||
{% if item.fields.color_rgb %}<span class="color-rgb color-rgb-table {% if item.fields.color == 9999 %}color-any{% endif %} align-middle border border-black" {% if item.fields.color != 9999 %}style="background-color: #{{ item.fields.color_rgb }};"{% endif %}></span>{% endif %}
|
||||
<span class="align-middle">{{ item.fields.color_name }}</span>
|
||||
</td>
|
||||
<td>{{ item.fields.total_quantity }}</td>
|
||||
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
|
||||
<td data-sort="{{ item.fields.total_missing }}" class="table-td-input">
|
||||
{{ form.input('Missing', item.fields.id, item.html_id('missing'), item.url_for_problem('missing'), item.fields.total_missing, all=all, read_only=read_only) }}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
|
||||
<td data-sort="{{ item.fields.total_damaged }}" class="table-td-input">
|
||||
{{ form.input('Damaged', item.fields.id, item.html_id('damaged'), item.url_for_problem('damaged'), item.fields.total_damaged, all=all, read_only=read_only) }}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>{{ item.fields.total_sets }}</td>
|
||||
<td>{{ item.fields.total_minifigures }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
+160
-5
@@ -1,4 +1,6 @@
|
||||
{% extends 'base.html' %}
|
||||
{% import 'macro/form.html' as form %}
|
||||
{% import 'macro/table.html' as table %}
|
||||
|
||||
{% block title %} - All parts{% endblock %}
|
||||
|
||||
@@ -10,7 +12,7 @@
|
||||
<label class="visually-hidden" for="table-search">Search</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="ri-search-line"></i><span class="ms-1 d-none d-md-inline"> Search</span></span>
|
||||
<input id="table-search" class="form-control form-control-sm px-1" type="text" placeholder="Part name, color, quantity, sets" value="">
|
||||
<input id="table-search" class="form-control form-control-sm px-1" type="text" placeholder="Part name, color, quantity, sets" value="{{ search_query or '' }}">
|
||||
<button id="table-search-clear" type="button" class="btn btn-light btn-outline-danger border"><i class="ri-eraser-line"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,9 +34,162 @@
|
||||
{% include 'part/sort.html' %}
|
||||
{% include 'part/filter.html' %}
|
||||
|
||||
{% with all=true %}
|
||||
{% include 'part/table.html' %}
|
||||
{% endwith %}
|
||||
{% if use_pagination %}
|
||||
<!-- PAGINATION MODE -->
|
||||
<div class="table-responsive-sm">
|
||||
<table data-table="false" class="table table-striped align-middle sortable mb-0" id="parts">
|
||||
{{ table.header(color=true, quantity=not no_quantity, sets=true, minifigures=true) }}
|
||||
{% with all=true %}
|
||||
{% include 'part/table_body.html' %}
|
||||
{% endwith %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div>
|
||||
{% if pagination and pagination.total_pages > 1 %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<!-- Desktop Pagination -->
|
||||
<div class="d-none d-md-block">
|
||||
<nav aria-label="Parts pagination">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page - 1) }}">
|
||||
<i class="ri-arrow-left-line"></i> Previous
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<!-- Show page numbers (with smart truncation) -->
|
||||
{% set start_page = [1, pagination.page - 2] | max %}
|
||||
{% set end_page = [pagination.total_pages, pagination.page + 2] | min %}
|
||||
|
||||
{% if start_page > 1 %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ request.url | replace_query('page', 1) }}">1</a>
|
||||
</li>
|
||||
{% if start_page > 2 %}
|
||||
<li class="page-item disabled"><span class="page-link">...</span></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in range(start_page, end_page + 1) %}
|
||||
{% if page_num == pagination.page %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_num }}</span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ request.url | replace_query('page', page_num) }}">{{ page_num }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if end_page < pagination.total_pages %}
|
||||
{% if end_page < pagination.total_pages - 1 %}
|
||||
<li class="page-item disabled"><span class="page-link">...</span></li>
|
||||
{% endif %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ request.url | replace_query('page', pagination.total_pages) }}">{{ pagination.total_pages }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ request.url | replace_query('page', pagination.page + 1) }}">
|
||||
Next <i class="ri-arrow-right-line"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Pagination -->
|
||||
<div class="d-md-none">
|
||||
<div class="btn-group w-100" role="group" aria-label="Mobile pagination">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="{{ request.url | replace_query('page', pagination.page - 1) }}"
|
||||
class="btn btn-outline-primary">
|
||||
<i class="ri-arrow-left-line"></i> Previous
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="btn btn-outline-secondary" disabled>
|
||||
<i class="ri-arrow-left-line"></i> Previous
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<span class="btn btn-light">
|
||||
Page {{ pagination.page }} of {{ pagination.total_pages }}
|
||||
</span>
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<a href="{{ request.url | replace_query('page', pagination.page + 1) }}"
|
||||
class="btn btn-outline-primary">
|
||||
Next <i class="ri-arrow-right-line"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="btn btn-outline-secondary" disabled>
|
||||
Next <i class="ri-arrow-right-line"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Info -->
|
||||
<div class="text-center mt-3">
|
||||
<small class="text-muted">
|
||||
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} to
|
||||
{{ [pagination.page * pagination.per_page, pagination.total_count] | min }}
|
||||
of {{ pagination.total_count }} parts
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- ORIGINAL MODE - Single page with client-side search -->
|
||||
<div class="table-responsive-sm">
|
||||
<table data-table="true" class="table table-striped align-middle {% if not all %}sortable mb-0{% endif %}" id="parts">
|
||||
{{ table.header(color=true, quantity=not no_quantity, sets=true, minifigures=true) }}
|
||||
<tbody>
|
||||
{% for item in table_collection %}
|
||||
<tr>
|
||||
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part, accordion=solo) }}
|
||||
<td data-sort="{{ item.fields.name }}">
|
||||
<a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a>
|
||||
{% if item.fields.spare %}<span class="badge rounded-pill text-bg-warning fw-normal"><i class="ri-loop-left-line"></i> Spare</span>{% endif %}
|
||||
{{ table.rebrickable(item) }}
|
||||
{{ table.bricklink(item) }}
|
||||
</td>
|
||||
<td data-sort="{{ item.fields.color_name }}">
|
||||
{% if item.fields.color_rgb %}<span class="color-rgb color-rgb-table {% if item.fields.color == 9999 %}color-any{% endif %} align-middle border border-black" {% if item.fields.color != 9999 %}style="background-color: #{{ item.fields.color_rgb }};"{% endif %}></span>{% endif %}
|
||||
<span class="align-middle">{{ item.fields.color_name }}</span>
|
||||
</td>
|
||||
<td>{{ item.fields.total_quantity }}</td>
|
||||
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
|
||||
<td data-sort="{{ item.fields.total_missing }}" class="table-td-input">
|
||||
{{ form.input('Missing', item.fields.id, item.html_id('missing'), item.url_for_problem('missing'), item.fields.total_missing, all=true, read_only=read_only) }}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
|
||||
<td data-sort="{{ item.fields.total_damaged }}" class="table-td-input">
|
||||
{{ form.input('Damaged', item.fields.id, item.html_id('damaged'), item.url_for_problem('damaged'), item.fields.total_damaged, all=true, read_only=read_only) }}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>{{ item.fields.total_sets }}</td>
|
||||
<td>{{ item.fields.total_minifigures }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="container-fluid">
|
||||
@@ -50,4 +205,4 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user