progress on conversion to tailwind

This commit is contained in:
badblocks 2025-03-06 21:28:36 -08:00
parent 6a872124c6
commit 6e2843c60e
110 changed files with 4997 additions and 1691 deletions

58
.bash_history Normal file
View file

@ -0,0 +1,58 @@
pip install django-crispy-forms
pip install crispy-tailwind
exit
npm i -D daisyui@beta
nano requirements.txt
cat requirements.txt
cd ..
exit
cd theme/static_src/
npm run build:clean
npm run build:tailwind
npm run build:tailwind
npm run build:tailwind
npm run build:clean && npm run build:tailwind
npm run build:clean && npm run build:tailwind
npm run build:clean && npm run build:tailwind
ps ax
npm run dev
exit
cd theme/static_src/
npm run dev
exit
pwd
cd /code
ls
cd theme/static_src/
ls
pwd
exit
cd /code/theme/static_src && npm run dev
exit
python manage.py migrate notifications
exit
exit
python manage.py dumpdata --indent 2 trades.TradeOffer
python manage.py dumpdata --indent 2 trades.TradeOffer > seed/0007_TestTradeOffers.json
exit
python manage.py dumpdata --indent 2 trades.TradeOffer > seed/0007_TestTradeOffers.json
python manage.py dumpdata --indent 2 trades.TradeOfferAcceptances > seed/0008_TestTradeOffersAcceptances.json
python manage.py dumpdata --indent 2 trades.TradeOfferAcceptances > seed/0008_TestTradeOffersAcceptance.json
python manage.py dumpdata --indent 2 trades.TradeOfferAcceptance > seed/0008_TestTradeOffersAcceptance.json
python manage.py dumpdata --indent 2 trades.TradeAcceptance > seed/0008_TestTradeAcceptances.json
python manage.py dumpdata --indent 2 trades.TradeOfferWantCard > seed/0008_TestOfferWantCard.json
python manage.py dumpdata --indent 2 trades.TradeOfferHaveCard > seed/0009_TestOfferHaveCard.json
python manage.py dumpdata --indent 2 trades.TradeAcceptance > seed/0008_TestTradeAcceptances.json
exit
python manage.py shell
exit
python manage.py dumpdata trades.TradeOffer --indent 2
python manage.py dumpdata trades.TradeOffer --indent 2 > seed/0007_TestTradeOffers.json
python manage.py dumpdata trades.TradeOfferHasCard --indent 2
python manage.py dumpdata trades.TradeOfferHaveCard --indent 2
python manage.py dumpdata trades.TradeOfferHaveCard --indent 2 > seed/0009_TestOfferHaveCard.json
python manage.py dumpdata trades.TradeOfferWantCard --indent 2 > seed/0009_TestOfferWantCard.json
python manage.py dumpdata trades.TradeOfferWantCard --indent 2 > seed/0008_TestOfferWantCard.json
rm seed/0009_TestOfferWantCard.json
cat seed/0008_TestOfferWantCard.json
exit

72
.cursorrules Normal file
View file

@ -0,0 +1,72 @@
You are an expert in Python, Django, and scalable web application development.
Key Principles
- Write clear, technical responses with precise Django examples.
- Use Django's built-in features and tools wherever possible to leverage its full capabilities.
- Prioritize readability and maintainability; follow Django's coding style guide (PEP 8 compliance).
- Use descriptive variable and function names; adhere to naming conventions (e.g., lowercase with underscores for functions and variables).
- Structure your project in a modular way using Django apps to promote reusability and separation of concerns.
Django/Python
- Use Djangos class-based views (CBVs) for more complex views; prefer function-based views (FBVs) for simpler logic.
- Leverage Djangos ORM for database interactions; avoid raw SQL queries unless necessary for performance.
- Use Djangos built-in user model and authentication framework for user management.
- Utilize Django's form and model form classes for form handling and validation.
- Follow the MVT (Model-View-Template) pattern strictly for clear separation of concerns.
- Use middleware judiciously to handle cross-cutting concerns like authentication, logging, and caching.
Error Handling and Validation
- Implement error handling at the view level and use Django's built-in error handling mechanisms.
- Use Django's validation framework to validate form and model data.
- Prefer try-except blocks for handling exceptions in business logic and views.
- Customize error pages (e.g., 404, 500) to improve user experience and provide helpful information.
- Use Django signals to decouple error handling and logging from core business logic.
Dependencies
- Django
- Django REST Framework (for API development)
- Celery (for background tasks)
- Redis (for caching and task queues)
- PostgreSQL or MySQL (preferred databases for production)
- Tailwind CSS for the frontend
- Django Crispy Forms for the frontend
- Django Allauth for authentication
- Django DaisyUI for the frontend
- Django El Pagination for the frontend
- Django Widget Tweaks for the frontend
- Django Crispy Tailwind for the frontend
Django-Specific Guidelines
- Use Django templates for rendering HTML and DRF serializers for JSON responses.
- Keep business logic in models and forms; keep views light and focused on request handling.
- Use Django's URL dispatcher (urls.py) to define clear and RESTful URL patterns.
- Apply Django's security best practices (e.g., CSRF protection, SQL injection protection, XSS prevention).
- Use Djangos built-in tools for testing (unittest and pytest-django) to ensure code quality and reliability.
- Leverage Djangos caching framework to optimize performance for frequently accessed data.
- Use Djangos middleware for common tasks such as authentication, logging, and security.
Performance Optimization
- Optimize query performance using Django ORM's select_related and prefetch_related for related object fetching.
- Use Djangos cache framework with backend support (e.g., Redis or Memcached) to reduce database load.
- Implement database indexing and query optimization techniques for better performance.
- Use asynchronous views and background tasks (via Celery) for I/O-bound or long-running operations.
- Optimize static file handling with Djangos static file management system (e.g., WhiteNoise or CDN integration).
Key Conventions
1. Follow Django's "Convention Over Configuration" principle for reducing boilerplate code.
2. Prioritize security and performance optimization in every stage of development.
3. Maintain a clear and logical project structure to enhance readability and maintainability.
Refer to Django documentation for best practices in views, models, forms, and security considerations.
Use the following guidelines for the project:
- Disable redis cache for now.
- Use PostgreSQL for the database.

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.13

22
.python_history Normal file
View file

@ -0,0 +1,22 @@
TradeOffer.objects.filter(initiated\_by=request.user.friend_code
TradeOffer.objects.filter(initiated_by=request.user.friend_code
TradeOffer.objects.filter(initiated_by=request.user.friend_code)
from .models import TradeOffer, TradeAcceptance; TradeOffer.objects.filter(initiated_by=request.user.friend_code)
exit
exit()
from django.contrib.auth.models import User
from trades.models import TradeOffer
# Replace 'your_username' with the username of the user you want to test.
user = User.objects.get(username="your_username")
friend_code = user.friend_code
# Verify the friend code:
print("Friend Code:", friend_code)
from django.contrib.auth.models import User
from trades.models import TradeOffer
# Replace 'your_username' with the username of the user you want to test.
user = User.objects.get(username="your_username")
friend_code = user.friend_code
# Verify the friend code:
print("Friend Code:", friend_code)
exit
exit()

29
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,29 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Reset DB, Make Migrations, And Seed Data",
"type": "shell",
"command": "./reset-db_make-migrations_seed-data.sh",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
},
"problemMatcher": []
},
{
"label": "Run app",
"type": "shell",
"command": "./entrypoint.sh",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
},
"problemMatcher": []
}
]
}

View file

@ -1,15 +1,63 @@
from django import forms
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from .models import CustomUser
from .models import CustomUser, FriendCode
from allauth.account.forms import SignupForm
class CustomUserCreationForm(UserCreationForm):
class Meta(UserCreationForm.Meta):
model = CustomUser
fields = ('email', 'username',)
fields = ('email',)
class CustomUserChangeForm(UserChangeForm):
class Meta:
model = CustomUser
fields = ('email', 'username',)
fields = ('email',)
class FriendCodeForm(forms.ModelForm):
class Meta:
model = FriendCode
fields = ["friend_code"]
def clean_friend_code(self):
friend_code = self.cleaned_data.get("friend_code", "").strip()
# Remove any dashes from the input for validation.
friend_code_clean = friend_code.replace("-", "")
if len(friend_code_clean) != 16 or not friend_code_clean.isdigit():
raise forms.ValidationError("Friend code must be exactly 16 digits long.")
# Format the friend code as: XXXX-XXXX-XXXX-XXXX.
friend_code_formatted = f"{friend_code_clean[:4]}-{friend_code_clean[4:8]}-{friend_code_clean[8:12]}-{friend_code_clean[12:16]}"
return friend_code_formatted
class CustomSignupForm(SignupForm):
friend_code = forms.CharField(
max_length=19,
required=True,
label="Friend Code",
help_text="Enter your friend code in the format XXXX-XXXX-XXXX-XXXX.",
widget=forms.TextInput(attrs={'placeholder': 'XXXX-XXXX-XXXX-XXXX'})
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Remove the username field completely.
if "username" in self.fields:
del self.fields["username"]
def clean_friend_code(self):
friend_code = self.cleaned_data.get("friend_code", "").strip().replace("-", "")
if len(friend_code) != 16 or not friend_code.isdigit():
raise forms.ValidationError("Friend code must be exactly 16 digits long.")
formatted = f"{friend_code[:4]}-{friend_code[4:8]}-{friend_code[8:12]}-{friend_code[12:16]}"
return formatted
def save(self, request):
# First, complete the normal signup process.
user = super().save(request)
# Create the associated FriendCode record.
FriendCode.objects.create(
friend_code=self.cleaned_data["friend_code"],
user=user
)
return user

View file

@ -0,0 +1,19 @@
from django.core.management.base import BaseCommand
from accounts.models import CustomUser, FriendCode
class Command(BaseCommand):
help = "Seed default friend codes for TestUsers after friend codes have been loaded."
def handle(self, *args, **options):
users_updated = 0
for user in CustomUser.objects.all():
# Automatically select the earliest friend code added for the user:
default_code = FriendCode.objects.filter(user=user).order_by('created_at').first()
if default_code:
user.default_friend_code = default_code
user.save(update_fields=["default_friend_code"])
self.stdout.write(f"Set default friend code for user {user.username} to {default_code.friend_code}.")
users_updated += 1
else:
self.stdout.write(f"No friend code found for user {user.username}.")
self.stdout.write(self.style.SUCCESS(f"Seeded default friend codes for {users_updated} user(s)."))

View file

@ -0,0 +1,61 @@
# Generated by Django 5.1.2 on 2025-03-07 01:04
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='FriendCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('friend_code', models.CharField(max_length=19)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='friend_codes', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='customuser',
name='default_friend_code',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.friendcode'),
),
]

View file

@ -1,8 +1,47 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.core.exceptions import ValidationError
class CustomUser(AbstractUser):
pass
default_friend_code = models.ForeignKey("FriendCode", on_delete=models.SET_NULL, null=True, blank=True)
def __str__(self):
return self.email
def set_default_friend_code(self, friend_code):
"""Set a friend code as default if it belongs to the user."""
if friend_code.user != self:
raise ValidationError("Friend code does not belong to this user.")
self.default_friend_code = friend_code
self.save(update_fields=["default_friend_code"])
def remove_default_friend_code(self, friend_code):
"""
If the given friend code is the current default,
assign another of the user's friend codes (if any) as default.
"""
if self.default_friend_code == friend_code:
other_codes = self.friend_codes.exclude(pk=friend_code.pk)
self.default_friend_code = other_codes.first() if other_codes.exists() else None
self.save(update_fields=["default_friend_code"])
class FriendCode(models.Model):
friend_code = models.CharField(max_length=19)
user = models.ForeignKey(CustomUser, on_delete=models.PROTECT, related_name='friend_codes')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
"""
When a new friend code is saved,
if the user has no default friend code yet,
automatically set this as the default.
"""
is_new = self.pk is None
super().save(*args, **kwargs)
if is_new and not self.user.default_friend_code:
self.user.default_friend_code = self
self.user.save(update_fields=["default_friend_code"])
def __str__(self):
return self.friend_code

View file

@ -26,6 +26,7 @@ def gravatar_url(email, size=20):
params = urlencode({'d': default, 's': str(size)})
return f"https://www.gravatar.com/avatar/{email_hash}?{params}"
@register.filter
def gravatar_profile_url(email=None):
"""
Returns the Gravatar Profile URL for a given email.

10
accounts/urls.py Normal file
View file

@ -0,0 +1,10 @@
from django.urls import path
from .views import ListFriendCodesView, AddFriendCodeView, DeleteFriendCodeView, ChangeDefaultFriendCodeView
urlpatterns = [
# ... other account URLs ...
path("friend-codes/", ListFriendCodesView.as_view(), name="list_friend_codes"),
path("friend-codes/add/", AddFriendCodeView.as_view(), name="add_friend_code"),
path("friend-codes/delete/<int:pk>/", DeleteFriendCodeView.as_view(), name="delete_friend_code"),
path("friend-codes/default/<int:pk>/", ChangeDefaultFriendCodeView.as_view(), name="change_default_friend_code"),
]

View file

@ -1,3 +1,113 @@
from django.shortcuts import render
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.shortcuts import redirect, get_object_or_404
from django.views.generic import ListView, CreateView, DeleteView, View
from accounts.models import FriendCode
from accounts.forms import FriendCodeForm
# Create your views here.
class ListFriendCodesView(LoginRequiredMixin, ListView):
"""
Display the current user's friend codes.
"""
model = FriendCode
template_name = "friend_codes/list_friend_codes.html"
context_object_name = "friend_codes"
def get_queryset(self):
return self.request.user.friend_codes.all()
class AddFriendCodeView(LoginRequiredMixin, CreateView):
"""
Add a new friend code for the current user. If the user does not yet have a default,
the newly added code will automatically become the default.
"""
model = FriendCode
form_class = FriendCodeForm
template_name = "friend_codes/add_friend_code.html"
success_url = reverse_lazy("list_friend_codes")
def form_valid(self, form):
form.instance.user = self.request.user
messages.success(self.request, "Friend code added successfully.")
return super().form_valid(form)
class DeleteFriendCodeView(LoginRequiredMixin, DeleteView):
"""
Remove an existing friend code.
Prevent deletion if the friend code is bound to any trade offers.
Also, prevent deletion if the friend code is either the only one or
is set as the default friend code.
"""
model = FriendCode
template_name = "friend_codes/confirm_delete_friend_code.html"
context_object_name = "friend_code"
success_url = reverse_lazy("list_friend_codes")
def get_queryset(self):
# Only allow deletion of friend codes owned by the current user.
return FriendCode.objects.filter(user=self.request.user)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
friend_code = self.get_object()
user = self.request.user
# Determine if the deletion should be disabled.
disable_delete = False
error_message = None
if user.friend_codes.count() == 1:
disable_delete = True
error_message = "Cannot delete your only friend code."
elif user.default_friend_code == friend_code:
disable_delete = True
error_message = (
"Cannot delete your default friend code. "
"Please set a different default first."
)
context["disable_delete"] = disable_delete
context["error_message"] = error_message
return context
def post(self, request, *args, **kwargs):
self.object = self.get_object()
user = self.object.user
# Check if the friend code is the only one; prevent deletion.
if user.friend_codes.count() == 1:
messages.error(request, "Cannot remove your only friend code.")
return redirect(self.success_url)
# Check if the friend code is set as default; prevent deletion.
if user.default_friend_code == self.object:
messages.error(
request,
"Cannot delete your default friend code. Please set a different default first."
)
return redirect(self.success_url)
# Also check if this friend code is referenced by any trade offer.
if self.object.initiated_by.exists() or self.object.accepted_by.exists():
messages.error(
request,
"Cannot remove this friend code because there are existing trade offers associated with it."
)
return redirect(self.success_url)
# Proceed to safe deletion.
self.object.delete()
messages.success(request, "Friend code removed successfully.")
return redirect(self.success_url + "?deleted=true")
class ChangeDefaultFriendCodeView(LoginRequiredMixin, View):
"""
Change the default friend code for the current user.
"""
def post(self, request, *args, **kwargs):
friend_code_id = kwargs.get("pk")
friend_code = get_object_or_404(FriendCode, pk=friend_code_id, user=request.user)
request.user.set_default_friend_code(friend_code)
messages.success(request, "Default friend code updated successfully.")
return redirect("list_friend_codes")

View file

@ -0,0 +1,92 @@
# Generated by Django 5.1.2 on 2025-03-07 01:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Card',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('cardnum', models.IntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='CardSet',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='Rarity',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('icons', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='CardNameTranslation',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('language', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.card')),
],
),
migrations.AddField(
model_name='card',
name='cardset',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cards', to='cards.cardset'),
),
migrations.CreateModel(
name='Deck',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('hex_color', models.CharField(max_length=9)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cardset', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='decks', to='cards.cardset')),
],
),
migrations.AddField(
model_name='card',
name='decks',
field=models.ManyToManyField(to='cards.deck'),
),
migrations.CreateModel(
name='DeckNameTranslation',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('language', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('deck', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='name_translations', to='cards.deck')),
],
),
migrations.AddField(
model_name='card',
name='rarity',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cards', to='cards.rarity'),
),
]

View file

@ -52,6 +52,15 @@ class Rarity(models.Model):
def __str__(self):
return self.name
@property
def normalized_id(self):
"""
For trading equivalence: treat Special Art Rare (pk 7) and Super Rare (pk 6) as the same.
"""
if self.pk in (6, 7):
return 6
return self.pk
class Card(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=64)
@ -63,4 +72,12 @@ class Card(models.Model):
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name + " " + self.rarity.icons + " " + self.cardset.name
# For display, we show the original rarity icons.
return f"{self.name} {self.rarity.icons} {self.cardset.name}"
@property
def normalized_rarity(self):
"""
Returns the canonical rarity id for trade logic.
"""
return self.rarity.normalized_id

View file

@ -1,11 +1,28 @@
from django import template
from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
register = template.Library()
@register.inclusion_tag("includes/card_badge.html")
def card_badge(card):
@register.inclusion_tag("templatetags/card_badge.html")
def card_badge(card, quantity=1, show_single_count=True):
return {
'card': card,
'quantity': quantity,
'decks': card.decks.all() if card else None,
'dropdown': card is None
'dropdown': card is None,
'show_single_count': show_single_count,
}
@register.filter
def card_badge_inline(card, quantity=1):
"""
Renders an inline card badge.
"""
html = render_to_string("templatetags/card_badge.html", {
'card': card,
'quantity': quantity,
'decks': card.decks.all() if card else None,
'dropdown': card is None,
})
return mark_safe(html)

View file

@ -3,38 +3,60 @@ from cards.models import Card
register = template.Library()
@register.inclusion_tag('includes/card_multiselect.html')
def card_multiselect(field_name, label, available_cards, placeholder, selected_values=None, cache_timeout=86400, cache_key="available_cards_options"):
@register.inclusion_tag('templatetags/card_multiselect.html')
def card_multiselect(field_name, label, placeholder, card_filter=None, selected_values=None, cache_timeout=86400, cache_key="available_cards_options"):
"""
Renders a Select2 field for choosing cards.
Renders a multiselect field for choosing cards, storing the card ID only as the option's value and
the quantity in a dedicated data attribute.
Parameters:
- field_name: The name attribute for the select tag.
- label: Label text to show above the selector.
- available_cards: A queryset or list of card objects that will populate the options.
- placeholder: Placeholder text to show in the select.
- selected_values: (Optional) A list of selected card IDs (will be compared as strings).
- card_filter: (Optional) A dictionary of filter parameters to apply on the Card query.
- selected_values: (Optional) A list of selected card values; if a value includes a quantity
it should be in the format "card_id:quantity".
- cache_timeout: (Optional) Cache timeout (in seconds) for the options block.
- cache_key: (Optional) Cache keyby default both select fields use the same key so that caching is shared.
"""
if selected_values is None:
selected_values = []
# Normalize selected_values to strings.
selected_values = [str(val) for val in selected_values]
# Map the selected values into a dictionary: { card_id (str): quantity (str) }
selected_cards = {}
for val in selected_values:
parts = str(val).split(':')
card_id = parts[0]
quantity = parts[1] if len(parts) > 1 else "1"
selected_cards[card_id] = quantity
# If a card_filter is provided, use it; otherwise retrieve all cards.
if card_filter:
available_cards_qs = Card.objects.filter(**card_filter)
else:
available_cards_qs = Card.objects.all()
# --- Available cards for the search form ---
available_cards = list(
Card.objects.order_by("name", "rarity__pk")
available_cards_qs.order_by("name", "rarity__pk")
.select_related("rarity", "cardset")
.prefetch_related("decks")
)
for card in available_cards:
if card.decks.count() == 1:
card.style = "background-color: " + card.decks.all()[0].hex_color + "; color: white;"
elif card.decks.count() == 2:
card.style = "background: linear-gradient(to right, " + card.decks.all()[0].hex_color + ", " + card.decks.all()[1].hex_color + "); color: white;"
elif card.decks.count() >= 3:
card.style = "background: linear-gradient(to right, " + card.decks.all()[0].hex_color + ", " + card.decks.all()[1].hex_color + ", " + card.decks.all()[2].hex_color + "); color: white;"
# Apply styling based on deck count.
deck_count = card.decks.count()
if deck_count == 1:
card.style = f"background-color: {card.decks.all()[0].hex_color}; color: white;"
elif deck_count == 2:
decks = card.decks.all()
card.style = f"background: linear-gradient(to right, {decks[0].hex_color}, {decks[1].hex_color}); color: white;"
elif deck_count >= 3:
decks = card.decks.all()
card.style = f"background: linear-gradient(to right, {decks[0].hex_color}, {decks[1].hex_color}, {decks[2].hex_color}); color: white;"
# Attach selected_quantity only if the card is preselected.
pk_str = str(card.pk)
if pk_str in selected_cards:
card.selected_quantity = selected_cards[pk_str]
return {
'field_name': field_name,
@ -42,7 +64,8 @@ def card_multiselect(field_name, label, available_cards, placeholder, selected_v
'label': label,
'available_cards': available_cards,
'placeholder': placeholder,
'selected_values': selected_values,
# For caching/selection checks, pass a list of the preselected card IDs.
'selected_values': list(selected_cards.keys()),
'cache_timeout': cache_timeout,
'cache_key': cache_key,
}

View file

@ -3,7 +3,6 @@ from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/
@ -11,13 +10,15 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-0peo@#x9jur3!h$ryje!$879xww8y1y66jx!%*#ymhg&jkozs2"
# Resend API Key
RESEND_API_KEY = "re_BBXJWctP_8gb4iNpfaHuau7Na95mc3feu"
# https://docs.djangoproject.com/en/dev/ref/settings/#debug
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", "pocket-trade-dev.fly.dev"]
ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", "pkmntradeclub.fly.dev", "pkmntrade.club"]
# Application definition
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
@ -35,7 +36,7 @@ INSTALLED_APPS = [
"allauth.account",
'allauth.socialaccount.providers.google',
"crispy_forms",
"crispy_bootstrap5",
"crispy_tailwind",
"debug_toolbar",
"el_pagination",
"tailwind",
@ -45,7 +46,7 @@ INSTALLED_APPS = [
"cards",
"home",
"trades.apps.TradesConfig",
"friend_codes"
"widget_tweaks",
]
TAILWIND_APP_NAME = 'theme'
@ -67,7 +68,7 @@ MIDDLEWARE = [
]
DAISY_SETTINGS = {
'SITE_TITLE': 'Pocket.Trade Admin',
'SITE_TITLE': 'PKMN Trade Club Admin',
'DONT_SUPPORT_ME': True,
}
@ -81,8 +82,7 @@ WSGI_APPLICATION = "django_project.wsgi.application"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
#"DIRS": [BASE_DIR / "templates"],
"DIRS": [BASE_DIR / "theme", BASE_DIR / "templates"],
"DIRS": [BASE_DIR / "theme/templates", BASE_DIR / "theme"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
@ -109,7 +109,7 @@ DATABASES = {
"ENGINE": "django.db.backends.postgresql",
"NAME": "postgres",
"USER": "postgres",
"PASSWORD": "postgres",
"PASSWORD": "",
"HOST": "db", # set in docker-compose.yml
"PORT": 5432, # default postgres port
},
@ -189,14 +189,19 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# django-crispy-forms
# https://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs
CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap5'
CRISPY_TEMPLATE_PACK = "bootstrap5"
CRISPY_ALLOWED_TEMPLATE_PACKS = 'tailwind'#'bootstrap5'
CRISPY_TEMPLATE_PACK = "tailwind"#"bootstrap5"
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
EMAIL_HOST = "smtp.resend.com"
EMAIL_PORT = 587
EMAIL_HOST_USER = "resend"
EMAIL_HOST_PASSWORD = RESEND_API_KEY
EMAIL_USE_TLS = True
# https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email
DEFAULT_FROM_EMAIL = "root@localhost"
DEFAULT_FROM_EMAIL = "noreply@pkmntrade.club"
# django-debug-toolbar
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html
@ -226,14 +231,17 @@ AUTHENTICATION_BACKENDS = (
# https://django-allauth.readthedocs.io/en/latest/configuration.html
ACCOUNT_SESSION_REMEMBER = True
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False
ACCOUNT_USERNAME_REQUIRED = False
ACCOUNT_AUTHENTICATION_METHOD = "email"
ACCOUNT_EMAIL_REQUIRED = True
#ACCOUNT_EMAIL_VERIFICATION = "mandatory"
ACCOUNT_EMAIL_VERIFICATION = "none"
ACCOUNT_CHANGE_EMAIL = True
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_SIGNUP_FORM_HONEYPOT_FIELD = "friend_code"
ACCOUNT_SIGNUP_FORM_HONEYPOT_FIELD = "in_game_username"
ACCOUNT_USERNAME_REQUIRED = False
ACCOUNT_FORMS = {
"signup": "accounts.forms.CustomSignupForm",
}
SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
SOCIALACCOUNT_ONLY = False

View file

@ -7,7 +7,7 @@ urlpatterns = [
path("accounts/", include("allauth.urls")),
path("", include("home.urls")),
path("cards/", include("cards.urls")),
path('friend_codes/', include('friend_codes.urls')),
path('account/', include('accounts.urls')),
path("trades/", include("trades.urls")),
path("__reload__/", include("django_browser_reload.urls")),
]

View file

@ -1,7 +1,7 @@
services:
web:
build: .
command: python /code/manage.py runserver 0.0.0.0:8000 &; python /code/manage.py tailwind start; fg
command: python /code/manage.py runserver 0.0.0.0:8000
volumes:
- .:/code:z
ports:

23
entrypoint.sh Executable file
View file

@ -0,0 +1,23 @@
#!/bin/bash
# Make the script exit when a command fails.
set -e
# Define a cleanup function to handle CTRL-C (SIGINT)
cleanup() {
echo "CTRL-C caught! Shutting down Docker Compose services..."
docker compose down
exit 1
}
# Set trap to call cleanup() when SIGINT (Ctrl-C) is received.
trap cleanup SIGINT
# Restart compose services.
echo "Restarting compose services..."
docker compose down
docker compose up -d
docker compose exec web bash -c "cd /code/theme/static_src && npm run dev" || true
docker compose down
echo "Done!"

View file

@ -1,5 +0,0 @@
from django.contrib import admin
from .models import FriendCode
# Register your models here.
admin.site.register(FriendCode)

View file

@ -1,6 +0,0 @@
from django.apps import AppConfig
class FriendCodesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'friend_codes'

View file

@ -1,22 +0,0 @@
from django import forms
from .models import FriendCode
class FriendCodeForm(forms.ModelForm):
class Meta:
model = FriendCode
fields = ["friend_code"]
def clean_friend_code(self):
friend_code = self.cleaned_data.get("friend_code", "").strip()
# Remove any dashes from the input so we can validate the digits only.
friend_code_clean = friend_code.replace("-", "")
# Ensure that the cleaned friend code is exactly 16 digits.
if len(friend_code_clean) != 16 or not friend_code_clean.isdigit():
raise forms.ValidationError("Friend code must be exactly 16 digits long.")
# Format the friend code with dashes: XXXX-XXXX-XXXX-XXXX.
friend_code_formatted = f"{friend_code_clean[:4]}-{friend_code_clean[4:8]}-{friend_code_clean[8:12]}-{friend_code_clean[12:16]}"
return friend_code_formatted

View file

@ -1,27 +0,0 @@
# Generated by Django 5.1.2 on 2025-02-20 02:54
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='FriendCode',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('friend_code', models.CharField(max_length=16)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='friend_codes', to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 5.1.2 on 2025-02-20 03:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('friend_codes', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='friendcode',
name='friend_code',
field=models.CharField(max_length=19),
),
]

View file

@ -1,13 +0,0 @@
from django.db import models
from django.conf import settings
from accounts.models import CustomUser
class FriendCode(models.Model):
id = models.AutoField(primary_key=True)
friend_code = models.CharField(max_length=19)
user = models.ForeignKey("accounts.CustomUser", on_delete=models.PROTECT, related_name='friend_codes')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.friend_code

View file

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View file

@ -1,8 +0,0 @@
from django.urls import path
from .views import ListFriendCodesView, AddFriendCodeView, DeleteFriendCodeView
urlpatterns = [
path('', ListFriendCodesView.as_view(), name='list_friend_codes'),
path('add/', AddFriendCodeView.as_view(), name='add_friend_code'),
path('delete/<int:pk>/', DeleteFriendCodeView.as_view(), name='delete_friend_code'),
]

View file

@ -1,67 +0,0 @@
from django.shortcuts import render, redirect
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.views.generic import ListView, CreateView, DeleteView
from .models import FriendCode
from .forms import FriendCodeForm
class ListFriendCodesView(LoginRequiredMixin, ListView):
"""
Display the current user's friend codes.
"""
model = FriendCode
template_name = "friend_codes/list_friend_codes.html"
context_object_name = "friend_codes"
def get_queryset(self):
# Only display friend codes that belong to the current user.
return self.request.user.friend_codes.all()
class AddFriendCodeView(LoginRequiredMixin, CreateView):
"""
Add a new friend code for the current user.
"""
model = FriendCode
form_class = FriendCodeForm
template_name = "friend_codes/add_friend_code.html"
success_url = reverse_lazy('list_friend_codes')
def form_valid(self, form):
# Set the friend code's user to the current user before saving.
form.instance.user = self.request.user
messages.success(self.request, "Friend code added successfully.")
return super().form_valid(form)
class DeleteFriendCodeView(LoginRequiredMixin, DeleteView):
"""
Remove an existing friend code.
The friend code will not be removed if it is referenced by trade offers via
either the initiated_by or accepted_by relationships.
"""
model = FriendCode
template_name = "friend_codes/confirm_delete_friend_code.html"
context_object_name = "friend_code"
success_url = reverse_lazy('list_friend_codes')
def get_queryset(self):
# Ensure the friend code belongs to the current user.
return FriendCode.objects.filter(user=self.request.user)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
# Check if there are any trade offers associated with this friend code.
if self.object.initiated_by.exists() or self.object.accepted_by.exists():
messages.error(
request,
"Cannot remove this friend code because there are existing trade offers associated with it."
)
return redirect(self.success_url)
else:
self.object.delete()
messages.success(request, "Friend code removed successfully.")
return redirect(self.success_url)

View file

@ -1,22 +1,22 @@
from collections import defaultdict
from django.views.generic import TemplateView
from django.urls import reverse_lazy
from django.db.models import Count, Q
from django.db.models import Count, Q, Prefetch, Sum
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from trades.models import TradeOffer
from cards.models import Card, CardSet
from cards.models import Card, CardSet, Rarity
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from django.template.response import TemplateResponse
@method_decorator(cache_page(60), name='get') # Cache view for 60 seconds (smallest cache time in the template)
@method_decorator(cache_page(60), name='get') # Cache view for 60 seconds
class HomePageView(TemplateView):
template_name = "home/home.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Use POST data if available, fallback to GET
# Use POST data if available, else fallback to GET
request_data = self.request.POST if self.request.method == "POST" else self.request.GET
# --- Search form logic ---
@ -25,22 +25,36 @@ class HomePageView(TemplateView):
context["offered_cards"] = offered_cards
context["wanted_cards"] = wanted_cards
# Define prefetch objects ordered by number of associated trade offers ascending,
# and by id secondarily.
have_cards_prefetch = Prefetch(
'have_cards',
queryset=Card.objects.annotate(
trade_offer_count=Count("trade_offers_have")
).order_by("trade_offer_count", "id")
)
want_cards_prefetch = Prefetch(
'want_cards',
queryset=Card.objects.annotate(
trade_offer_count=Count("trade_offers_want")
).order_by("trade_offer_count", "id")
)
search_results = None
if offered_cards or wanted_cards:
qs = TradeOffer.objects.filter(
state=TradeOffer.State.INITIATED
).prefetch_related(
"have_cards",
# Instead of filtering by a 'state' field (which no longer exists),
# we fetch all offers. You may later add logic to filter only "open" offers.
qs = TradeOffer.objects.all().prefetch_related(
have_cards_prefetch,
"have_cards__decks",
"have_cards__rarity",
"have_cards__cardset",
"want_cards",
want_cards_prefetch,
"want_cards__decks",
"want_cards__rarity",
"want_cards__cardset"
).select_related(
"initiated_by__user",
"accepted_by__user"
"initiated_by__user"
)
if offered_cards:
try:
@ -56,72 +70,90 @@ class HomePageView(TemplateView):
qs = qs.none()
else:
qs = qs.filter(have_cards__id__in=wanted_card_ids)
# Pagination: 3 results per page
page_number = request_data.get("page", 1)
paginator = Paginator(qs, 3)
paginator = Paginator(qs, 6)
try:
search_results = paginator.page(page_number)
except PageNotAnInteger:
search_results = paginator.page(1)
except EmptyPage:
search_results = paginator.page(paginator.num_pages)
context["search_results"] = search_results
# --- Recently posted offers (latest 5, newest first) ---
context["recent_offers"] = TradeOffer.objects.order_by("-created_at").prefetch_related(
"have_cards",
have_cards_prefetch,
"have_cards__decks",
"have_cards__rarity",
"have_cards__cardset",
"want_cards",
want_cards_prefetch,
"want_cards__decks",
"want_cards__rarity",
"want_cards__cardset"
).select_related(
"initiated_by__user",
"accepted_by__user"
"initiated_by__user"
)[:5]
# --- Most offered cards ---
context["most_offered_cards"] = Card.objects.annotate(
offer_count=Count("trade_offers_have")
context["most_offered_cards"] = Card.objects.filter(
tradeofferhavecard__isnull=False
).annotate(
offer_count=Sum("tradeofferhavecard__quantity")
).order_by("-offer_count").select_related("rarity", "cardset").prefetch_related("decks")[:5]
# --- Most wanted cards ---
context["most_wanted_cards"] = Card.objects.annotate(
offer_count=Count("trade_offers_want")
context["most_wanted_cards"] = Card.objects.filter(
tradeofferwantcard__isnull=False
).annotate(
offer_count=Sum("tradeofferwantcard__quantity")
).order_by("-offer_count").select_related("rarity", "cardset").prefetch_related("decks")[:5]
# --- Featured offers grouped by cardset ---
# --- Least offered cards ---
context["least_offered_cards"] = Card.objects.annotate(
offer_count=Sum("tradeofferhavecard__quantity")
).order_by("offer_count", "?")[:5]
# --- Featured offers grouped by rarity (using card.rarity.icon for tab names) ---
featured = {}
all_offers = list(
TradeOffer.objects.order_by("created_at").prefetch_related(
"have_cards",
have_cards_prefetch,
"have_cards__decks",
"have_cards__rarity",
"have_cards__cardset",
"want_cards",
want_cards_prefetch,
"want_cards__decks",
"want_cards__rarity",
"want_cards__cardset"
).select_related(
"initiated_by__user",
"accepted_by__user"
"initiated_by__user"
)
)
featured["All"] = all_offers[:5]
# Group offers by normalized rarity id from their have_cards
grouped = defaultdict(list)
for offer in all_offers:
cardsets_in_offer = set()
normalized_ids = set()
for card in offer.have_cards.all():
cardsets_in_offer.add(card.cardset.name)
for card in offer.want_cards.all():
cardsets_in_offer.add(card.cardset.name)
for cs_name in cardsets_in_offer:
grouped[cs_name].append(offer)
for cs_name, offers in grouped.items():
featured[cs_name] = offers[:5]
if card.rarity:
normalized_ids.add(card.rarity.normalized_id)
for norm in normalized_ids:
grouped[norm].append(offer)
# Map each normalized rarity id to a representative icon
norm_ids_available = list(grouped.keys())
rareness_qs = Rarity.objects.filter(pk__in=[6] + [nid for nid in norm_ids_available if nid != 6])
rarity_map = {rarity.pk: rarity.icons for rarity in rareness_qs}
# Order groups by descending normalized rarity id
for norm in sorted(grouped.keys(), reverse=True):
offers = grouped[norm]
icon_label = rarity_map.get(norm)
if icon_label:
featured[icon_label] = offers[:5]
context["featured_offers"] = featured

View file

@ -1,4 +1,40 @@
#!/usr/bin/env python
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "asgiref==3.8.1",
# "certifi==2022.12.7",
# "cffi==1.17.1",
# "charset-normalizer==3.0.1",
# "cookiecutter==2.6.0",
# "crispy-tailwind==1.0.3",
# "cryptography==39.0.1",
# "defusedxml==0.7.1",
# "django==5.1.2",
# "django-allauth==65.0.2",
# "django-browser-reload==1.17.0",
# "django-crispy-forms==2.3",
# "django-daisy==1.0.13",
# "django-debug-toolbar==4.4.6",
# "django-el-pagination==4.1.2",
# "django-tailwind-4[reload]==0.1.4",
# "gunicorn==23.0.0",
# "idna==3.4",
# "oauthlib==3.2.2",
# "packaging==23.1",
# "psycopg==3.2.3",
# "psycopg-binary==3.2.3",
# "pycparser==2.21",
# "pyjwt==2.6.0",
# "python3-openid==3.2.0",
# "requests==2.28.2",
# "requests-oauthlib==1.3.1",
# "sqlparse==0.4.3",
# "typing-extensions==4.9.0",
# "urllib3==1.26.14",
# "whitenoise==6.7.0",
# ]
# ///
"""Django's command-line utility for administrative tasks."""
import os
import sys

21
package-lock.json generated Normal file
View file

@ -0,0 +1,21 @@
{
"name": "code",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"daisyui": "^5.0.0-beta.9"
}
},
"node_modules/daisyui": {
"version": "5.0.0-beta.9",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.0.0-beta.9.tgz",
"integrity": "sha512-V+To8o1O8AaxSgdk9QrjXyq/e1AhdW1Z6oUI5iwrOjPs8avM7VQNqoTDCAE5rM0NcMbUfmFgQH8h8guiQ5QPOA==",
"dev": true,
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
}
}
}

5
package.json Normal file
View file

@ -0,0 +1,5 @@
{
"devDependencies": {
"daisyui": "^5.0.0-beta.9"
}
}

View file

@ -1,19 +1,20 @@
asgiref==3.8.1
certifi==2022.12.7
cffi==1.15.1
cffi==1.17.1
charset-normalizer==3.0.1
cookiecutter==2.6.0
crispy-bootstrap5==2024.10
crispy-tailwind==1.0.3
cryptography==39.0.1
defusedxml==0.7.1
Django==5.1.2
django-allauth==65.0.2
django-browser-reload==1.17.0
django-crispy-forms==2.3
django-daisy==1.0.11
django-daisy==1.0.13
django-debug-toolbar==4.4.6
django-el-pagination==4.1.2
django-tailwind-4[reload]==0.1.4
django-widget-tweaks==1.5.0
gunicorn==23.0.0
idna==3.4
oauthlib==3.2.2

View file

@ -0,0 +1,28 @@
#!/bin/bash
# Exit immediately if any command exits with a non-zero status.
set -e
# Reset the database and migrations.
echo "Resetting database and migrations..."
docker compose down \
&& docker volume prune -af \
&& find . -path "*/migrations/00*.py" -delete \
&& docker compose up -d
# Wait for the database to be ready.
echo "Waiting for the database to be ready..."
sleep 10
echo "Running makemigrations..."
docker compose exec web bash -c "python manage.py makemigrations"
echo "Running migrations..."
docker compose exec web bash -c "python manage.py migrate"
echo "Loading seed data..."
docker compose exec web bash -c "python manage.py loaddata seed/0*"
echo "Seeding default friend codes..."
docker compose exec web bash -c "python manage.py seed_default_friend_codes"
./entrypoint.sh

View file

@ -1,6 +1,6 @@
[
{
"model": "friend_codes.friendcode",
"model": "accounts.friendcode",
"pk": 1,
"fields": {
"friend_code": "3595-6375-9151-8459",
@ -10,7 +10,7 @@
}
},
{
"model": "friend_codes.friendcode",
"model": "accounts.friendcode",
"pk": 2,
"fields": {
"friend_code": "4863-0754-2764-1890",
@ -20,7 +20,7 @@
}
},
{
"model": "friend_codes.friendcode",
"model": "accounts.friendcode",
"pk": 3,
"fields": {
"friend_code": "1020-0576-9371-6042",
@ -30,7 +30,7 @@
}
},
{
"model": "friend_codes.friendcode",
"model": "accounts.friendcode",
"pk": 4,
"fields": {
"friend_code": "8358-5883-3807-6654",

View file

@ -3,232 +3,110 @@
"model": "trades.tradeoffer",
"pk": 1,
"fields": {
"manually_closed": false,
"hash": "c4ca4238",
"initiated_by": 1,
"accepted_by": null,
"created_at": "2025-02-26T06:26:31.024Z",
"updated_at": "2025-02-26T06:26:31.024Z",
"state": "INITIATED",
"want_cards": [
188,
312,
501
],
"have_cards": [
55,
117,
481
]
"initiated_by": 3,
"created_at": "2025-03-07T00:21:33.089Z",
"updated_at": "2025-03-07T00:21:33.089Z"
}
},
{
"model": "trades.tradeoffer",
"pk": 2,
"fields": {
"manually_closed": false,
"hash": "c81e728d",
"initiated_by": 2,
"accepted_by": null,
"created_at": "2025-02-26T06:29:06.154Z",
"updated_at": "2025-02-26T06:29:06.154Z",
"state": "INITIATED",
"want_cards": [
17,
417
],
"have_cards": [
91,
524
]
"initiated_by": 4,
"created_at": "2025-03-07T00:24:21.664Z",
"updated_at": "2025-03-07T00:24:21.664Z"
}
},
{
"model": "trades.tradeoffer",
"pk": 3,
"fields": {
"manually_closed": false,
"hash": "eccbc87e",
"initiated_by": 3,
"accepted_by": null,
"created_at": "2025-02-26T06:29:55.322Z",
"updated_at": "2025-02-26T06:50:58.181Z",
"state": "INITIATED",
"want_cards": [
275,
370
],
"have_cards": [
575
]
"created_at": "2025-03-07T00:27:36.345Z",
"updated_at": "2025-03-07T00:27:36.345Z"
}
},
{
"model": "trades.tradeoffer",
"pk": 4,
"fields": {
"manually_closed": false,
"hash": "a87ff679",
"initiated_by": 3,
"accepted_by": null,
"created_at": "2025-02-26T06:30:45.876Z",
"updated_at": "2025-02-26T06:30:45.876Z",
"state": "INITIATED",
"want_cards": [
367,
558
],
"have_cards": [
256,
258,
559
]
"initiated_by": 4,
"created_at": "2025-03-07T00:28:57.655Z",
"updated_at": "2025-03-07T00:28:57.655Z"
}
},
{
"model": "trades.tradeoffer",
"pk": 5,
"fields": {
"manually_closed": false,
"hash": "e4da3b7f",
"initiated_by": 4,
"accepted_by": null,
"created_at": "2025-02-26T06:32:37.741Z",
"updated_at": "2025-02-26T06:32:37.741Z",
"state": "INITIATED",
"want_cards": [
136,
165,
224,
321,
375,
417,
489
],
"have_cards": [
15,
75,
106,
116,
200,
383,
424,
447,
485,
512
]
"created_at": "2025-03-07T00:30:53.491Z",
"updated_at": "2025-03-07T00:30:53.491Z"
}
},
{
"model": "trades.tradeoffer",
"pk": 6,
"fields": {
"manually_closed": false,
"hash": "1679091c",
"initiated_by": 1,
"accepted_by": null,
"created_at": "2025-02-26T06:52:14.287Z",
"updated_at": "2025-02-26T06:52:14.287Z",
"state": "INITIATED",
"want_cards": [
16,
382
],
"have_cards": [
503,
517
]
"created_at": "2025-03-07T00:21:33.089Z",
"updated_at": "2025-03-07T00:21:33.089Z"
}
},
{
"model": "trades.tradeoffer",
"pk": 7,
"fields": {
"manually_closed": false,
"hash": "8f14e45f",
"initiated_by": 2,
"accepted_by": null,
"created_at": "2025-02-26T06:53:25.694Z",
"updated_at": "2025-02-26T06:53:25.694Z",
"state": "INITIATED",
"want_cards": [
202,
375,
391
],
"have_cards": [
180,
321,
489
]
"created_at": "2025-03-07T00:24:21.664Z",
"updated_at": "2025-03-07T00:24:21.664Z"
}
},
{
"model": "trades.tradeoffer",
"pk": 8,
"fields": {
"manually_closed": false,
"hash": "c9f0f895",
"initiated_by": 3,
"accepted_by": null,
"created_at": "2025-02-26T06:55:19.117Z",
"updated_at": "2025-02-26T06:55:19.117Z",
"state": "INITIATED",
"want_cards": [
284,
579
],
"have_cards": [
285,
578
]
"initiated_by": 1,
"created_at": "2025-03-07T00:27:36.345Z",
"updated_at": "2025-03-07T00:27:36.345Z"
}
},
{
"model": "trades.tradeoffer",
"pk": 9,
"fields": {
"manually_closed": false,
"hash": "45c48cce",
"initiated_by": 4,
"accepted_by": null,
"created_at": "2025-02-26T06:55:39.531Z",
"updated_at": "2025-02-26T06:55:39.531Z",
"state": "INITIATED",
"want_cards": [
507
],
"have_cards": [
115
]
"initiated_by": 2,
"created_at": "2025-03-07T00:28:57.655Z",
"updated_at": "2025-03-07T00:28:57.655Z"
}
},
{
"model": "trades.tradeoffer",
"pk": 10,
"fields": {
"manually_closed": false,
"hash": "d3d94468",
"initiated_by": 2,
"accepted_by": null,
"created_at": "2025-02-26T06:55:56.621Z",
"updated_at": "2025-02-26T06:55:56.621Z",
"state": "INITIATED",
"want_cards": [
136,
184
],
"have_cards": [
91
]
}
},
{
"model": "trades.tradeoffer",
"pk": 11,
"fields": {
"hash": "6512bd43",
"initiated_by": 3,
"accepted_by": null,
"created_at": "2025-02-26T07:05:16.870Z",
"updated_at": "2025-02-26T07:05:16.870Z",
"state": "INITIATED",
"want_cards": [
370
],
"have_cards": [
367
]
"initiated_by": 1,
"created_at": "2025-03-07T00:30:53.491Z",
"updated_at": "2025-03-07T00:30:53.491Z"
}
}
]

View file

@ -0,0 +1,632 @@
[
{
"model": "trades.tradeofferwantcard",
"pk": 1,
"fields": {
"trade_offer": 1,
"card": 113,
"quantity": 3
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 2,
"fields": {
"trade_offer": 1,
"card": 479,
"quantity": 2
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 3,
"fields": {
"trade_offer": 1,
"card": 206,
"quantity": 8
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 4,
"fields": {
"trade_offer": 1,
"card": 414,
"quantity": 7
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 5,
"fields": {
"trade_offer": 1,
"card": 329,
"quantity": 4
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 6,
"fields": {
"trade_offer": 1,
"card": 395,
"quantity": 6
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 7,
"fields": {
"trade_offer": 1,
"card": 42,
"quantity": 5
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 8,
"fields": {
"trade_offer": 1,
"card": 8,
"quantity": 1
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 9,
"fields": {
"trade_offer": 2,
"card": 165,
"quantity": 4
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 10,
"fields": {
"trade_offer": 2,
"card": 65,
"quantity": 5
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 11,
"fields": {
"trade_offer": 2,
"card": 309,
"quantity": 2
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 12,
"fields": {
"trade_offer": 2,
"card": 219,
"quantity": 3
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 13,
"fields": {
"trade_offer": 2,
"card": 413,
"quantity": 6
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 14,
"fields": {
"trade_offer": 2,
"card": 173,
"quantity": 7
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 15,
"fields": {
"trade_offer": 2,
"card": 469,
"quantity": 8
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 16,
"fields": {
"trade_offer": 2,
"card": 424,
"quantity": 1
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 17,
"fields": {
"trade_offer": 3,
"card": 394,
"quantity": 6
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 18,
"fields": {
"trade_offer": 3,
"card": 437,
"quantity": 5
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 19,
"fields": {
"trade_offer": 3,
"card": 384,
"quantity": 4
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 20,
"fields": {
"trade_offer": 3,
"card": 305,
"quantity": 7
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 21,
"fields": {
"trade_offer": 3,
"card": 13,
"quantity": 3
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 22,
"fields": {
"trade_offer": 3,
"card": 177,
"quantity": 2
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 23,
"fields": {
"trade_offer": 3,
"card": 103,
"quantity": 1
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 24,
"fields": {
"trade_offer": 4,
"card": 345,
"quantity": 1
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 25,
"fields": {
"trade_offer": 4,
"card": 76,
"quantity": 4
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 26,
"fields": {
"trade_offer": 4,
"card": 4,
"quantity": 3
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 27,
"fields": {
"trade_offer": 4,
"card": 471,
"quantity": 6
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 28,
"fields": {
"trade_offer": 4,
"card": 379,
"quantity": 5
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 29,
"fields": {
"trade_offer": 4,
"card": 104,
"quantity": 2
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 30,
"fields": {
"trade_offer": 5,
"card": 230,
"quantity": 1
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 31,
"fields": {
"trade_offer": 5,
"card": 529,
"quantity": 2
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 32,
"fields": {
"trade_offer": 5,
"card": 540,
"quantity": 4
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 33,
"fields": {
"trade_offer": 5,
"card": 239,
"quantity": 3
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 34,
"fields": {
"trade_offer": 5,
"card": 248,
"quantity": 6
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 35,
"fields": {
"trade_offer": 5,
"card": 355,
"quantity": 5
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 36,
"fields": {
"trade_offer": 6,
"card": 115,
"quantity": 8
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 37,
"fields": {
"trade_offer": 6,
"card": 502,
"quantity": 7
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 38,
"fields": {
"trade_offer": 6,
"card": 517,
"quantity": 6
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 39,
"fields": {
"trade_offer": 6,
"card": 105,
"quantity": 5
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 40,
"fields": {
"trade_offer": 6,
"card": 151,
"quantity": 1
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 41,
"fields": {
"trade_offer": 6,
"card": 442,
"quantity": 2
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 42,
"fields": {
"trade_offer": 6,
"card": 287,
"quantity": 4
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 43,
"fields": {
"trade_offer": 6,
"card": 194,
"quantity": 3
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 44,
"fields": {
"trade_offer": 7,
"card": 417,
"quantity": 1
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 45,
"fields": {
"trade_offer": 7,
"card": 321,
"quantity": 2
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 46,
"fields": {
"trade_offer": 7,
"card": 34,
"quantity": 3
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 47,
"fields": {
"trade_offer": 7,
"card": 524,
"quantity": 6
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 48,
"fields": {
"trade_offer": 7,
"card": 108,
"quantity": 7
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 49,
"fields": {
"trade_offer": 7,
"card": 30,
"quantity": 4
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 50,
"fields": {
"trade_offer": 7,
"card": 431,
"quantity": 8
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 51,
"fields": {
"trade_offer": 7,
"card": 150,
"quantity": 5
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 52,
"fields": {
"trade_offer": 8,
"card": 210,
"quantity": 1
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 53,
"fields": {
"trade_offer": 8,
"card": 117,
"quantity": 2
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 54,
"fields": {
"trade_offer": 8,
"card": 40,
"quantity": 3
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 55,
"fields": {
"trade_offer": 8,
"card": 486,
"quantity": 4
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 56,
"fields": {
"trade_offer": 8,
"card": 481,
"quantity": 5
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 57,
"fields": {
"trade_offer": 8,
"card": 425,
"quantity": 6
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 58,
"fields": {
"trade_offer": 8,
"card": 300,
"quantity": 7
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 59,
"fields": {
"trade_offer": 9,
"card": 332,
"quantity": 1
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 60,
"fields": {
"trade_offer": 9,
"card": 41,
"quantity": 2
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 61,
"fields": {
"trade_offer": 9,
"card": 84,
"quantity": 3
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 62,
"fields": {
"trade_offer": 9,
"card": 36,
"quantity": 4
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 63,
"fields": {
"trade_offer": 9,
"card": 482,
"quantity": 5
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 64,
"fields": {
"trade_offer": 9,
"card": 401,
"quantity": 6
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 65,
"fields": {
"trade_offer": 10,
"card": 236,
"quantity": 1
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 66,
"fields": {
"trade_offer": 10,
"card": 549,
"quantity": 2
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 67,
"fields": {
"trade_offer": 10,
"card": 227,
"quantity": 3
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 68,
"fields": {
"trade_offer": 10,
"card": 530,
"quantity": 4
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 69,
"fields": {
"trade_offer": 10,
"card": 359,
"quantity": 5
}
},
{
"model": "trades.tradeofferwantcard",
"pk": 70,
"fields": {
"trade_offer": 10,
"card": 238,
"quantity": 6
}
}
]

View file

@ -0,0 +1,632 @@
[
{
"model": "trades.tradeofferhavecard",
"pk": 9,
"fields": {
"trade_offer": 1,
"card": 115,
"quantity": 8
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 2,
"fields": {
"trade_offer": 1,
"card": 502,
"quantity": 7
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 3,
"fields": {
"trade_offer": 1,
"card": 517,
"quantity": 6
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 4,
"fields": {
"trade_offer": 1,
"card": 105,
"quantity": 5
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 5,
"fields": {
"trade_offer": 1,
"card": 151,
"quantity": 1
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 6,
"fields": {
"trade_offer": 1,
"card": 442,
"quantity": 2
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 7,
"fields": {
"trade_offer": 1,
"card": 287,
"quantity": 4
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 8,
"fields": {
"trade_offer": 1,
"card": 194,
"quantity": 3
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 1,
"fields": {
"trade_offer": 2,
"card": 417,
"quantity": 1
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 10,
"fields": {
"trade_offer": 2,
"card": 321,
"quantity": 2
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 11,
"fields": {
"trade_offer": 2,
"card": 34,
"quantity": 3
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 12,
"fields": {
"trade_offer": 2,
"card": 524,
"quantity": 6
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 13,
"fields": {
"trade_offer": 2,
"card": 108,
"quantity": 7
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 14,
"fields": {
"trade_offer": 2,
"card": 30,
"quantity": 4
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 15,
"fields": {
"trade_offer": 2,
"card": 431,
"quantity": 8
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 16,
"fields": {
"trade_offer": 2,
"card": 150,
"quantity": 5
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 17,
"fields": {
"trade_offer": 3,
"card": 210,
"quantity": 1
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 18,
"fields": {
"trade_offer": 3,
"card": 117,
"quantity": 2
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 19,
"fields": {
"trade_offer": 3,
"card": 40,
"quantity": 3
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 20,
"fields": {
"trade_offer": 3,
"card": 486,
"quantity": 4
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 21,
"fields": {
"trade_offer": 3,
"card": 481,
"quantity": 5
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 22,
"fields": {
"trade_offer": 3,
"card": 425,
"quantity": 6
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 23,
"fields": {
"trade_offer": 3,
"card": 300,
"quantity": 7
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 24,
"fields": {
"trade_offer": 4,
"card": 332,
"quantity": 1
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 25,
"fields": {
"trade_offer": 4,
"card": 41,
"quantity": 2
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 26,
"fields": {
"trade_offer": 4,
"card": 84,
"quantity": 3
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 27,
"fields": {
"trade_offer": 4,
"card": 36,
"quantity": 4
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 28,
"fields": {
"trade_offer": 4,
"card": 482,
"quantity": 5
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 29,
"fields": {
"trade_offer": 4,
"card": 401,
"quantity": 6
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 30,
"fields": {
"trade_offer": 5,
"card": 236,
"quantity": 1
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 31,
"fields": {
"trade_offer": 5,
"card": 549,
"quantity": 2
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 32,
"fields": {
"trade_offer": 5,
"card": 227,
"quantity": 3
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 33,
"fields": {
"trade_offer": 5,
"card": 530,
"quantity": 4
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 34,
"fields": {
"trade_offer": 5,
"card": 359,
"quantity": 5
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 35,
"fields": {
"trade_offer": 5,
"card": 238,
"quantity": 6
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 36,
"fields": {
"trade_offer": 6,
"card": 113,
"quantity": 3
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 37,
"fields": {
"trade_offer": 6,
"card": 479,
"quantity": 2
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 38,
"fields": {
"trade_offer": 6,
"card": 206,
"quantity": 8
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 39,
"fields": {
"trade_offer": 6,
"card": 414,
"quantity": 7
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 40,
"fields": {
"trade_offer": 6,
"card": 329,
"quantity": 4
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 41,
"fields": {
"trade_offer": 6,
"card": 395,
"quantity": 6
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 42,
"fields": {
"trade_offer": 6,
"card": 42,
"quantity": 5
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 43,
"fields": {
"trade_offer": 6,
"card": 8,
"quantity": 1
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 44,
"fields": {
"trade_offer": 7,
"card": 165,
"quantity": 4
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 45,
"fields": {
"trade_offer": 7,
"card": 65,
"quantity": 5
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 46,
"fields": {
"trade_offer": 7,
"card": 309,
"quantity": 2
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 47,
"fields": {
"trade_offer": 7,
"card": 219,
"quantity": 3
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 48,
"fields": {
"trade_offer": 7,
"card": 413,
"quantity": 6
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 49,
"fields": {
"trade_offer": 7,
"card": 173,
"quantity": 7
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 50,
"fields": {
"trade_offer": 7,
"card": 469,
"quantity": 8
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 51,
"fields": {
"trade_offer": 7,
"card": 424,
"quantity": 1
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 52,
"fields": {
"trade_offer": 8,
"card": 394,
"quantity": 6
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 53,
"fields": {
"trade_offer": 8,
"card": 437,
"quantity": 5
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 54,
"fields": {
"trade_offer": 8,
"card": 384,
"quantity": 4
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 55,
"fields": {
"trade_offer": 8,
"card": 305,
"quantity": 7
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 56,
"fields": {
"trade_offer": 8,
"card": 13,
"quantity": 3
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 57,
"fields": {
"trade_offer": 8,
"card": 177,
"quantity": 2
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 58,
"fields": {
"trade_offer": 8,
"card": 103,
"quantity": 1
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 59,
"fields": {
"trade_offer": 9,
"card": 345,
"quantity": 1
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 60,
"fields": {
"trade_offer": 9,
"card": 76,
"quantity": 4
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 61,
"fields": {
"trade_offer": 9,
"card": 4,
"quantity": 3
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 62,
"fields": {
"trade_offer": 9,
"card": 471,
"quantity": 6
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 63,
"fields": {
"trade_offer": 9,
"card": 379,
"quantity": 5
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 64,
"fields": {
"trade_offer": 9,
"card": 104,
"quantity": 2
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 65,
"fields": {
"trade_offer": 10,
"card": 230,
"quantity": 1
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 66,
"fields": {
"trade_offer": 10,
"card": 529,
"quantity": 2
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 67,
"fields": {
"trade_offer": 10,
"card": 540,
"quantity": 4
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 68,
"fields": {
"trade_offer": 10,
"card": 239,
"quantity": 3
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 69,
"fields": {
"trade_offer": 10,
"card": 248,
"quantity": 6
}
},
{
"model": "trades.tradeofferhavecard",
"pk": 70,
"fields": {
"trade_offer": 10,
"card": 355,
"quantity": 5
}
}
]

View file

@ -1,12 +1,12 @@
/* Helper classes
/* /* Helper classes
-------------------------------------------------- */
.min-width-fit-content {
/* .min-width-fit-content {
min-width: fit-content;
}
} */
/* Sticky footer styles
-------------------------------------------------- */
html {
/* html {
position: relative;
min-height: 100%;
font-size: 14px;
@ -18,7 +18,7 @@ html {
}
body {
margin-bottom: 60px; /* Margin bottom by footer height */
margin-bottom: 60px; /* Margin bottom by footer height
}
.container {
@ -33,10 +33,10 @@ body {
position: absolute;
bottom: 0;
width: 100%;
height: 60px; /* Set the fixed height of the footer here */
line-height: 60px; /* Vertically center the text there */
height: 60px; /* Set the fixed height of the footer here
line-height: 60px; /* Vertically center the text there
background-color: #f5f5f5;
}
} */
/* Trade Offer
-------------------------------------------------- */
.trade-offer-grid {
@ -77,52 +77,6 @@ body {
/* Card Badge
-------------------------------------------------- */
span:has(> .card-badge-grid) {
display: block;
}
.card-badge-grid {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr 1fr;
gap: 0.2rem;
padding: 0.3rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.9rem;
min-width: 150px;
}
.card-badge-name {
grid-column: 1 / span 2;
grid-row: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-badge-rarity {
grid-column: 1;
grid-row: 2;
text-align: left;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: inline-block;
padding-top: 4px;
padding-bottom: 4px;
line-height: 1.2;
}
.card-badge-cardset {
grid-column: 2;
grid-row: 2;
text-align: right;
font-size: 0.75rem;
opacity: 0.9;
}
/* Responsive: On narrow viewports, stack the Has and Wants sections */
@media (max-width: 576px) {
.trade-offer-grid {

View file

@ -1,33 +1,26 @@
$(document).ready(function () {
// Initialize Gravatar
const $ = x => Array.from(document.querySelectorAll(x));
const $$ = x => Array.from(document.querySelector(x));
document.addEventListener('DOMContentLoaded', function() {
// Initialize Gravatar if available
if (typeof Gravatar !== 'undefined' && typeof Gravatar.init === 'function') {
Gravatar.init();
}
// Initialize tooltips
$('[data-bs-toggle="tooltip"]').each(function () {
new bootstrap.Tooltip(this);
});
// Initialize select2 fields
// Updated slider functionality for tab content
$("button[data-bs-toggle='tab']").on("click", function(e) {
e.preventDefault(); // Prevent default Bootstrap behavior
// Get the target pane selector from the button attribute
var targetSelector = $(this).attr("data-bs-target");
var $targetPane = $(targetSelector);
// Update active class on the nav buttons
$(this).closest("ul").find("button").removeClass("active");
$(this).addClass("active");
// Compute the offset of the target pane relative to the grid container
// Using the DOM property offsetLeft ensures any grid gap is taken into account
var offset = $targetPane[0].offsetLeft;
// Slide the grid: translate the container to align the target pane with the viewport
$("#cardsetTabsContent").css("transform", "translateX(-" + offset + "px)");
const themeToggleBtn = document.getElementById('theme-toggle-btn');
if (themeToggleBtn) {
themeToggleBtn.addEventListener('click', function() {
const root = document.documentElement;
if (root.classList.contains("dark")) {
root.classList.remove("dark");
root.setAttribute("data-theme", "light");
localStorage.setItem("theme", "light");
} else {
root.classList.add("dark");
root.setAttribute("data-theme", "dark");
localStorage.setItem("theme", "dark");
}
});
}
});

View file

@ -1,8 +0,0 @@
{% extends '_base.html' %}
{% block title %}Forbidden (403){% endblock title %}
{% block content %}
<h1>Forbidden (403)</h1>
<p>CSRF verification failed. Request aborted.</p>
{% endblock content %}

View file

@ -1,7 +0,0 @@
{% extends '_base.html' %}
{% block title %}404 Page not found{% endblock %}
{% block content %}
<h1>Page not found</h1>
{% endblock content %}

View file

@ -1,8 +0,0 @@
{% extends '_base.html' %}
{% block title %}500 Server Error{% endblock %}
{% block content %}
<h1>500 Server Error</h1>
<p>Looks like something went wrong!</p>
{% endblock content %}

View file

@ -1,135 +0,0 @@
{% load static card_badge %}
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
<title>{% block title %}Pocket.Trade{% endblock title %}</title>
<meta name="description" content="A framework for launching new Django projects quickly.">
<meta name="author" content="">
<link rel="shortcut icon" type="image/x-icon" href="{% static 'images/favicon.ico' %}">
<!-- DaisyUI (disabled for now)-->
<!-- <link href="https://cdn.jsdelivr.net/npm/daisyui@5.0.0-beta.8/daisyui.min.css" rel="stylesheet"> -->
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link rel="stylesheet" href="{% static 'css/base.css' %}">
{% block css %}
{% endblock %}
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- Bootstrap JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
<!-- Tailwind CSS (disabled for now)-->
<!-- <script src="https://unpkg.com/@tailwindcss/browser@4"></script> -->
<!-- DaisyUI (disabled for now)-->
<!-- <script src="https://cdn.jsdelivr.net/npm/daisyui@5.0.0-beta.8/index.min.js"></!-->
<!-- Select2 -->
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<!-- Gravatar -->
<script src="https://www.gravatar.com/js/hovercards/hovercards.min.js"></script>
<!-- Project JS -->
<script src="{% static 'js/base.js' %}"></script>
<script>
function formatOption(option) {
if (!option.id) return option.text;
var $option = $(option.element);
var cardName = $option.data('name');
var rarity = $option.data('rarity');
var cardset = $option.data('cardset');
var style = $option.data('style');
var $container = $(
{% card_badge None %}
);
return $container;
}
</script>
{% block javascript %}
{% endblock javascript %}
</head>
<body>
<nav class="navbar navbar-expand-lg bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'home' %}">Pocket.Trade</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="{% url 'home' %}">Home</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="tradeOffersDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Trade Offers
</a>
<ul class="dropdown-menu" aria-labelledby="tradeOffersDropdown">
<li><a class="dropdown-item" href="{% url 'trade_offer_list' %}">All Offers</a></li>
{% if user.is_authenticated %}<li><a class="dropdown-item" href="{% url 'trade_offer_list' %}?my_trades=true">My Trades</a></li>{% endif %}
</ul>
</li>
</ul>
{% if user.is_authenticated %}
<div class="mr-auto">
<ul class="navbar-nav">
<li class="nav-item"><a href="{% url 'trade_offer_create' %}" class="btn btn-primary me-2 mb-2">
Create Trade Offer
</a></li>
</ul>
</div>
<div class="mr-auto">
<div class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
aria-expanded="false">
Settings
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#">{{ user.email }}</a></li>
<li>
<hr class="dropdown-divider">
</li>
<li><a class="dropdown-item" href="{% url 'list_friend_codes' %}">My Friend Codes</a></li>
<li><a class="dropdown-item" href="{% url 'account_change_password' %}">Change password</a></li>
<li><a class="dropdown-item" href="{% url 'account_logout' %}">Sign out</a></li>
</ul>
</li>
</div>
</div>
{% else %}
<div class="mr-auto">
<form class="form d-flex">
<a href="{% url 'account_login' %}" class="btn btn-outline-secondary">Log in</a>
<a href="{% url 'account_signup' %}" class="btn btn-primary ms-2">Sign up</a>
</form>
</div>
{% endif %}
</div>
</div>
</nav>
<div class="container">
{% block content %}
<p>Default content...</p>
{% endblock content %}
</div>
<footer class="footer">
<div class="container">
<span class="text-muted">Footer...</span>
</div>
</footer>
</body>
</html>

View file

@ -1,13 +0,0 @@
{% extends '_base.html' %}
{% load crispy_forms_tags %}
{% block title %}Log in{% endblock %}
{% block content %}
<h2>Log in</h2>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success" type="submit">Log in</button>
</form>
{% endblock content %}

View file

@ -1,18 +0,0 @@
{% extends '_base.html' %}
{% load crispy_forms_tags %}
{% block title %}Log out{% endblock %}
{% block content %}
<h1>Sign Out</h1>
<p>Are you sure you want to sign out?</p>
<form method="post" action="{% url 'account_logout' %}">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-danger" type="submit">Sign Out</button>
</form>
{% endblock content %}

View file

@ -1,13 +0,0 @@
{% extends '_base.html' %}
{% load crispy_forms_tags %}
{% block title %}Change Password{% endblock %}
{% block content %}
<h2>Change Password</h2>
<form method="post" action="{% url 'account_change_password' %}">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success" type="submit">Change Password</button>
</form>
{% endblock content %}

View file

@ -1,13 +0,0 @@
{% extends '_base.html' %}
{% load crispy_forms_tags %}
{% block title %}Password Reset{% endblock %}
{% block content %}
<h2>Forgot your password? </h2>
<form method="POST" action="{% url 'account_reset_password' %}" class="password_reset">
{% csrf_token %}
{{ form | crispy }}
<button class="btn btn-primary" type="submit">Reset Password</button>
</form>
{% endblock content %}

View file

@ -1,9 +0,0 @@
{% extends '_base.html' %}
{% load crispy_forms_tags %}
{% block title %}Password Reset Done{% endblock %}
{% block content %}
<h1>Password Reset</h1>
<p>We have sent you an e-mail. Please contact us if you do not receive it in a few minutes.</p>
{% endblock content %}

View file

@ -1,22 +0,0 @@
{% extends '_base.html' %}
{% load crispy_forms_tags %}
{% block title %}Change Password{% endblock title %}
{% block content %}
<h1>{% if token_fail %}Bad Token{% else %}Change Password{% endif %}</h1>
{% if token_fail %}
<p>The password reset link was invalid. Perhaps it has already been used? Please request a <a href="{% url 'account_reset_password' %}">new password reset</a>.</p>
{% else %}
{% if form %}
<form method="POST" action=".">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary" type="submit">Change Password</button>
</form>
{% else %}
<p>Your password is now changed.</p>
{% endif %}
{% endif %}
{% endblock content%}

View file

@ -1,9 +0,0 @@
{% extends '_base.html' %}
{% load crispy_forms_tags %}
{% block title %}Change Password Done{% endblock title %}
{% block content %}
<h1>Password Change Done</h1>
<p>Your password has been changed.</p>
{% endblock content %}

View file

@ -1,15 +0,0 @@
{% extends '_base.html' %}
{% load crispy_forms_tags %}
{% block title %}Set Password{% endblock title %}
{% block content %}
<form method="POST" action="" class="password_set">
{% csrf_token %}
{{ form | crispy }}
<div class="form-actions">
<button class="btn btn-primary" type="submit" name="action" value="Set Password">Change
Password</button>
</div>
</form>
{% endblock content %}

View file

@ -1,13 +0,0 @@
{% extends '_base.html' %}
{% load crispy_forms_tags %}
{% block title %}Sign up{% endblock %}
{% block content %}
<h2>Sign up</h2>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success" type="submit">Sign up</button>
</form>
{% endblock content %}

View file

@ -1,17 +0,0 @@
{% extends '_base.html' %}
{% load crispy_forms_tags %}
{% block title %}Add Friend Code{% endblock %}
{% block content %}
<h1>Add Friend Code</h1>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button type="submit">Add Friend Code</button>
</form>
<p>
<a href="{% url 'list_friend_codes' %}">Back to Friend Codes</a>
</p>
{% endblock %}

View file

@ -1,13 +0,0 @@
{% extends '_base.html' %}
{% load crispy_forms_tags %}
{% block title %}Log in{% endblock %}
{% block content %}
<h1>Are you sure you want to delete friend code: {{ friend_code.friend_code }}?</h1>
<form method="post">
{% csrf_token %}
<button type="submit">Confirm Delete</button>
<a href="{% url 'list_friend_codes' %}">Cancel</a>
</form>
{% endblock content %}

View file

@ -1,25 +0,0 @@
{% extends '_base.html' %}
{% block title %}My Friend Codes{% endblock %}
{% block content %}
<h1>My Friend Codes</h1>
{% if friend_codes %}
<ul>
{% for code in friend_codes %}
<li>
{{ code.friend_code }}
<!-- Link to the delete confirmation page for this friend code -->
<a href="{% url 'delete_friend_code' code.id %}">Delete</a>
</li>
{% endfor %}
</ul>
{% else %}
<p>You do not have any friend codes added yet.</p>
{% endif %}
<p>
<a href="{% url 'add_friend_code' %}">Add a New Friend Code</a>
</p>
{% endblock %}

View file

@ -1,42 +0,0 @@
{% load trade_offer_tags %}
{% if offered_cards or wanted_cards %}
<hr class="my-5">
<h2 class="mb-4">Results</h2>
{% if search_results and search_results.object_list %}
<ul class="list-group">
{% for offer in search_results %}
<li class="list-group-item border-0">
<a href="{% url 'trade_offer_update' offer.pk %}" class="d-flex align-items-center text-decoration-none">
{% render_trade_offer offer %}
</a>
</li>
{% endfor %}
</ul>
<!-- Pagination Controls -->
<nav aria-label="Search results pagination" class="mt-4">
<ul class="pagination">
{% if search_results.has_previous %}
<li class="page-item">
<a class="page-link ajax-page-link" data-page="{{ search_results.previous_page_number }}" href="#">Previous</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Previous</span></li>
{% endif %}
{% for num in search_results.paginator.page_range %}
<li class="page-item {% if search_results.number == num %}active{% endif %}">
<a class="page-link ajax-page-link" data-page="{{ num }}" href="#">{{ num }}</a>
</li>
{% endfor %}
{% if search_results.has_next %}
<li class="page-item">
<a class="page-link ajax-page-link" data-page="{{ search_results.next_page_number }}" href="#">Next</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Next</span></li>
{% endif %}
</ul>
</nav>
{% else %}
<div class="alert alert-info">No trade offers found.</div>
{% endif %}
{% endif %}

View file

@ -1,239 +0,0 @@
{% extends '_base.html' %}
{% load static %}
{% load trade_offer_tags card_badge %}
{% load cache %}
{% load card_multiselect %}
{% block content %}
<main class="container my-5">
<h1 class="text-center mb-5">Welcome to Pocket.Trade</h1>
<!-- Search Form Section -->
<section id="trade-search" class="mb-5">
<form method="post" action=".">
{% csrf_token %}
<div class="row">
<div class="col-md-6 mb-3">
{% card_multiselect "offered_cards" "Have:" available_cards "Select zero or more cards..." offered_cards %}
</div>
<div class="col-md-6 mb-3">
{% card_multiselect "wanted_cards" "Want:" available_cards "Select zero or more cards..." wanted_cards %}
</div>
</div>
<button type="submit" class="btn btn-primary w-100">Find a Trade Offer</button>
</form>
</section>
<!-- Search Results Section -->
<section id="search-results">
{% include "home/_search_results.html" %}
</section>
<!-- Market Stats Section -->
<section aria-labelledby="stats-heading" class="mb-5">
<h2 id="stats-heading" class="mb-4">Market Stats</h2>
<div class="row gx-5">
<!-- Most Offered Cards (cached for 3600 seconds / 1 hour) -->
<div class="col-md-6 mb-3">
<h5 class="mb-3">Most Offered Cards</h5>
<div class="card h-100 shadow border-0">
<div class="card-body">
{% cache 3600 most_offered_cards %}
{% if most_offered_cards %}
<div class="d-flex flex-column gap-3">
{% for card in most_offered_cards %}
{% if card.offer_count > 0 %}
<a href="?wanted_cards={{ card.id }}"
class="d-flex justify-content-between align-items-center text-decoration-none text-primary">
{% card_badge card %}
<span>{{ card.offer_count }}</span>
</a>
{% endif %}
{% endfor %}
</div>
{% else %}
<p>No cards found</p>
{% endif %}
{% endcache %}
</div>
</div>
</div>
<!-- Most Wanted Cards (cached for 3600 seconds / 1 hour) -->
<div class="col-md-6 mb-3">
<h5 class="mb-3">Most Wanted Cards</h5>
<div class="card h-100 shadow border-0">
<div class="card-body">
{% cache 3600 most_wanted_cards %}
{% if most_wanted_cards %}
<div class="d-flex flex-column gap-3">
{% for card in most_wanted_cards %}
{% if card.offer_count > 0 %}
<a href="?offered_cards={{ card.id }}"
class="d-flex justify-content-between align-items-center text-decoration-none text-primary">
{% card_badge card %}
<span>{{ card.offer_count }}</span>
</a>
{% endif %}
{% endfor %}
</div>
{% else %}
<p>No cards found</p>
{% endif %}
{% endcache %}
</div>
</div>
</div>
</div>
</section>
<!-- Featured Offers and Recent Offers Section -->
<div class="row mb-5">
<!-- Featured Offers Card (cached for 86400 seconds / 1 day) -->
<div class="col-md-6 mb-3">
{% cache 86400 featured_offers %}
<div class="card h-100 border-0">
<div class="card-header border-0 bg-transparent">
<h5 class="card-title mb-0">Featured Offers</h5>
<ul class="nav nav-tabs card-header-tabs mt-3" id="cardsetTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="all-tab" data-bs-toggle="tab" data-bs-target="#all"
type="button" role="tab" aria-controls="all" aria-selected="true">All</button>
</li>
{% for cardset, offers in featured_offers.items %}
{% if cardset != "All" %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="{{ cardset|slugify }}-tab" data-bs-toggle="tab" data-bs-target="#{{ cardset|slugify }}"
type="button" role="tab" aria-controls="{{ cardset|slugify }}" aria-selected="false">
{{ cardset }}
</button>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
<div class="card-body">
<div class="tab-content" id="cardsetTabsContent">
<!-- All Offers Tab Pane -->
<div class="tab-pane fade show active" id="all" role="tabpanel" aria-labelledby="all-tab">
{% if featured_offers.All %}
<div class="d-flex flex-column gap-3">
{% for offer in featured_offers.All %}
<a href="{% url 'trade_offer_update' offer.pk %}" class="d-flex align-items-center text-decoration-none">
{% render_trade_offer offer %}
</a>
{% endfor %}
</div>
{% else %}
<p>No featured offers available.</p>
{% endif %}
</div>
<!-- Other Cardset Tab Panes -->
{% for cardset, offers in featured_offers.items %}
{% if cardset != "All" %}
<div class="tab-pane fade" id="{{ cardset|slugify }}" role="tabpanel" aria-labelledby="{{ cardset|slugify }}-tab">
{% if offers %}
<div class="d-flex flex-column gap-3">
{% for offer in offers %}
<a href="{% url 'trade_offer_update' offer.pk %}" class="d-flex align-items-center text-decoration-none">
{% render_trade_offer offer %}
</a>
{% endfor %}
</div>
{% else %}
<p>No featured offers for {{ cardset }}.</p>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endcache %}
</div>
<!-- Recent Offers Card (cached for 60 seconds) -->
<div class="col-md-6 mb-3">
{% cache 60 recent_offers %}
<div class="card h-100 border-0">
<div class="card-body">
<h5 class="card-title">Recent Offers</h5>
<div class="d-flex flex-column gap-3">
{% for offer in recent_offers %}
<a href="{% url 'trade_offer_update' offer.pk %}" class="text-decoration-none">
{% render_trade_offer offer %}
</a>
{% empty %}
<div>No offers available</div>
{% endfor %}
</div>
</div>
</div>
{% endcache %}
</div>
</div>
</main>
{% endblock content %}
{% block javascript %}
<!-- <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script> -->
<script>
$(document).ready(function () {
// function formatOption(option) {
// if (!option.id) return option.text;
// var $option = $(option.element);
// var cardName = $option.data('name');
// var rarity = $option.data('rarity');
// var cardset = $option.data('cardset');
// var style = $option.data('style');
// return $('<span>').text(cardName + " " + rarity + " " + cardset).attr('style', style);
// }
// $('.select2-field').select2({
// placeholder: function() {
// return $(this).data('placeholder');
// },
// templateResult: formatOption,
// templateSelection: formatOption,
// width: '100%',
// dropdownAutoWidth: true,
// allowClear: true
// });
// AJAX form submission for trade search
$("#trade-search form").on('submit', function(e) {
e.preventDefault();
$.ajax({
type: $(this).attr("method"),
url: $(this).attr("action"),
data: $(this).serialize(),
headers: { "X-Requested-With": "XMLHttpRequest" },
success: function(data) {
$("#search-results").html(data);
},
error: function() {
alert("There was an error processing your search.");
}
});
});
// AJAX pagination for search results
$(document).on('click', '.ajax-page-link', function(e){
e.preventDefault();
var page = $(this).data('page');
if($("#page").length) {
$("#page").val(page);
} else {
$("<input>").attr({
type: "hidden",
id: "page",
name: "page",
value: page
}).appendTo("#trade-search form");
}
$("#trade-search form").submit();
});
});
</script>
{% endblock %}

View file

@ -1,13 +0,0 @@
{% if decks|length == 1 %}
{% if dropdown %}'{% endif %}<span class="badge card-badge-grid m-1" style="{% if dropdown %}'+ style +'{% else %}background-color: {{ decks.0.hex_color }}; color: white;{% endif %}">{% if dropdown %}' + {% endif %}
{% elif decks|length == 2 %}
{% if dropdown %}'{% endif %}<span class="badge card-badge-grid m-1" style="{% if dropdown %}'+ style +'{% else %}background: linear-gradient(to right, {{ decks.0.hex_color }}, {{ decks.1.hex_color }}); color: white;{% endif %}">{% if dropdown %}' + {% endif %}
{% elif decks|length >= 3 %}
{% if dropdown %}'{% endif %}<span class="badge card-badge-grid m-1" style="{% if dropdown %}'+ style +'{% else %}background: linear-gradient(to right, {{ decks.0.hex_color }}, {{ decks.1.hex_color }}, {{ decks.2.hex_color }}); color: white;{% endif %}">{% if dropdown %}' + {% endif %}
{% else %}
{% if dropdown %}'{% endif %}<span class="badge card-badge-grid m-1" style="{% if dropdown %}'+ style +'{% else %}background-color: #cccccc; color: white;{% endif %}">{% if dropdown %}' + {% endif %}
{% endif %}
{% if dropdown %}'{% endif %}<span class="card-badge-name">{% if dropdown %}'+ cardName +'{% else %}{{ card.name }}{% endif %}</span>{% if dropdown %}' + {% endif %}
{% if dropdown %}'{% endif %}<span class="card-badge-rarity">{% if dropdown %}'+ rarity +'{% else %}{{ card.rarity.icons }}{% endif %}</span>{% if dropdown %}' + {% endif %}
{% if dropdown %}'{% endif %}<span class="card-badge-cardset">{% if dropdown %}'+ cardset +'{% else %}{{ card.cardset.name }}{% endif %}</span>{% if dropdown %}' + {% endif %}
{% if dropdown %}'{% endif %}</span>{% if dropdown %}'{% endif %}

View file

@ -1,29 +0,0 @@
{% load cache card_badge %}
<label for="{{ field_id }}" class="form-label">{{ label }}</label>
<select name="{{ field_name }}" id="{{ field_id }}" class="form-select select2-field" data-placeholder="{{ placeholder }}" multiple="multiple">
{% cache cache_timeout cache_key %}
<option value="" disabled="disabled">{{ placeholder }}</option>
{% for card in available_cards %}
<option value="{{ card.pk }}"
data-name="{{ card.name }}"
data-rarity="{{ card.rarity.icons }}"
data-cardset="{{ card.cardset.name }}"
data-style="{{ card.style }}"
{{ card.name }} {{ card.rarity.icons }} {{ card.cardset.name }}
</option>
{% endfor %}
{% endcache %}
</select>
<script>
$(document).ready(function () {
$('#{{ field_id }}').select2({
placeholder: $('#{{ field_id }}').data('placeholder'),
templateResult: formatOption,
templateSelection: formatOption,
width: '100%',
dropdownAutoWidth: true,
allowClear: true
});
});
</script>

View file

@ -1,62 +0,0 @@
{% load gravatar card_badge %}
<div class="card trade-offer mb-3 mx-auto shadow-lg unified-card" style="border: none;">
<div class="card-body trade-offer-body">
<!-- Header Row: Using Grid, with relative positioning for avatar placement -->
<div class="row no-gutters">
<!-- Has Side -->
<div class="col-6 position-relative" style="padding: 1rem;">
{% if offer.initiated_by and offer.initiated_by.user.email %}
<!-- Positioned to the left -->
<div class="avatar position-absolute" style="left: 1rem; top: 50%; transform: translateY(-50%);">
{{ offer.initiated_by.user.email|gravatar:40 }}
</div>
{% endif %}
<!-- Centered text remains in the normal flow -->
<div class="text-center">
<h6 class="card-subtitle text-muted mb-0">Has</h6>
</div>
</div>
<!-- Wants Side -->
<div class="col-6 position-relative" style="padding: 1rem;">
{% if offer.accepted_by and offer.accepted_by.user.email %}
<!-- Positioned to the right -->
<div class="avatar position-absolute" style="right: 1rem; top: 50%; transform: translateY(-50%);">
{{ offer.accepted_by.user.email|gravatar:40 }}
</div>
{% endif %}
<!-- Centered text remains in the normal flow -->
<div class="text-center">
<h6 class="card-subtitle text-muted mb-0">Wants</h6>
</div>
</div>
</div>
<!-- Body Row: Using Grid, no separators; badge spacing is consistent -->
<div class="row no-gutters">
<div class="col-6" style="padding: 1rem;">
<div class="trade-offer-cards d-flex flex-wrap justify-content-center gap-2">
{% if offer.have_cards.all %}
{% for card in offer.have_cards.all %}
{% card_badge card %}
{% endfor %}
{% endif %}
</div>
</div>
<div class="col-6" style="padding: 1rem;">
<div class="trade-off-offer-cards d-flex flex-wrap justify-content-center gap-2">
{% if offer.want_cards.all %}
{% for card in offer.want_cards.all %}
{% card_badge card %}
{% endfor %}
{% endif %}
</div>
</div>
</div>
<!-- Trade ID Footer with Info Icon -->
<small class="text-muted mt-auto d-block text-end pe-2">
<i class="bi bi-info-circle-fill" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="Trade ID: {{ offer.hash }}" style="cursor: pointer;"></i>
</small>
</div>
</div>

View file

@ -1,41 +0,0 @@
{% extends '_base.html' %}
{% load static %}
{% load card_multiselect %}
{% block title %}Create Trade Offer{% endblock title %}
{% block content %}
<h2>Create a Trade Offer</h2>
<form method="post" novalidate>
{% csrf_token %}
{# Render the nonSelect2 field normally (e.g. initiated_by) #}
<div class="mb-3">
<label for="initiated_by" class="form-label">Initiated by</label>
{{ form.initiated_by }}
</div>
<div class="mb-3">
{% card_multiselect "have_cards" "Have:" available_cards "Select one or more cards..." form.have_cards.value %}
</div>
<div class="mb-3">
{% card_multiselect "want_cards" "Want:" available_cards "Select one or more cards..." form.want_cards.value %}
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% if form.errors %}
<div class="alert alert-danger">
<strong>Please correct the errors below:</strong>
<ul>
{% for field in form %}
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock content %}

View file

@ -1,14 +0,0 @@
{% extends '_base.html' %}
{% load static %}
{% block title %}Delete Trade Offer{% endblock title %}
{% block content %}
<h2>Delete Trade Offer</h2>
<p>Are you sure you want to delete this trade offer?</p>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Confirm Delete</button>
<a href="{% url 'trade_offer_list' %}" class="btn btn-secondary">Cancel</a>
</form>
{% endblock content %}

View file

@ -1,62 +0,0 @@
{% extends '_base.html' %}
{% load static %}
{% load el_pagination_tags %}
{% block title %}Trade Offer List{% endblock title %}
{% block content %}
<div class="d-flex justify-content-end mb-3">
<form method="get" class="d-flex align-items-center">
<div class="form-check me-3">
<input class="form-check-input" type="checkbox" name="show_completed" id="show_completed" value="true" {% if show_completed %}checked{% endif %}>
<label class="form-check-label" for="show_completed">
Only Completed
</label>
</div>
<button type="submit" class="btn btn-primary">Apply</button>
</form>
</div>
<h2>Trade Offers</h2>
<table class="table">
<thead>
<tr>
<th>Offer</th>
<th>State</th>
<th>Updated At</th>
</tr>
</thead>
<tbody>
{% paginate 10 object_list as paginated_offers %}
{% for offer in paginated_offers %}
<tr>
<td>
<a href="{% url 'trade_offer_update' offer.id %}" class="d-flex align-items-center text-decoration-none">
<div class="flex-grow-1 text-start">
FT: {% for card in offer.cards_ft.all %}
{{ card.name }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
<div class="px-2 text-center" style="min-width: 50px;">&#x27F6;</div>
<div class="flex-grow-1 text-end">
LF: {% for card in offer.cards_lf.all %}
{{ card.name }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
</a>
</td>
<td>{{ offer.get_state_display }}</td>
<td>{{ offer.updated_at }}</td>
</tr>
{% empty %}
<tr>
<td colspan="3">No trade offers available.</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pagination">
{% show_pages %}
</div>
<a href="{% url 'trade_offer_create' %}" class="btn btn-success">Create New Offer</a>
{% endblock content %}

View file

@ -14,6 +14,7 @@
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"cross-env": "^7.0.3",
"daisyui": "^5.0.0-beta.9",
"postcss": "^8.5.1",
"postcss-import": "^16.1.0",
"postcss-nested": "^7.0.2",
@ -711,6 +712,15 @@
"node": ">=4"
}
},
"node_modules/daisyui": {
"version": "5.0.0-beta.9",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.0.0-beta.9.tgz",
"integrity": "sha512-V+To8o1O8AaxSgdk9QrjXyq/e1AhdW1Z6oUI5iwrOjPs8avM7VQNqoTDCAE5rM0NcMbUfmFgQH8h8guiQ5QPOA==",
"dev": true,
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",

View file

@ -15,15 +15,16 @@
"license": "MIT",
"devDependencies": {
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/cli": "^4.0.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"cross-env": "^7.0.3",
"daisyui": "^5.0.0-beta.9",
"postcss": "^8.5.1",
"postcss-import": "^16.1.0",
"postcss-nested": "^7.0.2",
"postcss-simple-vars": "^7.0.1",
"rimraf": "^6.0.1",
"tailwindcss": "^4.0.0",
"@tailwindcss/cli": "^4.0.0"
"tailwindcss": "^4.0.0"
}
}

View file

@ -14,8 +14,7 @@
* @source "../../../templates";
*/
@import "tailwindcss" source("../../../");
@import "tailwindcss" source("../../");
/*
* If you would like to customise you theme, you can do that here too.
@ -38,38 +37,78 @@
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/aspect-ratio";
@tailwind base;
@tailwind components;
@tailwind utilities;
@plugin "daisyui/theme" {
name: "acid";
default: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(98% 0 0);
--color-base-200: oklch(92% 0 0);
--color-base-300: oklch(87% 0 0);
--color-base-content: oklch(0% 0 0);
@custom-variant dark (&:where(.dark, .dark *));
@plugin "daisyui";
/* @plugin "daisyui/theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: light;
--color-base-100: oklch(100% 0 0);
--color-base-200: oklch(98% 0 0);
--color-base-300: oklch(95% 0 0);
--color-base-content: oklch(21% 0.006 285.885);
--color-primary: #CF36E0;
--color-primary-content: oklch(98% 0.003 247.858);
--color-primary-content: oklch(100% 0 0);
--color-secondary: #8040E0;
--color-secondary-content: oklch(98% 0.003 247.858);
--color-accent: #1070EB;
--color-accent-content: oklch(18.556% 0.052 122.962);
--color-neutral: oklch(43% 0 0);
--color-neutral-content: oklch(98% 0.003 247.858);
--color-info: #302FD9;
--color-info-content: oklch(98% 0.003 247.858);
--color-secondary-content: oklch(100% 0 0);
--color-accent: #302FD9;
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(37% 0 0);
--color-neutral-content: oklch(100% 0 0);
--color-info: #1070EB;
--color-info-content: oklch(100% 0 0);
--color-success: #20AA80;
--color-success-content: oklch(12% 0.042 264.695);
--color-warning: #EB8600;
--color-warning-content: oklch(18.202% 0.042 100.5);
--color-success-content: oklch(100% 0 0);
--color-warning: #EA8200;
--color-warning-content: oklch(100% 0 0);
--color-error: #E00202;
--color-error-content: oklch(98% 0.003 247.858);
--radius-selector: 0rem;
--color-error-content: oklch(100% 0 0);
--radius-selector: 0.5rem;
--radius-field: 0rem;
--radius-box: 0rem;
--size-selector: 0.3125rem;
--size-field: 0.3125rem;
--border: 1.5px;
--border: 1px;
--depth: 1;
--noise: 0;
}
@plugin "daisyui/theme" {
name: "dark";
default: false;
prefersdark: false;
color-scheme: dark;
--color-base-100: oklch(25.33% 0.016 252.42);
--color-base-200: oklch(23.26% 0.014 253.1);
--color-base-300: oklch(21.15% 0.012 254.09);
--color-base-content: oklch(97.807% 0.029 256.847);
--color-primary: #CF36E0;
--color-primary-content: oklch(100% 0 0);
--color-secondary: #8040E0;
--color-secondary-content: oklch(100% 0 0);
--color-accent: #302FD9;
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(37% 0 0);
--color-neutral-content: oklch(100% 0 0);
--color-info: #1070EB;
--color-info-content: oklch(100% 0 0);
--color-success: #20AA80;
--color-success-content: oklch(100% 0 0);
--color-warning: #EA8200;
--color-warning-content: oklch(100% 0 0);
--color-error: #E00202;
--color-error-content: oklch(100% 0 0);
--radius-selector: 0.5rem;
--radius-field: 0rem;
--radius-box: 0rem;
--size-selector: 0.3125rem;
--size-field: 0.3125rem;
--border: 1px;
--depth: 1;
--noise: 0;
} */

View file

@ -54,4 +54,5 @@ module.exports = {
require('@tailwindcss/typography'),
require('@tailwindcss/aspect-ratio'),
],
darkMode: 'class',
}

View file

@ -0,0 +1,10 @@
{% extends 'base.html' %}
{% block title %}Forbidden (403){% endblock title %}
{% block content %}
<div class="container mx-auto max-w-md mt-12 text-center">
<h1 class="text-5xl font-bold mb-4">Forbidden (403)</h1>
<p class="text-xl mb-4">CSRF verification failed. Request aborted.</p>
<a href="{% url 'home' %}" class="btn btn-primary">Return Home</a>
</div>
{% endblock content %}

10
theme/templates/404.html Normal file
View file

@ -0,0 +1,10 @@
{% extends 'base.html' %}
{% block title %}404 Page not found{% endblock title %}
{% block content %}
<div class="container mx-auto max-w-md mt-12 text-center">
<h1 class="text-5xl font-bold mb-4">404</h1>
<p class="text-xl mb-4">Page not found</p>
<a href="{% url 'home' %}" class="btn btn-primary">Return Home</a>
</div>
{% endblock content %}

10
theme/templates/500.html Normal file
View file

@ -0,0 +1,10 @@
{% extends 'base.html' %}
{% block title %}500 Server Error{% endblock title %}
{% block content %}
<div class="container mx-auto max-w-md mt-12 text-center">
<h1 class="text-5xl font-bold mb-4">500</h1>
<p class="text-xl mb-4">Looks like something went wrong!</p>
<a href="{% url 'home' %}" class="btn btn-primary">Return Home</a>
</div>
{% endblock content %}

View file

@ -0,0 +1,34 @@
{% extends 'base.html' %}
{% load crispy_forms_tags i18n widget_tweaks %}
{% block head_title %}{% trans "Log In" %}{% endblock %}
{% block content %}
<div class="container mx-auto max-w-md mt-6">
<h1 class="text-3xl font-bold text-center mb-6">{% trans "Log In" %}</h1>
<form method="post" action="{% url 'account_login' %}" class="space-y-4">
{% csrf_token %}
{{ form.non_field_errors }}
<div>
<label for="{{ form.login.id_for_label }}" class="block font-medium text-gray-700">{{ form.login.label }}</label>
{{ form.login|add_class:"input input-bordered w-full" }}
{{ form.login.errors }}
</div>
<div>
<label for="{{ form.password.id_for_label }}" class="block font-medium text-gray-700">{{ form.password.label }}</label>
{{ form.password|add_class:"input input-bordered w-full" }}
{{ form.password.errors }}
</div>
{% if form.remember %}
<div class="flex items-center">
{{ form.remember }}
<label for="{{ form.remember.id_for_label }}" class="ml-2">{% trans "Remember Me" %}</label>
</div>
{% endif %}
<button type="submit" class="btn btn-primary w-full">{% trans "Log In" %}</button>
</form>
<div class="mt-4 text-center">
<a href="{% url 'account_reset_password' %}" class="text-primary underline">{% trans "Forgot Password?" %}</a>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends 'base.html' %}
{% load crispy_forms_tags i18n %}
{% block head_title %}{% trans "Log Out" %}{% endblock %}
{% block content %}
<div class="container mx-auto max-w-md mt-6">
<h2 class="text-3xl font-bold text-center mb-6">{% trans "Sign Out" %}</h2>
<p class="text-center mb-6">{% trans "Are you sure you want to sign out?" %}</p>
<form method="post" action="{% url 'account_logout' %}" class="space-y-4 text-center">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-error w-full" type="submit">{% trans "Sign Out" %}</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% load crispy_forms_tags i18n %}
{% block head_title %}{% trans "Change Password" %}{% endblock %}
{% block content %}
<div class="container mx-auto max-w-md mt-6">
<h2 class="text-3xl font-bold text-center mb-6">{% trans "Change Password" %}</h2>
<form method="post" action="{% url 'account_change_password' %}" class="space-y-4">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success w-full" type="submit">{% trans "Change Password" %}</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,21 @@
{% extends 'base.html' %}
{% load crispy_forms_tags i18n widget_tweaks %}
{% block head_title %}{% trans "Reset Password" %}{% endblock %}
{% block content %}
<div class="container mx-auto max-w-md mt-6">
<h2 class="text-3xl font-bold text-center mb-6">{% trans "Reset Password" %}</h2>
<p class="mb-4 text-center">{% trans "Enter your email address and we'll send you a link to reset your password." %}</p>
<form method="post" action="{% url 'account_reset_password' %}" class="space-y-4">
{% csrf_token %}
{{ form.non_field_errors }}
<div>
<label for="{{ form.email.id_for_label }}" class="block font-medium text-gray-700">{{ form.email.label }}</label>
{{ form.email|add_class:"input input-bordered w-full" }}
{{ form.email.errors }}
</div>
<button type="submit" class="btn btn-primary w-full">{% trans "Reset Password" %}</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load crispy_forms_tags i18n %}
{% block head_title %}{% trans "Password Reset Done" %}{% endblock %}
{% block content %}
<div class="container mx-auto max-w-md mt-6 text-center">
<h2 class="text-3xl font-bold mb-4">{% trans "Password Reset" %}</h2>
<p>{% trans "We have sent you an e-mail. Please contact us if you do not receive it in a few minutes." %}</p>
</div>
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% load crispy_forms_tags i18n %}
{% block head_title %}{% trans "Change Password" %}{% endblock %}
{% block content %}
<div class="container mx-auto max-w-md mt-6">
{% if token_fail %}
<h2 class="text-3xl font-bold text-center mb-4">{% trans "Bad Token" %}</h2>
<p class="mb-4 text-center">
{% trans "The password reset link was invalid. Perhaps it has already been used? Please request a" %}
<a href="{% url 'account_reset_password' %}" class="text-primary underline">{% trans "new password reset" %}</a>.
</p>
{% else %}
{% if form %}
<h2 class="text-3xl font-bold text-center mb-6">{% trans "Change Password" %}</h2>
<form method="POST" action="." class="space-y-4">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary w-full" type="submit">{% trans "Change Password" %}</button>
</form>
{% else %}
<h2 class="text-3xl font-bold text-center mb-4">{% trans "Password Changed" %}</h2>
<p class="text-center">{% trans "Your password is now changed." %}</p>
{% endif %}
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load crispy_forms_tags i18n %}
{% block head_title %}{% trans "Password Change Done" %}{% endblock %}
{% block content %}
<div class="container mx-auto max-w-md mt-6 text-center">
<h2 class="text-3xl font-bold mb-4">{% trans "Password Change Done" %}</h2>
<p>{% trans "Your password has been changed." %}</p>
</div>
{% endblock %}

View file

@ -0,0 +1,43 @@
{% extends 'base.html' %}
{% load i18n widget_tweaks %}
{% block head_title %}{% trans "Sign Up" %}{% endblock %}
{% block content %}
<div class="container mx-auto max-w-md mt-6">
<h1 class="text-3xl font-bold text-center mb-6">{% trans "Sign Up" %}</h1>
<form method="post" action="{% url 'account_signup' %}" class="space-y-4">
{% csrf_token %}
{{ form.non_field_errors }}
<div>
<label for="{{ form.username.id_for_label }}" class="block font-medium text-gray-700">{{ form.username.label }}</label>
{{ form.username|add_class:"input input-bordered w-full" }}
{{ form.username.errors }}
</div>
<div>
<label for="{{ form.email.id_for_label }}" class="block font-medium text-gray-700">{{ form.email.label }}</label>
{{ form.email|add_class:"input input-bordered w-full" }}
{{ form.email.errors }}
</div>
<div>
<label for="{{ form.password1.id_for_label }}" class="block font-medium text-gray-700">{{ form.password1.label }}</label>
{{ form.password1|add_class:"input input-bordered w-full" }}
{{ form.password1.errors }}
</div>
<div>
<label for="{{ form.password2.id_for_label }}" class="block font-medium text-gray-700">{{ form.password2.label }}</label>
{{ form.password2|add_class:"input input-bordered w-full" }}
{{ form.password2.errors }}
</div>
<div>
<label for="{{ form.friend_code.id_for_label }}" class="block font-medium text-gray-700">{{ form.friend_code.label }}</label>
{{ form.friend_code|add_class:"input input-bordered w-full" }}
{{ form.friend_code.errors }}
</div>
<button type="submit" class="btn btn-primary w-full">{% trans "Sign Up" %}</button>
</form>
<div class="mt-4 text-center">
<p>{% trans "Already have an account?" %} <a href="{% url 'account_login' %}" class="text-primary underline">{% trans "Log In" %}</a></p>
</div>
</div>
{% endblock %}

View file

@ -1 +1,173 @@
{% load static tailwind_tags %}
{% load static tailwind_tags gravatar %}
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Inline script to set the theme before rendering -->
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark');
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
<title>{% block title %}Pkmn Trade Club{% endblock title %}</title>
<link rel="shortcut icon" href="{% static 'images/favicon.ico' %}">
<!-- Choices.js -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js@11.0.6/public/assets/styles/choices.min.css" />
<script async src="https://cdn.jsdelivr.net/npm/choices.js@11.0.6/public/assets/scripts/choices.min.js"></script>
<!-- Tailwind CSS and Base stylesheet -->
{% tailwind_css %}
<link rel="stylesheet" href="{% static 'css/base.css' %}">
{% block css %}{% endblock %}
{% block javascript_head %}{% endblock %}
</head>
<body class="min-h-screen bg-base-200">
<!-- Header and Navigation -->
<div class="navbar bg-base-100 shadow-sm">
<div class="navbar-start">
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost hidden sm:flex md:hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /> </svg>
</div>
<ul
tabindex="0"
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
<li><a href="{% url 'home' %}">Home</a></li>
<li>
<a>Trade</a>
<ul class="p-2">
<li><a href="{% url 'trade_offer_list' %}">All Offers</a></li>
<li><a href="{% url 'trade_offer_list' %}?my_trades=true">My Trades</a></li>
</ul>
</li>
</ul>
</div>
<a class="btn btn-ghost text-xl" href="{% url 'home' %}">
<span aria-hidden="true">
<sup class="inline-block relative left-2">P</sup>
<sub class="inline-block relative">K</sub>
<sup class="inline-block relative -left-2">M</sup>
<sub class="inline-block relative -left-4">N</sub>
<span class="inline-block relative -left-4">Trade Club</span>
</span>
<span aria-hidden="false" class="sr-only">Pokemon Trade Club</span>
</a>
</div>
<div class="navbar-center hidden md:flex">
<ul class="menu menu-horizontal px-1">
<li><a href="{% url 'home' %}">Home</a></li>
<li>
<details>
<summary>Trade</summary>
<ul class="p-2 w-32 z-10">
<li><a href="{% url 'trade_offer_list' %}">All Offers</a></li>
<li><a href="{% url 'trade_offer_list' %}?my_trades=true">My Trades</a></li>
</ul>
</details>
</li>
</ul>
</div>
<div class="navbar-end">
<!-- <button class="btn btn-ghost btn-circle hidden sm:flex">
<div class="indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /> </svg>
<div aria-label="success" class="status status-success"></div>
</div>
</button> -->
<button id="theme-toggle-btn" class="btn btn-ghost btn-circle me-2" title="Toggle Theme">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 dark:hidden">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 hidden dark:block">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
</button>
{% if user.is_authenticated %}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full">
{{ user.email|gravatar:40 }}
</div>
</div>
<ul
tabindex="0"
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-32 p-2 shadow">
<li>
<a class="justify-between" href="https://www.gravatar.com/profile/" target="_blank" rel="noopener noreferrer">
Profile
</a>
</li>
<li>
<a class="justify-between" href="{% url 'list_friend_codes' %}">
Friend Codes
</a>
</li>
<li><a href="{% url 'account_logout' %}">Logout</a></li>
</ul>
</div>
</div>
{% else %}
<div class="flex gap-2">
<a class="btn btn-primary" href="{% url 'account_login' %}">Login</a>
<a class="btn btn-secondary" href="{% url 'account_signup' %}">Sign Up</a>
</div>
{% endif %}
</div>
</div>
<!-- Main Content -->
<main class="container mx-auto p-4 sm:w-4/5 md:w-full xl:w-256">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="bg-base-200 text-base-content p-4">
<div class="container mx-auto text-center">
<p>&copy; {% now "Y" %} PKMNTrade.Club. All rights reserved.</p>
</div>
</footer>
<!-- Dock -->
<div class="dock bg-neutral text-neutral-content sm:hidden">
<button>
<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="currentColor" stroke-linejoin="miter" stroke-linecap="butt"><polyline points="1 11 12 2 23 11" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="2"></polyline><path d="m5,13v7c0,1.105.895,2,2,2h10c1.105,0,2-.895,2-2v-7" fill="none" stroke="currentColor" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></path><line x1="12" y1="22" x2="12" y2="18" fill="none" stroke="currentColor" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></line></g></svg>
<span class="dock-label">Home</span>
</button>
<button class="dock-active">
<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="currentColor" stroke-linejoin="miter" stroke-linecap="butt"><polyline points="3 14 9 14 9 17 15 17 15 14 21 14" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="2"></polyline><rect x="3" y="3" width="18" height="18" rx="2" ry="2" fill="none" stroke="currentColor" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></rect></g></svg>
<span class="dock-label">Trades</span>
</button>
<button>
<svg class="size-[1.5em]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="currentColor" stroke-linejoin="miter" stroke-linecap="butt"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /> </g></svg>
<span class="dock-label">Notifications</span>
</button>
<button>
{% if user.is_authenticated %}<div tabindex="0" role="button" class="avatar"><div class="w-6 rounded-full">{{ user.email|gravatar:40 }}</div></div>{% else %}<svg class="size-[1.2em]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="currentColor" stroke-linejoin="miter" stroke-linecap="butt"><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></circle><path d="m22,13.25v-2.5l-2.318-.966c-.167-.581-.395-1.135-.682-1.654l.954-2.318-1.768-1.768-2.318.954c-.518-.287-1.073-.515-1.654-.682l-.966-2.318h-2.5l-.966,2.318c-.581.167-1.135.395-1.654.682l-2.318-.954-1.768,1.768.954,2.318c-.287.518-.515,1.073-.682,1.654l-2.318.966v2.5l2.318.966c.167.581.395,1.135.682,1.654l-.954,2.318,1.768,1.768,2.318-.954c.518.287,1.073.515,1.654.682l.966,2.318h2.5l.966-2.318c.581-.167,1.135-.395,1.654-.682l2.318.954,1.768-1.768-.954-2.318c.287-.518.515-1.073.682-1.654l2.318-.966Z" fill="none" stroke="currentColor" stroke-linecap="square" stroke-miterlimit="10" stroke-width="2"></path></g></svg>{% endif %}
<span class="dock-label">Settings</span>
</button>
</div>
<!-- Alpine Plugins -->
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.14.8/dist/cdn.min.js"></script>
<!-- Alpine Core -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
<script defer src="{% static 'js/base.js' %}"></script>
{% block javascript %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,33 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}Add Friend Code{% endblock %}
{% block content %}
<div class="container mx-auto max-w-md mt-6">
<h1 class="text-3xl font-bold mb-4">Add Friend Code</h1>
<form method="post" class="space-y-4">
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="btn btn-primary w-full">Add Friend Code</button>
</form>
<div class="mt-4">
<a href="{% url 'list_friend_codes' %}" class="btn btn-secondary">Back to Friend Codes</a>
</div>
</div>
<!-- Include Cleave Zen from a CDN -->
<script src="https://unpkg.com/cleave-zen@0.0.17/dist/cleave-zen.umd.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function(){
// Initialize Cleave Zen on the friend code input field.
// Make sure that the input ID is correct (e.g., provided by Django's widget rendering).
new CleaveZen('#id_friend_code', {
delimiters: ['-', '-', '-'], // Inserts dashes between the blocks.
blocks: [4, 4, 4, 4],
numericOnly: true
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,33 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}Delete Friend Code{% endblock %}
{% block content %}
<div class="container mx-auto max-w-md mt-6">
<h1 class="text-3xl font-bold mb-4">Delete Friend Code</h1>
<p class="mb-4">
Are you sure you want to delete friend code:
<span class="font-mono">{{ friend_code.friend_code }}</span>?
</p>
{% if error_message %}
<div class="alert alert-warning mb-4">
{{ error_message }}
</div>
{% endif %}
<form method="post" class="flex space-x-4">
{% csrf_token %}
<button type="submit" class="btn btn-error"
{% if disable_delete %} disabled {% endif %}>
{% if disable_delete %}
Delete Not Allowed
{% else %}
Confirm Delete
{% endif %}
</button>
<a href="{% url 'list_friend_codes' %}" class="btn btn-secondary">Cancel</a>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,50 @@
{% extends 'base.html' %}
{% block title %}My Friend Codes{% endblock %}
{% block content %}
<div class="container mx-auto max-w-xl mt-6">
{# Display messages if there are any. #}
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} mb-4">
{{ message }}
</div>
{% endfor %}
{% endif %}
<h1 class="text-3xl font-bold mb-4">My Friend Codes</h1>
{% if friend_codes %}
<ul class="space-y-2">
{% for code in friend_codes %}
<li class="flex items-center justify-between {% if user.default_friend_code and code.id == user.default_friend_code.id %}bg-green-100{% else %}bg-base-100{% endif %} p-4 rounded shadow">
<div>
<span class="font-mono">{{ code.friend_code }}</span>
{% if user.default_friend_code and code.id == user.default_friend_code.id %}
<span class="badge badge-success ml-2">Default</span>
{% endif %}
</div>
<div class="flex items-center space-x-2">
{% if user.default_friend_code and code.id == user.default_friend_code.id %}
<button type="button" class="btn btn-secondary btn-sm" disabled>Set as Default</button>
{% else %}
<form method="post" action="{% url 'change_default_friend_code' code.id %}">
{% csrf_token %}
<button type="submit" class="btn btn-secondary btn-sm">Set as Default</button>
</form>
{% endif %}
<a href="{% url 'delete_friend_code' code.id %}" class="btn btn-error btn-sm">Delete</a>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p>You do not have any friend codes added yet.</p>
{% endif %}
<div class="mt-4">
<a href="{% url 'add_friend_code' %}" class="btn btn-primary">Add a New Friend Code</a>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,26 @@
{% load card_badge %}
{% comment %}
This partial expects:
- cards: a list of card objects
- mode: a string that determines the render style.
It should be "offered" for Most Offered Cards and "wanted" for Most Wanted Cards.
- Optional 'show_zero' flag (default False): if True, also display cards with 0 offers.
{% endcomment %}
{% if cards %}
<div class="flex flex-col items-center gap-3">
{% for card in cards %}
{% if show_zero|default:False or card.offer_count > 0 %}
{% if mode == "offered" %}
<a href="?offered_cards={{ card.id }}"
{% else %}
<a href="?wanted_cards={{ card.id }}"
{% endif %}
class="flex justify-between items-center text-primary no-underline">
{% card_badge card card.offer_count %}
</a>
{% endif %}
{% endfor %}
</div>
{% else %}
<p class="text-center">No cards found</p>
{% endif %}

View file

@ -0,0 +1,10 @@
{% load trade_offer_tags %}
{% if offered_cards or wanted_cards %}
<hr class="my-8 border-t border-gray-200">
<h2 class="text-2xl font-bold mb-4">Results</h2>
{% if search_results and search_results.object_list %}
{% include "trades/_trade_offer_list.html" with offers=search_results %}
{% else %}
<div class="alert alert-info mt-4">No trade offers found.</div>
{% endif %}
{% endif %}

View file

@ -0,0 +1,331 @@
{% extends 'base.html' %}
{% load static trade_offer_tags card_badge cache card_multiselect %}
{% block content %}
<h1 class="text-center text-4xl font-bold mb-8 pt-4">
<span aria-hidden="true">
<span class="inline-block relative left-2 text-4xl">Welcome to</span>
<sup class="inline-block relative left-4 text-4xl">P</sup>
<sub class="inline-block relative text-4xl">K</sub>
<sup class="inline-block relative -left-2 text-4xl">M</sup>
<sub class="inline-block relative -left-4 text-4xl">N</sub>
<span class="inline-block relative -left-2 text-4xl">Trade Club</span>
</span>
<span aria-hidden="false" class="sr-only">Welcome to Pokemon Trade Club</span>
</h1>
<!-- Search Form Section -->
<section id="trade-search" class="mb-8">
<form method="post" action="." class="space-y-4">
{% csrf_token %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
{% card_multiselect "have_cards" "Have:" "Select zero or more cards..." available_cards have_cards %}
</div>
<div>
{% card_multiselect "want_cards" "Want:" "Select zero or more cards..." available_cards want_cards %}
</div>
</div>
{% if user.is_authenticated %}
<div class="flex flex-col md:flex-row gap-4">
<button type="submit" class="btn btn-primary flex-1">Find a Trade Offer</button>
<a href="{% url 'trade_offer_create' %}" id="createTradeOfferBtn" class="btn btn-secondary flex-1 text-center">Create Trade Offer</a>
</div>
{% else %}
<div>
<button type="submit" class="btn btn-primary w-full">Find a Trade Offer</button>
</div>
{% endif %}
</form>
</section>
<!-- Search Results Section -->
<section id="search-results" class="mb-8">
{% include "home/_search_results.html" %}
</section>
<!-- Market Stats Section -->
<section aria-labelledby="stats-heading" class="mb-8">
<h2 id="stats-heading" class="text-2xl font-semibold mb-4">Market Stats</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<!-- Most Offered Cards -->
<div>
<div class="card bg-base-100 shadow">
<div class="card-header text-base-content p-4">
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Most Offered Cards</h5>
</div>
<div class="card-body my-4 p-0">
{% cache 3600 most_offered_cards %}
{% include "home/_card_list.html" with cards=most_offered_cards mode="wanted" %}
{% endcache %}
</div>
</div>
</div>
<!-- Most Wanted Cards -->
<div>
<div class="card bg-base-100 shadow">
<div class="card-header text-base-content p-4">
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Most Wanted Cards</h5>
</div>
<div class="card-body my-4 p-0">
{% cache 3600 most_wanted_cards %}
{% include "home/_card_list.html" with cards=most_wanted_cards mode="offered" %}
{% endcache %}
</div>
</div>
</div>
<!-- Least Offered Cards -->
<div>
<div class="card bg-base-100 shadow">
<div class="card-header text-base-content p-4">
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Least Offered Cards</h5>
</div>
<div class="card-body my-4 p-0">
{% cache 3600 least_offered_cards %}
{% include "home/_card_list.html" with cards=least_offered_cards mode="wanted" show_zero=True %}
{% endcache %}
</div>
</div>
</div>
</div>
</section>
<!-- Featured Offers and Recent Offers Section -->
<section class="mb-8">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Featured Offers -->
<div>
{% cache 86400 featured_offers %}
<div class="card bg-base-100 shadow">
<div class="card-header text-base-content p-4">
<h5 class="text-xl text-center font-semibold whitespace-nowrap truncate mb-0">Featured Offers</h5>
</div>
<div class="card-body p-4">
<!-- New pure-CSS tabs for Featured Offers -->
<div class="featured-offers-tabs">
<!-- Radio inputs for all tabs -->
<input type="radio" name="featured_offers_tabs" id="tab-all" class="hidden" checked>
{% for rarity, offers in featured_offers.items %}
{% if rarity != "All" %}
<input type="radio" name="featured_offers_tabs" id="tab-{{ forloop.counter }}" class="hidden">
{% endif %}
{% endfor %}
<!-- Tab navigation: all tab labels appear together -->
<div class="tabs tabs-box grid grid-cols-3 gap-2">
<label for="tab-all" class="tab text-xs md:text-base">All</label>
{% for rarity, offers in featured_offers.items %}
{% if rarity != "All" %}
<label for="tab-{{ forloop.counter }}" class="tab text-xs md:text-base">{{ rarity }}</label>
{% endif %}
{% endfor %}
</div>
<!-- All tab content panels are placed in one content container -->
<div class="tab-contents">
<!-- Panel for All offers -->
<div class="tab-content" id="content-tab-all">
{% if featured_offers.All %}
<div class="flex flex-col items-center gap-3 w-auto mx-auto">
{% for offer in featured_offers.All %}
{% render_trade_offer offer %}
{% endfor %}
</div>
{% else %}
<p class="text-center">No featured offers available.</p>
{% endif %}
</div>
<!-- Panels for each additional rarity -->
{% for rarity, offers in featured_offers.items %}
{% if rarity != "All" %}
<div class="tab-content" id="content-tab-{{ forloop.counter }}">
{% if offers %}
<div class="flex flex-col items-center gap-3 w-auto mx-auto">
{% for offer in offers %}
{% render_trade_offer offer %}
{% endfor %}
</div>
{% else %}
<p class="text-center">No featured offers for {{ rarity }}.</p>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
{% endcache %}
</div>
<!-- Recent Offers -->
<div>
{% cache 60 recent_offers %}
<div class="card bg-base-100 shadow">
<div class="card-header text-center text-base-content p-4">
<h5 class="text-xl font-semibold whitespace-nowrap truncate mb-0">Recent Offers</h5>
</div>
<div class="card-body my-4 p-4">
<div class="flex flex-col items-center gap-3">
{% for offer in recent_offers %}
{% render_trade_offer offer %}
{% empty %}
<p>No recent offers available.</p>
{% endfor %}
</div>
</div>
</div>
{% endcache %}
</div>
</div>
</section>
{% endblock content %}
{% block css %}
<style>
/* Hide the hidden radio inputs */
.featured-offers-tabs input[type="radio"] {
display: none;
}
/* Styles for the tabs navigation */
.tabs.tabs-box {
margin-bottom: 1rem;
width: 100%;
}
.tabs.tabs-box .tab {
flex: 1; /* Each tab will equally expand */
text-align: center;
cursor: pointer;
padding: 0.5rem 1rem;
border: 1px solid transparent;
transition: border-color 0.3s;
}
.tabs.tabs-box .tab:hover {
border-color: currentColor;
}
/* Active tab styling based on the radio input state */
#tab-all:checked ~ .tabs.tabs-box label[for="tab-all"] {
border-color: #2563eb; /* Example blue highlight */
}
{% for rarity, offers in featured_offers.items %}
{% if rarity != "All" %}
#tab-{{ forloop.counter }}:checked ~ .tabs.tabs-box label[for="tab-{{ forloop.counter }}"] {
border-color: #2563eb;
font-weight: bold;
}
{% endif %}
{% endfor %}
/* Hide all content panels by default */
.featured-offers-tabs .tab-contents > .tab-content {
display: none;
transition: opacity 0.3s ease-in-out;
}
/* Display the panel corresponding to the checked radio input */
#tab-all:checked ~ .tab-contents #content-tab-all {
display: block;
}
{% for rarity, offers in featured_offers.items %}
{% if rarity != "All" %}
#tab-{{ forloop.counter }}:checked ~ .tab-contents #content-tab-{{ forloop.counter }} {
display: block;
}
{% endif %}
{% endfor %}
</style>
{% endblock %}
{% block javascript %}
<script defer>
document.addEventListener('DOMContentLoaded', function() {
// AJAX trade search form submission with vanilla JavaScript
const tradeSearchForm = document.querySelector('#trade-search form');
if (tradeSearchForm) {
tradeSearchForm.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(tradeSearchForm);
fetch(tradeSearchForm.action, {
method: tradeSearchForm.method,
headers: {
"X-Requested-With": "XMLHttpRequest"
},
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.text();
})
.then(data => {
document.querySelector('#search-results').innerHTML = data;
})
.catch(error => {
alert("There was an error processing your search.");
console.error('Error:', error);
});
});
}
// AJAX pagination click handling
document.addEventListener('click', function(e) {
const target = e.target.closest('.ajax-page-link');
if (target) {
e.preventDefault();
const page = target.getAttribute('data-page');
let pageInput = document.getElementById('page');
if (pageInput) {
pageInput.value = page;
} else {
pageInput = document.createElement('input');
pageInput.type = 'hidden';
pageInput.id = 'page';
pageInput.name = 'page';
pageInput.value = page;
tradeSearchForm.appendChild(pageInput);
}
tradeSearchForm.dispatchEvent(new Event('submit'));
}
});
// Updated: JS to carry over selections (including quantities) to the Create Trade Offer page.
const createBtn = document.getElementById('createTradeOfferBtn');
if (createBtn) {
createBtn.addEventListener('click', function(e) {
e.preventDefault();
// Use the standardized field names for both "have_cards" and "want_cards"
const haveSelect = document.querySelector('select[name="have_cards"]');
const wantSelect = document.querySelector('select[name="want_cards"]');
const url = new URL(createBtn.href, window.location.origin);
if (haveSelect) {
// For each selected option, include the quantity from data-quantity (defaulting to "1")
const selectedHave = Array.from(haveSelect.selectedOptions).map(opt => {
const cardId = opt.value;
const quantity = opt.getAttribute('data-quantity') || '1';
return cardId + ':' + quantity;
});
selectedHave.forEach(val => url.searchParams.append('have_cards', val));
}
if (wantSelect) {
const selectedWant = Array.from(wantSelect.selectedOptions).map(opt => {
const cardId = opt.value;
const quantity = opt.getAttribute('data-quantity') || '1';
return cardId + ':' + quantity;
});
selectedWant.forEach(val => url.searchParams.append('want_cards', val));
}
window.location.href = url.href;
});
}
});
</script>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% comment %}
This fragment renders a friend code selector used for filtering or form submissions.
Expected variables:
- friend_codes: A list or QuerySet of FriendCode objects.
- selected_friend_code: The currently selected FriendCode.
- field_name (optional): The name/id for the input element (default "friend_code").
- label (optional): The label text (default "Friend Code").
{% endcomment %}
{% with field_name=field_name|default:"friend_code" label=label|default:"Friend Code" %}
{% if friend_codes|length > 1 %}
<div class="form-control">
<label for="{{ field_name }}" class="label">
<span class="label-text p-2 rounded">{{ label }}</span>
</label>
<select id="{{ field_name }}" name="{{ field_name }}" class="select select-bordered w-full bg-secondary text-white">
{% for code in friend_codes %}
<option value="{{ code.pk }}" {% if code.pk|stringformat:"s" == selected_friend_code.pk|stringformat:"s" %}selected{% endif %}>
{{ code.friend_code }}
</option>
{% endfor %}
</select>
</div>
{% else %}
<input type="hidden" name="{{ field_name }}" value="{{ friend_codes.0.pk }}">
{% endif %}
{% endwith %}

View file

@ -0,0 +1,58 @@
{% load trade_offer_tags %}
{% comment %}
This snippet renders a grid of trade offer cards along with pagination controls,
using the trade_offer templatetag (i.e. {% render_trade_offer offer %}).
It expects a context variable:
- offers: an iterable or a paginated page of TradeOffer objects.
{% endcomment %}
<div class="flex flex-row gap-4 flex-wrap justify-center items-start">
{% for offer in offers %}
<div class="flex flex-none">
<a href="{% url 'trade_offer_detail' pk=offer.pk %}" class="no-underline">
{% render_trade_offer offer %}
</a>
</div>
{% empty %}
<div>No trade offers available.</div>
{% endfor %}
</div>
{% if offers.has_other_pages %}
<nav aria-label="Trade offers pagination" class="mt-6">
<ul class="flex justify-center space-x-2">
{% if offers.has_previous %}
<li>
<a class="btn btn-outline ajax-page-link" data-page="{{ offers.previous_page_number }}" href="#">
Previous
</a>
</li>
{% else %}
<li>
<span class="btn btn-outline btn-disabled">Previous</span>
</li>
{% endif %}
{% for num in offers.paginator.page_range %}
<li>
<a class="btn btn-outline ajax-page-link {% if offers.number == num %}btn-active{% endif %}" data-page="{{ num }}" href="#">
{{ num }}
</a>
</li>
{% endfor %}
{% if offers.has_next %}
<li>
<a class="btn btn-outline ajax-page-link" data-page="{{ offers.next_page_number }}" href="#">
Next
</a>
</li>
{% else %}
<li>
<span class="btn btn-outline btn-disabled">Next</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}

View file

@ -0,0 +1,32 @@
{% extends 'base.html' %}
{% block title %}Accept Trade Offer{% endblock title %}
{% block content %}
<div class="container mx-auto max-w-xl mt-6">
<h2 class="text-2xl font-bold">Accept Trade Offer</h2>
<form method="post" novalidate>
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Submit Acceptance</button>
</form>
{% if form.errors %}
<div class="alert alert-error mt-4">
<strong>Please correct the errors below:</strong>
<ul>
{% for field in form %}
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="mt-6">
<a href="{% url 'trade_offer_detail' pk=trade_offer.pk %}" class="btn btn-secondary">Back to Offer Details</a>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,32 @@
{% extends 'base.html' %}
{% block title %}Update Trade Acceptance{% endblock title %}
{% block content %}
<div class="container mx-auto max-w-xl mt-6">
<h2 class="text-2xl font-bold">Update Trade Acceptance</h2>
<form method="post" novalidate>
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Update</button>
</form>
{% if form.errors %}
<div class="alert alert-error mt-4">
<strong>Please correct the errors below:</strong>
<ul>
{% for field in form %}
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="mt-6">
<a href="{% url 'trade_offer_detail' pk=object.trade_offer.pk %}" class="btn btn-secondary">Back to Offer Details</a>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,77 @@
{% extends 'base.html' %}
{% load card_multiselect %}
{% block title %}Create Trade Offer{% endblock title %}
{% block content %}
<div class="container mx-auto max-w-xl mt-6">
<h2 class="text-2xl font-bold mb-4">Create a Trade Offer</h2>
<form method="post" novalidate class="space-y-4">
{% csrf_token %}
{# Use the DRY friend code selector fragment #}
{% include "trades/_friend_code_select.html" with friend_codes=friend_codes selected_friend_code=selected_friend_code field_name=form.initiated_by.html_name label="Initiated by" %}
<!-- Grid layout for Card Selectors: "Have" and "Want" -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
{% card_multiselect "have_cards" "Have:" "Select one or more cards..." available_cards form.initial.have_cards %}
</div>
<div class="form-control">
{% card_multiselect "want_cards" "Want:" "Select one or more cards..." available_cards form.initial.want_cards %}
</div>
</div>
<button type="submit" class="btn btn-primary w-full">Submit</button>
</form>
{% if form.errors %}
<div class="alert alert-error mt-4">
<strong>Please correct the errors below:</strong>
<ul class="mt-2">
{% for field in form %}
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<script defer>
document.addEventListener('DOMContentLoaded', () => {
const initiatedBySelect = document.getElementById('{{ form.initiated_by.html_name }}');
if (initiatedBySelect) {
const choicesInstance = new Choices(initiatedBySelect, {
searchEnabled: false,
classNames: {
containerOuter: 'choices',
containerInner: 'choices__inner',
input: 'choices__input',
},
callbackOnCreateTemplates: function(template) {
return {
choice: (classNames, data) => {
return template(`
<div class="${classNames.item} ${classNames.itemChoice} bg-accent text-white"
data-select-text="${this.config.itemSelectText}"
data-choice ${data.disabled ? 'data-choice-disabled aria-disabled="true"' : 'data-choice-selectable'}
data-id="${data.id}" data-value="${data.value}"
${data.groupId > 0 ? 'role="treeitem"' : 'role="option"'}>
${data.label}
</div>
`);
},
};
},
});
// Style the Choices control as needed
choicesInstance.containerOuter.element.classList.add('bg-secondary', 'select', 'select-bordered', 'w-full');
choicesInstance.containerInner.element.classList.add('bg-secondary', 'text-white');
}
});
</script>
{% endblock content %}

View file

@ -0,0 +1,47 @@
{% extends 'base.html' %}
{% block title %}Delete or Close Trade Offer{% endblock title %}
{% block content %}
<div class="container mx-auto max-w-md mt-6">
<h2 class="text-2xl font-bold mb-4">
{% if action == 'delete' %}
Delete Trade Offer
{% elif action == 'close' %}
Close Trade Offer
{% else %}
Delete/Close Trade Offer
{% endif %}
</h2>
{% if messages %}
{% for message in messages %}
<div class="alert {{ message.tags }}">{{ message }}</div>
{% endfor %}
{% endif %}
<p class="mb-4">
{% if action == 'delete' %}
Are you sure you want to delete this trade offer? This will permanently remove the offer.
{% elif action == 'close' %}
Are you sure you want to close this trade offer? It will remain in the system as closed.
{% else %}
This trade offer cannot be deleted or closed because there are active acceptances.
{% endif %}
</p>
<form method="post" class="space-x-4">
{% csrf_token %}
{% if action %}
{% if action == 'delete' %}
<button type="submit" class="btn btn-error">Confirm Delete</button>
{% elif action == 'close' %}
<button type="submit" class="btn btn-warning">Confirm Close Trade Offer</button>
{% endif %}
{% else %}
<button type="submit" class="btn btn-error" disabled>Cannot Delete/Close Trade Offer</button>
{% endif %}
<a href="{% url 'trade_offer_list' %}" class="btn btn-secondary">Cancel</a>
</form>
</div>
{% endblock content %}

View file

@ -0,0 +1,64 @@
{% extends 'base.html' %}
{% block title %}Trade Offer Detail{% endblock title %}
{% block content %}
<div class="container mx-auto max-w-2xl mt-6">
<h2 class="text-2xl font-bold">Trade Offer Details</h2>
<div class="card bg-base-100 shadow-lg p-4">
<p>
<strong>Hash:</strong> {{ object.hash }}<br>
<strong>Initiated By:</strong> {{ object.initiated_by }}<br>
<strong>Cards You Have (Offer):</strong>
{% for through in object.trade_offer_have_cards.all %}
{{ through.card.name }} x{{ through.quantity }}{% if not forloop.last %}, {% endif %}
{% endfor %}<br>
<strong>Cards You Want:</strong>
{% for through in object.trade_offer_want_cards.all %}
{{ through.card.name }} x{{ through.quantity }}{% if not forloop.last %}, {% endif %}
{% endfor %}<br>
<strong>Created At:</strong> {{ object.created_at|date:"M d, Y H:i" }}<br>
<strong>Updated At:</strong> {{ object.updated_at|date:"M d, Y H:i" }}<br>
<strong>Status:</strong> {% if object.is_closed %}Closed{% else %}Open{% endif %}
</p>
</div>
<h3 class="text-xl font-semibold mt-6">Acceptances</h3>
{% if acceptances %}
<ul class="space-y-2">
{% for acceptance in acceptances %}
<li class="card p-4">
<p>
<strong>Accepted By:</strong> {{ acceptance.accepted_by }}<br>
<strong>Requested Card:</strong> {{ acceptance.requested_card.name }}<br>
<strong>Offered Card:</strong> {{ acceptance.offered_card.name }}<br>
<strong>State:</strong> {{ acceptance.get_state_display }}
</p>
<a href="{% url 'trade_acceptance_update' acceptance.pk %}" class="btn btn-sm">Update</a>
</li>
{% endfor %}
</ul>
{% else %}
<p>No acceptances yet.</p>
{% endif %}
{% if acceptance_form %}
<h3 class="text-xl font-semibold mt-6">Accept This Offer</h3>
<div class="card p-4">
<form method="post" action="{% url 'trade_acceptance_create' offer_pk=object.pk %}">
{% csrf_token %}
{{ acceptance_form.as_p }}
<button type="submit" class="btn btn-primary">Submit Acceptance</button>
</form>
</div>
{% endif %}
<div class="mt-6">
<!-- Show delete/close button for the initiator -->
{% if is_initiator %}
<a href="{{ delete_close_url }}" class="btn btn-danger">Delete/Close Trade Offer</a>
{% endif %}
<a href="{% url 'trade_offer_list' %}" class="btn btn-secondary">Back to Trade Offers</a>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,94 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Trade Offer & Acceptance List{% endblock title %}
{% block content %}
<div class="container mx-auto max-w-4xl mt-6">
<!-- Filter Form: Friend Code Selector + Toggle for Completed view -->
<div class="flex justify-end mb-4">
<form method="get" class="flex items-center space-x-4">
{% include "trades/_friend_code_select.html" with friend_codes=friend_codes selected_friend_code=selected_friend_code field_name="friend_code" label="Filter by Friend Code" %}
<label class="cursor-pointer flex items-center space-x-2">
<span class="font-medium">Only Completed</span>
<input type="checkbox" name="show_completed" value="true" class="toggle toggle-primary" {% if show_completed %}checked{% endif %}>
</label>
<button type="submit" class="btn btn-primary">Apply</button>
</form>
</div>
<!-- Section 1: My Trade Offers -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-4">My Trade Offers</h2>
{% if my_trade_offers_paginated.object_list %}
{% include "trades/_trade_offer_list.html" with offers=my_trade_offers_paginated %}
<div class="flex justify-between items-center mt-4">
{% if my_trade_offers_paginated.has_previous %}
<a href="?{% for key, value in request.GET.items %}{% if key != 'offers_page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}offers_page={{ my_trade_offers_paginated.previous_page_number }}" class="btn btn-sm">Previous</a>
{% else %}
<span></span>
{% endif %}
<span>Page {{ my_trade_offers_paginated.number }} of {{ my_trade_offers_paginated.paginator.num_pages }}</span>
{% if my_trade_offers_paginated.has_next %}
<a href="?{% for key, value in request.GET.items %}{% if key != 'offers_page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}offers_page={{ my_trade_offers_paginated.next_page_number }}" class="btn btn-sm">Next</a>
{% else %}
<span></span>
{% endif %}
</div>
{% else %}
<p>No trade offers found.</p>
{% endif %}
</section>
<!-- Section 2: Trade Acceptances Waiting For Your Response -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-4">Trade Acceptances Waiting For Your Response</h2>
{% if trade_acceptances_waiting_paginated.object_list %}
{% include "trades/_trade_offer_list.html" with offers=trade_acceptances_waiting_paginated %}
<div class="flex justify-between items-center mt-4">
{% if trade_acceptances_waiting_paginated.has_previous %}
<a href="?{% for key, value in request.GET.items %}{% if key != 'waiting_page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}waiting_page={{ trade_acceptances_waiting_paginated.previous_page_number }}" class="btn btn-sm">Previous</a>
{% else %}
<span></span>
{% endif %}
<span>Page {{ trade_acceptances_waiting_paginated.number }} of {{ trade_acceptances_waiting_paginated.paginator.num_pages }}</span>
{% if trade_acceptances_waiting_paginated.has_next %}
<a href="?{% for key, value in request.GET.items %}{% if key != 'waiting_page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}waiting_page={{ trade_acceptances_waiting_paginated.next_page_number }}" class="btn btn-sm">Next</a>
{% else %}
<span></span>
{% endif %}
</div>
{% else %}
<p>No pending acceptances at this time.</p>
{% endif %}
</section>
<!-- Section 3: Other Trade Acceptances -->
<section>
<h2 class="text-2xl font-bold mb-4">Other Trade Acceptances</h2>
{% if other_trade_acceptances_paginated.object_list %}
{% include "trades/_trade_offer_list.html" with offers=other_trade_acceptances_paginated %}
<div class="flex justify-between items-center mt-4">
{% if other_trade_acceptances_paginated.has_previous %}
<a href="?{% for key, value in request.GET.items %}{% if key != 'other_page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}other_page={{ other_trade_acceptances_paginated.previous_page_number }}" class="btn btn-sm">Previous</a>
{% else %}
<span></span>
{% endif %}
<span>Page {{ other_trade_acceptances_paginated.number }} of {{ other_trade_acceptances_paginated.paginator.num_pages }}</span>
{% if other_trade_acceptances_paginated.has_next %}
<a href="?{% for key, value in request.GET.items %}{% if key != 'other_page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}other_page={{ other_trade_acceptances_paginated.next_page_number }}" class="btn btn-sm">Next</a>
{% else %}
<span></span>
{% endif %}
</div>
{% else %}
<p>No other acceptances found.</p>
{% endif %}
</section>
<div class="mt-6">
<a href="{% url 'trade_offer_create' %}" class="btn btn-success">Create New Offer</a>
</div>
</div>
{% endblock content %}

View file

@ -1,26 +1,16 @@
{% extends '_base.html' %}
{% load static %}
{% extends 'base.html' %}
{% block title %}Trade Offer Details & Update{% endblock title %}
{% block content %}
<div class="container my-4">
<h2 class="mb-4">Trade Offer Details</h2>
<div class="container mx-auto max-w-2xl mt-6 space-y-6">
<h2 class="text-2xl font-bold">Trade Offer Details</h2>
<!-- Offer Details Card -->
<div class="card mb-4">
<div class="card-header">
Offer Information
</div>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<p>
<p class="text-gray-700">
<strong>Created At:</strong> {{ object.created_at|date:"M d, Y H:i" }}<br>
<strong>Updated At:</strong> {{ object.updated_at|date:"M d, Y H:i" }}<br>
{% comment %}
Only display these fields if the current user is associated with the initiating friend code
or (if available) with the accepted friend code.
{% endcomment %}
{% if object.initiated_by.user == request.user or object.accepted_by and object.accepted_by.user == request.user %}
<strong>Initiated By:</strong> {{ object.initiated_by }}<br>
<strong>Accepted By:</strong>
@ -30,7 +20,6 @@
Not yet accepted
{% endif %}<br>
{% endif %}
<strong>Cards You Have:</strong>
{% for card in object.have_cards.all %}
{{ card.name }}{% if not forloop.last %}, {% endif %}
@ -46,16 +35,16 @@
{% if form.fields %}
<!-- Form Card -->
<div class="card mb-4">
<div class="card-header">
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<div class="mb-4 font-semibold text-lg">
{% if action == "accept" %}
Accept Trade Offer
{% else %}
Update Trade Offer
{% endif %}
</div>
<div class="card-body">
<form method="post" novalidate>
<form method="post" novalidate class="space-y-4">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn {% if action == 'accept' %}btn-success{% else %}btn-primary{% endif %}">
@ -75,9 +64,9 @@
{% endif %}
{% if form and form.errors %}
<div class="alert alert-danger mt-3">
<div class="alert alert-error">
<strong>Please correct the errors below:</strong>
<ul class="mb-0">
<ul class="mt-2">
{% for field in form %}
{% for error in field.errors %}
<li>{{ error }}</li>
@ -90,10 +79,10 @@
</div>
{% endif %}
<div class="mt-3">
<div class="flex space-x-4">
<a href="{% url 'trade_offer_list' %}" class="btn btn-secondary">Back to Trade Offers</a>
{% if can_delete %}
<a href="{% url 'trade_offer_delete' object.pk %}" class="btn btn-danger ms-2">Delete Trade Offer</a>
<a href="{% url 'trade_offer_delete' object.pk %}" class="btn btn-error">Delete Trade Offer</a>
{% endif %}
</div>
</div>

Some files were not shown because too many files have changed in this diff Show more