diff --git a/pyproject.toml b/pyproject.toml index 026d16e..f17ee73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "gunicorn>=23.0.0", "heroicons[django]>=2.11.0", "pillow>=12.0.0", + "python-dotenv>=1.2.1", "whitenoise>=6.11.0", ] diff --git a/src/config/settings.py b/src/config/settings.py index 0328d8b..b869d34 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -12,10 +12,18 @@ import os from pathlib import Path +from dotenv import load_dotenv # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +# Load environment variables from .env file if it exists +load_dotenv(BASE_DIR.parent / ".env") + +# Add NVM node path to system PATH if configured (for django-tailwind) +if nvm_node_path := os.environ.get("NVM_NODE_PATH"): + os.environ["PATH"] = f"{nvm_node_path}:{os.environ.get('PATH', '')}" + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ @@ -187,6 +195,14 @@ TAILWIND_APP_NAME = "theme" +# Configure path to npm (can be overridden via environment variable) +# This is useful when using nvm or other node version managers +# If NVM_NODE_PATH is set, use it; otherwise fallback to NPM_BIN_PATH or default "npm" +if nvm_node_path := os.environ.get("NVM_NODE_PATH"): + NPM_BIN_PATH = f"{nvm_node_path}/npm" +else: + NPM_BIN_PATH = os.environ.get("NPM_BIN_PATH", "npm") + # ============================================================================= # Security Settings for Production (behind reverse proxy like Cloudflare Tunnel) # ============================================================================= diff --git a/src/core/urls.py b/src/core/urls.py index dd12d5e..b09953c 100644 --- a/src/core/urls.py +++ b/src/core/urls.py @@ -9,6 +9,7 @@ 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"), path("media/validate_field/", validate_media_field, name="media_validate_field"), diff --git a/src/core/views.py b/src/core/views.py index 621e97e..68f99e9 100644 --- a/src/core/views.py +++ b/src/core/views.py @@ -6,6 +6,7 @@ from django.contrib.auth.decorators import login_required from django.core.management import call_command from django.core.management.base import CommandError +from django.core.paginator import Paginator from django.db.models import Q from django.http import FileResponse from django.shortcuts import get_object_or_404, redirect, render @@ -62,6 +63,16 @@ def _get_field_choices(): } +def _build_search_queryset(query): + """Build a filtered queryset based on search query.""" + return Media.objects.filter( + Q(title__icontains=query) + | Q(contributors__name__icontains=query) + | Q(pub_year__icontains=query) + | Q(review__icontains=query), + ).distinct() + + def _apply_filters(queryset, filters): """Apply filters to a queryset and return (queryset, contributor).""" contributor = None @@ -96,8 +107,14 @@ def index(request): queryset = Media.objects.order_by(ordering) queryset, contributor = _apply_filters(queryset, filters) + # Pagination: 20 items per page + page_number = request.GET.get("page", 1) + paginator = Paginator(queryset, 20) + page_obj = paginator.get_page(page_number) + context = { - "media_list": queryset, + "media_list": page_obj.object_list, + "page_obj": page_obj, "view_mode": view_mode, "order_by": ordering, "sort_field": sort_field, @@ -165,6 +182,41 @@ def media_delete(request, pk): return redirect("media_edit", pk=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", "list") + 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(), + } + + # 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", "") @@ -172,18 +224,19 @@ def search_media(request): sort_field, sort, ordering = _resolve_sorting(request) filters = _extract_filters(request) - media = Media.objects.filter( - Q(title__icontains=query) - | Q(contributors__name__icontains=query) - | Q(pub_year__icontains=query) - | Q(review__icontains=query), - ).distinct() + 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": media, + "media_list": page_obj.object_list, + "page_obj": page_obj, "view_mode": view_mode, "order_by": ordering, "sort_field": sort_field, diff --git a/src/templates/media.html b/src/templates/media.html index b1c4953..aab2acb 100644 --- a/src/templates/media.html +++ b/src/templates/media.html @@ -60,7 +60,7 @@

{% translate "My media" %}

hx-indicator="#spinner" hx-on::before-request="document.getElementById('media-list').style.opacity = 0.4" hx-on::after-request="document.getElementById('media-list').style.opacity = 1" /> - {% include "partials/spinner.html" %} + {% include "partials/spinner.html" with id="spinner" size="xl" htmx_indicator=True %} {% heroicon_outline "plus" %}{% translate "Add" %} diff --git a/src/templates/partials/load-more-trigger.html b/src/templates/partials/load-more-trigger.html new file mode 100644 index 0000000..77aa58e --- /dev/null +++ b/src/templates/partials/load-more-trigger.html @@ -0,0 +1,24 @@ +{% load i18n %} +{# 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 %} + + + {% include "partials/spinner.html" with size="lg" show_text=True text=_("Loading more...") inline=True %} + + + {% endif %} +{% endif %} diff --git a/src/templates/partials/media-items-page.html b/src/templates/partials/media-items-page.html new file mode 100644 index 0000000..f0e4f03 --- /dev/null +++ b/src/templates/partials/media-items-page.html @@ -0,0 +1,4 @@ +{% load i18n %} +{# This partial renders media items for a single page + the load more trigger #} +{% include "partials/media-items.html" %} +{% include "partials/load-more-trigger.html" %} diff --git a/src/templates/partials/media-list.html b/src/templates/partials/media-list.html index b866fa7..7a95ac4 100644 --- a/src/templates/partials/media-list.html +++ b/src/templates/partials/media-list.html @@ -1,10 +1,10 @@ {% load i18n %}
- {% if media_list %} + {% if page_obj %} {% if view_mode == 'grid' %} {# Grid view - Cards layout #}
{% include "partials/media-items.html" %}
+ id="media-container">{% include "partials/media-items-page.html" %}
{% else %} {# List view - Table layout #}
@@ -22,7 +22,7 @@ - {% include "partials/media-items.html" %} + {% include "partials/media-items-page.html" %}
diff --git a/src/templates/partials/spinner.html b/src/templates/partials/spinner.html index 36e27a6..112e43c 100644 --- a/src/templates/partials/spinner.html +++ b/src/templates/partials/spinner.html @@ -1,2 +1,18 @@ -
+{% comment %} +Spinner component - Unified loading indicator +Parameters: + - id: HTML id attribute (default: "spinner") + - size: "xs", "sm", "md", "lg", "xl" (default: "md") + - color: "primary", "secondary", "accent", "neutral" (default: "primary") + - htmx_indicator: boolean - add htmx-indicator class (default: false) + - show_text: boolean - show loading text (default: false) + - text: custom loading text (default: "Loading...") + - inline: boolean - display inline with text (default: false) +{% endcomment %} +{% load i18n %} +
+
+{% if show_text %} + {{ text|default:_("Loading...") }} +{% endif %} diff --git a/src/tests/core/test_views.py b/src/tests/core/test_views.py index 9f01624..1219f8f 100644 --- a/src/tests/core/test_views.py +++ b/src/tests/core/test_views.py @@ -571,3 +571,150 @@ def test_backup_import_handles_errors(self, logged_in_client): # Should redirect back to backup manage with error message assert response.status_code == 302 assert response.url == reverse("backup_manage") + + +class TestPaginationBehavior: + """Tests for pagination behavior across views.""" + + def test_index_paginates_results(self, logged_in_client, media_factory): + """Index view paginates results with 20 items per page.""" + # Create 25 media items + for i in range(25): + media_factory(title=f"Media {i}") + + response = logged_in_client.get(reverse("home")) + + assert response.status_code == 200 + assert "page_obj" in response.context + assert len(response.context["media_list"]) == 20 + assert response.context["page_obj"].has_next() + + def test_index_second_page_shows_remaining_items(self, logged_in_client, media_factory): + """Index view shows remaining items on second page.""" + # Create 25 media items + for i in range(25): + media_factory(title=f"Media {i}") + + response = logged_in_client.get(reverse("home"), {"page": 2}) + + assert response.status_code == 200 + assert len(response.context["media_list"]) == 5 + assert response.context["page_obj"].has_previous() + assert not response.context["page_obj"].has_next() + + def test_search_paginates_results(self, logged_in_client, media_factory): + """Search view paginates results.""" + # Create 25 media items with searchable title + for i in range(25): + media_factory(title=f"Searchable {i}") + + response = logged_in_client.get(reverse("search"), {"search": "Searchable"}) + + assert response.status_code == 200 + assert len(response.context["media_list"]) == 20 + assert response.context["page_obj"].has_next() + + +class TestLoadMoreMediaView: + """Tests for the load_more_media view (infinite scroll / lazy-loading).""" + + def test_load_more_requires_login(self, client): + """The load_more_media view requires authentication.""" + response = client.get(reverse("load_more_media")) + + assert response.status_code == 302 + assert "/login/" in response.url + + def test_load_more_returns_partial_template(self, logged_in_client, media_factory): + """Load more view returns the media-items-page partial template.""" + for i in range(25): + media_factory(title=f"Media {i}") + + response = logged_in_client.get(reverse("load_more_media"), {"page": 2}) + + assert response.status_code == 200 + assert "partials/media-items-page.html" in [t.name for t in response.templates] + + def test_load_more_returns_next_page_items(self, logged_in_client, media_factory): + """Load more view returns items for the requested page.""" + # Create 25 items + for i in range(25): + media_factory(title=f"Media {i:02d}") + + # Request page 2 (items 21-25) + response = logged_in_client.get(reverse("load_more_media"), {"page": 2}) + + assert response.status_code == 200 + assert len(response.context["media_list"]) == 5 + + def test_load_more_preserves_sorting(self, logged_in_client, media_factory): + """Load more view preserves sort order from initial request.""" + media_factory(title="A", score=5) + media_factory(title="B", score=8) + media_factory(title="C", score=3) + + response = logged_in_client.get(reverse("load_more_media"), {"page": 1, "sort": "score"}) + + assert response.status_code == 200 + scores = [m.score for m in response.context["media_list"] if m.score is not None] + # Should be sorted ascending by score + assert scores == sorted(scores) + + def test_load_more_preserves_filters(self, logged_in_client, media_factory): + """Load more view preserves filters from initial request.""" + media_factory(title="Book 1", media_type="BOOK") + media_factory(title="Film 1", media_type="FILM") + media_factory(title="Book 2", media_type="BOOK") + + response = logged_in_client.get(reverse("load_more_media"), {"page": 1, "type": "BOOK"}) + + assert response.status_code == 200 + media_types = [m.media_type for m in response.context["media_list"]] + assert all(mt == "BOOK" for mt in media_types) + + def test_load_more_with_search_query(self, logged_in_client, media_factory): + """Load more view applies search query when provided.""" + media_factory(title="Python Guide") + media_factory(title="JavaScript Guide") + media_factory(title="Python Cookbook") + + response = logged_in_client.get(reverse("load_more_media"), {"page": 1, "search": "Python"}) + + assert response.status_code == 200 + assert len(response.context["media_list"]) == 2 + titles = [m.title for m in response.context["media_list"]] + assert all("Python" in title for title in titles) + + def test_load_more_page_obj_has_next_when_more_pages(self, logged_in_client, media_factory): + """Load more view sets has_next correctly when more pages exist.""" + # Create 45 items (3 pages) + for i in range(45): + media_factory(title=f"Media {i}") + + response = logged_in_client.get(reverse("load_more_media"), {"page": 2}) + + assert response.status_code == 200 + assert response.context["page_obj"].has_next() + assert response.context["page_obj"].number == 2 + + def test_load_more_page_obj_no_next_on_last_page(self, logged_in_client, media_factory): + """Load more view sets has_next to False on last page.""" + # Create 25 items (2 pages) + for i in range(25): + media_factory(title=f"Media {i}") + + response = logged_in_client.get(reverse("load_more_media"), {"page": 2}) + + assert response.status_code == 200 + assert not response.context["page_obj"].has_next() + assert response.context["page_obj"].number == 2 + + def test_load_more_includes_view_mode_in_context(self, logged_in_client, media_factory): + """Load more view includes view_mode in context for template rendering.""" + media_factory(title="Test") + + response_list = logged_in_client.get(reverse("load_more_media"), {"page": 1, "view_mode": "list"}) + response_grid = logged_in_client.get(reverse("load_more_media"), {"page": 1, "view_mode": "grid"}) + + assert response_list.context["view_mode"] == "list" + assert response_grid.context["view_mode"] == "grid" diff --git a/src/theme/static_src/src/styles.css b/src/theme/static_src/src/styles.css index 2529201..5aeaad6 100644 --- a/src/theme/static_src/src/styles.css +++ b/src/theme/static_src/src/styles.css @@ -12,6 +12,7 @@ * the scope of this path. */ @source "../../../**/*.{html,py,js}"; +@source "!../node_modules/**"; /* Star rating widget custom styles */ .star-rating-widget .star-btn { diff --git a/uv.lock b/uv.lock index 4f89c8f..c6636e5 100644 --- a/uv.lock +++ b/uv.lock @@ -321,6 +321,7 @@ dependencies = [ { name = "gunicorn" }, { name = "heroicons", extra = ["django"] }, { name = "pillow" }, + { name = "python-dotenv" }, { name = "whitenoise" }, ] @@ -349,6 +350,7 @@ requires-dist = [ { name = "gunicorn", specifier = ">=23.0.0" }, { name = "heroicons", extras = ["django"], specifier = ">=2.11.0" }, { name = "pillow", specifier = ">=12.0.0" }, + { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "whitenoise", specifier = ">=6.11.0" }, ] @@ -1157,6 +1159,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + [[package]] name = "python-slugify" version = "8.0.4"