mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 16:11:08 -05:00
Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user