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