Compare commits

..

1 Commits

Author SHA1 Message Date
a8a48644af Merge pull request 'dev' (#1) from dev into main
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #1
2026-03-31 15:25:51 -04:00
34 changed files with 591 additions and 1504 deletions

119
CLAUDE.md
View File

@@ -1,119 +0,0 @@
# 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,14 +4,3 @@ from django.apps import AppConfig
class BackloggerConfig(AppConfig): class BackloggerConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'backlogger' 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 import forms
from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User from django.contrib.auth.models import User
from .models import Item, UserProfile from .models import Item
class SignupForm(UserCreationForm): class SignupForm(UserCreationForm):
@@ -54,16 +54,3 @@ class ItemForm(forms.ModelForm):
cleaned_data['watched'] = None cleaned_data['watched'] = None
cleaned_data['duration_minutes'] = None cleaned_data['duration_minutes'] = None
return cleaned_data 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',
}

View File

@@ -1,50 +0,0 @@
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

@@ -1,42 +0,0 @@
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

@@ -1,26 +0,0 @@
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

@@ -1,20 +0,0 @@
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

@@ -1,25 +0,0 @@
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

@@ -1,25 +0,0 @@
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

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

View File

@@ -1,24 +0,0 @@
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

@@ -1,23 +0,0 @@
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

@@ -1,16 +0,0 @@
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

@@ -1,16 +0,0 @@
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,19 +2,6 @@ from django.contrib.auth.models import User
from django.db import models 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): class Item(models.Model):
GAMES = 'games' GAMES = 'games'
BOOKS = 'books' BOOKS = 'books'
@@ -27,21 +14,9 @@ class Item(models.Model):
(OTHER, 'Other'), (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) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='items', null=True)
category = models.CharField(max_length=10, choices=CATEGORY_CHOICES) 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) name = models.CharField(max_length=200)
progress_percent = models.FloatField(default=0.0) progress_percent = models.FloatField(default=0.0)
favorite = models.BooleanField(default=False) favorite = models.BooleanField(default=False)
@@ -60,15 +35,6 @@ class Item(models.Model):
watched = models.BooleanField(null=True, blank=True) watched = models.BooleanField(null=True, blank=True)
duration_minutes = models.IntegerField(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: class Meta:
ordering = ['-favorite', 'name'] ordering = ['-favorite', 'name']

View File

@@ -1,105 +0,0 @@
/* ── 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;
}

View File

@@ -1,50 +0,0 @@
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

@@ -1,26 +0,0 @@
{% 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

@@ -1,39 +0,0 @@
{% 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,39 +1,144 @@
{% extends 'backlogger/base.html' %} <!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; }
{% block title %}{{ action }} Item — Backlogger{% endblock %} .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 styles %} .container {
<style> max-width: 520px;
.container { max-width: 520px; margin: 3rem auto; padding: 0 1.5rem; } margin: 3rem auto;
padding: 0 1.5rem;
}
.card { background: #0a0f1e; border: 1px solid #1e293b; border-radius: 12px; padding: 2rem; } .card {
h1 { font-size: 1.35rem; margin-bottom: 1.75rem; letter-spacing: -0.02em; } 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 { margin-bottom: 1.25rem; }
.field input[type="range"] { width: 100%; accent-color: #38bdf8; cursor: pointer; margin-top: 0.2rem; } .field label {
.progress-label { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.2rem; } display: block;
.progress-val { color: #38bdf8; font-variant-numeric: tabular-nums; font-size: 0.9rem; } 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; }
.checkbox-row { display: flex; align-items: center; gap: 0.5rem; } .field input[type="range"] {
.checkbox-row input[type="checkbox"] { accent-color: #38bdf8; width: 1rem; height: 1rem; cursor: pointer; } width: 100%;
.checkbox-row label { font-size: 0.9rem; color: #e2e8f0; text-transform: none; letter-spacing: 0; margin-bottom: 0; } 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; }
.section-divider { border: none; border-top: 1px solid #1e293b; margin: 1.5rem 0 1.25rem; } .checkbox-row {
.section-title { font-size: 0.72rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 1rem; } display: flex;
.optional { color: #475569; font-size: 0.7rem; margin-left: 0.3rem; text-transform: none; letter-spacing: 0; } 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;
}
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .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;
}
.actions { display: flex; justify-content: space-between; align-items: center; margin-top: 2rem; } .optional { color: #475569; font-size: 0.7rem; margin-left: 0.3rem; text-transform: none; letter-spacing: 0; }
.btn { padding: 0.5rem 1.25rem; font-size: 0.875rem; }
</style>
{% endblock %}
{% block nav %} .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
<a href="{% url 'backlogger:list' %}">&larr; Back to backlogger</a>
{% endblock %} .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 content %}
<div class="container"> <div class="container">
<div class="card"> <div class="card">
<h1>{{ action }} item</h1> <h1>{{ action }} item</h1>
@@ -53,7 +158,7 @@
{{ form.name.errors }} {{ form.name.errors }}
</div> </div>
<div class="field" data-hide-status="unending"> <div class="field">
<div class="progress-label"> <div class="progress-label">
<label for="{{ form.progress_percent.id_for_label }}">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> <span class="progress-val" id="progress-display">{{ form.progress_percent.value|default:0|floatformat:0 }}%</span>
@@ -69,7 +174,8 @@
</div> </div>
</div> </div>
<div class="cat-section" data-show-category="games"> <!-- Games fields -->
<div id="section-games" class="cat-section" style="display:none">
<hr class="section-divider"> <hr class="section-divider">
<div class="section-title">Games</div> <div class="section-title">Games</div>
<div class="two-col"> <div class="two-col">
@@ -78,23 +184,16 @@
{{ form.hours_played }} {{ form.hours_played }}
{{ form.hours_played.errors }} {{ form.hours_played.errors }}
</div> </div>
<div class="field" data-hide-status="unending"> <div class="field">
<label>Total hours <span class="optional">optional</span></label> <label>Total hours <span class="optional">optional</span></label>
{{ form.total_hours }} {{ form.total_hours }}
{{ form.total_hours.errors }} {{ 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> </div>
</div> </div>
<div class="cat-section" data-show-category="books"> <!-- Books fields -->
<div id="section-books" class="cat-section" style="display:none">
<hr class="section-divider"> <hr class="section-divider">
<div class="section-title">Books</div> <div class="section-title">Books</div>
<div class="two-col"> <div class="two-col">
@@ -111,11 +210,12 @@
</div> </div>
</div> </div>
<div class="cat-section" data-show-category="films"> <!-- Films fields -->
<div id="section-films" class="cat-section" style="display:none">
<hr class="section-divider"> <hr class="section-divider">
<div class="section-title">Films</div> <div class="section-title">Films</div>
<div class="two-col"> <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"> <div class="checkbox-row">
{{ form.watched }} {{ form.watched }}
<label for="{{ form.watched.id_for_label }}">Watched</label> <label for="{{ form.watched.id_for_label }}">Watched</label>
@@ -136,30 +236,25 @@
</form> </form>
</div> </div>
</div> </div>
{% endblock %}
{% block scripts %}
<script> <script>
const catSelect = document.getElementById('{{ form.category.id_for_label }}'); const catSelect = document.getElementById('{{ form.category.id_for_label }}');
const progressRange = document.getElementById('{{ form.progress_percent.id_for_label }}'); const progressRange = document.getElementById('{{ form.progress_percent.id_for_label }}');
const progressDisplay = document.getElementById('progress-display'); const progressDisplay = document.getElementById('progress-display');
const itemStatus = '{{ item.status|default:"active" }}';
function updateVisibility() { function updateSections() {
const cat = catSelect.value; document.querySelectorAll('.cat-section').forEach(el => el.style.display = 'none');
document.querySelectorAll('[data-show-category]').forEach(el => { const sec = document.getElementById('section-' + catSelect.value);
el.style.display = el.dataset.showCategory === cat ? 'block' : 'none'; if (sec) sec.style.display = 'block';
});
document.querySelectorAll('[data-hide-status]').forEach(el => {
el.style.display = el.dataset.hideStatus === itemStatus ? 'none' : '';
});
} }
catSelect.addEventListener('change', updateVisibility); catSelect.addEventListener('change', updateSections);
updateVisibility(); updateSections();
progressRange.addEventListener('input', function() { progressRange.addEventListener('input', function() {
progressDisplay.textContent = Math.round(this.value) + '%'; progressDisplay.textContent = Math.round(this.value) + '%';
}); });
</script> </script>
{% endblock %}
</body>
</html>

View File

@@ -1,121 +1,187 @@
{% extends 'backlogger/base.html' %} <!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; }
{% block title %}Library — Kill My Backlog{% endblock %} .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 styles %} .container { max-width: 1100px; margin: 0 auto; padding: 2rem; }
<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 {
.top-bar h1 { font-size: 1.75rem; letter-spacing: -0.03em; } display: flex;
.top-bar .count { color: #64748b; font-size: 1rem; font-weight: 400; margin-left: 0.5rem; } 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; }
.filter-bar { display: flex; justify-content: space-between; align-items: flex-end; border-bottom: 1px solid #1e293b; margin-bottom: 1.5rem; } .btn {
.tabs { display: flex; gap: 0; } display: inline-block;
.tab { padding: 0.6rem 1rem; font-size: 0.85rem; color: #64748b; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; } padding: 0.45rem 1rem;
.tab:hover { color: #e2e8f0; } border-radius: 6px;
.tab.active { color: #38bdf8; border-bottom-color: #38bdf8; } font-size: 0.85rem;
.tab-sep { width: 1px; background: #1e293b; margin: 0.5rem 0.25rem; align-self: stretch; } 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; }
.sort-wrap { padding-bottom: 0.75rem; } .filter-bar {
.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; } 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; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; } .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;
}
.card { background: #0a0f1e; border: 1px solid #1e293b; border-radius: 10px; padding: 1.1rem 1.25rem; display: flex; flex-direction: column; } .grid {
.card-top { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.6rem; } display: grid;
.badge { font-size: 0.62rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; padding: 0.18rem 0.45rem; border-radius: 4px; } grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
.badge-games { background: #3b2f6a; color: #a78bfa; } gap: 1rem;
.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; } .card {
.progress-bar { height: 4px; background: #1e293b; border-radius: 2px; margin-bottom: 0.3rem; } background: #0a0f1e;
.progress-fill { height: 100%; background: #38bdf8; border-radius: 2px; min-width: 0; } border: 1px solid #1e293b;
.card-stat { font-size: 0.78rem; color: #38bdf8; font-variant-numeric: tabular-nums; margin-bottom: 0.3rem; } border-radius: 10px;
.card-info { font-size: 0.76rem; color: #64748b; min-height: 1.1em; margin-bottom: 0.85rem; flex: 1; } padding: 1.1rem 1.25rem;
.card-actions { display: flex; gap: 0.4rem; } display: flex;
.card-actions form { display: inline; } 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; }
.empty { text-align: center; padding: 5rem 2rem; color: #64748b; } .card-name {
.empty a { color: #38bdf8; } 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; }
@keyframes complete-glow { .card-actions { display: flex; gap: 0.4rem; }
0% { box-shadow: 0 0 0px 0px rgba(255,255,255,0); transform: scale(1); } .card-actions form { display: inline; }
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; }
body[data-theme="light"] .card { background: #ffffff; border-color: #e2e8f0; } .empty {
body[data-theme="light"] .progress-bar { background: #e2e8f0; } text-align: center;
body[data-theme="light"] .progress-fill { background: #0284c7; } padding: 5rem 2rem;
body[data-theme="light"] .card-stat { color: #0284c7; } color: #64748b;
body[data-theme="light"] .filter-bar { border-color: #e2e8f0; } }
body[data-theme="light"] .tab-sep { background: #e2e8f0; } .empty a { color: #38bdf8; }
body[data-theme="light"] .tab.active { color: #0284c7; border-bottom-color: #0284c7; } </style>
body[data-theme="light"] .sort-wrap select { background: #f1f5f9; color: #0f172a; border-color: #cbd5e1; } </head>
body[data-theme="light"] .empty a { color: #0284c7; } <body>
</style>
{% endblock %}
{% block nav %} <header class="site-header">
<a href="{% url 'backlogger:profile' %}">{{ request.user.profile.display_name|default:request.user.username }}</a> <a class="brand" href="/">k-boris.tech</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> <nav>
{% endblock %} <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 content %}
<div class="container"> <div class="container">
<div class="top-bar"> <div class="top-bar">
<h1>Library <span class="count">{{ items|length }}</span></h1> <h1>Backlogger <span class="count">{{ items|length }}</span></h1>
<div style="display:flex;gap:0.5rem;align-items:center"> <a href="{% url 'backlogger:add' %}" class="btn btn-primary">+ Add item</a>
{% 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>
<div class="filter-bar"> <div class="filter-bar">
<div class="tabs"> <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> <a href="?sort={{ sort }}" class="tab {% if not category %}active{% endif %}">All</a>
{% for val, label in categories %} {% for val, label in categories %}
<a href="?category={{ val }}&sort={{ sort }}" class="tab {% if category == val %}active{% endif %}">{{ label }}</a> <a href="?category={{ val }}&sort={{ sort }}" class="tab {% if category == val %}active{% endif %}">{{ label }}</a>
{% endfor %} {% endfor %}
{% endif %}
</div> </div>
<div class="sort-wrap"> <div class="sort-wrap">
<select onchange="location='?shelf={{ shelf }}&category={{ category }}&sort='+this.value"> <select onchange="location='?category={{ category }}&sort='+this.value">
<option value="fav" {% if sort == 'fav' %}selected{% endif %}>Favorites first</option> <option value="fav" {% if sort == 'fav' %}selected{% endif %}>Favorites first</option>
<option value="az" {% if sort == 'az' %}selected{% endif %}>A → Z</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="za" {% if sort == 'za' %}selected{% endif %}>Z → A</option>
<option value="newest" {% if sort == 'newest' %}selected{% endif %}>Newest first</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="oldest" {% if sort == 'oldest' %}selected{% endif %}>Oldest first</option>
<option value="progress" {% if sort == 'progress' %}selected{% endif %}>Most complete</option> <option value="progress" {% if sort == 'progress' %}selected{% endif %}>Most complete</option>
<option value="updated" {% if sort == 'updated' %}selected{% endif %}>Recently updated</option>
</select> </select>
</div> </div>
</div> </div>
@@ -141,11 +207,6 @@
{% if item.hours_played is not None %} {% 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 %} {{ item.hours_played|floatformat:1 }}h played{% if item.total_hours %} / {{ item.total_hours|floatformat:0 }}h total{% endif %}
{% 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' %} {% elif item.category == 'books' %}
{% if item.pages_read is not None %} {% if item.pages_read is not None %}
{{ item.pages_read }} pages{% if item.total_pages %} / {{ item.total_pages }} total{% endif %} {{ item.pages_read }} pages{% if item.total_pages %} / {{ item.total_pages }} total{% endif %}
@@ -157,35 +218,6 @@
<div class="card-actions"> <div class="card-actions">
<a href="{% url 'backlogger:edit' item.pk %}" class="btn btn-sm btn-outline">Edit</a> <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;?')"> <form method="post" action="{% url 'backlogger:delete' item.pk %}" onsubmit="return confirm('Delete &quot;{{ item.name }}&quot;?')">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-danger">Delete</button> <button type="submit" class="btn btn-danger">Delete</button>
@@ -200,37 +232,6 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endblock %}
{% block scripts %} </body>
<script> </html>
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

@@ -1,111 +0,0 @@
{% 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,41 +1,123 @@
{% extends 'backlogger/base_auth.html' %} <!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>
{% block title %}Log in — k-boris.tech{% endblock %} {% 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 content %} <form method="post">
<div class="auth-card"> {% csrf_token %}
<a class="auth-brand" href="/">k-boris.tech</a> <input type="hidden" name="next" value="{{ next }}">
<p class="auth-subtitle">Backlogger</p>
{% if form.non_field_errors %} <div class="field">
{% for error in form.non_field_errors %} <label for="{{ form.username.id_for_label }}">Username</label>
{% if 'inactive' in error|lower or 'active' in error|lower %} {{ form.username }}
<div class="error-banner">Your account is pending approval. You'll be able to log in once it's activated.</div> {{ form.username.errors }}
{% else %} </div>
<div class="error-banner">Invalid username or password.</div> <div class="field">
{% endif %} <label for="{{ form.password.id_for_label }}">Password</label>
{% endfor %} {{ form.password }}
{% endif %} {{ form.password.errors }}
</div>
<form method="post"> <button type="submit" class="btn">Log in</button>
{% csrf_token %} </form>
<input type="hidden" name="next" value="{{ next }}"> <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>
<div class="field"> </p>
<label for="{{ form.username.id_for_label }}">Username</label> </div>
{{ form.username }} </body>
{{ form.username.errors }} </html>
</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

@@ -1,70 +0,0 @@
{% 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,46 +1,117 @@
{% extends 'backlogger/base_auth.html' %} <!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>
{% block title %}Sign up — Backlogger{% endblock %} <form method="post">
{% csrf_token %}
{% block styles %} <div class="field">
<style> <label for="{{ form.username.id_for_label }}">Username</label>
.help { font-size: 0.75rem; color: #475569; margin-top: 0.3rem; } {{ form.username }}
</style> {{ form.username.errors }}
{% endblock %} </div>
{% block content %} <div class="field">
<div class="auth-card"> <label for="{{ form.email.id_for_label }}">Email</label>
<a class="auth-brand" href="/">killmybacklog.com</a> {{ form.email }}
<p class="auth-subtitle">Create an account</p> {{ form.email.errors }}
</div>
<form method="post"> <div class="field">
{% csrf_token %} <label for="{{ form.password1.id_for_label }}">Password</label>
{{ form.password1 }}
{{ form.password1.errors }}
</div>
<div class="field"> <div class="field">
<label for="{{ form.username.id_for_label }}">Username</label> <label for="{{ form.password2.id_for_label }}">Confirm password</label>
{{ form.username }} {{ form.password2 }}
{{ form.username.errors }} {{ form.password2.errors }}
</div> </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 btn-primary" style="width:100%;margin-top:0.5rem">Request account</button> <button type="submit" class="btn">Request account</button>
</form> </form>
<p style="text-align:center;margin-top:1.25rem;font-size:0.83rem;color:#64748b;"> <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> Already have an account? <a href="/accounts/login/" style="color:#38bdf8; text-decoration:none;">Log in</a>
</p> </p>
</div> </div>
{% endblock %} </body>
</html>

View File

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

View File

@@ -1,147 +0,0 @@
{% 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,16 +4,7 @@ from . import views
app_name = 'backlogger' app_name = 'backlogger'
urlpatterns = [ urlpatterns = [
path('', views.item_list, name='list'), path('', views.item_list, name='list'),
path('live/', views.live, name='live'),
path('add/', views.item_add, name='add'), path('add/', views.item_add, name='add'),
path('<int:pk>/edit/', views.item_edit, name='edit'), path('<int:pk>/edit/', views.item_edit, name='edit'),
path('<int:pk>/delete/', views.item_delete, name='delete'), 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,11 +1,7 @@
from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.urls import reverse from .models import Item
from .models import Item, UserProfile from .forms import ItemForm, SignupForm
from .forms import ItemForm, ProfileForm, SignupForm
from . import steam as steam_api
from . import hltb as hltb_api
def signup(request): def signup(request):
@@ -28,38 +24,15 @@ SORT_MAP = {
'newest': ['-created_at'], 'newest': ['-created_at'],
'oldest': ['created_at'], 'oldest': ['created_at'],
'progress': ['-progress_percent'], '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 @login_required
def item_list(request): def item_list(request):
category = request.GET.get('category', '') category = request.GET.get('category', '')
sort = request.GET.get('sort', '') sort = request.GET.get('sort', 'fav')
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, status=shelf) items = Item.objects.filter(user=request.user)
if category: if category:
items = items.filter(category=category) items = items.filter(category=category)
items = items.order_by(*SORT_MAP.get(sort, SORT_MAP['fav'])) items = items.order_by(*SORT_MAP.get(sort, SORT_MAP['fav']))
@@ -68,9 +41,7 @@ def item_list(request):
'items': items, 'items': items,
'category': category, 'category': category,
'sort': sort, 'sort': sort,
'shelf': shelf,
'categories': Item.CATEGORY_CHOICES, 'categories': Item.CATEGORY_CHOICES,
'debug': settings.DEBUG,
}) })
@@ -82,7 +53,6 @@ def item_add(request):
item = form.save(commit=False) item = form.save(commit=False)
item.user = request.user item.user = request.user
item.save() item.save()
hltb_api.apply_to_item(item)
return redirect('backlogger:list') return redirect('backlogger:list')
else: else:
form = ItemForm() form = ItemForm()
@@ -95,158 +65,15 @@ def item_edit(request, pk):
if request.method == 'POST': if request.method == 'POST':
form = ItemForm(request.POST, instance=item) form = ItemForm(request.POST, instance=item)
if form.is_valid(): if form.is_valid():
updated = form.save() form.save()
if 'name' in form.changed_data or 'category' in form.changed_data:
hltb_api.apply_to_item(updated)
return redirect('backlogger:list') return redirect('backlogger:list')
else: else:
form = ItemForm(instance=item) form = ItemForm(instance=item)
return render(request, 'backlogger/item_form.html', {'form': form, 'action': 'Edit', 'item': 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 @login_required
def item_delete(request, pk): def item_delete(request, pk):
if request.method == 'POST': if request.method == 'POST':
get_object_or_404(Item, pk=pk, user=request.user).delete() get_object_or_404(Item, pk=pk, user=request.user).delete()
return redirect('backlogger:list') 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,8 +13,6 @@ else:
print(f'{Mineral.objects.count()} minerals already loaded') print(f'{Mineral.objects.count()} minerals already loaded')
" "
python manage.py run_hltb_worker &
exec gunicorn kboris.wsgi:application \ exec gunicorn kboris.wsgi:application \
--bind 0.0.0.0:8080 \ --bind 0.0.0.0:8080 \
--workers 2 \ --workers 2 \

View File

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

View File

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