Move from HTML templates to pure Python
Remove cruft
@@ -1,46 +0,0 @@
|
|||||||
# Suggested Improvements to common/components.py
|
|
||||||
|
|
||||||
## Completed
|
|
||||||
|
|
||||||
### Caching on template rendering
|
|
||||||
- Added `functools.lru_cache` on `_render_cached()` wrapper around `render_to_string`
|
|
||||||
- Cache key: `(template_path, json.dumps(context, sort_keys=True))` — deterministic and unique
|
|
||||||
- `maxsize=4096` in production, disabled entirely in DEBUG mode (so template changes are reflected immediately)
|
|
||||||
- Only caches `template` path calls; `tag_name` calls are already nanosecond string ops
|
|
||||||
- Verified working: identical calls return identical output, different inputs produce separate cache entries
|
|
||||||
|
|
||||||
### Non-deterministic IDs
|
|
||||||
`randomid()` was replaced with `hashlib.sha1(content_hash.encode()).hexdigest()[:10]` for deterministic ID generation.
|
|
||||||
- `Popover()` passes content hash (`wrapped_content:popover_content:wrapped_classes`) so IDs are deterministic per unique content
|
|
||||||
- `games/templatetags/randomid.py` uses the same hash-based approach
|
|
||||||
- Fixes: caching (Popover output now cacheable), page consistency, thread safety
|
|
||||||
|
|
||||||
### Inconsistent return types
|
|
||||||
All component functions now return `SafeText` and are annotated accordingly. Redundant `mark_safe()` wrappers removed from `LinkedPurchase()` and `NameWithIcon()`.
|
|
||||||
|
|
||||||
### Fragile A() URL resolution
|
|
||||||
Replaced single `url` parameter with explicit `url_name` (URL pattern name resolved via `reverse()`) and `href` (literal path). Removed dead `Callable` type hint. `reverse()` now raises `NoReverseMatch` instead of silently falling back to literal text. Added mutual exclusion check — providing both parameters raises `ValueError`. Updated all 10 call sites across 6 view files and internal callers (`LinkedPurchase()`, `NameWithIcon()`).
|
|
||||||
|
|
||||||
### Toast XSS vulnerability
|
|
||||||
The vulnerable `Toast()` component (which used unsafe string escaping for
|
|
||||||
Alpine.js interpolation) had no callers and was deleted entirely. Toast display
|
|
||||||
is handled by the existing event-driven pipeline: middleware → `HX-Trigger`
|
|
||||||
headers → `show-toast` CustomEvent → Alpine store.
|
|
||||||
|
|
||||||
### Default mutable arguments
|
|
||||||
All functions with mutable defaults (`attributes` and `children`) changed from `= []` to `| None = None` with `or []` conversion in the body.
|
|
||||||
|
|
||||||
What was fixed: `attributes: list[HTMLAttribute] = []` and `children: list[HTMLTag] | HTMLTag = []` are a classic Python gotcha — the default is shared across all callers and could silently corrupt state if ever mutated in place. Changed 8 functions (`Component`, `Popover`, `A`, `Button`, `Div`, `Input`, `Form`, `Icon`) to use the `None` sentinel pattern, preventing future bugs and eliminating linter warnings.
|
|
||||||
|
|
||||||
### NameWithIcon dead code and untestable design
|
|
||||||
The `NameWithIcon()` function had a `platform` parameter that was immediately overwritten by `platform = None` and never used (dead code). The function mixed data lookup (database queries via IDs) with rendering, making it untestable.
|
|
||||||
|
|
||||||
**Fix**: Refactored `NameWithIcon()` to follow the `LinkedPurchase` pattern — accepts model objects (`Game`, `Session`) instead of IDs. Extracted `_resolve_name_with_icon()` helper for testable computation logic (name resolution, platform extraction, link creation). Fixed bug where `platform` was not extracted when `session` parameter was passed. Removed dead `platform` parameter from the public API. Updated all 3 production call sites (already using model objects). Added 10 unit tests for `_resolve_name_with_icon()` covering session override, custom names, linkify behavior, platform resolution, and edge cases. Updated 6 integration tests to use model-based parameters.
|
|
||||||
|
|
||||||
### No tests
|
|
||||||
Zero test coverage for the entire component system.
|
|
||||||
|
|
||||||
**Fix**: Add unit tests for each component function — basic rendering, edge cases,
|
|
||||||
and cache hit/miss verification.
|
|
||||||
|
|
||||||
**Done**: 96 unit tests covering all component functions (`Component`, `randomid`, `Popover`, `PopoverTruncated`, `A`, `Button`, `Div`, `Icon`, `Form`, `Input`, `NameWithIcon`, `LinkedPurchase`, `PurchasePrice`, `_render_cached`, `enable_cache`). Includes template rendering, deterministic ID generation, LRU cache behavior, HTML output validation, edge cases, error handling, and model-dependent integration tests.
|
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.middleware.csrf import get_token
|
||||||
from django.template import TemplateDoesNotExist
|
|
||||||
from django.template.defaultfilters import floatformat
|
from django.template.defaultfilters import floatformat
|
||||||
from django.template.loader import render_to_string
|
from django.templatetags.static import static
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.html import conditional_escape
|
from django.utils.html import conditional_escape, escape
|
||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.icons import get_icon
|
from common.icons import get_icon
|
||||||
@@ -34,49 +32,52 @@ _SIZE_CLASSES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _render_cached_impl(template: str, context_json: str) -> str:
|
@lru_cache(maxsize=4096)
|
||||||
context = json.loads(context_json)
|
def _render_element(
|
||||||
context["slot"] = mark_safe(context["slot"])
|
tag_name: str,
|
||||||
return render_to_string(template, context)
|
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
|
||||||
if not settings.DEBUG:
|
elements are rendered once. `attrs_key` is (name, stringified value) pairs
|
||||||
_render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
|
(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 ``"<b>"``
|
||||||
|
and an unsafe ``"<b>"`` (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:
|
else:
|
||||||
_render_cached = _render_cached_impl
|
attributes_blob = ""
|
||||||
|
return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>"
|
||||||
|
|
||||||
def enable_cache():
|
|
||||||
"""Wrap _render_cached with LRU cache (for testing in DEBUG mode)."""
|
|
||||||
global _render_cached
|
|
||||||
_render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
|
|
||||||
|
|
||||||
|
|
||||||
def Component(
|
def Component(
|
||||||
attributes: list[HTMLAttribute] | None = None,
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
children: list[HTMLTag] | HTMLTag | None = None,
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
template: str = "",
|
|
||||||
tag_name: str = "",
|
tag_name: str = "",
|
||||||
) -> SafeText:
|
) -> 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 []
|
attributes = attributes or []
|
||||||
children = children or []
|
children = children or []
|
||||||
if not tag_name and not template:
|
if not tag_name:
|
||||||
raise ValueError("One of template or tag_name is required.")
|
raise ValueError("tag_name is required.")
|
||||||
if isinstance(children, str):
|
if isinstance(children, str):
|
||||||
children = [children]
|
children = [children]
|
||||||
childrenBlob = "\n".join(conditional_escape(child) for child in children)
|
attrs_key = tuple((name, str(value)) for name, value in attributes)
|
||||||
if len(attributes) == 0:
|
children_key = tuple((child, isinstance(child, SafeText)) for child in children)
|
||||||
attributesBlob = ""
|
return mark_safe(_render_element(tag_name, attrs_key, children_key))
|
||||||
else:
|
|
||||||
attributesList = [f'{name}="{conditional_escape(str(value))}"' for name, value in attributes]
|
|
||||||
attributesBlob = f" {' '.join(attributesList)}"
|
|
||||||
tag: str = ""
|
|
||||||
if tag_name != "":
|
|
||||||
tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
|
|
||||||
elif template != "":
|
|
||||||
context = {name: value for name, value in attributes} | {"slot": "\n".join(children)}
|
|
||||||
tag = _render_cached(template, json.dumps(context, sort_keys=True))
|
|
||||||
return mark_safe(tag)
|
|
||||||
|
|
||||||
|
|
||||||
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
||||||
@@ -84,7 +85,11 @@ def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
|||||||
return seed
|
return seed
|
||||||
hash_input = f"{seed}:{content}" if seed else content
|
hash_input = f"{seed}:{content}" if seed else content
|
||||||
content_hash = hashlib.sha1(hash_input.encode()).hexdigest()
|
content_hash = hashlib.sha1(hash_input.encode()).hexdigest()
|
||||||
base = content_hash[:length] if not seed else content_hash[:max(0, length - len(seed))]
|
base = (
|
||||||
|
content_hash[:length]
|
||||||
|
if not seed
|
||||||
|
else content_hash[: max(0, length - len(seed))]
|
||||||
|
)
|
||||||
return seed + base
|
return seed + base
|
||||||
|
|
||||||
|
|
||||||
@@ -413,18 +418,61 @@ def Input(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def Form(
|
def CsrfInput(request) -> SafeText:
|
||||||
action="",
|
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag."""
|
||||||
method="get",
|
return mark_safe(
|
||||||
attributes: list[HTMLAttribute] | None = None,
|
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
|
||||||
children: list[HTMLTag] | HTMLTag | None = None,
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ModuleScript(filename: str) -> SafeText:
|
||||||
|
"""A `<script type="module">` tag pointing at a static JS file."""
|
||||||
|
return mark_safe(
|
||||||
|
f'<script type="module" src="{static("js/" + filename)}"></script>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def AddForm(
|
||||||
|
form,
|
||||||
|
*,
|
||||||
|
request,
|
||||||
|
fields: SafeText | str | None = None,
|
||||||
|
additional_row: SafeText | str = "",
|
||||||
|
submit_class: str = "mt-3",
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
attributes = attributes or []
|
"""Page body for the generic add/edit form (Python equivalent of add.html).
|
||||||
children = children or []
|
|
||||||
return Component(
|
`fields` overrides the default ``form.as_div()`` field markup (used by the
|
||||||
|
session form, which lays out its fields manually). `additional_row` holds
|
||||||
|
extra submit buttons rendered below the main Submit button. `submit_class`
|
||||||
|
is applied to the main Submit button (the session form passes "" to match
|
||||||
|
its original markup).
|
||||||
|
"""
|
||||||
|
field_markup = fields if fields is not None else mark_safe(form.as_div())
|
||||||
|
submit_attrs = [("class", submit_class)] if submit_class else []
|
||||||
|
|
||||||
|
inner_form = Component(
|
||||||
tag_name="form",
|
tag_name="form",
|
||||||
attributes=attributes + [("action", action), ("method", method)],
|
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
|
||||||
children=children,
|
children=[
|
||||||
|
CsrfInput(request),
|
||||||
|
field_markup,
|
||||||
|
Div(children=[Button(submit_attrs, "Submit", type="submit")]),
|
||||||
|
Div(
|
||||||
|
[("class", "submit-button-container")],
|
||||||
|
[additional_row] if additional_row else [],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
[("id", "add-form"), ("class", "max-width-container")],
|
||||||
|
[
|
||||||
|
Div(
|
||||||
|
[("id", "add-form"), ("class", "form-container max-w-xl mx-auto")],
|
||||||
|
[inner_form],
|
||||||
|
)
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -604,7 +652,8 @@ def H1(
|
|||||||
return Component(
|
return Component(
|
||||||
tag_name="h1",
|
tag_name="h1",
|
||||||
attributes=[("class", heading_class)],
|
attributes=[("class", heading_class)],
|
||||||
children=(children if isinstance(children, list) else [children]) + ([badge_html] if badge_html else []),
|
children=(children if isinstance(children, list) else [children])
|
||||||
|
+ ([badge_html] if badge_html else []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -634,9 +683,7 @@ def Modal(
|
|||||||
"shadow-lg/50 rounded-md bg-white dark:bg-gray-900",
|
"shadow-lg/50 rounded-md bg-white dark:bg-gray-900",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
children=(
|
children=(children if isinstance(children, list) else [children]),
|
||||||
children if isinstance(children, list) else [children]
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -738,63 +785,147 @@ def TableHeader(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def Table(columns: list[str] | None = None, children=None) -> SafeText:
|
def _page_url(request, page) -> str:
|
||||||
"""Standalone table with header and body slot.
|
"""Current querystring with `page` replaced (mirrors {% param_replace %})."""
|
||||||
|
if request is None:
|
||||||
|
return f"?page={page}"
|
||||||
|
params = request.GET.copy()
|
||||||
|
params["page"] = page
|
||||||
|
return "?" + params.urlencode()
|
||||||
|
|
||||||
Currently unused — superseded by simple_table. Kept for optional future use.
|
|
||||||
"""
|
def _pagination_nav(page_obj, elided_page_range, request) -> str:
|
||||||
columns = columns or []
|
pages_html = ""
|
||||||
children = children or []
|
for page in elided_page_range:
|
||||||
return Component(
|
if page != page_obj.number:
|
||||||
tag_name="div",
|
pages_html += (
|
||||||
attributes=[("class", "relative overflow-x-auto shadow-md sm:rounded-lg")],
|
f'<li><a href="{_page_url(request, page)}" '
|
||||||
children=[
|
'class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 '
|
||||||
Component(
|
"bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 "
|
||||||
tag_name="table",
|
"dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 "
|
||||||
attributes=[
|
f'dark:hover:text-white">{conditional_escape(page)}</a></li>'
|
||||||
(
|
|
||||||
"class",
|
|
||||||
"w-full text-sm text-left rtl:text-right "
|
|
||||||
"text-gray-500 dark:text-gray-400",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
children=[
|
|
||||||
Component(
|
|
||||||
tag_name="thead",
|
|
||||||
attributes=[
|
|
||||||
(
|
|
||||||
"class",
|
|
||||||
"text-xs text-gray-700 uppercase bg-gray-50 "
|
|
||||||
"dark:bg-gray-700 dark:text-gray-400",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
children=[
|
|
||||||
Component(
|
|
||||||
tag_name="tr",
|
|
||||||
children=[
|
|
||||||
Component(
|
|
||||||
tag_name="th",
|
|
||||||
attributes=[
|
|
||||||
("scope", "col"),
|
|
||||||
("class", "px-6 py-3"),
|
|
||||||
],
|
|
||||||
children=[col],
|
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
pages_html += (
|
||||||
|
'<li><a aria-current="page" '
|
||||||
|
'class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight '
|
||||||
|
"text-white border bg-gray-400 border-gray-300 dark:bg-gray-900 dark:border-gray-700 "
|
||||||
|
f'dark:text-gray-200">{conditional_escape(page)}</a></li>'
|
||||||
|
)
|
||||||
|
|
||||||
|
if page_obj.has_previous():
|
||||||
|
prev_html = (
|
||||||
|
f'<a href="{_page_url(request, page_obj.previous_page_number())}" '
|
||||||
|
'class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 '
|
||||||
|
"bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 "
|
||||||
|
"dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 "
|
||||||
|
'dark:hover:text-white">Previous</a>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
prev_html = (
|
||||||
|
'<a aria-current="page" class="cursor-not-allowed flex items-center justify-center '
|
||||||
|
"px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-s-lg "
|
||||||
|
'dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Previous</a>'
|
||||||
|
)
|
||||||
|
|
||||||
|
if page_obj.has_next():
|
||||||
|
next_html = (
|
||||||
|
f'<a href="{_page_url(request, page_obj.next_page_number())}" '
|
||||||
|
'class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 '
|
||||||
|
"bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 "
|
||||||
|
"dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 "
|
||||||
|
'dark:hover:text-white">Next</a>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
next_html = (
|
||||||
|
'<a aria-current="page" class="cursor-not-allowed flex items-center justify-center '
|
||||||
|
"px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-e-lg "
|
||||||
|
'dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Next</a>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
'<nav class="flex items-center flex-col md:flex-row md:justify-between px-6 py-4 '
|
||||||
|
'dark:bg-gray-900 sm:rounded-b-lg" aria-label="Table navigation">'
|
||||||
|
'<span class="text-sm text-center font-normal text-gray-500 dark:text-gray-400 mb-4 '
|
||||||
|
'md:mb-0 block w-full md:inline md:w-auto">'
|
||||||
|
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.start_index()}</span>—'
|
||||||
|
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.end_index()}</span> of '
|
||||||
|
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.paginator.count}</span></span>'
|
||||||
|
'<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8"><li>'
|
||||||
|
f"{prev_html}{pages_html}{next_html}"
|
||||||
|
"</li></ul></nav>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def SimpleTable(
|
||||||
|
columns: list[str] | None = None,
|
||||||
|
rows: list | None = None,
|
||||||
|
header_action: SafeText | str | None = None,
|
||||||
|
page_obj=None,
|
||||||
|
elided_page_range=None,
|
||||||
|
request=None,
|
||||||
|
) -> SafeText:
|
||||||
|
"""Paginated table. Python equivalent of the old simple_table.html."""
|
||||||
|
columns = columns or []
|
||||||
|
rows = rows or []
|
||||||
|
|
||||||
|
header_html = ""
|
||||||
|
if header_action:
|
||||||
|
header_html = str(TableHeader(children=[header_action]))
|
||||||
|
|
||||||
|
columns_html = "".join(
|
||||||
|
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
|
||||||
for col in columns
|
for col in columns
|
||||||
|
)
|
||||||
|
rows_html = "".join(str(TableRow(data=row)) for row in rows)
|
||||||
|
|
||||||
|
pagination_html = ""
|
||||||
|
if page_obj and elided_page_range:
|
||||||
|
pagination_html = _pagination_nav(page_obj, elided_page_range, request)
|
||||||
|
|
||||||
|
return mark_safe(
|
||||||
|
'<div class="shadow-md" hx-boost="false">'
|
||||||
|
'<div class="relative overflow-x-auto sm:rounded-t-lg">'
|
||||||
|
'<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">'
|
||||||
|
f"{header_html}"
|
||||||
|
'<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 '
|
||||||
|
'dark:text-gray-400 max-sm:[&_th:not(:first-child):not(:last-child)]:hidden">'
|
||||||
|
f"<tr>{columns_html}</tr></thead>"
|
||||||
|
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
|
||||||
|
f"{rows_html}</tbody></table></div>"
|
||||||
|
f"{pagination_html}</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def paginated_table_content(
|
||||||
|
data: dict,
|
||||||
|
*,
|
||||||
|
page_obj=None,
|
||||||
|
elided_page_range=None,
|
||||||
|
request=None,
|
||||||
|
) -> SafeText:
|
||||||
|
"""Standard list-page body: a max-width Div wrapping a SimpleTable.
|
||||||
|
|
||||||
|
`data` is the table dict with keys ``columns``, ``rows`` and
|
||||||
|
``header_action`` (the same shape every list view already builds).
|
||||||
|
"""
|
||||||
|
return Div(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) "
|
||||||
|
"md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center",
|
||||||
|
)
|
||||||
],
|
],
|
||||||
),
|
[
|
||||||
],
|
SimpleTable(
|
||||||
),
|
columns=data["columns"],
|
||||||
Component(
|
rows=data["rows"],
|
||||||
tag_name="tbody",
|
header_action=data["header_action"],
|
||||||
children=(
|
page_obj=page_obj,
|
||||||
children
|
elided_page_range=elided_page_range,
|
||||||
if isinstance(children, list)
|
request=request,
|
||||||
else [children]
|
)
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -912,4 +1043,131 @@ def PurchasePrice(purchase) -> SafeText:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText:
|
||||||
|
"""Alpine.js dropdown to change a game's status."""
|
||||||
|
options_html = "\n".join(
|
||||||
|
f"<template x-if=\"status == '{value}'\">"
|
||||||
|
f"{GameStatus(status=value, children=[label], display='flex')}"
|
||||||
|
f"</template>"
|
||||||
|
for value, label in game_statuses
|
||||||
|
)
|
||||||
|
list_items = "\n".join(
|
||||||
|
f"<li><a href=\"#\" @click.prevent.stop=\"setStatus('{value}', '{label}'); 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': status === '{value}'}}\">"
|
||||||
|
f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}"
|
||||||
|
f"</a></li>"
|
||||||
|
for value, label in game_statuses
|
||||||
|
)
|
||||||
|
|
||||||
|
return mark_safe(f"""
|
||||||
|
<div class="flex gap-2 items-center"
|
||||||
|
x-data="{{
|
||||||
|
status: '{game.status}',
|
||||||
|
status_display: '{game.get_status_display()}',
|
||||||
|
open: false,
|
||||||
|
saving: false,
|
||||||
|
setStatus(newStatus, newStatusDisplay) {{
|
||||||
|
this.status = newStatus;
|
||||||
|
this.status_display = newStatusDisplay;
|
||||||
|
this.saving = true;
|
||||||
|
fetchWithHtmxTriggers(`/api/games/{game.id}/status`, {{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {{
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': '{csrf_token}'
|
||||||
|
}},
|
||||||
|
body: JSON.stringify({{ status: newStatus }})
|
||||||
|
}})
|
||||||
|
.then(() => {{
|
||||||
|
document.body.dispatchEvent(new CustomEvent('status-changed'));
|
||||||
|
}})
|
||||||
|
.catch(() => {{
|
||||||
|
console.error('Failed to update status');
|
||||||
|
}})
|
||||||
|
.finally(() => this.saving = false);
|
||||||
|
}}
|
||||||
|
}}">
|
||||||
|
{_dropdown_button_html(options_html, list_items)}
|
||||||
|
</div>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> SafeText:
|
||||||
|
"""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 mark_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>"
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import functools
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_ICON_DIR = (
|
_ICON_DIR = (
|
||||||
Path(__file__).resolve().parent.parent / "games" / "templates" / "cotton" / "icon"
|
Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ def import_data(data: DataList):
|
|||||||
# try exact match first
|
# try exact match first
|
||||||
try:
|
try:
|
||||||
game_id = Game.objects.get(name__iexact=name)
|
game_id = Game.objects.get(name__iexact=name)
|
||||||
except:
|
except (Game.DoesNotExist, Game.MultipleObjectsReturned):
|
||||||
pass
|
game_id = None
|
||||||
matching_names[name] = game_id
|
matching_names[name] = game_id
|
||||||
print(f"Exact matched {len(matching_names)} games.")
|
print(f"Exact matched {len(matching_names)} games.")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
"""A small fast_app-style layout system.
|
||||||
|
|
||||||
|
Instead of Django template inheritance (`{% extends "base.html" %}`), views
|
||||||
|
build their page body with Python components and wrap it with `Page()` /
|
||||||
|
`render_page()`. `Page()` is the equivalent of FastHTML's document wrapper:
|
||||||
|
it hoists shared `<head>` content (the `_HEADERS` block, analogous to
|
||||||
|
`fast_app(hdrs=...)`), renders the navbar, and assembles the full document.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.contrib.messages import get_messages
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.templatetags.static import static
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.html import conditional_escape
|
||||||
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
from django_htmx.jinja import django_htmx_script
|
||||||
|
|
||||||
|
from games.templatetags.version import version, version_date
|
||||||
|
|
||||||
|
# Static head script that sets the dark/light class before paint (avoids FOUC).
|
||||||
|
_THEME_FOUC_SCRIPT = """<script>
|
||||||
|
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
}
|
||||||
|
</script>"""
|
||||||
|
|
||||||
|
# The main module script: crown icon mount + theme-toggle wiring.
|
||||||
|
# Split around the single dynamic value (game.mastered).
|
||||||
|
_MAIN_SCRIPT_A = """<script type="module">
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (window.mountCrownIcon) {
|
||||||
|
window.mountCrownIcon('#crown-icon-mount-point', {
|
||||||
|
mastered: """
|
||||||
|
_MAIN_SCRIPT_B = """
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
|
||||||
|
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
|
||||||
|
const themeToggleBtn = document.getElementById('theme-toggle');
|
||||||
|
|
||||||
|
if (themeToggleDarkIcon && themeToggleLightIcon && themeToggleBtn) {
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
themeToggleLightIcon.classList.remove('hidden');
|
||||||
|
themeToggleDarkIcon.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
themeToggleDarkIcon.classList.remove('hidden');
|
||||||
|
themeToggleLightIcon.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
themeToggleBtn.addEventListener('click', function () {
|
||||||
|
themeToggleDarkIcon.classList.toggle('hidden');
|
||||||
|
themeToggleLightIcon.classList.toggle('hidden');
|
||||||
|
|
||||||
|
if (localStorage.getItem('color-theme')) {
|
||||||
|
if (localStorage.getItem('color-theme') === 'light') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
localStorage.setItem('color-theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
localStorage.setItem('color-theme', 'light');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
localStorage.setItem('color-theme', 'light');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
localStorage.setItem('color-theme', 'dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>"""
|
||||||
|
|
||||||
|
# Toast notification region (Alpine.js). Verbatim from the old base.html.
|
||||||
|
_TOAST_CONTAINER = """<div x-data="toastStore()"
|
||||||
|
role="region"
|
||||||
|
aria-label="Notifications"
|
||||||
|
aria-atomic="true"
|
||||||
|
class="fixed z-50 bottom-0 right-0 flex flex-col items-end pointer-events-none p-4">
|
||||||
|
<template x-for="toast in $store.toasts.toasts" :key="toast.id">
|
||||||
|
<div x-show="toast.visible"
|
||||||
|
x-transition:enter="transition ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0 translate-x-8"
|
||||||
|
x-transition:enter-end="opacity-100 translate-x-0"
|
||||||
|
x-transition:leave="transition ease-in duration-200"
|
||||||
|
x-transition:leave-start="opacity-100 translate-x-0"
|
||||||
|
x-transition:leave-end="opacity-0 translate-x-8"
|
||||||
|
:role="toast.type === 'error' || toast.type === 'warning' ? 'alert' : 'status'"
|
||||||
|
:aria-live="toast.type === 'error' ? 'assertive' : 'polite'"
|
||||||
|
tabindex="0"
|
||||||
|
class="pointer-events-auto max-w-sm w-72 cursor-pointer mb-3 last:mb-0"
|
||||||
|
:class="{
|
||||||
|
'success': toast.type === 'success',
|
||||||
|
'error': toast.type === 'error',
|
||||||
|
'info': toast.type === 'info',
|
||||||
|
'warning': toast.type === 'warning',
|
||||||
|
'debug': toast.type === 'debug'
|
||||||
|
}"
|
||||||
|
@click="dismissToast(toast.id)"
|
||||||
|
@mouseenter="$store.toasts.clearToastTimer(toast.id)"
|
||||||
|
@mouseleave="$store.toasts.resumeToastTimer(toast.id, 5000)"
|
||||||
|
@keydown.escape="dismissToast(toast.id)">
|
||||||
|
<div class="rounded-lg shadow-lg p-4 flex items-start gap-3"
|
||||||
|
:class="{
|
||||||
|
'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700': toast.type === 'success',
|
||||||
|
'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700': toast.type === 'error',
|
||||||
|
'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700': toast.type === 'info',
|
||||||
|
'bg-amber-50 dark:bg-amber-900 border border-amber-200 dark:border-amber-700': toast.type === 'warning',
|
||||||
|
'bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700': toast.type === 'debug'
|
||||||
|
}">
|
||||||
|
<span class="flex-shrink-0 mt-0.5"
|
||||||
|
:class="{
|
||||||
|
'text-green-500': toast.type === 'success',
|
||||||
|
'text-red-500': toast.type === 'error',
|
||||||
|
'text-blue-500': toast.type === 'info',
|
||||||
|
'text-amber-500': toast.type === 'warning',
|
||||||
|
'text-gray-500': toast.type === 'debug'
|
||||||
|
}">
|
||||||
|
<template x-if="toast.type === 'success'">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="toast.type === 'error'">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="toast.type === 'info'">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="toast.type === 'warning'">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 13l5 5 5-5M7 6l5 5 5-5"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="toast.type === 'debug'">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<p class="flex-1 text-sm"
|
||||||
|
:class="{
|
||||||
|
'text-green-800 dark:text-green-200': toast.type === 'success',
|
||||||
|
'text-red-800 dark:text-red-200': toast.type === 'error',
|
||||||
|
'text-blue-800 dark:text-blue-200': toast.type === 'info',
|
||||||
|
'text-amber-800 dark:text-amber-200': toast.type === 'warning',
|
||||||
|
'text-gray-800 dark:text-gray-200': toast.type === 'debug'
|
||||||
|
}"
|
||||||
|
x-text="toast.message"></p>
|
||||||
|
<button @click.stop="dismissToast(toast.id)"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
:class="{
|
||||||
|
'text-green-400 hover:text-green-600 dark:text-green-500 dark:hover:text-green-300': toast.type === 'success',
|
||||||
|
'text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300': toast.type === 'error',
|
||||||
|
'text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300': toast.type === 'info',
|
||||||
|
'text-amber-400 hover:text-amber-600 dark:text-amber-500 dark:hover:text-amber-300': toast.type === 'warning',
|
||||||
|
'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300': toast.type === 'debug'
|
||||||
|
}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _main_script(mastered: bool) -> str:
|
||||||
|
return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
|
||||||
|
|
||||||
|
|
||||||
|
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeText:
|
||||||
|
"""Top navigation bar."""
|
||||||
|
logo = static("icons/schedule.png")
|
||||||
|
return mark_safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
|
||||||
|
<div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
|
||||||
|
<a href="{reverse('games:index')}"
|
||||||
|
class="flex items-center space-x-3 rtl:space-x-reverse">
|
||||||
|
<img src="{logo}" height="48" width="48" alt="Timetracker Logo" class="mr-4" />
|
||||||
|
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Timetracker</span>
|
||||||
|
</a>
|
||||||
|
<button data-collapse-toggle="navbar-dropdown" type="button"
|
||||||
|
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-hidden focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
|
||||||
|
aria-controls="navbar-dropdown" aria-expanded="false">
|
||||||
|
<span class="sr-only">Open main menu</span>
|
||||||
|
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown">
|
||||||
|
<ul class="items-center flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
|
||||||
|
<li class="flex items-center">
|
||||||
|
<button id="theme-toggle" type="button" class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm hover:cursor-pointer">
|
||||||
|
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3.32031 11.6835C3.32031 16.6541 7.34975 20.6835 12.3203 20.6835C16.1075 20.6835 19.3483 18.3443 20.6768 15.032C19.6402 15.4486 18.5059 15.6834 17.3203 15.6834C12.3497 15.6834 8.32031 11.654 8.32031 6.68342C8.32031 5.50338 8.55165 4.36259 8.96453 3.32996C5.65605 4.66028 3.32031 7.89912 3.32031 11.6835Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 3V4M12 20V21M4 12H3M6.31412 6.31412L5.5 5.5M17.6859 6.31412L18.5 5.5M6.31412 17.69L5.5 18.5001M17.6859 17.69L18.5 18.5001M21 12H20M16 12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086 8 12 8C14.2091 8 16 9.79086 16 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="dark:text-white flex flex-col items-center text-xs">
|
||||||
|
<span class="flex uppercase gap-1">Today<span class="dark:text-gray-400">·</span>Last 7 days</span>
|
||||||
|
<span class="flex items-center gap-1">{today_played}<span class="dark:text-gray-400">·</span>{last_7_played}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#" class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent" aria-current="page">Home</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button id="dropdownNavbarNewLink" data-dropdown-toggle="dropdownNavbarNew"
|
||||||
|
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
|
||||||
|
New
|
||||||
|
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="dropdownNavbarNew" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||||
|
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
|
||||||
|
<li><a href="{reverse('games:add_device')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a></li>
|
||||||
|
<li><a href="{reverse('games:add_game')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a></li>
|
||||||
|
<li><a href="{reverse('games:add_platform')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a></li>
|
||||||
|
<li><a href="{reverse('games:add_purchase')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a></li>
|
||||||
|
<li><a href="{reverse('games:add_session')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button id="dropdownNavbarManageLink" data-dropdown-toggle="dropdownNavbarManage"
|
||||||
|
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
|
||||||
|
Manage
|
||||||
|
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="dropdownNavbarManage" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||||
|
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
|
||||||
|
<li><a href="{reverse('games:list_devices')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a></li>
|
||||||
|
<li><a href="{reverse('games:list_games')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a></li>
|
||||||
|
<li><a href="{reverse('games:list_platforms')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a></li>
|
||||||
|
<li><a href="{reverse('games:list_playevents')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a></li>
|
||||||
|
<li><a href="{reverse('games:list_purchases')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a></li>
|
||||||
|
<li><a href="{reverse('games:list_sessions')}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{reverse('games:stats_by_year', args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{reverse('logout')}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>""")
|
||||||
|
|
||||||
|
|
||||||
|
def Page(
|
||||||
|
content: SafeText | str,
|
||||||
|
*,
|
||||||
|
request: HttpRequest,
|
||||||
|
title: str = "",
|
||||||
|
scripts: SafeText | str = "",
|
||||||
|
mastered: bool = False,
|
||||||
|
) -> SafeText:
|
||||||
|
"""Assemble a full HTML document around `content` (the fast_app equivalent)."""
|
||||||
|
from games.views.general import global_current_year, model_counts
|
||||||
|
|
||||||
|
counts = model_counts(request)
|
||||||
|
year = global_current_year(request)["global_current_year"]
|
||||||
|
navbar = Navbar(
|
||||||
|
today_played=counts["today_played"],
|
||||||
|
last_7_played=counts["last_7_played"],
|
||||||
|
current_year=year,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"message": str(m.message), "type": (m.tags or "info")}
|
||||||
|
for m in get_messages(request)
|
||||||
|
]
|
||||||
|
# Embed as JSON; guard against `</script>` breaking out of the tag.
|
||||||
|
messages_json = json.dumps(messages).replace("</", "<\\/")
|
||||||
|
|
||||||
|
head = (
|
||||||
|
'<!DOCTYPE html>\n<html lang="en">\n <head>\n'
|
||||||
|
' <meta charset="utf-8" />\n'
|
||||||
|
' <meta name="description" content="Self-hosted time-tracker." />\n'
|
||||||
|
' <meta name="keywords" content="time, tracking, video games, self-hosted" />\n'
|
||||||
|
' <meta name="viewport" content="width=device-width, initial-scale=1" />\n'
|
||||||
|
f" <title>Timetracker - {conditional_escape(title)}</title>\n"
|
||||||
|
f' <script src="{static("js/htmx.min.js")}"></script>\n'
|
||||||
|
" <script>\n"
|
||||||
|
" htmx.config.scrollBehavior = 'smooth';\n"
|
||||||
|
" htmx.config.selfRequestsOnly = false;\n"
|
||||||
|
" </script>\n"
|
||||||
|
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
|
||||||
|
f" {django_htmx_script(nonce=None)}\n"
|
||||||
|
f' <link rel="stylesheet" href="{static("base.css")}" />\n'
|
||||||
|
' <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>\n'
|
||||||
|
' <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>\n'
|
||||||
|
' <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>\n'
|
||||||
|
f" {_THEME_FOUC_SCRIPT}\n"
|
||||||
|
" </head>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = (
|
||||||
|
' <body hx-indicator="#indicator" class="bg-neutral-primary">\n'
|
||||||
|
f' <script id="django-messages" type="application/json">{messages_json}</script>\n'
|
||||||
|
f' <img id="indicator" src="{static("icons/loading.png")}" class="absolute right-3 top-3 animate-spin htmx-indicator" height="24" width="24" alt="loading indicator" />\n'
|
||||||
|
' <div class="flex flex-col min-h-screen">\n'
|
||||||
|
f" {navbar}\n"
|
||||||
|
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
|
||||||
|
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
|
||||||
|
" </div>\n"
|
||||||
|
f" {scripts}\n"
|
||||||
|
f" {_main_script(mastered)}\n"
|
||||||
|
' <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n'
|
||||||
|
' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
|
||||||
|
f" {_TOAST_CONTAINER}\n"
|
||||||
|
f' <script src="{static("js/toast.js")}"></script>\n'
|
||||||
|
" </body>\n</html>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
return mark_safe(head + body)
|
||||||
|
|
||||||
|
|
||||||
|
def render_page(
|
||||||
|
request: HttpRequest,
|
||||||
|
content: SafeText | str,
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
scripts: SafeText | str = "",
|
||||||
|
mastered: bool = False,
|
||||||
|
status: int = 200,
|
||||||
|
) -> HttpResponse:
|
||||||
|
"""`render()`-style shortcut: build a full page and return an HttpResponse."""
|
||||||
|
return HttpResponse(
|
||||||
|
Page(content, request=request, title=title, scripts=scripts, mastered=mastered),
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
@@ -153,9 +153,9 @@ def redirect_to(default_view: str, *default_args):
|
|||||||
|
|
||||||
next_url = reverse(default_view, args=default_args)
|
next_url = reverse(default_view, args=default_args)
|
||||||
|
|
||||||
response = view_func(
|
# Execute the original view logic for its side effects, then
|
||||||
request, *args, **kwargs
|
# redirect to `next_url` instead of returning its response.
|
||||||
) # Execute the original view logic
|
view_func(request, *args, **kwargs)
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
|
|
||||||
return wrapped_view
|
return wrapped_view
|
||||||
|
|||||||
@@ -327,9 +327,6 @@ class Session(models.Model):
|
|||||||
def finish_now(self):
|
def finish_now(self):
|
||||||
self.timestamp_end = timezone.now()
|
self.timestamp_end = timezone.now()
|
||||||
|
|
||||||
def start_now():
|
|
||||||
self.timestamp_start = timezone.now()
|
|
||||||
|
|
||||||
def duration_formatted(self) -> str:
|
def duration_formatted(self) -> str:
|
||||||
result = format_duration(self.duration_total, "%02.1H")
|
result = format_duration(self.duration_total, "%02.1H")
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -895,18 +895,12 @@
|
|||||||
max-width: 96rem;
|
max-width: 96rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.m-4 {
|
|
||||||
margin: calc(var(--spacing) * 4);
|
|
||||||
}
|
|
||||||
.mx-2 {
|
.mx-2 {
|
||||||
margin-inline: calc(var(--spacing) * 2);
|
margin-inline: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
.my-4 {
|
|
||||||
margin-block: calc(var(--spacing) * 4);
|
|
||||||
}
|
|
||||||
.my-6 {
|
.my-6 {
|
||||||
margin-block: calc(var(--spacing) * 6);
|
margin-block: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
@@ -1574,9 +1568,6 @@
|
|||||||
.w-full {
|
.w-full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.max-w-\(--breakpoint-lg\) {
|
|
||||||
max-width: var(--breakpoint-lg);
|
|
||||||
}
|
|
||||||
.max-w-\(--breakpoint-xl\) {
|
.max-w-\(--breakpoint-xl\) {
|
||||||
max-width: var(--breakpoint-xl);
|
max-width: var(--breakpoint-xl);
|
||||||
}
|
}
|
||||||
@@ -3778,11 +3769,6 @@
|
|||||||
text-underline-offset: 4px;
|
text-underline-offset: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.\[\&_h1\]\:mb-2 {
|
|
||||||
& h1 {
|
|
||||||
margin-bottom: calc(var(--spacing) * 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.\[\&_li\:first-of-type_a\]\:rounded-none {
|
.\[\&_li\:first-of-type_a\]\:rounded-none {
|
||||||
& li:first-of-type a {
|
& li:first-of-type a {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import requests
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.template.defaultfilters import floatformat
|
from django.template.defaultfilters import floatformat
|
||||||
|
|
||||||
logger = logging.getLogger("games")
|
|
||||||
|
|
||||||
from games.models import ExchangeRate, Purchase
|
from games.models import ExchangeRate, Purchase
|
||||||
|
|
||||||
|
logger = logging.getLogger("games")
|
||||||
|
|
||||||
# fixme: save preferred currency in user model
|
# fixme: save preferred currency in user model
|
||||||
currency_to = "CZK"
|
currency_to = "CZK"
|
||||||
currency_to = currency_to.upper()
|
currency_to = currency_to.upper()
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
<c-layouts.add>
|
|
||||||
</c-layouts.add>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<c-layouts.add>
|
|
||||||
<c-slot name="additional_row">
|
|
||||||
<c-button type="submit" color="gray"
|
|
||||||
name="submit_and_redirect"
|
|
||||||
>
|
|
||||||
Submit & Create Purchase
|
|
||||||
</c-button>
|
|
||||||
</c-slot>
|
|
||||||
</c-layouts.add>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<c-layouts.add>
|
|
||||||
<c-slot name="additional_row">
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>
|
|
||||||
<c-button type="submit"
|
|
||||||
color="gray"
|
|
||||||
name="submit_and_redirect"
|
|
||||||
>
|
|
||||||
Submit & Create Session
|
|
||||||
</c-button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</c-slot>
|
|
||||||
</c-layouts.add>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<c-layouts.add>
|
|
||||||
<c-slot name="form_content">
|
|
||||||
<div class="max-width-container">
|
|
||||||
<div id="add-form" class="form-container max-w-xl mx-auto">
|
|
||||||
<form method="post" enctype="multipart/form-data" class="">
|
|
||||||
{% csrf_token %}
|
|
||||||
{% for field in form %}
|
|
||||||
<div>
|
|
||||||
{{ field.label_tag }}
|
|
||||||
{% if field.name == "note" %}
|
|
||||||
{{ field }}
|
|
||||||
{% else %}
|
|
||||||
{{ field }}
|
|
||||||
{% endif %}
|
|
||||||
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
|
|
||||||
<span class="form-row-button-group flex-row gap-3 justify-start mt-3" hx-boost="false">
|
|
||||||
<c-button data-target="{{ field.name }}" data-type="now" size="xs">Set to now</c-button>
|
|
||||||
<c-button data-target="{{ field.name }}" data-type="toggle" size="xs">Toggle text</c-button>
|
|
||||||
<c-button data-target="{{ field.name }}" data-type="copy" size="xs">
|
|
||||||
Copy {%if field.name == "timestamp_start" %}start{% else %}end{% endif %} value to {%if field.name == "timestamp_start" %}end{% else %}start{% endif %}
|
|
||||||
</c-button>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
<div>
|
|
||||||
<c-button type="submit">
|
|
||||||
Submit
|
|
||||||
</c-button>
|
|
||||||
</div>
|
|
||||||
<div class="submit-button-container">
|
|
||||||
{{ additional_row }}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</c-slot>
|
|
||||||
</c-layouts.add>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<c-vars color="blue" size="base" type="button" />
|
|
||||||
{% load button_tag %}
|
|
||||||
{% python_button color=color size=size icon=icon type=type class_=class hx_get=hx_get hx_target=hx_target hx_swap=hx_swap title=title onclick=onclick data_target=data_target data_type=data_type name=name slot=slot %}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
{% load button_group_tag %}
|
|
||||||
{% python_button_group buttons=buttons %}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
{% load gamelink_tag %}
|
|
||||||
{% python_gamelink game_id=game_id name=name slot=slot %}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
{% load gamestatus_tag %}
|
|
||||||
{% python_gamestatus status=status display=display class_=class slot=slot %}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<c-vars badge="" />
|
|
||||||
{% load h1_tag %}
|
|
||||||
{% python_h1 badge=badge slot=slot %}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<c-layouts.base>
|
|
||||||
{% load static %}
|
|
||||||
{% if form_content %}
|
|
||||||
{{ form_content }}
|
|
||||||
{% else %}
|
|
||||||
<div id="add-form" class="max-width-container">
|
|
||||||
<div id="add-form" class="form-container max-w-xl mx-auto">
|
|
||||||
<form method="post" enctype="multipart/form-data">
|
|
||||||
{% csrf_token %}
|
|
||||||
{{ form.as_div }}
|
|
||||||
<div>
|
|
||||||
<c-button type="submit" class="mt-3">
|
|
||||||
Submit
|
|
||||||
</c-button>
|
|
||||||
</div>
|
|
||||||
<div class="submit-button-container">
|
|
||||||
{{ additional_row }}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<c-slot name="scripts">
|
|
||||||
{% if script_name %}
|
|
||||||
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
|
|
||||||
{% endif %}
|
|
||||||
</c-slot>
|
|
||||||
</c-layouts.base>
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
{% load django_htmx %}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
{% load static %}
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="description" content="Self-hosted time-tracker." />
|
|
||||||
<meta name="keywords" content="time, tracking, video games, self-hosted" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Timetracker - {{ title }}</title>
|
|
||||||
<script src="{% static 'js/htmx.min.js' %}"></script>
|
|
||||||
<script>
|
|
||||||
htmx.config.scrollBehavior = 'smooth';
|
|
||||||
htmx.config.selfRequestsOnly = false;
|
|
||||||
</script>
|
|
||||||
<script src="{% static 'js/htmx-redirect-toast.js' %}"></script>
|
|
||||||
{% django_htmx_script %}
|
|
||||||
<link rel="stylesheet" href="{% static 'base.css' %}" />
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
|
|
||||||
{% comment %} <script src="//unpkg.com/alpinejs" defer></script>
|
|
||||||
<script src="//unpkg.com/@alpinejs/mask" defer></script> {% endcomment %}
|
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>
|
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
||||||
<script>
|
|
||||||
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
|
|
||||||
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body hx-indicator="#indicator" class="bg-neutral-primary">
|
|
||||||
<script id="django-messages" type="application/json">
|
|
||||||
[
|
|
||||||
{% for message in messages %}
|
|
||||||
{"message": "{{ message|escapejs }}", "type": "{{ message.tags|default:'info' }}"}{% if not forloop.last %},{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
<img id="indicator"
|
|
||||||
src="{% static 'icons/loading.png' %}"
|
|
||||||
class="absolute right-3 top-3 animate-spin htmx-indicator"
|
|
||||||
height="24"
|
|
||||||
width="24"
|
|
||||||
alt="loading indicator" />
|
|
||||||
<div class="flex flex-col min-h-screen">
|
|
||||||
{% include "navbar.html" %}
|
|
||||||
<div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{{ slot }}</div>
|
|
||||||
{% load version %}
|
|
||||||
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
|
|
||||||
</div>
|
|
||||||
{{ scripts }}
|
|
||||||
<script type="module">
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
if (window.mountCrownIcon) {
|
|
||||||
window.mountCrownIcon('#crown-icon-mount-point', {
|
|
||||||
mastered: {{ game.mastered|yesno:"true,false" }}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme toggle logic
|
|
||||||
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
|
|
||||||
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
|
|
||||||
const themeToggleBtn = document.getElementById('theme-toggle');
|
|
||||||
|
|
||||||
// Ensure all elements are found before proceeding
|
|
||||||
if (themeToggleDarkIcon && themeToggleLightIcon && themeToggleBtn) {
|
|
||||||
// Initial state of icons based on current theme
|
|
||||||
// The FOUC script in <head> already set document.documentElement.classList.add/remove('dark')
|
|
||||||
// So we just need to set the icon visibility based on that.
|
|
||||||
if (document.documentElement.classList.contains('dark')) {
|
|
||||||
themeToggleLightIcon.classList.remove('hidden');
|
|
||||||
themeToggleDarkIcon.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
themeToggleDarkIcon.classList.remove('hidden');
|
|
||||||
themeToggleLightIcon.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
themeToggleBtn.addEventListener('click', function () {
|
|
||||||
// toggle icons inside button
|
|
||||||
themeToggleDarkIcon.classList.toggle('hidden');
|
|
||||||
themeToggleLightIcon.classList.toggle('hidden');
|
|
||||||
|
|
||||||
// if set via local storage previously
|
|
||||||
if (localStorage.getItem('color-theme')) {
|
|
||||||
if (localStorage.getItem('color-theme') === 'light') {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
localStorage.setItem('color-theme', 'dark');
|
|
||||||
} else { // current theme is dark, switch to light
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
localStorage.setItem('color-theme', 'light');
|
|
||||||
}
|
|
||||||
|
|
||||||
// if NOT set via local storage previously
|
|
||||||
} else { // no theme in local storage, use system preference
|
|
||||||
if (document.documentElement.classList.contains('dark')) { // currently dark, switch to light
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
localStorage.setItem('color-theme', 'light');
|
|
||||||
} else { // currently light, switch to dark
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
localStorage.setItem('color-theme', 'dark');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->
|
|
||||||
<div id="global-modal-container" hx-swap-oob="true"></div>
|
|
||||||
|
|
||||||
<div x-data="toastStore()"
|
|
||||||
role="region"
|
|
||||||
aria-label="Notifications"
|
|
||||||
aria-atomic="true"
|
|
||||||
class="fixed z-50 bottom-0 right-0 flex flex-col items-end pointer-events-none p-4">
|
|
||||||
<template x-for="toast in $store.toasts.toasts" :key="toast.id">
|
|
||||||
<div x-show="toast.visible"
|
|
||||||
x-transition:enter="transition ease-out duration-300"
|
|
||||||
x-transition:enter-start="opacity-0 translate-x-8"
|
|
||||||
x-transition:enter-end="opacity-100 translate-x-0"
|
|
||||||
x-transition:leave="transition ease-in duration-200"
|
|
||||||
x-transition:leave-start="opacity-100 translate-x-0"
|
|
||||||
x-transition:leave-end="opacity-0 translate-x-8"
|
|
||||||
:role="toast.type === 'error' || toast.type === 'warning' ? 'alert' : 'status'"
|
|
||||||
:aria-live="toast.type === 'error' ? 'assertive' : 'polite'"
|
|
||||||
tabindex="0"
|
|
||||||
class="pointer-events-auto max-w-sm w-72 cursor-pointer mb-3 last:mb-0"
|
|
||||||
:class="{
|
|
||||||
'success': toast.type === 'success',
|
|
||||||
'error': toast.type === 'error',
|
|
||||||
'info': toast.type === 'info',
|
|
||||||
'warning': toast.type === 'warning',
|
|
||||||
'debug': toast.type === 'debug'
|
|
||||||
}"
|
|
||||||
@click="dismissToast(toast.id)"
|
|
||||||
@mouseenter="$store.toasts.clearToastTimer(toast.id)"
|
|
||||||
@mouseleave="$store.toasts.resumeToastTimer(toast.id, 5000)"
|
|
||||||
@keydown.escape="dismissToast(toast.id)">
|
|
||||||
<div class="rounded-lg shadow-lg p-4 flex items-start gap-3"
|
|
||||||
:class="{
|
|
||||||
'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700': toast.type === 'success',
|
|
||||||
'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700': toast.type === 'error',
|
|
||||||
'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700': toast.type === 'info',
|
|
||||||
'bg-amber-50 dark:bg-amber-900 border border-amber-200 dark:border-amber-700': toast.type === 'warning',
|
|
||||||
'bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700': toast.type === 'debug'
|
|
||||||
}">
|
|
||||||
<span class="flex-shrink-0 mt-0.5"
|
|
||||||
:class="{
|
|
||||||
'text-green-500': toast.type === 'success',
|
|
||||||
'text-red-500': toast.type === 'error',
|
|
||||||
'text-blue-500': toast.type === 'info',
|
|
||||||
'text-amber-500': toast.type === 'warning',
|
|
||||||
'text-gray-500': toast.type === 'debug'
|
|
||||||
}">
|
|
||||||
<template x-if="toast.type === 'success'">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
<template x-if="toast.type === 'error'">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
<template x-if="toast.type === 'info'">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
<template x-if="toast.type === 'warning'">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 13l5 5 5-5M7 6l5 5 5-5"/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
<template x-if="toast.type === 'debug'">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
<p class="flex-1 text-sm"
|
|
||||||
:class="{
|
|
||||||
'text-green-800 dark:text-green-200': toast.type === 'success',
|
|
||||||
'text-red-800 dark:text-red-200': toast.type === 'error',
|
|
||||||
'text-blue-800 dark:text-blue-200': toast.type === 'info',
|
|
||||||
'text-amber-800 dark:text-amber-200': toast.type === 'warning',
|
|
||||||
'text-gray-800 dark:text-gray-200': toast.type === 'debug'
|
|
||||||
}"
|
|
||||||
x-text="toast.message"></p>
|
|
||||||
<button @click.stop="dismissToast(toast.id)"
|
|
||||||
class="flex-shrink-0"
|
|
||||||
:class="{
|
|
||||||
'text-green-400 hover:text-green-600 dark:text-green-500 dark:hover:text-green-300': toast.type === 'success',
|
|
||||||
'text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300': toast.type === 'error',
|
|
||||||
'text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300': toast.type === 'info',
|
|
||||||
'text-amber-400 hover:text-amber-600 dark:text-amber-500 dark:hover:text-amber-300': toast.type === 'warning',
|
|
||||||
'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300': toast.type === 'debug'
|
|
||||||
}">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="{% static 'js/toast.js' %}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<c-vars popover_content="" wrapped_content="" wrapped_classes="" id="" />
|
|
||||||
{% load popover_tag %}
|
|
||||||
{% python_popover popover_content=popover_content wrapped_content=wrapped_content wrapped_classes=wrapped_classes id=id slot=slot %}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
{% load price_converted_tag %}
|
|
||||||
{% python_price_converted slot=slot %}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
{% load table_header_tag %}
|
|
||||||
{% python_table_header slot=slot %}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
{% load table_row_tag %}
|
|
||||||
{% python_table_row data=data %}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
{% load table_td_tag %}
|
|
||||||
{% python_table_td slot=slot %}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<c-layouts.base>
|
|
||||||
{% load static %}
|
|
||||||
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
|
|
||||||
<form method="post" class="dark:text-white">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div>
|
|
||||||
<p>Are you sure you want to delete this status change?</p>
|
|
||||||
<c-button color="red" type="submit" size="lg" class="w-full">Delete</c-button>
|
|
||||||
<a href="{% url 'games:view_game' object.game.id %}" class="">
|
|
||||||
<c-button color="gray" class="w-full">Cancel</c-button>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</c-layouts.base>
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 284 B After Width: | Height: | Size: 284 B |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 819 B After Width: | Height: | Size: 819 B |
|
Before Width: | Height: | Size: 477 B After Width: | Height: | Size: 477 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 571 B After Width: | Height: | Size: 571 B |
|
Before Width: | Height: | Size: 798 B After Width: | Height: | Size: 798 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 643 B After Width: | Height: | Size: 643 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 779 B After Width: | Height: | Size: 779 B |
|
Before Width: | Height: | Size: 252 B After Width: | Height: | Size: 252 B |
|
Before Width: | Height: | Size: 825 B After Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 248 B After Width: | Height: | Size: 248 B |
|
Before Width: | Height: | Size: 978 B After Width: | Height: | Size: 978 B |
|
Before Width: | Height: | Size: 978 B After Width: | Height: | Size: 978 B |
|
Before Width: | Height: | Size: 834 B After Width: | Height: | Size: 834 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 496 B After Width: | Height: | Size: 496 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 882 B After Width: | Height: | Size: 882 B |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 879 B After Width: | Height: | Size: 879 B |
|
Before Width: | Height: | Size: 669 B After Width: | Height: | Size: 669 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 345 B After Width: | Height: | Size: 345 B |
@@ -1,17 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}
|
|
||||||
{{ title }}
|
|
||||||
{% endblock title %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="text-slate-300 mx-auto max-w-(--breakpoint-lg) text-center">
|
|
||||||
{% if session_count > 0 %}
|
|
||||||
You have played a total of {{ session_count }} sessions for a total of {{ total_duration_formatted }}.
|
|
||||||
{% elif not game_available or not platform_available %}
|
|
||||||
There are no games in the database. Start by clicking "New Game" and "New Platform".
|
|
||||||
{% elif not purchase_available %}
|
|
||||||
There are no owned games. Click "New Purchase" at the top.
|
|
||||||
{% else %}
|
|
||||||
You haven't played any games yet. Click "New Session" to add one now.
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<c-layouts.base>
|
|
||||||
{% load static %}
|
|
||||||
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
|
|
||||||
{% include "simple_table.html" with rows=data.rows columns=data.columns page_obj=page_obj elided_page_range=elided_page_range header_action=data.header_action %}
|
|
||||||
</div>
|
|
||||||
</c-layouts.base>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<c-layouts.base>
|
|
||||||
{% load static %}
|
|
||||||
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
|
|
||||||
{% include "simple_table.html" with rows=data.rows columns=data.columns page_obj=page_obj elided_page_range=elided_page_range header_action=data.header_action %}
|
|
||||||
</div>
|
|
||||||
</c-layouts.base>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
{% block title %}
|
|
||||||
{{ title }}
|
|
||||||
{% endblock title %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="flex-col">
|
|
||||||
{% if dataset_count >= 1 %}
|
|
||||||
{% url 'games:list_sessions_start_session_from_session' last.id as start_session_url %}
|
|
||||||
<div class="mx-auto text-center my-4">
|
|
||||||
<a id="last-session-start"
|
|
||||||
href="{{ start_session_url }}"
|
|
||||||
hx-get="{{ start_session_url }}"
|
|
||||||
hx-swap="afterbegin"
|
|
||||||
hx-target=".responsive-table tbody"
|
|
||||||
onClick="document.querySelector('#last-session-start').classList.add('invisible')"
|
|
||||||
class="{% if last.timestamp_end == null %}invisible{% endif %}">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if dataset_count != 0 %}
|
|
||||||
<table class="responsive-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
|
|
||||||
<th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th>
|
|
||||||
<th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for session in dataset %}
|
|
||||||
{% partialdef session-row inline=True %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group">
|
|
||||||
<span class="inline-block relative">
|
|
||||||
<a class="underline decoration-slate-500 sm:decoration-2 inline-block truncate max-w-20char group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-xs group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4 group-hover:decoration-purple-900 group-hover:text-purple-100"
|
|
||||||
href="{% url 'games:view_game' session.game.id %}">
|
|
||||||
{{ session.game.name }}
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
|
|
||||||
{{ session.timestamp_start | date:"d/m/Y H:i" }}
|
|
||||||
</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
|
|
||||||
{% if not session.timestamp_end %}
|
|
||||||
{% url 'games:list_sessions_end_session' session.id as end_session_url %}
|
|
||||||
<a href="{{ end_session_url }}"
|
|
||||||
hx-get="{{ end_session_url }}"
|
|
||||||
hx-target="closest tr"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
hx-indicator="#indicator"
|
|
||||||
onClick="document.querySelector('#last-session-start').classList.remove('invisible')">
|
|
||||||
<span class="text-yellow-300">Finish now?</span>
|
|
||||||
</a>
|
|
||||||
{% elif session.duration_manual %}
|
|
||||||
--
|
|
||||||
{% else %}
|
|
||||||
{{ session.timestamp_end | date:"d/m/Y H:i" }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ session.duration_formatted }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endpartialdef %}
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
<div class="mx-auto text-center text-slate-300 text-xl">No sessions found.</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
{% load static %}
|
|
||||||
<nav class="bg-neutral-primary-soft border-b border-default">
|
|
||||||
<div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
|
|
||||||
<a href="{% url 'games:index' %}"
|
|
||||||
class="flex items-center space-x-3 rtl:space-x-reverse">
|
|
||||||
<img src="{% static 'icons/schedule.png' %}"
|
|
||||||
height="48"
|
|
||||||
width="48"
|
|
||||||
alt="Timetracker Logo"
|
|
||||||
class="mr-4" />
|
|
||||||
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Timetracker</span>
|
|
||||||
</a>
|
|
||||||
<button data-collapse-toggle="navbar-dropdown"
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-hidden focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
|
|
||||||
aria-controls="navbar-dropdown"
|
|
||||||
aria-expanded="false">
|
|
||||||
<span class="sr-only">Open main menu</span>
|
|
||||||
<svg class="w-5 h-5"
|
|
||||||
aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 17 14">
|
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown">
|
|
||||||
<ul class="items-center flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
|
|
||||||
<li class="flex items-center">
|
|
||||||
<button id="theme-toggle" type="button" class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm hover:cursor-pointer">
|
|
||||||
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M3.32031 11.6835C3.32031 16.6541 7.34975 20.6835 12.3203 20.6835C16.1075 20.6835 19.3483 18.3443 20.6768 15.032C19.6402 15.4486 18.5059 15.6834 17.3203 15.6834C12.3497 15.6834 8.32031 11.654 8.32031 6.68342C8.32031 5.50338 8.55165 4.36259 8.96453 3.32996C5.65605 4.66028 3.32031 7.89912 3.32031 11.6835Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M12 3V4M12 20V21M4 12H3M6.31412 6.31412L5.5 5.5M17.6859 6.31412L18.5 5.5M6.31412 17.69L5.5 18.5001M17.6859 17.69L18.5 18.5001M21 12H20M16 12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086 8 12 8C14.2091 8 16 9.79086 16 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="dark:text-white flex flex-col items-center text-xs">
|
|
||||||
<span class="flex uppercase gap-1">Today<span class="dark:text-gray-400">·</span>Last 7 days</span>
|
|
||||||
<span class="flex items-center gap-1">{{ today_played }}<span class="dark:text-gray-400">·</span>{{ last_7_played }}</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#"
|
|
||||||
class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent"
|
|
||||||
aria-current="page">Home</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button id="dropdownNavbarNewLink"
|
|
||||||
data-dropdown-toggle="dropdownNavbarNew"
|
|
||||||
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
|
|
||||||
New
|
|
||||||
<svg class="w-2.5 h-2.5 ms-2.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 10 6">
|
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<!-- Dropdown menu -->
|
|
||||||
<div id="dropdownNavbarNew"
|
|
||||||
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
|
||||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
|
|
||||||
aria-labelledby="dropdownLargeButton">
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'games:add_device' %}"
|
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'games:add_game' %}"
|
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'games:add_platform' %}"
|
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'games:add_purchase' %}"
|
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'games:add_session' %}"
|
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button id="dropdownNavbarManageLink"
|
|
||||||
data-dropdown-toggle="dropdownNavbarManage"
|
|
||||||
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
|
|
||||||
Manage
|
|
||||||
<svg class="w-2.5 h-2.5 ms-2.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 10 6">
|
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<!-- Dropdown menu -->
|
|
||||||
<div id="dropdownNavbarManage"
|
|
||||||
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
|
||||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
|
|
||||||
aria-labelledby="dropdownLargeButton">
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'games:list_devices' %}"
|
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'games:list_games' %}"
|
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'games:list_platforms' %}"
|
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'games:list_playevents' %}"
|
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'games:list_purchases' %}"
|
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'games:list_sessions' %}"
|
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'games:stats_by_year' global_current_year %}"
|
|
||||||
class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'logout' %}"
|
|
||||||
class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log
|
|
||||||
out</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{% load modal_tag %}
|
|
||||||
{% python_modal "delete-game-confirmation-modal" %}
|
|
||||||
<h1 class="text-2xl leading-6 font-medium dark:text-white text-center">Delete Game</h1>
|
|
||||||
<p class="dark:text-white text-center mt-5">
|
|
||||||
Are you sure you want to delete <strong>{{ game.name }}</strong>?
|
|
||||||
</p>
|
|
||||||
<form class=""
|
|
||||||
hx-post="{% url 'games:delete_game' game.id %}"
|
|
||||||
hx-replace-url="true"
|
|
||||||
hx-target="#main-container"
|
|
||||||
hx-select="#main-container"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
>
|
|
||||||
{% csrf_token %}
|
|
||||||
<p class="dark:text-white text-center mt-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
This will permanently delete this game and all associated data:
|
|
||||||
</p>
|
|
||||||
<ul class="dark:text-white text-center mt-1 text-sm text-gray-600 dark:text-gray-400 list-disc list-inside">
|
|
||||||
{% if session_count %}<li>{{ session_count }} session(s)</li>{% endif %}
|
|
||||||
{% if purchase_count %}<li>{{ purchase_count }} purchase(s)</li>{% endif %}
|
|
||||||
{% if playevent_count %}<li>{{ playevent_count }} play event(s)</li>{% endif %}
|
|
||||||
{% if not session_count and not purchase_count and not playevent_count %}<li>No associated data</li>{% endif %}
|
|
||||||
</ul>
|
|
||||||
<p class="dark:text-white text-center mt-3 text-sm font-medium text-red-600 dark:text-red-400">
|
|
||||||
This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
<div class="items-center mt-5">
|
|
||||||
<c-button color="red" size="lg" type="submit" class="w-full">Delete</c-button>
|
|
||||||
<c-button color="gray" size="base" class="mt-0 w-full" onclick="this.closest('#delete-game-confirmation-modal').remove()">Cancel</c-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endpython_modal %}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<div class="flex gap-2 items-center"
|
|
||||||
x-data="{
|
|
||||||
status: '{{ game.status }}',
|
|
||||||
status_display: '{{ game.get_status_display }}',
|
|
||||||
open: false,
|
|
||||||
saving: false,
|
|
||||||
setStatus(newStatus, newStatusDisplay) {
|
|
||||||
this.status = newStatus;
|
|
||||||
this.status_display = newStatusDisplay;
|
|
||||||
this.saving = true;
|
|
||||||
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
|
|
||||||
fetchWithHtmxTriggers(`/api/games/{{ game.id }}/status`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': '{{ csrf_token }}'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ status: newStatus })
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
document.body.dispatchEvent(new CustomEvent('status-changed'));
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
console.error('Failed to update status');
|
|
||||||
})
|
|
||||||
.finally(() => this.saving = false);
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<span class="flex flex-row gap-4 justify-between items-center">
|
|
||||||
{% for status_value, status_label in game_statuses %}
|
|
||||||
<template x-if="status == '{{ status_value }}'">
|
|
||||||
<c-gamestatus display="flex" status="{{ status_value }}">{{ status_label }}</c-gamestatus>
|
|
||||||
</template>
|
|
||||||
{% endfor %}
|
|
||||||
<c-icon.arrowdown />
|
|
||||||
</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">
|
|
||||||
{% for status_value, status_label in game_statuses %}
|
|
||||||
<li><a href="#" @click.prevent.stop="setStatus('{{ status_value }}', '{{ status_label }}'); open = false;" class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" :class="{ 'font-bold': status === '{{ status_value }}' }"><c-gamestatus display="flex" status="{{ status_value }}" class="text-slate-300">{{ status_label }}</c-gamestatus></a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<ul class="list-disc list-inside">
|
|
||||||
{% for change in statuschanges %}
|
|
||||||
<li class="text-slate-500">
|
|
||||||
{% if change.timestamp %}{{ change.timestamp | date:"d/m/Y H:i" }}: Changed{% else %}At some point changed{% endif %} status from <c-gamestatus :status="change.old_status">{{ change.get_old_status_display }}</c-gamestatus> to <c-gamestatus :status="change.new_status">{{ change.get_new_status_display }}</c-gamestatus> (<a href="{% url 'games:edit_statuschange' change.id %}">Edit</a>, <a href="{% url 'games:delete_statuschange' change.id %}">Delete</a>)</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{% load modal_tag %}
|
|
||||||
{% python_modal "refund-confirmation-modal" %}
|
|
||||||
<h1 class="text-2xl leading-6 font-medium dark:text-white text-center">Confirm Refund</h1>
|
|
||||||
<p class="dark:text-white text-center mt-5">
|
|
||||||
Are you sure you want to mark this purchase as refunded?
|
|
||||||
</p>
|
|
||||||
<form class="" hx-post="{% url 'games:refund_purchase' purchase_id %}" hx-target="#purchase-row-{{ purchase_id }}" hx-swap="outerHTML">
|
|
||||||
{% csrf_token %}
|
|
||||||
<p class="dark:text-white text-center mt-3 text-sm">
|
|
||||||
Games will be marked as abandoned.
|
|
||||||
</p>
|
|
||||||
<div class="items-center mt-5">
|
|
||||||
<c-button color="blue" size="lg" type="submit" class="w-full">Refund</c-button>
|
|
||||||
<c-button color="gray" size="base" class="mt-0 w-full" onclick="this.closest('#refund-confirmation-modal').remove()">Cancel</c-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endpython_modal %}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{{ form.related_purchase }}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<div class="flex gap-2 items-center"
|
|
||||||
x-data="{
|
|
||||||
originalDeviceId: {{ session.device.id|default:'null' }},
|
|
||||||
originalDeviceName: '{{ session.device.name|default:'Unknown'|escapejs }}',
|
|
||||||
deviceId: {{ session.device.id|default:'null' }},
|
|
||||||
deviceName: '{{ session.device.name|default:'Unknown'|escapejs }}',
|
|
||||||
open: false,
|
|
||||||
saving: false,
|
|
||||||
setDevice(newDeviceId, newDeviceName) {
|
|
||||||
this.deviceId = newDeviceId;
|
|
||||||
this.deviceName = newDeviceName;
|
|
||||||
this.saving = true;
|
|
||||||
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<span class="flex flex-row gap-4 justify-between items-center">
|
|
||||||
<span x-text="deviceName"></span>
|
|
||||||
<c-icon.arrowdown />
|
|
||||||
</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">
|
|
||||||
{% for device in session_devices %}
|
|
||||||
<li><a href="#" @click.prevent.stop="setDevice({{ device.id }}, '{{ device.name|escapejs }}'); open = false;" class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" :class="{ 'font-bold': deviceId === {{ device.id }} }">{{ device.name }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<c-layouts.base title="Login">
|
|
||||||
{% load static %}
|
|
||||||
<div class="flex items-center flex-col">
|
|
||||||
<h2 class="text-3xl text-white mb-8">Please log in to continue</h2>
|
|
||||||
<form method="post">
|
|
||||||
<table>
|
|
||||||
{% csrf_token %}
|
|
||||||
{{ form.as_table }}
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td>
|
|
||||||
<input type="submit" value="Login" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</form>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</c-layouts.base>
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
{% block title %}
|
|
||||||
{{ title }}
|
|
||||||
{% endblock title %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="flex self-center m-4 flex-col gap-4 [&_h1]:mb-2">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-white text-lg">No size</h1>
|
|
||||||
<c-button>
|
|
||||||
No attributes
|
|
||||||
</c-button>
|
|
||||||
<c-button color="blue">
|
|
||||||
No attributes, blue
|
|
||||||
</c-button>
|
|
||||||
<c-button color="red">
|
|
||||||
No attributes, red
|
|
||||||
</c-button>
|
|
||||||
<c-button color="green">
|
|
||||||
No attributes, green
|
|
||||||
</c-button>
|
|
||||||
<c-button color="gray">
|
|
||||||
No attributes, gray
|
|
||||||
</c-button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="text-white text-lg">No size, icons</h1>
|
|
||||||
<c-button>
|
|
||||||
<c-icon.edit />
|
|
||||||
</c-button>
|
|
||||||
<c-button>
|
|
||||||
<c-icon.finish />
|
|
||||||
</c-button>
|
|
||||||
<c-button>
|
|
||||||
<c-icon.end />
|
|
||||||
</c-button>
|
|
||||||
<c-button>
|
|
||||||
<c-icon.delete />
|
|
||||||
</c-button>
|
|
||||||
<c-button>
|
|
||||||
<c-icon.play />
|
|
||||||
</c-button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="text-white text-lg">Extra Small, icons</h1>
|
|
||||||
<c-button icon="true" size="xs">
|
|
||||||
<c-icon.edit /> Edit
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="xs">
|
|
||||||
<c-icon.finish />
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="xs">
|
|
||||||
<c-icon.end />
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="xs">
|
|
||||||
<c-icon.delete />
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="xs">
|
|
||||||
<c-icon.play />
|
|
||||||
</c-button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="text-white text-lg">Small, icons</h1>
|
|
||||||
<c-button icon="true" size="sm">
|
|
||||||
<c-icon.edit /> Edit
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="sm">
|
|
||||||
<c-icon.finish />
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="sm">
|
|
||||||
<c-icon.end />
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="sm">
|
|
||||||
<c-icon.delete />
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="sm">
|
|
||||||
<c-icon.play />
|
|
||||||
</c-button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="text-white text-lg">Base, icons</h1>
|
|
||||||
<c-button icon="true" size="base">
|
|
||||||
<c-icon.edit /> Edit
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="base">
|
|
||||||
<c-icon.finish />
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="base">
|
|
||||||
<c-icon.end />
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="base">
|
|
||||||
<c-icon.delete />
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="base">
|
|
||||||
<c-icon.play />
|
|
||||||
</c-button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="text-white text-lg">Large, icons</h1>
|
|
||||||
<c-button icon="true" size="lg">
|
|
||||||
<c-icon.edit /> Edit
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="lg">
|
|
||||||
<c-icon.finish />
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="lg">
|
|
||||||
<c-icon.end />
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="lg">
|
|
||||||
<c-icon.delete />
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="lg">
|
|
||||||
<c-icon.play />
|
|
||||||
</c-button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="text-white text-lg">Extra Large, icons</h1>
|
|
||||||
<c-button icon="true" size="xl">
|
|
||||||
<c-icon.edit /> Edit
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="xl">
|
|
||||||
<c-icon.finish />
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="xl">
|
|
||||||
<c-icon.end />
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="xl">
|
|
||||||
<c-icon.delete />
|
|
||||||
</c-button>
|
|
||||||
<c-button icon="true" size="xl">
|
|
||||||
<c-icon.play />
|
|
||||||
</c-button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="text-white text-lg">Group (sm)</h1>
|
|
||||||
<c-button-group>
|
|
||||||
<c-button-group-button-sm>
|
|
||||||
No attributes
|
|
||||||
</c-button-group-button-sm>
|
|
||||||
<c-button-group-button-sm color="blue">
|
|
||||||
No attributes, blue
|
|
||||||
</c-button-group-button-sm>
|
|
||||||
<c-button-group-button-sm color="red">
|
|
||||||
No attributes, red
|
|
||||||
</c-button-group-button-sm>
|
|
||||||
<c-button-group-button-sm color="green">
|
|
||||||
No attributes, green
|
|
||||||
</c-button-group-button-sm>
|
|
||||||
<c-button-group-button-sm color="gray">
|
|
||||||
No attributes, gray
|
|
||||||
</c-button-group-button-sm>
|
|
||||||
</c-button-group>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{% load param_utils table_header_tag table_row_tag %}
|
|
||||||
<div class="shadow-md" hx-boost="false">
|
|
||||||
<div class="relative overflow-x-auto sm:rounded-t-lg">
|
|
||||||
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
|
|
||||||
{% if header_action %}
|
|
||||||
{% python_table_header slot=header_action %}
|
|
||||||
{% endif %}
|
|
||||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 max-sm:[&_th:not(:first-child):not(:last-child)]:hidden">
|
|
||||||
<tr>
|
|
||||||
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">
|
|
||||||
{% for row in rows %}{% python_table_row data=row %}{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% if page_obj and elided_page_range %}
|
|
||||||
<nav class="flex items-center flex-col md:flex-row md:justify-between px-6 py-4 dark:bg-gray-900 sm:rounded-b-lg"
|
|
||||||
aria-label="Table navigation">
|
|
||||||
<span class="text-sm text-center font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto"><span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.start_index }}</span>—<span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.end_index }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.paginator.count }}</span></span>
|
|
||||||
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
|
|
||||||
<li>
|
|
||||||
{% if page_obj.has_previous %}
|
|
||||||
<a href="?{% param_replace page=page_obj.previous_page_number %}"
|
|
||||||
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">Previous</a>
|
|
||||||
{% else %}
|
|
||||||
<a aria-current="page"
|
|
||||||
class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-s-lg dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Previous</a>
|
|
||||||
{% endif %}
|
|
||||||
{% for page in elided_page_range %}
|
|
||||||
<li>
|
|
||||||
{% if page != page_obj.number %}
|
|
||||||
<a href="?{% param_replace page=page %}"
|
|
||||||
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">{{ page }}</a>
|
|
||||||
{% else %}
|
|
||||||
<a aria-current="page"
|
|
||||||
class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-white border bg-gray-400 border-gray-300 dark:bg-gray-900 dark:border-gray-700 dark:text-gray-200">{{ page }}</a>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
{% if page_obj.has_next %}
|
|
||||||
<a href="?{% param_replace page=page_obj.next_page_number %}"
|
|
||||||
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">Next</a>
|
|
||||||
{% else %}
|
|
||||||
<a aria-current="page"
|
|
||||||
class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-e-lg dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Next</a>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
<c-layouts.base>
|
|
||||||
{% load static %}
|
|
||||||
{% load duration_formatter %}
|
|
||||||
{% partialdef purchase-name %}
|
|
||||||
{% if purchase.type != 'game' %}
|
|
||||||
<c-gamelink :game_id=purchase.first_game.id>
|
|
||||||
{% if purchase.game_name %}{{ purchase.game_name }}{% else %}{{ purchase.name }}{% endif %} ({{ purchase.first_game.name }} {{ purchase.get_type_display }})
|
|
||||||
</c-gamelink>
|
|
||||||
{% else %}
|
|
||||||
{% if purchase.game_name %}
|
|
||||||
<c-gamelink :game_id=purchase.first_game.id :name=purchase.game_name />
|
|
||||||
{% else %}
|
|
||||||
<c-gamelink :game_id=purchase.first_game.id :name=purchase.first_game.name />
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% endpartialdef %}
|
|
||||||
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
|
|
||||||
<div class="flex justify-center items-center">
|
|
||||||
<form method="get" class="text-center">
|
|
||||||
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label>
|
|
||||||
<select name="year"
|
|
||||||
id="yearSelect"
|
|
||||||
onchange="this.form.submit();"
|
|
||||||
class="mx-2">
|
|
||||||
{% for year_item in stats_dropdown_year_range %}
|
|
||||||
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-5xl text-center my-6">Playtime</h1>
|
|
||||||
<table class="responsive-table">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Hours</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Sessions</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_sessions }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td>
|
|
||||||
</tr>
|
|
||||||
{% if total_games %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_year_games }}</td>
|
|
||||||
</tr>
|
|
||||||
{% if all_finished_this_year_count %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year_count }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year_count }}</td>
|
|
||||||
</tr>
|
|
||||||
{% if longest_session_game.id %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Longest session</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
|
||||||
{{ longest_session_time }} (<c-gamelink :game_id=longest_session_game.id :name=longest_session_game.name />)
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% if highest_session_count_game.id %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Most sessions</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
|
||||||
{{ highest_session_count }} (<c-gamelink :game_id=highest_session_count_game.id :name=highest_session_count_game.name />)
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% if highest_session_average_game.id %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Highest session average</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
|
||||||
{{ highest_session_average }} (<c-gamelink :game_id=highest_session_average_game.id :name=highest_session_average_game.name />)
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% if first_play_game.id %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">First play</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
|
||||||
<c-gamelink :game_id=first_play_game.id :name=first_play_game.name /> ({{ first_play_date }})
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% if last_play_game.id %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Last play</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
|
||||||
<c-gamelink :game_id=last_play_game.id :name=last_play_game.name /> ({{ last_play_date }})
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% if month_playtimes %}
|
|
||||||
<h1 class="text-5xl text-center my-6">Playtime per month</h1>
|
|
||||||
<table class="responsive-table">
|
|
||||||
<tbody>
|
|
||||||
{% for month in month_playtimes %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">{{ month.month | date:"F" }}</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime | format_duration }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
<h1 class="text-5xl text-center my-6">Purchases</h1>
|
|
||||||
<table class="responsive-table">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Total</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year_count }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
|
||||||
{{ all_purchased_refunded_this_year_count }} ({{ refunded_percent }}%)
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Dropped</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ dropped_count }} ({{ dropped_percentage }}%)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
|
||||||
{{ purchased_unfinished_count }} ({{ unfinished_purchases_percent }}%)
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ backlog_decrease_count }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
|
||||||
{{ total_spent | floatformat }} ({{ spent_per_game | floatformat }}/game)
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<h1 class="text-5xl text-center my-6">Games by playtime</h1>
|
|
||||||
<table class="responsive-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for game in top_10_games_by_playtime %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
|
||||||
<c-gamelink :game_id=game.id :name=game.name />
|
|
||||||
</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.total_playtime | format_duration }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<h1 class="text-5xl text-center my-6">Platforms by playtime</h1>
|
|
||||||
<table class="responsive-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Platform</th>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for item in total_playtime_per_platform %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.playtime | format_duration }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% if all_finished_this_year %}
|
|
||||||
<h1 class="text-5xl text-center my-6">Finished</h1>
|
|
||||||
<table class="responsive-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for purchase in all_finished_this_year %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
{% if this_year_finished_this_year %}
|
|
||||||
<h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1>
|
|
||||||
<table class="responsive-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for purchase in this_year_finished_this_year %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
{% if purchased_this_year_finished_this_year %}
|
|
||||||
<h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1>
|
|
||||||
<table class="responsive-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for purchase in purchased_this_year_finished_this_year %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
{% if purchased_unfinished %}
|
|
||||||
<h1 class="text-5xl text-center my-6">Unfinished Purchases</h1>
|
|
||||||
<table class="responsive-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for purchase in purchased_unfinished %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price | floatformat }}</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
{% if all_purchased_this_year %}
|
|
||||||
<h1 class="text-5xl text-center my-6">All Purchases</h1>
|
|
||||||
<table class="responsive-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
|
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for purchase in all_purchased_this_year %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.converted_price | floatformat }}</td>
|
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</c-layouts.base>
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
<c-layouts.base>
|
|
||||||
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
|
|
||||||
<div id="game-info" class="mb-10">
|
|
||||||
<div class="flex gap-5 mb-3">
|
|
||||||
<span class="text-balance max-w-120 text-4xl">
|
|
||||||
<span class="font-bold font-serif">{{ game.name }}</span>{% if game.year_released %} <c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-4 dark:text-slate-400 mb-3">
|
|
||||||
<c-popover id="popover-hours" popover_content="Total hours played" class="flex gap-2 items-center">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
class="size-6">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
|
||||||
</svg>
|
|
||||||
{{ game.playtime_formatted }}
|
|
||||||
</c-popover>
|
|
||||||
<c-popover id="popover-sessions" popover_content="Number of sessions" class="flex gap-2 items-center">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
class="size-6">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5" />
|
|
||||||
</svg>
|
|
||||||
{{ session_count }}
|
|
||||||
</c-popover>
|
|
||||||
<c-popover id="popover-average" popover_content="Average playtime per session" class="flex gap-2 items-center">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
class="size-6">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" />
|
|
||||||
</svg>
|
|
||||||
{{ session_average_without_manual }}
|
|
||||||
</c-popover>
|
|
||||||
<c-popover id="popover-playrange" popover_content="Earliest and latest dates played" class="flex gap-2 items-center">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
class="size-6">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" />
|
|
||||||
</svg>
|
|
||||||
{{ playrange }}
|
|
||||||
</c-popover>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4">
|
|
||||||
<div class="flex gap-2 items-center">
|
|
||||||
<span class="uppercase">Original year</span>
|
|
||||||
<span class="text-black dark:text-slate-300">{{ game.original_year_released }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 items-center"
|
|
||||||
>
|
|
||||||
<span class="uppercase">Status</span>
|
|
||||||
{% include "partials/gamestatus_selector.html" %}
|
|
||||||
{% if game.mastered %}👑{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 items-center"
|
|
||||||
x-data="{ open: false }"
|
|
||||||
>
|
|
||||||
<span class="uppercase">Played</span>
|
|
||||||
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: {{ game.playevents.count }} }">
|
|
||||||
<a href="{% url 'games:add_playevent' %}">
|
|
||||||
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-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:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
|
||||||
<span x-text="played"></span> times
|
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-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:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
|
|
||||||
<c-icon.arrowdown />
|
|
||||||
<div
|
|
||||||
class="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"
|
|
||||||
x-show="open"
|
|
||||||
>
|
|
||||||
<ul
|
|
||||||
class=""
|
|
||||||
>
|
|
||||||
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
|
|
||||||
<a href="{% url 'games:add_playevent_for_game' game.id %}">Add playthrough...</a>
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
x-on:click="createPlayEvent"
|
|
||||||
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
|
|
||||||
>
|
|
||||||
Played times +1
|
|
||||||
</li>
|
|
||||||
<script>
|
|
||||||
function createPlayEvent() {
|
|
||||||
this.played++;
|
|
||||||
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
|
|
||||||
fetchWithHtmxTriggers('{% url 'api-1.0.0:create_playevent' %}', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'X-CSRFToken': '{{ csrf_token }}', 'Content-Type': 'application/json' },
|
|
||||||
body: '{"game_id": {{ game.id }}}'
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
this.played--;
|
|
||||||
console.error('Failed to record play');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="flex gap-2 items-center">
|
|
||||||
<span class="uppercase">Platform</span>
|
|
||||||
<span class="text-black dark:text-slate-300">{{ game.platform }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="inline-flex rounded-md shadow-xs mb-3" role="group">
|
|
||||||
<a href="{% url 'games:edit_game' game.id %}">
|
|
||||||
<button type="button"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-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:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
<a href="#" hx-get="{% url 'games:delete_game_confirmation' game.id %}" hx-target="#global-modal-container">
|
|
||||||
<button type="button"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-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-red-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-6">
|
|
||||||
<c-h1 :badge="purchase_count">Purchases</c-h1>
|
|
||||||
{% if purchase_count %}
|
|
||||||
{% include "simple_table.html" with rows=purchase_data.rows columns=purchase_data.columns page_obj=None elided_page_range=None header_action=None %}
|
|
||||||
{% else %}
|
|
||||||
No purchases yet.
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="mb-6">
|
|
||||||
<c-h1 :badge="session_count">Sessions</c-h1>
|
|
||||||
{% if session_count %}
|
|
||||||
{% include "simple_table.html" with rows=session_data.rows columns=session_data.columns header_action=session_data.header_action page_obj=session_page_obj elided_page_range=session_elided_page_range %}
|
|
||||||
{% else %}
|
|
||||||
No sessions yet.
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<!-- list all playevents -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<c-h1 :badge="playevent_count">Play Events</c-h1>
|
|
||||||
{% if playevent_count %}
|
|
||||||
{% include "simple_table.html" with rows=playevent_data.rows columns=playevent_data.columns page_obj=None elided_page_range=None header_action=None %}
|
|
||||||
{% else %}
|
|
||||||
No play events yet.
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="mb-6" id="history-container" hx-get="" hx-trigger="status-changed from:body" hx-select="#history-container" hx-swap="outerHTML">
|
|
||||||
<c-h1 :badge="statuschange_count">History</c-h1>
|
|
||||||
{% include "partials/history.html" %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
function getSessionCount() {
|
|
||||||
return document.getElementById('session-count').textContent.match("[0-9]+");
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</c-layouts.base>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<c-layouts.base>
|
|
||||||
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-5 mb-3">
|
|
||||||
<div class="font-bold font-serif text-slate-500 text-2xl">
|
|
||||||
{% if not purchase.name %}
|
|
||||||
Unnamed purchase
|
|
||||||
{% else %}
|
|
||||||
{{ purchase.name }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<span class="text-balance max-w-120 text-4xl">
|
|
||||||
<span class="font-bold font-serif">
|
|
||||||
{{ purchase.date_purchased }} ({{ purchase.num_purchases }} game{{ purchase.num_purchases|pluralize}})
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div class="inline-flex rounded-md shadow-xs mb-3" role="group">
|
|
||||||
<a href="{% url 'games:edit_purchase' purchase.id %}">
|
|
||||||
<button type="button"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-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:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'games:delete_purchase' purchase.id %}">
|
|
||||||
<button type="button"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-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-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
Price:
|
|
||||||
<c-price-converted>{{ purchase.standardized_price }}</c-price-converted>
|
|
||||||
({{ purchase.price | floatformat:2 }} {{ purchase.price_currency }})
|
|
||||||
</p>
|
|
||||||
<p>Price per game: <c-price-converted>{{ purchase.price_per_game | floatformat:0 }} {{ purchase.converted_currency }}</c-price-converted> </p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 class="text-base">Items:</h2>
|
|
||||||
<ul class="list-disc list-inside">
|
|
||||||
{% for game in purchase.games.all %}
|
|
||||||
<li><c-gamelink :game_id=game.id :name=game.name /></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</c-layouts.base>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from django import template
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
from common.components import ButtonGroup
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag(takes_context=True)
|
|
||||||
def python_button_group(context, buttons=None):
|
|
||||||
"""Template tag that delegates button group rendering to ButtonGroup().
|
|
||||||
|
|
||||||
Supports two modes:
|
|
||||||
- buttons list passed: renders button links via ButtonGroup()
|
|
||||||
- no buttons (slot only): passes through children (showcase usage)
|
|
||||||
"""
|
|
||||||
if buttons is not None:
|
|
||||||
return ButtonGroup(buttons)
|
|
||||||
# Slot mode: render children directly (for <c-button-group> with direct children)
|
|
||||||
slot = context.get("slot", "")
|
|
||||||
return mark_safe(slot) if slot else ""
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
from django import template
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
from common.components import Button
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
|
||||||
def python_button(
|
|
||||||
color: str = "blue",
|
|
||||||
size: str = "base",
|
|
||||||
icon: str = "",
|
|
||||||
type: str = "button",
|
|
||||||
class_: str = "",
|
|
||||||
hx_get: str = "",
|
|
||||||
hx_target: str = "",
|
|
||||||
hx_swap: str = "",
|
|
||||||
title: str = "",
|
|
||||||
onclick: str = "",
|
|
||||||
data_target: str = "",
|
|
||||||
data_type: str = "",
|
|
||||||
name: str = "",
|
|
||||||
slot: str = "",
|
|
||||||
) -> str:
|
|
||||||
"""Template tag that delegates to the Python Button() component."""
|
|
||||||
|
|
||||||
extra_attrs: list[tuple[str, str]] = []
|
|
||||||
if class_:
|
|
||||||
extra_attrs.append(("class", class_))
|
|
||||||
if data_target:
|
|
||||||
extra_attrs.append(("data-target", data_target))
|
|
||||||
if data_type:
|
|
||||||
extra_attrs.append(("data-type", data_type))
|
|
||||||
|
|
||||||
children = [mark_safe(slot)] if slot else []
|
|
||||||
|
|
||||||
return Button(
|
|
||||||
attributes=extra_attrs or None,
|
|
||||||
children=children or None,
|
|
||||||
size=size,
|
|
||||||
icon=icon if isinstance(icon, bool) else str(icon).lower() == "true",
|
|
||||||
color=color,
|
|
||||||
type=type,
|
|
||||||
hx_get=hx_get,
|
|
||||||
hx_target=hx_target,
|
|
||||||
hx_swap=hx_swap,
|
|
||||||
title=title,
|
|
||||||
onclick=onclick,
|
|
||||||
name=name,
|
|
||||||
)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from django import template
|
|
||||||
|
|
||||||
from common.time import durationformat, format_duration
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
@register.filter(name="format_duration")
|
|
||||||
def filter_format_duration(duration: timedelta, argument: str = durationformat):
|
|
||||||
return format_duration(duration, format_string=argument)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from django import template
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
from common.components import GameLink
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
|
||||||
def python_gamelink(game_id: int, name: str = "", slot: str = "") -> str:
|
|
||||||
children = [mark_safe(slot)] if slot else []
|
|
||||||
return GameLink(game_id=game_id, name=name, children=children)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from django import template
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
from common.components import GameStatus
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
|
||||||
def python_gamestatus(status: str = "u", display: str = "", class_: str = "", slot: str = "") -> str:
|
|
||||||
children = [mark_safe(slot)] if slot else []
|
|
||||||
return GameStatus(children=children, status=status, display=display, class_=class_)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from django import template
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
from common.components import H1
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
|
||||||
def python_h1(badge: str = "", slot: str = "") -> str:
|
|
||||||
children = [mark_safe(slot)] if slot else []
|
|
||||||
return H1(children=children, badge=badge)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import markdown
|
|
||||||
from django import template
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
@register.filter(name="markdown")
|
|
||||||
def markdown_format(text):
|
|
||||||
return mark_safe(markdown.markdown(text))
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from django import template
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
from common.components import Modal
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
class ModalNode(template.Node):
|
|
||||||
def __init__(self, modal_id, nodelist):
|
|
||||||
self.modal_id = template.Variable(modal_id)
|
|
||||||
self.nodelist = nodelist
|
|
||||||
|
|
||||||
def render(self, context):
|
|
||||||
modal_id = self.modal_id.resolve(context)
|
|
||||||
content = self.nodelist.render(context)
|
|
||||||
return str(
|
|
||||||
Modal(modal_id=modal_id, children=[mark_safe(content)])
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@register.tag("python_modal")
|
|
||||||
def do_modal(parser, token):
|
|
||||||
bits = token.split_contents()
|
|
||||||
tag_name = bits[0]
|
|
||||||
if len(bits) != 2:
|
|
||||||
raise template.TemplateSyntaxError(
|
|
||||||
f"{tag_name} requires exactly one argument: the modal ID"
|
|
||||||
)
|
|
||||||
modal_id = bits[1]
|
|
||||||
nodelist = parser.parse(("endpython_modal",))
|
|
||||||
parser.delete_first_token()
|
|
||||||
return ModalNode(modal_id, nodelist)
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
from django import template
|
|
||||||
from django.http import QueryDict
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag(takes_context=True)
|
|
||||||
def param_replace(context: dict[Any, Any], **kwargs):
|
|
||||||
"""
|
|
||||||
Return encoded URL parameters that are the same as the current
|
|
||||||
request's parameters, only with the specified GET parameters added or changed.
|
|
||||||
"""
|
|
||||||
d: QueryDict = context["request"].GET.copy()
|
|
||||||
for k, v in kwargs.items():
|
|
||||||
d[k] = v
|
|
||||||
return d.urlencode()
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
from django import template
|
|
||||||
|
|
||||||
from common.components import _popover_html
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
|
||||||
def python_popover(
|
|
||||||
popover_content: str = "",
|
|
||||||
wrapped_content: str = "",
|
|
||||||
wrapped_classes: str = "",
|
|
||||||
id: str = "",
|
|
||||||
slot: str = "",
|
|
||||||
) -> str:
|
|
||||||
"""Template tag that generates popover HTML natively.
|
|
||||||
|
|
||||||
Called from the cotton/popover.html shim template.
|
|
||||||
Delegates HTML generation to _popover_html().
|
|
||||||
"""
|
|
||||||
return _popover_html(
|
|
||||||
id=id,
|
|
||||||
popover_content=popover_content,
|
|
||||||
wrapped_content=wrapped_content,
|
|
||||||
wrapped_classes=wrapped_classes,
|
|
||||||
slot=slot,
|
|
||||||
)
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
from django import template
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
from common.components import PriceConverted
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
|
||||||
def python_price_converted(slot: str = "") -> str:
|
|
||||||
return PriceConverted(children=[mark_safe(slot)] if slot else [])
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from django import template
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
from common.components import TableHeader
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
|
||||||
def python_table_header(slot: str = "") -> str:
|
|
||||||
children = [mark_safe(slot)] if slot else []
|
|
||||||
return TableHeader(children=children)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from django import template
|
|
||||||
|
|
||||||
from common.components import TableRow
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
|
||||||
def python_table_row(data=None) -> str:
|
|
||||||
return TableRow(data=data)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from django import template
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
from common.components import TableTd
|
|
||||||
|
|
||||||
register = template.Library()
|
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
|
||||||
def python_table_td(slot: str = "") -> str:
|
|
||||||
children = [mark_safe(slot)] if slot else []
|
|
||||||
return TableTd(children=children)
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
app_name = "games"
|
|
||||||
|
|
||||||
from games.api import api
|
|
||||||
from games.views import (
|
from games.views import (
|
||||||
device,
|
device,
|
||||||
game,
|
game,
|
||||||
@@ -14,6 +11,8 @@ from games.views import (
|
|||||||
statuschange,
|
statuschange,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app_name = "games"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", general.index, name="index"),
|
path("", general.index, name="index"),
|
||||||
path("device/add", device.add_device, name="add_device"),
|
path("device/add", device.add_device, name="add_device"),
|
||||||
@@ -115,13 +114,11 @@ urlpatterns = [
|
|||||||
path(
|
path(
|
||||||
"session/add/from-game/<int:session_id>",
|
"session/add/from-game/<int:session_id>",
|
||||||
session.new_session_from_existing_session,
|
session.new_session_from_existing_session,
|
||||||
{"template": "view_game.html#session-info"},
|
|
||||||
name="view_game_start_session_from_session",
|
name="view_game_start_session_from_session",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"session/add/from-list/<int:session_id>",
|
"session/add/from-list/<int:session_id>",
|
||||||
session.new_session_from_existing_session,
|
session.new_session_from_existing_session,
|
||||||
{"template": "list_sessions.html#session-row"},
|
|
||||||
name="list_sessions_start_session_from_session",
|
name="list_sessions_start_session_from_session",
|
||||||
),
|
),
|
||||||
path("session/<int:session_id>/edit", session.edit_session, name="edit_session"),
|
path("session/<int:session_id>/edit", session.edit_session, name="edit_session"),
|
||||||
@@ -133,35 +130,33 @@ urlpatterns = [
|
|||||||
path(
|
path(
|
||||||
"session/end/from-game/<int:session_id>",
|
"session/end/from-game/<int:session_id>",
|
||||||
session.end_session,
|
session.end_session,
|
||||||
{"template": "view_game.html#session-info"},
|
|
||||||
name="view_game_end_session",
|
name="view_game_end_session",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"session/end/from-list/<int:session_id>",
|
"session/end/from-list/<int:session_id>",
|
||||||
session.end_session,
|
session.end_session,
|
||||||
{"template": "list_sessions.html#session-row"},
|
|
||||||
name="list_sessions_end_session",
|
name="list_sessions_end_session",
|
||||||
),
|
),
|
||||||
path("session/list", session.list_sessions, name="list_sessions"),
|
path("session/list", session.list_sessions, name="list_sessions"),
|
||||||
path("session/search", session.search_sessions, name="search_sessions"),
|
path("session/search", session.search_sessions, name="search_sessions"),
|
||||||
path(
|
path(
|
||||||
"statuschange/add",
|
"statuschange/add",
|
||||||
statuschange.AddStatusChangeView.as_view(),
|
statuschange.add_statuschange,
|
||||||
name="add_statuschange",
|
name="add_statuschange",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"statuschange/edit/<int:statuschange_id>",
|
"statuschange/edit/<int:statuschange_id>",
|
||||||
statuschange.EditStatusChangeView.as_view(),
|
statuschange.edit_statuschange,
|
||||||
name="edit_statuschange",
|
name="edit_statuschange",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"statuschange/delete/<int:pk>",
|
"statuschange/delete/<int:pk>",
|
||||||
statuschange.GameStatusChangeDeleteView.as_view(),
|
statuschange.delete_statuschange,
|
||||||
name="delete_statuschange",
|
name="delete_statuschange",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"statuschange/list",
|
"statuschange/list",
|
||||||
statuschange.GameStatusChangeListView.as_view(),
|
statuschange.list_statuschanges,
|
||||||
name="list_statuschanges",
|
name="list_statuschanges",
|
||||||
),
|
),
|
||||||
path("stats/", general.stats_alltime, name="stats_alltime"),
|
path("stats/", general.stats_alltime, name="stats_alltime"),
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Authentication views rendered with the Python layout (replaces
|
||||||
|
registration/login.html)."""
|
||||||
|
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
|
from common.components import Component, CsrfInput, Div, Input
|
||||||
|
from common.layout import render_page
|
||||||
|
|
||||||
|
|
||||||
|
def _login_content(form, request) -> SafeText:
|
||||||
|
table = Component(
|
||||||
|
tag_name="table",
|
||||||
|
children=[
|
||||||
|
CsrfInput(request),
|
||||||
|
mark_safe(str(form.as_table())),
|
||||||
|
Component(
|
||||||
|
tag_name="tr",
|
||||||
|
children=[
|
||||||
|
Component(tag_name="td"),
|
||||||
|
Component(
|
||||||
|
tag_name="td",
|
||||||
|
children=[
|
||||||
|
Input(type="submit", attributes=[("value", "Login")])
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return Div(
|
||||||
|
[("class", "flex items-center flex-col")],
|
||||||
|
[
|
||||||
|
Component(
|
||||||
|
tag_name="h2",
|
||||||
|
attributes=[("class", "text-3xl text-white mb-8")],
|
||||||
|
children=["Please log in to continue"],
|
||||||
|
),
|
||||||
|
Component(
|
||||||
|
tag_name="form",
|
||||||
|
attributes=[("method", "post")],
|
||||||
|
children=[table],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginView(auth_views.LoginView):
|
||||||
|
"""Django's LoginView, but the page body is built in Python."""
|
||||||
|
|
||||||
|
def render_to_response(self, context, **response_kwargs) -> HttpResponse:
|
||||||
|
return render_page(
|
||||||
|
self.request,
|
||||||
|
_login_content(context["form"], self.request),
|
||||||
|
title="Login",
|
||||||
|
)
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from common.components import A, Button, ButtonGroup, Icon
|
from common.components import (
|
||||||
|
A,
|
||||||
|
AddForm,
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
Icon,
|
||||||
|
paginated_table_content,
|
||||||
|
)
|
||||||
|
from common.layout import render_page
|
||||||
from common.time import dateformat, local_strftime
|
from common.time import dateformat, local_strftime
|
||||||
from games.forms import DeviceForm
|
from games.forms import DeviceForm
|
||||||
from games.models import Device
|
from games.models import Device
|
||||||
@@ -14,7 +20,6 @@ from games.models import Device
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def list_devices(request: HttpRequest) -> HttpResponse:
|
def list_devices(request: HttpRequest) -> HttpResponse:
|
||||||
context: dict[Any, Any] = {}
|
|
||||||
page_number = request.GET.get("page", 1)
|
page_number = request.GET.get("page", 1)
|
||||||
limit = request.GET.get("limit", 10)
|
limit = request.GET.get("limit", 10)
|
||||||
devices = Device.objects.order_by("-created_at")
|
devices = Device.objects.order_by("-created_at")
|
||||||
@@ -23,18 +28,13 @@ def list_devices(request: HttpRequest) -> HttpResponse:
|
|||||||
paginator = Paginator(devices, limit)
|
paginator = Paginator(devices, limit)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
devices = page_obj.object_list
|
devices = page_obj.object_list
|
||||||
|
elided_page_range = (
|
||||||
context = {
|
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||||
"title": "Manage devices",
|
|
||||||
"page_obj": page_obj or None,
|
|
||||||
"elided_page_range": (
|
|
||||||
page_obj.paginator.get_elided_page_range(
|
|
||||||
page_number, on_each_side=1, on_ends=1
|
|
||||||
)
|
|
||||||
if page_obj
|
if page_obj
|
||||||
else None
|
else None
|
||||||
),
|
)
|
||||||
"data": {
|
|
||||||
|
data = {
|
||||||
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
|
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
|
||||||
"columns": [
|
"columns": [
|
||||||
"Name",
|
"Name",
|
||||||
@@ -64,9 +64,14 @@ def list_devices(request: HttpRequest) -> HttpResponse:
|
|||||||
]
|
]
|
||||||
for device in devices
|
for device in devices
|
||||||
],
|
],
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return render(request, "list_purchases.html", context)
|
content = paginated_table_content(
|
||||||
|
data,
|
||||||
|
page_obj=page_obj,
|
||||||
|
elided_page_range=elided_page_range,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return render_page(request, content, title="Manage devices")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -77,8 +82,7 @@ def edit_device(request: HttpRequest, device_id: int = 0) -> HttpResponse:
|
|||||||
form.save()
|
form.save()
|
||||||
return redirect("games:list_devices")
|
return redirect("games:list_devices")
|
||||||
|
|
||||||
context: dict[str, Any] = {"form": form, "title": "Edit device"}
|
return render_page(request, AddForm(form, request=request), title="Edit device")
|
||||||
return render(request, "add.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -90,12 +94,9 @@ def delete_device(request: HttpRequest, device_id: int) -> HttpResponse:
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def add_device(request: HttpRequest) -> HttpResponse:
|
def add_device(request: HttpRequest) -> HttpResponse:
|
||||||
context: dict[str, Any] = {}
|
|
||||||
form = DeviceForm(request.POST or None)
|
form = DeviceForm(request.POST or None)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect("games:index")
|
return redirect("games:index")
|
||||||
|
|
||||||
context["form"] = form
|
return render_page(request, AddForm(form, request=request), title="Add New Device")
|
||||||
context["title"] = "Add New Device"
|
|
||||||
return render(request, "add.html", context)
|
|
||||||
|
|||||||
@@ -2,25 +2,39 @@ from typing import Any
|
|||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Prefetch, Q
|
from django.middleware.csrf import get_token
|
||||||
|
from django.db.models import Q
|
||||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.template.loader import render_to_string
|
from django.template.defaultfilters import date as date_filter
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
|
AddForm,
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
|
Component,
|
||||||
|
CsrfInput,
|
||||||
Div,
|
Div,
|
||||||
|
GameStatus,
|
||||||
|
GameStatusSelector,
|
||||||
|
H1,
|
||||||
Icon,
|
Icon,
|
||||||
SearchField,
|
SearchField,
|
||||||
LinkedPurchase,
|
LinkedPurchase,
|
||||||
|
Modal,
|
||||||
|
ModuleScript,
|
||||||
NameWithIcon,
|
NameWithIcon,
|
||||||
Popover,
|
Popover,
|
||||||
PopoverTruncated,
|
PopoverTruncated,
|
||||||
PurchasePrice,
|
PurchasePrice,
|
||||||
|
SimpleTable,
|
||||||
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
|
from common.icons import get_icon
|
||||||
|
from common.layout import render_page
|
||||||
from common.time import (
|
from common.time import (
|
||||||
dateformat,
|
dateformat,
|
||||||
format_duration,
|
format_duration,
|
||||||
@@ -29,14 +43,13 @@ from common.time import (
|
|||||||
)
|
)
|
||||||
from common.utils import build_dynamic_filter, safe_division, truncate
|
from common.utils import build_dynamic_filter, safe_division, truncate
|
||||||
from games.forms import GameForm
|
from games.forms import GameForm
|
||||||
from games.models import Game, Purchase
|
from games.models import Game
|
||||||
from games.views.general import use_custom_redirect
|
from games.views.general import use_custom_redirect
|
||||||
from games.views.playevent import create_playevent_tabledata
|
from games.views.playevent import create_playevent_tabledata
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||||
context: dict[Any, Any] = {}
|
|
||||||
page_number = request.GET.get("page", 1)
|
page_number = request.GET.get("page", 1)
|
||||||
limit = request.GET.get("limit", 10)
|
limit = request.GET.get("limit", 10)
|
||||||
games = Game.objects.order_by("-created_at")
|
games = Game.objects.order_by("-created_at")
|
||||||
@@ -66,17 +79,13 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
|||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
games = page_obj.object_list
|
games = page_obj.object_list
|
||||||
|
|
||||||
context = {
|
elided_page_range = (
|
||||||
"title": "Manage games",
|
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||||
"page_obj": page_obj or None,
|
|
||||||
"elided_page_range": (
|
|
||||||
page_obj.paginator.get_elided_page_range(
|
|
||||||
page_number, on_each_side=1, on_ends=1
|
|
||||||
)
|
|
||||||
if page_obj
|
if page_obj
|
||||||
else None
|
else None
|
||||||
),
|
)
|
||||||
"data": {
|
|
||||||
|
data = {
|
||||||
"header_action": Div(
|
"header_action": Div(
|
||||||
children=[
|
children=[
|
||||||
SearchField(search_string=search_string),
|
SearchField(search_string=search_string),
|
||||||
@@ -102,14 +111,7 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
|||||||
else "(identical)"
|
else "(identical)"
|
||||||
),
|
),
|
||||||
game.year_released,
|
game.year_released,
|
||||||
render_to_string(
|
GameStatusSelector(game, Game.Status.choices, get_token(request)),
|
||||||
"partials/gamestatus_selector.html",
|
|
||||||
{
|
|
||||||
"game": game,
|
|
||||||
"game_statuses": Game.Status.choices,
|
|
||||||
},
|
|
||||||
request=request,
|
|
||||||
),
|
|
||||||
game.wikidata,
|
game.wikidata,
|
||||||
local_strftime(game.created_at, dateformat),
|
local_strftime(game.created_at, dateformat),
|
||||||
ButtonGroup(
|
ButtonGroup(
|
||||||
@@ -129,14 +131,18 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
|||||||
]
|
]
|
||||||
for game in games
|
for game in games
|
||||||
],
|
],
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return render(request, "list_purchases.html", context)
|
content = paginated_table_content(
|
||||||
|
data,
|
||||||
|
page_obj=page_obj,
|
||||||
|
elided_page_range=elided_page_range,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return render_page(request, content, title="Manage games")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def add_game(request: HttpRequest) -> HttpResponse:
|
def add_game(request: HttpRequest) -> HttpResponse:
|
||||||
context: dict[str, Any] = {}
|
|
||||||
form = GameForm(request.POST or None)
|
form = GameForm(request.POST or None)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
game = form.save()
|
game = form.save()
|
||||||
@@ -147,27 +153,154 @@ def add_game(request: HttpRequest) -> HttpResponse:
|
|||||||
else:
|
else:
|
||||||
return redirect("games:list_games")
|
return redirect("games:list_games")
|
||||||
|
|
||||||
context["form"] = form
|
return render_page(
|
||||||
context["title"] = "Add New Game"
|
request,
|
||||||
context["script_name"] = "add_game.js"
|
AddForm(
|
||||||
return render(request, "add_game.html", context)
|
form,
|
||||||
|
request=request,
|
||||||
|
additional_row=Button(
|
||||||
|
[],
|
||||||
|
"Submit & Create Purchase",
|
||||||
|
color="gray",
|
||||||
|
type="submit",
|
||||||
|
name="submit_and_redirect",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title="Add New Game",
|
||||||
|
scripts=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(
|
||||||
|
Component(tag_name="li", children=[f"{session_count} session(s)"])
|
||||||
|
)
|
||||||
|
if purchase_count:
|
||||||
|
data_items.append(
|
||||||
|
Component(tag_name="li", children=[f"{purchase_count} purchase(s)"])
|
||||||
|
)
|
||||||
|
if playevent_count:
|
||||||
|
data_items.append(
|
||||||
|
Component(tag_name="li", children=[f"{playevent_count} play event(s)"])
|
||||||
|
)
|
||||||
|
if not (session_count or purchase_count or playevent_count):
|
||||||
|
data_items.append(Component(tag_name="li", children=["No associated data"]))
|
||||||
|
|
||||||
|
form = Component(
|
||||||
|
tag_name="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),
|
||||||
|
Component(
|
||||||
|
tag_name="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:"
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Component(
|
||||||
|
tag_name="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,
|
||||||
|
),
|
||||||
|
Component(
|
||||||
|
tag_name="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=[
|
||||||
|
Component(
|
||||||
|
tag_name="h1",
|
||||||
|
attributes=[
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"text-2xl leading-6 font-medium dark:text-white text-center",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
children=["Delete Game"],
|
||||||
|
),
|
||||||
|
Component(
|
||||||
|
tag_name="p",
|
||||||
|
attributes=[("class", "dark:text-white text-center mt-5")],
|
||||||
|
children=[
|
||||||
|
"Are you sure you want to delete ",
|
||||||
|
Component(tag_name="strong", children=[game.name]),
|
||||||
|
"?",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
form,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def delete_game_confirmation(request: HttpRequest, game_id: int) -> HttpResponse:
|
def delete_game_confirmation(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||||
game = get_object_or_404(Game, id=game_id)
|
game = get_object_or_404(Game, id=game_id)
|
||||||
session_count = game.sessions.count()
|
return HttpResponse(
|
||||||
purchase_count = game.purchases.count()
|
_delete_game_confirmation_modal(
|
||||||
playevent_count = game.playevents.count()
|
game,
|
||||||
return render(
|
game.sessions.count(),
|
||||||
|
game.purchases.count(),
|
||||||
|
game.playevents.count(),
|
||||||
request,
|
request,
|
||||||
"partials/delete_game_confirmation.html",
|
)
|
||||||
{
|
|
||||||
"game": game,
|
|
||||||
"session_count": session_count,
|
|
||||||
"purchase_count": purchase_count,
|
|
||||||
"playevent_count": playevent_count,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -181,35 +314,224 @@ def delete_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||||||
@login_required
|
@login_required
|
||||||
@use_custom_redirect
|
@use_custom_redirect
|
||||||
def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||||
context = {}
|
|
||||||
purchase = get_object_or_404(Game, id=game_id)
|
purchase = get_object_or_404(Game, id=game_id)
|
||||||
form = GameForm(request.POST or None, instance=purchase)
|
form = GameForm(request.POST or None, instance=purchase)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect("games:list_sessions")
|
return redirect("games:list_sessions")
|
||||||
context["title"] = "Edit Game"
|
return render_page(request, AddForm(form, request=request), title="Edit Game")
|
||||||
context["form"] = form
|
|
||||||
return render(request, "add.html", context)
|
|
||||||
|
# --- view_game content builders -------------------------------------------
|
||||||
|
|
||||||
|
_STAT_SVGS = {
|
||||||
|
"hours": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>',
|
||||||
|
"sessions": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5" /></svg>',
|
||||||
|
"average": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" /></svg>',
|
||||||
|
"playrange": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>',
|
||||||
|
}
|
||||||
|
|
||||||
|
_PLAYED_ROW_TEMPLATE = """<div class="flex gap-2 items-center" x-data="{ open: false }">
|
||||||
|
<span class="uppercase">Played</span>
|
||||||
|
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: @@PLAYED_COUNT@@ }">
|
||||||
|
<a href="@@ADD_PE@@">
|
||||||
|
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-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:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
||||||
|
<span x-text="played"></span> times
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-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:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
|
||||||
|
@@ARROWDOWN@@
|
||||||
|
<div
|
||||||
|
class="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"
|
||||||
|
x-show="open"
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
class=""
|
||||||
|
>
|
||||||
|
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
|
||||||
|
<a href="@@ADD_PE_FOR_GAME@@">Add playthrough...</a>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
x-on:click="createPlayEvent"
|
||||||
|
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
|
||||||
|
>
|
||||||
|
Played times +1
|
||||||
|
</li>
|
||||||
|
<script>
|
||||||
|
function createPlayEvent() {
|
||||||
|
this.played++;
|
||||||
|
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
|
||||||
|
fetchWithHtmxTriggers('@@API_CREATE@@', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRFToken': '@@CSRF@@', 'Content-Type': 'application/json' },
|
||||||
|
body: '{"game_id": @@GAME_ID@@}'
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.played--;
|
||||||
|
console.error('Failed to record play');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _played_row(game: Game, request: HttpRequest) -> SafeText:
|
||||||
|
"""The 'Played N times' control with its Alpine.js dropdown."""
|
||||||
|
replacements = {
|
||||||
|
"@@PLAYED_COUNT@@": str(game.playevents.count()),
|
||||||
|
"@@ADD_PE@@": reverse("games:add_playevent"),
|
||||||
|
"@@ARROWDOWN@@": get_icon("arrowdown"),
|
||||||
|
"@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]),
|
||||||
|
"@@API_CREATE@@": reverse("api-1.0.0:create_playevent"),
|
||||||
|
"@@CSRF@@": get_token(request),
|
||||||
|
"@@GAME_ID@@": str(game.id),
|
||||||
|
}
|
||||||
|
html = _PLAYED_ROW_TEMPLATE
|
||||||
|
for token, value in replacements.items():
|
||||||
|
html = html.replace(token, value)
|
||||||
|
return mark_safe(html)
|
||||||
|
|
||||||
|
|
||||||
|
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
|
||||||
|
return Popover(
|
||||||
|
popover_content=tooltip,
|
||||||
|
wrapped_classes="flex gap-2 items-center",
|
||||||
|
id=popover_id,
|
||||||
|
children=[mark_safe(_STAT_SVGS[svg_key]), str(value)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _meta_row(
|
||||||
|
label: str, value: SafeText | str, extra: SafeText | str = ""
|
||||||
|
) -> SafeText:
|
||||||
|
children: list[SafeText | str] = [
|
||||||
|
Component(
|
||||||
|
tag_name="span", attributes=[("class", "uppercase")], children=[label]
|
||||||
|
),
|
||||||
|
value,
|
||||||
|
]
|
||||||
|
if extra:
|
||||||
|
children.append(extra)
|
||||||
|
return Div([("class", "flex gap-2 items-center")], children)
|
||||||
|
|
||||||
|
|
||||||
|
def _game_action_buttons(game: Game) -> SafeText:
|
||||||
|
edit_class = (
|
||||||
|
"px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
|
||||||
|
"rounded-s-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:text-white dark:hover:text-white dark:hover:bg-gray-700 "
|
||||||
|
"dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer"
|
||||||
|
)
|
||||||
|
delete_class = (
|
||||||
|
"px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
|
||||||
|
"rounded-e-lg hover:bg-red-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-red-700 "
|
||||||
|
"dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer"
|
||||||
|
)
|
||||||
|
edit_link = Component(
|
||||||
|
tag_name="a",
|
||||||
|
attributes=[("href", reverse("games:edit_game", args=[game.id]))],
|
||||||
|
children=[
|
||||||
|
Component(
|
||||||
|
tag_name="button",
|
||||||
|
attributes=[("type", "button"), ("class", edit_class)],
|
||||||
|
children=["Edit"],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
delete_link = Component(
|
||||||
|
tag_name="a",
|
||||||
|
attributes=[
|
||||||
|
("href", "#"),
|
||||||
|
("hx-get", reverse("games:delete_game_confirmation", args=[game.id])),
|
||||||
|
("hx-target", "#global-modal-container"),
|
||||||
|
],
|
||||||
|
children=[
|
||||||
|
Component(
|
||||||
|
tag_name="button",
|
||||||
|
attributes=[("type", "button"), ("class", delete_class)],
|
||||||
|
children=["Delete"],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return Div(
|
||||||
|
[("class", "inline-flex rounded-md shadow-xs mb-3"), ("role", "group")],
|
||||||
|
[edit_link, delete_link],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _game_history(statuschanges) -> SafeText:
|
||||||
|
items = []
|
||||||
|
for change in statuschanges:
|
||||||
|
if change.timestamp:
|
||||||
|
prefix = f"{date_filter(change.timestamp, 'd/m/Y H:i')}: Changed"
|
||||||
|
else:
|
||||||
|
prefix = "At some point changed"
|
||||||
|
old_status = GameStatus(
|
||||||
|
status=change.old_status or "u",
|
||||||
|
children=[change.get_old_status_display() if change.old_status else "-"],
|
||||||
|
)
|
||||||
|
new_status = GameStatus(
|
||||||
|
status=change.new_status,
|
||||||
|
children=[change.get_new_status_display()],
|
||||||
|
)
|
||||||
|
edit = Component(
|
||||||
|
tag_name="a",
|
||||||
|
attributes=[("href", reverse("games:edit_statuschange", args=[change.id]))],
|
||||||
|
children=["Edit"],
|
||||||
|
)
|
||||||
|
delete = Component(
|
||||||
|
tag_name="a",
|
||||||
|
attributes=[
|
||||||
|
("href", reverse("games:delete_statuschange", args=[change.id]))
|
||||||
|
],
|
||||||
|
children=["Delete"],
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
Component(
|
||||||
|
tag_name="li",
|
||||||
|
attributes=[("class", "text-slate-500")],
|
||||||
|
children=[
|
||||||
|
f"{prefix} status from ",
|
||||||
|
old_status,
|
||||||
|
" to ",
|
||||||
|
new_status,
|
||||||
|
" (",
|
||||||
|
edit,
|
||||||
|
", ",
|
||||||
|
delete,
|
||||||
|
")",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return Component(
|
||||||
|
tag_name="ul",
|
||||||
|
attributes=[("class", "list-disc list-inside")],
|
||||||
|
children=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _game_section(
|
||||||
|
title: str, count: int, table: SafeText, empty_message: str
|
||||||
|
) -> SafeText:
|
||||||
|
return Div(
|
||||||
|
[("class", "mb-6")],
|
||||||
|
[
|
||||||
|
H1(children=[title], badge=count),
|
||||||
|
table if count else empty_message,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||||
game = Game.objects.get(id=game_id)
|
game = Game.objects.get(id=game_id)
|
||||||
nongame_related_purchases_prefetch: Prefetch[Purchase] = Prefetch(
|
|
||||||
"related_purchases",
|
|
||||||
queryset=Purchase.objects.exclude(type=Purchase.GAME).order_by(
|
|
||||||
"date_purchased"
|
|
||||||
),
|
|
||||||
to_attr="nongame_related_purchases",
|
|
||||||
)
|
|
||||||
game_purchases_prefetch: Prefetch[Purchase] = Prefetch(
|
|
||||||
"purchases",
|
|
||||||
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
|
|
||||||
nongame_related_purchases_prefetch
|
|
||||||
),
|
|
||||||
to_attr="game_purchases",
|
|
||||||
)
|
|
||||||
|
|
||||||
purchases = game.purchases.order_by("date_purchased")
|
purchases = game.purchases.order_by("date_purchased")
|
||||||
|
|
||||||
sessions = game.sessions
|
sessions = game.sessions
|
||||||
@@ -230,7 +552,6 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||||||
playrange = "N/A"
|
playrange = "N/A"
|
||||||
latest_session = None
|
latest_session = None
|
||||||
|
|
||||||
total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H"))
|
|
||||||
total_hours_without_manual = float(
|
total_hours_without_manual = float(
|
||||||
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
|
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
|
||||||
)
|
)
|
||||||
@@ -251,7 +572,9 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||||||
"color": "gray",
|
"color": "gray",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"href": reverse("games:delete_purchase", args=[purchase.pk]),
|
"href": reverse(
|
||||||
|
"games:delete_purchase", args=[purchase.pk]
|
||||||
|
),
|
||||||
"slot": Icon("delete"),
|
"slot": Icon("delete"),
|
||||||
"color": "red",
|
"color": "red",
|
||||||
},
|
},
|
||||||
@@ -349,55 +672,166 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||||||
|
|
||||||
statuschanges = game.status_changes.all()
|
statuschanges = game.status_changes.all()
|
||||||
statuschange_count = statuschanges.count()
|
statuschange_count = statuschanges.count()
|
||||||
statuschange_data = {
|
|
||||||
"columns": [
|
|
||||||
"Old Status",
|
|
||||||
"New Status",
|
|
||||||
"Timestamp",
|
|
||||||
],
|
|
||||||
"rows": [
|
|
||||||
[
|
|
||||||
statuschange.get_old_status_display()
|
|
||||||
if statuschange.old_status
|
|
||||||
else "-",
|
|
||||||
statuschange.get_new_status_display(),
|
|
||||||
local_strftime(statuschange.timestamp, dateformat),
|
|
||||||
]
|
|
||||||
for statuschange in statuschanges
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
context: dict[str, Any] = {
|
purchase_count = game.purchases.count()
|
||||||
"statuschange_data": statuschange_data,
|
status_selector_html = GameStatusSelector(
|
||||||
"statuschange_count": statuschange_count,
|
game, Game.Status.choices, get_token(request)
|
||||||
"statuschanges": statuschanges,
|
)
|
||||||
"game": game,
|
session_average_without_manual = round(
|
||||||
"game_statuses": Game.Status.choices,
|
safe_division(total_hours_without_manual, int(session_count_without_manual)),
|
||||||
"playrange": playrange,
|
|
||||||
"purchase_count": game.purchases.count(),
|
|
||||||
"session_average_without_manual": round(
|
|
||||||
safe_division(
|
|
||||||
total_hours_without_manual, int(session_count_without_manual)
|
|
||||||
),
|
|
||||||
1,
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
grey_value_class = "text-black dark:text-slate-300"
|
||||||
|
title_span = Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[("class", "text-balance max-w-120 text-4xl")],
|
||||||
|
children=[
|
||||||
|
Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[("class", "font-bold font-serif")],
|
||||||
|
children=[game.name],
|
||||||
),
|
),
|
||||||
"session_count": session_count,
|
]
|
||||||
"sessions": sessions,
|
+ (
|
||||||
"title": f"Game Overview - {game.name}",
|
[
|
||||||
"hours_sum": total_hours,
|
mark_safe(" "),
|
||||||
"purchase_data": purchase_data,
|
Popover(
|
||||||
"playevent_data": playevent_data,
|
popover_content="Original release year",
|
||||||
"playevent_count": playevent_count,
|
wrapped_classes="text-slate-500 text-2xl",
|
||||||
"session_data": session_data,
|
id="popover-year",
|
||||||
"session_page_obj": session_page_obj,
|
children=[str(game.year_released)],
|
||||||
"session_elided_page_range": (
|
),
|
||||||
|
]
|
||||||
|
if game.year_released
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
)
|
||||||
|
title_row = Div([("class", "flex gap-5 mb-3")], [title_span])
|
||||||
|
|
||||||
|
stats_row = Div(
|
||||||
|
[("class", "flex gap-4 dark:text-slate-400 mb-3")],
|
||||||
|
[
|
||||||
|
_stat_popover(
|
||||||
|
"popover-hours",
|
||||||
|
"Total hours played",
|
||||||
|
"hours",
|
||||||
|
game.playtime_formatted(),
|
||||||
|
),
|
||||||
|
_stat_popover(
|
||||||
|
"popover-sessions", "Number of sessions", "sessions", session_count
|
||||||
|
),
|
||||||
|
_stat_popover(
|
||||||
|
"popover-average",
|
||||||
|
"Average playtime per session",
|
||||||
|
"average",
|
||||||
|
session_average_without_manual,
|
||||||
|
),
|
||||||
|
_stat_popover(
|
||||||
|
"popover-playrange",
|
||||||
|
"Earliest and latest dates played",
|
||||||
|
"playrange",
|
||||||
|
playrange,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = Div(
|
||||||
|
[("class", "flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4")],
|
||||||
|
[
|
||||||
|
_meta_row(
|
||||||
|
"Original year",
|
||||||
|
Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[("class", grey_value_class)],
|
||||||
|
children=[str(game.original_year_released)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_meta_row("Status", status_selector_html, "👑" if game.mastered else ""),
|
||||||
|
_played_row(game, request),
|
||||||
|
_meta_row(
|
||||||
|
"Platform",
|
||||||
|
Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[("class", grey_value_class)],
|
||||||
|
children=[str(game.platform)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
game_info = Div(
|
||||||
|
[("id", "game-info"), ("class", "mb-10")],
|
||||||
|
[title_row, stats_row, metadata, _game_action_buttons(game)],
|
||||||
|
)
|
||||||
|
|
||||||
|
session_elided_page_range = (
|
||||||
session_page_obj.paginator.get_elided_page_range(
|
session_page_obj.paginator.get_elided_page_range(
|
||||||
page_number, on_each_side=1, on_ends=1
|
page_number, on_each_side=1, on_ends=1
|
||||||
)
|
)
|
||||||
if session_page_obj and session_count > 5
|
if session_page_obj and session_count > 5
|
||||||
else None
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
purchases_table = SimpleTable(
|
||||||
|
columns=purchase_data["columns"], rows=purchase_data["rows"]
|
||||||
|
)
|
||||||
|
sessions_table = SimpleTable(
|
||||||
|
columns=session_data["columns"],
|
||||||
|
rows=session_data["rows"],
|
||||||
|
header_action=session_data["header_action"],
|
||||||
|
page_obj=session_page_obj,
|
||||||
|
elided_page_range=session_elided_page_range,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
playevents_table = SimpleTable(
|
||||||
|
columns=playevent_data["columns"], rows=playevent_data["rows"]
|
||||||
|
)
|
||||||
|
|
||||||
|
history = Div(
|
||||||
|
[
|
||||||
|
("class", "mb-6"),
|
||||||
|
("id", "history-container"),
|
||||||
|
("hx-get", ""),
|
||||||
|
("hx-trigger", "status-changed from:body"),
|
||||||
|
("hx-select", "#history-container"),
|
||||||
|
("hx-swap", "outerHTML"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
H1(children=["History"], badge=statuschange_count),
|
||||||
|
_game_history(statuschanges),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
content = Div(
|
||||||
|
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
|
||||||
|
[
|
||||||
|
game_info,
|
||||||
|
_game_section(
|
||||||
|
"Purchases", purchase_count, purchases_table, "No purchases yet."
|
||||||
),
|
),
|
||||||
}
|
_game_section(
|
||||||
|
"Sessions", session_count, sessions_table, "No sessions yet."
|
||||||
|
),
|
||||||
|
_game_section(
|
||||||
|
"Play Events", playevent_count, playevents_table, "No play events yet."
|
||||||
|
),
|
||||||
|
history,
|
||||||
|
mark_safe(
|
||||||
|
"<script>\n"
|
||||||
|
" function getSessionCount() {\n"
|
||||||
|
" return document.getElementById('session-count')"
|
||||||
|
'.textContent.match("[0-9]+");\n'
|
||||||
|
" }\n"
|
||||||
|
" </script>"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
request.session["return_path"] = request.path
|
request.session["return_path"] = request.path
|
||||||
return render(request, "view_game.html", context)
|
return render_page(
|
||||||
|
request,
|
||||||
|
content,
|
||||||
|
title=f"Game Overview - {game.name}",
|
||||||
|
mastered=game.mastered,
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,17 +2,31 @@ from datetime import datetime, timedelta
|
|||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db.models import Avg, Count, ExpressionWrapper, F, Max, OuterRef, Prefetch, Q, Subquery, Sum, fields
|
from django.db.models import (
|
||||||
|
Avg,
|
||||||
|
Count,
|
||||||
|
ExpressionWrapper,
|
||||||
|
F,
|
||||||
|
Max,
|
||||||
|
OuterRef,
|
||||||
|
Prefetch,
|
||||||
|
Q,
|
||||||
|
Subquery,
|
||||||
|
Sum,
|
||||||
|
fields,
|
||||||
|
)
|
||||||
from django.db.models.functions import TruncDate, TruncMonth
|
from django.db.models.functions import TruncDate, TruncMonth
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import now as timezone_now
|
from django.utils.timezone import now as timezone_now
|
||||||
|
|
||||||
|
from common.layout import render_page
|
||||||
from common.time import available_stats_year_range, dateformat, format_duration
|
from common.time import available_stats_year_range, dateformat, format_duration
|
||||||
from common.utils import safe_division
|
from common.utils import safe_division
|
||||||
from games.models import Game, Platform, Purchase, Session
|
from games.models import Game, Platform, Purchase, Session
|
||||||
|
from games.views.stats_content import stats_content
|
||||||
|
|
||||||
|
|
||||||
def model_counts(request: HttpRequest) -> dict[str, bool]:
|
def model_counts(request: HttpRequest) -> dict[str, bool]:
|
||||||
@@ -90,15 +104,12 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||||||
|
|
||||||
this_year_purchases = Purchase.objects.all()
|
this_year_purchases = Purchase.objects.all()
|
||||||
this_year_purchases_with_currency = this_year_purchases.select_related("games")
|
this_year_purchases_with_currency = this_year_purchases.select_related("games")
|
||||||
this_year_purchases_without_refunded = Purchase.objects.filter(
|
this_year_purchases_without_refunded = Purchase.objects.filter(date_refunded=None)
|
||||||
date_refunded=None
|
|
||||||
)
|
|
||||||
this_year_purchases_refunded = Purchase.objects.refunded()
|
this_year_purchases_refunded = Purchase.objects.refunded()
|
||||||
|
|
||||||
this_year_purchases_unfinished_dropped_nondropped = (
|
this_year_purchases_unfinished_dropped_nondropped = (
|
||||||
this_year_purchases_without_refunded.filter(
|
this_year_purchases_without_refunded.filter(
|
||||||
~Q(games__status="f")
|
~Q(games__status="f") & ~Q(games__playevents__ended__isnull=False)
|
||||||
& ~Q(games__playevents__ended__isnull=False)
|
|
||||||
)
|
)
|
||||||
.filter(infinite=False)
|
.filter(infinite=False)
|
||||||
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||||
@@ -106,14 +117,12 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||||||
|
|
||||||
this_year_purchases_unfinished = (
|
this_year_purchases_unfinished = (
|
||||||
this_year_purchases_unfinished_dropped_nondropped.filter(
|
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||||
~Q(games__status="r")
|
~Q(games__status="r") & ~Q(games__status="a")
|
||||||
& ~Q(games__status="a")
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
this_year_purchases_dropped = (
|
this_year_purchases_dropped = (
|
||||||
this_year_purchases.filter(
|
this_year_purchases.filter(
|
||||||
~Q(games__status="f")
|
~Q(games__status="f") & ~Q(games__playevents__ended__isnull=False)
|
||||||
& ~Q(games__playevents__ended__isnull=False)
|
|
||||||
)
|
)
|
||||||
.filter(Q(games__status="a") | Q(date_refunded__isnull=False))
|
.filter(Q(games__status="a") | Q(date_refunded__isnull=False))
|
||||||
.filter(infinite=False)
|
.filter(infinite=False)
|
||||||
@@ -144,27 +153,18 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||||||
purchases_finished_this_year_released_this_year = _finished_with_date.order_by(
|
purchases_finished_this_year_released_this_year = _finished_with_date.order_by(
|
||||||
"-date_finished"
|
"-date_finished"
|
||||||
)
|
)
|
||||||
purchased_this_year_finished_this_year = (
|
|
||||||
this_year_purchases_without_refunded.filter(pk__in=_finished_purchases_qs.values("pk"))
|
|
||||||
.annotate(
|
|
||||||
date_finished=Subquery(
|
|
||||||
Purchase.objects.filter(pk=OuterRef("pk"))
|
|
||||||
.annotate(max_ended=Max("games__playevents__ended"))
|
|
||||||
.values("max_ended")[:1]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).order_by("-date_finished")
|
|
||||||
|
|
||||||
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
||||||
total_spent=Sum(F("converted_price"))
|
total_spent=Sum(F("converted_price"))
|
||||||
)
|
)
|
||||||
total_spent = this_year_spendings["total_spent"] or 0
|
total_spent = this_year_spendings["total_spent"] or 0
|
||||||
|
|
||||||
games_with_playtime = Game.objects.filter(
|
games_with_playtime = (
|
||||||
sessions__in=this_year_sessions
|
Game.objects.filter(sessions__in=this_year_sessions)
|
||||||
).distinct().annotate(
|
.distinct()
|
||||||
total_playtime=Sum(F("sessions__duration_total"))
|
.annotate(total_playtime=Sum(F("sessions__duration_total")))
|
||||||
).filter(total_playtime__gt=timedelta(0))
|
.filter(total_playtime__gt=timedelta(0))
|
||||||
|
)
|
||||||
month_playtimes = (
|
month_playtimes = (
|
||||||
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
|
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
|
||||||
.values("month")
|
.values("month")
|
||||||
@@ -190,9 +190,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||||||
.order_by("-playtime")
|
.order_by("-playtime")
|
||||||
)
|
)
|
||||||
|
|
||||||
backlog_decrease_count = (
|
backlog_decrease_count = purchases_finished_this_year.count()
|
||||||
purchases_finished_this_year.count()
|
|
||||||
)
|
|
||||||
|
|
||||||
first_play_date = "N/A"
|
first_play_date = "N/A"
|
||||||
last_play_date = "N/A"
|
last_play_date = "N/A"
|
||||||
@@ -277,14 +275,16 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||||||
}
|
}
|
||||||
|
|
||||||
request.session["return_path"] = request.path
|
request.session["return_path"] = request.path
|
||||||
return render(request, "stats.html", context)
|
return render_page(request, stats_content(context), title=context["title"])
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||||
selected_year = request.GET.get("year")
|
selected_year = request.GET.get("year")
|
||||||
if selected_year:
|
if selected_year:
|
||||||
return HttpResponseRedirect(reverse("games:stats_by_year", args=[selected_year]))
|
return HttpResponseRedirect(
|
||||||
|
reverse("games:stats_by_year", args=[selected_year])
|
||||||
|
)
|
||||||
if year == 0:
|
if year == 0:
|
||||||
return HttpResponseRedirect(reverse("games:stats_alltime"))
|
return HttpResponseRedirect(reverse("games:stats_alltime"))
|
||||||
this_year_sessions = Session.objects.filter(
|
this_year_sessions = Session.objects.filter(
|
||||||
@@ -338,8 +338,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||||||
# only Game and DLC
|
# only Game and DLC
|
||||||
this_year_purchases_unfinished_dropped_nondropped = (
|
this_year_purchases_unfinished_dropped_nondropped = (
|
||||||
this_year_purchases_without_refunded.filter(
|
this_year_purchases_without_refunded.filter(
|
||||||
~Q(games__status="f")
|
~Q(games__status="f") & ~Q(games__playevents__ended__year=year)
|
||||||
& ~Q(games__playevents__ended__year=year)
|
|
||||||
)
|
)
|
||||||
.filter(infinite=False)
|
.filter(infinite=False)
|
||||||
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||||
@@ -348,15 +347,13 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||||||
# unfinished = not finished AND not dropped
|
# unfinished = not finished AND not dropped
|
||||||
this_year_purchases_unfinished = (
|
this_year_purchases_unfinished = (
|
||||||
this_year_purchases_unfinished_dropped_nondropped.filter(
|
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||||
~Q(games__status="r")
|
~Q(games__status="r") & ~Q(games__status="a")
|
||||||
& ~Q(games__status="a")
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# dropped = abandoned OR retired OR refunded (OR logic for transition)
|
# dropped = abandoned OR retired OR refunded (OR logic for transition)
|
||||||
this_year_purchases_dropped = (
|
this_year_purchases_dropped = (
|
||||||
this_year_purchases.filter(
|
this_year_purchases.filter(
|
||||||
~Q(games__status="f")
|
~Q(games__status="f") & ~Q(games__playevents__ended__year=year)
|
||||||
& ~Q(games__playevents__ended__year=year)
|
|
||||||
)
|
)
|
||||||
.filter(Q(games__status="a") | Q(date_refunded__isnull=False))
|
.filter(Q(games__status="a") | Q(date_refunded__isnull=False))
|
||||||
.filter(infinite=False)
|
.filter(infinite=False)
|
||||||
@@ -375,9 +372,13 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||||||
* 100
|
* 100
|
||||||
)
|
)
|
||||||
|
|
||||||
purchases_finished_this_year = Purchase.objects.finished().filter(
|
purchases_finished_this_year = (
|
||||||
games__playevents__ended__year=year
|
Purchase.objects.finished()
|
||||||
).annotate(game_name=F("games__name"), date_finished=F("games__playevents__ended"))
|
.filter(games__playevents__ended__year=year)
|
||||||
|
.annotate(
|
||||||
|
game_name=F("games__name"), date_finished=F("games__playevents__ended")
|
||||||
|
)
|
||||||
|
)
|
||||||
purchases_finished_this_year_released_this_year = (
|
purchases_finished_this_year_released_this_year = (
|
||||||
purchases_finished_this_year.filter(games__year_released=year).order_by(
|
purchases_finished_this_year.filter(games__year_released=year).order_by(
|
||||||
"games__playevents__ended"
|
"games__playevents__ended"
|
||||||
@@ -472,7 +473,6 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||||||
"total_playtime_per_platform": total_playtime_per_platform,
|
"total_playtime_per_platform": total_playtime_per_platform,
|
||||||
"total_spent": total_spent,
|
"total_spent": total_spent,
|
||||||
"total_spent_currency": selected_currency,
|
"total_spent_currency": selected_currency,
|
||||||
"all_purchased_this_year": this_year_purchases_without_refunded,
|
|
||||||
"spent_per_game": int(
|
"spent_per_game": int(
|
||||||
safe_division(total_spent, this_year_purchases_without_refunded_count)
|
safe_division(total_spent, this_year_purchases_without_refunded_count)
|
||||||
),
|
),
|
||||||
@@ -539,7 +539,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||||||
}
|
}
|
||||||
|
|
||||||
request.session["return_path"] = request.path
|
request.session["return_path"] = request.path
|
||||||
return render(request, "stats.html", context)
|
return render_page(request, stats_content(context), title=context["title"])
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from common.components import A, Button, ButtonGroup, Icon
|
from common.components import (
|
||||||
|
A,
|
||||||
|
AddForm,
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
Icon,
|
||||||
|
paginated_table_content,
|
||||||
|
)
|
||||||
|
from common.layout import render_page
|
||||||
from common.time import dateformat, local_strftime
|
from common.time import dateformat, local_strftime
|
||||||
from games.forms import PlatformForm
|
from games.forms import PlatformForm
|
||||||
from games.models import Platform
|
from games.models import Platform
|
||||||
@@ -15,7 +21,6 @@ from games.views.general import use_custom_redirect
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def list_platforms(request: HttpRequest) -> HttpResponse:
|
def list_platforms(request: HttpRequest) -> HttpResponse:
|
||||||
context: dict[Any, Any] = {}
|
|
||||||
page_number = request.GET.get("page", 1)
|
page_number = request.GET.get("page", 1)
|
||||||
limit = request.GET.get("limit", 10)
|
limit = request.GET.get("limit", 10)
|
||||||
platforms = Platform.objects.order_by("name")
|
platforms = Platform.objects.order_by("name")
|
||||||
@@ -24,19 +29,16 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
|
|||||||
paginator = Paginator(platforms, limit)
|
paginator = Paginator(platforms, limit)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
platforms = page_obj.object_list
|
platforms = page_obj.object_list
|
||||||
|
elided_page_range = (
|
||||||
context = {
|
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||||
"title": "Manage platforms",
|
|
||||||
"page_obj": page_obj or None,
|
|
||||||
"elided_page_range": (
|
|
||||||
page_obj.paginator.get_elided_page_range(
|
|
||||||
page_number, on_each_side=1, on_ends=1
|
|
||||||
)
|
|
||||||
if page_obj
|
if page_obj
|
||||||
else None
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"header_action": A(
|
||||||
|
[], Button([], "Add platform"), url_name="games:add_platform"
|
||||||
),
|
),
|
||||||
"data": {
|
|
||||||
"header_action": A([], Button([], "Add platform"), url_name="games:add_platform"),
|
|
||||||
"columns": [
|
"columns": [
|
||||||
"Name",
|
"Name",
|
||||||
"Icon",
|
"Icon",
|
||||||
@@ -58,7 +60,9 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
|
|||||||
"color": "gray",
|
"color": "gray",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"href": reverse("games:delete_platform", args=[platform.pk]),
|
"href": reverse(
|
||||||
|
"games:delete_platform", args=[platform.pk]
|
||||||
|
),
|
||||||
"slot": Icon("delete"),
|
"slot": Icon("delete"),
|
||||||
"color": "red",
|
"color": "red",
|
||||||
},
|
},
|
||||||
@@ -67,9 +71,14 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
|
|||||||
]
|
]
|
||||||
for platform in platforms
|
for platform in platforms
|
||||||
],
|
],
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return render(request, "list_purchases.html", context)
|
content = paginated_table_content(
|
||||||
|
data,
|
||||||
|
page_obj=page_obj,
|
||||||
|
elided_page_range=elided_page_range,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return render_page(request, content, title="Manage platforms")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -82,25 +91,21 @@ def delete_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
|
|||||||
@login_required
|
@login_required
|
||||||
@use_custom_redirect
|
@use_custom_redirect
|
||||||
def edit_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
|
def edit_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
|
||||||
context = {}
|
|
||||||
platform = get_object_or_404(Platform, id=platform_id)
|
platform = get_object_or_404(Platform, id=platform_id)
|
||||||
form = PlatformForm(request.POST or None, instance=platform)
|
form = PlatformForm(request.POST or None, instance=platform)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect("games:list_platforms")
|
return redirect("games:list_platforms")
|
||||||
context["title"] = "Edit Platform"
|
return render_page(request, AddForm(form, request=request), title="Edit Platform")
|
||||||
context["form"] = form
|
|
||||||
return render(request, "add.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def add_platform(request: HttpRequest) -> HttpResponse:
|
def add_platform(request: HttpRequest) -> HttpResponse:
|
||||||
context: dict[str, Any] = {}
|
|
||||||
form = PlatformForm(request.POST or None)
|
form = PlatformForm(request.POST or None)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect("games:index")
|
return redirect("games:index")
|
||||||
|
|
||||||
context["form"] = form
|
return render_page(
|
||||||
context["title"] = "Add New Platform"
|
request, AddForm(form, request=request), title="Add New Platform"
|
||||||
return render(request, "add.html", context)
|
)
|
||||||
|
|||||||
@@ -7,10 +7,18 @@ from django.core.paginator import Paginator
|
|||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.db.models.manager import BaseManager
|
from django.db.models.manager import BaseManager
|
||||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from common.components import A, Button, ButtonGroup, Icon
|
from common.components import (
|
||||||
|
A,
|
||||||
|
AddForm,
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
Icon,
|
||||||
|
paginated_table_content,
|
||||||
|
)
|
||||||
|
from common.layout import render_page
|
||||||
from common.time import dateformat, format_duration, local_strftime
|
from common.time import dateformat, format_duration, local_strftime
|
||||||
from games.forms import PlayEventForm
|
from games.forms import PlayEventForm
|
||||||
from games.models import Game, PlayEvent, Session
|
from games.models import Game, PlayEvent, Session
|
||||||
@@ -74,7 +82,9 @@ def create_playevent_tabledata(
|
|||||||
for row in row_list
|
for row in row_list
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
"header_action": A([], Button([], "Add play event"), url_name="games:add_playevent"),
|
"header_action": A(
|
||||||
|
[], Button([], "Add play event"), url_name="games:add_playevent"
|
||||||
|
),
|
||||||
"columns": list(filtered_column_list),
|
"columns": list(filtered_column_list),
|
||||||
"rows": filtered_row_list,
|
"rows": filtered_row_list,
|
||||||
}
|
}
|
||||||
@@ -123,19 +133,19 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
|
|||||||
paginator = Paginator(playevents, limit)
|
paginator = Paginator(playevents, limit)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
playevents = page_obj.object_list
|
playevents = page_obj.object_list
|
||||||
context: dict[str, Any] = {
|
elided_page_range = (
|
||||||
"title": "Manage play events",
|
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||||
"page_obj": page_obj or None,
|
|
||||||
"elided_page_range": (
|
|
||||||
page_obj.paginator.get_elided_page_range(
|
|
||||||
page_number, on_each_side=1, on_ends=1
|
|
||||||
)
|
|
||||||
if page_obj
|
if page_obj
|
||||||
else None
|
else None
|
||||||
),
|
)
|
||||||
"data": create_playevent_tabledata(playevents, request=request),
|
data = create_playevent_tabledata(playevents, request=request)
|
||||||
}
|
content = paginated_table_content(
|
||||||
return render(request, "list_playevents.html", context)
|
data,
|
||||||
|
page_obj=page_obj,
|
||||||
|
elided_page_range=elided_page_range,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return render_page(request, content, title="Manage play events")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -192,22 +202,21 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
game_id = form.instance.game.id
|
game_id = form.instance.game.id
|
||||||
return HttpResponseRedirect(reverse("games:view_game", args=[game_id]))
|
return HttpResponseRedirect(reverse("games:view_game", args=[game_id]))
|
||||||
|
|
||||||
return render(request, "add.html", {"form": form, "title": "Add new playthrough"})
|
return render_page(
|
||||||
|
request, AddForm(form, request=request), title="Add new playthrough"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
|
def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
|
||||||
context: dict[str, Any] = {}
|
|
||||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||||
form = PlayEventForm(request.POST or None, instance=playevent)
|
form = PlayEventForm(request.POST or None, instance=playevent)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return HttpResponseRedirect(reverse("games:view_game", args=[playevent.game.id]))
|
return HttpResponseRedirect(
|
||||||
|
reverse("games:view_game", args=[playevent.game.id])
|
||||||
|
)
|
||||||
|
|
||||||
context = {
|
return render_page(request, AddForm(form, request=request), title="Edit Play Event")
|
||||||
"form": form,
|
|
||||||
"title": "Edit Play Event",
|
|
||||||
}
|
|
||||||
return render(request, "add.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
|
def delete_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
@@ -8,12 +6,34 @@ from django.http import (
|
|||||||
HttpResponse,
|
HttpResponse,
|
||||||
HttpResponseRedirect,
|
HttpResponseRedirect,
|
||||||
)
|
)
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from common.components import A, Button, ButtonGroup, Icon, LinkedPurchase, PurchasePrice, TableRow
|
from django.template.defaultfilters import date as date_filter
|
||||||
|
from django.template.defaultfilters import floatformat
|
||||||
|
from django.utils.safestring import SafeText
|
||||||
|
|
||||||
|
from common.components import (
|
||||||
|
A,
|
||||||
|
AddForm,
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
Component,
|
||||||
|
CsrfInput,
|
||||||
|
Div,
|
||||||
|
GameLink,
|
||||||
|
Icon,
|
||||||
|
LinkedPurchase,
|
||||||
|
Modal,
|
||||||
|
ModuleScript,
|
||||||
|
PriceConverted,
|
||||||
|
PurchasePrice,
|
||||||
|
TableRow,
|
||||||
|
paginated_table_content,
|
||||||
|
)
|
||||||
|
from common.layout import render_page
|
||||||
from common.time import dateformat
|
from common.time import dateformat
|
||||||
from games.forms import PurchaseForm
|
from games.forms import PurchaseForm
|
||||||
from games.models import Game, Purchase
|
from games.models import Game, Purchase
|
||||||
@@ -75,7 +95,6 @@ def _render_purchase_row(purchase):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def list_purchases(request: HttpRequest) -> HttpResponse:
|
def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||||
context: dict[Any, Any] = {}
|
|
||||||
page_number = request.GET.get("page", 1)
|
page_number = request.GET.get("page", 1)
|
||||||
limit = request.GET.get("limit", 10)
|
limit = request.GET.get("limit", 10)
|
||||||
purchases = Purchase.objects.order_by("-date_purchased", "-created_at")
|
purchases = Purchase.objects.order_by("-date_purchased", "-created_at")
|
||||||
@@ -84,19 +103,16 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
|||||||
paginator = Paginator(purchases, limit)
|
paginator = Paginator(purchases, limit)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
purchases = page_obj.object_list
|
purchases = page_obj.object_list
|
||||||
|
elided_page_range = (
|
||||||
context = {
|
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||||
"title": "Manage purchases",
|
|
||||||
"page_obj": page_obj or None,
|
|
||||||
"elided_page_range": (
|
|
||||||
page_obj.paginator.get_elided_page_range(
|
|
||||||
page_number, on_each_side=1, on_ends=1
|
|
||||||
)
|
|
||||||
if page_obj
|
if page_obj
|
||||||
else None
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"header_action": A(
|
||||||
|
[], Button([], "Add purchase"), url_name="games:add_purchase"
|
||||||
),
|
),
|
||||||
"data": {
|
|
||||||
"header_action": A([], Button([], "Add purchase"), url_name="games:add_purchase"),
|
|
||||||
"columns": [
|
"columns": [
|
||||||
"Name",
|
"Name",
|
||||||
"Type",
|
"Type",
|
||||||
@@ -108,14 +124,40 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
|||||||
"Actions",
|
"Actions",
|
||||||
],
|
],
|
||||||
"rows": [_render_purchase_row(purchase) for purchase in purchases],
|
"rows": [_render_purchase_row(purchase) for purchase in purchases],
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return render(request, "list_purchases.html", context)
|
content = paginated_table_content(
|
||||||
|
data,
|
||||||
|
page_obj=page_obj,
|
||||||
|
elided_page_range=elided_page_range,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return render_page(request, content, title="Manage purchases")
|
||||||
|
|
||||||
|
|
||||||
|
def _purchase_additional_row() -> SafeText:
|
||||||
|
"""The 'Submit & Create Session' row shown below the main Submit button."""
|
||||||
|
return Component(
|
||||||
|
tag_name="tr",
|
||||||
|
children=[
|
||||||
|
Component(tag_name="td"),
|
||||||
|
Component(
|
||||||
|
tag_name="td",
|
||||||
|
children=[
|
||||||
|
Button(
|
||||||
|
[],
|
||||||
|
"Submit & Create Session",
|
||||||
|
color="gray",
|
||||||
|
type="submit",
|
||||||
|
name="submit_and_redirect",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||||
context: dict[str, Any] = {}
|
|
||||||
initial = {"date_purchased": timezone.now()}
|
initial = {"date_purchased": timezone.now()}
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -144,26 +186,28 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
else:
|
else:
|
||||||
form = PurchaseForm(initial=initial)
|
form = PurchaseForm(initial=initial)
|
||||||
|
|
||||||
context["form"] = form
|
return render_page(
|
||||||
context["title"] = "Add New Purchase"
|
request,
|
||||||
context["script_name"] = "add_purchase.js"
|
AddForm(form, request=request, additional_row=_purchase_additional_row()),
|
||||||
return render(request, "add_purchase.html", context)
|
title="Add New Purchase",
|
||||||
|
scripts=ModuleScript("add_purchase.js"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@use_custom_redirect
|
@use_custom_redirect
|
||||||
def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||||
context = {}
|
|
||||||
purchase = get_object_or_404(Purchase, id=purchase_id)
|
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||||
form = PurchaseForm(request.POST or None, instance=purchase)
|
form = PurchaseForm(request.POST or None, instance=purchase)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect("games:list_sessions")
|
return redirect("games:list_sessions")
|
||||||
context["title"] = "Edit Purchase"
|
return render_page(
|
||||||
context["form"] = form
|
request,
|
||||||
context["purchase_id"] = str(purchase_id)
|
AddForm(form, request=request, additional_row=_purchase_additional_row()),
|
||||||
context["script_name"] = "add_purchase.js"
|
title="Edit Purchase",
|
||||||
return render(request, "add_purchase.html", context)
|
scripts=ModuleScript("add_purchase.js"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -173,13 +217,67 @@ def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||||||
return redirect("games:list_purchases")
|
return redirect("games:list_purchases")
|
||||||
|
|
||||||
|
|
||||||
|
def _view_purchase_content(purchase: Purchase) -> SafeText:
|
||||||
|
first_game = purchase.first_game
|
||||||
|
owned = f"Owned on {date_filter(purchase.date_purchased, 'd/m/Y')}"
|
||||||
|
if purchase.date_refunded:
|
||||||
|
owned += f" (refunded {date_filter(purchase.date_refunded, 'd/m/Y')})"
|
||||||
|
|
||||||
|
row_class = "text-slate-500 text-xl"
|
||||||
|
inner = Div(
|
||||||
|
[("class", "flex flex-col gap-5 mb-3")],
|
||||||
|
[
|
||||||
|
Div(
|
||||||
|
[("class", "font-bold font-serif text-slate-500 text-2xl")],
|
||||||
|
[
|
||||||
|
A(
|
||||||
|
[],
|
||||||
|
first_game.name,
|
||||||
|
href=reverse("games:view_game", args=[first_game.id]),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Div([("class", row_class)], [purchase.get_type_display()]),
|
||||||
|
Div([("class", row_class)], [owned]),
|
||||||
|
Div(
|
||||||
|
[("class", row_class)], [PriceConverted([purchase.standardized_price])]
|
||||||
|
),
|
||||||
|
Div(
|
||||||
|
[("class", row_class)],
|
||||||
|
[
|
||||||
|
Component(
|
||||||
|
tag_name="p",
|
||||||
|
children=[
|
||||||
|
"Price per game: ",
|
||||||
|
PriceConverted([floatformat(purchase.price_per_game, 0)]),
|
||||||
|
f" {purchase.converted_currency}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Div([("class", row_class)], ["Games included in this purchase:"]),
|
||||||
|
Component(
|
||||||
|
tag_name="ul",
|
||||||
|
children=[
|
||||||
|
Component(tag_name="li", children=[GameLink(game.id, game.name)])
|
||||||
|
for game in purchase.games.all()
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return Div(
|
||||||
|
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
|
||||||
|
[inner],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def view_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
def view_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||||
purchase = get_object_or_404(Purchase, id=purchase_id)
|
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||||
return render(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
"view_purchase.html",
|
_view_purchase_content(purchase),
|
||||||
{"purchase": purchase, "title": f"Purchase: {purchase.full_name}"},
|
title=f"Purchase: {purchase.full_name}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -192,15 +290,70 @@ def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||||||
return redirect("games:list_purchases")
|
return redirect("games:list_purchases")
|
||||||
|
|
||||||
|
|
||||||
|
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeText:
|
||||||
|
form = Component(
|
||||||
|
tag_name="form",
|
||||||
|
attributes=[
|
||||||
|
("hx-post", reverse("games:refund_purchase", args=[purchase_id])),
|
||||||
|
("hx-target", f"#purchase-row-{purchase_id}"),
|
||||||
|
("hx-swap", "outerHTML"),
|
||||||
|
],
|
||||||
|
children=[
|
||||||
|
CsrfInput(request),
|
||||||
|
Component(
|
||||||
|
tag_name="p",
|
||||||
|
attributes=[("class", "dark:text-white text-center mt-3 text-sm")],
|
||||||
|
children=["Games will be marked as abandoned."],
|
||||||
|
),
|
||||||
|
Div(
|
||||||
|
[("class", "items-center mt-5")],
|
||||||
|
[
|
||||||
|
Button(
|
||||||
|
[("class", "w-full")],
|
||||||
|
"Refund",
|
||||||
|
color="blue",
|
||||||
|
size="lg",
|
||||||
|
type="submit",
|
||||||
|
),
|
||||||
|
Button(
|
||||||
|
[("class", "mt-0 w-full")],
|
||||||
|
"Cancel",
|
||||||
|
color="gray",
|
||||||
|
size="base",
|
||||||
|
onclick="this.closest('#refund-confirmation-modal').remove()",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return Modal(
|
||||||
|
"refund-confirmation-modal",
|
||||||
|
children=[
|
||||||
|
Component(
|
||||||
|
tag_name="h1",
|
||||||
|
attributes=[
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"text-2xl leading-6 font-medium dark:text-white text-center",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
children=["Confirm Refund"],
|
||||||
|
),
|
||||||
|
Component(
|
||||||
|
tag_name="p",
|
||||||
|
attributes=[("class", "dark:text-white text-center mt-5")],
|
||||||
|
children=["Are you sure you want to mark this purchase as refunded?"],
|
||||||
|
),
|
||||||
|
form,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def refund_purchase_confirmation(
|
def refund_purchase_confirmation(
|
||||||
request: HttpRequest, purchase_id: int
|
request: HttpRequest, purchase_id: int
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
return render(
|
return HttpResponse(_refund_confirmation_modal(purchase_id, request))
|
||||||
request,
|
|
||||||
"partials/refund_purchase_confirmation.html",
|
|
||||||
{"purchase_id": purchase_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -233,9 +386,7 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||||||
|
|
||||||
|
|
||||||
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
|
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
|
||||||
games: list[str] = []
|
games: list[str] = request.GET.getlist("games")
|
||||||
games = request.GET.getlist("games")
|
|
||||||
context = {}
|
|
||||||
if games:
|
if games:
|
||||||
form = PurchaseForm()
|
form = PurchaseForm()
|
||||||
qs = Purchase.objects.filter(games__in=games, type=Purchase.GAME).order_by(
|
qs = Purchase.objects.filter(games__in=games, type=Purchase.GAME).order_by(
|
||||||
@@ -246,8 +397,7 @@ def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
|
|||||||
first_option = qs.first()
|
first_option = qs.first()
|
||||||
if first_option:
|
if first_option:
|
||||||
form.fields["related_purchase"].initial = first_option.id
|
form.fields["related_purchase"].initial = first_option.id
|
||||||
context["form"] = form
|
return HttpResponse(str(form["related_purchase"]))
|
||||||
return render(request, "partials/related_purchase_field.html", context)
|
|
||||||
else:
|
else:
|
||||||
# abort swap
|
# abort swap
|
||||||
return HttpResponse(status=204)
|
return HttpResponse(status=204)
|
||||||
|
|||||||
@@ -4,21 +4,29 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.middleware.csrf import get_token
|
||||||
from django.template.loader import render_to_string
|
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.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
|
AddForm,
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
|
Component,
|
||||||
Div,
|
Div,
|
||||||
Icon,
|
Icon,
|
||||||
SearchField,
|
ModuleScript,
|
||||||
NameWithIcon,
|
NameWithIcon,
|
||||||
Popover,
|
Popover,
|
||||||
|
SearchField,
|
||||||
|
SessionDeviceSelector,
|
||||||
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
|
from common.layout import render_page
|
||||||
from common.time import (
|
from common.time import (
|
||||||
dateformat,
|
dateformat,
|
||||||
local_strftime,
|
local_strftime,
|
||||||
@@ -31,7 +39,6 @@ from games.models import Device, Game, Session
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||||
context: dict[Any, Any] = {}
|
|
||||||
page_number = request.GET.get("page", 1)
|
page_number = request.GET.get("page", 1)
|
||||||
limit = request.GET.get("limit", 10)
|
limit = request.GET.get("limit", 10)
|
||||||
sessions = Session.objects.order_by("-timestamp_start", "created_at")
|
sessions = Session.objects.order_by("-timestamp_start", "created_at")
|
||||||
@@ -55,17 +62,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
sessions = page_obj.object_list
|
sessions = page_obj.object_list
|
||||||
|
|
||||||
context = {
|
elided_page_range = (
|
||||||
"title": "Manage sessions",
|
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||||
"page_obj": page_obj or None,
|
|
||||||
"elided_page_range": (
|
|
||||||
page_obj.paginator.get_elided_page_range(
|
|
||||||
page_number, on_each_side=1, on_ends=1
|
|
||||||
)
|
|
||||||
if page_obj
|
if page_obj
|
||||||
else None
|
else None
|
||||||
),
|
)
|
||||||
"data": {
|
|
||||||
|
data = {
|
||||||
"header_action": Div(
|
"header_action": Div(
|
||||||
children=[
|
children=[
|
||||||
SearchField(search_string=search_string),
|
SearchField(search_string=search_string),
|
||||||
@@ -125,15 +128,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
NameWithIcon(session=session),
|
NameWithIcon(session=session),
|
||||||
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
||||||
session.duration_formatted_with_mark(),
|
session.duration_formatted_with_mark(),
|
||||||
render_to_string(
|
SessionDeviceSelector(session, device_list, get_token(request)),
|
||||||
"partials/sessiondevice_selector.html",
|
|
||||||
{
|
|
||||||
"session": session,
|
|
||||||
"session_device": session.device,
|
|
||||||
"session_devices": device_list,
|
|
||||||
},
|
|
||||||
request=request,
|
|
||||||
),
|
|
||||||
session.created_at.strftime(dateformat),
|
session.created_at.strftime(dateformat),
|
||||||
ButtonGroup(
|
ButtonGroup(
|
||||||
[
|
[
|
||||||
@@ -148,7 +143,9 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
if session.timestamp_end is None
|
if session.timestamp_end is None
|
||||||
else {},
|
else {},
|
||||||
{
|
{
|
||||||
"href": reverse("games:edit_session", args=[session.pk]),
|
"href": reverse(
|
||||||
|
"games:edit_session", args=[session.pk]
|
||||||
|
),
|
||||||
"slot": Icon("edit"),
|
"slot": Icon("edit"),
|
||||||
"title": "Edit",
|
"title": "Edit",
|
||||||
},
|
},
|
||||||
@@ -166,9 +163,14 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
}
|
}
|
||||||
for session in sessions
|
for session in sessions
|
||||||
],
|
],
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return render(request, "list_purchases.html", context)
|
content = paginated_table_content(
|
||||||
|
data,
|
||||||
|
page_obj=page_obj,
|
||||||
|
elided_page_range=elided_page_range,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return render_page(request, content, title="Manage sessions")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -176,13 +178,60 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
|
|||||||
return list_sessions(request, search_string=request.GET.get("search_string", ""))
|
return list_sessions(request, search_string=request.GET.get("search_string", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _session_fields(form) -> SafeText:
|
||||||
|
"""Manual per-field layout for the session form.
|
||||||
|
|
||||||
|
Mirrors the old add_session.html: each field gets its label and widget,
|
||||||
|
and the timestamp fields gain a row of now/toggle/copy helper buttons.
|
||||||
|
"""
|
||||||
|
rows: list[SafeText] = []
|
||||||
|
for field in form:
|
||||||
|
children: list[SafeText | str] = [
|
||||||
|
mark_safe(str(field.label_tag())),
|
||||||
|
mark_safe(str(field)),
|
||||||
|
]
|
||||||
|
if field.name in ("timestamp_start", "timestamp_end"):
|
||||||
|
this_side = "start" if field.name == "timestamp_start" else "end"
|
||||||
|
other_side = "end" if field.name == "timestamp_start" else "start"
|
||||||
|
children.append(
|
||||||
|
Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"form-row-button-group flex-row gap-3 justify-start mt-3",
|
||||||
|
),
|
||||||
|
("hx-boost", "false"),
|
||||||
|
],
|
||||||
|
children=[
|
||||||
|
Button(
|
||||||
|
[("data-target", field.name), ("data-type", "now")],
|
||||||
|
"Set to now",
|
||||||
|
size="xs",
|
||||||
|
),
|
||||||
|
Button(
|
||||||
|
[("data-target", field.name), ("data-type", "toggle")],
|
||||||
|
"Toggle text",
|
||||||
|
size="xs",
|
||||||
|
),
|
||||||
|
Button(
|
||||||
|
[("data-target", field.name), ("data-type", "copy")],
|
||||||
|
f"Copy {this_side} value to {other_side}",
|
||||||
|
size="xs",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows.append(Div(children=children))
|
||||||
|
return mark_safe("\n".join(rows))
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||||
context = {}
|
|
||||||
initial: dict[str, Any] = {"timestamp_start": timezone.now()}
|
initial: dict[str, Any] = {"timestamp_start": timezone.now()}
|
||||||
|
|
||||||
last = Session.objects.last()
|
last = Session.objects.last()
|
||||||
if last != None:
|
if last is not None:
|
||||||
initial["game"] = last.game
|
initial["game"] = last.game
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -202,25 +251,116 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
else:
|
else:
|
||||||
form = SessionForm(initial=initial)
|
form = SessionForm(initial=initial)
|
||||||
|
|
||||||
context["title"] = "Add New Session"
|
|
||||||
# TODO: re-add custom buttons #91
|
# TODO: re-add custom buttons #91
|
||||||
context["script_name"] = "add_session.js"
|
return render_page(
|
||||||
context["form"] = form
|
request,
|
||||||
return render(request, "add_session.html", context)
|
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||||
|
title="Add New Session",
|
||||||
|
scripts=ModuleScript("add_session.js"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
||||||
context = {}
|
|
||||||
session = get_object_or_404(Session, id=session_id)
|
session = get_object_or_404(Session, id=session_id)
|
||||||
form = SessionForm(request.POST or None, instance=session)
|
form = SessionForm(request.POST or None, instance=session)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect("games:list_sessions")
|
return redirect("games:list_sessions")
|
||||||
context["title"] = "Edit Session"
|
return render_page(
|
||||||
context["script_name"] = "add_session.js"
|
request,
|
||||||
context["form"] = form
|
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||||
return render(request, "add_session.html", context)
|
title="Edit Session",
|
||||||
|
scripts=ModuleScript("add_session.js"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_row_fragment(session: Session) -> SafeText:
|
||||||
|
"""A single session <tr> (the old list_sessions.html#session-row partial),
|
||||||
|
returned by the inline end/clone-session HTMX endpoints."""
|
||||||
|
name_link = Component(
|
||||||
|
tag_name="a",
|
||||||
|
attributes=[
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"underline decoration-slate-500 sm:decoration-2 inline-block "
|
||||||
|
"truncate max-w-20char group-hover:absolute group-hover:max-w-none "
|
||||||
|
"group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 "
|
||||||
|
"group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 "
|
||||||
|
"group-hover:rounded-xs group-hover:outline-dashed "
|
||||||
|
"group-hover:outline-purple-400 group-hover:outline-4 "
|
||||||
|
"group-hover:decoration-purple-900 group-hover:text-purple-100",
|
||||||
|
),
|
||||||
|
("href", reverse("games:view_game", args=[session.game.id])),
|
||||||
|
],
|
||||||
|
children=[session.game.name],
|
||||||
|
)
|
||||||
|
name_td = Component(
|
||||||
|
tag_name="td",
|
||||||
|
attributes=[
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top "
|
||||||
|
"w-24 h-12 group",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
children=[
|
||||||
|
Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[("class", "inline-block relative")],
|
||||||
|
children=[name_link],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
start_td = Component(
|
||||||
|
tag_name="td",
|
||||||
|
attributes=[
|
||||||
|
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell")
|
||||||
|
],
|
||||||
|
children=[date_filter(session.timestamp_start, "d/m/Y H:i")],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not session.timestamp_end:
|
||||||
|
end_url = reverse("games:list_sessions_end_session", args=[session.id])
|
||||||
|
end_inner: SafeText | str = Component(
|
||||||
|
tag_name="a",
|
||||||
|
attributes=[
|
||||||
|
("href", end_url),
|
||||||
|
("hx-get", end_url),
|
||||||
|
("hx-target", "closest tr"),
|
||||||
|
("hx-swap", "outerHTML"),
|
||||||
|
("hx-indicator", "#indicator"),
|
||||||
|
(
|
||||||
|
"onClick",
|
||||||
|
"document.querySelector('#last-session-start')"
|
||||||
|
".classList.remove('invisible')",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
children=[
|
||||||
|
Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[("class", "text-yellow-300")],
|
||||||
|
children=["Finish now?"],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
elif session.duration_manual:
|
||||||
|
end_inner = "--"
|
||||||
|
else:
|
||||||
|
end_inner = date_filter(session.timestamp_end, "d/m/Y H:i")
|
||||||
|
end_td = Component(
|
||||||
|
tag_name="td",
|
||||||
|
attributes=[
|
||||||
|
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell")
|
||||||
|
],
|
||||||
|
children=[end_inner],
|
||||||
|
)
|
||||||
|
duration_td = Component(
|
||||||
|
tag_name="td",
|
||||||
|
attributes=[("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono")],
|
||||||
|
children=[session.duration_formatted()],
|
||||||
|
)
|
||||||
|
return Component(tag_name="tr", children=[name_td, start_td, end_td, duration_td])
|
||||||
|
|
||||||
|
|
||||||
def clone_session_by_id(session_id: int) -> Session:
|
def clone_session_by_id(session_id: int) -> Session:
|
||||||
@@ -236,38 +376,21 @@ def clone_session_by_id(session_id: int) -> Session:
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def new_session_from_existing_session(
|
def new_session_from_existing_session(
|
||||||
request: HttpRequest, session_id: int, template: str = ""
|
request: HttpRequest, session_id: int
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
session = clone_session_by_id(session_id)
|
session = clone_session_by_id(session_id)
|
||||||
if request.htmx:
|
if request.htmx:
|
||||||
context = {
|
return HttpResponse(_session_row_fragment(session))
|
||||||
"session": session,
|
|
||||||
"session_count": int(request.GET.get("session_count", 0)) + 1,
|
|
||||||
}
|
|
||||||
return render(request, template, context)
|
|
||||||
return redirect("games:list_sessions")
|
return redirect("games:list_sessions")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def end_session(
|
def end_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
||||||
request: HttpRequest, session_id: int, template: str = ""
|
|
||||||
) -> HttpResponse:
|
|
||||||
session = get_object_or_404(Session, id=session_id)
|
session = get_object_or_404(Session, id=session_id)
|
||||||
session.timestamp_end = timezone.now()
|
session.timestamp_end = timezone.now()
|
||||||
session.save()
|
session.save()
|
||||||
if request.htmx:
|
if request.htmx:
|
||||||
context = {
|
return HttpResponse(_session_row_fragment(session))
|
||||||
"session": session,
|
|
||||||
"session_count": request.GET.get("session_count", 0),
|
|
||||||
}
|
|
||||||
return render(request, template, context)
|
|
||||||
return redirect("games: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("games:list_sessions")
|
return redirect("games:list_sessions")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
"""Python builder for the stats page body (replaces stats.html).
|
||||||
|
|
||||||
|
Both stats views (`stats_alltime`-style and per-year) assemble a `context`
|
||||||
|
dict and pass it here. Optional sections are driven by `ctx.get(...)` exactly
|
||||||
|
like the old `{% if key %}` blocks: a missing or empty value hides the section.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.template.defaultfilters import date as date_filter
|
||||||
|
from django.template.defaultfilters import floatformat
|
||||||
|
from django.utils.html import conditional_escape
|
||||||
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
|
from common.components import Component, Div, GameLink
|
||||||
|
from common.time import durationformat, format_duration
|
||||||
|
|
||||||
|
_CELL = "px-2 sm:px-4 md:px-6 md:py-2"
|
||||||
|
_CELL_MONO = f"{_CELL} font-mono"
|
||||||
|
_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
|
||||||
|
|
||||||
|
|
||||||
|
def _td(children, cls: str = _CELL_MONO) -> SafeText:
|
||||||
|
if not isinstance(children, list):
|
||||||
|
children = [children]
|
||||||
|
children = [c if isinstance(c, (str, SafeText)) else str(c) for c in children]
|
||||||
|
return Component(tag_name="td", attributes=[("class", cls)], children=children)
|
||||||
|
|
||||||
|
|
||||||
|
def _th(text: str, cls: str = _CELL) -> SafeText:
|
||||||
|
return Component(tag_name="th", attributes=[("class", cls)], children=[text])
|
||||||
|
|
||||||
|
|
||||||
|
def _tr(cells: list) -> SafeText:
|
||||||
|
return Component(tag_name="tr", children=cells)
|
||||||
|
|
||||||
|
|
||||||
|
def _kv(label, value) -> SafeText:
|
||||||
|
"""A label/value row: plain label cell + mono value cell."""
|
||||||
|
return _tr([_td(label, _CELL), _td(value)])
|
||||||
|
|
||||||
|
|
||||||
|
def _h1(title: str) -> SafeText:
|
||||||
|
return Component(
|
||||||
|
tag_name="h1",
|
||||||
|
attributes=[("class", "text-5xl text-center my-6")],
|
||||||
|
children=[title],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _table(rows: list, thead: SafeText | None = None) -> SafeText:
|
||||||
|
children = []
|
||||||
|
if thead is not None:
|
||||||
|
children.append(thead)
|
||||||
|
children.append(Component(tag_name="tbody", children=rows))
|
||||||
|
return Component(
|
||||||
|
tag_name="table",
|
||||||
|
attributes=[("class", "responsive-table")],
|
||||||
|
children=children,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dur(value) -> str:
|
||||||
|
return format_duration(value, durationformat)
|
||||||
|
|
||||||
|
|
||||||
|
def _purchase_name(purchase) -> SafeText:
|
||||||
|
"""Mirror of the `purchase-name` partial in the old template."""
|
||||||
|
game_name = getattr(purchase, "game_name", None)
|
||||||
|
first_game = purchase.first_game
|
||||||
|
if purchase.type != "game":
|
||||||
|
name = game_name or purchase.name
|
||||||
|
link = GameLink(first_game.id, name)
|
||||||
|
suffix = f" ({first_game.name} {purchase.get_type_display()})"
|
||||||
|
return mark_safe(str(link) + conditional_escape(suffix))
|
||||||
|
name = game_name or first_game.name
|
||||||
|
return GameLink(first_game.id, name)
|
||||||
|
|
||||||
|
|
||||||
|
def _year_dropdown(year, year_range) -> SafeText:
|
||||||
|
options = []
|
||||||
|
for year_item in year_range or []:
|
||||||
|
attrs = [("value", str(year_item))]
|
||||||
|
if year == year_item:
|
||||||
|
attrs.append(("selected", True))
|
||||||
|
options.append(
|
||||||
|
Component(tag_name="option", attributes=attrs, children=[str(year_item)])
|
||||||
|
)
|
||||||
|
select = Component(
|
||||||
|
tag_name="select",
|
||||||
|
attributes=[
|
||||||
|
("name", "year"),
|
||||||
|
("id", "yearSelect"),
|
||||||
|
("onchange", "this.form.submit();"),
|
||||||
|
("class", "mx-2"),
|
||||||
|
],
|
||||||
|
children=options,
|
||||||
|
)
|
||||||
|
label = Component(
|
||||||
|
tag_name="label",
|
||||||
|
attributes=[
|
||||||
|
("class", "text-5xl text-center inline-block mb-10"),
|
||||||
|
("for", "yearSelect"),
|
||||||
|
],
|
||||||
|
children=["Stats for:"],
|
||||||
|
)
|
||||||
|
form = Component(
|
||||||
|
tag_name="form",
|
||||||
|
attributes=[("method", "get"), ("class", "text-center")],
|
||||||
|
children=[label, select],
|
||||||
|
)
|
||||||
|
return Div([("class", "flex justify-center items-center")], [form])
|
||||||
|
|
||||||
|
|
||||||
|
def _playtime_table(ctx) -> SafeText:
|
||||||
|
year = ctx.get("year")
|
||||||
|
rows = [
|
||||||
|
_kv("Hours", ctx.get("total_hours")),
|
||||||
|
_kv("Sessions", ctx.get("total_sessions")),
|
||||||
|
_kv(
|
||||||
|
"Days",
|
||||||
|
f"{ctx.get('unique_days')} ({ctx.get('unique_days_percent')}%)",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if ctx.get("total_games"):
|
||||||
|
rows.append(_kv("Games", ctx.get("total_games")))
|
||||||
|
rows.append(_kv(f"Games ({year})", ctx.get("total_year_games")))
|
||||||
|
if ctx.get("all_finished_this_year_count"):
|
||||||
|
rows.append(_kv("Finished", ctx.get("all_finished_this_year_count")))
|
||||||
|
rows.append(
|
||||||
|
_kv(f"Finished ({year})", ctx.get("this_year_finished_this_year_count"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def _game_row(label, value, game):
|
||||||
|
return _tr(
|
||||||
|
[
|
||||||
|
_td(label, _CELL),
|
||||||
|
_td([str(value), " (", GameLink(game.id, game.name), ")"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
longest_game = ctx.get("longest_session_game")
|
||||||
|
if longest_game and longest_game.id:
|
||||||
|
rows.append(
|
||||||
|
_game_row("Longest session", ctx.get("longest_session_time"), longest_game)
|
||||||
|
)
|
||||||
|
most_sessions_game = ctx.get("highest_session_count_game")
|
||||||
|
if most_sessions_game and most_sessions_game.id:
|
||||||
|
rows.append(
|
||||||
|
_game_row(
|
||||||
|
"Most sessions", ctx.get("highest_session_count"), most_sessions_game
|
||||||
|
)
|
||||||
|
)
|
||||||
|
avg_game = ctx.get("highest_session_average_game")
|
||||||
|
if avg_game and avg_game.id:
|
||||||
|
rows.append(
|
||||||
|
_game_row(
|
||||||
|
"Highest session average", ctx.get("highest_session_average"), avg_game
|
||||||
|
)
|
||||||
|
)
|
||||||
|
first_game = ctx.get("first_play_game")
|
||||||
|
if first_game and first_game.id:
|
||||||
|
rows.append(
|
||||||
|
_tr(
|
||||||
|
[
|
||||||
|
_td("First play", _CELL),
|
||||||
|
_td(
|
||||||
|
[
|
||||||
|
GameLink(first_game.id, first_game.name),
|
||||||
|
f" ({ctx.get('first_play_date')})",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
last_game = ctx.get("last_play_game")
|
||||||
|
if last_game and last_game.id:
|
||||||
|
rows.append(
|
||||||
|
_tr(
|
||||||
|
[
|
||||||
|
_td("Last play", _CELL),
|
||||||
|
_td(
|
||||||
|
[
|
||||||
|
GameLink(last_game.id, last_game.name),
|
||||||
|
f" ({ctx.get('last_play_date')})",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return _table(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _purchases_table(ctx) -> SafeText:
|
||||||
|
rows = [
|
||||||
|
_kv("Total", ctx.get("all_purchased_this_year_count")),
|
||||||
|
_kv(
|
||||||
|
"Refunded",
|
||||||
|
f"{ctx.get('all_purchased_refunded_this_year_count')} "
|
||||||
|
f"({ctx.get('refunded_percent')}%)",
|
||||||
|
),
|
||||||
|
_kv(
|
||||||
|
"Dropped",
|
||||||
|
f"{ctx.get('dropped_count')} ({ctx.get('dropped_percentage')}%)",
|
||||||
|
),
|
||||||
|
_kv(
|
||||||
|
"Unfinished",
|
||||||
|
f"{ctx.get('purchased_unfinished_count')} "
|
||||||
|
f"({ctx.get('unfinished_purchases_percent')}%)",
|
||||||
|
),
|
||||||
|
_kv("Backlog Decrease", ctx.get("backlog_decrease_count")),
|
||||||
|
_kv(
|
||||||
|
f"Spendings ({ctx.get('total_spent_currency')})",
|
||||||
|
f"{floatformat(ctx.get('total_spent'))} "
|
||||||
|
f"({floatformat(ctx.get('spent_per_game'))}/game)",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return _table(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _two_col_table(header: str, items, name_key, value_fn) -> SafeText:
|
||||||
|
thead = Component(
|
||||||
|
tag_name="thead",
|
||||||
|
children=[_tr([_th(header), _th("Playtime")])],
|
||||||
|
)
|
||||||
|
rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items]
|
||||||
|
return _table(rows, thead)
|
||||||
|
|
||||||
|
|
||||||
|
def _finished_table(purchases) -> SafeText:
|
||||||
|
thead = Component(
|
||||||
|
tag_name="thead",
|
||||||
|
children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
|
||||||
|
)
|
||||||
|
rows = [
|
||||||
|
_tr([_td(_purchase_name(p)), _td(date_filter(p.date_finished, "d/m/Y"))])
|
||||||
|
for p in purchases
|
||||||
|
]
|
||||||
|
return _table(rows, thead)
|
||||||
|
|
||||||
|
|
||||||
|
def _priced_table(purchases, currency) -> SafeText:
|
||||||
|
thead = Component(
|
||||||
|
tag_name="thead",
|
||||||
|
children=[
|
||||||
|
_tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
|
||||||
|
],
|
||||||
|
)
|
||||||
|
rows = [
|
||||||
|
_tr(
|
||||||
|
[
|
||||||
|
_td(_purchase_name(p)),
|
||||||
|
_td(floatformat(p.converted_price)),
|
||||||
|
_td(date_filter(p.date_purchased, "d/m/Y")),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for p in purchases
|
||||||
|
]
|
||||||
|
return _table(rows, thead)
|
||||||
|
|
||||||
|
|
||||||
|
def stats_content(ctx: dict) -> SafeText:
|
||||||
|
year = ctx.get("year")
|
||||||
|
currency = ctx.get("total_spent_currency")
|
||||||
|
sections: list = [
|
||||||
|
_year_dropdown(year, ctx.get("stats_dropdown_year_range")),
|
||||||
|
_h1("Playtime"),
|
||||||
|
_playtime_table(ctx),
|
||||||
|
]
|
||||||
|
|
||||||
|
months = list(ctx.get("month_playtimes") or [])
|
||||||
|
if months:
|
||||||
|
sections.append(_h1("Playtime per month"))
|
||||||
|
month_rows = [
|
||||||
|
_kv(date_filter(m["month"], "F"), _dur(m["playtime"])) for m in months
|
||||||
|
]
|
||||||
|
sections.append(_table(month_rows))
|
||||||
|
|
||||||
|
sections += [
|
||||||
|
_h1("Purchases"),
|
||||||
|
_purchases_table(ctx),
|
||||||
|
_h1("Games by playtime"),
|
||||||
|
_two_col_table(
|
||||||
|
"Name",
|
||||||
|
ctx.get("top_10_games_by_playtime") or [],
|
||||||
|
lambda g: GameLink(g.id, g.name),
|
||||||
|
lambda g: _dur(g.total_playtime),
|
||||||
|
),
|
||||||
|
_h1("Platforms by playtime"),
|
||||||
|
_two_col_table(
|
||||||
|
"Platform",
|
||||||
|
ctx.get("total_playtime_per_platform") or [],
|
||||||
|
lambda item: item["platform_name"],
|
||||||
|
lambda item: _dur(item["playtime"]),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
all_finished = list(ctx.get("all_finished_this_year") or [])
|
||||||
|
if all_finished:
|
||||||
|
sections += [_h1("Finished"), _finished_table(all_finished)]
|
||||||
|
|
||||||
|
year_finished = list(ctx.get("this_year_finished_this_year") or [])
|
||||||
|
if year_finished:
|
||||||
|
sections += [_h1(f"Finished ({year} games)"), _finished_table(year_finished)]
|
||||||
|
|
||||||
|
bought_finished = list(ctx.get("purchased_this_year_finished_this_year") or [])
|
||||||
|
if bought_finished:
|
||||||
|
sections += [
|
||||||
|
_h1(f"Bought and Finished ({year})"),
|
||||||
|
_finished_table(bought_finished),
|
||||||
|
]
|
||||||
|
|
||||||
|
unfinished = list(ctx.get("purchased_unfinished") or [])
|
||||||
|
if unfinished:
|
||||||
|
sections += [
|
||||||
|
_h1("Unfinished Purchases"),
|
||||||
|
_priced_table(unfinished, currency),
|
||||||
|
]
|
||||||
|
|
||||||
|
all_purchased = list(ctx.get("all_purchased_this_year") or [])
|
||||||
|
if all_purchased:
|
||||||
|
sections += [_h1("All Purchases"), _priced_table(all_purchased, currency)]
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
|
||||||
|
sections,
|
||||||
|
)
|
||||||