diff --git a/src/core/templatetags/media_tags.py b/src/core/templatetags/media_tags.py index 6270e4a..0cb9a04 100644 --- a/src/core/templatetags/media_tags.py +++ b/src/core/templatetags/media_tags.py @@ -73,3 +73,86 @@ def status_badge_class(status): """ return STATUS_CLASSES.get(status, "badge-ghost") + + +@register.simple_tag +def query_string(request, **kwargs): + """ + Build a query string from current GET parameters, with updates from kwargs. + + Args: + request: The current request object + **kwargs: Parameters to add/update/remove (None to remove) + + Returns: + Query string with all parameters (including multi-value params) + + Example usage: + Grid + Clear sort + """ + if not hasattr(request, "GET"): + return "" + + # Start with a copy of current GET parameters (handles multi-value) + params = request.GET.copy() + + # Update with provided kwargs + for key, value in kwargs.items(): + if value is None: + # Remove parameter + params.pop(key, None) + else: + # Set parameter (replaces all values) + params[key] = value + + # Build query string + return params.urlencode() if params else "" + + +@register.simple_tag +def query_string_exclude(request, *exclude_keys): + """ + Build a query string from current GET parameters, excluding specified keys. + + Args: + request: The current request object + *exclude_keys: Parameter names to exclude + + Returns: + Query string with all parameters except excluded ones + + Example usage: + Without page + """ + if not hasattr(request, "GET"): + return "" + + params = request.GET.copy() + + for key in exclude_keys: + params.pop(key, None) + + return params.urlencode() if params else "" + + +@register.filter +def toggle_sort_direction(sort_value): + """ + Toggle the direction of a sort parameter. + + Args: + sort_value: Current sort value (e.g., '-review_date' or 'review_date') + + Returns: + Sort value with inverted direction + + Example usage: + {{ sort|toggle_sort_direction }} + """ + if not sort_value: + return "review_date" + + if sort_value.startswith("-"): + return sort_value[1:] + return f"-{sort_value}" diff --git a/src/core/urls.py b/src/core/urls.py index 949b3c8..b47a47b 100644 --- a/src/core/urls.py +++ b/src/core/urls.py @@ -9,7 +9,6 @@ path("media//", views.media_detail, name="media_detail"), path("media//edit/", views.media_edit, name="media_edit"), path("media//delete/", views.media_delete, name="media_delete"), - path("search/", views.search_media, name="search"), path("load-more/", views.load_more_media, name="load_more_media"), path("agents/search-htmx/", views.agent_search_htmx, name="agent_search_htmx"), path("agents/select-htmx/", views.agent_select_htmx, name="agent_select_htmx"), diff --git a/src/core/views.py b/src/core/views.py index eb28f3d..f39a7cb 100644 --- a/src/core/views.py +++ b/src/core/views.py @@ -1,9 +1,11 @@ +import contextlib import tarfile import tempfile from pathlib import Path from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError from django.core.management import call_command from django.core.management.base import CommandError from django.core.paginator import Paginator @@ -18,7 +20,7 @@ def _resolve_sorting(request): - """Return validated sorting info: selected field, sort string (with sign), and ordering.""" + """Return validated sorting info: selected field and normalized sort string (with sign).""" default_field = "review_date" sort = request.GET.get("sort") or request.GET.get("order_by") or f"-{default_field}" @@ -27,20 +29,21 @@ def _resolve_sorting(request): sort_field = raw_field if raw_field in valid_fields else default_field is_desc = sort.startswith("-") - ordering = f"-{sort_field}" if is_desc else sort_field - normalized_sort = ordering # ensure field is validated - return sort_field, normalized_sort, ordering + normalized_sort = f"-{sort_field}" if is_desc else sort_field + return sort_field, normalized_sort def _extract_filters(request): """Extract filter parameters from request and return filters dict.""" filters = { "contributor": request.GET.get("contributor", ""), - "type": request.GET.get("type", ""), - "status": request.GET.get("status", ""), - "score": request.GET.get("score", ""), + "type": request.GET.getlist("type"), + "status": request.GET.getlist("status"), + "score": request.GET.getlist("score"), "review_from": request.GET.get("review_from", ""), "review_to": request.GET.get("review_to", ""), + "has_review": request.GET.get("has_review", ""), + "has_cover": request.GET.get("has_cover", ""), } filters["has_any"] = any( [ @@ -49,16 +52,30 @@ def _extract_filters(request): filters["score"], filters["review_from"], filters["review_to"], + filters["has_review"], + filters["has_cover"], ] ) - # Add display names for active filters + # Add display names for active filters (as list of tuples: (value, label)) if filters["type"]: - filters["type_display"] = dict(Media.media_type.field.choices).get(filters["type"], filters["type"]) + type_choices_dict = dict(Media.media_type.field.choices) + filters["type_display"] = [(t, type_choices_dict.get(t, t)) for t in filters["type"]] if filters["status"]: - filters["status_display"] = dict(Media.status.field.choices).get(filters["status"], filters["status"]) - if filters["score"] and filters["score"] != "none": - filters["score_display"] = dict(Media.score.field.choices).get(int(filters["score"]), filters["score"]) + status_choices_dict = dict(Media.status.field.choices) + filters["status_display"] = [(s, status_choices_dict.get(s, s)) for s in filters["status"]] + if filters["score"]: + score_choices_dict = dict(Media.score.field.choices) + filters["score_display"] = [] + for s in filters["score"]: + if s == "none": + filters["score_display"].append(("none", _("Not rated"))) + else: + try: + filters["score_display"].append((s, score_choices_dict.get(int(s), s))) + except ValueError: + # Skip malformed score values from URL + continue return filters @@ -82,39 +99,96 @@ def _build_search_queryset(query): ).distinct() -def _apply_filters(queryset, filters): - """Apply filters to a queryset and return (queryset, contributor).""" +def _apply_contributor_filter(queryset, contributor_id): + """Apply contributor filter to queryset and return (queryset, contributor).""" contributor = None - if filters["contributor"]: - contributor = Agent.objects.filter(pk=filters["contributor"]).first() + if contributor_id: + contributor = Agent.objects.filter(pk=contributor_id).first() if contributor: queryset = queryset.filter(contributors=contributor) - if filters["type"]: - queryset = queryset.filter(media_type=filters["type"]) - if filters["status"]: - queryset = queryset.filter(status=filters["status"]) - if filters["score"]: - if filters["score"] == "none": - queryset = queryset.filter(score__isnull=True) + return queryset, contributor + + +def _apply_type_filter(queryset, media_types): + """Apply OR filter for media types.""" + if not media_types: + return queryset + return queryset.filter(media_type__in=media_types) + + +def _apply_status_filter(queryset, statuses): + """Apply OR filter for statuses.""" + if not statuses: + return queryset + return queryset.filter(status__in=statuses) + + +def _apply_score_filter(queryset, scores): + """Apply OR filter for scores (including 'none' for null scores).""" + if not scores: + return queryset + score_q = Q() + for score in scores: + if score == "none": + score_q |= Q(score__isnull=True) else: - queryset = queryset.filter(score=int(filters["score"])) + try: + score_q |= Q(score=int(score)) + except ValueError: + # Skip malformed score values from URL + continue + return queryset.filter(score_q) + + +def _apply_date_and_content_filters(queryset, filters): + """Apply review date, review content, and cover filters.""" if filters["review_from"]: - queryset = queryset.filter(review_date__gte=filters["review_from"]) + # Skip malformed date values from URL + with contextlib.suppress(ValueError, TypeError, ValidationError): + queryset = queryset.filter(review_date__gte=filters["review_from"]) if filters["review_to"]: - queryset = queryset.filter(review_date__lte=filters["review_to"]) + # Skip malformed date values from URL + with contextlib.suppress(ValueError, TypeError, ValidationError): + queryset = queryset.filter(review_date__lte=filters["review_to"]) + if filters["has_review"] == "empty": + queryset = queryset.filter(Q(review__isnull=True) | Q(review="")) + elif filters["has_review"] == "filled": + queryset = queryset.exclude(Q(review__isnull=True) | Q(review="")) + if filters["has_cover"] == "empty": + queryset = queryset.filter(Q(cover__isnull=True) | Q(cover="")) + elif filters["has_cover"] == "filled": + queryset = queryset.exclude(Q(cover__isnull=True) | Q(cover="")) + return queryset + + +def _apply_filters(queryset, filters): + """Apply filters to a queryset and return (queryset, contributor).""" + queryset, contributor = _apply_contributor_filter(queryset, filters["contributor"]) + queryset = _apply_type_filter(queryset, filters["type"]) + queryset = _apply_status_filter(queryset, filters["status"]) + queryset = _apply_score_filter(queryset, filters["score"]) + queryset = _apply_date_and_content_filters(queryset, filters) return queryset, contributor -@login_required -def index(request): - """Main view for displaying media list.""" - # Get query parameters - view_mode = request.GET.get("view_mode", "grid") # 'list' or 'grid' - sort_field, sort, ordering = _resolve_sorting(request) +def _build_media_context(request): + """ + Build and filter media queryset from request parameters. + + Returns a tuple of (page_obj, context_dict) ready for rendering. + This consolidates the common logic used by index and load_more_media views. + """ + view_mode = request.GET.get("view_mode", "grid") + sort_field, sort = _resolve_sorting(request) filters = _extract_filters(request) + search_query = request.GET.get("search", "").strip() + + # Build queryset based on whether it's a search or not + queryset = _build_search_queryset(search_query) if search_query else Media.objects.all() - queryset = Media.objects.order_by(ordering) + # Apply filters and sorting queryset, contributor = _apply_filters(queryset, filters) + queryset = queryset.order_by(sort) # Pagination: 20 items per page page_number = request.GET.get("page", 1) @@ -125,7 +199,6 @@ def index(request): "media_list": page_obj.object_list, "page_obj": page_obj, "view_mode": view_mode, - "order_by": ordering, "sort_field": sort_field, "sort": sort, "contributor": contributor, @@ -133,10 +206,13 @@ def index(request): **_get_field_choices(), } - # If it's an HTMX request (filters/sorting), return the full list - if request.headers.get("HX-Request"): - return render(request, "partials/media-list.html", context) + return page_obj, context + +@login_required +def index(request): + """Main view for displaying media list.""" + _, context = _build_media_context(request) return render(request, "media.html", context) @@ -202,69 +278,12 @@ def media_delete(request, pk): @login_required def load_more_media(request): """HTMX view: load next page of media items for infinite scrolling.""" - view_mode = request.GET.get("view_mode", "grid") - sort_field, sort, ordering = _resolve_sorting(request) - filters = _extract_filters(request) - query = request.GET.get("search", "") - - # Build queryset based on whether it's a search or not - queryset = _build_search_queryset(query) if query else Media.objects.all() - - queryset = queryset.order_by(ordering) - queryset, contributor = _apply_filters(queryset, filters) - - # Pagination - page_number = request.GET.get("page", 1) - paginator = Paginator(queryset, 20) - page_obj = paginator.get_page(page_number) - - context = { - "media_list": page_obj.object_list, - "page_obj": page_obj, - "view_mode": view_mode, - "order_by": ordering, - "sort_field": sort_field, - "sort": sort, - "contributor": contributor, - "filters": filters, - **_get_field_choices(), - } + _, context = _build_media_context(request) # Return only the items + load more button return render(request, "partials/media-items-page.html", context) -@login_required -def search_media(request): - query = request.GET.get("search", "") - view_mode = request.GET.get("view_mode", "grid") - sort_field, sort, ordering = _resolve_sorting(request) - filters = _extract_filters(request) - - media = _build_search_queryset(query) - - media, contributor = _apply_filters(media, filters) - media = media.order_by(ordering) - - # Pagination: 20 items per page - page_number = request.GET.get("page", 1) - paginator = Paginator(media, 20) - page_obj = paginator.get_page(page_number) - - context = { - "media_list": page_obj.object_list, - "page_obj": page_obj, - "view_mode": view_mode, - "order_by": ordering, - "sort_field": sort_field, - "sort": sort, - "contributor": contributor, - "filters": filters, - **_get_field_choices(), - } - return render(request, "partials/media-list.html", context) - - @login_required def agent_search_htmx(request): query = request.GET.get("q", "").strip() diff --git a/src/static/js/base.js b/src/static/js/base.js index 4eb025d..818c62f 100644 --- a/src/static/js/base.js +++ b/src/static/js/base.js @@ -39,160 +39,63 @@ const DEFAULT_PARAMS = { 'sort': '-review_date', }; -// Remove empty and default parameters before HTMX sends the request -document.body.addEventListener('htmx:configRequest', function(event) { - const params = event.detail.parameters; - const toDelete = []; +// Clean URL on page load - remove empty and default parameters +document.addEventListener('DOMContentLoaded', function() { + const url = new URL(window.location); + const params = url.searchParams; + let needsCleanup = false; - for (const key in params) { - const value = params[key]; + // Build list of keys to delete (can't delete while iterating) + const keysToDelete = []; + + for (const [key, value] of params.entries()) { // Remove empty values if (value === '' || value === null || value === undefined) { - toDelete.push(key); + keysToDelete.push(key); } // Remove default values else if (DEFAULT_PARAMS[key] === value) { - toDelete.push(key); + keysToDelete.push(key); } } - toDelete.forEach(key => delete params[key]); -}); - -// FILTER BADGES - -// Create a filter badge -function createFilterBadge(filterName, displayText, badgeClass = 'badge-secondary') { - const container = document.getElementById('active-filters-badges'); - if (!container) return null; - - // Remove existing badge for this filter - const existing = container.querySelector(`[data-filter="${filterName}"]`); - if (existing) existing.remove(); - - const badge = document.createElement('div'); - badge.className = `badge ${badgeClass} gap-1`; - badge.dataset.filter = filterName; - badge.innerHTML = ` - ${displayText} - - `; - container.appendChild(badge); - return badge; -} - -// Remove a filter badge and reset the corresponding input -function removeFilterBadge(filterName) { - const container = document.getElementById('active-filters-badges'); - if (!container) return; - - const badge = container.querySelector(`[data-filter="${filterName}"]`); - if (badge) badge.remove(); - - // Reset the corresponding input(s) - if (filterName === 'review') { - const reviewFrom = document.getElementById('review-from'); - const reviewTo = document.getElementById('review-to'); - if (reviewFrom) reviewFrom.value = ''; - if (reviewTo) reviewTo.value = ''; - } else if (filterName === 'contributor') { - const input = document.getElementById('contributor'); - if (input) input.value = ''; - } else { - const input = document.getElementById(filterName); - if (input) input.value = ''; - } -} - -// Handle filter badge remove button clicks -document.body.addEventListener('click', function(e) { - const removeBtn = e.target.closest('.filter-badge-remove'); - if (!removeBtn) return; - - const filterName = removeBtn.dataset.filter; - removeFilterBadge(filterName); - - // Trigger HTMX request to update the list - const typeSelect = document.getElementById('type'); - if (typeSelect) { - htmx.trigger(typeSelect, 'change'); - } -}); - -// Update badges when select filters change -document.body.addEventListener('change', function(e) { - const target = e.target; - const container = document.getElementById('active-filters-badges'); - if (!container) return; - - if (target.id === 'type' || target.id === 'status' || target.id === 'score') { - const existing = container.querySelector(`[data-filter="${target.id}"]`); - - if (target.value) { - // Get display text from selected option - const selectedOption = target.options[target.selectedIndex]; - let displayText = selectedOption.textContent.trim(); - - createFilterBadge(target.id, displayText); - } else if (existing) { - existing.remove(); - } - } + // Delete marked keys + keysToDelete.forEach(key => { + params.delete(key); + needsCleanup = true; + }); - if (target.id === 'review-from' || target.id === 'review-to') { - const reviewFrom = document.getElementById('review-from'); - const reviewTo = document.getElementById('review-to'); - const existing = container.querySelector('[data-filter="review"]'); - - if (reviewFrom.value || reviewTo.value) { - let displayText = '📅 '; - if (reviewFrom.value && reviewTo.value) { - displayText += `${reviewFrom.value} → ${reviewTo.value}`; - } else if (reviewFrom.value) { - displayText += `≥ ${reviewFrom.value}`; - } else { - displayText += `≤ ${reviewTo.value}`; - } - createFilterBadge('review', displayText); - } else if (existing) { - existing.remove(); - } + // Update URL if cleanup was needed + if (needsCleanup) { + const newUrl = url.pathname + (params.toString() ? '?' + params.toString() : ''); + window.history.replaceState({}, '', newUrl); } }); -// Handle contributor link clicks +// Handle badge removal - simple page reload with filter removed document.body.addEventListener('click', function(e) { - const link = e.target.closest('.contributor-link'); - if (!link) return; + const btn = e.target.closest('.remove-filter-badge'); + if (!btn) return; - const contributorId = link.dataset.contributorId; - const contributorName = link.dataset.contributorName; + const filterName = btn.dataset.filter; + const filterValue = btn.dataset.value; - // Update hidden input - document.getElementById('contributor').value = contributorId; + // Build new URL without this filter value + const url = new URL(window.location); + const params = url.searchParams; - // Create badge - const badge = createFilterBadge('contributor', contributorName, 'badge-primary'); - if (badge) { - badge.id = 'contributor-badge'; - const nameSpan = badge.querySelector('span'); - if (nameSpan) nameSpan.id = 'contributor-badge-name'; + if (filterValue) { + // For multi-value filters (type, status, score) + const values = params.getAll(filterName).filter(v => v !== filterValue); + params.delete(filterName); + values.forEach(v => params.append(filterName, v)); + } else { + // For single-value filters + params.delete(filterName); } -}); -// VIEW MODE TOGGLE -// Dynamically update active button state on click -const viewModeToggle = document.getElementById('view-mode'); -if (viewModeToggle) { - viewModeToggle.addEventListener('click', function(e) { - if (e.target.closest('button')) { - document.querySelectorAll('#view-mode button').forEach(btn => btn.classList.remove('btn-active')); - e.target.closest('button').classList.add('btn-active'); - } - }); -} + window.location.href = url.toString(); +}); // FORM VALIDATION STYLING // Toggle input-error class based on HTMX validation response diff --git a/src/templates/backup_manage.html b/src/templates/backup_manage.html index bb3fe0e..5142a7e 100644 --- a/src/templates/backup_manage.html +++ b/src/templates/backup_manage.html @@ -23,7 +23,7 @@

{% translate "Backup Management" %}

- {% heroicon_outline "arrow-down-tray" class="w-6 h-6" %} + {% heroicon_mini "arrow-down-tray" %} {% translate "Export Backup" %}

@@ -44,7 +44,7 @@

@@ -54,7 +54,7 @@

- {% heroicon_outline "arrow-up-tray" class="w-6 h-6" %} + {% heroicon_mini "arrow-up-tray" %} {% translate "Import Backup" %}

{% translate "Restore your data from a backup archive." %}

@@ -93,7 +93,7 @@

@@ -105,7 +105,7 @@

- {% heroicon_outline "information-circle" class="w-6 h-6" %} + {% heroicon_outline "information-circle" %} {% translate "Backup Information" %}

@@ -145,10 +145,12 @@

💡 {% translate "Best Practices" %}

{# Confirmation modal for backup import #} diff --git a/src/templates/base.html b/src/templates/base.html index 0c44227..c6e2e1f 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -38,7 +38,7 @@
+ class="btn btn-square btn-ghost">{% heroicon_outline "bars-3" %}
diff --git a/src/templates/media.html b/src/templates/media.html index 232cc50..4382424 100644 --- a/src/templates/media.html +++ b/src/templates/media.html @@ -1,97 +1,127 @@ {% extends "base.html" %} {% load i18n %} -{% translate "Search" %} +{% load static %} +{% load media_tags %} {% block title %} Datakult {% endblock title %} {% block content %} -

{% translate "My media" %}

+
+

{% translate "My media" %}

+ + {{ page_obj.paginator.count }} + {% if page_obj.paginator.count > 1 %} + {% translate "items" %} + {% else %} + {% translate "item" %} + {% endif %} + +
-
- {# View mode toggle #} - {% include "partials/view-mode-toggle.html" %} - {# Sort selector #} -
- + {# Top bar with search and controls #} +
+ {# Search bar - takes remaining space #} +
+ {# Hidden fields to preserve current state #} + + - + name="contributor" + value="{{ contributor.id|default:'' }}" /> + {% for type_val in filters.type %}{% endfor %} + {% for status_val in filters.status %}{% endfor %} + {% for score_val in filters.score %}{% endfor %} + {% if filters.review_from %}{% endif %} + {% if filters.review_to %}{% endif %} + {% if filters.has_review %}{% endif %} + {% if filters.has_cover %}{% endif %} + +
+ {# Right controls group #} +
+ {# View mode toggle #} + {% include "partials/view-mode-toggle.html" %} + {# Sort selector #} + + {# Filters button - opens drawer #} + + {# Add button #} + + {% heroicon_outline "plus" %} + +
- {# Search and add button #} - - {% heroicon_outline "plus" %}{% translate "Add" %}
- {# Filters #} - {% include "partials/filters.html" %} {# Active filters badges #}
{% if filters.type %} -
- {{ filters.type_display|default:filters.type }} - -
+ {% for type_val, type_label in filters.type_display %} +
+ {{ type_label }} + +
+ {% endfor %} {% endif %} {% if filters.status %} -
- {{ filters.status_display|default:filters.status }} - -
+ {% for status_val, status_label in filters.status_display %} +
+ {{ status_label }} + +
+ {% endfor %} {% endif %} {% if filters.score %} -
- {% if filters.score == 'none' %}{% translate "Not rated" %}{% else %}{{ filters.score }}⭐ - {{ filters.score_display|default:filters.score }}{% endif %} - -
+ {% for score_val, score_label in filters.score_display %} +
+ {{ score_label }} + +
+ {% endfor %} {% endif %} {% if filters.review_from or filters.review_to %}
@@ -99,8 +129,36 @@

{% translate "My media" %}

{% if filters.review_from and filters.review_to %}→{% endif %} {{ filters.review_to }} + class="btn btn-ghost btn-xs btn-circle remove-filter-badge" + data-filter="review">{% heroicon_mini "x-mark" %} +
+ {% endif %} + {% if filters.has_review %} +
+ + {% if filters.has_review == 'empty' %} + {% translate "No review" %} + {% else %} + {% translate "Has review" %} + {% endif %} + + +
+ {% endif %} + {% if filters.has_cover %} +
+ + {% if filters.has_cover == 'empty' %} + {% translate "No cover" %} + {% else %} + {% translate "Has cover" %} + {% endif %} + +
{% endif %} {% if contributor %} @@ -109,15 +167,13 @@

{% translate "My media" %}

id="contributor-badge"> {{ contributor.name }} + class="btn btn-ghost btn-xs btn-circle remove-filter-badge" + data-filter="contributor">{% heroicon_mini "x-mark" %}
{% endif %}
-
+ {# Filters drawer #} + {% include "partials/filters-drawer.html" %} {% include "partials/media-list.html" %} {% endblock content %} diff --git a/src/templates/media_detail.html b/src/templates/media_detail.html index 94c4bab..028a5c7 100644 --- a/src/templates/media_detail.html +++ b/src/templates/media_detail.html @@ -18,7 +18,7 @@

{% if media.pub_year %}({{ media.pub_year }}){% endif %}

- {% heroicon_outline "pencil" %}{% translate "Edit" %} + {% heroicon_mini "pencil-square" %}{% translate "Edit" %}
{# Main content #}
@@ -50,7 +50,7 @@

{# Contributors #} {% if media.contributors.all %}
- {% heroicon_outline "user" class="w-5 h-5 opacity-50 shrink-0" %} + {% heroicon_mini "user" class="opacity-50 shrink-0" %}
{% include "partials/media-contributors.html" with use_htmx=False %}
{% endif %} @@ -60,7 +60,7 @@

target="_blank" rel="noopener noreferrer" class="link link-primary text-sm flex items-center gap-2"> - {% heroicon_outline "arrow-top-right-on-square" class="w-5 h-5 shrink-0" %} + {% heroicon_mini "arrow-top-right-on-square" class="shrink-0" %} {% translate "External metadata" %} {% endif %} @@ -82,7 +82,7 @@

{% include "partials/media-status-badge.html" %}
{% if media.review_date %}
- {% heroicon_outline "calendar" class="w-5 h-5" %} + {% heroicon_mini "calendar" %} {{ media.review_date }}
{% endif %} diff --git a/src/templates/media_edit.html b/src/templates/media_edit.html index 7ce2cc8..b2ba4b8 100644 --- a/src/templates/media_edit.html +++ b/src/templates/media_edit.html @@ -166,7 +166,7 @@

id="set-today-btn" class="btn btn-xs btn-ghost gap-1" title="{% translate "Set to today" %}"> - {% heroicon_mini "calendar" class="w-4 h-4" %} + {% heroicon_mini "calendar" %} {% translate "Today" %} @@ -199,7 +199,7 @@

diff --git a/src/templates/partials/filters-drawer.html b/src/templates/partials/filters-drawer.html new file mode 100644 index 0000000..98bff8a --- /dev/null +++ b/src/templates/partials/filters-drawer.html @@ -0,0 +1,200 @@ +{% load i18n %} +{# Filters drawer - opens from the right side #} +
+ + + +
+ + + +
+ {# Header #} +
+

{% translate "Filters" %}

+ +
+ {# Hidden fields to preserve view_mode, sort, contributor and search #} + + + + {% if request.GET.search %}{% endif %} + {# Filters content #} +
+ {# Media type filter #} +
+ + +
+ {# Status filter #} +
+ + +
+ {# Score filter #} +
+ + +
+ {# Review date range filter #} +
+ +
+ + +
+
+ {# Has review filter #} +
+ + +
+ {# Has cover filter #} +
+ + +
+
+ {# Divider #} +
+ {# Actions #} +
+ {# Apply filters button #} + + {# Clear all filters button #} + + {% heroicon_mini "x-mark" %} + {% translate "Clear" %} + +
+
+
+
diff --git a/src/templates/partials/filters.html b/src/templates/partials/filters.html deleted file mode 100644 index 4ee6af6..0000000 --- a/src/templates/partials/filters.html +++ /dev/null @@ -1,71 +0,0 @@ -{% load i18n %} -{# Filter controls for media list #} -
- {# Media type filter #} - - {# Status filter #} - - {# Score filter #} - - {# Review date range filter #} -
- {% translate "Reviewed" %} - - {% translate "to" %} - -
-
diff --git a/src/templates/partials/load-more-trigger.html b/src/templates/partials/load-more-trigger.html index 77aa58e..df69dcb 100644 --- a/src/templates/partials/load-more-trigger.html +++ b/src/templates/partials/load-more-trigger.html @@ -1,21 +1,20 @@ {% load i18n %} +{% load media_tags %} {# This partial creates a "Load more" trigger that uses HTMX infinite scroll #} {% if page_obj.has_next %} {% if view_mode == 'grid' %}
{% include "partials/spinner.html" with size="lg" show_text=True text=_("Loading more...") inline=True %}
{% else %} + hx-swap="outerHTML"> {% include "partials/spinner.html" with size="lg" show_text=True text=_("Loading more...") inline=True %} diff --git a/src/templates/partials/media-contributors.html b/src/templates/partials/media-contributors.html index 22fd825..c314f14 100644 --- a/src/templates/partials/media-contributors.html +++ b/src/templates/partials/media-contributors.html @@ -1,23 +1,9 @@ {# Renders the list of contributors with links #} -{# Parameters: media, use_htmx (optional, default True if not set) #} +{# Parameters: media, view_mode, sort #} {% if media.contributors.all %} {% for contributor in media.contributors.all %} - {% if use_htmx == False %} - {# Normal link to home page with contributor filter #} - {{ contributor.name }} - {% if not forloop.last %};{% endif %} - {% else %} - {# HTMX link for in-page filtering (default) #} - {{ contributor.name }} - {% if not forloop.last %};{% endif %} - {% endif %} + {{ contributor.name }} + {% if not forloop.last %};{% endif %} {% endfor %} {% endif %} diff --git a/src/templates/partials/media-edit-button.html b/src/templates/partials/media-edit-button.html index fbdd8d7..80b8e1d 100644 --- a/src/templates/partials/media-edit-button.html +++ b/src/templates/partials/media-edit-button.html @@ -4,4 +4,4 @@ {% heroicon_outline "pencil-square" %} + aria-label="{% translate "Edit" %} {{ media.title }}">{% heroicon_mini "pencil-square" %} diff --git a/src/templates/partials/media-score-badge.html b/src/templates/partials/media-score-badge.html index d4594fc..c5f8ee4 100644 --- a/src/templates/partials/media-score-badge.html +++ b/src/templates/partials/media-score-badge.html @@ -3,6 +3,6 @@ {% if media.score %} {{ media.score }} - {% heroicon_mini "star" class="fill-orange-400 h-4" %} + {% heroicon_mini "star" class="fill-orange-400 h-4 w-4" %}  {{ media.get_score_display }} {% endif %} diff --git a/src/templates/partials/sidebar-nav.html b/src/templates/partials/sidebar-nav.html index 4ece954..16fdfbc 100644 --- a/src/templates/partials/sidebar-nav.html +++ b/src/templates/partials/sidebar-nav.html @@ -16,7 +16,7 @@
  • - {% heroicon_outline "rectangle-stack" class="w-5 h-5" %} + {% heroicon_outline "rectangle-stack" %} {% translate "All" %}
  • @@ -27,35 +27,35 @@
  • - {% heroicon_outline "clock" class="w-5 h-5" %} + {% heroicon_mini "clock" %} {% translate "Planned" %}
  • - {% heroicon_outline "play" class="w-5 h-5" %} + {% heroicon_outline "play" %} {% translate "In progress" %}
  • - {% heroicon_outline "check-circle" class="w-5 h-5" %} + {% heroicon_mini "check-circle" %} {% translate "Completed" %}
  • - {% heroicon_outline "pause" class="w-5 h-5" %} + {% heroicon_mini "pause" %} {% translate "Paused" %}
  • - {% heroicon_outline "x-circle" class="w-5 h-5" %} + {% heroicon_mini "x-circle" %} {% translate "Did not finish" %}
  • @@ -66,13 +66,13 @@ {% if user.is_authenticated %}
  • - {% heroicon_outline "user-circle" class="w-5 h-5" %} + {% heroicon_mini "user-circle" %} {% translate "Profile" %}
  • - {% heroicon_outline "arrow-down-tray" class="w-5 h-5" %} + {% heroicon_mini "arrow-down-tray" %} {% translate "Backups" %}
  • @@ -80,7 +80,7 @@