Compare commits

..

16 Commits

Author SHA1 Message Date
c1230c0c86 new page and nav system
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-03 21:03:52 +03:00
65e029a417 separate css into separate file
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-02 23:20:44 +03:00
13ace49e71 remember to update session
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-02 21:49:29 +03:00
c84600ae3e fix hltb worker
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-02 21:23:00 +03:00
f820e86277 steam sync
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-02 21:11:54 +03:00
457b8c8443 added user profile page
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-02 20:06:38 +03:00
b723214c86 Add completion animation and ding sound on Done button
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Intercepts the Done form submit, plays a two-note ding via Web Audio
API (no audio file), animates the card with a white glow + scale, then
submits after 680ms. Only fires for completion, not abandon or delete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:59:09 +03:00
b289e7c7fe Refactor item form visibility to data-attribute driven JS
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Replace hardcoded id-based section lookup with declarative rules:
- data-show-category="games|books|films" on sections
- data-hide-status="unending" on individual fields

JS now has a single updateVisibility() that evaluates attributes.
Adding new conditions only requires touching HTML, not JS.

Also hides Progress and Total Hours for unending items.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:55:47 +03:00
23eefc269b Fix migration graph: restore missing 0006 file, add 0008 cleanup
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
0007_merge referenced 0006_item_category_unending which git had
renamed, causing NodeNotFoundError on clean DBs. Restores the file
so both 0006 leaves exist, then 0008 reverts the stale category
choices back to the correct set (no unending in category).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:46:20 +03:00
05235d234e Add merge migration for conflicting 0006 leaves
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
0006_item_category_unending was already applied to the dev DB before
the rename commit, causing a conflict with 0006_item_status_unending.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:38:22 +03:00
2e0ca22dd9 Fix unending: shelf status, not category
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Unending belongs in STATUS_CHOICES alongside completed/abandoned.
Adds an Unending shelf tab and an ∞ button on active game cards.
Reverts the incorrect category addition from the previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:32:09 +03:00
b765067c5b Add unending category for session-based games
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
For games like Dota 2 that have no completion state — shows hours
played on the card, reuses the games fields in the form (hours played
/ total hours), and gets its own cyan badge. No DB column change,
only choices metadata update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:25:41 +03:00
da11a056ed Add completed/abandoned shelves with status transitions
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Items can be moved to Completed or Abandoned via card buttons.
Only active items appear in the default/category tabs; completed and
abandoned items are visible only in their respective shelf tabs.
Restore button moves items back to active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:19:10 +03:00
095614cb65 Fix howlongtobeatpy version constraint (max is 1.0.21)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-31 23:06:45 +03:00
ffcd8c40b4 Add HowLongToBeat estimates to game cards
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Fetch HLTB main/extra/completionist hours when a game item is saved
- Re-fetch only when name or category changes on edit
- Steam imports also fetch HLTB for each selected game
- Cards show compact HLTB row: "HLTB: 40h · +extra 60h · 100% 100h"
- Edit form shows HLTB breakdown as a hint next to Total hours field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:04:36 +03:00
a4c31bf40b Add Steam library import via OpenID
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Steam OpenID flow: user authenticates with Steam, we get their Steam ID
- Server-side API key fetches their owned games with playtime
- Import page shows full library, marks already-imported games
- Imported games land in backlog as GAMES items with hours_played set
- STEAM_API_KEY env var plumbed into both prod and dev containers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 22:38:28 +03:00
34 changed files with 1504 additions and 591 deletions

119
CLAUDE.md Normal file
View File

@@ -0,0 +1,119 @@
# k-boris website — Claude context
Django project serving **k-boris.tech** and **killmybacklog.com**.
## Project layout
```
website/
backlogger/ # Main app: game/book/movie backlog tracker
core/ # Landing page and shared views
dailystone/ # Daily mineral fact feature
kboris/ # Django project settings & URLs
nginx/ # Nginx site configs (for reference; deployed on VPS)
Dockerfile
entrypoint.sh # Runs migrate + loaddata, then gunicorn on :8080
requirements.txt
.woodpecker.yml
```
- Database: SQLite at `/data/db.sqlite3` (volume-mounted on VPS, not committed)
- Static files: collected to `staticfiles/`, served by WhiteNoise
- Auth env vars: `DJANGO_SECRET_KEY`, `STEAM_API_KEY`
## CI/CD — Woodpecker
Woodpecker CI runs at **https://ci.k-boris.tech** (Gitea OAuth, admin: boris).
Pipeline defined in `.woodpecker.yml`:
1. **test** step: runs `python manage.py test backlogger` (all branches)
2. **build-and-deploy** (main only): builds `k-boris-website` image, restarts `django` container
3. **build-and-deploy-dev** (dev only): builds `k-boris-website-dev` image, restarts `django-dev` container
Push to `main` → production. Push to `dev` → staging (DEBUG=true, port 8081).
The agent shares the host Docker socket, so it builds directly on the VPS.
## VPS access & Docker logs
```bash
ssh boris@46.202.143.107 # key: ~/.ssh/id_ed25519
# Production logs
ssh boris@46.202.143.107 "docker logs django --tail=50"
ssh boris@46.202.143.107 "docker logs django -f"
# Dev/staging logs
ssh boris@46.202.143.107 "docker logs django-dev --tail=50"
# Woodpecker / Gitea logs
ssh boris@46.202.143.107 "docker logs woodpecker-agent --tail=50"
ssh boris@46.202.143.107 "docker logs gitea --tail=30"
# All services
ssh boris@46.202.143.107 "docker ps"
ssh boris@46.202.143.107 "cd /opt/services && docker compose ps"
```
Compose file lives at `/opt/services/docker-compose.yml` on the VPS.
## Running locally with Docker (preferred)
Avoid setting up a Python venv locally — use Docker instead for quick checks:
```bash
cd website
# Build
docker build --build-arg DJANGO_SECRET_KEY=local-dev-key -t k-boris-local .
# Run (no persistent data)
docker run --rm -p 8080:8080 \
-e DJANGO_SECRET_KEY=local-dev-key \
-e STEAM_API_KEY=your_key_here \
-e DEBUG=true \
k-boris-local
# Run with a persistent local DB volume
docker run --rm -p 8080:8080 \
-e DJANGO_SECRET_KEY=local-dev-key \
-e DEBUG=true \
-v "$(pwd)/.local-data:/data" \
k-boris-local
```
Then open http://localhost:8080.
## Running tests locally
Tests don't need a running server — use the Docker image or a venv:
```bash
# Via Docker (no setup needed)
docker run --rm \
-e DJANGO_SECRET_KEY=test-key \
k-boris-local \
python manage.py test backlogger --verbosity=2
# Or directly if you have a venv active
python manage.py test backlogger
```
Tests live in `backlogger/tests.py` only. The CI only tests the `backlogger` app.
## Key URLs
| Environment | URL | Container | Port |
|---|---|---|---|
| Production | https://k-boris.tech / https://killmybacklog.com | `django` | 8080 |
| Staging/dev | https://debug.killmybacklog.com | `django-dev` | 8081 |
| Gitea | https://git.k-boris.tech | `gitea` | 3000 |
| Woodpecker | https://ci.k-boris.tech | `woodpecker-server` | 8000 |
## Workflow
1. Develop locally, test with Docker
2. Push to `dev` → CI runs tests + deploys to staging automatically
3. Check staging at https://debug.killmybacklog.com
4. Merge/push to `main` → CI deploys to production
5. Tail production logs via SSH if something looks wrong

View File

@@ -4,3 +4,14 @@ from django.apps import AppConfig
class BackloggerConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'backlogger'
def ready(self):
from django.contrib.auth.signals import user_logged_in
def set_theme_on_login(sender, request, user, **kwargs):
try:
request.session['theme'] = user.profile.theme
except Exception:
pass
user_logged_in.connect(set_theme_on_login)

View File

@@ -1,7 +1,7 @@
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
from .models import Item
from .models import Item, UserProfile
class SignupForm(UserCreationForm):
@@ -54,3 +54,16 @@ class ItemForm(forms.ModelForm):
cleaned_data['watched'] = None
cleaned_data['duration_minutes'] = None
return cleaned_data
class ProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['display_name', 'theme']
widgets = {
'display_name': forms.TextInput(attrs={'placeholder': 'Your full name (optional)'}),
}
labels = {
'display_name': 'Display name',
'theme': 'Theme',
}

50
backlogger/hltb.py Normal file
View File

@@ -0,0 +1,50 @@
from howlongtobeatpy import HowLongToBeat
def _h(val):
"""Return float hours if valid, else None."""
try:
v = float(val)
return v if v > 0 else None
except (TypeError, ValueError):
return None
def fetch(game_name):
"""
Search HowLongToBeat for game_name.
Returns dict with keys 'main', 'extra', 'complete' (each float or None),
or None if nothing found / on any error.
"""
try:
results = HowLongToBeat().search(game_name)
except Exception:
return None
if not results:
return None
best = max(results, key=lambda r: r.similarity)
if best.similarity < 0.4:
return None
return {
'main': _h(best.main_story),
'extra': _h(best.main_extra),
'complete': _h(best.completionist),
}
def apply_to_item(item):
"""Fetch HLTB data and save it onto item. Always marks hltb_fetched=True."""
if item.category != 'games' or not item.name:
return
data = fetch(item.name)
fields = ['hltb_fetched']
item.hltb_fetched = True
if data is not None:
item.hltb_main = data['main']
item.hltb_extra = data['extra']
item.hltb_complete = data['complete']
fields += ['hltb_main', 'hltb_extra', 'hltb_complete']
item.save(update_fields=fields)

View File

View File

@@ -0,0 +1,42 @@
import time
import logging
from django.core.management.base import BaseCommand
from django.utils import timezone
from datetime import timedelta
from backlogger.models import Item
from backlogger import hltb as hltb_api
logger = logging.getLogger(__name__)
INITIAL_DELAY_SECONDS = 20
BETWEEN_LOOKUPS_SECONDS = 5
IDLE_POLL_SECONDS = 10
class Command(BaseCommand):
help = 'Background worker: fetches HLTB data for newly created game items.'
def handle(self, *args, **options):
self.stdout.write('HLTB worker started.')
while True:
cutoff = timezone.now() - timedelta(seconds=INITIAL_DELAY_SECONDS)
item = (
Item.objects
.filter(category=Item.GAMES, hltb_fetched=False, created_at__lte=cutoff)
.order_by('created_at')
.first()
)
if item is None:
time.sleep(IDLE_POLL_SECONDS)
continue
self.stdout.write(f'Fetching HLTB for: {item.name} (id={item.pk})')
try:
hltb_api.apply_to_item(item)
except Exception as e:
logger.exception('HLTB lookup failed for item %s', item.pk)
# Mark as fetched anyway to avoid retrying indefinitely
item.hltb_fetched = True
item.save(update_fields=['hltb_fetched'])
time.sleep(BETWEEN_LOOKUPS_SECONDS)

View File

@@ -0,0 +1,26 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('backlogger', '0003_assign_existing_items_to_first_user'),
]
operations = [
migrations.AddField(
model_name='item',
name='hltb_main',
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name='item',
name='hltb_extra',
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name='item',
name='hltb_complete',
field=models.FloatField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('backlogger', '0004_item_hltb_fields'),
]
operations = [
migrations.AddField(
model_name='item',
name='status',
field=models.CharField(
choices=[('active', 'Active'), ('completed', 'Completed'), ('abandoned', 'Abandoned')],
default='active',
max_length=10,
),
),
]

View File

@@ -0,0 +1,25 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('backlogger', '0005_item_status'),
]
operations = [
migrations.AlterField(
model_name='item',
name='category',
field=models.CharField(
choices=[
('games', 'Games'),
('books', 'Books'),
('films', 'Films'),
('unending', 'Unending'),
('other', 'Other'),
],
max_length=10,
),
),
]

View File

@@ -0,0 +1,25 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('backlogger', '0005_item_status'),
]
operations = [
migrations.AlterField(
model_name='item',
name='status',
field=models.CharField(
choices=[
('active', 'Active'),
('completed', 'Completed'),
('abandoned', 'Abandoned'),
('unending', 'Unending'),
],
default='active',
max_length=10,
),
),
]

View File

@@ -0,0 +1,12 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('backlogger', '0006_item_category_unending'),
('backlogger', '0006_item_status_unending'),
]
operations = [
]

View File

@@ -0,0 +1,24 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('backlogger', '0007_merge'),
]
operations = [
migrations.AlterField(
model_name='item',
name='category',
field=models.CharField(
choices=[
('games', 'Games'),
('books', 'Books'),
('films', 'Films'),
('other', 'Other'),
],
max_length=10,
),
),
]

View File

@@ -0,0 +1,23 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('backlogger', '0008_fix_category_choices'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('display_name', models.CharField(blank=True, max_length=100)),
('theme', models.CharField(choices=[('dark', 'Dark'), ('light', 'Light')], default='dark', max_length=10)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('backlogger', '0009_userprofile'),
]
operations = [
migrations.AddField(
model_name='item',
name='steam_appid',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('backlogger', '0010_item_steam_appid'),
]
operations = [
migrations.AddField(
model_name='item',
name='hltb_fetched',
field=models.BooleanField(default=False),
),
]

View File

@@ -2,6 +2,19 @@ from django.contrib.auth.models import User
from django.db import models
class UserProfile(models.Model):
DARK = 'dark'
LIGHT = 'light'
THEME_CHOICES = [(DARK, 'Dark'), (LIGHT, 'Light')]
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
display_name = models.CharField(max_length=100, blank=True)
theme = models.CharField(max_length=10, choices=THEME_CHOICES, default=DARK)
def __str__(self):
return f"Profile({self.user.username})"
class Item(models.Model):
GAMES = 'games'
BOOKS = 'books'
@@ -14,9 +27,21 @@ class Item(models.Model):
(OTHER, 'Other'),
]
ACTIVE = 'active'
COMPLETED = 'completed'
ABANDONED = 'abandoned'
UNENDING = 'unending'
STATUS_CHOICES = [
(ACTIVE, 'Active'),
(COMPLETED, 'Completed'),
(ABANDONED, 'Abandoned'),
(UNENDING, 'Unending'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='items', null=True)
category = models.CharField(max_length=10, choices=CATEGORY_CHOICES)
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=ACTIVE)
name = models.CharField(max_length=200)
progress_percent = models.FloatField(default=0.0)
favorite = models.BooleanField(default=False)
@@ -35,6 +60,15 @@ class Item(models.Model):
watched = models.BooleanField(null=True, blank=True)
duration_minutes = models.IntegerField(null=True, blank=True)
# Steam
steam_appid = models.IntegerField(null=True, blank=True)
# HowLongToBeat estimates (games only)
hltb_main = models.FloatField(null=True, blank=True)
hltb_extra = models.FloatField(null=True, blank=True)
hltb_complete = models.FloatField(null=True, blank=True)
hltb_fetched = models.BooleanField(default=False)
class Meta:
ordering = ['-favorite', 'name']

View File

@@ -0,0 +1,105 @@
/* ── Reset ─────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Base ──────────────────────────────────────────────────────── */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
/* ── Site header ───────────────────────────────────────────────── */
.site-header {
background: #0a0f1e;
border-bottom: 1px solid #1e293b;
padding: 0.75rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.site-header .brand { color: #38bdf8; font-weight: 600; font-size: 0.95rem; }
.site-header nav { display: flex; gap: 1.5rem; align-items: center; }
.site-header nav a { color: #64748b; font-size: 0.85rem; }
.site-header nav a:hover { color: #e2e8f0; }
.site-header nav a.nav-active { color: #e2e8f0; font-weight: 500; }
/* ── Buttons ───────────────────────────────────────────────────── */
.btn {
display: inline-block;
padding: 0.45rem 1rem;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
border: none;
transition: opacity 0.15s;
font-family: inherit;
}
.btn:hover { opacity: 0.85; }
.btn-primary { background: #38bdf8; color: #0f172a; font-weight: 600; }
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.78rem; }
.btn-outline { background: transparent; color: #94a3b8; border: 1px solid #334155; }
.btn-danger { background: transparent; color: #f87171; border: 1px solid #f87171; padding: 0.3rem 0.65rem; font-size: 0.78rem; }
.btn-danger:hover { background: #f87171; color: #0f172a; opacity: 1; }
.btn-done { background: transparent; color: #34d399; border: 1px solid #34d399; padding: 0.3rem 0.65rem; font-size: 0.78rem; }
.btn-done:hover { background: #34d399; color: #0f172a; opacity: 1; }
.btn-abandon { background: transparent; color: #fb923c; border: 1px solid #fb923c; padding: 0.3rem 0.65rem; font-size: 0.78rem; }
.btn-abandon:hover { background: #fb923c; color: #0f172a; opacity: 1; }
.btn-ghost { background: transparent; color: #64748b; border: 1px solid #334155; }
.btn-ghost:hover { color: #e2e8f0; opacity: 1; }
.btn-text { background: none; border: none; color: #64748b; font-size: 0.8rem; cursor: pointer; font-family: inherit; padding: 0; }
.btn-text:hover { color: #e2e8f0; }
/* ── Form fields ───────────────────────────────────────────────── */
.field { margin-bottom: 1.1rem; }
.field label {
display: block;
font-size: 0.78rem;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.07em;
margin-bottom: 0.4rem;
}
.field input[type="text"],
.field input[type="number"],
.field input[type="password"],
.field select {
width: 100%;
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
color: #e2e8f0;
padding: 0.6rem 0.75rem;
font-size: 0.9rem;
font-family: inherit;
}
.field input:focus,
.field select:focus { outline: none; border-color: #38bdf8; }
/* ── Errors ────────────────────────────────────────────────────── */
.errorlist { list-style: none; color: #f87171; font-size: 0.8rem; margin-top: 0.3rem; }
.error-banner {
background: #450a0a;
border: 1px solid #f87171;
border-radius: 8px;
color: #fca5a5;
padding: 0.85rem 1.1rem;
margin-bottom: 1.5rem;
}
/* ── Light theme ───────────────────────────────────────────────── */
body[data-theme="light"] { background: #f8fafc; color: #0f172a; }
body[data-theme="light"] .site-header { background: #ffffff; border-color: #e2e8f0; }
body[data-theme="light"] .site-header .brand { color: #0284c7; }
body[data-theme="light"] .site-header nav a { color: #64748b; }
body[data-theme="light"] .btn-primary { background: #0284c7; }
body[data-theme="light"] .btn-outline { color: #64748b; border-color: #cbd5e1; }
body[data-theme="light"] .field input[type="text"],
body[data-theme="light"] .field input[type="number"],
body[data-theme="light"] .field input[type="password"],
body[data-theme="light"] .field select {
background: #f1f5f9;
color: #0f172a;
border-color: #cbd5e1;
}

50
backlogger/steam.py Normal file
View File

@@ -0,0 +1,50 @@
import re
import requests
from urllib.parse import urlencode
STEAM_OPENID_URL = 'https://steamcommunity.com/openid/login'
STEAM_API_BASE = 'https://api.steampowered.com'
def build_auth_url(return_to, realm):
params = {
'openid.ns': 'http://specs.openid.net/auth/2.0',
'openid.mode': 'checkid_setup',
'openid.return_to': return_to,
'openid.realm': realm,
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
}
return f"{STEAM_OPENID_URL}?{urlencode(params)}"
def verify_and_get_steam_id(params):
"""Verify OpenID assertion with Steam. Returns steam64 id string or None."""
verify_params = {k: v for k, v in params.items()}
verify_params['openid.mode'] = 'check_authentication'
try:
resp = requests.post(STEAM_OPENID_URL, data=verify_params, timeout=10)
resp.raise_for_status()
except requests.RequestException:
return None
if 'is_valid:true' not in resp.text:
return None
claimed_id = params.get('openid.claimed_id', '')
match = re.search(r'/openid/id/(\d+)$', claimed_id)
return match.group(1) if match else None
def get_owned_games(api_key, steam_id):
"""Return list of games sorted by playtime desc. Raises on API error."""
url = f"{STEAM_API_BASE}/IPlayerService/GetOwnedGames/v1/"
params = {
'key': api_key,
'steamid': steam_id,
'include_appinfo': 'true',
'include_played_free_games': 'true',
'format': 'json',
}
resp = requests.get(url, params=params, timeout=15)
resp.raise_for_status()
games = resp.json().get('response', {}).get('games', [])
return sorted(games, key=lambda g: g.get('playtime_forever', 0), reverse=True)

View File

@@ -0,0 +1,26 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Kill My Backlog{% endblock %}</title>
<link rel="stylesheet" href="{% static 'backlogger/style.css' %}">
{% block styles %}{% endblock %}
</head>
<body data-theme="{{ request.session.theme|default:'dark' }}">
<header class="site-header">
<a class="brand" href="{% url 'backlogger:list' %}">Kill My Backlog</a>
<nav>
<a href="{% url 'backlogger:list' %}" class="{% if request.resolver_match.url_name == 'list' %}nav-active{% endif %}">Library</a>
<a href="{% url 'backlogger:live' %}" class="{% if request.resolver_match.url_name == 'live' %}nav-active{% endif %}">Live</a>
{% block nav %}{% endblock %}
</nav>
</header>
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,39 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Backlogger{% endblock %}</title>
<link rel="stylesheet" href="{% static 'backlogger/style.css' %}">
<style>
body { display: flex; flex-direction: column; align-items: center; justify-content: center; }
.auth-card {
background: #0a0f1e;
border: 1px solid #1e293b;
border-radius: 12px;
padding: 2.5rem 2rem;
width: 100%;
max-width: 360px;
}
.auth-brand {
display: block;
text-align: center;
color: #38bdf8;
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 0.4rem;
}
.auth-subtitle {
text-align: center;
color: #64748b;
font-size: 0.85rem;
margin-bottom: 2rem;
}
</style>
{% block styles %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>

View File

@@ -1,144 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ action }} Item — Backlogger</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
}
a { text-decoration: none; color: inherit; }
{% extends 'backlogger/base.html' %}
.site-header {
background: #0a0f1e;
border-bottom: 1px solid #1e293b;
padding: 0.75rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.site-header .brand { color: #38bdf8; font-weight: 600; font-size: 0.95rem; }
.site-header nav a { color: #64748b; font-size: 0.85rem; }
.site-header nav a:hover { color: #e2e8f0; }
{% block title %}{{ action }} Item — Backlogger{% endblock %}
.container {
max-width: 520px;
margin: 3rem auto;
padding: 0 1.5rem;
}
{% block styles %}
<style>
.container { max-width: 520px; margin: 3rem auto; padding: 0 1.5rem; }
.card {
background: #0a0f1e;
border: 1px solid #1e293b;
border-radius: 12px;
padding: 2rem;
}
h1 { font-size: 1.35rem; margin-bottom: 1.75rem; letter-spacing: -0.02em; }
.card { background: #0a0f1e; border: 1px solid #1e293b; border-radius: 12px; padding: 2rem; }
h1 { font-size: 1.35rem; margin-bottom: 1.75rem; letter-spacing: -0.02em; }
.field { margin-bottom: 1.25rem; }
.field label {
display: block;
font-size: 0.8rem;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.07em;
margin-bottom: 0.4rem;
}
.field input[type="text"],
.field input[type="number"],
.field select {
width: 100%;
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
color: #e2e8f0;
padding: 0.55rem 0.75rem;
font-size: 0.9rem;
}
.field input:focus,
.field select:focus { outline: none; border-color: #38bdf8; }
.field { margin-bottom: 1.25rem; }
.field input[type="range"] { width: 100%; accent-color: #38bdf8; cursor: pointer; margin-top: 0.2rem; }
.progress-label { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.2rem; }
.progress-val { color: #38bdf8; font-variant-numeric: tabular-nums; font-size: 0.9rem; }
.field input[type="range"] {
width: 100%;
accent-color: #38bdf8;
cursor: pointer;
margin-top: 0.2rem;
}
.progress-label {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.2rem;
}
.progress-val { color: #38bdf8; font-variant-numeric: tabular-nums; font-size: 0.9rem; }
.checkbox-row { display: flex; align-items: center; gap: 0.5rem; }
.checkbox-row input[type="checkbox"] { accent-color: #38bdf8; width: 1rem; height: 1rem; cursor: pointer; }
.checkbox-row label { font-size: 0.9rem; color: #e2e8f0; text-transform: none; letter-spacing: 0; margin-bottom: 0; }
.checkbox-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.checkbox-row input[type="checkbox"] { accent-color: #38bdf8; width: 1rem; height: 1rem; cursor: pointer; }
.checkbox-row label {
font-size: 0.9rem;
color: #e2e8f0;
text-transform: none;
letter-spacing: 0;
margin-bottom: 0;
}
.section-divider { border: none; border-top: 1px solid #1e293b; margin: 1.5rem 0 1.25rem; }
.section-title { font-size: 0.72rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 1rem; }
.optional { color: #475569; font-size: 0.7rem; margin-left: 0.3rem; text-transform: none; letter-spacing: 0; }
.section-divider {
border: none;
border-top: 1px solid #1e293b;
margin: 1.5rem 0 1.25rem;
}
.section-title {
font-size: 0.72rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 1rem;
}
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.optional { color: #475569; font-size: 0.7rem; margin-left: 0.3rem; text-transform: none; letter-spacing: 0; }
.actions { display: flex; justify-content: space-between; align-items: center; margin-top: 2rem; }
.btn { padding: 0.5rem 1.25rem; font-size: 0.875rem; }
</style>
{% endblock %}
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 2rem;
}
.btn {
display: inline-block;
padding: 0.5rem 1.25rem;
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
border: none;
}
.btn-primary { background: #38bdf8; color: #0f172a; font-weight: 600; }
.btn-primary:hover { opacity: 0.88; }
.btn-ghost { background: transparent; color: #64748b; border: 1px solid #334155; text-decoration: none; padding: 0.5rem 1.25rem; }
.btn-ghost:hover { color: #e2e8f0; }
.errorlist { list-style: none; color: #f87171; font-size: 0.8rem; margin-top: 0.3rem; }
</style>
</head>
<body>
<header class="site-header">
<a class="brand" href="/">k-boris.tech</a>
<nav>
<a href="{% url 'backlogger:list' %}">&larr; Back to backlogger</a>
</nav>
</header>
{% block nav %}
<a href="{% url 'backlogger:list' %}">&larr; Back to backlogger</a>
{% endblock %}
{% block content %}
<div class="container">
<div class="card">
<h1>{{ action }} item</h1>
@@ -158,7 +53,7 @@
{{ form.name.errors }}
</div>
<div class="field">
<div class="field" data-hide-status="unending">
<div class="progress-label">
<label for="{{ form.progress_percent.id_for_label }}">Progress</label>
<span class="progress-val" id="progress-display">{{ form.progress_percent.value|default:0|floatformat:0 }}%</span>
@@ -174,8 +69,7 @@
</div>
</div>
<!-- Games fields -->
<div id="section-games" class="cat-section" style="display:none">
<div class="cat-section" data-show-category="games">
<hr class="section-divider">
<div class="section-title">Games</div>
<div class="two-col">
@@ -184,16 +78,23 @@
{{ form.hours_played }}
{{ form.hours_played.errors }}
</div>
<div class="field">
<div class="field" data-hide-status="unending">
<label>Total hours <span class="optional">optional</span></label>
{{ form.total_hours }}
{{ form.total_hours.errors }}
{% if item.hltb_main or item.hltb_extra or item.hltb_complete %}
<div style="margin-top:0.4rem;font-size:0.72rem;color:#475569;line-height:1.6">
HowLongToBeat:
{% if item.hltb_main %}<span style="color:#64748b">Main {{ item.hltb_main|floatformat:0 }}h</span>{% endif %}
{% if item.hltb_extra %}<span style="color:#64748b"> · +Extra {{ item.hltb_extra|floatformat:0 }}h</span>{% endif %}
{% if item.hltb_complete %}<span style="color:#64748b"> · 100% {{ item.hltb_complete|floatformat:0 }}h</span>{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Books fields -->
<div id="section-books" class="cat-section" style="display:none">
<div class="cat-section" data-show-category="books">
<hr class="section-divider">
<div class="section-title">Books</div>
<div class="two-col">
@@ -210,12 +111,11 @@
</div>
</div>
<!-- Films fields -->
<div id="section-films" class="cat-section" style="display:none">
<div class="cat-section" data-show-category="films">
<hr class="section-divider">
<div class="section-title">Films</div>
<div class="two-col">
<div class="field" style="display:flex; align-items:center; padding-top:1.5rem;">
<div class="field" style="display:flex;align-items:center;padding-top:1.5rem;">
<div class="checkbox-row">
{{ form.watched }}
<label for="{{ form.watched.id_for_label }}">Watched</label>
@@ -236,25 +136,30 @@
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const catSelect = document.getElementById('{{ form.category.id_for_label }}');
const progressRange = document.getElementById('{{ form.progress_percent.id_for_label }}');
const progressDisplay = document.getElementById('progress-display');
const itemStatus = '{{ item.status|default:"active" }}';
function updateSections() {
document.querySelectorAll('.cat-section').forEach(el => el.style.display = 'none');
const sec = document.getElementById('section-' + catSelect.value);
if (sec) sec.style.display = 'block';
function updateVisibility() {
const cat = catSelect.value;
document.querySelectorAll('[data-show-category]').forEach(el => {
el.style.display = el.dataset.showCategory === cat ? 'block' : 'none';
});
document.querySelectorAll('[data-hide-status]').forEach(el => {
el.style.display = el.dataset.hideStatus === itemStatus ? 'none' : '';
});
}
catSelect.addEventListener('change', updateSections);
updateSections();
catSelect.addEventListener('change', updateVisibility);
updateVisibility();
progressRange.addEventListener('input', function() {
progressDisplay.textContent = Math.round(this.value) + '%';
});
</script>
</body>
</html>
{% endblock %}

View File

@@ -1,187 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backlogger — k-boris.tech</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
{% extends 'backlogger/base.html' %}
.site-header {
background: #0a0f1e;
border-bottom: 1px solid #1e293b;
padding: 0.75rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.site-header .brand { color: #38bdf8; font-weight: 600; font-size: 0.95rem; }
.site-header nav { display: flex; gap: 1.5rem; align-items: center; }
.site-header nav a { color: #64748b; font-size: 0.85rem; }
.site-header nav a:hover { color: #e2e8f0; }
{% block title %}Library — Kill My Backlog{% endblock %}
.container { max-width: 1100px; margin: 0 auto; padding: 2rem; }
{% block styles %}
<style>
.container { max-width: 1100px; margin: 0 auto; padding: 2rem; }
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.top-bar h1 { font-size: 1.75rem; letter-spacing: -0.03em; }
.top-bar .count { color: #64748b; font-size: 1rem; font-weight: 400; margin-left: 0.5rem; }
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.top-bar h1 { font-size: 1.75rem; letter-spacing: -0.03em; }
.top-bar .count { color: #64748b; font-size: 1rem; font-weight: 400; margin-left: 0.5rem; }
.btn {
display: inline-block;
padding: 0.45rem 1rem;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
border: none;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
.btn-primary { background: #38bdf8; color: #0f172a; font-weight: 600; }
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.78rem; }
.btn-outline { background: transparent; color: #94a3b8; border: 1px solid #334155; }
.btn-danger { background: transparent; color: #f87171; border: 1px solid #f87171; padding: 0.3rem 0.65rem; font-size: 0.78rem; }
.btn-danger:hover { background: #f87171; color: #0f172a; opacity: 1; }
.filter-bar { display: flex; justify-content: space-between; align-items: flex-end; border-bottom: 1px solid #1e293b; margin-bottom: 1.5rem; }
.tabs { display: flex; gap: 0; }
.tab { padding: 0.6rem 1rem; font-size: 0.85rem; color: #64748b; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; }
.tab:hover { color: #e2e8f0; }
.tab.active { color: #38bdf8; border-bottom-color: #38bdf8; }
.tab-sep { width: 1px; background: #1e293b; margin: 0.5rem 0.25rem; align-self: stretch; }
.filter-bar {
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid #1e293b;
margin-bottom: 1.5rem;
}
.tabs { display: flex; gap: 0; }
.tab {
padding: 0.6rem 1rem;
font-size: 0.85rem;
color: #64748b;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
cursor: pointer;
}
.tab:hover { color: #e2e8f0; }
.tab.active { color: #38bdf8; border-bottom-color: #38bdf8; }
.sort-wrap { padding-bottom: 0.75rem; }
.sort-wrap select { background: #1e293b; color: #e2e8f0; border: 1px solid #334155; border-radius: 6px; padding: 0.3rem 0.65rem; font-size: 0.8rem; cursor: pointer; }
.sort-wrap { padding-bottom: 0.75rem; }
.sort-wrap select {
background: #1e293b;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 6px;
padding: 0.3rem 0.65rem;
font-size: 0.8rem;
cursor: pointer;
}
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
.card { background: #0a0f1e; border: 1px solid #1e293b; border-radius: 10px; padding: 1.1rem 1.25rem; display: flex; flex-direction: column; }
.card-top { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.6rem; }
.badge { font-size: 0.62rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; padding: 0.18rem 0.45rem; border-radius: 4px; }
.badge-games { background: #3b2f6a; color: #a78bfa; }
.badge-books { background: #064e3b; color: #34d399; }
.badge-films { background: #431407; color: #fb923c; }
.badge-other { background: #1e293b; color: #94a3b8; }
.star { color: #fbbf24; font-size: 0.9rem; margin-left: auto; }
.card {
background: #0a0f1e;
border: 1px solid #1e293b;
border-radius: 10px;
padding: 1.1rem 1.25rem;
display: flex;
flex-direction: column;
}
.card-top {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.badge {
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.18rem 0.45rem;
border-radius: 4px;
}
.badge-games { background: #3b2f6a; color: #a78bfa; }
.badge-books { background: #064e3b; color: #34d399; }
.badge-films { background: #431407; color: #fb923c; }
.badge-other { background: #1e293b; color: #94a3b8; }
.star { color: #fbbf24; font-size: 0.9rem; margin-left: auto; }
.card-name { font-size: 1rem; font-weight: 600; margin-bottom: 0.7rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.progress-bar { height: 4px; background: #1e293b; border-radius: 2px; margin-bottom: 0.3rem; }
.progress-fill { height: 100%; background: #38bdf8; border-radius: 2px; min-width: 0; }
.card-stat { font-size: 0.78rem; color: #38bdf8; font-variant-numeric: tabular-nums; margin-bottom: 0.3rem; }
.card-info { font-size: 0.76rem; color: #64748b; min-height: 1.1em; margin-bottom: 0.85rem; flex: 1; }
.card-actions { display: flex; gap: 0.4rem; }
.card-actions form { display: inline; }
.card-name {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.7rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-bar {
height: 4px;
background: #1e293b;
border-radius: 2px;
margin-bottom: 0.3rem;
}
.progress-fill {
height: 100%;
background: #38bdf8;
border-radius: 2px;
min-width: 0;
}
.card-stat { font-size: 0.78rem; color: #38bdf8; font-variant-numeric: tabular-nums; margin-bottom: 0.3rem; }
.card-info { font-size: 0.76rem; color: #64748b; min-height: 1.1em; margin-bottom: 0.85rem; flex: 1; }
.empty { text-align: center; padding: 5rem 2rem; color: #64748b; }
.empty a { color: #38bdf8; }
.card-actions { display: flex; gap: 0.4rem; }
.card-actions form { display: inline; }
@keyframes complete-glow {
0% { box-shadow: 0 0 0px 0px rgba(255,255,255,0); transform: scale(1); }
30% { box-shadow: 0 0 18px 6px rgba(255,255,255,0.55); transform: scale(1.03); }
70% { box-shadow: 0 0 24px 8px rgba(255,255,255,0.35); transform: scale(1.03); }
100% { box-shadow: 0 0 0px 0px rgba(255,255,255,0); transform: scale(1); opacity: 0; }
}
.card.completing { animation: complete-glow 0.7s ease-out forwards; pointer-events: none; }
.empty {
text-align: center;
padding: 5rem 2rem;
color: #64748b;
}
.empty a { color: #38bdf8; }
</style>
</head>
<body>
body[data-theme="light"] .card { background: #ffffff; border-color: #e2e8f0; }
body[data-theme="light"] .progress-bar { background: #e2e8f0; }
body[data-theme="light"] .progress-fill { background: #0284c7; }
body[data-theme="light"] .card-stat { color: #0284c7; }
body[data-theme="light"] .filter-bar { border-color: #e2e8f0; }
body[data-theme="light"] .tab-sep { background: #e2e8f0; }
body[data-theme="light"] .tab.active { color: #0284c7; border-bottom-color: #0284c7; }
body[data-theme="light"] .sort-wrap select { background: #f1f5f9; color: #0f172a; border-color: #cbd5e1; }
body[data-theme="light"] .empty a { color: #0284c7; }
</style>
{% endblock %}
<header class="site-header">
<a class="brand" href="/">k-boris.tech</a>
<nav>
<form method="post" action="{% url 'logout' %}" style="display:inline">{% csrf_token %}<button type="submit" style="background:none;border:none;color:#64748b;font-size:0.85rem;cursor:pointer;padding:0">Log out</button></form>
</nav>
</header>
{% block nav %}
<a href="{% url 'backlogger:profile' %}">{{ request.user.profile.display_name|default:request.user.username }}</a>
<form method="post" action="{% url 'logout' %}" style="display:inline">{% csrf_token %}<button type="submit" style="background:none;border:none;color:#64748b;font-size:0.85rem;cursor:pointer;padding:0">Log out</button></form>
{% endblock %}
{% block content %}
<div class="container">
<div class="top-bar">
<h1>Backlogger <span class="count">{{ items|length }}</span></h1>
<a href="{% url 'backlogger:add' %}" class="btn btn-primary">+ Add item</a>
<h1>Library <span class="count">{{ items|length }}</span></h1>
<div style="display:flex;gap:0.5rem;align-items:center">
{% if request.GET.imported %}
<span style="font-size:0.82rem;color:#34d399">&#10003; {{ request.GET.imported }} game{{ request.GET.imported|pluralize }} imported</span>
{% endif %}
{% if request.GET.synced %}
<span style="font-size:0.82rem;color:#34d399">&#10003; {{ request.GET.synced }} game{{ request.GET.synced|pluralize }} synced</span>
{% endif %}
{% if request.GET.sync_error %}
<span style="font-size:0.82rem;color:#f87171">Steam sync failed</span>
{% endif %}
<a href="{% url 'backlogger:steam_sync_login' %}" class="btn btn-outline" style="font-size:0.82rem" title="Sync hours played from Steam">&#8635; Sync</a>
<a href="{% url 'backlogger:steam_login' %}" class="btn btn-outline" style="font-size:0.82rem">&#9654; Import</a>
{% if debug %}
<form method="post" action="{% url 'backlogger:debug_delete_all' %}" onsubmit="return confirm('Delete ALL items?')">
{% csrf_token %}
<button type="submit" class="btn btn-danger" style="font-size:0.82rem">&#128465; Delete all</button>
</form>
{% endif %}
<a href="{% url 'backlogger:add' %}" class="btn btn-primary">+ Add item</a>
</div>
</div>
<div class="filter-bar">
<div class="tabs">
<a href="?sort={{ sort }}" class="tab {% if shelf == 'active' %}active{% endif %}">Active</a>
<a href="?shelf=completed&sort={{ sort }}" class="tab {% if shelf == 'completed' %}active{% endif %}">Completed</a>
<a href="?shelf=abandoned&sort={{ sort }}" class="tab {% if shelf == 'abandoned' %}active{% endif %}">Abandoned</a>
<a href="?shelf=unending&sort={{ sort }}" class="tab {% if shelf == 'unending' %}active{% endif %}">Unending</a>
{% if shelf == 'active' %}
<span class="tab-sep"></span>
<a href="?sort={{ sort }}" class="tab {% if not category %}active{% endif %}">All</a>
{% for val, label in categories %}
<a href="?category={{ val }}&sort={{ sort }}" class="tab {% if category == val %}active{% endif %}">{{ label }}</a>
{% endfor %}
{% endif %}
</div>
<div class="sort-wrap">
<select onchange="location='?category={{ category }}&sort='+this.value">
<select onchange="location='?shelf={{ shelf }}&category={{ category }}&sort='+this.value">
<option value="fav" {% if sort == 'fav' %}selected{% endif %}>Favorites first</option>
<option value="az" {% if sort == 'az' %}selected{% endif %}>A → Z</option>
<option value="za" {% if sort == 'za' %}selected{% endif %}>Z → A</option>
<option value="newest" {% if sort == 'newest' %}selected{% endif %}>Newest first</option>
<option value="oldest" {% if sort == 'oldest' %}selected{% endif %}>Oldest first</option>
<option value="progress" {% if sort == 'progress' %}selected{% endif %}>Most complete</option>
<option value="updated" {% if sort == 'updated' %}selected{% endif %}>Recently updated</option>
</select>
</div>
</div>
@@ -207,6 +141,11 @@
{% if item.hours_played is not None %}
{{ item.hours_played|floatformat:1 }}h played{% if item.total_hours %} / {{ item.total_hours|floatformat:0 }}h total{% endif %}
{% endif %}
{% if item.hltb_main or item.hltb_extra or item.hltb_complete %}
<div style="margin-top:0.3rem;font-size:0.72rem;color:#475569">
HLTB:{% if item.hltb_main %} {{ item.hltb_main|floatformat:0 }}h{% endif %}{% if item.hltb_extra %} · +extra {{ item.hltb_extra|floatformat:0 }}h{% endif %}{% if item.hltb_complete %} · 100% {{ item.hltb_complete|floatformat:0 }}h{% endif %}
</div>
{% endif %}
{% elif item.category == 'books' %}
{% if item.pages_read is not None %}
{{ item.pages_read }} pages{% if item.total_pages %} / {{ item.total_pages }} total{% endif %}
@@ -218,6 +157,35 @@
<div class="card-actions">
<a href="{% url 'backlogger:edit' item.pk %}" class="btn btn-sm btn-outline">Edit</a>
{% if shelf == 'active' %}
<form method="post" action="{% url 'backlogger:set_status' item.pk %}" data-complete>
{% csrf_token %}
<input type="hidden" name="status" value="completed">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="btn btn-done">Done</button>
</form>
{% if item.category == 'games' %}
<form method="post" action="{% url 'backlogger:set_status' item.pk %}">
{% csrf_token %}
<input type="hidden" name="status" value="unending">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="btn btn-sm btn-outline" style="color:#38bdf8;border-color:#38bdf8" title="Unending"></button>
</form>
{% endif %}
<form method="post" action="{% url 'backlogger:set_status' item.pk %}">
{% csrf_token %}
<input type="hidden" name="status" value="abandoned">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="btn btn-abandon">Abandon</button>
</form>
{% else %}
<form method="post" action="{% url 'backlogger:set_status' item.pk %}">
{% csrf_token %}
<input type="hidden" name="status" value="active">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="btn btn-sm btn-outline">Restore</button>
</form>
{% endif %}
<form method="post" action="{% url 'backlogger:delete' item.pk %}" onsubmit="return confirm('Delete &quot;{{ item.name }}&quot;?')">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Delete</button>
@@ -232,6 +200,37 @@
</div>
{% endif %}
</div>
{% endblock %}
</body>
</html>
{% block scripts %}
<script>
function playDing() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
[[880, 0, 0.15], [1320, 0.12, 0.28]].forEach(([freq, start, end]) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0, ctx.currentTime + start);
gain.gain.linearRampToValueAtTime(0.25, ctx.currentTime + start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + end);
osc.start(ctx.currentTime + start);
osc.stop(ctx.currentTime + end);
});
} catch (e) {}
}
document.querySelectorAll('form[data-complete]').forEach(form => {
form.addEventListener('submit', function(e) {
e.preventDefault();
const card = form.closest('.card');
playDing();
card.classList.add('completing');
setTimeout(() => form.submit(), 680);
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,111 @@
{% extends 'backlogger/base.html' %}
{% block title %}Live — Kill My Backlog{% endblock %}
{% block styles %}
<style>
.container { max-width: 720px; margin: 0 auto; padding: 2rem; }
.top-bar { margin-bottom: 2rem; }
.top-bar h1 { font-size: 1.75rem; letter-spacing: -0.03em; }
.top-bar p { color: #64748b; font-size: 0.85rem; margin-top: 0.35rem; }
.live-list { display: flex; flex-direction: column; gap: 0.75rem; }
.live-item {
background: #0a0f1e;
border: 1px solid #1e293b;
border-radius: 10px;
padding: 1rem 1.25rem;
display: flex;
align-items: center;
gap: 1rem;
}
.live-rank {
font-size: 0.75rem;
color: #334155;
font-variant-numeric: tabular-nums;
width: 1.5rem;
flex-shrink: 0;
text-align: right;
}
.live-body { flex: 1; min-width: 0; }
.live-name {
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.4rem;
}
.live-meta { display: flex; align-items: center; gap: 0.6rem; }
.badge { font-size: 0.62rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; padding: 0.18rem 0.45rem; border-radius: 4px; }
.badge-games { background: #3b2f6a; color: #a78bfa; }
.badge-books { background: #064e3b; color: #34d399; }
.badge-films { background: #431407; color: #fb923c; }
.badge-other { background: #1e293b; color: #94a3b8; }
.live-stat { font-size: 0.78rem; color: #64748b; }
.progress-bar { height: 3px; background: #1e293b; border-radius: 2px; margin-top: 0.5rem; }
.progress-fill { height: 100%; background: #38bdf8; border-radius: 2px; }
.live-time { font-size: 0.75rem; color: #334155; flex-shrink: 0; white-space: nowrap; }
.empty { text-align: center; padding: 5rem 2rem; color: #64748b; }
.empty a { color: #38bdf8; }
body[data-theme="light"] .live-item { background: #ffffff; border-color: #e2e8f0; }
body[data-theme="light"] .progress-bar { background: #e2e8f0; }
body[data-theme="light"] .progress-fill { background: #0284c7; }
body[data-theme="light"] .live-time { color: #94a3b8; }
body[data-theme="light"] .live-rank { color: #94a3b8; }
</style>
{% endblock %}
{% block nav %}
<a href="{% url 'backlogger:profile' %}">{{ request.user.profile.display_name|default:request.user.username }}</a>
<form method="post" action="{% url 'logout' %}" style="display:inline">{% csrf_token %}<button type="submit" style="background:none;border:none;color:#64748b;font-size:0.85rem;cursor:pointer;padding:0">Log out</button></form>
{% endblock %}
{% block content %}
<div class="container">
<div class="top-bar">
<h1>Live</h1>
<p>Your 10 most recently touched items.</p>
</div>
{% if items %}
<div class="live-list">
{% for item in items %}
<div class="live-item">
<div class="live-rank">{{ forloop.counter }}</div>
<div class="live-body">
<div class="live-name" title="{{ item.name }}">{{ item.name }}</div>
<div class="live-meta">
<span class="badge badge-{{ item.category }}">{{ item.get_category_display }}</span>
<span class="live-stat">
{% if item.category == 'games' %}
{% if item.hours_played is not None %}{{ item.hours_played|floatformat:1 }}h{% endif %}
{% elif item.category == 'books' %}
{% if item.pages_read is not None %}p. {{ item.pages_read }}{% if item.total_pages %}/{{ item.total_pages }}{% endif %}{% endif %}
{% elif item.category == 'films' %}
{% if item.watched %}watched{% else %}not watched{% endif %}
{% endif %}
</span>
<span class="live-stat">{{ item.get_status_display }}</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{ item.progress_percent }}%"></div>
</div>
</div>
<a href="{% url 'backlogger:edit' item.pk %}" class="live-time">{{ item.updated_at|date:"M j" }}</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">
Nothing here yet. <a href="{% url 'backlogger:add' %}">Add your first item.</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,123 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Log in — k-boris.tech</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.card {
background: #0a0f1e;
border: 1px solid #1e293b;
border-radius: 12px;
padding: 2.5rem 2rem;
width: 100%;
max-width: 360px;
}
.brand {
display: block;
text-align: center;
color: #38bdf8;
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 0.4rem;
text-decoration: none;
}
.subtitle {
text-align: center;
color: #64748b;
font-size: 0.85rem;
margin-bottom: 2rem;
}
.field { margin-bottom: 1.1rem; }
.field label {
display: block;
font-size: 0.78rem;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.07em;
margin-bottom: 0.4rem;
}
.field input {
width: 100%;
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
color: #e2e8f0;
padding: 0.6rem 0.75rem;
font-size: 0.9rem;
}
.field input:focus { outline: none; border-color: #38bdf8; }
.btn {
width: 100%;
background: #38bdf8;
color: #0f172a;
font-weight: 600;
border: none;
border-radius: 6px;
padding: 0.6rem;
font-size: 0.9rem;
cursor: pointer;
margin-top: 0.5rem;
}
.btn:hover { opacity: 0.88; }
.errorlist { list-style: none; color: #f87171; font-size: 0.8rem; margin-top: 0.3rem; }
.error-banner {
background: #450a0a;
border: 1px solid #f87171;
border-radius: 6px;
color: #fca5a5;
font-size: 0.83rem;
padding: 0.6rem 0.85rem;
margin-bottom: 1.25rem;
}
</style>
</head>
<body>
<div class="card">
<a class="brand" href="/">k-boris.tech</a>
<p class="subtitle">Backlogger</p>
{% extends 'backlogger/base_auth.html' %}
{% if form.non_field_errors %}
{% for error in form.non_field_errors %}
{% if 'inactive' in error|lower or 'active' in error|lower %}
<div class="error-banner">Your account is pending approval. You'll be able to log in once it's activated.</div>
{% else %}
<div class="error-banner">Invalid username or password.</div>
{% endif %}
{% endfor %}
{% endif %}
{% block title %}Log in — k-boris.tech{% endblock %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}">
{% block content %}
<div class="auth-card">
<a class="auth-brand" href="/">k-boris.tech</a>
<p class="auth-subtitle">Backlogger</p>
<div class="field">
<label for="{{ form.username.id_for_label }}">Username</label>
{{ form.username }}
{{ form.username.errors }}
</div>
<div class="field">
<label for="{{ form.password.id_for_label }}">Password</label>
{{ form.password }}
{{ form.password.errors }}
</div>
{% if form.non_field_errors %}
{% for error in form.non_field_errors %}
{% if 'inactive' in error|lower or 'active' in error|lower %}
<div class="error-banner">Your account is pending approval. You'll be able to log in once it's activated.</div>
{% else %}
<div class="error-banner">Invalid username or password.</div>
{% endif %}
{% endfor %}
{% endif %}
<button type="submit" class="btn">Log in</button>
</form>
<p style="text-align:center; margin-top:1.25rem; font-size:0.83rem; color:#64748b;">
No account? <a href="/accounts/signup/" style="color:#38bdf8; text-decoration:none;">Sign up</a>
</p>
</div>
</body>
</html>
<form method="post">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}">
<div class="field">
<label for="{{ form.username.id_for_label }}">Username</label>
{{ form.username }}
{{ form.username.errors }}
</div>
<div class="field">
<label for="{{ form.password.id_for_label }}">Password</label>
{{ form.password }}
{{ form.password.errors }}
</div>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:0.5rem">Log in</button>
</form>
<p style="text-align:center;margin-top:1.25rem;font-size:0.83rem;color:#64748b;">
No account? <a href="/accounts/signup/" style="color:#38bdf8;">Sign up</a>
</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,70 @@
{% extends 'backlogger/base.html' %}
{% block title %}Profile — Backlogger{% endblock %}
{% block styles %}
<style>
.container { max-width: 520px; margin: 0 auto; padding: 2.5rem 2rem; }
h1 { font-size: 1.5rem; letter-spacing: -0.02em; margin-bottom: 0.35rem; }
.subtitle { color: #64748b; font-size: 0.85rem; margin-bottom: 2rem; }
.card { background: #0a0f1e; border: 1px solid #1e293b; border-radius: 10px; padding: 1.5rem; }
.field { margin-bottom: 1.25rem; }
.field:last-of-type { margin-bottom: 0; }
.field label { font-size: 0.82rem; }
/* Profile uses a slightly darker input background than the default */
.field input[type="text"], .field select { background: #0f172a; }
.field-static { font-size: 0.9rem; color: #64748b; padding: 0.5rem 0; }
.divider { border: none; border-top: 1px solid #1e293b; margin: 1.5rem 0; }
.actions { margin-top: 1.5rem; display: flex; gap: 0.75rem; align-items: center; }
.saved-msg { font-size: 0.82rem; color: #34d399; display: none; }
.saved-msg.show { display: inline; }
body[data-theme="light"] .card { background: #ffffff; border-color: #e2e8f0; }
body[data-theme="light"] .field input[type="text"],
body[data-theme="light"] .field select { background: #f8fafc; color: #0f172a; border-color: #cbd5e1; }
body[data-theme="light"] .divider { border-color: #e2e8f0; }
</style>
{% endblock %}
{% block nav %}
<a href="{% url 'backlogger:list' %}">Backlog</a>
<a href="{% url 'backlogger:profile' %}" style="color:#e2e8f0">Profile</a>
<form method="post" action="{% url 'logout' %}" style="display:inline">{% csrf_token %}<button type="submit" style="background:none;border:none;color:#64748b;font-size:0.85rem;cursor:pointer;padding:0">Log out</button></form>
{% endblock %}
{% block content %}
<div class="container">
<h1>Profile</h1>
<p class="subtitle">@{{ request.user.username }}{% if request.user.date_joined %} · member since {{ request.user.date_joined|date:"N Y" }}{% endif %}</p>
<form method="post" id="profile-form">
{% csrf_token %}
<div class="card">
<div class="field">
<label for="{{ form.display_name.id_for_label }}">{{ form.display_name.label }}</label>
{{ form.display_name }}
{% if form.display_name.errors %}<div style="color:#f87171;font-size:0.78rem;margin-top:0.3rem">{{ form.display_name.errors.0 }}</div>{% endif %}
</div>
<hr class="divider">
<div class="field">
<label for="{{ form.theme.id_for_label }}">{{ form.theme.label }}</label>
{{ form.theme }}
</div>
</div>
<div class="actions">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{% url 'backlogger:list' %}" class="btn btn-outline">Back to backlog</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -1,117 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign up — Backlogger</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.card {
background: #0a0f1e;
border: 1px solid #1e293b;
border-radius: 12px;
padding: 2.5rem 2rem;
width: 100%;
max-width: 360px;
}
.brand {
display: block;
text-align: center;
color: #38bdf8;
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 0.4rem;
text-decoration: none;
}
.subtitle {
text-align: center;
color: #64748b;
font-size: 0.85rem;
margin-bottom: 2rem;
}
.field { margin-bottom: 1.1rem; }
.field label {
display: block;
font-size: 0.78rem;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.07em;
margin-bottom: 0.4rem;
}
.field input {
width: 100%;
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
color: #e2e8f0;
padding: 0.6rem 0.75rem;
font-size: 0.9rem;
}
.field input:focus { outline: none; border-color: #38bdf8; }
.btn {
width: 100%;
background: #38bdf8;
color: #0f172a;
font-weight: 600;
border: none;
border-radius: 6px;
padding: 0.6rem;
font-size: 0.9rem;
cursor: pointer;
margin-top: 0.5rem;
}
.btn:hover { opacity: 0.88; }
.errorlist { list-style: none; color: #f87171; font-size: 0.8rem; margin-top: 0.3rem; }
.help { font-size: 0.75rem; color: #475569; margin-top: 0.3rem; }
</style>
</head>
<body>
<div class="card">
<a class="brand" href="/">killmybacklog.com</a>
<p class="subtitle">Create an account</p>
{% extends 'backlogger/base_auth.html' %}
<form method="post">
{% csrf_token %}
{% block title %}Sign up — Backlogger{% endblock %}
<div class="field">
<label for="{{ form.username.id_for_label }}">Username</label>
{{ form.username }}
{{ form.username.errors }}
</div>
{% block styles %}
<style>
.help { font-size: 0.75rem; color: #475569; margin-top: 0.3rem; }
</style>
{% endblock %}
<div class="field">
<label for="{{ form.email.id_for_label }}">Email</label>
{{ form.email }}
{{ form.email.errors }}
</div>
{% block content %}
<div class="auth-card">
<a class="auth-brand" href="/">killmybacklog.com</a>
<p class="auth-subtitle">Create an account</p>
<div class="field">
<label for="{{ form.password1.id_for_label }}">Password</label>
{{ form.password1 }}
{{ form.password1.errors }}
</div>
<form method="post">
{% csrf_token %}
<div class="field">
<label for="{{ form.password2.id_for_label }}">Confirm password</label>
{{ form.password2 }}
{{ form.password2.errors }}
</div>
<div class="field">
<label for="{{ form.username.id_for_label }}">Username</label>
{{ form.username }}
{{ form.username.errors }}
</div>
<div class="field">
<label for="{{ form.email.id_for_label }}">Email</label>
{{ form.email }}
{{ form.email.errors }}
</div>
<div class="field">
<label for="{{ form.password1.id_for_label }}">Password</label>
{{ form.password1 }}
{{ form.password1.errors }}
</div>
<div class="field">
<label for="{{ form.password2.id_for_label }}">Confirm password</label>
{{ form.password2 }}
{{ form.password2.errors }}
</div>
<button type="submit" class="btn">Request account</button>
</form>
<p style="text-align:center; margin-top:1.25rem; font-size:0.83rem; color:#64748b;">
Already have an account? <a href="/accounts/login/" style="color:#38bdf8; text-decoration:none;">Log in</a>
</p>
</div>
</body>
</html>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:0.5rem">Request account</button>
</form>
<p style="text-align:center;margin-top:1.25rem;font-size:0.83rem;color:#64748b;">
Already have an account? <a href="/accounts/login/" style="color:#38bdf8;">Log in</a>
</p>
</div>
{% endblock %}

View File

@@ -1,51 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Account requested — Backlogger</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.card {
background: #0a0f1e;
border: 1px solid #1e293b;
border-radius: 12px;
padding: 2.5rem 2rem;
width: 100%;
max-width: 360px;
text-align: center;
}
.brand {
display: block;
color: #38bdf8;
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 0.4rem;
text-decoration: none;
}
.icon { font-size: 2rem; margin: 1.25rem 0 0.75rem; }
h2 { font-size: 1rem; font-weight: 600; margin-bottom: 0.6rem; }
p { font-size: 0.85rem; color: #64748b; line-height: 1.5; }
a { color: #38bdf8; text-decoration: none; }
</style>
</head>
<body>
<div class="card">
<a class="brand" href="/">killmybacklog.com</a>
<div class="icon">&#10003;</div>
<h2>Account requested</h2>
<p>Your account is pending approval.<br>You'll receive access once it's activated.</p>
<p style="margin-top:1.5rem;"><a href="/accounts/login/">Back to log in</a></p>
</div>
</body>
</html>
{% extends 'backlogger/base_auth.html' %}
{% block title %}Account requested — Backlogger{% endblock %}
{% block styles %}
<style>
.auth-card { text-align: center; }
.icon { font-size: 2rem; margin: 1.25rem 0 0.75rem; }
h2 { font-size: 1rem; font-weight: 600; margin-bottom: 0.6rem; }
p { font-size: 0.85rem; color: #64748b; line-height: 1.5; }
a { color: #38bdf8; }
</style>
{% endblock %}
{% block content %}
<div class="auth-card">
<a class="auth-brand" href="/">killmybacklog.com</a>
<div class="icon">&#10003;</div>
<h2>Account requested</h2>
<p>Your account is pending approval.<br>You'll receive access once it's activated.</p>
<p style="margin-top:1.5rem;"><a href="/accounts/login/">Back to log in</a></p>
</div>
{% endblock %}

View File

@@ -0,0 +1,147 @@
{% extends 'backlogger/base.html' %}
{% block title %}Import from Steam — Backlogger{% endblock %}
{% block brand %}killmybacklog.com{% endblock %}
{% block styles %}
<style>
.container { max-width: 860px; margin: 0 auto; padding: 2rem; }
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.75rem; }
.top-bar h1 { font-size: 1.5rem; letter-spacing: -0.03em; }
.top-bar .count { color: #64748b; font-size: 1rem; font-weight: 400; margin-left: 0.5rem; }
.toolbar { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 1rem; }
.game-table { width: 100%; border-collapse: collapse; }
.game-table thead th { text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; color: #475569; padding: 0 0.75rem 0.6rem; border-bottom: 1px solid #1e293b; }
.game-table thead th:first-child { padding-left: 0; width: 2rem; }
.game-table tbody tr { border-bottom: 1px solid #1a2235; transition: background 0.1s; }
.game-table tbody tr:hover:not(.already-imported) { background: #0d1526; }
.game-table td { padding: 0.6rem 0.75rem; font-size: 0.88rem; }
.game-table td:first-child { padding-left: 0; }
.already-imported td { opacity: 0.38; }
.already-imported .tag { font-size: 0.68rem; color: #475569; border: 1px solid #1e293b; border-radius: 4px; padding: 0.1rem 0.4rem; margin-left: 0.5rem; vertical-align: middle; }
.hours { color: #38bdf8; font-variant-numeric: tabular-nums; }
.hours-zero { color: #334155; }
input[type="checkbox"] { accent-color: #38bdf8; width: 1rem; height: 1rem; cursor: pointer; }
.sticky-footer { position: sticky; bottom: 0; background: #0a0f1e; border-top: 1px solid #1e293b; padding: 1rem 2rem; display: flex; align-items: center; gap: 1.25rem; }
.sticky-footer .summary { font-size: 0.85rem; color: #64748b; }
.sticky-footer .summary strong { color: #e2e8f0; }
.empty { text-align: center; padding: 4rem 2rem; color: #475569; }
.steam-btn { display: inline-flex; align-items: center; gap: 0.6rem; background: #1b2838; border: 1px solid #2a475e; color: #c7d5e0; font-weight: 600; border-radius: 6px; padding: 0.6rem 1.1rem; font-size: 0.9rem; cursor: pointer; transition: background 0.15s; }
.steam-btn:hover { background: #2a475e; }
.steam-logo { width: 20px; height: 20px; }
</style>
{% endblock %}
{% block nav %}
<a href="{% url 'backlogger:list' %}">← Back to backlog</a>
{% endblock %}
{% block content %}
<div class="container">
{% if error %}
<div class="error-banner">{{ error }}</div>
<div class="empty">
<p style="margin-bottom:1.25rem">Want to try again?</p>
<a href="{% url 'backlogger:steam_login' %}" class="steam-btn">
<svg class="steam-logo" viewBox="0 0 233 233" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M116.5 0C52.1 0 0 52.1 0 116.5c0 55.4 38.6 101.8 90.4 113.7l34.2-84.2c-1.2.1-2.4.1-3.6.1-22.1 0-40-17.9-40-40s17.9-40 40-40 40 17.9 40 40c0 19.1-13.4 35.1-31.4 39l-33.8 83.3C147.5 222 185 179.5 185 128c0-1.6-.1-3.1-.2-4.7l-31.5-13c.4 2.2.7 4.5.7 6.8 0 19.9-16.1 36-36 36s-36-16.1-36-36 16.1-36 36-36 36 16.1 36 36" fill="#c7d5e0"/>
</svg>
Connect with Steam
</a>
</div>
{% elif games %}
<div class="top-bar">
<h1>Import from Steam <span class="count">{{ games|length }} games</span></h1>
</div>
<form method="post" action="{% url 'backlogger:steam_import' %}">
{% csrf_token %}
<div class="toolbar">
<button type="submit" class="btn btn-primary">Import selected</button>
<button type="button" class="btn-text" onclick="toggleAll(true)">Select all</button>
<button type="button" class="btn-text" onclick="toggleAll(false)">Deselect all</button>
</div>
<table class="game-table">
<thead>
<tr>
<th></th>
<th>Game</th>
<th>Hours played</th>
</tr>
</thead>
<tbody>
{% for game in games %}
<tr class="{% if game.already_imported %}already-imported{% endif %}">
<td>
{% if game.already_imported %}
<input type="checkbox" disabled>
{% else %}
<input type="checkbox" name="appids" value="{{ game.appid }}" checked>
{% endif %}
</td>
<td>
{{ game.name }}
{% if game.already_imported %}<span class="tag">already in backlog</span>{% endif %}
</td>
<td class="{% if game.hours == 0 %}hours-zero{% else %}hours{% endif %}">
{{ game.hours }}h
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="sticky-footer">
<button type="submit" class="btn btn-primary">Import selected</button>
<span class="summary">
<strong id="sel-count">{{ games|length }}</strong> selected
</span>
<a href="{% url 'backlogger:list' %}" class="btn btn-outline">Cancel</a>
</div>
</form>
{% else %}
<div class="top-bar"><h1>Import from Steam</h1></div>
<div class="empty">
<p style="margin-bottom:1.25rem;color:#64748b">Connect your Steam account to import your library.</p>
<a href="{% url 'backlogger:steam_login' %}" class="steam-btn">
<svg class="steam-logo" viewBox="0 0 233 233" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M116.5 0C52.1 0 0 52.1 0 116.5c0 55.4 38.6 101.8 90.4 113.7l34.2-84.2c-1.2.1-2.4.1-3.6.1-22.1 0-40-17.9-40-40s17.9-40 40-40 40 17.9 40 40c0 19.1-13.4 35.1-31.4 39l-33.8 83.3C147.5 222 185 179.5 185 128c0-1.6-.1-3.1-.2-4.7l-31.5-13c.4 2.2.7 4.5.7 6.8 0 19.9-16.1 36-36 36s-36-16.1-36-36 16.1-36 36-36 36 16.1 36 36" fill="#c7d5e0"/>
</svg>
Connect with Steam
</a>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleAll(check) {
document.querySelectorAll('input[name="appids"]').forEach(cb => cb.checked = check);
updateCount();
}
function updateCount() {
const n = document.querySelectorAll('input[name="appids"]:checked').length;
const el = document.getElementById('sel-count');
if (el) el.textContent = n;
}
document.querySelectorAll('input[name="appids"]').forEach(cb => cb.addEventListener('change', updateCount));
updateCount();
</script>
{% endblock %}

View File

@@ -4,7 +4,16 @@ from . import views
app_name = 'backlogger'
urlpatterns = [
path('', views.item_list, name='list'),
path('live/', views.live, name='live'),
path('add/', views.item_add, name='add'),
path('<int:pk>/edit/', views.item_edit, name='edit'),
path('<int:pk>/delete/', views.item_delete, name='delete'),
path('<int:pk>/status/', views.item_set_status, name='set_status'),
path('profile/', views.profile, name='profile'),
path('steam/login/', views.steam_login, name='steam_login'),
path('steam/callback/', views.steam_callback, name='steam_callback'),
path('steam/import/', views.steam_import, name='steam_import'),
path('debug/delete-all/', views.debug_delete_all, name='debug_delete_all'),
path('steam/sync/', views.steam_sync_login, name='steam_sync_login'),
path('steam/sync/callback/', views.steam_sync_callback, name='steam_sync_callback'),
]

View File

@@ -1,7 +1,11 @@
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, get_object_or_404, redirect
from .models import Item
from .forms import ItemForm, SignupForm
from django.urls import reverse
from .models import Item, UserProfile
from .forms import ItemForm, ProfileForm, SignupForm
from . import steam as steam_api
from . import hltb as hltb_api
def signup(request):
@@ -24,15 +28,38 @@ SORT_MAP = {
'newest': ['-created_at'],
'oldest': ['created_at'],
'progress': ['-progress_percent'],
'updated': ['-updated_at'],
}
@login_required
def profile(request):
user_profile, _ = UserProfile.objects.get_or_create(user=request.user)
if request.method == 'POST':
form = ProfileForm(request.POST, instance=user_profile)
if form.is_valid():
saved = form.save()
request.session['theme'] = saved.theme
return redirect('backlogger:profile')
else:
form = ProfileForm(instance=user_profile)
request.session['theme'] = user_profile.theme
return render(request, 'backlogger/profile.html', {'form': form, 'profile': user_profile})
@login_required
def item_list(request):
category = request.GET.get('category', '')
sort = request.GET.get('sort', 'fav')
sort = request.GET.get('sort', '')
if sort in SORT_MAP:
request.session['sort'] = sort
else:
sort = request.session.get('sort', 'fav')
shelf = request.GET.get('shelf', Item.ACTIVE)
if shelf not in (Item.ACTIVE, Item.COMPLETED, Item.ABANDONED, Item.UNENDING):
shelf = Item.ACTIVE
items = Item.objects.filter(user=request.user)
items = Item.objects.filter(user=request.user, status=shelf)
if category:
items = items.filter(category=category)
items = items.order_by(*SORT_MAP.get(sort, SORT_MAP['fav']))
@@ -41,7 +68,9 @@ def item_list(request):
'items': items,
'category': category,
'sort': sort,
'shelf': shelf,
'categories': Item.CATEGORY_CHOICES,
'debug': settings.DEBUG,
})
@@ -53,6 +82,7 @@ def item_add(request):
item = form.save(commit=False)
item.user = request.user
item.save()
hltb_api.apply_to_item(item)
return redirect('backlogger:list')
else:
form = ItemForm()
@@ -65,15 +95,158 @@ def item_edit(request, pk):
if request.method == 'POST':
form = ItemForm(request.POST, instance=item)
if form.is_valid():
form.save()
updated = form.save()
if 'name' in form.changed_data or 'category' in form.changed_data:
hltb_api.apply_to_item(updated)
return redirect('backlogger:list')
else:
form = ItemForm(instance=item)
return render(request, 'backlogger/item_form.html', {'form': form, 'action': 'Edit', 'item': item})
@login_required
def item_set_status(request, pk):
if request.method == 'POST':
item = get_object_or_404(Item, pk=pk, user=request.user)
new_status = request.POST.get('status')
if new_status in (Item.ACTIVE, Item.COMPLETED, Item.ABANDONED, Item.UNENDING):
item.status = new_status
item.save(update_fields=['status', 'updated_at'])
next_url = request.POST.get('next') or reverse('backlogger:list')
return redirect(next_url)
@login_required
def item_delete(request, pk):
if request.method == 'POST':
get_object_or_404(Item, pk=pk, user=request.user).delete()
return redirect('backlogger:list')
@login_required
def steam_login(request):
callback = request.build_absolute_uri(reverse('backlogger:steam_callback'))
realm = f"{request.scheme}://{request.get_host()}"
return redirect(steam_api.build_auth_url(callback, realm))
@login_required
def steam_callback(request):
steam_id = steam_api.verify_and_get_steam_id(request.GET.dict())
if not steam_id:
return render(request, 'backlogger/steam_import.html', {'error': 'Steam verification failed. Please try again.'})
api_key = getattr(settings, 'STEAM_API_KEY', '')
if not api_key:
return render(request, 'backlogger/steam_import.html', {'error': 'Steam API key is not configured on the server.'})
try:
games = steam_api.get_owned_games(api_key, steam_id)
except Exception:
return render(request, 'backlogger/steam_import.html', {'error': 'Could not fetch your Steam library. Your profile may be set to private.'})
existing = set(
Item.objects.filter(user=request.user, category=Item.GAMES)
.values_list('name', flat=True)
)
game_list = []
for g in games:
name = g.get('name', '')
hours = round(g.get('playtime_forever', 0) / 60, 1)
game_list.append({
'appid': g.get('appid'),
'name': name,
'hours': hours,
'already_imported': name in existing,
})
request.session['steam_games'] = game_list
return render(request, 'backlogger/steam_import.html', {'games': game_list})
@login_required
def steam_import(request):
if request.method != 'POST':
return redirect('backlogger:list')
games_by_appid = {str(g['appid']): g for g in request.session.get('steam_games', [])}
selected = request.POST.getlist('appids')
imported = 0
for appid in selected:
game = games_by_appid.get(appid)
if not game or game['already_imported']:
continue
hours = game['hours']
progress = min(100.0, hours) if hours > 0 else 0.0
Item.objects.create(
user=request.user,
category=Item.GAMES,
name=game['name'],
hours_played=hours,
progress_percent=progress,
steam_appid=game['appid'],
)
imported += 1
del request.session['steam_games']
return redirect(f"{reverse('backlogger:list')}?category=games&imported={imported}")
@login_required
def live(request):
items = (
Item.objects
.filter(user=request.user)
.order_by('-updated_at')[:10]
)
return render(request, 'backlogger/live.html', {'items': items})
@login_required
def debug_delete_all(request):
if not settings.DEBUG:
return redirect('backlogger:list')
if request.method == 'POST':
Item.objects.filter(user=request.user).delete()
return redirect('backlogger:list')
@login_required
def steam_sync_login(request):
callback = request.build_absolute_uri(reverse('backlogger:steam_sync_callback'))
realm = f"{request.scheme}://{request.get_host()}"
return redirect(steam_api.build_auth_url(callback, realm))
@login_required
def steam_sync_callback(request):
steam_id = steam_api.verify_and_get_steam_id(request.GET.dict())
if not steam_id:
return redirect(f"{reverse('backlogger:list')}?sync_error=1")
api_key = getattr(settings, 'STEAM_API_KEY', '')
if not api_key:
return redirect(f"{reverse('backlogger:list')}?sync_error=1")
try:
games = steam_api.get_owned_games(api_key, steam_id)
except Exception:
return redirect(f"{reverse('backlogger:list')}?sync_error=1")
hours_by_appid = {
g['appid']: round(g.get('playtime_forever', 0) / 60, 1)
for g in games
}
steam_items = Item.objects.filter(user=request.user, steam_appid__isnull=False)
synced = 0
for item in steam_items:
new_hours = hours_by_appid.get(item.steam_appid)
if new_hours is not None and new_hours != item.hours_played:
item.hours_played = new_hours
item.save(update_fields=['hours_played', 'updated_at'])
synced += 1
return redirect(f"{reverse('backlogger:list')}?category=games&synced={synced}")

View File

@@ -13,6 +13,8 @@ else:
print(f'{Mineral.objects.count()} minerals already loaded')
"
python manage.py run_hltb_worker &
exec gunicorn kboris.wsgi:application \
--bind 0.0.0.0:8080 \
--workers 2 \

View File

@@ -5,6 +5,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'dev-insecure-key')
DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true'
STEAM_API_KEY = os.environ.get('STEAM_API_KEY', '')
ALLOWED_HOSTS = [
'k-boris.tech',

View File

@@ -4,3 +4,4 @@ whitenoise>=6.6
requests>=2.31
beautifulsoup4>=4.12
deep-translator>=1.11
howlongtobeatpy>=1.0.21