Compare commits

...

16 Commits

Author SHA1 Message Date
Boris Kamenev
41b1af1e3b update doc
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-11 14:58:50 +03:00
39155b12b8 Merge pull request 'dev' (#2) from dev into main
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #2
2026-04-02 13:26:44 -04: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
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
8f34d9388f Fix logout 405: use POST form instead of GET link
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Django 5+ LogoutView rejects GET requests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 22:23:32 +03:00
e119e79751 Add dev environment: debug subdomain, IP restriction, separate container
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Add debug.killmybacklog.com nginx config (IP-restricted to 164.215.8.152)
- Add django-dev container on port 8081 in docker-compose
- Add dev branch pipeline step deploying to django-dev
- Add killmybacklog.com and debug subdomain to ALLOWED_HOSTS/CSRF origins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 22:09:53 +03:00
23 changed files with 1164 additions and 22 deletions

View File

@@ -1,6 +1,6 @@
when:
branch: main
event: [push, manual]
branch: [main, dev]
steps:
test:
@@ -10,7 +10,6 @@ steps:
- python manage.py test backlogger --verbosity=2
environment:
DJANGO_SECRET_KEY: ci-test-key
DATABASE_URL: sqlite:///tmp/test.db
build-and-deploy:
image: docker:cli
@@ -23,3 +22,19 @@ steps:
- docker build -t k-boris-website /opt/services/app/
- docker compose -f /opt/services/docker-compose.yml up -d --no-deps django
depends_on: [test]
when:
branch: main
build-and-deploy-dev:
image: docker:cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/services:/opt/services
commands:
- mkdir -p /opt/services/app-dev
- cp -r . /opt/services/app-dev/
- docker build -t k-boris-website-dev /opt/services/app-dev/
- docker compose -f /opt/services/docker-compose.yml up -d --no-deps django-dev
depends_on: [test]
when:
branch: dev

151
CLAUDE.md Normal file
View File

@@ -0,0 +1,151 @@
# 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 |
| Dozzle (logs) | https://logs.k-boris.tech | `dozzle` | 8888 |
## Monitoring & Logs
**Dozzle** at https://logs.k-boris.tech provides a web UI for all Docker container logs.
Login: username `boris` (password in 1Password / your password manager).
Use this instead of SSH log-tailing during development.
Auth config lives at `/opt/services/dozzle/users.yml` on the VPS.
To update password: `docker run --rm httpd:alpine htpasswd -nbB boris 'newpassword'` → paste hash into users.yml.
## Claude Code tooling
### Playwright MCP (browser + screenshots)
Allows Claude to browse the live sites and take screenshots without manual intervention.
Add to `~/.claude/settings.json`:
```json
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless"]
}
}
}
```
Requires Node.js / npx available on the machine. `@playwright/mcp` is downloaded on first use.
Restart Claude Code after editing settings.json.
## 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. Check logs at https://logs.k-boris.tech instead of 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',
}

48
backlogger/hltb.py Normal file
View File

@@ -0,0 +1,48 @@
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. Silently does nothing on failure."""
if item.category != 'games' or not item.name:
return
data = fetch(item.name)
if data is None:
return
item.hltb_main = data['main']
item.hltb_extra = data['extra']
item.hltb_complete = data['complete']
item.save(update_fields=['hltb_main', 'hltb_extra', 'hltb_complete'])

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

@@ -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,11 @@ class Item(models.Model):
watched = models.BooleanField(null=True, blank=True)
duration_minutes = 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)
class Meta:
ordering = ['-favorite', 'name']

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

@@ -158,7 +158,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>
@@ -175,7 +175,7 @@
</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 +184,24 @@
{{ 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">
@@ -211,7 +219,7 @@
</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">
@@ -241,15 +249,24 @@
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;
// Show only the section matching the selected category
document.querySelectorAll('[data-show-category]').forEach(el => {
el.style.display = el.dataset.showCategory === cat ? 'block' : 'none';
});
// Hide fields that are not applicable for the current status
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) + '%';

View File

@@ -53,6 +53,21 @@
.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; }
@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;
}
.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; }
.filter-bar {
display: flex;
@@ -72,6 +87,7 @@
}
.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; }
.sort-wrap { padding-bottom: 0.75rem; }
.sort-wrap select {
@@ -150,32 +166,63 @@
color: #64748b;
}
.empty a { color: #38bdf8; }
/* Light theme overrides */
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"] .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"] .btn-outline { color: #64748b; border-color: #cbd5e1; }
body[data-theme="light"] .btn-primary { background: #0284c7; }
body[data-theme="light"] .site-header nav a { color: #64748b; }
body[data-theme="light"] .empty a { color: #0284c7; }
</style>
</head>
<body>
<body data-theme="{{ request.session.theme|default:'dark' }}">
<header class="site-header">
<a class="brand" href="/">k-boris.tech</a>
<nav>
<a href="{% url 'logout' %}">Log out</a>
<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>
</nav>
</header>
<div class="container">
<div class="top-bar">
<h1>Backlogger <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 %}
<a href="{% url 'backlogger:steam_login' %}" class="btn btn-outline" style="font-size:0.82rem">&#9654; Steam</a>
<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>
@@ -207,6 +254,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 +270,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>
@@ -233,5 +314,37 @@
{% endif %}
</div>
<script>
function playDing() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
// Two-note ding: base note then a fifth above
[[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>
</body>
</html>

View File

@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Profile — 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 { color: inherit; text-decoration: none; }
.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; }
.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; }
label { display: block; font-size: 0.82rem; color: #94a3b8; margin-bottom: 0.4rem; }
input[type="text"], select {
width: 100%;
background: #0f172a;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
font-family: inherit;
}
input[type="text"]:focus, select:focus {
outline: none;
border-color: #38bdf8;
}
select { cursor: pointer; }
.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; }
.btn {
display: inline-block;
padding: 0.5rem 1.25rem;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
border: none;
font-family: inherit;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
.btn-primary { background: #38bdf8; color: #0f172a; font-weight: 600; }
.btn-outline { background: transparent; color: #94a3b8; border: 1px solid #334155; }
.saved-msg { font-size: 0.82rem; color: #34d399; display: none; }
.saved-msg.show { display: inline; }
/* Light theme overrides */
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"] .card { background: #ffffff; border-color: #e2e8f0; }
body[data-theme="light"] input[type="text"],
body[data-theme="light"] select { background: #f8fafc; color: #0f172a; border-color: #cbd5e1; }
body[data-theme="light"] .divider { border-color: #e2e8f0; }
body[data-theme="light"] .btn-primary { background: #0284c7; }
body[data-theme="light"] .btn-outline { color: #64748b; border-color: #cbd5e1; }
</style>
</head>
<body data-theme="{{ request.session.theme|default:'dark' }}">
<header class="site-header">
<a class="brand" href="/">k-boris.tech</a>
<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>
</nav>
</header>
<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>
</body>
</html>

View File

@@ -0,0 +1,248 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Import from Steam — 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 { color: inherit; text-decoration: none; }
.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; }
.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; }
.error-banner {
background: #450a0a;
border: 1px solid #f87171;
border-radius: 8px;
color: #fca5a5;
padding: 0.85rem 1.1rem;
margin-bottom: 1.5rem;
}
.toolbar {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 1rem;
}
.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-outline { background: transparent; color: #94a3b8; border: 1px solid #334155; }
.btn-text { background: none; border: none; color: #64748b; font-size: 0.8rem; cursor: pointer; font-family: inherit; padding: 0; }
.btn-text:hover { color: #e2e8f0; }
.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;
text-decoration: none;
transition: background 0.15s;
}
.steam-btn:hover { background: #2a475e; }
.steam-logo { width: 20px; height: 20px; }
</style>
</head>
<body>
<header class="site-header">
<a class="brand" href="/">killmybacklog.com</a>
<nav><a href="{% url 'backlogger:list' %}">← Back to backlog</a></nav>
</header>
<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>
<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>
</body>
</html>

View File

@@ -7,4 +7,9 @@ urlpatterns = [
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'),
]

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):
@@ -27,12 +31,30 @@ SORT_MAP = {
}
@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')
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,6 +63,7 @@ def item_list(request):
'items': items,
'category': category,
'sort': sort,
'shelf': shelf,
'categories': Item.CATEGORY_CHOICES,
})
@@ -53,6 +76,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 +89,100 @@ 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 = Item.objects.create(
user=request.user,
category=Item.GAMES,
name=game['name'],
hours_played=hours,
progress_percent=progress,
)
hltb_api.apply_to_item(item)
imported += 1
del request.session['steam_games']
return redirect(f"{reverse('backlogger:list')}?category=games&imported={imported}")

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',
@@ -12,6 +13,7 @@ ALLOWED_HOSTS = [
'admin.k-boris.tech',
'killmybacklog.com',
'www.killmybacklog.com',
'debug.killmybacklog.com',
'localhost',
'127.0.0.1',
]
@@ -22,6 +24,7 @@ CSRF_TRUSTED_ORIGINS = [
'https://admin.k-boris.tech',
'https://killmybacklog.com',
'https://www.killmybacklog.com',
'https://debug.killmybacklog.com',
]
INSTALLED_APPS = [

View File

@@ -0,0 +1,34 @@
server {
listen 80;
listen [::]:80;
server_name debug.killmybacklog.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name debug.killmybacklog.com;
ssl_certificate /etc/letsencrypt/live/debug.killmybacklog.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/debug.killmybacklog.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 10m;
allow 164.215.8.152;
deny all;
location = / {
return 301 /backlogger/;
}
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

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