mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 10:31:08 -05:00
Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX
This commit is contained in:
421
backend/templates/README.md
Normal file
421
backend/templates/README.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# ThrillWiki Template System
|
||||
|
||||
This document describes the template architecture, conventions, and best practices for ThrillWiki.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
templates/
|
||||
├── base/ # Base templates
|
||||
│ └── base.html # Root template all pages extend
|
||||
├── components/ # Reusable UI components
|
||||
│ ├── ui/ # UI primitives (button, card, toast)
|
||||
│ ├── modals/ # Modal components
|
||||
│ ├── pagination.html # Pagination (supports HTMX)
|
||||
│ ├── status_badge.html # Status badge (parks/rides)
|
||||
│ ├── stats_card.html # Statistics card
|
||||
│ └── history_panel.html # History/audit trail
|
||||
├── forms/ # Form-related templates
|
||||
│ ├── partials/ # Form field components
|
||||
│ │ ├── form_field.html
|
||||
│ │ ├── field_error.html
|
||||
│ │ └── field_success.html
|
||||
│ └── layouts/ # Form layout templates
|
||||
│ ├── stacked.html # Vertical layout
|
||||
│ ├── inline.html # Horizontal layout
|
||||
│ └── grid.html # Multi-column grid
|
||||
├── htmx/ # HTMX-specific templates
|
||||
│ ├── components/ # HTMX components
|
||||
│ └── README.md # HTMX documentation
|
||||
├── {app}/ # App-specific templates
|
||||
│ ├── {model}_list.html # List views
|
||||
│ ├── {model}_detail.html # Detail views
|
||||
│ ├── {model}_form.html # Form views
|
||||
│ └── partials/ # App-specific partials
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Template Inheritance
|
||||
|
||||
### Base Template
|
||||
|
||||
All pages extend `base/base.html`. Available blocks:
|
||||
|
||||
```django
|
||||
{% extends "base/base.html" %}
|
||||
|
||||
{# Page title (appears in <title> and meta tags) #}
|
||||
{% block title %}My Page - ThrillWiki{% endblock %}
|
||||
|
||||
{# Main page content #}
|
||||
{% block content %}
|
||||
<h1>Page Content</h1>
|
||||
{% endblock %}
|
||||
|
||||
{# Additional CSS/meta in <head> #}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="...">
|
||||
{% endblock %}
|
||||
|
||||
{# Additional JavaScript before </body> #}
|
||||
{% block extra_js %}
|
||||
<script src="..."></script>
|
||||
{% endblock %}
|
||||
|
||||
{# Additional body classes #}
|
||||
{% block body_class %}custom-page{% endblock %}
|
||||
|
||||
{# Additional main element classes #}
|
||||
{% block main_class %}no-padding{% endblock %}
|
||||
|
||||
{# Override navigation (defaults to enhanced_header.html) #}
|
||||
{% block navigation %}{% endblock %}
|
||||
|
||||
{# Override footer #}
|
||||
{% block footer %}{% endblock %}
|
||||
|
||||
{# Meta tags for SEO #}
|
||||
{% block meta_description %}Page description{% endblock %}
|
||||
{% block og_title %}Open Graph title{% endblock %}
|
||||
{% block og_description %}Open Graph description{% endblock %}
|
||||
```
|
||||
|
||||
### Inheritance Example
|
||||
|
||||
```django
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
{% load park_tags %}
|
||||
|
||||
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{% static 'css/park-detail.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h1>{{ park.name }}</h1>
|
||||
{% include "parks/partials/park_header_badge.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'js/park-map.js' %}"></script>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
## Component Usage
|
||||
|
||||
### Pagination
|
||||
|
||||
```django
|
||||
{# 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' %}
|
||||
|
||||
{# Small size #}
|
||||
{% include 'components/pagination.html' with page_obj=page_obj size='sm' %}
|
||||
```
|
||||
|
||||
### Status Badge
|
||||
|
||||
```django
|
||||
{# Basic badge #}
|
||||
{% include 'components/status_badge.html' with status=park.status %}
|
||||
|
||||
{# Interactive badge with HTMX refresh #}
|
||||
{% include 'components/status_badge.html' with
|
||||
status=park.status
|
||||
badge_id='park-header-badge'
|
||||
refresh_trigger='park-status-changed'
|
||||
scroll_target='park-status-section'
|
||||
can_edit=perms.parks.change_park
|
||||
%}
|
||||
```
|
||||
|
||||
### Stats Card
|
||||
|
||||
```django
|
||||
{# Basic stat #}
|
||||
{% include 'components/stats_card.html' with label='Total Rides' value=park.ride_count %}
|
||||
|
||||
{# 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 %}
|
||||
```
|
||||
|
||||
### History Panel
|
||||
|
||||
```django
|
||||
{# 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
|
||||
%}
|
||||
```
|
||||
|
||||
### Loading Indicator
|
||||
|
||||
```django
|
||||
{# Block indicator #}
|
||||
{% include 'htmx/components/loading_indicator.html' with id='loading' message='Loading...' %}
|
||||
|
||||
{# Inline indicator (in buttons) #}
|
||||
{% include 'htmx/components/loading_indicator.html' with id='btn-loading' inline=True size='sm' %}
|
||||
|
||||
{# Overlay indicator #}
|
||||
{% include 'htmx/components/loading_indicator.html' with id='overlay' mode='overlay' %}
|
||||
```
|
||||
|
||||
## Form Rendering
|
||||
|
||||
### Form Layouts
|
||||
|
||||
```django
|
||||
{# Stacked layout (default) #}
|
||||
{% include 'forms/layouts/stacked.html' with form=form %}
|
||||
|
||||
{# Inline/horizontal layout #}
|
||||
{% include 'forms/layouts/inline.html' with form=form %}
|
||||
|
||||
{# 2-column grid #}
|
||||
{% include 'forms/layouts/grid.html' with form=form cols=2 %}
|
||||
|
||||
{# With excluded fields #}
|
||||
{% include 'forms/layouts/stacked.html' with form=form exclude='password2' %}
|
||||
|
||||
{# Custom submit text #}
|
||||
{% include 'forms/layouts/stacked.html' with form=form submit_text='Save Changes' %}
|
||||
```
|
||||
|
||||
### Individual Fields
|
||||
|
||||
```django
|
||||
{# Standard field #}
|
||||
{% include 'forms/partials/form_field.html' with field=form.email %}
|
||||
|
||||
{# Field with custom label #}
|
||||
{% include 'forms/partials/form_field.html' with field=form.email label='Email Address' %}
|
||||
|
||||
{# Field with HTMX validation #}
|
||||
{% include 'forms/partials/form_field.html' with
|
||||
field=form.username
|
||||
hx_validate=True
|
||||
hx_validate_url='/api/validate/username/'
|
||||
%}
|
||||
```
|
||||
|
||||
## Template Tags
|
||||
|
||||
### Loading Order
|
||||
|
||||
Always load template tags in this order:
|
||||
|
||||
```django
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load park_tags %} {# App-specific #}
|
||||
{% load safe_html %} {# Sanitization #}
|
||||
{% load common_filters %} {# Utility filters #}
|
||||
{% load cache %} {# Caching (if used) #}
|
||||
```
|
||||
|
||||
### Available Filters
|
||||
|
||||
**common_filters:**
|
||||
```django
|
||||
{{ datetime|humanize_timedelta }} {# "2 hours ago" #}
|
||||
{{ text|truncate_smart:50 }} {# Truncate at word boundary #}
|
||||
{{ number|format_number }} {# "1,234,567" #}
|
||||
{{ number|format_compact }} {# "1.2K", "3.4M" #}
|
||||
{{ dict|get_item:"key" }} {# Safe dict access #}
|
||||
{{ count|pluralize_custom:"item,items" }}
|
||||
{{ field|add_class:"form-control" }}
|
||||
```
|
||||
|
||||
**safe_html:**
|
||||
```django
|
||||
{{ content|sanitize }} {# Full HTML sanitization #}
|
||||
{{ comment|sanitize_minimal }} {# Basic text only #}
|
||||
{{ text|strip_html }} {# Remove all HTML #}
|
||||
{{ data|json_safe }} {# Safe JSON for JS #}
|
||||
{% icon "check" class="w-4 h-4" %} {# SVG icon #}
|
||||
```
|
||||
|
||||
## Context Variables
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Single object | Lowercase model name | `park`, `ride`, `user` |
|
||||
| List/QuerySet | `{model}_list` | `park_list`, `ride_list` |
|
||||
| Paginated | `page_obj` | Django standard |
|
||||
| Single form | `form` | Standard |
|
||||
| Multiple forms | `{purpose}_form` | `login_form`, `signup_form` |
|
||||
|
||||
### Avoid
|
||||
|
||||
- Generic names: `object`, `item`, `obj`, `data`
|
||||
- Abbreviated names: `p`, `r`, `f`, `frm`
|
||||
|
||||
## HTMX Patterns
|
||||
|
||||
See `htmx/README.md` for detailed HTMX documentation.
|
||||
|
||||
### Quick Reference
|
||||
|
||||
```django
|
||||
{# Swap strategies #}
|
||||
hx-swap="innerHTML" {# Replace content inside #}
|
||||
hx-swap="outerHTML" {# Replace entire element #}
|
||||
hx-swap="beforeend" {# Append #}
|
||||
hx-swap="afterbegin" {# Prepend #}
|
||||
|
||||
{# Target naming #}
|
||||
hx-target="#park-123" {# Specific object #}
|
||||
hx-target="#results" {# Page section #}
|
||||
hx-target="this" {# Self #}
|
||||
|
||||
{# Event naming #}
|
||||
hx-trigger="park-status-changed from:body"
|
||||
hx-trigger="auth-changed from:body"
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
Use fragment caching for expensive template sections:
|
||||
|
||||
```django
|
||||
{% load cache %}
|
||||
|
||||
{# Cache navigation for 5 minutes per user #}
|
||||
{% cache 300 nav user.id %}
|
||||
{% include 'components/layout/enhanced_header.html' %}
|
||||
{% endcache %}
|
||||
|
||||
{# Cache stats with object version #}
|
||||
{% cache 600 park_stats park.id park.updated_at %}
|
||||
{% include 'parks/partials/park_stats.html' %}
|
||||
{% endcache %}
|
||||
```
|
||||
|
||||
### Cache Keys
|
||||
|
||||
Include in cache key:
|
||||
- User ID for personalized content
|
||||
- Object ID for object-specific content
|
||||
- `updated_at` for automatic invalidation
|
||||
|
||||
Do NOT cache:
|
||||
- User-specific actions (edit buttons)
|
||||
- Form CSRF tokens
|
||||
- Real-time data
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Single `<h1>` per page
|
||||
- [ ] Heading hierarchy (h1 → h2 → h3)
|
||||
- [ ] Labels for all form inputs
|
||||
- [ ] `aria-label` for icon-only buttons
|
||||
- [ ] `aria-describedby` for help text
|
||||
- [ ] `aria-invalid` for fields with errors
|
||||
- [ ] `role="alert"` for error messages
|
||||
- [ ] `aria-live` for dynamic content
|
||||
|
||||
### Skip Links
|
||||
|
||||
Base template includes skip link to main content:
|
||||
```html
|
||||
<a href="#main-content" class="sr-only focus:not-sr-only ...">
|
||||
Skip to main content
|
||||
</a>
|
||||
```
|
||||
|
||||
### Landmarks
|
||||
|
||||
```html
|
||||
<nav role="navigation" aria-label="Main navigation">
|
||||
<main role="main" aria-label="Main content">
|
||||
<footer role="contentinfo">
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### Safe HTML Rendering
|
||||
|
||||
```django
|
||||
{# User content - ALWAYS sanitize #}
|
||||
{{ user_description|sanitize }}
|
||||
|
||||
{# Comments - minimal formatting #}
|
||||
{{ comment_text|sanitize_minimal }}
|
||||
|
||||
{# Remove all HTML #}
|
||||
{{ raw_text|strip_html }}
|
||||
|
||||
{# NEVER use |safe for user content #}
|
||||
{{ user_input|safe }} {# DANGEROUS! #}
|
||||
```
|
||||
|
||||
### JSON in Templates
|
||||
|
||||
```django
|
||||
{# Safe JSON for JavaScript #}
|
||||
<script>
|
||||
const data = {{ python_dict|json_safe }};
|
||||
</script>
|
||||
```
|
||||
|
||||
## Component Documentation Template
|
||||
|
||||
Each component should have a header comment:
|
||||
|
||||
```django
|
||||
{% comment %}
|
||||
Component Name
|
||||
==============
|
||||
|
||||
Brief description of what the component does.
|
||||
|
||||
Purpose:
|
||||
Detailed explanation of the component's purpose.
|
||||
|
||||
Usage Examples:
|
||||
{% include 'components/example.html' with param='value' %}
|
||||
|
||||
Parameters:
|
||||
- param1: Description (required/optional, default: value)
|
||||
- param2: Description
|
||||
|
||||
Dependencies:
|
||||
- Alpine.js for interactivity
|
||||
- HTMX for dynamic updates
|
||||
|
||||
Security: Notes about sanitization, XSS prevention
|
||||
{% endcomment %}
|
||||
```
|
||||
|
||||
## File Naming
|
||||
|
||||
| Type | Pattern | Example |
|
||||
|------|---------|---------|
|
||||
| List view | `{model}_list.html` | `park_list.html` |
|
||||
| Detail view | `{model}_detail.html` | `park_detail.html` |
|
||||
| Form view | `{model}_form.html` | `park_form.html` |
|
||||
| Partial | `{model}_{purpose}.html` | `park_header_badge.html` |
|
||||
| Component | `{purpose}.html` | `pagination.html` |
|
||||
@@ -1,37 +1,112 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="csrf-token" content="{{ csrf_token }}" />
|
||||
<title>{% block title %}ThrillWiki{% endblock %}</title>
|
||||
{% load cache %}
|
||||
{# =============================================================================
|
||||
ThrillWiki Base Template
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
This is the root template that all pages extend. It provides:
|
||||
- HTML5 document structure with accessibility features
|
||||
- SEO meta tags and Open Graph/Twitter cards
|
||||
- CSS and JavaScript asset loading
|
||||
- Navigation header and footer
|
||||
- Django messages and toast notifications
|
||||
- HTMX and Alpine.js configuration
|
||||
|
||||
Available Blocks:
|
||||
----------------
|
||||
Content Blocks:
|
||||
- title: Page title (appears in <title> tag and meta)
|
||||
- content: Main page content
|
||||
- navigation: Navigation header (defaults to enhanced_header.html)
|
||||
- footer: Page footer
|
||||
|
||||
Meta Blocks:
|
||||
- meta_description: Page meta description for SEO
|
||||
- meta_keywords: Page meta keywords
|
||||
- og_type: Open Graph type (default: website)
|
||||
- og_title: Open Graph title (defaults to title block)
|
||||
- og_description: Open Graph description
|
||||
- og_image: Open Graph image URL
|
||||
- twitter_title: Twitter card title
|
||||
- twitter_description: Twitter card description
|
||||
|
||||
Customization Blocks:
|
||||
- extra_head: Additional CSS/meta tags in <head>
|
||||
- extra_js: Additional JavaScript before </body>
|
||||
- body_class: Additional classes for <body> tag
|
||||
- main_class: Additional classes for <main> tag
|
||||
|
||||
Usage Example:
|
||||
{% extends "base/base.html" %}
|
||||
{% block title %}My Page - ThrillWiki{% endblock %}
|
||||
{% block content %}
|
||||
<h1>My Page Content</h1>
|
||||
{% endblock %}
|
||||
============================================================================= #}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<meta name="description" content="{% block meta_description %}ThrillWiki - Your comprehensive guide to theme parks and roller coasters{% endblock %}">
|
||||
<meta name="keywords" content="{% block meta_keywords %}theme parks, roller coasters, rides, amusement parks{% endblock %}">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
|
||||
<meta property="og:url" content="{{ request.build_absolute_uri }}">
|
||||
<meta property="og:title" content="{% block og_title %}{% block title %}ThrillWiki{% endblock %}{% endblock %}">
|
||||
<meta property="og:description" content="{% block og_description %}ThrillWiki - Your comprehensive guide to theme parks and roller coasters{% endblock %}">
|
||||
<meta property="og:image" content="{% block og_image %}{% static 'images/og-default.jpg' %}{% endblock %}">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:title" content="{% block twitter_title %}ThrillWiki{% endblock %}">
|
||||
<meta property="twitter:description" content="{% block twitter_description %}ThrillWiki - Your comprehensive guide to theme parks and roller coasters{% endblock %}">
|
||||
|
||||
{# Use title block directly #}
|
||||
<title>{% block page_title %}{% block title %}ThrillWiki{% endblock %}{% endblock %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/x-icon" href="{% static 'favicon.ico' %}">
|
||||
|
||||
<!-- Fonts - Preconnect for performance -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Playfair+Display:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Font Awesome Icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
<!-- Prevent flash of wrong theme -->
|
||||
<script>
|
||||
let theme = localStorage.getItem("theme");
|
||||
if (!theme) {
|
||||
theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
localStorage.setItem("theme", theme);
|
||||
}
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
(function() {
|
||||
let theme = localStorage.getItem('theme');
|
||||
if (!theme) {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
|
||||
<!-- Design System CSS - Load in correct order -->
|
||||
<link href="{% static 'css/design-tokens.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'css/components.css' %}" rel="stylesheet">
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="{% static 'js/alpine.min.js' %}"></script>
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- Alpine.js Plugins -->
|
||||
<script defer src="https://unpkg.com/@alpinejs/intersect@3.x.x/dist/cdn.min.js"></script>
|
||||
<script defer src="https://unpkg.com/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Alpine.js Core -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Alpine.js Stores (must load before alpine:init) -->
|
||||
<script src="{% static 'js/stores/index.js' %}"></script>
|
||||
|
||||
<!-- Alpine.js Components -->
|
||||
<script src="{% static 'js/alpine-components.js' %}"></script>
|
||||
@@ -39,93 +114,104 @@
|
||||
<!-- Location Autocomplete -->
|
||||
<script src="{% static 'js/location-autocomplete.js' %}"></script>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'css/components.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'css/alerts.css' %}" rel="stylesheet" />
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
||||
/>
|
||||
|
||||
<style>
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-top: 0.5rem;
|
||||
width: 12rem;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
z-index: 50;
|
||||
overflow: hidden;
|
||||
}
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
.htmx-request .htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
.htmx-request.htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
/* Hide elements until Alpine.js is ready */
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* HTMX loading indicator styles */
|
||||
.htmx-indicator { display: none; }
|
||||
.htmx-request .htmx-indicator { display: inline-block; }
|
||||
.htmx-request.htmx-indicator { display: inline-block; }
|
||||
</style>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body
|
||||
class="flex flex-col min-h-screen text-gray-900 bg-gradient-to-br from-white via-blue-50 to-indigo-50 dark:from-gray-950 dark:via-indigo-950 dark:to-purple-950 dark:text-white"
|
||||
>
|
||||
<!-- Enhanced Header -->
|
||||
{% include 'components/layout/enhanced_header.html' %}
|
||||
</head>
|
||||
|
||||
<body class="flex flex-col min-h-screen font-sans antialiased bg-background text-foreground {% block body_class %}{% endblock %}"
|
||||
x-data
|
||||
x-init="$store.theme.init(); $store.auth.init()"
|
||||
:class="{ 'dark': $store.theme.isDark }">
|
||||
|
||||
<!-- Skip to main content link for accessibility -->
|
||||
<a href="#main-content"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 z-50 px-4 py-2 rounded-md bg-primary text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
Skip to main content
|
||||
</a>
|
||||
|
||||
<!-- HTMX CSRF Configuration -->
|
||||
<div hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' style="display: none;"></div>
|
||||
|
||||
<!-- Navigation Header -->
|
||||
{% block navigation %}
|
||||
{% include 'components/layout/enhanced_header.html' %}
|
||||
{% endblock navigation %}
|
||||
|
||||
<!-- Breadcrumb Navigation -->
|
||||
{% block breadcrumbs %}
|
||||
{% if breadcrumbs %}
|
||||
<div class="container px-4 mx-auto md:px-6 lg:px-8">
|
||||
{% include 'components/navigation/breadcrumbs.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock breadcrumbs %}
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% if messages %}
|
||||
<div class="fixed top-0 right-0 z-50 p-4 space-y-4">
|
||||
{% for message in messages %}
|
||||
<div
|
||||
class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="fixed top-4 right-4 z-50 space-y-2" role="alert" aria-live="polite">
|
||||
{% for message in messages %}
|
||||
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %} animate-slide-in"
|
||||
x-data="{ show: true }"
|
||||
x-show="show"
|
||||
x-init="setTimeout(() => show = false, 5000)"
|
||||
x-transition:leave="transition ease-in duration-300"
|
||||
x-transition:leave-start="opacity-100 transform translate-x-0"
|
||||
x-transition:leave-end="opacity-0 transform translate-x-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ message }}</span>
|
||||
<button type="button"
|
||||
@click="show = false"
|
||||
class="ml-auto opacity-70 hover:opacity-100 focus:outline-none"
|
||||
aria-label="Dismiss">
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container flex-grow px-6 py-8 mx-auto">
|
||||
{% block content %}{% endblock %}
|
||||
<main id="main-content" class="container flex-grow px-4 py-8 mx-auto md:px-6 lg:px-8 {% block main_class %}{% endblock %}" role="main" aria-label="Main content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer
|
||||
class="mt-auto border-t bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50"
|
||||
>
|
||||
<div class="container px-6 py-6 mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-gray-600 dark:text-gray-400">
|
||||
<p>© {% now "Y" %} ThrillWiki. All rights reserved.</p>
|
||||
</div>
|
||||
<div class="space-x-4">
|
||||
<a
|
||||
href="{% url 'terms' %}"
|
||||
class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary"
|
||||
>Terms</a
|
||||
>
|
||||
<a
|
||||
href="{% url 'privacy' %}"
|
||||
class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary"
|
||||
>Privacy</a
|
||||
>
|
||||
</div>
|
||||
{% block footer %}
|
||||
{# Cache footer for 1 hour - static content #}
|
||||
{% cache 3600 footer_content %}
|
||||
<footer class="mt-auto border-t bg-card/50 backdrop-blur-sm border-border" role="contentinfo">
|
||||
<div class="container px-4 py-6 mx-auto md:px-6 lg:px-8">
|
||||
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<p>© {% now "Y" %} ThrillWiki. All rights reserved.</p>
|
||||
</div>
|
||||
<nav class="flex items-center gap-4 text-sm" aria-label="Footer navigation">
|
||||
<a href="{% url 'terms' %}"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Terms
|
||||
</a>
|
||||
<a href="{% url 'privacy' %}"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Privacy
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
{% endcache %}
|
||||
{% endblock footer %}
|
||||
|
||||
<!-- Global Auth Modal -->
|
||||
{% include 'components/auth/auth-modal.html' %}
|
||||
@@ -133,30 +219,135 @@
|
||||
<!-- Global Toast Container -->
|
||||
{% include 'components/ui/toast-container.html' %}
|
||||
|
||||
<!-- Custom JavaScript -->
|
||||
<!-- Core JavaScript -->
|
||||
<script src="{% static 'js/main.js' %}"></script>
|
||||
<script src="{% static 'js/alerts.js' %}"></script>
|
||||
<script src="{% static 'js/fsm-transitions.js' %}"></script>
|
||||
|
||||
<!-- Handle HX-Trigger headers for toast notifications -->
|
||||
<!-- HTMX Configuration and Error Handling -->
|
||||
<script>
|
||||
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
|
||||
const triggerHeader = evt.detail.xhr.getResponseHeader('HX-Trigger');
|
||||
if (triggerHeader) {
|
||||
try {
|
||||
const triggers = JSON.parse(triggerHeader);
|
||||
if (triggers.showToast && Alpine && Alpine.store('toast')) {
|
||||
Alpine.store('toast')[triggers.showToast.type || 'info'](
|
||||
triggers.showToast.message,
|
||||
triggers.showToast.duration
|
||||
);
|
||||
/**
|
||||
* HTMX Configuration
|
||||
* ==================
|
||||
* This section configures HTMX behavior and error handling.
|
||||
*
|
||||
* Swap Strategies Used:
|
||||
* - innerHTML: Replace content inside container (default for lists)
|
||||
* - outerHTML: Replace entire element (status badges, rows)
|
||||
* - beforeend: Append items (infinite scroll)
|
||||
* - afterbegin: Prepend items (new items at top)
|
||||
*
|
||||
* Target Naming Conventions:
|
||||
* - #object-type-id: For specific objects (e.g., #park-123)
|
||||
* - #section-name: For page sections (e.g., #results, #filters)
|
||||
* - #modal-container: For modals
|
||||
* - this: For self-replacement
|
||||
*
|
||||
* Custom Event Naming:
|
||||
* - {model}-status-changed: Status updates (park-status-changed)
|
||||
* - auth-changed: Authentication state changes
|
||||
* - {model}-created: New item created
|
||||
* - {model}-updated: Item updated
|
||||
* - {model}-deleted: Item deleted
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Configure HTMX defaults
|
||||
htmx.config.globalViewTransitions = true;
|
||||
htmx.config.useTemplateFragments = true;
|
||||
htmx.config.timeout = 30000; // 30 second timeout
|
||||
htmx.config.historyCacheSize = 10;
|
||||
htmx.config.refreshOnHistoryMiss = true;
|
||||
|
||||
// Add loading states
|
||||
document.body.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
evt.target.classList.add('htmx-request');
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
evt.target.classList.remove('htmx-request');
|
||||
});
|
||||
|
||||
// Comprehensive HTMX error handling
|
||||
document.body.addEventListener('htmx:responseError', function(evt) {
|
||||
const xhr = evt.detail.xhr;
|
||||
const showToast = (type, message) => {
|
||||
if (Alpine && Alpine.store('toast')) {
|
||||
Alpine.store('toast')[type](message);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle different HTTP status codes
|
||||
if (xhr.status >= 500) {
|
||||
showToast('error', 'Server error. Please try again later.');
|
||||
console.error('HTMX Server Error:', xhr.status, xhr.statusText);
|
||||
} else if (xhr.status === 429) {
|
||||
showToast('warning', 'Too many requests. Please wait a moment.');
|
||||
} else if (xhr.status === 403) {
|
||||
showToast('error', 'You do not have permission to perform this action.');
|
||||
} else if (xhr.status === 401) {
|
||||
showToast('warning', 'Please log in to continue.');
|
||||
// Optionally trigger auth modal
|
||||
document.body.dispatchEvent(new CustomEvent('show-login'));
|
||||
} else if (xhr.status === 404) {
|
||||
showToast('error', 'Resource not found.');
|
||||
} else if (xhr.status === 422) {
|
||||
showToast('error', 'Validation error. Please check your input.');
|
||||
} else if (xhr.status === 0) {
|
||||
showToast('error', 'Network error. Please check your connection.');
|
||||
} else if (xhr.status >= 400) {
|
||||
showToast('error', 'Request failed. Please try again.');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle HTMX timeout
|
||||
document.body.addEventListener('htmx:timeout', function(evt) {
|
||||
if (Alpine && Alpine.store('toast')) {
|
||||
Alpine.store('toast').error('Request timed out. Please try again.');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle network errors (sendError)
|
||||
document.body.addEventListener('htmx:sendError', function(evt) {
|
||||
if (Alpine && Alpine.store('toast')) {
|
||||
Alpine.store('toast').error('Network error. Please check your connection and try again.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle HX-Trigger headers for toast notifications
|
||||
// Expected format: {"showToast": {"type": "success|error|warning|info", "message": "...", "duration": 5000}}
|
||||
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
|
||||
const triggerHeader = evt.detail.xhr.getResponseHeader('HX-Trigger');
|
||||
if (triggerHeader) {
|
||||
try {
|
||||
const triggers = JSON.parse(triggerHeader);
|
||||
if (triggers.showToast && Alpine && Alpine.store('toast')) {
|
||||
const { type = 'info', message, duration } = triggers.showToast;
|
||||
Alpine.store('toast')[type](message, duration);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parsing errors for non-JSON triggers (e.g., simple event names)
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parsing errors for non-JSON triggers
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Auth Context for Alpine.js -->
|
||||
<script>
|
||||
window.__AUTH_USER__ = {% if user.is_authenticated %}{
|
||||
id: {{ user.id }},
|
||||
username: "{{ user.username|escapejs }}",
|
||||
email: "{{ user.email|escapejs }}",
|
||||
avatar: "{{ user.profile.avatar.url|default:''|escapejs }}"
|
||||
}{% else %}null{% endif %};
|
||||
|
||||
window.__AUTH_PERMISSIONS__ = [
|
||||
{% for perm in perms %}
|
||||
"{{ perm }}",
|
||||
{% endfor %}
|
||||
];
|
||||
</script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
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>
|
||||
|
||||
225
backend/templates/forms/README.md
Normal file
225
backend/templates/forms/README.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Form Templates
|
||||
|
||||
This directory contains form-related templates for ThrillWiki.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
forms/
|
||||
├── partials/ # Individual form components
|
||||
│ ├── form_field.html # Complete form field
|
||||
│ ├── field_error.html # Error messages
|
||||
│ ├── field_success.html # Success indicator
|
||||
│ └── form_actions.html # Submit/cancel buttons
|
||||
├── layouts/ # Form layout templates
|
||||
│ ├── stacked.html # Vertical layout
|
||||
│ ├── inline.html # Horizontal layout
|
||||
│ └── grid.html # Multi-column grid
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Form Layouts
|
||||
|
||||
### Stacked Layout (Default)
|
||||
|
||||
Vertical layout with full-width fields:
|
||||
|
||||
```django
|
||||
{% include 'forms/layouts/stacked.html' with form=form %}
|
||||
|
||||
{# With options #}
|
||||
{% include 'forms/layouts/stacked.html' with
|
||||
form=form
|
||||
submit_text='Save Changes'
|
||||
show_cancel=True
|
||||
cancel_url='/parks/'
|
||||
%}
|
||||
```
|
||||
|
||||
### Inline Layout
|
||||
|
||||
Horizontal layout with labels beside inputs:
|
||||
|
||||
```django
|
||||
{% include 'forms/layouts/inline.html' with form=form %}
|
||||
|
||||
{# Custom label width #}
|
||||
{% include 'forms/layouts/inline.html' with form=form label_width='w-1/4' %}
|
||||
```
|
||||
|
||||
### Grid Layout
|
||||
|
||||
Multi-column responsive grid:
|
||||
|
||||
```django
|
||||
{# 2-column grid #}
|
||||
{% include 'forms/layouts/grid.html' with form=form cols=2 %}
|
||||
|
||||
{# 3-column grid #}
|
||||
{% include 'forms/layouts/grid.html' with form=form cols=3 %}
|
||||
|
||||
{# Full-width description field #}
|
||||
{% include 'forms/layouts/grid.html' with form=form cols=2 full_width='description' %}
|
||||
```
|
||||
|
||||
## Layout Parameters
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `form` | Django form object | Required |
|
||||
| `exclude` | Comma-separated fields to exclude | None |
|
||||
| `fields` | Comma-separated fields to include | All |
|
||||
| `show_help` | Show help text | True |
|
||||
| `show_required` | Show required indicator | True |
|
||||
| `submit_text` | Submit button text | 'Submit' |
|
||||
| `submit_class` | Submit button CSS class | 'btn-primary' |
|
||||
| `show_cancel` | Show cancel button | False |
|
||||
| `cancel_url` | URL for cancel link | None |
|
||||
| `cancel_text` | Cancel button text | 'Cancel' |
|
||||
| `show_actions` | Show action buttons | True |
|
||||
|
||||
## Individual Components
|
||||
|
||||
### Form Field
|
||||
|
||||
Complete field with label, input, help, and errors:
|
||||
|
||||
```django
|
||||
{% include 'forms/partials/form_field.html' with field=form.email %}
|
||||
|
||||
{# Custom label #}
|
||||
{% include 'forms/partials/form_field.html' with field=form.email label='Your Email' %}
|
||||
|
||||
{# Without label #}
|
||||
{% include 'forms/partials/form_field.html' with field=form.search show_label=False %}
|
||||
|
||||
{# Inline layout #}
|
||||
{% include 'forms/partials/form_field.html' with field=form.email layout='inline' %}
|
||||
|
||||
{# HTMX validation #}
|
||||
{% include 'forms/partials/form_field.html' with
|
||||
field=form.username
|
||||
hx_validate=True
|
||||
hx_validate_url='/api/validate/username/'
|
||||
%}
|
||||
```
|
||||
|
||||
### Field Error
|
||||
|
||||
Error message display:
|
||||
|
||||
```django
|
||||
{% include 'forms/partials/field_error.html' with errors=field.errors %}
|
||||
|
||||
{# Without icon #}
|
||||
{% include 'forms/partials/field_error.html' with errors=field.errors show_icon=False %}
|
||||
|
||||
{# Different size #}
|
||||
{% include 'forms/partials/field_error.html' with errors=field.errors size='md' %}
|
||||
```
|
||||
|
||||
### Field Success
|
||||
|
||||
Success indicator for validation:
|
||||
|
||||
```django
|
||||
{% include 'forms/partials/field_success.html' with message='Username available' %}
|
||||
|
||||
{# Just checkmark #}
|
||||
{% include 'forms/partials/field_success.html' %}
|
||||
```
|
||||
|
||||
### Form Actions
|
||||
|
||||
Submit and cancel buttons:
|
||||
|
||||
```django
|
||||
{% include 'forms/partials/form_actions.html' %}
|
||||
|
||||
{# With cancel #}
|
||||
{% include 'forms/partials/form_actions.html' with show_cancel=True cancel_url='/list/' %}
|
||||
|
||||
{# With loading state #}
|
||||
{% include 'forms/partials/form_actions.html' with show_loading=True %}
|
||||
|
||||
{# Left-aligned #}
|
||||
{% include 'forms/partials/form_actions.html' with align='left' %}
|
||||
|
||||
{# Custom icon #}
|
||||
{% include 'forms/partials/form_actions.html' with submit_icon='fas fa-check' %}
|
||||
```
|
||||
|
||||
## HTMX Integration
|
||||
|
||||
### Inline Validation
|
||||
|
||||
```django
|
||||
<form hx-post="/submit/" hx-target="#form-results">
|
||||
{% for field in form %}
|
||||
{% include 'forms/partials/form_field.html' with
|
||||
field=field
|
||||
hx_validate=True
|
||||
hx_validate_url='/validate/'
|
||||
%}
|
||||
{% endfor %}
|
||||
{% include 'forms/partials/form_actions.html' with show_loading=True %}
|
||||
</form>
|
||||
```
|
||||
|
||||
### Validation Endpoint Response
|
||||
|
||||
```django
|
||||
{# Success #}
|
||||
{% include 'forms/partials/field_success.html' with message='Valid!' %}
|
||||
|
||||
{# Error #}
|
||||
{% include 'forms/partials/field_error.html' with errors=errors %}
|
||||
```
|
||||
|
||||
## Custom Form Rendering
|
||||
|
||||
For complete control, use the components directly:
|
||||
|
||||
```django
|
||||
<form method="post" action="{% url 'submit' %}">
|
||||
{% csrf_token %}
|
||||
|
||||
{# Non-field errors #}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="mb-4 p-4 bg-red-50 rounded-lg" role="alert">
|
||||
{% for error in form.non_field_errors %}
|
||||
<p>{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Custom field layout #}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{% include 'forms/partials/form_field.html' with field=form.first_name %}
|
||||
{% include 'forms/partials/form_field.html' with field=form.last_name %}
|
||||
</div>
|
||||
|
||||
{% include 'forms/partials/form_field.html' with field=form.email %}
|
||||
|
||||
{% include 'forms/partials/form_actions.html' with submit_text='Create Account' %}
|
||||
</form>
|
||||
```
|
||||
|
||||
## CSS Classes
|
||||
|
||||
The form components use these CSS classes (defined in `components.css`):
|
||||
|
||||
- `.btn-primary` - Primary action button
|
||||
- `.btn-secondary` - Secondary action button
|
||||
- `.form-field` - Field wrapper
|
||||
- `.field-error` - Error message styling
|
||||
|
||||
## Accessibility
|
||||
|
||||
All form components include:
|
||||
|
||||
- Labels properly associated with inputs (`for`/`id`)
|
||||
- Required field indicators with screen reader text
|
||||
- Error messages with `role="alert"`
|
||||
- Help text linked via `aria-describedby`
|
||||
- Invalid state with `aria-invalid`
|
||||
106
backend/templates/forms/layouts/grid.html
Normal file
106
backend/templates/forms/layouts/grid.html
Normal file
@@ -0,0 +1,106 @@
|
||||
{% comment %}
|
||||
Grid Form Layout
|
||||
================
|
||||
|
||||
Renders form fields in a responsive grid layout.
|
||||
|
||||
Purpose:
|
||||
Provides a multi-column grid layout for forms with many fields.
|
||||
Responsive - adjusts columns based on screen size.
|
||||
|
||||
Usage Examples:
|
||||
2-column grid:
|
||||
{% include 'forms/layouts/grid.html' with form=form cols=2 %}
|
||||
|
||||
3-column grid:
|
||||
{% include 'forms/layouts/grid.html' with form=form cols=3 %}
|
||||
|
||||
With full-width fields:
|
||||
{% include 'forms/layouts/grid.html' with form=form cols=2 full_width='description,notes' %}
|
||||
|
||||
Parameters:
|
||||
Required:
|
||||
- form: Django form object
|
||||
|
||||
Optional:
|
||||
- cols: Number of columns (2 or 3, default: 2)
|
||||
- exclude: Comma-separated field names to exclude
|
||||
- fields: Comma-separated field names to include
|
||||
- full_width: Comma-separated field names that span full width
|
||||
- show_help: Show help text (default: True)
|
||||
- show_required: Show required indicator (default: True)
|
||||
- gap: Grid gap class (default: 'gap-4')
|
||||
- submit_text: Submit button text (default: 'Submit')
|
||||
- submit_class: Submit button CSS class
|
||||
- form_class: Additional CSS classes for form wrapper
|
||||
|
||||
Dependencies:
|
||||
- forms/partials/form_field.html
|
||||
- Tailwind CSS
|
||||
|
||||
Accessibility:
|
||||
- Responsive grid maintains logical order
|
||||
- Labels properly associated with inputs
|
||||
{% endcomment %}
|
||||
|
||||
{% load common_filters %}
|
||||
|
||||
{% with show_help=show_help|default:True show_required=show_required|default:True cols=cols|default:2 gap=gap|default:'gap-4' submit_text=submit_text|default:'Submit' %}
|
||||
|
||||
<div class="form-layout-grid {{ form_class }}">
|
||||
{# Non-field errors #}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="mb-4 p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
|
||||
role="alert">
|
||||
<ul class="text-sm text-red-700 dark:text-red-300 space-y-1">
|
||||
{% for error in form.non_field_errors %}
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="fas fa-exclamation-circle mt-0.5" aria-hidden="true"></i>
|
||||
{{ error }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Grid container #}
|
||||
<div class="grid {% if cols == 3 %}grid-cols-1 md:grid-cols-2 lg:grid-cols-3{% else %}grid-cols-1 md:grid-cols-2{% endif %} {{ gap }}">
|
||||
{% for field in form %}
|
||||
{% if field.is_hidden %}
|
||||
{{ field }}
|
||||
{% elif fields %}
|
||||
{% if field.name in fields %}
|
||||
<div class="{% if full_width and field.name in full_width %}{% if cols == 3 %}md:col-span-2 lg:col-span-3{% else %}md:col-span-2{% endif %}{% endif %}">
|
||||
{% include 'forms/partials/form_field.html' with field=field show_help=show_help required_indicator=show_required %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif exclude %}
|
||||
{% if field.name not in exclude %}
|
||||
<div class="{% if full_width and field.name in full_width %}{% if cols == 3 %}md:col-span-2 lg:col-span-3{% else %}md:col-span-2{% endif %}{% endif %}">
|
||||
{% include 'forms/partials/form_field.html' with field=field show_help=show_help required_indicator=show_required %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="{% if full_width and field.name in full_width %}{% if cols == 3 %}md:col-span-2 lg:col-span-3{% else %}md:col-span-2{% endif %}{% endif %}">
|
||||
{% include 'forms/partials/form_field.html' with field=field show_help=show_help required_indicator=show_required %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Form actions - full width #}
|
||||
{% if show_actions|default:True %}
|
||||
<div class="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{% if show_cancel and cancel_url %}
|
||||
<a href="{{ cancel_url }}" class="btn-secondary">
|
||||
{{ cancel_text|default:'Cancel' }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<button type="submit" class="{{ submit_class|default:'btn-primary' }}">
|
||||
{{ submit_text }}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
149
backend/templates/forms/layouts/inline.html
Normal file
149
backend/templates/forms/layouts/inline.html
Normal file
@@ -0,0 +1,149 @@
|
||||
{% comment %}
|
||||
Inline Form Layout
|
||||
==================
|
||||
|
||||
Renders form fields horizontally with labels inline with inputs.
|
||||
|
||||
Purpose:
|
||||
Provides an inline/horizontal form layout where labels appear
|
||||
to the left of inputs on larger screens.
|
||||
|
||||
Usage Examples:
|
||||
Basic inline form:
|
||||
{% include 'forms/layouts/inline.html' with form=form %}
|
||||
|
||||
Custom label width:
|
||||
{% include 'forms/layouts/inline.html' with form=form label_width='w-1/4' %}
|
||||
|
||||
Parameters:
|
||||
Required:
|
||||
- form: Django form object
|
||||
|
||||
Optional:
|
||||
- exclude: Comma-separated field names to exclude
|
||||
- fields: Comma-separated field names to include
|
||||
- label_width: Label column width class (default: 'w-1/3')
|
||||
- show_help: Show help text (default: True)
|
||||
- show_required: Show required indicator (default: True)
|
||||
- submit_text: Submit button text (default: 'Submit')
|
||||
- submit_class: Submit button CSS class (default: 'btn-primary')
|
||||
- show_cancel: Show cancel button (default: False)
|
||||
- cancel_url: URL for cancel button
|
||||
- form_class: Additional CSS classes for form wrapper
|
||||
|
||||
Dependencies:
|
||||
- forms/partials/form_field.html
|
||||
- Tailwind CSS
|
||||
|
||||
Accessibility:
|
||||
- Labels properly associated with inputs
|
||||
- Responsive - stacks on mobile
|
||||
{% endcomment %}
|
||||
|
||||
{% load common_filters %}
|
||||
|
||||
{% with show_help=show_help|default:True show_required=show_required|default:True label_width=label_width|default:'w-1/3' submit_text=submit_text|default:'Submit' %}
|
||||
|
||||
<div class="form-layout-inline {{ form_class }}">
|
||||
{# Non-field errors #}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="mb-4 p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
|
||||
role="alert">
|
||||
<ul class="text-sm text-red-700 dark:text-red-300 space-y-1">
|
||||
{% for error in form.non_field_errors %}
|
||||
<li class="flex items-start gap-2">
|
||||
<i class="fas fa-exclamation-circle mt-0.5" aria-hidden="true"></i>
|
||||
{{ error }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Form fields - inline layout #}
|
||||
{% for field in form %}
|
||||
{% if field.is_hidden %}
|
||||
{{ field }}
|
||||
{% elif fields %}
|
||||
{% if field.name in fields %}
|
||||
<div class="mb-4 sm:flex sm:items-start">
|
||||
<label for="{{ field.id_for_label }}"
|
||||
class="block mb-1 sm:mb-0 sm:{{ label_width }} sm:pt-2 sm:pr-4 sm:text-right text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ field.label }}
|
||||
{% if show_required and field.field.required %}
|
||||
<span class="text-red-500" aria-hidden="true">*</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
{{ field|add_class:'w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white' }}
|
||||
{% if field.errors %}
|
||||
{% include 'forms/partials/field_error.html' with errors=field.errors %}
|
||||
{% endif %}
|
||||
{% if show_help and field.help_text %}
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ field.help_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif exclude %}
|
||||
{% if field.name not in exclude %}
|
||||
<div class="mb-4 sm:flex sm:items-start">
|
||||
<label for="{{ field.id_for_label }}"
|
||||
class="block mb-1 sm:mb-0 sm:{{ label_width }} sm:pt-2 sm:pr-4 sm:text-right text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ field.label }}
|
||||
{% if show_required and field.field.required %}
|
||||
<span class="text-red-500" aria-hidden="true">*</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
{{ field|add_class:'w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white' }}
|
||||
{% if field.errors %}
|
||||
{% include 'forms/partials/field_error.html' with errors=field.errors %}
|
||||
{% endif %}
|
||||
{% if show_help and field.help_text %}
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ field.help_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="mb-4 sm:flex sm:items-start">
|
||||
<label for="{{ field.id_for_label }}"
|
||||
class="block mb-1 sm:mb-0 sm:{{ label_width }} sm:pt-2 sm:pr-4 sm:text-right text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ field.label }}
|
||||
{% if show_required and field.field.required %}
|
||||
<span class="text-red-500" aria-hidden="true">*</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
<div class="flex-1">
|
||||
{{ field|add_class:'w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white' }}
|
||||
{% if field.errors %}
|
||||
{% include 'forms/partials/field_error.html' with errors=field.errors %}
|
||||
{% endif %}
|
||||
{% if show_help and field.help_text %}
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ field.help_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{# Form actions - aligned with inputs #}
|
||||
{% if show_actions|default:True %}
|
||||
<div class="sm:flex sm:items-center mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="sm:{{ label_width }}"></div>
|
||||
<div class="flex-1 flex items-center justify-start gap-3">
|
||||
<button type="submit" class="{{ submit_class|default:'btn-primary' }}">
|
||||
{{ submit_text }}
|
||||
</button>
|
||||
{% if show_cancel and cancel_url %}
|
||||
<a href="{{ cancel_url }}" class="btn-secondary">
|
||||
{{ cancel_text|default:'Cancel' }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
106
backend/templates/forms/layouts/stacked.html
Normal file
106
backend/templates/forms/layouts/stacked.html
Normal file
@@ -0,0 +1,106 @@
|
||||
{% comment %}
|
||||
Stacked Form Layout
|
||||
===================
|
||||
|
||||
Renders form fields in a vertical stacked layout (default form layout).
|
||||
|
||||
Purpose:
|
||||
Provides a standard vertical form layout where each field takes
|
||||
full width with labels above inputs.
|
||||
|
||||
Usage Examples:
|
||||
Basic form:
|
||||
{% include 'forms/layouts/stacked.html' with form=form %}
|
||||
|
||||
With fieldsets:
|
||||
{% include 'forms/layouts/stacked.html' with form=form show_fieldsets=True %}
|
||||
|
||||
Exclude fields:
|
||||
{% include 'forms/layouts/stacked.html' with form=form exclude='password2' %}
|
||||
|
||||
Parameters:
|
||||
Required:
|
||||
- form: Django form object
|
||||
|
||||
Optional:
|
||||
- exclude: Comma-separated field names to exclude
|
||||
- fields: Comma-separated field names to include (if set, only these are shown)
|
||||
- show_fieldsets: Group fields by fieldset (default: False)
|
||||
- show_help: Show help text (default: True)
|
||||
- show_required: Show required indicator (default: True)
|
||||
- submit_text: Submit button text (default: 'Submit')
|
||||
- submit_class: Submit button CSS class (default: 'btn-primary')
|
||||
- show_cancel: Show cancel button (default: False)
|
||||
- cancel_url: URL for cancel button
|
||||
- cancel_text: Cancel button text (default: 'Cancel')
|
||||
- form_class: Additional CSS classes for form wrapper
|
||||
|
||||
Dependencies:
|
||||
- forms/partials/form_field.html
|
||||
- forms/partials/field_error.html
|
||||
- Tailwind CSS
|
||||
|
||||
Accessibility:
|
||||
- Uses fieldset/legend for grouped fields
|
||||
- Labels properly associated with inputs
|
||||
- Error summary at top for screen readers
|
||||
{% endcomment %}
|
||||
|
||||
{% load common_filters %}
|
||||
|
||||
{% with show_help=show_help|default:True show_required=show_required|default:True submit_text=submit_text|default:'Submit' %}
|
||||
|
||||
<div class="form-layout-stacked {{ form_class }}">
|
||||
{# Non-field errors at top #}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="mb-4 p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
|
||||
role="alert">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="fas fa-exclamation-circle text-red-500 mt-0.5" aria-hidden="true"></i>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">Please correct the following errors:</h3>
|
||||
<ul class="mt-1 text-sm text-red-700 dark:text-red-300 list-disc list-inside">
|
||||
{% for error in form.non_field_errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Form fields #}
|
||||
{% for field in form %}
|
||||
{% if field.is_hidden %}
|
||||
{{ field }}
|
||||
{% elif fields %}
|
||||
{# Only show specified fields #}
|
||||
{% if field.name in fields %}
|
||||
{% include 'forms/partials/form_field.html' with field=field show_help=show_help required_indicator=show_required %}
|
||||
{% endif %}
|
||||
{% elif exclude %}
|
||||
{# Exclude specified fields #}
|
||||
{% if field.name not in exclude %}
|
||||
{% include 'forms/partials/form_field.html' with field=field show_help=show_help required_indicator=show_required %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% include 'forms/partials/form_field.html' with field=field show_help=show_help required_indicator=show_required %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{# Form actions #}
|
||||
{% if show_actions|default:True %}
|
||||
<div class="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{% if show_cancel and cancel_url %}
|
||||
<a href="{{ cancel_url }}" class="btn-secondary {{ cancel_class }}">
|
||||
{{ cancel_text|default:'Cancel' }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<button type="submit" class="{{ submit_class|default:'btn-primary' }}">
|
||||
{{ submit_text }}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
@@ -1,7 +1,57 @@
|
||||
{% comment %}
|
||||
Field Error Component
|
||||
=====================
|
||||
|
||||
Displays error messages for a form field with icon and proper accessibility.
|
||||
|
||||
Purpose:
|
||||
Renders error messages in a consistent, accessible format with visual
|
||||
indicators. Used within form_field.html or standalone.
|
||||
|
||||
Usage Examples:
|
||||
Within form_field.html (automatic):
|
||||
{% include 'forms/partials/field_error.html' with errors=field.errors %}
|
||||
|
||||
Standalone:
|
||||
{% include 'forms/partials/field_error.html' with errors=form.email.errors %}
|
||||
|
||||
Non-field errors:
|
||||
{% include 'forms/partials/field_error.html' with errors=form.non_field_errors %}
|
||||
|
||||
Parameters:
|
||||
Required:
|
||||
- errors: List of error messages (typically field.errors)
|
||||
|
||||
Optional:
|
||||
- show_icon: Show error icon (default: True)
|
||||
- animate: Add entrance animation (default: True)
|
||||
- size: 'sm', 'md', 'lg' (default: 'sm')
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS for styling
|
||||
- Font Awesome icons
|
||||
|
||||
Accessibility:
|
||||
- Uses role="alert" for immediate screen reader announcement
|
||||
- aria-live="assertive" for dynamic error display
|
||||
- Error icon is decorative (aria-hidden)
|
||||
{% endcomment %}
|
||||
|
||||
{% with show_icon=show_icon|default:True animate=animate|default:True size=size|default:'sm' %}
|
||||
|
||||
{% if errors %}
|
||||
<ul class="field-errors">
|
||||
{% for e in errors %}
|
||||
<li>{{ e }}</li>
|
||||
<ul class="{% if size == 'lg' %}text-base{% elif size == 'md' %}text-sm{% else %}text-xs{% endif %} text-red-600 dark:text-red-400 space-y-1 {% if animate %}animate-slide-down{% endif %}"
|
||||
role="alert"
|
||||
aria-live="assertive">
|
||||
{% for error in errors %}
|
||||
<li class="flex items-start gap-1.5">
|
||||
{% if show_icon %}
|
||||
<i class="fas fa-exclamation-circle mt-0.5 flex-shrink-0" aria-hidden="true"></i>
|
||||
{% endif %}
|
||||
<span>{{ error }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endwith %}
|
||||
|
||||
@@ -1 +1,48 @@
|
||||
<div class="field-success" aria-hidden="true">✓</div>
|
||||
{% comment %}
|
||||
Field Success Component
|
||||
=======================
|
||||
|
||||
Displays success message for a validated form field with icon.
|
||||
|
||||
Purpose:
|
||||
Renders a success indicator when a field passes validation.
|
||||
Typically used with HTMX inline validation.
|
||||
|
||||
Usage Examples:
|
||||
After successful validation:
|
||||
{% include 'forms/partials/field_success.html' with message='Username is available' %}
|
||||
|
||||
Simple checkmark (no message):
|
||||
{% include 'forms/partials/field_success.html' %}
|
||||
|
||||
Parameters:
|
||||
Optional:
|
||||
- message: Success message text (if empty, shows only icon)
|
||||
- show_icon: Show success icon (default: True)
|
||||
- animate: Add entrance animation (default: True)
|
||||
- size: 'sm', 'md', 'lg' (default: 'sm')
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS for styling
|
||||
- Font Awesome icons
|
||||
|
||||
Accessibility:
|
||||
- Uses role="status" for polite screen reader announcement
|
||||
- aria-live="polite" for non-urgent updates
|
||||
- Success icon is decorative (aria-hidden)
|
||||
{% endcomment %}
|
||||
|
||||
{% with show_icon=show_icon|default:True animate=animate|default:True size=size|default:'sm' %}
|
||||
|
||||
<div class="{% if size == 'lg' %}text-base{% elif size == 'md' %}text-sm{% else %}text-xs{% endif %} text-green-600 dark:text-green-400 flex items-center gap-1.5 {% if animate %}animate-slide-down{% endif %}"
|
||||
role="status"
|
||||
aria-live="polite">
|
||||
{% if show_icon %}
|
||||
<i class="fas fa-check-circle flex-shrink-0" aria-hidden="true"></i>
|
||||
{% endif %}
|
||||
{% if message %}
|
||||
<span>{{ message }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
|
||||
@@ -1,4 +1,135 @@
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
<button type="button" class="btn-secondary" hx-trigger="click" hx-swap="none">Cancel</button>
|
||||
{% comment %}
|
||||
Form Actions Component
|
||||
======================
|
||||
|
||||
Renders form submit and cancel buttons with consistent styling.
|
||||
|
||||
Purpose:
|
||||
Provides a standardized form actions section with submit button,
|
||||
optional cancel button, and loading state support.
|
||||
|
||||
Usage Examples:
|
||||
Basic submit:
|
||||
{% include 'forms/partials/form_actions.html' %}
|
||||
|
||||
With cancel:
|
||||
{% include 'forms/partials/form_actions.html' with show_cancel=True cancel_url='/list/' %}
|
||||
|
||||
Custom text:
|
||||
{% include 'forms/partials/form_actions.html' with submit_text='Save Changes' %}
|
||||
|
||||
With loading state:
|
||||
{% include 'forms/partials/form_actions.html' with show_loading=True loading_id='submit-loading' %}
|
||||
|
||||
Right-aligned (default):
|
||||
{% include 'forms/partials/form_actions.html' %}
|
||||
|
||||
Left-aligned:
|
||||
{% include 'forms/partials/form_actions.html' with align='left' %}
|
||||
|
||||
HTMX form:
|
||||
{% include 'forms/partials/form_actions.html' with hx_disable='true' %}
|
||||
|
||||
Parameters:
|
||||
Optional (submit):
|
||||
- submit_text: Submit button text (default: 'Save')
|
||||
- submit_class: CSS class (default: 'btn-primary')
|
||||
- submit_disabled: Disable submit button (default: False)
|
||||
- submit_icon: Icon class for submit button (e.g., 'fas fa-check')
|
||||
|
||||
Optional (cancel):
|
||||
- show_cancel: Show cancel button (default: False)
|
||||
- cancel_url: URL for cancel link
|
||||
- cancel_text: Cancel button text (default: 'Cancel')
|
||||
- cancel_class: CSS class (default: 'btn-secondary')
|
||||
|
||||
Optional (loading):
|
||||
- show_loading: Show loading indicator on submit (default: False)
|
||||
- loading_id: ID for htmx-indicator (default: 'submit-loading')
|
||||
|
||||
Optional (layout):
|
||||
- align: 'left', 'right', 'center', 'between' (default: 'right')
|
||||
- show_border: Show top border (default: True)
|
||||
|
||||
Optional (HTMX):
|
||||
- hx_disable: Add hx-disable on submit (default: False)
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS
|
||||
- HTMX (optional, for loading states)
|
||||
- Font Awesome (optional, for icons)
|
||||
|
||||
Accessibility:
|
||||
- Submit button is properly typed
|
||||
- Loading state announced to screen readers
|
||||
{% endcomment %}
|
||||
|
||||
{% with submit_text=submit_text|default:'Save' cancel_text=cancel_text|default:'Cancel' align=align|default:'right' show_border=show_border|default:True %}
|
||||
|
||||
<div class="form-actions flex items-center gap-3 mt-6
|
||||
{% if show_border %}pt-4 border-t border-gray-200 dark:border-gray-700{% endif %}
|
||||
{% if align == 'left' %}justify-start
|
||||
{% elif align == 'center' %}justify-center
|
||||
{% elif align == 'between' %}justify-between
|
||||
{% else %}justify-end{% endif %}">
|
||||
|
||||
{# Cancel button (left side for 'between' alignment) #}
|
||||
{% if show_cancel and align == 'between' %}
|
||||
{% if cancel_url %}
|
||||
<a href="{{ cancel_url }}"
|
||||
class="{{ cancel_class|default:'btn-secondary' }}">
|
||||
{{ cancel_text }}
|
||||
</a>
|
||||
{% else %}
|
||||
<button type="button"
|
||||
class="{{ cancel_class|default:'btn-secondary' }}"
|
||||
onclick="history.back()">
|
||||
{{ cancel_text }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Button group #}
|
||||
<div class="flex items-center gap-3">
|
||||
{# Cancel button (for non-between alignment) #}
|
||||
{% if show_cancel and align != 'between' %}
|
||||
{% if cancel_url %}
|
||||
<a href="{{ cancel_url }}"
|
||||
class="{{ cancel_class|default:'btn-secondary' }}">
|
||||
{{ cancel_text }}
|
||||
</a>
|
||||
{% else %}
|
||||
<button type="button"
|
||||
class="{{ cancel_class|default:'btn-secondary' }}"
|
||||
onclick="history.back()">
|
||||
{{ cancel_text }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Submit button #}
|
||||
<button type="submit"
|
||||
class="{{ submit_class|default:'btn-primary' }} relative"
|
||||
{% if submit_disabled %}disabled{% endif %}
|
||||
{% if hx_disable %}hx-disable{% endif %}>
|
||||
{# Icon (optional) #}
|
||||
{% if submit_icon %}
|
||||
<i class="{{ submit_icon }} mr-2" aria-hidden="true"></i>
|
||||
{% endif %}
|
||||
|
||||
{# Text #}
|
||||
<span>{{ submit_text }}</span>
|
||||
|
||||
{# Loading indicator (optional) #}
|
||||
{% if show_loading %}
|
||||
<span id="{{ loading_id|default:'submit-loading' }}"
|
||||
class="htmx-indicator ml-2">
|
||||
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
<span class="sr-only">Submitting...</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
|
||||
@@ -1,5 +1,198 @@
|
||||
<div class="form-field" data-field-name="{{ field.name }}">
|
||||
<label for="id_{{ field.name }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
<div class="field-feedback" aria-live="polite">{% include "forms/partials/field_error.html" %}</div>
|
||||
{% comment %}
|
||||
Form Field Component
|
||||
====================
|
||||
|
||||
A comprehensive form field component with label, input, help text, and error handling.
|
||||
|
||||
Purpose:
|
||||
Renders a complete form field with consistent styling, accessibility attributes,
|
||||
and optional HTMX validation support.
|
||||
|
||||
Usage Examples:
|
||||
Basic field:
|
||||
{% include 'forms/partials/form_field.html' with field=form.email %}
|
||||
|
||||
Field with custom label:
|
||||
{% include 'forms/partials/form_field.html' with field=form.email label='Email Address' %}
|
||||
|
||||
Field without label:
|
||||
{% include 'forms/partials/form_field.html' with field=form.hidden_field show_label=False %}
|
||||
|
||||
Field with HTMX validation:
|
||||
{% include 'forms/partials/form_field.html' with field=form.username hx_validate=True hx_validate_url='/api/validate-username/' %}
|
||||
|
||||
Inline field:
|
||||
{% include 'forms/partials/form_field.html' with field=form.email layout='inline' %}
|
||||
|
||||
With success state:
|
||||
{% include 'forms/partials/form_field.html' with field=form.email show_success=True %}
|
||||
|
||||
Parameters:
|
||||
Required:
|
||||
- field: Django form field object
|
||||
|
||||
Optional (labels):
|
||||
- label: Custom label text (default: field.label)
|
||||
- show_label: Show label (default: True)
|
||||
- label_class: Additional CSS classes for label
|
||||
|
||||
Optional (input):
|
||||
- input_class: Additional CSS classes for input
|
||||
- placeholder: Custom placeholder text
|
||||
- autofocus: Add autofocus attribute (default: False)
|
||||
- disabled: Disable input (default: False)
|
||||
- readonly: Make input readonly (default: False)
|
||||
|
||||
Optional (help/errors):
|
||||
- help_text: Custom help text (default: field.help_text)
|
||||
- show_help: Show help text (default: True)
|
||||
- show_errors: Show error messages (default: True)
|
||||
- show_success: Show success indicator when valid (default: False)
|
||||
|
||||
Optional (HTMX validation):
|
||||
- hx_validate: Enable HTMX inline validation (default: False)
|
||||
- hx_validate_url: URL for validation endpoint
|
||||
- hx_validate_trigger: Trigger event (default: 'blur changed delay:500ms')
|
||||
- hx_validate_target: Target for validation response (default: auto-generated)
|
||||
|
||||
Optional (layout):
|
||||
- layout: 'stacked' or 'inline' (default: 'stacked')
|
||||
- required_indicator: Show required indicator (default: True)
|
||||
- size: 'sm', 'md', 'lg' for input size (default: 'md')
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS for styling
|
||||
- HTMX (optional, for inline validation)
|
||||
- Alpine.js (optional, for enhanced interactions)
|
||||
- common_filters template tags (for add_class filter)
|
||||
|
||||
Accessibility:
|
||||
- Label properly associated with input via for/id
|
||||
- aria-describedby links help text and errors to input
|
||||
- aria-invalid set when field has errors
|
||||
- aria-required set for required fields
|
||||
- Error messages use role="alert" for screen reader announcement
|
||||
{% endcomment %}
|
||||
{% load common_filters %}
|
||||
|
||||
{% with show_label=show_label|default:True show_help=show_help|default:True show_errors=show_errors|default:True show_success=show_success|default:False required_indicator=required_indicator|default:True layout=layout|default:'stacked' size=size|default:'md' %}
|
||||
|
||||
<div class="form-field {% if layout == 'inline' %}flex items-center gap-4{% else %}mb-4{% endif %}"
|
||||
data-field-name="{{ field.name }}"
|
||||
data-field-required="{{ field.field.required|yesno:'true,false' }}"
|
||||
{% if hx_validate %}x-data="{ valid: null, validating: false, touched: false }"{% endif %}>
|
||||
|
||||
{# Label #}
|
||||
{% if show_label %}
|
||||
<label for="{{ field.id_for_label }}"
|
||||
class="{% if layout == 'inline' %}w-1/3 text-right{% else %}block mb-1.5{% endif %} {% if size == 'sm' %}text-xs{% elif size == 'lg' %}text-base{% else %}text-sm{% endif %} font-medium text-foreground {{ label_class }}">
|
||||
{{ label|default:field.label }}
|
||||
{% if required_indicator and field.field.required %}
|
||||
<span class="text-destructive ml-0.5" aria-hidden="true">*</span>
|
||||
<span class="sr-only">(required)</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
{# Input wrapper #}
|
||||
<div class="{% if layout == 'inline' and show_label %}flex-1{% else %}w-full{% endif %} relative">
|
||||
{# Field input with enhanced attributes #}
|
||||
{# Base widget classes for sizing, spacing, and transitions #}
|
||||
{% with base_class='w-full border rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-0 bg-background text-foreground placeholder:text-muted-foreground' %}
|
||||
{# Size classes #}
|
||||
{% with size_class=size|default:'md'|yesno:'px-2.5 py-1.5 text-xs,px-3 py-2 text-sm,px-4 py-2.5 text-base' %}
|
||||
{% if size == 'sm' %}{% with actual_size='px-2.5 py-1.5 text-xs' %}
|
||||
{# Error-specific classes override border and focus ring colors #}
|
||||
{% with error_class='border-destructive focus:ring-destructive/30 focus:border-destructive' %}
|
||||
{# Success-specific classes #}
|
||||
{% with success_class='border-green-500 focus:ring-green-500/30 focus:border-green-500' %}
|
||||
{# Normal state classes for border and focus ring #}
|
||||
{% with normal_class='border-input focus:ring-ring/30 focus:border-ring' %}
|
||||
|
||||
{# Render the field with base classes plus state-specific classes #}
|
||||
{% if field.errors %}
|
||||
{{ field|add_class:base_class|add_class:actual_size|add_class:error_class|add_class:input_class }}
|
||||
{% else %}
|
||||
{{ field|add_class:base_class|add_class:actual_size|add_class:normal_class|add_class:input_class }}
|
||||
{% endif %}
|
||||
|
||||
{% endwith %}{% endwith %}{% endwith %}{% endwith %}
|
||||
{% elif size == 'lg' %}{% with actual_size='px-4 py-2.5 text-base' %}
|
||||
{% with error_class='border-destructive focus:ring-destructive/30 focus:border-destructive' %}
|
||||
{% with success_class='border-green-500 focus:ring-green-500/30 focus:border-green-500' %}
|
||||
{% with normal_class='border-input focus:ring-ring/30 focus:border-ring' %}
|
||||
{% if field.errors %}
|
||||
{{ field|add_class:base_class|add_class:actual_size|add_class:error_class|add_class:input_class }}
|
||||
{% else %}
|
||||
{{ field|add_class:base_class|add_class:actual_size|add_class:normal_class|add_class:input_class }}
|
||||
{% endif %}
|
||||
{% endwith %}{% endwith %}{% endwith %}{% endwith %}
|
||||
{% else %}{% with actual_size='px-3 py-2 text-sm' %}
|
||||
{% with error_class='border-destructive focus:ring-destructive/30 focus:border-destructive' %}
|
||||
{% with success_class='border-green-500 focus:ring-green-500/30 focus:border-green-500' %}
|
||||
{% with normal_class='border-input focus:ring-ring/30 focus:border-ring' %}
|
||||
{% if field.errors %}
|
||||
{{ field|add_class:base_class|add_class:actual_size|add_class:error_class|add_class:input_class }}
|
||||
{% else %}
|
||||
{{ field|add_class:base_class|add_class:actual_size|add_class:normal_class|add_class:input_class }}
|
||||
{% endif %}
|
||||
{% endwith %}{% endwith %}{% endwith %}{% endwith %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
|
||||
{# HTMX validation attributes (when enabled) #}
|
||||
{% if hx_validate and hx_validate_url %}
|
||||
<script>
|
||||
(function() {
|
||||
var el = document.getElementById('{{ field.id_for_label }}');
|
||||
if (el) {
|
||||
el.setAttribute('hx-post', '{{ hx_validate_url }}');
|
||||
el.setAttribute('hx-trigger', '{{ hx_validate_trigger|default:"blur changed delay:500ms" }}');
|
||||
el.setAttribute('hx-target', '#{{ field.name }}-feedback');
|
||||
el.setAttribute('hx-swap', 'innerHTML');
|
||||
el.setAttribute('hx-indicator', '#{{ field.name }}-indicator');
|
||||
el.setAttribute('hx-include', '[name="{{ field.name }}"]');
|
||||
if (typeof htmx !== 'undefined') htmx.process(el);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{# Validation indicator (for HTMX) - positioned inside input #}
|
||||
{% if hx_validate %}
|
||||
<div id="{{ field.name }}-indicator" class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<i class="fas fa-spinner fa-spin text-muted-foreground" aria-hidden="true"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Success indicator (shown when valid and no errors) #}
|
||||
{% if show_success and not field.errors and field.value %}
|
||||
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<i class="fas fa-check-circle text-green-500" aria-hidden="true"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Feedback area (errors, success, help) #}
|
||||
<div id="{{ field.name }}-feedback"
|
||||
class="field-feedback mt-1.5"
|
||||
aria-live="polite"
|
||||
aria-atomic="true">
|
||||
|
||||
{# Error messages #}
|
||||
{% if show_errors and field.errors %}
|
||||
{% include 'forms/partials/field_error.html' with errors=field.errors %}
|
||||
{% endif %}
|
||||
|
||||
{# Help text #}
|
||||
{% if show_help and field.help_text %}
|
||||
<p id="{{ field.name }}-help"
|
||||
class="{% if size == 'sm' %}text-xs{% elif size == 'lg' %}text-sm{% else %}text-xs{% endif %} text-muted-foreground {% if field.errors %}mt-1{% endif %}">
|
||||
{{ help_text|default:field.help_text }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
|
||||
194
backend/templates/forms/partials/form_submission_feedback.html
Normal file
194
backend/templates/forms/partials/form_submission_feedback.html
Normal file
@@ -0,0 +1,194 @@
|
||||
{% comment %}
|
||||
Form Submission Feedback Component
|
||||
==================================
|
||||
|
||||
Displays feedback after form submission with various states.
|
||||
|
||||
Purpose:
|
||||
Provides consistent feedback UI for form submission results including
|
||||
success confirmations, error summaries, and loading states.
|
||||
|
||||
Usage Examples:
|
||||
Success state:
|
||||
{% include 'forms/partials/form_submission_feedback.html' with state='success' message='Park saved successfully!' %}
|
||||
|
||||
Error state:
|
||||
{% include 'forms/partials/form_submission_feedback.html' with state='error' message='Please fix the errors below' %}
|
||||
|
||||
Loading state:
|
||||
{% include 'forms/partials/form_submission_feedback.html' with state='loading' message='Saving...' %}
|
||||
|
||||
With redirect countdown:
|
||||
{% include 'forms/partials/form_submission_feedback.html' with state='success' message='Saved!' redirect_url='/parks/' redirect_seconds=3 %}
|
||||
|
||||
Parameters:
|
||||
Required:
|
||||
- state: 'success', 'error', 'warning', 'loading' (default: 'success')
|
||||
- message: Main feedback message
|
||||
|
||||
Optional:
|
||||
- title: Optional title text
|
||||
- show_icon: Show status icon (default: True)
|
||||
- show_actions: Show action buttons (default: True)
|
||||
- primary_action_text: Primary button text (default: varies by state)
|
||||
- primary_action_url: Primary button URL
|
||||
- secondary_action_text: Secondary button text
|
||||
- secondary_action_url: Secondary button URL
|
||||
- redirect_url: URL to redirect to after countdown
|
||||
- redirect_seconds: Seconds before redirect (default: 3)
|
||||
- errors: List of error messages (for error state)
|
||||
- animate: Enable animations (default: True)
|
||||
|
||||
Dependencies:
|
||||
- Tailwind CSS for styling
|
||||
- Alpine.js for countdown functionality
|
||||
- Font Awesome icons
|
||||
|
||||
Accessibility:
|
||||
- Uses appropriate ARIA roles based on state
|
||||
- Announces changes to screen readers
|
||||
{% endcomment %}
|
||||
|
||||
{% with state=state|default:'success' show_icon=show_icon|default:True show_actions=show_actions|default:True animate=animate|default:True redirect_seconds=redirect_seconds|default:3 %}
|
||||
|
||||
<div class="form-submission-feedback rounded-lg p-4 {% if animate %}animate-fade-in{% endif %}
|
||||
{% if state == 'success' %}bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800
|
||||
{% elif state == 'error' %}bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800
|
||||
{% elif state == 'warning' %}bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800
|
||||
{% elif state == 'loading' %}bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800
|
||||
{% endif %}"
|
||||
role="{% if state == 'error' %}alert{% else %}status{% endif %}"
|
||||
aria-live="{% if state == 'error' %}assertive{% else %}polite{% endif %}"
|
||||
{% if redirect_url %}
|
||||
x-data="{ countdown: {{ redirect_seconds }} }"
|
||||
x-init="setInterval(() => { if(countdown > 0) countdown--; else window.location.href = '{{ redirect_url }}'; }, 1000)"
|
||||
{% endif %}>
|
||||
|
||||
<div class="flex {% if title or errors %}flex-col gap-3{% else %}items-center gap-3{% endif %}">
|
||||
{# Icon #}
|
||||
{% if show_icon %}
|
||||
<div class="flex-shrink-0 {% if title or errors %}flex items-center gap-3{% endif %}">
|
||||
{% if state == 'success' %}
|
||||
<div class="w-10 h-10 rounded-full bg-green-100 dark:bg-green-800/30 flex items-center justify-center">
|
||||
<i class="fas fa-check text-green-600 dark:text-green-400 text-lg" aria-hidden="true"></i>
|
||||
</div>
|
||||
{% elif state == 'error' %}
|
||||
<div class="w-10 h-10 rounded-full bg-red-100 dark:bg-red-800/30 flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-400 text-lg" aria-hidden="true"></i>
|
||||
</div>
|
||||
{% elif state == 'warning' %}
|
||||
<div class="w-10 h-10 rounded-full bg-yellow-100 dark:bg-yellow-800/30 flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600 dark:text-yellow-400 text-lg" aria-hidden="true"></i>
|
||||
</div>
|
||||
{% elif state == 'loading' %}
|
||||
<div class="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-800/30 flex items-center justify-center">
|
||||
<i class="fas fa-spinner fa-spin text-blue-600 dark:text-blue-400 text-lg" aria-hidden="true"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Title (if provided) #}
|
||||
{% if title %}
|
||||
<h3 class="font-semibold
|
||||
{% if state == 'success' %}text-green-800 dark:text-green-200
|
||||
{% elif state == 'error' %}text-red-800 dark:text-red-200
|
||||
{% elif state == 'warning' %}text-yellow-800 dark:text-yellow-200
|
||||
{% elif state == 'loading' %}text-blue-800 dark:text-blue-200
|
||||
{% endif %}">
|
||||
{{ title }}
|
||||
</h3>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Content #}
|
||||
<div class="flex-1 min-w-0">
|
||||
{# Main message #}
|
||||
<p class="text-sm
|
||||
{% if state == 'success' %}text-green-700 dark:text-green-300
|
||||
{% elif state == 'error' %}text-red-700 dark:text-red-300
|
||||
{% elif state == 'warning' %}text-yellow-700 dark:text-yellow-300
|
||||
{% elif state == 'loading' %}text-blue-700 dark:text-blue-300
|
||||
{% endif %}">
|
||||
{{ message }}
|
||||
</p>
|
||||
|
||||
{# Error list (for error state) #}
|
||||
{% if state == 'error' and errors %}
|
||||
<ul class="mt-2 text-sm text-red-600 dark:text-red-400 space-y-1 list-disc list-inside">
|
||||
{% for error in errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{# Redirect countdown #}
|
||||
{% if redirect_url %}
|
||||
<p class="mt-2 text-xs
|
||||
{% if state == 'success' %}text-green-600 dark:text-green-400
|
||||
{% else %}text-muted-foreground
|
||||
{% endif %}">
|
||||
Redirecting in <span x-text="countdown"></span> seconds...
|
||||
<a href="{{ redirect_url }}" class="underline hover:no-underline ml-1">Go now</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
{% if show_actions and state != 'loading' %}
|
||||
<div class="flex-shrink-0 flex items-center gap-2 {% if title or errors %}mt-2{% endif %}">
|
||||
{% if state == 'error' %}
|
||||
{# Error state actions #}
|
||||
{% if secondary_action_url %}
|
||||
<a href="{{ secondary_action_url }}"
|
||||
class="btn btn-sm btn-ghost text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-800/30">
|
||||
{{ secondary_action_text|default:'Cancel' }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if primary_action_url %}
|
||||
<a href="{{ primary_action_url }}"
|
||||
class="btn btn-sm bg-red-600 hover:bg-red-700 text-white">
|
||||
{{ primary_action_text|default:'Try Again' }}
|
||||
</a>
|
||||
{% else %}
|
||||
<button type="submit"
|
||||
class="btn btn-sm bg-red-600 hover:bg-red-700 text-white">
|
||||
{{ primary_action_text|default:'Try Again' }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% elif state == 'success' %}
|
||||
{# Success state actions #}
|
||||
{% if secondary_action_url %}
|
||||
<a href="{{ secondary_action_url }}"
|
||||
class="btn btn-sm btn-ghost text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-800/30">
|
||||
{{ secondary_action_text|default:'Add Another' }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if primary_action_url %}
|
||||
<a href="{{ primary_action_url }}"
|
||||
class="btn btn-sm bg-green-600 hover:bg-green-700 text-white">
|
||||
{{ primary_action_text|default:'View' }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% elif state == 'warning' %}
|
||||
{# Warning state actions #}
|
||||
{% if secondary_action_url %}
|
||||
<a href="{{ secondary_action_url }}"
|
||||
class="btn btn-sm btn-ghost">
|
||||
{{ secondary_action_text|default:'Cancel' }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if primary_action_url %}
|
||||
<a href="{{ primary_action_url }}"
|
||||
class="btn btn-sm bg-yellow-600 hover:bg-yellow-700 text-white">
|
||||
{{ primary_action_text|default:'Continue' }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
273
backend/templates/htmx/README.md
Normal file
273
backend/templates/htmx/README.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# HTMX Templates and Patterns
|
||||
|
||||
This directory contains HTMX-specific templates and components for ThrillWiki.
|
||||
|
||||
## Overview
|
||||
|
||||
HTMX is used throughout ThrillWiki for dynamic content updates without full page reloads. This guide documents the standardized patterns and conventions.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
htmx/
|
||||
├── components/ # Reusable HTMX components
|
||||
│ ├── confirm_dialog.html # Confirmation modal
|
||||
│ ├── error_message.html # Error display
|
||||
│ ├── filter_badge.html # Filter tag/badge
|
||||
│ ├── inline_edit_field.html # Inline editing
|
||||
│ ├── loading_indicator.html # Loading spinners
|
||||
│ └── success_toast.html # Success notification
|
||||
├── partials/ # HTMX response partials
|
||||
└── README.md # This documentation
|
||||
```
|
||||
|
||||
## Swap Strategies
|
||||
|
||||
Use consistent swap strategies across the application:
|
||||
|
||||
| Strategy | Use Case | Example |
|
||||
|----------|----------|---------|
|
||||
| `innerHTML` | Replace content inside container | List updates, search results |
|
||||
| `outerHTML` | Replace entire element | Status badges, table rows |
|
||||
| `beforeend` | Append items | Infinite scroll, new items |
|
||||
| `afterbegin` | Prepend items | New items at top of list |
|
||||
|
||||
### Examples
|
||||
|
||||
```html
|
||||
<!-- Replace content inside container -->
|
||||
<div id="results"
|
||||
hx-get="/api/search"
|
||||
hx-swap="innerHTML">
|
||||
|
||||
<!-- Replace entire element (e.g., status badge) -->
|
||||
<span id="park-status"
|
||||
hx-get="/api/park/status"
|
||||
hx-swap="outerHTML">
|
||||
|
||||
<!-- Infinite scroll - append items -->
|
||||
<div id="item-list"
|
||||
hx-get="/api/items?page=2"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="beforeend">
|
||||
```
|
||||
|
||||
## Target Naming Conventions
|
||||
|
||||
Follow these naming patterns for `hx-target`:
|
||||
|
||||
| Pattern | Use Case | Example |
|
||||
|---------|----------|---------|
|
||||
| `#object-type-id` | Specific objects | `#park-123`, `#ride-456` |
|
||||
| `#section-name` | Page sections | `#results`, `#filters`, `#stats` |
|
||||
| `#modal-container` | Modal content | `#modal-container` |
|
||||
| `this` | Self-replacement | Status badges, inline edits |
|
||||
|
||||
### Examples
|
||||
|
||||
```html
|
||||
<!-- Target specific object -->
|
||||
<button hx-post="/api/parks/123/status"
|
||||
hx-target="#park-123"
|
||||
hx-swap="outerHTML">
|
||||
|
||||
<!-- Target page section -->
|
||||
<form hx-post="/api/search"
|
||||
hx-target="#results"
|
||||
hx-swap="innerHTML">
|
||||
|
||||
<!-- Self-replacement -->
|
||||
<span hx-get="/api/badge"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML">
|
||||
```
|
||||
|
||||
## Custom Event Naming
|
||||
|
||||
Use these conventions for custom HTMX events:
|
||||
|
||||
| Pattern | Description | Example |
|
||||
|---------|-------------|---------|
|
||||
| `{model}-status-changed` | Status updates | `park-status-changed`, `ride-status-changed` |
|
||||
| `{model}-created` | New item created | `park-created`, `review-created` |
|
||||
| `{model}-updated` | Item updated | `ride-updated`, `photo-updated` |
|
||||
| `{model}-deleted` | Item deleted | `comment-deleted` |
|
||||
| `auth-changed` | Auth state change | User login/logout |
|
||||
|
||||
### Triggering Events
|
||||
|
||||
From Django views:
|
||||
```python
|
||||
response['HX-Trigger'] = 'park-status-changed'
|
||||
# or with data
|
||||
response['HX-Trigger'] = json.dumps({
|
||||
'showToast': {'type': 'success', 'message': 'Status updated!'}
|
||||
})
|
||||
```
|
||||
|
||||
Listening for events:
|
||||
```html
|
||||
<div hx-get="/api/park/header"
|
||||
hx-trigger="park-status-changed from:body">
|
||||
```
|
||||
|
||||
## Loading Indicators
|
||||
|
||||
Use the standardized loading indicator component:
|
||||
|
||||
```html
|
||||
<!-- Inline (in buttons) -->
|
||||
<button hx-post="/api/action" hx-indicator="#btn-loading">
|
||||
Save
|
||||
{% include 'htmx/components/loading_indicator.html' with id='btn-loading' inline=True size='sm' %}
|
||||
</button>
|
||||
|
||||
<!-- Block (below content) -->
|
||||
<div hx-get="/api/data" hx-indicator="#loading">
|
||||
Content
|
||||
</div>
|
||||
{% include 'htmx/components/loading_indicator.html' with id='loading' message='Loading...' %}
|
||||
|
||||
<!-- Overlay (covers container) -->
|
||||
<div class="relative" hx-get="/api/data" hx-indicator="#overlay">
|
||||
Content
|
||||
{% include 'htmx/components/loading_indicator.html' with id='overlay' mode='overlay' %}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
HTMX errors are handled globally in `base.html`. The system:
|
||||
|
||||
1. Shows toast notifications for different HTTP status codes
|
||||
2. Handles timeouts (30 second default)
|
||||
3. Handles network errors
|
||||
4. Supports retry logic
|
||||
|
||||
### Custom Error Responses
|
||||
|
||||
Return error templates for 4xx/5xx responses:
|
||||
```html
|
||||
{% include 'htmx/components/error_message.html' with title='Error' message='Something went wrong.' %}
|
||||
```
|
||||
|
||||
## Toast Notifications via HTMX
|
||||
|
||||
Trigger toast notifications from server responses:
|
||||
|
||||
```python
|
||||
from django.http import JsonResponse
|
||||
|
||||
def my_view(request):
|
||||
response = render(request, 'partial.html')
|
||||
response['HX-Trigger'] = json.dumps({
|
||||
'showToast': {
|
||||
'type': 'success', # success, error, warning, info
|
||||
'message': 'Action completed!',
|
||||
'duration': 5000 # optional, in milliseconds
|
||||
}
|
||||
})
|
||||
return response
|
||||
```
|
||||
|
||||
## Form Validation
|
||||
|
||||
Use inline HTMX validation for forms:
|
||||
|
||||
```html
|
||||
<input type="text"
|
||||
name="username"
|
||||
hx-post="/api/validate/username"
|
||||
hx-trigger="blur changed delay:500ms"
|
||||
hx-target="#username-feedback"
|
||||
hx-swap="innerHTML">
|
||||
<div id="username-feedback"></div>
|
||||
```
|
||||
|
||||
Validation endpoint returns:
|
||||
```html
|
||||
<!-- Success -->
|
||||
{% include 'forms/partials/field_success.html' with message='Username available' %}
|
||||
|
||||
<!-- Error -->
|
||||
{% include 'forms/partials/field_error.html' with errors=errors %}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Search with Debounce
|
||||
|
||||
```html
|
||||
<input type="search"
|
||||
hx-get="/api/search"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#results"
|
||||
hx-indicator="#search-loading">
|
||||
```
|
||||
|
||||
### Modal Content Loading
|
||||
|
||||
```html
|
||||
<button hx-get="/api/park/123/edit"
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
@click="$store.modal.open()">
|
||||
Edit
|
||||
</button>
|
||||
```
|
||||
|
||||
### Infinite Scroll
|
||||
|
||||
```html
|
||||
<div id="items">
|
||||
{% for item in items %}
|
||||
{% include 'item.html' %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<div hx-get="?page={{ page_obj.next_page_number }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="#items > *">
|
||||
{% include 'htmx/components/loading_indicator.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Status Badge Refresh
|
||||
|
||||
```html
|
||||
<span id="park-header-badge"
|
||||
hx-get="{% url 'parks:park_header_badge' park.slug %}"
|
||||
hx-trigger="park-status-changed from:body"
|
||||
hx-swap="outerHTML">
|
||||
{% include 'components/status_badge.html' with status=park.status %}
|
||||
</span>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always specify `hx-swap`** even for default behavior (clarity)
|
||||
2. **Use meaningful target IDs** following naming conventions
|
||||
3. **Include loading indicators** for all async operations
|
||||
4. **Handle errors gracefully** with user-friendly messages
|
||||
5. **Debounce search/filter inputs** to reduce server load
|
||||
6. **Use `hx-push-url`** for URL changes that should be bookmarkable
|
||||
7. **Provide fallback** for JavaScript-disabled browsers where possible
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- CSRF tokens are automatically included via `hx-headers` in base.html
|
||||
- All HTMX endpoints should validate permissions
|
||||
- Use Django's `@require_http_methods` decorator
|
||||
- Sanitize any user input before rendering
|
||||
|
||||
## Debugging
|
||||
|
||||
Enable HTMX debugging in development:
|
||||
```javascript
|
||||
htmx.logAll();
|
||||
```
|
||||
|
||||
Check browser DevTools Network tab for HTMX requests (look for `HX-Request: true` header).
|
||||
191
backend/templates/htmx/components/README.md
Normal file
191
backend/templates/htmx/components/README.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# HTMX Components
|
||||
|
||||
This directory contains HTMX-related template components for loading states, error handling, and success feedback.
|
||||
|
||||
## Loading State Guidelines
|
||||
|
||||
### When to Use Each Type
|
||||
|
||||
| Scenario | Component | Example |
|
||||
|----------|-----------|---------|
|
||||
| Initial page load, full content replacement | Skeleton screens | Parks list loading |
|
||||
| Button actions, form submissions | Loading indicator (inline) | Submit button spinner |
|
||||
| Partial content updates | Loading indicator (block) | Search results loading |
|
||||
| Container replacement | Loading indicator (overlay) | Modal content loading |
|
||||
|
||||
### Components
|
||||
|
||||
#### loading_indicator.html
|
||||
|
||||
Standardized loading indicator for HTMX requests with three modes:
|
||||
|
||||
**Inline Mode** - For buttons and links:
|
||||
```django
|
||||
<button hx-post="/api/action" hx-indicator="#btn-loading">
|
||||
Save
|
||||
{% include 'htmx/components/loading_indicator.html' with id='btn-loading' mode='inline' %}
|
||||
</button>
|
||||
```
|
||||
|
||||
**Block Mode** (default) - For content areas:
|
||||
```django
|
||||
<div hx-get="/api/list" hx-indicator="#list-loading">
|
||||
<!-- Content -->
|
||||
</div>
|
||||
{% include 'htmx/components/loading_indicator.html' with id='list-loading' %}
|
||||
```
|
||||
|
||||
**Overlay Mode** - For containers:
|
||||
```django
|
||||
<div class="relative" hx-get="/api/data" hx-indicator="#overlay-loading">
|
||||
<!-- Content to cover -->
|
||||
{% include 'htmx/components/loading_indicator.html' with id='overlay-loading' mode='overlay' %}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `id` | str | - | ID for hx-indicator targeting |
|
||||
| `message` | str | "Loading..." | Loading text to display |
|
||||
| `mode` | str | "block" | 'inline', 'block', or 'overlay' |
|
||||
| `size` | str | "md" | 'sm', 'md', or 'lg' |
|
||||
| `spinner` | str | "border" | 'spin' (fa-spinner) or 'border' (CSS) |
|
||||
|
||||
## Skeleton Screens
|
||||
|
||||
Located in `components/skeletons/`, these provide content-aware loading placeholders:
|
||||
|
||||
### Available Skeletons
|
||||
|
||||
| Component | Use Case |
|
||||
|-----------|----------|
|
||||
| `list_skeleton.html` | List views, search results |
|
||||
| `card_grid_skeleton.html` | Card-based grid layouts |
|
||||
| `detail_skeleton.html` | Detail/show pages |
|
||||
| `form_skeleton.html` | Form loading states |
|
||||
| `table_skeleton.html` | Data tables |
|
||||
|
||||
### Usage with HTMX
|
||||
|
||||
```django
|
||||
{# Initial content shows skeleton, replaced by HTMX #}
|
||||
<div id="parks-list"
|
||||
hx-get="/parks/"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
{% include 'components/skeletons/card_grid_skeleton.html' with cards=6 %}
|
||||
</div>
|
||||
```
|
||||
|
||||
### With hx-indicator
|
||||
|
||||
```django
|
||||
<div id="results">
|
||||
<!-- Results here -->
|
||||
</div>
|
||||
|
||||
{# Skeleton shown during loading #}
|
||||
<div id="results-skeleton" class="htmx-indicator">
|
||||
{% include 'components/skeletons/list_skeleton.html' %}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### error_message.html
|
||||
|
||||
Displays error messages with consistent styling:
|
||||
|
||||
```django
|
||||
{% include 'htmx/components/error_message.html' with
|
||||
message="Unable to load data"
|
||||
show_retry=True
|
||||
retry_url="/api/data"
|
||||
%}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `message` | str | - | Error message text |
|
||||
| `code` | str | - | HTTP status code |
|
||||
| `show_retry` | bool | False | Show retry button |
|
||||
| `retry_url` | str | - | URL for retry action |
|
||||
| `show_report` | bool | False | Show "Report Issue" link |
|
||||
|
||||
## Success Feedback
|
||||
|
||||
### success_toast.html
|
||||
|
||||
Triggers a toast notification via HTMX response:
|
||||
|
||||
```django
|
||||
{% include 'htmx/components/success_toast.html' with
|
||||
message="Park saved successfully"
|
||||
type="success"
|
||||
%}
|
||||
```
|
||||
|
||||
### Using HX-Trigger Header
|
||||
|
||||
From views, trigger toasts via response headers:
|
||||
|
||||
```python
|
||||
from django.http import HttpResponse
|
||||
|
||||
def save_park(request):
|
||||
# ... save logic ...
|
||||
response = HttpResponse()
|
||||
response['HX-Trigger'] = json.dumps({
|
||||
'showToast': {
|
||||
'type': 'success',
|
||||
'message': 'Park saved successfully'
|
||||
}
|
||||
})
|
||||
return response
|
||||
```
|
||||
|
||||
## HTMX Configuration
|
||||
|
||||
The base template configures HTMX with:
|
||||
|
||||
- 30-second timeout
|
||||
- Global view transitions enabled
|
||||
- Template fragments enabled
|
||||
- Comprehensive error handling
|
||||
|
||||
### Swap Strategies
|
||||
|
||||
| Strategy | Use Case |
|
||||
|----------|----------|
|
||||
| `innerHTML` | Replace content inside container (lists, search results) |
|
||||
| `outerHTML` | Replace entire element (status badges, individual items) |
|
||||
| `beforeend` | Append items (infinite scroll) |
|
||||
| `afterbegin` | Prepend items (new items at top) |
|
||||
|
||||
### Target Naming Conventions
|
||||
|
||||
- `#object-type-id` - For specific objects (e.g., `#park-123`)
|
||||
- `#section-name` - For page sections (e.g., `#results`, `#filters`)
|
||||
- `#modal-container` - For modals
|
||||
- `this` - For self-replacement
|
||||
|
||||
### Custom Events
|
||||
|
||||
- `{model}-status-changed` - Status updates (e.g., `park-status-changed`)
|
||||
- `auth-changed` - Authentication state changes
|
||||
- `{model}-created` - New item created
|
||||
- `{model}-updated` - Item updated
|
||||
- `{model}-deleted` - Item deleted
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always specify hx-indicator** for user feedback
|
||||
2. **Use skeleton screens** for initial page loads and full content replacement
|
||||
3. **Use inline indicators** for button actions
|
||||
4. **Use overlay indicators** for modal content loading
|
||||
5. **Add aria attributes** for accessibility (role="status", aria-busy)
|
||||
6. **Clean up loading states** after request completion (automatic with HTMX)
|
||||
@@ -1,33 +1,125 @@
|
||||
{% comment %}
|
||||
Loading Indicator Component
|
||||
HTMX Loading Indicator Component
|
||||
================================
|
||||
|
||||
Displays a loading spinner for HTMX requests.
|
||||
A standardized loading indicator for HTMX requests.
|
||||
|
||||
Optional context:
|
||||
- size: 'sm', 'md', or 'lg' (defaults to 'md')
|
||||
- inline: Whether to render inline (defaults to false)
|
||||
- message: Loading message text (defaults to 'Loading...')
|
||||
- id: Optional ID for the indicator element
|
||||
Purpose:
|
||||
Provides consistent loading feedback during HTMX requests.
|
||||
Can be used inline, block, or as an overlay.
|
||||
|
||||
Usage Examples:
|
||||
Inline spinner (in button/link):
|
||||
<button hx-get="/api/data" hx-indicator="#btn-loading">
|
||||
Load Data
|
||||
{% include 'htmx/components/loading_indicator.html' with id='btn-loading' inline=True size='sm' %}
|
||||
</button>
|
||||
|
||||
Block indicator (below content):
|
||||
<div hx-get="/api/list" hx-indicator="#list-loading">
|
||||
Content here
|
||||
</div>
|
||||
{% include 'htmx/components/loading_indicator.html' with id='list-loading' message='Loading items...' %}
|
||||
|
||||
Overlay indicator (covers container):
|
||||
<div class="relative" hx-get="/api/data" hx-indicator="#overlay-loading">
|
||||
Content to cover
|
||||
{% include 'htmx/components/loading_indicator.html' with id='overlay-loading' mode='overlay' %}
|
||||
</div>
|
||||
|
||||
Custom spinner:
|
||||
{% include 'htmx/components/loading_indicator.html' with id='custom-loading' spinner='border' %}
|
||||
|
||||
Parameters:
|
||||
Optional:
|
||||
- id: Unique identifier for the indicator (used with hx-indicator)
|
||||
- message: Loading text to display (default: 'Loading...')
|
||||
- mode: 'inline', 'block', or 'overlay' (default: 'block')
|
||||
- inline: Shortcut for mode='inline' (backwards compatible)
|
||||
- size: 'sm', 'md', 'lg' (default: 'md')
|
||||
- spinner: 'spin' (fa-spinner) or 'border' (CSS animation) (default: 'border')
|
||||
|
||||
Dependencies:
|
||||
- HTMX for .htmx-indicator class behavior
|
||||
- Tailwind CSS for styling
|
||||
- Font Awesome icons (for 'spin' spinner)
|
||||
|
||||
Accessibility:
|
||||
- Uses role="status" and aria-live="polite" for screen readers
|
||||
- aria-hidden while not loading (HTMX handles visibility)
|
||||
- Loading message announced to screen readers
|
||||
{% endcomment %}
|
||||
|
||||
{% if inline %}
|
||||
<!-- Inline Loading Indicator -->
|
||||
<span class="htmx-indicator inline-flex items-center gap-2 {% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-lg{% endif %}"
|
||||
{% if id %}id="{{ id }}"{% endif %}
|
||||
aria-hidden="true">
|
||||
<i class="fas fa-spinner fa-spin text-blue-500"></i>
|
||||
{% if message %}<span class="text-gray-500 dark:text-gray-400">{{ message }}</span>{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<!-- Block Loading Indicator -->
|
||||
<div class="htmx-indicator flex items-center justify-center p-4 {% if size == 'sm' %}p-2{% elif size == 'lg' %}p-6{% endif %}"
|
||||
{# Support both 'inline' param and 'mode' param #}
|
||||
{% with actual_mode=mode|default:inline|yesno:'inline,block' %}
|
||||
|
||||
{% if actual_mode == 'overlay' %}
|
||||
{# ============================================
|
||||
Overlay Mode - Covers parent element
|
||||
Parent must have position: relative
|
||||
============================================ #}
|
||||
<div class="htmx-indicator absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm z-10 rounded-lg"
|
||||
{% if id %}id="{{ id }}"{% endif %}
|
||||
aria-hidden="true">
|
||||
role="status"
|
||||
aria-live="polite">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
{# Spinner #}
|
||||
{% if spinner == 'spin' %}
|
||||
<i class="fas fa-spinner fa-spin {% if size == 'sm' %}text-xl{% elif size == 'lg' %}text-5xl{% else %}text-3xl{% endif %} text-blue-500"
|
||||
aria-hidden="true"></i>
|
||||
{% else %}
|
||||
<div class="{% if size == 'sm' %}w-6 h-6 border-3{% elif size == 'lg' %}w-12 h-12 border-4{% else %}w-8 h-8 border-4{% endif %} border-blue-500 rounded-full animate-spin border-t-transparent"
|
||||
aria-hidden="true"></div>
|
||||
{% endif %}
|
||||
{# Message #}
|
||||
<span class="{% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-lg{% else %}text-base{% endif %} font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ message|default:"Loading..." }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif actual_mode == 'inline' or inline %}
|
||||
{# ============================================
|
||||
Inline Mode - For use within buttons/links
|
||||
============================================ #}
|
||||
<span class="htmx-indicator inline-flex items-center gap-2"
|
||||
{% if id %}id="{{ id }}"{% endif %}
|
||||
role="status"
|
||||
aria-live="polite">
|
||||
{% if spinner == 'spin' or spinner == '' %}
|
||||
<i class="fas fa-spinner fa-spin {% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-lg{% endif %} text-blue-500"
|
||||
aria-hidden="true"></i>
|
||||
{% else %}
|
||||
<div class="{% if size == 'sm' %}w-4 h-4 border-2{% elif size == 'lg' %}w-6 h-6 border-3{% else %}w-5 h-5 border-2{% endif %} border-current rounded-full animate-spin border-t-transparent"
|
||||
aria-hidden="true"></div>
|
||||
{% endif %}
|
||||
{% if message %}<span class="text-gray-500 dark:text-gray-400">{{ message }}</span>{% endif %}
|
||||
{% if not message %}<span class="sr-only">Loading...</span>{% endif %}
|
||||
</span>
|
||||
|
||||
{% else %}
|
||||
{# ============================================
|
||||
Block Mode (default) - Centered block
|
||||
============================================ #}
|
||||
<div class="htmx-indicator flex items-center justify-center {% if size == 'sm' %}p-2{% elif size == 'lg' %}p-6{% else %}p-4{% endif %}"
|
||||
{% if id %}id="{{ id }}"{% endif %}
|
||||
role="status"
|
||||
aria-live="polite">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="{% if size == 'sm' %}w-5 h-5{% elif size == 'lg' %}w-10 h-10{% else %}w-8 h-8{% endif %} border-4 border-blue-500 rounded-full animate-spin border-t-transparent"></div>
|
||||
{# Spinner #}
|
||||
{% if spinner == 'spin' %}
|
||||
<i class="fas fa-spinner fa-spin {% if size == 'sm' %}text-lg{% elif size == 'lg' %}text-3xl{% else %}text-2xl{% endif %} text-blue-500"
|
||||
aria-hidden="true"></i>
|
||||
{% else %}
|
||||
<div class="{% if size == 'sm' %}w-5 h-5 border-3{% elif size == 'lg' %}w-10 h-10 border-4{% else %}w-8 h-8 border-4{% endif %} border-blue-500 rounded-full animate-spin border-t-transparent"
|
||||
aria-hidden="true"></div>
|
||||
{% endif %}
|
||||
{# Message #}
|
||||
<span class="{% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-lg{% else %}text-base{% endif %} text-gray-600 dark:text-gray-300">
|
||||
{{ message|default:"Loading..." }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endwith %}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<nav class="htmx-pagination" role="navigation" aria-label="Pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<button hx-get="{{ request.path }}?page={{ page_obj.previous_page_number }}" hx-swap="#results">Previous</button>
|
||||
{% endif %}
|
||||
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||
{% if page_obj.has_next %}
|
||||
<button hx-get="{{ request.path }}?page={{ page_obj.next_page_number }}" hx-swap="#results">Next</button>
|
||||
{% endif %}
|
||||
</nav>
|
||||
@@ -1,30 +1,4 @@
|
||||
{# Park header status badge partial - refreshes via HTMX on park-status-changed #}
|
||||
<span id="park-header-badge"
|
||||
hx-get="{% url 'parks:park_header_badge' park.slug %}"
|
||||
hx-trigger="park-status-changed from:body"
|
||||
hx-swap="outerHTML">
|
||||
{% if perms.parks.change_park %}
|
||||
<!-- Clickable status badge for moderators -->
|
||||
<button type="button"
|
||||
onclick="document.getElementById('park-status-section').scrollIntoView({behavior: 'smooth'})"
|
||||
class="status-badge text-sm font-medium py-1 px-3 transition-all hover:ring-2 hover:ring-blue-500 cursor-pointer
|
||||
{% if park.status == 'OPERATING' %}status-operating
|
||||
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||
{% elif park.status == 'DEMOLISHED' %}status-demolished
|
||||
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
<i class="fas fa-chevron-down ml-1 text-xs"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<!-- Static status badge for non-moderators -->
|
||||
<span class="status-badge text-sm font-medium py-1 px-3
|
||||
{% if park.status == 'OPERATING' %}status-operating
|
||||
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||
{% elif park.status == 'DEMOLISHED' %}status-demolished
|
||||
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{# Uses unified status_badge component for consistent styling #}
|
||||
|
||||
{% include "components/status_badge.html" with status=park.status badge_id='park-header-badge' refresh_url=park.get_header_badge_url|default:'' refresh_trigger='park-status-changed' scroll_target='park-status-section' can_edit=perms.parks.change_park %}
|
||||
|
||||
@@ -1,30 +1,4 @@
|
||||
{# Ride header status badge partial - refreshes via HTMX on ride-status-changed #}
|
||||
<span id="ride-header-badge"
|
||||
hx-get="{% url 'parks:rides:ride_header_badge' park_slug=ride.park.slug ride_slug=ride.slug %}"
|
||||
hx-trigger="ride-status-changed from:body"
|
||||
hx-swap="outerHTML">
|
||||
{% if perms.rides.change_ride %}
|
||||
<!-- Clickable status badge for moderators -->
|
||||
<button type="button"
|
||||
onclick="document.getElementById('ride-status-section').scrollIntoView({behavior: 'smooth'})"
|
||||
class="px-3 py-1 text-sm font-medium status-badge transition-all hover:ring-2 hover:ring-blue-500 cursor-pointer
|
||||
{% if ride.status == 'OPERATING' %}status-operating
|
||||
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
|
||||
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||
{% elif ride.status == 'DEMOLISHED' %}status-demolished
|
||||
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||
{{ ride.get_status_display }}
|
||||
<i class="fas fa-chevron-down ml-1 text-xs"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<!-- Static status badge for non-moderators -->
|
||||
<span class="px-3 py-1 text-sm font-medium status-badge
|
||||
{% if ride.status == 'OPERATING' %}status-operating
|
||||
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
|
||||
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||
{% elif ride.status == 'DEMOLISHED' %}status-demolished
|
||||
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||
{{ ride.get_status_display }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{# Uses unified status_badge component for consistent styling #}
|
||||
|
||||
{% include "components/status_badge.html" with status=ride.status badge_id='ride-header-badge' refresh_url=ride.get_header_badge_url|default:'' refresh_trigger='ride-status-changed' scroll_target='ride-status-section' can_edit=perms.rides.change_ride %}
|
||||
|
||||
282
backend/templates/tests/design-system-test.html
Normal file
282
backend/templates/tests/design-system-test.html
Normal file
@@ -0,0 +1,282 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Design System Test - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-responsive">
|
||||
<h1 class="mb-8">Design System Test Page</h1>
|
||||
<p class="mb-8 text-muted-foreground">
|
||||
This page validates all design system components are rendering correctly.
|
||||
</p>
|
||||
|
||||
<!-- Typography Section -->
|
||||
<section class="mb-12" aria-labelledby="typography-heading">
|
||||
<h2 id="typography-heading" class="mb-4 text-2xl font-semibold">Typography</h2>
|
||||
<div class="p-6 border rounded-lg bg-card">
|
||||
<h1>Heading 1</h1>
|
||||
<h2>Heading 2</h2>
|
||||
<h3>Heading 3</h3>
|
||||
<h4>Heading 4</h4>
|
||||
<h5>Heading 5</h5>
|
||||
<h6>Heading 6</h6>
|
||||
<p class="mt-4">Regular paragraph text. <a href="#">This is a link</a>.</p>
|
||||
<p class="text-muted-foreground">Muted text for secondary information.</p>
|
||||
<p class="text-sm">Small text</p>
|
||||
<p class="text-lg">Large text</p>
|
||||
<code class="font-mono">Monospace code</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Color Palette Section -->
|
||||
<section class="mb-12" aria-labelledby="colors-heading">
|
||||
<h2 id="colors-heading" class="mb-4 text-2xl font-semibold">Color Palette</h2>
|
||||
|
||||
<h3 class="mb-2 text-lg font-medium">Primary Colors</h3>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<div class="w-16 h-16 rounded" style="background: var(--color-primary-50);"></div>
|
||||
<div class="w-16 h-16 rounded" style="background: var(--color-primary-100);"></div>
|
||||
<div class="w-16 h-16 rounded" style="background: var(--color-primary-200);"></div>
|
||||
<div class="w-16 h-16 rounded" style="background: var(--color-primary-300);"></div>
|
||||
<div class="w-16 h-16 rounded" style="background: var(--color-primary-400);"></div>
|
||||
<div class="w-16 h-16 rounded" style="background: var(--color-primary-500);"></div>
|
||||
<div class="w-16 h-16 rounded" style="background: var(--color-primary-600);"></div>
|
||||
<div class="w-16 h-16 rounded" style="background: var(--color-primary-700);"></div>
|
||||
<div class="w-16 h-16 rounded" style="background: var(--color-primary-800);"></div>
|
||||
<div class="w-16 h-16 rounded" style="background: var(--color-primary-900);"></div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 text-lg font-medium">Semantic Colors</h3>
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div class="p-4 rounded bg-success-500">
|
||||
<span class="text-white">Success</span>
|
||||
</div>
|
||||
<div class="p-4 rounded bg-warning-500">
|
||||
<span class="text-white">Warning</span>
|
||||
</div>
|
||||
<div class="p-4 rounded bg-error-500">
|
||||
<span class="text-white">Error</span>
|
||||
</div>
|
||||
<div class="p-4 rounded bg-info-500">
|
||||
<span class="text-white">Info</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Button Component Section -->
|
||||
<section class="mb-12" aria-labelledby="buttons-heading">
|
||||
<h2 id="buttons-heading" class="mb-4 text-2xl font-semibold">Buttons</h2>
|
||||
|
||||
<h3 class="mb-2 text-lg font-medium">Variants</h3>
|
||||
<div class="flex flex-wrap gap-4 mb-6">
|
||||
{% include "components/ui/button.html" with text="Default" variant="default" %}
|
||||
{% include "components/ui/button.html" with text="Secondary" variant="secondary" %}
|
||||
{% include "components/ui/button.html" with text="Destructive" variant="destructive" %}
|
||||
{% include "components/ui/button.html" with text="Outline" variant="outline" %}
|
||||
{% include "components/ui/button.html" with text="Ghost" variant="ghost" %}
|
||||
{% include "components/ui/button.html" with text="Link" variant="link" %}
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 text-lg font-medium">Sizes</h3>
|
||||
<div class="flex flex-wrap items-center gap-4 mb-6">
|
||||
{% include "components/ui/button.html" with text="Small" size="sm" %}
|
||||
{% include "components/ui/button.html" with text="Default" %}
|
||||
{% include "components/ui/button.html" with text="Large" size="lg" %}
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 text-lg font-medium">With Icons</h3>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
{% include "components/ui/button.html" with text="Search" icon="search" %}
|
||||
{% include "components/ui/button.html" with text="Settings" icon="settings" variant="secondary" %}
|
||||
{% include "components/ui/button.html" with text="Delete" icon="trash" variant="destructive" %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Card Component Section -->
|
||||
<section class="mb-12" aria-labelledby="cards-heading">
|
||||
<h2 id="cards-heading" class="mb-4 text-2xl font-semibold">Cards</h2>
|
||||
<div class="grid-responsive-3">
|
||||
{% include "components/ui/card.html" with title="Card Title" description="Card description text" body_content="<p>Card body content goes here.</p>" %}
|
||||
{% include "components/ui/card.html" with title="Another Card" description="With footer" body_content="<p>More content here.</p>" footer_content="<button class='btn btn-primary'>Action</button>" %}
|
||||
{% include "components/ui/card.html" with title="Minimal Card" body_content="<p>Just content, no description.</p>" %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Input Component Section -->
|
||||
<section class="mb-12" aria-labelledby="inputs-heading">
|
||||
<h2 id="inputs-heading" class="mb-4 text-2xl font-semibold">Form Inputs</h2>
|
||||
<div class="max-w-md space-y-4">
|
||||
{% include "components/ui/input.html" with name="text-input" label="Text Input" placeholder="Enter text..." %}
|
||||
{% include "components/ui/input.html" with name="email-input" label="Email Input" type="email" placeholder="email@example.com" %}
|
||||
{% include "components/ui/input.html" with name="disabled-input" label="Disabled Input" disabled="true" value="Disabled value" %}
|
||||
{% include "components/ui/input.html" with name="textarea-input" label="Textarea" type="textarea" placeholder="Enter longer text..." %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Icon System Section -->
|
||||
<section class="mb-12" aria-labelledby="icons-heading">
|
||||
<h2 id="icons-heading" class="mb-4 text-2xl font-semibold">Icons</h2>
|
||||
|
||||
<h3 class="mb-2 text-lg font-medium">Sizes</h3>
|
||||
<div class="flex items-end gap-4 mb-6">
|
||||
<div class="text-center">
|
||||
{% include "components/ui/icon.html" with name="star" size="xs" %}
|
||||
<p class="text-xs">xs</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
{% include "components/ui/icon.html" with name="star" size="sm" %}
|
||||
<p class="text-xs">sm</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
{% include "components/ui/icon.html" with name="star" size="md" %}
|
||||
<p class="text-xs">md</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
{% include "components/ui/icon.html" with name="star" size="lg" %}
|
||||
<p class="text-xs">lg</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
{% include "components/ui/icon.html" with name="star" size="xl" %}
|
||||
<p class="text-xs">xl</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 text-lg font-medium">Common Icons</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
{% include "components/ui/icon.html" with name="search" %}
|
||||
{% include "components/ui/icon.html" with name="user" %}
|
||||
{% include "components/ui/icon.html" with name="settings" %}
|
||||
{% include "components/ui/icon.html" with name="heart" %}
|
||||
{% include "components/ui/icon.html" with name="star" %}
|
||||
{% include "components/ui/icon.html" with name="home" %}
|
||||
{% include "components/ui/icon.html" with name="menu" %}
|
||||
{% include "components/ui/icon.html" with name="close" %}
|
||||
{% include "components/ui/icon.html" with name="check" %}
|
||||
{% include "components/ui/icon.html" with name="plus" %}
|
||||
{% include "components/ui/icon.html" with name="minus" %}
|
||||
{% include "components/ui/icon.html" with name="edit" %}
|
||||
{% include "components/ui/icon.html" with name="trash" %}
|
||||
{% include "components/ui/icon.html" with name="copy" %}
|
||||
{% include "components/ui/icon.html" with name="external-link" %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Responsive Utilities Section -->
|
||||
<section class="mb-12" aria-labelledby="responsive-heading">
|
||||
<h2 id="responsive-heading" class="mb-4 text-2xl font-semibold">Responsive Utilities</h2>
|
||||
|
||||
<h3 class="mb-2 text-lg font-medium">Visibility</h3>
|
||||
<div class="p-4 mb-4 border rounded-lg">
|
||||
<p class="show-mobile text-success-600">Visible on mobile only</p>
|
||||
<p class="hidden-mobile text-info-600">Hidden on mobile</p>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 text-lg font-medium">Grid Responsive</h3>
|
||||
<div class="grid-responsive-4 mb-4">
|
||||
<div class="p-4 text-center rounded bg-primary-100">1</div>
|
||||
<div class="p-4 text-center rounded bg-primary-200">2</div>
|
||||
<div class="p-4 text-center rounded bg-primary-300">3</div>
|
||||
<div class="p-4 text-center rounded bg-primary-400">4</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 text-lg font-medium">Stack to Row</h3>
|
||||
<div class="stack-to-row">
|
||||
<div class="flex-1 p-4 text-center rounded bg-secondary-200">Item 1</div>
|
||||
<div class="flex-1 p-4 text-center rounded bg-secondary-300">Item 2</div>
|
||||
<div class="flex-1 p-4 text-center rounded bg-secondary-400">Item 3</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Accessibility Section -->
|
||||
<section class="mb-12" aria-labelledby="a11y-heading">
|
||||
<h2 id="a11y-heading" class="mb-4 text-2xl font-semibold">Accessibility</h2>
|
||||
|
||||
<h3 class="mb-2 text-lg font-medium">Focus States</h3>
|
||||
<p class="mb-4 text-muted-foreground">Tab through these elements to see focus indicators:</p>
|
||||
<div class="flex flex-wrap gap-4 mb-6">
|
||||
<button class="btn btn-primary focus-ring">Focus Me</button>
|
||||
<a href="#" class="text-primary focus-ring">Focusable Link</a>
|
||||
<input type="text" class="input focus-ring" placeholder="Focusable Input">
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 text-lg font-medium">Touch Targets</h3>
|
||||
<div class="flex gap-4">
|
||||
<button class="touch-target btn btn-outline">44px Min</button>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 text-lg font-medium">Screen Reader Text</h3>
|
||||
<div class="p-4 border rounded-lg">
|
||||
<button class="btn btn-icon">
|
||||
{% include "components/ui/icon.html" with name="settings" %}
|
||||
<span class="sr-only">Settings</span>
|
||||
</button>
|
||||
<p class="mt-2 text-sm text-muted-foreground">The button above has screen reader text "Settings"</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Dark Mode Section -->
|
||||
<section class="mb-12" aria-labelledby="darkmode-heading">
|
||||
<h2 id="darkmode-heading" class="mb-4 text-2xl font-semibold">Dark Mode</h2>
|
||||
<p class="mb-4 text-muted-foreground">Toggle dark mode using the theme switcher in the navbar to test dark mode styling.</p>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-4 rounded bg-background">
|
||||
<p class="text-foreground">Background/Foreground</p>
|
||||
</div>
|
||||
<div class="p-4 rounded bg-card">
|
||||
<p class="text-card-foreground">Card/Card Foreground</p>
|
||||
</div>
|
||||
<div class="p-4 rounded bg-muted">
|
||||
<p class="text-muted-foreground">Muted/Muted Foreground</p>
|
||||
</div>
|
||||
<div class="p-4 rounded bg-primary">
|
||||
<p class="text-primary-foreground">Primary/Primary Foreground</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Alerts Section -->
|
||||
<section class="mb-12" aria-labelledby="alerts-heading">
|
||||
<h2 id="alerts-heading" class="mb-4 text-2xl font-semibold">Alerts</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="alert alert-default" role="alert">
|
||||
<div class="alert-title">Default Alert</div>
|
||||
<div class="alert-description">This is a default alert message.</div>
|
||||
</div>
|
||||
<div class="alert alert-success" role="alert">
|
||||
<div class="alert-title">Success</div>
|
||||
<div class="alert-description">Your action was completed successfully.</div>
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<div class="alert-title">Warning</div>
|
||||
<div class="alert-description">Please review before continuing.</div>
|
||||
</div>
|
||||
<div class="alert alert-error" role="alert">
|
||||
<div class="alert-title">Error</div>
|
||||
<div class="alert-description">Something went wrong. Please try again.</div>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<div class="alert-title">Info</div>
|
||||
<div class="alert-description">Here's some helpful information.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Spacing & Layout Section -->
|
||||
<section class="mb-12" aria-labelledby="spacing-heading">
|
||||
<h2 id="spacing-heading" class="mb-4 text-2xl font-semibold">Spacing & Layout</h2>
|
||||
|
||||
<h3 class="mb-2 text-lg font-medium">Container Sizes</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 mx-auto border rounded container-sm bg-muted">
|
||||
<p class="text-center">container-sm (640px)</p>
|
||||
</div>
|
||||
<div class="p-4 mx-auto border rounded container-md bg-muted">
|
||||
<p class="text-center">container-md (768px)</p>
|
||||
</div>
|
||||
<div class="p-4 mx-auto border rounded container-lg bg-muted">
|
||||
<p class="text-center">container-lg (1024px)</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user