diff --git a/common/components.py b/common/components.py index d1130d7..f16e0ba 100644 --- a/common/components.py +++ b/common/components.py @@ -4,7 +4,9 @@ from typing import Any, Callable from django.template.loader import render_to_string from django.urls import NoReverseMatch, reverse -from django.utils.safestring import mark_safe +from django.utils.safestring import SafeText, mark_safe + +from common.utils import truncate HTMLAttribute = tuple[str, str | int | bool] HTMLTag = str @@ -65,6 +67,13 @@ def Popover( ) +def PopoverTruncated(input_string: str) -> str: + if (truncated := truncate(input_string)) != input_string: + return Popover(wrapped_content=truncated, popover_content=input_string) + else: + return input_string + + def A( attributes: list[HTMLAttribute] = [], children: list[HTMLTag] | HTMLTag = [], @@ -120,3 +129,39 @@ def Icon( attributes: list[HTMLAttribute] = [], ): return Component(template=f"cotton/icon/{name}.html", attributes=attributes) + + +def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeText: + link = reverse("view_game", args=[int(game_id)]) + a_content = Div( + [("class", "inline-flex gap-2 items-center")], + [ + Icon( + platform.icon, + [("title", platform.name)], + ), + PopoverTruncated(name), + ], + ) + + return mark_safe( + A( + url=link, + children=[a_content], + ), + ) + + +def NameWithPlatformIcon(name: str, platform: str) -> SafeText: + content = Div( + [("class", "inline-flex gap-2 items-center")], + [ + Icon( + platform.icon, + [("title", platform.name)], + ), + PopoverTruncated(name), + ], + ) + + return mark_safe(content) diff --git a/common/utils.py b/common/utils.py index 160ab21..15d117c 100644 --- a/common/utils.py +++ b/common/utils.py @@ -1,8 +1,6 @@ from datetime import date from typing import Any, Generator, TypeVar -from common.components import Popover - def safe_division(numerator: int | float, denominator: int | float) -> int | float: """ @@ -44,13 +42,6 @@ def truncate(input_string: str, length: int = 30, ellipsis: str = "…") -> str: ) -def truncate_with_popover(input_string: str) -> str: - if (truncated := truncate(input_string)) != input_string: - return Popover(wrapped_content=truncated, popover_content=input_string) - else: - return input_string - - T = TypeVar("T", str, int, date) diff --git a/games/forms.py b/games/forms.py index 979d990..10413bf 100644 --- a/games/forms.py +++ b/games/forms.py @@ -164,7 +164,11 @@ class GameForm(forms.ModelForm): class PlatformForm(forms.ModelForm): class Meta: model = Platform - fields = ["name", "group"] + fields = [ + "name", + "icon", + "group", + ] widgets = {"name": autofocus_input_widget} diff --git a/games/migrations/0037_platform_icon.py b/games/migrations/0037_platform_icon.py new file mode 100644 index 0000000..7b174df --- /dev/null +++ b/games/migrations/0037_platform_icon.py @@ -0,0 +1,26 @@ +# Generated by Django 5.1.1 on 2024-09-14 07:05 + +from django.db import migrations, models +from django.utils.text import slugify + + +def update_empty_icons(apps, schema_editor): + Platform = apps.get_model("games", "Platform") + for platform in Platform.objects.filter(icon=""): + platform.icon = slugify(platform.name) + platform.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("games", "0036_alter_edition_platform"), + ] + + operations = [ + migrations.AddField( + model_name="platform", + name="icon", + field=models.SlugField(blank=True), + ), + migrations.RunPython(update_empty_icons), + ] diff --git a/games/models.py b/games/models.py index 15d6dd1..165b664 100644 --- a/games/models.py +++ b/games/models.py @@ -3,6 +3,7 @@ from datetime import timedelta from django.core.exceptions import ValidationError from django.db import models from django.db.models import F, Sum +from django.template.defaultfilters import slugify from django.utils import timezone from common.time import format_duration @@ -25,11 +26,17 @@ class Game(models.Model): class Platform(models.Model): name = models.CharField(max_length=255) group = models.CharField(max_length=255, null=True, blank=True, default=None) + icon = models.SlugField(blank=True) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return self.name + def save(self, *args, **kwargs): + if not self.icon: + self.icon = slugify(self.name) + super().save(*args, **kwargs) + class Edition(models.Model): class Meta: diff --git a/games/static/base.css b/games/static/base.css index cc9bf9d..139dea8 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -3207,3 +3207,7 @@ textarea:disabled:is(.dark *) { .\[\&_h1\]\:mb-2 h1 { margin-bottom: 0.5rem; } + +.\[\&_td\:last-child\]\:text-right td:last-child { + text-align: right; +} diff --git a/games/templates/cotton/icon/ps4.html b/games/templates/cotton/icon/ps4.html index 95e750c..62149ad 100644 --- a/games/templates/cotton/icon/ps4.html +++ b/games/templates/cotton/icon/ps4.html @@ -1,4 +1,5 @@ - + + M 1 19 A 1.0001 1.0001 0 1 0 1 21 L 12.5 21 C 13.340812 21 14 21.659188 14 22.5 C 14 23.340812 13.340812 24 12.5 24 L 3 24 C 1.3550302 24 0 25.35503 0 27 L 0 30 A 1.0001 1.0001 0 1 0 2 30 L 2 27 C 2 26.43497 2.4349698 26 3 26 L 12.5 26 C 14.28508 26 15.719786 24.619005 15.921875 22.884766 A 1.0001 1.0001 0 0 0 16 22.5 C 16 20.578812 14.421188 19 12.5 19 L 1 19 z M 26 19 C 24.35503 19 23 20.35503 23 22 L 23 28 C 23 28.56503 22.56503 29 22 29 L 16 29 A 1.0001 1.0001 0 1 0 16 31 L 22 31 C 23.64497 31 25 29.64497 25 28 L 25 22 C 25 21.43497 25.43497 21 26 21 L 32 21 A 1.0001 1.0001 0 1 0 32 19 L 26 19 z M 46.970703 19 A 1.0001 1.0001 0 0 0 46.503906 19.130859 L 32.503906 27.130859 A 1.0001 1.0001 0 0 0 33 29 L 46 29 L 46 30 A 1.0001 1.0001 0 1 0 48 30 L 48 29 L 49 29 A 1.0001 1.0001 0 1 0 49 27 L 48 27 L 48 20 A 1.0001 1.0001 0 0 0 46.970703 19 z M 46 21.724609 L 46 27 L 36.767578 27 L 46 21.724609 z diff --git a/games/templates/cotton/table_row.html b/games/templates/cotton/table_row.html index c2d9c7d..b2925d0 100644 --- a/games/templates/cotton/table_row.html +++ b/games/templates/cotton/table_row.html @@ -1,4 +1,4 @@ - + {% if slot %} {{ slot }} {% else %} diff --git a/games/views/edition.py b/games/views/edition.py index 734ce3b..5fbf323 100644 --- a/games/views/edition.py +++ b/games/views/edition.py @@ -7,9 +7,14 @@ from django.shortcuts import get_object_or_404, redirect, render from django.template.loader import render_to_string from django.urls import reverse -from common.components import A, Button, Icon +from common.components import ( + A, + Button, + Icon, + LinkedNameWithPlatformIcon, + PopoverTruncated, +) from common.time import dateformat, local_strftime -from common.utils import truncate_with_popover from games.forms import EditionForm from games.models import Edition, Game @@ -50,30 +55,22 @@ def list_editions(request: HttpRequest) -> HttpResponse: ], "rows": [ [ - A( - [ - ( - "href", - reverse( - "view_game", - args=[edition.game.pk], - ), - ) - ], - truncate_with_popover(edition.game.name), + LinkedNameWithPlatformIcon( + name=edition.name, + game_id=edition.game.id, + platform=edition.platform, ), - truncate_with_popover( + PopoverTruncated( edition.name if edition.game.name != edition.name else "(identical)" ), - truncate_with_popover( + PopoverTruncated( edition.sort_name if edition.sort_name is not None and edition.game.name != edition.sort_name else "(identical)" ), - truncate_with_popover(str(edition.platform)), edition.year_released, edition.wikidata, local_strftime(edition.created_at, dateformat), diff --git a/games/views/game.py b/games/views/game.py index 8ddf45a..0f358fe 100644 --- a/games/views/game.py +++ b/games/views/game.py @@ -8,7 +8,15 @@ from django.shortcuts import get_object_or_404, redirect, render from django.template.loader import render_to_string from django.urls import reverse -from common.components import A, Button, Div, Icon, Popover +from common.components import ( + A, + Button, + Div, + Icon, + NameWithPlatformIcon, + Popover, + PopoverTruncated, +) from common.time import ( dateformat, durationformat, @@ -17,7 +25,7 @@ from common.time import ( local_strftime, timeformat, ) -from common.utils import safe_division, truncate, truncate_with_popover +from common.utils import safe_division, truncate from games.forms import GameForm from games.models import Edition, Game, Purchase, Session from games.views.general import use_custom_redirect @@ -67,9 +75,9 @@ def list_games(request: HttpRequest) -> HttpResponse: ), ) ], - truncate_with_popover(game.name), + PopoverTruncated(game.name), ), - truncate_with_popover( + PopoverTruncated( game.sort_name if game.sort_name is not None and game.name != game.sort_name else "(identical)" @@ -197,14 +205,15 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: edition_data: dict[str, Any] = { "columns": [ "Name", - "Platform", "Year Released", "Actions", ], "rows": [ [ - edition.name, - Icon(str(edition.platform).lower().replace(".", "")), + NameWithPlatformIcon( + name=edition.name, + platform=edition.platform, + ), edition.year_released, render_to_string( "cotton/button_group.html", @@ -232,7 +241,10 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: "columns": ["Name", "Type", "Date", "Price", "Actions"], "rows": [ [ - purchase.name if purchase.name else purchase.edition.name, + NameWithPlatformIcon( + name=purchase.name if purchase.name else purchase.edition.name, + platform=purchase.platform, + ), purchase.get_type_display(), purchase.date_purchased.strftime(dateformat), f"{purchase.price} {purchase.price_currency}", @@ -301,9 +313,15 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse: ), ], ), - "columns": ["Date", "Duration", "Actions"], + "columns": ["Edition", "Date", "Duration", "Actions"], "rows": [ [ + NameWithPlatformIcon( + name=session.purchase.name + if session.purchase.name + else session.purchase.edition.name, + platform=session.purchase.platform, + ), f"{local_strftime(session.timestamp_start)}{f" — {session.timestamp_end.strftime(timeformat)}" if session.timestamp_end else ""}", ( format_duration(session.duration_calculated, durationformat) diff --git a/games/views/platform.py b/games/views/platform.py index 88f4ef1..08ac121 100644 --- a/games/views/platform.py +++ b/games/views/platform.py @@ -40,6 +40,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse: "header_action": A([], Button([], "Add platform"), url="add_platform"), "columns": [ "Name", + "Icon", "Group", "Created", "Actions", @@ -47,6 +48,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse: "rows": [ [ platform.name, + Icon(platform.icon), platform.group, local_strftime(platform.created_at, dateformat), render_to_string( diff --git a/games/views/purchase.py b/games/views/purchase.py index 217e7a4..76be33a 100644 --- a/games/views/purchase.py +++ b/games/views/purchase.py @@ -13,9 +13,8 @@ from django.template.loader import render_to_string from django.urls import reverse from django.utils import timezone -from common.components import A, Button, Div, Icon +from common.components import A, Button, Icon, LinkedNameWithPlatformIcon from common.time import dateformat -from common.utils import truncate_with_popover from games.forms import PurchaseForm from games.models import Edition, Purchase from games.views.general import use_custom_redirect @@ -60,35 +59,10 @@ def list_purchases(request: HttpRequest) -> HttpResponse: ], "rows": [ [ - A( - [ - ( - "href", - reverse( - "view_game", - args=[purchase.edition.game.pk], - ), - ), - ], - Div( - attributes=[("class", "inline-flex gap-2 items-center")], - children=[ - Icon( - str(purchase.platform) - .lower() - .translate( - str(purchase.platform) - .lower() - .maketrans("", "", ". /()") - ) - ), - truncate_with_popover( - purchase.edition.game.name - if purchase.type == "game" - else f"{purchase.edition.game.name} ({purchase.name})" - ), - ], - ), + LinkedNameWithPlatformIcon( + name=purchase.edition.name, + game_id=purchase.edition.game.pk, + platform=purchase.platform, ), purchase.get_type_display(), purchase.price, diff --git a/games/views/session.py b/games/views/session.py index 30c7143..3f12724 100644 --- a/games/views/session.py +++ b/games/views/session.py @@ -8,7 +8,7 @@ from django.template.loader import render_to_string from django.urls import reverse from django.utils import timezone -from common.components import A, Button, Div, Icon, Popover +from common.components import A, Button, Div, Icon, LinkedNameWithPlatformIcon, Popover from common.time import ( dateformat, durationformat, @@ -17,7 +17,7 @@ from common.time import ( local_strftime, timeformat, ) -from common.utils import truncate, truncate_with_popover +from common.utils import truncate from games.forms import SessionForm from games.models import Purchase, Session from games.views.general import use_custom_redirect @@ -91,12 +91,10 @@ def list_sessions(request: HttpRequest) -> HttpResponse: ], "rows": [ [ - A( - children=truncate_with_popover(session.purchase.edition.name), - url=reverse( - "view_game", - args=[session.purchase.edition.game.pk], - ), + LinkedNameWithPlatformIcon( + name=session.purchase.edition.name, + game_id=session.purchase.edition.game.pk, + platform=session.purchase.platform, ), f"{local_strftime(session.timestamp_start)}{f" — {local_strftime(session.timestamp_end, timeformat)}" if session.timestamp_end else ""}", ( @@ -238,6 +236,14 @@ def end_session( return redirect("list_sessions") +@login_required +def delete_session(request: HttpRequest, session_id: int = 0) -> HttpResponse: + session = get_object_or_404(Session, id=session_id) + session.delete() + return redirect("list_sessions") + return redirect("list_sessions") + + @login_required def delete_session(request: HttpRequest, session_id: int = 0) -> HttpResponse: session = get_object_or_404(Session, id=session_id)