From ae31e889d7f27f7131301d1074eba325ad989802 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:56:27 -0500 Subject: [PATCH] Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX --- backend/apps/api/v1/maps/views.py | 25 +- backend/apps/core/context_processors.py | 124 +- backend/apps/core/htmx_utils.py | 349 ++++- .../apps/core/templatetags/common_filters.py | 417 ++++++ backend/apps/core/utils/__init__.py | 76 +- backend/apps/core/utils/breadcrumbs.py | 415 ++++++ backend/apps/core/utils/messages.py | 463 +++++++ backend/apps/core/utils/meta.py | 340 +++++ backend/apps/parks/templatetags/park_tags.py | 68 + backend/config/django/base.py | 78 +- backend/pyproject.toml | 67 + backend/static/css/alerts.css | 44 - backend/static/css/components.css | 1186 +++++++++++++---- backend/static/css/src/input.css | 198 ++- backend/static/js/alpine-components.js | 1094 +++++++-------- backend/static/js/form-validation.js | 551 ++++++++ backend/static/js/main.js | 129 +- backend/templates/README.md | 421 ++++++ backend/templates/base/base.html | 419 ++++-- .../templates/components/history_panel.html | 172 +++ .../components/layout/page_header.html | 141 ++ .../components/modals/modal_base.html | 99 +- .../components/modals/modal_confirm.html | 187 ++- .../components/modals/modal_inner.html | 142 ++ .../templates/components/navigation/README.md | 133 ++ .../components/navigation/breadcrumbs.html | 120 ++ backend/templates/components/pagination.html | 144 +- .../components/pagination_inner.html | 156 +++ backend/templates/components/search_form.html | 1 + .../skeletons/card_grid_skeleton.html | 108 ++ .../components/skeletons/detail_skeleton.html | 118 ++ .../components/skeletons/form_skeleton.html | 119 ++ .../components/skeletons/list_skeleton.html | 85 ++ .../components/skeletons/table_skeleton.html | 137 ++ backend/templates/components/stats_card.html | 87 ++ .../templates/components/status_badge.html | 96 +- .../components/status_badge_inner.html | 99 ++ .../templates/components/ui/action_bar.html | 169 +++ backend/templates/components/ui/button.html | 202 ++- backend/templates/components/ui/card.html | 116 +- backend/templates/components/ui/dialog.html | 140 ++ backend/templates/components/ui/icon.html | 227 ++++ backend/templates/components/ui/input.html | 182 ++- .../components/ui/toast-container.html | 106 +- backend/templates/forms/README.md | 225 ++++ backend/templates/forms/layouts/grid.html | 106 ++ backend/templates/forms/layouts/inline.html | 149 +++ backend/templates/forms/layouts/stacked.html | 106 ++ .../templates/forms/partials/field_error.html | 58 +- .../forms/partials/field_success.html | 49 +- .../forms/partials/form_actions.html | 137 +- .../templates/forms/partials/form_field.html | 201 ++- .../partials/form_submission_feedback.html | 194 +++ backend/templates/htmx/README.md | 273 ++++ backend/templates/htmx/components/README.md | 191 +++ .../htmx/components/loading_indicator.html | 132 +- .../templates/htmx/components/pagination.html | 9 - .../parks/partials/park_header_badge.html | 32 +- .../rides/partials/ride_header_badge.html | 32 +- .../templates/tests/design-system-test.html | 282 ++++ backend/tests/api/__init__.py | 6 + backend/tests/api/test_auth_api.py | 596 +++++++++ backend/tests/api/test_error_handling.py | 120 ++ backend/tests/api/test_filters.py | 146 ++ backend/tests/api/test_pagination.py | 118 ++ backend/tests/api/test_parks_api.py | 547 ++++++++ backend/tests/api/test_response_format.py | 120 ++ backend/tests/api/test_rides_api.py | 770 +++++++++++ backend/tests/conftest.py | 271 ++++ backend/tests/e2e/__init__.py | 6 + backend/tests/e2e/conftest.py | 82 +- backend/tests/e2e/test_park_browsing.py | 182 +++ backend/tests/e2e/test_review_submission.py | 372 ++++++ backend/tests/e2e/test_user_registration.py | 280 ++++ backend/tests/factories.py | 55 + backend/tests/forms/__init__.py | 6 + backend/tests/forms/test_park_forms.py | 315 +++++ backend/tests/forms/test_ride_forms.py | 371 ++++++ .../test_fsm_transition_workflow.py | 230 ++++ .../test_park_creation_workflow.py | 233 ++++ .../integration/test_photo_upload_workflow.py | 224 ++++ backend/tests/managers/__init__.py | 6 + backend/tests/managers/test_core_managers.py | 354 +++++ backend/tests/managers/test_park_managers.py | 381 ++++++ backend/tests/managers/test_ride_managers.py | 408 ++++++ backend/tests/middleware/__init__.py | 5 + .../test_contract_validation_middleware.py | 368 +++++ backend/tests/serializers/__init__.py | 6 + .../serializers/test_account_serializers.py | 514 +++++++ .../serializers/test_park_serializers.py | 477 +++++++ .../serializers/test_ride_serializers.py | 573 ++++++++ backend/tests/services/__init__.py | 6 + .../tests/services/test_park_media_service.py | 290 ++++ backend/tests/services/test_ride_service.py | 381 ++++++ .../services/test_user_deletion_service.py | 332 +++++ backend/tests/ux/__init__.py | 1 + backend/tests/ux/test_breadcrumbs.py | 193 +++ backend/tests/ux/test_components.py | 357 +++++ backend/tests/ux/test_htmx_utils.py | 282 ++++ backend/tests/ux/test_messages.py | 139 ++ backend/tests/ux/test_meta.py | 203 +++ backend/thrillwiki/urls.py | 9 + docs/design-system/MIGRATION.md | 336 +++++ docs/design-system/README.md | 405 ++++++ docs/ux/README.md | 165 +++ docs/ux/component-library.md | 453 +++++++ docs/ux/cta-guidelines.md | 423 ++++++ docs/ux/developer-guidelines.md | 510 +++++++ docs/ux/htmx-conventions.md | 528 ++++++++ docs/ux/interaction-patterns.md | 375 ++++++ docs/ux/migration-guide.md | 437 ++++++ static/css/design-tokens.css | 1012 ++++++++++++-- static/js/stores/index.js | 410 +++++- templates/base/base.html | 130 -- templates/base/htmx-base.html | 9 - .../components/data-display/data-table.html | 88 -- templates/components/navigation/footer.html | 58 - templates/components/navigation/navbar.html | 182 --- templates/components/search/multi-select.html | 42 - templates/components/ui/avatar.html | 9 - templates/components/ui/button.html | 62 - templates/components/ui/card.html | 29 - templates/components/ui/dialog.html | 60 - templates/components/ui/dropdown.html | 39 - templates/components/ui/form/checkbox.html | 12 - templates/components/ui/form/input.html | 19 - templates/components/ui/form/select.html | 19 - templates/components/ui/form/textarea.html | 19 - templates/components/ui/skeleton.html | 1 - templates/components/ui/toast-container.html | 58 - templates/pages/auth/login.html | 78 -- templates/pages/auth/signup.html | 81 -- templates/pages/home/homepage.html | 175 --- templates/pages/homepage.html | 415 ------ templates/pages/parks/detail.html | 159 --- templates/pages/parks/list.html | 125 -- templates/pages/rides/detail.html | 177 --- templates/pages/rides/list.html | 130 -- templates/pages/search/results.html | 120 -- .../partials/homepage/featured_parks.html | 96 -- .../partials/homepage/featured_rides.html | 128 -- .../partials/homepage/recent_activity.html | 183 --- .../partials/homepage/search_results.html | 194 --- templates/partials/homepage/stats.html | 20 - 144 files changed, 25792 insertions(+), 4440 deletions(-) create mode 100644 backend/apps/core/templatetags/common_filters.py create mode 100644 backend/apps/core/utils/breadcrumbs.py create mode 100644 backend/apps/core/utils/messages.py create mode 100644 backend/apps/core/utils/meta.py delete mode 100644 backend/static/css/alerts.css create mode 100644 backend/static/js/form-validation.js create mode 100644 backend/templates/README.md create mode 100644 backend/templates/components/history_panel.html create mode 100644 backend/templates/components/layout/page_header.html create mode 100644 backend/templates/components/modals/modal_inner.html create mode 100644 backend/templates/components/navigation/README.md create mode 100644 backend/templates/components/navigation/breadcrumbs.html create mode 100644 backend/templates/components/pagination_inner.html create mode 100644 backend/templates/components/skeletons/card_grid_skeleton.html create mode 100644 backend/templates/components/skeletons/detail_skeleton.html create mode 100644 backend/templates/components/skeletons/form_skeleton.html create mode 100644 backend/templates/components/skeletons/list_skeleton.html create mode 100644 backend/templates/components/skeletons/table_skeleton.html create mode 100644 backend/templates/components/stats_card.html create mode 100644 backend/templates/components/status_badge_inner.html create mode 100644 backend/templates/components/ui/action_bar.html create mode 100644 backend/templates/components/ui/dialog.html create mode 100644 backend/templates/components/ui/icon.html create mode 100644 backend/templates/forms/README.md create mode 100644 backend/templates/forms/layouts/grid.html create mode 100644 backend/templates/forms/layouts/inline.html create mode 100644 backend/templates/forms/layouts/stacked.html create mode 100644 backend/templates/forms/partials/form_submission_feedback.html create mode 100644 backend/templates/htmx/README.md create mode 100644 backend/templates/htmx/components/README.md delete mode 100644 backend/templates/htmx/components/pagination.html create mode 100644 backend/templates/tests/design-system-test.html create mode 100644 backend/tests/api/__init__.py create mode 100644 backend/tests/api/test_auth_api.py create mode 100644 backend/tests/api/test_error_handling.py create mode 100644 backend/tests/api/test_filters.py create mode 100644 backend/tests/api/test_pagination.py create mode 100644 backend/tests/api/test_parks_api.py create mode 100644 backend/tests/api/test_response_format.py create mode 100644 backend/tests/api/test_rides_api.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/e2e/__init__.py create mode 100644 backend/tests/e2e/test_park_browsing.py create mode 100644 backend/tests/e2e/test_review_submission.py create mode 100644 backend/tests/e2e/test_user_registration.py create mode 100644 backend/tests/forms/__init__.py create mode 100644 backend/tests/forms/test_park_forms.py create mode 100644 backend/tests/forms/test_ride_forms.py create mode 100644 backend/tests/integration/test_fsm_transition_workflow.py create mode 100644 backend/tests/integration/test_park_creation_workflow.py create mode 100644 backend/tests/integration/test_photo_upload_workflow.py create mode 100644 backend/tests/managers/__init__.py create mode 100644 backend/tests/managers/test_core_managers.py create mode 100644 backend/tests/managers/test_park_managers.py create mode 100644 backend/tests/managers/test_ride_managers.py create mode 100644 backend/tests/middleware/__init__.py create mode 100644 backend/tests/middleware/test_contract_validation_middleware.py create mode 100644 backend/tests/serializers/__init__.py create mode 100644 backend/tests/serializers/test_account_serializers.py create mode 100644 backend/tests/serializers/test_park_serializers.py create mode 100644 backend/tests/serializers/test_ride_serializers.py create mode 100644 backend/tests/services/__init__.py create mode 100644 backend/tests/services/test_park_media_service.py create mode 100644 backend/tests/services/test_ride_service.py create mode 100644 backend/tests/services/test_user_deletion_service.py create mode 100644 backend/tests/ux/__init__.py create mode 100644 backend/tests/ux/test_breadcrumbs.py create mode 100644 backend/tests/ux/test_components.py create mode 100644 backend/tests/ux/test_htmx_utils.py create mode 100644 backend/tests/ux/test_messages.py create mode 100644 backend/tests/ux/test_meta.py create mode 100644 docs/design-system/MIGRATION.md create mode 100644 docs/design-system/README.md create mode 100644 docs/ux/README.md create mode 100644 docs/ux/component-library.md create mode 100644 docs/ux/cta-guidelines.md create mode 100644 docs/ux/developer-guidelines.md create mode 100644 docs/ux/htmx-conventions.md create mode 100644 docs/ux/interaction-patterns.md create mode 100644 docs/ux/migration-guide.md delete mode 100644 templates/base/base.html delete mode 100644 templates/base/htmx-base.html delete mode 100644 templates/components/data-display/data-table.html delete mode 100644 templates/components/navigation/footer.html delete mode 100644 templates/components/navigation/navbar.html delete mode 100644 templates/components/search/multi-select.html delete mode 100644 templates/components/ui/avatar.html delete mode 100644 templates/components/ui/button.html delete mode 100644 templates/components/ui/card.html delete mode 100644 templates/components/ui/dialog.html delete mode 100644 templates/components/ui/dropdown.html delete mode 100644 templates/components/ui/form/checkbox.html delete mode 100644 templates/components/ui/form/input.html delete mode 100644 templates/components/ui/form/select.html delete mode 100644 templates/components/ui/form/textarea.html delete mode 100644 templates/components/ui/skeleton.html delete mode 100644 templates/components/ui/toast-container.html delete mode 100644 templates/pages/auth/login.html delete mode 100644 templates/pages/auth/signup.html delete mode 100644 templates/pages/home/homepage.html delete mode 100644 templates/pages/homepage.html delete mode 100644 templates/pages/parks/detail.html delete mode 100644 templates/pages/parks/list.html delete mode 100644 templates/pages/rides/detail.html delete mode 100644 templates/pages/rides/list.html delete mode 100644 templates/pages/search/results.html delete mode 100644 templates/partials/homepage/featured_parks.html delete mode 100644 templates/partials/homepage/featured_rides.html delete mode 100644 templates/partials/homepage/recent_activity.html delete mode 100644 templates/partials/homepage/search_results.html delete mode 100644 templates/partials/homepage/stats.html diff --git a/backend/apps/api/v1/maps/views.py b/backend/apps/api/v1/maps/views.py index 298c3947..78701a3d 100644 --- a/backend/apps/api/v1/maps/views.py +++ b/backend/apps/api/v1/maps/views.py @@ -925,10 +925,7 @@ class MapBoundsAPIView(APIView): except Exception as e: logger.error(f"Error in MapBoundsAPIView: {str(e)}", exc_info=True) return Response( - { - "status": "error", - "message": "Failed to retrieve locations within bounds", - }, + {"status": "error", "message": "Failed to retrieve locations within bounds"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -961,20 +958,18 @@ class MapStatsAPIView(APIView): return Response( { "status": "success", - "data": { - "total_locations": total_locations, - "parks_with_location": parks_with_location, - "rides_with_location": rides_with_location, - "cache_hits": 0, # TODO(THRILLWIKI-109): Implement cache statistics tracking - "cache_misses": 0, # TODO(THRILLWIKI-109): Implement cache statistics tracking - }, + "total_locations": total_locations, + "parks_with_location": parks_with_location, + "rides_with_location": rides_with_location, + "cache_hits": 0, # TODO(THRILLWIKI-109): Implement cache statistics tracking + "cache_misses": 0, # TODO(THRILLWIKI-109): Implement cache statistics tracking } ) except Exception as e: logger.error(f"Error in MapStatsAPIView: {str(e)}", exc_info=True) return Response( - {"error": f"Internal server error: {str(e)}"}, + {"status": "error", "message": "Failed to retrieve map statistics"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -1019,13 +1014,14 @@ class MapCacheAPIView(APIView): { "status": "success", "message": f"Map cache cleared successfully. Cleared {cleared_count} entries.", + "cleared_count": cleared_count, } ) except Exception as e: logger.error(f"Error in MapCacheAPIView.delete: {str(e)}", exc_info=True) return Response( - {"error": f"Internal server error: {str(e)}"}, + {"status": "error", "message": "Failed to clear map cache"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -1046,13 +1042,14 @@ class MapCacheAPIView(APIView): { "status": "success", "message": f"Cache invalidated successfully. Invalidated {invalidated_count} entries.", + "invalidated_count": invalidated_count, } ) except Exception as e: logger.error(f"Error in MapCacheAPIView.post: {str(e)}", exc_info=True) return Response( - {"error": f"Internal server error: {str(e)}"}, + {"status": "error", "message": "Failed to invalidate cache"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) diff --git a/backend/apps/core/context_processors.py b/backend/apps/core/context_processors.py index 45cfe0bd..5513c4e5 100644 --- a/backend/apps/core/context_processors.py +++ b/backend/apps/core/context_processors.py @@ -3,14 +3,29 @@ Context processors for the core app. This module provides context processors that add useful utilities and data to template contexts across the application. + +Available Context Processors: + - fsm_context: FSM state machine utilities + - breadcrumbs: Breadcrumb navigation data + - page_meta: Page metadata for SEO and social sharing """ + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + from django_fsm import can_proceed from .state_machine.exceptions import format_transition_error from .state_machine.mixins import TRANSITION_METADATA +from .utils.breadcrumbs import Breadcrumb, BreadcrumbBuilder, breadcrumbs_to_schema + +if TYPE_CHECKING: + from django.http import HttpRequest -def fsm_context(request): +def fsm_context(request: HttpRequest) -> dict[str, Any]: """ Add FSM utilities to template context. @@ -31,7 +46,108 @@ def fsm_context(request): Dictionary of FSM utilities """ return { - 'can_proceed': can_proceed, - 'format_transition_error': format_transition_error, - 'TRANSITION_METADATA': TRANSITION_METADATA, + "can_proceed": can_proceed, + "format_transition_error": format_transition_error, + "TRANSITION_METADATA": TRANSITION_METADATA, + } + + +def breadcrumbs(request: HttpRequest) -> dict[str, Any]: + """ + Add breadcrumb utilities to template context. + + This context processor provides breadcrumb-related utilities and data + to all templates. Views can override the default breadcrumbs by setting + `request.breadcrumbs` before the context processor runs. + + Available context variables: + - breadcrumbs: List of Breadcrumb instances (from view or auto-generated) + - breadcrumbs_json: JSON-LD Schema.org BreadcrumbList for SEO + - BreadcrumbBuilder: Class for building breadcrumbs in templates + - build_breadcrumb: Function for creating single breadcrumb items + + Usage in views: + def park_detail(request, slug): + park = get_object_or_404(Park, slug=slug) + request.breadcrumbs = [ + build_breadcrumb('Home', '/', icon='fas fa-home'), + build_breadcrumb('Parks', reverse('parks:list')), + build_breadcrumb(park.name, is_current=True), + ] + return render(request, 'parks/detail.html', {'park': park}) + + Usage in templates: + {% if breadcrumbs %} + {% include 'components/navigation/breadcrumbs.html' %} + {% endif %} + + {# For Schema.org structured data #} + + + Returns: + Dictionary with breadcrumb utilities and data + """ + from .utils.breadcrumbs import build_breadcrumb + + # Get breadcrumbs from request if set by view + crumbs: list[Breadcrumb] = getattr(request, "breadcrumbs", []) + + # Generate Schema.org JSON-LD + breadcrumbs_json = "" + if crumbs: + schema = breadcrumbs_to_schema(crumbs, request) + breadcrumbs_json = json.dumps(schema) + + return { + "breadcrumbs": crumbs, + "breadcrumbs_json": breadcrumbs_json, + "BreadcrumbBuilder": BreadcrumbBuilder, + "build_breadcrumb": build_breadcrumb, + } + + +def page_meta(request: HttpRequest) -> dict[str, Any]: + """ + Add page metadata utilities to template context. + + This context processor provides default values and utilities for + page metadata including titles, descriptions, and social sharing tags. + Views can override defaults by setting attributes on the request. + + Available context variables: + - site_name: Default site name ('ThrillWiki') + - default_description: Default meta description + - default_og_image: Default Open Graph image URL + + Request attributes that views can set: + - request.page_title: Override page title + - request.meta_description: Override meta description + - request.og_image: Override Open Graph image + - request.og_type: Override Open Graph type + - request.canonical_url: Override canonical URL + + Usage in views: + def park_detail(request, slug): + park = get_object_or_404(Park, slug=slug) + request.page_title = f'{park.name} - Parks - ThrillWiki' + request.meta_description = park.description[:160] + request.og_image = park.featured_image.url if park.featured_image else None + request.og_type = 'place' + return render(request, 'parks/detail.html', {'park': park}) + + Returns: + Dictionary with page metadata + """ + from django.templatetags.static import static + + return { + "site_name": "ThrillWiki", + "default_description": "ThrillWiki - Your comprehensive guide to theme parks and roller coasters", + "default_og_image": static("images/og-default.jpg"), + # Pass through any request-level overrides + "page_title": getattr(request, "page_title", None), + "meta_description": getattr(request, "meta_description", None), + "og_image": getattr(request, "og_image", None), + "og_type": getattr(request, "og_type", "website"), + "canonical_url": getattr(request, "canonical_url", None), } diff --git a/backend/apps/core/htmx_utils.py b/backend/apps/core/htmx_utils.py index 79d83601..1bec2ea3 100644 --- a/backend/apps/core/htmx_utils.py +++ b/backend/apps/core/htmx_utils.py @@ -1,9 +1,36 @@ -"""Utilities for HTMX integration in Django views.""" +""" +Utilities for HTMX integration in Django views. + +This module provides helper functions for creating standardized HTMX responses +with consistent patterns for success, error, and redirect handling. + +Usage Examples: + Success with toast: + return htmx_success('Park saved successfully!') + + Error with message: + return htmx_error('Validation failed', status=422) + + Redirect with message: + return htmx_redirect_with_message('/parks/', 'Park created!') + + Close modal and refresh: + return htmx_modal_close(refresh_target='#park-list') +""" + +from __future__ import annotations + +import json from functools import wraps +from typing import TYPE_CHECKING, Any + from django.http import HttpResponse, JsonResponse from django.template import TemplateDoesNotExist from django.template.loader import render_to_string +if TYPE_CHECKING: + from django.http import HttpRequest + def _resolve_context_and_template(resp, default_template): """Extract context and template from view response.""" @@ -55,31 +82,31 @@ def htmx_partial(template_name): return decorator -def htmx_redirect(url): +def htmx_redirect(url: str) -> HttpResponse: """Create a response that triggers a client-side redirect via HTMX.""" resp = HttpResponse("") resp["HX-Redirect"] = url return resp -def htmx_trigger(name: str, payload: dict = None): +def htmx_trigger(name: str, payload: dict | None = None) -> HttpResponse: """Create a response that triggers a client-side event via HTMX.""" resp = HttpResponse("") if payload is None: resp["HX-Trigger"] = name else: - resp["HX-Trigger"] = JsonResponse({name: payload}).content.decode() + resp["HX-Trigger"] = json.dumps({name: payload}) return resp -def htmx_refresh(): +def htmx_refresh() -> HttpResponse: """Create a response that triggers a client-side page refresh via HTMX.""" resp = HttpResponse("") resp["HX-Refresh"] = "true" return resp -def htmx_swap_oob(target_id: str, html: str): +def htmx_swap_oob(target_id: str, html: str) -> HttpResponse: """Return an out-of-band swap response by wrapping HTML and setting headers. Note: For simple use cases this returns an HttpResponse containing the @@ -88,3 +115,313 @@ def htmx_swap_oob(target_id: str, html: str): resp = HttpResponse(html) resp["HX-Trigger"] = f"oob:{target_id}" return resp + + +# ============================================================================= +# Standardized HTMX Response Helpers +# ============================================================================= + + +def htmx_success( + message: str, + html: str = "", + toast_type: str = "success", + duration: int = 5000, + title: str | None = None, + action: dict[str, Any] | None = None, +) -> HttpResponse: + """ + Create a standardized success response with toast notification. + + Args: + message: Success message to display + html: Optional HTML content for the response body + toast_type: Toast type (success, info, warning) + duration: Toast display duration in ms (0 for persistent) + title: Optional toast title + action: Optional action button {label: str, onClick: str} + + Returns: + HttpResponse with HX-Trigger header for toast + + Examples: + return htmx_success('Park saved successfully!') + + return htmx_success( + 'Item deleted', + action={'label': 'Undo', 'onClick': 'undoDelete()'} + ) + """ + resp = HttpResponse(html) + toast_data: dict[str, Any] = { + "type": toast_type, + "message": message, + "duration": duration, + } + if title: + toast_data["title"] = title + if action: + toast_data["action"] = action + + resp["HX-Trigger"] = json.dumps({"showToast": toast_data}) + return resp + + +def htmx_error( + message: str, + html: str = "", + status: int = 400, + duration: int = 0, + title: str | None = None, + show_retry: bool = False, +) -> HttpResponse: + """ + Create a standardized error response with toast notification. + + Args: + message: Error message to display + html: Optional HTML content for the response body + status: HTTP status code (default: 400) + duration: Toast display duration in ms (0 for persistent) + title: Optional toast title + show_retry: Whether to show a retry action + + Returns: + HttpResponse with HX-Trigger header for error toast + + Examples: + return htmx_error('Validation failed. Please check your input.') + + return htmx_error('Server error', status=500, show_retry=True) + """ + resp = HttpResponse(html, status=status) + toast_data: dict[str, Any] = { + "type": "error", + "message": message, + "duration": duration, + } + if title: + toast_data["title"] = title + if show_retry: + toast_data["action"] = {"label": "Retry", "onClick": "location.reload()"} + + resp["HX-Trigger"] = json.dumps({"showToast": toast_data}) + return resp + + +def htmx_warning( + message: str, + html: str = "", + duration: int = 8000, + title: str | None = None, +) -> HttpResponse: + """ + Create a standardized warning response with toast notification. + + Args: + message: Warning message to display + html: Optional HTML content for the response body + duration: Toast display duration in ms + title: Optional toast title + + Returns: + HttpResponse with HX-Trigger header for warning toast + + Examples: + return htmx_warning('Your session will expire in 5 minutes.') + """ + resp = HttpResponse(html) + toast_data: dict[str, Any] = { + "type": "warning", + "message": message, + "duration": duration, + } + if title: + toast_data["title"] = title + + resp["HX-Trigger"] = json.dumps({"showToast": toast_data}) + return resp + + +def htmx_redirect_with_message( + url: str, + message: str, + toast_type: str = "success", +) -> HttpResponse: + """ + Create a redirect response with a message to show after redirect. + + The message is passed via session to be displayed on the target page. + + Args: + url: URL to redirect to + message: Message to display after redirect + toast_type: Toast type (success, info, warning, error) + + Returns: + HttpResponse with HX-Redirect header + + Examples: + return htmx_redirect_with_message('/parks/', 'Park created successfully!') + """ + resp = HttpResponse("") + resp["HX-Redirect"] = url + # Note: The toast will be shown via Django messages framework + # The view should add the message to the session before returning + return resp + + +def htmx_refresh_section( + target: str, + html: str = "", + message: str | None = None, +) -> HttpResponse: + """ + Create a response that refreshes a specific section. + + Args: + target: CSS selector for the target element to refresh + html: HTML content for the response + message: Optional success message to show + + Returns: + HttpResponse with retarget header + + Examples: + return htmx_refresh_section('#park-list', parks_html, 'List updated') + """ + resp = HttpResponse(html) + resp["HX-Retarget"] = target + resp["HX-Reswap"] = "innerHTML" + + if message: + toast_data = {"type": "success", "message": message, "duration": 3000} + resp["HX-Trigger"] = json.dumps({"showToast": toast_data}) + + return resp + + +def htmx_modal_close( + message: str | None = None, + refresh_target: str | None = None, + refresh_url: str | None = None, +) -> HttpResponse: + """ + Create a response that closes a modal and optionally refreshes content. + + Args: + message: Optional success message to show + refresh_target: CSS selector for element to refresh + refresh_url: URL to fetch for refresh content + + Returns: + HttpResponse with modal close trigger + + Examples: + return htmx_modal_close('Item saved!', refresh_target='#items-list') + """ + resp = HttpResponse("") + + triggers: dict[str, Any] = {"closeModal": True} + + if message: + triggers["showToast"] = { + "type": "success", + "message": message, + "duration": 5000, + } + + if refresh_target: + triggers["refreshSection"] = { + "target": refresh_target, + "url": refresh_url, + } + + resp["HX-Trigger"] = json.dumps(triggers) + return resp + + +def htmx_validation_response( + field_name: str, + errors: list[str] | None = None, + success_message: str | None = None, + request: HttpRequest | None = None, +) -> HttpResponse: + """ + Create a response for inline field validation. + + Args: + field_name: Name of the field being validated + errors: List of error messages (None = valid) + success_message: Message to show on successful validation + request: Optional request for rendering templates + + Returns: + HttpResponse with validation feedback HTML + + Examples: + # Validation error + return htmx_validation_response('email', errors=['Invalid email format']) + + # Validation success + return htmx_validation_response('username', success_message='Username available') + """ + if errors: + html = render_to_string( + "forms/partials/field_error.html", + {"errors": errors}, + request=request, + ) + elif success_message: + html = render_to_string( + "forms/partials/field_success.html", + {"message": success_message}, + request=request, + ) + else: + html = render_to_string( + "forms/partials/field_success.html", + {}, + request=request, + ) + + return HttpResponse(html) + + +def is_htmx_request(request: HttpRequest) -> bool: + """ + Check if the request is an HTMX request. + + Args: + request: Django HttpRequest + + Returns: + True if the request is from HTMX + """ + return request.headers.get("HX-Request") == "true" + + +def get_htmx_target(request: HttpRequest) -> str | None: + """ + Get the target element ID from an HTMX request. + + Args: + request: Django HttpRequest + + Returns: + Target element ID or None + """ + return request.headers.get("HX-Target") + + +def get_htmx_trigger(request: HttpRequest) -> str | None: + """ + Get the trigger element ID from an HTMX request. + + Args: + request: Django HttpRequest + + Returns: + Trigger element ID or None + """ + return request.headers.get("HX-Trigger") diff --git a/backend/apps/core/templatetags/common_filters.py b/backend/apps/core/templatetags/common_filters.py new file mode 100644 index 00000000..5453513d --- /dev/null +++ b/backend/apps/core/templatetags/common_filters.py @@ -0,0 +1,417 @@ +""" +Common Template Filters for ThrillWiki. + +This module provides commonly used template filters for formatting, +text manipulation, and utility operations. + +Usage: + {% load common_filters %} + + {{ timedelta|humanize_timedelta }} + {{ text|truncate_smart:50 }} + {{ number|format_number }} + {{ dict|get_item:"key" }} +""" + +from datetime import timedelta +from django import template +from django.template.defaultfilters import stringfilter +from django.utils import timezone +from django.utils.html import format_html + +register = template.Library() + + +# ============================================================================= +# Time and Date Filters +# ============================================================================= + +@register.filter +def humanize_timedelta(value): + """ + Convert a timedelta or datetime to a human-readable relative time. + + Usage: + {{ last_updated|humanize_timedelta }} + Output: "2 hours ago", "3 days ago", "just now" + + Args: + value: datetime, timedelta, or seconds (int) + + Returns: + Human-readable string like "2 hours ago" + """ + if value is None: + return '' + + # Convert datetime to timedelta from now + if hasattr(value, 'tzinfo'): # It's a datetime + now = timezone.now() + if value > now: + return 'in the future' + value = now - value + + # Convert seconds to timedelta + if isinstance(value, (int, float)): + value = timedelta(seconds=value) + + if not isinstance(value, timedelta): + return '' + + seconds = int(value.total_seconds()) + + if seconds < 60: + return 'just now' + elif seconds < 3600: + minutes = seconds // 60 + return f'{minutes} minute{"s" if minutes != 1 else ""} ago' + elif seconds < 86400: + hours = seconds // 3600 + return f'{hours} hour{"s" if hours != 1 else ""} ago' + elif seconds < 604800: + days = seconds // 86400 + return f'{days} day{"s" if days != 1 else ""} ago' + elif seconds < 2592000: + weeks = seconds // 604800 + return f'{weeks} week{"s" if weeks != 1 else ""} ago' + elif seconds < 31536000: + months = seconds // 2592000 + return f'{months} month{"s" if months != 1 else ""} ago' + else: + years = seconds // 31536000 + return f'{years} year{"s" if years != 1 else ""} ago' + + +@register.filter +def time_until(value): + """ + Convert a future datetime to human-readable time until. + + Usage: + {{ event_date|time_until }} + Output: "in 2 days", "in 3 hours" + """ + if value is None: + return '' + + if hasattr(value, 'tzinfo'): + now = timezone.now() + if value <= now: + return 'now' + diff = value - now + return humanize_timedelta(diff).replace(' ago', '') + + return '' + + +# ============================================================================= +# Text Manipulation Filters +# ============================================================================= + +@register.filter +@stringfilter +def truncate_smart(value, max_length=50): + """ + Truncate text at word boundary, preserving whole words. + + Usage: + {{ description|truncate_smart:100 }} + + Args: + value: Text to truncate + max_length: Maximum length (default: 50) + + Returns: + Truncated text with "..." if truncated + """ + max_length = int(max_length) + if len(value) <= max_length: + return value + + # Find the last space before max_length + truncated = value[:max_length] + last_space = truncated.rfind(' ') + + if last_space > max_length * 0.5: # Only use word boundary if reasonable + truncated = truncated[:last_space] + + return truncated.rstrip('.,!?;:') + '...' + + +@register.filter +@stringfilter +def truncate_middle(value, max_length=50): + """ + Truncate text in the middle, showing start and end. + + Usage: + {{ long_filename|truncate_middle:30 }} + Output: "very_long_fi...le_name.txt" + """ + max_length = int(max_length) + if len(value) <= max_length: + return value + + keep_chars = (max_length - 3) // 2 + return f'{value[:keep_chars]}...{value[-keep_chars:]}' + + +@register.filter +@stringfilter +def initials(value, max_initials=2): + """ + Get initials from a name. + + Usage: + {{ user.full_name|initials }} + Output: "JD" for "John Doe" + """ + words = value.split() + return ''.join(word[0].upper() for word in words[:max_initials] if word) + + +# ============================================================================= +# Number Formatting Filters +# ============================================================================= + +@register.filter +def format_number(value, decimals=0): + """ + Format number with thousand separators. + + Usage: + {{ count|format_number }} + Output: "1,234,567" + + {{ price|format_number:2 }} + Output: "1,234.56" + """ + if value is None: + return '' + + try: + value = float(value) + decimals = int(decimals) + if decimals > 0: + return f'{value:,.{decimals}f}' + return f'{int(value):,}' + except (ValueError, TypeError): + return value + + +@register.filter +def format_compact(value): + """ + Format large numbers compactly (K, M, B). + + Usage: + {{ view_count|format_compact }} + Output: "1.2K", "3.4M", "2.1B" + """ + if value is None: + return '' + + try: + value = float(value) + if value >= 1_000_000_000: + return f'{value / 1_000_000_000:.1f}B' + elif value >= 1_000_000: + return f'{value / 1_000_000:.1f}M' + elif value >= 1_000: + return f'{value / 1_000:.1f}K' + return str(int(value)) + except (ValueError, TypeError): + return value + + +@register.filter +def percentage(value, total): + """ + Calculate percentage of value from total. + + Usage: + {{ completed|percentage:total }} + Output: "75%" + """ + try: + value = float(value) + total = float(total) + if total == 0: + return '0%' + return f'{(value / total * 100):.0f}%' + except (ValueError, TypeError, ZeroDivisionError): + return '0%' + + +# ============================================================================= +# Dictionary/List Filters +# ============================================================================= + +@register.filter +def get_item(dictionary, key): + """ + Get item from dictionary safely in templates. + + Usage: + {{ my_dict|get_item:"key" }} + {{ my_dict|get_item:variable_key }} + """ + if dictionary is None: + return None + return dictionary.get(key) + + +@register.filter +def getlist(querydict, key): + """ + Get list of values from QueryDict (request.GET/POST). + + Usage: + {{ request.GET|getlist:"categories" }} + + Args: + querydict: Django QueryDict (request.GET or request.POST) + key: Key to retrieve list for + + Returns: + List of values for the key, or empty list if not found + """ + if querydict is None: + return [] + if hasattr(querydict, 'getlist'): + return querydict.getlist(key) + return [] + + +@register.filter +def get_attr(obj, attr): + """ + Get attribute from object safely in templates. + + Usage: + {{ object|get_attr:"field_name" }} + """ + if obj is None: + return None + return getattr(obj, attr, None) + + +@register.filter +def index(sequence, i): + """ + Get item by index from list/tuple. + + Usage: + {{ my_list|index:0 }} + """ + try: + return sequence[int(i)] + except (IndexError, TypeError, ValueError): + return None + + +# ============================================================================= +# Pluralization Filters +# ============================================================================= + +@register.filter +def pluralize_custom(count, forms): + """ + Custom pluralization with specified singular/plural forms. + + Usage: + {{ count|pluralize_custom:"item,items" }} + {{ count|pluralize_custom:"person,people" }} + {{ count|pluralize_custom:"goose,geese" }} + + Args: + count: Number to check + forms: Comma-separated "singular,plural" forms + """ + try: + count = int(count) + singular, plural = forms.split(',') + return singular if count == 1 else plural + except (ValueError, AttributeError): + return forms + + +@register.filter +def count_with_label(count, forms): + """ + Format count with appropriate label. + + Usage: + {{ rides|length|count_with_label:"ride,rides" }} + Output: "1 ride" or "5 rides" + """ + try: + count = int(count) + singular, plural = forms.split(',') + label = singular if count == 1 else plural + return f'{count} {label}' + except (ValueError, AttributeError): + return str(count) + + +# ============================================================================= +# CSS Class Manipulation +# ============================================================================= + +@register.filter +def add_class(field, css_class): + """ + Add CSS class to form field widget. + + Usage: + {{ form.email|add_class:"form-control" }} + """ + if hasattr(field, 'as_widget'): + existing = field.field.widget.attrs.get('class', '') + new_classes = f'{existing} {css_class}'.strip() + return field.as_widget(attrs={'class': new_classes}) + return field + + +@register.filter +def set_attr(field, attr_value): + """ + Set attribute on form field widget. + + Usage: + {{ form.email|set_attr:"placeholder:Enter email" }} + """ + if hasattr(field, 'as_widget'): + attr, value = attr_value.split(':') + return field.as_widget(attrs={attr: value}) + return field + + +# ============================================================================= +# Conditional Filters +# ============================================================================= + +@register.filter +def default_if_none(value, default): + """ + Return default if value is None (not just falsy). + + Usage: + {{ value|default_if_none:"N/A" }} + """ + return default if value is None else value + + +@register.filter +def yesno_icon(value, icons="fa-check,fa-times"): + """ + Return icon class based on boolean value. + + Usage: + {{ is_active|yesno_icon }} + Output: "fa-check" or "fa-times" + + {{ has_feature|yesno_icon:"fa-star,fa-star-o" }} + """ + true_icon, false_icon = icons.split(',') + return true_icon if value else false_icon diff --git a/backend/apps/core/utils/__init__.py b/backend/apps/core/utils/__init__.py index 8729095b..9a4e73f6 100644 --- a/backend/apps/core/utils/__init__.py +++ b/backend/apps/core/utils/__init__.py @@ -1 +1,75 @@ -# Core utilities +""" +Core utilities for the ThrillWiki application. + +This package provides utility functions and classes used across the application, +including breadcrumb generation, message helpers, and meta tag utilities. +""" + +from .breadcrumbs import ( + Breadcrumb, + BreadcrumbBuilder, + breadcrumbs_to_schema, + build_breadcrumb, + get_model_breadcrumb, +) +from .messages import ( + confirm_delete, + error_network, + error_not_found, + error_permission, + error_server, + error_validation, + format_count_message, + info_loading, + info_message, + info_processing, + success_action, + success_created, + success_deleted, + success_updated, + warning_permission, + warning_rate_limit, + warning_unsaved_changes, +) +from .meta import ( + build_canonical_url, + build_meta_context, + build_page_title, + generate_meta_description, + get_og_image, + get_twitter_card_type, +) + +__all__ = [ + # Breadcrumbs + "Breadcrumb", + "BreadcrumbBuilder", + "breadcrumbs_to_schema", + "build_breadcrumb", + "get_model_breadcrumb", + # Messages + "confirm_delete", + "error_network", + "error_not_found", + "error_permission", + "error_server", + "error_validation", + "format_count_message", + "info_loading", + "info_message", + "info_processing", + "success_action", + "success_created", + "success_deleted", + "success_updated", + "warning_permission", + "warning_rate_limit", + "warning_unsaved_changes", + # Meta + "build_canonical_url", + "build_meta_context", + "build_page_title", + "generate_meta_description", + "get_og_image", + "get_twitter_card_type", +] diff --git a/backend/apps/core/utils/breadcrumbs.py b/backend/apps/core/utils/breadcrumbs.py new file mode 100644 index 00000000..cfc8276f --- /dev/null +++ b/backend/apps/core/utils/breadcrumbs.py @@ -0,0 +1,415 @@ +""" +Breadcrumb utilities for the ThrillWiki application. + +This module provides functions and classes for building breadcrumb navigation +with support for dynamic breadcrumb generation from URL patterns and model instances. + +Usage Examples: + Basic breadcrumb list: + breadcrumbs = [ + build_breadcrumb('Home', '/'), + build_breadcrumb('Parks', '/parks/'), + build_breadcrumb('Cedar Point', is_current=True), + ] + + From a model instance: + park = Park.objects.get(slug='cedar-point') + breadcrumbs = get_model_breadcrumb(park) + # Returns: [Home, Parks, Cedar Point] + + Using the builder pattern: + breadcrumbs = ( + BreadcrumbBuilder() + .add_home() + .add('Parks', '/parks/') + .add_current('Cedar Point') + .build() + ) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any +from urllib.parse import urljoin + +from django.urls import reverse + +if TYPE_CHECKING: + from django.db.models import Model + from django.http import HttpRequest + + +@dataclass +class Breadcrumb: + """ + Represents a single breadcrumb item. + + Attributes: + label: Display text for the breadcrumb + url: URL the breadcrumb links to (None for current page) + icon: Optional icon class (e.g., 'fas fa-home') + is_current: Whether this is the current page (last item) + schema_position: Position in Schema.org BreadcrumbList (1-indexed) + """ + + label: str + url: str | None = None + icon: str | None = None + is_current: bool = False + schema_position: int = 1 + + def __post_init__(self) -> None: + """Set is_current to True if no URL is provided.""" + if self.url is None: + self.is_current = True + + @property + def is_clickable(self) -> bool: + """Return True if the breadcrumb should be a link.""" + return self.url is not None and not self.is_current + + def to_schema_dict(self) -> dict[str, Any]: + """ + Return Schema.org BreadcrumbList item format. + + Returns: + Dictionary formatted for JSON-LD structured data + """ + item: dict[str, Any] = { + "@type": "ListItem", + "position": self.schema_position, + "name": self.label, + } + if self.url: + item["item"] = self.url + return item + + +def build_breadcrumb( + label: str, + url: str | None = None, + icon: str | None = None, + is_current: bool = False, +) -> Breadcrumb: + """ + Create a single breadcrumb item. + + Args: + label: Display text for the breadcrumb + url: URL the breadcrumb links to (None for current page) + icon: Optional icon class (e.g., 'fas fa-home') + is_current: Whether this is the current page + + Returns: + Breadcrumb instance + + Examples: + >>> build_breadcrumb('Home', '/', icon='fas fa-home') + Breadcrumb(label='Home', url='/', icon='fas fa-home', is_current=False) + + >>> build_breadcrumb('Cedar Point', is_current=True) + Breadcrumb(label='Cedar Point', url=None, icon=None, is_current=True) + """ + return Breadcrumb(label=label, url=url, icon=icon, is_current=is_current) + + +class BreadcrumbBuilder: + """ + Builder pattern for constructing breadcrumb lists. + + Provides a fluent API for building breadcrumb navigation with + automatic position tracking and common patterns. + + Examples: + >>> builder = BreadcrumbBuilder() + >>> breadcrumbs = ( + ... builder + ... .add_home() + ... .add('Parks', '/parks/') + ... .add_current('Cedar Point') + ... .build() + ... ) + """ + + def __init__(self, base_url: str = "") -> None: + """ + Initialize the breadcrumb builder. + + Args: + base_url: Base URL to prepend to all relative URLs + """ + self._items: list[Breadcrumb] = [] + self._base_url = base_url + + def add( + self, + label: str, + url: str | None = None, + icon: str | None = None, + ) -> BreadcrumbBuilder: + """ + Add a breadcrumb item to the list. + + Args: + label: Display text for the breadcrumb + url: URL the breadcrumb links to + icon: Optional icon class + + Returns: + Self for method chaining + """ + position = len(self._items) + 1 + full_url = urljoin(self._base_url, url) if url else url + self._items.append( + Breadcrumb( + label=label, + url=full_url, + icon=icon, + is_current=False, + schema_position=position, + ) + ) + return self + + def add_home( + self, + label: str = "Home", + url: str = "/", + icon: str = "fas fa-home", + ) -> BreadcrumbBuilder: + """ + Add the home breadcrumb (typically first item). + + Args: + label: Home label (default: 'Home') + url: Home URL (default: '/') + icon: Home icon class (default: 'fas fa-home') + + Returns: + Self for method chaining + """ + return self.add(label, url, icon) + + def add_current( + self, + label: str, + icon: str | None = None, + ) -> BreadcrumbBuilder: + """ + Add the current page breadcrumb (last item, non-clickable). + + Args: + label: Display text for current page + icon: Optional icon class + + Returns: + Self for method chaining + """ + position = len(self._items) + 1 + self._items.append( + Breadcrumb( + label=label, + url=None, + icon=icon, + is_current=True, + schema_position=position, + ) + ) + return self + + def add_from_url( + self, + url_name: str, + label: str, + url_kwargs: dict[str, Any] | None = None, + icon: str | None = None, + ) -> BreadcrumbBuilder: + """ + Add a breadcrumb using Django URL name reverse lookup. + + Args: + url_name: Django URL name to reverse + label: Display text for the breadcrumb + url_kwargs: Keyword arguments for URL reverse + icon: Optional icon class + + Returns: + Self for method chaining + """ + url = reverse(url_name, kwargs=url_kwargs) + return self.add(label, url, icon) + + def add_model( + self, + instance: Model, + url_attr: str = "get_absolute_url", + label_attr: str = "name", + icon: str | None = None, + ) -> BreadcrumbBuilder: + """ + Add a breadcrumb from a model instance. + + Args: + instance: Django model instance + url_attr: Method name to get URL (default: 'get_absolute_url') + label_attr: Attribute name for label (default: 'name') + icon: Optional icon class + + Returns: + Self for method chaining + """ + url_method = getattr(instance, url_attr, None) + url = url_method() if callable(url_method) else None + label = getattr(instance, label_attr, str(instance)) + return self.add(label, url, icon) + + def add_model_current( + self, + instance: Model, + label_attr: str = "name", + icon: str | None = None, + ) -> BreadcrumbBuilder: + """ + Add a model instance as the current page breadcrumb. + + Args: + instance: Django model instance + label_attr: Attribute name for label (default: 'name') + icon: Optional icon class + + Returns: + Self for method chaining + """ + label = getattr(instance, label_attr, str(instance)) + return self.add_current(label, icon) + + def build(self) -> list[Breadcrumb]: + """ + Build and return the breadcrumb list. + + Returns: + List of Breadcrumb instances + """ + return self._items.copy() + + def clear(self) -> BreadcrumbBuilder: + """ + Clear all breadcrumb items. + + Returns: + Self for method chaining + """ + self._items = [] + return self + + +def get_model_breadcrumb( + instance: Model, + include_home: bool = True, + parent_attr: str | None = None, + list_url_name: str | None = None, + list_label: str | None = None, +) -> list[Breadcrumb]: + """ + Generate breadcrumbs for a model instance with parent relationships. + + This function automatically builds breadcrumbs by traversing parent + relationships and including model list pages. + + Args: + instance: Django model instance + include_home: Include home breadcrumb (default: True) + parent_attr: Attribute name for parent relationship (e.g., 'park' for Ride) + list_url_name: URL name for the model's list page + list_label: Label for the list page breadcrumb + + Returns: + List of Breadcrumb instances + + Examples: + >>> ride = Ride.objects.get(slug='millennium-force') + >>> breadcrumbs = get_model_breadcrumb( + ... ride, + ... parent_attr='park', + ... list_url_name='rides:list', + ... list_label='Rides', + ... ) + # Returns: [Home, Parks, Cedar Point, Rides, Millennium Force] + """ + builder = BreadcrumbBuilder() + + if include_home: + builder.add_home() + + # Add parent breadcrumbs if parent_attr is specified + if parent_attr: + parent = getattr(instance, parent_attr, None) + if parent: + # Recursively get parent's model name for list URL + parent_model_name = parent.__class__.__name__.lower() + parent_list_url = f"{parent_model_name}s:list" + parent_list_label = f"{parent.__class__.__name__}s" + + try: + builder.add_from_url(parent_list_url, parent_list_label) + except Exception: + pass + + builder.add_model(parent) + + # Add list page breadcrumb + if list_url_name and list_label: + try: + builder.add_from_url(list_url_name, list_label) + except Exception: + pass + + # Add current model instance + builder.add_model_current(instance) + + return builder.build() + + +def breadcrumbs_to_schema( + breadcrumbs: list[Breadcrumb], + request: HttpRequest | None = None, +) -> dict[str, Any]: + """ + Convert breadcrumbs to Schema.org BreadcrumbList JSON-LD format. + + Args: + breadcrumbs: List of Breadcrumb instances + request: Optional HttpRequest for building absolute URLs + + Returns: + Dictionary formatted for JSON-LD structured data + + Examples: + >>> breadcrumbs = [ + ... build_breadcrumb('Home', '/'), + ... build_breadcrumb('Parks', '/parks/'), + ... ] + >>> schema = breadcrumbs_to_schema(breadcrumbs, request) + >>> print(json.dumps(schema, indent=2)) + """ + base_url = "" + if request: + base_url = f"{request.scheme}://{request.get_host()}" + + items = [] + for i, crumb in enumerate(breadcrumbs, 1): + item: dict[str, Any] = { + "@type": "ListItem", + "position": i, + "name": crumb.label, + } + if crumb.url: + item["item"] = urljoin(base_url, crumb.url) if base_url else crumb.url + items.append(item) + + return { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + "itemListElement": items, + } diff --git a/backend/apps/core/utils/messages.py b/backend/apps/core/utils/messages.py new file mode 100644 index 00000000..7e2bd14f --- /dev/null +++ b/backend/apps/core/utils/messages.py @@ -0,0 +1,463 @@ +""" +Standardized message utilities for the ThrillWiki application. + +This module provides helper functions for creating consistent user-facing +messages across the application. These functions ensure standardized +messaging patterns for success, error, warning, and info notifications. + +Usage Examples: + >>> from apps.core.utils.messages import success_created, error_validation + >>> message = success_created('Park', 'Cedar Point') + >>> # Returns: 'Cedar Point has been created successfully.' + + >>> message = error_validation('email') + >>> # Returns: 'Please check the email field and try again.' +""" + +from __future__ import annotations + +from typing import Any + + +def success_created( + model_name: str, + object_name: str | None = None, + custom_message: str | None = None, +) -> str: + """ + Generate a success message for object creation. + + Args: + model_name: The type of object created (e.g., 'Park', 'Ride') + object_name: Optional name of the created object + custom_message: Optional custom message to use instead of default + + Returns: + Formatted success message + + Examples: + >>> success_created('Park', 'Cedar Point') + 'Cedar Point has been created successfully.' + + >>> success_created('Review') + 'Review has been created successfully.' + """ + if custom_message: + return custom_message + if object_name: + return f"{object_name} has been created successfully." + return f"{model_name} has been created successfully." + + +def success_updated( + model_name: str, + object_name: str | None = None, + custom_message: str | None = None, +) -> str: + """ + Generate a success message for object update. + + Args: + model_name: The type of object updated (e.g., 'Park', 'Ride') + object_name: Optional name of the updated object + custom_message: Optional custom message to use instead of default + + Returns: + Formatted success message + + Examples: + >>> success_updated('Park', 'Cedar Point') + 'Cedar Point has been updated successfully.' + + >>> success_updated('Profile') + 'Profile has been updated successfully.' + """ + if custom_message: + return custom_message + if object_name: + return f"{object_name} has been updated successfully." + return f"{model_name} has been updated successfully." + + +def success_deleted( + model_name: str, + object_name: str | None = None, + custom_message: str | None = None, +) -> str: + """ + Generate a success message for object deletion. + + Args: + model_name: The type of object deleted (e.g., 'Park', 'Ride') + object_name: Optional name of the deleted object + custom_message: Optional custom message to use instead of default + + Returns: + Formatted success message + + Examples: + >>> success_deleted('Park', 'Old Park') + 'Old Park has been deleted successfully.' + + >>> success_deleted('Review') + 'Review has been deleted successfully.' + """ + if custom_message: + return custom_message + if object_name: + return f"{object_name} has been deleted successfully." + return f"{model_name} has been deleted successfully." + + +def success_action( + action: str, + model_name: str, + object_name: str | None = None, +) -> str: + """ + Generate a success message for a custom action. + + Args: + action: The action performed (e.g., 'approved', 'published') + model_name: The type of object + object_name: Optional name of the object + + Returns: + Formatted success message + + Examples: + >>> success_action('approved', 'Submission', 'New Cedar Point Photo') + 'New Cedar Point Photo has been approved successfully.' + + >>> success_action('published', 'Article') + 'Article has been published successfully.' + """ + if object_name: + return f"{object_name} has been {action} successfully." + return f"{model_name} has been {action} successfully." + + +def error_validation( + field_name: str | None = None, + custom_message: str | None = None, +) -> str: + """ + Generate an error message for validation failures. + + Args: + field_name: Optional field name that failed validation + custom_message: Optional custom message to use instead of default + + Returns: + Formatted error message + + Examples: + >>> error_validation('email') + 'Please check the email field and try again.' + + >>> error_validation() + 'Please check the form and correct any errors.' + """ + if custom_message: + return custom_message + if field_name: + return f"Please check the {field_name} field and try again." + return "Please check the form and correct any errors." + + +def error_permission( + action: str | None = None, + custom_message: str | None = None, +) -> str: + """ + Generate an error message for permission denied. + + Args: + action: Optional action that was denied + custom_message: Optional custom message to use instead of default + + Returns: + Formatted error message + + Examples: + >>> error_permission('edit this park') + 'You do not have permission to edit this park.' + + >>> error_permission() + 'You do not have permission to perform this action.' + """ + if custom_message: + return custom_message + if action: + return f"You do not have permission to {action}." + return "You do not have permission to perform this action." + + +def error_not_found( + model_name: str, + identifier: str | None = None, + custom_message: str | None = None, +) -> str: + """ + Generate an error message for object not found. + + Args: + model_name: The type of object not found + identifier: Optional identifier that was searched for + custom_message: Optional custom message to use instead of default + + Returns: + Formatted error message + + Examples: + >>> error_not_found('Park', 'cedar-point') + 'Park "cedar-point" was not found.' + + >>> error_not_found('Ride') + 'Ride was not found.' + """ + if custom_message: + return custom_message + if identifier: + return f'{model_name} "{identifier}" was not found.' + return f"{model_name} was not found." + + +def error_server( + custom_message: str | None = None, +) -> str: + """ + Generate an error message for server errors. + + Args: + custom_message: Optional custom message to use instead of default + + Returns: + Formatted error message + + Examples: + >>> error_server() + 'An unexpected error occurred. Please try again later.' + """ + if custom_message: + return custom_message + return "An unexpected error occurred. Please try again later." + + +def error_network( + custom_message: str | None = None, +) -> str: + """ + Generate an error message for network errors. + + Args: + custom_message: Optional custom message to use instead of default + + Returns: + Formatted error message + + Examples: + >>> error_network() + 'Network error. Please check your connection and try again.' + """ + if custom_message: + return custom_message + return "Network error. Please check your connection and try again." + + +def warning_permission( + action: str | None = None, + custom_message: str | None = None, +) -> str: + """ + Generate a warning message for permission issues. + + Args: + action: Optional action that requires permission + custom_message: Optional custom message to use instead of default + + Returns: + Formatted warning message + + Examples: + >>> warning_permission('edit') + 'You may not have permission to edit. Please log in to continue.' + + >>> warning_permission() + 'Please log in to continue.' + """ + if custom_message: + return custom_message + if action: + return f"You may not have permission to {action}. Please log in to continue." + return "Please log in to continue." + + +def warning_unsaved_changes( + custom_message: str | None = None, +) -> str: + """ + Generate a warning message for unsaved changes. + + Args: + custom_message: Optional custom message to use instead of default + + Returns: + Formatted warning message + + Examples: + >>> warning_unsaved_changes() + 'You have unsaved changes. Are you sure you want to leave?' + """ + if custom_message: + return custom_message + return "You have unsaved changes. Are you sure you want to leave?" + + +def warning_rate_limit( + custom_message: str | None = None, +) -> str: + """ + Generate a warning message for rate limiting. + + Args: + custom_message: Optional custom message to use instead of default + + Returns: + Formatted warning message + + Examples: + >>> warning_rate_limit() + 'Too many requests. Please wait a moment before trying again.' + """ + if custom_message: + return custom_message + return "Too many requests. Please wait a moment before trying again." + + +def info_message( + message: str, +) -> str: + """ + Generate an info message. + + Args: + message: The information message to display + + Returns: + The message as-is (for consistency with other functions) + + Examples: + >>> info_message('Your session will expire in 5 minutes.') + 'Your session will expire in 5 minutes.' + """ + return message + + +def info_loading( + action: str | None = None, +) -> str: + """ + Generate an info message for loading states. + + Args: + action: Optional action being performed + + Returns: + Formatted info message + + Examples: + >>> info_loading('parks') + 'Loading parks...' + + >>> info_loading() + 'Loading...' + """ + if action: + return f"Loading {action}..." + return "Loading..." + + +def info_processing( + action: str | None = None, +) -> str: + """ + Generate an info message for processing states. + + Args: + action: Optional action being processed + + Returns: + Formatted info message + + Examples: + >>> info_processing('your request') + 'Processing your request...' + + >>> info_processing() + 'Processing...' + """ + if action: + return f"Processing {action}..." + return "Processing..." + + +def confirm_delete( + model_name: str, + object_name: str | None = None, +) -> str: + """ + Generate a confirmation message for deletion. + + Args: + model_name: The type of object to delete + object_name: Optional name of the object + + Returns: + Formatted confirmation message + + Examples: + >>> confirm_delete('Park', 'Cedar Point') + 'Are you sure you want to delete "Cedar Point"? This action cannot be undone.' + + >>> confirm_delete('Review') + 'Are you sure you want to delete this Review? This action cannot be undone.' + """ + if object_name: + return f'Are you sure you want to delete "{object_name}"? This action cannot be undone.' + return f"Are you sure you want to delete this {model_name}? This action cannot be undone." + + +def format_count_message( + count: int, + singular: str, + plural: str | None = None, + zero_message: str | None = None, +) -> str: + """ + Generate a count-aware message. + + Args: + count: The count of items + singular: Singular form of the message + plural: Optional plural form (defaults to singular + 's') + zero_message: Optional message for zero count + + Returns: + Formatted count message + + Examples: + >>> format_count_message(1, 'park', 'parks') + '1 park' + + >>> format_count_message(5, 'park', 'parks') + '5 parks' + + >>> format_count_message(0, 'park', 'parks', 'No parks found') + 'No parks found' + """ + if count == 0 and zero_message: + return zero_message + if plural is None: + plural = f"{singular}s" + return f"{count} {singular if count == 1 else plural}" diff --git a/backend/apps/core/utils/meta.py b/backend/apps/core/utils/meta.py new file mode 100644 index 00000000..e1ea48b6 --- /dev/null +++ b/backend/apps/core/utils/meta.py @@ -0,0 +1,340 @@ +""" +Meta tag utilities for the ThrillWiki application. + +This module provides helper functions for generating consistent meta tags, +Open Graph data, and canonical URLs for SEO and social sharing. + +Usage Examples: + >>> from apps.core.utils.meta import generate_meta_description, get_og_image + >>> description = generate_meta_description(park) + >>> # Returns: 'Cedar Point is a world-famous amusement park located in Sandusky, Ohio...' + + >>> og_image = get_og_image(park) + >>> # Returns: URL to the park's featured image or default OG image +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Any +from urllib.parse import urljoin + +from django.conf import settings +from django.templatetags.static import static + +if TYPE_CHECKING: + from django.db.models import Model + from django.http import HttpRequest + + +def generate_meta_description( + instance: Model | None = None, + text: str | None = None, + max_length: int = 160, + fallback: str = "ThrillWiki - Your comprehensive guide to theme parks and roller coasters", +) -> str: + """ + Generate a meta description from a model instance or text. + + Automatically truncates to max_length, preserving word boundaries + and adding ellipsis if truncated. + + Args: + instance: Django model instance with description/content attribute + text: Direct text to use for description + max_length: Maximum length for the description (default: 160) + fallback: Fallback text if no description found + + Returns: + Formatted meta description + + Examples: + >>> park = Park.objects.get(slug='cedar-point') + >>> generate_meta_description(park) + 'Cedar Point is a world-famous amusement park located in Sandusky, Ohio...' + + >>> generate_meta_description(text='A very long description that needs to be truncated...') + 'A very long description that needs to be...' + """ + description = "" + + if text: + description = text + elif instance: + # Try common description attributes + for attr in ("description", "content", "bio", "summary", "overview"): + value = getattr(instance, attr, None) + if value and isinstance(value, str): + description = value + break + + # If no description found, try to build from name and location + if not description: + name = getattr(instance, "name", None) + location = getattr(instance, "location", None) + if name and location: + description = f"{name} is located in {location}." + elif name: + description = f"Learn more about {name} on ThrillWiki." + + if not description: + return fallback + + # Clean up the description + description = _clean_text(description) + + # Truncate if needed + if len(description) > max_length: + description = _truncate_text(description, max_length) + + return description + + +def get_og_image( + instance: Model | None = None, + image_url: str | None = None, + request: HttpRequest | None = None, +) -> str: + """ + Get the Open Graph image URL for a model instance. + + Attempts to find an image from the model instance, falls back to + the default OG image. + + Args: + instance: Django model instance with image attribute + image_url: Direct image URL to use + request: HttpRequest for building absolute URLs + + Returns: + Absolute URL to the Open Graph image + + Examples: + >>> park = Park.objects.get(slug='cedar-point') + >>> get_og_image(park, request=request) + 'https://thrillwiki.com/media/parks/cedar-point.jpg' + """ + base_url = "" + if request: + base_url = f"{request.scheme}://{request.get_host()}" + elif hasattr(settings, "FRONTEND_DOMAIN"): + base_url = settings.FRONTEND_DOMAIN + + # Use provided image URL + if image_url: + if image_url.startswith(("http://", "https://")): + return image_url + return urljoin(base_url, image_url) + + # Try to get image from model instance + if instance: + for attr in ("featured_image", "image", "photo", "cover_image", "thumbnail"): + image_field = getattr(instance, attr, None) + if image_field: + try: + if hasattr(image_field, "url"): + return urljoin(base_url, image_field.url) + except ValueError: + continue + + # Try to get from related photos + if hasattr(instance, "photos"): + try: + first_photo = instance.photos.first() + if first_photo and hasattr(first_photo, "image"): + return urljoin(base_url, first_photo.image.url) + except Exception: + pass + + # Fall back to default OG image + default_og = static("images/og-default.jpg") + return urljoin(base_url, default_og) + + +def build_canonical_url( + request: HttpRequest | None = None, + path: str | None = None, + instance: Model | None = None, +) -> str: + """ + Build the canonical URL for a page. + + Args: + request: HttpRequest for building the URL from current request + path: Direct path to use for the canonical URL + instance: Model instance with get_absolute_url method + + Returns: + Canonical URL + + Examples: + >>> build_canonical_url(request=request) + 'https://thrillwiki.com/parks/cedar-point/' + + >>> build_canonical_url(path='/parks/') + 'https://thrillwiki.com/parks/' + """ + base_url = "" + if hasattr(settings, "FRONTEND_DOMAIN"): + base_url = settings.FRONTEND_DOMAIN + elif request: + base_url = f"{request.scheme}://{request.get_host()}" + + # Get path from various sources + url_path = "" + if path: + url_path = path + elif instance and hasattr(instance, "get_absolute_url"): + url_path = instance.get_absolute_url() + elif request: + url_path = request.path + + # Remove query strings for canonical URL + if "?" in url_path: + url_path = url_path.split("?")[0] + + return urljoin(base_url, url_path) + + +def build_page_title( + title: str, + section: str | None = None, + site_name: str = "ThrillWiki", + separator: str = " - ", +) -> str: + """ + Build a consistent page title. + + Args: + title: Page-specific title + section: Optional section name (e.g., 'Parks', 'Rides') + site_name: Site name to append (default: 'ThrillWiki') + separator: Separator between parts (default: ' - ') + + Returns: + Formatted page title + + Examples: + >>> build_page_title('Cedar Point', 'Parks') + 'Cedar Point - Parks - ThrillWiki' + + >>> build_page_title('Search Results') + 'Search Results - ThrillWiki' + """ + parts = [title] + if section: + parts.append(section) + parts.append(site_name) + return separator.join(parts) + + +def get_twitter_card_type( + instance: Model | None = None, + card_type: str | None = None, +) -> str: + """ + Determine the appropriate Twitter card type. + + Args: + instance: Model instance to check for images + card_type: Explicit card type to use + + Returns: + Twitter card type ('summary_large_image' or 'summary') + + Examples: + >>> get_twitter_card_type(park) + 'summary_large_image' # Park has featured image + + >>> get_twitter_card_type() + 'summary' + """ + if card_type: + return card_type + + # Use large image card if instance has an image + if instance: + for attr in ("featured_image", "image", "photo", "cover_image"): + image_field = getattr(instance, attr, None) + if image_field: + try: + if hasattr(image_field, "url") and image_field.url: + return "summary_large_image" + except ValueError: + continue + + return "summary" + + +def build_meta_context( + title: str, + description: str | None = None, + instance: Model | None = None, + request: HttpRequest | None = None, + section: str | None = None, + og_type: str = "website", + twitter_card: str | None = None, +) -> dict[str, Any]: + """ + Build a complete meta context dictionary for templates. + + Args: + title: Page title + description: Meta description (auto-generated if None) + instance: Model instance for generating meta data + request: HttpRequest for URLs + section: Section name for title + og_type: Open Graph type (default: 'website') + twitter_card: Twitter card type (auto-detected if None) + + Returns: + Dictionary with all meta tag values + + Examples: + >>> context = build_meta_context( + ... title='Cedar Point', + ... instance=park, + ... request=request, + ... section='Parks', + ... og_type='place', + ... ) + """ + return { + "page_title": build_page_title(title, section), + "meta_description": generate_meta_description(instance, description), + "canonical_url": build_canonical_url(request, instance=instance), + "og_type": og_type, + "og_title": title, + "og_description": generate_meta_description(instance, description), + "og_image": get_og_image(instance, request=request), + "twitter_card": get_twitter_card_type(instance, twitter_card), + "twitter_title": title, + "twitter_description": generate_meta_description(instance, description), + } + + +def _clean_text(text: str) -> str: + """ + Clean text for use in meta tags. + + Removes HTML tags, extra whitespace, and normalizes line breaks. + """ + # Remove HTML tags + text = re.sub(r"<[^>]+>", "", text) + # Replace multiple whitespace with single space + text = re.sub(r"\s+", " ", text) + # Strip leading/trailing whitespace + text = text.strip() + return text + + +def _truncate_text(text: str, max_length: int) -> str: + """ + Truncate text at word boundary with ellipsis. + """ + if len(text) <= max_length: + return text + + # Truncate at word boundary + truncated = text[: max_length - 3].rsplit(" ", 1)[0] + return f"{truncated}..." diff --git a/backend/apps/parks/templatetags/park_tags.py b/backend/apps/parks/templatetags/park_tags.py index 42c15e64..31370d15 100644 --- a/backend/apps/parks/templatetags/park_tags.py +++ b/backend/apps/parks/templatetags/park_tags.py @@ -3,6 +3,74 @@ from django import template register = template.Library() +# Status configuration mapping for parks and rides +STATUS_CONFIG = { + 'OPERATING': { + 'label': 'Operating', + 'classes': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + 'icon': True, + }, + 'CLOSED_TEMP': { + 'label': 'Temporarily Closed', + 'classes': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + 'icon': True, + }, + 'CLOSED_PERM': { + 'label': 'Permanently Closed', + 'classes': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', + 'icon': True, + }, + 'CONSTRUCTION': { + 'label': 'Under Construction', + 'classes': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', + 'icon': True, + }, + 'DEMOLISHED': { + 'label': 'Demolished', + 'classes': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', + 'icon': True, + }, + 'RELOCATED': { + 'label': 'Relocated', + 'classes': 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', + 'icon': True, + }, + 'SBNO': { + 'label': 'Standing But Not Operating', + 'classes': 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', + 'icon': True, + }, +} + +# Default config for unknown statuses +DEFAULT_STATUS_CONFIG = { + 'label': 'Unknown', + 'classes': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', + 'icon': False, +} + + +@register.filter +def get_status_config(status): + """ + Get status badge configuration for a given status value. + + Usage: + {% with config=status|get_status_config %} + {{ config.label }} + {% endwith %} + + Args: + status: Status string (e.g., 'OPERATING', 'CLOSED_TEMP') + + Returns: + Dictionary with 'label', 'classes', and 'icon' keys + """ + if status is None: + return DEFAULT_STATUS_CONFIG + return STATUS_CONFIG.get(status, DEFAULT_STATUS_CONFIG) + + @register.filter def has_reviewed_park(user, park): """Check if a user has reviewed a park""" diff --git a/backend/config/django/base.py b/backend/config/django/base.py index 4744d26d..b68c1529 100644 --- a/backend/config/django/base.py +++ b/backend/config/django/base.py @@ -151,6 +151,8 @@ if TEMPLATES_ENABLED: "django.contrib.messages.context_processors.messages", "apps.moderation.context_processors.moderation_access", "apps.core.context_processors.fsm_context", + "apps.core.context_processors.breadcrumbs", + "apps.core.context_processors.page_meta", ] }, } @@ -170,6 +172,8 @@ else: "django.contrib.messages.context_processors.messages", "apps.moderation.context_processors.moderation_access", "apps.core.context_processors.fsm_context", + "apps.core.context_processors.breadcrumbs", + "apps.core.context_processors.page_meta", ] }, } @@ -337,6 +341,7 @@ REST_FRAMEWORK = { ], "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", "PAGE_SIZE": 20, + "MAX_PAGE_SIZE": 100, "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.AcceptHeaderVersioning", "DEFAULT_VERSION": "v1", "ALLOWED_VERSIONS": ["v1"], @@ -355,18 +360,59 @@ REST_FRAMEWORK = { "rest_framework.filters.SearchFilter", "rest_framework.filters.OrderingFilter", ], + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": { + "anon": "60/minute", + "user": "1000/hour", + }, "TEST_REQUEST_DEFAULT_FORMAT": "json", "NON_FIELD_ERRORS_KEY": "non_field_errors", "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", } # CORS Settings for API +# https://github.com/adamchainz/django-cors-headers CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_ALL_ORIGINS = config( "CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool ) # type: ignore[arg-type] +# Allowed HTTP headers for CORS requests +CORS_ALLOW_HEADERS = [ + "accept", + "accept-encoding", + "authorization", + "content-type", + "dnt", + "origin", + "user-agent", + "x-csrftoken", + "x-requested-with", + "x-api-version", +] + +# HTTP methods allowed for CORS requests +CORS_ALLOW_METHODS = [ + "DELETE", + "GET", + "OPTIONS", + "PATCH", + "POST", + "PUT", +] + +# Expose rate limit headers to browsers +CORS_EXPOSE_HEADERS = [ + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-RateLimit-Reset", + "X-API-Version", +] + API_RATE_LIMIT_PER_MINUTE = config( "API_RATE_LIMIT_PER_MINUTE", default=60, cast=int @@ -376,13 +422,43 @@ API_RATE_LIMIT_PER_HOUR = config( ) # type: ignore[arg-type] SPECTACULAR_SETTINGS = { "TITLE": "ThrillWiki API", - "DESCRIPTION": "Comprehensive theme park and ride information API", + "DESCRIPTION": """Comprehensive theme park and ride information API. + +## API Conventions + +### Response Format +All successful responses include a `success: true` field with data nested under `data`. +All error responses include an `error` object with `code` and `message` fields. + +### Pagination +List endpoints support pagination with `page` and `page_size` parameters. +Default page size is 20, maximum is 100. + +### Filtering +Range filters use `{field}_min` and `{field}_max` naming convention. +Search uses the `search` parameter. +Ordering uses the `ordering` parameter (prefix with `-` for descending). + +### Field Naming +All field names use snake_case convention (e.g., `image_url`, `created_at`). +""", "VERSION": "1.0.0", "SERVE_INCLUDE_SCHEMA": False, "COMPONENT_SPLIT_REQUEST": True, "TAGS": [ {"name": "Parks", "description": "Theme park operations"}, {"name": "Rides", "description": "Ride information and management"}, + {"name": "Park Media", "description": "Park photos and media management"}, + {"name": "Ride Media", "description": "Ride photos and media management"}, + {"name": "Authentication", "description": "User authentication and session management"}, + {"name": "Social Authentication", "description": "Social provider login and account linking"}, + {"name": "User Profile", "description": "User profile management"}, + {"name": "User Settings", "description": "User preferences and settings"}, + {"name": "User Notifications", "description": "User notification management"}, + {"name": "User Content", "description": "User-generated content (top lists, reviews)"}, + {"name": "User Management", "description": "Admin user management operations"}, + {"name": "Self-Service Account Management", "description": "User account deletion and management"}, + {"name": "Core", "description": "Core utility endpoints (search, suggestions)"}, { "name": "Statistics", "description": "Statistical endpoints providing aggregated data and insights", diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a3039eaf..d9732966 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -82,5 +82,72 @@ typeCheckingMode = "basic" [tool.pylance] stubPath = "stubs" +# ============================================================================= +# Pytest Configuration +# ============================================================================= + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.django.test" +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--strict-markers", + "--tb=short", +] +markers = [ + "unit: Unit tests (fast, isolated)", + "integration: Integration tests (may use database)", + "e2e: End-to-end browser tests (slow, requires server)", + "slow: Tests that take a long time to run", + "api: API endpoint tests", +] +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", +] + +# ============================================================================= +# Coverage Configuration +# ============================================================================= + +[tool.coverage.run] +source = ["apps"] +branch = true +omit = [ + "*/migrations/*", + "*/tests/*", + "*/__pycache__/*", + "*/admin.py", + "*/apps.py", + "manage.py", + "config/*", +] +parallel = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "def __str__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "if typing.TYPE_CHECKING:", + "@abstractmethod", + "@abc.abstractmethod", +] +show_missing = true +skip_covered = false +fail_under = 70 + +[tool.coverage.html] +directory = "htmlcov" + +[tool.coverage.xml] +output = "coverage.xml" + [tool.uv.sources] python-json-logger = { url = "https://github.com/nhairs/python-json-logger/releases/download/v3.0.0/python_json_logger-3.0.0-py3-none-any.whl" } diff --git a/backend/static/css/alerts.css b/backend/static/css/alerts.css deleted file mode 100644 index 3b7d4260..00000000 --- a/backend/static/css/alerts.css +++ /dev/null @@ -1,44 +0,0 @@ -/* Alert Styles */ -.alert { - @apply fixed z-50 px-4 py-3 transition-all duration-500 transform rounded-lg shadow-lg right-4 top-4; - animation: slideIn 0.5s ease-out forwards; -} - -.alert-success { - @apply text-white bg-green-500; -} - -.alert-error { - @apply text-white bg-red-500; -} - -.alert-info { - @apply text-white bg-blue-500; -} - -.alert-warning { - @apply text-white bg-yellow-500; -} - -/* Animation keyframes */ -@keyframes slideIn { - 0% { - transform: translateX(100%); - opacity: 0; - } - 100% { - transform: translateX(0); - opacity: 1; - } -} - -@keyframes slideOut { - 0% { - transform: translateX(0); - opacity: 1; - } - 100% { - transform: translateX(100%); - opacity: 0; - } -} diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 0f934cd5..492dff70 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -1,447 +1,1108 @@ /** * ThrillWiki Component Styles - * Enhanced CSS matching shadcn/ui design system from React frontend + * Enhanced CSS matching shadcn/ui design system + * + * IMPORTANT: This file depends on design-tokens.css for CSS variables. + * Load order: design-tokens.css → tailwind.css → components.css + * + * Design Tokens Used: + * - Color tokens: --color-primary, --color-secondary, --color-accent, etc. + * - Semantic tokens: --color-background, --color-foreground, --color-muted, etc. + * - Spacing: --spacing-*, --radius-* + * - Shadows: --shadow-* + * - Typography: --font-family-*, --font-size-* */ -/* CSS Variables for Design System */ -:root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 262.1 83.3% 57.8%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96%; - --secondary-foreground: 222.2 84% 4.9%; - --muted: 210 40% 96%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96%; - --accent-foreground: 222.2 84% 4.9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 262.1 83.3% 57.8%; - --radius: 0.5rem; -} +/* ============================================================================= + Base Styles - Apply design token defaults + ============================================================================= */ -.dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 262.1 83.3% 57.8%; - --primary-foreground: 210 40% 98%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 262.1 83.3% 57.8%; -} - -/* Base Styles */ * { - border-color: hsl(var(--border)); + border-color: var(--color-border); } body { - background-color: hsl(var(--background)); - color: hsl(var(--foreground)); + background-color: var(--color-background); + color: var(--color-foreground); } -/* Component Classes */ -.bg-background { background-color: hsl(var(--background)); } -.bg-foreground { background-color: hsl(var(--foreground)); } -.bg-card { background-color: hsl(var(--card)); } -.bg-card-foreground { background-color: hsl(var(--card-foreground)); } -.bg-popover { background-color: hsl(var(--popover)); } -.bg-popover-foreground { background-color: hsl(var(--popover-foreground)); } -.bg-primary { background-color: hsl(var(--primary)); } -.bg-primary-foreground { background-color: hsl(var(--primary-foreground)); } -.bg-secondary { background-color: hsl(var(--secondary)); } -.bg-secondary-foreground { background-color: hsl(var(--secondary-foreground)); } -.bg-muted { background-color: hsl(var(--muted)); } -.bg-muted-foreground { background-color: hsl(var(--muted-foreground)); } -.bg-accent { background-color: hsl(var(--accent)); } -.bg-accent-foreground { background-color: hsl(var(--accent-foreground)); } -.bg-destructive { background-color: hsl(var(--destructive)); } -.bg-destructive-foreground { background-color: hsl(var(--destructive-foreground)); } +/* ============================================================================= + Component Color Classes - Using Design Tokens + ============================================================================= */ -.text-background { color: hsl(var(--background)); } -.text-foreground { color: hsl(var(--foreground)); } -.text-card { color: hsl(var(--card)); } -.text-card-foreground { color: hsl(var(--card-foreground)); } -.text-popover { color: hsl(var(--popover)); } -.text-popover-foreground { color: hsl(var(--popover-foreground)); } -.text-primary { color: hsl(var(--primary)); } -.text-primary-foreground { color: hsl(var(--primary-foreground)); } -.text-secondary { color: hsl(var(--secondary)); } -.text-secondary-foreground { color: hsl(var(--secondary-foreground)); } -.text-muted { color: hsl(var(--muted)); } -.text-muted-foreground { color: hsl(var(--muted-foreground)); } -.text-accent { color: hsl(var(--accent)); } -.text-accent-foreground { color: hsl(var(--accent-foreground)); } -.text-destructive { color: hsl(var(--destructive)); } -.text-destructive-foreground { color: hsl(var(--destructive-foreground)); } +/* Background Colors */ +.bg-background { background-color: var(--color-background); } +.bg-foreground { background-color: var(--color-foreground); } +.bg-card { background-color: var(--color-card); } +.bg-card-foreground { background-color: var(--color-card-foreground); } +.bg-popover { background-color: var(--color-popover); } +.bg-popover-foreground { background-color: var(--color-popover-foreground); } +.bg-primary { background-color: var(--color-primary); } +.bg-primary-foreground { background-color: var(--color-primary-foreground); } +.bg-secondary { background-color: var(--color-secondary); } +.bg-secondary-foreground { background-color: var(--color-secondary-foreground); } +.bg-muted { background-color: var(--color-muted); } +.bg-muted-foreground { background-color: var(--color-muted-foreground); } +.bg-accent { background-color: var(--color-accent); } +.bg-accent-foreground { background-color: var(--color-accent-foreground); } +.bg-destructive { background-color: var(--color-destructive); } +.bg-destructive-foreground { background-color: var(--color-destructive-foreground); } -.border-background { border-color: hsl(var(--background)); } -.border-foreground { border-color: hsl(var(--foreground)); } -.border-card { border-color: hsl(var(--card)); } -.border-card-foreground { border-color: hsl(var(--card-foreground)); } -.border-popover { border-color: hsl(var(--popover)); } -.border-popover-foreground { border-color: hsl(var(--popover-foreground)); } -.border-primary { border-color: hsl(var(--primary)); } -.border-primary-foreground { border-color: hsl(var(--primary-foreground)); } -.border-secondary { border-color: hsl(var(--secondary)); } -.border-secondary-foreground { border-color: hsl(var(--secondary-foreground)); } -.border-muted { border-color: hsl(var(--muted)); } -.border-muted-foreground { border-color: hsl(var(--muted-foreground)); } -.border-accent { border-color: hsl(var(--accent)); } -.border-accent-foreground { border-color: hsl(var(--accent-foreground)); } -.border-destructive { border-color: hsl(var(--destructive)); } -.border-destructive-foreground { border-color: hsl(var(--destructive-foreground)); } -.border-input { border-color: hsl(var(--input)); } +/* Semantic Background Colors */ +.bg-success { background-color: var(--color-success-500); } +.bg-warning { background-color: var(--color-warning-500); } +.bg-error { background-color: var(--color-error-500); } +.bg-info { background-color: var(--color-info-500); } -.ring-background { --tw-ring-color: hsl(var(--background)); } -.ring-foreground { --tw-ring-color: hsl(var(--foreground)); } -.ring-card { --tw-ring-color: hsl(var(--card)); } -.ring-card-foreground { --tw-ring-color: hsl(var(--card-foreground)); } -.ring-popover { --tw-ring-color: hsl(var(--popover)); } -.ring-popover-foreground { --tw-ring-color: hsl(var(--popover-foreground)); } -.ring-primary { --tw-ring-color: hsl(var(--primary)); } -.ring-primary-foreground { --tw-ring-color: hsl(var(--primary-foreground)); } -.ring-secondary { --tw-ring-color: hsl(var(--secondary)); } -.ring-secondary-foreground { --tw-ring-color: hsl(var(--secondary-foreground)); } -.ring-muted { --tw-ring-color: hsl(var(--muted)); } -.ring-muted-foreground { --tw-ring-color: hsl(var(--muted-foreground)); } -.ring-accent { --tw-ring-color: hsl(var(--accent)); } -.ring-accent-foreground { --tw-ring-color: hsl(var(--accent-foreground)); } -.ring-destructive { --tw-ring-color: hsl(var(--destructive)); } -.ring-destructive-foreground { --tw-ring-color: hsl(var(--destructive-foreground)); } -.ring-ring { --tw-ring-color: hsl(var(--ring)); } +/* Text Colors */ +.text-background { color: var(--color-background); } +.text-foreground { color: var(--color-foreground); } +.text-card { color: var(--color-card); } +.text-card-foreground { color: var(--color-card-foreground); } +.text-popover { color: var(--color-popover); } +.text-popover-foreground { color: var(--color-popover-foreground); } +.text-primary { color: var(--color-primary); } +.text-primary-foreground { color: var(--color-primary-foreground); } +.text-secondary { color: var(--color-secondary); } +.text-secondary-foreground { color: var(--color-secondary-foreground); } +.text-muted { color: var(--color-muted); } +.text-muted-foreground { color: var(--color-muted-foreground); } +.text-accent { color: var(--color-accent); } +.text-accent-foreground { color: var(--color-accent-foreground); } +.text-destructive { color: var(--color-destructive); } +.text-destructive-foreground { color: var(--color-destructive-foreground); } -.ring-offset-background { --tw-ring-offset-color: hsl(var(--background)); } +/* Semantic Text Colors */ +.text-success { color: var(--color-success-600); } +.text-warning { color: var(--color-warning-600); } +.text-error { color: var(--color-error-600); } +.text-info { color: var(--color-info-600); } + +/* Border Colors */ +.border-background { border-color: var(--color-background); } +.border-foreground { border-color: var(--color-foreground); } +.border-card { border-color: var(--color-card); } +.border-card-foreground { border-color: var(--color-card-foreground); } +.border-popover { border-color: var(--color-popover); } +.border-popover-foreground { border-color: var(--color-popover-foreground); } +.border-primary { border-color: var(--color-primary); } +.border-primary-foreground { border-color: var(--color-primary-foreground); } +.border-secondary { border-color: var(--color-secondary); } +.border-secondary-foreground { border-color: var(--color-secondary-foreground); } +.border-muted { border-color: var(--color-muted); } +.border-muted-foreground { border-color: var(--color-muted-foreground); } +.border-accent { border-color: var(--color-accent); } +.border-accent-foreground { border-color: var(--color-accent-foreground); } +.border-destructive { border-color: var(--color-destructive); } +.border-destructive-foreground { border-color: var(--color-destructive-foreground); } +.border-input { border-color: var(--color-input); } +.border-border { border-color: var(--color-border); } + +/* Ring Colors */ +.ring-background { --tw-ring-color: var(--color-background); } +.ring-foreground { --tw-ring-color: var(--color-foreground); } +.ring-card { --tw-ring-color: var(--color-card); } +.ring-card-foreground { --tw-ring-color: var(--color-card-foreground); } +.ring-popover { --tw-ring-color: var(--color-popover); } +.ring-popover-foreground { --tw-ring-color: var(--color-popover-foreground); } +.ring-primary { --tw-ring-color: var(--color-primary); } +.ring-primary-foreground { --tw-ring-color: var(--color-primary-foreground); } +.ring-secondary { --tw-ring-color: var(--color-secondary); } +.ring-secondary-foreground { --tw-ring-color: var(--color-secondary-foreground); } +.ring-muted { --tw-ring-color: var(--color-muted); } +.ring-muted-foreground { --tw-ring-color: var(--color-muted-foreground); } +.ring-accent { --tw-ring-color: var(--color-accent); } +.ring-accent-foreground { --tw-ring-color: var(--color-accent-foreground); } +.ring-destructive { --tw-ring-color: var(--color-destructive); } +.ring-destructive-foreground { --tw-ring-color: var(--color-destructive-foreground); } +.ring-ring { --tw-ring-color: var(--color-primary); } + +.ring-offset-background { --tw-ring-offset-color: var(--color-background); } + +/* ============================================================================= + Enhanced Button Styles + ============================================================================= */ -/* Enhanced Button Styles */ .btn { - @apply inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + white-space: nowrap; + border-radius: 0.375rem; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + --tw-ring-color: var(--color-primary); + --tw-ring-offset-color: var(--color-background); +} +.btn:focus-visible { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} +.btn:disabled { + pointer-events: none; + opacity: 0.5; } .btn-default { - @apply bg-primary text-primary-foreground hover:bg-primary/90; + background-color: var(--color-primary); + color: var(--color-primary-foreground); +} +.btn-default:hover { + background-color: var(--color-primary-700); } .btn-destructive { - @apply bg-destructive text-destructive-foreground hover:bg-destructive/90; + background-color: var(--color-destructive); + color: var(--color-destructive-foreground); +} +.btn-destructive:hover { + background-color: var(--color-error-700); } .btn-outline { - @apply border border-input bg-background hover:bg-accent hover:text-accent-foreground; + border: 1px solid var(--color-border); + background-color: var(--color-background); +} +.btn-outline:hover { + background-color: var(--color-accent); + color: var(--color-accent-foreground); } .btn-secondary { - @apply bg-secondary text-secondary-foreground hover:bg-secondary/80; + background-color: var(--color-secondary); + color: var(--color-secondary-foreground); +} +.btn-secondary:hover { + background-color: var(--color-secondary-300); } -.btn-ghost { - @apply hover:bg-accent hover:text-accent-foreground; +.btn-ghost:hover { + background-color: var(--color-accent); + color: var(--color-accent-foreground); } .btn-link { - @apply text-primary underline-offset-4 hover:underline; + color: var(--color-primary); + text-underline-offset: 4px; +} +.btn-link:hover { + text-decoration: underline; } .btn-sm { - @apply h-9 rounded-md px-3; + height: 2.25rem; + border-radius: 0.375rem; + padding-left: 0.75rem; + padding-right: 0.75rem; } .btn-lg { - @apply h-11 rounded-md px-8; + height: 2.75rem; + border-radius: 0.375rem; + padding-left: 2rem; + padding-right: 2rem; } .btn-icon { - @apply h-10 w-10; + height: 2.5rem; + width: 2.5rem; } -/* Enhanced Card Styles */ +/* ============================================================================= + Enhanced Card Styles + ============================================================================= */ + .card { - @apply rounded-lg border bg-card text-card-foreground shadow-sm; + border-radius: 0.5rem; + border-width: 1px; + background-color: var(--color-card); + color: var(--color-card-foreground); + border-color: var(--color-border); + box-shadow: var(--shadow-sm); } .card-header { - @apply flex flex-col space-y-1.5 p-6; + display: flex; + flex-direction: column; + gap: 0.375rem; + padding: 1.5rem; } .card-title { - @apply text-2xl font-semibold leading-none tracking-tight; + font-size: 1.5rem; + line-height: 2rem; + font-weight: 600; + line-height: 1; + letter-spacing: -0.025em; } .card-description { - @apply text-sm text-muted-foreground; + font-size: 0.875rem; + line-height: 1.25rem; + color: var(--color-muted-foreground); } .card-content { - @apply p-6 pt-0; + padding: 1.5rem; + padding-top: 0; } .card-footer { - @apply flex items-center p-6 pt-0; + display: flex; + align-items: center; + padding: 1.5rem; + padding-top: 0; } -/* Enhanced Input Styles */ +/* ============================================================================= + Enhanced Input Styles + ============================================================================= */ + .input { - @apply flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50; + display: flex; + height: 2.5rem; + width: 100%; + border-radius: 0.375rem; + border-width: 1px; + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + border-color: var(--color-border); + background-color: var(--color-background); + --tw-ring-color: var(--color-primary); + --tw-ring-offset-color: var(--color-background); +} +.input:focus-visible { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} +.input:disabled { + cursor: not-allowed; + opacity: 0.5; +} +.input::file-selector-button { + border-width: 0; + background-color: transparent; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; } -/* Enhanced Form Styles */ +.input::placeholder { + color: var(--color-muted-foreground); +} + +/* ============================================================================= + Enhanced Form Styles + ============================================================================= */ + .form-group { - @apply space-y-2; + display: flex; + flex-direction: column; + gap: 0.5rem; } .form-label { - @apply text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + line-height: 1; +} +.peer:disabled ~ .form-label { + cursor: not-allowed; + opacity: 0.7; } .form-error { - @apply text-sm font-medium text-destructive; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + color: var(--color-destructive); } .form-description { - @apply text-sm text-muted-foreground; + font-size: 0.875rem; + line-height: 1.25rem; + color: var(--color-muted-foreground); } -/* Enhanced Navigation Styles */ +/* ============================================================================= + Enhanced Navigation Styles + ============================================================================= */ + .nav-link { - @apply flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors; + display: flex; + align-items: center; + gap: 0.5rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + border-radius: 0.375rem; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} +.nav-link:hover { + background-color: var(--color-accent); + color: var(--color-accent-foreground); } .nav-link.active { - @apply bg-accent text-accent-foreground; + background-color: var(--color-accent); + color: var(--color-accent-foreground); } -/* Enhanced Dropdown Styles */ +/* ============================================================================= + Enhanced Dropdown Styles + ============================================================================= */ + .dropdown-content { - @apply z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md; + z-index: 50; + min-width: 8rem; + overflow: hidden; + border-radius: 0.375rem; + border-width: 1px; + padding: 0.25rem; + background-color: var(--color-popover); + color: var(--color-popover-foreground); + border-color: var(--color-border); + box-shadow: var(--shadow-md); } .dropdown-item { - @apply relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50; + position: relative; + display: flex; + cursor: default; + user-select: none; + align-items: center; + border-radius: 0.125rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + padding-top: 0.375rem; + padding-bottom: 0.375rem; + font-size: 0.875rem; + line-height: 1.25rem; + outline: none; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} +.dropdown-item[data-disabled] { + pointer-events: none; + opacity: 0.5; +} +.dropdown-item:focus, +.dropdown-item:hover { + background-color: var(--color-accent); + color: var(--color-accent-foreground); } .dropdown-separator { - @apply -mx-1 my-1 h-px bg-muted; + margin-left: -0.25rem; + margin-right: -0.25rem; + margin-top: 0.25rem; + margin-bottom: 0.25rem; + height: 1px; + background-color: var(--color-muted); } -/* Enhanced Modal Styles */ +/* ============================================================================= + Enhanced Modal Styles + ============================================================================= */ + .modal-overlay { - @apply fixed inset-0 z-50 bg-background/80 backdrop-blur-sm; + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 50; + backdrop-filter: blur(4px); + background-color: rgba(0, 0, 0, 0.5); } .modal-content { - @apply fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg; + position: fixed; + left: 50%; + top: 50%; + z-index: 50; + display: grid; + width: 100%; + max-width: 32rem; + transform: translate(-50%, -50%); + gap: 1rem; + border-width: 1px; + padding: 1.5rem; + transition-duration: 200ms; + background-color: var(--color-background); + border-color: var(--color-border); + box-shadow: var(--shadow-lg); +} +@media (min-width: 640px) { + .modal-content { + border-radius: 0.5rem; + } } .modal-header { - @apply flex flex-col space-y-1.5 text-center sm:text-left; + display: flex; + flex-direction: column; + gap: 0.375rem; + text-align: center; +} +@media (min-width: 640px) { + .modal-header { + text-align: left; + } } .modal-title { - @apply text-lg font-semibold leading-none tracking-tight; + font-size: 1.125rem; + line-height: 1.75rem; + font-weight: 600; + line-height: 1; + letter-spacing: -0.025em; } .modal-description { - @apply text-sm text-muted-foreground; + font-size: 0.875rem; + line-height: 1.25rem; + color: var(--color-muted-foreground); } .modal-footer { - @apply flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2; + display: flex; + flex-direction: column-reverse; +} +@media (min-width: 640px) { + .modal-footer { + flex-direction: row; + justify-content: flex-end; + gap: 0.5rem; + } } -/* Enhanced Alert Styles */ +/* ============================================================================= + Enhanced Alert Styles (consolidated from alerts.css) + ============================================================================= */ + .alert { - @apply relative w-full rounded-lg border p-4; + position: relative; + width: 100%; + border-radius: 0.5rem; + border-width: 1px; + padding: 1rem; + animation: slideInRight 0.3s ease-out; } .alert-default { - @apply bg-background text-foreground; + background-color: var(--color-background); + color: var(--color-foreground); + border-color: var(--color-border); } .alert-destructive { - @apply border-destructive/50 text-destructive dark:border-destructive; + border-color: var(--color-destructive); + color: var(--color-destructive); +} + +.alert-success { + background-color: var(--color-success-500); + color: white; + border-color: var(--color-success-600); +} + +.alert-error { + background-color: var(--color-error-500); + color: white; + border-color: var(--color-error-600); +} + +.alert-warning { + background-color: var(--color-warning-500); + color: white; + border-color: var(--color-warning-600); +} + +.alert-info { + background-color: var(--color-info-500); + color: white; + border-color: var(--color-info-600); +} + +/* Toast/Alert positioning (fixed position variant) */ +.alert-toast { + position: fixed; + z-index: 50; + padding-left: 1rem; + padding-right: 1rem; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + border-radius: 0.5rem; + right: 1rem; + top: 1rem; + box-shadow: var(--shadow-lg); } .alert-title { - @apply mb-1 font-medium leading-none tracking-tight; + margin-bottom: 0.25rem; + font-weight: 500; + line-height: 1; + letter-spacing: -0.025em; } .alert-description { - @apply text-sm opacity-90; + font-size: 0.875rem; + line-height: 1.25rem; + opacity: 0.9; } -/* Enhanced Badge Styles */ +/* ============================================================================= + Enhanced Badge Styles + ============================================================================= */ + .badge { - @apply inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2; + display: inline-flex; + align-items: center; + border-radius: 9999px; + border-width: 1px; + padding-left: 0.625rem; + padding-right: 0.625rem; + padding-top: 0.125rem; + padding-bottom: 0.125rem; + font-size: 0.75rem; + line-height: 1rem; + font-weight: 600; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + --tw-ring-color: var(--color-primary); +} +.badge:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); } .badge-default { - @apply border-transparent bg-primary text-primary-foreground hover:bg-primary/80; + border-color: transparent; + background-color: var(--color-primary); + color: var(--color-primary-foreground); +} +.badge-default:hover { + background-color: var(--color-primary-700); } .badge-secondary { - @apply border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80; + border-color: transparent; + background-color: var(--color-secondary); + color: var(--color-secondary-foreground); +} +.badge-secondary:hover { + background-color: var(--color-secondary-300); } .badge-destructive { - @apply border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80; + border-color: transparent; + background-color: var(--color-destructive); + color: var(--color-destructive-foreground); +} +.badge-destructive:hover { + background-color: var(--color-error-700); } .badge-outline { - @apply text-foreground; + color: var(--color-foreground); + border-color: var(--color-border); } -/* Enhanced Table Styles */ +.badge-success { + border-color: transparent; + background-color: var(--color-success-500); + color: white; +} + +.badge-warning { + border-color: transparent; + background-color: var(--color-warning-500); + color: white; +} + +.badge-info { + border-color: transparent; + background-color: var(--color-info-500); + color: white; +} + +/* ============================================================================= + Enhanced Table Styles + ============================================================================= */ + .table { - @apply w-full caption-bottom text-sm; + width: 100%; + caption-side: bottom; + font-size: 0.875rem; + line-height: 1.25rem; } .table-header { - @apply border-b; + border-bottom: 1px solid var(--color-border); } .table-body { - @apply divide-y; + --tw-divide-color: var(--color-border); +} +.table-body > * + * { + border-top-width: 1px; + border-color: var(--color-border); } .table-row { - @apply border-b transition-colors hover:bg-muted/50; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + border-bottom: 1px solid var(--color-border); +} +.table-row:hover { + background-color: var(--color-muted); } .table-head { - @apply h-12 px-4 text-left align-middle font-medium text-muted-foreground; + height: 3rem; + padding-left: 1rem; + padding-right: 1rem; + text-align: left; + vertical-align: middle; + font-weight: 500; + color: var(--color-muted-foreground); } .table-cell { - @apply p-4 align-middle; + padding: 1rem; + vertical-align: middle; } -/* Enhanced Skeleton Styles */ +/* ============================================================================= + Enhanced Skeleton Styles + ============================================================================= */ + .skeleton { - @apply animate-pulse rounded-md bg-muted; + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + border-radius: 0.375rem; + background-color: var(--color-muted); +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } } -/* Enhanced Separator Styles */ +/* ============================================================================= + Enhanced Separator Styles + ============================================================================= */ + .separator { - @apply shrink-0 bg-border; + flex-shrink: 0; + background-color: var(--color-border); } .separator-horizontal { - @apply h-[1px] w-full; + height: 1px; + width: 100%; } .separator-vertical { - @apply h-full w-[1px]; + height: 100%; + width: 1px; } -/* Enhanced Avatar Styles */ +/* ============================================================================= + Enhanced Avatar Styles + ============================================================================= */ + .avatar { - @apply relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full; + position: relative; + display: flex; + height: 2.5rem; + width: 2.5rem; + flex-shrink: 0; + overflow: hidden; + border-radius: 9999px; } .avatar-image { - @apply aspect-square h-full w-full object-cover; + aspect-ratio: 1 / 1; + height: 100%; + width: 100%; + object-fit: cover; } .avatar-fallback { - @apply flex h-full w-full items-center justify-center rounded-full bg-muted; + display: flex; + height: 100%; + width: 100%; + align-items: center; + justify-content: center; + border-radius: 9999px; + background-color: var(--color-muted); } -/* Enhanced Progress Styles */ +/* ============================================================================= + Enhanced Progress Styles + ============================================================================= */ + .progress { - @apply relative h-4 w-full overflow-hidden rounded-full bg-secondary; + position: relative; + height: 1rem; + width: 100%; + overflow: hidden; + border-radius: 9999px; + background-color: var(--color-secondary); } .progress-indicator { - @apply h-full w-full flex-1 bg-primary transition-all; + height: 100%; + width: 100%; + flex: 1; + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + background-color: var(--color-primary); } -/* Enhanced Scroll Area Styles */ +/* ============================================================================= + Enhanced Scroll Area Styles + ============================================================================= */ + .scroll-area { - @apply relative overflow-hidden; + position: relative; + overflow: hidden; } .scroll-viewport { - @apply h-full w-full rounded-[inherit]; + height: 100%; + width: 100%; + border-radius: inherit; } .scroll-bar { - @apply flex touch-none select-none transition-colors; + display: flex; + touch-action: none; + user-select: none; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; } .scroll-thumb { - @apply relative flex-1 rounded-full bg-border; + position: relative; + flex: 1; + border-radius: 9999px; + background-color: var(--color-border); } -/* Enhanced Tabs Styles */ +/* ============================================================================= + Enhanced Tabs Styles + ============================================================================= */ + .tabs-list { - @apply inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground; + display: inline-flex; + height: 2.5rem; + align-items: center; + justify-content: center; + border-radius: 0.375rem; + padding: 0.25rem; + background-color: var(--color-muted); + color: var(--color-muted-foreground); } .tabs-trigger { - @apply inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm; + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + border-radius: 0.125rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.375rem; + padding-bottom: 0.375rem; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + --tw-ring-color: var(--color-primary); + --tw-ring-offset-color: var(--color-background); +} +.tabs-trigger:focus-visible { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} +.tabs-trigger:disabled { + pointer-events: none; + opacity: 0.5; +} +.tabs-trigger[data-state=active] { + background-color: var(--color-background); + color: var(--color-foreground); + box-shadow: var(--shadow-sm); } .tabs-content { - @apply mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2; + margin-top: 0.5rem; + --tw-ring-color: var(--color-primary); + --tw-ring-offset-color: var(--color-background); +} +.tabs-content:focus-visible { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); } -/* Enhanced Tooltip Styles */ +/* ============================================================================= + Enhanced Tooltip Styles + ============================================================================= */ + .tooltip-content { - @apply z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95; + z-index: 50; + overflow: hidden; + border-radius: 0.375rem; + border-width: 1px; + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.375rem; + padding-bottom: 0.375rem; + font-size: 0.875rem; + line-height: 1.25rem; + animation: fadeIn 0.15s ease-out, scaleIn 0.15s ease-out; + background-color: var(--color-popover); + color: var(--color-popover-foreground); + border-color: var(--color-border); + box-shadow: var(--shadow-md); } -/* Enhanced Switch Styles */ +/* ============================================================================= + Enhanced Switch Styles + ============================================================================= */ + .switch { - @apply peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input; + display: inline-flex; + height: 1.5rem; + width: 2.75rem; + flex-shrink: 0; + cursor: pointer; + align-items: center; + border-radius: 9999px; + border-width: 2px; + border-color: transparent; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + --tw-ring-color: var(--color-primary); + --tw-ring-offset-color: var(--color-background); +} +.switch:focus-visible { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} +.switch:disabled { + cursor: not-allowed; + opacity: 0.5; +} +.switch[data-state=checked] { + background-color: var(--color-primary); +} +.switch[data-state=unchecked] { + background-color: var(--color-input); } .switch-thumb { - @apply pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0; + pointer-events: none; + display: block; + height: 1.25rem; + width: 1.25rem; + border-radius: 9999px; + --tw-ring-shadow: 0 0 #0000; + transition-property: transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + background-color: var(--color-background); + box-shadow: var(--shadow-lg); +} +.switch-thumb[data-state=checked] { + transform: translateX(1.25rem); +} +.switch-thumb[data-state=unchecked] { + transform: translateX(0); } -/* Enhanced Checkbox Styles */ +/* ============================================================================= + Enhanced Checkbox Styles + ============================================================================= */ + .checkbox { - @apply peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground; + height: 1rem; + width: 1rem; + flex-shrink: 0; + border-radius: 0.125rem; + border-width: 1px; + border-color: var(--color-primary); + --tw-ring-color: var(--color-primary); + --tw-ring-offset-color: var(--color-background); +} +.checkbox:focus-visible { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} +.checkbox:disabled { + cursor: not-allowed; + opacity: 0.5; +} +.checkbox[data-state=checked] { + background-color: var(--color-primary); + color: var(--color-primary-foreground); } -/* Enhanced Radio Styles */ +/* ============================================================================= + Enhanced Radio Styles + ============================================================================= */ + .radio { - @apply aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50; + aspect-ratio: 1 / 1; + height: 1rem; + width: 1rem; + border-radius: 9999px; + border-width: 1px; + border-color: var(--color-primary); + color: var(--color-primary); + --tw-ring-color: var(--color-primary); + --tw-ring-offset-color: var(--color-background); +} +.radio:focus { + outline: none; +} +.radio:focus-visible { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} +.radio:disabled { + cursor: not-allowed; + opacity: 0.5; } -/* Enhanced Select Styles */ +/* ============================================================================= + Enhanced Select Styles + ============================================================================= */ + .select-trigger { - @apply flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50; + display: flex; + height: 2.5rem; + width: 100%; + align-items: center; + justify-content: space-between; + border-radius: 0.375rem; + border-width: 1px; + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + border-color: var(--color-border); + background-color: var(--color-background); + --tw-ring-color: var(--color-primary); + --tw-ring-offset-color: var(--color-background); +} +.select-trigger:focus { + outline: none; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} +.select-trigger:disabled { + cursor: not-allowed; + opacity: 0.5; +} +.select-trigger::placeholder { + color: var(--color-muted-foreground); } .select-content { - @apply relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md; + position: relative; + z-index: 50; + max-height: 24rem; + min-width: 8rem; + overflow: hidden; + border-radius: 0.375rem; + border-width: 1px; + background-color: var(--color-popover); + color: var(--color-popover-foreground); + border-color: var(--color-border); + box-shadow: var(--shadow-md); } .select-item { - @apply relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50; + position: relative; + display: flex; + width: 100%; + cursor: default; + user-select: none; + align-items: center; + border-radius: 0.125rem; + padding-top: 0.375rem; + padding-bottom: 0.375rem; + padding-left: 2rem; + padding-right: 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + outline: none; +} +.select-item[data-disabled] { + pointer-events: none; + opacity: 0.5; +} +.select-item:focus, +.select-item:hover { + background-color: var(--color-accent); + color: var(--color-accent-foreground); } -/* Utility Classes */ +/* ============================================================================= + Utility Classes + ============================================================================= */ + .sr-only { position: absolute; width: 1px; @@ -454,7 +1115,10 @@ body { border-width: 0; } -/* Animation Classes */ +/* ============================================================================= + Animation Classes + ============================================================================= */ + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } @@ -475,6 +1139,17 @@ body { to { transform: translateX(100%); } } +@keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + @keyframes scaleIn { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } @@ -509,18 +1184,26 @@ body { animation: scaleOut 0.2s ease-out; } -/* Responsive Design Helpers */ +/* ============================================================================= + Responsive Design Helpers + ============================================================================= */ + @media (max-width: 640px) { .modal-content { - @apply w-[95vw] max-w-none; + width: 95vw; + max-width: none; } - + .dropdown-content { - @apply w-screen max-w-none; + width: 100vw; + max-width: none; } } -/* Dark Mode Specific Adjustments */ +/* ============================================================================= + Dark Mode Specific Adjustments + ============================================================================= */ + .dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3); } @@ -533,36 +1216,45 @@ body { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2); } -/* Focus Visible Improvements */ +/* ============================================================================= + Focus Visible Improvements + ============================================================================= */ + .focus-visible\:ring-2:focus-visible { --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); } -/* High Contrast Mode Support */ +/* ============================================================================= + High Contrast Mode Support + ============================================================================= */ + @media (prefers-contrast: high) { .border { border-width: 2px; } - + .btn { border-width: 2px; } - + .input { border-width: 2px; } } -/* Reduced Motion Support */ +/* ============================================================================= + Reduced Motion Support + ============================================================================= */ + @media (prefers-reduced-motion: reduce) { .transition-colors, .transition-all, .transition-transform { transition: none; } - + .animate-fade-in, .animate-fade-out, .animate-slide-in, @@ -572,3 +1264,47 @@ body { animation: none; } } + +/* ============================================================================= + Loading States + ============================================================================= */ + +.loading { + opacity: 0.6; + pointer-events: none; +} + +.spinner { + display: inline-block; + width: 1rem; + height: 1rem; + border: 2px solid var(--color-secondary-200); + border-radius: 50%; + border-top-color: var(--color-primary); + animation: spin 1s ease-in-out infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ============================================================================= + Utility Gradient Classes + ============================================================================= */ + +.text-gradient { + background: linear-gradient(135deg, var(--color-primary), var(--color-accent-500)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.bg-gradient-primary { + background: linear-gradient(135deg, var(--color-primary), var(--color-primary-700)); +} + +.bg-gradient-secondary { + background: linear-gradient(135deg, var(--color-accent-500), var(--color-accent-700)); +} diff --git a/backend/static/css/src/input.css b/backend/static/css/src/input.css index 750739b3..f6223a4a 100644 --- a/backend/static/css/src/input.css +++ b/backend/static/css/src/input.css @@ -1,17 +1,18 @@ @import "tailwindcss"; -@theme { - --color-primary: #4f46e5; - --color-secondary: #e11d48; - --color-accent: #8b5cf6; - --font-family-sans: Poppins, sans-serif; -} +/** + * ThrillWiki Tailwind Input CSS + * + * This file imports Tailwind CSS and adds custom component styles. + * Color definitions are inherited from design-tokens.css. + * Do NOT define inline colors here - use design token variables instead. + */ /* Base Component Styles */ .site-logo { font-size: 1.5rem; font-weight: 700; - background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); + background: linear-gradient(135deg, var(--color-primary), var(--color-accent-500)); -webkit-background-clip: text; background-clip: text; color: transparent; @@ -36,18 +37,21 @@ padding: 0.5rem 0.75rem; border-radius: 0.5rem; font-weight: 500; - color: #6b7280; + color: var(--color-muted-foreground); transition: all 0.2s ease; text-decoration: none; } .nav-link:hover { color: var(--color-primary); - background-color: rgba(79, 70, 229, 0.1); + background-color: var(--color-primary-100); } -.nav-link i { +.nav-link i, +.nav-link svg { font-size: 1rem; + width: 1rem; + height: 1rem; } @media (max-width: 640px) { @@ -60,39 +64,38 @@ .form-input { width: 100%; padding: 0.75rem 1rem; - border: 1px solid #d1d5db; + border: 1px solid var(--color-border); border-radius: 0.5rem; font-size: 0.875rem; - background-color: white; + background-color: var(--color-background); + color: var(--color-foreground); transition: all 0.2s ease; } .form-input:focus { outline: none; border-color: var(--color-primary); - box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); + box-shadow: 0 0 0 3px var(--color-primary-100); } .form-input::placeholder { - color: #9ca3af; + color: var(--color-muted-foreground); } /* Dark mode form styles */ -@media (prefers-color-scheme: dark) { - .form-input { - background-color: #374151; - border-color: #4b5563; - color: white; - } - - .form-input:focus { - border-color: var(--color-primary); - box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2); - } - - .form-input::placeholder { - color: #6b7280; - } +.dark .form-input { + background-color: var(--color-secondary-800); + border-color: var(--color-secondary-700); + color: var(--color-foreground); +} + +.dark .form-input:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-900); +} + +.dark .form-input::placeholder { + color: var(--color-secondary-500); } /* Button Styles */ @@ -106,18 +109,18 @@ border-radius: 0.5rem; font-size: 0.875rem; font-weight: 600; - color: white; - background: linear-gradient(135deg, var(--color-primary), #3730a3); - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + color: var(--color-primary-foreground); + background: linear-gradient(135deg, var(--color-primary), var(--color-primary-700)); + box-shadow: var(--shadow-sm); transition: all 0.2s ease; cursor: pointer; text-decoration: none; } .btn-primary:hover { - background: linear-gradient(135deg, #3730a3, #312e81); + background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-800)); transform: translateY(-1px); - box-shadow: 0 6px 12px -2px rgba(0, 0, 0, 0.15); + box-shadow: var(--shadow-md); } .btn-primary:active { @@ -130,23 +133,23 @@ justify-content: center; gap: 0.5rem; padding: 0.75rem 1.5rem; - border: 1px solid #d1d5db; + border: 1px solid var(--color-border); border-radius: 0.5rem; font-size: 0.875rem; font-weight: 600; - color: #374151; - background-color: white; - box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06); + color: var(--color-foreground); + background-color: var(--color-background); + box-shadow: var(--shadow-sm); transition: all 0.2s ease; cursor: pointer; text-decoration: none; } .btn-secondary:hover { - background-color: #f9fafb; - border-color: #9ca3af; + background-color: var(--color-secondary-100); + border-color: var(--color-secondary-400); transform: translateY(-1px); - box-shadow: 0 4px 8px -2px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-md); } .btn-secondary:active { @@ -154,17 +157,15 @@ } /* Dark mode button styles */ -@media (prefers-color-scheme: dark) { - .btn-secondary { - border-color: #4b5563; - color: #e5e7eb; - background-color: #374151; - } - - .btn-secondary:hover { - background-color: #4b5563; - border-color: #6b7280; - } +.dark .btn-secondary { + border-color: var(--color-secondary-700); + color: var(--color-secondary-100); + background-color: var(--color-secondary-800); +} + +.dark .btn-secondary:hover { + background-color: var(--color-secondary-700); + border-color: var(--color-secondary-600); } /* Menu Styles */ @@ -175,7 +176,7 @@ width: 100%; padding: 0.75rem 1rem; font-size: 0.875rem; - color: #374151; + color: var(--color-foreground); background: none; border: none; text-align: left; @@ -185,25 +186,24 @@ } .menu-item:hover { - background-color: #f3f4f6; + background-color: var(--color-secondary-100); color: var(--color-primary); } -.menu-item i { +.menu-item i, +.menu-item svg { width: 1.25rem; text-align: center; } /* Dark mode menu styles */ -@media (prefers-color-scheme: dark) { - .menu-item { - color: #e5e7eb; - } - - .menu-item:hover { - background-color: #4b5563; - color: var(--color-primary); - } +.dark .menu-item { + color: var(--color-secondary-100); +} + +.dark .menu-item:hover { + background-color: var(--color-secondary-700); + color: var(--color-primary); } /* Theme Toggle Styles */ @@ -216,24 +216,18 @@ } .theme-toggle-btn:hover { - background-color: rgba(79, 70, 229, 0.1); + background-color: var(--color-primary-100); } -.theme-toggle-btn i::before { - content: "\f185"; /* sun icon */ -} - -@media (prefers-color-scheme: dark) { - .theme-toggle-btn i::before { - content: "\f186"; /* moon icon */ - } +.dark .theme-toggle-btn:hover { + background-color: var(--color-primary-900); } /* Mobile Menu Styles */ #mobileMenu { display: none; padding: 1rem 0; - border-top: 1px solid #e5e7eb; + border-top: 1px solid var(--color-border); margin-top: 1rem; } @@ -241,10 +235,8 @@ display: block; } -@media (prefers-color-scheme: dark) { - #mobileMenu { - border-top-color: #4b5563; - } +.dark #mobileMenu { + border-top-color: var(--color-secondary-700); } /* Grid Adaptive Styles */ @@ -274,30 +266,28 @@ /* Card Styles */ .card { - background: white; + background: var(--color-card); border-radius: 0.75rem; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-sm); overflow: hidden; transition: all 0.3s ease; } .card:hover { transform: translateY(-2px); - box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-lg); } -@media (prefers-color-scheme: dark) { - .card { - background: #1f2937; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); - } - - .card:hover { - box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.4); - } +.dark .card { + background: var(--color-card); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); } -/* Alert Styles */ +.dark .card:hover { + box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.4); +} + +/* Alert Styles - Using design tokens */ .alert { position: fixed; top: 1rem; @@ -305,28 +295,28 @@ z-index: 50; padding: 1rem 1.5rem; border-radius: 0.5rem; - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-lg); transition: all 0.3s ease; animation: slideInRight 0.3s ease-out; } .alert-success { - background-color: #10b981; + background-color: var(--color-success-500); color: white; } .alert-error { - background-color: #ef4444; + background-color: var(--color-error-500); color: white; } .alert-info { - background-color: #3b82f6; + background-color: var(--color-info-500); color: white; } .alert-warning { - background-color: #f59e0b; + background-color: var(--color-warning-500); color: white; } @@ -351,7 +341,7 @@ display: inline-block; width: 1rem; height: 1rem; - border: 2px solid #f3f4f6; + border: 2px solid var(--color-secondary-200); border-radius: 50%; border-top-color: var(--color-primary); animation: spin 1s ease-in-out infinite; @@ -365,18 +355,18 @@ /* Utility Classes */ .text-gradient { - background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); + background: linear-gradient(135deg, var(--color-primary), var(--color-accent-500)); -webkit-background-clip: text; background-clip: text; color: transparent; } .bg-gradient-primary { - background: linear-gradient(135deg, var(--color-primary), #3730a3); + background: linear-gradient(135deg, var(--color-primary), var(--color-primary-700)); } .bg-gradient-secondary { - background: linear-gradient(135deg, var(--color-secondary), #be185d); + background: linear-gradient(135deg, var(--color-accent-500), var(--color-accent-700)); } /* Responsive Utilities */ @@ -390,7 +380,7 @@ .lg\:flex { display: flex !important; } - + .lg\:hidden { display: none !important; } @@ -403,7 +393,11 @@ .focus-ring:focus { outline: none; - box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2); + box-shadow: 0 0 0 3px var(--color-primary-200); +} + +.dark .focus-ring:focus { + box-shadow: 0 0 0 3px var(--color-primary-800); } /* Animation Classes */ diff --git a/backend/static/js/alpine-components.js b/backend/static/js/alpine-components.js index b4e5a03b..c731fc6b 100644 --- a/backend/static/js/alpine-components.js +++ b/backend/static/js/alpine-components.js @@ -1,545 +1,559 @@ -// Reduced Alpine components: keep only pure client-side UI state -document.addEventListener('alpine:init', () => { - Alpine.data('themeToggle', () => ({ - theme: localStorage.getItem('theme') || 'system', - init() { this.updateTheme(); }, - toggle() { - this.theme = this.theme === 'dark' ? 'light' : 'dark'; - localStorage.setItem('theme', this.theme); - this.updateTheme(); - }, - updateTheme() { - if (this.theme === 'dark') document.documentElement.classList.add('dark'); - else document.documentElement.classList.remove('dark'); - } - })); - - Alpine.data('mobileMenu', () => ({ - open: false, - toggle() { - this.open = !this.open; - document.body.style.overflow = this.open ? 'hidden' : ''; - } - })); - - Alpine.data('dropdown', () => ({ - open: false, - toggle() { this.open = !this.open; } - })); -}); /** - * Alpine.js Components for ThrillWiki - * Enhanced components matching React frontend functionality + * ThrillWiki Alpine.js Components + * + * This file contains Alpine.js component definitions for client-side UI state management. + * Store definitions are in stores/index.js - this file focuses on reusable data components. + * + * Note: Theme, toast, and auth stores are defined in stores/index.js to avoid duplication. + * Use $store.theme, $store.toast, $store.auth, $store.ui, $store.search for global state. */ -// Theme Toggle Component -Alpine.data('themeToggle', () => ({ - theme: localStorage.getItem('theme') || 'system', - - init() { - this.updateTheme(); - - // Watch for system theme changes - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { - if (this.theme === 'system') { - this.updateTheme(); - } - }); - }, - - toggleTheme() { - const themes = ['light', 'dark', 'system']; - const currentIndex = themes.indexOf(this.theme); - this.theme = themes[(currentIndex + 1) % themes.length]; - localStorage.setItem('theme', this.theme); - this.updateTheme(); - }, - - updateTheme() { - const root = document.documentElement; - - if (this.theme === 'dark' || - (this.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) { - root.classList.add('dark'); - } else { - root.classList.remove('dark'); - } - } -})); - -// Search Component -Alpine.data('searchComponent', () => ({ - query: '', - results: [], - loading: false, - showResults: false, - - async search() { - if (this.query.length < 2) { - this.results = []; - this.showResults = false; - return; - } - - this.loading = true; - - try { - const response = await fetch(`/api/search/?q=${encodeURIComponent(this.query)}`); - const data = await response.json(); - this.results = data.results || []; - this.showResults = this.results.length > 0; - } catch (error) { - console.error('Search error:', error); - this.results = []; - this.showResults = false; - } finally { - this.loading = false; - } - }, - - selectResult(result) { - window.location.href = result.url; - this.showResults = false; - this.query = ''; - }, - - clearSearch() { - this.query = ''; - this.results = []; - this.showResults = false; - } -})); - -// Browse Menu Component -Alpine.data('browseMenu', () => ({ - open: false, - - toggle() { - this.open = !this.open; - }, - - close() { - this.open = false; - } -})); - -// Mobile Menu Component -Alpine.data('mobileMenu', () => ({ - open: false, - - toggle() { - this.open = !this.open; - - // Prevent body scroll when menu is open - if (this.open) { - document.body.style.overflow = 'hidden'; - } else { - document.body.style.overflow = ''; - } - }, - - close() { - this.open = false; - document.body.style.overflow = ''; - } -})); - -// User Menu Component -Alpine.data('userMenu', () => ({ - open: false, - - toggle() { - this.open = !this.open; - }, - - close() { - this.open = false; - } -})); - -// Modal Component -Alpine.data('modal', (initialOpen = false) => ({ - open: initialOpen, - - show() { - this.open = true; - document.body.style.overflow = 'hidden'; - }, - - hide() { - this.open = false; - document.body.style.overflow = ''; - }, - - toggle() { - if (this.open) { - this.hide(); - } else { - this.show(); - } - } -})); - -// Dropdown Component -Alpine.data('dropdown', (initialOpen = false) => ({ - open: initialOpen, - - toggle() { - this.open = !this.open; - }, - - close() { - this.open = false; - }, - - show() { - this.open = true; - } -})); - -// Tabs Component -Alpine.data('tabs', (defaultTab = 0) => ({ - activeTab: defaultTab, - - setTab(index) { - this.activeTab = index; - }, - - isActive(index) { - return this.activeTab === index; - } -})); - -// Accordion Component -Alpine.data('accordion', (allowMultiple = false) => ({ - openItems: [], - - toggle(index) { - if (this.isOpen(index)) { - this.openItems = this.openItems.filter(item => item !== index); - } else { - if (allowMultiple) { - this.openItems.push(index); - } else { - this.openItems = [index]; - } - } - }, - - isOpen(index) { - return this.openItems.includes(index); - }, - - open(index) { - if (!this.isOpen(index)) { - if (allowMultiple) { - this.openItems.push(index); - } else { - this.openItems = [index]; - } - } - }, - - close(index) { - this.openItems = this.openItems.filter(item => item !== index); - } -})); - -// Form Component with Validation -Alpine.data('form', (initialData = {}) => ({ - data: initialData, - errors: {}, - loading: false, - - setField(field, value) { - this.data[field] = value; - // Clear error when user starts typing - if (this.errors[field]) { - delete this.errors[field]; - } - }, - - setError(field, message) { - this.errors[field] = message; - }, - - clearErrors() { - this.errors = {}; - }, - - hasError(field) { - return !!this.errors[field]; - }, - - getError(field) { - return this.errors[field] || ''; - }, - - async submit(url, options = {}) { - this.loading = true; - this.clearErrors(); - - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '', - ...options.headers - }, - body: JSON.stringify(this.data), - ...options - }); - - const result = await response.json(); - - if (!response.ok) { - if (result.errors) { - this.errors = result.errors; - } - throw new Error(result.message || 'Form submission failed'); - } - - return result; - } catch (error) { - console.error('Form submission error:', error); - throw error; - } finally { - this.loading = false; - } - } -})); - -// Pagination Component -Alpine.data('pagination', (initialPage = 1, totalPages = 1) => ({ - currentPage: initialPage, - totalPages: totalPages, - - goToPage(page) { - if (page >= 1 && page <= this.totalPages) { - this.currentPage = page; - } - }, - - nextPage() { - this.goToPage(this.currentPage + 1); - }, - - prevPage() { - this.goToPage(this.currentPage - 1); - }, - - hasNext() { - return this.currentPage < this.totalPages; - }, - - hasPrev() { - return this.currentPage > 1; - }, - - getPages() { - const pages = []; - const start = Math.max(1, this.currentPage - 2); - const end = Math.min(this.totalPages, this.currentPage + 2); - - for (let i = start; i <= end; i++) { - pages.push(i); - } - - return pages; - } -})); - -// Toast/Alert Component -Alpine.data('toast', () => ({ - toasts: [], - - show(message, type = 'info', duration = 5000) { - const id = Date.now(); - const toast = { id, message, type, visible: true }; - - this.toasts.push(toast); - - if (duration > 0) { - setTimeout(() => { - this.hide(id); - }, duration); - } - - return id; - }, - - hide(id) { - const toast = this.toasts.find(t => t.id === id); - if (toast) { - toast.visible = false; - setTimeout(() => { - this.toasts = this.toasts.filter(t => t.id !== id); - }, 300); // Wait for animation - } - }, - - success(message, duration) { - return this.show(message, 'success', duration); - }, - - error(message, duration) { - return this.show(message, 'error', duration); - }, - - warning(message, duration) { - return this.show(message, 'warning', duration); - }, - - info(message, duration) { - return this.show(message, 'info', duration); - } -})); - - - -// Enhanced Toast Component with Better UX -Alpine.data('toast', () => ({ - toasts: [], - - show(message, type = 'info', duration = 5000) { - const id = Date.now() + Math.random(); - const toast = { - id, - message, - type, - visible: true, - progress: 100 - }; - - this.toasts.push(toast); - - if (duration > 0) { - // Animate progress bar - const interval = setInterval(() => { - toast.progress -= (100 / (duration / 100)); - if (toast.progress <= 0) { - clearInterval(interval); - this.hide(id); - } - }, 100); - } - - return id; - }, - - hide(id) { - const toast = this.toasts.find(t => t.id === id); - if (toast) { - toast.visible = false; - setTimeout(() => { - this.toasts = this.toasts.filter(t => t.id !== id); - }, 300); - } - }, - - success(message, duration = 5000) { - return this.show(message, 'success', duration); - }, - - error(message, duration = 7000) { - return this.show(message, 'error', duration); - }, - - warning(message, duration = 6000) { - return this.show(message, 'warning', duration); - }, - - info(message, duration = 5000) { - return this.show(message, 'info', duration); - } -})); - -// Global Store for App State -Alpine.store('app', { - user: null, - theme: 'system', - searchQuery: '', - notifications: [], - - setUser(user) { - this.user = user; - }, - - setTheme(theme) { - this.theme = theme; - localStorage.setItem('theme', theme); - }, - - addNotification(notification) { - this.notifications.push({ - id: Date.now(), - ...notification - }); - }, - - removeNotification(id) { - this.notifications = this.notifications.filter(n => n.id !== id); - } -}); - -// Global Toast Store -Alpine.store('toast', { - toasts: [], - - show(message, type = 'info', duration = 5000) { - const id = Date.now() + Math.random(); - const toast = { - id, - message, - type, - visible: true, - progress: 100 - }; - - this.toasts.push(toast); - - if (duration > 0) { - const interval = setInterval(() => { - toast.progress -= (100 / (duration / 100)); - if (toast.progress <= 0) { - clearInterval(interval); - this.hide(id); - } - }, 100); - } - - return id; - }, - - hide(id) { - const toast = this.toasts.find(t => t.id === id); - if (toast) { - toast.visible = false; - setTimeout(() => { - this.toasts = this.toasts.filter(t => t.id !== id); - }, 300); - } - }, - - success(message, duration = 5000) { - return this.show(message, 'success', duration); - }, - - error(message, duration = 7000) { - return this.show(message, 'error', duration); - }, - - warning(message, duration = 6000) { - return this.show(message, 'warning', duration); - }, - - info(message, duration = 5000) { - return this.show(message, 'info', duration); - } -}); - -// Initialize Alpine.js when DOM is ready document.addEventListener('alpine:init', () => { - console.log('Alpine.js components initialized'); + // ========================================================================= + // Search Component - Client-side search with debouncing + // ========================================================================= + + Alpine.data('searchComponent', () => ({ + query: '', + results: [], + loading: false, + showResults: false, + debounceTimer: null, + + init() { + // Watch for query changes with debouncing + this.$watch('query', (value) => { + clearTimeout(this.debounceTimer); + if (value.length < 2) { + this.results = []; + this.showResults = false; + return; + } + this.debounceTimer = setTimeout(() => this.search(), 300); + }); + }, + + async search() { + if (this.query.length < 2) { + this.results = []; + this.showResults = false; + return; + } + + this.loading = true; + + try { + const response = await fetch(`/api/search/?q=${encodeURIComponent(this.query)}`); + const data = await response.json(); + this.results = data.results || []; + this.showResults = this.results.length > 0; + } catch (error) { + console.error('Search error:', error); + this.results = []; + this.showResults = false; + } finally { + this.loading = false; + } + }, + + selectResult(result) { + window.location.href = result.url; + this.showResults = false; + this.query = ''; + }, + + clearSearch() { + this.query = ''; + this.results = []; + this.showResults = false; + }, + + // Keyboard navigation + highlightedIndex: -1, + + highlightNext() { + if (this.highlightedIndex < this.results.length - 1) { + this.highlightedIndex++; + } + }, + + highlightPrev() { + if (this.highlightedIndex > 0) { + this.highlightedIndex--; + } + }, + + selectHighlighted() { + if (this.highlightedIndex >= 0 && this.results[this.highlightedIndex]) { + this.selectResult(this.results[this.highlightedIndex]); + } + } + })); + + // ========================================================================= + // Browse Menu Component + // ========================================================================= + + Alpine.data('browseMenu', () => ({ + open: false, + + toggle() { + this.open = !this.open; + }, + + close() { + this.open = false; + } + })); + + // ========================================================================= + // Mobile Menu Component + // ========================================================================= + + Alpine.data('mobileMenu', () => ({ + open: false, + + toggle() { + this.open = !this.open; + // Prevent body scroll when menu is open + document.body.style.overflow = this.open ? 'hidden' : ''; + }, + + close() { + this.open = false; + document.body.style.overflow = ''; + } + })); + + // ========================================================================= + // User Menu Component + // ========================================================================= + + Alpine.data('userMenu', () => ({ + open: false, + + toggle() { + this.open = !this.open; + }, + + close() { + this.open = false; + } + })); + + // ========================================================================= + // Modal Component + // ========================================================================= + + Alpine.data('modal', (initialOpen = false) => ({ + open: initialOpen, + + show() { + this.open = true; + document.body.style.overflow = 'hidden'; + }, + + hide() { + this.open = false; + document.body.style.overflow = ''; + }, + + toggle() { + if (this.open) { + this.hide(); + } else { + this.show(); + } + } + })); + + // ========================================================================= + // Dropdown Component + // ========================================================================= + + Alpine.data('dropdown', (initialOpen = false) => ({ + open: initialOpen, + + toggle() { + this.open = !this.open; + }, + + close() { + this.open = false; + }, + + show() { + this.open = true; + } + })); + + // ========================================================================= + // Tabs Component + // ========================================================================= + + Alpine.data('tabs', (defaultTab = 0) => ({ + activeTab: defaultTab, + + setTab(index) { + this.activeTab = index; + }, + + isActive(index) { + return this.activeTab === index; + } + })); + + // ========================================================================= + // Accordion Component + // ========================================================================= + + Alpine.data('accordion', (allowMultiple = false) => ({ + openItems: [], + + toggle(index) { + if (this.isOpen(index)) { + this.openItems = this.openItems.filter(item => item !== index); + } else { + if (allowMultiple) { + this.openItems.push(index); + } else { + this.openItems = [index]; + } + } + }, + + isOpen(index) { + return this.openItems.includes(index); + }, + + open(index) { + if (!this.isOpen(index)) { + if (allowMultiple) { + this.openItems.push(index); + } else { + this.openItems = [index]; + } + } + }, + + close(index) { + this.openItems = this.openItems.filter(item => item !== index); + } + })); + + // ========================================================================= + // Form Component with Validation + // ========================================================================= + + Alpine.data('form', (initialData = {}) => ({ + data: initialData, + errors: {}, + loading: false, + submitted: false, + + setField(field, value) { + this.data[field] = value; + // Clear error when user starts typing + if (this.errors[field]) { + delete this.errors[field]; + } + }, + + setError(field, message) { + this.errors[field] = message; + }, + + clearErrors() { + this.errors = {}; + }, + + hasError(field) { + return !!this.errors[field]; + }, + + getError(field) { + return this.errors[field] || ''; + }, + + validate(rules = {}) { + this.clearErrors(); + let isValid = true; + + for (const [field, fieldRules] of Object.entries(rules)) { + const value = this.data[field]; + + if (fieldRules.required && !value) { + this.setError(field, `${field} is required`); + isValid = false; + } + + if (fieldRules.email && value && !this.isValidEmail(value)) { + this.setError(field, 'Please enter a valid email address'); + isValid = false; + } + + if (fieldRules.minLength && value && value.length < fieldRules.minLength) { + this.setError(field, `Must be at least ${fieldRules.minLength} characters`); + isValid = false; + } + + if (fieldRules.maxLength && value && value.length > fieldRules.maxLength) { + this.setError(field, `Must be no more than ${fieldRules.maxLength} characters`); + isValid = false; + } + } + + return isValid; + }, + + isValidEmail(email) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + }, + + async submit(url, options = {}) { + this.loading = true; + this.clearErrors(); + + try { + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || + document.querySelector('meta[name="csrf-token"]')?.content || ''; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken, + ...options.headers + }, + body: JSON.stringify(this.data), + ...options + }); + + const result = await response.json(); + + if (!response.ok) { + if (result.errors) { + this.errors = result.errors; + } + throw new Error(result.message || 'Form submission failed'); + } + + this.submitted = true; + return result; + } catch (error) { + console.error('Form submission error:', error); + throw error; + } finally { + this.loading = false; + } + }, + + reset() { + this.data = {}; + this.errors = {}; + this.loading = false; + this.submitted = false; + } + })); + + // ========================================================================= + // Pagination Component + // ========================================================================= + + Alpine.data('pagination', (initialPage = 1, totalPages = 1) => ({ + currentPage: initialPage, + totalPages: totalPages, + + goToPage(page) { + if (page >= 1 && page <= this.totalPages) { + this.currentPage = page; + } + }, + + nextPage() { + this.goToPage(this.currentPage + 1); + }, + + prevPage() { + this.goToPage(this.currentPage - 1); + }, + + hasNext() { + return this.currentPage < this.totalPages; + }, + + hasPrev() { + return this.currentPage > 1; + }, + + getPages(maxVisible = 5) { + const pages = []; + let start = Math.max(1, this.currentPage - Math.floor(maxVisible / 2)); + let end = Math.min(this.totalPages, start + maxVisible - 1); + + // Adjust start if we're near the end + if (end - start + 1 < maxVisible) { + start = Math.max(1, end - maxVisible + 1); + } + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + return pages; + } + })); + + // ========================================================================= + // Image Gallery Component + // ========================================================================= + + Alpine.data('imageGallery', (images = []) => ({ + images: images, + currentIndex: 0, + lightboxOpen: false, + + get currentImage() { + return this.images[this.currentIndex] || null; + }, + + openLightbox(index) { + this.currentIndex = index; + this.lightboxOpen = true; + document.body.style.overflow = 'hidden'; + }, + + closeLightbox() { + this.lightboxOpen = false; + document.body.style.overflow = ''; + }, + + next() { + this.currentIndex = (this.currentIndex + 1) % this.images.length; + }, + + prev() { + this.currentIndex = (this.currentIndex - 1 + this.images.length) % this.images.length; + }, + + goTo(index) { + if (index >= 0 && index < this.images.length) { + this.currentIndex = index; + } + } + })); + + // ========================================================================= + // Rating Component + // ========================================================================= + + Alpine.data('rating', (initialValue = 0, maxStars = 5, readonly = false) => ({ + value: initialValue, + hoverValue: 0, + maxStars: maxStars, + readonly: readonly, + + setRating(rating) { + if (!this.readonly) { + this.value = rating; + } + }, + + setHover(rating) { + if (!this.readonly) { + this.hoverValue = rating; + } + }, + + clearHover() { + this.hoverValue = 0; + }, + + getDisplayValue() { + return this.hoverValue || this.value; + }, + + isActive(star) { + return star <= this.getDisplayValue(); + } + })); + + // ========================================================================= + // Infinite Scroll Component + // ========================================================================= + + Alpine.data('infiniteScroll', (loadUrl, containerSelector = null) => ({ + loading: false, + hasMore: true, + page: 1, + + async loadMore() { + if (this.loading || !this.hasMore) return; + + this.loading = true; + this.page++; + + try { + const url = loadUrl.includes('?') + ? `${loadUrl}&page=${this.page}` + : `${loadUrl}?page=${this.page}`; + + const response = await fetch(url, { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }); + + if (!response.ok) throw new Error('Failed to load more items'); + + const html = await response.text(); + + if (html.trim()) { + const container = containerSelector + ? document.querySelector(containerSelector) + : this.$el; + + if (container) { + container.insertAdjacentHTML('beforeend', html); + } + } else { + this.hasMore = false; + } + } catch (error) { + console.error('Infinite scroll error:', error); + this.page--; // Revert page increment on error + } finally { + this.loading = false; + } + } + })); + + // ========================================================================= + // Clipboard Component + // ========================================================================= + + Alpine.data('clipboard', () => ({ + copied: false, + + async copy(text) { + try { + await navigator.clipboard.writeText(text); + this.copied = true; + setTimeout(() => { + this.copied = false; + }, 2000); + + // Show toast notification if available + if (Alpine.store('toast')) { + Alpine.store('toast').success('Copied to clipboard!'); + } + } catch (error) { + console.error('Failed to copy:', error); + if (Alpine.store('toast')) { + Alpine.store('toast').error('Failed to copy to clipboard'); + } + } + } + })); + + // Log initialization + console.log('Alpine.js components initialized'); }); diff --git a/backend/static/js/form-validation.js b/backend/static/js/form-validation.js new file mode 100644 index 00000000..41c94f06 --- /dev/null +++ b/backend/static/js/form-validation.js @@ -0,0 +1,551 @@ +/** + * ThrillWiki Form Validation Module + * + * Provides client-side form validation helpers with HTMX integration. + * Works with Alpine.js form components and Django form fields. + * + * Features: + * - Debounced HTMX validation triggers + * - Field state management (pristine, dirty, valid, invalid) + * - Real-time validation feedback + * - Integration with Alpine.js stores + * - Accessible error announcements + */ + +// ============================================================================= +// Form Field State Management +// ============================================================================= + +/** + * Field state constants + */ +const FieldState = { + PRISTINE: 'pristine', // Field has not been touched + DIRTY: 'dirty', // Field has been modified + VALIDATING: 'validating', // Field is being validated + VALID: 'valid', // Field passed validation + INVALID: 'invalid', // Field failed validation +}; + +/** + * FormValidator class for managing form validation state + */ +class FormValidator { + constructor(formElement, options = {}) { + this.form = formElement; + this.options = { + validateOnBlur: true, + validateOnChange: true, + debounceMs: 500, + showSuccessState: true, + scrollToFirstError: true, + ...options, + }; + + this.fields = new Map(); + this.debounceTimers = new Map(); + + this.init(); + } + + /** + * Initialize form validation + */ + init() { + if (!this.form) return; + + // Find all form fields + const inputs = this.form.querySelectorAll('input, textarea, select'); + inputs.forEach(input => { + if (input.name) { + this.registerField(input); + } + }); + + // Prevent default form submission if validation fails + this.form.addEventListener('submit', (e) => { + if (!this.validateAll()) { + e.preventDefault(); + if (this.options.scrollToFirstError) { + this.scrollToFirstError(); + } + } + }); + } + + /** + * Register a field for validation + */ + registerField(input) { + const fieldName = input.name; + + this.fields.set(fieldName, { + element: input, + state: FieldState.PRISTINE, + errors: [], + touched: false, + }); + + // Add event listeners + if (this.options.validateOnBlur) { + input.addEventListener('blur', () => this.onFieldBlur(fieldName)); + } + + if (this.options.validateOnChange) { + input.addEventListener('input', () => this.onFieldChange(fieldName)); + } + } + + /** + * Handle field blur event + */ + onFieldBlur(fieldName) { + const field = this.fields.get(fieldName); + if (!field) return; + + field.touched = true; + this.validateField(fieldName); + } + + /** + * Handle field change event with debouncing + */ + onFieldChange(fieldName) { + const field = this.fields.get(fieldName); + if (!field) return; + + field.state = FieldState.DIRTY; + this.updateFieldUI(fieldName); + + // Debounce validation + clearTimeout(this.debounceTimers.get(fieldName)); + this.debounceTimers.set(fieldName, setTimeout(() => { + if (field.touched) { + this.validateField(fieldName); + } + }, this.options.debounceMs)); + } + + /** + * Validate a single field + */ + validateField(fieldName) { + const field = this.fields.get(fieldName); + if (!field) return true; + + field.state = FieldState.VALIDATING; + this.updateFieldUI(fieldName); + + const input = field.element; + const errors = []; + + // Built-in HTML5 validation + if (!input.validity.valid) { + if (input.validity.valueMissing) { + errors.push(`${this.getFieldLabel(fieldName)} is required`); + } + if (input.validity.typeMismatch) { + errors.push(`Please enter a valid ${input.type}`); + } + if (input.validity.tooShort) { + errors.push(`Must be at least ${input.minLength} characters`); + } + if (input.validity.tooLong) { + errors.push(`Must be no more than ${input.maxLength} characters`); + } + if (input.validity.patternMismatch) { + errors.push(input.title || 'Please match the requested format'); + } + if (input.validity.rangeUnderflow) { + errors.push(`Must be at least ${input.min}`); + } + if (input.validity.rangeOverflow) { + errors.push(`Must be no more than ${input.max}`); + } + } + + // Custom validation rules from data attributes + if (input.dataset.validateUrl && input.value) { + try { + new URL(input.value); + } catch { + errors.push('Please enter a valid URL'); + } + } + + if (input.dataset.validateMatch) { + const matchField = this.form.querySelector(`[name="${input.dataset.validateMatch}"]`); + if (matchField && input.value !== matchField.value) { + errors.push(`Must match ${this.getFieldLabel(input.dataset.validateMatch)}`); + } + } + + // Update field state + field.errors = errors; + field.state = errors.length > 0 ? FieldState.INVALID : FieldState.VALID; + this.updateFieldUI(fieldName); + + return errors.length === 0; + } + + /** + * Validate all fields + */ + validateAll() { + let isValid = true; + + this.fields.forEach((field, fieldName) => { + field.touched = true; + if (!this.validateField(fieldName)) { + isValid = false; + } + }); + + return isValid; + } + + /** + * Update field UI based on state + */ + updateFieldUI(fieldName) { + const field = this.fields.get(fieldName); + if (!field) return; + + const input = field.element; + const wrapper = input.closest('.form-field'); + const feedback = wrapper?.querySelector('.field-feedback'); + + // Remove all state classes + input.classList.remove( + 'border-red-500', 'border-green-500', 'border-gray-300', + 'focus:ring-red-500', 'focus:ring-green-500', 'focus:ring-blue-500' + ); + + // Set aria attributes + input.setAttribute('aria-invalid', field.state === FieldState.INVALID); + + switch (field.state) { + case FieldState.VALIDATING: + input.classList.add('border-blue-300'); + break; + + case FieldState.INVALID: + input.classList.add('border-red-500', 'focus:ring-red-500'); + if (feedback) { + feedback.innerHTML = this.renderErrors(field.errors); + } + break; + + case FieldState.VALID: + if (this.options.showSuccessState && field.touched) { + input.classList.add('border-green-500', 'focus:ring-green-500'); + if (feedback) { + feedback.innerHTML = this.renderSuccess(); + } + } + break; + + default: + input.classList.add('border-gray-300', 'focus:ring-blue-500'); + } + } + + /** + * Render error messages HTML + */ + renderErrors(errors) { + if (errors.length === 0) return ''; + + return ` +
+ `; + } + + /** + * Render success indicator HTML + */ + renderSuccess() { + return ` + + `; + } + + /** + * Get field label text + */ + getFieldLabel(fieldName) { + const field = this.fields.get(fieldName); + if (!field) return fieldName; + + const wrapper = field.element.closest('.form-field'); + const label = wrapper?.querySelector('label'); + return label?.textContent?.replace('*', '').trim() || fieldName; + } + + /** + * Scroll to first error + */ + scrollToFirstError() { + for (const [fieldName, field] of this.fields) { + if (field.state === FieldState.INVALID) { + field.element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + field.element.focus(); + break; + } + } + } + + /** + * Set external errors (from server) + */ + setServerErrors(errors) { + Object.entries(errors).forEach(([fieldName, messages]) => { + const field = this.fields.get(fieldName); + if (field) { + field.errors = Array.isArray(messages) ? messages : [messages]; + field.state = FieldState.INVALID; + field.touched = true; + this.updateFieldUI(fieldName); + } + }); + } + + /** + * Clear all errors + */ + clearErrors() { + this.fields.forEach((field, fieldName) => { + field.errors = []; + field.state = FieldState.PRISTINE; + this.updateFieldUI(fieldName); + }); + } + + /** + * Reset form to initial state + */ + reset() { + this.clearErrors(); + this.fields.forEach(field => { + field.touched = false; + }); + this.form.reset(); + } +} + +// ============================================================================= +// HTMX Validation Integration +// ============================================================================= + +/** + * Setup HTMX validation for a field + * @param {HTMLElement} input - The input element + * @param {string} validateUrl - URL for validation endpoint + * @param {object} options - Configuration options + */ +function setupHTMXValidation(input, validateUrl, options = {}) { + const defaults = { + trigger: 'blur changed delay:500ms', + indicator: true, + targetSelector: null, + }; + + const config = { ...defaults, ...options }; + + // Generate target ID + const targetId = config.targetSelector || `#${input.name}-feedback`; + + // Set HTMX attributes + input.setAttribute('hx-post', validateUrl); + input.setAttribute('hx-trigger', config.trigger); + input.setAttribute('hx-target', targetId); + input.setAttribute('hx-swap', 'innerHTML'); + + if (config.indicator) { + const indicatorId = `#${input.name}-indicator`; + input.setAttribute('hx-indicator', indicatorId); + + // Create indicator if it doesn't exist + const wrapper = input.closest('.form-field'); + if (wrapper && !wrapper.querySelector(indicatorId)) { + const indicator = document.createElement('span'); + indicator.id = `${input.name}-indicator`; + indicator.className = 'htmx-indicator absolute right-3 top-1/2 -translate-y-1/2'; + indicator.innerHTML = ''; + const inputWrapper = wrapper.querySelector('.relative') || wrapper; + inputWrapper.appendChild(indicator); + } + } + + // Process HTMX attributes + if (typeof htmx !== 'undefined') { + htmx.process(input); + } +} + +// ============================================================================= +// Alpine.js Integration +// ============================================================================= + +/** + * Alpine.js form validation component + * Usage: x-data="formValidation('/api/validate/')" + */ +document.addEventListener('alpine:init', () => { + if (typeof Alpine === 'undefined') return; + + Alpine.data('formValidation', (validateUrl = null) => ({ + fields: {}, + errors: {}, + touched: {}, + validating: {}, + submitted: false, + + init() { + // Find all form fields within this component + this.$el.querySelectorAll('input, textarea, select').forEach(input => { + if (input.name) { + this.fields[input.name] = input.value; + this.errors[input.name] = []; + this.touched[input.name] = false; + this.validating[input.name] = false; + } + }); + }, + + async validateField(fieldName) { + if (!validateUrl) return true; + + this.validating[fieldName] = true; + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || + document.querySelector('[name=csrfmiddlewaretoken]')?.value; + + const response = await fetch(validateUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken, + }, + body: JSON.stringify({ + field: fieldName, + value: this.fields[fieldName], + }), + }); + + const data = await response.json(); + + if (data.errors && data.errors[fieldName]) { + this.errors[fieldName] = data.errors[fieldName]; + return false; + } else { + this.errors[fieldName] = []; + return true; + } + } catch (error) { + console.error('Validation error:', error); + return true; // Don't block on network errors + } finally { + this.validating[fieldName] = false; + } + }, + + onBlur(fieldName) { + this.touched[fieldName] = true; + this.validateField(fieldName); + }, + + hasError(fieldName) { + return this.touched[fieldName] && this.errors[fieldName]?.length > 0; + }, + + isValid(fieldName) { + return this.touched[fieldName] && this.errors[fieldName]?.length === 0; + }, + + getErrors(fieldName) { + return this.errors[fieldName] || []; + }, + + setServerErrors(errors) { + Object.entries(errors).forEach(([field, messages]) => { + this.errors[field] = Array.isArray(messages) ? messages : [messages]; + this.touched[field] = true; + }); + }, + + clearErrors() { + Object.keys(this.errors).forEach(field => { + this.errors[field] = []; + }); + }, + + async validateAll() { + let isValid = true; + + for (const fieldName of Object.keys(this.fields)) { + this.touched[fieldName] = true; + const fieldValid = await this.validateField(fieldName); + if (!fieldValid) isValid = false; + } + + return isValid; + }, + + async submitForm(url, options = {}) { + this.submitted = true; + + const isValid = await this.validateAll(); + if (!isValid) { + return { success: false, errors: this.errors }; + } + + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || + document.querySelector('[name=csrfmiddlewaretoken]')?.value; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken, + ...options.headers, + }, + body: JSON.stringify(this.fields), + }); + + const data = await response.json(); + + if (!response.ok) { + if (data.errors) { + this.setServerErrors(data.errors); + } + return { success: false, errors: data.errors || data }; + } + + return { success: true, data }; + } catch (error) { + console.error('Form submission error:', error); + return { success: false, error: error.message }; + } + }, + })); +}); + +// ============================================================================= +// Global Exports +// ============================================================================= + +window.FormValidator = FormValidator; +window.setupHTMXValidation = setupHTMXValidation; +window.FieldState = FieldState; diff --git a/backend/static/js/main.js b/backend/static/js/main.js index fb5609e9..a607dbfa 100644 --- a/backend/static/js/main.js +++ b/backend/static/js/main.js @@ -7,6 +7,7 @@ * - Mobile menu functionality * - Flash message handling * - Tooltip initialization + * - Global HTMX loading state management */ // ============================================================================= @@ -171,15 +172,139 @@ document.addEventListener('DOMContentLoaded', () => { tooltipEl.className = 'absolute z-50 px-2 py-1 text-sm text-white bg-gray-900 rounded tooltip'; tooltipEl.textContent = text; document.body.appendChild(tooltipEl); - + const rect = e.target.getBoundingClientRect(); tooltipEl.style.top = rect.bottom + 5 + 'px'; tooltipEl.style.left = rect.left + (rect.width - tooltipEl.offsetWidth) / 2 + 'px'; }); - + tooltip.addEventListener('mouseleave', () => { const tooltips = document.querySelectorAll('.tooltip'); tooltips.forEach(t => t.remove()); }); }); }); + +// ============================================================================= +// HTMX Loading State Management +// ============================================================================= + +/** + * Global HTMX Loading State Management + * + * Provides consistent loading state handling across the application: + * - Adds 'htmx-loading' class to body during requests + * - Manages button disabled states during form submissions + * - Handles search input loading states with debouncing + * - Provides skeleton screen swap utilities + */ +document.addEventListener('DOMContentLoaded', () => { + // Track active HTMX requests + let activeRequests = 0; + + /** + * Add global loading class to body during HTMX requests + */ + document.body.addEventListener('htmx:beforeRequest', (evt) => { + activeRequests++; + document.body.classList.add('htmx-loading'); + + // Disable submit buttons within the target element + const target = evt.target; + if (target.tagName === 'FORM' || target.closest('form')) { + const form = target.tagName === 'FORM' ? target : target.closest('form'); + const submitBtn = form.querySelector('[type="submit"]'); + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.classList.add('htmx-request'); + } + } + }); + + /** + * Remove global loading class when request completes + */ + document.body.addEventListener('htmx:afterRequest', (evt) => { + activeRequests--; + if (activeRequests <= 0) { + activeRequests = 0; + document.body.classList.remove('htmx-loading'); + } + + // Re-enable submit buttons + const target = evt.target; + if (target.tagName === 'FORM' || target.closest('form')) { + const form = target.tagName === 'FORM' ? target : target.closest('form'); + const submitBtn = form.querySelector('[type="submit"]'); + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.classList.remove('htmx-request'); + } + } + }); + + /** + * Handle search inputs with loading states + * Automatically adds loading indicator to search inputs during HTMX requests + */ + document.querySelectorAll('input[type="search"], input[data-search]').forEach(input => { + const wrapper = input.closest('.search-wrapper, .relative'); + if (!wrapper) return; + + // Create loading indicator if it doesn't exist + let indicator = wrapper.querySelector('.search-loading'); + if (!indicator) { + indicator = document.createElement('span'); + indicator.className = 'search-loading htmx-indicator absolute right-3 top-1/2 -translate-y-1/2'; + indicator.innerHTML = ''; + wrapper.appendChild(indicator); + } + }); + + /** + * Swap skeleton with content utility + * Use data-skeleton-target to specify which skeleton to hide when content loads + */ + document.body.addEventListener('htmx:afterSwap', (evt) => { + const skeletonTarget = evt.target.dataset.skeletonTarget; + if (skeletonTarget) { + const skeleton = document.querySelector(skeletonTarget); + if (skeleton) { + skeleton.style.display = 'none'; + } + } + }); +}); + +/** + * Utility function to show skeleton and trigger HTMX load + * @param {string} targetId - ID of the target element + * @param {string} skeletonId - ID of the skeleton element + */ +window.showSkeletonAndLoad = function(targetId, skeletonId) { + const target = document.getElementById(targetId); + const skeleton = document.getElementById(skeletonId); + + if (skeleton) { + skeleton.style.display = 'block'; + } + + if (target) { + htmx.trigger(target, 'load'); + } +}; + +/** + * Utility function to replace content with skeleton during reload + * @param {string} targetId - ID of the target element + * @param {string} skeletonHtml - HTML string of skeleton to show + */ +window.reloadWithSkeleton = function(targetId, skeletonHtml) { + const target = document.getElementById(targetId); + if (target && skeletonHtml) { + // Store original content temporarily + target.dataset.originalContent = target.innerHTML; + target.innerHTML = skeletonHtml; + htmx.trigger(target, 'load'); + } +}; diff --git a/backend/templates/README.md b/backend/templates/README.md new file mode 100644 index 00000000..277e7955 --- /dev/null +++ b/backend/templates/README.md @@ -0,0 +1,421 @@ +# ThrillWiki Template System + +This document describes the template architecture, conventions, and best practices for ThrillWiki. + +## Directory Structure + +``` +templates/ +├── base/ # Base templates +│ └── base.html # Root template all pages extend +├── components/ # Reusable UI components +│ ├── ui/ # UI primitives (button, card, toast) +│ ├── modals/ # Modal components +│ ├── pagination.html # Pagination (supports HTMX) +│ ├── status_badge.html # Status badge (parks/rides) +│ ├── stats_card.html # Statistics card +│ └── history_panel.html # History/audit trail +├── forms/ # Form-related templates +│ ├── partials/ # Form field components +│ │ ├── form_field.html +│ │ ├── field_error.html +│ │ └── field_success.html +│ └── layouts/ # Form layout templates +│ ├── stacked.html # Vertical layout +│ ├── inline.html # Horizontal layout +│ └── grid.html # Multi-column grid +├── htmx/ # HTMX-specific templates +│ ├── components/ # HTMX components +│ └── README.md # HTMX documentation +├── {app}/ # App-specific templates +│ ├── {model}_list.html # List views +│ ├── {model}_detail.html # Detail views +│ ├── {model}_form.html # Form views +│ └── partials/ # App-specific partials +└── README.md # This file +``` + +## Template Inheritance + +### Base Template + +All pages extend `base/base.html`. Available blocks: + +```django +{% extends "base/base.html" %} + +{# Page title (appears in