added user profile page
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-04-02 20:06:38 +03:00
parent b723214c86
commit 457b8c8443
9 changed files with 356 additions and 4 deletions

119
CLAUDE.md Normal file
View File

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

View File

@@ -4,3 +4,14 @@ from django.apps import AppConfig
class BackloggerConfig(AppConfig): 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 from .models import Item, UserProfile
class SignupForm(UserCreationForm): class SignupForm(UserCreationForm):
@@ -54,3 +54,16 @@ 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

@@ -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 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'

View File

@@ -166,13 +166,31 @@
color: #64748b; color: #64748b;
} }
.empty a { color: #38bdf8; } .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> </style>
</head> </head>
<body> <body data-theme="{{ request.session.theme|default:'dark' }}">
<header class="site-header"> <header class="site-header">
<a class="brand" href="/">k-boris.tech</a> <a class="brand" href="/">k-boris.tech</a>
<nav> <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> <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> </nav>
</header> </header>

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

@@ -8,6 +8,7 @@ urlpatterns = [
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('<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/login/', views.steam_login, name='steam_login'),
path('steam/callback/', views.steam_callback, name='steam_callback'), path('steam/callback/', views.steam_callback, name='steam_callback'),
path('steam/import/', views.steam_import, name='steam_import'), path('steam/import/', views.steam_import, name='steam_import'),

View File

@@ -2,8 +2,8 @@ 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 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 steam as steam_api
from . import hltb as hltb_api from . import hltb as hltb_api
@@ -31,6 +31,21 @@ 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 @login_required
def item_list(request): def item_list(request):
category = request.GET.get('category', '') category = request.GET.get('category', '')