Compare commits

...

252 Commits
1.2.0 ... main

Author SHA1 Message Date
Lukáš Kucharczyk 60d3ba6569
order platforms by name
Django CI/CD / test (push) Successful in 1m4s Details
Django CI/CD / build-and-push (push) Successful in 1m51s Details
2024-09-14 20:19:30 +02:00
Lukáš Kucharczyk bcb845adac
remove css cruft 2024-09-14 20:19:20 +02:00
Lukáš Kucharczyk bd222f253e
add more icons 2024-09-14 20:19:14 +02:00
Lukáš Kucharczyk 45e3cfed00
Remove extraneous statement
Django CI/CD / test (push) Successful in 54s Details
Django CI/CD / build-and-push (push) Successful in 1m58s Details
2024-09-14 11:10:28 +02:00
Lukáš Kucharczyk 36dd5635b2
add icon field to platform, use everywhere
Django CI/CD / test (push) Successful in 52s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-09-14 11:07:38 +02:00
Lukáš Kucharczyk c40764a02f
fix bug in Component
A leftover from when there was only the A component function,
this bug was not found earlier because we used
templates instead of tags most of the time.
2024-09-14 10:40:03 +02:00
Lukáš Kucharczyk 649351efde
implement platform icons
Django CI/CD / test (push) Successful in 1m1s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-09-14 06:42:34 +02:00
Lukáš Kucharczyk 698c8966c0
add purchase date to game view
Django CI/CD / test (push) Successful in 1m9s Details
Django CI/CD / build-and-push (push) Successful in 2m8s Details
2024-09-11 11:40:17 +02:00
Lukáš Kucharczyk 7f6584ecf7
finish purchase from list 2024-09-11 11:39:54 +02:00
Lukáš Kucharczyk 540f5ee42c
align last column to the right 2024-09-11 11:39:48 +02:00
Lukáš Kucharczyk 1c73268258
redirect to purchase list after modifying purchase 2024-09-10 14:50:49 +02:00
Lukáš Kucharczyk 3063a3d143
refund purchase from list 2024-09-10 14:50:02 +02:00
Lukáš Kucharczyk b589199ca6
drop purchase from list 2024-09-10 14:46:50 +02:00
Lukáš Kucharczyk 2fc661dade
re-add button titles 2024-09-10 14:46:10 +02:00
Lukáš Kucharczyk 1f535a6e84
formatting 2024-09-10 14:26:06 +02:00
Lukáš Kucharczyk a9c1135639
improve layout
Django CI/CD / test (push) Successful in 1m7s Details
Django CI/CD / build-and-push (push) Successful in 2m12s Details
2024-09-09 11:25:29 +02:00
Lukáš Kucharczyk 58cfaca1a9
add table header actions
Django CI/CD / test (push) Successful in 1m3s Details
Django CI/CD / build-and-push (push) Successful in 2m23s Details
2024-09-08 21:03:37 +02:00
Lukáš Kucharczyk c1b3493c80
Merge calculated and manual duration
Django CI/CD / test (push) Successful in 1m2s Details
Django CI/CD / build-and-push (push) Successful in 1m55s Details
2024-09-07 23:35:59 +02:00
Lukáš Kucharczyk a1df8720f5
Fix missing variable reference
Django CI/CD / test (push) Successful in 1m11s Details
Django CI/CD / build-and-push (push) Successful in 2m3s Details
2024-09-07 23:20:17 +02:00
Lukáš Kucharczyk 5a852bc2b9
tailwind: define accent and background colors
Django CI/CD / test (push) Successful in 55s Details
Django CI/CD / build-and-push (push) Successful in 2m6s Details
2024-09-04 21:59:29 +02:00
Lukáš Kucharczyk 8ab9bfeeeb
update deps 2024-09-04 21:59:06 +02:00
Lukáš Kucharczyk 5eee7176d4
add streak-releted basic functionality 2024-09-04 21:58:56 +02:00
Lukáš Kucharczyk 98c9c1faee
move time-related functionality out of views.general
Django CI/CD / test (push) Successful in 58s Details
Django CI/CD / build-and-push (push) Successful in 1m52s Details
2024-09-04 21:55:22 +02:00
Lukáš Kucharczyk 645ffa0dad
update styles
Django CI/CD / test (push) Successful in 1m3s Details
Django CI/CD / build-and-push (push) Successful in 2m4s Details
2024-09-03 22:39:25 +02:00
Lukáš Kucharczyk 4358708262
add links to add a new X to: game, edition, purchase, session, device, platform
Django CI/CD / test (push) Successful in 55s Details
Django CI/CD / build-and-push (push) Successful in 1m57s Details
2024-09-03 15:48:58 +02:00
Lukáš Kucharczyk c738245783
Properly display non-game type names
Django CI/CD / test (push) Successful in 1m8s Details
Django CI/CD / build-and-push (push) Successful in 1m55s Details
2024-09-02 23:52:28 +02:00
Lukáš Kucharczyk 57184ceea0
add one more breakpoint to better utilize smaller screens
Django CI/CD / test (push) Successful in 53s Details
Django CI/CD / build-and-push (push) Successful in 1m56s Details
2024-09-02 23:44:18 +02:00
Lukáš Kucharczyk c2b9409562
update styles
Django CI/CD / test (push) Successful in 53s Details
Django CI/CD / build-and-push (push) Successful in 1m52s Details
2024-09-02 20:14:52 +02:00
Lukáš Kucharczyk e067e65bce
linkify game, edition, purchase, session references
Django CI/CD / test (push) Successful in 1m0s Details
Django CI/CD / build-and-push (push) Has been cancelled Details
also add link styles for links in a table row
2024-09-02 20:04:21 +02:00
Lukáš Kucharczyk b8258e2937
replace slippers with django-cotton
Django CI/CD / test (push) Successful in 59s Details
Django CI/CD / build-and-push (push) Successful in 2m4s Details
main reason: slippers cannot pass request via context inside its
components, making it annoying to use template takes that take request.
more reasons: not actively worked on, no named slots, having to define
components in components.yaml + new components do not get registered
without restarting server
2024-09-02 17:43:41 +02:00
Lukáš Kucharczyk 9af4c79947
improve game view
Django CI/CD / test (push) Successful in 56s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-08-19 21:58:43 +02:00
Lukáš Kucharczyk d8b8182b91
fix table top rounding 2024-08-13 08:36:40 +02:00
Lukáš Kucharczyk 2fd44c1f53
separate views out 2/2
Django CI/CD / test (push) Successful in 57s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-08-12 21:52:26 +02:00
Lukáš Kucharczyk c3f99d124c
update base.css 2024-08-12 21:42:56 +02:00
Lukáš Kucharczyk 51f5b9fceb
update ruff path 2024-08-12 21:42:47 +02:00
Lukáš Kucharczyk 973f4416de
separate views out 1/2 2024-08-12 21:42:34 +02:00
Lukáš Kucharczyk a84209eb81
sort by timestamp
Django CI/CD / test (push) Successful in 51s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-08-11 21:39:14 +02:00
Lukáš Kucharczyk 498cd69328
improve display of game names, durations 2024-08-11 20:29:47 +02:00
Lukáš Kucharczyk b28c42d945
delete platform
Django CI/CD / test (push) Successful in 51s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-08-11 20:21:44 +02:00
Lukáš Kucharczyk 3099f02145
list editions 2024-08-11 20:21:27 +02:00
Lukáš Kucharczyk 74b9d0421c
list platforms, fix editing platform 2024-08-11 18:34:50 +02:00
Lukáš Kucharczyk c61adad180
list games 2024-08-11 18:21:11 +02:00
Lukáš Kucharczyk 298ecb4092
formatting 2024-08-11 17:58:35 +02:00
Lukáš Kucharczyk 020e12e20b
remove session recent filter 2024-08-11 17:58:08 +02:00
Lukáš Kucharczyk 6ef56bfed5
list, edit, and delete devices 2024-08-11 17:53:36 +02:00
Lukáš Kucharczyk fda4913c97
add ruff to shell.nix
Django CI/CD / test (push) Successful in 56s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-08-11 17:24:50 +02:00
Lukáš Kucharczyk e85b32e22f
update styles 2024-08-11 17:24:33 +02:00
Lukáš Kucharczyk 2d6d6d24a4
formatting 2024-08-11 17:24:26 +02:00
Lukáš Kucharczyk 00993a85db
remove black 2024-08-11 17:24:19 +02:00
Lukáš Kucharczyk 4f7e708255
vscode: replace black with ruff 2024-08-11 17:23:59 +02:00
Lukáš Kucharczyk 238e4839e0
formatting 2024-08-11 17:23:28 +02:00
Lukáš Kucharczyk b0ad806a93
fix version_date 2024-08-11 17:23:18 +02:00
Lukáš Kucharczyk 453b4fd922
add manage -> sessions 2024-08-11 17:22:58 +02:00
Lukáš Kucharczyk bb0d24809e
make sure titles are truncated 2024-08-11 17:13:31 +02:00
Lukáš Kucharczyk 3abd4c4af9
reuse existing variable 2024-08-09 13:59:14 +02:00
Lukáš Kucharczyk 2e5e77b4e5
replace navbar
Django CI/CD / test (push) Successful in 1m12s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-08-09 13:14:18 +02:00
Lukáš Kucharczyk e79cf5de7a
fix non-working views 2024-08-09 13:12:47 +02:00
Lukáš Kucharczyk c15eaca205
only overflow table, not paginator, improve styling
Django CI/CD / test (push) Successful in 1m5s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-08-09 12:42:54 +02:00
Lukáš Kucharczyk 496c99ccf1
formatting 2024-08-09 12:23:49 +02:00
Lukáš Kucharczyk 992622e8d1
make it possible to not use paginator when limit = 0 2024-08-09 12:23:40 +02:00
Lukáš Kucharczyk cabe36c822
add dark/light mode toggle 2024-08-09 12:22:26 +02:00
Lukáš Kucharczyk d84b67c460
improve pagination 2024-08-09 11:47:10 +02:00
Lukáš Kucharczyk 1c28950b53
add pagination
Django CI/CD / test (push) Successful in 1m1s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-08-08 22:54:15 +02:00
Lukáš Kucharczyk b54bcdd9e9
remove cruft 2024-08-08 21:20:17 +02:00
Lukáš Kucharczyk 9ec6c958c8
remove unnecessary styles 2024-08-08 21:20:08 +02:00
Lukáš Kucharczyk 25deac6ea9
add more types 2024-08-08 21:19:43 +02:00
Lukáš Kucharczyk a5ac10b20d
use model variables for foreign keys where possible 2024-08-08 20:22:25 +02:00
Lukáš Kucharczyk 3de40ccad3
create purchase list without paging 2024-08-08 20:17:43 +02:00
Lukáš Kucharczyk 6a5dc9b62c
even more formatting 2024-08-08 15:08:50 +02:00
Lukáš Kucharczyk b6014a72e0
.gitignore: add .direnv 2024-08-08 14:49:09 +02:00
Lukáš Kucharczyk 245b47b8b3
improve shell.nix
do not let poetry manage venvs
no need to override python3
2024-08-08 14:48:58 +02:00
Lukáš Kucharczyk e33f23c18f
add .envrc 2024-08-08 14:48:20 +02:00
Lukáš Kucharczyk 33012bc328
vscode: add extensions and settings 2024-08-08 14:48:10 +02:00
Lukáš Kucharczyk 447bd4820c
reformat with djlint --reformat 2024-08-08 14:47:51 +02:00
Lukáš Kucharczyk 72e89dae77
remove cruft
Django CI/CD / test (push) Successful in 1m7s Details
Django CI/CD / build-and-push (push) Successful in 2m0s Details
2024-08-08 09:47:06 +02:00
Lukáš Kucharczyk 1cd0a8c0fb
add shell.nix 2024-08-08 09:27:51 +02:00
Lukáš Kucharczyk a9a430f856
change vscode settings 2024-08-08 09:27:36 +02:00
Lukáš Kucharczyk 0ee4c50a24
update dependencies
Django CI/CD / test (push) Successful in 1m5s Details
Django CI/CD / build-and-push (push) Successful in 2m1s Details
2024-08-08 09:17:09 +02:00
Lukáš Kucharczyk 714f0d97a9
Reformat
Django CI/CD / test (push) Successful in 1m0s Details
Django CI/CD / build-and-push (push) Successful in 2m10s Details
2024-08-04 22:40:43 +02:00
Lukáš Kucharczyk d622ddfbf3
Add all-time stats 2024-08-04 22:40:37 +02:00
Lukáš Kucharczyk 86fd40cc4a
Do not save non-durations as manual
Django CI/CD / test (push) Successful in 1m56s Details
Django CI/CD / build-and-push (push) Successful in 2m23s Details
2024-07-23 09:51:15 +02:00
Lukáš Kucharczyk e174850262
Update deps
Django CI/CD / test (push) Successful in 1m3s Details
Django CI/CD / build-and-push (push) Successful in 1m56s Details
2024-07-11 13:28:09 +02:00
Lukáš Kucharczyk 6328d835ee
Fix formatting 2024-07-09 23:04:14 +02:00
Lukáš Kucharczyk 34d42e2af5
Fix list session links
Django CI/CD / test (push) Successful in 1m2s Details
Django CI/CD / build-and-push (push) Successful in 2m1s Details
2024-07-09 23:03:52 +02:00
Lukáš Kucharczyk e19caf47bf
Make game overview more appealing
Django CI/CD / build-and-push (push) Blocked by required conditions Details
Django CI/CD / test (push) Has been cancelled Details
2024-07-09 23:03:03 +02:00
Lukáš Kucharczyk 72998ffc02
Fix incorrect font name 2024-07-09 20:38:03 +02:00
Lukáš Kucharczyk ba44814474
Improve game links
Django CI/CD / test (push) Successful in 1m6s Details
Django CI/CD / build-and-push (push) Successful in 1m56s Details
2024-07-09 19:40:47 +02:00
Lukáš Kucharczyk 86f8fde8fa
Avoid errors when displaying game overview with zero sessions
Django CI/CD / test (push) Successful in 1m4s Details
Django CI/CD / build-and-push (push) Successful in 2m10s Details
2024-07-09 07:32:49 +02:00
Lukáš Kucharczyk 811fec4b11
Ignore manual sessions when calculating session average
Django CI/CD / test (push) Successful in 1m1s Details
Django CI/CD / build-and-push (push) Successful in 1m58s Details
2024-07-02 17:27:44 +02:00
Lukáš Kucharczyk fe6cf2758c
make dev does not ignore warnings
Django CI/CD / test (push) Successful in 1m9s Details
Django CI/CD / build-and-push (push) Successful in 1m55s Details
2024-06-26 18:35:05 +02:00
Lukáš Kucharczyk 1e1372ca56
Update Python deps 2024-06-26 18:34:38 +02:00
Lukáš Kucharczyk d91c0bc255
Update npm deps
Django CI/CD / test (push) Successful in 1m1s Details
Django CI/CD / build-and-push (push) Successful in 1m57s Details
2024-06-26 17:39:53 +02:00
Lukáš Kucharczyk a14f5d3ae5
Add npm-check-updates 2024-06-26 17:39:39 +02:00
Lukáš Kucharczyk 4ac13053d5
Use new Poetry section for main deps 2024-06-26 17:31:43 +02:00
Lukáš Kucharczyk e9311225e7
Make setting up and developing easier 2024-06-26 17:18:58 +02:00
Lukáš Kucharczyk 44c70a5ee7
Formatting
Django CI/CD / test (push) Successful in 1m21s Details
Django CI/CD / build-and-push (push) Successful in 1m57s Details
2024-06-03 18:19:11 +02:00
Lukáš Kucharczyk cd804f2c77
Sort url paths 2024-06-03 18:18:58 +02:00
Lukáš Kucharczyk 15997bd5af
Re-enable delete session delete view 2024-06-03 18:07:10 +02:00
Lukáš Kucharczyk 880ea93424
Unify url path names 2024-06-03 18:05:34 +02:00
Lukáš Kucharczyk dc1a9d5c4f
Make sure attribute chains are evaluated safely 2024-05-30 14:26:38 +02:00
Lukáš Kucharczyk 51c25659a9
djhtml formatting
Django CI/CD / test (push) Successful in 1m5s Details
Django CI/CD / build-and-push (push) Successful in 1m57s Details
2024-04-30 12:04:16 +02:00
Lukáš Kucharczyk 973dda59d2
Improve game overview header 2024-04-30 12:03:52 +02:00
Lukáš Kucharczyk 64edca9ffa
Use display name in session list
Django CI/CD / test (push) Successful in 52s Details
Django CI/CD / build-and-push (push) Successful in 1m52s Details
2024-04-29 19:21:05 +02:00
Lukáš Kucharczyk 86e25b84ab
Allow deleting purchases
Django CI/CD / test (push) Successful in 53s Details
Django CI/CD / build-and-push (push) Successful in 1m47s Details
2024-04-29 16:35:54 +02:00
Lukáš Kucharczyk edc1d062bc
Update gunicorn to version 22.0.0
Django CI/CD / test (push) Successful in 1m46s Details
Django CI/CD / build-and-push (push) Successful in 2m28s Details
2024-04-17 12:28:10 +02:00
Lukáš Kucharczyk 12a517c9fa
Update sqlparse to version 0.5
Django CI/CD / test (push) Successful in 1m2s Details
Django CI/CD / build-and-push (push) Successful in 1m58s Details
2024-04-16 11:44:24 +02:00
Lukáš Kucharczyk c1882f66e3
Improve purchase name consistency on stats page
Django CI/CD / test (push) Successful in 1m0s Details
Django CI/CD / build-and-push (push) Successful in 2m0s Details
2024-04-15 13:55:17 +02:00
Lukáš Kucharczyk 1e87e67eb1
Reformat HTML with djhtml
Django CI/CD / test (push) Successful in 1m4s Details
Django CI/CD / build-and-push (push) Successful in 1m36s Details
2024-04-04 11:27:33 +02:00
Lukáš Kucharczyk 84552e088b
Update more dependencies 2024-04-04 11:27:14 +02:00
Lukáš Kucharczyk 79dc8ae25c
Update black
Django CI/CD / test (push) Failing after 44s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-04-04 11:09:09 +02:00
Lukáš Kucharczyk cee06e4f64
Update dependencies
Django CI/CD / test (push) Successful in 47s Details
Django CI/CD / build-and-push (push) Successful in 1m47s Details
2024-04-04 10:46:59 +02:00
Lukáš Kucharczyk d9b5f0eab2
stats: add monthly playtimes
Django CI/CD / test (push) Successful in 58s Details
Django CI/CD / build-and-push (push) Successful in 1m54s Details
2024-04-02 08:18:58 +02:00
Lukáš Kucharczyk ff28600710
Fix timestamp minutes on game page
Django CI/CD / test (push) Successful in 56s Details
Django CI/CD / build-and-push (push) Successful in 2m2s Details
Fixed #72
2024-03-27 14:38:00 +01:00
Lukáš Kucharczyk 7517bf5f37
Add stats for dropped purchases
Django CI/CD / test (push) Successful in 1m2s Details
Django CI/CD / build-and-push (push) Successful in 1m57s Details
2024-03-10 22:48:46 +01:00
Lukáš Kucharczyk 780a04d13f
Do not edit sort_name invisibly
Django CI/CD / test (push) Successful in 1m0s Details
Django CI/CD / build-and-push (push) Successful in 2m0s Details
Fixes #64
2024-03-04 16:50:37 +01:00
Lukáš Kucharczyk fd04e9fa77
Sort prefetch instead of the result
Django CI/CD / test (push) Successful in 57s Details
Django CI/CD / build-and-push (push) Successful in 1m52s Details
order_by on the final queryset results in duplicating editions, 1 for each purchase
to fix it we sort the thing we actually want to sort - non-game purchases - in a prefetch earlier in the code
2024-02-18 12:31:03 +01:00
Lukáš Kucharczyk 18902aedac
Reformat
Django CI/CD / test (push) Successful in 56s Details
Django CI/CD / build-and-push (push) Successful in 1m53s Details
2024-02-18 09:03:35 +01:00
Lukáš Kucharczyk f9e37e9b1e
Sort purchases also by date purchased
Django CI/CD / test (push) Successful in 1m4s Details
Django CI/CD / build-and-push (push) Has been cancelled Details
2024-02-18 09:02:08 +01:00
Lukáš Kucharczyk c747cd1fd8
Reformat
Django CI/CD / test (push) Successful in 55s Details
Django CI/CD / build-and-push (push) Successful in 1m33s Details
2024-02-10 09:50:53 +01:00
Lukáš Kucharczyk 6a5457191a
Add logout button 2024-02-10 09:48:09 +01:00
Lukáš Kucharczyk 76f6d0c377
Fix CSS bug 2024-02-10 09:03:16 +01:00
Lukáš Kucharczyk ae93703c08
Remove login_required from clone_session_by_id
Django CI/CD / test (push) Successful in 53s Details
Django CI/CD / build-and-push (push) Successful in 1m35s Details
2024-02-09 22:27:28 +01:00
Lukáš Kucharczyk c55176090c
Temporarily disable tests
Django CI/CD / test (push) Successful in 52s Details
Django CI/CD / build-and-push (push) Successful in 1m41s Details
2024-02-09 22:08:49 +01:00
Lukáš Kucharczyk 081b8a92de
Require login by default
Django CI/CD / test (push) Failing after 1m1s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-02-09 22:03:24 +01:00
Lukáš Kucharczyk d02a60675f
Render notes as Markdown
Django CI/CD / test (push) Failing after 1m5s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-02-09 21:37:39 +01:00
Lukáš Kucharczyk 4670568acb
Add .DS_Store to .gitignore
Django CI/CD / test (push) Successful in 1m4s Details
Django CI/CD / build-and-push (push) Successful in 1m34s Details
2024-01-15 22:09:29 +01:00
Lukáš Kucharczyk 4b75a1dea9
Increase session count on game overview when starting a new session
Django CI/CD / test (push) Successful in 50s Details
Django CI/CD / build-and-push (push) Successful in 1m32s Details
2024-01-15 21:41:25 +01:00
Lukáš Kucharczyk e2b7ff2e15
Remove cruft
Django CI/CD / test (push) Successful in 53s Details
Django CI/CD / build-and-push (push) Successful in 1m30s Details
2024-01-15 19:17:27 +01:00
Lukáš Kucharczyk b94aa49fc3
Fix title not being displayed on the Recent sessions page 2024-01-15 19:17:24 +01:00
Lukáš Kucharczyk 73a92e5636
Mark refunded purchases red
Django CI/CD / test (push) Successful in 1m3s Details
Django CI/CD / build-and-push (push) Successful in 1m36s Details
2024-01-15 11:19:18 +01:00
Lukáš Kucharczyk 42b28665e1
Version 1.5.2
Django CI/CD / test (push) Successful in 1m15s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-01-14 21:28:38 +01:00
Lukáš Kucharczyk 6ba187f8e4
Make it possible to end session from game overview 2024-01-14 21:27:18 +01:00
Lukáš Kucharczyk a765fd8d00
More game overview optimizations
Django CI/CD / test (push) Successful in 1m15s Details
Django CI/CD / build-and-push (push) Successful in 1m37s Details
2024-01-14 17:04:06 +01:00
Lukáš Kucharczyk 854e3cc54a
Do not copy notes when cloning session
Django CI/CD / test (push) Successful in 1m13s Details
Django CI/CD / build-and-push (push) Successful in 1m45s Details
2024-01-14 13:05:45 +01:00
Lukáš Kucharczyk 2d8eb32e90
Remove cruft
Django CI/CD / test (push) Successful in 1m3s Details
Django CI/CD / build-and-push (push) Successful in 1m29s Details
2024-01-10 17:13:59 +01:00
Lukáš Kucharczyk 1f1ed79ee5
Optimize session listing 2024-01-10 16:57:01 +01:00
Lukáš Kucharczyk 01fd7bad69
Remove cruft 2024-01-10 15:55:08 +01:00
Lukáš Kucharczyk 44f49e5974
Session list: speed up starting new sessions
Django CI/CD / test (push) Successful in 1m1s Details
Django CI/CD / build-and-push (push) Successful in 1m33s Details
2024-01-10 15:54:09 +01:00
Lukáš Kucharczyk 0cf3411f63
Make ending session from session list faster
Django CI/CD / test (push) Successful in 1m16s Details
Django CI/CD / build-and-push (push) Successful in 1m40s Details
2024-01-10 15:12:45 +01:00
Lukáš Kucharczyk aa669710e1
Change update_session to template partial
Django CI/CD / test (push) Failing after 1m3s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2024-01-10 14:10:13 +01:00
Lukáš Kucharczyk 242833f886
Make it possible to drop purchases, or consider them infinite
Django CI/CD / build-and-push (push) Blocked by required conditions Details
Django CI/CD / test (push) Has been cancelled Details
2024-01-03 22:35:39 +01:00
Lukáš Kucharczyk 0cdfd3c298
Stats: optimize
Django CI/CD / test (push) Successful in 56s Details
Django CI/CD / build-and-push (push) Successful in 1m33s Details
2024-01-03 21:35:47 +01:00
Lukáš Kucharczyk a98b4839dd
Fix wrong unfinished purchases calculation
Django CI/CD / test (push) Successful in 1m10s Details
Django CI/CD / build-and-push (push) Successful in 1m42s Details
2024-01-02 20:03:59 +01:00
Lukáš Kucharczyk 1999f13cf2
stats: add first and last play
Django CI/CD / test (push) Successful in 1m12s Details
Django CI/CD / build-and-push (push) Successful in 1m36s Details
2024-01-01 18:42:14 +01:00
Lukáš Kucharczyk 8466f67c86
Fix errors caused by empty values
Django CI/CD / test (push) Successful in 1m10s Details
Django CI/CD / build-and-push (push) Successful in 1m41s Details
2024-01-01 18:21:50 +01:00
Lukáš Kucharczyk d9fbb4b896
Add title to stats page
Django CI/CD / build-and-push (push) Blocked by required conditions Details
Django CI/CD / test (push) Has been cancelled Details
2023-12-15 10:58:15 +01:00
Lukáš Kucharczyk 4ff3692606
Remove duplicate block
Django CI/CD / test (push) Successful in 1m34s Details
Django CI/CD / build-and-push (push) Successful in 1m29s Details
2023-11-30 18:05:52 +01:00
Lukáš Kucharczyk 8289c48896
Fix CI error
Django CI/CD / test (push) Successful in 1m14s Details
Django CI/CD / build-and-push (push) Successful in 1m24s Details
2023-11-30 17:44:31 +01:00
Lukáš Kucharczyk d1b9202337
Update poetry.lock
Django CI/CD / test (push) Failing after 1m15s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2023-11-30 17:35:44 +01:00
Lukáš Kucharczyk fde93cb875
Organize better 2023-11-30 17:35:44 +01:00
Lukáš Kucharczyk d1c3ac6079
Revert "Move GraphQL to separata app"
This reverts commit 6ac4209492.
2023-11-30 17:35:44 +01:00
Lukáš Kucharczyk d921c2d8a6
Revert "Add UpdateGameMutation"
This reverts commit e9e61403a9.
2023-11-30 17:35:44 +01:00
Lukáš Kucharczyk 52513e1ed8
Add UpdateGameMutation 2023-11-30 17:35:44 +01:00
Lukáš Kucharczyk cb380814a7
Move GraphQL to separata app 2023-11-30 17:35:44 +01:00
Lukáš Kucharczyk 5ef8c07f30
Initial working API 2023-11-30 17:35:44 +01:00
Lukáš Kucharczyk 9573c3b8ff
Better formatting
Django CI/CD / test (push) Successful in 1m11s Details
Django CI/CD / build-and-push (push) Successful in 1m22s Details
2023-11-29 22:26:43 +01:00
Lukáš Kucharczyk c4354a1380
Update poetry.lock
Django CI/CD / test (push) Successful in 1m3s Details
Django CI/CD / build-and-push (push) Successful in 1m14s Details
2023-11-29 22:22:23 +01:00
Lukáš Kucharczyk a245b6ff0f
Fix longest session formatting
Django CI/CD / test (push) Successful in 1m1s Details
Django CI/CD / build-and-push (push) Successful in 1m24s Details
Put space between hours and minutes
2023-11-29 09:08:10 +01:00
Lukáš Kucharczyk 6329d380b7
Editions are unique if name, platform OR year is different
Django CI/CD / test (push) Successful in 1m25s Details
Django CI/CD / build-and-push (push) Successful in 1m20s Details
2023-11-28 14:44:11 +01:00
Lukáš Kucharczyk 76fbc39fed
Disable hx-boost
Django CI/CD / test (push) Successful in 1m1s Details
Django CI/CD / build-and-push (push) Successful in 1m17s Details
2023-11-28 14:29:56 +01:00
Lukáš Kucharczyk 4b6734c173
Add width, height, alt to images 2023-11-28 14:29:11 +01:00
Lukáš Kucharczyk b505b5b430
Stats: add highest session average
Django CI/CD / test (push) Successful in 1m2s Details
Django CI/CD / build-and-push (push) Successful in 1m23s Details
2023-11-21 21:57:17 +01:00
Lukáš Kucharczyk 87553ebdc5
Add djlint pre-commit hook
Django CI/CD / test (push) Successful in 1m13s Details
Django CI/CD / build-and-push (push) Successful in 1m15s Details
2023-11-21 18:19:25 +01:00
Lukáš Kucharczyk ba4fc0cac5
Do not trigger hx-boost for non-submit buttons
Django CI/CD / test (push) Successful in 1m7s Details
Django CI/CD / build-and-push (push) Successful in 1m20s Details
2023-11-21 18:12:58 +01:00
Lukáš Kucharczyk 8cb0276215
Use better way to find out if model record exists 2023-11-21 18:03:01 +01:00
Lukáš Kucharczyk f9a51ee83d
Remove experimental layout 2023-11-21 18:03:01 +01:00
Lukáš Kucharczyk c9deba7d65
Add stats for most sessions, longest session 2023-11-21 17:02:44 +01:00
Lukáš Kucharczyk c55fbe86b5
Support HTMX with Django Debug Toolbar 2023-11-21 16:59:21 +01:00
Lukáš Kucharczyk 0e93993498
Add django-debug-toolbar 2023-11-21 14:42:37 +01:00
Lukáš Kucharczyk 9fccdfbff0
Make links colorful 2023-11-20 23:07:11 +01:00
Lukáš Kucharczyk d78139a5b3
Display finished DLCs in stats better
Django CI/CD / test (push) Successful in 1m6s Details
Django CI/CD / build-and-push (push) Successful in 1m26s Details
2023-11-20 21:56:16 +01:00
Lukáš Kucharczyk 7dc43fbf77
Fix wrong export name 2023-11-20 21:54:51 +01:00
Lukáš Kucharczyk 5442926457
Allow DLC to have date_finished set
Django CI/CD / test (push) Successful in 1m6s Details
Django CI/CD / build-and-push (push) Successful in 1m35s Details
2023-11-20 21:42:23 +01:00
Lukáš Kucharczyk db4c635260
Remote JavaScript files 2023-11-20 21:25:21 +01:00
Lukáš Kucharczyk 4a1d08d4df
Fix CI, re-add test step
Django CI/CD / test (push) Successful in 1m0s Details
Django CI/CD / build-and-push (push) Successful in 1m45s Details
2023-11-18 11:10:33 +01:00
Lukáš Kucharczyk c35b539c42 Merge sessions and notes
Django CI/CD / build-and-push (push) Successful in 1m9s Details
2023-11-17 21:20:33 +01:00
Lukáš Kucharczyk bbe5e072b2 Don't display prices if zero 2023-11-17 21:10:56 +01:00
Lukáš Kucharczyk 6fc2f623dc Apply djlint 2023-11-17 21:06:57 +01:00
Lukáš Kucharczyk 9481bd5fef Add pre-commit
Django CI/CD / build-and-push (push) Successful in 1m33s Details
2023-11-17 09:34:51 +01:00
Lukáš Kucharczyk 4083165123 Use the black profile for isort 2023-11-17 09:15:18 +01:00
Lukáš Kucharczyk 45bb2681c7 Use isort on migrations 2023-11-17 09:15:06 +01:00
Lukáš Kucharczyk dbb8ec3f9a Handle empty edition_id 2023-11-17 09:14:25 +01:00
Lukáš Kucharczyk 206b5f6d46 Prevent HTMX from messing up the initial state
Django CI/CD / build-and-push (push) Successful in 1m15s Details
2023-11-16 20:33:56 +01:00
Lukáš Kucharczyk b7e14ecc83 Account for no sessions
Django CI/CD / build-and-push (push) Successful in 1m21s Details
2023-11-16 20:29:08 +01:00
Lukáš Kucharczyk 912e010729 Enable hx-boost everywhere
Django CI/CD / build-and-push (push) Successful in 1m18s Details
2023-11-16 19:56:08 +01:00
Lukáš Kucharczyk a485237456 Fix form not syncing due to HTMX
Django CI/CD / build-and-push (push) Successful in 2m38s Details
2023-11-16 19:03:16 +01:00
Lukáš Kucharczyk f5faf92ee0 Fix error
Django CI/CD / build-and-push (push) Successful in 1m57s Details
2023-11-16 16:53:59 +01:00
Lukáš Kucharczyk 07452d8c43 Re-instance gitea actions
Django CI/CD / test (push) Failing after 34s Details
Django CI/CD / build-and-push (push) Has been skipped Details
2023-11-16 16:51:52 +01:00
Lukáš Kucharczyk 229a79d266 Update .drone.yml testing
continuous-integration/drone/push Build is failing Details
2023-11-16 16:30:17 +01:00
Lukáš Kucharczyk c6ed577fe3 Formatting
continuous-integration/drone/push Build is failing Details
2023-11-16 16:27:41 +01:00
Lukáš Kucharczyk 171e4779a3 Move static files in prod 2023-11-16 16:27:41 +01:00
Lukáš Kucharczyk 79f94e5984 Fix docker-compose.yml 2023-11-16 16:27:41 +01:00
Lukáš Kucharczyk ccebcb89c6 Improve Dockerfile
Major inspiration (aka direct theft) from https://github.com/wemake-services/wemake-django-template
2023-11-16 16:27:41 +01:00
Lukáš Kucharczyk fe0a6b39e3 Fix .dockerignore 2023-11-16 16:27:41 +01:00
Lukáš Kucharczyk 6a495f951f Remove Django admin 2023-11-16 16:27:41 +01:00
Lukáš Kucharczyk c8646d0a0c Update dependencies 2023-11-16 16:27:41 +01:00
Lukáš Kucharczyk f2bb15e669 Fix naive date 2023-11-16 16:27:41 +01:00
Lukáš Kucharczyk c49177d63c isort 2023-11-16 16:27:41 +01:00
Lukáš Kucharczyk bd8d30eac1 Improve time-related stuff
Add created_at to all models
Add modified_at to Session
Get rid of custom now() function
Make sure aware datetime is used everywhere
2023-11-16 16:27:41 +01:00
Lukáš Kucharczyk c44d8bf427 Improve time-related stuff
continuous-integration/drone/push Build is passing Details
Add created_at to all models
Add modified_at to Session
Get rid of custom now() function
Make sure aware datetime is used everywhere
2023-11-15 19:14:09 +01:00
Lukáš Kucharczyk 3f037b4c7c Only allow choosing purchases of selected edition
continuous-integration/drone/push Build is passing Details
2023-11-15 14:25:42 +01:00
Lukáš Kucharczyk 8783d1fc8e Name and related_purchase validation for non-games 2023-11-15 13:04:47 +01:00
Lukáš Kucharczyk 9a1d24dbfd Sort imports, remove cruft 2023-11-15 12:19:31 +01:00
Lukáš Kucharczyk 4720660cff Fix wrong playrange ordering 2023-11-15 10:40:52 +01:00
Lukáš Kucharczyk e158bc0623 Improve how editions and purchases are displayed
continuous-integration/drone/push Build is passing Details
2023-11-15 10:37:24 +01:00
Lukáš Kucharczyk 8982fc5086 Game View: order editions by year 2023-11-14 21:19:36 +01:00
Lukáš Kucharczyk 729e1d939b Version 1.5.1
continuous-integration/drone/push Build encountered an error Details
2023-11-14 21:10:42 +01:00
Lukáš Kucharczyk 2b4683e489 Improve and cleanup ConditionalElementHandler 2023-11-14 21:09:43 +01:00
Lukáš Kucharczyk cce810e8cf Improve purchase __str__ 2023-11-14 19:55:56 +01:00
Lukáš Kucharczyk 62cd17f702 Disallow choosing non-game purchase as related purchase 2023-11-14 19:55:19 +01:00
Lukáš Kucharczyk f31280c682 Version 1.5.0
continuous-integration/drone/push Build is passing Details
2023-11-14 19:31:17 +01:00
Lukáš Kucharczyk a745d16ec3 Order purchases by date on game view 2023-11-14 19:30:19 +01:00
Lukáš Kucharczyk ae079e36ec Add purchase types 2023-11-14 19:27:00 +01:00
Lukáš Kucharczyk c8a3212b77 CI: run migrations before tests
continuous-integration/drone/push Build was killed Details
2023-11-12 08:11:22 +01:00
Lukáš Kucharczyk d211326c3f Make sure empty stats are 0
continuous-integration/drone/push Build is failing Details
2023-11-12 08:01:12 +01:00
Lukáš Kucharczyk 270a291f05 Change stats years to 2000 up to current year 2023-11-12 07:50:12 +01:00
Lukáš Kucharczyk 13b750ca92 Add stat for finished this year's games 2023-11-12 07:40:29 +01:00
Lukáš Kucharczyk 015b6db2f7 Fix detecting manual durations
continuous-integration/drone/push Build is failing Details
2023-11-11 15:02:28 +01:00
Lukáš Kucharczyk 667b161fff Remove deprecated USE_L10N
continuous-integration/drone/push Build is failing Details
2023-11-10 21:37:13 +01:00
Lukáš Kucharczyk 5958cbf4a6 Add more tests 2023-11-10 21:34:36 +01:00
Lukáš Kucharczyk 3b37f2c3f0 Fix edge case in format_duration
continuous-integration/drone/push Build is passing Details
Fixes #65

```python
def test_specific_precise_if_unncessary(self):
        delta = timedelta(hours=2, minutes=40)
        result = format_duration(delta, "%02.0H:%02.0m")
        self.assertEqual(result, "02:40")
```
This test fails by returning "03:40" instead. The problem is in the way `format_duration` handles fractional hours.
To fix it, we need to switch between using hours and fractional hours
depending on if minutes are present in the formatted string.
2023-11-10 20:07:41 +01:00
Lukáš Kucharczyk 4517ff2b5a Fix ordering
continuous-integration/drone/push Build is passing Details
2023-11-09 21:43:17 +01:00
Lukáš Kucharczyk 884ce13e26 Also prefill year between game and edition
continuous-integration/drone/push Build is passing Details
2023-11-09 21:20:12 +01:00
Lukáš Kucharczyk dd219bae9d Version 1.4.0
continuous-integration/drone/push Build is passing Details
2023-11-09 21:11:43 +01:00
Lukáš Kucharczyk 60d29090a1 Adding new games is easier 2023-11-09 21:11:28 +01:00
Lukáš Kucharczyk 1bc3ca057b Refactor, remove cruft 2023-11-09 19:35:57 +01:00
Lukáš Kucharczyk c2c0886451 Add backlog decrease count 2023-11-09 19:15:49 +01:00
Lukáš Kucharczyk b0be7b5887 Fix sort names getting mangled
continuous-integration/drone/push Build is passing Details
2023-11-09 15:41:46 +01:00
Lukáš Kucharczyk 099d989f16 Pre-fill year when adding edition 2023-11-09 15:20:30 +01:00
Lukáš Kucharczyk a879360ebd UX improvements
continuous-integration/drone/push Build is passing Details
* ignore English articles when sorting names
  * added a new sort_name field that gets automatically created
* automatically fill certain values in forms:
  * new game: name and sort name after typing
  * new edition: name and sort name when selecting game
  * new purchase: platform when selecting edition
2023-11-09 14:49:00 +01:00
Lukáš Kucharczyk 866f2526e6 Fix hardcoded year 2023-11-09 10:10:44 +01:00
Lukáš Kucharczyk ce3c4b55f0 Order devices alphabetically on new session form
continuous-integration/drone/push Build is passing Details
2023-11-09 10:09:32 +01:00
Lukáš Kucharczyk c52cd822ae Use safe_division in more places 2023-11-09 10:06:14 +01:00
Lukáš Kucharczyk cdc6ca1324 Fix potential division by zero
continuous-integration/drone/push Build is passing Details
2023-11-09 09:18:49 +01:00
Lukáš Kucharczyk e7ed349356 Add more stats
continuous-integration/drone/push Build is passing Details
* Finished (count)
* Unfinished (count)
* Refunded (count)
2023-11-08 18:13:48 +01:00
Lukáš Kucharczyk 5052ca7dbf Add more stats
continuous-integration/drone/push Build is passing Details
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Total sessions
* Days played
2023-11-08 16:24:22 +01:00
Lukáš Kucharczyk f408bfd927 stats: change overall stats table layout 2023-11-08 15:48:06 +01:00
Lukáš Kucharczyk 666dee33ba Model changes
continuous-integration/drone/push Build is passing Details
* More fields are now optional. This is to make it easier to add new items in bulk.
  * Game: Wikidata ID
  * Edition: Platform, Year
  * Purchase: Platform
  * Platform: Group
  * Session: Device
* New fields:
  * Game: Year Released
    * To record original year of release
    * Upon migration, this will be set to a year of any of the game's edition that has it set
  * Purchase: Date Finished
* Editions are now unique combination of name and platform
2023-11-06 19:48:12 +01:00
Lukáš Kucharczyk e0b09e051a simplify playtime range display 2023-11-06 12:05:39 +01:00
Lukáš Kucharczyk 4552cf7616 Version 1.3.0
continuous-integration/drone/push Build is passing Details
2023-11-05 15:10:56 +01:00
Lukáš Kucharczyk a614b51d29 Make some pages redirect back instead to session list 2023-11-05 15:09:51 +01:00
Lukáš Kucharczyk e67aa3fda1 Add more stats
continuous-integration/drone/push Build is passing Details
2023-11-02 20:12:32 +01:00
Lukáš Kucharczyk 8423fd02b4 Extend stats range to 2018
continuous-integration/drone/push Build is passing Details
2023-11-02 15:32:57 +01:00
Lukáš Kucharczyk 2bd07e5f2d Remove cruft
continuous-integration/drone/push Build is passing Details
2023-11-02 15:14:57 +01:00
Lukáš Kucharczyk 058b83522c Group by game instead of purchase 2023-11-02 15:14:50 +01:00
Lukáš Kucharczyk f13ed8a078 Reorder imports in views.py
continuous-integration/drone/push Build is passing Details
2023-11-02 09:53:28 +01:00
Lukáš Kucharczyk 02d5adcb3c Remove hardcoded year 2023-11-02 09:52:59 +01:00
Lukáš Kucharczyk d6fb16bb74 Make navigation more compact 2023-11-02 09:52:42 +01:00
Lukáš Kucharczyk 71b90b8202 Add stats link, year selector 2023-11-02 09:20:09 +01:00
Lukáš Kucharczyk 3ee36932c3 Limit stats of single year correctly 2023-11-02 09:17:08 +01:00
Lukáš Kucharczyk 391fcc79a8 Version 1.2.0
continuous-integration/drone/push Build is passing Details
2023-11-01 20:35:58 +01:00
Lukáš Kucharczyk 57d4fd7212 Add yearly stats page
Fixes #15
2023-11-01 20:35:52 +01:00
155 changed files with 8282 additions and 1444 deletions

View File

@ -5,4 +5,13 @@
.venv
.vscode
node_modules
src/timetracker/static/*
static
.drone.yml
.editorconfig
.gitignore
Caddyfile
CHANGELOG.md
db.sqlite3
docker-compose*
Dockerfile
Makefile

View File

@ -5,11 +5,12 @@ name: default
steps:
- name: test
image: python:3.10
image: python:3.12
commands:
- python -m pip install poetry
- poetry install
- poetry env info
- poetry run python manage.py migrate
- poetry run pytest
- name: build-prod

1
.envrc Normal file
View File

@ -0,0 +1 @@
use nix

36
.github/workflows/build-docker.yml vendored Normal file
View File

@ -0,0 +1,36 @@
name: Django CI/CD
on:
push:
paths-ignore: [ 'README.md' ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: 3.12
- run: |
python -m pip install poetry
poetry install
poetry env info
poetry run python manage.py migrate
# PROD=1 poetry run pytest
build-and-push:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
registry.kucharczyk.xyz/timetracker:latest
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
env:
VERSION_NUMBER: 1.5.1

5
.gitignore vendored
View File

@ -1,9 +1,12 @@
__pycache__
.mypy_cache
.pytest_cache
.venv
.venv/
node_modules
package-lock.json
db.sqlite3
/static/
dist/
.DS_Store
.python-version
.direnv

20
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,20 @@
repos:
# disable due to incomaptible formatting between
# black and ruff
# TODO: replace with ruff when it works on NixOS
# - repo: https://github.com/psf/black
# rev: 24.8.0
# hooks:
# - id: black
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
name: isort (python)
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.34.0
hooks:
- id: djlint-reformat-django
args: ["--ignore", "H011"]
- id: djlint-django
args: ["--ignore", "H011"]

11
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"recommendations": [
"charliermarsh.ruff",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-python.debugpy",
"batisteo.vscode-django",
"bradlc.vscode-tailwindcss",
"EditorConfig.EditorConfig"
]
}

26
.vscode/settings.json vendored
View File

@ -4,8 +4,30 @@
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.analysis.typeCheckingMode": "basic",
"python.analysis.typeCheckingMode": "strict",
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
},
"ruff.path": ["/nix/store/jaibb3v0rrnlw5ib54qqq3452yhp1xcb-ruff-0.5.7/bin/ruff"],
"tailwind-fold.supportedLanguages": [
"html",
"typescriptreact",
"javascriptreact",
"typescript",
"javascript",
"vue-html",
"vue",
"php",
"markdown",
"coffeescript",
"svelte",
"astro",
"erb",
"django-html"
]
}

View File

@ -1,5 +1,116 @@
## Unreleased
## New
* Render notes as Markdown
* Require login by default
* Add stats for dropped purchases, monthly playtimes
* Allow deleting purchases
* Add all-time stats
* Manage purchases
## Improved
* mark refunded purchases red on game overview
* increase session count on game overview when starting a new session
* game overview:
* sort purchases also by date purchased (on top of date released)
* improve header format, make it more appealing
* ignore manual sessions when calculating session average
* stats: improve purchase name consistency
* session list: use display name instead of sort name
* unify the appearance of game links, and make them expand to full size on hover
## Fixed
* Fix title not being displayed on the Recent sessions page
* Avoid errors when displaying game overview with zero sessions
## 1.5.2 / 2024-01-14 21:27+01:00
## Improved
* game overview:
* improve how editions and purchases are displayed
* make it possible to end session from overview
* add purchase: only allow choosing purchases of selected edition
* session list:
* starting and ending sessions is much faster/doest not reload the page
* listing sessions is much faster
## 1.5.1 / 2023-11-14 21:10+01:00
## Improved
* Disallow choosing non-game purchase as related purchase
* Improve display of purchases
## 1.5.0 / 2023-11-14 19:27+01:00
## New
* Add stat for finished this year's games
* Add purchase types:
* Game (previously all of them were this type)
* DLC
* Season Pass
* Battle Pass
## Fixed
* Order purchases by date on game view
## 1.4.0 / 2023-11-09 21:01+01:00
### New
* More fields are now optional. This is to make it easier to add new items in bulk.
* Game: Wikidata ID
* Edition: Platform, Year
* Purchase: Platform
* Platform: Group
* Session: Device
* New fields:
* Game: Year Released
* To record original year of release
* Upon migration, this will be set to a year of any of the game's edition that has it set
* Purchase: Date Finished
* Editions are now unique combination of name and platform
* Add more stats:
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Sessions (count)
* Days played
* Finished (count)
* Unfinished (count)
* Refunded (count)
* Backlog Decrease (count)
* New workflow:
* Adding Game, Edition, Purchase, and Session in a row is now much faster
### Improved
* game overview: simplify playtime range display
* new session: order devices alphabetically
* ignore English articles when sorting names
* added a new sort_name field that gets automatically created
* automatically fill certain values in forms:
* new game: name and sort name after typing
* new edition: name, sort name, and year when selecting game
* new purchase: platform when selecting edition
## 1.3.0 / 2023-11-05 15:09+01:00
### New
* Add Stats to the main navigation
* Allow selecting year on the Stats page
### Improved
* Make some pages redirect back instead to session list
### Improved
* Make navigation more compact
### Fixed
* Correctly limit sessions to a single year for stats
## 1.2.0 / 2023-11-01 20:18+01:00
### New
* Add yearly stats page (https://git.kucharczyk.xyz/lukas/timetracker/issues/15)
### Enhancements
* Add a button to start session from game overview

View File

@ -1,27 +1,45 @@
FROM node as css
WORKDIR /app
COPY . /app
RUN npm install && \
npx tailwindcss -i ./common/input.css -o ./static/base.css --minify
FROM python:3.12.0-slim-bullseye
FROM python:3.10.9-slim-bullseye
ENV VERSION_NUMBER=1.5.2 \
PROD=1 \
PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \
PYTHONHASHSEED=random \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_DEFAULT_TIMEOUT=100 \
PIP_ROOT_USER_ACTION=ignore \
POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=false \
POETRY_CACHE_DIR='/var/cache/pypoetry' \
POETRY_HOME='/usr/local'
ENV VERSION_NUMBER 1.1.2
ENV PROD 1
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get upgrade -y \
&& apt-get install --no-install-recommends -y \
bash \
curl \
&& curl -sSL 'https://install.python-poetry.org' | python - \
&& poetry --version \
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& apt-get clean -y && rm -rf /var/lib/apt/lists/*
RUN useradd -m --uid 1000 timetracker
RUN useradd -m --uid 1000 timetracker \
&& mkdir -p '/var/www/django/static' \
&& chown timetracker:timetracker '/var/www/django/static'
WORKDIR /home/timetracker/app
COPY . /home/timetracker/app/
RUN chown -R timetracker:timetracker /home/timetracker/app
COPY --from=css ./app/static/base.css /home/timetracker/app/static/base.css
COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh
RUN --mount=type=cache,target="$POETRY_CACHE_DIR" \
echo "$PROD" \
&& poetry version \
&& poetry run pip install -U pip \
&& poetry install --only main --no-interaction --no-ansi --sync
USER timetracker
ENV PATH="$PATH:/home/timetracker/.local/bin"
RUN pip install --no-cache-dir poetry
RUN poetry install
EXPOSE 8000
CMD [ "/entrypoint.sh" ]

View File

@ -3,6 +3,7 @@ all: css migrate
initialize: npm css migrate sethookdir loadplatforms
HTMLFILES := $(shell find games/templates -type f)
PYTHON_VERSION = 3.12
npm:
npm install
@ -10,17 +11,26 @@ npm:
css: common/input.css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
css-dev: css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch
makemigrations:
poetry run python manage.py makemigrations
migrate: makemigrations
poetry run python manage.py migrate
dev: migrate
poetry run python manage.py runserver
init:
pyenv install -s $(PYTHON_VERSION)
pyenv local $(PYTHON_VERSION)
pip install poetry
poetry install
npm install
dev:
@npx concurrently \
--names "Django,Tailwind" \
--prefix-colors "blue,green" \
"poetry run python -Wa manage.py runserver" \
"npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
caddy:
caddy run --watch

View File

@ -1,3 +1,15 @@
# Timetracker
A simple game catalogue and play session tracker.
# Development
The project uses `pyenv` to manage installed Python versions.
If you have `pyenv` installed, you can simply run:
```
make init
```
This will make sure the correct Python version is installed, and it will install all dependencies using `poetry`.
Afterwards, you can start the development server using `make dev`.

167
common/components.py Normal file
View File

@ -0,0 +1,167 @@
from random import choices as random_choices
from string import ascii_lowercase
from typing import Any, Callable
from django.template.loader import render_to_string
from django.urls import NoReverseMatch, reverse
from django.utils.safestring import SafeText, mark_safe
from common.utils import truncate
HTMLAttribute = tuple[str, str | int | bool]
HTMLTag = str
def Component(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
template: str = "",
tag_name: str = "",
) -> HTMLTag:
if not tag_name and not template:
raise ValueError("One of template or tag_name is required.")
if isinstance(children, str):
children = [children]
childrenBlob = "\n".join(children)
if len(attributes) == 0:
attributesBlob = ""
else:
attributesList = [f'{name}="{value}"' for name, value in attributes]
# make attribute list into a string
# and insert space between tag and attribute list
attributesBlob = f" {" ".join(attributesList)}"
tag: str = ""
if tag_name != "":
tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
elif template != "":
tag = render_to_string(
template,
{name: value for name, value in attributes}
| {"slot": mark_safe("\n".join(children))},
)
return mark_safe(tag)
def randomid(seed: str = "", length: int = 10) -> str:
return seed + "".join(random_choices(ascii_lowercase, k=length))
def Popover(
popover_content: str,
wrapped_content: str = "",
children: list[HTMLTag] = [],
attributes: list[HTMLAttribute] = [],
) -> str:
if not wrapped_content and not children:
raise ValueError("One of wrapped_content or children is required.")
id = randomid()
return Component(
attributes=attributes
+ [
("id", id),
("wrapped_content", wrapped_content),
("popover_content", popover_content),
],
children=children,
template="cotton/popover.html",
)
def PopoverTruncated(input_string: str) -> str:
if (truncated := truncate(input_string)) != input_string:
return Popover(wrapped_content=truncated, popover_content=input_string)
else:
return input_string
def A(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
url: str | Callable[..., Any] = "",
):
"""
Returns the HTML tag "a".
"url" can either be:
- URL (string)
- path name passed to reverse() (string)
- function
"""
additional_attributes = []
if url:
if type(url) is str:
try:
url_result = reverse(url)
except NoReverseMatch:
url_result = url
elif callable(url):
url_result = url()
else:
raise TypeError("'url' is neither str nor function.")
additional_attributes = [("href", url_result)]
return Component(
tag_name="a", attributes=attributes + additional_attributes, children=children
)
def Button(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
size: str = "base",
icon: bool = False,
color: str = "blue",
):
return Component(
template="cotton/button.html",
attributes=attributes + [("size", size), ("icon", icon), ("color", color)],
children=children,
)
def Div(
attributes: list[HTMLAttribute] = [],
children: list[HTMLTag] | HTMLTag = [],
):
return Component(tag_name="div", attributes=attributes, children=children)
def Icon(
name: str,
attributes: list[HTMLAttribute] = [],
):
return Component(template=f"cotton/icon/{name}.html", attributes=attributes)
def LinkedNameWithPlatformIcon(name: str, game_id: int, platform: str) -> SafeText:
link = reverse("view_game", args=[int(game_id)])
a_content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
platform.icon,
[("title", platform.name)],
),
PopoverTruncated(name),
],
)
return mark_safe(
A(
url=link,
children=[a_content],
),
)
def NameWithPlatformIcon(name: str, platform: str) -> SafeText:
content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
platform.icon,
[("title", platform.name)],
),
PopoverTruncated(name),
],
)
return mark_safe(content)

View File

@ -4,7 +4,7 @@
@font-face {
font-family: "IBM Plex Mono";
src: url("fonts/IBMPlexMono-regular.woff2") format("woff2");
src: url("fonts/IBMPlexMono-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@ -23,12 +23,33 @@
font-style: normal;
}
@font-face {
font-family: "IBM Plex Serif";
src: url("fonts/IBMPlexSerif-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Sans Condensed";
src: url("fonts/IBMPlexSansCondensed-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
/* a:hover {
text-decoration-color: #ff4400;
color: rgb(254, 185, 160);
transition: all 0.2s ease-out;
} */
form label {
@apply dark:text-slate-400;
}
.responsive-table {
@apply dark:text-white mx-auto;
@apply dark:text-white mx-auto table-fixed;
}
.responsive-table tr:nth-child(even) {
@ -49,11 +70,20 @@ form label {
}
@layer utilities {
.min-w-20char {
min-width: 20ch;
}
.max-w-20char {
max-width: 20ch;
}
.min-w-30char {
min-width: 30ch;
}
.max-w-30char {
max-width: 30ch;
}
.max-w-35char {
max-width: 40ch;
max-width: 35ch;
}
.max-w-40char {
max-width: 40ch;
@ -66,6 +96,16 @@ textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
}
form input:disabled,
select:disabled,
textarea:disabled {
@apply dark:bg-slate-700 dark:text-slate-400;
}
.errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
}
@media screen and (min-width: 768px) {
form input,
select,
@ -86,14 +126,6 @@ textarea {
@apply mx-1;
}
th {
@apply text-right;
}
th label {
@apply mr-4;
}
.basic-button-container {
@apply flex space-x-2 justify-center;
}
@ -101,3 +133,39 @@ th label {
.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;
}
.markdown-content ul {
list-style-type: disc;
list-style-position: inside;
padding-left: 1em;
}
.markdown-content ol {
list-style-type: decimal;
list-style-position: inside;
padding-left: 1em;
}
.markdown-content ul,
.markdown-content ol {
list-style-position: outside;
padding-left: 1em;
}
.markdown-content ul ul,
.markdown-content ul ol,
.markdown-content ol ul,
.markdown-content ol ol {
list-style-type: circle;
margin-top: 0.5em;
margin-bottom: 0.5em;
padding-left: 1em;
}
/* .truncate-container {
@apply inline-block relative;
a {
@apply inline-block truncate max-w-20char transition-all group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-sm group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4;
}
} */

View File

@ -1,12 +1,15 @@
import re
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from datetime import date, datetime, timedelta
from django.conf import settings
from django.utils import timezone
from common.utils import generate_split_ranges
def now() -> datetime:
return datetime.now(ZoneInfo(settings.TIME_ZONE))
dateformat: str = "%d/%m/%Y"
datetimeformat: str = "%d/%m/%Y %H:%M"
timeformat: str = "%H:%M"
durationformat: str = "%2.1H hours"
durationformat_manual: str = "%H hours"
def _safe_timedelta(duration: timedelta | int | None):
@ -19,7 +22,7 @@ def _safe_timedelta(duration: timedelta | int | None):
def format_duration(
duration: timedelta | int | None, format_string: str = "%H hours"
duration: timedelta | int | float | None, format_string: str = "%H hours"
) -> str:
"""
Format timedelta into the specified format_string.
@ -44,7 +47,7 @@ def format_duration(
# timestamps where end is before start
if seconds_total < 0:
seconds_total = 0
days = hours = minutes = seconds = 0
days = hours = hours_float = minutes = seconds = 0
remainder = seconds = seconds_total
if "%d" in format_string:
days, remainder = divmod(seconds_total, day_seconds)
@ -55,7 +58,7 @@ def format_duration(
minutes, seconds = divmod(remainder, minute_seconds)
literals = {
"d": str(days),
"H": str(hours),
"H": str(hours) if "m" not in format_string else str(hours_float),
"m": str(minutes),
"s": str(seconds),
"r": str(seconds_total),
@ -77,3 +80,86 @@ def format_duration(
rf"%\d*\.?\d*{pattern}", replacement, formatted_string
)
return formatted_string
def local_strftime(datetime: datetime, format: str = datetimeformat) -> str:
return timezone.localtime(datetime).strftime(format)
def daterange(start: date, end: date, end_inclusive: bool = False) -> list[date]:
time_between: timedelta = end - start
if (days_between := time_between.days) < 1:
raise ValueError("start and end have to be at least 1 day apart.")
if end_inclusive:
print(f"{end_inclusive=}")
print(f"{days_between=}")
days_between += 1
print(f"{days_between=}")
return [start + timedelta(x) for x in range(days_between)]
def streak(datelist: list[date]) -> dict[str, int | tuple[date, date]]:
if len(datelist) == 1:
return {"days": 1, "dates": (datelist[0], datelist[0])}
else:
print(f"Processing {len(datelist)} dates.")
missing = sorted(
set(
datelist[0] + timedelta(x)
for x in range((datelist[-1] - datelist[0]).days)
)
- set(datelist)
)
print(f"{len(missing)} days missing.")
datelist_with_missing = sorted(datelist + missing)
ranges = list(generate_split_ranges(datelist_with_missing, missing))
print(f"{len(ranges)} ranges calculated.")
longest_consecutive_days = timedelta(0)
longest_range: tuple[date, date] = (date(1970, 1, 1), date(1970, 1, 1))
for start, end in ranges:
if (current_streak := end - start) > longest_consecutive_days:
longest_consecutive_days = current_streak
longest_range = (start, end)
return {"days": longest_consecutive_days.days + 1, "dates": longest_range}
def streak_bruteforce(datelist: list[date]) -> dict[str, int | tuple[date, date]]:
if (datelist_length := len(datelist)) == 0:
raise ValueError("Number of dates in the list is 0.")
datelist.sort()
current_streak = 1
current_start = datelist[0]
current_end = datelist[0]
current_date = datelist[0]
highest_streak = 1
highest_streak_daterange = (current_start, current_end)
def update_highest_streak():
nonlocal highest_streak, highest_streak_daterange
if current_streak > highest_streak:
highest_streak = current_streak
highest_streak_daterange = (current_start, current_end)
def reset_streak():
nonlocal current_start, current_end, current_streak
current_start = current_end = current_date
current_streak = 1
def increment_streak():
nonlocal current_end, current_streak
current_end = current_date
current_streak += 1
for i, datelist_item in enumerate(datelist, start=1):
current_date = datelist_item
if current_date == current_start or current_date == current_end:
continue
if current_date - timedelta(1) != current_end and i != datelist_length:
update_highest_streak()
reset_streak()
elif current_date - timedelta(1) == current_end and i == datelist_length:
increment_streak()
update_highest_streak()
else:
increment_streak()
return {"days": highest_streak, "dates": highest_streak_daterange}

62
common/utils.py Normal file
View File

@ -0,0 +1,62 @@
from datetime import date
from typing import Any, Generator, TypeVar
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
"""
Divides without triggering division by zero exception.
Returns 0 if denominator is 0.
"""
try:
return numerator / denominator
except ZeroDivisionError:
return 0
def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> object:
"""
Safely get the nested attribute from an object.
Parameters:
obj (object): The object from which to retrieve the attribute.
attr_chain (str): The chain of attributes, separated by dots.
default: The default value to return if any attribute in the chain does not exist.
Returns:
The value of the nested attribute if it exists, otherwise the default value.
"""
attrs = attr_chain.split(".")
for attr in attrs:
try:
obj = getattr(obj, attr)
except AttributeError:
return default
return obj
def truncate(input_string: str, length: int = 30, ellipsis: str = "") -> str:
return (
(f"{input_string[:length-len(ellipsis)]}{ellipsis}")
if len(input_string) > 30
else input_string
)
T = TypeVar("T", str, int, date)
def generate_split_ranges(
value_list: list[T], split_points: list[T]
) -> Generator[tuple[T, T], None, None]:
for x in range(0, len(split_points) + 1):
if x == 0:
start = 0
elif x >= len(split_points):
start = value_list.index(split_points[x - 1]) + 1
else:
start = value_list.index(split_points[x - 1]) + 1
try:
end = value_list.index(split_points[x])
except IndexError:
end = len(value_list)
yield (value_list[start], value_list[end - 1])

View File

@ -10,13 +10,14 @@ services:
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
user: "1000"
volumes:
- "static-files:/home/timetracker/app/static"
- "static-files:/var/www/django/static"
- "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3"
restart: unless-stopped
frontend:
image: caddy
volumes:
- "static-files:/usr/share/caddy"
- "static-files:/usr/share/caddy:ro"
- "$PWD/Caddyfile:/etc/caddy/Caddyfile"
ports:
- "8000:8000"
@ -26,3 +27,4 @@ services:
volumes:
static-files:

View File

@ -1,6 +1,6 @@
from django.contrib import admin
from games.models import Game, Platform, Purchase, Session, Edition, Device
from games.models import Device, Edition, Game, Platform, Purchase, Session
# Register your models here.
admin.site.register(Game)

View File

@ -1,12 +1,13 @@
from django import forms
from django.urls import reverse
from games.models import Game, Platform, Purchase, Session, Edition, Device
from common.utils import safe_getattr
from games.models import Device, Edition, Game, Platform, Purchase, Session
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"})
@ -15,10 +16,12 @@ class SessionForm(forms.ModelForm):
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
# )
purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__name"),
widget=autofocus_select_widget,
queryset=Purchase.objects.order_by("edition__sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
class Meta:
widgets = {
"timestamp_start": custom_datetime_widget,
@ -37,19 +40,51 @@ class SessionForm(forms.ModelForm):
class EditionChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.name} ({obj.platform}, {obj.year_released})"
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class IncludePlatformSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if platform_id := safe_getattr(value, "instance.platform.id"):
option["attrs"]["data-platform"] = platform_id
return option
class PurchaseForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/>
# to only include purchases of the selected edition.
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
self.fields["edition"].widget.attrs.update(
{
"hx-trigger": "load, click",
"hx-get": related_purchase_by_edition_url,
"hx-target": "#id_related_purchase",
"hx-swap": "outerHTML",
}
)
edition = EditionChoiceField(
queryset=Edition.objects.order_by("name"), widget=autofocus_select_widget
queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
)
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
"edition__sort_name"
),
required=False,
)
class Meta:
widgets = {
"date_purchased": custom_date_widget,
"date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
"date_dropped": custom_date_widget,
}
model = Purchase
fields = [
@ -57,34 +92,83 @@ class PurchaseForm(forms.ModelForm):
"platform",
"date_purchased",
"date_refunded",
"date_finished",
"date_dropped",
"infinite",
"price",
"price_currency",
"ownership_type",
"type",
"related_purchase",
"name",
]
def clean(self):
cleaned_data = super().clean()
purchase_type = cleaned_data.get("type")
related_purchase = cleaned_data.get("related_purchase")
name = cleaned_data.get("name")
# Set the type on the instance to use get_type_display()
# This is safe because we're not saving the instance.
self.instance.type = purchase_type
if purchase_type != Purchase.GAME:
type_display = self.instance.get_type_display()
if not related_purchase:
self.add_error(
"related_purchase",
f"{type_display} must have a related purchase.",
)
if not name:
self.add_error("name", f"{type_display} must have a name.")
return cleaned_data
class IncludeNameSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if value:
option["attrs"]["data-name"] = value.instance.name
option["attrs"]["data-year"] = value.instance.year_released
return option
class GameModelChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
# Use sort_name as the label for the option
return obj.sort_name
class EditionForm(forms.ModelForm):
game = forms.ModelChoiceField(
queryset=Game.objects.order_by("name"), widget=autofocus_select_widget
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
)
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False
)
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
class Meta:
model = Edition
fields = ["game", "name", "platform", "year_released", "wikidata"]
fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm):
class Meta:
model = Game
fields = ["name", "wikidata"]
fields = ["name", "sort_name", "year_released", "wikidata"]
widgets = {"name": autofocus_input_widget}
class PlatformForm(forms.ModelForm):
class Meta:
model = Platform
fields = ["name", "group"]
fields = [
"name",
"icon",
"group",
]
widgets = {"name": autofocus_input_widget}

View File

@ -0,0 +1 @@
from .game import Mutation as GameMutation

View File

@ -0,0 +1,29 @@
import graphene
from games.graphql.types import Game
from games.models import Game as GameModel
class UpdateGameMutation(graphene.Mutation):
class Arguments:
id = graphene.ID(required=True)
name = graphene.String()
year_released = graphene.Int()
wikidata = graphene.String()
game = graphene.Field(Game)
def mutate(self, info, id, name=None, year_released=None, wikidata=None):
game_instance = GameModel.objects.get(pk=id)
if name is not None:
game_instance.name = name
if year_released is not None:
game_instance.year_released = year_released
if wikidata is not None:
game_instance.wikidata = wikidata
game_instance.save()
return UpdateGameMutation(game=game_instance)
class Mutation(graphene.ObjectType):
update_game = UpdateGameMutation.Field()

View File

@ -0,0 +1,6 @@
from .device import Query as DeviceQuery
from .edition import Query as EditionQuery
from .game import Query as GameQuery
from .platform import Query as PlatformQuery
from .purchase import Query as PurchaseQuery
from .session import Query as SessionQuery

View File

@ -0,0 +1,11 @@
import graphene
from games.graphql.types import Device
from games.models import Device as DeviceModel
class Query(graphene.ObjectType):
devices = graphene.List(Device)
def resolve_devices(self, info, **kwargs):
return DeviceModel.objects.all()

View File

@ -0,0 +1,11 @@
import graphene
from games.graphql.types import Edition
from games.models import Game as EditionModel
class Query(graphene.ObjectType):
editions = graphene.List(Edition)
def resolve_editions(self, info, **kwargs):
return EditionModel.objects.all()

View File

@ -0,0 +1,18 @@
import graphene
from games.graphql.types import Game
from games.models import Game as GameModel
class Query(graphene.ObjectType):
games = graphene.List(Game)
game_by_name = graphene.Field(Game, name=graphene.String(required=True))
def resolve_games(self, info, **kwargs):
return GameModel.objects.all()
def resolve_game_by_name(self, info, name):
try:
return GameModel.objects.get(name=name)
except GameModel.DoesNotExist:
return None

View File

@ -0,0 +1,11 @@
import graphene
from games.graphql.types import Platform
from games.models import Platform as PlatformModel
class Query(graphene.ObjectType):
platforms = graphene.List(Platform)
def resolve_platforms(self, info, **kwargs):
return PlatformModel.objects.all()

View File

@ -0,0 +1,11 @@
import graphene
from games.graphql.types import Purchase
from games.models import Purchase as PurchaseModel
class Query(graphene.ObjectType):
purchases = graphene.List(Purchase)
def resolve_purchases(self, info, **kwargs):
return PurchaseModel.objects.all()

View File

@ -0,0 +1,11 @@
import graphene
from games.graphql.types import Session
from games.models import Session as SessionModel
class Query(graphene.ObjectType):
sessions = graphene.List(Session)
def resolve_sessions(self, info, **kwargs):
return SessionModel.objects.all()

44
games/graphql/types.py Normal file
View File

@ -0,0 +1,44 @@
from graphene_django import DjangoObjectType
from games.models import Device as DeviceModel
from games.models import Edition as EditionModel
from games.models import Game as GameModel
from games.models import Platform as PlatformModel
from games.models import Purchase as PurchaseModel
from games.models import Session as SessionModel
class Game(DjangoObjectType):
class Meta:
model = GameModel
fields = "__all__"
class Edition(DjangoObjectType):
class Meta:
model = EditionModel
fields = "__all__"
class Purchase(DjangoObjectType):
class Meta:
model = PurchaseModel
fields = "__all__"
class Session(DjangoObjectType):
class Meta:
model = SessionModel
fields = "__all__"
class Platform(DjangoObjectType):
class Meta:
model = PlatformModel
fields = "__all__"
class Device(DjangoObjectType):
class Meta:
model = DeviceModel
fields = "__all__"

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-01-19 18:30
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-02-18 16:29
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-02-18 19:06
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-02-18 19:59
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):

View File

@ -0,0 +1,51 @@
# Generated by Django 4.1.5 on 2023-11-06 11:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0015_edition_wikidata_edition_year_released"),
]
operations = [
migrations.AlterField(
model_name="edition",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.AlterField(
model_name="edition",
name="year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AlterField(
model_name="platform",
name="group",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
]

View File

@ -0,0 +1,141 @@
# Generated by Django 4.1.5 on 2023-11-06 18:14
import django.db.models.deletion
from django.db import migrations, models
def rename_duplicates(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
duplicates = (
Edition.objects.values("name", "platform")
.annotate(name_count=models.Count("id"))
.filter(name_count__gt=1)
)
for duplicate in duplicates:
counter = 1
duplicate_editions = Edition.objects.filter(
name=duplicate["name"], platform_id=duplicate["platform"]
).order_by("id")
for edition in duplicate_editions[1:]: # Skip the first one
edition.name = f"{edition.name} {counter}"
edition.save()
counter += 1
def update_game_year(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
for game in Game.objects.filter(year__isnull=True):
# Try to get the first related edition with a non-null year_released
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
if edition:
# If an edition is found, update the game's year
game.year = edition.year_released
game.save()
class Migration(migrations.Migration):
replaces = [
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
("games", "0017_alter_device_type_alter_purchase_platform"),
("games", "0018_auto_20231106_1825"),
("games", "0019_alter_edition_unique_together"),
("games", "0020_game_year"),
("games", "0021_auto_20231106_1909"),
("games", "0022_rename_year_game_year_released"),
]
dependencies = [
("games", "0015_edition_wikidata_edition_year_released"),
]
operations = [
migrations.AlterField(
model_name="edition",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.AlterField(
model_name="edition",
name="year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AlterField(
model_name="platform",
name="group",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
migrations.AlterField(
model_name="device",
name="type",
field=models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
("un", "Unknown"),
],
default="un",
max_length=3,
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.RunPython(
code=rename_duplicates,
),
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform")},
),
migrations.AddField(
model_name="game",
name="year",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.RunPython(
code=update_game_year,
),
migrations.RenameField(
model_name="game",
old_name="year",
new_name="year_released",
),
]

View File

@ -0,0 +1,41 @@
# Generated by Django 4.1.5 on 2023-11-06 16:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
]
operations = [
migrations.AlterField(
model_name="device",
name="type",
field=models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
("un", "Unknown"),
],
default="un",
max_length=3,
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 4.1.5 on 2023-11-06 17:25
from django.db import migrations, models
def rename_duplicates(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
duplicates = (
Edition.objects.values("name", "platform")
.annotate(name_count=models.Count("id"))
.filter(name_count__gt=1)
)
for duplicate in duplicates:
counter = 1
duplicate_editions = Edition.objects.filter(
name=duplicate["name"], platform_id=duplicate["platform"]
).order_by("id")
for edition in duplicate_editions[1:]: # Skip the first one
edition.name = f"{edition.name} {counter}"
edition.save()
counter += 1
class Migration(migrations.Migration):
dependencies = [
("games", "0017_alter_device_type_alter_purchase_platform"),
]
operations = [
migrations.RunPython(rename_duplicates),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.1.5 on 2023-11-06 17:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0018_auto_20231106_1825"),
]
operations = [
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform")},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-11-06 18:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0019_alter_edition_unique_together"),
]
operations = [
migrations.AddField(
model_name="game",
name="year",
field=models.IntegerField(blank=True, default=None, null=True),
),
]

View File

@ -0,0 +1,24 @@
from django.db import migrations
def update_game_year(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
for game in Game.objects.filter(year__isnull=True):
# Try to get the first related edition with a non-null year_released
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
if edition:
# If an edition is found, update the game's year
game.year = edition.year_released
game.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0020_game_year"),
]
operations = [
migrations.RunPython(update_game_year),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-11-06 18:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0021_auto_20231106_1909"),
]
operations = [
migrations.RenameField(
model_name="game",
old_name="year",
new_name="year_released",
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.1.5 on 2023-11-06 18:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"games",
"0016_alter_edition_platform_alter_edition_year_released_and_more_squashed_0022_rename_year_game_year_released",
),
]
operations = [
migrations.AddField(
model_name="purchase",
name="date_finished",
field=models.DateField(blank=True, null=True),
),
]

View File

@ -0,0 +1,39 @@
# Generated by Django 4.1.5 on 2023-11-09 09:32
from django.db import migrations, models
def create_sort_name(apps, schema_editor):
Edition = apps.get_model(
"games", "Edition"
) # Replace 'your_app_name' with the actual name of your app
for edition in Edition.objects.all():
name = edition.name
# Check for articles at the beginning of the name and move them to the end
if name.lower().startswith("the "):
sort_name = f"{name[4:]}, The"
elif name.lower().startswith("a "):
sort_name = f"{name[2:]}, A"
elif name.lower().startswith("an "):
sort_name = f"{name[3:]}, An"
else:
sort_name = name
# Save the sort_name back to the database
edition.sort_name = sort_name
edition.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0023_purchase_date_finished"),
]
operations = [
migrations.AddField(
model_name="edition",
name="sort_name",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.RunPython(create_sort_name),
]

View File

@ -0,0 +1,39 @@
# Generated by Django 4.1.5 on 2023-11-09 09:32
from django.db import migrations, models
def create_sort_name(apps, schema_editor):
Game = apps.get_model(
"games", "Game"
) # Replace 'your_app_name' with the actual name of your app
for game in Game.objects.all():
name = game.name
# Check for articles at the beginning of the name and move them to the end
if name.lower().startswith("the "):
sort_name = f"{name[4:]}, The"
elif name.lower().startswith("a "):
sort_name = f"{name[2:]}, A"
elif name.lower().startswith("an "):
sort_name = f"{name[3:]}, An"
else:
sort_name = name
# Save the sort_name back to the database
game.sort_name = sort_name
game.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0024_edition_sort_name"),
]
operations = [
migrations.AddField(
model_name="game",
name="sort_name",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.RunPython(create_sort_name),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 4.1.5 on 2023-11-14 08:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0025_game_sort_name"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="type",
field=models.CharField(
choices=[
("game", "Game"),
("dlc", "DLC"),
("season_pass", "Season Pass"),
("battle_pass", "Battle Pass"),
],
default="game",
max_length=255,
),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1.5 on 2023-11-14 08:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0026_purchase_type"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="games.purchase",
),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.1.5 on 2023-11-14 11:05
from django.db import migrations, models
from games.models import Purchase
def null_game_name(apps, schema_editor):
Purchase.objects.filter(type=Purchase.GAME).update(name=None)
class Migration(migrations.Migration):
dependencies = [
("games", "0027_purchase_related_purchase"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="name",
field=models.CharField(
blank=True, default="Unknown Name", max_length=255, null=True
),
),
migrations.RunPython(null_game_name),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.1.5 on 2023-11-14 21:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0028_purchase_name"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="related_purchases",
to="games.purchase",
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-11-15 12:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0029_alter_purchase_related_purchase"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="name",
field=models.CharField(blank=True, default="", max_length=255, null=True),
),
]

View File

@ -0,0 +1,44 @@
# Generated by Django 4.1.5 on 2023-11-15 13:51
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0030_alter_purchase_name"),
]
operations = [
migrations.AddField(
model_name="device",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="edition",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="game",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="platform",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="purchase",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="session",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

View File

@ -0,0 +1,52 @@
# Generated by Django 4.1.5 on 2023-11-15 18:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0031_device_created_at_edition_created_at_game_created_at_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="session",
options={"get_latest_by": "timestamp_start"},
),
migrations.AddField(
model_name="session",
name="modified_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="device",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="edition",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="game",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="platform",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="purchase",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="session",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.7 on 2023-11-28 13:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0032_alter_session_options_session_modified_at_and_more"),
]
operations = [
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform", "year_released")},
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.7 on 2024-01-03 21:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0033_alter_edition_unique_together"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="date_dropped",
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name="purchase",
name="infinite",
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 5.1 on 2024-08-11 15:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0034_purchase_date_dropped_purchase_infinite"),
]
operations = [
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="games.device",
),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.1 on 2024-08-11 16:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0035_alter_session_device'),
]
operations = [
migrations.AlterField(
model_name='edition',
name='platform',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform'),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 5.1.1 on 2024-09-14 07:05
from django.db import migrations, models
from django.utils.text import slugify
def update_empty_icons(apps, schema_editor):
Platform = apps.get_model("games", "Platform")
for platform in Platform.objects.filter(icon=""):
platform.icon = slugify(platform.name)
platform.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0036_alter_edition_platform"),
]
operations = [
migrations.AddField(
model_name="platform",
name="icon",
field=models.SlugField(blank=True),
),
migrations.RunPython(update_empty_icons),
]

View File

@ -1,30 +1,73 @@
from datetime import datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Sum
from django.template.defaultfilters import slugify
from django.utils import timezone
from common.time import format_duration
from django.conf import settings
from django.db import models
from django.db.models import F, Manager, Sum
class Game(models.Model):
name = models.CharField(max_length=255)
wikidata = models.CharField(max_length=50)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
session_average: float | int | timedelta | None
session_count: int | None
def __str__(self):
return self.name
class Platform(models.Model):
name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None)
icon = models.SlugField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.icon:
self.icon = slugify(self.name)
super().save(*args, **kwargs)
class Edition(models.Model):
game = models.ForeignKey("Game", on_delete=models.CASCADE)
class Meta:
unique_together = [["name", "platform", "year_released"]]
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)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
platform = models.ForeignKey(
Platform, on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
return self.sort_name
class PurchaseQueryset(models.QuerySet):
def refunded(self):
return self.filter(date_refunded__isnull=False)
def not_refunded(self):
return self.filter(date_refunded__isnull=True)
def finished(self):
return self.filter(date_finished__isnull=False)
def games_only(self):
return self.filter(type=Purchase.GAME)
class Purchase(models.Model):
@ -46,30 +89,69 @@ class Purchase(models.Model):
(DEMO, "Demo"),
(PIRATED, "Pirated"),
]
GAME = "game"
DLC = "dlc"
SEASONPASS = "season_pass"
BATTLEPASS = "battle_pass"
TYPES = [
(GAME, "Game"),
(DLC, "DLC"),
(SEASONPASS, "Season Pass"),
(BATTLEPASS, "Battle Pass"),
]
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
objects = PurchaseQueryset().as_manager()
edition = models.ForeignKey(Edition, on_delete=models.CASCADE)
platform = models.ForeignKey(
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
)
date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True)
date_finished = models.DateField(blank=True, null=True)
date_dropped = models.DateField(blank=True, null=True)
infinite = models.BooleanField(default=False)
price = models.IntegerField(default=0)
price_currency = models.CharField(max_length=3, default="USD")
ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
)
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, default="", null=True, blank=True)
related_purchase = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
default=None,
null=True,
blank=True,
related_name="related_purchases",
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
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()})"
additional_info = [
self.get_type_display() if self.type != Purchase.GAME else "",
(
f"{self.edition.platform} version on {self.platform}"
if self.platform != self.edition.platform
else self.platform
),
self.edition.year_released,
self.get_ownership_type_display(),
]
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
def is_game(self):
return self.type == self.GAME
class Platform(models.Model):
name = models.CharField(max_length=255)
group = models.CharField(max_length=255)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if self.type == Purchase.GAME:
self.name = ""
elif self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)
super().save(*args, **kwargs)
class SessionQuerySet(models.QuerySet):
@ -82,32 +164,56 @@ class SessionQuerySet(models.QuerySet):
)
return result["duration"]
def calculated_duration_formatted(self):
return format_duration(self.calculated_duration_unformatted())
def calculated_duration_unformatted(self):
result = self.aggregate(duration=Sum(F("duration_calculated")))
return result["duration"]
def without_manual(self):
return self.exclude(duration_calculated__iexact=0)
def only_manual(self):
return self.filter(duration_calculated__iexact=0)
class Session(models.Model):
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
class Meta:
get_latest_by = "timestamp_start"
purchase = models.ForeignKey(Purchase, on_delete=models.CASCADE)
timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
duration_calculated = models.DurationField(blank=True, null=True)
device = models.ForeignKey("Device", on_delete=models.CASCADE, null=True)
device = models.ForeignKey(
"Device",
on_delete=models.SET_DEFAULT,
null=True,
blank=True,
default=None,
)
note = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
objects = SessionQuerySet.as_manager()
def __str__(self):
mark = ", manual" if self.duration_manual != None else ""
mark = ", manual" if self.is_manual() else ""
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self):
self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
self.timestamp_end = timezone.now()
def start_now():
self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE))
self.timestamp_start = timezone.now()
def duration_seconds(self) -> timedelta:
manual = timedelta(0)
calculated = timedelta(0)
if not self.duration_manual in (None, 0, timedelta(0)):
if self.is_manual() and isinstance(self.duration_manual, timedelta):
manual = self.duration_manual
if self.timestamp_end != None and self.timestamp_start != None:
calculated = self.timestamp_end - self.timestamp_start
@ -117,15 +223,27 @@ class Session(models.Model):
result = format_duration(self.duration_seconds(), "%02.0H:%02.0m")
return result
def is_manual(self) -> bool:
return not self.duration_manual == timedelta(0)
@property
def duration_sum(self) -> str:
return Session.objects.all().total_duration_formatted()
def save(self, *args, **kwargs):
def save(self, *args, **kwargs) -> None:
if self.timestamp_start != None and self.timestamp_end != None:
self.duration_calculated = self.timestamp_end - self.timestamp_start
else:
self.duration_calculated = timedelta(0)
if not isinstance(self.duration_manual, timedelta):
self.duration_manual = timedelta(0)
if not self.device:
default_device, _ = Device.objects.get_or_create(
type=Device.UNKNOWN, defaults={"name": "Unknown"}
)
self.device = default_device
super(Session, self).save(*args, **kwargs)
@ -135,15 +253,18 @@ class Device(models.Model):
HANDHELD = "ha"
MOBILE = "mo"
SBC = "sbc"
UNKNOWN = "un"
DEVICE_TYPES = [
(PC, "PC"),
(CONSOLE, "Console"),
(HANDHELD, "Handheld"),
(MOBILE, "Mobile"),
(SBC, "Single-board computer"),
(UNKNOWN, "Unknown"),
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=PC)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name} ({self.get_type_display()})"

30
games/schema.py Normal file
View File

@ -0,0 +1,30 @@
import graphene
from games.graphql.mutations import GameMutation
from games.graphql.queries import (
DeviceQuery,
EditionQuery,
GameQuery,
PlatformQuery,
PurchaseQuery,
SessionQuery,
)
class Query(
GameQuery,
EditionQuery,
DeviceQuery,
PlatformQuery,
PurchaseQuery,
SessionQuery,
graphene.ObjectType,
):
pass
class Mutation(GameMutation, graphene.ObjectType):
pass
schema = graphene.Schema(query=Query, mutation=Mutation)

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@ -1,29 +1,24 @@
/**
* @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);
}
import { syncSelectInputUntilChanged } from "./utils.js";
sourceElement.addEventListener("change", sourceElementHandler);
targetElement.addEventListener("focus", targetElementHandler);
}
let syncData = [
{
source: "#id_game",
source_value: "dataset.name",
target: "#id_name",
target_value: "value",
},
{
source: "#id_game",
source_value: "textContent",
target: "#id_sort_name",
target_value: "value",
},
{
source: "#id_game",
source_value: "dataset.year",
target: "#id_year_released",
target_value: "value",
},
];
window.addEventListener("load", () => {
syncSelectInputUntilChanged("#id_game", "#id_name");
});
syncSelectInputUntilChanged(syncData, "form");

View File

@ -0,0 +1,12 @@
import { syncSelectInputUntilChanged } from "./utils.js";
let syncData = [
{
source: "#id_name",
source_value: "value",
target: "#id_sort_name",
target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form");

View File

@ -0,0 +1,47 @@
import {
syncSelectInputUntilChanged,
getEl,
disableElementsWhenTrue,
disableElementsWhenValueNotEqual,
} from "./utils.js";
let syncData = [
{
source: "#id_edition",
source_value: "dataset.platform",
target: "#id_platform",
target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form");
function setupElementHandlers() {
disableElementsWhenTrue("#id_type", "game", [
"#id_name",
"#id_related_purchase",
]);
disableElementsWhenValueNotEqual(
"#id_type",
["game", "dlc"],
["#id_date_finished"]
);
}
document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").onchange = () => {
setupElementHandlers();
};
document.body.addEventListener("htmx:beforeRequest", function (event) {
// Assuming 'Purchase1' is the element that triggers the HTMX request
if (event.target.id === "id_edition") {
var idEditionValue = document.getElementById("id_edition").value;
// Condition to check - replace this with your actual logic
if (idEditionValue != "") {
event.preventDefault(); // This cancels the HTMX request
}
}
});

View File

@ -7,10 +7,14 @@ for (let button of document.querySelectorAll("[data-target]")) {
button.addEventListener("click", (event) => {
event.preventDefault();
if (type == "now") {
targetElement.value = toISOUTCString(new Date);
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;
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";

View File

@ -3,7 +3,7 @@
* @param {Date} date
* @returns {string}
*/
export function toISOUTCString(date) {
function toISOUTCString(date) {
function stringAndPad(number) {
return number.toString().padStart(2, 0);
}
@ -14,3 +14,194 @@ export function toISOUTCString(date) {
const minutes = stringAndPad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
/**
* @description Sync values between source and target elements based on syncData configuration.
* @param {Array} syncData - Array of objects to define source and target elements with their respective value types.
*/
function syncSelectInputUntilChanged(syncData, parentSelector = document) {
const parentElement =
parentSelector === document
? document
: document.querySelector(parentSelector);
if (!parentElement) {
console.error(`The parent selector "${parentSelector}" is not valid.`);
return;
}
// Set up a single change event listener on the document for handling all source changes
parentElement.addEventListener("change", function (event) {
// Loop through each sync configuration item
syncData.forEach((syncItem) => {
// Check if the change event target matches the source selector
if (event.target.matches(syncItem.source)) {
const sourceElement = event.target;
const valueToSync = getValueFromProperty(
sourceElement,
syncItem.source_value
);
const targetElement = document.querySelector(syncItem.target);
if (targetElement && valueToSync !== null) {
targetElement[syncItem.target_value] = valueToSync;
}
}
});
});
// Set up a single focus event listener on the document for handling all target focuses
parentElement.addEventListener(
"focus",
function (event) {
// Loop through each sync configuration item
syncData.forEach((syncItem) => {
// Check if the focus event target matches the target selector
if (event.target.matches(syncItem.target)) {
// Remove the change event listener to stop syncing
// This assumes you want to stop syncing once any target receives focus
// You may need a more sophisticated way to remove listeners if you want to stop
// syncing selectively based on other conditions
document.removeEventListener("change", syncSelectInputUntilChanged);
}
});
},
true
); // Use capture phase to ensure the event is captured during focus, not bubble
}
/**
* @description Retrieve the value from the source element based on the provided property.
* @param {Element} sourceElement - The source HTML element.
* @param {string} property - The property to retrieve the value from.
*/
function getValueFromProperty(sourceElement, property) {
let source =
sourceElement instanceof HTMLSelectElement
? sourceElement.selectedOptions[0]
: sourceElement;
if (property.startsWith("dataset.")) {
let datasetKey = property.slice(8); // Remove 'dataset.' part
return source.dataset[datasetKey];
} else if (property in source) {
return source[property];
} else {
console.error(`Property ${property} is not valid for the option element.`);
return null;
}
}
/**
* @description Returns a single element by name.
* @param {string} selector The selector to look for.
*/
function getEl(selector) {
if (selector.startsWith("#")) {
return document.getElementById(selector.slice(1));
} else if (selector.startsWith(".")) {
return document.getElementsByClassName(selector);
} else {
return document.getElementsByTagName(selector);
}
}
/**
* @description Applies different behaviors to elements based on multiple conditional configurations.
* Each configuration is an array containing a condition function, an array of target element selectors,
* and two callback functions for handling matched and unmatched conditions.
* @param {...Array} configs Each configuration is an array of the form:
* - 0: {function(): boolean} condition - Function that returns true or false based on a condition.
* - 1: {string[]} targetElements - Array of CSS selectors for target elements.
* - 2: {function(HTMLElement): void} callbackfn1 - Function to execute when condition is true.
* - 3: {function(HTMLElement): void} callbackfn2 - Function to execute when condition is false.
*/
function conditionalElementHandler(...configs) {
configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
if (condition()) {
targetElements.forEach((elementName) => {
let el = getEl(elementName);
if (el === null) {
console.error(`Element ${elementName} doesn't exist.`);
} else {
callbackfn1(el);
}
});
} else {
targetElements.forEach((elementName) => {
let el = getEl(elementName);
if (el === null) {
console.error(`Element ${elementName} doesn't exist.`);
} else {
callbackfn2(el);
}
});
}
});
}
function disableElementsWhenValueNotEqual(
targetSelect,
targetValue,
elementList
) {
return conditionalElementHandler([
() => {
let target = getEl(targetSelect);
console.debug(
`${disableElementsWhenTrue.name}: triggered on ${target.id}`
);
console.debug(`
${disableElementsWhenTrue.name}: matching against value(s): ${targetValue}`);
if (targetValue instanceof Array) {
if (targetValue.every((value) => target.value != value)) {
console.debug(
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
);
return true;
}
} else {
console.debug(
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
);
return target.value != targetValue;
}
},
elementList,
(el) => {
console.debug(
`${disableElementsWhenTrue.name}: evaluated true, disabling ${el.id}.`
);
el.disabled = "disabled";
},
(el) => {
console.debug(
`${disableElementsWhenTrue.name}: evaluated false, NOT disabling ${el.id}.`
);
el.disabled = "";
},
]);
}
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
return getEl(targetSelect).value == targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
export {
toISOUTCString,
syncSelectInputUntilChanged,
getEl,
conditionalElementHandler,
disableElementsWhenValueNotEqual,
disableElementsWhenTrue,
getValueFromProperty,
};

View File

@ -1,17 +1,24 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% load static %}
{% 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>
<td>
<input type="submit" value="Submit" />
</td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,22 +1,32 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% load static %}
{% 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>
<td>
<input type="submit" name="submit" value="Submit" />
</td>
</tr>
<tr>
<td></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Purchase" />
</td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% load static %}
<script type="module" src="{% static 'js/add_edition.js' %}"></script>
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% load static %}
{% 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" name="submit" value="Submit" />
</td>
</tr>
<tr>
<td></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Edition" />
</td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% load static %}
{% 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" name="submit" value="Submit" />
</td>
</tr>
<tr>
<td></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Session" />
</td>
</tr>
{% if purchase_id %}
<tr>
<td></td>
<td>
<a href="{% url 'delete_purchase' purchase_id %}"
class="text-red-600"
onclick="return confirm('Are you sure you want to delete this purchase?');">Delete</a>
</td>
</tr>
{% endif %}
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,12 +1,11 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% 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>
@ -17,9 +16,11 @@
{% endif %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<td>
<div class="basic-button-container">
<div class="basic-button-container" hx-boost="false">
<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="toggle">Toggle text</button>
<button class="basic-button" data-target="{{ field.name }}" data-type="copy">Copy</button>
</div>
</td>
@ -28,7 +29,9 @@
{% endfor %}
<tr>
<td></td>
<td><input type="submit" value="Submit"/></td>
<td>
<input type="submit" value="Submit" />
</td>
</tr>
</table>
</form>

View File

@ -1,54 +1,90 @@
<!doctype html>
{% load django_htmx %}
<!DOCTYPE html>
<html lang="en">
{% load static %}
<head>
<meta charset="utf-8" />
<meta name="description" content="Self-hosted time-tracker." />
<meta name="keywords" content="time, tracking, video games, self-hosted" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Timetracker - {% block title %}Untitled{% endblock title %}</title>
<title>Timetracker -
{% block title %}
Untitled
{% endblock title %}
</title>
<script src="{% static 'js/htmx.min.js' %}"></script>
{% django_htmx_script %}
<link rel="stylesheet" href="{% static 'base.css' %}" />
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
<script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark')
}
</script>
</head>
<body 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"><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">
<ul
class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li>
{% if game_available and platform_available %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_edition' %}">New Edition</a></li>
{% endif %}
{% if edition_available %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
{% endif %}
{% if purchase_available %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_device' %}">New Device</a></li>
{% endif %}
{% if session_count > 0 %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
{% block content %}No content here.{% endblock content %}
<body hx-indicator="#indicator">
<img id="indicator"
src="{% static 'icons/loading.png' %}"
class="absolute right-3 top-3 animate-spin htmx-indicator"
height="24"
width="24"
alt="loading indicator" />
<div class="flex flex-col min-h-screen">
{% include "navbar.html" %}
<div class="flex flex-1 flex-col dark:bg-gray-800 pt-8">
{% block content %}
No content here.
{% endblock content %}
</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>
</div>
{% block scripts %}
{% endblock scripts %}
<script>
var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
// Change the icons inside the button based on previous settings
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
themeToggleLightIcon.classList.remove('hidden');
} else {
themeToggleDarkIcon.classList.remove('hidden');
}
var themeToggleBtn = document.getElementById('theme-toggle');
themeToggleBtn.addEventListener('click', function () {
// toggle icons inside button
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
// if set via local storage previously
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
// if NOT set via local storage previously
} else {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
}
});
</script>
</body>
</html>

View File

@ -1,26 +0,0 @@
{% comment %}
title
text
{% endcomment %}
<a
href="{{ link }}"
title="{{ title }}"
class="truncate max-w-xs py-1 px-2 text-xs 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 font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm"
>
{% comment %} <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>
{% endcomment %}
{{ text }}
</a>

View File

@ -1,26 +0,0 @@
{% comment %}
title
text
{% endcomment %}
<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>

View File

@ -1,21 +0,0 @@
<a href="{{ edit_url }}">
<button
type="button"
title="Edit"
class="ml-1 py-1 px-2 flex justify-center items-center bg-violet-600 hover:bg-violet-700 focus:ring-violet-500 focus:ring-offset-violet-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>

View File

@ -0,0 +1,6 @@
<c-vars color="blue" size="base" />
<button type="button"
title="{{ title }}"
class=" {% if color == "blue" %} bg-blue-700 dark:bg-blue-600 dark:focus:ring-blue-800 dark:hover:bg-blue-700 focus:ring-blue-300 hover:bg-blue-800 text-white {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} focus:outline-none focus:ring-4 font-medium mb-2 me-2 rounded-lg {% if size == "xs" %} px-3 py-2 text-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} ">
{{ slot }}
</button>

View File

@ -0,0 +1,8 @@
<div class="inline-flex rounded-md shadow-sm" role="group">
{% if slot %}{{ slot }}{% endif %}
{% for button in buttons %}
{% if button.slot %}
<c-button-group-button-sm :href=button.href :slot=button.slot :color=button.color :hover=button.hover :title=button.title />
{% endif %}
{% endfor %}
</div>

View File

@ -0,0 +1,23 @@
<c-vars color="gray" />
<a href="{{ href }}"
class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg">
{% if color == "gray" %}
<button type="button"
title="{{ title }}"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
{{ slot }}
</button>
{% elif color == "red" %}
<button type="button"
title="{{ title }}"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
{{ slot }}
</button>
{% elif color == "green" %}
<button type="button"
title="{{ title }}"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white">
{{ slot }}
</button>
{% endif %}
</a>

View File

@ -0,0 +1,13 @@
{% comment %}
title
text
{% endcomment %}
<a href="{{ link }}"
title="{{ title }}"
class="truncate max-w-xs py-1 px-2 text-xs 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 font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm">
{% comment %} <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>
{% endcomment %}
{{ text }}
</a>

View File

@ -0,0 +1,18 @@
{% comment %}
title
text
{% endcomment %}
<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>

View File

@ -0,0 +1,10 @@
<span class="truncate-container">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'view_game' game_id %}">
{% if slot %}
{{ slot }}
{% else %}
{{ name }}
{% endif %}
</a>
</span>

View File

@ -0,0 +1,8 @@
<h1 class="{% if badge %}flex items-center {% endif %}mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white">
{{ slot }}
{% if badge %}
<span class="bg-blue-100 text-blue-800 text-2xl font-semibold me-2 px-2.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800 ms-2">
{{ badge }}
</span>
{% endif %}
</h1>

View File

@ -0,0 +1,5 @@
<c-svg title="Battle.net">
<c-slot name="path">
M 43.113281 22.152344 C 43.113281 22.152344 47.058594 22.351563 47.058594 20.03125 C 47.058594 16.996094 41.804688 14.261719 41.804688 14.261719 C 41.804688 14.261719 42.628906 12.515625 43.140625 11.539063 C 43.65625 10.5625 45.101563 6.753906 45.230469 5.886719 C 45.394531 4.792969 45.144531 4.449219 45.144531 4.449219 C 44.789063 6.792969 40.972656 13.539063 40.671875 13.769531 C 36.949219 12.023438 31.835938 11.539063 31.835938 11.539063 C 31.835938 11.539063 26.832031 1 22.125 1 C 17.457031 1 17.480469 10.023438 17.480469 10.023438 C 17.480469 10.023438 16.160156 7.464844 14.507813 7.464844 C 12.085938 7.464844 11.292969 11.128906 11.292969 15.097656 C 6.511719 15.097656 2.492188 16.164063 2.132813 16.265625 C 1.773438 16.371094 0.644531 17.191406 1.15625 17.089844 C 2.203125 16.753906 7.113281 15.992188 11.410156 16.367188 C 11.648438 20.140625 13.851563 25.054688 13.851563 25.054688 C 13.851563 25.054688 9.128906 31.894531 9.128906 36.78125 C 9.128906 38.066406 9.6875 40.417969 13.078125 40.417969 C 15.917969 40.417969 19.105469 38.710938 19.707031 38.363281 C 19.183594 39.113281 18.796875 40.535156 18.796875 41.191406 C 18.796875 41.726563 19.113281 43.246094 21.304688 43.246094 C 24.117188 43.246094 27.257813 41.089844 27.257813 41.089844 C 27.257813 41.089844 30.222656 46.019531 32.761719 48.28125 C 33.445313 48.890625 34.097656 49 34.097656 49 C 34.097656 49 31.578125 46.574219 28.257813 40.324219 C 31.34375 38.417969 34.554688 33.921875 34.554688 33.921875 C 34.554688 33.921875 34.933594 33.933594 37.863281 33.933594 C 42.453125 33.933594 48.972656 32.96875 48.972656 29.320313 C 48.972656 25.554688 43.113281 22.152344 43.113281 22.152344 Z M 43.625 19.886719 C 43.625 21.21875 42.359375 21.199219 42.359375 21.199219 L 41.394531 21.265625 C 41.394531 21.265625 39.566406 20.304688 38.460938 19.855469 C 38.460938 19.855469 40.175781 17.207031 40.578125 16.46875 C 40.882813 16.644531 43.625 18.363281 43.625 19.886719 Z M 24.421875 6.308594 C 26.578125 6.308594 29.65625 11.402344 29.65625 11.402344 C 29.65625 11.402344 24.851563 10.972656 20.898438 13.296875 C 21.003906 9.628906 22.238281 6.308594 24.421875 6.308594 Z M 15.871094 10.4375 C 16.558594 10.4375 17.230469 11.269531 17.507813 11.976563 C 17.507813 12.445313 17.75 15.171875 17.75 15.171875 L 13.789063 15.023438 C 13.789063 11.449219 15.1875 10.4375 15.871094 10.4375 Z M 15.464844 35.246094 C 13.300781 35.246094 12.851563 34.039063 12.851563 32.953125 C 12.851563 30.496094 14.8125 27.058594 14.8125 27.058594 C 14.8125 27.058594 17.011719 31.683594 20.851563 33.636719 C 18.945313 34.753906 17.375 35.246094 15.464844 35.246094 Z M 22.492188 40.089844 C 20.972656 40.089844 20.789063 39.105469 20.789063 38.878906 C 20.789063 38.171875 21.339844 37.335938 21.339844 37.335938 C 21.339844 37.335938 23.890625 35.613281 24.054688 35.429688 L 25.9375 38.945313 C 25.9375 38.945313 24.007813 40.089844 22.492188 40.089844 Z M 27.226563 38.171875 C 26.300781 36.554688 25.621094 34.867188 25.621094 34.867188 C 25.621094 34.867188 29.414063 35.113281 31.453125 33.007813 C 30.183594 33.578125 28.15625 34.300781 25.800781 34.082031 C 30.726563 29.742188 33.601563 26.597656 36.03125 23.34375 C 35.824219 23.09375 34.710938 22.316406 34.4375 22.1875 C 32.972656 23.953125 27.265625 30.054688 21.984375 33.074219 C 15.292969 29.425781 13.890625 18.691406 13.746094 16.460938 L 17.402344 16.8125 C 17.402344 16.8125 16.027344 19.246094 16.027344 21.039063 C 16.027344 22.828125 16.242188 22.925781 16.242188 22.925781 C 16.242188 22.925781 16.195313 19.800781 18.125 17.390625 C 19.59375 25.210938 21.125 29.21875 22.320313 31.605469 C 22.925781 31.355469 24.058594 30.851563 24.058594 30.851563 C 24.058594 30.851563 20.683594 21.121094 20.871094 14.535156 C 22.402344 13.71875 24.667969 12.875 27.226563 12.875 C 33.957031 12.875 39.367188 15.773438 39.367188 15.773438 L 37.25 18.730469 C 37.25 18.730469 35.363281 15.3125 32.699219 14.703125 C 34.105469 15.753906 35.679688 17.136719 36.496094 19.128906 C 30.917969 16.949219 24.1875 15.796875 22.027344 15.542969 C 21.839844 16.339844 21.863281 17.480469 21.863281 17.480469 C 21.863281 17.480469 30.890625 19.144531 37.460938 22.90625 C 37.414063 31.125 28.460938 37.4375 27.226563 38.171875 Z M 35.777344 32.027344 C 35.777344 32.027344 38.578125 28.347656 38.535156 23.476563 C 38.535156 23.476563 43.0625 26.28125 43.0625 29.015625 C 43.0625 32.074219 35.777344 32.027344 35.777344 32.027344 Z
</c-slot>
</c-svg>

View File

@ -0,0 +1,5 @@
<c-svg viewBox="0 0 20 20">
<c-slot name="path">
M2.069,11 L5,11 L5,9 L2.069,9 C2.252,7.542 2.828,6.208 3.688,5.102 L5.757,7.172 L7.171,5.757 L5.102,3.688 C6.208,2.828 8,2.252 9,2.069 L9,5 L11,5 L11,2.069 C12,2.252 13.791,2.828 14.897,3.688 L12.828,5.757 L14.242,7.172 L16.311,5.102 C17.171,6.208 17.747,7.542 17.93,9 L15,9 L15,11 L17.93,11 C17.747,12.458 17.171,13.792 16.311,14.898 L14.242,12.828 L12.828,14.243 L14.897,16.312 C13.791,17.172 12,17.748 11,17.931 L11,15 L9,15 L9,17.931 C8,17.748 6.208,17.172 5.102,16.312 L7.171,14.243 L5.757,12.828 L3.688,14.898 C2.828,13.792 2.252,12.458 2.069,11 M10,0 C4.477,0 0,4.477 0,10 C0,15.523 4.477,20 10,20 C15.522,20 20,15.523 20,10 C20,4.477 15.522,0 10,0
</c-slot>
</c-svg>

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
viewBox="0 0 48 48"
class="text-black dark:text-white w-4 h-4">
<path fill="currentColor" d="M 43.470703 8.9863281 A 1.50015 1.50015 0 0 0 42.439453 9.4394531 L 16.5 35.378906 L 5.5605469 24.439453 A 1.50015 1.50015 0 1 0 3.4394531 26.560547 L 15.439453 38.560547 A 1.50015 1.50015 0 0 0 17.560547 38.560547 L 44.560547 11.560547 A 1.50015 1.50015 0 0 0 43.470703 8.9863281 z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 477 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
viewBox="0 0 48 48"
class="text-black dark:text-white w-4 h-4">
<path fill="currentColor" d="M 24 4 C 20.491685 4 17.570396 6.6214322 17.080078 10 L 10.238281 10 A 1.50015 1.50015 0 0 0 9.9804688 9.9785156 A 1.50015 1.50015 0 0 0 9.7578125 10 L 6.5 10 A 1.50015 1.50015 0 1 0 6.5 13 L 8.6386719 13 L 11.15625 39.029297 C 11.427329 41.835926 13.811782 44 16.630859 44 L 31.367188 44 C 34.186411 44 36.570826 41.836168 36.841797 39.029297 L 39.361328 13 L 41.5 13 A 1.50015 1.50015 0 1 0 41.5 10 L 38.244141 10 A 1.50015 1.50015 0 0 0 37.763672 10 L 30.919922 10 C 30.429604 6.6214322 27.508315 4 24 4 z M 24 7 C 25.879156 7 27.420767 8.2681608 27.861328 10 L 20.138672 10 C 20.579233 8.2681608 22.120844 7 24 7 z M 11.650391 13 L 36.347656 13 L 33.855469 38.740234 C 33.730439 40.035363 32.667963 41 31.367188 41 L 16.630859 41 C 15.331937 41 14.267499 40.033606 14.142578 38.740234 L 11.650391 13 z M 20.476562 17.978516 A 1.50015 1.50015 0 0 0 19 19.5 L 19 34.5 A 1.50015 1.50015 0 1 0 22 34.5 L 22 19.5 A 1.50015 1.50015 0 0 0 20.476562 17.978516 z M 27.476562 17.978516 A 1.50015 1.50015 0 0 0 26 19.5 L 26 34.5 A 1.50015 1.50015 0 1 0 29 34.5 L 29 19.5 A 1.50015 1.50015 0 0 0 27.476562 17.978516 z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,9 @@
<c-svg viewbox="0 0 50 50">
<g transform="scale(0.09765625)">
<title>EA/Origin</title>
<g>
<path fill="currentColor" d="M299.125,126.274H126.628L97.876,183.93h172.499L299.125,126.274z" />
<path fill="currentColor" d="M342.248,126.274L224.462,328.066h-105.8l32.862-57.653h61.347l28.758-57.658H69.125l-28.746,57.658H85.31L26.001,385.727h232.784l83.463-153.654l18.169,38.342h-18.169l-28.75,57.654h75.67l28.75,57.658h68.081L342.248,126.274z" />
</g>
</g>
</c-svg>

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
viewBox="0 0 48 48"
class="text-black dark:text-white w-4 h-4">
<path fill="currentColor" d="M 40.5 6 C 40.11625 6 39.732453 6.1464531 39.439453 6.4394531 L 21.462891 24.417969 L 20 28 L 23.582031 26.537109 L 41.560547 8.5605469 C 42.145547 7.9745469 42.145547 7.0254531 41.560547 6.4394531 C 41.267547 6.1464531 40.88375 6 40.5 6 z M 12.5 7 C 9.4802259 7 7 9.4802259 7 12.5 L 7 35.5 C 7 38.519774 9.4802259 41 12.5 41 L 35.5 41 C 38.519774 41 41 38.519774 41 35.5 L 41 18.5 A 1.50015 1.50015 0 1 0 38 18.5 L 38 35.5 C 38 36.898226 36.898226 38 35.5 38 L 12.5 38 C 11.101774 38 10 36.898226 10 35.5 L 10 12.5 C 10 11.101774 11.101774 10 12.5 10 L 29.5 10 A 1.50015 1.50015 0 1 0 29.5 7 L 12.5 7 z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 798 B

View File

@ -0,0 +1,6 @@
<c-vars title="Epic Games Store" />
<c-svg :title=title viewbox="0 0 50 50">
<c-slot name="path">
M 10 3 C 6.69 3 4 5.69 4 9 L 4 41.240234 L 25 47.539062 L 46 41.240234 L 46 9 C 46 5.69 43.31 3 40 3 L 10 3 z M 11 8 L 15 8 L 15 11 L 11 11 L 11 18 L 14 18 L 14 21 L 11 21 L 11 28 L 15 28 L 15 31 L 11 31 C 9.34 31 8 29.66 8 28 L 8 11 C 8 9.34 9.34 8 11 8 z M 17 8 L 23 8 C 24.66 8 26 9.34 26 11 L 26 18 C 26 19.66 24.66 21 23 21 L 20 21 L 20 31 L 17 31 L 17 8 z M 28 8 L 31 8 L 31 31 L 28 31 L 28 8 z M 36 8 L 39 8 C 40.66 8 42 9.34 42 11 L 42 15 L 39 15 L 39 11 L 36 11 L 36 28 L 39 28 L 39 24 L 42 24 L 42 28 C 42 29.66 40.66 31 39 31 L 36 31 C 34.34 31 33 29.66 33 28 L 33 11 C 33 9.34 34.34 8 36 8 z M 20 11 L 20 18 L 23 18 L 23 11 L 20 11 z M 9 34 L 13 34 C 13.55 34 14 34.45 14 35 L 14 36 L 13 36 L 13 35.25 C 13 35.11 12.89 35 12.75 35 L 9.25 35 C 9.11 35 9 35.11 9 35.25 L 9 38.75 C 9 38.89 9.11 39 9.25 39 L 12.75 39 C 12.89 39 13 38.89 13 38.75 L 13 38 L 12 38 L 12 37 L 14 37 L 14 39 C 14 39.55 13.55 40 13 40 L 9 40 C 8.45 40 8 39.55 8 39 L 8 35 C 8 34.45 8.45 34 9 34 z M 18 34 L 19 34 L 22 40 L 21 40 L 20.5 39 L 16.5 39 L 16 40 L 15 40 L 18 34 z M 23 34 L 24 34 L 26 38 L 28 34 L 29 34 L 29 40 L 28 40 L 28 36 L 26.5 39 L 25.5 39 L 24 36 L 24 40 L 23 40 L 23 34 z M 30 34 L 35 34 L 35 35 L 31 35 L 31 36.5 L 33 36.5 L 33 37.5 L 31 37.5 L 31 39 L 35 39 L 35 40 L 30 40 L 30 34 z M 37 34 L 41 34 C 41.55 34 42 34.45 42 35 L 42 35.5 L 41 35.5 L 41 35.25 C 41 35.11 40.89 35 40.75 35 L 37.25 35 C 37.11 35 37 35.11 37 35.25 L 37 36.25 C 37 36.39 37.11 36.5 37.25 36.5 L 41 36.5 C 41.55 36.5 42 36.95 42 37.5 L 42 39 C 42 39.55 41.55 40 41 40 L 37 40 C 36.45 40 36 39.55 36 39 L 36 38.5 L 37 38.5 L 37 38.75 C 37 38.89 37.11 39 37.25 39 L 40.75 39 C 40.89 39 41 38.89 41 38.75 L 41 37.75 C 41 37.61 40.89 37.5 40.75 37.5 L 37 37.5 C 36.45 37.5 36 37.05 36 36.5 L 36 35 C 36 34.45 36.45 34 37 34 z M 18.5 35 L 17 38 L 20 38 L 18.5 35 z
</c-slot>
</c-svg>

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
viewBox="0 0 48 48"
class="text-black dark:text-white w-4 h-4">
<path fill="currentColor" d="M 24 5.0507812 C 22.945045 5.0507812 21.890232 5.4877258 21.160156 6.3613281 L 7.0566406 23.257812 C 5.2226244 25.45627 6.8812774 29 9.7441406 29 L 38.255859 29 C 41.119312 29 42.778426 25.453888 40.943359 23.255859 L 26.839844 6.3613281 C 26.109768 5.4877258 25.054955 5.0507812 24 5.0507812 z M 24 8.015625 C 24.194045 8.015625 24.387185 8.105759 24.537109 8.2851562 L 38.638672 25.179688 C 38.977605 25.585659 38.784407 26 38.255859 26 L 9.7441406 26 C 9.2150038 26 9.0213443 25.58723 9.3613281 25.179688 L 23.462891 8.2851562 C 23.612815 8.1057586 23.805955 8.015625 24 8.015625 z M 10.5 33 C 8.0324991 33 6 35.032499 6 37.5 L 6 38.5 C 6 40.967501 8.0324991 43 10.5 43 L 37.5 43 C 39.967501 43 42 40.967501 42 38.5 L 42 37.5 C 42 35.032499 39.967501 33 37.5 33 L 10.5 33 z M 10.5 36 L 37.5 36 C 38.346499 36 39 36.653501 39 37.5 L 39 38.5 C 39 39.346499 38.346499 40 37.5 40 L 10.5 40 C 9.6535009 40 9 39.346499 9 38.5 L 9 37.5 C 9 36.653501 9.6535009 36 10.5 36 z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
viewBox="0 0 48 48"
class="text-black dark:text-white w-4 h-4">
<path fill="currentColor" d="M 35.5 6 C 33.585045 6 32 7.5850452 32 9.5 L 32 19.365234 L 11.339844 6.6074219 C 9.0734225 5.2081236 6 6.9228749 6 9.5859375 L 6 38.414062 C 6 41.077126 9.0734225 42.791876 11.339844 41.392578 L 32 28.634766 L 32 38.5 C 32 40.414955 33.585045 42 35.5 42 L 38.5 42 C 40.414955 42 42 40.414955 42 38.5 L 42 9.5 C 42 7.5850452 40.414955 6 38.5 6 L 35.5 6 z M 35.5 9 L 38.5 9 C 38.795045 9 39 9.2049548 39 9.5 L 39 38.5 C 39 38.795045 38.795045 39 38.5 39 L 35.5 39 C 35.204955 39 35 38.795045 35 38.5 L 35 9.5 C 35 9.2049548 35.204955 9 35.5 9 z M 9.4765625 9.0566406 C 9.5668015 9.0647233 9.6637771 9.0984812 9.7636719 9.1601562 L 32 22.892578 L 32 25.107422 L 9.7636719 38.839844 C 9.364093 39.086546 9 38.883001 9 38.414062 L 9 9.5859375 C 9 9.3514688 9.091623 9.1841053 9.2324219 9.1054688 C 9.3028213 9.0661502 9.3863235 9.048558 9.4765625 9.0566406 z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
viewBox="0 0 48 48"
class="text-black dark:text-white w-4 h-4">
<path fill="currentColor" d="M 7.4765625 4.9785156 A 1.50015 1.50015 0 0 0 6 6.5 L 6 31.5 L 6 43.5 A 1.50015 1.50015 0 1 0 9 43.5 L 9 33 L 40.5 33 C 41.329 33 42 32.328 42 31.5 L 42 6.5 C 42 5.672 41.329 5 40.5 5 L 7.7460938 5 A 1.50015 1.50015 0 0 0 7.4765625 4.9785156 z M 16 8 L 24 8 L 24 15 L 32 15 L 32 8 L 39 8 L 39 15 L 32 15 L 32 23 L 39 23 L 39 30 L 32 30 L 32 23 L 24 23 L 24 30 L 16 30 L 16 23 L 9 23 L 9 15 L 16 15 L 16 8 z M 16 15 L 16 23 L 24 23 L 24 15 L 16 15 z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 643 B

View File

@ -0,0 +1,5 @@
<c-svg title="GOG.com" viewbox="0 0 50 50">
<c-slot name="path">
M 5.75 6 C 3.703125 6 2 7.703125 2 9.75 L 2 16.25 C 2 18.296875 3.703125 20 5.75 20 L 12 20 L 12 22 L 4 22 C 3.277344 21.988281 2.609375 22.367188 2.246094 22.992188 C 1.878906 23.613281 1.878906 24.386719 2.246094 25.007813 C 2.609375 25.632813 3.277344 26.011719 4 26 L 12.25 26 C 14.296875 26 16 24.296875 16 22.25 L 16 15.75 C 16.003906 15.6875 16.003906 15.625 16 15.5625 L 16 9.75 C 16 7.703125 14.296875 6 12.25 6 Z M 21.75 6 C 19.703125 6 18 7.703125 18 9.75 L 18 16.25 C 18 18.296875 19.703125 20 21.75 20 L 28.25 20 C 30.296875 20 32 18.296875 32 16.25 L 32 9.75 C 32 7.703125 30.296875 6 28.25 6 Z M 37.75 6 C 35.703125 6 34 7.703125 34 9.75 L 34 16.25 C 34 18.296875 35.703125 20 37.75 20 L 44 20 L 44 22 L 36 22 C 35.277344 21.988281 34.609375 22.367188 34.246094 22.992188 C 33.878906 23.613281 33.878906 24.386719 34.246094 25.007813 C 34.609375 25.632813 35.277344 26.011719 36 26 L 44.25 26 C 46.296875 26 48 24.296875 48 22.25 L 48 15.75 C 48.003906 15.6875 48.003906 15.625 48 15.5625 L 48 9.75 C 48 7.703125 46.296875 6 44.25 6 Z M 6 10 L 12 10 L 12 15.59375 C 11.996094 15.644531 11.996094 15.699219 12 15.75 L 12 16 L 6 16 Z M 22 10 L 28 10 L 28 16 L 22 16 Z M 38 10 L 44 10 L 44 15.59375 C 43.996094 15.644531 43.996094 15.699219 44 15.75 L 44 16 L 38 16 Z M 5.75 30 C 3.703125 30 2 31.703125 2 33.75 L 2 40.25 C 2 42.296875 3.703125 44 5.75 44 L 12 44 C 12.722656 44.011719 13.390625 43.632813 13.753906 43.007813 C 14.121094 42.386719 14.121094 41.613281 13.753906 40.992188 C 13.390625 40.367188 12.722656 39.988281 12 40 L 6 40 L 6 34 L 12 34 C 12.722656 34.011719 13.390625 33.632813 13.753906 33.007813 C 14.121094 32.386719 14.121094 31.613281 13.753906 30.992188 C 13.390625 30.367188 12.722656 29.988281 12 30 Z M 19.75 30 C 17.703125 30 16 31.703125 16 33.75 L 16 40.25 C 16 42.296875 17.703125 44 19.75 44 L 26.25 44 C 28.296875 44 29.996094 42.296875 30 40.25 L 30 33.75 C 30 31.703125 28.296875 30 26.25 30 Z M 38.65625 30 C 38.65625 30 37.933594 30 37.15625 30.03125 C 36.769531 30.046875 36.355469 30.066406 36 30.09375 C 35.824219 30.105469 35.667969 30.136719 35.5 30.15625 C 35.332031 30.175781 35.242188 30.152344 34.8125 30.3125 C 33.738281 30.714844 32.972656 31.429688 32.4375 32.4375 C 32.5 32.320313 32.355469 32.496094 32.21875 32.90625 C 32.082031 33.316406 32.078125 33.566406 32.0625 33.875 C 32.03125 34.496094 32.011719 35.507813 32 37.8125 C 31.992188 39.167969 32 40.203125 32 40.90625 C 32 41.257813 31.996094 41.515625 32 41.71875 C 32 41.820313 31.996094 41.914063 32 42 C 32 42.042969 31.992188 42.085938 32 42.15625 C 32.007813 42.226563 31.914063 42.171875 32.125 42.71875 C 32.453125 43.707031 33.484375 44.277344 34.492188 44.035156 C 35.503906 43.789063 36.160156 42.808594 36 41.78125 C 36 41.777344 36 41.722656 36 41.71875 C 36 41.703125 36 41.707031 36 41.6875 C 35.996094 41.527344 36 41.25 36 40.90625 C 36 40.21875 35.992188 39.199219 36 37.84375 C 36.011719 35.640625 36.011719 34.671875 36.03125 34.25 C 36.101563 34.183594 36.167969 34.117188 36.1875 34.09375 C 36.230469 34.089844 36.257813 34.097656 36.3125 34.09375 C 36.585938 34.074219 36.949219 34.046875 37.3125 34.03125 C 37.667969 34.015625 37.75 34.003906 38 34 L 38 42 C 37.988281 42.722656 38.367188 43.390625 38.992188 43.753906 C 39.613281 44.121094 40.386719 44.121094 41.007813 43.753906 C 41.632813 43.390625 42.011719 42.722656 42 42 L 42 34 C 42.800781 34 43.28125 34 44 34 L 44 42 C 43.988281 42.722656 44.367188 43.390625 44.992188 43.753906 C 45.613281 44.121094 46.386719 44.121094 47.007813 43.753906 C 47.632813 43.390625 48.011719 42.722656 48 42 L 48 30 L 46 30 C 46 30 39.59375 29.996094 38.6875 30 Z M 20 34 L 26 34 L 26 40 L 20 40 Z
</c-slot>
</c-svg>

View File

@ -0,0 +1,6 @@
<c-svg title="Itch.io" viewBox="0 0 245.371 220.736" preserveAspectRatio="xMidYMid meet">
<c-slot name="path">
M31.99 1.365C21.287 7.72.2 31.945 0 38.298v10.516C0 62.144 12.46 73.86 23.773 73.86c13.584 0 24.902-11.258 24.903-24.62 0 13.362 10.93 24.62 24.515 24.62 13.586 0 24.165-11.258 24.165-24.62 0 13.362 11.622 24.62 25.207 24.62h.246c13.586 0 25.208-11.258 25.208-24.62 0 13.362 10.58 24.62 24.164 24.62 13.585 0 24.515-11.258 24.515-24.62 0 13.362 11.32 24.62 24.903 24.62 11.313 0 23.773-11.714 23.773-25.046V38.298c-.2-6.354-21.287-30.58-31.988-36.933C180.118.197 157.056-.005 122.685 0c-34.37.003-81.228.54-90.697 1.365zm65.194 66.217a28.025 28.025 0 0 1-4.78 6.155c-5.128 5.014-12.157 8.122-19.906 8.122a28.482 28.482 0 0 1-19.948-8.126c-1.858-1.82-3.27-3.766-4.563-6.032l-.006.004c-1.292 2.27-3.092 4.215-4.954 6.037a28.5 28.5 0 0 1-19.948 8.12c-.934 0-1.906-.258-2.692-.528-1.092 11.372-1.553 22.24-1.716 30.164l-.002.045c-.02 4.024-.04 7.333-.06 11.93.21 23.86-2.363 77.334 10.52 90.473 19.964 4.655 56.7 6.775 93.555 6.788h.006c36.854-.013 73.59-2.133 93.554-6.788 12.883-13.14 10.31-66.614 10.52-90.474-.022-4.596-.04-7.905-.06-11.93l-.003-.045c-.162-7.926-.623-18.793-1.715-30.165-.786.27-1.757.528-2.692.528a28.5 28.5 0 0 1-19.948-8.12c-1.862-1.822-3.662-3.766-4.955-6.037l-.006-.004c-1.294 2.266-2.705 4.213-4.563 6.032a28.48 28.48 0 0 1-19.947 8.125c-7.748 0-14.778-3.11-19.906-8.123a28.025 28.025 0 0 1-4.78-6.155 27.99 27.99 0 0 1-4.736 6.155 28.49 28.49 0 0 1-19.95 8.124c-.27 0-.54-.012-.81-.02h-.007c-.27.008-.54.02-.813.02a28.49 28.49 0 0 1-19.95-8.123 27.992 27.992 0 0 1-4.736-6.155zm-20.486 26.49l-.002.01h.015c8.113.017 15.32 0 24.25 9.746 7.028-.737 14.372-1.105 21.722-1.094h.006c7.35-.01 14.694.357 21.723 1.094 8.93-9.747 16.137-9.73 24.25-9.746h.014l-.002-.01c3.833 0 19.166 0 29.85 30.007L210 165.244c8.504 30.624-2.723 31.373-16.727 31.4-20.768-.773-32.267-15.855-32.267-30.935-11.496 1.884-24.907 2.826-38.318 2.827h-.006c-13.412 0-26.823-.943-38.318-2.827 0 15.08-11.5 30.162-32.267 30.935-14.004-.027-25.23-.775-16.726-31.4L46.85 124.08C57.534 94.073 72.867 94.073 76.7 94.073zm45.985 23.582v.006c-.02.02-21.863 20.08-25.79 27.215l14.304-.573v12.474c0 .584 5.74.346 11.486.08h.006c5.744.266 11.485.504 11.485-.08v-12.474l14.304.573c-3.928-7.135-25.79-27.215-25.79-27.215v-.006l-.003.002z
</c-slot>
</c-svg>
{% comment %} <svg xmlns="http://www.w3.org/2000/svg" height="235.452" width="261.728" viewBox="0 0 245.371 220.736"><path d="" color="#000" /></svg> {% endcomment %}

View File

@ -0,0 +1,5 @@
<c-svg title="Microsoft Store" viewbox="0 0 30 30">
<c-slot name="path">
M 6 4 C 4.895 4 4 4.895 4 6 L 4 12 C 4 13.105 4.895 14 6 14 L 12 14 C 13.105 14 14 13.105 14 12 L 14 6 C 14 4.895 13.105 4 12 4 L 6 4 z M 18 4 C 16.895 4 16 4.895 16 6 L 16 12 C 16 13.105 16.895 14 18 14 L 24 14 C 25.105 14 26 13.105 26 12 L 26 6 C 26 4.895 25.105 4 24 4 L 18 4 z M 6 16 C 4.895 16 4 16.895 4 18 L 4 24 C 4 25.105 4.895 26 6 26 L 12 26 C 13.105 26 14 25.105 14 24 L 14 18 C 14 16.895 13.105 16 12 16 L 6 16 z M 18 16 C 16.895 16 16 16.895 16 18 L 16 24 C 16 25.105 16.895 26 18 26 L 24 26 C 25.105 26 26 25.105 26 24 L 26 18 C 26 16.895 25.105 16 24 16 L 18 16 z
</c-slot>
</c-svg>

View File

@ -0,0 +1 @@
<c-icon.nintendo />

View File

@ -0,0 +1,5 @@
<c-svg title="Nintendo Switch" viewbox="0 0 32 32">
<c-slot name="path">
M18.901 32h4.901c4.5 0 8.198-3.698 8.198-8.198v-15.604c0-4.5-3.698-8.198-8.198-8.198h-5c-0.099 0-0.203 0.099-0.203 0.198v31.604c0 0.099 0.099 0.198 0.302 0.198zM25 14.401c1.802 0 3.198 1.5 3.198 3.198 0 1.802-1.5 3.198-3.198 3.198-1.802 0-3.198-1.396-3.198-3.198-0.104-1.797 1.396-3.198 3.198-3.198zM15.198 0h-7c-4.5 0-8.198 3.698-8.198 8.198v15.604c0 4.5 3.698 8.198 8.198 8.198h7c0.099 0 0.203-0.099 0.203-0.198v-31.604c0-0.099-0.099-0.198-0.203-0.198zM12.901 29.401h-4.703c-3.099 0-5.599-2.5-5.599-5.599v-15.604c0-3.099 2.5-5.599 5.599-5.599h4.604zM5 9.599c0 1.698 1.302 3 3 3s3-1.302 3-3c0-1.698-1.302-3-3-3s-3 1.302-3 3z
</c-slot>
</c-svg>

View File

@ -0,0 +1,6 @@
<c-vars title="Nintendo" />
<c-svg viewBox="0 0 24 24">
<c-slot name="path">
M0 .6h7.1l9.85 15.9V.6H24v22.8h-7.04L7.06 7.5v15.9H0V.6
</c-slot>
</c-svg>

View File

@ -0,0 +1,5 @@
<c-svg viewbox="0 0 512 512">
<title>Physical Media</title>
<path fill="currentColor" d="M277.333,256c0-11.755-9.557-21.333-21.333-21.333s-21.333,9.579-21.333,21.333c0,11.755,9.557,21.333,21.333,21.333 S277.333,267.755,277.333,256z" />
<path fill="currentColor" d="M256,0C114.837,0,0,114.837,0,256s114.837,256,256,256s256-114.837,256-256S397.163,0,256,0z M128,256 c0,11.776-9.536,21.333-21.333,21.333c-11.797,0-21.333-9.557-21.333-21.333c0-94.101,76.565-170.667,170.667-170.667 c11.797,0,21.333,9.557,21.333,21.333S267.797,128,256,128C185.408,128,128,185.408,128,256z M192,256c0-35.285,28.715-64,64-64 s64,28.715,64,64s-28.715,64-64,64S192,291.285,192,256z M256,426.667c-11.797,0-21.333-9.557-21.333-21.333S244.203,384,256,384 c70.592,0,128-57.408,128-128c0-11.776,9.536-21.333,21.333-21.333s21.333,9.557,21.333,21.333 C426.667,350.101,350.101,426.667,256,426.667z" />
</c-svg>

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
viewBox="0 0 48 48"
class="text-black dark:text-white w-4 h-4">
<path fill="currentColor" d="M 11.396484 4.1113281 C 9.1042001 4.2020187 7 6.0721788 7 8.5917969 L 7 39.408203 C 7 42.767694 10.742758 44.971891 13.681641 43.34375 L 41.490234 27.935547 C 44.513674 26.260259 44.513674 21.739741 41.490234 20.064453 L 13.681641 4.65625 C 12.94692 4.2492148 12.160579 4.0810979 11.396484 4.1113281 z M 11.431641 7.0664062 C 11.690234 7.0652962 11.961284 7.1323321 12.226562 7.2792969 L 40.037109 22.6875 C 41.13567 23.296212 41.13567 24.703788 40.037109 25.3125 L 12.226562 40.720703 C 11.165446 41.308562 10 40.620712 10 39.408203 L 10 8.5917969 C 10 7.9855423 10.290709 7.5116121 10.714844 7.2617188 C 10.926911 7.136772 11.173048 7.0675163 11.431641 7.0664062 z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 861 B

Some files were not shown because too many files have changed in this diff Show More