2364d868fa
Two visual regressions from the custom-element port: 1. The played-row nested its dropdown menu (which contains <button> options) inside the toggle <button>. A <button> may not contain another <button>; the HTML parser force-closes the toggle on the nested button, and the source's explicit </div> tags then close ancestors early — ejecting the Purchases/Sessions/etc. sections out of the centered max-w container (they rendered full-width). Make the menu a sibling of the toggle, wrapped in a relative div so it still anchors under the toggle. 2. Both selector toggles dropped the original `flex flex-row gap-4 justify-between items-center` wrapper around their content, so the chevron stacked under the label (the GameStatus label is a display:flex block). Restore the wrapper — chevron sits on the right with proper spacing again. Verified by screenshot: sections back inside the centered container; both dropdowns render correctly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
304 lines
9.0 KiB
Python
304 lines
9.0 KiB
Python
"""Domain components for games / purchases / sessions."""
|
|
|
|
from typing import Any
|
|
|
|
from django.template.defaultfilters import floatformat
|
|
from django.urls import reverse
|
|
|
|
from common.components.core import Children, Node, Safe, as_children
|
|
from common.components.primitives import (
|
|
A,
|
|
Div,
|
|
Icon,
|
|
Popover,
|
|
PopoverTruncated,
|
|
Span,
|
|
)
|
|
from games.models import Game, Purchase, Session
|
|
|
|
|
|
def GameLink(
|
|
game_id: int,
|
|
name: str = "",
|
|
children: Children = None,
|
|
) -> Node:
|
|
"""Link to a game's detail page. Uses children (slot) if provided, otherwise name."""
|
|
from django.urls import reverse
|
|
|
|
display = as_children(children) or [name]
|
|
link = reverse("games:view_game", args=[game_id])
|
|
|
|
return Span(
|
|
attributes=[("class", "truncate-container")],
|
|
children=[
|
|
A(
|
|
href=link,
|
|
attributes=[
|
|
("class", "underline decoration-slate-500 sm:decoration-2"),
|
|
],
|
|
children=display,
|
|
),
|
|
],
|
|
)
|
|
|
|
|
|
_STATUS_COLORS = {
|
|
"u": "bg-gray-500",
|
|
"p": "bg-orange-400",
|
|
"f": "bg-green-500",
|
|
"a": "bg-red-500",
|
|
"r": "bg-purple-500",
|
|
}
|
|
|
|
|
|
def GameStatus(
|
|
children: Children = None,
|
|
status: str = "u",
|
|
display: str = "",
|
|
class_: str = "",
|
|
) -> Node:
|
|
"""Colored status dot with label. Status codes: u/p/f/a/r."""
|
|
children = children or []
|
|
outer_class = (
|
|
f"{'flex' if display == 'flex' else 'inline-flex'} "
|
|
"gap-2 items-center align-middle"
|
|
)
|
|
if class_:
|
|
outer_class += f" {class_}"
|
|
dot_color = _STATUS_COLORS.get(status, _STATUS_COLORS["u"])
|
|
|
|
dot = Span(
|
|
attributes=[("class", f"rounded-xl w-3 h-3 {dot_color}")],
|
|
children=["\xa0"],
|
|
)
|
|
|
|
return Span(
|
|
attributes=[("class", outer_class)],
|
|
children=[dot] + as_children(children),
|
|
)
|
|
|
|
|
|
def PriceConverted(
|
|
children: Children = None,
|
|
) -> Node:
|
|
"""Wrap content in a span that indicates the price was converted."""
|
|
children = children or []
|
|
return Span(
|
|
attributes=[
|
|
("title", "Price is a result of conversion and rounding."),
|
|
("class", "decoration-dotted underline"),
|
|
],
|
|
children=as_children(children),
|
|
)
|
|
|
|
|
|
def LinkedPurchase(purchase: Purchase) -> Node:
|
|
link = reverse("games:view_purchase", args=[int(purchase.id)])
|
|
link_content = ""
|
|
popover_content = ""
|
|
game_count = purchase.games.count()
|
|
popover_if_not_truncated = False
|
|
if game_count == 1:
|
|
link_content += purchase.games.first().name
|
|
popover_content = link_content
|
|
if game_count > 1:
|
|
if purchase.name:
|
|
link_content += f"{purchase.name}"
|
|
popover_content += f"<h1>{purchase.name}</h1><br>"
|
|
else:
|
|
link_content += f"{game_count} games"
|
|
popover_if_not_truncated = True
|
|
popover_content += f"""
|
|
<ul class="list-disc list-inside">
|
|
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
|
|
</ul>
|
|
"""
|
|
icon = (
|
|
(purchase.platform.icon if purchase.platform else "unspecified")
|
|
if game_count == 1
|
|
else "unspecified"
|
|
)
|
|
if link_content == "":
|
|
raise ValueError("link_content is empty!!")
|
|
a_content = Div(
|
|
[("class", "inline-flex gap-2 items-center")],
|
|
[
|
|
Icon(
|
|
icon,
|
|
[("title", "Multiple")],
|
|
),
|
|
PopoverTruncated(
|
|
input_string=link_content,
|
|
popover_content=Safe(popover_content),
|
|
popover_if_not_truncated=popover_if_not_truncated,
|
|
),
|
|
],
|
|
)
|
|
return A(href=link, children=[a_content])
|
|
|
|
|
|
def NameWithIcon(
|
|
name: str = "",
|
|
game: Game | None = None,
|
|
session: Session | None = None,
|
|
linkify: bool = True,
|
|
emulated: bool = False,
|
|
) -> Node:
|
|
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
|
|
name, game, session, linkify
|
|
)
|
|
|
|
content = Div(
|
|
[("class", "inline-flex gap-2 items-center")],
|
|
[
|
|
Icon(
|
|
platform.icon,
|
|
[("title", platform.name)],
|
|
)
|
|
if platform
|
|
else "",
|
|
Icon("emulated", [("title", "Emulated")]) if final_emulated else "",
|
|
PopoverTruncated(_name),
|
|
],
|
|
)
|
|
|
|
return (
|
|
A(
|
|
href=link,
|
|
children=[content],
|
|
)
|
|
if create_link
|
|
else content
|
|
)
|
|
|
|
|
|
def _resolve_name_with_icon(
|
|
name: str,
|
|
game: Game | None,
|
|
session: Session | None,
|
|
linkify: bool,
|
|
) -> tuple[str, Any, bool, bool, str]:
|
|
create_link = False
|
|
link = ""
|
|
platform = None
|
|
final_emulated = False
|
|
|
|
if session is not None:
|
|
game = session.game
|
|
platform = game.platform
|
|
final_emulated = session.emulated
|
|
if linkify:
|
|
create_link = True
|
|
link = reverse("games:view_game", args=[int(game.pk)])
|
|
elif game is not None:
|
|
platform = game.platform
|
|
if linkify:
|
|
create_link = True
|
|
link = reverse("games:view_game", args=[int(game.pk)])
|
|
|
|
_name = name or (game.name if game else "")
|
|
|
|
return _name, platform, final_emulated, create_link, link
|
|
|
|
|
|
def PurchasePrice(purchase) -> Node:
|
|
return Popover(
|
|
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
|
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
|
wrapped_classes="underline decoration-dotted",
|
|
)
|
|
|
|
|
|
_SELECTOR_MENU_CLASS = (
|
|
"absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm "
|
|
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none "
|
|
"border border-gray-200 dark:border-gray-700"
|
|
)
|
|
_SELECTOR_TOGGLE_CLASS = (
|
|
"relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 "
|
|
"rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 "
|
|
"dark:hover:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
|
|
)
|
|
|
|
|
|
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
|
|
"""Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
|
|
from common.components import custom_element
|
|
from common.components.core import Element
|
|
from common.components.custom_elements import GameStatusSelectorProps
|
|
from common.components.primitives import Li, Ul
|
|
|
|
options = [
|
|
Li()[
|
|
Element(
|
|
"button",
|
|
[("type", "button"), ("data-option", ""), ("data-value", str(value))],
|
|
GameStatus(status=value, children=[label], display="flex"),
|
|
)
|
|
]
|
|
for value, label in game_statuses
|
|
]
|
|
current_label = Span(data_label="")[
|
|
GameStatus(
|
|
status=game.status,
|
|
children=[game.get_status_display()],
|
|
display="flex",
|
|
)
|
|
]
|
|
toggle = Element(
|
|
"button",
|
|
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
|
|
Span(class_="flex flex-row gap-4 justify-between items-center")[
|
|
current_label, Icon("arrowdown")
|
|
],
|
|
)
|
|
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
|
|
dropdown = Div(
|
|
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
|
)[toggle, menu]
|
|
return custom_element(
|
|
"game-status-selector",
|
|
GameStatusSelectorProps(game_id=game.id, status=game.status, csrf=csrf_token),
|
|
children=[Div(class_="flex gap-2 items-center")[dropdown]],
|
|
)
|
|
|
|
|
|
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
|
|
"""Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
|
|
from common.components import custom_element
|
|
from common.components.core import Element
|
|
from common.components.custom_elements import SessionDeviceSelectorProps
|
|
from common.components.primitives import Li, Ul
|
|
|
|
current_name = session.device.name if session.device else "Unknown"
|
|
options = [
|
|
Li()[
|
|
Element(
|
|
"button",
|
|
[
|
|
("type", "button"),
|
|
("data-option", ""),
|
|
("data-value", str(device.id)),
|
|
],
|
|
children=[device.name],
|
|
)
|
|
]
|
|
for device in session_devices
|
|
]
|
|
toggle = Element(
|
|
"button",
|
|
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
|
|
Span(class_="flex flex-row gap-4 justify-between items-center")[
|
|
Span(data_label="")[current_name], Icon("arrowdown")
|
|
],
|
|
)
|
|
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
|
|
dropdown = Div(
|
|
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
|
)[toggle, menu]
|
|
return custom_element(
|
|
"session-device-selector",
|
|
SessionDeviceSelectorProps(session_id=session.id, csrf=csrf_token),
|
|
children=[Div(class_="flex gap-2 items-center")[dropdown]],
|
|
)
|