mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 09:51:09 -05:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
129
templates/moderation/partials/filters_store.html
Normal file
129
templates/moderation/partials/filters_store.html
Normal 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>
|
||||
66
templates/moderation/partials/loading_skeleton.html
Normal file
66
templates/moderation/partials/loading_skeleton.html
Normal 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>
|
||||
Reference in New Issue
Block a user