mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 12:31:09 -05:00
Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX
This commit is contained in:
172
backend/templates/components/history_panel.html
Normal file
172
backend/templates/components/history_panel.html
Normal file
@@ -0,0 +1,172 @@
|
||||
{% comment %}
|
||||
History Panel Component
|
||||
=======================
|
||||
|
||||
A reusable history panel component for displaying object change history and FSM transitions.
|
||||
|
||||
Purpose:
|
||||
Displays both regular history records and FSM (Finite State Machine) transition
|
||||
history for parks, rides, and other entities with historical tracking.
|
||||
|
||||
Usage Examples:
|
||||
Basic history:
|
||||
{% include 'components/history_panel.html' with history=history %}
|
||||
|
||||
With FSM toggle (for moderators):
|
||||
{% include 'components/history_panel.html' with history=history show_fsm_toggle=True fsm_history_url=fsm_url model_type='park' object_id=park.id can_view_fsm=perms.parks.change_park %}
|
||||
|
||||
Ride history:
|
||||
{% include 'components/history_panel.html' with history=history show_fsm_toggle=True fsm_history_url=fsm_url model_type='ride' object_id=ride.id can_view_fsm=perms.rides.change_ride %}
|
||||
|
||||
Parameters:
|
||||
Required:
|
||||
- history: QuerySet or list of history records
|
||||
|
||||
Optional (FSM):
|
||||
- show_fsm_toggle: Show toggle button for FSM history (default: False)
|
||||
- fsm_history_url: URL for loading FSM transition history via HTMX
|
||||
- model_type: Model type for FSM history (e.g., 'park', 'ride')
|
||||
- object_id: Object ID for FSM history
|
||||
- can_view_fsm: Whether user can view FSM history (default: False)
|
||||
|
||||
Optional (styling):
|
||||
- title: Panel title (default: 'History')
|
||||
- panel_class: Additional CSS classes for panel
|
||||
- max_height: Maximum height for scrollable area (default: 'max-h-96')
|
||||
- collapsed: Start collapsed (default: False)
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS for styling
|
||||
- Alpine.js for interactivity
|
||||
- HTMX (optional, for FSM history lazy loading)
|
||||
- Font Awesome icons
|
||||
|
||||
Accessibility:
|
||||
- Uses heading structure for panel title
|
||||
- Toggle button has accessible label
|
||||
- History items use semantic structure
|
||||
{% endcomment %}
|
||||
|
||||
{% with title=title|default:'History' show_fsm_toggle=show_fsm_toggle|default:False can_view_fsm=can_view_fsm|default:False max_height=max_height|default:'max-h-96' collapsed=collapsed|default:False %}
|
||||
|
||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800 {{ panel_class }}"
|
||||
x-data="{ showFsmHistory: false {% if collapsed %}, showHistory: false{% endif %} }">
|
||||
|
||||
{# Header with optional FSM toggle #}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{% if collapsed %}
|
||||
<button type="button"
|
||||
@click="showHistory = !showHistory"
|
||||
class="flex items-center gap-2 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
<i class="fas fa-chevron-right transition-transform"
|
||||
:class="{ 'rotate-90': showHistory }"
|
||||
aria-hidden="true"></i>
|
||||
{{ title }}
|
||||
</button>
|
||||
{% else %}
|
||||
{{ title }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
{% if show_fsm_toggle and can_view_fsm %}
|
||||
<button type="button"
|
||||
@click="showFsmHistory = !showFsmHistory"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
aria-expanded="showFsmHistory"
|
||||
aria-controls="{{ model_type }}-fsm-history-container">
|
||||
<i class="mr-2 fas fa-history" aria-hidden="true"></i>
|
||||
<span x-text="showFsmHistory ? 'Hide Transitions' : 'Show Transitions'"></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Collapsible wrapper #}
|
||||
<div {% if collapsed %}x-show="showHistory" x-cloak x-transition{% endif %}>
|
||||
|
||||
{# FSM Transition History (Moderators Only) #}
|
||||
{% if show_fsm_toggle and can_view_fsm and fsm_history_url %}
|
||||
<div x-show="showFsmHistory" x-cloak x-transition class="mb-4">
|
||||
<div id="{{ model_type }}-fsm-history-container"
|
||||
x-show="showFsmHistory"
|
||||
x-init="$watch('showFsmHistory', value => { if(value && !$el.dataset.loaded) { htmx.trigger($el, 'load-history'); $el.dataset.loaded = 'true'; } })"
|
||||
hx-get="{{ fsm_history_url }}{% if model_type and object_id %}?model_type={{ model_type }}&object_id={{ object_id }}{% endif %}"
|
||||
hx-trigger="load-history"
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#{{ model_type }}-fsm-loading"
|
||||
class="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||
{# Loading State #}
|
||||
<div id="{{ model_type }}-fsm-loading" class="htmx-indicator flex items-center justify-center py-4">
|
||||
<i class="mr-2 text-blue-500 fas fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Loading transitions...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Regular History #}
|
||||
<div class="space-y-4 overflow-y-auto {{ max_height }}">
|
||||
{% for record in history %}
|
||||
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||
{# Timestamp and user #}
|
||||
<div class="mb-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{# Support both simple_history and pghistory formats #}
|
||||
{% if record.history_date %}
|
||||
{{ record.history_date|date:"M d, Y H:i" }}
|
||||
{% if record.history_user %}
|
||||
by {{ record.history_user.username }}
|
||||
{% endif %}
|
||||
{% elif record.pgh_created_at %}
|
||||
{{ record.pgh_created_at|date:"M d, Y H:i" }}
|
||||
{% if record.pgh_context.user %}
|
||||
by {{ record.pgh_context.user }}
|
||||
{% endif %}
|
||||
{% if record.pgh_label %}
|
||||
- {{ record.pgh_label }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Changes #}
|
||||
{% if record.diff_against_previous %}
|
||||
<div class="mt-2 space-y-2">
|
||||
{# Support both dictionary and method formats #}
|
||||
{% if record.get_display_changes %}
|
||||
{% for field, change in record.get_display_changes.items %}
|
||||
{% if field != "updated_at" %}
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ field }}:</span>
|
||||
<span class="text-red-600 dark:text-red-400">{{ change.old|default:"—" }}</span>
|
||||
<span class="mx-1 text-gray-400">→</span>
|
||||
<span class="text-green-600 dark:text-green-400">{{ change.new|default:"—" }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for field, changes in record.diff_against_previous.items %}
|
||||
{% if field != "updated_at" %}
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ field|title }}:</span>
|
||||
<span class="text-red-600 dark:text-red-400">{{ changes.old|default:"—" }}</span>
|
||||
<span class="mx-1 text-gray-400">→</span>
|
||||
<span class="text-green-600 dark:text-green-400">{{ changes.new|default:"—" }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
<i class="fas fa-history mr-2" aria-hidden="true"></i>
|
||||
No history available.
|
||||
</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
141
backend/templates/components/layout/page_header.html
Normal file
141
backend/templates/components/layout/page_header.html
Normal file
@@ -0,0 +1,141 @@
|
||||
{% comment %}
|
||||
Page Header Component
|
||||
=====================
|
||||
|
||||
Standardized page header with title, subtitle, icon, and action buttons.
|
||||
|
||||
Purpose:
|
||||
Provides consistent page header layout across the application with
|
||||
responsive design and optional breadcrumb integration.
|
||||
|
||||
Usage Examples:
|
||||
Basic header:
|
||||
{% include 'components/layout/page_header.html' with title='Parks' %}
|
||||
|
||||
With subtitle:
|
||||
{% include 'components/layout/page_header.html' with title='Cedar Point' subtitle='Sandusky, Ohio' %}
|
||||
|
||||
With icon:
|
||||
{% include 'components/layout/page_header.html' with title='Parks' icon='fas fa-map-marker-alt' %}
|
||||
|
||||
With actions:
|
||||
{% include 'components/layout/page_header.html' with title='Parks' %}
|
||||
{% block page_header_actions %}
|
||||
<a href="{% url 'parks:create' %}" class="btn btn-primary">
|
||||
<i class="fas fa-plus mr-2"></i>Add Park
|
||||
</a>
|
||||
{% endblock %}
|
||||
{% endinclude %}
|
||||
|
||||
Full example:
|
||||
{% include 'components/layout/page_header.html' with
|
||||
title=park.name
|
||||
subtitle=park.location
|
||||
icon='fas fa-building'
|
||||
show_breadcrumbs=True
|
||||
badge_text='Active'
|
||||
badge_variant='success'
|
||||
%}
|
||||
|
||||
Parameters:
|
||||
Required:
|
||||
- title: Page title text
|
||||
|
||||
Optional:
|
||||
- subtitle: Subtitle or description
|
||||
- icon: Icon class (e.g., 'fas fa-home')
|
||||
- show_breadcrumbs: Include breadcrumbs (default: False)
|
||||
- badge_text: Status badge text
|
||||
- badge_variant: 'success', 'warning', 'error', 'info' (default: 'info')
|
||||
- size: 'sm', 'md', 'lg' for title size (default: 'lg')
|
||||
- align: 'left', 'center' (default: 'left')
|
||||
- border: Show bottom border (default: True)
|
||||
- actions_slot: HTML for action buttons
|
||||
|
||||
Blocks:
|
||||
- page_header_actions: Action buttons area
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS for styling
|
||||
- Font Awesome icons (optional)
|
||||
- breadcrumbs.html component (if show_breadcrumbs=True)
|
||||
|
||||
Accessibility:
|
||||
- Uses semantic heading element
|
||||
- Actions have proper button semantics
|
||||
{% endcomment %}
|
||||
|
||||
{% with size=size|default:'lg' align=align|default:'left' border=border|default:True show_breadcrumbs=show_breadcrumbs|default:False %}
|
||||
|
||||
<header class="page-header mb-6 {% if border %}pb-6 border-b border-border{% endif %}">
|
||||
{# Breadcrumbs (optional) #}
|
||||
{% if show_breadcrumbs and breadcrumbs %}
|
||||
<div class="mb-4">
|
||||
{% include 'components/navigation/breadcrumbs.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between {% if align == 'center' %}sm:justify-center text-center{% endif %}">
|
||||
{# Title Section #}
|
||||
<div class="flex-1 min-w-0 {% if align == 'center' %}flex flex-col items-center{% endif %}">
|
||||
<div class="flex items-center gap-3 {% if align == 'center' %}justify-center{% endif %}">
|
||||
{# Icon #}
|
||||
{% if icon %}
|
||||
<div class="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<i class="{{ icon }} text-primary {% if size == 'sm' %}text-lg{% elif size == 'lg' %}text-xl sm:text-2xl{% else %}text-xl{% endif %}" aria-hidden="true"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Title and Subtitle #}
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h1 class="font-bold text-foreground truncate
|
||||
{% if size == 'sm' %}text-xl sm:text-2xl
|
||||
{% elif size == 'lg' %}text-2xl sm:text-3xl lg:text-4xl
|
||||
{% else %}text-2xl sm:text-3xl{% endif %}">
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
{# Status Badge #}
|
||||
{% if badge_text %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{% if badge_variant == 'success' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
|
||||
{% elif badge_variant == 'warning' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
|
||||
{% elif badge_variant == 'error' %}bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300
|
||||
{% else %}bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300{% endif %}">
|
||||
{{ badge_text }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Subtitle #}
|
||||
{% if subtitle %}
|
||||
<p class="mt-1 text-muted-foreground truncate
|
||||
{% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-base sm:text-lg{% else %}text-base{% endif %}">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{# Meta info slot #}
|
||||
{% if meta %}
|
||||
<div class="mt-2 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
{{ meta }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Actions Section #}
|
||||
{% if actions_slot or block.page_header_actions %}
|
||||
<div class="flex-shrink-0 flex flex-wrap items-center gap-3 {% if align == 'center' %}justify-center{% else %}sm:justify-end{% endif %}">
|
||||
{% if actions_slot %}
|
||||
{{ actions_slot }}
|
||||
{% endif %}
|
||||
{% block page_header_actions %}{% endblock %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% endwith %}
|
||||
@@ -1,5 +1,94 @@
|
||||
<div id="modal-container" class="modal" role="dialog" aria-modal="true" tabindex="-1">
|
||||
<div class="modal-content">
|
||||
{% block modal_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% comment %}
|
||||
Modal Base Component
|
||||
====================
|
||||
|
||||
A flexible, accessible modal dialog component with Alpine.js integration.
|
||||
|
||||
Purpose:
|
||||
Provides a base modal structure with backdrop, header, body, and footer
|
||||
sections. Includes keyboard navigation (ESC to close), focus trapping,
|
||||
and proper ARIA attributes for accessibility.
|
||||
|
||||
Usage Examples:
|
||||
Basic modal:
|
||||
{% include 'components/modals/modal_base.html' with modal_id='my-modal' title='Modal Title' %}
|
||||
{% block modal_body %}
|
||||
<p>Modal content here</p>
|
||||
{% endblock %}
|
||||
{% endinclude %}
|
||||
|
||||
Modal with footer:
|
||||
<div x-data="{ showModal: false }">
|
||||
<button @click="showModal = true">Open Modal</button>
|
||||
{% include 'components/modals/modal_base.html' with modal_id='confirm-modal' title='Confirm Action' show_var='showModal' %}
|
||||
{% block modal_body %}
|
||||
<p>Are you sure?</p>
|
||||
{% endblock %}
|
||||
{% block modal_footer %}
|
||||
<button @click="showModal = false" class="btn-secondary">Cancel</button>
|
||||
<button @click="confirmAction(); showModal = false" class="btn-primary">Confirm</button>
|
||||
{% endblock %}
|
||||
{% endinclude %}
|
||||
</div>
|
||||
|
||||
Different sizes:
|
||||
{% include 'components/modals/modal_base.html' with modal_id='lg-modal' title='Large Modal' size='lg' %}
|
||||
|
||||
Parameters:
|
||||
Required:
|
||||
- modal_id: Unique identifier for the modal (used for ARIA and targeting)
|
||||
|
||||
Optional:
|
||||
- title: Modal title text (if empty, header section is hidden)
|
||||
- size: Size variant 'sm', 'md', 'lg', 'xl', 'full' (default: 'md')
|
||||
- show_close_button: Show X button in header (default: True)
|
||||
- show_var: Alpine.js variable name for show/hide state (default: 'show')
|
||||
- close_on_backdrop: Close when clicking backdrop (default: True)
|
||||
- close_on_escape: Close when pressing Escape (default: True)
|
||||
- prevent_scroll: Prevent body scroll when open (default: True)
|
||||
|
||||
Blocks:
|
||||
- modal_header: Custom header content (replaces default header)
|
||||
- modal_body: Main modal content (required)
|
||||
- modal_footer: Footer content (optional)
|
||||
|
||||
Dependencies:
|
||||
- Alpine.js for interactivity
|
||||
- Tailwind CSS for styling
|
||||
- Font Awesome icons (for close button)
|
||||
|
||||
Accessibility:
|
||||
- Uses dialog role with aria-modal="true"
|
||||
- Focus is trapped within modal when open
|
||||
- ESC key closes the modal
|
||||
- aria-labelledby points to title
|
||||
- aria-describedby available for body content
|
||||
{% endcomment %}
|
||||
|
||||
{# Default values #}
|
||||
{% with size=size|default:'md' show_close_button=show_close_button|default:True show_var=show_var|default:'show' close_on_backdrop=close_on_backdrop|default:True close_on_escape=close_on_escape|default:True prevent_scroll=prevent_scroll|default:True %}
|
||||
|
||||
{# Size classes mapping #}
|
||||
{% if size == 'sm' %}
|
||||
{% with size_class='max-w-sm' %}
|
||||
{% include 'components/modals/modal_inner.html' %}
|
||||
{% endwith %}
|
||||
{% elif size == 'lg' %}
|
||||
{% with size_class='max-w-2xl' %}
|
||||
{% include 'components/modals/modal_inner.html' %}
|
||||
{% endwith %}
|
||||
{% elif size == 'xl' %}
|
||||
{% with size_class='max-w-4xl' %}
|
||||
{% include 'components/modals/modal_inner.html' %}
|
||||
{% endwith %}
|
||||
{% elif size == 'full' %}
|
||||
{% with size_class='max-w-full mx-4' %}
|
||||
{% include 'components/modals/modal_inner.html' %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{% with size_class='max-w-lg' %}
|
||||
{% include 'components/modals/modal_inner.html' %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
{% endwith %}
|
||||
|
||||
@@ -1,5 +1,184 @@
|
||||
{% extends "components/modals/modal_base.html" %}
|
||||
{% comment %}
|
||||
Confirmation Modal Component
|
||||
============================
|
||||
|
||||
{% block modal_content %}
|
||||
{% include "htmx/components/confirm_dialog.html" %}
|
||||
{% endblock %}
|
||||
Pre-styled confirmation dialog for destructive or important actions.
|
||||
|
||||
Purpose:
|
||||
Provides a standardized confirmation dialog with customizable
|
||||
title, message, and action buttons.
|
||||
|
||||
Usage Examples:
|
||||
Basic confirmation:
|
||||
<div x-data="{ showDeleteModal: false }">
|
||||
<button @click="showDeleteModal = true">Delete</button>
|
||||
{% include 'components/modals/modal_confirm.html' with
|
||||
modal_id='delete-confirm'
|
||||
show_var='showDeleteModal'
|
||||
title='Delete Park'
|
||||
message='Are you sure you want to delete this park? This action cannot be undone.'
|
||||
confirm_text='Delete'
|
||||
confirm_variant='destructive'
|
||||
%}
|
||||
</div>
|
||||
|
||||
With icon:
|
||||
{% include 'components/modals/modal_confirm.html' with
|
||||
modal_id='publish-confirm'
|
||||
show_var='showPublishModal'
|
||||
title='Publish Changes'
|
||||
message='This will make your changes visible to all users.'
|
||||
icon='fas fa-globe'
|
||||
icon_variant='info'
|
||||
confirm_text='Publish'
|
||||
%}
|
||||
|
||||
With HTMX:
|
||||
{% include 'components/modals/modal_confirm.html' with
|
||||
modal_id='archive-confirm'
|
||||
show_var='showArchiveModal'
|
||||
title='Archive Item'
|
||||
message='This will archive the item.'
|
||||
confirm_hx_post='/api/archive/123/'
|
||||
%}
|
||||
|
||||
Parameters:
|
||||
Required:
|
||||
- modal_id: Unique identifier for the modal
|
||||
- show_var: Alpine.js variable name for show/hide state
|
||||
- title: Modal title
|
||||
- message: Confirmation message
|
||||
|
||||
Optional:
|
||||
- icon: Icon class (default: auto based on variant)
|
||||
- icon_variant: 'destructive', 'warning', 'info', 'success' (default: 'warning')
|
||||
- confirm_text: Confirm button text (default: 'Confirm')
|
||||
- confirm_variant: 'destructive', 'primary', 'warning' (default: 'primary')
|
||||
- cancel_text: Cancel button text (default: 'Cancel')
|
||||
- confirm_url: URL for confirm action (makes it a link)
|
||||
- confirm_hx_post: HTMX post URL for confirm action
|
||||
- confirm_hx_delete: HTMX delete URL for confirm action
|
||||
- on_confirm: Alpine.js expression to run on confirm
|
||||
|
||||
Dependencies:
|
||||
- modal_base.html component
|
||||
- Tailwind CSS
|
||||
- Alpine.js
|
||||
- HTMX (optional)
|
||||
{% endcomment %}
|
||||
|
||||
{% with icon_variant=icon_variant|default:'warning' confirm_variant=confirm_variant|default:'primary' confirm_text=confirm_text|default:'Confirm' cancel_text=cancel_text|default:'Cancel' %}
|
||||
|
||||
{# Determine icon based on variant if not specified #}
|
||||
{% with default_icon=icon|default:'fas fa-exclamation-triangle' %}
|
||||
|
||||
<div id="{{ modal_id }}"
|
||||
x-show="{{ show_var }}"
|
||||
x-cloak
|
||||
@keydown.escape.window="{{ show_var }} = false"
|
||||
x-init="$watch('{{ show_var }}', value => { document.body.style.overflow = value ? 'hidden' : '' })"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="{{ modal_id }}-title"
|
||||
aria-describedby="{{ modal_id }}-message">
|
||||
|
||||
{# Backdrop #}
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
x-show="{{ show_var }}"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@click="{{ show_var }} = false"
|
||||
aria-hidden="true">
|
||||
</div>
|
||||
|
||||
{# Modal Content #}
|
||||
<div class="relative w-full max-w-md bg-background rounded-xl shadow-2xl overflow-hidden border border-border"
|
||||
x-show="{{ show_var }}"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
@click.stop>
|
||||
|
||||
<div class="p-6">
|
||||
{# Icon and Title #}
|
||||
<div class="text-center">
|
||||
{# Icon #}
|
||||
<div class="mx-auto mb-4 w-14 h-14 rounded-full flex items-center justify-center
|
||||
{% if icon_variant == 'destructive' or confirm_variant == 'destructive' %}bg-red-100 dark:bg-red-900/30
|
||||
{% elif icon_variant == 'success' %}bg-green-100 dark:bg-green-900/30
|
||||
{% elif icon_variant == 'info' %}bg-blue-100 dark:bg-blue-900/30
|
||||
{% else %}bg-yellow-100 dark:bg-yellow-900/30{% endif %}">
|
||||
<i class="{{ default_icon }} text-2xl
|
||||
{% if icon_variant == 'destructive' or confirm_variant == 'destructive' %}text-red-600 dark:text-red-400
|
||||
{% elif icon_variant == 'success' %}text-green-600 dark:text-green-400
|
||||
{% elif icon_variant == 'info' %}text-blue-600 dark:text-blue-400
|
||||
{% else %}text-yellow-600 dark:text-yellow-400{% endif %}"
|
||||
aria-hidden="true"></i>
|
||||
</div>
|
||||
|
||||
{# Title #}
|
||||
<h3 id="{{ modal_id }}-title" class="text-lg font-semibold text-foreground mb-2">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
{# Message #}
|
||||
<p id="{{ modal_id }}-message" class="text-muted-foreground">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
<div class="mt-6 flex flex-col-reverse sm:flex-row gap-3 sm:justify-center">
|
||||
{# Cancel button #}
|
||||
<button type="button"
|
||||
@click="{{ show_var }} = false"
|
||||
class="btn btn-outline w-full sm:w-auto">
|
||||
{{ cancel_text }}
|
||||
</button>
|
||||
|
||||
{# Confirm button #}
|
||||
{% if confirm_url %}
|
||||
<a href="{{ confirm_url }}"
|
||||
class="btn w-full sm:w-auto text-center
|
||||
{% if confirm_variant == 'destructive' %}btn-destructive
|
||||
{% elif confirm_variant == 'warning' %}bg-yellow-600 hover:bg-yellow-700 text-white
|
||||
{% else %}btn-primary{% endif %}">
|
||||
{{ confirm_text }}
|
||||
</a>
|
||||
{% elif confirm_hx_post or confirm_hx_delete %}
|
||||
<button type="button"
|
||||
{% if confirm_hx_post %}hx-post="{{ confirm_hx_post }}"{% endif %}
|
||||
{% if confirm_hx_delete %}hx-delete="{{ confirm_hx_delete }}"{% endif %}
|
||||
hx-swap="outerHTML"
|
||||
@htmx:after-request="{{ show_var }} = false"
|
||||
class="btn w-full sm:w-auto
|
||||
{% if confirm_variant == 'destructive' %}btn-destructive
|
||||
{% elif confirm_variant == 'warning' %}bg-yellow-600 hover:bg-yellow-700 text-white
|
||||
{% else %}btn-primary{% endif %}">
|
||||
{{ confirm_text }}
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button"
|
||||
@click="{% if on_confirm %}{{ on_confirm }};{% endif %} {{ show_var }} = false"
|
||||
class="btn w-full sm:w-auto
|
||||
{% if confirm_variant == 'destructive' %}btn-destructive
|
||||
{% elif confirm_variant == 'warning' %}bg-yellow-600 hover:bg-yellow-700 text-white
|
||||
{% else %}btn-primary{% endif %}">
|
||||
{{ confirm_text }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
|
||||
142
backend/templates/components/modals/modal_inner.html
Normal file
142
backend/templates/components/modals/modal_inner.html
Normal file
@@ -0,0 +1,142 @@
|
||||
{# Inner modal template - do not use directly, use modal_base.html instead #}
|
||||
{# Enhanced with animations, focus trap, and loading states #}
|
||||
|
||||
{% with animation=animation|default:'scale' loading=loading|default:False %}
|
||||
|
||||
<div id="{{ modal_id }}"
|
||||
x-show="{{ show_var }}"
|
||||
x-cloak
|
||||
{% if close_on_escape %}@keydown.escape.window="{{ show_var }} = false"{% endif %}
|
||||
x-init="
|
||||
$watch('{{ show_var }}', value => {
|
||||
{% if prevent_scroll %}document.body.style.overflow = value ? 'hidden' : '';{% endif %}
|
||||
if (value) {
|
||||
$nextTick(() => {
|
||||
const firstFocusable = $el.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');
|
||||
if (firstFocusable) firstFocusable.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
"
|
||||
@keydown.tab.prevent="
|
||||
const focusables = $el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if ($event.shiftKey && document.activeElement === first) {
|
||||
last.focus();
|
||||
} else if (!$event.shiftKey && document.activeElement === last) {
|
||||
first.focus();
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
{% if title %}aria-labelledby="{{ modal_id }}-title"{% endif %}
|
||||
aria-describedby="{{ modal_id }}-body">
|
||||
|
||||
{# Backdrop #}
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
x-show="{{ show_var }}"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
{% if close_on_backdrop %}@click="{{ show_var }} = false"{% endif %}
|
||||
aria-hidden="true">
|
||||
</div>
|
||||
|
||||
{# Modal Content #}
|
||||
<div class="relative w-full {{ size_class }} bg-background rounded-xl shadow-2xl overflow-hidden border border-border"
|
||||
x-show="{{ show_var }}"
|
||||
{% if animation == 'slide-up' %}
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-8"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 translate-y-8"
|
||||
{% elif animation == 'fade' %}
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
{% else %}
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
{% endif %}
|
||||
@click.stop>
|
||||
|
||||
{# Loading Overlay #}
|
||||
{% if loading %}
|
||||
<div x-show="loading"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="w-8 h-8 border-4 border-primary rounded-full animate-spin border-t-transparent"></div>
|
||||
<span class="text-sm text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Header #}
|
||||
{% if title or show_close_button %}
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
{% block modal_header %}
|
||||
{% if title %}
|
||||
<div class="flex items-center gap-3">
|
||||
{% if icon %}
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<i class="{{ icon }} text-primary" aria-hidden="true"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<h3 id="{{ modal_id }}-title" class="text-lg font-semibold text-foreground">
|
||||
{{ title }}
|
||||
</h3>
|
||||
{% if subtitle %}
|
||||
<p class="text-sm text-muted-foreground">{{ subtitle }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div></div>
|
||||
{% endif %}
|
||||
{% endblock modal_header %}
|
||||
|
||||
{% if show_close_button %}
|
||||
<button type="button"
|
||||
@click="{{ show_var }} = false"
|
||||
class="p-2 -mr-2 text-muted-foreground hover:text-foreground rounded-lg hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring transition-colors"
|
||||
aria-label="Close modal">
|
||||
<i class="fas fa-times text-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Body #}
|
||||
<div id="{{ modal_id }}-body" class="px-6 py-4 overflow-y-auto max-h-[70vh]">
|
||||
{% block modal_body %}{% endblock modal_body %}
|
||||
</div>
|
||||
|
||||
{# Footer (optional) #}
|
||||
{% block modal_footer_wrapper %}
|
||||
{% if block.modal_footer %}
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-border bg-muted/30">
|
||||
{% block modal_footer %}{% endblock modal_footer %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock modal_footer_wrapper %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
133
backend/templates/components/navigation/README.md
Normal file
133
backend/templates/components/navigation/README.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Navigation Components
|
||||
|
||||
This directory contains navigation-related template components.
|
||||
|
||||
## Components
|
||||
|
||||
### breadcrumbs.html
|
||||
|
||||
Semantic breadcrumb navigation with Schema.org structured data support.
|
||||
|
||||
#### Features
|
||||
|
||||
- Accessible navigation with proper ARIA attributes
|
||||
- Schema.org BreadcrumbList JSON-LD for SEO
|
||||
- Responsive design with mobile-friendly collapse
|
||||
- Customizable separators and icons
|
||||
- Truncation for long labels
|
||||
|
||||
#### Basic Usage
|
||||
|
||||
```django
|
||||
{# Breadcrumbs are automatically included from context processor #}
|
||||
{% include 'components/navigation/breadcrumbs.html' %}
|
||||
```
|
||||
|
||||
#### Setting Breadcrumbs in Views
|
||||
|
||||
```python
|
||||
from apps.core.utils.breadcrumbs import build_breadcrumb, BreadcrumbBuilder
|
||||
from django.urls import reverse
|
||||
|
||||
def park_detail(request, slug):
|
||||
park = get_object_or_404(Park, slug=slug)
|
||||
|
||||
# Option 1: Build breadcrumbs manually
|
||||
request.breadcrumbs = [
|
||||
build_breadcrumb('Home', '/', icon='fas fa-home'),
|
||||
build_breadcrumb('Parks', reverse('parks:list')),
|
||||
build_breadcrumb(park.name, is_current=True),
|
||||
]
|
||||
|
||||
# Option 2: Use the builder pattern
|
||||
request.breadcrumbs = (
|
||||
BreadcrumbBuilder()
|
||||
.add_home()
|
||||
.add('Parks', reverse('parks:list'))
|
||||
.add_current(park.name)
|
||||
.build()
|
||||
)
|
||||
|
||||
return render(request, 'parks/detail.html', {'park': park})
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `items` | list | `breadcrumbs` | List of Breadcrumb objects |
|
||||
| `show_schema` | bool | `True` | Include Schema.org JSON-LD |
|
||||
| `show_home_icon` | bool | `True` | Show icon on home breadcrumb |
|
||||
| `separator` | str | chevron | Custom separator character |
|
||||
| `max_visible` | int | `3` | Max items before mobile collapse |
|
||||
| `container_class` | str | `""` | Additional CSS classes |
|
||||
|
||||
#### Accessibility
|
||||
|
||||
- Uses `<nav>` element with `aria-label="Breadcrumb"`
|
||||
- Ordered list (`<ol>`) for semantic structure
|
||||
- `aria-current="page"` on current page item
|
||||
- Hidden separators for screen readers
|
||||
|
||||
#### Examples
|
||||
|
||||
**Custom separator:**
|
||||
```django
|
||||
{% include 'components/navigation/breadcrumbs.html' with separator='/' %}
|
||||
```
|
||||
|
||||
**Without Schema.org:**
|
||||
```django
|
||||
{% include 'components/navigation/breadcrumbs.html' with show_schema=False %}
|
||||
```
|
||||
|
||||
**Custom breadcrumbs:**
|
||||
```django
|
||||
{% include 'components/navigation/breadcrumbs.html' with items=custom_crumbs %}
|
||||
```
|
||||
|
||||
## Breadcrumb Utilities
|
||||
|
||||
### BreadcrumbBuilder
|
||||
|
||||
Fluent builder for constructing breadcrumbs:
|
||||
|
||||
```python
|
||||
from apps.core.utils.breadcrumbs import BreadcrumbBuilder
|
||||
|
||||
breadcrumbs = (
|
||||
BreadcrumbBuilder()
|
||||
.add_home()
|
||||
.add_from_url('parks:list', 'Parks')
|
||||
.add_model(park)
|
||||
.add_from_url('rides:list', 'Rides', {'park_slug': park.slug})
|
||||
.add_model_current(ride)
|
||||
.build()
|
||||
)
|
||||
```
|
||||
|
||||
### get_model_breadcrumb
|
||||
|
||||
Generate breadcrumbs for model instances with parent relationships:
|
||||
|
||||
```python
|
||||
from apps.core.utils.breadcrumbs import get_model_breadcrumb
|
||||
|
||||
# For a Ride that belongs to a Park
|
||||
breadcrumbs = get_model_breadcrumb(
|
||||
ride,
|
||||
parent_attr='park',
|
||||
list_url_name='rides:list',
|
||||
list_label='Rides',
|
||||
)
|
||||
# Returns: [Home, Parks, Cedar Point, Rides, Millennium Force]
|
||||
```
|
||||
|
||||
## Context Processor
|
||||
|
||||
The `breadcrumbs` context processor (`apps.core.context_processors.breadcrumbs`) provides:
|
||||
|
||||
- `breadcrumbs`: List of Breadcrumb objects from view
|
||||
- `breadcrumbs_json`: Schema.org JSON-LD string
|
||||
- `BreadcrumbBuilder`: Builder class for templates
|
||||
- `build_breadcrumb`: Helper function for creating items
|
||||
120
backend/templates/components/navigation/breadcrumbs.html
Normal file
120
backend/templates/components/navigation/breadcrumbs.html
Normal file
@@ -0,0 +1,120 @@
|
||||
{% comment %}
|
||||
Breadcrumb Navigation Component
|
||||
===============================
|
||||
|
||||
Semantic breadcrumb navigation with Schema.org structured data support.
|
||||
|
||||
Purpose:
|
||||
Renders accessible breadcrumb navigation with proper ARIA attributes,
|
||||
Schema.org BreadcrumbList markup, and responsive design.
|
||||
|
||||
Usage Examples:
|
||||
Basic usage (breadcrumbs from context processor):
|
||||
{% include 'components/navigation/breadcrumbs.html' %}
|
||||
|
||||
Custom breadcrumbs:
|
||||
{% include 'components/navigation/breadcrumbs.html' with items=custom_breadcrumbs %}
|
||||
|
||||
Without Schema.org markup:
|
||||
{% include 'components/navigation/breadcrumbs.html' with show_schema=False %}
|
||||
|
||||
Custom separator:
|
||||
{% include 'components/navigation/breadcrumbs.html' with separator='>' %}
|
||||
|
||||
Without home icon:
|
||||
{% include 'components/navigation/breadcrumbs.html' with show_home_icon=False %}
|
||||
|
||||
Parameters:
|
||||
Optional:
|
||||
- items: List of Breadcrumb objects (default: breadcrumbs from context)
|
||||
- show_schema: Include Schema.org JSON-LD (default: True)
|
||||
- show_home_icon: Show icon on home breadcrumb (default: True)
|
||||
- separator: Separator character/icon (default: chevron icon)
|
||||
- max_visible: Maximum items to show on mobile before collapsing (default: 3)
|
||||
- container_class: Additional CSS classes for container
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS for styling
|
||||
- Font Awesome icons (for home icon and separator)
|
||||
- breadcrumbs context processor for default breadcrumbs
|
||||
|
||||
Accessibility:
|
||||
- Uses <nav> element with aria-label="Breadcrumb"
|
||||
- Ordered list for semantic structure
|
||||
- aria-current="page" on current page item
|
||||
- Hidden separators (aria-hidden) for screen readers
|
||||
{% endcomment %}
|
||||
|
||||
{% with items=items|default:breadcrumbs show_schema=show_schema|default:True show_home_icon=show_home_icon|default:True max_visible=max_visible|default:3 %}
|
||||
|
||||
{% if items %}
|
||||
{# Main Navigation #}
|
||||
<nav aria-label="Breadcrumb"
|
||||
class="breadcrumb-nav py-3 {{ container_class }}"
|
||||
data-breadcrumb>
|
||||
|
||||
<ol class="flex flex-wrap items-center gap-1 text-sm" role="list">
|
||||
{% for crumb in items %}
|
||||
<li class="flex items-center {% if not forloop.last %}{% if forloop.counter > 1 and forloop.counter < items|length|add:'-1' %}hidden sm:flex{% endif %}{% endif %}">
|
||||
{# Separator (except for first item) #}
|
||||
{% if not forloop.first %}
|
||||
<span class="mx-2 text-muted-foreground/50" aria-hidden="true">
|
||||
{% if separator %}
|
||||
{{ separator }}
|
||||
{% else %}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{# Breadcrumb Item #}
|
||||
{% if crumb.is_current %}
|
||||
{# Current page (not a link) #}
|
||||
<span class="font-medium text-foreground truncate max-w-[200px] sm:max-w-[300px]"
|
||||
aria-current="page"
|
||||
title="{{ crumb.label }}">
|
||||
{% if crumb.icon %}
|
||||
<i class="{{ crumb.icon }} mr-1.5" aria-hidden="true"></i>
|
||||
{% endif %}
|
||||
{{ crumb.label }}
|
||||
</span>
|
||||
{% else %}
|
||||
{# Clickable breadcrumb #}
|
||||
<a href="{{ crumb.url }}"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors truncate max-w-[150px] sm:max-w-[200px] inline-flex items-center"
|
||||
title="{{ crumb.label }}">
|
||||
{% if crumb.icon and show_home_icon %}
|
||||
<i class="{{ crumb.icon }} mr-1.5" aria-hidden="true"></i>
|
||||
<span class="sr-only sm:not-sr-only">{{ crumb.label }}</span>
|
||||
{% else %}
|
||||
{{ crumb.label }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
|
||||
{# Mobile ellipsis for long breadcrumb trails #}
|
||||
{% if forloop.counter == 1 and items|length > max_visible %}
|
||||
<li class="flex items-center sm:hidden" aria-hidden="true">
|
||||
<span class="mx-2 text-muted-foreground/50">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-muted-foreground">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{# Schema.org Structured Data #}
|
||||
{% if show_schema and breadcrumbs_json %}
|
||||
<script type="application/ld+json">{{ breadcrumbs_json|safe }}</script>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endwith %}
|
||||
@@ -1,93 +1,61 @@
|
||||
{% comment %}
|
||||
Reusable pagination component with accessibility and responsive design.
|
||||
Usage: {% include 'components/pagination.html' with page_obj=page_obj %}
|
||||
Pagination Component
|
||||
====================
|
||||
|
||||
A reusable pagination component with accessibility features and HTMX support.
|
||||
|
||||
Purpose:
|
||||
Renders pagination controls for paginated querysets. Supports both
|
||||
standard page navigation and HTMX-powered dynamic updates.
|
||||
|
||||
Usage Examples:
|
||||
Standard pagination:
|
||||
{% include 'components/pagination.html' with page_obj=page_obj %}
|
||||
|
||||
HTMX-enabled pagination:
|
||||
{% include 'components/pagination.html' with page_obj=page_obj use_htmx=True hx_target='#results' %}
|
||||
|
||||
Custom styling:
|
||||
{% include 'components/pagination.html' with page_obj=page_obj size='sm' %}
|
||||
|
||||
Parameters:
|
||||
- page_obj: Django Page object from paginator (required)
|
||||
- use_htmx: Enable HTMX for dynamic updates (optional, default: False)
|
||||
- hx_target: HTMX target selector (optional, default: '#results')
|
||||
- hx_swap: HTMX swap strategy (optional, default: 'innerHTML')
|
||||
- hx_push_url: Whether to push URL to history (optional, default: 'true')
|
||||
- size: Size variant 'sm', 'md', 'lg' (optional, default: 'md')
|
||||
- show_info: Show "Showing X to Y of Z" info (optional, default: True)
|
||||
- base_url: Base URL for pagination (optional, default: request.path)
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS for styling
|
||||
- HTMX (optional, for dynamic pagination)
|
||||
|
||||
Accessibility:
|
||||
- Uses nav element with aria-label="Pagination"
|
||||
- Current page marked with aria-current="page"
|
||||
- Previous/Next buttons have aria-labels
|
||||
- Disabled buttons use aria-disabled
|
||||
{% endcomment %}
|
||||
|
||||
{% if page_obj.has_other_pages %}
|
||||
<nav class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6" aria-label="Pagination">
|
||||
<div class="hidden sm:block">
|
||||
<p class="text-sm text-gray-700">
|
||||
Showing
|
||||
<span class="font-medium">{{ page_obj.start_index }}</span>
|
||||
to
|
||||
<span class="font-medium">{{ page_obj.end_index }}</span>
|
||||
of
|
||||
<span class="font-medium">{{ page_obj.paginator.count }}</span>
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex justify-between sm:justify-end">
|
||||
{% if page_obj.has_previous %}
|
||||
<a
|
||||
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.previous_page_number }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Previous
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-400 bg-gray-100 cursor-not-allowed">
|
||||
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Previous
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Page numbers for larger screens -->
|
||||
<div class="hidden md:flex">
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if num == page_obj.number %}
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-blue-500 bg-blue-50 text-sm font-medium text-blue-600 mx-1">
|
||||
{{ num }}
|
||||
</span>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<a
|
||||
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 mx-1 transition-colors"
|
||||
aria-label="Go to page {{ num }}"
|
||||
>
|
||||
{{ num }}
|
||||
</a>
|
||||
{% elif num == 1 or num == page_obj.paginator.num_pages %}
|
||||
<a
|
||||
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 mx-1 transition-colors"
|
||||
aria-label="Go to page {{ num }}"
|
||||
>
|
||||
{{ num }}
|
||||
</a>
|
||||
{% elif num == page_obj.number|add:'-4' or num == page_obj.number|add:'4' %}
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-500 mx-1">
|
||||
...
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a
|
||||
href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.next_page_number }}"
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
Next
|
||||
<svg class="ml-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-400 bg-gray-100 cursor-not-allowed">
|
||||
Next
|
||||
<svg class="ml-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
{% with use_htmx=use_htmx|default:False hx_target=hx_target|default:'#results' hx_swap=hx_swap|default:'innerHTML' size=size|default:'md' show_info=show_info|default:True %}
|
||||
|
||||
{# Size-based classes #}
|
||||
{% if size == 'sm' %}
|
||||
{% with btn_padding='px-2 py-1 text-xs' info_class='text-xs' %}
|
||||
{% include 'components/pagination_inner.html' %}
|
||||
{% endwith %}
|
||||
{% elif size == 'lg' %}
|
||||
{% with btn_padding='px-5 py-3 text-base' info_class='text-base' %}
|
||||
{% include 'components/pagination_inner.html' %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{% with btn_padding='px-4 py-2 text-sm' info_class='text-sm' %}
|
||||
{% include 'components/pagination_inner.html' %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
156
backend/templates/components/pagination_inner.html
Normal file
156
backend/templates/components/pagination_inner.html
Normal file
@@ -0,0 +1,156 @@
|
||||
{# Inner pagination template - do not use directly, use pagination.html instead #}
|
||||
<nav class="bg-white dark:bg-gray-800 px-4 py-3 flex items-center justify-between border-t border-gray-200 dark:border-gray-700 sm:px-6 rounded-b-lg"
|
||||
aria-label="Pagination"
|
||||
role="navigation">
|
||||
|
||||
{# Results info - Hidden on mobile #}
|
||||
{% if show_info %}
|
||||
<div class="hidden sm:block">
|
||||
<p class="{{ info_class }} text-gray-700 dark:text-gray-300">
|
||||
Showing
|
||||
<span class="font-medium">{{ page_obj.start_index }}</span>
|
||||
to
|
||||
<span class="font-medium">{{ page_obj.end_index }}</span>
|
||||
of
|
||||
<span class="font-medium">{{ page_obj.paginator.count }}</span>
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-1 flex justify-between sm:justify-end gap-2">
|
||||
{# Previous Button #}
|
||||
{% if page_obj.has_previous %}
|
||||
{% if use_htmx %}
|
||||
<button type="button"
|
||||
hx-get="{{ base_url|default:request.path }}?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.previous_page_number }}"
|
||||
hx-target="{{ hx_target }}"
|
||||
hx-swap="{{ hx_swap }}"
|
||||
hx-push-url="{{ hx_push_url|default:'true' }}"
|
||||
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
aria-label="Go to previous page">
|
||||
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Previous
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.previous_page_number }}"
|
||||
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
aria-label="Go to previous page">
|
||||
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 cursor-not-allowed"
|
||||
aria-disabled="true">
|
||||
<svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Previous
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{# Page numbers - Hidden on mobile, visible on medium+ screens #}
|
||||
<div class="hidden md:flex items-center gap-1">
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if num == page_obj.number %}
|
||||
{# Current page #}
|
||||
<span class="relative inline-flex items-center {{ btn_padding }} border border-blue-500 bg-blue-50 dark:bg-blue-900/30 font-medium text-blue-600 dark:text-blue-400 rounded-md"
|
||||
aria-current="page">
|
||||
{{ num }}
|
||||
</span>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
{# Pages near current #}
|
||||
{% if use_htmx %}
|
||||
<button type="button"
|
||||
hx-get="{{ base_url|default:request.path }}?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
|
||||
hx-target="{{ hx_target }}"
|
||||
hx-swap="{{ hx_swap }}"
|
||||
hx-push-url="{{ hx_push_url|default:'true' }}"
|
||||
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded-md transition-colors"
|
||||
aria-label="Go to page {{ num }}">
|
||||
{{ num }}
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
|
||||
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded-md transition-colors"
|
||||
aria-label="Go to page {{ num }}">
|
||||
{{ num }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif num == 1 or num == page_obj.paginator.num_pages %}
|
||||
{# First and last page always visible #}
|
||||
{% if use_htmx %}
|
||||
<button type="button"
|
||||
hx-get="{{ base_url|default:request.path }}?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
|
||||
hx-target="{{ hx_target }}"
|
||||
hx-swap="{{ hx_swap }}"
|
||||
hx-push-url="{{ hx_push_url|default:'true' }}"
|
||||
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded-md transition-colors"
|
||||
aria-label="Go to page {{ num }}">
|
||||
{{ num }}
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ num }}"
|
||||
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded-md transition-colors"
|
||||
aria-label="Go to page {{ num }}">
|
||||
{{ num }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif num == page_obj.number|add:'-4' or num == page_obj.number|add:'4' %}
|
||||
{# Ellipsis #}
|
||||
<span class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 font-medium text-gray-500 dark:text-gray-400 rounded-md"
|
||||
aria-hidden="true">
|
||||
…
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Mobile page indicator #}
|
||||
<div class="flex md:hidden items-center">
|
||||
<span class="{{ info_class }} text-gray-700 dark:text-gray-300">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Next Button #}
|
||||
{% if page_obj.has_next %}
|
||||
{% if use_htmx %}
|
||||
<button type="button"
|
||||
hx-get="{{ base_url|default:request.path }}?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.next_page_number }}"
|
||||
hx-target="{{ hx_target }}"
|
||||
hx-swap="{{ hx_swap }}"
|
||||
hx-push-url="{{ hx_push_url|default:'true' }}"
|
||||
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
aria-label="Go to next page">
|
||||
Next
|
||||
<svg class="ml-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="?{% if request.GET.urlencode %}{{ request.GET.urlencode }}&{% endif %}page={{ page_obj.next_page_number }}"
|
||||
class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
aria-label="Go to next page">
|
||||
Next
|
||||
<svg class="ml-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="relative inline-flex items-center {{ btn_padding }} border border-gray-300 dark:border-gray-600 font-medium rounded-md text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 cursor-not-allowed"
|
||||
aria-disabled="true">
|
||||
Next
|
||||
<svg class="ml-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
@@ -2,6 +2,7 @@
|
||||
Reusable search form component with filtering capabilities.
|
||||
Usage: {% include 'components/search_form.html' with placeholder="Search parks..." filters=filter_options %}
|
||||
{% endcomment %}
|
||||
{% load common_filters %}
|
||||
|
||||
<form method="get" class="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
|
||||
108
backend/templates/components/skeletons/card_grid_skeleton.html
Normal file
108
backend/templates/components/skeletons/card_grid_skeleton.html
Normal file
@@ -0,0 +1,108 @@
|
||||
{% comment %}
|
||||
Card Grid Skeleton Component
|
||||
============================
|
||||
|
||||
Animated skeleton placeholder for card grid layouts while content loads.
|
||||
|
||||
Purpose:
|
||||
Displays pulsing skeleton cards in a grid layout for pages like
|
||||
parks list, rides list, and search results.
|
||||
|
||||
Usage Examples:
|
||||
Basic card grid:
|
||||
{% include 'components/skeletons/card_grid_skeleton.html' %}
|
||||
|
||||
Custom card count:
|
||||
{% include 'components/skeletons/card_grid_skeleton.html' with cards=8 %}
|
||||
|
||||
Horizontal cards:
|
||||
{% include 'components/skeletons/card_grid_skeleton.html' with layout='horizontal' %}
|
||||
|
||||
Custom columns:
|
||||
{% include 'components/skeletons/card_grid_skeleton.html' with cols='4' %}
|
||||
|
||||
Parameters:
|
||||
Optional:
|
||||
- cards: Number of skeleton cards to display (default: 6)
|
||||
- cols: Grid columns ('2', '3', '4', 'auto') (default: 'auto')
|
||||
- layout: Card layout ('vertical', 'horizontal') (default: 'vertical')
|
||||
- show_image: Show image placeholder (default: True)
|
||||
- show_badge: Show badge placeholder (default: True)
|
||||
- show_footer: Show footer with stats (default: True)
|
||||
- image_aspect: Image aspect ratio ('video', 'square', 'portrait') (default: 'video')
|
||||
- animate: Enable pulse animation (default: True)
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS for styling and animation
|
||||
|
||||
Accessibility:
|
||||
- Uses role="status" and aria-busy="true" for screen readers
|
||||
{% endcomment %}
|
||||
|
||||
{% with cards=cards|default:6 cols=cols|default:'auto' layout=layout|default:'vertical' show_image=show_image|default:True show_badge=show_badge|default:True show_footer=show_footer|default:True image_aspect=image_aspect|default:'video' animate=animate|default:True %}
|
||||
|
||||
<div class="skeleton-card-grid grid gap-4 sm:gap-6
|
||||
{% if cols == '2' %}grid-cols-1 sm:grid-cols-2
|
||||
{% elif cols == '3' %}grid-cols-1 sm:grid-cols-2 lg:grid-cols-3
|
||||
{% elif cols == '4' %}grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4
|
||||
{% else %}grid-cols-1 sm:grid-cols-2 lg:grid-cols-3{% endif %}"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label="Loading cards...">
|
||||
|
||||
{% for i in "123456789012"|slice:cards %}
|
||||
<div class="skeleton-card bg-card rounded-xl border border-border overflow-hidden
|
||||
{% if layout == 'horizontal' %}flex flex-row{% else %}flex flex-col{% endif %}">
|
||||
|
||||
{# Image placeholder #}
|
||||
{% if show_image %}
|
||||
<div class="{% if layout == 'horizontal' %}w-1/3 flex-shrink-0{% else %}w-full{% endif %}">
|
||||
<div class="{% if image_aspect == 'square' %}aspect-square{% elif image_aspect == 'portrait' %}aspect-[3/4]{% else %}aspect-video{% endif %} bg-muted {% if animate %}animate-pulse{% endif %}"
|
||||
style="animation-delay: {{ forloop.counter0 }}50ms;">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Content area #}
|
||||
<div class="flex-1 p-4 space-y-3">
|
||||
{# Badge placeholder #}
|
||||
{% if show_badge %}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-5 w-16 bg-muted rounded-full {% if animate %}animate-pulse{% endif %}" style="animation-delay: {{ forloop.counter0 }}75ms;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Title #}
|
||||
<div class="space-y-2">
|
||||
<div class="h-5 bg-muted rounded {% if animate %}animate-pulse{% endif %}"
|
||||
style="width: {% widthratio forloop.counter0 1 3 %}5%; animation-delay: {{ forloop.counter }}00ms;">
|
||||
</div>
|
||||
<div class="h-4 w-3/4 bg-muted/70 rounded {% if animate %}animate-pulse{% endif %}"
|
||||
style="animation-delay: {{ forloop.counter }}25ms;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Description lines #}
|
||||
<div class="space-y-2 pt-2">
|
||||
<div class="h-3 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: {{ forloop.counter }}50ms;"></div>
|
||||
<div class="h-3 w-5/6 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: {{ forloop.counter }}75ms;"></div>
|
||||
</div>
|
||||
|
||||
{# Footer with stats #}
|
||||
{% if show_footer %}
|
||||
<div class="flex items-center justify-between pt-3 mt-auto border-t border-border">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="h-4 w-16 bg-muted/50 rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
<div class="h-4 w-12 bg-muted/50 rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
</div>
|
||||
<div class="h-4 w-20 bg-muted/50 rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<span class="sr-only">Loading cards, please wait...</span>
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
118
backend/templates/components/skeletons/detail_skeleton.html
Normal file
118
backend/templates/components/skeletons/detail_skeleton.html
Normal file
@@ -0,0 +1,118 @@
|
||||
{% comment %}
|
||||
Detail Page Skeleton Component
|
||||
==============================
|
||||
|
||||
Animated skeleton placeholder for detail pages while content loads.
|
||||
|
||||
Purpose:
|
||||
Displays pulsing skeleton elements for detail page layouts including
|
||||
header, image, and content sections.
|
||||
|
||||
Usage Examples:
|
||||
Basic detail skeleton:
|
||||
{% include 'components/skeletons/detail_skeleton.html' %}
|
||||
|
||||
With image placeholder:
|
||||
{% include 'components/skeletons/detail_skeleton.html' with show_image=True %}
|
||||
|
||||
Custom content sections:
|
||||
{% include 'components/skeletons/detail_skeleton.html' with sections=4 %}
|
||||
|
||||
Parameters:
|
||||
Optional:
|
||||
- show_image: Show large image placeholder (default: True)
|
||||
- show_badge: Show status badge placeholder (default: True)
|
||||
- show_meta: Show metadata row (default: True)
|
||||
- show_actions: Show action buttons placeholder (default: True)
|
||||
- sections: Number of content sections (default: 3)
|
||||
- paragraphs_per_section: Lines per section (default: 4)
|
||||
- animate: Enable pulse animation (default: True)
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS for styling and animation
|
||||
|
||||
Accessibility:
|
||||
- Uses role="status" and aria-busy="true" for screen readers
|
||||
{% endcomment %}
|
||||
|
||||
{% with show_image=show_image|default:True show_badge=show_badge|default:True show_meta=show_meta|default:True show_actions=show_actions|default:True sections=sections|default:3 paragraphs_per_section=paragraphs_per_section|default:4 animate=animate|default:True %}
|
||||
|
||||
<div class="skeleton-detail space-y-6"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label="Loading page content...">
|
||||
|
||||
{# Header Section #}
|
||||
<div class="skeleton-detail-header space-y-4">
|
||||
{# Breadcrumb placeholder #}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-3 w-12 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
<div class="h-3 w-3 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
<div class="h-3 w-20 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
<div class="h-3 w-3 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
<div class="h-3 w-32 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
</div>
|
||||
|
||||
{# Title and badge row #}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div class="space-y-2">
|
||||
{# Title #}
|
||||
<div class="h-8 sm:h-10 w-64 sm:w-80 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
|
||||
{# Subtitle/location #}
|
||||
<div class="h-4 w-48 bg-muted/70 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: 100ms;"></div>
|
||||
</div>
|
||||
|
||||
{% if show_badge %}
|
||||
{# Status badge #}
|
||||
<div class="h-7 w-24 bg-muted rounded-full {% if animate %}animate-pulse{% endif %}"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Meta row (date, author, etc.) #}
|
||||
{% if show_meta %}
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm">
|
||||
<div class="h-4 w-32 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: 150ms;"></div>
|
||||
<div class="h-4 w-24 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: 200ms;"></div>
|
||||
<div class="h-4 w-28 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: 250ms;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Action buttons #}
|
||||
{% if show_actions %}
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="h-10 w-28 bg-muted rounded-lg {% if animate %}animate-pulse{% endif %}"></div>
|
||||
<div class="h-10 w-24 bg-muted/80 rounded-lg {% if animate %}animate-pulse{% endif %}" style="animation-delay: 50ms;"></div>
|
||||
<div class="h-10 w-10 bg-muted/60 rounded-lg {% if animate %}animate-pulse{% endif %}" style="animation-delay: 100ms;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Image Section #}
|
||||
{% if show_image %}
|
||||
<div class="skeleton-detail-image">
|
||||
<div class="w-full aspect-video bg-muted rounded-xl {% if animate %}animate-pulse{% endif %}"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Content Sections #}
|
||||
<div class="skeleton-detail-content space-y-8">
|
||||
{% for s in "1234567890"|slice:sections %}
|
||||
<div class="space-y-3">
|
||||
{# Section heading #}
|
||||
<div class="h-6 w-48 bg-muted rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: {{ forloop.counter0 }}50ms;"></div>
|
||||
|
||||
{# Paragraph lines #}
|
||||
{% for p in "12345678"|slice:paragraphs_per_section %}
|
||||
<div class="h-4 bg-muted/{% if forloop.last %}50{% else %}70{% endif %} rounded {% if animate %}animate-pulse{% endif %}"
|
||||
style="width: {% if forloop.last %}65{% else %}{% widthratio forloop.counter0 1 5 %}5{% endif %}%; animation-delay: {{ forloop.counter }}00ms;">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<span class="sr-only">Loading content, please wait...</span>
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
119
backend/templates/components/skeletons/form_skeleton.html
Normal file
119
backend/templates/components/skeletons/form_skeleton.html
Normal file
@@ -0,0 +1,119 @@
|
||||
{% comment %}
|
||||
Form Skeleton Component
|
||||
=======================
|
||||
|
||||
Animated skeleton placeholder for forms while content loads.
|
||||
|
||||
Purpose:
|
||||
Displays pulsing skeleton form elements including labels, inputs,
|
||||
and action buttons.
|
||||
|
||||
Usage Examples:
|
||||
Basic form skeleton:
|
||||
{% include 'components/skeletons/form_skeleton.html' %}
|
||||
|
||||
Custom field count:
|
||||
{% include 'components/skeletons/form_skeleton.html' with fields=6 %}
|
||||
|
||||
Without textarea:
|
||||
{% include 'components/skeletons/form_skeleton.html' with show_textarea=False %}
|
||||
|
||||
Compact form:
|
||||
{% include 'components/skeletons/form_skeleton.html' with size='sm' %}
|
||||
|
||||
Parameters:
|
||||
Optional:
|
||||
- fields: Number of input fields (default: 4)
|
||||
- show_textarea: Show a textarea field (default: True)
|
||||
- show_checkbox: Show checkbox fields (default: False)
|
||||
- show_select: Show select dropdown (default: True)
|
||||
- checkbox_count: Number of checkboxes (default: 3)
|
||||
- size: 'sm', 'md', 'lg' for field sizes (default: 'md')
|
||||
- animate: Enable pulse animation (default: True)
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS for styling and animation
|
||||
|
||||
Accessibility:
|
||||
- Uses role="status" and aria-busy="true" for screen readers
|
||||
{% endcomment %}
|
||||
|
||||
{% with fields=fields|default:4 show_textarea=show_textarea|default:True show_checkbox=show_checkbox|default:False show_select=show_select|default:True checkbox_count=checkbox_count|default:3 size=size|default:'md' animate=animate|default:True %}
|
||||
|
||||
<div class="skeleton-form space-y-{% if size == 'sm' %}4{% elif size == 'lg' %}8{% else %}6{% endif %}"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label="Loading form...">
|
||||
|
||||
{# Regular input fields #}
|
||||
{% for i in "12345678"|slice:fields %}
|
||||
<div class="skeleton-form-field space-y-{% if size == 'sm' %}1{% elif size == 'lg' %}2{% else %}1.5{% endif %}">
|
||||
{# Label #}
|
||||
<div class="{% if size == 'sm' %}h-3 w-20{% elif size == 'lg' %}h-5 w-28{% else %}h-4 w-24{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"
|
||||
style="animation-delay: {{ forloop.counter0 }}50ms;">
|
||||
</div>
|
||||
|
||||
{# Input #}
|
||||
<div class="{% if size == 'sm' %}h-8{% elif size == 'lg' %}h-12{% else %}h-10{% endif %} w-full bg-muted/70 rounded-lg border border-muted {% if animate %}animate-pulse{% endif %}"
|
||||
style="animation-delay: {{ forloop.counter0 }}75ms;">
|
||||
</div>
|
||||
|
||||
{# Help text (occasionally) #}
|
||||
{% if forloop.counter|divisibleby:2 %}
|
||||
<div class="{% if size == 'sm' %}h-2 w-48{% elif size == 'lg' %}h-4 w-64{% else %}h-3 w-56{% endif %} bg-muted/50 rounded {% if animate %}animate-pulse{% endif %}"
|
||||
style="animation-delay: {{ forloop.counter }}00ms;">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Select dropdown #}
|
||||
{% if show_select %}
|
||||
<div class="skeleton-form-field space-y-{% if size == 'sm' %}1{% elif size == 'lg' %}2{% else %}1.5{% endif %}">
|
||||
<div class="{% if size == 'sm' %}h-3 w-24{% elif size == 'lg' %}h-5 w-32{% else %}h-4 w-28{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
<div class="{% if size == 'sm' %}h-8{% elif size == 'lg' %}h-12{% else %}h-10{% endif %} w-full bg-muted/70 rounded-lg border border-muted {% if animate %}animate-pulse{% endif %} relative">
|
||||
{# Dropdown arrow indicator #}
|
||||
<div class="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div class="{% if size == 'sm' %}w-3 h-3{% elif size == 'lg' %}w-5 h-5{% else %}w-4 h-4{% endif %} bg-muted/90 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Textarea #}
|
||||
{% if show_textarea %}
|
||||
<div class="skeleton-form-field space-y-{% if size == 'sm' %}1{% elif size == 'lg' %}2{% else %}1.5{% endif %}">
|
||||
<div class="{% if size == 'sm' %}h-3 w-28{% elif size == 'lg' %}h-5 w-36{% else %}h-4 w-32{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
<div class="{% if size == 'sm' %}h-20{% elif size == 'lg' %}h-40{% else %}h-32{% endif %} w-full bg-muted/70 rounded-lg border border-muted {% if animate %}animate-pulse{% endif %}"></div>
|
||||
<div class="{% if size == 'sm' %}h-2 w-40{% elif size == 'lg' %}h-4 w-56{% else %}h-3 w-48{% endif %} bg-muted/50 rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Checkboxes #}
|
||||
{% if show_checkbox %}
|
||||
<div class="skeleton-form-checkboxes space-y-3">
|
||||
<div class="{% if size == 'sm' %}h-3 w-32{% elif size == 'lg' %}h-5 w-40{% else %}h-4 w-36{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
{% for c in "12345"|slice:checkbox_count %}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="{% if size == 'sm' %}w-4 h-4{% elif size == 'lg' %}w-6 h-6{% else %}w-5 h-5{% endif %} bg-muted/70 rounded border border-muted {% if animate %}animate-pulse{% endif %}"></div>
|
||||
<div class="{% if size == 'sm' %}h-3{% elif size == 'lg' %}h-5{% else %}h-4{% endif %} bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}"
|
||||
style="width: {% widthratio forloop.counter0 1 4 %}0%;">
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Form actions #}
|
||||
<div class="skeleton-form-actions flex items-center justify-end gap-3 pt-{% if size == 'sm' %}3{% elif size == 'lg' %}6{% else %}4{% endif %} mt-{% if size == 'sm' %}3{% elif size == 'lg' %}6{% else %}4{% endif %} border-t border-border">
|
||||
{# Cancel button #}
|
||||
<div class="{% if size == 'sm' %}h-8 w-20{% elif size == 'lg' %}h-12 w-28{% else %}h-10 w-24{% endif %} bg-muted/60 rounded-lg {% if animate %}animate-pulse{% endif %}"></div>
|
||||
|
||||
{# Submit button #}
|
||||
<div class="{% if size == 'sm' %}h-8 w-24{% elif size == 'lg' %}h-12 w-32{% else %}h-10 w-28{% endif %} bg-muted rounded-lg {% if animate %}animate-pulse{% endif %}"></div>
|
||||
</div>
|
||||
|
||||
<span class="sr-only">Loading form, please wait...</span>
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
85
backend/templates/components/skeletons/list_skeleton.html
Normal file
85
backend/templates/components/skeletons/list_skeleton.html
Normal file
@@ -0,0 +1,85 @@
|
||||
{% comment %}
|
||||
List Skeleton Component
|
||||
=======================
|
||||
|
||||
Animated skeleton placeholder for list items while content loads.
|
||||
|
||||
Purpose:
|
||||
Displays pulsing skeleton rows to indicate loading state for list views,
|
||||
reducing perceived loading time and preventing layout shift.
|
||||
|
||||
Usage Examples:
|
||||
Basic list skeleton:
|
||||
{% include 'components/skeletons/list_skeleton.html' %}
|
||||
|
||||
Custom row count:
|
||||
{% include 'components/skeletons/list_skeleton.html' with rows=10 %}
|
||||
|
||||
With avatar placeholder:
|
||||
{% include 'components/skeletons/list_skeleton.html' with show_avatar=True %}
|
||||
|
||||
Compact variant:
|
||||
{% include 'components/skeletons/list_skeleton.html' with size='sm' %}
|
||||
|
||||
Parameters:
|
||||
Optional:
|
||||
- rows: Number of skeleton rows to display (default: 5)
|
||||
- show_avatar: Show circular avatar placeholder (default: False)
|
||||
- show_meta: Show metadata line below title (default: True)
|
||||
- show_action: Show action button placeholder (default: False)
|
||||
- size: 'sm', 'md', 'lg' for padding/spacing (default: 'md')
|
||||
- animate: Enable pulse animation (default: True)
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS for styling and animation
|
||||
|
||||
Accessibility:
|
||||
- Uses role="status" and aria-busy="true" for screen readers
|
||||
- aria-label describes loading state
|
||||
{% endcomment %}
|
||||
|
||||
{% with rows=rows|default:5 show_avatar=show_avatar|default:False show_meta=show_meta|default:True show_action=show_action|default:False size=size|default:'md' animate=animate|default:True %}
|
||||
|
||||
<div class="skeleton-list space-y-{% if size == 'sm' %}2{% elif size == 'lg' %}6{% else %}4{% endif %}"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label="Loading list items...">
|
||||
|
||||
{% for i in "12345678901234567890"|slice:rows %}
|
||||
<div class="skeleton-list-item flex items-center gap-{% if size == 'sm' %}2{% elif size == 'lg' %}4{% else %}3{% endif %} {% if size == 'sm' %}p-2{% elif size == 'lg' %}p-5{% else %}p-4{% endif %} bg-card rounded-lg border border-border">
|
||||
|
||||
{# Avatar placeholder #}
|
||||
{% if show_avatar %}
|
||||
<div class="flex-shrink-0">
|
||||
<div class="{% if size == 'sm' %}w-8 h-8{% elif size == 'lg' %}w-14 h-14{% else %}w-10 h-10{% endif %} rounded-full bg-muted {% if animate %}animate-pulse{% endif %}"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Content area #}
|
||||
<div class="flex-1 min-w-0 space-y-{% if size == 'sm' %}1{% elif size == 'lg' %}3{% else %}2{% endif %}">
|
||||
{# Title line #}
|
||||
<div class="{% if size == 'sm' %}h-3{% elif size == 'lg' %}h-5{% else %}h-4{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"
|
||||
style="width: {% widthratio forloop.counter0 1 7 %}0%;">
|
||||
</div>
|
||||
|
||||
{# Meta line #}
|
||||
{% if show_meta %}
|
||||
<div class="{% if size == 'sm' %}h-2{% elif size == 'lg' %}h-4{% else %}h-3{% endif %} bg-muted/70 rounded {% if animate %}animate-pulse{% endif %}"
|
||||
style="width: {% widthratio forloop.counter0 1 5 %}0%; animation-delay: {{ forloop.counter0 }}00ms;">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Action button placeholder #}
|
||||
{% if show_action %}
|
||||
<div class="flex-shrink-0">
|
||||
<div class="{% if size == 'sm' %}w-16 h-6{% elif size == 'lg' %}w-24 h-10{% else %}w-20 h-8{% endif %} bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<span class="sr-only">Loading content, please wait...</span>
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
137
backend/templates/components/skeletons/table_skeleton.html
Normal file
137
backend/templates/components/skeletons/table_skeleton.html
Normal file
@@ -0,0 +1,137 @@
|
||||
{% comment %}
|
||||
Table Skeleton Component
|
||||
========================
|
||||
|
||||
Animated skeleton placeholder for data tables while content loads.
|
||||
|
||||
Purpose:
|
||||
Displays pulsing skeleton table rows for data-heavy pages like
|
||||
admin dashboards, moderation queues, and data exports.
|
||||
|
||||
Usage Examples:
|
||||
Basic table skeleton:
|
||||
{% include 'components/skeletons/table_skeleton.html' %}
|
||||
|
||||
Custom dimensions:
|
||||
{% include 'components/skeletons/table_skeleton.html' with rows=10 cols=6 %}
|
||||
|
||||
With checkbox column:
|
||||
{% include 'components/skeletons/table_skeleton.html' with show_checkbox=True %}
|
||||
|
||||
With action column:
|
||||
{% include 'components/skeletons/table_skeleton.html' with show_actions=True %}
|
||||
|
||||
Parameters:
|
||||
Optional:
|
||||
- rows: Number of table rows (default: 5)
|
||||
- cols: Number of data columns (default: 4)
|
||||
- show_header: Show table header row (default: True)
|
||||
- show_checkbox: Show checkbox column (default: False)
|
||||
- show_actions: Show actions column (default: True)
|
||||
- show_avatar: Show avatar in first column (default: False)
|
||||
- striped: Use striped row styling (default: False)
|
||||
- animate: Enable pulse animation (default: True)
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS for styling and animation
|
||||
|
||||
Accessibility:
|
||||
- Uses role="status" and aria-busy="true" for screen readers
|
||||
{% endcomment %}
|
||||
|
||||
{% with rows=rows|default:5 cols=cols|default:4 show_header=show_header|default:True show_checkbox=show_checkbox|default:False show_actions=show_actions|default:True show_avatar=show_avatar|default:False striped=striped|default:False animate=animate|default:True %}
|
||||
|
||||
<div class="skeleton-table overflow-hidden rounded-lg border border-border"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label="Loading table data...">
|
||||
|
||||
<table class="w-full">
|
||||
{# Table Header #}
|
||||
{% if show_header %}
|
||||
<thead class="bg-muted/30">
|
||||
<tr>
|
||||
{# Checkbox header #}
|
||||
{% if show_checkbox %}
|
||||
<th class="w-12 p-4">
|
||||
<div class="w-5 h-5 bg-muted rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
</th>
|
||||
{% endif %}
|
||||
|
||||
{# Data column headers #}
|
||||
{% for c in "12345678"|slice:cols %}
|
||||
<th class="p-4 text-left">
|
||||
<div class="h-4 bg-muted rounded {% if animate %}animate-pulse{% endif %}"
|
||||
style="width: {% widthratio forloop.counter0 1 4 %}5%; animation-delay: {{ forloop.counter0 }}25ms;">
|
||||
</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
|
||||
{# Actions header #}
|
||||
{% if show_actions %}
|
||||
<th class="w-28 p-4 text-right">
|
||||
<div class="h-4 w-16 bg-muted rounded ml-auto {% if animate %}animate-pulse{% endif %}"></div>
|
||||
</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
{% endif %}
|
||||
|
||||
{# Table Body #}
|
||||
<tbody class="divide-y divide-border">
|
||||
{% for r in "12345678901234567890"|slice:rows %}
|
||||
<tr class="{% if striped and forloop.counter|divisibleby:2 %}bg-muted/10{% endif %}">
|
||||
{# Checkbox cell #}
|
||||
{% if show_checkbox %}
|
||||
<td class="p-4">
|
||||
<div class="w-5 h-5 bg-muted/70 rounded border border-muted {% if animate %}animate-pulse{% endif %}"
|
||||
style="animation-delay: {{ forloop.counter0 }}50ms;">
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
{# Data cells #}
|
||||
{% for c in "12345678"|slice:cols %}
|
||||
<td class="p-4">
|
||||
{% if forloop.first and show_avatar %}
|
||||
{# First column with avatar #}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-muted {% if animate %}animate-pulse{% endif %}"
|
||||
style="animation-delay: {{ forloop.parentloop.counter0 }}25ms;">
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="h-4 w-24 bg-muted rounded {% if animate %}animate-pulse{% endif %}"
|
||||
style="animation-delay: {{ forloop.parentloop.counter0 }}50ms;">
|
||||
</div>
|
||||
<div class="h-3 w-32 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}"
|
||||
style="animation-delay: {{ forloop.parentloop.counter0 }}75ms;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Regular data cell #}
|
||||
<div class="h-4 bg-muted/70 rounded {% if animate %}animate-pulse{% endif %}"
|
||||
style="width: {% widthratio forloop.counter0 cols 100 %}%; min-width: 40%; animation-delay: {{ forloop.parentloop.counter0 }}{{ forloop.counter0 }}0ms;">
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
|
||||
{# Actions cell #}
|
||||
{% if show_actions %}
|
||||
<td class="p-4">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<div class="w-8 h-8 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}"></div>
|
||||
<div class="w-8 h-8 bg-muted/60 rounded {% if animate %}animate-pulse{% endif %}" style="animation-delay: 50ms;"></div>
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<span class="sr-only">Loading table data, please wait...</span>
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
87
backend/templates/components/stats_card.html
Normal file
87
backend/templates/components/stats_card.html
Normal file
@@ -0,0 +1,87 @@
|
||||
{% comment %}
|
||||
Statistics Card Component
|
||||
=========================
|
||||
|
||||
A reusable card component for displaying statistics and metrics.
|
||||
|
||||
Purpose:
|
||||
Renders a consistent statistics card with label, value, optional icon,
|
||||
and optional link. Used for displaying metrics on detail pages.
|
||||
|
||||
Usage Examples:
|
||||
Basic stat:
|
||||
{% include 'components/stats_card.html' with label='Total Rides' value=park.ride_count %}
|
||||
|
||||
Stat with icon:
|
||||
{% include 'components/stats_card.html' with label='Rating' value='4.5/5' icon='fas fa-star' %}
|
||||
|
||||
Clickable stat:
|
||||
{% include 'components/stats_card.html' with label='Total Rides' value=42 link=rides_url %}
|
||||
|
||||
Priority stat (highlighted):
|
||||
{% include 'components/stats_card.html' with label='Operator' value=park.operator.name priority=True %}
|
||||
|
||||
Stat with subtitle:
|
||||
{% include 'components/stats_card.html' with label='Height' value='250 ft' subtitle='76 meters' %}
|
||||
|
||||
Parameters:
|
||||
Required:
|
||||
- label: Stat label/title
|
||||
- value: Stat value to display
|
||||
|
||||
Optional:
|
||||
- icon: Font Awesome icon class (e.g., 'fas fa-star')
|
||||
- link: URL to link to (makes card clickable)
|
||||
- subtitle: Secondary text below value
|
||||
- priority: Boolean to highlight as priority card (default: False)
|
||||
- size: Size variant 'sm', 'md', 'lg' (default: 'md')
|
||||
- value_class: Additional CSS classes for value
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS for styling
|
||||
- Font Awesome icons (optional)
|
||||
|
||||
Accessibility:
|
||||
- Uses semantic dt/dd structure for label/value
|
||||
- Clickable cards use proper link semantics
|
||||
- Priority cards use visual emphasis, not just color
|
||||
{% endcomment %}
|
||||
|
||||
{% with priority=priority|default:False size=size|default:'md' %}
|
||||
|
||||
{% if link %}
|
||||
<a href="{{ link }}"
|
||||
class="block bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats transition-transform hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-blue-500 {% if priority %}card-stats-priority{% endif %}">
|
||||
{% else %}
|
||||
<div class="bg-white rounded-lg shadow-lg dark:bg-gray-800 p-compact card-stats {% if priority %}card-stats-priority{% endif %}">
|
||||
{% endif %}
|
||||
|
||||
<div class="text-center">
|
||||
{# Label #}
|
||||
<dt class="{% if size == 'sm' %}text-xs{% elif size == 'lg' %}text-base{% else %}text-sm{% endif %} font-semibold text-gray-900 dark:text-white">
|
||||
{% if icon %}
|
||||
<i class="{{ icon }} mr-1 text-gray-500 dark:text-gray-400" aria-hidden="true"></i>
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
</dt>
|
||||
|
||||
{# Value #}
|
||||
<dd class="mt-1 {% if size == 'sm' %}text-lg{% elif size == 'lg' %}text-3xl{% else %}text-2xl{% endif %} font-bold text-sky-900 dark:text-sky-400 {{ value_class }}{% if link %} hover:text-sky-800 dark:hover:text-sky-300{% endif %}">
|
||||
{{ value|default:"N/A" }}
|
||||
</dd>
|
||||
|
||||
{# Subtitle (optional) #}
|
||||
{% if subtitle %}
|
||||
<dd class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ subtitle }}
|
||||
</dd>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if link %}
|
||||
</a>
|
||||
{% else %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endwith %}
|
||||
@@ -1,22 +1,86 @@
|
||||
{% comment %}
|
||||
Reusable status badge component with consistent styling.
|
||||
Usage: {% include 'components/status_badge.html' with status="OPERATING" %}
|
||||
Usage (clickable): {% include 'components/status_badge.html' with status="OPERATING" clickable=True %}
|
||||
Status Badge Component
|
||||
======================
|
||||
|
||||
A unified, reusable status badge component for parks, rides, and other entities.
|
||||
|
||||
Purpose:
|
||||
Displays a status badge with consistent styling across the application.
|
||||
Supports both static display and interactive HTMX-powered refresh.
|
||||
|
||||
Usage Examples:
|
||||
Basic badge (uses park_tags for config):
|
||||
{% include 'components/status_badge.html' with status='OPERATING' %}
|
||||
|
||||
Clickable badge:
|
||||
{% include 'components/status_badge.html' with status='OPERATING' clickable=True %}
|
||||
|
||||
Interactive badge with HTMX (for moderators):
|
||||
{% include 'components/status_badge.html' with status=park.status badge_id='park-header-badge' refresh_url=park_badge_url refresh_trigger='park-status-changed' scroll_target='park-status-section' can_edit=perms.parks.change_park %}
|
||||
|
||||
Manual status display (without park_tags config lookup):
|
||||
{% include 'components/status_badge.html' with status=obj.status status_display=obj.get_status_display manual_mode=True %}
|
||||
|
||||
Manual mode with custom classes:
|
||||
{% include 'components/status_badge.html' with status=obj.status status_display=obj.get_status_display status_classes='bg-blue-100 text-blue-800' manual_mode=True %}
|
||||
|
||||
Parameters:
|
||||
Required:
|
||||
- status: The status value (e.g., 'OPERATING', 'CLOSED_TEMP')
|
||||
|
||||
Optional (auto mode - uses park_tags):
|
||||
- clickable: Enable click interactions (default: False)
|
||||
|
||||
Optional (HTMX mode):
|
||||
- badge_id: ID for HTMX targeting
|
||||
- refresh_url: URL for HTMX refresh on trigger
|
||||
- refresh_trigger: HTMX trigger event name (e.g., 'park-status-changed')
|
||||
- scroll_target: Element ID to scroll to on click
|
||||
- can_edit: Whether user can edit/click the badge (default: False)
|
||||
|
||||
Optional (manual mode):
|
||||
- manual_mode: Use status_display instead of park_tags config lookup (default: False)
|
||||
- status_display: Human-readable status text (used when manual_mode=True)
|
||||
- status_classes: CSS classes for badge styling (default: 'bg-gray-100 text-gray-800')
|
||||
|
||||
Optional (styling):
|
||||
- size: Size variant 'sm', 'md', 'lg' (default: 'md')
|
||||
|
||||
Status Classes (auto mode - defined in park_tags):
|
||||
- OPERATING: Green (bg-green-100 text-green-800)
|
||||
- CLOSED_TEMP: Yellow (bg-yellow-100 text-yellow-800)
|
||||
- CLOSED_PERM: Red (bg-red-100 text-red-800)
|
||||
- CONSTRUCTION: Orange (bg-orange-100 text-orange-800)
|
||||
- DEMOLISHED: Gray (bg-gray-100 text-gray-800)
|
||||
- RELOCATED: Purple (bg-purple-100 text-purple-800)
|
||||
- SBNO: Amber (bg-amber-100 text-amber-800)
|
||||
|
||||
Dependencies:
|
||||
- park_tags template tags (for get_status_config filter, only needed in auto mode)
|
||||
- HTMX (optional, for interactive features)
|
||||
- Font Awesome icons (for dropdown indicator)
|
||||
|
||||
Accessibility:
|
||||
- Uses semantic button or span based on interactivity
|
||||
- Provides appropriate focus states
|
||||
- Uses color + text for status indication
|
||||
{% endcomment %}
|
||||
|
||||
{% load park_tags %}
|
||||
|
||||
{% with status_config=status|get_status_config %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ status_config.classes }}
|
||||
{% if clickable %}cursor-pointer transition-all hover:ring-2 hover:ring-blue-500{% endif %}">
|
||||
{% if status_config.icon %}
|
||||
<svg class="-ml-0.5 mr-1.5 h-2 w-2" fill="currentColor" viewBox="0 0 8 8">
|
||||
<circle cx="4" cy="4" r="3" />
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ status_config.label }}
|
||||
{% if clickable %}
|
||||
<i class="fas fa-chevron-down ml-1.5 text-xs"></i>
|
||||
{% endif %}
|
||||
</span>
|
||||
{# Determine sizing classes #}
|
||||
{% with size=size|default:'md' %}
|
||||
{% if size == 'sm' %}
|
||||
{% with size_classes='px-2 py-0.5 text-xs' icon_size='h-1.5 w-1.5' %}
|
||||
{% include 'components/status_badge_inner.html' %}
|
||||
{% endwith %}
|
||||
{% elif size == 'lg' %}
|
||||
{% with size_classes='px-4 py-1.5 text-sm' icon_size='h-2.5 w-2.5' %}
|
||||
{% include 'components/status_badge_inner.html' %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{% with size_classes='px-2.5 py-0.5 text-xs' icon_size='h-2 w-2' %}
|
||||
{% include 'components/status_badge_inner.html' %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
99
backend/templates/components/status_badge_inner.html
Normal file
99
backend/templates/components/status_badge_inner.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{# Inner status badge template - do not use directly, use status_badge.html instead #}
|
||||
{# This template expects: status, size_classes, icon_size, and optionally other params #}
|
||||
|
||||
{# When manual_mode is true, use provided status_display and default classes #}
|
||||
{# Otherwise use get_status_config filter from park_tags #}
|
||||
|
||||
{# Wrapper with optional HTMX refresh #}
|
||||
{% if badge_id %}
|
||||
<span id="{{ badge_id }}"
|
||||
{% if refresh_url and refresh_trigger %}
|
||||
hx-get="{{ refresh_url }}"
|
||||
hx-trigger="{{ refresh_trigger }} from:body"
|
||||
hx-swap="outerHTML"
|
||||
{% endif %}>
|
||||
{% endif %}
|
||||
|
||||
{% if manual_mode %}
|
||||
{# Manual mode: use provided status_display and derive classes from status value #}
|
||||
{% with badge_label=status_display|default:status badge_classes=status_classes|default:'bg-gray-100 text-gray-800' show_icon=True %}
|
||||
{% if can_edit and scroll_target %}
|
||||
<button type="button"
|
||||
onclick="document.getElementById('{{ scroll_target }}').scrollIntoView({behavior: 'smooth'})"
|
||||
class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium transition-all hover:ring-2 hover:ring-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 cursor-pointer {{ badge_classes }}"
|
||||
aria-label="View status options for {{ badge_label }}">
|
||||
{% if show_icon %}
|
||||
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
|
||||
<circle cx="4" cy="4" r="3" />
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ badge_label }}
|
||||
<i class="fas fa-chevron-down ml-1.5 text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% elif clickable %}
|
||||
<span class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium transition-all hover:ring-2 hover:ring-blue-500 cursor-pointer {{ badge_classes }}"
|
||||
role="button"
|
||||
tabindex="0">
|
||||
{% if show_icon %}
|
||||
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
|
||||
<circle cx="4" cy="4" r="3" />
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ badge_label }}
|
||||
<i class="fas fa-chevron-down ml-1.5 text-xs" aria-hidden="true"></i>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium {{ badge_classes }}">
|
||||
{% if show_icon %}
|
||||
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
|
||||
<circle cx="4" cy="4" r="3" />
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ badge_label }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{# Auto mode: use get_status_config filter from park_tags #}
|
||||
{% with status_config=status|get_status_config %}
|
||||
{% if can_edit and scroll_target %}
|
||||
<button type="button"
|
||||
onclick="document.getElementById('{{ scroll_target }}').scrollIntoView({behavior: 'smooth'})"
|
||||
class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium transition-all hover:ring-2 hover:ring-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 cursor-pointer {{ status_config.classes }}"
|
||||
aria-label="View status options for {{ status_config.label }}">
|
||||
{% if status_config.icon %}
|
||||
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
|
||||
<circle cx="4" cy="4" r="3" />
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ status_config.label }}
|
||||
<i class="fas fa-chevron-down ml-1.5 text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% elif clickable %}
|
||||
<span class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium transition-all hover:ring-2 hover:ring-blue-500 cursor-pointer {{ status_config.classes }}"
|
||||
role="button"
|
||||
tabindex="0">
|
||||
{% if status_config.icon %}
|
||||
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
|
||||
<circle cx="4" cy="4" r="3" />
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ status_config.label }}
|
||||
<i class="fas fa-chevron-down ml-1.5 text-xs" aria-hidden="true"></i>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="status-badge inline-flex items-center {{ size_classes }} rounded-full font-medium {{ status_config.classes }}">
|
||||
{% if status_config.icon %}
|
||||
<svg class="-ml-0.5 mr-1.5 {{ icon_size }}" fill="currentColor" viewBox="0 0 8 8" aria-hidden="true">
|
||||
<circle cx="4" cy="4" r="3" />
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ status_config.label }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
{% if badge_id %}
|
||||
</span>
|
||||
{% endif %}
|
||||
169
backend/templates/components/ui/action_bar.html
Normal file
169
backend/templates/components/ui/action_bar.html
Normal file
@@ -0,0 +1,169 @@
|
||||
{% comment %}
|
||||
Action Bar Component
|
||||
====================
|
||||
|
||||
Standardized container for action buttons with consistent layout and spacing.
|
||||
|
||||
Purpose:
|
||||
Provides a consistent action button container for page headers, card footers,
|
||||
and section actions with responsive layout.
|
||||
|
||||
Usage Examples:
|
||||
Basic action bar:
|
||||
{% include 'components/ui/action_bar.html' %}
|
||||
{% block actions %}
|
||||
<a href="{% url 'item:edit' %}" class="btn btn-primary">Edit</a>
|
||||
{% endblock %}
|
||||
{% endinclude %}
|
||||
|
||||
With primary and secondary actions:
|
||||
{% include 'components/ui/action_bar.html' with
|
||||
primary_action_url='/create/'
|
||||
primary_action_text='Create Park'
|
||||
primary_action_icon='fas fa-plus'
|
||||
secondary_action_url='/import/'
|
||||
secondary_action_text='Import'
|
||||
%}
|
||||
|
||||
Between alignment (cancel left, submit right):
|
||||
{% include 'components/ui/action_bar.html' with align='between' %}
|
||||
|
||||
Multiple actions via slot:
|
||||
{% include 'components/ui/action_bar.html' %}
|
||||
{% block actions %}
|
||||
<button class="btn btn-ghost">Preview</button>
|
||||
<button class="btn btn-outline">Save Draft</button>
|
||||
<button class="btn btn-primary">Publish</button>
|
||||
{% endblock %}
|
||||
{% endinclude %}
|
||||
|
||||
Parameters:
|
||||
Optional:
|
||||
- align: 'left', 'right', 'center', 'between' (default: 'right')
|
||||
- mobile_stack: Stack vertically on mobile (default: True)
|
||||
- show_border: Show top border (default: False)
|
||||
- padding: Add padding (default: True)
|
||||
|
||||
Primary action:
|
||||
- primary_action_url: URL for primary button
|
||||
- primary_action_text: Primary button text
|
||||
- primary_action_icon: Primary button icon class
|
||||
- primary_action_class: Primary button CSS class (default: 'btn-primary')
|
||||
|
||||
Secondary action:
|
||||
- secondary_action_url: URL for secondary button
|
||||
- secondary_action_text: Secondary button text
|
||||
- secondary_action_icon: Secondary button icon class
|
||||
- secondary_action_class: Secondary button CSS class (default: 'btn-outline')
|
||||
|
||||
Tertiary action:
|
||||
- tertiary_action_url: URL for tertiary button
|
||||
- tertiary_action_text: Tertiary button text
|
||||
- tertiary_action_class: Tertiary button CSS class (default: 'btn-ghost')
|
||||
|
||||
Blocks:
|
||||
- actions: Custom action buttons slot
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS for styling
|
||||
- Font Awesome icons (optional)
|
||||
- Button component styles from components.css
|
||||
|
||||
Accessibility:
|
||||
- Uses proper button/link semantics
|
||||
- Focus states for keyboard navigation
|
||||
{% endcomment %}
|
||||
|
||||
{% with align=align|default:'right' mobile_stack=mobile_stack|default:True show_border=show_border|default:False padding=padding|default:True %}
|
||||
|
||||
<div class="action-bar flex flex-wrap items-center gap-3
|
||||
{% if mobile_stack %}flex-col sm:flex-row{% endif %}
|
||||
{% if padding %}py-4{% endif %}
|
||||
{% if show_border %}pt-4 border-t border-border{% endif %}
|
||||
{% if align == 'left' %}justify-start
|
||||
{% elif align == 'center' %}justify-center
|
||||
{% elif align == 'between' %}justify-between
|
||||
{% else %}justify-end{% endif %}">
|
||||
|
||||
{# Left side actions (for 'between' alignment) #}
|
||||
{% if align == 'between' %}
|
||||
<div class="flex items-center gap-3 {% if mobile_stack %}w-full sm:w-auto{% endif %}">
|
||||
{# Tertiary action (left side) #}
|
||||
{% if tertiary_action_url or tertiary_action_text %}
|
||||
{% if tertiary_action_url %}
|
||||
<a href="{{ tertiary_action_url }}"
|
||||
class="{{ tertiary_action_class|default:'btn btn-ghost' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
|
||||
{% if tertiary_action_icon %}<i class="{{ tertiary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
|
||||
{{ tertiary_action_text }}
|
||||
</a>
|
||||
{% else %}
|
||||
<button type="button"
|
||||
class="{{ tertiary_action_class|default:'btn btn-ghost' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}"
|
||||
onclick="history.back()">
|
||||
{% if tertiary_action_icon %}<i class="{{ tertiary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
|
||||
{{ tertiary_action_text|default:'Cancel' }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Main actions group #}
|
||||
<div class="flex items-center gap-3 {% if mobile_stack %}w-full sm:w-auto {% if align == 'between' %}justify-end{% endif %}{% endif %}">
|
||||
{# Custom actions slot #}
|
||||
{% block actions %}{% endblock %}
|
||||
|
||||
{# Tertiary action (non-between alignment) #}
|
||||
{% if tertiary_action_text and align != 'between' %}
|
||||
{% if tertiary_action_url %}
|
||||
<a href="{{ tertiary_action_url }}"
|
||||
class="{{ tertiary_action_class|default:'btn btn-ghost' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
|
||||
{% if tertiary_action_icon %}<i class="{{ tertiary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
|
||||
{{ tertiary_action_text }}
|
||||
</a>
|
||||
{% else %}
|
||||
<button type="button"
|
||||
class="{{ tertiary_action_class|default:'btn btn-ghost' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
|
||||
{% if tertiary_action_icon %}<i class="{{ tertiary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
|
||||
{{ tertiary_action_text }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Secondary action #}
|
||||
{% if secondary_action_text %}
|
||||
{% if secondary_action_url %}
|
||||
<a href="{{ secondary_action_url }}"
|
||||
class="{{ secondary_action_class|default:'btn btn-outline' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
|
||||
{% if secondary_action_icon %}<i class="{{ secondary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
|
||||
{{ secondary_action_text }}
|
||||
</a>
|
||||
{% else %}
|
||||
<button type="button"
|
||||
class="{{ secondary_action_class|default:'btn btn-outline' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
|
||||
{% if secondary_action_icon %}<i class="{{ secondary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
|
||||
{{ secondary_action_text }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Primary action #}
|
||||
{% if primary_action_text %}
|
||||
{% if primary_action_url %}
|
||||
<a href="{{ primary_action_url }}"
|
||||
class="{{ primary_action_class|default:'btn btn-primary' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
|
||||
{% if primary_action_icon %}<i class="{{ primary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
|
||||
{{ primary_action_text }}
|
||||
</a>
|
||||
{% else %}
|
||||
<button type="submit"
|
||||
class="{{ primary_action_class|default:'btn btn-primary' }} {% if mobile_stack %}w-full sm:w-auto{% endif %}">
|
||||
{% if primary_action_icon %}<i class="{{ primary_action_icon }} mr-2" aria-hidden="true"></i>{% endif %}
|
||||
{{ primary_action_text }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
@@ -1,63 +1,155 @@
|
||||
{% comment %}
|
||||
Button Component - Django Template Version of shadcn/ui Button
|
||||
Usage: {% include 'components/ui/button.html' with variant='default' size='default' text='Click me' %}
|
||||
Button Component - Unified Django Template Version of shadcn/ui Button
|
||||
|
||||
A versatile button component that supports multiple variants, sizes, icons, and both
|
||||
button/link elements. Compatible with HTMX and Alpine.js.
|
||||
|
||||
Usage Examples:
|
||||
Basic button:
|
||||
{% include 'components/ui/button.html' with text='Click me' %}
|
||||
|
||||
With variant and size:
|
||||
{% include 'components/ui/button.html' with text='Submit' variant='default' size='lg' %}
|
||||
|
||||
Link button:
|
||||
{% include 'components/ui/button.html' with href='/path' text='Go' type='link' %}
|
||||
|
||||
With HTMX:
|
||||
{% include 'components/ui/button.html' with text='Load' hx_get='/api/data' hx_target='#target' %}
|
||||
|
||||
With Alpine.js:
|
||||
{% include 'components/ui/button.html' with text='Toggle' x_on_click='open = !open' %}
|
||||
|
||||
With SVG icon (preferred):
|
||||
{% include 'components/ui/button.html' with icon=search_icon_svg text='Search' %}
|
||||
|
||||
Icon-only button:
|
||||
{% include 'components/ui/button.html' with icon=icon_svg size='icon' aria_label='Close' %}
|
||||
|
||||
Parameters:
|
||||
- variant: 'default', 'destructive', 'outline', 'secondary', 'ghost', 'link' (default: 'default')
|
||||
- size: 'default', 'sm', 'lg', 'icon' (default: 'default')
|
||||
- type: 'button', 'submit', 'reset', 'link' (default: 'button')
|
||||
- text: Button text content
|
||||
- label: Alias for text (for backwards compatibility)
|
||||
- content: Alias for text (for backwards compatibility)
|
||||
- href: URL for link buttons (required when type='link')
|
||||
- icon: SVG icon content (will be sanitized)
|
||||
- icon_left: Font Awesome class for left icon (deprecated, prefer icon)
|
||||
- icon_right: Font Awesome class for right icon (deprecated)
|
||||
- disabled: Boolean to disable the button
|
||||
- class: Additional CSS classes
|
||||
- id: Element ID
|
||||
- aria_label: Accessibility label (required for icon-only buttons)
|
||||
- onclick: JavaScript click handler
|
||||
- hx_get, hx_post, hx_target, hx_swap, hx_trigger, hx_indicator, hx_include: HTMX attributes
|
||||
- x_data, x_on_click, x_bind, x_show: Alpine.js attributes
|
||||
- attrs: Additional HTML attributes as string
|
||||
|
||||
Security: Icon SVGs are sanitized using the sanitize_svg filter to prevent XSS attacks.
|
||||
{% endcomment %}
|
||||
|
||||
{% load static %}
|
||||
{% load static safe_html %}
|
||||
|
||||
{% with variant=variant|default:'default' size=size|default:'default' %}
|
||||
<button
|
||||
class="
|
||||
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
|
||||
{% if variant == 'default' %}
|
||||
bg-primary text-primary-foreground hover:bg-primary/90
|
||||
{% elif variant == 'destructive' %}
|
||||
bg-destructive text-destructive-foreground hover:bg-destructive/90
|
||||
{% elif variant == 'outline' %}
|
||||
border border-input bg-background hover:bg-accent hover:text-accent-foreground
|
||||
{% elif variant == 'secondary' %}
|
||||
bg-secondary text-secondary-foreground hover:bg-secondary/80
|
||||
{% elif variant == 'ghost' %}
|
||||
hover:bg-accent hover:text-accent-foreground
|
||||
{% elif variant == 'link' %}
|
||||
text-primary underline-offset-4 hover:underline
|
||||
{% with variant=variant|default:'default' size=size|default:'default' btn_type=type|default:'button' btn_text=text|default:label|default:content %}
|
||||
|
||||
{% if btn_type == 'link' or href %}
|
||||
{# Link element styled as button #}
|
||||
<a
|
||||
href="{{ href|default:'#' }}"
|
||||
{% if id %}id="{{ id }}"{% endif %}
|
||||
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50
|
||||
{% if variant == 'destructive' %}bg-destructive text-destructive-foreground hover:bg-destructive/90
|
||||
{% elif variant == 'outline' %}border border-input bg-background hover:bg-accent hover:text-accent-foreground
|
||||
{% elif variant == 'secondary' %}bg-secondary text-secondary-foreground hover:bg-secondary/80
|
||||
{% elif variant == 'ghost' %}hover:bg-accent hover:text-accent-foreground
|
||||
{% elif variant == 'link' %}text-primary underline-offset-4 hover:underline
|
||||
{% else %}bg-primary text-primary-foreground hover:bg-primary/90{% endif %}
|
||||
{% if size == 'sm' %}h-9 rounded-md px-3
|
||||
{% elif size == 'lg' %}h-11 rounded-md px-8
|
||||
{% elif size == 'icon' %}h-10 w-10
|
||||
{% else %}h-10 px-4 py-2{% endif %}
|
||||
{{ class|default:'' }}"
|
||||
{% if disabled %}aria-disabled="true" tabindex="-1"{% endif %}
|
||||
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
|
||||
{% if x_data %}x-data="{{ x_data }}"{% endif %}
|
||||
{% if x_on_click %}@click="{{ x_on_click }}"{% endif %}
|
||||
{% if x_bind %}x-bind="{{ x_bind }}"{% endif %}
|
||||
{% if x_show %}x-show="{{ x_show }}"{% endif %}
|
||||
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
|
||||
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
|
||||
{% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %}
|
||||
{% if hx_indicator %}hx-indicator="{{ hx_indicator }}"{% endif %}
|
||||
{{ attrs|default:'' }}>
|
||||
|
||||
{% if icon %}
|
||||
<span class="w-4 h-4 flex items-center justify-center">{{ icon|sanitize_svg }}</span>
|
||||
{% if btn_text %}<span>{{ btn_text }}</span>{% endif %}
|
||||
{% elif icon_left %}
|
||||
<i class="{{ icon_left }} w-4 h-4" aria-hidden="true"></i>
|
||||
{% if btn_text %}{{ btn_text }}{% endif %}
|
||||
{% else %}
|
||||
{{ btn_text }}
|
||||
{% endif %}
|
||||
{% if size == 'default' %}
|
||||
h-10 px-4 py-2
|
||||
{% elif size == 'sm' %}
|
||||
h-9 rounded-md px-3
|
||||
{% elif size == 'lg' %}
|
||||
h-11 rounded-md px-8
|
||||
{% elif size == 'icon' %}
|
||||
h-10 w-10
|
||||
|
||||
{% if icon_right %}
|
||||
<i class="{{ icon_right }} w-4 h-4" aria-hidden="true"></i>
|
||||
{% endif %}
|
||||
{{ class|default:'' }}
|
||||
"
|
||||
{% if type %}type="{{ type }}"{% endif %}
|
||||
{% if onclick %}onclick="{{ onclick }}"{% endif %}
|
||||
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
|
||||
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
|
||||
{% if x_data %}x-data="{{ x_data }}"{% endif %}
|
||||
{% if x_on %}{{ x_on }}{% endif %}
|
||||
{% if disabled %}disabled{% endif %}
|
||||
{{ attrs|default:'' }}
|
||||
>
|
||||
{% if icon_left %}
|
||||
<i class="{{ icon_left }} w-4 h-4"></i>
|
||||
{% endif %}
|
||||
|
||||
{% if text %}
|
||||
{{ text }}
|
||||
{% else %}
|
||||
{{ content|default:'' }}
|
||||
{% endif %}
|
||||
|
||||
{% if icon_right %}
|
||||
<i class="{{ icon_right }} w-4 h-4"></i>
|
||||
{% endif %}
|
||||
|
||||
{% block link_content %}{% endblock %}
|
||||
</a>
|
||||
|
||||
{% else %}
|
||||
{# Button element #}
|
||||
<button
|
||||
type="{{ btn_type }}"
|
||||
{% if id %}id="{{ id }}"{% endif %}
|
||||
class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50
|
||||
{% if variant == 'destructive' %}bg-destructive text-destructive-foreground hover:bg-destructive/90
|
||||
{% elif variant == 'outline' %}border border-input bg-background hover:bg-accent hover:text-accent-foreground
|
||||
{% elif variant == 'secondary' %}bg-secondary text-secondary-foreground hover:bg-secondary/80
|
||||
{% elif variant == 'ghost' %}hover:bg-accent hover:text-accent-foreground
|
||||
{% elif variant == 'link' %}text-primary underline-offset-4 hover:underline
|
||||
{% else %}bg-primary text-primary-foreground hover:bg-primary/90{% endif %}
|
||||
{% if size == 'sm' %}h-9 rounded-md px-3
|
||||
{% elif size == 'lg' %}h-11 rounded-md px-8
|
||||
{% elif size == 'icon' %}h-10 w-10
|
||||
{% else %}h-10 px-4 py-2{% endif %}
|
||||
{{ class|default:'' }}"
|
||||
{% if disabled %}disabled{% endif %}
|
||||
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
|
||||
{% if onclick %}onclick="{{ onclick }}"{% endif %}
|
||||
{% if x_data %}x-data="{{ x_data }}"{% endif %}
|
||||
{% if x_on_click %}@click="{{ x_on_click }}"{% endif %}
|
||||
{% if x_bind %}x-bind="{{ x_bind }}"{% endif %}
|
||||
{% if x_show %}x-show="{{ x_show }}"{% endif %}
|
||||
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
|
||||
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
|
||||
{% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %}
|
||||
{% if hx_indicator %}hx-indicator="{{ hx_indicator }}"{% endif %}
|
||||
{% if hx_include %}hx-include="{{ hx_include }}"{% endif %}
|
||||
{{ attrs|default:'' }}>
|
||||
|
||||
{% if icon %}
|
||||
<span class="w-4 h-4 flex items-center justify-center">{{ icon|sanitize_svg }}</span>
|
||||
{% if btn_text %}<span>{{ btn_text }}</span>{% endif %}
|
||||
{% elif icon_left %}
|
||||
<i class="{{ icon_left }} w-4 h-4" aria-hidden="true"></i>
|
||||
{% if btn_text %}{{ btn_text }}{% endif %}
|
||||
{% else %}
|
||||
{{ btn_text }}
|
||||
{% endif %}
|
||||
|
||||
{% if icon_right %}
|
||||
<i class="{{ icon_right }} w-4 h-4" aria-hidden="true"></i>
|
||||
{% endif %}
|
||||
|
||||
{% block button_content %}{% endblock %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% endwith %}
|
||||
|
||||
@@ -1,40 +1,92 @@
|
||||
{% comment %}
|
||||
Card Component - Django Template Version of shadcn/ui Card
|
||||
Usage: {% include 'components/ui/card.html' with title='Card Title' content='Card content' %}
|
||||
Card Component - Unified Django Template Version of shadcn/ui Card
|
||||
|
||||
Security: All content variables are sanitized to prevent XSS attacks.
|
||||
A flexible card container with optional header, content, and footer sections.
|
||||
Uses design tokens for consistent styling.
|
||||
|
||||
Usage Examples:
|
||||
Basic card with title:
|
||||
{% include 'components/ui/card.html' with title='Card Title' content='Card content here' %}
|
||||
|
||||
Card with all sections:
|
||||
{% include 'components/ui/card.html' with title='Title' description='Subtitle' body_content='<p>Content</p>' footer_content='<button>Action</button>' %}
|
||||
|
||||
Card with custom header:
|
||||
{% include 'components/ui/card.html' with header_content='<div>Custom header</div>' content='Content' %}
|
||||
|
||||
Card with block content (for more complex layouts):
|
||||
{% include 'components/ui/card.html' with title='Title' %}
|
||||
{% block card_content %}
|
||||
Complex content here
|
||||
{% endblock %}
|
||||
|
||||
Parameters:
|
||||
- title: Card title text
|
||||
- description: Card subtitle/description text
|
||||
- header_content: HTML content for the header area (sanitized)
|
||||
- content: Main content (sanitized)
|
||||
- body_content: Alias for content (sanitized)
|
||||
- footer_content: Footer content (sanitized)
|
||||
- footer: Alias for footer_content (sanitized)
|
||||
- header: Alias for header_content (sanitized)
|
||||
- class: Additional CSS classes for the card container
|
||||
- id: Element ID
|
||||
|
||||
Security: All content variables are sanitized using the sanitize filter to prevent XSS attacks.
|
||||
Only trusted HTML elements and attributes are allowed.
|
||||
{% endcomment %}
|
||||
|
||||
{% load safe_html %}
|
||||
|
||||
<div class="rounded-lg border bg-card text-card-foreground shadow-sm {{ class|default:'' }}">
|
||||
{% if title or header_content %}
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
{% if title %}
|
||||
<h3 class="text-2xl font-semibold leading-none tracking-tight">{{ title }}</h3>
|
||||
{% endif %}
|
||||
{% if description %}
|
||||
<p class="text-sm text-muted-foreground">{{ description }}</p>
|
||||
{% endif %}
|
||||
{% if header_content %}
|
||||
{{ header_content|sanitize }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div
|
||||
{% if id %}id="{{ id }}"{% endif %}
|
||||
class="rounded-lg border bg-card text-card-foreground shadow-sm {{ class|default:'' }}">
|
||||
|
||||
{% if content or body_content %}
|
||||
<div class="p-6 pt-0">
|
||||
{% if content %}
|
||||
{{ content|sanitize }}
|
||||
{% endif %}
|
||||
{% if body_content %}
|
||||
{{ body_content|sanitize }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Header Section #}
|
||||
{% if title or description or header_content or header %}
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
{% if title %}
|
||||
<h3 class="text-2xl font-semibold leading-none tracking-tight">{{ title }}</h3>
|
||||
{% endif %}
|
||||
|
||||
{% if footer_content %}
|
||||
<div class="flex items-center p-6 pt-0">
|
||||
{{ footer_content|sanitize }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if description %}
|
||||
<p class="text-sm text-muted-foreground">{{ description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if header_content %}
|
||||
{{ header_content|sanitize }}
|
||||
{% elif header %}
|
||||
{{ header|sanitize }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Content Section #}
|
||||
{% if content or body_content %}
|
||||
<div class="p-6 pt-0">
|
||||
{% if content %}
|
||||
{{ content|sanitize }}
|
||||
{% endif %}
|
||||
{% if body_content %}
|
||||
{{ body_content|sanitize }}
|
||||
{% endif %}
|
||||
{% block card_content %}{% endblock %}
|
||||
</div>
|
||||
{% else %}
|
||||
{# Allow block content even without content parameter #}
|
||||
<div class="p-6 pt-0">
|
||||
{% block card_content_fallback %}{% endblock %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Footer Section #}
|
||||
{% if footer_content or footer %}
|
||||
<div class="flex items-center p-6 pt-0">
|
||||
{% if footer_content %}
|
||||
{{ footer_content|sanitize }}
|
||||
{% elif footer %}
|
||||
{{ footer|sanitize }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
140
backend/templates/components/ui/dialog.html
Normal file
140
backend/templates/components/ui/dialog.html
Normal file
@@ -0,0 +1,140 @@
|
||||
{% comment %}
|
||||
Dialog/Modal Component - Unified Django Template
|
||||
|
||||
A flexible dialog/modal component that supports both HTMX-triggered and Alpine.js-controlled modals.
|
||||
Includes proper accessibility attributes (ARIA) and keyboard navigation support.
|
||||
|
||||
Usage Examples:
|
||||
Alpine.js controlled modal:
|
||||
{% include 'components/ui/dialog.html' with title='Confirm Action' content='Are you sure?' id='confirm-modal' %}
|
||||
<button @click="$store.ui.openModal('confirm-modal')">Open Modal</button>
|
||||
|
||||
HTMX triggered modal (loads content dynamically):
|
||||
<button hx-get="/modal/content" hx-target="#modal-container">Load Modal</button>
|
||||
<div id="modal-container">
|
||||
{% include 'components/ui/dialog.html' with title='Dynamic Content' %}
|
||||
</div>
|
||||
|
||||
With footer actions:
|
||||
{% include 'components/ui/dialog.html' with title='Delete Item' description='This cannot be undone.' footer='<button class="btn">Cancel</button><button class="btn btn-destructive">Delete</button>' %}
|
||||
|
||||
Parameters:
|
||||
- id: Modal ID (used for Alpine.js state management)
|
||||
- title: Dialog title
|
||||
- description: Dialog subtitle/description
|
||||
- content: Main content (sanitized)
|
||||
- footer: Footer content with actions (sanitized)
|
||||
- open: Boolean to control initial open state (default: true for HTMX-loaded content)
|
||||
- closable: Boolean to allow closing (default: true)
|
||||
- size: 'sm', 'default', 'lg', 'xl', 'full' (default: 'default')
|
||||
- class: Additional CSS classes for the dialog panel
|
||||
|
||||
Accessibility:
|
||||
- role="dialog" and aria-modal="true" for screen readers
|
||||
- Focus trap within modal when open
|
||||
- Escape key closes the modal
|
||||
- Click outside closes the modal (backdrop click)
|
||||
|
||||
Security: Content and footer are sanitized to prevent XSS attacks.
|
||||
{% endcomment %}
|
||||
|
||||
{% load safe_html %}
|
||||
|
||||
{% with modal_id=id|default:'dialog' is_open=open|default:True %}
|
||||
|
||||
<div class="fixed inset-0 z-50 flex items-start justify-center sm:items-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
{% if title %}aria-labelledby="{{ modal_id }}-title"{% endif %}
|
||||
{% if description %}aria-describedby="{{ modal_id }}-description"{% endif %}
|
||||
x-data="{ open: {{ is_open|yesno:'true,false' }} }"
|
||||
x-show="open"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@keydown.escape.window="open = false">
|
||||
|
||||
{# Backdrop #}
|
||||
<div class="fixed inset-0 transition-all bg-black/50 backdrop-blur-sm"
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
{% if closable|default:True %}
|
||||
@click="open = false; setTimeout(() => { if ($el.closest('[hx-history-elt]')) $el.closest('[hx-history-elt]').innerHTML = ''; }, 200)"
|
||||
{% endif %}
|
||||
aria-hidden="true"></div>
|
||||
|
||||
{# Dialog Panel #}
|
||||
<div class="fixed z-50 grid w-full gap-4 p-6 duration-200 border shadow-lg bg-background sm:rounded-lg
|
||||
{% if size == 'sm' %}sm:max-w-sm
|
||||
{% elif size == 'lg' %}sm:max-w-2xl
|
||||
{% elif size == 'xl' %}sm:max-w-4xl
|
||||
{% elif size == 'full' %}sm:max-w-[90vw] sm:max-h-[90vh]
|
||||
{% else %}sm:max-w-lg{% endif %}
|
||||
{{ class|default:'' }}"
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
@click.stop>
|
||||
|
||||
{# Header #}
|
||||
{% if title or description %}
|
||||
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
|
||||
{% if title %}
|
||||
<h2 id="{{ modal_id }}-title" class="text-lg font-semibold leading-none tracking-tight">
|
||||
{{ title }}
|
||||
</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if description %}
|
||||
<p id="{{ modal_id }}-description" class="text-sm text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Content #}
|
||||
<div class="py-4">
|
||||
{% if content %}
|
||||
{{ content|sanitize }}
|
||||
{% endif %}
|
||||
{% block dialog_content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
{# Footer #}
|
||||
{% if footer %}
|
||||
<div class="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2">
|
||||
{{ footer|sanitize }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block dialog_footer %}{% endblock %}
|
||||
|
||||
{# Close Button #}
|
||||
{% if closable|default:True %}
|
||||
<button type="button"
|
||||
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
||||
@click="open = false; setTimeout(() => { if ($el.closest('[hx-history-elt]')) $el.closest('[hx-history-elt]').innerHTML = ''; }, 200)"
|
||||
aria-label="Close">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
<span class="sr-only">Close</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
227
backend/templates/components/ui/icon.html
Normal file
227
backend/templates/components/ui/icon.html
Normal file
@@ -0,0 +1,227 @@
|
||||
{% comment %}
|
||||
Icon Component - SVG Icon Wrapper
|
||||
|
||||
A component for rendering SVG icons consistently. Provides a library of common icons
|
||||
and supports custom SVG content. Replaces Font Awesome with inline SVGs for better
|
||||
customization and smaller bundle size.
|
||||
|
||||
Usage Examples:
|
||||
Named icon:
|
||||
{% include 'components/ui/icon.html' with name='search' %}
|
||||
|
||||
With size:
|
||||
{% include 'components/ui/icon.html' with name='menu' size='lg' %}
|
||||
|
||||
With custom class:
|
||||
{% include 'components/ui/icon.html' with name='user' class='text-primary' %}
|
||||
|
||||
Custom SVG content:
|
||||
{% include 'components/ui/icon.html' with svg='<path d="..."/>' %}
|
||||
|
||||
Parameters:
|
||||
- name: Icon name from the built-in library
|
||||
- size: 'xs', 'sm', 'md', 'lg', 'xl' (default: 'md')
|
||||
- class: Additional CSS classes
|
||||
- svg: Custom SVG path content (for icons not in the library)
|
||||
- stroke_width: SVG stroke width (default: 2)
|
||||
- aria_label: Accessibility label (required for meaningful icons)
|
||||
- aria_hidden: Set to 'false' for meaningful icons (default: 'true' for decorative)
|
||||
|
||||
Available Icons:
|
||||
Navigation: menu, close, chevron-down, chevron-up, chevron-left, chevron-right,
|
||||
arrow-left, arrow-right, arrow-up, arrow-down, external-link
|
||||
Actions: search, plus, minus, edit, trash, download, upload, copy, share, refresh
|
||||
User: user, users, settings, logout, login
|
||||
Status: check, x, info, warning, error, question
|
||||
Media: image, video, music, file, folder
|
||||
Communication: mail, phone, message, bell, send
|
||||
Social: heart, star, bookmark, thumbs-up, thumbs-down
|
||||
Misc: home, calendar, clock, map-pin, globe, sun, moon, eye, eye-off, lock, unlock
|
||||
{% endcomment %}
|
||||
|
||||
{% with icon_size=size|default:'md' %}
|
||||
|
||||
<svg
|
||||
class="icon icon-{{ name }}
|
||||
{% if icon_size == 'xs' %}w-3 h-3
|
||||
{% elif icon_size == 'sm' %}w-4 h-4
|
||||
{% elif icon_size == 'lg' %}w-6 h-6
|
||||
{% elif icon_size == 'xl' %}w-8 h-8
|
||||
{% else %}w-5 h-5{% endif %}
|
||||
{{ class|default:'' }}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="{{ stroke_width|default:'2' }}"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
{% if aria_label %}aria-label="{{ aria_label }}" role="img"{% else %}aria-hidden="{{ aria_hidden|default:'true' }}"{% endif %}>
|
||||
|
||||
{% if svg %}
|
||||
{{ svg|safe }}
|
||||
{% elif name == 'search' %}
|
||||
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
{% elif name == 'menu' %}
|
||||
<path d="M4 6h16M4 12h16M4 18h16"/>
|
||||
{% elif name == 'close' or name == 'x' %}
|
||||
<path d="M6 18L18 6M6 6l12 12"/>
|
||||
{% elif name == 'chevron-down' %}
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
{% elif name == 'chevron-up' %}
|
||||
<path d="M5 15l7-7 7 7"/>
|
||||
{% elif name == 'chevron-left' %}
|
||||
<path d="M15 19l-7-7 7-7"/>
|
||||
{% elif name == 'chevron-right' %}
|
||||
<path d="M9 5l7 7-7 7"/>
|
||||
{% elif name == 'arrow-left' %}
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
{% elif name == 'arrow-right' %}
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
{% elif name == 'arrow-up' %}
|
||||
<path d="M12 19V5M5 12l7-7 7 7"/>
|
||||
{% elif name == 'arrow-down' %}
|
||||
<path d="M12 5v14M19 12l-7 7-7-7"/>
|
||||
{% elif name == 'external-link' %}
|
||||
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3"/>
|
||||
{% elif name == 'plus' %}
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
{% elif name == 'minus' %}
|
||||
<path d="M5 12h14"/>
|
||||
{% elif name == 'edit' or name == 'pencil' %}
|
||||
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
{% elif name == 'trash' or name == 'delete' %}
|
||||
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
||||
<path d="M10 11v6M14 11v6"/>
|
||||
{% elif name == 'download' %}
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
|
||||
{% elif name == 'upload' %}
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
|
||||
{% elif name == 'copy' %}
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
|
||||
{% elif name == 'share' %}
|
||||
<circle cx="18" cy="5" r="3"/>
|
||||
<circle cx="6" cy="12" r="3"/>
|
||||
<circle cx="18" cy="19" r="3"/>
|
||||
<path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98"/>
|
||||
{% elif name == 'refresh' %}
|
||||
<path d="M23 4v6h-6M1 20v-6h6"/>
|
||||
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
|
||||
{% elif name == 'user' %}
|
||||
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
{% elif name == 'users' %}
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
|
||||
{% elif name == 'settings' or name == 'cog' %}
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>
|
||||
{% elif name == 'logout' or name == 'sign-out' %}
|
||||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/>
|
||||
{% elif name == 'login' or name == 'sign-in' %}
|
||||
<path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M15 12H3"/>
|
||||
{% elif name == 'check' %}
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
{% elif name == 'info' %}
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 16v-4M12 8h.01"/>
|
||||
{% elif name == 'warning' or name == 'alert-triangle' %}
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0zM12 9v4M12 17h.01"/>
|
||||
{% elif name == 'error' or name == 'alert-circle' %}
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 8v4M12 16h.01"/>
|
||||
{% elif name == 'question' or name == 'help-circle' %}
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3M12 17h.01"/>
|
||||
{% elif name == 'image' %}
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<path d="M21 15l-5-5L5 21"/>
|
||||
{% elif name == 'file' %}
|
||||
<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/>
|
||||
<path d="M13 2v7h7"/>
|
||||
{% elif name == 'folder' %}
|
||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
||||
{% elif name == 'mail' or name == 'email' %}
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||
<path d="M22 6l-10 7L2 6"/>
|
||||
{% elif name == 'phone' %}
|
||||
<path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72 12.84 12.84 0 00.7 2.81 2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45 12.84 12.84 0 002.81.7A2 2 0 0122 16.92z"/>
|
||||
{% elif name == 'message' or name == 'chat' %}
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
|
||||
{% elif name == 'bell' %}
|
||||
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 01-3.46 0"/>
|
||||
{% elif name == 'send' %}
|
||||
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
|
||||
{% elif name == 'heart' %}
|
||||
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/>
|
||||
{% elif name == 'star' %}
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
{% elif name == 'bookmark' %}
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
|
||||
{% elif name == 'thumbs-up' %}
|
||||
<path d="M14 9V5a3 3 0 00-3-3l-4 9v11h11.28a2 2 0 002-1.7l1.38-9a2 2 0 00-2-2.3zM7 22H4a2 2 0 01-2-2v-7a2 2 0 012-2h3"/>
|
||||
{% elif name == 'thumbs-down' %}
|
||||
<path d="M10 15v4a3 3 0 003 3l4-9V2H5.72a2 2 0 00-2 1.7l-1.38 9a2 2 0 002 2.3zm7-13h2.67A2.31 2.31 0 0122 4v7a2.31 2.31 0 01-2.33 2H17"/>
|
||||
{% elif name == 'home' %}
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
|
||||
<path d="M9 22V12h6v10"/>
|
||||
{% elif name == 'calendar' %}
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<path d="M16 2v4M8 2v4M3 10h18"/>
|
||||
{% elif name == 'clock' %}
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 6v6l4 2"/>
|
||||
{% elif name == 'map-pin' or name == 'location' %}
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/>
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
{% elif name == 'globe' %}
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>
|
||||
{% elif name == 'sun' %}
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
{% elif name == 'moon' %}
|
||||
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
|
||||
{% elif name == 'eye' %}
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
{% elif name == 'eye-off' %}
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24M1 1l22 22"/>
|
||||
{% elif name == 'lock' %}
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0110 0v4"/>
|
||||
{% elif name == 'unlock' %}
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 019.9-1"/>
|
||||
{% elif name == 'filter' %}
|
||||
<path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/>
|
||||
{% elif name == 'sort' %}
|
||||
<path d="M3 6h18M6 12h12M9 18h6"/>
|
||||
{% elif name == 'grid' %}
|
||||
<rect x="3" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="14" width="7" height="7"/>
|
||||
<rect x="3" y="14" width="7" height="7"/>
|
||||
{% elif name == 'list' %}
|
||||
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
|
||||
{% elif name == 'more-horizontal' or name == 'dots' %}
|
||||
<circle cx="12" cy="12" r="1"/>
|
||||
<circle cx="19" cy="12" r="1"/>
|
||||
<circle cx="5" cy="12" r="1"/>
|
||||
{% elif name == 'more-vertical' %}
|
||||
<circle cx="12" cy="12" r="1"/>
|
||||
<circle cx="12" cy="5" r="1"/>
|
||||
<circle cx="12" cy="19" r="1"/>
|
||||
{% elif name == 'loader' or name == 'spinner' %}
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
|
||||
{% else %}
|
||||
{# Default: question mark icon for unknown names #}
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3M12 17h.01"/>
|
||||
{% endif %}
|
||||
</svg>
|
||||
|
||||
{% endwith %}
|
||||
@@ -1,26 +1,162 @@
|
||||
{% comment %}
|
||||
Input Component - Django Template Version of shadcn/ui Input
|
||||
Usage: {% include 'components/ui/input.html' with type='text' placeholder='Enter text...' name='field_name' %}
|
||||
Input Component - Unified Django Template Version of shadcn/ui Input
|
||||
|
||||
A versatile input component that supports both Django form fields and standalone inputs.
|
||||
Compatible with HTMX and Alpine.js for dynamic behavior.
|
||||
|
||||
Usage Examples:
|
||||
Standalone input:
|
||||
{% include 'components/ui/input.html' with type='text' name='email' placeholder='Enter email' %}
|
||||
|
||||
With Django form field:
|
||||
{% include 'components/ui/input.html' with field=form.email label='Email Address' %}
|
||||
|
||||
With HTMX validation:
|
||||
{% include 'components/ui/input.html' with name='username' hx_post='/validate' hx_trigger='blur' %}
|
||||
|
||||
With Alpine.js binding:
|
||||
{% include 'components/ui/input.html' with name='search' x_model='query' %}
|
||||
|
||||
Textarea mode:
|
||||
{% include 'components/ui/input.html' with type='textarea' name='message' rows='4' %}
|
||||
|
||||
Parameters:
|
||||
Standalone Mode:
|
||||
- type: Input type (text, email, password, number, etc.) or 'textarea' (default: 'text')
|
||||
- name: Input name attribute
|
||||
- id: Input ID (auto-generated from name if not provided)
|
||||
- placeholder: Placeholder text
|
||||
- value: Initial value
|
||||
- label: Label text
|
||||
- help_text: Help text displayed below the input
|
||||
- error: Error message to display
|
||||
- required: Boolean for required field
|
||||
- disabled: Boolean to disable the input
|
||||
- readonly: Boolean for readonly input
|
||||
- autocomplete: Autocomplete attribute value
|
||||
- rows: Number of rows for textarea
|
||||
- class: Additional CSS classes for the input
|
||||
|
||||
Django Form Field Mode:
|
||||
- field: Django form field object
|
||||
- label: Override field label
|
||||
- placeholder: Override field placeholder
|
||||
- help_text: Override field help text
|
||||
|
||||
HTMX Attributes:
|
||||
- hx_get, hx_post, hx_target, hx_swap, hx_trigger, hx_include: HTMX attributes
|
||||
|
||||
Alpine.js Attributes:
|
||||
- x_model: Two-way binding
|
||||
- x_on: Event handlers (as string, e.g., "@input=...")
|
||||
- x_data: Alpine data
|
||||
|
||||
Other:
|
||||
- attrs: Additional HTML attributes as string
|
||||
- aria_describedby: ID of element describing this input
|
||||
- aria_invalid: Boolean for invalid state
|
||||
{% endcomment %}
|
||||
|
||||
<input
|
||||
type="{{ type|default:'text' }}"
|
||||
class="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 {{ class|default:'' }}"
|
||||
{% if name %}name="{{ name }}"{% endif %}
|
||||
{% if id %}id="{{ id }}"{% endif %}
|
||||
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
|
||||
{% if value %}value="{{ value }}"{% endif %}
|
||||
{% if required %}required{% endif %}
|
||||
{% if disabled %}disabled{% endif %}
|
||||
{% if readonly %}readonly{% endif %}
|
||||
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %}
|
||||
{% if x_model %}x-model="{{ x_model }}"{% endif %}
|
||||
{% if x_on %}{{ x_on }}{% endif %}
|
||||
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
|
||||
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||
{% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %}
|
||||
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
|
||||
{% if hx_include %}hx-include="{{ hx_include }}"{% endif %}
|
||||
{{ attrs|default:'' }}
|
||||
/>
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% if field %}
|
||||
{# Django Form Field Mode #}
|
||||
<div class="space-y-2">
|
||||
{% if label or field.label %}
|
||||
<label for="{{ field.id_for_label }}"
|
||||
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{{ label|default:field.label }}
|
||||
{% if field.field.required %}<span class="text-destructive">*</span>{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
{% render_field field class+="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" placeholder=placeholder|default:field.label aria-describedby=field.id_for_label|add:"-description" aria-invalid=field.errors|yesno:"true,false" %}
|
||||
|
||||
{% if help_text or field.help_text %}
|
||||
<p id="{{ field.id_for_label }}-description" class="text-sm text-muted-foreground">
|
||||
{{ help_text|default:field.help_text }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if field.errors %}
|
||||
<p class="text-sm font-medium text-destructive" role="alert">
|
||||
{{ field.errors.0 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# Standalone Mode #}
|
||||
{% with input_id=id|default:name %}
|
||||
<div class="space-y-2">
|
||||
{% if label %}
|
||||
<label for="{{ input_id }}"
|
||||
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{{ label }}
|
||||
{% if required %}<span class="text-destructive">*</span>{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
{% if type == 'textarea' %}
|
||||
<textarea
|
||||
{% if name %}name="{{ name }}"{% endif %}
|
||||
{% if input_id %}id="{{ input_id }}"{% endif %}
|
||||
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background 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 {{ class|default:'' }}"
|
||||
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
|
||||
{% if rows %}rows="{{ rows }}"{% endif %}
|
||||
{% if required %}required{% endif %}
|
||||
{% if disabled %}disabled{% endif %}
|
||||
{% if readonly %}readonly{% endif %}
|
||||
{% if aria_describedby %}aria-describedby="{{ aria_describedby }}"{% elif help_text %}aria-describedby="{{ input_id }}-description"{% endif %}
|
||||
{% if aria_invalid or error %}aria-invalid="true"{% endif %}
|
||||
{% if x_model %}x-model="{{ x_model }}"{% endif %}
|
||||
{% if x_on %}{{ x_on }}{% endif %}
|
||||
{% if x_data %}x-data="{{ x_data }}"{% endif %}
|
||||
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
|
||||
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||
{% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %}
|
||||
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
|
||||
{% if hx_include %}hx-include="{{ hx_include }}"{% endif %}
|
||||
{{ attrs|default:'' }}>{{ value|default:'' }}</textarea>
|
||||
{% else %}
|
||||
<input
|
||||
type="{{ type|default:'text' }}"
|
||||
{% if name %}name="{{ name }}"{% endif %}
|
||||
{% if input_id %}id="{{ input_id }}"{% endif %}
|
||||
class="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 {{ class|default:'' }}"
|
||||
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
|
||||
{% if value %}value="{{ value }}"{% endif %}
|
||||
{% if required %}required{% endif %}
|
||||
{% if disabled %}disabled{% endif %}
|
||||
{% if readonly %}readonly{% endif %}
|
||||
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %}
|
||||
{% if aria_describedby %}aria-describedby="{{ aria_describedby }}"{% elif help_text %}aria-describedby="{{ input_id }}-description"{% endif %}
|
||||
{% if aria_invalid or error %}aria-invalid="true"{% endif %}
|
||||
{% if x_model %}x-model="{{ x_model }}"{% endif %}
|
||||
{% if x_on %}{{ x_on }}{% endif %}
|
||||
{% if x_data %}x-data="{{ x_data }}"{% endif %}
|
||||
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
|
||||
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||
{% if hx_trigger %}hx-trigger="{{ hx_trigger }}"{% endif %}
|
||||
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
|
||||
{% if hx_include %}hx-include="{{ hx_include }}"{% endif %}
|
||||
{{ attrs|default:'' }}
|
||||
/>
|
||||
{% endif %}
|
||||
|
||||
{% if help_text %}
|
||||
<p id="{{ input_id }}-description" class="text-sm text-muted-foreground">
|
||||
{{ help_text }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<p class="text-sm font-medium text-destructive" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,34 +1,64 @@
|
||||
{% comment %}
|
||||
Toast Notification Container Component
|
||||
Matches React frontend toast functionality with Sonner-like behavior
|
||||
======================================
|
||||
|
||||
Enhanced toast notification system with Sonner-like behavior.
|
||||
|
||||
Features:
|
||||
- Multiple toast types (success, error, warning, info)
|
||||
- Progress bar for auto-dismiss countdown
|
||||
- Action button support (Undo, Retry, View)
|
||||
- Toast stacking with max limit
|
||||
- Persistent toast option (duration: 0)
|
||||
- Accessible announcements
|
||||
|
||||
Usage Examples:
|
||||
Basic toast:
|
||||
Alpine.store('toast').success('Item saved!')
|
||||
|
||||
With action:
|
||||
Alpine.store('toast').success('Item deleted', 5000, {
|
||||
action: { label: 'Undo', onClick: () => undoDelete() }
|
||||
})
|
||||
|
||||
Persistent toast:
|
||||
Alpine.store('toast').error('Connection lost', 0)
|
||||
|
||||
From HTMX via HX-Trigger header:
|
||||
response['HX-Trigger'] = '{"showToast": {"type": "success", "message": "Saved!"}}'
|
||||
{% endcomment %}
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div
|
||||
<div
|
||||
x-data="toast()"
|
||||
x-show="$store.toast.toasts.length > 0"
|
||||
class="fixed top-4 right-4 z-50 space-y-2"
|
||||
class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-h-screen overflow-hidden pointer-events-none"
|
||||
role="region"
|
||||
aria-label="Notifications"
|
||||
x-cloak
|
||||
>
|
||||
<template x-for="toast in $store.toast.toasts" :key="toast.id">
|
||||
<div
|
||||
<template x-for="(toast, index) in $store.toast.toasts.slice(0, 5)" :key="toast.id">
|
||||
<div
|
||||
x-show="toast.visible"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="transform opacity-0 translate-x-full"
|
||||
x-transition:enter-end="transform opacity-100 translate-x-0"
|
||||
x-transition:enter-start="transform opacity-0 translate-x-full scale-95"
|
||||
x-transition:enter-end="transform opacity-100 translate-x-0 scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="transform opacity-100 translate-x-0"
|
||||
x-transition:leave-end="transform opacity-0 translate-x-full"
|
||||
class="relative max-w-sm w-full bg-background border rounded-lg shadow-lg overflow-hidden"
|
||||
x-transition:leave-start="transform opacity-100 translate-x-0 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 translate-x-full scale-95"
|
||||
class="relative max-w-sm w-full bg-background border rounded-lg shadow-lg overflow-hidden pointer-events-auto"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 dark:bg-green-900/20 dark:border-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 dark:bg-red-900/20 dark:border-red-800': toast.type === 'error',
|
||||
'border-yellow-200 bg-yellow-50 dark:bg-yellow-900/20 dark:border-yellow-800': toast.type === 'warning',
|
||||
'border-blue-200 bg-blue-50 dark:bg-blue-900/20 dark:border-blue-800': toast.type === 'info'
|
||||
}"
|
||||
role="alert"
|
||||
:aria-live="toast.type === 'error' ? 'assertive' : 'polite'"
|
||||
>
|
||||
<!-- Progress Bar -->
|
||||
<div
|
||||
<!-- Progress Bar (only show if not persistent) -->
|
||||
<div
|
||||
x-show="toast.duration > 0"
|
||||
class="absolute top-0 left-0 h-1 bg-current opacity-30 transition-all duration-100 ease-linear"
|
||||
:style="`width: ${toast.progress}%`"
|
||||
:class="{
|
||||
@@ -43,29 +73,60 @@ Matches React frontend toast functionality with Sonner-like behavior
|
||||
<div class="flex items-start">
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0 mr-3">
|
||||
<i
|
||||
class="w-5 h-5"
|
||||
<i
|
||||
class="text-lg"
|
||||
:class="{
|
||||
'fas fa-check-circle text-green-500': toast.type === 'success',
|
||||
'fas fa-exclamation-circle text-red-500': toast.type === 'error',
|
||||
'fas fa-exclamation-triangle text-yellow-500': toast.type === 'warning',
|
||||
'fas fa-info-circle text-blue-500': toast.type === 'info'
|
||||
}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-sm font-medium"
|
||||
<!-- Title (optional) -->
|
||||
<p
|
||||
x-show="toast.title"
|
||||
class="text-sm font-semibold mb-0.5"
|
||||
:class="{
|
||||
'text-green-800 dark:text-green-200': toast.type === 'success',
|
||||
'text-red-800 dark:text-red-200': toast.type === 'error',
|
||||
'text-yellow-800 dark:text-yellow-200': toast.type === 'warning',
|
||||
'text-blue-800 dark:text-blue-200': toast.type === 'info'
|
||||
}"
|
||||
x-text="toast.title"
|
||||
></p>
|
||||
|
||||
<!-- Message -->
|
||||
<p
|
||||
class="text-sm"
|
||||
:class="{
|
||||
'text-green-700 dark:text-green-300': toast.type === 'success',
|
||||
'text-red-700 dark:text-red-300': toast.type === 'error',
|
||||
'text-yellow-700 dark:text-yellow-300': toast.type === 'warning',
|
||||
'text-blue-700 dark:text-blue-300': toast.type === 'info'
|
||||
}"
|
||||
x-text="toast.message"
|
||||
></p>
|
||||
|
||||
<!-- Action Button (optional) -->
|
||||
<div x-show="toast.action" class="mt-2">
|
||||
<button
|
||||
x-show="toast.action"
|
||||
@click="toast.action?.onClick?.(); $store.toast.hide(toast.id)"
|
||||
class="text-xs font-medium underline hover:no-underline focus:outline-none focus:ring-2 focus:ring-offset-1 rounded"
|
||||
:class="{
|
||||
'text-green-700 dark:text-green-300 focus:ring-green-500': toast.type === 'success',
|
||||
'text-red-700 dark:text-red-300 focus:ring-red-500': toast.type === 'error',
|
||||
'text-yellow-700 dark:text-yellow-300 focus:ring-yellow-500': toast.type === 'warning',
|
||||
'text-blue-700 dark:text-blue-300 focus:ring-blue-500': toast.type === 'info'
|
||||
}"
|
||||
x-text="toast.action?.label || 'Action'"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
@@ -79,12 +140,21 @@ Matches React frontend toast functionality with Sonner-like behavior
|
||||
'text-yellow-500 hover:bg-yellow-100 focus:ring-yellow-500 dark:hover:bg-yellow-800': toast.type === 'warning',
|
||||
'text-blue-500 hover:bg-blue-100 focus:ring-blue-500 dark:hover:bg-blue-800': toast.type === 'info'
|
||||
}"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<i class="fas fa-times w-4 h-4"></i>
|
||||
<i class="fas fa-times text-sm" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Overflow indicator when more than 5 toasts -->
|
||||
<div
|
||||
x-show="$store.toast.toasts.length > 5"
|
||||
class="text-center text-xs text-muted-foreground pointer-events-auto"
|
||||
>
|
||||
<span x-text="`+${$store.toast.toasts.length - 5} more`"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user