from typing import Any from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator from django.db.models import Q from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.middleware.csrf import get_token from django.shortcuts import get_object_or_404, redirect from django.template.defaultfilters import date as date_filter from django.urls import reverse from django.utils.safestring import SafeText from common.components import ( Fragment, H1, A, AddForm, Button, ButtonGroup, CsrfInput, Div, Element, FilterBar, GameStatus, GameStatusSelector, Icon, LinkedPurchase, Modal, ModuleScript, NameWithIcon, Node, Popover, PopoverTruncated, PurchasePrice, Safe, SearchField, SimpleTable, Ul, paginated_table_content, ) from common.components.primitives import Li, P, Span, Strong from common.layout import render_page from common.time import ( dateformat, format_duration, local_strftime, timeformat, ) from common.utils import build_dynamic_filter, paginate, safe_division, truncate from games.filters import parse_game_filter from games.forms import GameForm from games.models import Game from games.views.general import use_custom_redirect from games.views.playevent import create_playevent_tabledata @login_required def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse: games = Game.objects.order_by("-created_at") # ── Structured filter (Stash-style JSON) ── filter_json = request.GET.get("filter", "") if filter_json: game_filter = parse_game_filter(filter_json) if game_filter is not None: games = games.filter(game_filter.to_q()) else: # ── Legacy free-text search ── search_string = request.GET.get("search_string", search_string) if search_string != "": filters = [ Q(name__icontains=search_string), Q(sort_name__icontains=search_string), Q(platform__name__icontains=search_string), ] try: year_value = int(search_string) except ValueError: year_value = None if year_value: filters.append(Q(year_released=year_value)) search_string_parts = search_string.split() if len(search_string_parts) == 1: if search_string.title() in Game.Status.labels: search_status = Game.Status[search_string.upper()] filters.append(Q(status=search_status)) games = games.filter(build_dynamic_filter(filters, "|")) games, page_obj, elided_page_range = paginate(request, games) data = { "header_action": Div( children=[ SearchField(search_string=search_string), A([], Button([], "Add game"), url_name="games:add_game"), ], attributes=[("class", "flex justify-between")], ), "columns": [ "Name", "Sort Name", "Year", "Status", "Wikidata", "Created", "Actions", ], "rows": [ [ NameWithIcon(game=game), PopoverTruncated( game.sort_name if game.sort_name is not None and game.name != game.sort_name else "(identical)" ), game.year_released, GameStatusSelector(game, Game.Status.choices, get_token(request)), game.wikidata, local_strftime(game.created_at, dateformat), ButtonGroup( [ { "href": reverse("games:edit_game", args=[game.pk]), "slot": Icon("edit"), "color": "gray", }, { "href": reverse("games:delete_game", args=[game.pk]), "slot": Icon("delete"), "color": "red", }, ] ), ] for game in games ], } content = paginated_table_content( data, page_obj=page_obj, elided_page_range=elided_page_range, request=request, ) # Prepend the filter bar above the table filter_bar = FilterBar( filter_json=filter_json, preset_list_url=reverse("games:list_presets"), preset_save_url=reverse("games:save_preset"), ) content = Fragment(filter_bar, content) return render_page( request, content, title="Manage games", ) @login_required def add_game(request: HttpRequest) -> HttpResponse: form = GameForm(request.POST or None) if form.is_valid(): game = form.save() if "submit_and_redirect" in request.POST: return HttpResponseRedirect( reverse("games:add_purchase_for_game", kwargs={"game_id": game.id}) ) else: return redirect("games:list_games") return render_page( request, AddForm( form, request=request, additional_row=Button( [], "Submit & Create Purchase", color="gray", type="submit", name="submit_and_redirect", ), ), title="Add New Game", scripts=ModuleScript("search_select.js") + ModuleScript("add_game.js"), ) def _delete_game_confirmation_modal( game: Game, session_count: int, purchase_count: int, playevent_count: int, request: HttpRequest, ) -> SafeText: data_items = [] if session_count: data_items.append(Li(children=[f"{session_count} session(s)"])) if purchase_count: data_items.append(Li(children=[f"{purchase_count} purchase(s)"])) if playevent_count: data_items.append(Li(children=[f"{playevent_count} play event(s)"])) if not (session_count or purchase_count or playevent_count): data_items.append(Li(children=["No associated data"])) form = Element( "form", attributes=[ ("hx-post", reverse("games:delete_game", args=[game.id])), ("hx-replace-url", "true"), ("hx-target", "#main-container"), ("hx-select", "#main-container"), ("hx-swap", "outerHTML"), ], children=[ CsrfInput(request), P( attributes=[ ( "class", "dark:text-white text-center mt-3 text-sm text-gray-600 " "dark:text-gray-400", ) ], children=[ "This will permanently delete this game and all associated data:" ], ), Ul( attributes=[ ( "class", "dark:text-white text-center mt-1 text-sm text-gray-600 " "dark:text-gray-400 list-disc list-inside", ) ], children=data_items, ), P( attributes=[ ( "class", "dark:text-white text-center mt-3 text-sm font-medium " "text-red-600 dark:text-red-400", ) ], children=["This action cannot be undone."], ), Div( [("class", "items-center mt-5")], [ Button( [("class", "w-full")], "Delete", color="red", size="lg", type="submit", ), Button( [("class", "mt-0 w-full")], "Cancel", color="gray", size="base", onclick=( "this.closest('#delete-game-confirmation-modal').remove()" ), ), ], ), ], ) return Modal( "delete-game-confirmation-modal", children=[ P( attributes=[ ( "class", "text-2xl leading-6 font-medium dark:text-white text-center", ) ], children=["Delete Game"], ), P( attributes=[("class", "dark:text-white text-center mt-5")], children=[ "Are you sure you want to delete ", Strong(children=[game.name]), "?", ], ), form, ], ) @login_required def delete_game_confirmation(request: HttpRequest, game_id: int) -> HttpResponse: game = get_object_or_404(Game, id=game_id) return HttpResponse( _delete_game_confirmation_modal( game, game.sessions.count(), game.purchases.count(), game.playevents.count(), request, ) ) @login_required def delete_game(request: HttpRequest, game_id: int) -> HttpResponse: game = get_object_or_404(Game, id=game_id) game.delete() return redirect("games:list_sessions") @login_required @use_custom_redirect def edit_game(request: HttpRequest, game_id: int) -> HttpResponse: purchase = get_object_or_404(Game, id=game_id) form = GameForm(request.POST or None, instance=purchase) if form.is_valid(): form.save() return redirect("games:list_sessions") return render_page( request, AddForm(form, request=request), title="Edit Game", scripts=ModuleScript("search_select.js"), ) # --- view_game content builders ------------------------------------------- _STAT_SVGS = { "hours": '', "sessions": '', "average": '', "playrange": '', } _PLAYED_BTN = ( "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 " "hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 " "dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer" ) _PLAYED_MENU = ( "absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium " "bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border " "border-gray-200 dark:border-gray-700" ) def _played_row(game: Game, request: HttpRequest) -> Node: """'Played N times' control as a custom element (ts/elements/play-event-row.ts).""" from common.components import Element, custom_element from common.components.custom_elements import PlayEventRowProps, _PlayEventRow played = game.playevents.count() count_button = A(href=reverse("games:add_playevent"))[ Element( "button", [("type", "button"), ("class", _PLAYED_BTN + " rounded-s-lg")], [Span(data_count="")[str(played)], " times"], ) ] menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[ Ul()[ Li(attributes=[("class", "px-4 py-2")])[ A(href=reverse("games:add_playevent_for_game", args=[game.id]))[ "Add playthrough..." ] ], Li(attributes=[("class", "px-4 py-2 cursor-pointer")])[ Element( "button", [("type", "button"), ("data-add-play", "")], children=["Played times +1"], ) ], ] ] toggle = Element( "button", [ ("type", "button"), ("data-toggle", ""), ("class", _PLAYED_BTN + " rounded-e-lg"), ], [Icon("arrowdown")], ) # Menu is a SIBLING of the toggle (not nested inside it): a