Move from HTML templates to pure Python
Remove cruft
@@ -327,9 +327,6 @@ class Session(models.Model):
|
||||
def finish_now(self):
|
||||
self.timestamp_end = timezone.now()
|
||||
|
||||
def start_now():
|
||||
self.timestamp_start = timezone.now()
|
||||
|
||||
def duration_formatted(self) -> str:
|
||||
result = format_duration(self.duration_total, "%02.1H")
|
||||
return result
|
||||
|
||||
@@ -895,18 +895,12 @@
|
||||
max-width: 96rem;
|
||||
}
|
||||
}
|
||||
.m-4 {
|
||||
margin: calc(var(--spacing) * 4);
|
||||
}
|
||||
.mx-2 {
|
||||
margin-inline: calc(var(--spacing) * 2);
|
||||
}
|
||||
.mx-auto {
|
||||
margin-inline: auto;
|
||||
}
|
||||
.my-4 {
|
||||
margin-block: calc(var(--spacing) * 4);
|
||||
}
|
||||
.my-6 {
|
||||
margin-block: calc(var(--spacing) * 6);
|
||||
}
|
||||
@@ -1574,9 +1568,6 @@
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.max-w-\(--breakpoint-lg\) {
|
||||
max-width: var(--breakpoint-lg);
|
||||
}
|
||||
.max-w-\(--breakpoint-xl\) {
|
||||
max-width: var(--breakpoint-xl);
|
||||
}
|
||||
@@ -3778,11 +3769,6 @@
|
||||
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 {
|
||||
border-radius: 0;
|
||||
|
||||
@@ -4,10 +4,10 @@ import requests
|
||||
from django.db import models
|
||||
from django.template.defaultfilters import floatformat
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
|
||||
from games.models import ExchangeRate, Purchase
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
|
||||
# fixme: save preferred currency in user model
|
||||
currency_to = "CZK"
|
||||
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
|
||||
|
||||
app_name = "games"
|
||||
|
||||
from games.api import api
|
||||
from games.views import (
|
||||
device,
|
||||
game,
|
||||
@@ -14,6 +11,8 @@ from games.views import (
|
||||
statuschange,
|
||||
)
|
||||
|
||||
app_name = "games"
|
||||
|
||||
urlpatterns = [
|
||||
path("", general.index, name="index"),
|
||||
path("device/add", device.add_device, name="add_device"),
|
||||
@@ -115,13 +114,11 @@ urlpatterns = [
|
||||
path(
|
||||
"session/add/from-game/<int:session_id>",
|
||||
session.new_session_from_existing_session,
|
||||
{"template": "view_game.html#session-info"},
|
||||
name="view_game_start_session_from_session",
|
||||
),
|
||||
path(
|
||||
"session/add/from-list/<int:session_id>",
|
||||
session.new_session_from_existing_session,
|
||||
{"template": "list_sessions.html#session-row"},
|
||||
name="list_sessions_start_session_from_session",
|
||||
),
|
||||
path("session/<int:session_id>/edit", session.edit_session, name="edit_session"),
|
||||
@@ -133,35 +130,33 @@ urlpatterns = [
|
||||
path(
|
||||
"session/end/from-game/<int:session_id>",
|
||||
session.end_session,
|
||||
{"template": "view_game.html#session-info"},
|
||||
name="view_game_end_session",
|
||||
),
|
||||
path(
|
||||
"session/end/from-list/<int:session_id>",
|
||||
session.end_session,
|
||||
{"template": "list_sessions.html#session-row"},
|
||||
name="list_sessions_end_session",
|
||||
),
|
||||
path("session/list", session.list_sessions, name="list_sessions"),
|
||||
path("session/search", session.search_sessions, name="search_sessions"),
|
||||
path(
|
||||
"statuschange/add",
|
||||
statuschange.AddStatusChangeView.as_view(),
|
||||
statuschange.add_statuschange,
|
||||
name="add_statuschange",
|
||||
),
|
||||
path(
|
||||
"statuschange/edit/<int:statuschange_id>",
|
||||
statuschange.EditStatusChangeView.as_view(),
|
||||
statuschange.edit_statuschange,
|
||||
name="edit_statuschange",
|
||||
),
|
||||
path(
|
||||
"statuschange/delete/<int:pk>",
|
||||
statuschange.GameStatusChangeDeleteView.as_view(),
|
||||
statuschange.delete_statuschange,
|
||||
name="delete_statuschange",
|
||||
),
|
||||
path(
|
||||
"statuschange/list",
|
||||
statuschange.GameStatusChangeListView.as_view(),
|
||||
statuschange.list_statuschanges,
|
||||
name="list_statuschanges",
|
||||
),
|
||||
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.core.paginator import Paginator
|
||||
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 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 games.forms import DeviceForm
|
||||
from games.models import Device
|
||||
@@ -14,7 +20,6 @@ from games.models import Device
|
||||
|
||||
@login_required
|
||||
def list_devices(request: HttpRequest) -> HttpResponse:
|
||||
context: dict[Any, Any] = {}
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
devices = Device.objects.order_by("-created_at")
|
||||
@@ -23,50 +28,50 @@ def list_devices(request: HttpRequest) -> HttpResponse:
|
||||
paginator = Paginator(devices, limit)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
devices = page_obj.object_list
|
||||
elided_page_range = (
|
||||
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||
if page_obj
|
||||
else None
|
||||
)
|
||||
|
||||
context = {
|
||||
"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
|
||||
else None
|
||||
),
|
||||
"data": {
|
||||
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
|
||||
"columns": [
|
||||
"Name",
|
||||
"Type",
|
||||
"Created",
|
||||
"Actions",
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
device.name,
|
||||
device.get_type_display(),
|
||||
local_strftime(device.created_at, dateformat),
|
||||
ButtonGroup(
|
||||
[
|
||||
{
|
||||
"href": reverse("games:edit_device", args=[device.pk]),
|
||||
"slot": Icon("edit"),
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse("games:delete_device", args=[device.pk]),
|
||||
"slot": Icon("delete"),
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
),
|
||||
]
|
||||
for device in devices
|
||||
],
|
||||
},
|
||||
data = {
|
||||
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
|
||||
"columns": [
|
||||
"Name",
|
||||
"Type",
|
||||
"Created",
|
||||
"Actions",
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
device.name,
|
||||
device.get_type_display(),
|
||||
local_strftime(device.created_at, dateformat),
|
||||
ButtonGroup(
|
||||
[
|
||||
{
|
||||
"href": reverse("games:edit_device", args=[device.pk]),
|
||||
"slot": Icon("edit"),
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse("games:delete_device", args=[device.pk]),
|
||||
"slot": Icon("delete"),
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
),
|
||||
]
|
||||
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
|
||||
@@ -77,8 +82,7 @@ def edit_device(request: HttpRequest, device_id: int = 0) -> HttpResponse:
|
||||
form.save()
|
||||
return redirect("games:list_devices")
|
||||
|
||||
context: dict[str, Any] = {"form": form, "title": "Edit device"}
|
||||
return render(request, "add.html", context)
|
||||
return render_page(request, AddForm(form, request=request), title="Edit device")
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -90,12 +94,9 @@ def delete_device(request: HttpRequest, device_id: int) -> HttpResponse:
|
||||
|
||||
@login_required
|
||||
def add_device(request: HttpRequest) -> HttpResponse:
|
||||
context: dict[str, Any] = {}
|
||||
form = DeviceForm(request.POST or None)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("games:index")
|
||||
|
||||
context["form"] = form
|
||||
context["title"] = "Add New Device"
|
||||
return render(request, "add.html", context)
|
||||
return render_page(request, AddForm(form, request=request), title="Add New Device")
|
||||
|
||||
@@ -2,25 +2,39 @@ from typing import Any
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
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.shortcuts import get_object_or_404, redirect, render
|
||||
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.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Component,
|
||||
CsrfInput,
|
||||
Div,
|
||||
GameStatus,
|
||||
GameStatusSelector,
|
||||
H1,
|
||||
Icon,
|
||||
SearchField,
|
||||
LinkedPurchase,
|
||||
Modal,
|
||||
ModuleScript,
|
||||
NameWithIcon,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
PurchasePrice,
|
||||
SimpleTable,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.icons import get_icon
|
||||
from common.layout import render_page
|
||||
from common.time import (
|
||||
dateformat,
|
||||
format_duration,
|
||||
@@ -29,14 +43,13 @@ from common.time import (
|
||||
)
|
||||
from common.utils import build_dynamic_filter, safe_division, truncate
|
||||
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.playevent import create_playevent_tabledata
|
||||
|
||||
|
||||
@login_required
|
||||
def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
context: dict[Any, Any] = {}
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
games = Game.objects.order_by("-created_at")
|
||||
@@ -66,77 +79,70 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
page_obj = paginator.get_page(page_number)
|
||||
games = page_obj.object_list
|
||||
|
||||
context = {
|
||||
"title": "Manage games",
|
||||
"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
|
||||
else None
|
||||
elided_page_range = (
|
||||
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||
if page_obj
|
||||
else None
|
||||
)
|
||||
|
||||
data = {
|
||||
"header_action": Div(
|
||||
children=[
|
||||
SearchField(search_string=search_string),
|
||||
A([], Button([], "Add game"), url_name="games:add_game"),
|
||||
],
|
||||
attributes=[("class", "flex justify-between")],
|
||||
),
|
||||
"data": {
|
||||
"header_action": Div(
|
||||
children=[
|
||||
SearchField(search_string=search_string),
|
||||
A([], Button([], "Add game"), url_name="games:add_game"),
|
||||
],
|
||||
attributes=[("class", "flex justify-between")],
|
||||
),
|
||||
"columns": [
|
||||
"Name",
|
||||
"Sort Name",
|
||||
"Year",
|
||||
"Status",
|
||||
"Wikidata",
|
||||
"Created",
|
||||
"Actions",
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
NameWithIcon(game=game),
|
||||
PopoverTruncated(
|
||||
game.sort_name
|
||||
if game.sort_name is not None and game.name != game.sort_name
|
||||
else "(identical)"
|
||||
),
|
||||
game.year_released,
|
||||
render_to_string(
|
||||
"partials/gamestatus_selector.html",
|
||||
"columns": [
|
||||
"Name",
|
||||
"Sort Name",
|
||||
"Year",
|
||||
"Status",
|
||||
"Wikidata",
|
||||
"Created",
|
||||
"Actions",
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
NameWithIcon(game=game),
|
||||
PopoverTruncated(
|
||||
game.sort_name
|
||||
if game.sort_name is not None and game.name != game.sort_name
|
||||
else "(identical)"
|
||||
),
|
||||
game.year_released,
|
||||
GameStatusSelector(game, Game.Status.choices, get_token(request)),
|
||||
game.wikidata,
|
||||
local_strftime(game.created_at, dateformat),
|
||||
ButtonGroup(
|
||||
[
|
||||
{
|
||||
"game": game,
|
||||
"game_statuses": Game.Status.choices,
|
||||
"href": reverse("games:edit_game", args=[game.pk]),
|
||||
"slot": Icon("edit"),
|
||||
"color": "gray",
|
||||
},
|
||||
request=request,
|
||||
),
|
||||
game.wikidata,
|
||||
local_strftime(game.created_at, dateformat),
|
||||
ButtonGroup(
|
||||
[
|
||||
{
|
||||
"href": reverse("games:edit_game", args=[game.pk]),
|
||||
"slot": Icon("edit"),
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse("games:delete_game", args=[game.pk]),
|
||||
"slot": Icon("delete"),
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
),
|
||||
]
|
||||
for game in games
|
||||
],
|
||||
},
|
||||
{
|
||||
"href": reverse("games:delete_game", args=[game.pk]),
|
||||
"slot": Icon("delete"),
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
),
|
||||
]
|
||||
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
|
||||
def add_game(request: HttpRequest) -> HttpResponse:
|
||||
context: dict[str, Any] = {}
|
||||
form = GameForm(request.POST or None)
|
||||
if form.is_valid():
|
||||
game = form.save()
|
||||
@@ -147,27 +153,154 @@ def add_game(request: HttpRequest) -> HttpResponse:
|
||||
else:
|
||||
return redirect("games:list_games")
|
||||
|
||||
context["form"] = form
|
||||
context["title"] = "Add New Game"
|
||||
context["script_name"] = "add_game.js"
|
||||
return render(request, "add_game.html", context)
|
||||
return render_page(
|
||||
request,
|
||||
AddForm(
|
||||
form,
|
||||
request=request,
|
||||
additional_row=Button(
|
||||
[],
|
||||
"Submit & Create Purchase",
|
||||
color="gray",
|
||||
type="submit",
|
||||
name="submit_and_redirect",
|
||||
),
|
||||
),
|
||||
title="Add New Game",
|
||||
scripts=ModuleScript("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
|
||||
def delete_game_confirmation(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
game = get_object_or_404(Game, id=game_id)
|
||||
session_count = game.sessions.count()
|
||||
purchase_count = game.purchases.count()
|
||||
playevent_count = game.playevents.count()
|
||||
return render(
|
||||
request,
|
||||
"partials/delete_game_confirmation.html",
|
||||
{
|
||||
"game": game,
|
||||
"session_count": session_count,
|
||||
"purchase_count": purchase_count,
|
||||
"playevent_count": playevent_count,
|
||||
},
|
||||
return HttpResponse(
|
||||
_delete_game_confirmation_modal(
|
||||
game,
|
||||
game.sessions.count(),
|
||||
game.purchases.count(),
|
||||
game.playevents.count(),
|
||||
request,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -181,35 +314,224 @@ def delete_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
@login_required
|
||||
@use_custom_redirect
|
||||
def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
context = {}
|
||||
purchase = get_object_or_404(Game, id=game_id)
|
||||
form = GameForm(request.POST or None, instance=purchase)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("games:list_sessions")
|
||||
context["title"] = "Edit Game"
|
||||
context["form"] = form
|
||||
return render(request, "add.html", context)
|
||||
return render_page(request, AddForm(form, request=request), title="Edit Game")
|
||||
|
||||
|
||||
# --- 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
|
||||
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
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")
|
||||
|
||||
sessions = game.sessions
|
||||
@@ -230,7 +552,6 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
playrange = "N/A"
|
||||
latest_session = None
|
||||
|
||||
total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H"))
|
||||
total_hours_without_manual = float(
|
||||
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
|
||||
)
|
||||
@@ -251,7 +572,9 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse("games:delete_purchase", args=[purchase.pk]),
|
||||
"href": reverse(
|
||||
"games:delete_purchase", args=[purchase.pk]
|
||||
),
|
||||
"slot": Icon("delete"),
|
||||
"color": "red",
|
||||
},
|
||||
@@ -349,55 +672,166 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
|
||||
statuschanges = game.status_changes.all()
|
||||
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] = {
|
||||
"statuschange_data": statuschange_data,
|
||||
"statuschange_count": statuschange_count,
|
||||
"statuschanges": statuschanges,
|
||||
"game": game,
|
||||
"game_statuses": Game.Status.choices,
|
||||
"playrange": playrange,
|
||||
"purchase_count": game.purchases.count(),
|
||||
"session_average_without_manual": round(
|
||||
safe_division(
|
||||
total_hours_without_manual, int(session_count_without_manual)
|
||||
purchase_count = game.purchases.count()
|
||||
status_selector_html = GameStatusSelector(
|
||||
game, Game.Status.choices, get_token(request)
|
||||
)
|
||||
session_average_without_manual = round(
|
||||
safe_division(total_hours_without_manual, int(session_count_without_manual)),
|
||||
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],
|
||||
),
|
||||
1,
|
||||
]
|
||||
+ (
|
||||
[
|
||||
mark_safe(" "),
|
||||
Popover(
|
||||
popover_content="Original release year",
|
||||
wrapped_classes="text-slate-500 text-2xl",
|
||||
id="popover-year",
|
||||
children=[str(game.year_released)],
|
||||
),
|
||||
]
|
||||
if game.year_released
|
||||
else []
|
||||
),
|
||||
"session_count": session_count,
|
||||
"sessions": sessions,
|
||||
"title": f"Game Overview - {game.name}",
|
||||
"hours_sum": total_hours,
|
||||
"purchase_data": purchase_data,
|
||||
"playevent_data": playevent_data,
|
||||
"playevent_count": playevent_count,
|
||||
"session_data": session_data,
|
||||
"session_page_obj": session_page_obj,
|
||||
"session_elided_page_range": (
|
||||
session_page_obj.paginator.get_elided_page_range(
|
||||
page_number, on_each_side=1, on_ends=1
|
||||
)
|
||||
if session_page_obj and session_count > 5
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
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(
|
||||
page_number, on_each_side=1, on_ends=1
|
||||
)
|
||||
if session_page_obj and session_count > 5
|
||||
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
|
||||
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 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.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import redirect, render
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
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.utils import safe_division
|
||||
from games.models import Game, Platform, Purchase, Session
|
||||
from games.views.stats_content import stats_content
|
||||
|
||||
|
||||
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_with_currency = this_year_purchases.select_related("games")
|
||||
this_year_purchases_without_refunded = Purchase.objects.filter(
|
||||
date_refunded=None
|
||||
)
|
||||
this_year_purchases_without_refunded = Purchase.objects.filter(date_refunded=None)
|
||||
this_year_purchases_refunded = Purchase.objects.refunded()
|
||||
|
||||
this_year_purchases_unfinished_dropped_nondropped = (
|
||||
this_year_purchases_without_refunded.filter(
|
||||
~Q(games__status="f")
|
||||
& ~Q(games__playevents__ended__isnull=False)
|
||||
~Q(games__status="f") & ~Q(games__playevents__ended__isnull=False)
|
||||
)
|
||||
.filter(infinite=False)
|
||||
.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_dropped_nondropped.filter(
|
||||
~Q(games__status="r")
|
||||
& ~Q(games__status="a")
|
||||
~Q(games__status="r") & ~Q(games__status="a")
|
||||
)
|
||||
)
|
||||
this_year_purchases_dropped = (
|
||||
this_year_purchases.filter(
|
||||
~Q(games__status="f")
|
||||
& ~Q(games__playevents__ended__isnull=False)
|
||||
~Q(games__status="f") & ~Q(games__playevents__ended__isnull=False)
|
||||
)
|
||||
.filter(Q(games__status="a") | Q(date_refunded__isnull=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(
|
||||
"-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(
|
||||
total_spent=Sum(F("converted_price"))
|
||||
)
|
||||
total_spent = this_year_spendings["total_spent"] or 0
|
||||
|
||||
games_with_playtime = Game.objects.filter(
|
||||
sessions__in=this_year_sessions
|
||||
).distinct().annotate(
|
||||
total_playtime=Sum(F("sessions__duration_total"))
|
||||
).filter(total_playtime__gt=timedelta(0))
|
||||
games_with_playtime = (
|
||||
Game.objects.filter(sessions__in=this_year_sessions)
|
||||
.distinct()
|
||||
.annotate(total_playtime=Sum(F("sessions__duration_total")))
|
||||
.filter(total_playtime__gt=timedelta(0))
|
||||
)
|
||||
month_playtimes = (
|
||||
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
|
||||
.values("month")
|
||||
@@ -190,9 +190,7 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
||||
.order_by("-playtime")
|
||||
)
|
||||
|
||||
backlog_decrease_count = (
|
||||
purchases_finished_this_year.count()
|
||||
)
|
||||
backlog_decrease_count = purchases_finished_this_year.count()
|
||||
|
||||
first_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
|
||||
return render(request, "stats.html", context)
|
||||
return render_page(request, stats_content(context), title=context["title"])
|
||||
|
||||
|
||||
@login_required
|
||||
def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
selected_year = request.GET.get("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:
|
||||
return HttpResponseRedirect(reverse("games:stats_alltime"))
|
||||
this_year_sessions = Session.objects.filter(
|
||||
@@ -338,8 +338,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
# only Game and DLC
|
||||
this_year_purchases_unfinished_dropped_nondropped = (
|
||||
this_year_purchases_without_refunded.filter(
|
||||
~Q(games__status="f")
|
||||
& ~Q(games__playevents__ended__year=year)
|
||||
~Q(games__status="f") & ~Q(games__playevents__ended__year=year)
|
||||
)
|
||||
.filter(infinite=False)
|
||||
.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
|
||||
this_year_purchases_unfinished = (
|
||||
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||
~Q(games__status="r")
|
||||
& ~Q(games__status="a")
|
||||
~Q(games__status="r") & ~Q(games__status="a")
|
||||
)
|
||||
)
|
||||
# dropped = abandoned OR retired OR refunded (OR logic for transition)
|
||||
this_year_purchases_dropped = (
|
||||
this_year_purchases.filter(
|
||||
~Q(games__status="f")
|
||||
& ~Q(games__playevents__ended__year=year)
|
||||
~Q(games__status="f") & ~Q(games__playevents__ended__year=year)
|
||||
)
|
||||
.filter(Q(games__status="a") | Q(date_refunded__isnull=False))
|
||||
.filter(infinite=False)
|
||||
@@ -375,9 +372,13 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
* 100
|
||||
)
|
||||
|
||||
purchases_finished_this_year = Purchase.objects.finished().filter(
|
||||
games__playevents__ended__year=year
|
||||
).annotate(game_name=F("games__name"), date_finished=F("games__playevents__ended"))
|
||||
purchases_finished_this_year = (
|
||||
Purchase.objects.finished()
|
||||
.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.filter(games__year_released=year).order_by(
|
||||
"games__playevents__ended"
|
||||
@@ -472,7 +473,6 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
"total_playtime_per_platform": total_playtime_per_platform,
|
||||
"total_spent": total_spent,
|
||||
"total_spent_currency": selected_currency,
|
||||
"all_purchased_this_year": this_year_purchases_without_refunded,
|
||||
"spent_per_game": int(
|
||||
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
|
||||
return render(request, "stats.html", context)
|
||||
return render_page(request, stats_content(context), title=context["title"])
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
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 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 games.forms import PlatformForm
|
||||
from games.models import Platform
|
||||
@@ -15,7 +21,6 @@ from games.views.general import use_custom_redirect
|
||||
|
||||
@login_required
|
||||
def list_platforms(request: HttpRequest) -> HttpResponse:
|
||||
context: dict[Any, Any] = {}
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
platforms = Platform.objects.order_by("name")
|
||||
@@ -24,52 +29,56 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
|
||||
paginator = Paginator(platforms, limit)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
platforms = page_obj.object_list
|
||||
elided_page_range = (
|
||||
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||
if page_obj
|
||||
else None
|
||||
)
|
||||
|
||||
context = {
|
||||
"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
|
||||
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": [
|
||||
"Name",
|
||||
"Icon",
|
||||
"Group",
|
||||
"Created",
|
||||
"Actions",
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
platform.name,
|
||||
Icon(platform.icon),
|
||||
platform.group,
|
||||
local_strftime(platform.created_at, dateformat),
|
||||
ButtonGroup(
|
||||
[
|
||||
{
|
||||
"href": reverse("games:edit_platform", args=[platform.pk]),
|
||||
"slot": Icon("edit"),
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse("games:delete_platform", args=[platform.pk]),
|
||||
"slot": Icon("delete"),
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
),
|
||||
]
|
||||
for platform in platforms
|
||||
],
|
||||
},
|
||||
"columns": [
|
||||
"Name",
|
||||
"Icon",
|
||||
"Group",
|
||||
"Created",
|
||||
"Actions",
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
platform.name,
|
||||
Icon(platform.icon),
|
||||
platform.group,
|
||||
local_strftime(platform.created_at, dateformat),
|
||||
ButtonGroup(
|
||||
[
|
||||
{
|
||||
"href": reverse("games:edit_platform", args=[platform.pk]),
|
||||
"slot": Icon("edit"),
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse(
|
||||
"games:delete_platform", args=[platform.pk]
|
||||
),
|
||||
"slot": Icon("delete"),
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
),
|
||||
]
|
||||
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
|
||||
@@ -82,25 +91,21 @@ def delete_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
|
||||
@login_required
|
||||
@use_custom_redirect
|
||||
def edit_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
|
||||
context = {}
|
||||
platform = get_object_or_404(Platform, id=platform_id)
|
||||
form = PlatformForm(request.POST or None, instance=platform)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("games:list_platforms")
|
||||
context["title"] = "Edit Platform"
|
||||
context["form"] = form
|
||||
return render(request, "add.html", context)
|
||||
return render_page(request, AddForm(form, request=request), title="Edit Platform")
|
||||
|
||||
|
||||
@login_required
|
||||
def add_platform(request: HttpRequest) -> HttpResponse:
|
||||
context: dict[str, Any] = {}
|
||||
form = PlatformForm(request.POST or None)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("games:index")
|
||||
|
||||
context["form"] = form
|
||||
context["title"] = "Add New Platform"
|
||||
return render(request, "add.html", context)
|
||||
return render_page(
|
||||
request, AddForm(form, request=request), title="Add New Platform"
|
||||
)
|
||||
|
||||
@@ -7,10 +7,18 @@ from django.core.paginator import Paginator
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models.manager import BaseManager
|
||||
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 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 games.forms import PlayEventForm
|
||||
from games.models import Game, PlayEvent, Session
|
||||
@@ -74,7 +82,9 @@ def create_playevent_tabledata(
|
||||
for row in row_list
|
||||
]
|
||||
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),
|
||||
"rows": filtered_row_list,
|
||||
}
|
||||
@@ -123,19 +133,19 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
|
||||
paginator = Paginator(playevents, limit)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
playevents = page_obj.object_list
|
||||
context: dict[str, Any] = {
|
||||
"title": "Manage play events",
|
||||
"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
|
||||
else None
|
||||
),
|
||||
"data": create_playevent_tabledata(playevents, request=request),
|
||||
}
|
||||
return render(request, "list_playevents.html", context)
|
||||
elided_page_range = (
|
||||
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||
if page_obj
|
||||
else None
|
||||
)
|
||||
data = create_playevent_tabledata(playevents, request=request)
|
||||
content = paginated_table_content(
|
||||
data,
|
||||
page_obj=page_obj,
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
return render_page(request, content, title="Manage play events")
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -192,22 +202,21 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
game_id = form.instance.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:
|
||||
context: dict[str, Any] = {}
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
form = PlayEventForm(request.POST or None, instance=playevent)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return HttpResponseRedirect(reverse("games:view_game", args=[playevent.game.id]))
|
||||
return HttpResponseRedirect(
|
||||
reverse("games:view_game", args=[playevent.game.id])
|
||||
)
|
||||
|
||||
context = {
|
||||
"form": form,
|
||||
"title": "Edit Play Event",
|
||||
}
|
||||
return render(request, "add.html", context)
|
||||
return render_page(request, AddForm(form, request=request), title="Edit Play Event")
|
||||
|
||||
|
||||
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.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
@@ -8,12 +6,34 @@ from django.http import (
|
||||
HttpResponse,
|
||||
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.utils import timezone
|
||||
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 games.forms import PurchaseForm
|
||||
from games.models import Game, Purchase
|
||||
@@ -75,7 +95,6 @@ def _render_purchase_row(purchase):
|
||||
|
||||
@login_required
|
||||
def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
context: dict[Any, Any] = {}
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
purchases = Purchase.objects.order_by("-date_purchased", "-created_at")
|
||||
@@ -84,38 +103,61 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
paginator = Paginator(purchases, limit)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
purchases = page_obj.object_list
|
||||
elided_page_range = (
|
||||
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||
if page_obj
|
||||
else None
|
||||
)
|
||||
|
||||
context = {
|
||||
"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
|
||||
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": [
|
||||
"Name",
|
||||
"Type",
|
||||
"Price",
|
||||
"Infinite",
|
||||
"Purchased",
|
||||
"Refunded",
|
||||
"Created",
|
||||
"Actions",
|
||||
],
|
||||
"rows": [_render_purchase_row(purchase) for purchase in purchases],
|
||||
},
|
||||
"columns": [
|
||||
"Name",
|
||||
"Type",
|
||||
"Price",
|
||||
"Infinite",
|
||||
"Purchased",
|
||||
"Refunded",
|
||||
"Created",
|
||||
"Actions",
|
||||
],
|
||||
"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
|
||||
def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
context: dict[str, Any] = {}
|
||||
initial = {"date_purchased": timezone.now()}
|
||||
|
||||
if request.method == "POST":
|
||||
@@ -144,26 +186,28 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
else:
|
||||
form = PurchaseForm(initial=initial)
|
||||
|
||||
context["form"] = form
|
||||
context["title"] = "Add New Purchase"
|
||||
context["script_name"] = "add_purchase.js"
|
||||
return render(request, "add_purchase.html", context)
|
||||
return render_page(
|
||||
request,
|
||||
AddForm(form, request=request, additional_row=_purchase_additional_row()),
|
||||
title="Add New Purchase",
|
||||
scripts=ModuleScript("add_purchase.js"),
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@use_custom_redirect
|
||||
def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
context = {}
|
||||
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||
form = PurchaseForm(request.POST or None, instance=purchase)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("games:list_sessions")
|
||||
context["title"] = "Edit Purchase"
|
||||
context["form"] = form
|
||||
context["purchase_id"] = str(purchase_id)
|
||||
context["script_name"] = "add_purchase.js"
|
||||
return render(request, "add_purchase.html", context)
|
||||
return render_page(
|
||||
request,
|
||||
AddForm(form, request=request, additional_row=_purchase_additional_row()),
|
||||
title="Edit Purchase",
|
||||
scripts=ModuleScript("add_purchase.js"),
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -173,13 +217,67 @@ def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
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
|
||||
def view_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||
return render(
|
||||
return render_page(
|
||||
request,
|
||||
"view_purchase.html",
|
||||
{"purchase": purchase, "title": f"Purchase: {purchase.full_name}"},
|
||||
_view_purchase_content(purchase),
|
||||
title=f"Purchase: {purchase.full_name}",
|
||||
)
|
||||
|
||||
|
||||
@@ -192,15 +290,70 @@ def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
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
|
||||
def refund_purchase_confirmation(
|
||||
request: HttpRequest, purchase_id: int
|
||||
) -> HttpResponse:
|
||||
return render(
|
||||
request,
|
||||
"partials/refund_purchase_confirmation.html",
|
||||
{"purchase_id": purchase_id},
|
||||
)
|
||||
return HttpResponse(_refund_confirmation_modal(purchase_id, request))
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -233,9 +386,7 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
|
||||
|
||||
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
|
||||
games: list[str] = []
|
||||
games = request.GET.getlist("games")
|
||||
context = {}
|
||||
games: list[str] = request.GET.getlist("games")
|
||||
if games:
|
||||
form = PurchaseForm()
|
||||
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()
|
||||
if first_option:
|
||||
form.fields["related_purchase"].initial = first_option.id
|
||||
context["form"] = form
|
||||
return render(request, "partials/related_purchase_field.html", context)
|
||||
return HttpResponse(str(form["related_purchase"]))
|
||||
else:
|
||||
# abort swap
|
||||
return HttpResponse(status=204)
|
||||
|
||||
@@ -4,21 +4,29 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Q
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.template.loader import render_to_string
|
||||
from django.middleware.csrf import get_token
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.defaultfilters import date as date_filter
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Component,
|
||||
Div,
|
||||
Icon,
|
||||
SearchField,
|
||||
ModuleScript,
|
||||
NameWithIcon,
|
||||
Popover,
|
||||
SearchField,
|
||||
SessionDeviceSelector,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.layout import render_page
|
||||
from common.time import (
|
||||
dateformat,
|
||||
local_strftime,
|
||||
@@ -31,7 +39,6 @@ from games.models import Device, Game, Session
|
||||
|
||||
@login_required
|
||||
def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
context: dict[Any, Any] = {}
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
sessions = Session.objects.order_by("-timestamp_start", "created_at")
|
||||
@@ -55,120 +62,115 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
page_obj = paginator.get_page(page_number)
|
||||
sessions = page_obj.object_list
|
||||
|
||||
context = {
|
||||
"title": "Manage sessions",
|
||||
"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
|
||||
else None
|
||||
),
|
||||
"data": {
|
||||
"header_action": Div(
|
||||
children=[
|
||||
SearchField(search_string=search_string),
|
||||
Div(
|
||||
children=[
|
||||
A(
|
||||
url_name="games:add_session",
|
||||
children=Button(
|
||||
icon=True,
|
||||
size="xs",
|
||||
children=[Icon("play"), "LOG"],
|
||||
),
|
||||
elided_page_range = (
|
||||
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||
if page_obj
|
||||
else None
|
||||
)
|
||||
|
||||
data = {
|
||||
"header_action": Div(
|
||||
children=[
|
||||
SearchField(search_string=search_string),
|
||||
Div(
|
||||
children=[
|
||||
A(
|
||||
url_name="games:add_session",
|
||||
children=Button(
|
||||
icon=True,
|
||||
size="xs",
|
||||
children=[Icon("play"), "LOG"],
|
||||
),
|
||||
A(
|
||||
href=reverse(
|
||||
"games:list_sessions_start_session_from_session",
|
||||
args=[last_session.pk],
|
||||
),
|
||||
A(
|
||||
href=reverse(
|
||||
"games:list_sessions_start_session_from_session",
|
||||
args=[last_session.pk],
|
||||
),
|
||||
children=Popover(
|
||||
popover_content=last_session.game.name,
|
||||
children=[
|
||||
Button(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
children=[
|
||||
Icon("play"),
|
||||
truncate(f"{last_session.game.name}"),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
if last_session
|
||||
else "",
|
||||
]
|
||||
),
|
||||
],
|
||||
attributes=[("class", "flex justify-between")],
|
||||
),
|
||||
"columns": [
|
||||
"Name",
|
||||
"Date",
|
||||
"Duration",
|
||||
"Device",
|
||||
"Created",
|
||||
"Actions",
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"row_id": f"session-row-{session.pk}",
|
||||
"hx_trigger": "device-changed from:body",
|
||||
"hx_get": "",
|
||||
"hx_select": f"#session-row-{session.pk}",
|
||||
"hx_swap": "outerHTML",
|
||||
"cell_data": [
|
||||
NameWithIcon(session=session),
|
||||
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
||||
session.duration_formatted_with_mark(),
|
||||
SessionDeviceSelector(session, device_list, get_token(request)),
|
||||
session.created_at.strftime(dateformat),
|
||||
ButtonGroup(
|
||||
[
|
||||
{
|
||||
"href": reverse(
|
||||
"games:list_sessions_end_session", args=[session.pk]
|
||||
),
|
||||
children=Popover(
|
||||
popover_content=last_session.game.name,
|
||||
children=[
|
||||
Button(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
children=[
|
||||
Icon("play"),
|
||||
truncate(f"{last_session.game.name}"),
|
||||
],
|
||||
)
|
||||
],
|
||||
"slot": Icon("end"),
|
||||
"title": "Finish session now",
|
||||
"color": "green",
|
||||
}
|
||||
if session.timestamp_end is None
|
||||
else {},
|
||||
{
|
||||
"href": reverse(
|
||||
"games:edit_session", args=[session.pk]
|
||||
),
|
||||
)
|
||||
if last_session
|
||||
else "",
|
||||
"slot": Icon("edit"),
|
||||
"title": "Edit",
|
||||
},
|
||||
{
|
||||
"href": reverse(
|
||||
"games:delete_session", args=[session.pk]
|
||||
),
|
||||
"slot": Icon("delete"),
|
||||
"title": "Delete",
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
),
|
||||
],
|
||||
attributes=[("class", "flex justify-between")],
|
||||
),
|
||||
"columns": [
|
||||
"Name",
|
||||
"Date",
|
||||
"Duration",
|
||||
"Device",
|
||||
"Created",
|
||||
"Actions",
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"row_id": f"session-row-{session.pk}",
|
||||
"hx_trigger": "device-changed from:body",
|
||||
"hx_get": "",
|
||||
"hx_select": f"#session-row-{session.pk}",
|
||||
"hx_swap": "outerHTML",
|
||||
"cell_data": [
|
||||
NameWithIcon(session=session),
|
||||
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
||||
session.duration_formatted_with_mark(),
|
||||
render_to_string(
|
||||
"partials/sessiondevice_selector.html",
|
||||
{
|
||||
"session": session,
|
||||
"session_device": session.device,
|
||||
"session_devices": device_list,
|
||||
},
|
||||
request=request,
|
||||
),
|
||||
session.created_at.strftime(dateformat),
|
||||
ButtonGroup(
|
||||
[
|
||||
{
|
||||
"href": reverse(
|
||||
"games:list_sessions_end_session", args=[session.pk]
|
||||
),
|
||||
"slot": Icon("end"),
|
||||
"title": "Finish session now",
|
||||
"color": "green",
|
||||
}
|
||||
if session.timestamp_end is None
|
||||
else {},
|
||||
{
|
||||
"href": reverse("games:edit_session", args=[session.pk]),
|
||||
"slot": Icon("edit"),
|
||||
"title": "Edit",
|
||||
},
|
||||
{
|
||||
"href": reverse(
|
||||
"games:delete_session", args=[session.pk]
|
||||
),
|
||||
"slot": Icon("delete"),
|
||||
"title": "Delete",
|
||||
"color": "red",
|
||||
},
|
||||
]
|
||||
),
|
||||
],
|
||||
}
|
||||
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
|
||||
@@ -176,13 +178,60 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
|
||||
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
|
||||
def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
context = {}
|
||||
initial: dict[str, Any] = {"timestamp_start": timezone.now()}
|
||||
|
||||
last = Session.objects.last()
|
||||
if last != None:
|
||||
if last is not None:
|
||||
initial["game"] = last.game
|
||||
|
||||
if request.method == "POST":
|
||||
@@ -202,25 +251,116 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
else:
|
||||
form = SessionForm(initial=initial)
|
||||
|
||||
context["title"] = "Add New Session"
|
||||
# TODO: re-add custom buttons #91
|
||||
context["script_name"] = "add_session.js"
|
||||
context["form"] = form
|
||||
return render(request, "add_session.html", context)
|
||||
return render_page(
|
||||
request,
|
||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||
title="Add New Session",
|
||||
scripts=ModuleScript("add_session.js"),
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
||||
context = {}
|
||||
session = get_object_or_404(Session, id=session_id)
|
||||
form = SessionForm(request.POST or None, instance=session)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("games:list_sessions")
|
||||
context["title"] = "Edit Session"
|
||||
context["script_name"] = "add_session.js"
|
||||
context["form"] = form
|
||||
return render(request, "add_session.html", context)
|
||||
return render_page(
|
||||
request,
|
||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||
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:
|
||||
@@ -236,38 +376,21 @@ def clone_session_by_id(session_id: int) -> Session:
|
||||
|
||||
@login_required
|
||||
def new_session_from_existing_session(
|
||||
request: HttpRequest, session_id: int, template: str = ""
|
||||
request: HttpRequest, session_id: int
|
||||
) -> HttpResponse:
|
||||
session = clone_session_by_id(session_id)
|
||||
if request.htmx:
|
||||
context = {
|
||||
"session": session,
|
||||
"session_count": int(request.GET.get("session_count", 0)) + 1,
|
||||
}
|
||||
return render(request, template, context)
|
||||
return HttpResponse(_session_row_fragment(session))
|
||||
return redirect("games:list_sessions")
|
||||
|
||||
|
||||
@login_required
|
||||
def end_session(
|
||||
request: HttpRequest, session_id: int, template: str = ""
|
||||
) -> HttpResponse:
|
||||
def end_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
||||
session = get_object_or_404(Session, id=session_id)
|
||||
session.timestamp_end = timezone.now()
|
||||
session.save()
|
||||
if request.htmx:
|
||||
context = {
|
||||
"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 HttpResponse(_session_row_fragment(session))
|
||||
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,
|
||||
)
|
||||
@@ -1,57 +1,130 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import CreateView, DeleteView, ListView, UpdateView
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeText
|
||||
|
||||
from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
Component,
|
||||
CsrfInput,
|
||||
Div,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.layout import render_page
|
||||
from common.time import dateformat, local_strftime
|
||||
from games.forms import GameStatusChangeForm
|
||||
from games.models import GameStatusChange
|
||||
|
||||
|
||||
class EditStatusChangeView(LoginRequiredMixin, UpdateView):
|
||||
model = GameStatusChange
|
||||
form_class = GameStatusChangeForm
|
||||
template_name = "add.html"
|
||||
context_object_name = "form"
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
return get_object_or_404(GameStatusChange, id=self.kwargs["statuschange_id"])
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("games:list_platforms")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["title"] = "Edit Platform"
|
||||
return context
|
||||
@login_required
|
||||
def add_statuschange(request: HttpRequest) -> HttpResponse:
|
||||
form = GameStatusChangeForm(request.POST or None)
|
||||
if form.is_valid():
|
||||
obj = form.save()
|
||||
return redirect("games:view_game", game_id=obj.game.id)
|
||||
return render_page(
|
||||
request, AddForm(form, request=request), title="Add status change"
|
||||
)
|
||||
|
||||
|
||||
class AddStatusChangeView(LoginRequiredMixin, CreateView):
|
||||
model = GameStatusChange
|
||||
form_class = GameStatusChangeForm
|
||||
template_name = "add.html"
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("games:view_game", kwargs={"pk": self.object.game.id})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["title"] = "Add status change"
|
||||
return context
|
||||
@login_required
|
||||
def edit_statuschange(request: HttpRequest, statuschange_id: int) -> HttpResponse:
|
||||
statuschange = get_object_or_404(GameStatusChange, id=statuschange_id)
|
||||
form = GameStatusChangeForm(request.POST or None, instance=statuschange)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("games:list_platforms")
|
||||
return render_page(
|
||||
request, AddForm(form, request=request), title="Edit status change"
|
||||
)
|
||||
|
||||
|
||||
class GameStatusChangeListView(LoginRequiredMixin, ListView):
|
||||
model = GameStatusChange
|
||||
template_name = "list_purchases.html"
|
||||
context_object_name = "status_changes"
|
||||
paginate_by = 10
|
||||
@login_required
|
||||
def list_statuschanges(request: HttpRequest) -> HttpResponse:
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
statuschanges = GameStatusChange.objects.select_related("game").all()
|
||||
page_obj = None
|
||||
if int(limit) != 0:
|
||||
paginator = Paginator(statuschanges, limit)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
statuschanges = page_obj.object_list
|
||||
elided_page_range = (
|
||||
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||
if page_obj
|
||||
else None
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return GameStatusChange.objects.select_related("game").all()
|
||||
data = {
|
||||
"header_action": None,
|
||||
"columns": ["Game", "Old Status", "New Status", "Timestamp"],
|
||||
"rows": [
|
||||
[
|
||||
sc.game.name,
|
||||
sc.get_old_status_display() if sc.old_status else "-",
|
||||
sc.get_new_status_display(),
|
||||
local_strftime(sc.timestamp, dateformat) if sc.timestamp else "-",
|
||||
]
|
||||
for sc in statuschanges
|
||||
],
|
||||
}
|
||||
content = paginated_table_content(
|
||||
data,
|
||||
page_obj=page_obj,
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
return render_page(request, content, title="Status changes")
|
||||
|
||||
|
||||
class GameStatusChangeDeleteView(LoginRequiredMixin, DeleteView):
|
||||
model = GameStatusChange
|
||||
template_name = "gamestatuschange_confirm_delete.html"
|
||||
def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText:
|
||||
inner = Div(
|
||||
[],
|
||||
[
|
||||
Component(
|
||||
tag_name="p",
|
||||
children=["Are you sure you want to delete this status change?"],
|
||||
),
|
||||
Button(
|
||||
[("class", "w-full")], "Delete", color="red", type="submit", size="lg"
|
||||
),
|
||||
A(
|
||||
[("class", "")],
|
||||
Button([("class", "w-full")], "Cancel", color="gray"),
|
||||
href=reverse("games:view_game", args=[statuschange.game.id]),
|
||||
),
|
||||
],
|
||||
)
|
||||
form = Component(
|
||||
tag_name="form",
|
||||
attributes=[("method", "post"), ("class", "dark:text-white")],
|
||||
children=[CsrfInput(request), inner],
|
||||
)
|
||||
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",
|
||||
)
|
||||
],
|
||||
[form],
|
||||
)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("games:view_game", kwargs={"game_id": self.object.game.id})
|
||||
|
||||
@login_required
|
||||
def delete_statuschange(request: HttpRequest, pk: int) -> HttpResponse:
|
||||
statuschange = get_object_or_404(GameStatusChange, id=pk)
|
||||
if request.method == "POST":
|
||||
game_id = statuschange.game.id
|
||||
statuschange.delete()
|
||||
return redirect("games:view_game", game_id=game_id)
|
||||
return render_page(
|
||||
request,
|
||||
_delete_statuschange_content(statuschange, request),
|
||||
title="Delete status change",
|
||||
)
|
||||
|
||||