Compare commits
	
		
			46 Commits
		
	
	
		
			1.0.2
			...
			e3ee832d3f
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e3ee832d3f | |||
| 7467e2732d | |||
| 787ee8640f | |||
| ab41222f3c | |||
| 29bf3b1946 | |||
| 3f7ccea2e2 | |||
| b5ffb3586b | |||
| 26d57a238e | |||
| 2d5ad3182c | |||
| 49cc3ea0cc | |||
| 440e1cfb71 | |||
| 1cbd8c5c55 | |||
| bc81a0ee8e | |||
| c5653977ff | |||
| f151730ab6 | |||
| f469a67d94 | |||
| 104ffc9d03 | |||
| a4b13eb247 | |||
| 2307fac83a | |||
| 6b52c0d4c4 | |||
| ff5d8c215d | |||
| cdb3b89b08 | |||
| ffa8198540 | |||
| 0b7da3550c | |||
| e1655d6cfa | |||
| 29c41865d0 | |||
| d21b461726 | |||
| 95489cfb78 | |||
| fa4f1c4810 | |||
| 366c25a1ff | |||
| a3042caa20 | |||
| 7997f9bbb2 | |||
| 
						
						
							
						
						b78c4ba9c5
	
				 | 
					
					
						|||
| 
						
						
							
						
						1df889c45d
	
				 | 
					
					
						|||
| 
						
						
							
						
						468d05a9e2
	
				 | 
					
					
						|||
| 
						
						
							
						
						2640a49734
	
				 | 
					
					
						|||
| 
						
						
							
						
						65c175afb2
	
				 | 
					
					
						|||
| 
						
						
							
						
						0814071a26
	
				 | 
					
					
						|||
| 
						
						
							
						
						5f845f866e
	
				 | 
					
					
						|||
| 
						
						
							
						
						c3d4697470
	
				 | 
					
					
						|||
| 77293f03e9 | |||
| 1fa364e2ec | |||
| 4a6f4a2f9a | |||
| 9590988b6a | |||
| 938c82a395 | |||
| 33939f631c | 
							
								
								
									
										17
									
								
								.editorconfig
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,17 @@
 | 
			
		||||
root = true
 | 
			
		||||
 | 
			
		||||
[*]
 | 
			
		||||
end_of_line = lf
 | 
			
		||||
insert_final_newline = true
 | 
			
		||||
 | 
			
		||||
[*.{js,py}]
 | 
			
		||||
charset = utf-8
 | 
			
		||||
 | 
			
		||||
# 4 space indentation
 | 
			
		||||
[*.py]
 | 
			
		||||
indent_style = space
 | 
			
		||||
indent_size = 4
 | 
			
		||||
 | 
			
		||||
[**/*.js]
 | 
			
		||||
indent_style = space
 | 
			
		||||
indent_size = 2
 | 
			
		||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						@ -5,5 +5,5 @@ __pycache__
 | 
			
		||||
node_modules
 | 
			
		||||
package-lock.json
 | 
			
		||||
db.sqlite3
 | 
			
		||||
static
 | 
			
		||||
/static/
 | 
			
		||||
dist/
 | 
			
		||||
							
								
								
									
										7
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						@ -4,5 +4,8 @@
 | 
			
		||||
    ],
 | 
			
		||||
    "python.testing.unittestEnabled": false,
 | 
			
		||||
    "python.testing.pytestEnabled": true,
 | 
			
		||||
    "python.analysis.typeCheckingMode": "basic"
 | 
			
		||||
}
 | 
			
		||||
    "python.analysis.typeCheckingMode": "basic",
 | 
			
		||||
    "[python]": {
 | 
			
		||||
        "editor.defaultFormatter": "ms-python.black-formatter"
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										22
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						@ -1,3 +1,25 @@
 | 
			
		||||
## Unreleased
 | 
			
		||||
 | 
			
		||||
* Improve form appearance
 | 
			
		||||
* Add helper buttons next to datime fields
 | 
			
		||||
* Change recent session view to current year instead of last 30 days
 | 
			
		||||
* Fix bug when filtering only manual sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/51)
 | 
			
		||||
* Add copy button on Add session page to copy times between fields
 | 
			
		||||
* Use the same form when editing a session as when adding a session
 | 
			
		||||
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
 | 
			
		||||
* Focus important fields on forms
 | 
			
		||||
* Improve session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
 | 
			
		||||
* Change fonts to IBM Plex
 | 
			
		||||
* Only use local WOFF2 font files
 | 
			
		||||
* Add game overview page (https://git.kucharczyk.xyz/lukas/timetracker/issues/8)
 | 
			
		||||
 | 
			
		||||
## 1.0.3 / 2023-02-20 17:16+01:00
 | 
			
		||||
 | 
			
		||||
* Add wikidata ID and year for editions
 | 
			
		||||
* Add icons for game, edition, purchase filters
 | 
			
		||||
* Allow filtering by game, edition, purchase from the session list
 | 
			
		||||
* Allow editing filtered entities from session list
 | 
			
		||||
 | 
			
		||||
## 1.0.2 / 2023-02-18 21:48+01:00
 | 
			
		||||
 | 
			
		||||
* Add support for device info (https://git.kucharczyk.xyz/lukas/timetracker/issues/49)
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,7 @@ RUN npm install && \
 | 
			
		||||
 | 
			
		||||
FROM python:3.10.9-slim-bullseye
 | 
			
		||||
 | 
			
		||||
ENV VERSION_NUMBER 1.0.2
 | 
			
		||||
ENV VERSION_NUMBER 1.0.3
 | 
			
		||||
ENV PROD 1
 | 
			
		||||
ENV PYTHONUNBUFFERED=1
 | 
			
		||||
 | 
			
		||||
@ -28,7 +28,7 @@ RUN chmod +x /entrypoint.sh
 | 
			
		||||
USER timetracker
 | 
			
		||||
ENV PATH="$PATH:/home/timetracker/.local/bin"
 | 
			
		||||
RUN pip install --no-cache-dir poetry
 | 
			
		||||
RUN poetry install --without dev
 | 
			
		||||
RUN poetry install
 | 
			
		||||
 | 
			
		||||
EXPOSE 8000
 | 
			
		||||
CMD [ "/entrypoint.sh" ]
 | 
			
		||||
CMD [ "/entrypoint.sh" ]
 | 
			
		||||
 | 
			
		||||
@ -2,21 +2,102 @@
 | 
			
		||||
@tailwind components;
 | 
			
		||||
@tailwind utilities;
 | 
			
		||||
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: "IBM Plex Mono";
 | 
			
		||||
  src: url("fonts/IBMPlexMono-regular.woff2") format("woff2");
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
  font-style: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: "IBM Plex Sans";
 | 
			
		||||
  src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
  font-style: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: "IBM Plex Serif";
 | 
			
		||||
  src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
  font-style: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
form label {
 | 
			
		||||
  @apply dark:text-slate-400;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.responsive-table {
 | 
			
		||||
  @apply dark:text-white mx-auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.responsive-table tr:nth-child(even) {
 | 
			
		||||
  @apply bg-slate-800
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.responsive-table tbody tr:nth-child(odd) {
 | 
			
		||||
  @apply bg-slate-900
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.responsive-table thead th {
 | 
			
		||||
  @apply text-left border-b-2 border-b-slate-500 text-xl;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.responsive-table thead th:not(:first-child),
 | 
			
		||||
.responsive-table td:not(:first-child) {
 | 
			
		||||
  @apply border-l border-l-slate-500;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@layer utilities {
 | 
			
		||||
  .max-w-20char {
 | 
			
		||||
    max-width: 20ch;
 | 
			
		||||
  }
 | 
			
		||||
  .max-w-35char {
 | 
			
		||||
    max-width: 40ch;
 | 
			
		||||
  }
 | 
			
		||||
  .max-w-40char {
 | 
			
		||||
    max-width: 40ch;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
form input,
 | 
			
		||||
select,
 | 
			
		||||
textarea {
 | 
			
		||||
  @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#session-table {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr;
 | 
			
		||||
@media screen and (min-width: 768px) {
 | 
			
		||||
  form input,
 | 
			
		||||
  select,
 | 
			
		||||
  textarea {
 | 
			
		||||
    width: 300px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media screen and (max-width: 768px) {
 | 
			
		||||
  form input,
 | 
			
		||||
  select,
 | 
			
		||||
  textarea {
 | 
			
		||||
    width: 150px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#button-container button {
 | 
			
		||||
  @apply mx-1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
th {
 | 
			
		||||
  @apply text-right;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
th label {
 | 
			
		||||
  @apply mr-4;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.basic-button-container {
 | 
			
		||||
  @apply flex space-x-2 justify-center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.basic-button {
 | 
			
		||||
  @apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -7,5 +7,13 @@ poetry run python manage.py migrate
 | 
			
		||||
echo "Collect static files"
 | 
			
		||||
poetry run python manage.py collectstatic --clear --no-input
 | 
			
		||||
 | 
			
		||||
_term() {
 | 
			
		||||
  echo "Caught SIGTERM signal!"
 | 
			
		||||
  kill -SIGTERM "$gunicorn_pid"
 | 
			
		||||
}
 | 
			
		||||
trap _term SIGTERM
 | 
			
		||||
 | 
			
		||||
echo "Starting app"
 | 
			
		||||
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile -
 | 
			
		||||
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
 | 
			
		||||
 | 
			
		||||
wait "$gunicorn_pid"
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,11 @@
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
from games.models import Game, Platform, Purchase, Session
 | 
			
		||||
from games.models import Game, Platform, Purchase, Session, Edition, Device
 | 
			
		||||
 | 
			
		||||
# Register your models here.
 | 
			
		||||
admin.site.register(Game)
 | 
			
		||||
admin.site.register(Purchase)
 | 
			
		||||
admin.site.register(Platform)
 | 
			
		||||
admin.site.register(Session)
 | 
			
		||||
admin.site.register(Edition)
 | 
			
		||||
admin.site.register(Device)
 | 
			
		||||
 | 
			
		||||
@ -2,13 +2,28 @@ from django import forms
 | 
			
		||||
 | 
			
		||||
from games.models import Game, Platform, Purchase, Session, Edition, Device
 | 
			
		||||
 | 
			
		||||
custom_date_widget = forms.DateInput(attrs={"type": "date"})
 | 
			
		||||
custom_datetime_widget = forms.DateTimeInput(
 | 
			
		||||
    attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
 | 
			
		||||
)
 | 
			
		||||
autofocus_select_widget = forms.Select(attrs={"autofocus": "autofocus"})
 | 
			
		||||
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SessionForm(forms.ModelForm):
 | 
			
		||||
    # purchase = forms.ModelChoiceField(
 | 
			
		||||
    #     queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
 | 
			
		||||
    # )
 | 
			
		||||
    purchase = forms.ModelChoiceField(
 | 
			
		||||
        queryset=Purchase.objects.order_by("edition__name")
 | 
			
		||||
        queryset=Purchase.objects.order_by("edition__name"),
 | 
			
		||||
        widget=autofocus_select_widget,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        widgets = {
 | 
			
		||||
            "timestamp_start": custom_datetime_widget,
 | 
			
		||||
            "timestamp_end": custom_datetime_widget,
 | 
			
		||||
        }
 | 
			
		||||
        model = Session
 | 
			
		||||
        fields = [
 | 
			
		||||
            "purchase",
 | 
			
		||||
@ -20,11 +35,22 @@ class SessionForm(forms.ModelForm):
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EditionChoiceField(forms.ModelChoiceField):
 | 
			
		||||
    def label_from_instance(self, obj) -> str:
 | 
			
		||||
        return f"{obj.name} ({obj.platform}, {obj.year_released})"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PurchaseForm(forms.ModelForm):
 | 
			
		||||
    edition = forms.ModelChoiceField(queryset=Edition.objects.order_by("name"))
 | 
			
		||||
    edition = EditionChoiceField(
 | 
			
		||||
        queryset=Edition.objects.order_by("name"), widget=autofocus_select_widget
 | 
			
		||||
    )
 | 
			
		||||
    platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        widgets = {
 | 
			
		||||
            "date_purchased": custom_date_widget,
 | 
			
		||||
            "date_refunded": custom_date_widget,
 | 
			
		||||
        }
 | 
			
		||||
        model = Purchase
 | 
			
		||||
        fields = [
 | 
			
		||||
            "edition",
 | 
			
		||||
@ -38,24 +64,32 @@ class PurchaseForm(forms.ModelForm):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EditionForm(forms.ModelForm):
 | 
			
		||||
    game = forms.ModelChoiceField(
 | 
			
		||||
        queryset=Game.objects.order_by("name"), widget=autofocus_select_widget
 | 
			
		||||
    )
 | 
			
		||||
    platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Edition
 | 
			
		||||
        fields = ["game", "name", "platform"]
 | 
			
		||||
        fields = ["game", "name", "platform", "year_released", "wikidata"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GameForm(forms.ModelForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Game
 | 
			
		||||
        fields = ["name", "wikidata"]
 | 
			
		||||
        widgets = {"name": autofocus_input_widget}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PlatformForm(forms.ModelForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Platform
 | 
			
		||||
        fields = ["name", "group"]
 | 
			
		||||
        widgets = {"name": autofocus_input_widget}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DeviceForm(forms.ModelForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Device
 | 
			
		||||
        fields = ["name", "type"]
 | 
			
		||||
        widgets = {"name": autofocus_input_widget}
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,23 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-02-20 14:55
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0014_device_session_device"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="edition",
 | 
			
		||||
            name="wikidata",
 | 
			
		||||
            field=models.CharField(blank=True, default=None, max_length=50, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="edition",
 | 
			
		||||
            name="year_released",
 | 
			
		||||
            field=models.IntegerField(default=2023),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -20,6 +20,8 @@ class Edition(models.Model):
 | 
			
		||||
    game = models.ForeignKey("Game", on_delete=models.CASCADE)
 | 
			
		||||
    name = models.CharField(max_length=255)
 | 
			
		||||
    platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
 | 
			
		||||
    year_released = models.IntegerField(default=datetime.today().year)
 | 
			
		||||
    wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
@ -56,7 +58,10 @@ class Purchase(models.Model):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f"{self.edition} ({self.platform}, {self.get_ownership_type_display()})"
 | 
			
		||||
        platform_info = self.platform
 | 
			
		||||
        if self.platform != self.edition.platform:
 | 
			
		||||
            platform_info = f"{self.edition.platform} version on {self.platform}"
 | 
			
		||||
        return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Platform(models.Model):
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
/*
 | 
			
		||||
! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com
 | 
			
		||||
! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
@ -31,6 +31,7 @@
 | 
			
		||||
3. Use a more readable tab size.
 | 
			
		||||
4. Use the user's configured `sans` font-family by default.
 | 
			
		||||
5. Use the user's configured `sans` font-feature-settings by default.
 | 
			
		||||
6. Use the user's configured `sans` font-variation-settings by default.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
html {
 | 
			
		||||
@ -43,10 +44,12 @@ html {
 | 
			
		||||
  -o-tab-size: 4;
 | 
			
		||||
     tab-size: 4;
 | 
			
		||||
  /* 3 */
 | 
			
		||||
  font-family: Inter, sans-serif;
 | 
			
		||||
  font-family: IBM Plex Sans, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
 | 
			
		||||
  /* 4 */
 | 
			
		||||
  font-feature-settings: normal;
 | 
			
		||||
  /* 5 */
 | 
			
		||||
  font-variation-settings: normal;
 | 
			
		||||
  /* 6 */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
@ -126,7 +129,7 @@ code,
 | 
			
		||||
kbd,
 | 
			
		||||
samp,
 | 
			
		||||
pre {
 | 
			
		||||
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
 | 
			
		||||
  font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
 | 
			
		||||
  /* 1 */
 | 
			
		||||
  font-size: 1em;
 | 
			
		||||
  /* 2 */
 | 
			
		||||
@ -188,6 +191,10 @@ select,
 | 
			
		||||
textarea {
 | 
			
		||||
  font-family: inherit;
 | 
			
		||||
  /* 1 */
 | 
			
		||||
  font-feature-settings: inherit;
 | 
			
		||||
  /* 1 */
 | 
			
		||||
  font-variation-settings: inherit;
 | 
			
		||||
  /* 1 */
 | 
			
		||||
  font-size: 100%;
 | 
			
		||||
  /* 1 */
 | 
			
		||||
  font-weight: inherit;
 | 
			
		||||
@ -338,6 +345,14 @@ menu {
 | 
			
		||||
  padding: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Reset default styling for dialogs.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
dialog {
 | 
			
		||||
  padding: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Prevent resizing textareas horizontally by default.
 | 
			
		||||
*/
 | 
			
		||||
@ -419,7 +434,7 @@ video {
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[type='text'],[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select {
 | 
			
		||||
[type='text'],input:where(:not([type])),[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select {
 | 
			
		||||
  -webkit-appearance: none;
 | 
			
		||||
     -moz-appearance: none;
 | 
			
		||||
          appearance: none;
 | 
			
		||||
@ -436,7 +451,7 @@ video {
 | 
			
		||||
  --tw-shadow: 0 0 #0000;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus {
 | 
			
		||||
[type='text']:focus, input:where(:not([type])):focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus {
 | 
			
		||||
  outline: 2px solid transparent;
 | 
			
		||||
  outline-offset: 2px;
 | 
			
		||||
  --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);
 | 
			
		||||
@ -465,6 +480,11 @@ input::placeholder,textarea::placeholder {
 | 
			
		||||
 | 
			
		||||
::-webkit-date-and-time-value {
 | 
			
		||||
  min-height: 1.5em;
 | 
			
		||||
  text-align: inherit;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
::-webkit-datetime-edit {
 | 
			
		||||
  display: inline-flex;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field {
 | 
			
		||||
@ -482,7 +502,7 @@ select {
 | 
			
		||||
          print-color-adjust: exact;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[multiple] {
 | 
			
		||||
[multiple],[size]:where(select:not([size="1"])) {
 | 
			
		||||
  background-image: initial;
 | 
			
		||||
  background-position: initial;
 | 
			
		||||
  background-repeat: unset;
 | 
			
		||||
@ -599,6 +619,9 @@ select {
 | 
			
		||||
  --tw-pan-y:  ;
 | 
			
		||||
  --tw-pinch-zoom:  ;
 | 
			
		||||
  --tw-scroll-snap-strictness: proximity;
 | 
			
		||||
  --tw-gradient-from-position:  ;
 | 
			
		||||
  --tw-gradient-via-position:  ;
 | 
			
		||||
  --tw-gradient-to-position:  ;
 | 
			
		||||
  --tw-ordinal:  ;
 | 
			
		||||
  --tw-slashed-zero:  ;
 | 
			
		||||
  --tw-numeric-figure:  ;
 | 
			
		||||
@ -646,6 +669,9 @@ select {
 | 
			
		||||
  --tw-pan-y:  ;
 | 
			
		||||
  --tw-pinch-zoom:  ;
 | 
			
		||||
  --tw-scroll-snap-strictness: proximity;
 | 
			
		||||
  --tw-gradient-from-position:  ;
 | 
			
		||||
  --tw-gradient-via-position:  ;
 | 
			
		||||
  --tw-gradient-to-position:  ;
 | 
			
		||||
  --tw-ordinal:  ;
 | 
			
		||||
  --tw-slashed-zero:  ;
 | 
			
		||||
  --tw-numeric-figure:  ;
 | 
			
		||||
@ -713,6 +739,10 @@ select {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.invisible {
 | 
			
		||||
  visibility: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.static {
 | 
			
		||||
  position: static;
 | 
			
		||||
}
 | 
			
		||||
@ -721,16 +751,24 @@ select {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.left-2 {
 | 
			
		||||
  left: 0.5rem;
 | 
			
		||||
.absolute {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.bottom-2 {
 | 
			
		||||
  bottom: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.clear-both {
 | 
			
		||||
  clear: both;
 | 
			
		||||
.left-2 {
 | 
			
		||||
  left: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.right-3 {
 | 
			
		||||
  right: 0.75rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.top-3 {
 | 
			
		||||
  top: 0.75rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mx-auto {
 | 
			
		||||
@ -738,26 +776,62 @@ select {
 | 
			
		||||
  margin-right: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.my-4 {
 | 
			
		||||
  margin-top: 1rem;
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mx-2 {
 | 
			
		||||
  margin-left: 0.5rem;
 | 
			
		||||
  margin-right: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.my-1 {
 | 
			
		||||
  margin-top: 0.25rem;
 | 
			
		||||
  margin-bottom: 0.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.my-2 {
 | 
			
		||||
  margin-top: 0.5rem;
 | 
			
		||||
  margin-bottom: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mb-4 {
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mr-4 {
 | 
			
		||||
  margin-right: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mt-4 {
 | 
			
		||||
  margin-top: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mb-3 {
 | 
			
		||||
  margin-bottom: 0.75rem;
 | 
			
		||||
.mt-1 {
 | 
			
		||||
  margin-top: 0.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mt-10 {
 | 
			
		||||
  margin-top: 2.5rem;
 | 
			
		||||
.mt-2 {
 | 
			
		||||
  margin-top: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mb-2 {
 | 
			
		||||
  margin-bottom: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mb-1 {
 | 
			
		||||
  margin-bottom: 0.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ml-1 {
 | 
			
		||||
  margin-left: 0.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ml-2 {
 | 
			
		||||
  margin-left: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.block {
 | 
			
		||||
  display: block;
 | 
			
		||||
}
 | 
			
		||||
@ -770,46 +844,72 @@ select {
 | 
			
		||||
  display: flex;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.table {
 | 
			
		||||
  display: table;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.list-item {
 | 
			
		||||
  display: list-item;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.hidden {
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.h-6 {
 | 
			
		||||
  height: 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.h-5 {
 | 
			
		||||
  height: 1.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.h-4 {
 | 
			
		||||
  height: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.min-h-screen {
 | 
			
		||||
  min-height: 100vh;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.w-full {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.w-6 {
 | 
			
		||||
  width: 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.w-full {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.w-5 {
 | 
			
		||||
  width: 1.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.w-7 {
 | 
			
		||||
  width: 1.75rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.w-4 {
 | 
			
		||||
  width: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.max-w-screen-lg {
 | 
			
		||||
  max-width: 1024px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.max-w-xs {
 | 
			
		||||
  max-width: 20rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.max-w-lg {
 | 
			
		||||
  max-width: 32rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.max-w-3xl {
 | 
			
		||||
  max-width: 48rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.max-w-sm {
 | 
			
		||||
  max-width: 24rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes spin {
 | 
			
		||||
  to {
 | 
			
		||||
    transform: rotate(360deg);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.animate-spin {
 | 
			
		||||
  animation: spin 1s linear infinite;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.list-disc {
 | 
			
		||||
  list-style-type: disc;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.flex-col {
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
}
 | 
			
		||||
@ -822,32 +922,18 @@ select {
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.justify-end {
 | 
			
		||||
  justify-content: flex-end;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.justify-center {
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.justify-between {
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.gap-4 {
 | 
			
		||||
  gap: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.self-center {
 | 
			
		||||
  align-self: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.overflow-hidden {
 | 
			
		||||
.truncate {
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-ellipsis {
 | 
			
		||||
  text-overflow: ellipsis;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.whitespace-nowrap {
 | 
			
		||||
@ -862,18 +948,24 @@ select {
 | 
			
		||||
  border-radius: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.rounded-xl {
 | 
			
		||||
  border-radius: 0.75rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.border-gray-200 {
 | 
			
		||||
  --tw-border-opacity: 1;
 | 
			
		||||
  border-color: rgb(229 231 235 / var(--tw-border-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.bg-white {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(255 255 255 / var(--tw-bg-opacity));
 | 
			
		||||
.border-red-300 {
 | 
			
		||||
  --tw-border-opacity: 1;
 | 
			
		||||
  border-color: rgb(252 165 165 / var(--tw-border-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.border-red-500 {
 | 
			
		||||
  --tw-border-opacity: 1;
 | 
			
		||||
  border-color: rgb(239 68 68 / var(--tw-border-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.border-slate-500 {
 | 
			
		||||
  --tw-border-opacity: 1;
 | 
			
		||||
  border-color: rgb(100 116 139 / var(--tw-border-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.bg-green-600 {
 | 
			
		||||
@ -881,27 +973,23 @@ select {
 | 
			
		||||
  background-color: rgb(22 163 74 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.bg-blue-600 {
 | 
			
		||||
.bg-white {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(37 99 235 / var(--tw-bg-opacity));
 | 
			
		||||
  background-color: rgb(255 255 255 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.bg-red-600 {
 | 
			
		||||
.bg-slate-400 {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(220 38 38 / var(--tw-bg-opacity));
 | 
			
		||||
  background-color: rgb(148 163 184 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.p-4 {
 | 
			
		||||
  padding: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.p-2 {
 | 
			
		||||
  padding: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.py-2 {
 | 
			
		||||
  padding-top: 0.5rem;
 | 
			
		||||
  padding-bottom: 0.5rem;
 | 
			
		||||
.px-2 {
 | 
			
		||||
  padding-left: 0.5rem;
 | 
			
		||||
  padding-right: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.py-1 {
 | 
			
		||||
@ -909,9 +997,9 @@ select {
 | 
			
		||||
  padding-bottom: 0.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.px-2 {
 | 
			
		||||
  padding-left: 0.5rem;
 | 
			
		||||
  padding-right: 0.5rem;
 | 
			
		||||
.py-2 {
 | 
			
		||||
  padding-top: 0.5rem;
 | 
			
		||||
  padding-bottom: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pl-3 {
 | 
			
		||||
@ -926,8 +1014,16 @@ select {
 | 
			
		||||
  text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-right {
 | 
			
		||||
  text-align: right;
 | 
			
		||||
.font-mono {
 | 
			
		||||
  font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.font-serif {
 | 
			
		||||
  font-family: IBM Plex Serif, ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.font-sans {
 | 
			
		||||
  font-family: IBM Plex Sans, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-4xl {
 | 
			
		||||
@ -935,6 +1031,11 @@ select {
 | 
			
		||||
  line-height: 2.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-base {
 | 
			
		||||
  font-size: 1rem;
 | 
			
		||||
  line-height: 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-xl {
 | 
			
		||||
  font-size: 1.25rem;
 | 
			
		||||
  line-height: 1.75rem;
 | 
			
		||||
@ -945,9 +1046,34 @@ select {
 | 
			
		||||
  line-height: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-base {
 | 
			
		||||
  font-size: 1rem;
 | 
			
		||||
  line-height: 1.5rem;
 | 
			
		||||
.text-2xl {
 | 
			
		||||
  font-size: 1.5rem;
 | 
			
		||||
  line-height: 2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-9xl {
 | 
			
		||||
  font-size: 8rem;
 | 
			
		||||
  line-height: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-8xl {
 | 
			
		||||
  font-size: 6rem;
 | 
			
		||||
  line-height: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-7xl {
 | 
			
		||||
  font-size: 4.5rem;
 | 
			
		||||
  line-height: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-6xl {
 | 
			
		||||
  font-size: 3.75rem;
 | 
			
		||||
  line-height: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-5xl {
 | 
			
		||||
  font-size: 3rem;
 | 
			
		||||
  line-height: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-lg {
 | 
			
		||||
@ -955,13 +1081,25 @@ select {
 | 
			
		||||
  line-height: 1.75rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-3xl {
 | 
			
		||||
  font-size: 1.875rem;
 | 
			
		||||
  line-height: 2.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.font-semibold {
 | 
			
		||||
  font-weight: 600;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-white {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(255 255 255 / var(--tw-text-opacity));
 | 
			
		||||
.font-bold {
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.capitalize {
 | 
			
		||||
  text-transform: capitalize;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.italic {
 | 
			
		||||
  font-style: italic;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-slate-300 {
 | 
			
		||||
@ -969,9 +1107,35 @@ select {
 | 
			
		||||
  color: rgb(203 213 225 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-red-400 {
 | 
			
		||||
.text-white {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(248 113 113 / var(--tw-text-opacity));
 | 
			
		||||
  color: rgb(255 255 255 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-yellow-300 {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(253 224 71 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-gray-600 {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(75 85 99 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.underline {
 | 
			
		||||
  text-decoration-line: underline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.decoration-red-500 {
 | 
			
		||||
  text-decoration-color: #ef4444;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.decoration-slate-400 {
 | 
			
		||||
  text-decoration-color: #94a3b8;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.decoration-slate-500 {
 | 
			
		||||
  text-decoration-color: #64748b;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.shadow-md {
 | 
			
		||||
@ -980,10 +1144,8 @@ select {
 | 
			
		||||
  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.shadow {
 | 
			
		||||
  --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
 | 
			
		||||
  --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
 | 
			
		||||
  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
 | 
			
		||||
.filter {
 | 
			
		||||
  filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.transition {
 | 
			
		||||
@ -1002,14 +1164,88 @@ select {
 | 
			
		||||
  transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dark form label {
 | 
			
		||||
.max-w-20char {
 | 
			
		||||
  max-width: 20ch;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.\[a-zA-Z\:\\-\] {
 | 
			
		||||
  a-z-a--z: \-;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: "IBM Plex Mono";
 | 
			
		||||
 | 
			
		||||
  src: url("fonts/IBMPlexMono-regular.woff2") format("woff2");
 | 
			
		||||
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
 | 
			
		||||
  font-style: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: "IBM Plex Sans";
 | 
			
		||||
 | 
			
		||||
  src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
 | 
			
		||||
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
 | 
			
		||||
  font-style: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: "IBM Plex Serif";
 | 
			
		||||
 | 
			
		||||
  src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
 | 
			
		||||
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
 | 
			
		||||
  font-style: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:is(.dark form label) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(148 163 184 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dark form input,.dark 
 | 
			
		||||
select,.dark 
 | 
			
		||||
textarea {
 | 
			
		||||
.responsive-table {
 | 
			
		||||
  margin-left: auto;
 | 
			
		||||
  margin-right: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:is(.dark .responsive-table) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(255 255 255 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.responsive-table tr:nth-child(even) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(30 41 59 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.responsive-table tbody tr:nth-child(odd) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(15 23 42 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.responsive-table thead th {
 | 
			
		||||
  border-bottom-width: 2px;
 | 
			
		||||
  --tw-border-opacity: 1;
 | 
			
		||||
  border-bottom-color: rgb(100 116 139 / var(--tw-border-opacity));
 | 
			
		||||
  text-align: left;
 | 
			
		||||
  font-size: 1.25rem;
 | 
			
		||||
  line-height: 1.75rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.responsive-table thead th:not(:first-child),
 | 
			
		||||
.responsive-table td:not(:first-child) {
 | 
			
		||||
  border-left-width: 1px;
 | 
			
		||||
  --tw-border-opacity: 1;
 | 
			
		||||
  border-left-color: rgb(100 116 139 / var(--tw-border-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:is(.dark form input),:is(.dark 
 | 
			
		||||
select),:is(.dark 
 | 
			
		||||
textarea) {
 | 
			
		||||
  border-width: 1px;
 | 
			
		||||
  --tw-border-opacity: 1;
 | 
			
		||||
  border-color: rgb(15 23 42 / var(--tw-border-opacity));
 | 
			
		||||
@ -1019,9 +1255,20 @@ textarea {
 | 
			
		||||
  color: rgb(241 245 249 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#session-table {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr;
 | 
			
		||||
@media screen and (min-width: 768px) {
 | 
			
		||||
  form input,
 | 
			
		||||
  select,
 | 
			
		||||
  textarea {
 | 
			
		||||
    width: 300px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media screen and (max-width: 768px) {
 | 
			
		||||
  form input,
 | 
			
		||||
  select,
 | 
			
		||||
  textarea {
 | 
			
		||||
    width: 150px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#button-container button {
 | 
			
		||||
@ -1029,21 +1276,85 @@ textarea {
 | 
			
		||||
  margin-right: 0.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
th {
 | 
			
		||||
  text-align: right;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
th label {
 | 
			
		||||
  margin-right: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.basic-button-container {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.basic-button-container > :not([hidden]) ~ :not([hidden]) {
 | 
			
		||||
  --tw-space-x-reverse: 0;
 | 
			
		||||
  margin-right: calc(0.5rem * var(--tw-space-x-reverse));
 | 
			
		||||
  margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.basic-button {
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  border-radius: 0.25rem;
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(37 99 235 / var(--tw-bg-opacity));
 | 
			
		||||
  padding-left: 1.5rem;
 | 
			
		||||
  padding-right: 1.5rem;
 | 
			
		||||
  padding-top: 0.625rem;
 | 
			
		||||
  padding-bottom: 0.625rem;
 | 
			
		||||
  font-size: 0.75rem;
 | 
			
		||||
  line-height: 1rem;
 | 
			
		||||
  font-weight: 500;
 | 
			
		||||
  text-transform: uppercase;
 | 
			
		||||
  line-height: 1.25;
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(255 255 255 / var(--tw-text-opacity));
 | 
			
		||||
  --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
 | 
			
		||||
  --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
 | 
			
		||||
  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
 | 
			
		||||
  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
 | 
			
		||||
  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
 | 
			
		||||
  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
 | 
			
		||||
  transition-duration: 150ms;
 | 
			
		||||
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.basic-button:hover {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(29 78 216 / var(--tw-bg-opacity));
 | 
			
		||||
  --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
 | 
			
		||||
  --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
 | 
			
		||||
  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.basic-button:focus {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(29 78 216 / var(--tw-bg-opacity));
 | 
			
		||||
  --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
 | 
			
		||||
  --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
 | 
			
		||||
  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
 | 
			
		||||
  outline: 2px solid transparent;
 | 
			
		||||
  outline-offset: 2px;
 | 
			
		||||
  --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
 | 
			
		||||
  --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);
 | 
			
		||||
  box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.basic-button:active {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(30 64 175 / var(--tw-bg-opacity));
 | 
			
		||||
  --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
 | 
			
		||||
  --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
 | 
			
		||||
  box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.hover\:bg-green-700:hover {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(21 128 61 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.hover\:bg-blue-700:hover {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(29 78 216 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.hover\:bg-red-700:hover {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(185 28 28 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.hover\:underline:hover {
 | 
			
		||||
  text-decoration-line: underline;
 | 
			
		||||
}
 | 
			
		||||
@ -1064,16 +1375,6 @@ textarea {
 | 
			
		||||
  --tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.focus\:ring-blue-500:focus {
 | 
			
		||||
  --tw-ring-opacity: 1;
 | 
			
		||||
  --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.focus\:ring-red-500:focus {
 | 
			
		||||
  --tw-ring-opacity: 1;
 | 
			
		||||
  --tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.focus\:ring-offset-2:focus {
 | 
			
		||||
  --tw-ring-offset-width: 2px;
 | 
			
		||||
}
 | 
			
		||||
@ -1082,44 +1383,63 @@ textarea {
 | 
			
		||||
  --tw-ring-offset-color: #bfdbfe;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dark .dark\:border-white {
 | 
			
		||||
  --tw-border-opacity: 1;
 | 
			
		||||
  border-color: rgb(255 255 255 / var(--tw-border-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dark .dark\:bg-gray-800 {
 | 
			
		||||
:is(.dark .dark\:bg-gray-800) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(31 41 55 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dark .dark\:bg-gray-900 {
 | 
			
		||||
:is(.dark .dark\:bg-gray-900) {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(17 24 39 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dark .dark\:bg-slate-700 {
 | 
			
		||||
  --tw-bg-opacity: 1;
 | 
			
		||||
  background-color: rgb(51 65 85 / var(--tw-bg-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dark .dark\:text-white {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(255 255 255 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dark .dark\:text-slate-600 {
 | 
			
		||||
:is(.dark .dark\:text-slate-600) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(71 85 105 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dark .dark\:text-slate-400 {
 | 
			
		||||
:is(.dark .dark\:text-white) {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(148 163 184 / var(--tw-text-opacity));
 | 
			
		||||
  color: rgb(255 255 255 / var(--tw-text-opacity));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dark .dark\:text-slate-300 {
 | 
			
		||||
  --tw-text-opacity: 1;
 | 
			
		||||
  color: rgb(203 213 225 / var(--tw-text-opacity));
 | 
			
		||||
@media (min-width: 640px) {
 | 
			
		||||
  .sm\:ml-2 {
 | 
			
		||||
    margin-left: 0.5rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .sm\:inline {
 | 
			
		||||
    display: inline;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .sm\:table-cell {
 | 
			
		||||
    display: table-cell;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .sm\:max-w-md {
 | 
			
		||||
    max-width: 28rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .sm\:max-w-lg {
 | 
			
		||||
    max-width: 32rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .sm\:max-w-xl {
 | 
			
		||||
    max-width: 36rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .sm\:px-4 {
 | 
			
		||||
    padding-left: 1rem;
 | 
			
		||||
    padding-right: 1rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .sm\:pl-2 {
 | 
			
		||||
    padding-left: 0.5rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .sm\:decoration-2 {
 | 
			
		||||
    text-decoration-thickness: 2px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (min-width: 768px) {
 | 
			
		||||
@ -1134,4 +1454,32 @@ textarea {
 | 
			
		||||
  .md\:flex-row {
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .md\:px-6 {
 | 
			
		||||
    padding-left: 1.5rem;
 | 
			
		||||
    padding-right: 1.5rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .md\:py-2 {
 | 
			
		||||
    padding-top: 0.5rem;
 | 
			
		||||
    padding-bottom: 0.5rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .md\:max-w-40char {
 | 
			
		||||
    max-width: 40ch;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (min-width: 1024px) {
 | 
			
		||||
  .lg\:table-cell {
 | 
			
		||||
    display: table-cell;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .lg\:max-w-lg {
 | 
			
		||||
    max-width: 32rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .lg\:max-w-3xl {
 | 
			
		||||
    max-width: 48rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								games/static/fonts/IBMPlexMono-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								games/static/fonts/IBMPlexSans-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								games/static/fonts/IBMPlexSerif-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/edition_black.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 292 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/edition_white.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 321 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/game_black.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 22 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/game_white.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 306 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/loading.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 787 B  | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/purchase_black.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 312 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/purchase_white.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 364 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/schedule.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 42 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/wikidata.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 504 B  | 
							
								
								
									
										29
									
								
								games/static/js/add_edition.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,29 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @description Sync select field with input field until user focuses it.
 | 
			
		||||
 * @param {HTMLSelectElement} sourceElementSelector
 | 
			
		||||
 * @param {HTMLInputElement} targetElementSelector
 | 
			
		||||
 */
 | 
			
		||||
function syncSelectInputUntilChanged(
 | 
			
		||||
  sourceElementSelector,
 | 
			
		||||
  targetElementSelector
 | 
			
		||||
) {
 | 
			
		||||
  const sourceElement = document.querySelector(sourceElementSelector);
 | 
			
		||||
  const targetElement = document.querySelector(targetElementSelector);
 | 
			
		||||
  function sourceElementHandler(event) {
 | 
			
		||||
    let selected = event.target.value;
 | 
			
		||||
    let selectedValue = document.querySelector(
 | 
			
		||||
      `#id_game option[value='${selected}']`
 | 
			
		||||
    ).textContent;
 | 
			
		||||
    targetElement.value = selectedValue;
 | 
			
		||||
  }
 | 
			
		||||
  function targetElementHandler(event) {
 | 
			
		||||
    sourceElement.removeEventListener("change", sourceElementHandler);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  sourceElement.addEventListener("change", sourceElementHandler);
 | 
			
		||||
  targetElement.addEventListener("focus", targetElementHandler);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
window.addEventListener("load", () => {
 | 
			
		||||
  syncSelectInputUntilChanged("#id_game", "#id_name");
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										19
									
								
								games/static/js/add_session.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,19 @@
 | 
			
		||||
import { toISOUTCString } from "./utils.js";
 | 
			
		||||
 | 
			
		||||
for (let button of document.querySelectorAll("[data-target]")) {
 | 
			
		||||
  let target = button.getAttribute("data-target");
 | 
			
		||||
  let type = button.getAttribute("data-type");
 | 
			
		||||
  let targetElement = document.querySelector(`#id_${target}`);
 | 
			
		||||
  button.addEventListener("click", (event) => {
 | 
			
		||||
    event.preventDefault();
 | 
			
		||||
    if (type == "now") {
 | 
			
		||||
      targetElement.value = toISOUTCString(new Date);
 | 
			
		||||
    } else if (type == "copy") {
 | 
			
		||||
      const oppositeName = targetElement.name == "timestamp_start" ? "timestamp_end" : "timestamp_start";
 | 
			
		||||
      document.querySelector(`[name='${oppositeName}']`).value = targetElement.value;
 | 
			
		||||
    } else if (type == "toggle") {
 | 
			
		||||
      if (targetElement.type == "datetime-local") targetElement.type = "text";
 | 
			
		||||
      else targetElement.type = "datetime-local";
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								games/static/js/htmx.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										16
									
								
								games/static/js/utils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,16 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @description Formats Date to a UTC string accepted by the datetime-local input field.
 | 
			
		||||
 * @param {Date} date
 | 
			
		||||
 * @returns {string}
 | 
			
		||||
 */
 | 
			
		||||
export function toISOUTCString(date) {
 | 
			
		||||
  function stringAndPad(number) {
 | 
			
		||||
    return number.toString().padStart(2, 0);
 | 
			
		||||
  }
 | 
			
		||||
  const year = date.getFullYear();
 | 
			
		||||
  const month = stringAndPad(date.getMonth() + 1);
 | 
			
		||||
  const day = stringAndPad(date.getDate());
 | 
			
		||||
  const hours = stringAndPad(date.getHours());
 | 
			
		||||
  const minutes = stringAndPad(date.getMinutes());
 | 
			
		||||
  return `${year}-${month}-${day}T${hours}:${minutes}`;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								games/static/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,43 @@
 | 
			
		||||
function elt(type, props, ...children) {
 | 
			
		||||
  let dom = document.createElement(type);
 | 
			
		||||
  if (props) Object.assign(dom, props);
 | 
			
		||||
  for (let child of children) {
 | 
			
		||||
    if (typeof child != "string") dom.appendChild(child);
 | 
			
		||||
    else dom.appendChild(document.createTextNode(child));
 | 
			
		||||
  }
 | 
			
		||||
  return dom;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @param {Node} targetNode
 | 
			
		||||
 */
 | 
			
		||||
function addToggleButton(targetNode) {
 | 
			
		||||
  let manualToggleButton = elt(
 | 
			
		||||
    "td",
 | 
			
		||||
    {},
 | 
			
		||||
    elt(
 | 
			
		||||
      "div",
 | 
			
		||||
      { className: "basic-button" },
 | 
			
		||||
      elt(
 | 
			
		||||
        "button",
 | 
			
		||||
        {
 | 
			
		||||
          onclick: (event) => {
 | 
			
		||||
            let textInputField = elt("input", { type: "text", id: targetNode.id });
 | 
			
		||||
            targetNode.replaceWith(textInputField);
 | 
			
		||||
            event.target.addEventListener("click", (event) => {
 | 
			
		||||
              textInputField.replaceWith(targetNode);
 | 
			
		||||
            });
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        "Toggle manual"
 | 
			
		||||
      )
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
  targetNode.parentElement.appendChild(manualToggleButton);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const toggleableFields = ["#id_game", "#id_edition", "#id_platform"];
 | 
			
		||||
 | 
			
		||||
toggleableFields.map((selector) => {
 | 
			
		||||
  addToggleButton(document.querySelector(selector));
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										2
									
								
								games/static/robots.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,2 @@
 | 
			
		||||
User-agent: *
 | 
			
		||||
Disallow: /
 | 
			
		||||
@ -3,11 +3,15 @@
 | 
			
		||||
{% block title %}{{ title }}{% endblock title %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <form method="post" enctype="multipart/form-data" class="mx-auto">
 | 
			
		||||
    <form method="post" enctype="multipart/form-data">
 | 
			
		||||
        <table class="mx-auto">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
 | 
			
		||||
        {{ form.as_p }}
 | 
			
		||||
 | 
			
		||||
        <input type="submit" value="Submit"/>
 | 
			
		||||
        {{ form.as_table }}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td></td>
 | 
			
		||||
            <td><input type="submit" value="Submit"/></td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        </table>
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										22
									
								
								games/templates/add_edition.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,22 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}{{ title }}{% endblock title %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <form method="post" enctype="multipart/form-data">
 | 
			
		||||
        <table class="mx-auto">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
 | 
			
		||||
        {{ form.as_table }}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td></td>
 | 
			
		||||
            <td><input type="submit" value="Submit"/></td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        </table>
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
 | 
			
		||||
{% block scripts %}
 | 
			
		||||
{% load static %}
 | 
			
		||||
<script type="module" src="{% static 'js/add_edition.js' %}"></script>
 | 
			
		||||
{% endblock scripts %}
 | 
			
		||||
							
								
								
									
										37
									
								
								games/templates/add_session.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,37 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}{{ title }}{% endblock title %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <form method="post" enctype="multipart/form-data">
 | 
			
		||||
        <table class="mx-auto">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
 | 
			
		||||
        {% for field in form %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <th>{{ field.label_tag }}</th>
 | 
			
		||||
            {% if field.name == "note" %}
 | 
			
		||||
            <td>{{ field }}</td>
 | 
			
		||||
            {% else %}
 | 
			
		||||
            <td>{{ field }}</td>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
 | 
			
		||||
            <td>
 | 
			
		||||
                <div class="basic-button-container">
 | 
			
		||||
                    <button class="basic-button" data-target="{{field.name}}" data-type="now">Set to now</button>
 | 
			
		||||
                    <button class="basic-button" data-target="{{field.name}}" data-type="toggle">Toggle text</button>
 | 
			
		||||
                    <button class="basic-button" data-target="{{field.name}}" data-type="copy">Copy</button>
 | 
			
		||||
                </div>
 | 
			
		||||
            </td>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        </tr>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td></td>
 | 
			
		||||
            <td><input type="submit" value="Submit"/></td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        </table>
 | 
			
		||||
    </form>
 | 
			
		||||
    {% load static %}
 | 
			
		||||
    <script type="module" src="{% static 'js/add_session.js' %}"></script>
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
@ -9,16 +9,17 @@
 | 
			
		||||
        <meta name="keywords" content="time, tracking, video games, self-hosted"/>
 | 
			
		||||
        <meta name="viewport" content="width=device-width, initial-scale=1"/>
 | 
			
		||||
        <title>Timetracker - {% block title %}Untitled{% endblock title %}</title>
 | 
			
		||||
        <link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
 | 
			
		||||
        <script src="{% static 'js/htmx.min.js' %}"></script>
 | 
			
		||||
        <link rel="stylesheet" href="{% static 'base.css' %}" />
 | 
			
		||||
    </head>
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
    <body class="dark">
 | 
			
		||||
        <img id="indicator" src="{% static 'icons/loading.png' %}" class="absolute right-3 top-3 animate-spin htmx-indicator" />
 | 
			
		||||
        <div class="dark:bg-gray-800 min-h-screen">
 | 
			
		||||
            <nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
 | 
			
		||||
                <div class="container flex flex-wrap items-center justify-between mx-auto">
 | 
			
		||||
                    <a href="{% url 'list_sessions_recent' %}" class="flex items-center">
 | 
			
		||||
                        <span class="text-4xl">⌚</span>
 | 
			
		||||
                        <span class="text-4xl"><img src="{% static 'icons/schedule.png' %}" width="48" class="mr-4" /></span>
 | 
			
		||||
                        <span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
 | 
			
		||||
                    </a>
 | 
			
		||||
                    <div class="w-full md:block md:w-auto">
 | 
			
		||||
@ -47,6 +48,7 @@
 | 
			
		||||
        </div>
 | 
			
		||||
        {% load version %}
 | 
			
		||||
        <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
 | 
			
		||||
    {% block scripts %}{% endblock scripts %}
 | 
			
		||||
    </body>
 | 
			
		||||
 | 
			
		||||
</html>
 | 
			
		||||
</html>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										22
									
								
								games/templates/components/button.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,22 @@
 | 
			
		||||
<button
 | 
			
		||||
  type="button"
 | 
			
		||||
  title="{{ title }}"
 | 
			
		||||
  autofocus
 | 
			
		||||
  class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"
 | 
			
		||||
>
 | 
			
		||||
  <svg
 | 
			
		||||
    xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
    fill="none"
 | 
			
		||||
    viewBox="0 0 24 24"
 | 
			
		||||
    stroke-width="1.5"
 | 
			
		||||
    stroke="currentColor"
 | 
			
		||||
    class="self-center w-6 h-6 inline"
 | 
			
		||||
  >
 | 
			
		||||
    <path
 | 
			
		||||
      stroke-linecap="round"
 | 
			
		||||
      stroke-linejoin="round"
 | 
			
		||||
      d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
 | 
			
		||||
    />
 | 
			
		||||
  </svg>
 | 
			
		||||
  {{ text }}
 | 
			
		||||
</button>
 | 
			
		||||
@ -1,86 +1,77 @@
 | 
			
		||||
{% extends 'base.html' %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
 | 
			
		||||
{% block title %}{{ title }}{% endblock title %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
        <div class="text-center text-xl mb-4 dark:text-slate-400">
 | 
			
		||||
            {% if dataset.count >= 2 %}
 | 
			
		||||
            <img src="data:image/svg+xml;base64,{{ chart|safe }}" class="mx-auto mb-3" />
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% if dataset.count >= 1 %}
 | 
			
		||||
            <div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% if purchase or platform or edition %}
 | 
			
		||||
            <span class="block">
 | 
			
		||||
                <a class="text-red-400 inline" href="{% url 'list_sessions' %}">
 | 
			
		||||
                    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 inline">
 | 
			
		||||
                        <path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
 | 
			
		||||
                    </svg>
 | 
			
		||||
                </a>Filtering by "{% firstof purchase platform edition %}"
 | 
			
		||||
            </span>
 | 
			
		||||
            {% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_edition' purchase.edition.id %}">See all platforms</a>{% endif %}
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% if dataset.count >= 1 %}
 | 
			
		||||
            <a href="{% url 'start_session' last.id %}">
 | 
			
		||||
                <button type="button" title="Track last tracked" class="mt-10 py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg ">
 | 
			
		||||
                    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline">
 | 
			
		||||
                        <path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
 | 
			
		||||
                    </svg>
 | 
			
		||||
                    {{ last.purchase }}
 | 
			
		||||
                </button>
 | 
			
		||||
            </a>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
    <div id="session-table" class="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center">
 | 
			
		||||
        <div class="dark:border-white dark:text-slate-300 text-lg">Name</div>
 | 
			
		||||
        <div class="dark:border-white dark:text-slate-300 text-lg">Platform</div>
 | 
			
		||||
        <div class="dark:border-white dark:text-slate-300 text-lg text-center">Start</div>
 | 
			
		||||
        <div class="dark:border-white dark:text-slate-300 text-lg text-center">End</div>
 | 
			
		||||
        <div class="dark:border-white dark:text-slate-300 text-lg">Duration</div>
 | 
			
		||||
        <div class="dark:border-white dark:text-slate-300 text-lg text-right">Manage</div>
 | 
			
		||||
        {% for data in dataset %}
 | 
			
		||||
            <div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"><a class="hover:underline" href="{% url 'list_sessions_by_purchase' data.purchase.id %}">{{ data.purchase.edition }}</a></div>
 | 
			
		||||
            <div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"><a class="hover:underline" href="{% url 'list_sessions_by_platform' data.purchase.platform.id %}">{{ data.purchase.platform }}</a></div>
 | 
			
		||||
            <div class="dark:text-slate-400 text-center">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div>
 | 
			
		||||
            <div class="dark:text-slate-400 text-center">
 | 
			
		||||
                {% if data.unfinished %}
 | 
			
		||||
                    <span class="text-red-400">Not finished yet.</span>
 | 
			
		||||
                {% elif data.duration_manual %}
 | 
			
		||||
                    --
 | 
			
		||||
                {% else %}
 | 
			
		||||
                    {{ data.timestamp_end | date:"d/m/Y H:i" }}
 | 
			
		||||
                {% endif %}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="dark:text-slate-400 flex">{{ data.duration_formatted }}{% if data.duration_manual %} <svg title="Added manually" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 ml-1 self-center">
 | 
			
		||||
                <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" />
 | 
			
		||||
              </svg>
 | 
			
		||||
              {% endif %}</div>
 | 
			
		||||
            <div id="button-container" class="flex justify-end">
 | 
			
		||||
                {% if data.unfinished %}
 | 
			
		||||
                    <a href="{% url 'update_session' data.id %}">
 | 
			
		||||
                        <button type="button" title="Set to finished" class="py-1 px-2 flex justify-center items-center  bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg ">
 | 
			
		||||
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
 | 
			
		||||
                                <path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
 | 
			
		||||
                            </svg>
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </a>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                <a href="{% url 'edit_session' data.id %}">
 | 
			
		||||
                    <button type="button" title="Edit" class="py-1 px-2 flex justify-center items-center  bg-blue-600 hover:bg-blue-700 focus:ring-blue-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg ">
 | 
			
		||||
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
 | 
			
		||||
                            <path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" />
 | 
			
		||||
                            <path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" />
 | 
			
		||||
                        </svg>
 | 
			
		||||
                    </button>
 | 
			
		||||
                </a>
 | 
			
		||||
                <a href="{% url 'delete_session' data.id %}">
 | 
			
		||||
                    <button type="button" edit="Delete" class="py-1 px-2 flex justify-center items-center  bg-red-600 hover:bg-red-700 focus:ring-red-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg ">
 | 
			
		||||
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
 | 
			
		||||
                            <path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z" clip-rule="evenodd" />
 | 
			
		||||
                        </svg>
 | 
			
		||||
                    </button>
 | 
			
		||||
                </a>
 | 
			
		||||
            </div>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
    </div>
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
 | 
			
		||||
{% if dataset.count >= 1 %}
 | 
			
		||||
<div class="mx-auto text-center my-4">
 | 
			
		||||
  <a
 | 
			
		||||
    id="last-session-start"
 | 
			
		||||
    href="{% url 'start_session' last.id %}"
 | 
			
		||||
    hx-get="{% url 'start_session' last.id %}"
 | 
			
		||||
    hx-indicator="#indicator"
 | 
			
		||||
    hx-swap="afterbegin"
 | 
			
		||||
    hx-target=".responsive-table tbody"
 | 
			
		||||
    hx-select=".responsive-table tbody tr:first-child"
 | 
			
		||||
    onClick="document.querySelector('#last-session-start').classList.add('invisible')"
 | 
			
		||||
    class="{% if last.timestamp_end == null %}invisible{% endif %}"
 | 
			
		||||
  >
 | 
			
		||||
    {% include 'components/button.html' with text=last.purchase title="Start session of last played game" only %}
 | 
			
		||||
  </a>
 | 
			
		||||
</div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
<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 data in dataset %}
 | 
			
		||||
    <tr>
 | 
			
		||||
      <td
 | 
			
		||||
        class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char"
 | 
			
		||||
      >
 | 
			
		||||
        <a
 | 
			
		||||
          class="underline decoration-slate-500 sm:decoration-2"
 | 
			
		||||
          href="{% url 'view_game' data.purchase.edition.game.id %}">
 | 
			
		||||
            {{ data.purchase.edition }}
 | 
			
		||||
        </a>
 | 
			
		||||
      </td>
 | 
			
		||||
      <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
 | 
			
		||||
        {{ data.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 data.unfinished %}
 | 
			
		||||
        <a
 | 
			
		||||
          href="{% url 'update_session' data.id %}"
 | 
			
		||||
          hx-get="{% url 'update_session' data.id %}"
 | 
			
		||||
          hx-swap="outerHTML"
 | 
			
		||||
          hx-target=".responsive-table tbody tr:first-child"
 | 
			
		||||
          hx-select=".responsive-table tbody tr:first-child"
 | 
			
		||||
          hx-indicator="#indicator"
 | 
			
		||||
          onClick="document.querySelector('#last-session-start').classList.remove('invisible')"
 | 
			
		||||
          >
 | 
			
		||||
            <span class="text-yellow-300">Finish now?</span>
 | 
			
		||||
        </a>
 | 
			
		||||
        {% elif data.duration_manual %}
 | 
			
		||||
            -- 
 | 
			
		||||
        {% else %}
 | 
			
		||||
            {{ data.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">
 | 
			
		||||
        {{ data.duration_formatted }}
 | 
			
		||||
      </td>
 | 
			
		||||
    </tr>
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
  </tbody>
 | 
			
		||||
</table>
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										41
									
								
								games/templates/view_game.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,41 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}{{ title }}{% endblock title %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
 | 
			
		||||
        <h1 class="text-4xl">{{ game.name }} (#{{ game.pk }})</h1>
 | 
			
		||||
        <h2 class="text-lg my-2 ml-2">{{ total_playtime }} ({{ first_session.timestamp_end | date:"M Y"}} — {{ first_session.timestamp_end | date:"M Y"}}) </h2>
 | 
			
		||||
        <hr class="border-slate-500">
 | 
			
		||||
        <h1 class="text-3xl mt-4 mb-1">Editions</h1>
 | 
			
		||||
        <ul>
 | 
			
		||||
            {% for edition in editions %}
 | 
			
		||||
            <li class="sm:pl-2">
 | 
			
		||||
                {{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
 | 
			
		||||
                {% if edition.wikidata %}
 | 
			
		||||
                <span class="hidden sm:inline">
 | 
			
		||||
                    <a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
 | 
			
		||||
                        <img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}"/>
 | 
			
		||||
                    </a>
 | 
			
		||||
                </span>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
            </li>
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
        </ul>
 | 
			
		||||
        <h1 class="text-3xl mt-4 mb-1">Purchases</h1>
 | 
			
		||||
        <ul>
 | 
			
		||||
            {% for purchase in purchases %}
 | 
			
		||||
            <li class="sm:pl-2">{{ purchase.platform }} ({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}})</li>
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
        </ul>
 | 
			
		||||
        <h1 class="text-3xl mt-4 mb-1">Sessions</h1>
 | 
			
		||||
        <ul>
 | 
			
		||||
            {% for session in sessions %}
 | 
			
		||||
            <li class="sm:pl-2">{{ session.timestamp_end | date:"d/m/Y" }} ({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})</li>
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
        </ul>
 | 
			
		||||
        
 | 
			
		||||
    </div>
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
@ -23,14 +23,17 @@ urlpatterns = [
 | 
			
		||||
        views.start_session,
 | 
			
		||||
        name="start_session",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "delete_session/by-id/<int:session_id>",
 | 
			
		||||
        views.delete_session,
 | 
			
		||||
        name="delete_session",
 | 
			
		||||
    ),
 | 
			
		||||
    # path(
 | 
			
		||||
    #     "delete_session/by-id/<int:session_id>",
 | 
			
		||||
    #     views.delete_session,
 | 
			
		||||
    #     name="delete_session",
 | 
			
		||||
    # ),
 | 
			
		||||
    path("add-purchase/", views.add_purchase, name="add_purchase"),
 | 
			
		||||
    path("add-edition/", views.add_edition, name="add_edition"),
 | 
			
		||||
    path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"),
 | 
			
		||||
    path("game/<int:game_id>/view", views.view_game, name="view_game"),
 | 
			
		||||
    path("game/<int:game_id>/edit", views.edit_game, name="edit_game"),
 | 
			
		||||
    path("edit-platform/<int:platform_id>", views.edit_platform, name="edit_platform"),
 | 
			
		||||
    path("add-device/", views.add_device, name="add_device"),
 | 
			
		||||
    path("edit-session/<int:session_id>", views.edit_session, name="edit_session"),
 | 
			
		||||
    path("edit-purchase/<int:purchase_id>", views.edit_purchase, name="edit_purchase"),
 | 
			
		||||
@ -47,10 +50,22 @@ urlpatterns = [
 | 
			
		||||
        {"filter": "platform"},
 | 
			
		||||
        name="list_sessions_by_platform",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "list-sessions/by-game/<int:game_id>",
 | 
			
		||||
        views.list_sessions,
 | 
			
		||||
        {"filter": "game"},
 | 
			
		||||
        name="list_sessions_by_game",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "list-sessions/by-edition/<int:edition_id>",
 | 
			
		||||
        views.list_sessions,
 | 
			
		||||
        {"filter": "edition"},
 | 
			
		||||
        name="list_sessions_by_edition",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "list-sessions/by-ownership/<str:ownership_type>",
 | 
			
		||||
        views.list_sessions,
 | 
			
		||||
        {"filter": "ownership_type"},
 | 
			
		||||
        name="list_sessions_by_ownership_type",
 | 
			
		||||
    ),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
@ -45,7 +45,7 @@ def add_session(request):
 | 
			
		||||
 | 
			
		||||
    context["title"] = "Add New Session"
 | 
			
		||||
    context["form"] = form
 | 
			
		||||
    return render(request, "add.html", context)
 | 
			
		||||
    return render(request, "add_session.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def update_session(request, session_id=None):
 | 
			
		||||
@ -64,7 +64,7 @@ def edit_session(request, session_id=None):
 | 
			
		||||
        return redirect("list_sessions")
 | 
			
		||||
    context["title"] = "Edit Session"
 | 
			
		||||
    context["form"] = form
 | 
			
		||||
    return render(request, "add.html", context)
 | 
			
		||||
    return render(request, "add_session.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def edit_purchase(request, purchase_id=None):
 | 
			
		||||
@ -79,6 +79,44 @@ def edit_purchase(request, purchase_id=None):
 | 
			
		||||
    return render(request, "add.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def edit_game(request, game_id=None):
 | 
			
		||||
    context = {}
 | 
			
		||||
    purchase = Game.objects.get(id=game_id)
 | 
			
		||||
    form = GameForm(request.POST or None, instance=purchase)
 | 
			
		||||
    if form.is_valid():
 | 
			
		||||
        form.save()
 | 
			
		||||
        return redirect("list_sessions")
 | 
			
		||||
    context["title"] = "Edit Game"
 | 
			
		||||
    context["form"] = form
 | 
			
		||||
    return render(request, "add.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def view_game(request, game_id=None):
 | 
			
		||||
    context = {}
 | 
			
		||||
    game = Game.objects.get(id=game_id)
 | 
			
		||||
    context["title"] = "View Game"
 | 
			
		||||
    context["game"] = game
 | 
			
		||||
    context["editions"] = Edition.objects.filter(game_id=game_id)
 | 
			
		||||
    context["purchases"] = Purchase.objects.filter(edition__game_id=game_id)
 | 
			
		||||
    context["sessions"] = Session.objects.filter(purchase__edition__game_id=game_id)
 | 
			
		||||
    context["total_playtime"] = context["sessions"].total_duration()
 | 
			
		||||
    context["last_session"] = context["sessions"].last
 | 
			
		||||
    context["first_session"] = context["sessions"].first
 | 
			
		||||
    return render(request, "view_game.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def edit_platform(request, platform_id=None):
 | 
			
		||||
    context = {}
 | 
			
		||||
    purchase = Platform.objects.get(id=platform_id)
 | 
			
		||||
    form = PlatformForm(request.POST or None, instance=purchase)
 | 
			
		||||
    if form.is_valid():
 | 
			
		||||
        form.save()
 | 
			
		||||
        return redirect("list_sessions")
 | 
			
		||||
    context["title"] = "Edit Platform"
 | 
			
		||||
    context["form"] = form
 | 
			
		||||
    return render(request, "add.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def edit_edition(request, edition_id=None):
 | 
			
		||||
    context = {}
 | 
			
		||||
    edition = Edition.objects.get(id=edition_id)
 | 
			
		||||
@ -104,13 +142,21 @@ def start_session(request, last_session_id: int):
 | 
			
		||||
    return redirect("list_sessions")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def delete_session(request, session_id=None):
 | 
			
		||||
    session = Session.objects.get(id=session_id)
 | 
			
		||||
    session.delete()
 | 
			
		||||
    return redirect("list_sessions")
 | 
			
		||||
# def delete_session(request, session_id=None):
 | 
			
		||||
#     session = Session.objects.get(id=session_id)
 | 
			
		||||
#     session.delete()
 | 
			
		||||
#     return redirect("list_sessions")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def list_sessions(request, filter="", purchase_id="", platform_id="", edition_id=""):
 | 
			
		||||
def list_sessions(
 | 
			
		||||
    request,
 | 
			
		||||
    filter="",
 | 
			
		||||
    purchase_id="",
 | 
			
		||||
    platform_id="",
 | 
			
		||||
    game_id="",
 | 
			
		||||
    edition_id="",
 | 
			
		||||
    ownership_type: str = "",
 | 
			
		||||
):
 | 
			
		||||
    context = {}
 | 
			
		||||
    context["title"] = "Sessions"
 | 
			
		||||
 | 
			
		||||
@ -123,11 +169,19 @@ def list_sessions(request, filter="", purchase_id="", platform_id="", edition_id
 | 
			
		||||
    elif filter == "edition":
 | 
			
		||||
        dataset = Session.objects.filter(purchase__edition=edition_id)
 | 
			
		||||
        context["edition"] = Edition.objects.get(id=edition_id)
 | 
			
		||||
    elif filter == "game":
 | 
			
		||||
        dataset = Session.objects.filter(purchase__edition__game=game_id)
 | 
			
		||||
        context["game"] = Game.objects.get(id=game_id)
 | 
			
		||||
    elif filter == "ownership_type":
 | 
			
		||||
        dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
 | 
			
		||||
        context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
 | 
			
		||||
    elif filter == "recent":
 | 
			
		||||
        current_year = datetime.now().year
 | 
			
		||||
        first_day_of_year = datetime(current_year, 1, 1)
 | 
			
		||||
        dataset = Session.objects.filter(
 | 
			
		||||
            timestamp_start__gte=datetime.now() - timedelta(days=30)
 | 
			
		||||
            timestamp_start__gte=first_day_of_year
 | 
			
		||||
        ).order_by("-timestamp_start")
 | 
			
		||||
        context["title"] = "Last 30 days"
 | 
			
		||||
        context["title"] = "This year"
 | 
			
		||||
    else:
 | 
			
		||||
        # by default, sort from newest to oldest
 | 
			
		||||
        dataset = Session.objects.all().order_by("-timestamp_start")
 | 
			
		||||
@ -141,8 +195,9 @@ def list_sessions(request, filter="", purchase_id="", platform_id="", edition_id
 | 
			
		||||
    context["dataset"] = dataset
 | 
			
		||||
    # cannot use dataset[0] here because that might be only partial QuerySet
 | 
			
		||||
    context["last"] = Session.objects.all().order_by("timestamp_start").last()
 | 
			
		||||
    # charts are always oldest->newest
 | 
			
		||||
    if Session.objects.count() >= 2:
 | 
			
		||||
    # only if 2 or more non-manual sessions exist
 | 
			
		||||
    if dataset.filter(timestamp_end__isnull=False).count() >= 2:
 | 
			
		||||
        # charts are always oldest->newest
 | 
			
		||||
        context["chart"] = playtime_over_time_chart(dataset.order_by("timestamp_start"))
 | 
			
		||||
 | 
			
		||||
    return render(request, "list_sessions.html", context)
 | 
			
		||||
@ -183,7 +238,7 @@ def add_edition(request):
 | 
			
		||||
 | 
			
		||||
    context["form"] = form
 | 
			
		||||
    context["title"] = "Add New Edition"
 | 
			
		||||
    return render(request, "add.html", context)
 | 
			
		||||
    return render(request, "add_edition.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def add_platform(request):
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@tailwindcss/forms": "^0.5.3",
 | 
			
		||||
    "@tailwindcss/typography": "^0.5.8",
 | 
			
		||||
    "tailwindcss": "^3.2.4"
 | 
			
		||||
    "@tailwindcss/forms": "^0.5.6",
 | 
			
		||||
    "@tailwindcss/typography": "^0.5.10",
 | 
			
		||||
    "tailwindcss": "^3.3.3"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
[tool.poetry]
 | 
			
		||||
name = "timetracker"
 | 
			
		||||
version = "1.0.2"
 | 
			
		||||
version = "1.0.3"
 | 
			
		||||
description = "A simple time tracker."
 | 
			
		||||
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
 | 
			
		||||
license = "GPL"
 | 
			
		||||
 | 
			
		||||
@ -1,14 +1,19 @@
 | 
			
		||||
const defaultTheme = require('tailwindcss/defaultTheme')
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    darkMode: 'class',
 | 
			
		||||
    content: ["./**/*.{html,js}"],
 | 
			
		||||
    content: ["./games/**/*.{html,js}"],
 | 
			
		||||
    theme: {
 | 
			
		||||
        fontFamily: {
 | 
			
		||||
            sans: ['Inter', 'sans-serif'],
 | 
			
		||||
        extend: {
 | 
			
		||||
          fontFamily: {
 | 
			
		||||
            'sans': ['IBM Plex Sans', ...defaultTheme.fontFamily.sans],
 | 
			
		||||
            'mono': ['IBM Plex Mono', ...defaultTheme.fontFamily.mono],
 | 
			
		||||
            'serif': ['IBM Plex Serif', ...defaultTheme.fontFamily.serif],
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        extend: {},
 | 
			
		||||
    },
 | 
			
		||||
    plugins: [
 | 
			
		||||
        require('@tailwindcss/typography'),
 | 
			
		||||
        require('@tailwindcss/forms')
 | 
			
		||||
    ],
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -40,9 +40,9 @@ INSTALLED_APPS = [
 | 
			
		||||
    "django.contrib.staticfiles",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
if DEBUG:
 | 
			
		||||
    INSTALLED_APPS.append("django_extensions")
 | 
			
		||||
    INSTALLED_APPS.append("django.contrib.admin")
 | 
			
		||||
# if DEBUG:
 | 
			
		||||
INSTALLED_APPS.append("django_extensions")
 | 
			
		||||
INSTALLED_APPS.append("django.contrib.admin")
 | 
			
		||||
 | 
			
		||||
MIDDLEWARE = [
 | 
			
		||||
    "django.middleware.security.SecurityMiddleware",
 | 
			
		||||
@ -149,3 +149,5 @@ if _csrf_trusted_origins:
 | 
			
		||||
    CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
 | 
			
		||||
else:
 | 
			
		||||
    CSRF_TRUSTED_ORIGINS = []
 | 
			
		||||
 | 
			
		||||
USE_L10N = False
 | 
			
		||||
 | 
			
		||||
@ -23,5 +23,6 @@ urlpatterns = [
 | 
			
		||||
    path("tracker/", include("games.urls")),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
if settings.DEBUG:
 | 
			
		||||
    urlpatterns.append(path("admin/", admin.site.urls))
 | 
			
		||||
# if settings.DEBUG:
 | 
			
		||||
#     urlpatterns.append(path("admin/", admin.site.urls))
 | 
			
		||||
urlpatterns.append(path("admin/", admin.site.urls))
 | 
			
		||||
 | 
			
		||||