Added pagination to /parts page.

This commit is contained in:
2025-09-16 15:30:54 +02:00
parent 787a376553
commit c876e1e3a4
16 changed files with 645 additions and 100 deletions
+14 -1
View File
@@ -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
+4
View File
@@ -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:
+1
View File
@@ -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},
+76
View File
@@ -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(
+4
View File
@@ -60,3 +60,7 @@ ORDER BY {{ order }}
{% if limit %}
LIMIT {{ limit }}
{% endif %}
{% if offset %}
OFFSET {{ offset }}
{% endif %}
+28
View File
@@ -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 ') }}
)
+9 -1
View File
@@ -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 %}
+9 -7
View File
@@ -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 %}
+13
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
});
+3 -1
View File
@@ -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],
+35
View File
@@ -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
View File
@@ -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 %}