import hashlib from functools import lru_cache from typing import Any from django.middleware.csrf import get_token from django.template.defaultfilters import floatformat from django.templatetags.static import static from django.urls import reverse from django.utils.html import conditional_escape, escape from django.utils.safestring import SafeText, mark_safe from common.icons import get_icon from common.utils import truncate from games.models import Game, Purchase, Session HTMLAttribute = tuple[str, str | int | bool] HTMLTag = str _COLOR_CLASSES = { "blue": "text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium", "red": "bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white", "gray": "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border", "green": "bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white", } _SIZE_CLASSES = { "xs": "px-3 py-2 text-xs shadow-xs", "sm": "px-3 py-2 text-sm", "base": "px-5 py-2.5 text-sm", "lg": "px-5 py-3 text-base", "xl": "px-6 py-3.5 text-base", } @lru_cache(maxsize=4096) def _render_element( tag_name: str, attrs_key: tuple[tuple[str, str], ...], children_key: tuple[tuple[str, bool], ...], ) -> str: """Pure, memoized HTML builder behind `Component`. Inputs are fully hashable and fully determine the output, so identical elements are rendered once. `attrs_key` is (name, stringified value) pairs (attribute values are always escaped). `children_key` is (child, is_safe) pairs: SafeText children pass through, plain strings are escaped. The `is_safe` flag is part of the key on purpose — otherwise a safe ``""`` and an unsafe ``""`` (equal as strings) would collide and one would render with the wrong escaping. """ children_blob = "\n".join( child if is_safe else escape(child) for child, is_safe in children_key ) if attrs_key: attributes_blob = " " + " ".join( f'{name}="{escape(value)}"' for name, value in attrs_key ) else: attributes_blob = "" return f"<{tag_name}{attributes_blob}>{children_blob}" def Component( attributes: list[HTMLAttribute] | None = None, children: list[HTMLTag] | HTMLTag | None = None, tag_name: str = "", ) -> SafeText: """Render an HTML element. Attribute values are always escaped; children are escaped unless they are `SafeText` (so nested components pass through), preventing accidental HTML injection. Rendering is memoized via `_render_element`.""" attributes = attributes or [] children = children or [] if not tag_name: raise ValueError("tag_name is required.") if isinstance(children, str): children = [children] attrs_key = tuple((name, str(value)) for name, value in attributes) children_key = tuple((child, isinstance(child, SafeText)) for child in children) return mark_safe(_render_element(tag_name, attrs_key, children_key)) def randomid(seed: str = "", content: str = "", length: int = 10) -> str: if not seed and not content: return seed hash_input = f"{seed}:{content}" if seed else content content_hash = hashlib.sha1(hash_input.encode()).hexdigest() base = ( content_hash[:length] if not seed else content_hash[: max(0, length - len(seed))] ) return seed + base def _popover_html( id: str, popover_content: str, wrapped_content: str = "", wrapped_classes: str = "", slot: str = "", ) -> SafeText: """Generate popover HTML using Component(tag_name=...). Single source of truth for popover HTML structure. Used by Popover() and the python_popover template tag bridge. """ display_content = wrapped_content if wrapped_content else slot span = Component( tag_name="span", attributes=[ ("data-popover-target", id), ("class", wrapped_classes), ], children=[display_content] if display_content else [], ) popover_tooltip_class = ( "absolute z-10 invisible inline-block text-sm text-white " "transition-opacity duration-300 bg-white border border-purple-200 " "rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 " "dark:bg-purple-800" ) div = Component( tag_name="div", attributes=[ ("data-popover", ""), ("id", id), ("role", "tooltip"), ("class", popover_tooltip_class), ], children=[ Component( tag_name="div", attributes=[("class", "px-3 py-2")], children=[popover_content], ), Component(tag_name="div", attributes=[("data-popper-arrow", "")]), mark_safe( # nosec — intentional HTML comment for Tailwind JIT "" ), Component( tag_name="span", attributes=[("class", "hidden decoration-dotted")], ), ], ) return mark_safe(span + "\n" + div) def Popover( popover_content: str, wrapped_content: str = "", wrapped_classes: str = "", children: list[HTMLTag] | None = None, attributes: list[HTMLAttribute] | None = None, id: str = "", ) -> str: children = children or [] if not wrapped_content and not children: raise ValueError("One of wrapped_content or children is required.") if not id: id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}") slot = mark_safe("\n".join(children)) return _popover_html( id=id, popover_content=popover_content, wrapped_content=wrapped_content, wrapped_classes=wrapped_classes, slot=slot, ) def PopoverTruncated( input_string: str, popover_content: str = "", popover_if_not_truncated: bool = False, length: int = 30, ellipsis: str = "…", endpart: str = "", ) -> str: """ Returns `input_string` truncated after `length` of characters and displays the untruncated text in a popover HTML element. The truncated text ends in `ellipsis`, and optionally an always-visible `endpart` can be specified. `popover_content` can be specified if: 1. It needs to be always displayed regardless if text is truncated. 2. It needs to differ from `input_string`. """ if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string: return Popover( wrapped_content=truncated, popover_content=popover_content if popover_content else input_string, ) else: if popover_content and popover_if_not_truncated: return Popover( wrapped_content=input_string, popover_content=popover_content if popover_content else "", ) else: return input_string def A( attributes: list[HTMLAttribute] | None = None, children: list[HTMLTag] | HTMLTag | None = None, url_name: str | None = None, href: str | None = None, ) -> SafeText: """ Returns an anchor tag. Accepts one of two mutually-exclusive URL specifications: - url_name: URL pattern name, resolved via reverse() - href: Literal path string passed through as-is """ attributes = attributes or [] children = children or [] if url_name is not None and href is not None: raise ValueError("Provide exactly one of 'url_name' or 'href', not both.") additional_attributes = [] if url_name is not None: additional_attributes = [("href", reverse(url_name))] elif href is not None: additional_attributes = [("href", href)] return Component( tag_name="a", attributes=attributes + additional_attributes, children=children ) def Button( attributes: list[HTMLAttribute] | None = None, children: list[HTMLTag] | HTMLTag | None = None, size: str = "base", icon: bool = False, color: str = "blue", type: str = "button", hx_get: str = "", hx_target: str = "", hx_swap: str = "", title: str = "", onclick: str = "", name: str = "", ) -> SafeText: attributes = attributes or [] children = children or [] # Separate custom class from other generic attributes custom_class = "" other_attrs: list[HTMLAttribute] = [] for attr_name, attr_value in attributes: if attr_name == "class": custom_class = str(attr_value) else: other_attrs.append((attr_name, attr_value)) # Build class string: custom class first, then base, color, size, icon class_parts: list[str] = [] if custom_class: class_parts.append(custom_class) class_parts.append( "hover:cursor-pointer leading-5 focus:outline-hidden focus:ring-4 " "font-medium mb-2 me-2 rounded-base" ) class_parts.append(_COLOR_CLASSES.get(color, _COLOR_CLASSES["blue"])) class_parts.append(_SIZE_CLASSES.get(size, _SIZE_CLASSES["base"])) if icon: class_parts.append("inline-flex text-center items-center gap-2") # Build the full attribute list for the button tag button_attrs: list[HTMLAttribute] = [ ("type", type), ("class", " ".join(class_parts)), ] if hx_get: button_attrs.append(("hx-get", hx_get)) if hx_target: button_attrs.append(("hx-target", hx_target)) if hx_swap: button_attrs.append(("hx-swap", hx_swap)) if title: button_attrs.append(("title", title)) if onclick: button_attrs.append(("onclick", onclick)) if name: button_attrs.append(("name", name)) button_attrs.extend(other_attrs) return Component( tag_name="button", attributes=button_attrs, children=children, ) _GROUP_BUTTON_COLORS = { "gray": ( "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " "border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 " "focus:ring-2 focus:ring-blue-700 focus:text-blue-700 " "dark:bg-gray-800 dark:border-gray-700 dark:text-white " "dark:hover:text-white dark:hover:bg-gray-700 " "dark:focus:ring-blue-500 dark:focus:text-white" ), "red": ( "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " "border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 " "focus:ring-2 focus:ring-blue-700 focus:text-blue-700 " "dark:bg-gray-800 dark:border-gray-700 dark:text-white " "dark:hover:text-white dark:hover:border-red-700 " "dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white" ), "green": ( "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " "border-gray-200 hover:bg-green-500 hover:border-green-600 " "hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 " "focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 " "dark:text-white dark:hover:text-white dark:hover:border-green-700 " "dark:hover:bg-green-600 dark:focus:ring-green-500 " "dark:focus:text-white" ), } def _button_group_button( href: str, slot: str, color: str = "gray", title: str = "", hx_get: str = "", hx_target: str = "", ) -> SafeText: """Generate a single button-group button (inner " "" )