diff --git a/.env.sample b/.env.sample index ffa6b49..0de6bf1 100644 --- a/.env.sample +++ b/.env.sample @@ -134,6 +134,10 @@ # Default: false # BK_HIDE_TABLE_MISSING_PARTS=true +# Optional: Hide the 'Checked' column from the parts table. +# Default: false +# BK_HIDE_TABLE_CHECKED_PARTS=true + # Optional: Hide the 'Wishlist' entry from the menu. Does not disable the route. # Default: false # BK_HIDE_WISHES=true diff --git a/bricktracker/config.py b/bricktracker/config.py index 58bd828..9c662f1 100644 --- a/bricktracker/config.py +++ b/bricktracker/config.py @@ -34,6 +34,7 @@ CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'HIDE_SET_INSTRUCTIONS', 'c': bool}, {'n': 'HIDE_TABLE_DAMAGED_PARTS', 'c': bool}, {'n': 'HIDE_TABLE_MISSING_PARTS', 'c': bool}, + {'n': 'HIDE_TABLE_CHECKED_PARTS', 'c': bool}, {'n': 'HIDE_WISHES', 'c': bool}, {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"rebrickable_minifigures"."name" ASC'}, # noqa: E501 {'n': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True}, diff --git a/bricktracker/part.py b/bricktracker/part.py index 12eab28..ba1e427 100644 --- a/bricktracker/part.py +++ b/bricktracker/part.py @@ -159,6 +159,43 @@ class BrickPart(RebrickablePart): return self + # Update checked state for part walkthrough + def update_checked(self, json: Any | None, /) -> bool: + # Handle both direct 'checked' key and changer.js 'value' key format + if json: + checked = json.get('checked', json.get('value', False)) + else: + checked = False + + checked = bool(checked) + + # Update the field + self.fields.checked = checked + + BrickSQL().execute_and_commit( + 'part/update/checked', + parameters=self.sql_parameters() + ) + + return checked + + # Compute the url for updating checked state + def url_for_checked(self, /) -> str: + # Different URL for a minifigure part + if self.minifigure is not None: + figure = self.minifigure.fields.figure + else: + figure = None + + return url_for( + 'set.checked_part', + id=self.fields.id, + figure=figure, + part=self.fields.part, + color=self.fields.color, + spare=self.fields.spare, + ) + # Update a problematic part def update_problem(self, problem: str, json: Any | None, /) -> int: amount: str | int = json.get('value', '') # type: ignore diff --git a/bricktracker/sql/migrations/0018.sql b/bricktracker/sql/migrations/0018.sql new file mode 100644 index 0000000..9ff3d97 --- /dev/null +++ b/bricktracker/sql/migrations/0018.sql @@ -0,0 +1,9 @@ +-- description: Add checked field to bricktracker_parts table for part walkthrough tracking + +BEGIN TRANSACTION; + +-- Add checked field to the bricktracker_parts table +-- This allows users to track which parts they have checked during walkthroughs +ALTER TABLE "bricktracker_parts" ADD COLUMN "checked" BOOLEAN DEFAULT 0; + +COMMIT; \ No newline at end of file diff --git a/bricktracker/sql/part/base/base.sql b/bricktracker/sql/part/base/base.sql index e3a8332..890cf20 100644 --- a/bricktracker/sql/part/base/base.sql +++ b/bricktracker/sql/part/base/base.sql @@ -9,6 +9,7 @@ SELECT --"bricktracker_parts"."rebrickable_inventory", "bricktracker_parts"."missing", "bricktracker_parts"."damaged", + "bricktracker_parts"."checked", --"rebrickable_parts"."part", --"rebrickable_parts"."color_id", "rebrickable_parts"."color_name", diff --git a/bricktracker/sql/part/update/checked.sql b/bricktracker/sql/part/update/checked.sql new file mode 100644 index 0000000..a29a3fc --- /dev/null +++ b/bricktracker/sql/part/update/checked.sql @@ -0,0 +1,7 @@ +UPDATE "bricktracker_parts" +SET "checked" = :checked +WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id +AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure +AND "bricktracker_parts"."part" IS NOT DISTINCT FROM :part +AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color +AND "bricktracker_parts"."spare" IS NOT DISTINCT FROM :spare \ No newline at end of file diff --git a/bricktracker/sql/set/list/damaged_minifigure.sql b/bricktracker/sql/set/list/damaged_minifigure.sql index 51a615d..45868e6 100644 --- a/bricktracker/sql/set/list/damaged_minifigure.sql +++ b/bricktracker/sql/set/list/damaged_minifigure.sql @@ -5,7 +5,7 @@ WHERE "bricktracker_sets"."id" IN ( SELECT "bricktracker_parts"."id" FROM "bricktracker_parts" WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure - AND "bricktracker_parts"."missing" > 0 + AND "bricktracker_parts"."damaged" > 0 GROUP BY "bricktracker_parts"."id" ) {% endblock %} diff --git a/bricktracker/version.py b/bricktracker/version.py index 7bc5cf9..1cb7de2 100644 --- a/bricktracker/version.py +++ b/bricktracker/version.py @@ -1,4 +1,4 @@ from typing import Final -__version__: Final[str] = '1.2.5' -__database_version__: Final[int] = 17 +__version__: Final[str] = '1.3.0' +__database_version__: Final[int] = 18 diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py index 59544f8..63a48b8 100644 --- a/bricktracker/views/set.py +++ b/bricktracker/views/set.py @@ -294,6 +294,50 @@ def problem_part( return jsonify({problem: amount}) +# Update checked state of parts during walkthrough +@set_page.route('//parts////checked', defaults={'figure': None}, methods=['POST']) # noqa: E501 +@set_page.route('//minifigures/
/parts////checked', methods=['POST']) # noqa: E501 +@login_required +@exception_handler(__file__, json=True) +def checked_part( + *, + id: str, + figure: str | None, + part: str, + color: int, + spare: int, +) -> Response: + brickset = BrickSet().select_specific(id) + + if figure is not None: + brickminifigure = BrickMinifigure().select_specific(brickset, figure) + else: + brickminifigure = None + + brickpart = BrickPart().select_specific( + brickset, + part, + color, + spare, + minifigure=brickminifigure, + ) + + checked = brickpart.update_checked(request.json) + + # Info + logger.info('Set {set} ({id}): updated part ({part} color: {color}, spare: {spare}, minifigure: {figure}) checked state to {checked}'.format( # noqa: E501 + set=brickset.fields.set, + id=brickset.fields.id, + figure=figure, + part=brickpart.fields.part, + color=brickpart.fields.color, + spare=brickpart.fields.spare, + checked=checked + )) + + return jsonify({'checked': checked}) + + # Refresh a set @set_page.route('/refresh//', methods=['GET']) @set_page.route('//refresh', methods=['GET']) diff --git a/static/scripts/parts-bulk-operations.js b/static/scripts/parts-bulk-operations.js new file mode 100644 index 0000000..1a032bd --- /dev/null +++ b/static/scripts/parts-bulk-operations.js @@ -0,0 +1,182 @@ +// Bulk operations for parts in set details page +class PartsBulkOperations { + constructor(accordionId) { + this.accordionId = accordionId; + this.setupModal(); + this.setupEventListeners(); + } + + setupModal() { + // Create Bootstrap modal if it doesn't exist + if (!document.getElementById('partsConfirmModal')) { + const modalHTML = ` + + `; + document.body.insertAdjacentHTML('beforeend', modalHTML); + } + } + + setupEventListeners() { + // Mark all as missing (only if missing parts are not hidden) + const markAllMissingBtn = document.getElementById(`mark-all-missing-${this.accordionId}`); + if (markAllMissingBtn) { + markAllMissingBtn.addEventListener('click', (e) => { + e.preventDefault(); + this.confirmAndExecute( + 'Mark all parts as missing?', + 'This will set the missing count to the maximum quantity for all parts in this section.', + () => this.markAllMissing() + ); + }); + } + + // Clear all missing (only if missing parts are not hidden) + const clearAllMissingBtn = document.getElementById(`clear-all-missing-${this.accordionId}`); + if (clearAllMissingBtn) { + clearAllMissingBtn.addEventListener('click', (e) => { + e.preventDefault(); + this.confirmAndExecute( + 'Clear all missing parts?', + 'This will clear the missing field for all parts in this section.', + () => this.clearAllMissing() + ); + }); + } + + // Check all checkboxes (only if checked parts are not hidden) + const checkAllBtn = document.getElementById(`check-all-${this.accordionId}`); + if (checkAllBtn) { + checkAllBtn.addEventListener('click', (e) => { + e.preventDefault(); + this.checkAll(); + }); + } + + // Uncheck all checkboxes (only if checked parts are not hidden) + const uncheckAllBtn = document.getElementById(`uncheck-all-${this.accordionId}`); + if (uncheckAllBtn) { + uncheckAllBtn.addEventListener('click', (e) => { + e.preventDefault(); + this.uncheckAll(); + }); + } + } + + confirmAndExecute(title, message, callback) { + const modal = document.getElementById('partsConfirmModal'); + const modalTitle = document.getElementById('partsConfirmModalLabel'); + const modalMessage = document.getElementById('partsConfirmModalMessage'); + const confirmBtn = document.getElementById('partsConfirmModalConfirm'); + + // Set modal content + modalTitle.textContent = title; + modalMessage.textContent = message; + + // Remove any existing event listeners and add new one + const newConfirmBtn = confirmBtn.cloneNode(true); + confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn); + + newConfirmBtn.addEventListener('click', () => { + const modalInstance = bootstrap.Modal.getInstance(modal); + modalInstance.hide(); + callback(); + }); + + // Show modal + const modalInstance = new bootstrap.Modal(modal); + modalInstance.show(); + } + + markAllMissing() { + const accordionElement = document.getElementById(this.accordionId); + if (!accordionElement) return; + + // Find all rows in this accordion + const rows = accordionElement.querySelectorAll('tbody tr'); + rows.forEach(row => { + // Find the quantity cell (usually 4th column) + const quantityCell = row.cells[3]; // Index 3 for quantity column + const missingInput = row.querySelector('input[id*="-missing-"]'); + + if (quantityCell && missingInput) { + // Extract quantity from cell text content + const quantityText = quantityCell.textContent.trim(); + const quantity = parseInt(quantityText) || 1; // Default to 1 if can't parse + + if (missingInput.value !== quantity.toString()) { + missingInput.value = quantity.toString(); + // Trigger change event to activate BrickChanger + missingInput.dispatchEvent(new Event('change', { bubbles: true })); + } + } + }); + } + + clearAllMissing() { + const accordionElement = document.getElementById(this.accordionId); + if (!accordionElement) return; + + const missingInputs = accordionElement.querySelectorAll('input[id*="-missing-"]'); + missingInputs.forEach(input => { + if (input.value !== '') { + input.value = ''; + // Trigger change event to activate BrickChanger + input.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + } + + checkAll() { + const accordionElement = document.getElementById(this.accordionId); + if (!accordionElement) return; + + const checkboxes = accordionElement.querySelectorAll('input[id*="-checked-"][type="checkbox"]'); + checkboxes.forEach(checkbox => { + if (!checkbox.checked) { + checkbox.checked = true; + // Trigger change event to activate BrickChanger + checkbox.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + } + + uncheckAll() { + const accordionElement = document.getElementById(this.accordionId); + if (!accordionElement) return; + + const checkboxes = accordionElement.querySelectorAll('input[id*="-checked-"][type="checkbox"]'); + checkboxes.forEach(checkbox => { + if (checkbox.checked) { + checkbox.checked = false; + // Trigger change event to activate BrickChanger + checkbox.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + } +} + +// Initialize bulk operations for all part accordions when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + // Find all hamburger menus and initialize bulk operations + const hamburgerMenus = document.querySelectorAll('button[id^="hamburger-"]'); + hamburgerMenus.forEach(button => { + const accordionId = button.id.replace('hamburger-', ''); + new PartsBulkOperations(accordionId); + }); +}); \ No newline at end of file diff --git a/static/styles.css b/static/styles.css index 81c4f51..0907b95 100644 --- a/static/styles.css +++ b/static/styles.css @@ -50,6 +50,67 @@ max-width: 150px; } +/* Checkbox column width constraint */ +.table-td-input:has(.form-check-input[type="checkbox"]) { + width: 120px; + max-width: 120px; + min-width: 120px; +} + +/* Reserve space for status icon to prevent layout shift */ +.form-check-label i[id^="status-"] { + display: inline-block; + width: 1.2em; + text-align: center; + margin-left: 0.25rem; +} + +/* Hamburger menu styling */ +.table th .dropdown { + position: relative; +} + +.table th .dropdown-toggle { + border-radius: 0.375rem; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + border-color: #6c757d; +} + +.table th .dropdown-toggle:focus { + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); +} + +.table th .dropdown-toggle:hover { + background-color: #f8f9fa; + border-color: #6c757d; +} + +/* Style dropdown items */ +.dropdown-menu .dropdown-header { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: #6c757d; + padding: 0.25rem 1rem; +} + +.dropdown-menu .dropdown-item { + font-size: 0.875rem; + padding: 0.5rem 1rem; +} + +.dropdown-menu .dropdown-item:hover { + background-color: #f8f9fa; + color: #212529; +} + +.dropdown-menu .dropdown-item i { + width: 1.25rem; + text-align: center; +} + /* Fixes for sortable.js */ .sortable { --th-color: #000 !important; diff --git a/templates/base.html b/templates/base.html index c548e8f..7dcfac3 100644 --- a/templates/base.html +++ b/templates/base.html @@ -105,6 +105,9 @@ {% if request.endpoint == 'set.list' %} {% endif %} + {% if request.endpoint == 'set.details' %} + + {% endif %} {% if request.endpoint == 'instructions.download' or request.endpoint == 'instructions.do_download' %} {% endif %} diff --git a/templates/macro/accordion.html b/templates/macro/accordion.html index c5c8954..b1ba007 100644 --- a/templates/macro/accordion.html +++ b/templates/macro/accordion.html @@ -1,4 +1,4 @@ -{% macro header(title, id, parent, quantity=none, expanded=false, icon=none, class=none, danger=none, image=none, alt=none) %} +{% macro header(title, id, parent, quantity=none, expanded=false, icon=none, class=none, danger=none, image=none, alt=none, hamburger_menu=none) %} {% if danger %} {% set icon='alert-fill' %} {% endif %} @@ -43,10 +43,10 @@ {% endif %} {% endmacro %} -{% macro table(table_collection, title, id, parent, target, quantity=none, icon=none, image=none, alt=none, details=none, read_only=none) %} +{% macro table(table_collection, title, id, parent, target, quantity=none, icon=none, image=none, alt=none, details=none, read_only=none, hamburger_menu=none) %} {% set size=table_collection | length %} {% if size %} - {{ header(title, id, parent, quantity=quantity, icon=icon, class='p-0', image=image, alt=alt) }} + {{ header(title, id, parent, quantity=quantity, icon=icon, class='p-0', image=image, alt=alt, hamburger_menu=hamburger_menu) }} {% if details %}

{% if image %} @@ -57,7 +57,7 @@ {% if icon %}{% endif %} Details

{% endif %} - {% with solo=true, all=false %} + {% with solo=true, all=false, accordion_id=id %} {% include target %} {% endwith %} {{ footer() }} diff --git a/templates/macro/table.html b/templates/macro/table.html index 32cc63c..86e6334 100644 --- a/templates/macro/table.html +++ b/templates/macro/table.html @@ -1,4 +1,4 @@ -{% macro header(image=true, color=false, parts=false, quantity=false, missing=true, missing_parts=false, damaged=true, damaged_parts=false, sets=false, minifigures=false) %} +{% macro header(image=true, color=false, parts=false, quantity=false, missing=true, missing_parts=false, damaged=true, damaged_parts=false, sets=false, minifigures=false, checked=false, hamburger_menu=false, accordion_id='') %} {% if image %} @@ -26,6 +26,37 @@ {% if minifigures %} Minifigures {% endif %} + {% if checked and not config['HIDE_TABLE_CHECKED_PARTS'] %} + Checked + {% endif %} + {% if hamburger_menu and g.login.is_authenticated() %} + {% set show_missing_menu = not config['HIDE_TABLE_MISSING_PARTS'] %} + {% set show_checked_menu = not config['HIDE_TABLE_CHECKED_PARTS'] %} + {% if show_missing_menu or show_checked_menu %} + + + + {% endif %} + {% endif %} {% endmacro %} diff --git a/templates/part/table.html b/templates/part/table.html index d2f6569..36146a7 100644 --- a/templates/part/table.html +++ b/templates/part/table.html @@ -3,7 +3,7 @@
- {{ table.header(color=true, quantity=not no_quantity, sets=all, minifigures=all) }} + {{ table.header(color=true, quantity=not no_quantity, sets=all, minifigures=all, checked=not all and not read_only, hamburger_menu=not all and not read_only, accordion_id=accordion_id|default('')) }} {% for item in table_collection %} @@ -40,6 +40,19 @@ {% if all %} + {% else %} + {% if not config['HIDE_TABLE_CHECKED_PARTS'] and not read_only %} + + {% endif %} + {% if g.login.is_authenticated() and not read_only %} + {% set show_missing_menu = not config['HIDE_TABLE_MISSING_PARTS'] %} + {% set show_checked_menu = not config['HIDE_TABLE_CHECKED_PARTS'] %} + {% if show_missing_menu or show_checked_menu %} + + {% endif %} + {% endif %} {% endif %} {% endfor %} diff --git a/templates/set/card.html b/templates/set/card.html index 77cf33f..0d505d9 100644 --- a/templates/set/card.html +++ b/templates/set/card.html @@ -104,14 +104,14 @@ {% endif %} {% endif %} {% if g.login.is_authenticated() %} - Download instructions from Rebrickable + Download instructions {% endif %} {{ accordion.footer() }} {% endif %} - {{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line')}} + {{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line', hamburger_menu=g.login.is_authenticated())}} {% for minifigure in item.minifigures() %} - {{ accordion.table(minifigure.parts(), minifigure.fields.name, minifigure.fields.figure, 'set-details', 'part/table.html', quantity=minifigure.fields.quantity, icon='group-line', image=minifigure.url_for_image(), alt=minifigure.fields.figure, details=minifigure.url())}} + {{ accordion.table(minifigure.parts(), minifigure.fields.name, minifigure.fields.figure, 'set-details', 'part/table.html', quantity=minifigure.fields.quantity, icon='group-line', image=minifigure.url_for_image(), alt=minifigure.fields.figure, details=minifigure.url(), hamburger_menu=g.login.is_authenticated())}} {% endfor %} {% include 'set/management.html' %} {% endif %}
{{ item.fields.total_sets }} {{ item.fields.total_minifigures }} +
{{ form.checkbox('', item.fields.id, item.html_id('checked'), item.url_for_checked(), item.fields.checked | default(false), parent='part', delete=read_only) }}
+