diff --git a/common/utils.py b/common/utils.py index 413eefc..45db583 100644 --- a/common/utils.py +++ b/common/utils.py @@ -1,10 +1,14 @@ import operator from dataclasses import dataclass from datetime import date -from functools import reduce +from functools import reduce, wraps from typing import Any, Callable, Generator, Literal, TypeVar +from urllib.parse import urlencode from django.db.models import Q +from django.http import HttpRequest +from django.shortcuts import redirect + def safe_division(numerator: int | float, denominator: int | float) -> int | float: """ @@ -40,7 +44,7 @@ def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> ob def truncate_(input_string: str, length: int = 30, ellipsis: str = "…") -> str: return ( - (f"{input_string[:length-len(ellipsis)].rstrip()}{ellipsis}") + (f"{input_string[: length - len(ellipsis)].rstrip()}{ellipsis}") if len(input_string) > length else input_string ) @@ -54,12 +58,12 @@ def truncate( raise ValueError("Length cannot be shorter than the length of endpart.") if len(input_string) > max_content_length: - return f"{input_string[:max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}" + return f"{input_string[: max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}" return ( f"{input_string}{endpart}" if len(input_string) + len(endpart) <= length - else f"{input_string[:length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}" + else f"{input_string[: length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}" ) @@ -128,3 +132,36 @@ def build_dynamic_filter( processed_filters, Q(), ) + + +def redirect_to(default_view: str, *default_args): + """ + A decorator that redirects the user back to the referring page or a default view if no 'next' parameter is provided. + + :param default_view: The name of the default view to redirect to if 'next' is missing. + :param default_args: Any arguments required for the default view. + """ + + def decorator(view_func): + @wraps(view_func) + def wrapped_view(request: HttpRequest, *args, **kwargs): + next_url = request.GET.get("next") + if not next_url: + from django.urls import ( + reverse, # Import inside function to avoid circular imports + ) + + next_url = reverse(default_view, args=default_args) + + response = view_func( + request, *args, **kwargs + ) # Execute the original view logic + return redirect(next_url) + + return wrapped_view + + return decorator + + +def add_next_param_to_url(url: str, nexturl: str) -> str: + return f"{url}?{urlencode({'next': nexturl})}" diff --git a/games/api.py b/games/api.py new file mode 100644 index 0000000..f9fea63 --- /dev/null +++ b/games/api.py @@ -0,0 +1,80 @@ +from datetime import date, datetime +from typing import List + +from django.shortcuts import get_object_or_404 +from django.utils.timezone import now as django_timezone_now +from ninja import Field, ModelSchema, NinjaAPI, Router, Schema + +from games.models import PlayEvent + +api = NinjaAPI() +playevent_router = Router() + +NOW_FACTORY = django_timezone_now + + +class PlayEventIn(Schema): + game_id: int + started: date | None = None + ended: date | None = None + note: str = "" + days_to_finish: int | None = None + + +class AutoPlayEventIn(ModelSchema): + class Meta: + model = PlayEvent + fields = ["game", "started", "ended", "note"] + + +class UpdatePlayEventIn(Schema): + started: date | None = None + ended: date | None = None + note: str = "" + + +class PlayEventOut(Schema): + id: int + game: str = Field(..., alias="game.name") + started: date | None = None + ended: date | None = None + days_to_finish: int | None = None + note: str = "" + updated_at: datetime + created_at: datetime + + +@playevent_router.get("/", response=List[PlayEventOut]) +def list_playevents(request): + return PlayEvent.objects.all() + + +@playevent_router.post("/", response={201: PlayEventOut}) +def create_playevent(request, payload: PlayEventIn): + playevent = PlayEvent.objects.create(**payload.dict()) + return playevent + + +@playevent_router.get("/{playevent_id}", response=PlayEventOut) +def get_playevent(request, playevent_id: int): + playevent = get_object_or_404(PlayEvent, id=playevent_id) + return playevent + + +@playevent_router.patch("/{playevent_id}", response=PlayEventOut) +def partial_update_playevent(request, playevent_id: int, payload: UpdatePlayEventIn): + playevent = get_object_or_404(PlayEvent, id=playevent_id) + for attr, value in payload.dict(exclude_unset=True).items(): + setattr(playevent, attr, value) + playevent.save() + return playevent + + +@playevent_router.delete("/{playevent_id}", response={204: None}) +def delete_playevent(request, playevent_id: int): + playevent = get_object_or_404(PlayEvent, id=playevent_id) + playevent.delete() + return 204, None + + +api.add_router("/playevent", playevent_router) diff --git a/games/apps.py b/games/apps.py index 4cd6420..db1faf5 100644 --- a/games/apps.py +++ b/games/apps.py @@ -1,9 +1,10 @@ -from datetime import timedelta +# from datetime import timedelta from django.apps import AppConfig from django.core.management import call_command from django.db.models.signals import post_migrate -from django.utils.timezone import now + +# from django.utils.timezone import now class GamesConfig(AppConfig): @@ -17,26 +18,26 @@ class GamesConfig(AppConfig): def schedule_tasks(sender, **kwargs): - from django_q.models import Schedule - from django_q.tasks import schedule + # from django_q.models import Schedule + # from django_q.tasks import schedule - if not Schedule.objects.filter(name="Update converted prices").exists(): - schedule( - "games.tasks.convert_prices", - name="Update converted prices", - schedule_type=Schedule.MINUTES, - next_run=now() + timedelta(seconds=30), - catchup=False, - ) + # if not Schedule.objects.filter(name="Update converted prices").exists(): + # schedule( + # "games.tasks.convert_prices", + # name="Update converted prices", + # schedule_type=Schedule.MINUTES, + # next_run=now() + timedelta(seconds=30), + # catchup=False, + # ) - if not Schedule.objects.filter(name="Update price per game").exists(): - schedule( - "games.tasks.calculate_price_per_game", - name="Update price per game", - schedule_type=Schedule.MINUTES, - next_run=now() + timedelta(seconds=30), - catchup=False, - ) + # if not Schedule.objects.filter(name="Update price per game").exists(): + # schedule( + # "games.tasks.calculate_price_per_game", + # name="Update price per game", + # schedule_type=Schedule.MINUTES, + # next_run=now() + timedelta(seconds=30), + # catchup=False, + # ) from games.models import ExchangeRate diff --git a/games/forms.py b/games/forms.py index 4620218..6f16bd2 100644 --- a/games/forms.py +++ b/games/forms.py @@ -2,7 +2,7 @@ from django import forms from django.urls import reverse from common.utils import safe_getattr -from games.models import Device, Game, Platform, Purchase, Session +from games.models import Device, Game, Platform, PlayEvent, Purchase, Session custom_date_widget = forms.DateInput(attrs={"type": "date"}) custom_datetime_widget = forms.DateTimeInput( @@ -103,8 +103,6 @@ class PurchaseForm(forms.ModelForm): widgets = { "date_purchased": custom_date_widget, "date_refunded": custom_date_widget, - "date_finished": custom_date_widget, - "date_dropped": custom_date_widget, } model = Purchase fields = [ @@ -112,8 +110,6 @@ class PurchaseForm(forms.ModelForm): "platform", "date_purchased", "date_refunded", - "date_finished", - "date_dropped", "infinite", "price", "price_currency", @@ -171,6 +167,7 @@ class GameForm(forms.ModelForm): "name", "sort_name", "platform", + "original_year_released", "year_released", "status", "mastered", @@ -195,3 +192,23 @@ class DeviceForm(forms.ModelForm): model = Device fields = ["name", "type"] widgets = {"name": autofocus_input_widget} + + +class PlayEventForm(forms.ModelForm): + game = GameModelChoiceField( + queryset=Game.objects.order_by("sort_name"), + widget=forms.Select(attrs={"autofocus": "autofocus"}), + ) + + class Meta: + model = PlayEvent + fields = [ + "game", + "started", + "ended", + "note", + ] + widgets = { + "started": custom_date_widget, + "ended": custom_date_widget, + } diff --git a/games/migrations/0008_game_original_year_released_gamestatuschange_and_more.py b/games/migrations/0008_game_original_year_released_gamestatuschange_and_more.py new file mode 100644 index 0000000..48c0840 --- /dev/null +++ b/games/migrations/0008_game_original_year_released_gamestatuschange_and_more.py @@ -0,0 +1,190 @@ +# Generated by Django 5.1.7 on 2025-03-19 13:11 + +import django.db.models.deletion +import django.db.models.expressions +from django.db import migrations, models +from django.db.models import F, Min + + +def copy_year_released(apps, schema_editor): + Game = apps.get_model("games", "Game") + Game.objects.update(original_year_released=F("year_released")) + + +def set_abandoned_status(apps, schema_editor): + Game = apps.get_model("games", "Game") + Game = apps.get_model("games", "Game") + PlayEvent = apps.get_model("games", "PlayEvent") + + Game.objects.filter(purchases__date_refunded__isnull=False).update(status="a") + Game.objects.filter(purchases__date_dropped__isnull=False).update(status="a") + + finished = Game.objects.filter(purchases__date_finished__isnull=False) + + for game in finished: + for purchase in game.purchases.all(): + first_session = game.sessions.filter( + timestamp_start__gte=purchase.date_purchased + ).aggregate(Min("timestamp_start"))["timestamp_start__min"] + first_session_date = first_session.date() if first_session else None + if purchase.date_finished: + play_event = PlayEvent( + game=game, + started=first_session_date + if first_session_date + else purchase.date_purchased, + ended=purchase.date_finished, + ) + play_event.save() + + +def create_game_status_changes(apps, schema_editor): + Game = apps.get_model("games", "Game") + GameStatusChange = apps.get_model("games", "GameStatusChange") + + # if game has any sessions, find the earliest session and create a status change from unplayed to played with that sessions's timestamp_start + for game in Game.objects.filter(sessions__isnull=False).distinct(): + if game.sessions.exists(): + earliest_session = game.sessions.earliest() + GameStatusChange.objects.create( + game=game, + old_status="u", + new_status="p", + timestamp=earliest_session.timestamp_start, + ) + + for game in Game.objects.filter(purchases__date_dropped__isnull=False): + GameStatusChange.objects.create( + game=game, + old_status="p", + new_status="a", + timestamp=game.purchases.first().date_dropped, + ) + + for game in Game.objects.filter(purchases__date_refunded__isnull=False): + GameStatusChange.objects.create( + game=game, + old_status="p", + new_status="a", + timestamp=game.purchases.first().date_refunded, + ) + + # check if game has any playevents, if so create a status change from current status to finished based on playevent's ended date + # consider only the first playevent + for game in Game.objects.filter(playevents__isnull=False): + first_playevent = game.playevents.first() + GameStatusChange.objects.create( + game=game, + old_status="p", + new_status="f", + timestamp=first_playevent.ended, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("games", "0007_game_updated_at"), + ] + + operations = [ + migrations.AddField( + model_name="game", + name="original_year_released", + field=models.IntegerField(blank=True, default=None, null=True), + ), + migrations.RunPython(copy_year_released), + migrations.CreateModel( + name="GameStatusChange", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "old_status", + models.CharField( + blank=True, + choices=[ + ("u", "Unplayed"), + ("p", "Played"), + ("f", "Finished"), + ("r", "Retired"), + ("a", "Abandoned"), + ], + max_length=1, + null=True, + ), + ), + ( + "new_status", + models.CharField( + choices=[ + ("u", "Unplayed"), + ("p", "Played"), + ("f", "Finished"), + ("r", "Retired"), + ("a", "Abandoned"), + ], + max_length=1, + ), + ), + ("timestamp", models.DateTimeField(null=True)), + ( + "game", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="status_changes", + to="games.game", + ), + ), + ], + options={ + "ordering": ["-timestamp"], + }, + ), + migrations.CreateModel( + name="PlayEvent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("started", models.DateField(blank=True, null=True)), + ("ended", models.DateField(blank=True, null=True)), + ( + "days_to_finish", + models.GeneratedField( + db_persist=True, + expression=django.db.models.expressions.RawSQL( + "\n COALESCE(\n CASE \n WHEN date(ended) = date(started) THEN 1\n ELSE julianday(ended) - julianday(started)\n END, 0\n )\n ", + [], + ), + output_field=models.IntegerField(), + ), + ), + ("note", models.CharField(blank=True, default="", max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "game", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="playevents", + to="games.game", + ), + ), + ], + ), + migrations.RunPython(set_abandoned_status), + migrations.RunPython(create_game_status_changes), + ] diff --git a/games/migrations/0009_remove_purchase_date_dropped_and_more.py b/games/migrations/0009_remove_purchase_date_dropped_and_more.py new file mode 100644 index 0000000..4ee2d17 --- /dev/null +++ b/games/migrations/0009_remove_purchase_date_dropped_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.7 on 2025-03-20 11:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0008_game_original_year_released_gamestatuschange_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='purchase', + name='date_dropped', + ), + migrations.RemoveField( + model_name='purchase', + name='date_finished', + ), + ] diff --git a/games/migrations/0010_remove_purchase_price_per_game.py b/games/migrations/0010_remove_purchase_price_per_game.py new file mode 100644 index 0000000..5befafc --- /dev/null +++ b/games/migrations/0010_remove_purchase_price_per_game.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.7 on 2025-03-22 17:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0009_remove_purchase_date_dropped_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='purchase', + name='price_per_game', + ), + ] diff --git a/games/migrations/0011_purchase_price_per_game.py b/games/migrations/0011_purchase_price_per_game.py new file mode 100644 index 0000000..ab9e6f2 --- /dev/null +++ b/games/migrations/0011_purchase_price_per_game.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.7 on 2025-03-22 17:46 + +import django.db.models.expressions +import django.db.models.functions.comparison +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0010_remove_purchase_price_per_game'), + ] + + operations = [ + migrations.AddField( + model_name='purchase', + name='price_per_game', + field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()), + ), + ] diff --git a/games/models.py b/games/models.py index 5132354..ccfcdaf 100644 --- a/games/models.py +++ b/games/models.py @@ -1,13 +1,20 @@ +import logging from datetime import timedelta +import requests from django.core.exceptions import ValidationError from django.db import models from django.db.models import F, Sum +from django.db.models.expressions import RawSQL +from django.db.models.fields.generated import GeneratedField +from django.db.models.functions import Coalesce from django.template.defaultfilters import floatformat, pluralize, slugify from django.utils import timezone from common.time import format_duration +logger = logging.getLogger("games") + class Game(models.Model): class Meta: @@ -16,6 +23,7 @@ class Game(models.Model): name = models.CharField(max_length=255) sort_name = models.CharField(max_length=255, blank=True, default="") year_released = models.IntegerField(null=True, blank=True, default=None) + original_year_released = models.IntegerField(null=True, blank=True, default=None) wikidata = models.CharField(max_length=50, blank=True, default="") platform = models.ForeignKey( "Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None @@ -55,6 +63,21 @@ class Game(models.Model): def __str__(self): return self.name + def finished(self): + return self.status == self.Status.FINISHED + + def abandoned(self): + return self.status == self.Status.ABANDONED + + def retired(self): + return self.status == self.Status.RETIRED + + def played(self): + return self.status == self.Status.PLAYED + + def unplayed(self): + return self.status == self.Status.UNPLAYED + def save(self, *args, **kwargs): if self.platform is None: self.platform = get_sentinel_platform() @@ -89,9 +112,6 @@ class PurchaseQueryset(models.QuerySet): def not_refunded(self): return self.filter(date_refunded__isnull=True) - def finished(self): - return self.filter(date_finished__isnull=False) - def games_only(self): return self.filter(type=Purchase.GAME) @@ -135,14 +155,22 @@ class Purchase(models.Model): ) date_purchased = models.DateField() date_refunded = models.DateField(blank=True, null=True) - date_finished = models.DateField(blank=True, null=True) - date_dropped = models.DateField(blank=True, null=True) + # move date_finished to PlayEvent model's Finished field + # also set Game's model Status field to Finished + # date_finished = models.DateField(blank=True, null=True) + # move date_dropped to Game model's field Status (Abandoned) + # date_dropped = models.DateField(blank=True, null=True) infinite = models.BooleanField(default=False) price = models.FloatField(default=0) price_currency = models.CharField(max_length=3, default="USD") converted_price = models.FloatField(null=True) converted_currency = models.CharField(max_length=3, blank=True, default="") - price_per_game = models.FloatField(null=True) + price_per_game = GeneratedField( + expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"), + output_field=models.FloatField(), + db_persist=True, + editable=False, + ) num_purchases = models.IntegerField(default=0) ownership_type = models.CharField( max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL @@ -198,6 +226,12 @@ class Purchase(models.Model): def is_game(self): return self.type == self.GAME + def price_or_currency_differ_from(self, purchase_to_compare): + return ( + self.price != purchase_to_compare.price + or self.price_currency != purchase_to_compare.price_currency + ) + def save(self, *args, **kwargs): if self.type != Purchase.GAME and not self.related_purchase: raise ValidationError( @@ -207,12 +241,15 @@ class Purchase(models.Model): # Retrieve the existing instance from the database existing_purchase = Purchase.objects.get(pk=self.pk) # If price has changed, reset converted fields - if ( - existing_purchase.price != self.price - or existing_purchase.price_currency != self.price_currency - ): - self.converted_price = None - self.converted_currency = "" + if existing_purchase.price_or_currency_differ_from(self): + from games.tasks import currency_to + + exchange_rate = get_or_create_rate( + self.price_currency, currency_to, self.date_purchased.year + ) + if exchange_rate: + self.converted_price = floatformat(self.price * exchange_rate, 0) + self.converted_currency = currency_to super().save(*args, **kwargs) @@ -351,3 +388,97 @@ class ExchangeRate(models.Model): def __str__(self): return f"{self.currency_from}/{self.currency_to} - {self.rate} ({self.year})" + + +def get_or_create_rate(currency_from: str, currency_to: str, year: int) -> float | None: + exchange_rate = None + result = ExchangeRate.objects.filter( + currency_from=currency_from, currency_to=currency_to, year=year + ) + if result: + exchange_rate = result[0].rate + else: + try: + # this API endpoint only accepts lowercase currency string + response = requests.get( + f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json" + ) + response.raise_for_status() + data = response.json() + currency_from_data = data.get(currency_from.lower()) + rate = currency_from_data.get(currency_to.lower()) + + if rate: + logger.info(f"[convert_prices]: Got {rate}, saving...") + exchange_rate = ExchangeRate.objects.create( + currency_from=currency_from, + currency_to=currency_to, + year=year, + rate=floatformat(rate, 2), + ) + exchange_rate = exchange_rate.rate + else: + logger.info("[convert_prices]: Could not get an exchange rate.") + except requests.RequestException as e: + logger.info( + f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}" + ) + return exchange_rate + + +class PlayEvent(models.Model): + game = models.ForeignKey(Game, related_name="playevents", on_delete=models.CASCADE) + started = models.DateField(null=True, blank=True) + ended = models.DateField(null=True, blank=True) + days_to_finish = GeneratedField( + # special cases: + # missing ended, started, or both = 0 + # same day = 1 day to finish + expression=RawSQL( + """ + COALESCE( + CASE + WHEN date(ended) = date(started) THEN 1 + ELSE julianday(ended) - julianday(started) + END, 0 + ) + """, + [], + ), + output_field=models.IntegerField(), + db_persist=True, + editable=False, + blank=True, + ) + note = models.CharField(max_length=255, blank=True, default="") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + +# class PlayMarker(models.Model): +# game = models.ForeignKey(Game, related_name="markers", on_delete=models.CASCADE) +# played_since = models.DurationField() +# played_total = models.DurationField() +# note = models.CharField(max_length=255) + + +class GameStatusChange(models.Model): + """ + Tracks changes to the status of a Game. + """ + + game = models.ForeignKey( + Game, on_delete=models.CASCADE, related_name="status_changes" + ) + old_status = models.CharField( + max_length=1, choices=Game.Status.choices, blank=True, null=True + ) + new_status = models.CharField(max_length=1, choices=Game.Status.choices) + timestamp = models.DateTimeField(null=True) + + def __str__(self): + return f"{self.game.name}: {self.old_status or 'None'} -> {self.new_status} at {self.timestamp}" + + class Meta: + ordering = ["-timestamp"] diff --git a/games/signals.py b/games/signals.py index 032dbcc..5d986d6 100644 --- a/games/signals.py +++ b/games/signals.py @@ -1,8 +1,8 @@ -from django.db.models.signals import m2m_changed +from django.db.models.signals import m2m_changed, pre_save from django.dispatch import receiver from django.utils.timezone import now -from games.models import Purchase +from games.models import Game, GameStatusChange, Purchase @receiver(m2m_changed, sender=Purchase.games.through) @@ -10,3 +10,31 @@ def update_num_purchases(sender, instance, **kwargs): instance.num_purchases = instance.games.count() instance.updated_at = now() instance.save(update_fields=["num_purchases"]) + + +@receiver(pre_save, sender=Game) +def game_status_changed(sender, instance, **kwargs): + """ + Signal handler to create a GameStatusChange record whenever a Game's status is updated. + """ + try: + old_instance = sender.objects.get(pk=instance.pk) + old_status = old_instance.status + print("Got old instance") + except sender.DoesNotExist: + # Handle the case where the instance was deleted before the signal was sent + print("Instance does not exist") + return + + if old_status != instance.status: + print("Status changed") + GameStatusChange.objects.create( + game=instance, + old_status=old_status, + new_status=instance.status, + timestamp=instance.updated_at, + ) + else: + print("Status not changed") + print(f"{old_instance.status}") + print(f"{instance.status}") diff --git a/games/static/base.css b/games/static/base.css index eee8f34..2665765 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -1303,6 +1303,10 @@ input:checked + .toggle-bg { left: -0.75rem; } +.-left-\[1px\] { + left: -1px; +} + .bottom-0 { bottom: 0px; } @@ -1347,6 +1351,10 @@ input:checked + .toggle-bg { top: 0.75rem; } +.top-\[100\%\] { + top: 100%; +} + .z-10 { z-index: 10; } @@ -1552,6 +1560,10 @@ input:checked + .toggle-bg { width: 6rem; } +.w-3 { + width: 0.75rem; +} + .w-4 { width: 1rem; } @@ -1576,6 +1588,10 @@ input:checked + .toggle-bg { width: 20rem; } +.w-auto { + width: auto; +} + .w-full { width: 100%; } @@ -1744,6 +1760,10 @@ input:checked + .toggle-bg { gap: 1.25rem; } +.gap-y-4 { + row-gap: 1rem; +} + .-space-x-px > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(-1px * var(--tw-space-x-reverse)); @@ -1819,6 +1839,11 @@ input:checked + .toggle-bg { border-radius: 0.75rem; } +.rounded-b-md { + border-bottom-right-radius: 0.375rem; + border-bottom-left-radius: 0.375rem; +} + .rounded-e-lg { border-start-end-radius: 0.5rem; border-end-end-radius: 0.5rem; @@ -1839,6 +1864,14 @@ input:checked + .toggle-bg { border-end-start-radius: 0.5rem; } +.rounded-tl-none { + border-top-left-radius: 0px; +} + +.rounded-tr-md { + border-top-right-radius: 0.375rem; +} + .border { border-width: 1px; } @@ -1847,6 +1880,18 @@ input:checked + .toggle-bg { border-width: 0px; } +.border-b { + border-bottom-width: 1px; +} + +.border-e { + border-inline-end-width: 1px; +} + +.border-t { + border-top-width: 1px; +} + .border-blue-600 { --tw-border-opacity: 1; border-color: rgb(28 100 242 / var(--tw-border-opacity)); @@ -1922,6 +1967,10 @@ input:checked + .toggle-bg { background-color: rgb(31 41 55 / var(--tw-bg-opacity)); } +.bg-gray-800\/20 { + background-color: rgb(31 41 55 / 0.2); +} + .bg-gray-900\/50 { background-color: rgb(17 24 39 / 0.5); } @@ -2087,6 +2136,10 @@ input:checked + .toggle-bg { vertical-align: top; } +.align-middle { + vertical-align: middle; +} + .font-mono { font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } @@ -2311,6 +2364,12 @@ input:checked + .toggle-bg { filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); } +.backdrop-blur-lg { + --tw-backdrop-blur: blur(16px); + -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); +} + .transition { transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; diff --git a/games/static/js/add_purchase.js b/games/static/js/add_purchase.js index 713a8d2..46497cd 100644 --- a/games/static/js/add_purchase.js +++ b/games/static/js/add_purchase.js @@ -21,11 +21,6 @@ function setupElementHandlers() { "#id_name", "#id_related_purchase", ]); - disableElementsWhenValueNotEqual( - "#id_type", - ["game", "dlc"], - ["#id_date_finished"] - ); } document.addEventListener("DOMContentLoaded", setupElementHandlers); diff --git a/games/templates/cotton/layouts/base.html b/games/templates/cotton/layouts/base.html index 4755b4b..b438e6a 100644 --- a/games/templates/cotton/layouts/base.html +++ b/games/templates/cotton/layouts/base.html @@ -12,6 +12,10 @@ {% django_htmx_script %} + {% comment %} + {% endcomment %} + + + + + + + + + + + +