mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 02:51:08 -05:00
good stuff
This commit is contained in:
@@ -2,5 +2,3 @@
|
|||||||
# https://curl.se/docs/http-cookies.html
|
# https://curl.se/docs/http-cookies.html
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
#HttpOnly_localhost FALSE / FALSE 1731262680 sessionid angfimq8dc7q8qmn064ud2s2svheq5es
|
|
||||||
localhost FALSE / FALSE 1761502680 csrftoken 8C8T7QuLCNRoSYeothorKYe6PYadNtOO
|
|
||||||
|
|||||||
0
moderation/__init__.py
Normal file
0
moderation/__init__.py
Normal file
78
moderation/admin.py
Normal file
78
moderation/admin.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.admin import AdminSite
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.urls import reverse
|
||||||
|
from .models import EditSubmission, PhotoSubmission
|
||||||
|
|
||||||
|
class ModerationAdminSite(AdminSite):
|
||||||
|
site_header = 'ThrillWiki Moderation'
|
||||||
|
site_title = 'ThrillWiki Moderation'
|
||||||
|
index_title = 'Moderation Dashboard'
|
||||||
|
|
||||||
|
def has_permission(self, request):
|
||||||
|
"""Only allow moderators and above to access this admin site"""
|
||||||
|
return request.user.is_authenticated and request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
|
||||||
|
|
||||||
|
moderation_site = ModerationAdminSite(name='moderation')
|
||||||
|
|
||||||
|
class EditSubmissionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['id', 'user_link', 'content_type', 'content_link', 'status', 'submitted_at', 'reviewed_by']
|
||||||
|
list_filter = ['status', 'content_type', 'submitted_at']
|
||||||
|
search_fields = ['user__username', 'reason', 'source', 'review_notes']
|
||||||
|
readonly_fields = ['user', 'content_type', 'object_id', 'changes', 'submitted_at']
|
||||||
|
|
||||||
|
def user_link(self, obj):
|
||||||
|
url = reverse('admin:accounts_user_change', args=[obj.user.id])
|
||||||
|
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
||||||
|
user_link.short_description = 'User'
|
||||||
|
|
||||||
|
def content_link(self, obj):
|
||||||
|
if hasattr(obj.content_object, 'get_absolute_url'):
|
||||||
|
url = obj.content_object.get_absolute_url()
|
||||||
|
return format_html('<a href="{}">{}</a>', url, str(obj.content_object))
|
||||||
|
return str(obj.content_object)
|
||||||
|
content_link.short_description = 'Content'
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
if 'status' in form.changed_data:
|
||||||
|
if obj.status == 'APPROVED':
|
||||||
|
obj.approve(request.user, obj.review_notes)
|
||||||
|
elif obj.status == 'REJECTED':
|
||||||
|
obj.reject(request.user, obj.review_notes)
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
class PhotoSubmissionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['id', 'user_link', 'content_type', 'content_link', 'photo_preview', 'status', 'submitted_at', 'reviewed_by']
|
||||||
|
list_filter = ['status', 'content_type', 'submitted_at']
|
||||||
|
search_fields = ['user__username', 'caption', 'review_notes']
|
||||||
|
readonly_fields = ['user', 'content_type', 'object_id', 'photo_preview', 'submitted_at']
|
||||||
|
|
||||||
|
def user_link(self, obj):
|
||||||
|
url = reverse('admin:accounts_user_change', args=[obj.user.id])
|
||||||
|
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
||||||
|
user_link.short_description = 'User'
|
||||||
|
|
||||||
|
def content_link(self, obj):
|
||||||
|
if hasattr(obj.content_object, 'get_absolute_url'):
|
||||||
|
url = obj.content_object.get_absolute_url()
|
||||||
|
return format_html('<a href="{}">{}</a>', url, str(obj.content_object))
|
||||||
|
return str(obj.content_object)
|
||||||
|
content_link.short_description = 'Content'
|
||||||
|
|
||||||
|
def photo_preview(self, obj):
|
||||||
|
if obj.photo:
|
||||||
|
return format_html('<img src="{}" style="max-height: 100px; max-width: 200px;" />', obj.photo.url)
|
||||||
|
return ''
|
||||||
|
photo_preview.short_description = 'Photo Preview'
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
if 'status' in form.changed_data:
|
||||||
|
if obj.status == 'APPROVED':
|
||||||
|
obj.approve(request.user, obj.review_notes)
|
||||||
|
elif obj.status == 'REJECTED':
|
||||||
|
obj.reject(request.user, obj.review_notes)
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
# Register with moderation site only
|
||||||
|
moderation_site.register(EditSubmission, EditSubmissionAdmin)
|
||||||
|
moderation_site.register(PhotoSubmission, PhotoSubmissionAdmin)
|
||||||
6
moderation/apps.py
Normal file
6
moderation/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class ModerationConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'moderation'
|
||||||
|
verbose_name = 'Content Moderation'
|
||||||
16
moderation/context_processors.py
Normal file
16
moderation/context_processors.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
def moderation_access(request):
|
||||||
|
"""Add moderation access check to template context"""
|
||||||
|
context = {
|
||||||
|
'has_moderation_access': False,
|
||||||
|
'has_admin_access': False,
|
||||||
|
'has_superuser_access': False,
|
||||||
|
'user_role': None
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
context['user_role'] = request.user.role
|
||||||
|
context['has_moderation_access'] = request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
|
||||||
|
context['has_admin_access'] = request.user.role in ['ADMIN', 'SUPERUSER']
|
||||||
|
context['has_superuser_access'] = request.user.role == 'SUPERUSER'
|
||||||
|
|
||||||
|
return context
|
||||||
183
moderation/migrations/0001_initial.py
Normal file
183
moderation/migrations/0001_initial.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-10-30 00:41
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="EditSubmission",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("object_id", models.PositiveIntegerField()),
|
||||||
|
(
|
||||||
|
"changes",
|
||||||
|
models.JSONField(
|
||||||
|
help_text="JSON representation of the changes made"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("reason", models.TextField(help_text="Why this edit is needed")),
|
||||||
|
(
|
||||||
|
"source",
|
||||||
|
models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Source of information for this edit (if applicable)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("PENDING", "Pending"),
|
||||||
|
("APPROVED", "Approved"),
|
||||||
|
("REJECTED", "Rejected"),
|
||||||
|
("AUTO_APPROVED", "Auto Approved"),
|
||||||
|
],
|
||||||
|
default="PENDING",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("submitted_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("reviewed_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"review_notes",
|
||||||
|
models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Notes from the moderator about this submission",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"content_type",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="contenttypes.contenttype",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"reviewed_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="reviewed_submissions",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="edit_submissions",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-submitted_at"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(
|
||||||
|
fields=["content_type", "object_id"],
|
||||||
|
name="moderation__content_922d2b_idx",
|
||||||
|
),
|
||||||
|
models.Index(
|
||||||
|
fields=["status"], name="moderation__status_e4eb2b_idx"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PhotoSubmission",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("object_id", models.PositiveIntegerField()),
|
||||||
|
("photo", models.ImageField(upload_to="submissions/photos/")),
|
||||||
|
("caption", models.CharField(blank=True, max_length=255)),
|
||||||
|
("date_taken", models.DateField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("PENDING", "Pending"),
|
||||||
|
("APPROVED", "Approved"),
|
||||||
|
("REJECTED", "Rejected"),
|
||||||
|
("AUTO_APPROVED", "Auto Approved"),
|
||||||
|
],
|
||||||
|
default="PENDING",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("submitted_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("reviewed_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"review_notes",
|
||||||
|
models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Notes from the moderator about this photo submission",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"content_type",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="contenttypes.contenttype",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"reviewed_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="reviewed_photos",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="photo_submissions",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-submitted_at"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(
|
||||||
|
fields=["content_type", "object_id"],
|
||||||
|
name="moderation__content_7a7bc1_idx",
|
||||||
|
),
|
||||||
|
models.Index(
|
||||||
|
fields=["status"], name="moderation__status_7a1914_idx"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-10-30 01:07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("moderation", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="editsubmission",
|
||||||
|
name="submission_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("EDIT", "Edit Existing"), ("CREATE", "Create New")],
|
||||||
|
default="EDIT",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="editsubmission",
|
||||||
|
name="changes",
|
||||||
|
field=models.JSONField(
|
||||||
|
help_text="JSON representation of the changes or new object data"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="editsubmission",
|
||||||
|
name="object_id",
|
||||||
|
field=models.PositiveIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="editsubmission",
|
||||||
|
name="reason",
|
||||||
|
field=models.TextField(help_text="Why this edit/addition is needed"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="editsubmission",
|
||||||
|
name="source",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True, help_text="Source of information (if applicable)"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
moderation/migrations/__init__.py
Normal file
0
moderation/migrations/__init__.py
Normal file
205
moderation/mixins.py
Normal file
205
moderation/mixins.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.http import JsonResponse, HttpResponseForbidden
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.utils import timezone
|
||||||
|
import json
|
||||||
|
from .models import EditSubmission, PhotoSubmission
|
||||||
|
|
||||||
|
class EditSubmissionMixin(LoginRequiredMixin):
|
||||||
|
"""
|
||||||
|
Mixin for handling edit submissions with proper moderation.
|
||||||
|
"""
|
||||||
|
def handle_edit_submission(self, request, changes, reason='', source='', submission_type='EDIT'):
|
||||||
|
"""
|
||||||
|
Handle an edit submission based on user's role.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The HTTP request
|
||||||
|
changes: Dict of field changes {field_name: new_value}
|
||||||
|
reason: Why this edit is needed
|
||||||
|
source: Source of information (optional)
|
||||||
|
submission_type: 'EDIT' or 'CREATE'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JsonResponse with status and message
|
||||||
|
"""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'You must be logged in to make edits.'
|
||||||
|
}, status=403)
|
||||||
|
|
||||||
|
content_type = ContentType.objects.get_for_model(self.model)
|
||||||
|
|
||||||
|
# Create the submission
|
||||||
|
submission = EditSubmission(
|
||||||
|
user=request.user,
|
||||||
|
content_type=content_type,
|
||||||
|
submission_type=submission_type,
|
||||||
|
changes=changes,
|
||||||
|
reason=reason,
|
||||||
|
source=source
|
||||||
|
)
|
||||||
|
|
||||||
|
# For edits, set the object_id
|
||||||
|
if submission_type == 'EDIT':
|
||||||
|
obj = self.get_object()
|
||||||
|
submission.object_id = obj.id
|
||||||
|
|
||||||
|
# Auto-approve for moderators and above
|
||||||
|
if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
|
||||||
|
obj = submission.auto_approve()
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Changes saved successfully.',
|
||||||
|
'auto_approved': True,
|
||||||
|
'redirect_url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None
|
||||||
|
})
|
||||||
|
|
||||||
|
# Submit for approval for regular users
|
||||||
|
submission.save()
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Your changes have been submitted for approval.',
|
||||||
|
'auto_approved': False
|
||||||
|
})
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
"""Handle POST requests for editing"""
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
changes = data.get('changes', {})
|
||||||
|
reason = data.get('reason', '')
|
||||||
|
source = data.get('source', '')
|
||||||
|
submission_type = data.get('submission_type', 'EDIT')
|
||||||
|
|
||||||
|
if not changes:
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No changes provided.'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
if not reason and request.user.role == 'USER':
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Please provide a reason for your changes.'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
return self.handle_edit_submission(
|
||||||
|
request, changes, reason, source, submission_type
|
||||||
|
)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid JSON data.'
|
||||||
|
}, status=400)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
class PhotoSubmissionMixin(LoginRequiredMixin):
|
||||||
|
"""
|
||||||
|
Mixin for handling photo submissions with proper moderation.
|
||||||
|
"""
|
||||||
|
def handle_photo_submission(self, request):
|
||||||
|
"""Handle a photo submission based on user's role"""
|
||||||
|
if not request.FILES.get('photo'):
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No photo provided.'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
obj = self.get_object()
|
||||||
|
content_type = ContentType.objects.get_for_model(obj)
|
||||||
|
|
||||||
|
submission = PhotoSubmission(
|
||||||
|
user=request.user,
|
||||||
|
content_type=content_type,
|
||||||
|
object_id=obj.id,
|
||||||
|
photo=request.FILES['photo'],
|
||||||
|
caption=request.POST.get('caption', ''),
|
||||||
|
date_taken=request.POST.get('date_taken')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Auto-approve for moderators and above
|
||||||
|
if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
|
||||||
|
submission.auto_approve()
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Photo uploaded successfully.',
|
||||||
|
'auto_approved': True
|
||||||
|
})
|
||||||
|
|
||||||
|
# Submit for approval for regular users
|
||||||
|
submission.save()
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Your photo has been submitted for approval.',
|
||||||
|
'auto_approved': False
|
||||||
|
})
|
||||||
|
|
||||||
|
class ModeratorRequiredMixin(UserPassesTestMixin):
|
||||||
|
"""Require moderator or higher role for access"""
|
||||||
|
def test_func(self):
|
||||||
|
return (
|
||||||
|
self.request.user.is_authenticated and
|
||||||
|
self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle_no_permission(self):
|
||||||
|
if not self.request.user.is_authenticated:
|
||||||
|
return super().handle_no_permission()
|
||||||
|
return HttpResponseForbidden("You must be a moderator to access this page.")
|
||||||
|
|
||||||
|
class AdminRequiredMixin(UserPassesTestMixin):
|
||||||
|
"""Require admin or superuser role for access"""
|
||||||
|
def test_func(self):
|
||||||
|
return (
|
||||||
|
self.request.user.is_authenticated and
|
||||||
|
self.request.user.role in ['ADMIN', 'SUPERUSER']
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle_no_permission(self):
|
||||||
|
if not self.request.user.is_authenticated:
|
||||||
|
return super().handle_no_permission()
|
||||||
|
return HttpResponseForbidden("You must be an admin to access this page.")
|
||||||
|
|
||||||
|
class InlineEditMixin:
|
||||||
|
"""Add inline editing context to views"""
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
if self.request.user.is_authenticated:
|
||||||
|
context['can_edit'] = True
|
||||||
|
context['can_auto_approve'] = self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
|
||||||
|
if hasattr(self, 'get_object'):
|
||||||
|
obj = self.get_object()
|
||||||
|
context['pending_edits'] = EditSubmission.objects.filter(
|
||||||
|
content_type=ContentType.objects.get_for_model(obj),
|
||||||
|
object_id=obj.id,
|
||||||
|
status='PENDING'
|
||||||
|
).select_related('user').order_by('-submitted_at')
|
||||||
|
return context
|
||||||
|
|
||||||
|
class HistoryMixin:
|
||||||
|
"""Add edit history context to views"""
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
obj = self.get_object()
|
||||||
|
|
||||||
|
# Get historical records
|
||||||
|
context['history'] = obj.history.all().select_related('history_user')
|
||||||
|
|
||||||
|
# Get related edit submissions
|
||||||
|
content_type = ContentType.objects.get_for_model(obj)
|
||||||
|
context['edit_submissions'] = EditSubmission.objects.filter(
|
||||||
|
content_type=content_type,
|
||||||
|
object_id=obj.id
|
||||||
|
).exclude(
|
||||||
|
status='PENDING'
|
||||||
|
).select_related('user', 'reviewed_by').order_by('-submitted_at')
|
||||||
|
|
||||||
|
return context
|
||||||
262
moderation/models.py
Normal file
262
moderation/models.py
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.apps import apps
|
||||||
|
|
||||||
|
class EditSubmission(models.Model):
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('PENDING', 'Pending'),
|
||||||
|
('APPROVED', 'Approved'),
|
||||||
|
('REJECTED', 'Rejected'),
|
||||||
|
('AUTO_APPROVED', 'Auto Approved'),
|
||||||
|
]
|
||||||
|
|
||||||
|
SUBMISSION_TYPE_CHOICES = [
|
||||||
|
('EDIT', 'Edit Existing'),
|
||||||
|
('CREATE', 'Create New'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Who submitted the edit
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='edit_submissions'
|
||||||
|
)
|
||||||
|
|
||||||
|
# What is being edited (Park or Ride)
|
||||||
|
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||||
|
object_id = models.PositiveIntegerField(null=True, blank=True) # Null for new objects
|
||||||
|
content_object = GenericForeignKey('content_type', 'object_id')
|
||||||
|
|
||||||
|
# Type of submission
|
||||||
|
submission_type = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=SUBMISSION_TYPE_CHOICES,
|
||||||
|
default='EDIT'
|
||||||
|
)
|
||||||
|
|
||||||
|
# The actual changes/data
|
||||||
|
changes = models.JSONField(
|
||||||
|
help_text='JSON representation of the changes or new object data'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
reason = models.TextField(
|
||||||
|
help_text='Why this edit/addition is needed'
|
||||||
|
)
|
||||||
|
source = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text='Source of information (if applicable)'
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='PENDING'
|
||||||
|
)
|
||||||
|
submitted_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
# Review details
|
||||||
|
reviewed_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='reviewed_submissions'
|
||||||
|
)
|
||||||
|
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
review_notes = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text='Notes from the moderator about this submission'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-submitted_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['content_type', 'object_id']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
action = "creation" if self.submission_type == 'CREATE' else "edit"
|
||||||
|
target = self.content_object or self.content_type.model_class().__name__
|
||||||
|
return f"{action} by {self.user.username} on {target}"
|
||||||
|
|
||||||
|
def _resolve_foreign_keys(self, data):
|
||||||
|
"""Convert foreign key IDs to model instances"""
|
||||||
|
model_class = self.content_type.model_class()
|
||||||
|
resolved_data = data.copy()
|
||||||
|
|
||||||
|
for field_name, value in data.items():
|
||||||
|
field = model_class._meta.get_field(field_name)
|
||||||
|
if isinstance(field, models.ForeignKey) and value is not None:
|
||||||
|
related_model = field.related_model
|
||||||
|
resolved_data[field_name] = related_model.objects.get(id=value)
|
||||||
|
|
||||||
|
return resolved_data
|
||||||
|
|
||||||
|
def approve(self, moderator, notes=''):
|
||||||
|
"""Approve the submission and apply the changes"""
|
||||||
|
self.status = 'APPROVED'
|
||||||
|
self.reviewed_by = moderator
|
||||||
|
self.reviewed_at = timezone.now()
|
||||||
|
self.review_notes = notes
|
||||||
|
|
||||||
|
model_class = self.content_type.model_class()
|
||||||
|
resolved_data = self._resolve_foreign_keys(self.changes)
|
||||||
|
|
||||||
|
if self.submission_type == 'CREATE':
|
||||||
|
# Create new object
|
||||||
|
obj = model_class(**resolved_data)
|
||||||
|
obj.save()
|
||||||
|
# Update object_id after creation
|
||||||
|
self.object_id = obj.id
|
||||||
|
else:
|
||||||
|
# Apply changes to existing object
|
||||||
|
obj = self.content_object
|
||||||
|
for field, value in resolved_data.items():
|
||||||
|
setattr(obj, field, value)
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def reject(self, moderator, notes):
|
||||||
|
"""Reject the submission"""
|
||||||
|
self.status = 'REJECTED'
|
||||||
|
self.reviewed_by = moderator
|
||||||
|
self.reviewed_at = timezone.now()
|
||||||
|
self.review_notes = notes
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def auto_approve(self):
|
||||||
|
"""Auto-approve the submission (for moderators/admins)"""
|
||||||
|
self.status = 'AUTO_APPROVED'
|
||||||
|
self.reviewed_by = self.user
|
||||||
|
self.reviewed_at = timezone.now()
|
||||||
|
|
||||||
|
model_class = self.content_type.model_class()
|
||||||
|
resolved_data = self._resolve_foreign_keys(self.changes)
|
||||||
|
|
||||||
|
if self.submission_type == 'CREATE':
|
||||||
|
# Create new object
|
||||||
|
obj = model_class(**resolved_data)
|
||||||
|
obj.save()
|
||||||
|
# Update object_id after creation
|
||||||
|
self.object_id = obj.id
|
||||||
|
else:
|
||||||
|
# Apply changes to existing object
|
||||||
|
obj = self.content_object
|
||||||
|
for field, value in resolved_data.items():
|
||||||
|
setattr(obj, field, value)
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
return obj
|
||||||
|
|
||||||
|
class PhotoSubmission(models.Model):
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('PENDING', 'Pending'),
|
||||||
|
('APPROVED', 'Approved'),
|
||||||
|
('REJECTED', 'Rejected'),
|
||||||
|
('AUTO_APPROVED', 'Auto Approved'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Who submitted the photo
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='photo_submissions'
|
||||||
|
)
|
||||||
|
|
||||||
|
# What the photo is for (Park or Ride)
|
||||||
|
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||||
|
object_id = models.PositiveIntegerField()
|
||||||
|
content_object = GenericForeignKey('content_type', 'object_id')
|
||||||
|
|
||||||
|
# The photo itself
|
||||||
|
photo = models.ImageField(upload_to='submissions/photos/')
|
||||||
|
caption = models.CharField(max_length=255, blank=True)
|
||||||
|
date_taken = models.DateField(null=True, blank=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='PENDING'
|
||||||
|
)
|
||||||
|
submitted_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
# Review details
|
||||||
|
reviewed_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='reviewed_photos'
|
||||||
|
)
|
||||||
|
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
review_notes = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text='Notes from the moderator about this photo submission'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-submitted_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['content_type', 'object_id']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Photo submission by {self.user.username} for {self.content_object}"
|
||||||
|
|
||||||
|
def approve(self, moderator, notes=''):
|
||||||
|
"""Approve the photo submission"""
|
||||||
|
from media.models import Photo
|
||||||
|
|
||||||
|
self.status = 'APPROVED'
|
||||||
|
self.reviewed_by = moderator
|
||||||
|
self.reviewed_at = timezone.now()
|
||||||
|
self.review_notes = notes
|
||||||
|
|
||||||
|
# Create the approved photo
|
||||||
|
Photo.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
content_type=self.content_type,
|
||||||
|
object_id=self.object_id,
|
||||||
|
image=self.photo,
|
||||||
|
caption=self.caption,
|
||||||
|
date_taken=self.date_taken
|
||||||
|
)
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def reject(self, moderator, notes):
|
||||||
|
"""Reject the photo submission"""
|
||||||
|
self.status = 'REJECTED'
|
||||||
|
self.reviewed_by = moderator
|
||||||
|
self.reviewed_at = timezone.now()
|
||||||
|
self.review_notes = notes
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def auto_approve(self):
|
||||||
|
"""Auto-approve the photo submission (for moderators/admins)"""
|
||||||
|
from media.models import Photo
|
||||||
|
|
||||||
|
self.status = 'AUTO_APPROVED'
|
||||||
|
self.reviewed_by = self.user
|
||||||
|
self.reviewed_at = timezone.now()
|
||||||
|
|
||||||
|
# Create the approved photo
|
||||||
|
Photo.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
content_type=self.content_type,
|
||||||
|
object_id=self.object_id,
|
||||||
|
image=self.photo,
|
||||||
|
caption=self.caption,
|
||||||
|
date_taken=self.date_taken
|
||||||
|
)
|
||||||
|
|
||||||
|
self.save()
|
||||||
3
moderation/tests.py
Normal file
3
moderation/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
16
moderation/urls.py
Normal file
16
moderation/urls.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from .admin import moderation_site
|
||||||
|
from .views import EditSubmissionListView, PhotoSubmissionListView
|
||||||
|
|
||||||
|
app_name = 'moderation'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Custom moderation views
|
||||||
|
path('submissions/', include([
|
||||||
|
path('edits/', EditSubmissionListView.as_view(), name='edit_submissions'),
|
||||||
|
path('photos/', PhotoSubmissionListView.as_view(), name='photo_submissions'),
|
||||||
|
])),
|
||||||
|
|
||||||
|
# Admin site URLs
|
||||||
|
path('admin/', moderation_site.urls),
|
||||||
|
]
|
||||||
100
moderation/views.py
Normal file
100
moderation/views.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
from django.views.generic import ListView
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils import timezone
|
||||||
|
from .models import EditSubmission, PhotoSubmission
|
||||||
|
from .mixins import ModeratorRequiredMixin
|
||||||
|
|
||||||
|
class EditSubmissionListView(ModeratorRequiredMixin, ListView):
|
||||||
|
model = EditSubmission
|
||||||
|
template_name = 'moderation/admin/edit_submission_list.html'
|
||||||
|
context_object_name = 'submissions'
|
||||||
|
paginate_by = 20
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset().select_related(
|
||||||
|
'user', 'reviewed_by', 'content_type'
|
||||||
|
).order_by('-submitted_at')
|
||||||
|
|
||||||
|
# Filter by status
|
||||||
|
status = self.request.GET.get('status')
|
||||||
|
if status:
|
||||||
|
queryset = queryset.filter(status=status)
|
||||||
|
|
||||||
|
# Filter by submission type
|
||||||
|
submission_type = self.request.GET.get('type')
|
||||||
|
if submission_type:
|
||||||
|
queryset = queryset.filter(submission_type=submission_type)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
submission_id = request.POST.get('submission_id')
|
||||||
|
action = request.POST.get('action')
|
||||||
|
review_notes = request.POST.get('review_notes', '')
|
||||||
|
|
||||||
|
submission = get_object_or_404(EditSubmission, id=submission_id)
|
||||||
|
|
||||||
|
if action == 'approve':
|
||||||
|
obj = submission.approve(request.user, review_notes)
|
||||||
|
message = 'New addition approved successfully.' if submission.submission_type == 'CREATE' else 'Changes approved successfully.'
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message,
|
||||||
|
'redirect_url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None
|
||||||
|
})
|
||||||
|
elif action == 'reject':
|
||||||
|
submission.reject(request.user, review_notes)
|
||||||
|
message = 'New addition rejected.' if submission.submission_type == 'CREATE' else 'Changes rejected.'
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid action.'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
class PhotoSubmissionListView(ModeratorRequiredMixin, ListView):
|
||||||
|
model = PhotoSubmission
|
||||||
|
template_name = 'moderation/admin/photo_submission_list.html'
|
||||||
|
context_object_name = 'submissions'
|
||||||
|
paginate_by = 20
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset().select_related(
|
||||||
|
'user', 'reviewed_by', 'content_type'
|
||||||
|
).order_by('-submitted_at')
|
||||||
|
|
||||||
|
status = self.request.GET.get('status')
|
||||||
|
if status:
|
||||||
|
queryset = queryset.filter(status=status)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
submission_id = request.POST.get('submission_id')
|
||||||
|
action = request.POST.get('action')
|
||||||
|
review_notes = request.POST.get('review_notes', '')
|
||||||
|
|
||||||
|
submission = get_object_or_404(PhotoSubmission, id=submission_id)
|
||||||
|
|
||||||
|
if action == 'approve':
|
||||||
|
submission.approve(request.user, review_notes)
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Photo approved successfully.'
|
||||||
|
})
|
||||||
|
elif action == 'reject':
|
||||||
|
submission.reject(request.user, review_notes)
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Photo rejected successfully.'
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid action.'
|
||||||
|
}, status=400)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,45 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-10-30 01:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("parks", "0002_add_country_field"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="historicalpark",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("OPERATING", "Operating"),
|
||||||
|
("CLOSED_TEMP", "Temporarily Closed"),
|
||||||
|
("CLOSED_PERM", "Permanently Closed"),
|
||||||
|
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||||
|
("DEMOLISHED", "Demolished"),
|
||||||
|
("RELOCATED", "Relocated"),
|
||||||
|
],
|
||||||
|
default="OPERATING",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="park",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("OPERATING", "Operating"),
|
||||||
|
("CLOSED_TEMP", "Temporarily Closed"),
|
||||||
|
("CLOSED_PERM", "Permanently Closed"),
|
||||||
|
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||||
|
("DEMOLISHED", "Demolished"),
|
||||||
|
("RELOCATED", "Relocated"),
|
||||||
|
],
|
||||||
|
default="OPERATING",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -11,6 +11,7 @@ class Park(models.Model):
|
|||||||
('CLOSED_PERM', 'Permanently Closed'),
|
('CLOSED_PERM', 'Permanently Closed'),
|
||||||
('UNDER_CONSTRUCTION', 'Under Construction'),
|
('UNDER_CONSTRUCTION', 'Under Construction'),
|
||||||
('DEMOLISHED', 'Demolished'),
|
('DEMOLISHED', 'Demolished'),
|
||||||
|
('RELOCATED', 'Relocated'), # Added to match Ride model
|
||||||
]
|
]
|
||||||
|
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ app_name = 'parks'
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.ParkListView.as_view(), name='park_list'),
|
path('', views.ParkListView.as_view(), name='park_list'),
|
||||||
|
path('create/', views.ParkCreateView.as_view(), name='park_create'),
|
||||||
path('<slug:slug>/', views.ParkDetailView.as_view(), name='park_detail'),
|
path('<slug:slug>/', views.ParkDetailView.as_view(), name='park_detail'),
|
||||||
path('<slug:park_slug>/<slug:ride_slug>/', RideDetailView.as_view(), name='ride_detail'),
|
path('<slug:park_slug>/<slug:ride_slug>/', RideDetailView.as_view(), name='ride_detail'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,12 +1,48 @@
|
|||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView, CreateView
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.http import JsonResponse, HttpResponseRedirect
|
||||||
from .models import Park, ParkArea
|
from .models import Park, ParkArea
|
||||||
from rides.models import Ride
|
from rides.models import Ride
|
||||||
from core.views import SlugRedirectMixin
|
from core.views import SlugRedirectMixin
|
||||||
|
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin
|
||||||
|
from moderation.models import EditSubmission
|
||||||
|
|
||||||
class ParkDetailView(SlugRedirectMixin, DetailView):
|
class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||||
|
model = Park
|
||||||
|
template_name = 'parks/park_form.html'
|
||||||
|
fields = ['name', 'location', 'country', 'description', 'owner', 'status',
|
||||||
|
'opening_date', 'closing_date', 'operating_season', 'size_acres', 'website']
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
# If user is moderator or above, save directly
|
||||||
|
if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
|
||||||
|
self.object = form.save()
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
|
# Otherwise, create a submission
|
||||||
|
cleaned_data = form.cleaned_data.copy()
|
||||||
|
# Convert model instances to IDs for JSON serialization
|
||||||
|
if cleaned_data.get('owner'):
|
||||||
|
cleaned_data['owner'] = cleaned_data['owner'].id
|
||||||
|
|
||||||
|
submission = EditSubmission.objects.create(
|
||||||
|
user=self.request.user,
|
||||||
|
content_type=ContentType.objects.get_for_model(Park),
|
||||||
|
submission_type='CREATE',
|
||||||
|
changes=cleaned_data,
|
||||||
|
reason=self.request.POST.get('reason', ''),
|
||||||
|
source=self.request.POST.get('source', '')
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(reverse('parks:park_list'))
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('parks:park_detail', kwargs={'slug': self.object.slug})
|
||||||
|
|
||||||
|
class ParkDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView):
|
||||||
model = Park
|
model = Park
|
||||||
template_name = 'parks/park_detail.html'
|
template_name = 'parks/park_detail.html'
|
||||||
context_object_name = 'park'
|
context_object_name = 'park'
|
||||||
@@ -27,9 +63,9 @@ class ParkDetailView(SlugRedirectMixin, DetailView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
def get_redirect_url_pattern(self):
|
def get_redirect_url_pattern(self):
|
||||||
return 'park_detail'
|
return 'parks:park_detail'
|
||||||
|
|
||||||
class ParkAreaDetailView(SlugRedirectMixin, DetailView):
|
class ParkAreaDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView):
|
||||||
model = ParkArea
|
model = ParkArea
|
||||||
template_name = 'parks/area_detail.html'
|
template_name = 'parks/area_detail.html'
|
||||||
context_object_name = 'area'
|
context_object_name = 'area'
|
||||||
@@ -54,7 +90,7 @@ class ParkAreaDetailView(SlugRedirectMixin, DetailView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
def get_redirect_url_pattern(self):
|
def get_redirect_url_pattern(self):
|
||||||
return 'area_detail'
|
return 'parks:area_detail'
|
||||||
|
|
||||||
def get_redirect_url_kwargs(self):
|
def get_redirect_url_kwargs(self):
|
||||||
return {
|
return {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -5,5 +5,6 @@ app_name = 'rides'
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.RideListView.as_view(), name='ride_list'),
|
path('', views.RideListView.as_view(), name='ride_list'),
|
||||||
|
path('create/', views.RideCreateView.as_view(), name='ride_create'),
|
||||||
path('<slug:park_slug>/<slug:ride_slug>/', views.RideDetailView.as_view(), name='ride_detail'),
|
path('<slug:park_slug>/<slug:ride_slug>/', views.RideDetailView.as_view(), name='ride_detail'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,13 +1,57 @@
|
|||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView, CreateView
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.http import JsonResponse, HttpResponseRedirect
|
||||||
from .models import Ride, RollerCoasterStats
|
from .models import Ride, RollerCoasterStats
|
||||||
from parks.models import Park
|
from parks.models import Park
|
||||||
from core.views import SlugRedirectMixin
|
from core.views import SlugRedirectMixin
|
||||||
|
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin
|
||||||
|
from moderation.models import EditSubmission
|
||||||
|
|
||||||
class RideDetailView(SlugRedirectMixin, DetailView):
|
class RideCreateView(LoginRequiredMixin, CreateView):
|
||||||
|
model = Ride
|
||||||
|
template_name = 'rides/ride_form.html'
|
||||||
|
fields = ['name', 'park', 'park_area', 'category', 'manufacturer', 'model_name', 'status',
|
||||||
|
'opening_date', 'closing_date', 'status_since', 'min_height_in', 'max_height_in',
|
||||||
|
'accessibility_options', 'capacity_per_hour', 'ride_duration_seconds', 'description']
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
# If user is moderator or above, save directly
|
||||||
|
if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
|
||||||
|
self.object = form.save()
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
|
# Otherwise, create a submission
|
||||||
|
cleaned_data = form.cleaned_data.copy()
|
||||||
|
# Convert model instances to IDs for JSON serialization
|
||||||
|
if cleaned_data.get('park'):
|
||||||
|
cleaned_data['park'] = cleaned_data['park'].id
|
||||||
|
if cleaned_data.get('park_area'):
|
||||||
|
cleaned_data['park_area'] = cleaned_data['park_area'].id
|
||||||
|
if cleaned_data.get('manufacturer'):
|
||||||
|
cleaned_data['manufacturer'] = cleaned_data['manufacturer'].id
|
||||||
|
|
||||||
|
submission = EditSubmission.objects.create(
|
||||||
|
user=self.request.user,
|
||||||
|
content_type=ContentType.objects.get_for_model(Ride),
|
||||||
|
submission_type='CREATE',
|
||||||
|
changes=cleaned_data,
|
||||||
|
reason=self.request.POST.get('reason', ''),
|
||||||
|
source=self.request.POST.get('source', '')
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(reverse('rides:ride_list'))
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('rides:ride_detail', kwargs={
|
||||||
|
'park_slug': self.object.park.slug,
|
||||||
|
'ride_slug': self.object.slug
|
||||||
|
})
|
||||||
|
|
||||||
|
class RideDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView):
|
||||||
model = Ride
|
model = Ride
|
||||||
template_name = 'rides/ride_detail.html'
|
template_name = 'rides/ride_detail.html'
|
||||||
context_object_name = 'ride'
|
context_object_name = 'ride'
|
||||||
@@ -31,7 +75,7 @@ class RideDetailView(SlugRedirectMixin, DetailView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
def get_redirect_url_pattern(self):
|
def get_redirect_url_pattern(self):
|
||||||
return 'ride_detail'
|
return 'rides:ride_detail'
|
||||||
|
|
||||||
def get_redirect_url_kwargs(self):
|
def get_redirect_url_kwargs(self):
|
||||||
return {
|
return {
|
||||||
@@ -88,8 +132,6 @@ class RideListView(ListView):
|
|||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
# Check if this is an HTMX request
|
# Check if this is an HTMX request
|
||||||
if request.htmx:
|
if request.htmx:
|
||||||
|
|||||||
187
static/css/inline-edit.css
Normal file
187
static/css/inline-edit.css
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/* Inline editing styles */
|
||||||
|
.editable-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-editable] {
|
||||||
|
position: relative;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-editable]:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark [data-editable]:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-edit-button] {
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
right: 0.5rem;
|
||||||
|
top: 0.5rem;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark [data-edit-button] {
|
||||||
|
background-color: rgba(31, 41, 55, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-container:hover [data-edit-button] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input, .form-textarea, .form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: white;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .form-input, .dark .form-textarea, .dark .form-select {
|
||||||
|
background-color: #1f2937;
|
||||||
|
border-color: #374151;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus, .form-textarea:focus, .form-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .form-input:focus, .dark .form-textarea:focus, .dark .form-select:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notifications */
|
||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: white;
|
||||||
|
max-width: 24rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 50;
|
||||||
|
animation: slide-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-success {
|
||||||
|
background-color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .notification-success {
|
||||||
|
background-color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-error {
|
||||||
|
background-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .notification-error {
|
||||||
|
background-color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-info {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .notification-info {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add/Edit Form Styles */
|
||||||
|
.form-section {
|
||||||
|
@apply space-y-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
@apply space-y-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
@apply mt-1 text-sm text-red-600 dark:text-red-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help {
|
||||||
|
@apply mt-1 text-sm text-gray-500 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Styles */
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center px-4 py-2 font-medium transition-colors rounded-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply text-gray-700 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
@apply text-white bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badges */
|
||||||
|
.status-badge {
|
||||||
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-operating {
|
||||||
|
@apply text-green-800 bg-green-100 dark:bg-green-900 dark:text-green-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-closed {
|
||||||
|
@apply text-red-800 bg-red-100 dark:bg-red-900 dark:text-red-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-construction {
|
||||||
|
@apply text-yellow-800 bg-yellow-100 dark:bg-yellow-900 dark:text-yellow-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation Links */
|
||||||
|
.nav-link {
|
||||||
|
@apply flex items-center px-3 py-2 text-gray-700 transition-colors rounded-lg dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link i {
|
||||||
|
@apply mr-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu Items */
|
||||||
|
.menu-item {
|
||||||
|
@apply flex items-center w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item i {
|
||||||
|
@apply mr-3;
|
||||||
|
}
|
||||||
@@ -2391,6 +2391,10 @@ select {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.right-0 {
|
||||||
|
right: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
.top-1\/2 {
|
.top-1\/2 {
|
||||||
top: 50%;
|
top: 50%;
|
||||||
}
|
}
|
||||||
@@ -2399,6 +2403,10 @@ select {
|
|||||||
grid-column: span 2 / span 2;
|
grid-column: span 2 / span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.col-span-3 {
|
||||||
|
grid-column: span 3 / span 3;
|
||||||
|
}
|
||||||
|
|
||||||
.col-span-full {
|
.col-span-full {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
@@ -2430,6 +2438,10 @@ select {
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mb-3 {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mb-4 {
|
.mb-4 {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -2514,6 +2526,10 @@ select {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h-16 {
|
||||||
|
height: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.h-24 {
|
.h-24 {
|
||||||
height: 6rem;
|
height: 6rem;
|
||||||
}
|
}
|
||||||
@@ -2558,6 +2574,10 @@ select {
|
|||||||
width: 1rem;
|
width: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.w-48 {
|
||||||
|
width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
.w-5 {
|
.w-5 {
|
||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
}
|
}
|
||||||
@@ -2630,6 +2650,10 @@ select {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.justify-end {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.justify-center {
|
.justify-center {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
@@ -2726,6 +2750,16 @@ select {
|
|||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rounded-l-lg {
|
||||||
|
border-top-left-radius: 0.5rem;
|
||||||
|
border-bottom-left-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-r-lg {
|
||||||
|
border-top-right-radius: 0.5rem;
|
||||||
|
border-bottom-right-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.border {
|
.border {
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
}
|
}
|
||||||
@@ -2805,6 +2839,11 @@ select {
|
|||||||
background-color: rgb(219 234 254 / 0.9);
|
background-color: rgb(219 234 254 / 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-blue-50 {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(239 246 255 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.bg-blue-600 {
|
.bg-blue-600 {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
|
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
|
||||||
@@ -2834,6 +2873,11 @@ select {
|
|||||||
background-color: rgb(220 252 231 / 0.9);
|
background-color: rgb(220 252 231 / 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-green-600 {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.bg-primary\/10 {
|
.bg-primary\/10 {
|
||||||
background-color: rgb(79 70 229 / 0.1);
|
background-color: rgb(79 70 229 / 0.1);
|
||||||
}
|
}
|
||||||
@@ -2847,6 +2891,11 @@ select {
|
|||||||
background-color: rgb(254 226 226 / 0.9);
|
background-color: rgb(254 226 226 / 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-red-600 {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.bg-white {
|
.bg-white {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
||||||
@@ -2894,14 +2943,14 @@ select {
|
|||||||
--tw-gradient-stops: var(--tw-gradient-from), #eff6ff var(--tw-gradient-via-position), var(--tw-gradient-to);
|
--tw-gradient-stops: var(--tw-gradient-from), #eff6ff var(--tw-gradient-via-position), var(--tw-gradient-to);
|
||||||
}
|
}
|
||||||
|
|
||||||
.to-indigo-50 {
|
|
||||||
--tw-gradient-to: #eef2ff var(--tw-gradient-to-position);
|
|
||||||
}
|
|
||||||
|
|
||||||
.to-secondary {
|
.to-secondary {
|
||||||
--tw-gradient-to: #e11d48 var(--tw-gradient-to-position);
|
--tw-gradient-to: #e11d48 var(--tw-gradient-to-position);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.to-indigo-50 {
|
||||||
|
--tw-gradient-to: #eef2ff var(--tw-gradient-to-position);
|
||||||
|
}
|
||||||
|
|
||||||
.bg-clip-text {
|
.bg-clip-text {
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
@@ -3073,6 +3122,11 @@ select {
|
|||||||
color: rgb(37 99 235 / var(--tw-text-opacity));
|
color: rgb(37 99 235 / var(--tw-text-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-blue-700 {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(29 78 216 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.text-blue-800 {
|
.text-blue-800 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(30 64 175 / var(--tw-text-opacity));
|
color: rgb(30 64 175 / var(--tw-text-opacity));
|
||||||
@@ -3329,15 +3383,30 @@ select {
|
|||||||
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
|
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hover\:bg-gray-300:hover {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(209 213 219 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.hover\:bg-gray-50:hover {
|
.hover\:bg-gray-50:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
|
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hover\:bg-green-700:hover {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.hover\:bg-primary\/10:hover {
|
.hover\:bg-primary\/10:hover {
|
||||||
background-color: rgb(79 70 229 / 0.1);
|
background-color: rgb(79 70 229 / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hover\:bg-red-700:hover {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.hover\:from-primary\/90:hover {
|
.hover\:from-primary\/90:hover {
|
||||||
--tw-gradient-from: rgb(79 70 229 / 0.9) var(--tw-gradient-from-position);
|
--tw-gradient-from: rgb(79 70 229 / 0.9) var(--tw-gradient-from-position);
|
||||||
--tw-gradient-to: rgb(79 70 229 / 0) var(--tw-gradient-to-position);
|
--tw-gradient-to: rgb(79 70 229 / 0) var(--tw-gradient-to-position);
|
||||||
@@ -3427,6 +3496,11 @@ select {
|
|||||||
background-color: rgb(96 165 250 / 0.3);
|
background-color: rgb(96 165 250 / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:bg-blue-500:is(.dark *) {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:bg-blue-700:is(.dark *) {
|
.dark\:bg-blue-700:is(.dark *) {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
|
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
|
||||||
@@ -3436,6 +3510,15 @@ select {
|
|||||||
background-color: rgb(30 64 175 / 0.3);
|
background-color: rgb(30 64 175 / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:bg-blue-900:is(.dark *) {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(30 58 138 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark\:bg-blue-900\/50:is(.dark *) {
|
||||||
|
background-color: rgb(30 58 138 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:bg-gray-600:is(.dark *) {
|
.dark\:bg-gray-600:is(.dark *) {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
|
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
|
||||||
@@ -3463,6 +3546,11 @@ select {
|
|||||||
background-color: rgb(31 41 55 / 0.9);
|
background-color: rgb(31 41 55 / 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:bg-green-500:is(.dark *) {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:bg-green-700:is(.dark *) {
|
.dark\:bg-green-700:is(.dark *) {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
|
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
|
||||||
@@ -3472,6 +3560,16 @@ select {
|
|||||||
background-color: rgb(22 101 52 / 0.3);
|
background-color: rgb(22 101 52 / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:bg-green-900:is(.dark *) {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(20 83 45 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark\:bg-red-500:is(.dark *) {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:bg-red-700:is(.dark *) {
|
.dark\:bg-red-700:is(.dark *) {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
|
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
|
||||||
@@ -3481,6 +3579,11 @@ select {
|
|||||||
background-color: rgb(153 27 27 / 0.3);
|
background-color: rgb(153 27 27 / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:bg-red-900:is(.dark *) {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(127 29 29 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:bg-yellow-400\/30:is(.dark *) {
|
.dark\:bg-yellow-400\/30:is(.dark *) {
|
||||||
background-color: rgb(250 204 21 / 0.3);
|
background-color: rgb(250 204 21 / 0.3);
|
||||||
}
|
}
|
||||||
@@ -3494,6 +3597,11 @@ select {
|
|||||||
background-color: rgb(133 77 14 / 0.3);
|
background-color: rgb(133 77 14 / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:bg-yellow-900:is(.dark *) {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(113 63 18 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:from-gray-950:is(.dark *) {
|
.dark\:from-gray-950:is(.dark *) {
|
||||||
--tw-gradient-from: #030712 var(--tw-gradient-from-position);
|
--tw-gradient-from: #030712 var(--tw-gradient-from-position);
|
||||||
--tw-gradient-to: rgb(3 7 18 / 0) var(--tw-gradient-to-position);
|
--tw-gradient-to: rgb(3 7 18 / 0) var(--tw-gradient-to-position);
|
||||||
@@ -3549,6 +3657,11 @@ select {
|
|||||||
color: rgb(220 252 231 / var(--tw-text-opacity));
|
color: rgb(220 252 231 / var(--tw-text-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:text-green-200:is(.dark *) {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(187 247 208 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:text-primary:is(.dark *) {
|
.dark\:text-primary:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(79 70 229 / var(--tw-text-opacity));
|
color: rgb(79 70 229 / var(--tw-text-opacity));
|
||||||
@@ -3559,6 +3672,16 @@ select {
|
|||||||
color: rgb(254 226 226 / var(--tw-text-opacity));
|
color: rgb(254 226 226 / var(--tw-text-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:text-red-200:is(.dark *) {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(254 202 202 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark\:text-red-400:is(.dark *) {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(248 113 113 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:text-white:is(.dark *) {
|
.dark\:text-white:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||||
@@ -3598,20 +3721,45 @@ select {
|
|||||||
--tw-ring-color: rgb(250 204 21 / 0.3);
|
--tw-ring-color: rgb(250 204 21 / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:hover\:bg-blue-600:hover:is(.dark *) {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark\:hover\:bg-gray-500:hover:is(.dark *) {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:hover\:bg-gray-700:hover:is(.dark *) {
|
.dark\:hover\:bg-gray-700:hover:is(.dark *) {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
|
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:hover\:bg-green-600:hover:is(.dark *) {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:hover\:bg-primary\/20:hover:is(.dark *) {
|
.dark\:hover\:bg-primary\/20:hover:is(.dark *) {
|
||||||
background-color: rgb(79 70 229 / 0.2);
|
background-color: rgb(79 70 229 / 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:hover\:bg-red-600:hover:is(.dark *) {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:hover\:text-blue-300:hover:is(.dark *) {
|
.dark\:hover\:text-blue-300:hover:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(147 197 253 / var(--tw-text-opacity));
|
color: rgb(147 197 253 / var(--tw-text-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:hover\:text-blue-400:hover:is(.dark *) {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(96 165 250 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:hover\:text-primary:hover:is(.dark *) {
|
.dark\:hover\:text-primary:hover:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(79 70 229 / var(--tw-text-opacity));
|
color: rgb(79 70 229 / var(--tw-text-opacity));
|
||||||
|
|||||||
262
static/js/inline-edit.js
Normal file
262
static/js/inline-edit.js
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Handle edit button clicks
|
||||||
|
document.querySelectorAll('[data-edit-button]').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const contentId = this.dataset.contentId;
|
||||||
|
const contentType = this.dataset.contentType;
|
||||||
|
const editableFields = document.querySelectorAll(`[data-editable][data-content-id="${contentId}"]`);
|
||||||
|
|
||||||
|
// Toggle edit mode
|
||||||
|
editableFields.forEach(field => {
|
||||||
|
const currentValue = field.textContent.trim();
|
||||||
|
const fieldName = field.dataset.fieldName;
|
||||||
|
const fieldType = field.dataset.fieldType || 'text';
|
||||||
|
|
||||||
|
// Create input field
|
||||||
|
let input;
|
||||||
|
if (fieldType === 'textarea') {
|
||||||
|
input = document.createElement('textarea');
|
||||||
|
input.value = currentValue;
|
||||||
|
input.rows = 4;
|
||||||
|
} else if (fieldType === 'select') {
|
||||||
|
input = document.createElement('select');
|
||||||
|
// Get options from data attribute
|
||||||
|
const options = JSON.parse(field.dataset.options || '[]');
|
||||||
|
options.forEach(option => {
|
||||||
|
const optionEl = document.createElement('option');
|
||||||
|
optionEl.value = option.value;
|
||||||
|
optionEl.textContent = option.label;
|
||||||
|
optionEl.selected = option.value === currentValue;
|
||||||
|
input.appendChild(optionEl);
|
||||||
|
});
|
||||||
|
} else if (fieldType === 'date') {
|
||||||
|
input = document.createElement('input');
|
||||||
|
input.type = 'date';
|
||||||
|
input.value = currentValue;
|
||||||
|
} else if (fieldType === 'number') {
|
||||||
|
input = document.createElement('input');
|
||||||
|
input.type = 'number';
|
||||||
|
input.value = currentValue;
|
||||||
|
if (field.dataset.min) input.min = field.dataset.min;
|
||||||
|
if (field.dataset.max) input.max = field.dataset.max;
|
||||||
|
if (field.dataset.step) input.step = field.dataset.step;
|
||||||
|
} else {
|
||||||
|
input = document.createElement('input');
|
||||||
|
input.type = fieldType;
|
||||||
|
input.value = currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.className = 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white';
|
||||||
|
input.dataset.originalValue = currentValue;
|
||||||
|
input.dataset.fieldName = fieldName;
|
||||||
|
|
||||||
|
// Replace content with input
|
||||||
|
field.textContent = '';
|
||||||
|
field.appendChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show save/cancel buttons
|
||||||
|
const actionButtons = document.createElement('div');
|
||||||
|
actionButtons.className = 'flex gap-2 mt-2';
|
||||||
|
actionButtons.innerHTML = `
|
||||||
|
<button class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600" data-save-button>
|
||||||
|
<i class="mr-2 fas fa-save"></i>Save Changes
|
||||||
|
</button>
|
||||||
|
<button class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500" data-cancel-button>
|
||||||
|
<i class="mr-2 fas fa-times"></i>Cancel
|
||||||
|
</button>
|
||||||
|
${this.dataset.requireReason ? `
|
||||||
|
<div class="flex-grow">
|
||||||
|
<input type="text" class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="Reason for changes (required)"
|
||||||
|
data-reason-input>
|
||||||
|
<input type="text" class="w-full mt-1 border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="Source (optional)"
|
||||||
|
data-source-input>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const container = editableFields[0].closest('.editable-container');
|
||||||
|
container.appendChild(actionButtons);
|
||||||
|
|
||||||
|
// Hide edit button while editing
|
||||||
|
this.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle form submissions
|
||||||
|
document.querySelectorAll('form[data-submit-type]').forEach(form => {
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const submitType = this.dataset.submitType;
|
||||||
|
const formData = new FormData(this);
|
||||||
|
const data = {};
|
||||||
|
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
data[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get CSRF token from meta tag
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
fetch(this.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
submission_type: submitType,
|
||||||
|
...data
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
showNotification(data.message, 'success');
|
||||||
|
if (data.redirect_url) {
|
||||||
|
window.location.href = data.redirect_url;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showNotification(data.message, 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showNotification('An error occurred while submitting the form.', 'error');
|
||||||
|
console.error('Error:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle save button clicks using event delegation
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.matches('[data-save-button]')) {
|
||||||
|
const container = e.target.closest('.editable-container');
|
||||||
|
const contentId = container.querySelector('[data-editable]').dataset.contentId;
|
||||||
|
const contentType = container.querySelector('[data-edit-button]').dataset.contentType;
|
||||||
|
const editableFields = container.querySelectorAll('[data-editable]');
|
||||||
|
|
||||||
|
// Collect changes
|
||||||
|
const changes = {};
|
||||||
|
editableFields.forEach(field => {
|
||||||
|
const input = field.querySelector('input, textarea, select');
|
||||||
|
if (input && input.value !== input.dataset.originalValue) {
|
||||||
|
changes[input.dataset.fieldName] = input.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no changes, just cancel
|
||||||
|
if (Object.keys(changes).length === 0) {
|
||||||
|
cancelEdit(container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reason and source if required
|
||||||
|
const reasonInput = container.querySelector('[data-reason-input]');
|
||||||
|
const sourceInput = container.querySelector('[data-source-input]');
|
||||||
|
const reason = reasonInput ? reasonInput.value : '';
|
||||||
|
const source = sourceInput ? sourceInput.value : '';
|
||||||
|
|
||||||
|
// Validate reason if required
|
||||||
|
if (reasonInput && !reason) {
|
||||||
|
alert('Please provide a reason for your changes.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get CSRF token from meta tag
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||||
|
|
||||||
|
// Submit changes
|
||||||
|
fetch(window.location.pathname, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content_type: contentType,
|
||||||
|
content_id: contentId,
|
||||||
|
changes,
|
||||||
|
reason,
|
||||||
|
source
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
if (data.auto_approved) {
|
||||||
|
// Update the display immediately
|
||||||
|
Object.entries(changes).forEach(([field, value]) => {
|
||||||
|
const element = container.querySelector(`[data-editable][data-field-name="${field}"]`);
|
||||||
|
if (element) {
|
||||||
|
element.textContent = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showNotification(data.message, 'success');
|
||||||
|
if (data.redirect_url) {
|
||||||
|
window.location.href = data.redirect_url;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showNotification(data.message, 'error');
|
||||||
|
}
|
||||||
|
cancelEdit(container);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showNotification('An error occurred while saving changes.', 'error');
|
||||||
|
console.error('Error:', error);
|
||||||
|
cancelEdit(container);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle cancel button clicks using event delegation
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.matches('[data-cancel-button]')) {
|
||||||
|
const container = e.target.closest('.editable-container');
|
||||||
|
cancelEdit(container);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function cancelEdit(container) {
|
||||||
|
// Restore original content
|
||||||
|
container.querySelectorAll('[data-editable]').forEach(field => {
|
||||||
|
const input = field.querySelector('input, textarea, select');
|
||||||
|
if (input) {
|
||||||
|
field.textContent = input.dataset.originalValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove action buttons
|
||||||
|
const actionButtons = container.querySelector('.flex.gap-2');
|
||||||
|
if (actionButtons) {
|
||||||
|
actionButtons.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show edit button
|
||||||
|
const editButton = container.querySelector('[data-edit-button]');
|
||||||
|
if (editButton) {
|
||||||
|
editButton.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotification(message, type = 'info') {
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `fixed bottom-4 right-4 p-4 rounded-lg shadow-lg text-white ${
|
||||||
|
type === 'success' ? 'bg-green-600 dark:bg-green-500' :
|
||||||
|
type === 'error' ? 'bg-red-600 dark:bg-red-500' :
|
||||||
|
'bg-blue-600 dark:bg-blue-500'
|
||||||
|
}`;
|
||||||
|
notification.textContent = message;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
// Remove after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.remove();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
@@ -8,15 +8,17 @@
|
|||||||
id="turnstile-widget"
|
id="turnstile-widget"
|
||||||
class="cf-turnstile"
|
class="cf-turnstile"
|
||||||
data-sitekey="{{ site_key }}"
|
data-sitekey="{{ site_key }}"
|
||||||
|
data-theme="dark"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Apply theme to the Turnstile widget based on the retrieved theme
|
// Apply theme to the Turnstile widget based on system preference
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
const turnstileWidget = document.getElementById("turnstile-widget");
|
const turnstileWidget = document.getElementById("turnstile-widget");
|
||||||
if (turnstileWidget) {
|
if (turnstileWidget) {
|
||||||
turnstileWidget.setAttribute("data-theme", theme);
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
turnstileWidget.setAttribute("data-theme", prefersDark ? "dark" : "light");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<!-- [Previous head content remains unchanged...] -->
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
<title>{% block title %}ThrillWiki{% endblock %}</title>
|
<title>{% block title %}ThrillWiki{% endblock %}</title>
|
||||||
|
|
||||||
<!-- [Previous head content remains unchanged...] -->
|
|
||||||
<!-- Google Fonts -->
|
<!-- Google Fonts -->
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
|
||||||
@@ -42,9 +41,13 @@
|
|||||||
|
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="flex flex-col min-h-screen text-gray-900 bg-gradient-to-br from-white via-blue-50 to-indigo-50 dark:from-gray-950 dark:via-indigo-950 dark:to-purple-950 dark:text-white">
|
<body
|
||||||
|
class="flex flex-col min-h-screen text-gray-900 bg-gradient-to-br from-white via-blue-50 to-indigo-50 dark:from-gray-950 dark:via-indigo-950 dark:to-purple-950 dark:text-white"
|
||||||
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="border-b shadow-lg bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50">
|
<header
|
||||||
|
class="border-b shadow-lg bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50"
|
||||||
|
>
|
||||||
<nav class="container mx-auto nav-container">
|
<nav class="container mx-auto nav-container">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
@@ -97,31 +100,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- [Rest of the content remains unchanged...] -->
|
|
||||||
<!-- User Menu -->
|
<!-- User Menu -->
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<div class="relative">
|
{% if has_moderation_access %}
|
||||||
<button
|
<a href="{% url 'moderation:edit_submissions' %}" class="nav-link">
|
||||||
id="userMenuBtn"
|
<i class="fas fa-shield-alt"></i>
|
||||||
class="flex items-center space-x-2 transition-transform hover:scale-105"
|
<span>Moderation</span>
|
||||||
>
|
</a>
|
||||||
{% if user.profile.avatar %}
|
{% endif %}
|
||||||
<img
|
<div class="relative" x-data="{ open: false }">
|
||||||
src="{{ user.profile.avatar.url }}"
|
<button
|
||||||
alt="{{ user.username }}"
|
@click="open = !open"
|
||||||
class="w-8 h-8 rounded-full ring-2 ring-primary/20"
|
class="flex items-center space-x-2 transition-transform hover:scale-105"
|
||||||
/>
|
>
|
||||||
{% else %}
|
{% if user.profile.avatar %}
|
||||||
<div class="flex items-center justify-center w-8 h-8 text-white rounded-full bg-gradient-to-br from-primary to-secondary">
|
<img
|
||||||
{{ user.username.0|upper }}
|
src="{{ user.profile.avatar.url }}"
|
||||||
</div>
|
alt="{{ user.username }}"
|
||||||
{% endif %}
|
class="w-8 h-8 rounded-full ring-2 ring-primary/20"
|
||||||
</button>
|
/>
|
||||||
|
{% else %}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center w-8 h-8 text-white rounded-full bg-gradient-to-br from-primary to-secondary"
|
||||||
|
>
|
||||||
|
{{ user.username.0|upper }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ user.username }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Dropdown Menu -->
|
<!-- Dropdown Menu -->
|
||||||
<div id="userDropdown" class="dropdown-menu">
|
<div
|
||||||
<div class="py-1">
|
x-show="open"
|
||||||
<a href="{% url 'user_profile' user.username %}" class="menu-item">
|
@click.away="open = false"
|
||||||
|
class="absolute right-0 w-48 py-1 mt-2 bg-white rounded-md shadow-lg dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{% url 'profile' user.username %}"
|
||||||
|
class="menu-item"
|
||||||
|
>
|
||||||
<i class="w-5 fas fa-user"></i>
|
<i class="w-5 fas fa-user"></i>
|
||||||
<span>Profile</span>
|
<span>Profile</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -129,7 +146,7 @@
|
|||||||
<i class="w-5 fas fa-cog"></i>
|
<i class="w-5 fas fa-cog"></i>
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</a>
|
</a>
|
||||||
{% if user.is_staff or user.is_superuser %}
|
{% if has_admin_access %}
|
||||||
<a href="{% url 'admin:index' %}" class="menu-item">
|
<a href="{% url 'admin:index' %}" class="menu-item">
|
||||||
<i class="w-5 fas fa-shield-alt"></i>
|
<i class="w-5 fas fa-shield-alt"></i>
|
||||||
<span>Admin</span>
|
<span>Admin</span>
|
||||||
@@ -144,19 +161,18 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Login/Register (Desktop) -->
|
<!-- Login/Register (Desktop) -->
|
||||||
<div class="hidden space-x-3 lg:flex">
|
<div class="hidden space-x-3 lg:flex">
|
||||||
<a href="{% url 'account_login' %}" class="btn-secondary">
|
<a href="{% url 'account_login' %}" class="btn-secondary">
|
||||||
<i class="mr-2 fas fa-sign-in-alt"></i>
|
<i class="mr-2 fas fa-sign-in-alt"></i>
|
||||||
Login
|
Login
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'account_signup' %}" class="btn-primary">
|
<a href="{% url 'account_signup' %}" class="btn-primary">
|
||||||
<i class="mr-2 fas fa-user-plus"></i>
|
<i class="mr-2 fas fa-user-plus"></i>
|
||||||
Register
|
Register
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Mobile Menu Button -->
|
<!-- Mobile Menu Button -->
|
||||||
@@ -186,11 +202,17 @@
|
|||||||
{% if not user.is_authenticated %}
|
{% if not user.is_authenticated %}
|
||||||
<!-- Login/Register (Mobile) -->
|
<!-- Login/Register (Mobile) -->
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<a href="{% url 'account_login' %}" class="flex-1 mobile-nav-link secondary">
|
<a
|
||||||
|
href="{% url 'account_login' %}"
|
||||||
|
class="flex-1 mobile-nav-link secondary"
|
||||||
|
>
|
||||||
<i class="fas fa-sign-in-alt"></i>
|
<i class="fas fa-sign-in-alt"></i>
|
||||||
<span>Login</span>
|
<span>Login</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'account_signup' %}" class="flex-1 mobile-nav-link primary">
|
<a
|
||||||
|
href="{% url 'account_signup' %}"
|
||||||
|
class="flex-1 mobile-nav-link primary"
|
||||||
|
>
|
||||||
<i class="fas fa-user-plus"></i>
|
<i class="fas fa-user-plus"></i>
|
||||||
<span>Register</span>
|
<span>Register</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -201,12 +223,13 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- [Rest of the content remains unchanged...] -->
|
|
||||||
<!-- Flash Messages -->
|
<!-- Flash Messages -->
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<div class="container px-6 mx-auto mt-4">
|
<div class="container px-6 mx-auto mt-4">
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}">
|
<div
|
||||||
|
class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}"
|
||||||
|
>
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -219,7 +242,9 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="mt-auto border-t bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50">
|
<footer
|
||||||
|
class="mt-auto border-t bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50"
|
||||||
|
>
|
||||||
<div class="container px-6 py-6 mx-auto">
|
<div class="container px-6 py-6 mx-auto">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="text-gray-600 dark:text-gray-400">
|
<div class="text-gray-600 dark:text-gray-400">
|
||||||
@@ -229,11 +254,13 @@
|
|||||||
<a
|
<a
|
||||||
href="{% url 'terms' %}"
|
href="{% url 'terms' %}"
|
||||||
class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary"
|
class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary"
|
||||||
>Terms</a>
|
>Terms</a
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
href="{% url 'privacy' %}"
|
href="{% url 'privacy' %}"
|
||||||
class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary"
|
class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary"
|
||||||
>Privacy</a>
|
>Privacy</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,6 +268,6 @@
|
|||||||
|
|
||||||
<!-- Custom JavaScript -->
|
<!-- Custom JavaScript -->
|
||||||
<script src="{% static 'js/main.js' %}"></script>
|
<script src="{% static 'js/main.js' %}"></script>
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
159
templates/moderation/admin/base.html
Normal file
159
templates/moderation/admin/base.html
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
{% extends "base/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}ThrillWiki Moderation{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="{% static 'css/inline-edit.css' %}">
|
||||||
|
<style>
|
||||||
|
.moderation-nav {
|
||||||
|
@apply bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moderation-nav-container {
|
||||||
|
@apply container px-4 mx-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moderation-nav ul {
|
||||||
|
@apply flex items-center h-16 space-x-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moderation-nav li a {
|
||||||
|
@apply flex items-center px-3 py-2 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moderation-nav li a.active {
|
||||||
|
@apply bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moderation-nav li a i {
|
||||||
|
@apply mr-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moderation-content {
|
||||||
|
@apply container px-4 py-6 mx-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-list {
|
||||||
|
@apply space-y-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-card {
|
||||||
|
@apply bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200/50 dark:border-gray-700/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-header {
|
||||||
|
@apply flex justify-between items-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-meta {
|
||||||
|
@apply text-sm text-gray-600 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-changes {
|
||||||
|
@apply mt-4 space-y-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-item {
|
||||||
|
@apply flex items-start space-x-2 p-2 rounded bg-gray-50 dark:bg-gray-700/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-field {
|
||||||
|
@apply font-medium text-gray-900 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-value {
|
||||||
|
@apply text-gray-700 dark:text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
@apply flex justify-end space-x-4 mt-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-approve {
|
||||||
|
@apply px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reject {
|
||||||
|
@apply px-4 py-2 text-white bg-red-600 rounded-lg hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-notes {
|
||||||
|
@apply mt-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-notes textarea {
|
||||||
|
@apply w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
@apply p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200/50 dark:border-gray-700/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
@apply border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
@apply px-2 py-1 text-sm rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-approved, .status-auto_approved {
|
||||||
|
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-rejected {
|
||||||
|
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="moderation-nav">
|
||||||
|
<div class="moderation-nav-container">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'moderation:admin:index' %}" class="{% if request.resolver_match.url_name == 'index' %}active{% endif %}">
|
||||||
|
<i class="fas fa-tachometer-alt"></i>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'moderation:edit_submissions' %}"
|
||||||
|
class="{% if request.resolver_match.url_name == 'edit_submissions' %}active{% endif %}">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
Edit Submissions
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'moderation:photo_submissions' %}"
|
||||||
|
class="{% if request.resolver_match.url_name == 'photo_submissions' %}active{% endif %}">
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
Photo Submissions
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% if user.role == 'ADMIN' or user.role == 'SUPERUSER' %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'admin:index' %}"
|
||||||
|
class="{% if request.resolver_match.url_name == 'admin' %}active{% endif %}">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
Admin Panel
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="moderation-content">
|
||||||
|
{% block moderation_content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'js/inline-edit.js' %}"></script>
|
||||||
|
{% endblock %}
|
||||||
142
templates/moderation/admin/edit_submission_list.html
Normal file
142
templates/moderation/admin/edit_submission_list.html
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
{% extends "moderation/admin/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block moderation_content %}
|
||||||
|
<div class="submission-list">
|
||||||
|
<h1 class="mb-6 text-2xl font-bold">Content Submissions</h1>
|
||||||
|
|
||||||
|
<div class="mb-6 filters">
|
||||||
|
<form method="get" class="flex flex-wrap gap-4">
|
||||||
|
<select name="status" class="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="AUTO_APPROVED" {% if request.GET.status == 'AUTO_APPROVED' %}selected{% endif %}>Auto Approved</option>
|
||||||
|
</select>
|
||||||
|
<select name="type" class="form-select">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="EDIT" {% if request.GET.type == 'EDIT' %}selected{% endif %}>Edits</option>
|
||||||
|
<option value="CREATE" {% if request.GET.type == 'CREATE' %}selected{% endif %}>New Additions</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600">
|
||||||
|
<i class="mr-2 fas fa-filter"></i>Filter
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for submission in submissions %}
|
||||||
|
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold">
|
||||||
|
{% if submission.submission_type == 'CREATE' %}
|
||||||
|
New {{ submission.content_type.name }}
|
||||||
|
{% else %}
|
||||||
|
Edit to {{ submission.content_type.name }}: {{ submission.content_object }}
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Submitted by {{ submission.user.username }} on {{ submission.submitted_at|date:"M d, Y H:i" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="px-2 py-1 text-sm rounded-full {% if submission.submission_type == 'CREATE' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200{% else %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200{% endif %}">
|
||||||
|
{{ submission.get_submission_type_display }}
|
||||||
|
</div>
|
||||||
|
<div class="px-2 py-1 text-sm rounded-full {% if submission.status == 'PENDING' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
|
||||||
|
{% elif submission.status == 'APPROVED' or submission.status == 'AUTO_APPROVED' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
||||||
|
{% else %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200{% endif %}">
|
||||||
|
{{ submission.get_status_display }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Reason:</strong> {{ submission.reason }}
|
||||||
|
</div>
|
||||||
|
{% if submission.source %}
|
||||||
|
<div>
|
||||||
|
<strong>Source:</strong> {{ submission.source }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<h4 class="mb-2 font-medium">
|
||||||
|
{% if submission.submission_type == 'CREATE' %}
|
||||||
|
Details:
|
||||||
|
{% else %}
|
||||||
|
Changes:
|
||||||
|
{% endif %}
|
||||||
|
</h4>
|
||||||
|
{% for field, value in submission.changes.items %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<span class="font-medium">{{ field }}:</span>
|
||||||
|
<span>{{ value }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if submission.status == 'PENDING' %}
|
||||||
|
<form method="post" class="mt-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="submission_id" value="{{ submission.id }}">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block mb-2 font-medium">Review Notes:</label>
|
||||||
|
<textarea name="review_notes" rows="3"
|
||||||
|
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="{% if submission.submission_type == 'CREATE' %}Explain why you're approving/rejecting this new addition{% else %}Explain why you're approving/rejecting these changes{% endif %}"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-4">
|
||||||
|
<button type="submit" name="action" value="reject"
|
||||||
|
class="px-4 py-2 text-white bg-red-600 rounded-lg hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600">
|
||||||
|
<i class="mr-2 fas fa-times"></i>Reject
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="action" value="approve"
|
||||||
|
class="px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600">
|
||||||
|
<i class="mr-2 fas fa-check"></i>Approve
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Reviewed by:</strong> {{ submission.reviewed_by.username }} on {{ submission.reviewed_at|date:"M d, Y H:i" }}
|
||||||
|
</div>
|
||||||
|
{% if submission.review_notes %}
|
||||||
|
<div>
|
||||||
|
<strong>Review Notes:</strong> {{ submission.review_notes }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No submissions found.
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if is_paginated %}
|
||||||
|
<div class="flex justify-center mt-6">
|
||||||
|
<div class="inline-flex rounded-md shadow-sm">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<a href="?page=1{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">« First</a>
|
||||||
|
<a href="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
|
||||||
|
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<a href="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
|
||||||
|
<a href="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last »</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
104
templates/moderation/admin/photo_submission_list.html
Normal file
104
templates/moderation/admin/photo_submission_list.html
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "moderation/admin/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block moderation_content %}
|
||||||
|
<div class="submission-list">
|
||||||
|
<h1 class="mb-6 text-2xl font-bold">Photo Submissions</h1>
|
||||||
|
|
||||||
|
<div class="mb-6 filters">
|
||||||
|
<form method="get" class="flex space-x-4">
|
||||||
|
<select name="status" class="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="AUTO_APPROVED" {% if request.GET.status == 'AUTO_APPROVED' %}selected{% endif %}>Auto Approved</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary">Filter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for submission in submissions %}
|
||||||
|
<div class="submission-card">
|
||||||
|
<div class="submission-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold">
|
||||||
|
Photo for {{ submission.content_type.name }}: {{ submission.content_object }}
|
||||||
|
</h3>
|
||||||
|
<div class="submission-meta">
|
||||||
|
Submitted by {{ submission.user.username }} on {{ submission.submitted_at|date:"M d, Y H:i" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-badge status-{{ submission.status|lower }}">
|
||||||
|
{{ submission.get_status_display }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="submission-details">
|
||||||
|
<div class="mb-4 photo-preview">
|
||||||
|
<img src="{{ submission.photo.url }}" alt="Submitted photo" class="max-w-lg rounded">
|
||||||
|
</div>
|
||||||
|
{% if submission.caption %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Caption:</strong> {{ submission.caption }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if submission.date_taken %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Date Taken:</strong> {{ submission.date_taken|date:"M d, Y" }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if submission.status == 'PENDING' %}
|
||||||
|
<form method="post" class="mt-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="submission_id" value="{{ submission.id }}">
|
||||||
|
<div class="review-notes">
|
||||||
|
<label class="block mb-2">Review Notes:</label>
|
||||||
|
<textarea name="review_notes" rows="3" class="w-full"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button type="submit" name="action" value="approve" class="btn-approve">
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="action" value="reject" class="btn-reject">
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="mt-4">
|
||||||
|
<strong>Reviewed by:</strong> {{ submission.reviewed_by.username }}
|
||||||
|
<br>
|
||||||
|
<strong>Review Notes:</strong> {{ submission.review_notes }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="py-8 text-center text-gray-500">
|
||||||
|
No submissions found.
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if is_paginated %}
|
||||||
|
<div class="mt-6 pagination">
|
||||||
|
<span class="step-links">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<a href="?page=1">« first</a>
|
||||||
|
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="current">
|
||||||
|
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<a href="?page={{ page_obj.next_page_number }}">next</a>
|
||||||
|
<a href="?page={{ page_obj.paginator.num_pages }}">last »</a>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,27 +3,38 @@
|
|||||||
|
|
||||||
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
|
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="{% static 'css/inline-edit.css' %}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto px-4">
|
<div class="container px-4 mx-auto">
|
||||||
<!-- Park Header -->
|
<!-- Park Header -->
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-6 mb-6">
|
<div class="p-6 mb-6 bg-white border border-gray-200 rounded-lg shadow-lg dark:bg-gray-800 dark:border-gray-700 editable-container">
|
||||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center">
|
<div class="flex flex-col items-start justify-between md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ park.name }}</h1>
|
<h1 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ park.id }}"
|
||||||
|
data-field-name="name">{{ park.name }}</h1>
|
||||||
<p class="text-gray-600 dark:text-gray-300">
|
<p class="text-gray-600 dark:text-gray-300">
|
||||||
<i class="fas fa-map-marker-alt mr-2"></i>{{ park.location }}
|
<i class="mr-2 fas fa-map-marker-alt"></i>
|
||||||
|
<span data-editable data-content-id="{{ park.id }}"
|
||||||
|
data-field-name="location">{{ park.location }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 md:mt-0 flex gap-2">
|
<div class="flex gap-2 mt-4 md:mt-0">
|
||||||
{% if park.website %}
|
{% if park.website %}
|
||||||
<a href="{{ park.website }}" target="_blank" rel="noopener noreferrer"
|
<a href="{{ park.website }}" target="_blank" rel="noopener noreferrer"
|
||||||
class="btn-secondary">
|
class="btn-secondary">
|
||||||
<i class="fas fa-external-link-alt mr-2"></i>Visit Website
|
<i class="mr-2 fas fa-external-link-alt"></i>Visit Website
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<button class="btn-secondary">
|
<button class="btn-secondary" data-edit-button
|
||||||
<i class="fas fa-edit mr-2"></i>Edit
|
data-content-id="{{ park.id }}"
|
||||||
|
data-content-type="park"
|
||||||
|
{% if not can_auto_approve %}data-require-reason="true"{% endif %}>
|
||||||
|
<i class="mr-2 fas fa-edit"></i>Edit
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -32,12 +43,24 @@
|
|||||||
<div class="flex flex-wrap gap-2 mt-4">
|
<div class="flex flex-wrap gap-2 mt-4">
|
||||||
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
|
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
|
||||||
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||||
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction{% endif %}">
|
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||||
|
{% elif park.status == 'DEMOLISHED' %}status-demolished
|
||||||
|
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}"
|
||||||
|
data-editable data-content-id="{{ park.id }}"
|
||||||
|
data-field-name="status" data-field-type="select"
|
||||||
|
data-options='[
|
||||||
|
{"value": "OPERATING", "label": "Operating"},
|
||||||
|
{"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
|
||||||
|
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
|
||||||
|
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
|
||||||
|
{"value": "DEMOLISHED", "label": "Demolished"},
|
||||||
|
{"value": "RELOCATED", "label": "Relocated"}
|
||||||
|
]'>
|
||||||
{{ park.get_status_display }}
|
{{ park.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
{% if park.average_rating %}
|
{% if park.average_rating %}
|
||||||
<span class="status-badge bg-yellow-100 text-yellow-800 dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
|
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
|
||||||
<i class="fas fa-star text-yellow-500 dark:text-yellow-300 mr-1"></i>
|
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
|
||||||
{{ park.average_rating|floatformat:1 }}/10
|
{{ park.average_rating|floatformat:1 }}/10
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -45,39 +68,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Park Stats -->
|
<!-- Park Stats -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
<div class="grid grid-cols-1 gap-6 mb-6 md:grid-cols-3">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 text-center transform transition-transform hover:-translate-y-1">
|
<div class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||||
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
{{ rides.count }}
|
{{ rides.count }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600 dark:text-gray-300 mt-1">Total Attractions</div>
|
<div class="mt-1 text-gray-600 dark:text-gray-300">Total Attractions</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 text-center transform transition-transform hover:-translate-y-1">
|
<div class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||||
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
{% with roller_coasters=rides|dictsortreversed:"category"|slice:":RC" %}
|
{% with roller_coasters=rides|dictsortreversed:"category"|slice:":RC" %}
|
||||||
{{ roller_coasters|length }}
|
{{ roller_coasters|length }}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600 dark:text-gray-300 mt-1">Roller Coasters</div>
|
<div class="mt-1 text-gray-600 dark:text-gray-300">Roller Coasters</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 text-center transform transition-transform hover:-translate-y-1">
|
<div class="p-6 text-center transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||||
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
{{ areas.count }}
|
{{ areas.count }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600 dark:text-gray-300 mt-1">Areas</div>
|
<div class="mt-1 text-gray-600 dark:text-gray-300">Areas</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Grid -->
|
<!-- Main Content Grid -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<!-- Left Column - Description and Areas -->
|
<!-- Left Column - Description and Areas -->
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
{% if park.description %}
|
{% if park.description %}
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
|
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800 editable-container">
|
||||||
<h2 class="text-2xl font-bold mb-4 text-gray-900 dark:text-white">About</h2>
|
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">About</h2>
|
||||||
<div class="text-gray-700 dark:text-gray-300">
|
<div class="text-gray-700 dark:text-gray-300"
|
||||||
|
data-editable data-content-id="{{ park.id }}"
|
||||||
|
data-field-name="description" data-field-type="textarea">
|
||||||
{{ park.description|linebreaks }}
|
{{ park.description|linebreaks }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,14 +110,16 @@
|
|||||||
|
|
||||||
<!-- Park Areas -->
|
<!-- Park Areas -->
|
||||||
{% if areas %}
|
{% if areas %}
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
|
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||||
<h2 class="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Areas</h2>
|
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">Areas</h2>
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
{% for area in areas %}
|
{% for area in areas %}
|
||||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
|
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">{{ area.name }}</h3>
|
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
<a href="{% url 'parks:area_detail' park.slug area.slug %}">{{ area.name }}</a>
|
||||||
|
</h3>
|
||||||
{% if area.description %}
|
{% if area.description %}
|
||||||
<p class="text-gray-600 dark:text-gray-300 mb-2">
|
<p class="mb-2 text-gray-600 dark:text-gray-300">
|
||||||
{{ area.description }}
|
{{ area.description }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -106,19 +133,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Rides List -->
|
<!-- Rides List -->
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
<div class="p-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||||
<h2 class="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Attractions</h2>
|
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">Attractions</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{% for ride in rides %}
|
{% for ride in rides %}
|
||||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
|
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||||
<h3 class="text-lg font-semibold mb-2">
|
<h3 class="mb-2 text-lg font-semibold">
|
||||||
<a href="{% url 'rides:ride_detail' park.slug ride.slug %}"
|
<a href="{% url 'rides:ride_detail' park.slug ride.slug %}"
|
||||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
|
class="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
|
||||||
{{ ride.name }}
|
{{ ride.name }}
|
||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex flex-wrap gap-2 mb-2">
|
<div class="flex flex-wrap gap-2 mb-2">
|
||||||
<span class="status-badge bg-blue-100 text-blue-800 dark:bg-blue-400/30 dark:text-blue-200 dark:ring-1 dark:ring-blue-400/30">
|
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-400/30 dark:text-blue-200 dark:ring-1 dark:ring-blue-400/30">
|
||||||
{{ ride.get_category_display }}
|
{{ ride.get_category_display }}
|
||||||
</span>
|
</span>
|
||||||
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
|
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
|
||||||
@@ -128,7 +155,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% if ride.coaster_stats %}
|
{% if ride.coaster_stats %}
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
{% if ride.coaster_stats.height_ft %}
|
{% if ride.coaster_stats.height_ft %}
|
||||||
<div>Height: {{ ride.coaster_stats.height_ft }}ft</div>
|
<div>Height: {{ ride.coaster_stats.height_ft }}ft</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -139,7 +166,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<div class="col-span-2 text-center py-8">
|
<div class="col-span-2 py-8 text-center">
|
||||||
<p class="text-gray-500 dark:text-gray-400">No attractions found.</p>
|
<p class="text-gray-500 dark:text-gray-400">No attractions found.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -147,18 +174,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column - Quick Facts -->
|
<!-- Right Column - Quick Facts and History -->
|
||||||
<div>
|
<div>
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
|
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800 editable-container">
|
||||||
<h2 class="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Quick Facts</h2>
|
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">Quick Facts</h2>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 gap-4">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
||||||
{% if park.owner %}
|
{% if park.owner %}
|
||||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
|
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||||
<dt class="flex items-center text-gray-600 dark:text-gray-300 mb-1">
|
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
|
||||||
<i class="fas fa-building text-blue-500 dark:text-blue-400 w-5"></i>
|
<i class="w-5 text-blue-500 fas fa-building dark:text-blue-400"></i>
|
||||||
<span class="ml-2">Operator</span>
|
<span class="ml-2">Operator</span>
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="text-gray-900 dark:text-white font-medium">
|
<dd class="font-medium text-gray-900 dark:text-white">
|
||||||
<a href="{% url 'companies:company_detail' park.owner.slug %}"
|
<a href="{% url 'companies:company_detail' park.owner.slug %}"
|
||||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
|
class="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
|
||||||
{{ park.owner.name }}
|
{{ park.owner.name }}
|
||||||
@@ -166,55 +193,65 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
|
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||||
<dt class="flex items-center text-gray-600 dark:text-gray-300 mb-1">
|
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
|
||||||
<i class="fas fa-globe text-blue-500 dark:text-blue-400 w-5"></i>
|
<i class="w-5 text-blue-500 fas fa-globe dark:text-blue-400"></i>
|
||||||
<span class="ml-2">Country</span>
|
<span class="ml-2">Country</span>
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="text-gray-900 dark:text-white font-medium">
|
<dd class="font-medium text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ park.id }}"
|
||||||
|
data-field-name="country">
|
||||||
{{ park.get_country_name }}
|
{{ park.get_country_name }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{% if park.opening_date %}
|
{% if park.opening_date %}
|
||||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
|
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||||
<dt class="flex items-center text-gray-600 dark:text-gray-300 mb-1">
|
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
|
||||||
<i class="fas fa-calendar-alt text-blue-500 dark:text-blue-400 w-5"></i>
|
<i class="w-5 text-blue-500 fas fa-calendar-alt dark:text-blue-400"></i>
|
||||||
<span class="ml-2">Opening Date</span>
|
<span class="ml-2">Opening Date</span>
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="text-gray-900 dark:text-white font-medium">
|
<dd class="font-medium text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ park.id }}"
|
||||||
|
data-field-name="opening_date" data-field-type="date">
|
||||||
{{ park.opening_date }}
|
{{ park.opening_date }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if park.closing_date %}
|
{% if park.closing_date %}
|
||||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
|
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||||
<dt class="flex items-center text-gray-600 dark:text-gray-300 mb-1">
|
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
|
||||||
<i class="fas fa-calendar-times text-blue-500 dark:text-blue-400 w-5"></i>
|
<i class="w-5 text-blue-500 fas fa-calendar-times dark:text-blue-400"></i>
|
||||||
<span class="ml-2">Closing Date</span>
|
<span class="ml-2">Closing Date</span>
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="text-gray-900 dark:text-white font-medium">
|
<dd class="font-medium text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ park.id }}"
|
||||||
|
data-field-name="closing_date" data-field-type="date">
|
||||||
{{ park.closing_date }}
|
{{ park.closing_date }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if park.operating_season %}
|
{% if park.operating_season %}
|
||||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
|
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||||
<dt class="flex items-center text-gray-600 dark:text-gray-300 mb-1">
|
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
|
||||||
<i class="fas fa-clock text-blue-500 dark:text-blue-400 w-5"></i>
|
<i class="w-5 text-blue-500 fas fa-clock dark:text-blue-400"></i>
|
||||||
<span class="ml-2">Operating Season</span>
|
<span class="ml-2">Operating Season</span>
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="text-gray-900 dark:text-white font-medium">
|
<dd class="font-medium text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ park.id }}"
|
||||||
|
data-field-name="operating_season">
|
||||||
{{ park.operating_season }}
|
{{ park.operating_season }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if park.size_acres %}
|
{% if park.size_acres %}
|
||||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 transform transition-transform hover:-translate-y-1">
|
<div class="p-4 transition-transform transform rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:-translate-y-1">
|
||||||
<dt class="flex items-center text-gray-600 dark:text-gray-300 mb-1">
|
<dt class="flex items-center mb-1 text-gray-600 dark:text-gray-300">
|
||||||
<i class="fas fa-ruler-combined text-blue-500 dark:text-blue-400 w-5"></i>
|
<i class="w-5 text-blue-500 fas fa-ruler-combined dark:text-blue-400"></i>
|
||||||
<span class="ml-2">Size</span>
|
<span class="ml-2">Size</span>
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="text-gray-900 dark:text-white font-medium">
|
<dd class="font-medium text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ park.id }}"
|
||||||
|
data-field-name="size_acres" data-field-type="number">
|
||||||
{{ park.size_acres }} acres
|
{{ park.size_acres }} acres
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,9 +259,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- History Panel -->
|
||||||
|
<div class="p-6 mb-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||||
|
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">History</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% for record in history %}
|
||||||
|
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ record.history_date|date:"M d, Y H:i" }}
|
||||||
|
{% if record.history_user %}
|
||||||
|
by {{ record.history_user.username }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
{% for field, change in record.diff_against_previous %}
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="font-medium">{{ field }}:</span>
|
||||||
|
{{ change.old }} → {{ change.new }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">No history available.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photos -->
|
||||||
{% if park.photos.exists %}
|
{% if park.photos.exists %}
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
<div class="p-6 bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||||
<h2 class="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Photos</h2>
|
<h2 class="mb-4 text-2xl font-bold text-gray-900 dark:text-white">Photos</h2>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
{% for photo in park.photos.all %}
|
{% for photo in park.photos.all %}
|
||||||
<div class="aspect-w-16 aspect-h-9">
|
<div class="aspect-w-16 aspect-h-9">
|
||||||
@@ -240,3 +305,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'js/inline-edit.js' %}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
92
templates/parks/park_form.html
Normal file
92
templates/parks/park_form.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{% extends 'base/base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Add Park - ThrillWiki{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container px-4 mx-auto">
|
||||||
|
<div class="max-w-3xl 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">Add Park</h1>
|
||||||
|
|
||||||
|
<form method="post" class="space-y-6">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for field in form %}
|
||||||
|
<div>
|
||||||
|
<label for="{{ field.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ field.label }}
|
||||||
|
</label>
|
||||||
|
{% if field.field.widget.input_type == 'text' or field.field.widget.input_type == 'date' %}
|
||||||
|
<input type="{{ field.field.widget.input_type }}"
|
||||||
|
name="{{ field.name }}"
|
||||||
|
id="{{ field.id_for_label }}"
|
||||||
|
value="{{ field.value|default:'' }}"
|
||||||
|
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
{% if field.field.required %}required{% endif %}>
|
||||||
|
{% elif field.field.widget.input_type == 'select' %}
|
||||||
|
<select name="{{ field.name }}"
|
||||||
|
id="{{ field.id_for_label }}"
|
||||||
|
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
{% if field.field.required %}required{% endif %}>
|
||||||
|
{% for value, label in field.field.choices %}
|
||||||
|
<option value="{{ value }}" {% if field.value == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% elif field.field.widget.input_type == 'textarea' %}
|
||||||
|
<textarea name="{{ field.name }}"
|
||||||
|
id="{{ field.id_for_label }}"
|
||||||
|
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
rows="4"
|
||||||
|
{% if field.field.required %}required{% endif %}>{{ field.value|default:'' }}</textarea>
|
||||||
|
{% endif %}
|
||||||
|
{% if field.help_text %}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ field.help_text }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if field.errors %}
|
||||||
|
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ field.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if not user.role == 'MODERATOR' and not user.role == 'ADMIN' and not user.role == 'SUPERUSER' %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="reason" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Reason for Addition
|
||||||
|
</label>
|
||||||
|
<textarea name="reason"
|
||||||
|
id="reason"
|
||||||
|
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
rows="3"
|
||||||
|
required
|
||||||
|
placeholder="Please explain why you're adding this park and provide any relevant details."></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Source (Optional)
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
name="source"
|
||||||
|
id="source"
|
||||||
|
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="Link to official website, news article, or other source">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-4">
|
||||||
|
<a href="{% url 'parks:park_list' %}" class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,55 +4,135 @@
|
|||||||
{% block title %}Parks - ThrillWiki{% endblock %}
|
{% block title %}Parks - ThrillWiki{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto px-4">
|
<div class="container px-4 mx-auto">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Theme Parks & Amusement Parks</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Parks</h1>
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<a href="{% url 'parks:park_create' %}" class="btn-primary">
|
||||||
|
<i class="mr-2 fas fa-plus"></i>Add Park
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-4 mb-6">
|
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||||
<h2 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">Filters</h2>
|
<form method="get" class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
<form hx-get="{% url 'parks:park_list' %}"
|
|
||||||
hx-target="#parks-grid"
|
|
||||||
hx-push-url="true"
|
|
||||||
hx-trigger="change from:select, keyup[target.name=='search'] delay:500ms from:input"
|
|
||||||
class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search</label>
|
<label for="search" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||||
<input type="text"
|
<input type="text" name="search" id="search"
|
||||||
name="search"
|
|
||||||
value="{{ current_filters.search }}"
|
value="{{ current_filters.search }}"
|
||||||
placeholder="Search parks..."
|
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
class="form-input w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
placeholder="Search parks...">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Location</label>
|
<label for="location" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Location</label>
|
||||||
<select name="location"
|
<select name="location" id="location"
|
||||||
class="form-select w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||||
<option value="">All Locations</option>
|
<option value="">All Locations</option>
|
||||||
{% for location in locations %}
|
{% for location in locations %}
|
||||||
<option value="{{ location }}" {% if current_filters.location == location %}selected{% endif %}>{{ location }}</option>
|
<option value="{{ location }}" {% if current_filters.location == location %}selected{% endif %}>
|
||||||
|
{{ location }}
|
||||||
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
|
<label for="status" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
|
||||||
<select name="status"
|
<select name="status" id="status"
|
||||||
class="form-select w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
<option value="OPERATING" {% if current_filters.status == 'OPERATING' %}selected{% endif %}>Operating</option>
|
<option value="OPERATING" {% if current_filters.status == 'OPERATING' %}selected{% endif %}>Operating</option>
|
||||||
<option value="CLOSED_TEMP" {% if current_filters.status == 'CLOSED_TEMP' %}selected{% endif %}>Temporarily Closed</option>
|
<option value="CLOSED_TEMP" {% if current_filters.status == 'CLOSED_TEMP' %}selected{% endif %}>Temporarily Closed</option>
|
||||||
<option value="CLOSED_PERM" {% if current_filters.status == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
|
<option value="CLOSED_PERM" {% if current_filters.status == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
|
||||||
<option value="UNDER_CONSTRUCTION" {% if current_filters.status == 'UNDER_CONSTRUCTION' %}selected{% endif %}>Under Construction</option>
|
<option value="UNDER_CONSTRUCTION" {% if current_filters.status == 'UNDER_CONSTRUCTION' %}selected{% endif %}>Under Construction</option>
|
||||||
<option value="DEMOLISHED" {% if current_filters.status == 'DEMOLISHED' %}selected{% endif %}>Demolished</option>
|
<option value="DEMOLISHED" {% if current_filters.status == 'DEMOLISHED' %}selected{% endif %}>Demolished</option>
|
||||||
|
<option value="RELOCATED" {% if current_filters.status == 'RELOCATED' %}selected{% endif %}>Relocated</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button type="submit" class="w-full btn-primary">
|
||||||
|
<i class="mr-2 fas fa-filter"></i>Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Parks Grid -->
|
<!-- Parks Grid -->
|
||||||
<div id="parks-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{% include 'parks/partials/park_list.html' %}
|
{% for park in parks %}
|
||||||
|
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||||
|
{% if park.photos.exists %}
|
||||||
|
<div class="aspect-w-16 aspect-h-9">
|
||||||
|
<img src="{{ park.photos.first.image.url }}"
|
||||||
|
alt="{{ park.name }}"
|
||||||
|
class="object-cover w-full">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="p-4">
|
||||||
|
<h2 class="mb-2 text-xl font-bold">
|
||||||
|
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||||
|
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
|
||||||
|
{{ park.name }}
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
<p class="mb-3 text-gray-600 dark:text-gray-400">
|
||||||
|
<i class="mr-1 fas fa-map-marker-alt"></i>
|
||||||
|
{{ park.location }}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span class="status-badge {% if park.status == 'OPERATING' %}status-operating
|
||||||
|
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||||
|
{% elif park.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||||
|
{% elif park.status == 'DEMOLISHED' %}status-demolished
|
||||||
|
{% elif park.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||||
|
{{ park.get_status_display }}
|
||||||
|
</span>
|
||||||
|
{% if park.average_rating %}
|
||||||
|
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
|
||||||
|
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
|
||||||
|
{{ park.average_rating|floatformat:1 }}/10
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between mt-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ park.rides.count }} attractions
|
||||||
|
</div>
|
||||||
|
{% if park.owner %}
|
||||||
|
<a href="{% url 'companies:company_detail' park.owner.slug %}"
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||||
|
{{ park.owner.name }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-span-3 py-8 text-center">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">No parks found matching your criteria.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if is_paginated %}
|
||||||
|
<div class="flex justify-center mt-6">
|
||||||
|
<div class="inline-flex rounded-md shadow-sm">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<a href="?page=1{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">« First</a>
|
||||||
|
<a href="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
|
||||||
|
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<a href="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
|
||||||
|
<a href="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last »</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,33 +3,61 @@
|
|||||||
|
|
||||||
{% block title %}{{ ride.name }} at {{ ride.park.name }} - ThrillWiki{% endblock %}
|
{% block title %}{{ ride.name }} at {{ ride.park.name }} - ThrillWiki{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="{% static 'css/inline-edit.css' %}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto px-4">
|
<div class="container px-4 mx-auto">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
|
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800 editable-container">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold mb-2">{{ ride.name }}</h1>
|
<h1 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white"
|
||||||
<p class="text-gray-600 dark:text-gray-400 mb-2">
|
data-editable data-content-id="{{ ride.id }}"
|
||||||
|
data-field-name="name">{{ ride.name }}</h1>
|
||||||
|
<p class="mb-2 text-gray-600 dark:text-gray-400">
|
||||||
at <a href="{% url 'parks:park_detail' ride.park.slug %}" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">
|
at <a href="{% url 'parks:park_detail' ride.park.slug %}" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">
|
||||||
{{ ride.park.name }}
|
{{ ride.park.name }}
|
||||||
</a>
|
</a>
|
||||||
{% if ride.area %}
|
{% if ride.park_area %}
|
||||||
- {{ ride.area.name }}
|
- {{ ride.park_area.name }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-wrap gap-2 mt-3">
|
<div class="flex flex-wrap gap-2 mt-3">
|
||||||
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
|
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
|
||||||
{% elif ride.status == 'CLOSED' %}status-closed
|
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
|
||||||
{% else %}status-construction{% endif %}">
|
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||||
|
{% elif ride.status == 'DEMOLISHED' %}status-demolished
|
||||||
|
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}"
|
||||||
|
data-editable data-content-id="{{ ride.id }}"
|
||||||
|
data-field-name="status" data-field-type="select"
|
||||||
|
data-options='[
|
||||||
|
{"value": "OPERATING", "label": "Operating"},
|
||||||
|
{"value": "CLOSED_TEMP", "label": "Temporarily Closed"},
|
||||||
|
{"value": "CLOSED_PERM", "label": "Permanently Closed"},
|
||||||
|
{"value": "UNDER_CONSTRUCTION", "label": "Under Construction"},
|
||||||
|
{"value": "DEMOLISHED", "label": "Demolished"},
|
||||||
|
{"value": "RELOCATED", "label": "Relocated"}
|
||||||
|
]'>
|
||||||
{{ ride.get_status_display }}
|
{{ ride.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
<span class="status-badge bg-blue-100 text-blue-800 dark:bg-blue-700 dark:text-blue-50">
|
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50"
|
||||||
|
data-editable data-content-id="{{ ride.id }}"
|
||||||
|
data-field-name="category" data-field-type="select"
|
||||||
|
data-options='[
|
||||||
|
{"value": "RC", "label": "Roller Coaster"},
|
||||||
|
{"value": "DR", "label": "Dark Ride"},
|
||||||
|
{"value": "FR", "label": "Flat Ride"},
|
||||||
|
{"value": "WR", "label": "Water Ride"},
|
||||||
|
{"value": "TR", "label": "Transport"},
|
||||||
|
{"value": "OT", "label": "Other"}
|
||||||
|
]'>
|
||||||
{{ ride.get_category_display }}
|
{{ ride.get_category_display }}
|
||||||
</span>
|
</span>
|
||||||
{% if ride.average_rating %}
|
{% if ride.average_rating %}
|
||||||
<span class="status-badge bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50 flex items-center">
|
<span class="flex items-center text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
|
||||||
<span class="text-yellow-500 dark:text-yellow-200 mr-1">★</span>
|
<span class="mr-1 text-yellow-500 dark:text-yellow-200">★</span>
|
||||||
{{ ride.average_rating|floatformat:1 }}/10
|
{{ ride.average_rating|floatformat:1 }}/10
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -37,14 +65,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button class="btn-secondary">
|
<button class="btn-secondary" data-edit-button
|
||||||
<i class="fas fa-edit mr-2"></i>
|
data-content-id="{{ ride.id }}"
|
||||||
|
data-content-type="ride"
|
||||||
|
{% if not can_auto_approve %}data-require-reason="true"{% endif %}>
|
||||||
|
<i class="mr-2 fas fa-edit"></i>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-secondary">
|
|
||||||
<i class="fas fa-history mr-2"></i>
|
|
||||||
History
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -52,9 +79,9 @@
|
|||||||
|
|
||||||
<!-- Photos Grid -->
|
<!-- Photos Grid -->
|
||||||
{% if ride.photos.exists %}
|
{% if ride.photos.exists %}
|
||||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
|
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||||
<h2 class="text-xl font-semibold mb-4">Photos</h2>
|
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Photos</h2>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||||||
{% for photo in ride.photos.all %}
|
{% for photo in ride.photos.all %}
|
||||||
<div class="aspect-w-16 aspect-h-9">
|
<div class="aspect-w-16 aspect-h-9">
|
||||||
<img src="{{ photo.image.url }}"
|
<img src="{{ photo.image.url }}"
|
||||||
@@ -67,19 +94,21 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Main Content Grid -->
|
<!-- Main Content Grid -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<!-- Left Column - Description and Details -->
|
<!-- Left Column - Description and Details -->
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
|
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800 editable-container">
|
||||||
<h2 class="text-xl font-semibold mb-4">About</h2>
|
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">About</h2>
|
||||||
<div class="prose dark:prose-invert max-w-none">
|
<div class="prose dark:prose-invert max-w-none"
|
||||||
|
data-editable data-content-id="{{ ride.id }}"
|
||||||
|
data-field-name="description" data-field-type="textarea">
|
||||||
{{ ride.description|linebreaks }}
|
{{ ride.description|linebreaks }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if ride.previous_names %}
|
{% if ride.previous_names %}
|
||||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
|
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||||
<h2 class="text-xl font-semibold mb-4">Previous Names</h2>
|
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Previous Names</h2>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{% for name_history in ride.previous_names %}
|
{% for name_history in ride.previous_names %}
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
@@ -92,35 +121,55 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if coaster_stats %}
|
{% if coaster_stats %}
|
||||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
|
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800 editable-container">
|
||||||
<h2 class="text-xl font-semibold mb-4">Roller Coaster Statistics</h2>
|
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Roller Coaster Statistics</h2>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||||
{% if coaster_stats.height_ft %}
|
{% if coaster_stats.height_ft %}
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500 block">Height</span>
|
<span class="block text-gray-500">Height</span>
|
||||||
<span class="text-2xl font-bold">{{ coaster_stats.height_ft }} ft</span>
|
<span class="text-2xl font-bold text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ coaster_stats.id }}"
|
||||||
|
data-field-name="height_ft" data-field-type="number">
|
||||||
|
{{ coaster_stats.height_ft }} ft
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if coaster_stats.length_ft %}
|
{% if coaster_stats.length_ft %}
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500 block">Length</span>
|
<span class="block text-gray-500">Length</span>
|
||||||
<span class="text-2xl font-bold">{{ coaster_stats.length_ft }} ft</span>
|
<span class="text-2xl font-bold text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ coaster_stats.id }}"
|
||||||
|
data-field-name="length_ft" data-field-type="number">
|
||||||
|
{{ coaster_stats.length_ft }} ft
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if coaster_stats.speed_mph %}
|
{% if coaster_stats.speed_mph %}
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500 block">Speed</span>
|
<span class="block text-gray-500">Speed</span>
|
||||||
<span class="text-2xl font-bold">{{ coaster_stats.speed_mph }} mph</span>
|
<span class="text-2xl font-bold text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ coaster_stats.id }}"
|
||||||
|
data-field-name="speed_mph" data-field-type="number">
|
||||||
|
{{ coaster_stats.speed_mph }} mph
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500 block">Inversions</span>
|
<span class="block text-gray-500">Inversions</span>
|
||||||
<span class="text-2xl font-bold">{{ coaster_stats.inversions }}</span>
|
<span class="text-2xl font-bold text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ coaster_stats.id }}"
|
||||||
|
data-field-name="inversions" data-field-type="number">
|
||||||
|
{{ coaster_stats.inversions }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% if coaster_stats.ride_time_seconds %}
|
{% if coaster_stats.ride_time_seconds %}
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500 block">Ride Duration</span>
|
<span class="block text-gray-500">Ride Duration</span>
|
||||||
<span class="text-2xl font-bold">{{ coaster_stats.ride_time_seconds }} sec</span>
|
<span class="text-2xl font-bold text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ coaster_stats.id }}"
|
||||||
|
data-field-name="ride_time_seconds" data-field-type="number">
|
||||||
|
{{ coaster_stats.ride_time_seconds }} sec
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -130,75 +179,112 @@
|
|||||||
|
|
||||||
<!-- Right Column - Quick Facts -->
|
<!-- Right Column - Quick Facts -->
|
||||||
<div class="lg:col-span-1">
|
<div class="lg:col-span-1">
|
||||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
|
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800 editable-container">
|
||||||
<h2 class="text-xl font-semibold mb-4">Quick Facts</h2>
|
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Quick Facts</h2>
|
||||||
<dl class="space-y-4">
|
<dl class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">Manufacturer</dt>
|
<dt class="text-gray-500">Manufacturer</dt>
|
||||||
<dd>{{ ride.manufacturer }}</dd>
|
<dd class="font-medium text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ ride.id }}"
|
||||||
|
data-field-name="manufacturer">{{ ride.manufacturer }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% if ride.model_name %}
|
{% if ride.model_name %}
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">Model</dt>
|
<dt class="text-gray-500">Model</dt>
|
||||||
<dd>{{ ride.model_name }}</dd>
|
<dd class="font-medium text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ ride.id }}"
|
||||||
|
data-field-name="model_name">{{ ride.model_name }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if ride.opening_date %}
|
{% if ride.opening_date %}
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">Opening Date</dt>
|
<dt class="text-gray-500">Opening Date</dt>
|
||||||
<dd>{{ ride.opening_date }}</dd>
|
<dd class="font-medium text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ ride.id }}"
|
||||||
|
data-field-name="opening_date" data-field-type="date">
|
||||||
|
{{ ride.opening_date }}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if ride.status_since %}
|
{% if ride.status_since %}
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">Status Since</dt>
|
<dt class="text-gray-500">Status Since</dt>
|
||||||
<dd>{{ ride.status_since }}</dd>
|
<dd class="font-medium text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ ride.id }}"
|
||||||
|
data-field-name="status_since" data-field-type="date">
|
||||||
|
{{ ride.status_since }}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if ride.closing_date %}
|
{% if ride.closing_date %}
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">Closing Date</dt>
|
<dt class="text-gray-500">Closing Date</dt>
|
||||||
<dd>{{ ride.closing_date }}</dd>
|
<dd class="font-medium text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ ride.id }}"
|
||||||
|
data-field-name="closing_date" data-field-type="date">
|
||||||
|
{{ ride.closing_date }}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if ride.capacity_per_hour %}
|
{% if ride.capacity_per_hour %}
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">Capacity</dt>
|
<dt class="text-gray-500">Capacity</dt>
|
||||||
<dd>{{ ride.capacity_per_hour }} riders/hour</dd>
|
<dd class="font-medium text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ ride.id }}"
|
||||||
|
data-field-name="capacity_per_hour" data-field-type="number">
|
||||||
|
{{ ride.capacity_per_hour }} riders/hour
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if ride.minimum_height %}
|
{% if ride.min_height_in %}
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-500">Minimum Height</dt>
|
<dt class="text-gray-500">Minimum Height</dt>
|
||||||
<dd>{{ ride.minimum_height }} inches</dd>
|
<dd class="font-medium text-gray-900 dark:text-white"
|
||||||
|
data-editable data-content-id="{{ ride.id }}"
|
||||||
|
data-field-name="min_height_in" data-field-type="number">
|
||||||
|
{{ ride.min_height_in }} inches
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if ride.other_details %}
|
<!-- History Panel -->
|
||||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
|
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||||
<h2 class="text-xl font-semibold mb-4">Additional Details</h2>
|
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">History</h2>
|
||||||
<dl class="space-y-4">
|
<div class="space-y-4">
|
||||||
{% for key, value in ride.other_details.items %}
|
{% for record in history %}
|
||||||
<div>
|
<div class="p-4 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||||
<dt class="text-gray-500">{{ key }}</dt>
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
<dd>{{ value }}</dd>
|
{{ record.history_date|date:"M d, Y H:i" }}
|
||||||
|
{% if record.history_user %}
|
||||||
|
by {{ record.history_user.username }}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
<div class="mt-2">
|
||||||
</dl>
|
{% for field, change in record.diff_against_previous %}
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="font-medium">{{ field }}:</span>
|
||||||
|
{{ change.old }} → {{ change.new }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-gray-500">No history available.</p>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reviews Section -->
|
<!-- Reviews Section -->
|
||||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mt-6">
|
<div class="p-6 mt-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-xl font-semibold">Reviews</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Reviews</h2>
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<button class="btn-primary">
|
<button class="btn-primary">
|
||||||
<i class="fas fa-star mr-2"></i>
|
<i class="mr-2 fas fa-star"></i>
|
||||||
Write a Review
|
Write a Review
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -207,20 +293,20 @@
|
|||||||
{% if ride.reviews.exists %}
|
{% if ride.reviews.exists %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{% for review in ride.reviews.all %}
|
{% for review in ride.reviews.all %}
|
||||||
<div class="border-b border-gray-200 dark:border-gray-700 pb-4">
|
<div class="pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold">{{ review.title }}</h3>
|
<h3 class="font-semibold text-gray-900 dark:text-white">{{ review.title }}</h3>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
by {{ review.user.username }} on {{ review.created_at|date }}
|
by {{ review.user.username }} on {{ review.created_at|date }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="text-yellow-400 mr-1">★</span>
|
<span class="mr-1 text-yellow-400">★</span>
|
||||||
<span>{{ review.rating }}/10</span>
|
<span>{{ review.rating }}/10</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2">{{ review.content }}</p>
|
<p class="mt-2 text-gray-700 dark:text-gray-300">{{ review.content }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -230,3 +316,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'js/inline-edit.js' %}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
92
templates/rides/ride_form.html
Normal file
92
templates/rides/ride_form.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{% extends 'base/base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Add Ride - ThrillWiki{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container px-4 mx-auto">
|
||||||
|
<div class="max-w-3xl 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">Add Ride</h1>
|
||||||
|
|
||||||
|
<form method="post" class="space-y-6">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for field in form %}
|
||||||
|
<div>
|
||||||
|
<label for="{{ field.id_for_label }}" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ field.label }}
|
||||||
|
</label>
|
||||||
|
{% if field.field.widget.input_type == 'text' or field.field.widget.input_type == 'date' or field.field.widget.input_type == 'number' %}
|
||||||
|
<input type="{{ field.field.widget.input_type }}"
|
||||||
|
name="{{ field.name }}"
|
||||||
|
id="{{ field.id_for_label }}"
|
||||||
|
value="{{ field.value|default:'' }}"
|
||||||
|
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
{% if field.field.required %}required{% endif %}>
|
||||||
|
{% elif field.field.widget.input_type == 'select' %}
|
||||||
|
<select name="{{ field.name }}"
|
||||||
|
id="{{ field.id_for_label }}"
|
||||||
|
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
{% if field.field.required %}required{% endif %}>
|
||||||
|
{% for value, label in field.field.choices %}
|
||||||
|
<option value="{{ value }}" {% if field.value == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% elif field.field.widget.input_type == 'textarea' %}
|
||||||
|
<textarea name="{{ field.name }}"
|
||||||
|
id="{{ field.id_for_label }}"
|
||||||
|
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
rows="4"
|
||||||
|
{% if field.field.required %}required{% endif %}>{{ field.value|default:'' }}</textarea>
|
||||||
|
{% endif %}
|
||||||
|
{% if field.help_text %}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ field.help_text }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if field.errors %}
|
||||||
|
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ field.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if not user.role == 'MODERATOR' and not user.role == 'ADMIN' and not user.role == 'SUPERUSER' %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="reason" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Reason for Addition
|
||||||
|
</label>
|
||||||
|
<textarea name="reason"
|
||||||
|
id="reason"
|
||||||
|
class="w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
rows="3"
|
||||||
|
required
|
||||||
|
placeholder="Please explain why you're adding this ride and provide any relevant details."></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="source" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Source (Optional)
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
name="source"
|
||||||
|
id="source"
|
||||||
|
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="Link to official website, news article, or other source">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-4">
|
||||||
|
<a href="{% url 'rides:ride_list' %}" class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,31 +4,30 @@
|
|||||||
{% block title %}Rides - ThrillWiki{% endblock %}
|
{% block title %}Rides - ThrillWiki{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto px-4">
|
<div class="container px-4 mx-auto">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex flex-col items-start justify-between gap-4 mb-6 md:flex-row md:items-center">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Rides & Attractions</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Rides</h1>
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<a href="{% url 'rides:ride_create' %}" class="btn-primary">
|
||||||
|
<i class="mr-2 fas fa-plus"></i>Add Ride
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-4 mb-6">
|
<div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||||
<h2 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">Filters</h2>
|
<form method="get" class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
<form hx-get="{% url 'rides:ride_list' %}"
|
|
||||||
hx-target="#rides-grid"
|
|
||||||
hx-push-url="true"
|
|
||||||
hx-trigger="change from:select, keyup[target.name=='search'] delay:500ms from:input"
|
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search</label>
|
<label for="search" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||||
<input type="text"
|
<input type="text" name="search" id="search"
|
||||||
name="search"
|
|
||||||
value="{{ current_filters.search }}"
|
value="{{ current_filters.search }}"
|
||||||
placeholder="Search rides..."
|
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
class="form-input w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
placeholder="Search rides...">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
|
<label for="category" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Category</label>
|
||||||
<select name="category"
|
<select name="category" id="category"
|
||||||
class="form-select w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
<option value="RC" {% if current_filters.category == 'RC' %}selected{% endif %}>Roller Coaster</option>
|
<option value="RC" {% if current_filters.category == 'RC' %}selected{% endif %}>Roller Coaster</option>
|
||||||
<option value="DR" {% if current_filters.category == 'DR' %}selected{% endif %}>Dark Ride</option>
|
<option value="DR" {% if current_filters.category == 'DR' %}selected{% endif %}>Dark Ride</option>
|
||||||
@@ -39,9 +38,9 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
|
<label for="status" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
|
||||||
<select name="status"
|
<select name="status" id="status"
|
||||||
class="form-select w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
class="w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
<option value="OPERATING" {% if current_filters.status == 'OPERATING' %}selected{% endif %}>Operating</option>
|
<option value="OPERATING" {% if current_filters.status == 'OPERATING' %}selected{% endif %}>Operating</option>
|
||||||
<option value="CLOSED_TEMP" {% if current_filters.status == 'CLOSED_TEMP' %}selected{% endif %}>Temporarily Closed</option>
|
<option value="CLOSED_TEMP" {% if current_filters.status == 'CLOSED_TEMP' %}selected{% endif %}>Temporarily Closed</option>
|
||||||
@@ -51,22 +50,98 @@
|
|||||||
<option value="RELOCATED" {% if current_filters.status == 'RELOCATED' %}selected{% endif %}>Relocated</option>
|
<option value="RELOCATED" {% if current_filters.status == 'RELOCATED' %}selected{% endif %}>Relocated</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex items-end">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Manufacturer</label>
|
<button type="submit" class="w-full btn-primary">
|
||||||
<select name="manufacturer"
|
<i class="mr-2 fas fa-filter"></i>Filter
|
||||||
class="form-select w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white">
|
</button>
|
||||||
<option value="">All Manufacturers</option>
|
|
||||||
{% for manufacturer in manufacturers %}
|
|
||||||
<option value="{{ manufacturer }}" {% if current_filters.manufacturer == manufacturer %}selected{% endif %}>{{ manufacturer }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rides Grid -->
|
<!-- Rides Grid -->
|
||||||
<div id="rides-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{% include 'rides/partials/ride_list.html' %}
|
{% for ride in rides %}
|
||||||
|
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||||
|
{% if ride.photos.exists %}
|
||||||
|
<div class="aspect-w-16 aspect-h-9">
|
||||||
|
<img src="{{ ride.photos.first.image.url }}"
|
||||||
|
alt="{{ ride.name }}"
|
||||||
|
class="object-cover w-full">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="p-4">
|
||||||
|
<h2 class="mb-2 text-xl font-bold">
|
||||||
|
<a href="{% url 'rides:ride_detail' ride.park.slug ride.slug %}"
|
||||||
|
class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
|
||||||
|
{{ ride.name }}
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
<p class="mb-3 text-gray-600 dark:text-gray-400">
|
||||||
|
at <a href="{% url 'parks:park_detail' ride.park.slug %}"
|
||||||
|
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||||
|
{{ ride.park.name }}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50">
|
||||||
|
{{ ride.get_category_display }}
|
||||||
|
</span>
|
||||||
|
<span class="status-badge {% if ride.status == 'OPERATING' %}status-operating
|
||||||
|
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
|
||||||
|
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||||
|
{% elif ride.status == 'DEMOLISHED' %}status-demolished
|
||||||
|
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||||
|
{{ ride.get_status_display }}
|
||||||
|
</span>
|
||||||
|
{% if ride.average_rating %}
|
||||||
|
<span class="text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-400/30 dark:text-yellow-200 dark:ring-1 dark:ring-yellow-400/30">
|
||||||
|
<i class="mr-1 text-yellow-500 fas fa-star dark:text-yellow-300"></i>
|
||||||
|
{{ ride.average_rating|floatformat:1 }}/10
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if ride.coaster_stats %}
|
||||||
|
<div class="grid grid-cols-2 gap-2 mt-4">
|
||||||
|
{% if ride.coaster_stats.height_ft %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Height: {{ ride.coaster_stats.height_ft }}ft
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if ride.coaster_stats.speed_mph %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Speed: {{ ride.coaster_stats.speed_mph }}mph
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-span-3 py-8 text-center">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">No rides found matching your criteria.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if is_paginated %}
|
||||||
|
<div class="flex justify-center mt-6">
|
||||||
|
<div class="inline-flex rounded-md shadow-sm">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<a href="?page=1{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">« First</a>
|
||||||
|
<a href="?page={{ page_obj.previous_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Previous</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="px-3 py-2 text-gray-700 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
|
||||||
|
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<a href="?page={{ page_obj.next_page_number }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border-t border-b border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Next</a>
|
||||||
|
<a href="?page={{ page_obj.paginator.num_pages }}{{ request.GET.urlencode }}" class="px-3 py-2 text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">Last »</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -46,6 +46,7 @@ INSTALLED_APPS = [
|
|||||||
'reviews',
|
'reviews',
|
||||||
'email_service',
|
'email_service',
|
||||||
'media',
|
'media',
|
||||||
|
'moderation',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -61,7 +62,7 @@ MIDDLEWARE = [
|
|||||||
'allauth.account.middleware.AccountMiddleware',
|
'allauth.account.middleware.AccountMiddleware',
|
||||||
'django.middleware.cache.FetchFromCacheMiddleware',
|
'django.middleware.cache.FetchFromCacheMiddleware',
|
||||||
'simple_history.middleware.HistoryRequestMiddleware',
|
'simple_history.middleware.HistoryRequestMiddleware',
|
||||||
'django_htmx.middleware.HtmxMiddleware', # Added HTMX middleware
|
'django_htmx.middleware.HtmxMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'thrillwiki.urls'
|
ROOT_URLCONF = 'thrillwiki.urls'
|
||||||
@@ -77,6 +78,7 @@ TEMPLATES = [
|
|||||||
'django.template.context_processors.request',
|
'django.template.context_processors.request',
|
||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
'moderation.context_processors.moderation_access', # Added moderation context processor
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -159,8 +161,8 @@ ACCOUNT_EMAIL_REQUIRED = True
|
|||||||
ACCOUNT_USERNAME_REQUIRED = True
|
ACCOUNT_USERNAME_REQUIRED = True
|
||||||
ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
|
ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
|
||||||
ACCOUNT_EMAIL_VERIFICATION = 'optional'
|
ACCOUNT_EMAIL_VERIFICATION = 'optional'
|
||||||
LOGIN_REDIRECT_URL = '/' # Updated to use Django template URL
|
LOGIN_REDIRECT_URL = '/'
|
||||||
ACCOUNT_LOGOUT_REDIRECT_URL = '/' # Updated to use Django template URL
|
ACCOUNT_LOGOUT_REDIRECT_URL = '/'
|
||||||
|
|
||||||
# Custom adapters
|
# Custom adapters
|
||||||
ACCOUNT_ADAPTER = 'accounts.adapters.CustomAccountAdapter'
|
ACCOUNT_ADAPTER = 'accounts.adapters.CustomAccountAdapter'
|
||||||
@@ -193,8 +195,8 @@ SOCIALACCOUNT_PROVIDERS = {
|
|||||||
|
|
||||||
# Additional social account settings
|
# Additional social account settings
|
||||||
SOCIALACCOUNT_LOGIN_ON_GET = True
|
SOCIALACCOUNT_LOGIN_ON_GET = True
|
||||||
SOCIALACCOUNT_AUTO_SIGNUP = False # We want to handle the signup process
|
SOCIALACCOUNT_AUTO_SIGNUP = False
|
||||||
SOCIALACCOUNT_STORE_TOKENS = True # Store the OAuth tokens
|
SOCIALACCOUNT_STORE_TOKENS = True
|
||||||
|
|
||||||
# Email settings
|
# Email settings
|
||||||
EMAIL_BACKEND = 'email_service.backends.ForwardEmailBackend'
|
EMAIL_BACKEND = 'email_service.backends.ForwardEmailBackend'
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Redirect /user/ to the user's profile if logged in
|
# Redirect /user/ to the user's profile if logged in
|
||||||
path('user/', accounts_views.user_redirect_view, name='user_redirect'),
|
path('user/', accounts_views.user_redirect_view, name='user_redirect'),
|
||||||
|
|
||||||
|
# Moderation URLs - placed after other URLs but before static/media serving
|
||||||
|
path('moderation/', include('moderation.urls', namespace='moderation')),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Serve static files in development
|
# Serve static files in development
|
||||||
|
|||||||
Reference in New Issue
Block a user