Refactor authentication settings and enhance frontend moderation panel with performance optimizations, loading states, error handling, mobile responsiveness, and accessibility improvements

This commit is contained in:
pacnpal
2025-02-06 14:20:12 -05:00
parent 7d25d6f992
commit 323aa561a5
14 changed files with 1728 additions and 61 deletions

View File

@@ -5,27 +5,37 @@
{% block extra_css %}
<style>
/* Base Styles */
:root {
--loading-gradient: linear-gradient(90deg, var(--tw-gradient-from) 0%, var(--tw-gradient-to) 50%, var(--tw-gradient-from) 100%);
}
/* Responsive Layout */
@media (max-width: 768px) {
.grid-cols-responsive {
@apply grid-cols-1;
}
.action-buttons {
@apply flex-col;
}
.action-buttons > * {
@apply w-full justify-center;
}
}
/* Form Elements */
.form-select {
@apply rounded-lg border-gray-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 bg-gray-800 text-gray-300 transition-colors duration-200;
}
/* Transitions */
/* State Management */
[x-cloak] {
display: none !important;
}
.fade-enter-active,
.fade-leave-active {
@apply transition-all duration-200;
}
.fade-enter-from,
.fade-leave-to {
@apply opacity-0;
}
/* HTMX Loading States */
/* Loading States */
.htmx-request .htmx-indicator {
@apply opacity-100;
}
@@ -38,31 +48,131 @@
@apply opacity-0 transition-opacity duration-200;
}
/* Animations */
@keyframes spin {
to {
transform: rotate(360deg);
/* Skeleton Loading Animation */
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}
.animate-spin {
animation: spin 1s linear infinite;
/* Transitions */
.fade-enter-active,
.fade-leave-active {
@apply transition-all duration-200;
}
.fade-enter-from,
.fade-leave-to {
@apply opacity-0;
}
/* Custom Animations */
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
.animate-shimmer {
position: relative;
overflow: hidden;
}
.animate-shimmer::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: var(--loading-gradient);
animation: shimmer 2s infinite;
content: '';
}
/* Accessibility Enhancements */
:focus-visible {
@apply outline-none ring-2 ring-blue-500 ring-offset-2 ring-offset-white dark:ring-offset-gray-900;
}
@media (prefers-reduced-motion: reduce) {
.animate-shimmer::after {
animation: none;
}
.animate-pulse {
animation: none;
}
}
/* Touch Device Optimizations */
@media (hover: none) {
.hover\:shadow-md {
@apply shadow-sm;
}
.action-buttons > * {
@apply active:transform active:scale-95;
}
}
/* Dark Mode Optimizations */
.dark .animate-shimmer::after {
--tw-gradient-from: rgba(31, 41, 55, 0);
--tw-gradient-to: rgba(31, 41, 55, 0.1);
}
/* Error States */
.error-shake {
animation: shake 0.82s cubic-bezier(.36,.07,.19,.97) both;
transform: translate3d(0, 0, 0);
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
</style>
{% endblock %}
{% block content %}
<div class="container max-w-6xl px-4 py-6 mx-auto">
<div id="dashboard-content" class="relative transition-opacity duration-200">
<div id="dashboard-content" class="relative transition-all duration-200">
{% block moderation_content %}
{% include "moderation/partials/dashboard_content.html" %}
{% endblock %}
<div id="loading-indicator"
class="absolute inset-0 flex items-center justify-center rounded-lg htmx-indicator bg-gray-900/80">
<div class="flex items-center p-6 space-x-4">
<div class="w-8 h-8 border-4 border-blue-500 rounded-full animate-spin border-t-transparent"></div>
<span class="text-gray-300">Loading...</span>
<!-- Loading Skeleton -->
<div class="absolute inset-0 htmx-indicator" id="loading-skeleton">
{% include "moderation/partials/loading_skeleton.html" %}
</div>
<!-- Error State -->
<div class="absolute inset-0 hidden" id="error-state">
<div class="flex flex-col items-center justify-center h-full p-6 space-y-4 text-center">
<div class="p-4 text-red-500 bg-red-100 rounded-full dark:bg-red-900/40">
<i class="text-4xl fas fa-exclamation-circle"></i>
</div>
<h3 class="text-lg font-medium text-red-600 dark:text-red-400">
Something went wrong
</h3>
<p class="max-w-md text-gray-600 dark:text-gray-400" id="error-message">
There was a problem loading the content. Please try again.
</p>
<button class="px-4 py-2 font-medium text-white transition-all duration-200 bg-red-600 rounded-lg hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600"
onclick="window.location.reload()">
<i class="mr-2 fas fa-sync-alt"></i>
Retry
</button>
</div>
</div>
</div>
@@ -71,20 +181,116 @@
{% block extra_js %}
<script>
// Configure HTMX to include CSRF token in all requests
// HTMX Configuration and Enhancements
document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
});
// Loading and Error State Management
const dashboard = {
content: document.getElementById('dashboard-content'),
skeleton: document.getElementById('loading-skeleton'),
errorState: document.getElementById('error-state'),
errorMessage: document.getElementById('error-message'),
showLoading() {
this.content.setAttribute('aria-busy', 'true');
this.content.style.opacity = '0';
this.errorState.classList.add('hidden');
},
hideLoading() {
this.content.setAttribute('aria-busy', 'false');
this.content.style.opacity = '1';
},
showError(message) {
this.errorState.classList.remove('hidden');
this.errorMessage.textContent = message || 'There was a problem loading the content. Please try again.';
// Announce error to screen readers
this.errorMessage.setAttribute('role', 'alert');
}
};
// Enhanced HTMX Event Handlers
document.body.addEventListener('htmx:beforeRequest', function(evt) {
if (evt.detail.target.id === 'dashboard-content') {
evt.detail.target.style.opacity = '0.5';
dashboard.showLoading();
}
});
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
if (evt.detail.target.id === 'dashboard-content') {
evt.detail.target.style.opacity = '1';
dashboard.hideLoading();
// Reset focus for accessibility
const firstFocusable = evt.detail.target.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (firstFocusable) {
firstFocusable.focus();
}
}
});
document.body.addEventListener('htmx:responseError', function(evt) {
if (evt.detail.target.id === 'dashboard-content') {
dashboard.showError(evt.detail.error);
}
});
// Search Input Debouncing
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Apply debouncing to search inputs
document.querySelectorAll('[data-search]').forEach(input => {
const originalSearch = () => {
htmx.trigger(input, 'input');
};
const debouncedSearch = debounce(originalSearch, 300);
input.addEventListener('input', (e) => {
e.preventDefault();
debouncedSearch();
});
});
// Virtual Scrolling for Large Lists
const observerOptions = {
root: null,
rootMargin: '100px',
threshold: 0.1
};
const loadMoreContent = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting && !entry.target.classList.contains('loading')) {
entry.target.classList.add('loading');
htmx.trigger(entry.target, 'intersect');
}
});
};
const observer = new IntersectionObserver(loadMoreContent, observerOptions);
document.querySelectorAll('[data-infinite-scroll]').forEach(el => observer.observe(el));
// Keyboard Navigation Enhancement
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const openModals = document.querySelectorAll('[x-show="showNotes"]');
openModals.forEach(modal => {
const alpineData = modal.__x.$data;
if (alpineData.showNotes) {
alpineData.showNotes = false;
}
});
}
});
</script>

View File

@@ -56,44 +56,93 @@
</div>
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
<form class="flex flex-wrap items-end gap-4 mb-6"
<form class="mb-6"
x-data="{ showFilters: false }"
hx-get="{% url 'moderation:submission_list' %}"
hx-target="#dashboard-content"
hx-trigger="change"
hx-push-url="true">
hx-trigger="change from:select"
hx-push-url="true"
aria-label="Submission filters">
<div class="flex-1 min-w-[200px]">
<label class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Submission Type
</label>
<select name="submission_type" class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">All Submissions</option>
<option value="text" {% if request.GET.submission_type == 'text' %}selected{% endif %}>Text Submissions</option>
<option value="photo" {% if request.GET.submission_type == 'photo' %}selected{% endif %}>Photo Submissions</option>
</select>
</div>
<!-- Mobile Filter Toggle -->
<button type="button"
class="flex items-center w-full gap-2 p-3 mb-4 font-medium text-left text-gray-700 transition-colors duration-200 bg-gray-100 rounded-lg md:hidden dark:text-gray-300 dark:bg-gray-900"
@click="showFilters = !showFilters"
:aria-expanded="showFilters"
aria-controls="filter-controls">
<i class="fas" :class="showFilters ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
<span>Filter Options</span>
<span class="flex items-center ml-auto space-x-1 text-sm text-gray-500 dark:text-gray-400">
<span class="active-filters">{{ request.GET|length }} active</span>
</span>
</button>
<div class="flex-1 min-w-[200px]">
<label class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Type
</label>
<select name="type" class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">All Types</option>
<option value="CREATE" {% if request.GET.type == 'CREATE' %}selected{% endif %}>New Submissions</option>
<option value="EDIT" {% if request.GET.type == 'EDIT' %}selected{% endif %}>Edit Submissions</option>
</select>
</div>
<!-- Filter Controls -->
<div id="filter-controls"
class="grid gap-4 transition-all duration-200 md:grid-cols-3"
:class="{'hidden md:grid': !showFilters, 'grid': showFilters}"
role="group"
aria-label="Filter controls">
<div class="relative">
<label id="submission-type-label"
class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Submission Type
</label>
<select name="submission_type"
aria-labelledby="submission-type-label"
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="$dispatch('filter-changed')">
<option value="">All Submissions</option>
<option value="text" {% if request.GET.submission_type == 'text' %}selected{% endif %}>Text Submissions</option>
<option value="photo" {% if request.GET.submission_type == 'photo' %}selected{% endif %}>Photo Submissions</option>
</select>
</div>
<div class="flex-1 min-w-[200px]">
<label class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Content Type
</label>
<select name="content_type" class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">All Content</option>
<option value="park" {% if request.GET.content_type == 'park' %}selected{% endif %}>Parks</option>
<option value="ride" {% if request.GET.content_type == 'ride' %}selected{% endif %}>Rides</option>
<option value="company" {% if request.GET.content_type == 'company' %}selected{% endif %}>Companies</option>
</select>
<div class="relative">
<label id="type-label"
class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Type
</label>
<select name="type"
aria-labelledby="type-label"
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="$dispatch('filter-changed')">
<option value="">All Types</option>
<option value="CREATE" {% if request.GET.type == 'CREATE' %}selected{% endif %}>New Submissions</option>
<option value="EDIT" {% if request.GET.type == 'EDIT' %}selected{% endif %}>Edit Submissions</option>
</select>
</div>
<div class="relative">
<label id="content-type-label"
class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Content Type
</label>
<select name="content_type"
aria-labelledby="content-type-label"
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50 focus:outline-none focus:ring-2 focus:ring-blue-500"
@change="$dispatch('filter-changed')">
<option value="">All Content</option>
<option value="park" {% if request.GET.content_type == 'park' %}selected{% endif %}>Parks</option>
<option value="ride" {% if request.GET.content_type == 'ride' %}selected{% endif %}>Rides</option>
<option value="company" {% if request.GET.content_type == 'company' %}selected{% endif %}>Companies</option>
</select>
</div>
<!-- Applied Filters Summary -->
<div class="hidden pt-2 md:col-span-3 md:block">
<div class="flex flex-wrap gap-2"
x-show="$store.filters.hasActiveFilters"
x-cloak>
<template x-for="filter in $store.filters.active" :key="filter.name">
<span class="inline-flex items-center px-2 py-1 text-sm bg-blue-100 rounded dark:bg-blue-900/40">
<span class="mr-1 text-blue-700 dark:text-blue-300" x-text="filter.label + ':'"></span>
<span class="font-medium text-blue-900 dark:text-blue-100" x-text="filter.value"></span>
</span>
</template>
</div>
</div>
</div>
</form>

View File

@@ -0,0 +1,129 @@
{% comment %}
This template contains the Alpine.js store for managing filter state in the moderation dashboard
{% endcomment %}
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('filters', {
active: [],
init() {
this.updateActiveFilters();
// Listen for filter changes
window.addEventListener('filter-changed', () => {
this.updateActiveFilters();
});
},
updateActiveFilters() {
const urlParams = new URLSearchParams(window.location.search);
this.active = [];
// Submission Type
if (urlParams.has('submission_type')) {
this.active.push({
name: 'submission_type',
label: 'Submission',
value: this.getSubmissionTypeLabel(urlParams.get('submission_type'))
});
}
// Type
if (urlParams.has('type')) {
this.active.push({
name: 'type',
label: 'Type',
value: this.getTypeLabel(urlParams.get('type'))
});
}
// Content Type
if (urlParams.has('content_type')) {
this.active.push({
name: 'content_type',
label: 'Content',
value: this.getContentTypeLabel(urlParams.get('content_type'))
});
}
},
getSubmissionTypeLabel(value) {
const labels = {
'text': 'Text',
'photo': 'Photo'
};
return labels[value] || value;
},
getTypeLabel(value) {
const labels = {
'CREATE': 'New',
'EDIT': 'Edit'
};
return labels[value] || value;
},
getContentTypeLabel(value) {
const labels = {
'park': 'Parks',
'ride': 'Rides',
'company': 'Companies'
};
return labels[value] || value;
},
get hasActiveFilters() {
return this.active.length > 0;
},
clear() {
const form = document.querySelector('form[hx-get]');
if (form) {
form.querySelectorAll('select').forEach(select => {
select.value = '';
});
form.dispatchEvent(new Event('change'));
}
},
// Accessibility Helpers
announceFilterChange() {
const message = this.hasActiveFilters
? `Applied filters: ${this.active.map(f => f.label + ': ' + f.value).join(', ')}`
: 'All filters cleared';
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', 'polite');
announcement.className = 'sr-only';
announcement.textContent = message;
document.body.appendChild(announcement);
setTimeout(() => announcement.remove(), 1000);
}
});
});
// Watch for filter changes and update URL params
document.addEventListener('filter-changed', (e) => {
const form = e.target.closest('form');
if (!form) return;
const formData = new FormData(form);
const params = new URLSearchParams();
for (let [key, value] of formData.entries()) {
if (value) {
params.append(key, value);
}
}
// Update URL without page reload
const newUrl = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
window.history.pushState({}, '', newUrl);
// Announce changes for screen readers
Alpine.store('filters').announceFilterChange();
});
</script>

View File

@@ -0,0 +1,66 @@
{% load static %}
<div class="animate-pulse">
<!-- Filter Bar Skeleton -->
<div class="flex items-center justify-between p-4 mb-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
<div class="flex items-center space-x-4">
{% for i in "1234" %}
<div class="w-24 h-10 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
{% endfor %}
</div>
<div class="w-24 h-10 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
</div>
<!-- Filter Form Skeleton -->
<div class="p-6 mb-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
<div class="flex flex-wrap items-end gap-4">
{% for i in "123" %}
<div class="flex-1 min-w-[200px] space-y-2">
<div class="w-24 h-4 bg-gray-200 rounded dark:bg-gray-700"></div>
<div class="w-full h-10 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
</div>
{% endfor %}
</div>
</div>
<!-- Submission List Skeleton -->
{% for i in "123" %}
<div class="p-6 mb-4 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<!-- Left Column -->
<div class="space-y-4 md:col-span-1">
<div class="flex items-center gap-3">
<div class="w-24 h-6 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
</div>
<div class="space-y-3">
{% for i in "1234" %}
<div class="flex items-center gap-2">
<div class="w-5 h-5 bg-gray-200 rounded dark:bg-gray-700"></div>
<div class="w-32 h-4 bg-gray-200 rounded dark:bg-gray-700"></div>
</div>
{% endfor %}
</div>
</div>
<!-- Right Column -->
<div class="md:col-span-2">
{% for i in "12" %}
<div class="p-4 mb-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
<div class="w-24 h-4 mb-2 bg-gray-200 rounded dark:bg-gray-700"></div>
<div class="w-full h-4 bg-gray-200 rounded dark:bg-gray-700"></div>
</div>
{% endfor %}
<div class="grid grid-cols-1 gap-3 mt-4 md:grid-cols-2">
{% for i in "1234" %}
<div class="p-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
<div class="w-24 h-4 mb-2 bg-gray-200 rounded dark:bg-gray-700"></div>
<div class="w-full h-4 bg-gray-200 rounded dark:bg-gray-700"></div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>