mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:51:10 -05:00
feat: complete monorepo structure with frontend and shared resources
- Add complete backend/ directory with full Django application - Add frontend/ directory with Vite + TypeScript setup ready for Next.js - Add comprehensive shared/ directory with: - Complete documentation and memory-bank archives - Media files and avatars (letters, park/ride images) - Deployment scripts and automation tools - Shared types and utilities - Add architecture/ directory with migration guides - Configure pnpm workspace for monorepo development - Update .gitignore to exclude .django_tailwind_cli/ build artifacts - Preserve all historical documentation in shared/docs/memory-bank/ - Set up proper structure for full-stack development with shared resources
This commit is contained in:
297
backend/templates/moderation/dashboard.html
Normal file
297
backend/templates/moderation/dashboard.html
Normal file
@@ -0,0 +1,297 @@
|
||||
{% 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-hidden 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-xs;
|
||||
}
|
||||
|
||||
.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">
|
||||
{% block moderation_content %}
|
||||
{% include "moderation/partials/dashboard_content.html" %}
|
||||
{% endblock %}
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// 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') {
|
||||
dashboard.showLoading();
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
|
||||
if (evt.detail.target.id === 'dashboard-content') {
|
||||
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>
|
||||
{% endblock %}
|
||||
7
backend/templates/moderation/edit_submission_list.html
Normal file
7
backend/templates/moderation/edit_submission_list.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% extends "moderation/dashboard.html" %}
|
||||
|
||||
{% block moderation_content %}
|
||||
<div id="submissions-content">
|
||||
{% include "moderation/partials/edit_submission_content.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
127
backend/templates/moderation/edit_submissions.html
Normal file
127
backend/templates/moderation/edit_submissions.html
Normal file
@@ -0,0 +1,127 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Moderation - ThrillWiki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-4 mx-auto">
|
||||
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h1 class="mb-6 text-3xl font-bold text-gray-900 dark:text-white">Moderation Queue</h1>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<ul class="flex flex-wrap -mb-px" role="tablist">
|
||||
<li class="mr-2">
|
||||
<button class="tab-button {% if active_tab == 'new' %}active{% endif %}"
|
||||
data-tab="new"
|
||||
hx-get="{% url 'moderation:edit_submissions' %}?tab=new"
|
||||
hx-target="#submissions-content"
|
||||
hx-push-url="true">
|
||||
New
|
||||
{% if new_count %}<span class="ml-2 badge">{{ new_count }}</span>{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="mr-2">
|
||||
<button class="tab-button {% if active_tab == 'approved' %}active{% endif %}"
|
||||
data-tab="approved"
|
||||
hx-get="{% url 'moderation:edit_submissions' %}?tab=approved"
|
||||
hx-target="#submissions-content"
|
||||
hx-push-url="true">
|
||||
Approved
|
||||
</button>
|
||||
</li>
|
||||
<li class="mr-2">
|
||||
<button class="tab-button {% if active_tab == 'rejected' %}active{% endif %}"
|
||||
data-tab="rejected"
|
||||
hx-get="{% url 'moderation:edit_submissions' %}?tab=rejected"
|
||||
hx-target="#submissions-content"
|
||||
hx-push-url="true">
|
||||
Rejected
|
||||
</button>
|
||||
</li>
|
||||
{% if user.role == 'ADMIN' or user.role == 'SUPERUSER' %}
|
||||
<li>
|
||||
<button class="tab-button {% if active_tab == 'escalated' %}active{% endif %}"
|
||||
data-tab="escalated"
|
||||
hx-get="{% url 'moderation:edit_submissions' %}?tab=escalated"
|
||||
hx-target="#submissions-content"
|
||||
hx-push-url="true">
|
||||
Escalated
|
||||
{% if escalated_count %}<span class="ml-2 badge">{{ escalated_count }}</span>{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div id="submissions-content">
|
||||
{% include 'moderation/partials/submission_list.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.tab-button {
|
||||
@apply inline-flex items-center px-4 py-2 text-sm font-medium border-b-2 border-transparent rounded-t-lg hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
@apply text-blue-600 border-blue-600 dark:text-blue-500 dark:border-blue-500;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply px-2 py-1 text-xs font-semibold text-white bg-blue-500 rounded-full;
|
||||
}
|
||||
|
||||
.submission-card {
|
||||
@apply p-4 mb-4 bg-white border rounded-lg shadow dark:bg-gray-700 dark:border-gray-600;
|
||||
}
|
||||
|
||||
.submission-header {
|
||||
@apply flex items-center justify-between mb-2;
|
||||
}
|
||||
|
||||
.submission-title {
|
||||
@apply text-lg font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.submission-meta {
|
||||
@apply text-sm text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.submission-changes {
|
||||
@apply mt-4 space-y-2;
|
||||
}
|
||||
|
||||
.change-item {
|
||||
@apply flex items-start;
|
||||
}
|
||||
|
||||
.change-label {
|
||||
@apply w-32 font-medium text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.change-value {
|
||||
@apply flex-1 text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
@apply flex gap-2 mt-4;
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
@apply px-4 py-2 text-white bg-green-500 rounded-lg hover:bg-green-600;
|
||||
}
|
||||
|
||||
.btn-reject {
|
||||
@apply px-4 py-2 text-white bg-red-500 rounded-lg hover:bg-red-600;
|
||||
}
|
||||
|
||||
.btn-escalate {
|
||||
@apply px-4 py-2 text-white bg-yellow-500 rounded-lg hover:bg-yellow-600;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
135
backend/templates/moderation/partials/coaster_fields.html
Normal file
135
backend/templates/moderation/partials/coaster_fields.html
Normal file
@@ -0,0 +1,135 @@
|
||||
{% load moderation_tags %}
|
||||
|
||||
<div class="p-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<h3 class="mb-4 text-lg font-semibold">Coaster Stats</h3>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Height (ft):
|
||||
</label>
|
||||
<input type="number"
|
||||
name="stats.height_ft"
|
||||
value="{{ stats.height_ft }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Height in feet">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Length (ft):
|
||||
</label>
|
||||
<input type="number"
|
||||
name="stats.length_ft"
|
||||
value="{{ stats.length_ft }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Length in feet">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Speed (mph):
|
||||
</label>
|
||||
<input type="number"
|
||||
name="stats.speed_mph"
|
||||
value="{{ stats.speed_mph }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Speed in mph">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Inversions:
|
||||
</label>
|
||||
<input type="number"
|
||||
name="stats.inversions"
|
||||
value="{{ stats.inversions }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Number of inversions">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Launch Type:
|
||||
</label>
|
||||
<select name="stats.launch_type"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Select launch type</option>
|
||||
<option value="CHAIN_LIFT" {% if stats.launch_type == 'CHAIN_LIFT' %}selected{% endif %}>Chain Lift</option>
|
||||
<option value="LSM" {% if stats.launch_type == 'LSM' %}selected{% endif %}>LSM</option>
|
||||
<option value="HYDRAULIC" {% if stats.launch_type == 'HYDRAULIC' %}selected{% endif %}>Hydraulic</option>
|
||||
<option value="TIRE_DRIVE" {% if stats.launch_type == 'TIRE_DRIVE' %}selected{% endif %}>Tire Drive</option>
|
||||
<option value="CABLE_LIFT" {% if stats.launch_type == 'CABLE_LIFT' %}selected{% endif %}>Cable Lift</option>
|
||||
<option value="OTHER" {% if stats.launch_type == 'OTHER' %}selected{% endif %}>Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Track Material:
|
||||
</label>
|
||||
<select name="stats.track_material"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Select track material</option>
|
||||
<option value="STEEL" {% if stats.track_material == 'STEEL' %}selected{% endif %}>Steel</option>
|
||||
<option value="WOOD" {% if stats.track_material == 'WOOD' %}selected{% endif %}>Wood</option>
|
||||
<option value="HYBRID" {% if stats.track_material == 'HYBRID' %}selected{% endif %}>Hybrid</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Coaster Type:
|
||||
</label>
|
||||
<select name="stats.roller_coaster_type"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Select coaster type</option>
|
||||
<option value="SIT_DOWN" {% if stats.roller_coaster_type == 'SIT_DOWN' %}selected{% endif %}>Sit Down</option>
|
||||
<option value="INVERTED" {% if stats.roller_coaster_type == 'INVERTED' %}selected{% endif %}>Inverted</option>
|
||||
<option value="FLYING" {% if stats.roller_coaster_type == 'FLYING' %}selected{% endif %}>Flying</option>
|
||||
<option value="STAND_UP" {% if stats.roller_coaster_type == 'STAND_UP' %}selected{% endif %}>Stand Up</option>
|
||||
<option value="WING" {% if stats.roller_coaster_type == 'WING' %}selected{% endif %}>Wing</option>
|
||||
<option value="OTHER" {% if stats.roller_coaster_type == 'OTHER' %}selected{% endif %}>Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Number of Trains:
|
||||
</label>
|
||||
<input type="number"
|
||||
name="stats.trains_count"
|
||||
value="{{ stats.trains_count }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Number of trains">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Cars per Train:
|
||||
</label>
|
||||
<input type="number"
|
||||
name="stats.cars_per_train"
|
||||
value="{{ stats.cars_per_train }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Cars per train">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
Seats per Car:
|
||||
</label>
|
||||
<input type="number"
|
||||
name="stats.seats_per_car"
|
||||
value="{{ stats.seats_per_car }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Seats per car">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
222
backend/templates/moderation/partials/dashboard_content.html
Normal file
222
backend/templates/moderation/partials/dashboard_content.html
Normal file
@@ -0,0 +1,222 @@
|
||||
{% load static %}
|
||||
|
||||
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-gray-200">Moderation Dashboard</h1>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between p-4 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">
|
||||
<a href="{% url 'moderation:submission_list' %}?status=PENDING"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'PENDING' or not request.GET.status %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=PENDING"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-clock"></i>
|
||||
<span>Pending</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=APPROVED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'APPROVED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=APPROVED"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-check"></i>
|
||||
<span>Approved</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=REJECTED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'REJECTED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=REJECTED"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-times"></i>
|
||||
<span>Rejected</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=ESCALATED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'ESCALATED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=ESCALATED"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-exclamation-triangle"></i>
|
||||
<span>Escalated</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 text-gray-600 dark:text-gray-400 hover:text-blue-900 hover:bg-blue-100 dark:hover:text-blue-400 dark:hover:bg-blue-900/40"
|
||||
hx-get="{{ request.get_full_path }}"
|
||||
hx-target="#dashboard-content"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-sync-alt"></i>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</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="mb-6"
|
||||
x-data="{ showFilters: false }"
|
||||
hx-get="{% url 'moderation:submission_list' %}"
|
||||
hx-target="#dashboard-content"
|
||||
hx-trigger="change from:select"
|
||||
hx-push-url="true"
|
||||
aria-label="Submission filters">
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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-hidden 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="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-hidden 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-hidden 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>
|
||||
|
||||
<div class="space-y-4">
|
||||
{% include "moderation/partials/submission_list.html" with submissions=submissions user=user %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"
|
||||
x-data="{
|
||||
show: false,
|
||||
message: '',
|
||||
icon: 'check',
|
||||
color: 'green',
|
||||
showToast(msg, icn = 'check', clr = 'green') {
|
||||
this.message = msg;
|
||||
this.icon = icn;
|
||||
this.color = clr;
|
||||
this.show = true;
|
||||
setTimeout(() => this.show = false, 3000);
|
||||
}
|
||||
}"
|
||||
@submission-approved.window="showToast('Submission approved successfully', 'check', 'green')"
|
||||
@submission-rejected.window="showToast('Submission rejected', 'times', 'red')"
|
||||
@submission-escalated.window="showToast('Submission escalated', 'exclamation-triangle', 'yellow')"
|
||||
@submission-updated.window="showToast('Changes saved successfully', 'check', 'blue')"
|
||||
class="fixed z-50 bottom-4 right-4">
|
||||
|
||||
<div x-show="show"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-full"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 translate-y-full"
|
||||
class="flex items-center w-full max-w-xs p-4 text-gray-400 bg-gray-800 rounded-lg shadow">
|
||||
<div class="inline-flex items-center justify-center shrink-0 w-8 h-8 rounded-lg"
|
||||
:class="{
|
||||
'text-green-400 bg-green-900/40': color === 'green',
|
||||
'text-red-400 bg-red-900/40': color === 'red',
|
||||
'text-yellow-400 bg-yellow-900/40': color === 'yellow',
|
||||
'text-blue-400 bg-blue-900/40': color === 'blue'
|
||||
}">
|
||||
<i class="fas" :class="'fa-' + icon"></i>
|
||||
</div>
|
||||
<div class="ml-3 text-sm font-normal" x-text="message"></div>
|
||||
<button type="button"
|
||||
class="ml-auto -mx-1.5 -my-1.5 text-gray-400 hover:text-gray-300 rounded-lg p-1.5 inline-flex h-8 w-8"
|
||||
@click="show = false">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail.successful) {
|
||||
const path = evt.detail.requestConfig.path;
|
||||
let event;
|
||||
|
||||
if (path.includes('approve')) {
|
||||
event = new CustomEvent('submission-approved');
|
||||
} else if (path.includes('reject')) {
|
||||
event = new CustomEvent('submission-rejected');
|
||||
} else if (path.includes('escalate')) {
|
||||
event = new CustomEvent('submission-escalated');
|
||||
} else if (path.includes('edit')) {
|
||||
event = new CustomEvent('submission-updated');
|
||||
}
|
||||
|
||||
if (event) {
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<div class="w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
|
||||
{% if designers %}
|
||||
{% for designer in designers %}
|
||||
<button type="button"
|
||||
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
|
||||
onclick="selectDesignerForSubmission('{{ designer.id }}', '{{ designer.name|escapejs }}', '{{ submission_id }}')">
|
||||
{{ designer.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
|
||||
{% if search_term %}
|
||||
No designers found
|
||||
{% else %}
|
||||
Start typing to search...
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectDesignerForSubmission(id, name, submissionId) {
|
||||
// Debug logging
|
||||
console.log('Selecting designer:', {id, name, submissionId});
|
||||
|
||||
// Find elements
|
||||
const designerInput = document.querySelector(`#designer-input-${submissionId}`);
|
||||
const searchInput = document.querySelector(`#designer-search-${submissionId}`);
|
||||
const resultsDiv = document.querySelector(`#designer-search-results-${submissionId}`);
|
||||
|
||||
// Debug logging
|
||||
console.log('Found elements:', {
|
||||
designerInput: designerInput?.id,
|
||||
searchInput: searchInput?.id,
|
||||
resultsDiv: resultsDiv?.id
|
||||
});
|
||||
|
||||
// Update hidden input
|
||||
if (designerInput) {
|
||||
designerInput.value = id;
|
||||
console.log('Updated designer input value:', designerInput.value);
|
||||
}
|
||||
|
||||
// Update search input
|
||||
if (searchInput) {
|
||||
searchInput.value = name;
|
||||
console.log('Updated search input value:', searchInput.value);
|
||||
}
|
||||
|
||||
// Clear results
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = '';
|
||||
console.log('Cleared results div');
|
||||
}
|
||||
}
|
||||
|
||||
// Close search results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const searchResults = document.querySelectorAll('[id^="designer-search-results-"]');
|
||||
searchResults.forEach(function(resultsDiv) {
|
||||
const searchInput = document.querySelector(`#designer-search-${resultsDiv.id.split('-').pop()}`);
|
||||
if (!resultsDiv.contains(e.target) && e.target !== searchInput) {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,2 @@
|
||||
{% include "moderation/partials/filters.html" %}
|
||||
{% include "moderation/partials/submission_list.html" %}
|
||||
132
backend/templates/moderation/partials/edit_submission_form.html
Normal file
132
backend/templates/moderation/partials/edit_submission_form.html
Normal file
@@ -0,0 +1,132 @@
|
||||
{% load moderation_tags %}
|
||||
|
||||
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50"
|
||||
id="edit-form-{{ submission.id }}">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-300">
|
||||
Edit Submission
|
||||
</h3>
|
||||
|
||||
<form hx-post="{% url 'moderation:edit_submission' submission.id %}"
|
||||
hx-target="#submissions-content"
|
||||
class="space-y-4">
|
||||
|
||||
{% for field, value in changes.items %}
|
||||
{% if field != 'model_name' %}
|
||||
<div class="p-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
{% if field == 'stats' %}
|
||||
Coaster Stats:
|
||||
{% elif field == 'park_area' %}
|
||||
Park Area:
|
||||
{% elif field == 'ride_model' %}
|
||||
Ride Model:
|
||||
{% elif field == 'min_height_in' %}
|
||||
Minimum Height:
|
||||
{% elif field == 'max_height_in' %}
|
||||
Maximum Height:
|
||||
{% elif field == 'capacity_per_hour' %}
|
||||
Hourly Capacity:
|
||||
{% elif field == 'ride_duration_seconds' %}
|
||||
Ride Duration:
|
||||
{% elif field == 'opening_date' %}
|
||||
Opening Date:
|
||||
{% elif field == 'closing_date' %}
|
||||
Closing Date:
|
||||
{% elif field == 'status_since' %}
|
||||
Status Since:
|
||||
{% elif field == 'operating_season' %}
|
||||
Operating Season:
|
||||
{% elif field == 'size_acres' %}
|
||||
Size (Acres):
|
||||
{% elif field == 'post_closing_status' %}
|
||||
Post-Closing Status:
|
||||
{% else %}
|
||||
{{ field|title }}:
|
||||
{% endif %}
|
||||
</label>
|
||||
|
||||
{% if field == 'stats' %}
|
||||
<div class="space-y-2">
|
||||
{% for stat_name, stat_value in value.items %}
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-700 dark:text-gray-400">{{ stat_name|title }}:</label>
|
||||
<input type="text"
|
||||
name="stats.{{ stat_name }}"
|
||||
value="{{ stat_value }}"
|
||||
class="flex-1 px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif field == 'park' %}
|
||||
<select name="{{ field }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
{% for park_id, park_name in parks %}
|
||||
<option value="{{ park_id }}" {% if park_id == value %}selected{% endif %}>{{ park_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% elif field == 'designer' %}
|
||||
<select name="{{ field }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<option value="">None</option>
|
||||
{% for designer_id, designer_name in designers %}
|
||||
<option value="{{ designer_id }}" {% if designer_id == value %}selected{% endif %}>{{ designer_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% elif field == 'manufacturer' %}
|
||||
<select name="{{ field }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<option value="">None</option>
|
||||
{% for manufacturer_id, manufacturer_name in manufacturers %}
|
||||
<option value="{{ manufacturer_id }}" {% if manufacturer_id == value %}selected{% endif %}>{{ manufacturer_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% elif field == 'ride_model' %}
|
||||
<select name="{{ field }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<option value="">None</option>
|
||||
{% for model_id, model_name in ride_models %}
|
||||
<option value="{{ model_id }}" {% if model_id == value %}selected{% endif %}>{{ model_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% elif field == 'park_area' %}
|
||||
<select name="{{ field }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
<option value="">None</option>
|
||||
{% for area_id, area_name in park_areas %}
|
||||
<option value="{{ area_id }}" {% if area_id == value %}selected{% endif %}>{{ area_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="{% if field == 'opening_date' or field == 'closing_date' or field == 'status_since' %}date{% else %}text{% endif %}"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="p-4 border rounded-lg bg-blue-50 dark:bg-blue-900/30 border-blue-200/50 dark:border-blue-700/50">
|
||||
<label class="block mb-2 text-sm font-medium text-blue-900 dark:text-blue-300">
|
||||
Notes (required):
|
||||
</label>
|
||||
<textarea name="notes"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg resize-none dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50"
|
||||
rows="3"
|
||||
required
|
||||
placeholder="Explain why you're editing this submission"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button"
|
||||
class="px-4 py-2 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
@click="showEditForm = false">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 font-medium text-white transition-all duration-200 bg-blue-600 rounded-lg hover:bg-blue-500 dark:bg-blue-700 dark:hover:bg-blue-600">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
44
backend/templates/moderation/partials/filters.html
Normal file
44
backend/templates/moderation/partials/filters.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<div class="mb-6 filters">
|
||||
<form class="flex flex-wrap items-end gap-4"
|
||||
hx-get="{% url 'moderation:submission_list' %}"
|
||||
hx-target="#submissions-content"
|
||||
hx-trigger="change"
|
||||
hx-push-url="true">
|
||||
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status
|
||||
</label>
|
||||
<select name="status" class="w-full form-select">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="PENDING" {% if request.GET.status == 'PENDING' %}selected{% endif %}>Pending</option>
|
||||
<option value="APPROVED" {% if request.GET.status == 'APPROVED' %}selected{% endif %}>Approved</option>
|
||||
<option value="REJECTED" {% if request.GET.status == 'REJECTED' %}selected{% endif %}>Rejected</option>
|
||||
<option value="ESCALATED" {% if request.GET.status == 'ESCALATED' %}selected{% endif %}>Escalated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Type
|
||||
</label>
|
||||
<select name="type" class="w-full form-select">
|
||||
<option value="">All Types</option>
|
||||
<option value="CREATE" {% if request.GET.type == 'CREATE' %}selected{% endif %}>New Submissions</option>
|
||||
<option value="UPDATE" {% if request.GET.type == 'UPDATE' %}selected{% endif %}>Edit Submissions</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Content Type
|
||||
</label>
|
||||
<select name="content_type" class="w-full form-select">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
129
backend/templates/moderation/partials/filters_store.html
Normal file
129
backend/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
backend/templates/moderation/partials/loading_skeleton.html
Normal file
66
backend/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>
|
||||
48
backend/templates/moderation/partials/location_map.html
Normal file
48
backend/templates/moderation/partials/location_map.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% load moderation_tags %}
|
||||
|
||||
<div class="p-4 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-300">Location</h3>
|
||||
|
||||
<!-- Map Container -->
|
||||
<div class="h-[300px] w-full rounded-lg border border-gray-300 dark:border-gray-600"
|
||||
id="viewMap-{{ submission.id }}"
|
||||
x-init="setTimeout(() => {
|
||||
const map = L.map('viewMap-{{ submission.id }}');
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map);
|
||||
|
||||
{% if submission.changes.latitude and submission.changes.longitude %}
|
||||
const lat = {{ submission.changes.latitude }};
|
||||
const lng = {{ submission.changes.longitude }};
|
||||
map.setView([lat, lng], 13);
|
||||
L.marker([lat, lng]).addTo(map);
|
||||
{% else %}
|
||||
map.setView([0, 0], 2);
|
||||
{% endif %}
|
||||
}, 100)"></div>
|
||||
|
||||
<!-- Address Display -->
|
||||
<div class="mt-4 space-y-1">
|
||||
{% if submission.changes.street_address %}
|
||||
<div class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<i class="w-5 mr-2 fas fa-map-marker-alt"></i>
|
||||
{{ submission.changes.street_address }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<i class="w-5 mr-2 fas fa-city"></i>
|
||||
{% if submission.changes.city %}{{ submission.changes.city }}{% endif %}
|
||||
{% if submission.changes.state %}, {{ submission.changes.state }}{% endif %}
|
||||
{% if submission.changes.postal_code %} {{ submission.changes.postal_code }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% if submission.changes.country %}
|
||||
<div class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<i class="w-5 mr-2 fas fa-globe"></i>
|
||||
{{ submission.changes.country }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
414
backend/templates/moderation/partials/location_widget.html
Normal file
414
backend/templates/moderation/partials/location_widget.html
Normal file
@@ -0,0 +1,414 @@
|
||||
{% load static %}
|
||||
|
||||
<style>
|
||||
/* Ensure map container and its elements stay below other UI elements */
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
.leaflet-control {
|
||||
z-index: 2 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="p-4 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-300">Location</h3>
|
||||
|
||||
<div class="location-widget" id="locationWidget-{{ submission.id }}">
|
||||
{# Search Form #}
|
||||
<div class="relative mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Search Location
|
||||
</label>
|
||||
<input type="text"
|
||||
id="locationSearch-{{ submission.id }}"
|
||||
class="relative w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
|
||||
placeholder="Search for a location..."
|
||||
autocomplete="off"
|
||||
style="z-index: 10;">
|
||||
<div id="searchResults-{{ submission.id }}"
|
||||
style="position: absolute; top: 100%; left: 0; right: 0; z-index: 1000;"
|
||||
class="hidden w-full mt-1 overflow-auto bg-white border rounded-md shadow-lg max-h-60 dark:bg-gray-700 dark:border-gray-600">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Map Container #}
|
||||
<div class="relative mb-4" style="z-index: 1;">
|
||||
<div id="locationMap-{{ submission.id }}"
|
||||
class="h-[300px] w-full rounded-lg border border-gray-300 dark:border-gray-600"></div>
|
||||
</div>
|
||||
|
||||
{# Location Form Fields #}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Street Address
|
||||
</label>
|
||||
<input type="text"
|
||||
name="street_address"
|
||||
id="streetAddress-{{ submission.id }}"
|
||||
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
|
||||
value="{{ submission.changes.street_address }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
City
|
||||
</label>
|
||||
<input type="text"
|
||||
name="city"
|
||||
id="city-{{ submission.id }}"
|
||||
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
|
||||
value="{{ submission.changes.city }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
State/Region
|
||||
</label>
|
||||
<input type="text"
|
||||
name="state"
|
||||
id="state-{{ submission.id }}"
|
||||
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
|
||||
value="{{ submission.changes.state }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Country
|
||||
</label>
|
||||
<input type="text"
|
||||
name="country"
|
||||
id="country-{{ submission.id }}"
|
||||
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
|
||||
value="{{ submission.changes.country }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Postal Code
|
||||
</label>
|
||||
<input type="text"
|
||||
name="postal_code"
|
||||
id="postalCode-{{ submission.id }}"
|
||||
class="w-full px-4 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 form-input"
|
||||
value="{{ submission.changes.postal_code }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Hidden Coordinate Fields #}
|
||||
<div class="hidden">
|
||||
<input type="hidden" name="latitude" id="latitude-{{ submission.id }}" value="{{ submission.changes.latitude }}">
|
||||
<input type="hidden" name="longitude" id="longitude-{{ submission.id }}" value="{{ submission.changes.longitude }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let maps = {};
|
||||
let markers = {};
|
||||
const searchInput = document.getElementById('locationSearch-{{ submission.id }}');
|
||||
const searchResults = document.getElementById('searchResults-{{ submission.id }}');
|
||||
let searchTimeout;
|
||||
|
||||
// Initialize form fields with existing values
|
||||
const fields = {
|
||||
city: '{{ submission.changes.city|default:"" }}',
|
||||
state: '{{ submission.changes.state|default:"" }}',
|
||||
country: '{{ submission.changes.country|default:"" }}',
|
||||
postal_code: '{{ submission.changes.postal_code|default:"" }}',
|
||||
street_address: '{{ submission.changes.street_address|default:"" }}',
|
||||
latitude: '{{ submission.changes.latitude|default:"" }}',
|
||||
longitude: '{{ submission.changes.longitude|default:"" }}'
|
||||
};
|
||||
|
||||
Object.entries(fields).forEach(([field, value]) => {
|
||||
const element = document.getElementById(`${field}-{{ submission.id }}`);
|
||||
if (element) {
|
||||
element.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial search input value if location exists
|
||||
if (fields.street_address || fields.city) {
|
||||
const parts = [
|
||||
fields.street_address,
|
||||
fields.city,
|
||||
fields.state,
|
||||
fields.country
|
||||
].filter(Boolean);
|
||||
searchInput.value = parts.join(', ');
|
||||
}
|
||||
|
||||
function normalizeCoordinate(value, maxDigits, decimalPlaces) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const rounded = Number(value).toFixed(decimalPlaces);
|
||||
const strValue = rounded.replace('.', '').replace('-', '');
|
||||
const strippedValue = strValue.replace(/0+$/, '');
|
||||
|
||||
if (strippedValue.length > maxDigits) {
|
||||
return Number(Number(value).toFixed(decimalPlaces - 1));
|
||||
}
|
||||
|
||||
return rounded;
|
||||
} catch (error) {
|
||||
console.error('Coordinate normalization failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function validateCoordinates(lat, lng) {
|
||||
const normalizedLat = normalizeCoordinate(lat, 9, 6);
|
||||
const normalizedLng = normalizeCoordinate(lng, 10, 6);
|
||||
|
||||
if (normalizedLat === null || normalizedLng === null) {
|
||||
throw new Error('Invalid coordinate format');
|
||||
}
|
||||
|
||||
const parsedLat = parseFloat(normalizedLat);
|
||||
const parsedLng = parseFloat(normalizedLng);
|
||||
|
||||
if (parsedLat < -90 || parsedLat > 90) {
|
||||
throw new Error('Latitude must be between -90 and 90 degrees.');
|
||||
}
|
||||
if (parsedLng < -180 || parsedLng > 180) {
|
||||
throw new Error('Longitude must be between -180 and 180 degrees.');
|
||||
}
|
||||
|
||||
return { lat: normalizedLat, lng: normalizedLng };
|
||||
}
|
||||
|
||||
function initMap() {
|
||||
const submissionId = '{{ submission.id }}';
|
||||
const mapId = `locationMap-${submissionId}`;
|
||||
const mapContainer = document.getElementById(mapId);
|
||||
|
||||
if (!mapContainer) {
|
||||
console.error(`Map container ${mapId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If map already exists, remove it
|
||||
if (maps[submissionId]) {
|
||||
maps[submissionId].remove();
|
||||
delete maps[submissionId];
|
||||
delete markers[submissionId];
|
||||
}
|
||||
|
||||
// Create new map
|
||||
maps[submissionId] = L.map(mapId);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(maps[submissionId]);
|
||||
|
||||
// Initialize with existing coordinates if available
|
||||
const initialLat = fields.latitude;
|
||||
const initialLng = fields.longitude;
|
||||
|
||||
if (initialLat && initialLng) {
|
||||
try {
|
||||
const normalized = validateCoordinates(initialLat, initialLng);
|
||||
maps[submissionId].setView([normalized.lat, normalized.lng], 13);
|
||||
addMarker(normalized.lat, normalized.lng);
|
||||
} catch (error) {
|
||||
console.error('Invalid initial coordinates:', error);
|
||||
maps[submissionId].setView([0, 0], 2);
|
||||
}
|
||||
} else {
|
||||
maps[submissionId].setView([0, 0], 2);
|
||||
}
|
||||
|
||||
// Handle map clicks
|
||||
maps[submissionId].on('click', async function(e) {
|
||||
try {
|
||||
const normalized = validateCoordinates(e.latlng.lat, e.latlng.lng);
|
||||
const response = await fetch(`/parks/search/reverse-geocode/?lat=${normalized.lat}&lon=${normalized.lng}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Geocoding request failed');
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
updateLocation(normalized.lat, normalized.lng, data);
|
||||
} catch (error) {
|
||||
console.error('Location update failed:', error);
|
||||
alert(error.message || 'Failed to update location. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addMarker(lat, lng) {
|
||||
const submissionId = '{{ submission.id }}';
|
||||
if (markers[submissionId]) {
|
||||
markers[submissionId].remove();
|
||||
}
|
||||
markers[submissionId] = L.marker([lat, lng]).addTo(maps[submissionId]);
|
||||
maps[submissionId].setView([lat, lng], 13);
|
||||
}
|
||||
|
||||
function updateLocation(lat, lng, data) {
|
||||
try {
|
||||
const normalized = validateCoordinates(lat, lng);
|
||||
const submissionId = '{{ submission.id }}';
|
||||
|
||||
// Update coordinates
|
||||
document.getElementById(`latitude-${submissionId}`).value = normalized.lat;
|
||||
document.getElementById(`longitude-${submissionId}`).value = normalized.lng;
|
||||
|
||||
// Update marker
|
||||
addMarker(normalized.lat, normalized.lng);
|
||||
|
||||
// Update form fields with English names where available
|
||||
const address = data.address || {};
|
||||
document.getElementById(`streetAddress-${submissionId}`).value =
|
||||
`${address.house_number || ''} ${address.road || address.street || ''}`.trim() || '';
|
||||
document.getElementById(`city-${submissionId}`).value =
|
||||
address.city || address.town || address.village || '';
|
||||
document.getElementById(`state-${submissionId}`).value =
|
||||
address.state || address.region || '';
|
||||
document.getElementById(`country-${submissionId}`).value = address.country || '';
|
||||
document.getElementById(`postalCode-${submissionId}`).value = address.postcode || '';
|
||||
|
||||
// Update search input
|
||||
const locationString-3 = [
|
||||
document.getElementById(`streetAddress-${submissionId}`).value,
|
||||
document.getElementById(`city-${submissionId}`).value,
|
||||
document.getElementById(`state-${submissionId}`).value,
|
||||
document.getElementById(`country-${submissionId}`).value
|
||||
].filter(Boolean).join(', ');
|
||||
searchInput.value = locationString;
|
||||
} catch (error) {
|
||||
console.error('Location update failed:', error);
|
||||
alert(error.message || 'Failed to update location. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function selectLocation(result) {
|
||||
if (!result) return;
|
||||
|
||||
try {
|
||||
const lat = parseFloat(result.lat);
|
||||
const lon = parseFloat(result.lon);
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
throw new Error('Invalid coordinates in search result');
|
||||
}
|
||||
|
||||
const normalized = validateCoordinates(lat, lon);
|
||||
|
||||
// Create a normalized address object
|
||||
const address = {
|
||||
name: result.display_name || result.name || '',
|
||||
address: {
|
||||
house_number: result.address ? result.address.house_number : '',
|
||||
road: result.address ? (result.address.road || result.address.street) : '',
|
||||
city: result.address ? (result.address.city || result.address.town || result.address.village) : '',
|
||||
state: result.address ? (result.address.state || result.address.region) : '',
|
||||
country: result.address ? result.address.country : '',
|
||||
postcode: result.address ? result.address.postcode : ''
|
||||
}
|
||||
};
|
||||
|
||||
updateLocation(normalized.lat, normalized.lng, address);
|
||||
searchResults.classList.add('hidden');
|
||||
searchInput.value = address.name;
|
||||
} catch (error) {
|
||||
console.error('Location selection failed:', error);
|
||||
alert(error.message || 'Failed to select location. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle location search
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = this.value.trim();
|
||||
|
||||
if (!query) {
|
||||
searchResults.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async function() {
|
||||
try {
|
||||
const response = await fetch(`/parks/search/location/?q=${encodeURIComponent(query)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Search request failed');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.results && data.results.length > 0) {
|
||||
const resultsHtml = data.results.map((result, index) => `
|
||||
<div class="p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
data-result-index="${index}">
|
||||
<div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
${(result.address && result.address.city) ? result.address.city + ', ' : ''}${(result.address && result.address.country) || ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
searchResults.innerHTML = resultsHtml;
|
||||
searchResults.classList.remove('hidden');
|
||||
|
||||
// Store results data
|
||||
searchResults.dataset.results = JSON.stringify(data.results);
|
||||
|
||||
// Add click handlers
|
||||
searchResults.querySelectorAll('[data-result-index]').forEach(el => {
|
||||
el.addEventListener('click', function() {
|
||||
const results = JSON.parse(searchResults.dataset.results);
|
||||
const result = results[this.dataset.resultIndex];
|
||||
selectLocation(result);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
searchResults.innerHTML = '<div class="p-2 text-gray-500 dark:text-gray-400">No results found</div>';
|
||||
searchResults.classList.remove('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
searchResults.innerHTML = '<div class="p-2 text-red-500 dark:text-red-400">Search failed. Please try again.</div>';
|
||||
searchResults.classList.remove('hidden');
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Hide search results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!searchResults.contains(e.target) && e.target !== searchInput) {
|
||||
searchResults.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize map when the element becomes visible
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
|
||||
const mapContainer = document.getElementById(`locationMap-{{ submission.id }}`);
|
||||
if (mapContainer && window.getComputedStyle(mapContainer).display !== 'none') {
|
||||
initMap();
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const mapContainer = document.getElementById(`locationMap-{{ submission.id }}`);
|
||||
if (mapContainer) {
|
||||
observer.observe(mapContainer.parentElement.parentElement, { attributes: true });
|
||||
|
||||
// Also initialize immediately if the container is already visible
|
||||
if (window.getComputedStyle(mapContainer).display !== 'none') {
|
||||
initMap();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<div class="w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
|
||||
{% if manufacturers %}
|
||||
{% for manufacturer in manufacturers %}
|
||||
<button type="button"
|
||||
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
|
||||
onclick="selectManufacturerForSubmission('{{ manufacturer.id }}', '{{ manufacturer.name|escapejs }}', '{{ submission_id }}')">
|
||||
{{ manufacturer.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
|
||||
{% if search_term %}
|
||||
No manufacturers found
|
||||
{% else %}
|
||||
Start typing to search...
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectManufacturerForSubmission(id, name, submissionId) {
|
||||
// Debug logging
|
||||
console.log('Selecting manufacturer:', {id, name, submissionId});
|
||||
|
||||
// Find elements
|
||||
const manufacturerInput = document.querySelector(`#manufacturer-input-${submissionId}`);
|
||||
const searchInput = document.querySelector(`#manufacturer-search-${submissionId}`);
|
||||
const resultsDiv = document.querySelector(`#manufacturer-search-results-${submissionId}`);
|
||||
|
||||
// Debug logging
|
||||
console.log('Found elements:', {
|
||||
manufacturerInput: manufacturerInput?.id,
|
||||
searchInput: searchInput?.id,
|
||||
resultsDiv: resultsDiv?.id
|
||||
});
|
||||
|
||||
// Update hidden input
|
||||
if (manufacturerInput) {
|
||||
manufacturerInput.value = id;
|
||||
console.log('Updated manufacturer input value:', manufacturerInput.value);
|
||||
}
|
||||
|
||||
// Update search input
|
||||
if (searchInput) {
|
||||
searchInput.value = name;
|
||||
console.log('Updated search input value:', searchInput.value);
|
||||
}
|
||||
|
||||
// Clear results
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = '';
|
||||
console.log('Cleared results div');
|
||||
}
|
||||
}
|
||||
|
||||
// Close search results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const searchResults = document.querySelectorAll('[id^="manufacturer-search-results-"]');
|
||||
searchResults.forEach(function(resultsDiv) {
|
||||
const searchInput = document.querySelector(`#manufacturer-search-${resultsDiv.id.split('-').pop()}`);
|
||||
if (!resultsDiv.contains(e.target) && e.target !== searchInput) {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
43
backend/templates/moderation/partials/moderation_nav.html
Normal file
43
backend/templates/moderation/partials/moderation_nav.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{% load static %}
|
||||
|
||||
<div class="flex items-center p-4 space-x-4 bg-gray-900 rounded-lg">
|
||||
<a href="{% url 'moderation:submission_list' %}?status=NEW"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'NEW' or not request.GET.status %}bg-blue-900/40 text-blue-400{% else %}text-gray-400 hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=NEW"
|
||||
hx-target="body"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-clock"></i>
|
||||
<span>Pending</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=APPROVED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'APPROVED' %}bg-blue-900/40 text-blue-400{% else %}text-gray-400 hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=APPROVED"
|
||||
hx-target="body"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-check"></i>
|
||||
<span>Approved</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=REJECTED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'REJECTED' %}bg-blue-900/40 text-blue-400{% else %}text-gray-400 hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=REJECTED"
|
||||
hx-target="body"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-times"></i>
|
||||
<span>Rejected</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=ESCALATED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'ESCALATED' %}bg-blue-900/40 text-blue-400{% else %}text-gray-400 hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=ESCALATED"
|
||||
hx-target="body"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-exclamation-triangle"></i>
|
||||
<span>Escalated</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,73 @@
|
||||
<div class="w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
|
||||
{% if parks %}
|
||||
{% for park in parks %}
|
||||
<button type="button"
|
||||
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
|
||||
onclick="selectParkForSubmission('{{ park.id }}', '{{ park.name|escapejs }}', '{{ submission_id }}')">
|
||||
{{ park.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
|
||||
{% if search_term %}
|
||||
No parks found
|
||||
{% else %}
|
||||
Start typing to search...
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectParkForSubmission(id, name, submissionId) {
|
||||
// Debug logging
|
||||
console.log('Selecting park:', {id, name, submissionId});
|
||||
|
||||
// Find elements
|
||||
const parkInput = document.querySelector(`#park-input-${submissionId}`);
|
||||
const searchInput = document.querySelector(`#park-search-${submissionId}`);
|
||||
const resultsDiv = document.querySelector(`#park-search-results-${submissionId}`);
|
||||
|
||||
// Debug logging
|
||||
console.log('Found elements:', {
|
||||
parkInput: parkInput?.id,
|
||||
searchInput: searchInput?.id,
|
||||
resultsDiv: resultsDiv?.id
|
||||
});
|
||||
|
||||
// Update hidden input
|
||||
if (parkInput) {
|
||||
parkInput.value = id;
|
||||
console.log('Updated park input value:', parkInput.value);
|
||||
}
|
||||
|
||||
// Update search input
|
||||
if (searchInput) {
|
||||
searchInput.value = name;
|
||||
console.log('Updated search input value:', searchInput.value);
|
||||
}
|
||||
|
||||
// Clear results
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = '';
|
||||
console.log('Cleared results div');
|
||||
}
|
||||
|
||||
// Trigger park areas update
|
||||
if (parkInput) {
|
||||
htmx.trigger(parkInput, 'change');
|
||||
console.log('Triggered change event');
|
||||
}
|
||||
}
|
||||
|
||||
// Close search results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const searchResults = document.querySelectorAll('[id^="park-search-results-"]');
|
||||
searchResults.forEach(function(resultsDiv) {
|
||||
const searchInput = document.querySelector(`#park-search-${resultsDiv.id.split('-').pop()}`);
|
||||
if (!resultsDiv.contains(e.target) && e.target !== searchInput) {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
110
backend/templates/moderation/partials/photo_submission.html
Normal file
110
backend/templates/moderation/partials/photo_submission.html
Normal file
@@ -0,0 +1,110 @@
|
||||
<div class="p-6 submission-card" id="submission-{{ submission.id }}">
|
||||
<div class="mb-4 submission-header">
|
||||
<div>
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<span class="status-badge
|
||||
{% if submission.status == 'PENDING' %}status-pending
|
||||
{% elif submission.status == 'APPROVED' %}status-approved
|
||||
{% elif submission.status == 'REJECTED' %}status-rejected
|
||||
{% elif submission.status == 'ESCALATED' %}status-escalated{% endif %}">
|
||||
<i class="mr-1.5 fas fa-{% if submission.status == 'PENDING' %}clock
|
||||
{% elif submission.status == 'APPROVED' %}check
|
||||
{% elif submission.status == 'REJECTED' %}times
|
||||
{% elif submission.status == 'ESCALATED' %}exclamation{% endif %}"></i>
|
||||
{{ submission.get_status_display }}
|
||||
</span>
|
||||
Photo for {{ submission.content_object }}
|
||||
</h3>
|
||||
<div class="mt-1 submission-meta">
|
||||
<span class="inline-flex items-center">
|
||||
<i class="mr-1 fas fa-user"></i>
|
||||
{{ submission.user.username }}
|
||||
</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span class="inline-flex items-center">
|
||||
<i class="mr-1 fas fa-clock"></i>
|
||||
{{ submission.created_at|date:"M d, Y H:i" }}
|
||||
</span>
|
||||
{% if submission.date_taken %}
|
||||
<span class="mx-2">•</span>
|
||||
<span class="inline-flex items-center">
|
||||
<i class="mr-1 fas fa-calendar"></i>
|
||||
Taken: {{ submission.date_taken|date:"M d, Y" }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-hidden bg-gray-100 rounded-lg aspect-w-16 aspect-h-9 dark:bg-gray-800">
|
||||
<img src="{{ submission.photo.url }}"
|
||||
alt="{{ submission.caption|default:'Submitted photo' }}"
|
||||
class="object-contain w-full h-full">
|
||||
</div>
|
||||
|
||||
{% if submission.caption %}
|
||||
<div class="p-4 mt-2 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">Caption:</div>
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-400">{{ submission.caption }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if submission.notes %}
|
||||
<div class="p-4 mt-4 border rounded-lg bg-blue-50 dark:bg-blue-900/30 border-blue-200/50 dark:border-blue-700/50">
|
||||
<div class="text-sm font-medium text-blue-900 dark:text-blue-300">Review Notes:</div>
|
||||
<div class="mt-1.5 text-blue-800 dark:text-blue-200">{{ submission.notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if submission.status == 'PENDING' or submission.status == 'ESCALATED' and user.role in 'ADMIN,SUPERUSER' %}
|
||||
<div class="mt-4 review-notes" x-data="{ showNotes: false }">
|
||||
<textarea x-show="showNotes"
|
||||
name="notes"
|
||||
class="w-full p-3 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Add review notes (optional)"
|
||||
rows="3"></textarea>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 mt-4 action-buttons">
|
||||
<button class="px-4 py-2 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"
|
||||
@click="showNotes = !showNotes">
|
||||
<i class="mr-2 fas fa-comment-alt"></i>
|
||||
Add Notes
|
||||
</button>
|
||||
|
||||
{% if submission.status != 'ESCALATED' or user.role in 'ADMIN,SUPERUSER' %}
|
||||
<button class="btn-approve"
|
||||
hx-post="{% url 'moderation:approve_photo' submission.id %}"
|
||||
hx-target="#submission-{{ submission.id }}"
|
||||
hx-include="closest .review-notes"
|
||||
hx-confirm="Are you sure you want to approve this photo?"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2 fas fa-check"></i>
|
||||
Approve
|
||||
</button>
|
||||
|
||||
<button class="btn-reject"
|
||||
hx-post="{% url 'moderation:reject_photo' submission.id %}"
|
||||
hx-target="#submission-{{ submission.id }}"
|
||||
hx-include="closest .review-notes"
|
||||
hx-confirm="Are you sure you want to reject this photo?"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2 fas fa-times"></i>
|
||||
Reject
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if user.role == 'MODERATOR' and submission.status != 'ESCALATED' %}
|
||||
<button class="btn-escalate"
|
||||
hx-post="{% url 'moderation:escalate_photo' submission.id %}"
|
||||
hx-target="#submission-{{ submission.id }}"
|
||||
hx-include="closest .review-notes"
|
||||
hx-confirm="Are you sure you want to escalate this photo?"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2 fas fa-arrow-up"></i>
|
||||
Escalate
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
{% load static %}
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="p-6 bg-white border rounded-lg shadow-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Photo Submissions</h3>
|
||||
<span class="px-3 py-1 text-sm font-medium text-yellow-800 bg-yellow-100 rounded-full dark:bg-yellow-900/50 dark:text-yellow-200">
|
||||
{{ submissions|length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for submission in submissions %}
|
||||
{% include "moderation/partials/photo_submission.html" %}
|
||||
{% empty %}
|
||||
<div class="py-8 text-center col-span-full">
|
||||
<i class="mb-3 text-4xl text-gray-400 fas fa-camera"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400">No photo submissions found</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
<div class="w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg dark:bg-gray-700 dark:border-gray-600" style="max-height: 240px; overflow-y: auto;">
|
||||
{% if ride_models %}
|
||||
{% for model in ride_models %}
|
||||
<button type="button"
|
||||
class="w-full px-4 py-2 text-left text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600"
|
||||
onclick="selectRideModelForSubmission('{{ model.id }}', '{{ model.name|escapejs }}', '{{ submission_id }}')">
|
||||
{{ model.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-gray-700 dark:text-gray-300">
|
||||
{% if search_term %}
|
||||
No ride models found
|
||||
{% else %}
|
||||
Start typing to search...
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function selectRideModelForSubmission(id, name, submissionId) {
|
||||
// Debug logging
|
||||
console.log('Selecting ride model:', {id, name, submissionId});
|
||||
|
||||
// Find elements
|
||||
const modelInput = document.querySelector(`#ride-model-input-${submissionId}`);
|
||||
const searchInput = document.querySelector(`#ride-model-search-${submissionId}`);
|
||||
const resultsDiv = document.querySelector(`#ride-model-search-results-${submissionId}`);
|
||||
|
||||
// Debug logging
|
||||
console.log('Found elements:', {
|
||||
modelInput: modelInput?.id,
|
||||
searchInput: searchInput?.id,
|
||||
resultsDiv: resultsDiv?.id
|
||||
});
|
||||
|
||||
// Update hidden input
|
||||
if (modelInput) {
|
||||
modelInput.value = id;
|
||||
console.log('Updated ride model input value:', modelInput.value);
|
||||
}
|
||||
|
||||
// Update search input
|
||||
if (searchInput) {
|
||||
searchInput.value = name;
|
||||
console.log('Updated search input value:', searchInput.value);
|
||||
}
|
||||
|
||||
// Clear results
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = '';
|
||||
console.log('Cleared results div');
|
||||
}
|
||||
}
|
||||
|
||||
// Close search results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const searchResults = document.querySelectorAll('[id^="ride-model-search-results-"]');
|
||||
searchResults.forEach(function(resultsDiv) {
|
||||
const searchInput = document.querySelector(`#ride-model-search-${resultsDiv.id.split('-').pop()}`);
|
||||
if (!resultsDiv.contains(e.target) && e.target !== searchInput) {
|
||||
resultsDiv.innerHTML = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
498
backend/templates/moderation/partials/submission_list.html
Normal file
498
backend/templates/moderation/partials/submission_list.html
Normal file
@@ -0,0 +1,498 @@
|
||||
{% load moderation_tags %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
{% endblock %}
|
||||
|
||||
{% for submission in submissions %}
|
||||
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50"
|
||||
id="submission-{{ submission.id }}"
|
||||
x-data="{
|
||||
showSuccess: false,
|
||||
isEditing: false,
|
||||
status: '{{ submission.changes.status|default:"" }}',
|
||||
category: '{{ submission.changes.category|default:"" }}',
|
||||
showCoasterFields: {% if submission.changes.category == 'RC' %}true{% else %}false{% endif %},
|
||||
init() {
|
||||
this.$watch('category', value => {
|
||||
this.showCoasterFields = value === 'RC';
|
||||
});
|
||||
}
|
||||
}"
|
||||
@htmx:afterOnLoad="showSuccess = true; setTimeout(() => showSuccess = false, 3000)">
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<!-- Left Column: Header & Status -->
|
||||
<div class="md:col-span-1">
|
||||
<div class="submission-header">
|
||||
<h3 class="flex items-center gap-3 text-lg font-semibold text-gray-900 dark:text-gray-300">
|
||||
<span class="status-badge
|
||||
{% if submission.status == 'PENDING' %}status-pending
|
||||
{% elif submission.status == 'APPROVED' %}status-approved
|
||||
{% elif submission.status == 'REJECTED' %}status-rejected
|
||||
{% elif submission.status == 'ESCALATED' %}status-escalated{% endif %}">
|
||||
<i class="mr-1.5 fas fa-{% if submission.status == 'PENDING' %}clock
|
||||
{% elif submission.status == 'APPROVED' %}check
|
||||
{% elif submission.status == 'REJECTED' %}times
|
||||
{% elif submission.status == 'ESCALATED' %}exclamation{% endif %}"></i>
|
||||
{{ submission.get_status_display }}
|
||||
</span>
|
||||
</h3>
|
||||
<div class="mt-3 text-gray-600 dark:text-gray-400">
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="w-5 mr-2 fas fa-file-alt"></i>
|
||||
{{ submission.get_content_type_display }} -
|
||||
{% if submission.submission_type == 'CREATE' %}New{% else %}Edit{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="w-5 mr-2 fas fa-user"></i>
|
||||
{{ submission.user.username }}
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="w-5 mr-2 fas fa-clock"></i>
|
||||
{{ submission.created_at|date:"M d, Y H:i" }}
|
||||
</div>
|
||||
{% if submission.handled_by %}
|
||||
<div class="flex items-center">
|
||||
<i class="w-5 mr-2 fas fa-user-shield"></i>
|
||||
{{ submission.handled_by.username }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Middle Column: Content Details -->
|
||||
<div class="md:col-span-2">
|
||||
{% if submission.content_object %}
|
||||
<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="text-sm font-medium text-gray-900 dark:text-gray-300">Current Object:</div>
|
||||
<div class="mt-1.5 text-gray-600 dark:text-gray-400">
|
||||
{{ submission.content_object }}
|
||||
{% if submission.content_type.model == 'park' or submission.content_type.model == 'company' or submission.content_type.model == 'designer' %}
|
||||
<div class="mt-1 text-sm">
|
||||
{{ submission.content_object.address }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if submission.reason %}
|
||||
<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="text-sm font-medium text-gray-900 dark:text-gray-300">Reason:</div>
|
||||
<div class="mt-1.5 text-gray-600 dark:text-gray-400">{{ submission.reason }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if submission.source %}
|
||||
<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="text-sm font-medium text-gray-900 dark:text-gray-300">Source:</div>
|
||||
<div class="mt-1.5">
|
||||
<a href="{{ submission.source }}"
|
||||
target="_blank"
|
||||
class="inline-flex items-center text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
<span>{{ submission.source }}</span>
|
||||
<i class="ml-1.5 text-xs fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- View Mode -->
|
||||
<div x-show="!isEditing">
|
||||
<!-- Location Map (View Mode) -->
|
||||
{% if submission.content_type.model == 'park' %}
|
||||
<div class="mb-4">
|
||||
{% include "moderation/partials/location_map.html" with submission=submission %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{% for field, value in submission.changes.items %}
|
||||
{% if field != 'model_name' and field != 'latitude' and field != 'longitude' and field != 'street_address' and field != 'city' and field != 'state' and field != 'postal_code' and field != 'country' and field != 'location' %}
|
||||
<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="text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
{{ field|title }}:
|
||||
</div>
|
||||
<div class="mt-1.5 text-gray-600 dark:text-gray-400">
|
||||
{% if field == 'opening_date' or field == 'closing_date' or field == 'status_since' %}
|
||||
{{ value|date:"Y-m-d" }}
|
||||
{% elif field == 'size_acres' %}
|
||||
{{ value }} acres
|
||||
{% elif field == 'website' %}
|
||||
<a href="{{ value }}" target="_blank" class="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
{{ value }}
|
||||
</a>
|
||||
{% elif field == 'park' %}
|
||||
{% with park_name=value|get_object_name:'parks.Park' %}
|
||||
{{ park_name }}
|
||||
{% endwith %}
|
||||
{% elif field == 'designer' %}
|
||||
{% with designer_name=value|get_object_name:'designers.Designer' %}
|
||||
{{ designer_name|default:'None' }}
|
||||
{% endwith %}
|
||||
{% elif field == 'manufacturer' %}
|
||||
{% with manufacturer_name=value|get_object_name:'companies.Manufacturer' %}
|
||||
{{ manufacturer_name|default:'None' }}
|
||||
{% endwith %}
|
||||
{% elif field == 'ride_model' %}
|
||||
{% with model_name=value|get_object_name:'rides.RideModel' %}
|
||||
{{ model_name|default:'None' }}
|
||||
{% endwith %}
|
||||
{% elif field == 'park_area' %}
|
||||
{% with park_id=submission.changes.park %}
|
||||
{{ value|get_park_area_name:park_id|default:'None' }}
|
||||
{% endwith %}
|
||||
{% elif field == 'category' %}
|
||||
{{ value|get_category_display }}
|
||||
{% elif field == 'stats' %}
|
||||
<div class="space-y-2">
|
||||
{% if value.height_ft %}<div>Height: {{ value.height_ft }} ft</div>{% endif %}
|
||||
{% if value.length_ft %}<div>Length: {{ value.length_ft }} ft</div>{% endif %}
|
||||
{% if value.speed_mph %}<div>Speed: {{ value.speed_mph }} mph</div>{% endif %}
|
||||
{% if value.inversions %}<div>Inversions: {{ value.inversions }}</div>{% endif %}
|
||||
{% if value.launch_type %}<div>Launch Type: {{ value.launch_type }}</div>{% endif %}
|
||||
{% if value.track_material %}<div>Track Material: {{ value.track_material }}</div>{% endif %}
|
||||
{% if value.roller_coaster_type %}<div>Type: {{ value.roller_coaster_type }}</div>{% endif %}
|
||||
{% if value.trains_count %}<div>Number of Trains: {{ value.trains_count }}</div>{% endif %}
|
||||
{% if value.cars_per_train %}<div>Cars per Train: {{ value.cars_per_train }}</div>{% endif %}
|
||||
{% if value.seats_per_car %}<div>Seats per Car: {{ value.seats_per_car }}</div>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ value }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Mode -->
|
||||
<form x-show="isEditing"
|
||||
x-cloak
|
||||
hx-post="{% url 'moderation:edit_submission' submission.id %}"
|
||||
hx-target="#submission-{{ submission.id }}"
|
||||
class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
|
||||
<!-- Location Widget for Parks -->
|
||||
{% if submission.content_type.model == 'park' %}
|
||||
<div class="col-span-2">
|
||||
{% include "moderation/partials/location_widget.html" with submission=submission %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for field, value in submission.changes.items %}
|
||||
{% if field != 'model_name' and field != 'stats' and field != 'latitude' and field != 'longitude' and field != 'street_address' and field != 'city' and field != 'state' and field != 'postal_code' and field != 'country' and field != 'location' %}
|
||||
<div class="p-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50"
|
||||
{% if field == 'post_closing_status' %}x-show="status === 'CLOSING'"{% endif %}
|
||||
{% if field == 'closing_date' %}x-show="['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(status)"{% endif %}>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
{{ field|title }}:
|
||||
</label>
|
||||
|
||||
{% if field == 'category' %}
|
||||
<select name="{{ field }}"
|
||||
x-model="category"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Select ride type</option>
|
||||
<option value="RC" {% if value == 'RC' %}selected{% endif %}>Roller Coaster</option>
|
||||
<option value="DR" {% if value == 'DR' %}selected{% endif %}>Dark Ride</option>
|
||||
<option value="FR" {% if value == 'FR' %}selected{% endif %}>Flat Ride</option>
|
||||
<option value="WR" {% if value == 'WR' %}selected{% endif %}>Water Ride</option>
|
||||
<option value="TR" {% if value == 'TR' %}selected{% endif %}>Transport</option>
|
||||
<option value="OT" {% if value == 'OT' %}selected{% endif %}>Other</option>
|
||||
</select>
|
||||
{% elif field == 'status' %}
|
||||
<select name="{{ field }}"
|
||||
x-model="status"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
<option value="OPERATING" {% if value == 'OPERATING' %}selected{% endif %}>Operating</option>
|
||||
<option value="SBNO" {% if value == 'SBNO' %}selected{% endif %}>Standing But Not Operating</option>
|
||||
<option value="CLOSING" {% if value == 'CLOSING' %}selected{% endif %}>Closing</option>
|
||||
<option value="CLOSED_PERM" {% if value == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
|
||||
<option value="UNDER_CONSTRUCTION" {% if value == 'UNDER_CONSTRUCTION' %}selected{% endif %}>Under Construction</option>
|
||||
<option value="DEMOLISHED" {% if value == 'DEMOLISHED' %}selected{% endif %}>Demolished</option>
|
||||
<option value="RELOCATED" {% if value == 'RELOCATED' %}selected{% endif %}>Relocated</option>
|
||||
</select>
|
||||
{% elif field == 'post_closing_status' %}
|
||||
<select name="{{ field }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
<option value="SBNO" {% if value == 'SBNO' %}selected{% endif %}>Standing But Not Operating</option>
|
||||
<option value="CLOSED_PERM" {% if value == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
|
||||
</select>
|
||||
{% elif field == 'opening_date' or field == 'closing_date' or field == 'status_since' %}
|
||||
<input type="date"
|
||||
name="{{ field }}"
|
||||
value="{{ value|date:'Y-m-d' }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
{% if field == 'closing_date' %}
|
||||
:required="status === 'CLOSING'"
|
||||
{% endif %}>
|
||||
{% else %}
|
||||
{% if field == 'park' %}
|
||||
<div class="relative space-y-2">
|
||||
<input type="text"
|
||||
id="park-search-{{ submission.id }}"
|
||||
placeholder="Search for a park..."
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
hx-get="{% url 'moderation:search_parks' %}"
|
||||
hx-trigger="click, input delay:200ms"
|
||||
hx-target="#park-search-results-{{ submission.id }}"
|
||||
hx-vals='{"submission_id": "{{ submission.id }}"}'
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
{% with park_name=value|get_object_name:'parks.Park' %}
|
||||
value="{{ park_name }}"
|
||||
{% endwith %}>
|
||||
<div id="park-search-results-{{ submission.id }}" class="absolute z-50 w-full"></div>
|
||||
<input type="hidden"
|
||||
id="park-input-{{ submission.id }}"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}"
|
||||
hx-trigger="change"
|
||||
hx-get="/parks/areas/"
|
||||
hx-target="#park-area-select-{{ submission.id }}">
|
||||
</div>
|
||||
{% elif field == 'park_area' %}
|
||||
<select name="{{ field }}"
|
||||
id="park-area-select-{{ submission.id }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Select area</option>
|
||||
{% with park_id=submission.changes.park %}
|
||||
{% with areas=park_areas_by_park|get_item:park_id %}
|
||||
{% for area_id, area_name in areas %}
|
||||
<option value="{{ area_id }}" {% if value == area_id %}selected{% endif %}>{{ area_name }}</option>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</select>
|
||||
{% elif field == 'manufacturer' %}
|
||||
<div class="relative space-y-2">
|
||||
<input type="text"
|
||||
id="manufacturer-search-{{ submission.id }}"
|
||||
placeholder="Search for a manufacturer..."
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
hx-get="{% url 'moderation:search_manufacturers' %}"
|
||||
hx-trigger="click, input delay:200ms"
|
||||
hx-target="#manufacturer-search-results-{{ submission.id }}"
|
||||
hx-vals='{"submission_id": "{{ submission.id }}"}'
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
{% with manufacturer_name=value|get_object_name:'companies.Manufacturer' %}
|
||||
value="{{ manufacturer_name }}"
|
||||
{% endwith %}>
|
||||
<div id="manufacturer-search-results-{{ submission.id }}" class="absolute z-50 w-full"></div>
|
||||
<input type="hidden"
|
||||
id="manufacturer-input-{{ submission.id }}"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}">
|
||||
</div>
|
||||
{% elif field == 'designer' %}
|
||||
<div class="relative space-y-2">
|
||||
<input type="text"
|
||||
id="designer-search-{{ submission.id }}"
|
||||
placeholder="Search for a designer..."
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
hx-get="{% url 'moderation:search_designers' %}"
|
||||
hx-trigger="click, input delay:200ms"
|
||||
hx-target="#designer-search-results-{{ submission.id }}"
|
||||
hx-vals='{"submission_id": "{{ submission.id }}"}'
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
{% with designer_name=value|get_object_name:'designers.Designer' %}
|
||||
value="{{ designer_name }}"
|
||||
{% endwith %}>
|
||||
<div id="designer-search-results-{{ submission.id }}" class="absolute z-50 w-full"></div>
|
||||
<input type="hidden"
|
||||
id="designer-input-{{ submission.id }}"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}">
|
||||
</div>
|
||||
{% elif field == 'ride_model' %}
|
||||
<div class="relative space-y-2">
|
||||
<input type="text"
|
||||
id="ride-model-search-{{ submission.id }}"
|
||||
placeholder="Search for a ride model..."
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
hx-get="{% url 'moderation:search_ride_models' %}"
|
||||
hx-trigger="click, input delay:200ms"
|
||||
hx-target="#ride-model-search-results-{{ submission.id }}"
|
||||
hx-vals='{"submission_id": "{{ submission.id }}"}'
|
||||
hx-include="[name='manufacturer']"
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
{% with model_name=value|get_object_name:'rides.RideModel' %}
|
||||
value="{{ model_name }}"
|
||||
{% endwith %}>
|
||||
<div id="ride-model-search-results-{{ submission.id }}" class="absolute z-50 w-full"></div>
|
||||
<input type="hidden"
|
||||
id="ride-model-input-{{ submission.id }}"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}">
|
||||
</div>
|
||||
{% elif field == 'description' %}
|
||||
<textarea name="{{ field }}"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="General description and notable features">{{ value }}</textarea>
|
||||
{% elif field == 'min_height_in' or field == 'max_height_in' %}
|
||||
<input type="number"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Height in inches">
|
||||
{% elif field == 'capacity_per_hour' %}
|
||||
<input type="number"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Theoretical hourly ride capacity">
|
||||
{% elif field == 'ride_duration_seconds' %}
|
||||
<input type="number"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Total duration of one ride cycle in seconds">
|
||||
{% else %}
|
||||
<input type="text"
|
||||
name="{{ field }}"
|
||||
value="{{ value }}"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Coaster Fields -->
|
||||
<div x-show="showCoasterFields"
|
||||
x-cloak
|
||||
class="col-span-2">
|
||||
{% include 'moderation/partials/coaster_fields.html' with stats=submission.changes.stats %}
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 p-4 border rounded-lg bg-blue-50 dark:bg-blue-900/30 border-blue-200/50 dark:border-blue-700/50">
|
||||
<label class="block mb-2 text-sm font-medium text-blue-900 dark:text-blue-300">
|
||||
Notes (required):
|
||||
</label>
|
||||
<textarea name="notes"
|
||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg resize-none dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
rows="3"
|
||||
required
|
||||
placeholder="Explain why you're editing this submission"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end col-span-2 gap-3">
|
||||
<button type="button"
|
||||
class="px-4 py-2 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
@click="isEditing = false">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 font-medium text-white transition-all duration-200 bg-blue-600 rounded-lg hover:bg-blue-500 dark:bg-blue-700 dark:hover:bg-blue-600">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Keep existing review notes section -->
|
||||
{% if submission.notes %}
|
||||
<div class="p-4 mt-4 border rounded-lg bg-blue-50 dark:bg-blue-900/30 border-blue-200/50 dark:border-blue-700/50">
|
||||
<div class="text-sm font-medium text-blue-900 dark:text-blue-300">Review Notes:</div>
|
||||
<div class="mt-1.5 text-blue-800 dark:text-blue-200">{{ submission.notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if submission.status == 'PENDING' or submission.status == 'ESCALATED' and user.role in 'ADMIN,SUPERUSER' %}
|
||||
<div class="mt-6 review-notes" x-data="{ showNotes: false }">
|
||||
<div x-show="showNotes"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
||||
x-transition:leave-end="opacity-0 transform -translate-y-2">
|
||||
<textarea name="notes"
|
||||
class="w-full px-4 py-3 text-gray-900 bg-white border rounded-lg resize-none dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Add review notes (optional)"
|
||||
rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 action-buttons">
|
||||
<button class="inline-flex items-center px-4 py-2.5 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white shadow-xs hover:shadow-md"
|
||||
@click="showNotes = !showNotes">
|
||||
<i class="mr-2 fas fa-comment-alt"></i>
|
||||
Add Notes
|
||||
</button>
|
||||
|
||||
<button class="inline-flex items-center px-4 py-2.5 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white shadow-xs hover:shadow-md"
|
||||
@click="isEditing = !isEditing">
|
||||
<i class="mr-2 fas fa-edit"></i>
|
||||
Edit
|
||||
</button>
|
||||
|
||||
{% if submission.status != 'ESCALATED' or user.role in 'ADMIN,SUPERUSER' %}
|
||||
<button class="inline-flex items-center px-4 py-2.5 font-medium text-white transition-all duration-200 bg-green-600 rounded-lg hover:bg-green-500 dark:bg-green-700 dark:hover:bg-green-600 shadow-xs hover:shadow-md"
|
||||
hx-post="{% url 'moderation:approve_submission' submission.id %}"
|
||||
hx-target="#submissions-content"
|
||||
hx-include="closest .review-notes"
|
||||
hx-confirm="Are you sure you want to approve this submission?"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2 fas fa-check"></i>
|
||||
Approve
|
||||
</button>
|
||||
|
||||
<button class="inline-flex items-center px-4 py-2.5 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 shadow-xs hover:shadow-md"
|
||||
hx-post="{% url 'moderation:reject_submission' submission.id %}"
|
||||
hx-target="#submissions-content"
|
||||
hx-include="closest .review-notes"
|
||||
hx-confirm="Are you sure you want to reject this submission?"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2 fas fa-times"></i>
|
||||
Reject
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if user.role == 'MODERATOR' and submission.status != 'ESCALATED' %}
|
||||
<button class="inline-flex items-center px-4 py-2.5 font-medium text-white transition-all duration-200 bg-yellow-600 rounded-lg hover:bg-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-600 shadow-xs hover:shadow-md"
|
||||
hx-post="{% url 'moderation:escalate_submission' submission.id %}"
|
||||
hx-target="#submissions-content"
|
||||
hx-include="closest .review-notes"
|
||||
hx-confirm="Are you sure you want to escalate this submission?"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2 fas fa-arrow-up"></i>
|
||||
Escalate
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="relative p-8 text-center bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="text-gray-600 dark:text-gray-400">
|
||||
<i class="mb-4 text-5xl fas fa-inbox"></i>
|
||||
<p class="text-lg">No submissions found matching your filters.</p>
|
||||
</div>
|
||||
|
||||
<div id="loading-indicator"
|
||||
class="absolute inset-0 flex items-center justify-center rounded-lg htmx-indicator bg-white/80 dark: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-900 dark:text-gray-300">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
{% endblock %}
|
||||
7
backend/templates/moderation/photo_submission_list.html
Normal file
7
backend/templates/moderation/photo_submission_list.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% extends "moderation/dashboard.html" %}
|
||||
|
||||
{% block moderation_content %}
|
||||
<div id="submissions-content">
|
||||
{% include "moderation/partials/photo_submission_content.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user