04552aa8f6
The Game status dropdown is now a <game-status-selector> light-DOM custom
element: the Python builder emits the tag + kebab attrs htpy-style, behavior
lives in ts/elements/{dropdown,game-status-selector}.ts wired by the native
connectedCallback, and GameStatusSelectorProps is the codegen'd contract. The
~70-line inline-Alpine f-string is gone.
Also fix SimpleTable to collect and re-attach the media of its row/header
nodes: it stringifies cells into the table markup, which silently dropped each
cell component's declared Media — so a <game-status-selector> in a cell never
got its <script> emitted. Now Page() emits it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
341 lines
11 KiB
Python
341 lines
11 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)],
|
|
[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:
|
|
"""Alpine.js dropdown to change a session's device."""
|
|
device_id = session.device_id or "null"
|
|
device_name = (session.device.name if session.device else "Unknown").replace(
|
|
"'", "\\'"
|
|
)
|
|
|
|
list_items = "\n".join(
|
|
f'<li><a href="#" @click.prevent.stop="setDevice({d.id}, \'{d.name.replace(chr(39), chr(92) + chr(39))}\'); open = false;" '
|
|
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
|
|
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
|
|
f":class=\"{{'font-bold': deviceId === {d.id}}}\">{d.name}</a></li>"
|
|
for d in session_devices
|
|
)
|
|
|
|
return Safe(f"""
|
|
<div class="flex gap-2 items-center"
|
|
x-data="{{
|
|
originalDeviceId: {device_id},
|
|
originalDeviceName: '{device_name}',
|
|
deviceId: {device_id},
|
|
deviceName: '{device_name}',
|
|
open: false,
|
|
saving: false,
|
|
setDevice(newDeviceId, newDeviceName) {{
|
|
this.deviceId = newDeviceId;
|
|
this.deviceName = newDeviceName;
|
|
this.saving = true;
|
|
fetchWithHtmxTriggers(`/api/session/{session.id}/device`, {{
|
|
method: 'PATCH',
|
|
headers: {{
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': '{csrf_token}'
|
|
}},
|
|
body: JSON.stringify({{ device_id: newDeviceId }})
|
|
}})
|
|
.then((res) => {{
|
|
document.body.dispatchEvent(new CustomEvent('device-changed'));
|
|
}})
|
|
.catch(() => {{
|
|
this.deviceName = this.originalDeviceName;
|
|
this.deviceId = this.originalDeviceId;
|
|
console.error('Failed to update device');
|
|
}})
|
|
.finally(() => this.saving = false);
|
|
}}
|
|
}}">
|
|
{
|
|
_dropdown_button_html(
|
|
'<span x-text="deviceName"></span>' + str(Icon("arrowdown")), list_items
|
|
)
|
|
}
|
|
</div>
|
|
""")
|
|
|
|
|
|
def _dropdown_button_html(button_content: str, list_items: str) -> str:
|
|
"""Shared dropdown button + list structure for Alpine.js selectors."""
|
|
return (
|
|
'<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">'
|
|
'<button type="button" @click="open = !open" '
|
|
'class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 '
|
|
"rounded-lg 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:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 "
|
|
'dark:focus:text-white align-middle hover:cursor-pointer">'
|
|
f'<span class="flex flex-row gap-4 justify-between items-center">{button_content}</span>'
|
|
'<div 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" x-show="open" style="display: none;">'
|
|
'<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">'
|
|
f"{list_items}"
|
|
"</ul>"
|
|
"</div>"
|
|
"</button>"
|
|
"</div>"
|
|
)
|