Files
thrillwiki_django_no_react/templates/moderation/dashboard.html

342 lines
10 KiB
HTML

{% extends "base/base.html" %}
{% load static %}
{% block title %}ThrillWiki Moderation{% endblock %}
{% 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;
}
/* State Management */
[x-cloak] {
display: none !important;
}
/* Loading States */
.htmx-request .htmx-indicator {
@apply opacity-100;
}
.htmx-request.htmx-indicator {
@apply opacity-100;
}
.htmx-indicator {
@apply opacity-0 transition-opacity duration-200;
}
/* 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;
}
}
/* 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-all duration-200"
hx-target="this"
hx-push-url="true"
hx-indicator="#loading-skeleton"
hx-swap="outerHTML">
{% block moderation_content %}
{% include "moderation/partials/dashboard_content.html" %}
{% endblock %}
<!-- Loading Skeleton -->
<div class="absolute inset-0 htmx-indicator opacity-0"
id="loading-skeleton"
aria-hidden="true">
{% include "moderation/partials/loading_skeleton.html" %}
</div>
<!-- Error State -->
<div class="absolute inset-0 hidden"
id="error-state"
role="alert"
aria-live="assertive">
<div class="flex flex-col items-center justify-center h-full p-6 space-y-4 text-center"
x-data="{ errorMessage: 'There was a problem loading the content. Please try again.' }"
x-init="$watch('errorMessage', value => $dispatch('show-toast', { message: value, type: 'error' }))">
<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" aria-hidden="true"></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"
x-text="errorMessage"></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 disabled:opacity-50 disabled:cursor-not-allowed"
hx-get="{{ request.path }}"
hx-target="#dashboard-content"
hx-push-url="true"
hx-indicator="this"
@click="$el.disabled = true"
hx-on::after-request="$el.disabled = false">
<span class="htmx-indicator">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
Retrying...
</span>
<span class="htmx-settled">
<i class="mr-2 fas fa-sync-alt" aria-hidden="true"></i>
Retry
</span>
</button>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div id="toast-container"
class="fixed bottom-4 right-4 z-50 space-y-2"
x-data="{
toasts: [],
add(message, type = 'success') {
const id = Date.now();
this.toasts.push({ id, message, type });
setTimeout(() => this.remove(id), 5000);
},
remove(id) {
this.toasts = this.toasts.filter(t => t.id !== id);
}
}"
@show-toast.window="add($event.detail.message, $event.detail.type)"
@htmx:responseError.window="add($event.detail.error || 'An error occurred', 'error')"
aria-live="polite"
aria-atomic="true">
<template x-for="toast in toasts" :key="toast.id">
<div class="flex items-center p-4 rounded-lg shadow-lg transform transition-all duration-300"
:class="{
'bg-green-600': toast.type === 'success',
'bg-red-600': toast.type === 'error',
'bg-yellow-600': toast.type === 'warning',
'bg-blue-600': toast.type === 'info'
}"
x-transition:enter="ease-out"
x-transition:enter-start="opacity-0 translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="ease-in"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
<div class="flex-1 text-white">
<p class="font-medium" x-text="toast.message"></p>
</div>
<button @click="remove(toast.id)"
class="ml-4 text-white hover:text-white/80"
aria-label="Close notification">
<i class="fas fa-times"></i>
</button>
</div>
</template>
</div>
<!-- HTMX Event Handlers -->
<script>
document.body.addEventListener('htmx:beforeRequest', function(evt) {
const target = evt.detail.target;
if (target.hasAttribute('hx-disabled-elt')) {
const disabledElt = document.querySelector(target.getAttribute('hx-disabled-elt'));
if (disabledElt) {
disabledElt.disabled = true;
}
}
});
document.body.addEventListener('htmx:afterRequest', function(evt) {
const target = evt.detail.target;
if (target.hasAttribute('hx-disabled-elt')) {
const disabledElt = document.querySelector(target.getAttribute('hx-disabled-elt'));
if (disabledElt) {
disabledElt.disabled = false;
}
}
});
document.body.addEventListener('htmx:responseError', function(evt) {
const errorToast = new CustomEvent('show-toast', {
detail: {
message: evt.detail.error || 'An error occurred while processing your request',
type: 'error'
}
});
window.dispatchEvent(errorToast);
});
document.body.addEventListener('htmx:sendError', function(evt) {
const errorToast = new CustomEvent('show-toast', {
detail: {
message: 'Network error: Could not connect to the server',
type: 'error'
}
});
window.dispatchEvent(errorToast);
});
</script>
</div>
{% endblock %}
{% block extra_js %}
<!-- Base HTMX Configuration -->
<script>
document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
});
</script>
<!-- Custom Moderation JS -->
<script src="{% static 'js/moderation.js' %}"></script>
<!-- Enhanced Mobile Styles -->
<style>
@media (max-width: 640px) {
.action-buttons {
@apply flex-col w-full space-y-2;
}
.action-buttons > button {
@apply w-full justify-center;
}
.search-results {
@apply fixed bottom-0 left-0 right-0 max-h-[50vh] overflow-y-auto bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-t-xl shadow-xl;
}
.form-grid {
@apply grid-cols-1;
}
}
/* Touch Device Optimizations */
@media (hover: none) {
.touch-target {
@apply min-h-[44px] min-w-[44px] p-2;
}
.touch-friendly-select {
@apply py-2.5;
}
}
</style>
<!-- Accessibility Improvements -->
<div id="a11y-announcer"
class="sr-only"
aria-live="polite"
aria-atomic="true">
</div>
{% endblock %}