diff --git a/accounts/forms.py b/accounts/forms.py index c8909e8..af40a45 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -3,6 +3,8 @@ from django.contrib.auth.forms import UserCreationForm, UserChangeForm from .models import CustomUser, FriendCode from allauth.account.forms import SignupForm from crispy_tailwind.tailwind import CSSContainer +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout, Field, Submit class CustomUserChangeForm(UserChangeForm): @@ -79,4 +81,9 @@ class CustomUserCreationForm(SignupForm): ) user.default_friend_code = friend_code_pk user.save() - return user \ No newline at end of file + return user + +class UserSettingsForm(forms.ModelForm): + class Meta: + model = CustomUser + fields = ['show_friend_code_on_link_previews'] \ No newline at end of file diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py index 0cab225..da58fb7 100644 --- a/accounts/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2025-03-20 00:08 +# Generated by Django 5.1.2 on 2025-03-22 04:08 import django.contrib.auth.models import django.contrib.auth.validators @@ -31,6 +31,7 @@ class Migration(migrations.Migration): ('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')), + ('show_friend_code_on_link_previews', models.BooleanField(default=False, help_text='This will primarily affect share link previews on X, Discord, etc.', verbose_name='Show Friend Code on Link Previews')), ('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')), ], diff --git a/accounts/models.py b/accounts/models.py index 96337be..2fbb250 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -4,6 +4,11 @@ from django.core.exceptions import ValidationError class CustomUser(AbstractUser): default_friend_code = models.ForeignKey("FriendCode", on_delete=models.SET_NULL, null=True, blank=True) + show_friend_code_on_link_previews = models.BooleanField( + default=False, + verbose_name="Show Friend Code on Link Previews", + help_text="This will primarily affect share link previews on X, Discord, etc." + ) def __str__(self): return self.email diff --git a/accounts/views.py b/accounts/views.py index f32c776..b810eca 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -3,8 +3,8 @@ 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, TemplateView, UpdateView -from accounts.models import FriendCode -from accounts.forms import FriendCodeForm +from accounts.models import FriendCode, CustomUser +from accounts.forms import FriendCodeForm, UserSettingsForm from django.db.models import Case, When, Value, BooleanField class ListFriendCodesView(LoginRequiredMixin, ListView): @@ -123,11 +123,23 @@ class ChangeDefaultFriendCodeView(LoginRequiredMixin, View): messages.success(request, "Default friend code updated successfully.") return redirect("list_friend_codes") -class SettingsView(LoginRequiredMixin, TemplateView): +# Updated SettingsView to update the new user setting. +class SettingsView(LoginRequiredMixin, UpdateView): """ - Display the user's settings. + Display account navigation links and allow the user to update their friend code + visibility setting. """ + model = CustomUser + form_class = UserSettingsForm template_name = "account/settings.html" + success_url = reverse_lazy("settings") + + def get_object(self): + return self.request.user + + def form_valid(self, form): + messages.success(self.request, "Settings updated successfully.") + return super().form_valid(form) class ProfileView(LoginRequiredMixin, TemplateView): """ diff --git a/cards/migrations/0001_initial.py b/cards/migrations/0001_initial.py index 6cb7d2a..c3ce8f3 100644 --- a/cards/migrations/0001_initial.py +++ b/cards/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2025-03-20 00:08 +# Generated by Django 5.1.2 on 2025-03-22 04:08 import django.db.models.deletion from django.db import migrations, models diff --git a/cards/urls.py b/cards/urls.py index a3811ef..ccb00fd 100644 --- a/cards/urls.py +++ b/cards/urls.py @@ -1,5 +1,14 @@ from django.urls import path +from .views import ( + CardDetailView, + TradeOfferHaveCardListView, + TradeOfferWantCardListView, +) +app_name = "cards" urlpatterns = [ + path('/', CardDetailView.as_view(), name='card_detail'), + path('/trade-offers-have/', TradeOfferHaveCardListView.as_view(), name='card_trade_offer_have_list'), + path('/trade-offers-want/', TradeOfferWantCardListView.as_view(), name='card_trade_offer_want_list'), ] diff --git a/cards/views.py b/cards/views.py index 6fe30ea..b543c92 100644 --- a/cards/views.py +++ b/cards/views.py @@ -1,4 +1,64 @@ from django.views.generic import TemplateView from django.urls import reverse_lazy from django.views.generic import UpdateView, DeleteView, CreateView, ListView, DetailView +from cards.models import Card +from trades.models import TradeOffer + +class CardDetailView(DetailView): + model = Card + template_name = "cards/card_detail.html" + context_object_name = "card" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + card = self.get_object() + # Count of trade offers where the card appears as a "have" in a trade. + context['trade_offer_have_count'] = TradeOffer.objects.filter( + trade_offer_have_cards__card=card + ).distinct().count() + # Count of trade offers where the card appears as a "want" in a trade. + context['trade_offer_want_count'] = TradeOffer.objects.filter( + trade_offer_want_cards__card=card + ).distinct().count() + return context + + +class TradeOfferHaveCardListView(ListView): + model = TradeOffer + template_name = "cards/_trade_offer_list.html" + context_object_name = "trade_offers" + paginate_by = 2 + + def get_queryset(self): + card_id = self.kwargs.get("pk") + order_param = self.request.GET.get("order", "newest") + ordering = "-updated_at" if order_param == "newest" else "updated_at" + return TradeOffer.objects.filter( + trade_offer_have_cards__card_id=card_id + ).order_by(ordering).distinct() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['side'] = 'have' + return context + + +class TradeOfferWantCardListView(ListView): + model = TradeOffer + template_name = "cards/_trade_offer_list.html" + context_object_name = "trade_offers" + paginate_by = 2 + + def get_queryset(self): + card_id = self.kwargs.get("pk") + order_param = self.request.GET.get("order", "newest") + ordering = "-updated_at" if order_param == "newest" else "updated_at" + return TradeOffer.objects.filter( + trade_offer_want_cards__card_id=card_id + ).order_by(ordering).distinct() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['side'] = 'want' + return context diff --git a/django_project/settings.py b/django_project/settings.py index f0f1182..6bc0b5a 100644 --- a/django_project/settings.py +++ b/django_project/settings.py @@ -57,7 +57,6 @@ INSTALLED_APPS = [ "home", "trades.apps.TradesConfig", "meta", - #"silk", ] SILKY_PYTHON_PROFILER = True diff --git a/home/views.py b/home/views.py index 8cc6ca9..59e43bd 100644 --- a/home/views.py +++ b/home/views.py @@ -60,6 +60,7 @@ class HomePageView(TemplateView): for rarity_level, rarity_icon in distinct_rarities: offers = base_offer_qs.filter(rarity_level=rarity_level).order_by("created_at")[:6] rarity_offers.append((rarity_level, rarity_icon, offers)) + print(rarity_offers) # Sort by rarity_level (from greatest to least) rarity_offers.sort(key=lambda x: x[0], reverse=True) diff --git a/static/css/base.css b/static/css/base.css index 3ab72c8..6511b28 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -1,3 +1,11 @@ +[x-cloak] { display: none !important; } + +select.card-multiselect { + height: calc(var(--spacing) * 35); + /*background-image: linear-gradient(45deg, #0000 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, #0000 50%); */ + background-image: none; +} + .choices.is-disabled .choices__inner, .choices.is-disabled .choices__input { background-color: var(--color-neutral); diff --git a/static/images/favicon.ico b/static/images/favicon.ico index eeebd56..04d79ac 100644 Binary files a/static/images/favicon.ico and b/static/images/favicon.ico differ diff --git a/static/js/base.js b/static/js/base.js index ded9290..d3ce09e 100644 --- a/static/js/base.js +++ b/static/js/base.js @@ -1,20 +1,137 @@ -const $ = x => Array.from(document.querySelectorAll(x)); -const $$ = x => Array.from(document.querySelector(x)); +/* global window, document, localStorage */ -document.addEventListener('DOMContentLoaded', function() { - 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"); +const $ = selector => Array.from(document.querySelectorAll(selector)); +const $$ = selector => Array.from(document.querySelector(selector)); + +(() => { + "use strict"; + + /** + * Initialize the theme toggle button functionality. + * Toggles between 'dark' and 'light' themes and persists the state in localStorage. + */ + function initThemeToggle() { + const themeToggleButton = document.getElementById("theme-toggle-btn"); + if (!themeToggleButton) return; + themeToggleButton.addEventListener("click", () => { + const documentRoot = document.documentElement; + const isDarkTheme = documentRoot.classList.contains("dark"); + const newTheme = isDarkTheme ? "light" : "dark"; + + if (newTheme === "light") { + documentRoot.classList.remove("dark"); } else { - root.classList.add("dark"); - root.setAttribute("data-theme", "dark"); - localStorage.setItem("theme", "dark"); + documentRoot.classList.add("dark"); + } + documentRoot.setAttribute("data-theme", newTheme); + localStorage.setItem("theme", newTheme); + }); + } + + /** + * Initialize event listeners for forms containing multiselect fields. + * When the form is submitted, process each 'card-multiselect' to create hidden inputs. + */ + function initCardMultiselectHandling() { + const forms = document.querySelectorAll("form"); + forms.forEach(form => { + if (form.querySelector("select.card-multiselect")) { + form.addEventListener("submit", () => { + processMultiselectForm(form); + }); } }); } -}); \ No newline at end of file + + /** + * Process multiselect fields within a form before submission by: + * - Creating hidden inputs for each selected option with value in 'card_id:quantity' format. + * - Removing the original name attribute to avoid duplicate submissions. + * + * @param {HTMLFormElement} form - The form element to process. + */ + function processMultiselectForm(form) { + const multiselectFields = form.querySelectorAll("select.card-multiselect"); + multiselectFields.forEach(selectField => { + const originalFieldName = + selectField.getAttribute("data-original-name") || selectField.getAttribute("name"); + if (!originalFieldName) return; + selectField.setAttribute("data-original-name", originalFieldName); + + // Remove any previously generated hidden inputs for this multiselect. + form + .querySelectorAll(`input[data-generated-for-card-multiselect="${originalFieldName}"]`) + .forEach(input => input.remove()); + + // For each selected option, create a hidden input. + selectField.querySelectorAll("option:checked").forEach(option => { + const cardId = option.value; + const quantity = option.getAttribute("data-quantity") || "1"; + const hiddenInput = document.createElement("input"); + hiddenInput.type = "hidden"; + hiddenInput.name = originalFieldName; + hiddenInput.value = `${cardId}:${quantity}`; + hiddenInput.setAttribute("data-generated-for-card-multiselect", originalFieldName); + form.appendChild(hiddenInput); + }); + + // Prevent the browser from submitting the select field directly. + selectField.removeAttribute("name"); + }); + } + + /** + * Reset stale selections in all card multiselect fields. + * This is triggered on the window's 'pageshow' event to clear any lingering selections. + */ + function resetCardMultiselectState() { + const multiselectFields = document.querySelectorAll("select.card-multiselect"); + multiselectFields.forEach(selectField => { + // Deselect all options. + selectField.querySelectorAll("option").forEach(option => { + option.selected = false; + }); + + // If the select field has an associated Choices.js instance, clear its selection. + if (selectField.choicesInstance) { + const activeSelections = selectField.choicesInstance.getValue(true); + if (activeSelections.length > 0) { + selectField.choicesInstance.removeActiveItemsByValue(activeSelections); + } + selectField.choicesInstance.setValue([]); + } + }); + } + + // On DOMContentLoaded, initialize theme toggling and form processing. + document.addEventListener("DOMContentLoaded", () => { + initThemeToggle(); + initCardMultiselectHandling(); + }); + + // On pageshow, only reset multiselect state if the page was loaded from bfcache. + window.addEventListener("pageshow", function(event) { + if (event.persisted) { + resetCardMultiselectState(); + } + }); + + // Expose tradeOfferCard globally if not already defined. + if (!window.tradeOfferCard) { + window.tradeOfferCard = function() { + return { + flipped: false, + badgeExpanded: false, + acceptanceExpanded: false, + /** + * Update the badge's expanded state. + * + * @param {boolean} expanded - The new state of the badge. + */ + setBadge(expanded) { + this.badgeExpanded = expanded; + }, + }; + }; + } +})(); \ No newline at end of file diff --git a/theme/static_src/tailwind.config.js b/theme/static_src/tailwind.config.js index 6e97ede..b8e3664 100644 --- a/theme/static_src/tailwind.config.js +++ b/theme/static_src/tailwind.config.js @@ -41,6 +41,16 @@ module.exports = { */ // '../../**/*.py' ], + safelist: [ + 'alert-info', + 'alert-success', + 'alert-warning', + 'alert-error', + 'bg-info', + 'bg-success', + 'bg-warning', + 'bg-error', + ], theme: { extend: {}, }, diff --git a/theme/templates/_messages.html b/theme/templates/_messages.html index c248650..7a07b57 100644 --- a/theme/templates/_messages.html +++ b/theme/templates/_messages.html @@ -1,9 +1,9 @@ {% if messages %}
{% for message in messages %} -
+
{{ message }} - +
{% endfor %}
diff --git a/theme/templates/account/profile.html b/theme/templates/account/profile.html index 087badc..0ab842d 100644 --- a/theme/templates/account/profile.html +++ b/theme/templates/account/profile.html @@ -8,15 +8,19 @@

{% trans "Profile" %}

-
{{ user.email|gravatar:100 }}
+
+ +

All profile information is managed through Gravatar.

-

- +

What is Gravatar?

Gravatar (Globally Recognized Avatar) is a free service that links your email address to a profile picture and, optionally, a profile. Many websites, including this one, use Gravatar to display your avatar and profile automatically.

@@ -24,6 +28,9 @@

How does it work?

If you've set up a Gravatar, your profile picture will appear here whenever you use your email on supported sites. When someone hovers over or clicks on your avatar, your Gravatar profile will also appear if you have one. If you don't have a Gravatar yet, you'll see a default image instead.

+

Is it safe? What about privacy?

+

Gravatar is completely optional, opt-in, and prioritizes your security and privacy. Your email is never shared and only a hashed version is sent to Gravatar, protecting your identity while ensuring that your email address is not exposed to bots or scrapers. Your personal data remains secure, and you maintain full control over your public profile.

+

Want to update or add a Gravatar?

Go to Gravatar.com to set up or change your avatar or profile. Your updates will appear here once saved!

diff --git a/theme/templates/account/settings.html b/theme/templates/account/settings.html index f014b4a..0b3ddbd 100644 --- a/theme/templates/account/settings.html +++ b/theme/templates/account/settings.html @@ -1,19 +1,31 @@ {% extends 'base.html' %} -{% load i18n %} +{% load i18n crispy_forms_tags %} {% block head_title %}{% trans "Settings" %}{% endblock %} {% block content %} -
+

{% trans "Settings" %}

-
-
- - {% trans "Profile" %} - - - {% trans "Friend Codes" %} - + + +
+
+ {% csrf_token %} + {{ form|crispy }} + +
+
+
+ {% trans "Sign Out" %} diff --git a/theme/templates/base.html b/theme/templates/base.html index e03166e..1dcca97 100644 --- a/theme/templates/base.html +++ b/theme/templates/base.html @@ -46,11 +46,13 @@ + + {% block css %}{% endblock %} {% block javascript_head %}{% endblock %} -
+