chore: remove legacy Django app and runtime
Some checks failed
CI/CD / ci (push) Failing after 1m2s
CI/CD / deploy (push) Has been skipped

The public blog at richardnixon.dev is fully served by the Hugo
companion repo (richardnixon.dev-hugo) since the cutover. This commit
drops everything Django-related from this repo:

- Containers stopped and removed (already done on the host):
  platform-web, platform-celery, platform-celery-beat,
  platform-frontend, platform-db, platform-redis.
- Volumes dropped: platform-db-data, platform-redis-data,
  platform-static, platform-media (no backup; DB only held an empty draft).
- docker-compose.yml: removed all platform-* services, the
  platform-internal network, and the postgres-exporter / redis-exporter
  that only monitored the dropped DB/Redis pair.
- traefik/dynamic.yml: removed platform-api router and the
  platform-api / platform-frontend service definitions.
- Source tree: deleted apps/, config/, docker/, frontend/, locale/,
  templates/, media/, static/, requirements/, manage.py,
  requirements.txt, conftest.py, pytest.ini.
- docs/development.md removed (was 100% Django dev guide).
- README and docs/deployment.md rewritten as infra-only references.
This commit is contained in:
authentik Default Admin 2026-05-27 17:41:28 +02:00
parent 1e8c7ccf6f
commit a87db639eb
128 changed files with 154 additions and 8657 deletions

403
README.md
View file

@ -2,30 +2,30 @@
Self-hosted infrastructure (Traefik + Docker Compose) running every service on the `richardnixon.dev` apex and subdomains.
The public blog at `richardnixon.dev` is now a **static Hugo site** — its source lives in a separate repo: [`Richard/richardnixon.dev-hugo`](https://git.richardnixon.dev/Richard/richardnixon.dev-hugo). The Django app in `apps/` here is kept around as **legacy**: it still answers `/admin/`, `/api/`, `/media/`, `/static/` while the database is preserved, but its blog/portfolio/contact views are no longer the public surface.
The public blog at `richardnixon.dev` is a **static Hugo site** — its source lives in a companion repo: [`Richard/richardnixon.dev-hugo`](https://git.richardnixon.dev/Richard/richardnixon.dev-hugo). Deploys are driven by Forgejo Actions; the runner in this stack builds the site and writes directly into the `blog-static` bind-mount.
## Architecture
```mermaid
graph TD
DNS[Cloudflare DNS] --> Traefik[Traefik + SSL/TLS]
DNS[Cloudflare DNS] --> Traefik[Traefik + Let's Encrypt]
Traefik --> CrowdSec[CrowdSec IPS]
Traefik --> Blog[Hugo blog-static<br>richardnixon.dev]
Traefik --> Platform[Django legacy<br>richardnixon.dev/admin·/api]
Traefik --> WP[WordPress<br>richardemanu.com]
Traefik --> LocFlow[LocFlow API<br>locflow.richardnixon.dev]
Traefik --> EireScope[EireScope<br>eirescope.richardnixon.dev]
Traefik --> Forgejo[Forgejo<br>git.richardnixon.dev]
Traefik --> Forgejo[Forgejo + runner<br>git.richardnixon.dev]
Traefik --> Umami[Umami Analytics<br>analytics.richardnixon.dev]
Traefik --> Authentik[Authentik SSO<br>auth.richardnixon.dev]
Traefik --> Grafana[Grafana<br>status.richardnixon.dev]
Traefik --> Portainer[Portainer<br>portainer.richardnixon.dev]
Traefik --> VStatus[Valheim Status<br>valheim.richardnixon.dev]
Platform --> PlatformDB[(PostgreSQL + Redis<br>+ Celery)]
LocFlow --> LocFlowDB[(PostgreSQL)]
Umami --> UmamiDB[(PostgreSQL)]
Forgejo --> ForgejoDB[(PostgreSQL)]
Authentik --> AuthDB[(PostgreSQL + Redis)]
WP --> WPDB[(MariaDB)]
Grafana --> Prometheus[Prometheus + Loki]
VStatus --> Valheim[Valheim Server<br>UDP:2456-2458]
@ -35,56 +35,134 @@ graph TD
| Service | Domain | Description |
|---------|--------|-------------|
| Hugo blog (blog-static) | richardnixon.dev | Static blog (Hugo + PaperMod) — content in [`Richard/richardnixon.dev-hugo`](https://git.richardnixon.dev/Richard/richardnixon.dev-hugo) |
| Django legacy (platform-web) | richardnixon.dev/admin · /api | Admin + API for migration window; will be retired |
| Forgejo Actions runner | (internal) | CI runner for Hugo blog deploy (joins `forgejo-internal` network) |
| Hugo blog (blog-static) | richardnixon.dev | Static blog (Hugo + PaperMod) — content in [`richardnixon.dev-hugo`](https://git.richardnixon.dev/Richard/richardnixon.dev-hugo) |
| Forgejo | git.richardnixon.dev | Self-hosted Git forge (Actions enabled, SSO via Authentik) |
| Forgejo runner | (internal) | CI runner for Hugo blog deploys |
| WordPress | richardemanu.com | Personal site |
| LocFlow | locflow.richardnixon.dev | Localization automation platform (REST API) |
| EireScope | eirescope.richardnixon.dev | OSINT dashboard |
| Forgejo | git.richardnixon.dev | Self-hosted Git forge (public repos, SSO via Authentik, Actions enabled) |
| Umami | analytics.richardnixon.dev | Privacy-focused analytics |
| Authentik | auth.richardnixon.dev | SSO/OIDC provider |
| Grafana | status.richardnixon.dev | Observability dashboards |
| Portainer | portainer.richardnixon.dev | Docker management |
| Valheim Status | valheim.richardnixon.dev | Game server status page |
| Valheim Server | valheim.richardnixon.dev:2456 | Valheim dedicated server |
| Valheim Server | valheim.richardnixon.dev:2456 | Dedicated game server |
## Technology Stack
### Edge
- **Traefik v3** as reverse proxy with Let's Encrypt SSL
- **CrowdSec** as IPS with community threat intelligence + Traefik bouncer
- **Authentik** for SSO/OIDC on admin-only routes
### Public blog (`richardnixon.dev`)
- **Generator**: Hugo 0.123.7 extended + PaperMod theme (pinned `v8.0`)
- **Server**: nginx:alpine (`blog-static` service), volume bind-mounted from `/root/richardnixon.dev-hugo/public/`
- **CI**: Forgejo Actions on push to `main` of [`Richard/richardnixon.dev-hugo`](https://git.richardnixon.dev/Richard/richardnixon.dev-hugo)
- **i18n**: PT-BR (default) + EN, native Hugo language config
- **Hugo 0.123.7 extended** + PaperMod theme (pinned `v8.0`)
- **nginx:alpine** (`blog-static`) serves `/root/richardnixon.dev-hugo/public/` via bind-mount
- **Forgejo Actions** runner builds on push and copies output into the volume
### Legacy Django app (kept for `/admin` and `/api` only)
- **Backend**: Django 5.x, Celery, PostgreSQL 16, Redis 7
- **Frontend**: Django Templates, HTMX, TailwindCSS
- **Auth**: django-allauth (Google/GitHub OAuth), django-two-factor-auth (2FA)
- **i18n**: English + Portuguese (django-modeltranslation)
- **Routes**: limited to `/api`, `/admin`, `/static`, `/media` via Traefik dynamic.yml (priority 110); blog/portfolio/contact views are dormant.
### Observability
- **Prometheus, Grafana, Loki, Promtail, cAdvisor, Node Exporter** as the monitoring stack
### Infrastructure
- **Reverse Proxy**: Traefik v3 with Let's Encrypt SSL
- **Containers**: Docker Compose
- **Monitoring**: Prometheus, Grafana, Loki, Promtail, cAdvisor
- **Exporters**: Node Exporter, PostgreSQL Exporter, Redis Exporter
### Security
- **CrowdSec**: IPS with community threat intelligence + Traefik bouncer
### Host hardening
- **Fail2ban**: SSH brute-force protection (3 attempts = 24h ban)
- **GeoIP Blocking**: Country-based filtering for SSH
- **reCAPTCHA v3**: Contact form spam protection
- **GeoIP**: country-based filtering for SSH
- **CrowdSec**: web + SSH attack detection
## Project Structure
```
richardnixon.dev/
├── docs/
│ └── deployment.md
├── infrastructure/
│ ├── docker-compose.yml
│ ├── .env.example
│ ├── traefik/
│ │ ├── traefik.yml
│ │ └── dynamic.yml
│ ├── blog-static/
│ │ └── nginx.conf
│ ├── forgejo-runner/
│ │ ├── config.yml
│ │ └── entrypoint.sh
│ ├── prometheus/
│ ├── loki/
│ ├── promtail/
│ ├── grafana/
│ │ └── provisioning/
│ ├── crowdsec/
│ └── valheim-status/
└── README.md
```
## Quick Start
### Prerequisites
- Docker and Docker Compose v2
- Domain with DNS configured
### Deployment
1. Clone both repositories:
```bash
cd /root
git clone https://git.richardnixon.dev/Richard/richardnixon.dev.git
git clone --recurse-submodules https://git.richardnixon.dev/Richard/richardnixon.dev-hugo.git
```
2. Configure environment:
```bash
cd richardnixon.dev/infrastructure
cp .env.example .env
# Edit .env with your credentials
```
3. Start services:
```bash
docker compose up -d
```
Traefik provisions Let's Encrypt certificates on first start. Detailed steps live in [`docs/deployment.md`](docs/deployment.md), including Forgejo Actions runner registration.
## URL Routes
Configured in `infrastructure/traefik/dynamic.yml`.
| Path | Backend | Notes |
|------|---------|-------|
| `/` | blog-static (Hugo) | Redirects to `/pt-br/` (default content language) |
| `/pt-br/*`, `/en/*` | blog-static (Hugo) | Bilingual blog content |
| `/feed.xml`, `/sitemap.xml`, `/robots.txt` | blog-static (Hugo) | Static SEO/feed files |
All other domains route by `Host()` rule to their respective services.
## Environment Variables
See `infrastructure/.env.example` for the full list. Key categories:
| Category | Examples |
|----------|----------|
| Forgejo | `FORGEJO_DB_PASSWORD`, `FORGEJO_SECRET_KEY`, `FORGEJO_INTERNAL_TOKEN`, `FORGEJO_OAUTH2_JWT_SECRET`, `FORGEJO_RUNNER_REGISTRATION_TOKEN` |
| LocFlow | `LOCFLOW_DB_PASSWORD`, `LOCFLOW_SECRET_KEY`, `LOCFLOW_UMAMI_WEBSITE_ID` |
| EireScope | `EIRESCOPE_SECRET_KEY`, `EIRESCOPE_UMAMI_WEBSITE_ID` |
| Authentik | `AUTHENTIK_SECRET_KEY`, `AUTHENTIK_DB_PASSWORD` |
| Umami | `UMAMI_DB_PASSWORD`, `UMAMI_HASH_SALT` |
| WordPress | `MYSQL_ROOT_PASSWORD`, `WP_DB_PASSWORD` |
| CrowdSec | `CROWDSEC_BOUNCER_KEY` |
| Grafana | `GRAFANA_ADMIN_PASSWORD` |
| Valheim | `VALHEIM_PASSWORD`, `ADMINLIST_IDS`, `PERMITTEDLIST_IDS` |
## Grafana Dashboards
URL: https://status.richardnixon.dev
| Dashboard | Description |
|-----------|-------------|
| VPS System | CPU, memory, disk, load average, uptime |
| CrowdSec Security | Active bans, alerts, attack types |
| Traefik Proxy | Requests/s, latency, status codes |
| Database | PostgreSQL and Redis metrics |
| Network & Firewall | Bandwidth, TCP connections, security events |
| Container Logs | Real-time log viewer with search |
| Container Logs | Real-time log viewer with search (Loki-backed) |
| Valheim Server | Players online, server status, resource usage |
## Prometheus Metrics
@ -95,161 +173,39 @@ graph TD
| cadvisor | cadvisor:8080 | Container metrics |
| traefik | traefik:8080 | HTTP requests, latency |
| node | node-exporter:9100 | VPS system metrics |
| postgres | postgres-exporter:9187 | PostgreSQL stats |
| redis | redis-exporter:9121 | Redis stats |
| crowdsec | crowdsec:6060 | Security metrics |
| valheim | valheim-metrics:3903 | Game server metrics |
| forgejo | forgejo:3000 | Git forge metrics (repos, users, HTTP) |
## Project Structure
## Forgejo Actions (Hugo blog CI)
```
richardnixon.dev/
├── apps/
│ ├── accounts/ # Custom user model (email-based)
│ ├── blog/ # Blog posts, Markdown, RSS
│ ├── portfolio/ # Projects showcase
│ └── contact/ # Contact form with reCAPTCHA
├── config/
│ ├── settings/
│ │ ├── base.py
│ │ ├── development.py
│ │ └── production.py
│ ├── urls.py
│ ├── celery.py
│ └── wsgi.py
├── templates/
├── static/
├── locale/ # i18n translations
├── docker/
│ ├── Dockerfile
│ └── Dockerfile.full
└── infrastructure/
├── docker-compose.yml
├── .env.example
├── traefik/
├── prometheus/
├── loki/
├── promtail/
├── grafana/
│ └── provisioning/
│ ├── datasources/
│ └── dashboards/
├── crowdsec/
└── valheim-status/
```
The `forgejo-runner` service registers itself on first boot using `FORGEJO_RUNNER_REGISTRATION_TOKEN` and joins the `forgejo-internal` network so job containers can resolve the internal `forgejo` hostname for `actions/checkout`.
## Quick Start
Config (`infrastructure/forgejo-runner/config.yml`):
- `container.network: infrastructure_forgejo-internal` — without this, checkout fails to resolve `forgejo`.
- `container.docker_host: unix:///var/run/docker.sock` — required value; `"automatic"` is not accepted by recent runner versions.
- `valid_volumes: ["/root/richardnixon.dev-hugo/public"]` — the only host path the workflow is allowed to write to.
### Prerequisites
- Docker and Docker Compose
- Domain with DNS configured
The deploy workflow itself lives in the **Hugo repo** at `.forgejo/workflows/deploy.yml`. Pushing to its `main` branch builds Hugo (downloaded fresh per run) and copies `public/` into the bind-mount served by `blog-static`. End-to-end push → live is ~3060s.
### Deployment
1. Clone the repository:
```bash
git clone https://git.richardnixon.dev/Richard/richardnixon.dev.git
cd richardnixon.dev/infrastructure
```
For the public blog content (Hugo source), clone the companion repo:
```bash
git clone --recurse-submodules https://git.richardnixon.dev/Richard/richardnixon.dev-hugo.git
```
2. Create environment file:
```bash
cp .env.example .env
# Edit .env with your credentials
```
3. Start services:
```bash
docker compose up -d
```
4. Run migrations:
```bash
docker compose exec platform-web python manage.py migrate
docker compose exec platform-web python manage.py createsuperuser
```
## Environment Variables
See `infrastructure/.env.example` for all required variables:
### Django
| Variable | Description |
|----------|-------------|
| `DJANGO_SECRET_KEY` | Django secret key |
| `PLATFORM_DB_PASSWORD` | PostgreSQL password |
| `DJANGO_SUPERUSER_EMAIL` | Admin email (optional) |
| `RECAPTCHA_PUBLIC_KEY` | reCAPTCHA v3 site key |
| `RECAPTCHA_PRIVATE_KEY` | reCAPTCHA v3 secret key |
### Monitoring
| Variable | Description |
|----------|-------------|
| `GRAFANA_ADMIN_PASSWORD` | Grafana admin password |
| `CROWDSEC_BOUNCER_KEY` | CrowdSec bouncer API key |
### LocFlow
| Variable | Description |
|----------|-------------|
| `LOCFLOW_DB_PASSWORD` | LocFlow PostgreSQL password |
| `LOCFLOW_SECRET_KEY` | LocFlow Django secret key |
| `LOCFLOW_UMAMI_WEBSITE_ID` | Umami website ID for tracking |
### EireScope
| Variable | Description |
|----------|-------------|
| `EIRESCOPE_SECRET_KEY` | EireScope secret key |
| `EIRESCOPE_UMAMI_WEBSITE_ID` | Umami website ID for tracking |
### Forgejo
| Variable | Description |
|----------|-------------|
| `FORGEJO_DB_PASSWORD` | Forgejo PostgreSQL password |
| `FORGEJO_SECRET_KEY` | Internal session/cookie secret |
| `FORGEJO_INTERNAL_TOKEN` | API token for internal RPC |
| `FORGEJO_OAUTH2_JWT_SECRET` | JWT secret for Forgejo's OAuth2 provider |
| `FORGEJO_OIDC_CLIENT_ID` | Authentik OIDC client ID (optional, for SSO) |
| `FORGEJO_OIDC_CLIENT_SECRET` | Authentik OIDC client secret (optional, for SSO) |
### Valheim
| Variable | Description |
|----------|-------------|
| `VALHEIM_PASSWORD` | Server password |
| `ADMINLIST_IDS` | Comma-separated Steam IDs of admins |
| `PERMITTEDLIST_IDS` | Comma-separated Steam IDs for whitelist |
## Security Configuration
## Security
### CrowdSec
Installed collections:
- `crowdsecurity/traefik` - Web attack detection
- `crowdsecurity/http-cve` - CVE exploit detection
- `crowdsecurity/linux` - Linux system protection
- `crowdsecurity/whitelist-good-actors` - CDN/bot whitelist
- `crowdsecurity/traefik`
- `crowdsecurity/http-cve`
- `crowdsecurity/linux`
- `crowdsecurity/whitelist-good-actors`
Country blocks (web traffic):
- Russia (RU), China (CN), North Korea (KP), Iran (IR), Ukraine (UA)
Country blocks (web traffic): RU, CN, KP, IR, UA.
Useful commands:
```bash
# View metrics
docker exec crowdsec cscli metrics
# List active bans
docker exec crowdsec cscli decisions list
# Add manual ban
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --duration 24h --reason "manual"
# View alerts
docker exec crowdsec cscli alerts list
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --duration 24h --reason "manual"
```
### SSH Security
@ -293,119 +249,36 @@ info - Server info
### Managing Admins
Add Steam IDs to docker-compose.yml:
```yaml
ADMINLIST_IDS: "76561198012345678,76561198087654321"
PERMITTEDLIST_IDS: "76561198012345678,76561198087654321"
Edit `infrastructure/.env`:
```
ADMINLIST_IDS=76561198012345678,76561198087654321
PERMITTEDLIST_IDS=76561198012345678,76561198087654321
```
Then restart the server:
Then restart:
```bash
docker compose up -d valheim --force-recreate
```
## Monitoring
### Grafana Access
URL: https://status.richardnixon.dev
### Log Aggregation
Loki + Promtail collect logs from:
- All Docker containers
- System logs (/var/log)
Query logs in Grafana:
```
{container_name="platform-web"} |~ "error"
{job="docker"} |= "blocked"
```
## Common Commands
### Docker Management
```bash
# Start all services
docker compose up -d
# View container status
# Status
docker compose ps
# View logs
docker compose logs -f platform-web
docker compose logs -f platform-celery
docker compose logs -f locflow-web
# Logs
docker compose logs -f <service>
# Rebuild after code changes
docker compose build platform-web platform-celery platform-celery-beat
docker compose up -d platform-web platform-celery platform-celery-beat
# Restart a service
docker compose restart <service>
# Rebuild LocFlow
docker compose build locflow-web
docker compose up -d locflow-web
# Rebuild a service that has a build context
docker compose build <service> && docker compose up -d <service>
# Rebuild EireScope
docker compose build eirescope
docker compose up -d eirescope
# Pull image updates
docker compose pull && docker compose up -d
```
### Django Management (Platform)
```bash
docker compose exec platform-web python manage.py migrate
docker compose exec platform-web python manage.py makemigrations
docker compose exec platform-web python manage.py createsuperuser
docker compose exec platform-web python manage.py collectstatic
docker compose exec platform-web python manage.py shell
```
### Django Management (LocFlow)
```bash
docker compose exec locflow-web python manage.py migrate
docker compose exec locflow-web python manage.py createsuperuser
docker compose exec locflow-web python manage.py shell
```
### Security Management
```bash
# Check fail2ban status
fail2ban-client status sshd
# View CrowdSec decisions
docker exec crowdsec cscli decisions list
# View iptables rules
iptables -L INPUT -n -v
```
### Valheim Management
```bash
# View server logs
docker compose logs -f valheim
# Restart server
docker compose restart valheim
# Backup world manually
docker exec valheim backup
# Check connected players
docker logs valheim 2>&1 | grep "Got connection"
```
## URL Routes (post-Hugo cutover)
Routing is configured in `infrastructure/traefik/dynamic.yml`.
| Path | Backend | Notes |
|------|---------|-------|
| `/` | blog-static (Hugo) | Redirects to `/pt-br/` (default content language) |
| `/pt-br/...`, `/en/...` | blog-static (Hugo) | Bilingual blog content |
| `/feed.xml`, `/sitemap.xml` | blog-static (Hugo) | Per-language RSS and sitemap index |
| `/admin/` | platform-web (Django) | Admin interface (2FA) — priority 110 |
| `/api/` | platform-web (Django) | REST stub for legacy clients |
| `/static/`, `/media/` | platform-web (Django) | Legacy static/media uploads |
## License
MIT License

View file

@ -1,33 +0,0 @@
# Django Apps
## accounts
Custom user model with email-based authentication (no username). Roles: `owner` and `registered`. Extends `AbstractBaseUser` + `PermissionsMixin`. Used by django-allauth for Google/GitHub OAuth and django-two-factor-auth for 2FA/OTP.
## api
REST API powered by django-ninja. Exposes endpoints for blog posts, portfolio projects, contact form, and homepage data. Consumed by the Next.js frontend (`frontend/`).
## blog
Blog engine with posts, tags, and categories. Features:
- Markdown rendering with Pygments syntax highlighting
- CKEditor 5 rich text editor in admin
- RSS feed (`/feed/`)
- SEO sitemap (`sitemaps.py`)
- i18n support via django-modeltranslation
## contact
Contact form with model-backed storage and reCAPTCHA v3 validation. Includes a resume download view.
## core
Lightweight utility app. Contains the `/health/` endpoint for Docker healthchecks and monitoring.
## portfolio
Projects showcase with technologies/tags. Features:
- SEO sitemap (`sitemaps.py`)
- i18n support via django-modeltranslation
- Related projects

View file

@ -1 +0,0 @@
# Apps package

View file

@ -1 +0,0 @@
default_app_config = 'apps.accounts.apps.AccountsConfig'

View file

@ -1,25 +0,0 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .models import User
@admin.register(User)
class UserAdmin(BaseUserAdmin):
list_display = ('email', 'name', 'role', 'is_active', 'is_staff', 'created_at')
list_filter = ('role', 'is_active', 'is_staff', 'is_superuser')
search_fields = ('email', 'name')
ordering = ('-created_at',)
fieldsets = (
(None, {'fields': ('email', 'password')}),
('Personal Info', {'fields': ('name', 'avatar')}),
('Permissions', {'fields': ('role', 'is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
('Dates', {'fields': ('last_login',)}),
)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('email', 'password1', 'password2', 'name', 'role'),
}),
)

View file

@ -1,7 +0,0 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.accounts'
verbose_name = 'Accounts'

View file

@ -1,38 +0,0 @@
# Generated by Django 5.2.10 on 2026-01-25 00:02
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('email', models.EmailField(max_length=254, unique=True)),
('name', models.CharField(blank=True, max_length=150)),
('avatar', models.URLField(blank=True)),
('role', models.CharField(choices=[('owner', 'Owner'), ('registered', 'Registered User')], default='registered', max_length=20)),
('is_active', models.BooleanField(default=True)),
('is_staff', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
},
),
]

View file

@ -1,64 +0,0 @@
from django.db import models
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
class UserManager(BaseUserManager):
def create_user(self, email, password=None, **extra_fields):
if not email:
raise ValueError('Email is required')
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, email, password=None, **extra_fields):
extra_fields.setdefault('is_staff', True)
extra_fields.setdefault('is_superuser', True)
extra_fields.setdefault('is_active', True)
if extra_fields.get('is_staff') is not True:
raise ValueError('Superuser must have is_staff=True.')
if extra_fields.get('is_superuser') is not True:
raise ValueError('Superuser must have is_superuser=True.')
return self.create_user(email, password, **extra_fields)
class User(AbstractBaseUser, PermissionsMixin):
"""Custom user model using email as identifier."""
class Role(models.TextChoices):
OWNER = 'owner', 'Owner'
REGISTERED = 'registered', 'Registered User'
email = models.EmailField(unique=True)
name = models.CharField(max_length=150, blank=True)
avatar = models.URLField(blank=True)
role = models.CharField(max_length=20, choices=Role.choices, default=Role.REGISTERED)
is_active = models.BooleanField(default=True)
is_staff = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = UserManager()
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = []
class Meta:
verbose_name = 'user'
verbose_name_plural = 'users'
def __str__(self):
return self.email
def get_full_name(self):
return self.name or self.email
def get_short_name(self):
return self.name.split()[0] if self.name else self.email.split('@')[0]
@property
def is_owner(self):
return self.role == self.Role.OWNER

View file

@ -1,51 +0,0 @@
import pytest
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.mark.django_db
class TestUserModel:
def test_create_user_with_email(self):
user = User.objects.create_user(email="user@example.com", password="pass1234")
assert user.email == "user@example.com"
assert user.check_password("pass1234")
assert not user.is_staff
assert not user.is_superuser
assert user.is_active
def test_create_user_without_email_raises(self):
with pytest.raises(ValueError, match="Email is required"):
User.objects.create_user(email="", password="pass1234")
def test_create_superuser(self):
user = User.objects.create_superuser(email="admin@example.com", password="admin1234")
assert user.is_staff
assert user.is_superuser
assert user.is_active
def test_create_superuser_not_staff_raises(self):
with pytest.raises(ValueError, match="is_staff=True"):
User.objects.create_superuser(email="a@b.com", password="x", is_staff=False)
def test_create_superuser_not_superuser_raises(self):
with pytest.raises(ValueError, match="is_superuser=True"):
User.objects.create_superuser(email="a@b.com", password="x", is_superuser=False)
def test_email_is_normalized(self):
user = User.objects.create_user(email="User@EXAMPLE.COM", password="pass1234")
assert user.email == "User@example.com"
def test_str_returns_email(self):
user = User.objects.create_user(email="user@test.com", password="pass1234")
assert str(user) == "user@test.com"
def test_username_field_is_email(self):
assert User.USERNAME_FIELD == "email"
def test_is_owner_property(self):
user = User.objects.create_user(email="owner@test.com", password="pass1234", role="owner")
assert user.is_owner
regular = User.objects.create_user(email="regular@test.com", password="pass1234")
assert not regular.is_owner

View file

View file

@ -1,13 +0,0 @@
from ninja import NinjaAPI
from .routes import blog_router, portfolio_router, contact_router, home_router
api = NinjaAPI(
title="richardnixon.dev API",
version="1.0.0",
urls_namespace="api",
)
api.add_router("/home", home_router)
api.add_router("/blog", blog_router)
api.add_router("/portfolio", portfolio_router)
api.add_router("/contact", contact_router)

View file

@ -1,181 +0,0 @@
from django.db.models import Q
from django.shortcuts import get_object_or_404
from ninja import Router, Query
from apps.blog.models import BlogPost, Tag
from apps.portfolio.models import Project, Technology
from apps.contact.models import ContactMessage, Resume
from .schemas import (
TagSchema, PostListSchema, PostDetailSchema, RelatedPostSchema,
TechnologySchema, ProjectListSchema, ProjectDetailSchema,
ContactSubmitSchema, ContactResponseSchema, ResumeSchema,
HomeDataSchema,
)
blog_router = Router(tags=["blog"])
portfolio_router = Router(tags=["portfolio"])
contact_router = Router(tags=["contact"])
home_router = Router(tags=["home"])
# ---- Home ----
@home_router.get("/", response=HomeDataSchema)
def home(request):
posts = (
BlogPost.objects.filter(status="published", is_private=False)
.select_related("author")
.prefetch_related("tags")
.order_by("-published_at")[:5]
)
return {"recent_posts": posts}
# ---- Blog ----
@blog_router.get("/posts", response=list[PostListSchema])
def list_posts(
request,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=50),
search: str = Query(""),
tag: str = Query(""),
):
qs = (
BlogPost.objects.filter(status="published", is_private=False)
.select_related("author")
.prefetch_related("tags")
.order_by("-published_at")
)
if search:
qs = qs.filter(
Q(title__icontains=search)
| Q(content__icontains=search)
| Q(tags__name__icontains=search)
).distinct()
if tag:
qs = qs.filter(tags__slug=tag)
offset = (page - 1) * page_size
return qs[offset : offset + page_size]
@blog_router.get("/posts/count", response={200: int})
def posts_count(request, search: str = Query(""), tag: str = Query("")):
qs = BlogPost.objects.filter(status="published", is_private=False)
if search:
qs = qs.filter(
Q(title__icontains=search)
| Q(content__icontains=search)
| Q(tags__name__icontains=search)
).distinct()
if tag:
qs = qs.filter(tags__slug=tag)
return qs.count()
@blog_router.get("/posts/{slug}", response=PostDetailSchema)
def get_post(request, slug: str):
post = get_object_or_404(
BlogPost.objects.select_related("author").prefetch_related("tags"),
slug=slug,
status="published",
)
return post
@blog_router.get("/posts/{slug}/related", response=list[RelatedPostSchema])
def related_posts(request, slug: str):
post = get_object_or_404(BlogPost, slug=slug, status="published")
related = (
BlogPost.objects.filter(
status="published", is_private=False, tags__in=post.tags.all()
)
.exclude(pk=post.pk)
.distinct()[:3]
)
return related
@blog_router.get("/tags", response=list[TagSchema])
def list_tags(request):
return Tag.objects.all().order_by("name")
# ---- Portfolio ----
@portfolio_router.get("/projects", response=list[ProjectListSchema])
def list_projects(request, tech: str = Query("")):
qs = (
Project.objects.filter(status="published")
.prefetch_related("technologies")
.order_by("order", "-created_at")
)
if tech:
qs = qs.filter(technologies__slug=tech)
return qs
@portfolio_router.get("/projects/featured", response=list[ProjectListSchema])
def featured_projects(request):
return (
Project.objects.filter(status="published", is_featured=True)
.prefetch_related("technologies")
.order_by("order")[:3]
)
@portfolio_router.get("/projects/{slug}", response=ProjectDetailSchema)
def get_project(request, slug: str):
return get_object_or_404(
Project.objects.prefetch_related("technologies", "images"),
slug=slug,
status="published",
)
@portfolio_router.get("/projects/{slug}/related", response=list[ProjectListSchema])
def related_projects(request, slug: str):
project = get_object_or_404(Project, slug=slug, status="published")
related = (
Project.objects.filter(
status="published", technologies__in=project.technologies.all()
)
.exclude(pk=project.pk)
.prefetch_related("technologies")
.distinct()[:3]
)
return related
@portfolio_router.get("/technologies", response=list[TechnologySchema])
def list_technologies(request):
return Technology.objects.all().order_by("name")
# ---- Contact ----
@contact_router.post("/submit", response=ContactResponseSchema)
def submit_contact(request, data: ContactSubmitSchema):
msg = ContactMessage.objects.create(
name=data.name,
email=data.email,
subject=data.subject,
message=data.message,
ip_address=request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", "")),
user_agent=request.META.get("HTTP_USER_AGENT", ""),
referrer=request.META.get("HTTP_REFERER", ""),
)
if msg.is_likely_spam:
msg.status = "spam"
msg.save()
return {"success": True, "message": "Message sent successfully."}
@contact_router.get("/resume", response={200: ResumeSchema, 404: ContactResponseSchema})
def get_resume(request):
resume = Resume.objects.filter(is_active=True).first()
if not resume:
return 404, {"success": False, "message": "No resume available."}
resume.increment_download()
return resume

View file

@ -1,144 +0,0 @@
import os
from datetime import datetime
from ninja import Schema
SITE_URL = os.environ.get("SITE_URL", "https://richardnixon.dev")
def _abs_url(field_file):
if not field_file:
return None
return f"{SITE_URL}{field_file.url}"
# ---- Blog ----
class TagSchema(Schema):
name: str
slug: str
description: str
class PostListSchema(Schema):
id: int
title: str
slug: str
excerpt: str
featured_image: str | None
tags: list[TagSchema]
reading_time: int
published_at: datetime | None
author_name: str | None
@staticmethod
def resolve_featured_image(obj):
return _abs_url(obj.featured_image)
@staticmethod
def resolve_author_name(obj):
return obj.author.get_short_name() if obj.author else None
class PostDetailSchema(PostListSchema):
content: str
meta_description: str
meta_keywords: str
created_at: datetime
updated_at: datetime
class RelatedPostSchema(Schema):
title: str
slug: str
excerpt: str
featured_image: str | None
reading_time: int
published_at: datetime | None
@staticmethod
def resolve_featured_image(obj):
return _abs_url(obj.featured_image)
# ---- Portfolio ----
class TechnologySchema(Schema):
name: str
slug: str
icon: str
color: str
class ProjectListSchema(Schema):
id: int
title: str
slug: str
tagline: str
featured_image: str | None
thumbnail: str | None
technologies: list[TechnologySchema]
live_url: str
github_url: str
is_featured: bool
is_ongoing: bool
start_date: datetime | None
end_date: datetime | None
@staticmethod
def resolve_featured_image(obj):
return _abs_url(obj.featured_image)
@staticmethod
def resolve_thumbnail(obj):
return _abs_url(obj.thumbnail)
class ProjectImageSchema(Schema):
image: str
caption: str
order: int
@staticmethod
def resolve_image(obj):
return _abs_url(obj.image)
class ProjectDetailSchema(ProjectListSchema):
description: str
documentation_url: str
images: list[ProjectImageSchema]
created_at: datetime
updated_at: datetime
@staticmethod
def resolve_images(obj):
return obj.images.all().order_by('order')
# ---- Contact ----
class ContactSubmitSchema(Schema):
name: str
email: str
subject: str
message: str
class ContactResponseSchema(Schema):
success: bool
message: str
class ResumeSchema(Schema):
title: str
file_url: str
@staticmethod
def resolve_file_url(obj):
return _abs_url(obj.file)
# ---- Home ----
class HomeDataSchema(Schema):
recent_posts: list[PostListSchema]

View file

@ -1,220 +0,0 @@
from django.test import TestCase, Client
from apps.blog.models import BlogPost, Tag
from apps.portfolio.models import Project, Technology
from apps.contact.models import ContactMessage, Resume
from apps.accounts.models import User
class APITestBase(TestCase):
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(
email="test@example.com", password="testpass123"
)
self.tag = Tag.objects.create(name="Python", description="Python programming")
self.post = BlogPost.objects.create(
title="Test Post",
content="<p>Test content</p>",
excerpt="Test excerpt",
author=self.user,
status="published",
)
self.post.tags.add(self.tag)
self.tech = Technology.objects.create(
name="Django", icon="django", color="#092E20"
)
self.project = Project.objects.create(
title="Test Project",
tagline="A test project",
description="<p>Description</p>",
status="published",
is_featured=True,
)
self.project.technologies.add(self.tech)
class HomeAPITest(APITestBase):
def test_home(self):
r = self.client.get("/api/home/")
self.assertEqual(r.status_code, 200)
data = r.json()
self.assertIn("recent_posts", data)
self.assertEqual(len(data["recent_posts"]), 1)
self.assertEqual(data["recent_posts"][0]["title"], "Test Post")
class BlogAPITest(APITestBase):
def test_list_posts(self):
r = self.client.get("/api/blog/posts")
self.assertEqual(r.status_code, 200)
data = r.json()
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["title"], "Test Post")
self.assertEqual(data[0]["tags"][0]["name"], "Python")
def test_list_posts_search(self):
r = self.client.get("/api/blog/posts?search=Test")
self.assertEqual(r.status_code, 200)
self.assertEqual(len(r.json()), 1)
def test_list_posts_search_no_match(self):
r = self.client.get("/api/blog/posts?search=nonexistent")
self.assertEqual(r.status_code, 200)
self.assertEqual(len(r.json()), 0)
def test_list_posts_tag_filter(self):
r = self.client.get("/api/blog/posts?tag=python")
self.assertEqual(r.status_code, 200)
self.assertEqual(len(r.json()), 1)
def test_posts_count(self):
r = self.client.get("/api/blog/posts/count")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json(), 1)
def test_get_post(self):
r = self.client.get(f"/api/blog/posts/{self.post.slug}")
self.assertEqual(r.status_code, 200)
data = r.json()
self.assertEqual(data["title"], "Test Post")
self.assertEqual(data["content"], "<p>Test content</p>")
def test_get_post_not_found(self):
r = self.client.get("/api/blog/posts/nonexistent")
self.assertEqual(r.status_code, 404)
def test_related_posts(self):
r = self.client.get(f"/api/blog/posts/{self.post.slug}/related")
self.assertEqual(r.status_code, 200)
self.assertIsInstance(r.json(), list)
def test_draft_post_not_listed(self):
BlogPost.objects.create(
title="Draft", content="x", status="draft", author=self.user
)
r = self.client.get("/api/blog/posts")
self.assertEqual(len(r.json()), 1)
def test_private_post_not_listed(self):
BlogPost.objects.create(
title="Private",
content="x",
status="published",
is_private=True,
author=self.user,
)
r = self.client.get("/api/blog/posts")
self.assertEqual(len(r.json()), 1)
def test_list_tags(self):
r = self.client.get("/api/blog/tags")
self.assertEqual(r.status_code, 200)
data = r.json()
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["name"], "Python")
class PortfolioAPITest(APITestBase):
def test_list_projects(self):
r = self.client.get("/api/portfolio/projects")
self.assertEqual(r.status_code, 200)
data = r.json()
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["title"], "Test Project")
def test_list_projects_tech_filter(self):
r = self.client.get("/api/portfolio/projects?tech=django")
self.assertEqual(r.status_code, 200)
self.assertEqual(len(r.json()), 1)
def test_featured_projects(self):
r = self.client.get("/api/portfolio/projects/featured")
self.assertEqual(r.status_code, 200)
self.assertEqual(len(r.json()), 1)
def test_get_project(self):
r = self.client.get(f"/api/portfolio/projects/{self.project.slug}")
self.assertEqual(r.status_code, 200)
data = r.json()
self.assertEqual(data["title"], "Test Project")
self.assertEqual(data["technologies"][0]["name"], "Django")
def test_get_project_not_found(self):
r = self.client.get("/api/portfolio/projects/nonexistent")
self.assertEqual(r.status_code, 404)
def test_related_projects(self):
r = self.client.get(f"/api/portfolio/projects/{self.project.slug}/related")
self.assertEqual(r.status_code, 200)
self.assertIsInstance(r.json(), list)
def test_draft_project_not_listed(self):
Project.objects.create(
title="Draft Project", tagline="x", description="x", status="draft"
)
r = self.client.get("/api/portfolio/projects")
self.assertEqual(len(r.json()), 1)
def test_list_technologies(self):
r = self.client.get("/api/portfolio/technologies")
self.assertEqual(r.status_code, 200)
self.assertEqual(len(r.json()), 1)
class ContactAPITest(APITestBase):
def test_submit_contact(self):
r = self.client.post(
"/api/contact/submit",
data={
"name": "John",
"email": "john@example.com",
"subject": "Hello",
"message": "Test message",
},
content_type="application/json",
)
self.assertEqual(r.status_code, 200)
data = r.json()
self.assertTrue(data["success"])
self.assertEqual(ContactMessage.objects.count(), 1)
def test_submit_contact_invalid(self):
r = self.client.post(
"/api/contact/submit",
data={"name": "John"},
content_type="application/json",
)
self.assertEqual(r.status_code, 422)
def test_resume_not_found(self):
r = self.client.get("/api/contact/resume")
self.assertEqual(r.status_code, 404)
class MediaTest(TestCase):
def test_media_route_exists(self):
r = self.client.get("/media/nonexistent.png")
# Should return 404 (not found) not 502 (bad gateway)
self.assertIn(r.status_code, [404, 200])
class RoutingTest(TestCase):
def test_api_docs(self):
r = self.client.get("/api/docs")
self.assertEqual(r.status_code, 200)
def test_sitemap(self):
# Sitemap requires django.contrib.sitemaps templates which
# are available via APP_DIRS in production but may not resolve
# in test environment without the sitemaps app in INSTALLED_APPS
try:
r = self.client.get("/sitemap.xml")
self.assertIn(r.status_code, [200, 500])
except Exception:
pass # Template not found in test env is OK
def test_admin_redirects(self):
r = self.client.get("/admin/")
# Should redirect to language-prefixed login
self.assertIn(r.status_code, [301, 302])

View file

@ -1 +0,0 @@
default_app_config = 'apps.blog.apps.BlogConfig'

View file

@ -1,77 +0,0 @@
from django.contrib import admin
from django.utils import timezone
from django import forms
from django_ckeditor_5.widgets import CKEditor5Widget
from modeltranslation.admin import TranslationAdmin
from .models import Tag, BlogPost, PostView
class BlogPostAdminForm(forms.ModelForm):
class Meta:
model = BlogPost
fields = '__all__'
widgets = {
'content_pt_br': CKEditor5Widget(config_name='extends'),
'content_en': CKEditor5Widget(config_name='extends'),
'content': CKEditor5Widget(config_name='extends'),
}
@admin.register(Tag)
class TagAdmin(TranslationAdmin):
list_display = ('name', 'slug', 'post_count')
search_fields = ('name',)
prepopulated_fields = {'slug': ('name',)}
def post_count(self, obj):
return obj.posts.count()
post_count.short_description = 'Posts'
@admin.register(BlogPost)
class BlogPostAdmin(TranslationAdmin):
form = BlogPostAdminForm
list_display = ('title', 'status', 'is_private', 'author', 'published_at', 'view_count')
list_filter = ('status', 'is_private', 'tags', 'created_at')
search_fields = ('title', 'content')
prepopulated_fields = {'slug': ('title',)}
filter_horizontal = ('tags',)
date_hierarchy = 'created_at'
raw_id_fields = ('author',)
fieldsets = (
(None, {
'fields': ('title', 'slug', 'excerpt', 'content', 'featured_image')
}),
('Classification', {
'fields': ('author', 'tags', 'status', 'is_private')
}),
('SEO', {
'fields': ('meta_description', 'meta_keywords'),
'classes': ('collapse',)
}),
('Dates', {
'fields': ('published_at',),
'classes': ('collapse',)
}),
)
def view_count(self, obj):
return obj.views.count()
view_count.short_description = 'Views'
def save_model(self, request, obj, form, change):
if not obj.author:
obj.author = request.user
if obj.status == BlogPost.Status.PUBLISHED and not obj.published_at:
obj.published_at = timezone.now()
super().save_model(request, obj, form, change)
@admin.register(PostView)
class PostViewAdmin(admin.ModelAdmin):
list_display = ('post', 'ip_address', 'viewed_at')
list_filter = ('viewed_at',)
search_fields = ('post__title', 'ip_address')
date_hierarchy = 'viewed_at'
readonly_fields = ('post', 'ip_address', 'user_agent', 'viewed_at')

View file

@ -1,7 +0,0 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.blog'
verbose_name = 'Blog'

View file

@ -1,131 +0,0 @@
"""Export BlogPost rows to Hugo-ready HTML files with YAML frontmatter.
Output tree:
<out>/pt-br/posts/<slug>.html
<out>/en/posts/<slug>.html
Posts with empty content for a given language are skipped silently.
Frontmatter contains: title, date, slug, tags, description, draft.
"""
import json
from pathlib import Path
from django.core.management.base import BaseCommand
from apps.blog.models import BlogPost
LANGS = ("pt-br", "en")
def lang_attr(post, field, lang):
"""Read django-modeltranslation field, e.g. title_en / content_pt_br."""
suffix = lang.replace("-", "_")
return getattr(post, f"{field}_{suffix}", None)
def safe_title(value, fallback):
cleaned = (value or "").strip()
return cleaned or fallback
def build_frontmatter(post, lang):
title = safe_title(lang_attr(post, "title", lang), post.title)
description = (lang_attr(post, "meta_description", lang) or post.meta_description or "").strip()
if not description:
excerpt = (lang_attr(post, "excerpt", lang) or post.excerpt or "").strip()
description = excerpt[:200]
date = post.published_at or post.created_at
tags = sorted(post.tags.values_list("name", flat=True))
return {
"title": title,
"date": date.isoformat() if date else None,
"slug": post.slug,
"tags": list(tags),
"description": description,
"draft": post.status != BlogPost.Status.PUBLISHED,
}
def _yaml_scalar(value):
"""Quote scalars safely; bare-emit None as empty string."""
if value is None:
return '""'
s = str(value)
return json.dumps(s, ensure_ascii=False)
def render_file(frontmatter, html_body):
lines = ["---"]
for key, value in frontmatter.items():
if isinstance(value, list):
if not value:
lines.append(f"{key}: []")
else:
lines.append(f"{key}:")
for item in value:
lines.append(f" - {_yaml_scalar(item)}")
elif isinstance(value, bool):
lines.append(f"{key}: {'true' if value else 'false'}")
else:
lines.append(f"{key}: {_yaml_scalar(value)}")
lines.append("---")
lines.append("")
return "\n".join(lines) + "\n" + html_body + "\n"
class Command(BaseCommand):
help = "Export published blog posts to Hugo-compatible HTML files."
def add_arguments(self, parser):
parser.add_argument(
"--out",
default="/tmp/hugo-export",
help="Output directory (default: /tmp/hugo-export)",
)
parser.add_argument(
"--include-private",
action="store_true",
help="Include private posts (default: only public).",
)
parser.add_argument(
"--include-drafts",
action="store_true",
help="Include drafts (marks them draft:true in frontmatter).",
)
def handle(self, *args, **opts):
out_root = Path(opts["out"])
if opts["include_drafts"]:
qs = BlogPost.objects.all()
else:
qs = BlogPost.objects.filter(status=BlogPost.Status.PUBLISHED)
if not opts["include_private"]:
qs = qs.filter(is_private=False)
written = {lang: 0 for lang in LANGS}
skipped = {lang: 0 for lang in LANGS}
for post in qs.iterator():
for lang in LANGS:
content = lang_attr(post, "content", lang) or ""
if not content.strip():
skipped[lang] += 1
continue
fm = build_frontmatter(post, lang)
rendered = render_file(fm, content)
target = out_root / lang / "posts" / f"{post.slug}.html"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(rendered, encoding="utf-8")
written[lang] += 1
for lang in LANGS:
self.stdout.write(
f"[{lang}] wrote {written[lang]} files, skipped {skipped[lang]} (empty)"
)
self.stdout.write(self.style.SUCCESS(f"Done -> {out_root}"))

View file

@ -1,65 +0,0 @@
# Generated by Django 5.2.10 on 2026-01-25 00:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Tag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
('description', models.TextField(blank=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='BlogPost',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('slug', models.SlugField(max_length=200, unique=True)),
('excerpt', models.TextField(blank=True, help_text='Short description for listings')),
('content', models.TextField(help_text='Markdown content')),
('featured_image', models.ImageField(blank=True, null=True, upload_to='blog/images/')),
('is_private', models.BooleanField(default=False, help_text='Only visible to owner')),
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('published_at', models.DateTimeField(blank=True, null=True)),
('meta_description', models.CharField(blank=True, max_length=160)),
('meta_keywords', models.CharField(blank=True, max_length=255)),
('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blog_posts', to=settings.AUTH_USER_MODEL)),
('tags', models.ManyToManyField(blank=True, related_name='posts', to='blog.tag')),
],
options={
'ordering': ['-published_at', '-created_at'],
},
),
migrations.CreateModel(
name='PostView',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.TextField(blank=True)),
('viewed_at', models.DateTimeField(auto_now_add=True)),
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='views', to='blog.blogpost')),
],
options={
'ordering': ['-viewed_at'],
},
),
]

View file

@ -1,19 +0,0 @@
# Generated by Django 5.2.10 on 2026-01-25 16:03
import django_ckeditor_5.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('blog', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='blogpost',
name='content',
field=django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content'),
),
]

View file

@ -1,88 +0,0 @@
# Generated by Django 5.2.10 on 2026-01-25 16:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0002_alter_blogpost_content'),
]
operations = [
migrations.AddField(
model_name='blogpost',
name='content_en',
field=models.TextField(help_text='HTML content', null=True),
),
migrations.AddField(
model_name='blogpost',
name='content_pt_br',
field=models.TextField(help_text='HTML content', null=True),
),
migrations.AddField(
model_name='blogpost',
name='excerpt_en',
field=models.TextField(blank=True, help_text='Short description for listings', null=True),
),
migrations.AddField(
model_name='blogpost',
name='excerpt_pt_br',
field=models.TextField(blank=True, help_text='Short description for listings', null=True),
),
migrations.AddField(
model_name='blogpost',
name='meta_description_en',
field=models.CharField(blank=True, max_length=160, null=True),
),
migrations.AddField(
model_name='blogpost',
name='meta_description_pt_br',
field=models.CharField(blank=True, max_length=160, null=True),
),
migrations.AddField(
model_name='blogpost',
name='meta_keywords_en',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='blogpost',
name='meta_keywords_pt_br',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='blogpost',
name='title_en',
field=models.CharField(max_length=200, null=True),
),
migrations.AddField(
model_name='blogpost',
name='title_pt_br',
field=models.CharField(max_length=200, null=True),
),
migrations.AddField(
model_name='tag',
name='description_en',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='tag',
name='description_pt_br',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='tag',
name='name_en',
field=models.CharField(max_length=50, null=True, unique=True),
),
migrations.AddField(
model_name='tag',
name='name_pt_br',
field=models.CharField(max_length=50, null=True, unique=True),
),
migrations.AlterField(
model_name='blogpost',
name='content',
field=models.TextField(help_text='HTML content'),
),
]

View file

@ -1,100 +0,0 @@
from django.db import models
from django.urls import reverse
from django.conf import settings
from django.utils.text import slugify
from django.utils.html import strip_tags
class Tag(models.Model):
"""Tag for categorizing blog posts."""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(max_length=50, unique=True)
description = models.TextField(blank=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('blog:tag_detail', kwargs={'slug': self.slug})
class BlogPost(models.Model):
"""Blog post with markdown content."""
class Status(models.TextChoices):
DRAFT = 'draft', 'Draft'
PUBLISHED = 'published', 'Published'
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
excerpt = models.TextField(blank=True, help_text='Short description for listings')
content = models.TextField(help_text='HTML content')
featured_image = models.ImageField(upload_to='blog/images/', blank=True, null=True)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='blog_posts'
)
tags = models.ManyToManyField(Tag, blank=True, related_name='posts')
is_private = models.BooleanField(default=False, help_text='Only visible to owner')
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
published_at = models.DateTimeField(null=True, blank=True)
# SEO fields
meta_description = models.CharField(max_length=160, blank=True)
meta_keywords = models.CharField(max_length=255, blank=True)
class Meta:
ordering = ['-published_at', '-created_at']
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
if not self.excerpt and self.content:
# Strip HTML tags for excerpt
plain_text = strip_tags(self.content)
self.excerpt = plain_text[:200] + '...' if len(plain_text) > 200 else plain_text
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('blog:post_detail', kwargs={'slug': self.slug})
@property
def content_html(self):
"""Return HTML content from CKEditor."""
return self.content
@property
def reading_time(self):
"""Estimate reading time in minutes."""
plain_text = strip_tags(self.content)
word_count = len(plain_text.split())
return max(1, round(word_count / 200))
class PostView(models.Model):
"""Track post views for analytics."""
post = models.ForeignKey(BlogPost, on_delete=models.CASCADE, related_name='views')
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.TextField(blank=True)
viewed_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-viewed_at']

View file

@ -1,16 +0,0 @@
from django.contrib.sitemaps import Sitemap
from .models import BlogPost
class BlogSitemap(Sitemap):
changefreq = 'weekly'
priority = 0.8
def items(self):
return BlogPost.objects.filter(
status=BlogPost.Status.PUBLISHED,
is_private=False
)
def lastmod(self, obj):
return obj.updated_at

View file

@ -1,34 +0,0 @@
import pytest
from django.test import Client
from django.urls import reverse
@pytest.mark.django_db
class TestBlogViews:
def test_home_returns_200(self):
client = Client()
response = client.get(reverse("blog:home"))
assert response.status_code == 200
def test_post_list_returns_200(self):
client = Client()
response = client.get(reverse("blog:post_list"))
assert response.status_code == 200
def test_rss_feed_returns_200(self):
client = Client()
response = client.get(reverse("blog:feed"))
assert response.status_code == 200
assert "application/rss+xml" in response["Content-Type"]
@pytest.mark.django_db
class TestHealthEndpoint:
def test_health_returns_200(self):
client = Client()
response = client.get("/health/")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["db"] is True

View file

@ -1,14 +0,0 @@
from modeltranslation.translator import translator, TranslationOptions
from .models import BlogPost, Tag
class BlogPostTranslationOptions(TranslationOptions):
fields = ('title', 'excerpt', 'content', 'meta_description', 'meta_keywords')
class TagTranslationOptions(TranslationOptions):
fields = ('name', 'description')
translator.register(BlogPost, BlogPostTranslationOptions)
translator.register(Tag, TagTranslationOptions)

View file

@ -1,12 +0,0 @@
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
path('', views.home, name='home'),
path('blog/', views.PostListView.as_view(), name='post_list'),
path('blog/<slug:slug>/', views.PostDetailView.as_view(), name='post_detail'),
path('blog/tag/<slug:slug>/', views.TagDetailView.as_view(), name='tag_detail'),
path('feed/', views.LatestPostsFeed(), name='feed'),
]

View file

@ -1,147 +0,0 @@
from django.shortcuts import render, get_object_or_404
from django.views.generic import ListView, DetailView
from django.db.models import Q
from django.contrib.syndication.views import Feed
from django.urls import reverse
from .models import BlogPost, Tag, PostView
class PostListView(ListView):
model = BlogPost
template_name = 'blog/post_list.html'
context_object_name = 'posts'
paginate_by = 10
def get_queryset(self):
queryset = BlogPost.objects.filter(status=BlogPost.Status.PUBLISHED)
# Filter private posts unless user is owner
if not self.request.user.is_authenticated or not getattr(self.request.user, 'is_owner', False):
queryset = queryset.filter(is_private=False)
# Search functionality
search = self.request.GET.get('search')
if search:
queryset = queryset.filter(
Q(title__icontains=search) |
Q(content__icontains=search) |
Q(tags__name__icontains=search)
).distinct()
# Tag filter
tag = self.request.GET.get('tag')
if tag:
queryset = queryset.filter(tags__slug=tag)
return queryset.select_related('author').prefetch_related('tags')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['tags'] = Tag.objects.all()
context['search'] = self.request.GET.get('search', '')
context['active_tag'] = self.request.GET.get('tag', '')
return context
class PostDetailView(DetailView):
model = BlogPost
template_name = 'blog/post_detail.html'
context_object_name = 'post'
def get_queryset(self):
queryset = BlogPost.objects.filter(status=BlogPost.Status.PUBLISHED)
# Filter private posts unless user is owner
if not self.request.user.is_authenticated or not getattr(self.request.user, 'is_owner', False):
queryset = queryset.filter(is_private=False)
return queryset.select_related('author').prefetch_related('tags')
def get_object(self, queryset=None):
obj = super().get_object(queryset)
# Track view
PostView.objects.create(
post=obj,
ip_address=self.get_client_ip(),
user_agent=self.request.META.get('HTTP_USER_AGENT', '')[:500]
)
return obj
def get_client_ip(self):
x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
return x_forwarded_for.split(',')[0].strip()
return self.request.META.get('REMOTE_ADDR')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Get related posts by tags
post_tags = self.object.tags.all()
context['related_posts'] = BlogPost.objects.filter(
tags__in=post_tags,
status=BlogPost.Status.PUBLISHED,
is_private=False
).exclude(pk=self.object.pk).distinct()[:3]
return context
class TagDetailView(ListView):
model = BlogPost
template_name = 'blog/tag_detail.html'
context_object_name = 'posts'
paginate_by = 10
def get_queryset(self):
self.tag = get_object_or_404(Tag, slug=self.kwargs['slug'])
queryset = BlogPost.objects.filter(
tags=self.tag,
status=BlogPost.Status.PUBLISHED
)
if not self.request.user.is_authenticated or not getattr(self.request.user, 'is_owner', False):
queryset = queryset.filter(is_private=False)
return queryset.select_related('author').prefetch_related('tags')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['tag'] = self.tag
context['tags'] = Tag.objects.all()
return context
class LatestPostsFeed(Feed):
title = "richardnixon.dev - Blog"
link = "/blog/"
description = "Latest posts from richardnixon.dev"
def items(self):
return BlogPost.objects.filter(
status=BlogPost.Status.PUBLISHED,
is_private=False
).order_by('-published_at')[:10]
def item_title(self, item):
return item.title
def item_description(self, item):
return item.excerpt
def item_link(self, item):
return item.get_absolute_url()
def item_pubdate(self, item):
return item.published_at
def home(request):
"""Homepage view."""
recent_posts = BlogPost.objects.filter(
status=BlogPost.Status.PUBLISHED,
is_private=False
).select_related('author').prefetch_related('tags')[:5]
return render(request, 'blog/home.html', {
'recent_posts': recent_posts,
})

View file

@ -1 +0,0 @@
default_app_config = 'apps.contact.apps.ContactConfig'

View file

@ -1,50 +0,0 @@
from django.contrib import admin
from .models import ContactMessage, Resume
@admin.register(ContactMessage)
class ContactMessageAdmin(admin.ModelAdmin):
list_display = ('name', 'email', 'subject', 'status', 'created_at', 'is_spam')
list_filter = ('status', 'created_at')
search_fields = ('name', 'email', 'subject', 'message')
readonly_fields = ('ip_address', 'user_agent', 'referrer', 'honeypot', 'submission_time', 'created_at')
date_hierarchy = 'created_at'
fieldsets = (
(None, {
'fields': ('name', 'email', 'subject', 'message', 'status')
}),
('Metadata', {
'fields': ('ip_address', 'user_agent', 'referrer', 'created_at'),
'classes': ('collapse',)
}),
('Spam Detection', {
'fields': ('honeypot', 'submission_time'),
'classes': ('collapse',)
}),
)
actions = ['mark_as_spam', 'mark_as_read', 'mark_as_archived']
def is_spam(self, obj):
return obj.is_likely_spam
is_spam.boolean = True
is_spam.short_description = 'Spam?'
@admin.action(description='Mark selected as spam')
def mark_as_spam(self, request, queryset):
queryset.update(status=ContactMessage.Status.SPAM)
@admin.action(description='Mark selected as read')
def mark_as_read(self, request, queryset):
queryset.update(status=ContactMessage.Status.READ)
@admin.action(description='Archive selected')
def mark_as_archived(self, request, queryset):
queryset.update(status=ContactMessage.Status.ARCHIVED)
@admin.register(Resume)
class ResumeAdmin(admin.ModelAdmin):
list_display = ('title', 'is_active', 'download_count', 'created_at')
list_filter = ('is_active',)

View file

@ -1,7 +0,0 @@
from django.apps import AppConfig
class ContactConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.contact'
verbose_name = 'Contact'

View file

@ -1,83 +0,0 @@
from django import forms
from django.conf import settings
from django_recaptcha.fields import ReCaptchaField
from django_recaptcha.widgets import ReCaptchaV3
from .models import ContactMessage
class ContactForm(forms.ModelForm):
"""Contact form with spam protection."""
# Honeypot field - should be hidden and left empty
website = forms.CharField(
required=False,
widget=forms.TextInput(attrs={
'class': 'hidden',
'tabindex': '-1',
'autocomplete': 'off'
})
)
# Hidden timestamp field for timing check
form_time = forms.FloatField(widget=forms.HiddenInput(), required=False)
# reCAPTCHA v3 field (invisible)
captcha = ReCaptchaField(widget=ReCaptchaV3())
class Meta:
model = ContactMessage
fields = ['name', 'email', 'subject', 'message']
widgets = {
'name': forms.TextInput(attrs={
'class': 'w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent',
'placeholder': 'Your name'
}),
'email': forms.EmailInput(attrs={
'class': 'w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent',
'placeholder': 'your@email.com'
}),
'subject': forms.TextInput(attrs={
'class': 'w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent',
'placeholder': 'Subject'
}),
'message': forms.Textarea(attrs={
'class': 'w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent',
'placeholder': 'Your message...',
'rows': 5
}),
}
def clean(self):
cleaned_data = super().clean()
# Check honeypot
if cleaned_data.get('website'):
raise forms.ValidationError('Spam detected.')
return cleaned_data
def save(self, commit=True, **kwargs):
instance = super().save(commit=False)
# Store honeypot value
instance.honeypot = self.cleaned_data.get('website', '')
# Calculate submission time
import time
form_time = self.cleaned_data.get('form_time', 0)
if form_time:
instance.submission_time = time.time() - form_time
# Store additional metadata from kwargs
instance.ip_address = kwargs.get('ip_address')
instance.user_agent = kwargs.get('user_agent', '')[:500]
instance.referrer = kwargs.get('referrer', '')[:200]
# Auto-mark as spam if detected
if instance.is_likely_spam:
instance.status = ContactMessage.Status.SPAM
if commit:
instance.save()
return instance

View file

@ -1,52 +0,0 @@
# Generated by Django 5.2.10 on 2026-01-25 00:02
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ContactMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('email', models.EmailField(max_length=254)),
('subject', models.CharField(max_length=200)),
('message', models.TextField()),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.TextField(blank=True)),
('referrer', models.URLField(blank=True)),
('honeypot', models.CharField(blank=True, help_text='Hidden field for spam detection', max_length=200)),
('submission_time', models.FloatField(default=0, help_text='Time taken to submit form in seconds')),
('status', models.CharField(choices=[('new', 'New'), ('read', 'Read'), ('replied', 'Replied'), ('spam', 'Spam'), ('archived', 'Archived')], default='new', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Contact Message',
'verbose_name_plural': 'Contact Messages',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Resume',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(default='Resume', max_length=100)),
('file', models.FileField(upload_to='resumes/')),
('is_active', models.BooleanField(default=True)),
('download_count', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-created_at'],
},
),
]

View file

@ -1,75 +0,0 @@
from django.db import models
from django.conf import settings
class ContactMessage(models.Model):
"""Contact form submission."""
class Status(models.TextChoices):
NEW = 'new', 'New'
READ = 'read', 'Read'
REPLIED = 'replied', 'Replied'
SPAM = 'spam', 'Spam'
ARCHIVED = 'archived', 'Archived'
name = models.CharField(max_length=100)
email = models.EmailField()
subject = models.CharField(max_length=200)
message = models.TextField()
# Metadata
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.TextField(blank=True)
referrer = models.URLField(blank=True)
# Spam detection
honeypot = models.CharField(max_length=200, blank=True, help_text='Hidden field for spam detection')
submission_time = models.FloatField(default=0, help_text='Time taken to submit form in seconds')
status = models.CharField(max_length=20, choices=Status.choices, default=Status.NEW)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
verbose_name = 'Contact Message'
verbose_name_plural = 'Contact Messages'
def __str__(self):
return f"{self.name} - {self.subject}"
@property
def is_likely_spam(self):
"""Basic spam detection heuristics."""
# Honeypot filled
if self.honeypot:
return True
# Form submitted too quickly (less than 3 seconds)
if self.submission_time > 0 and self.submission_time < 3:
return True
# Check for common spam patterns
spam_keywords = ['viagra', 'casino', 'crypto', 'bitcoin', 'lottery', 'prize']
content = f"{self.subject} {self.message}".lower()
if any(keyword in content for keyword in spam_keywords):
return True
return False
class Resume(models.Model):
"""Uploaded resume/CV for download."""
title = models.CharField(max_length=100, default='Resume')
file = models.FileField(upload_to='resumes/')
is_active = models.BooleanField(default=True)
download_count = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.title
def increment_download(self):
self.download_count += 1
self.save(update_fields=['download_count'])

View file

@ -1,12 +0,0 @@
import pytest
from django.test import Client
from django.urls import reverse
@pytest.mark.django_db
class TestContactViews:
def test_contact_form_get_returns_200(self):
client = Client()
response = client.get(reverse("contact:contact"))
assert response.status_code == 200

View file

@ -1,9 +0,0 @@
from django.urls import path
from . import views
app_name = 'contact'
urlpatterns = [
path('', views.ContactView.as_view(), name='contact'),
path('resume/', views.ResumeDownloadView.as_view(), name='resume_download'),
]

View file

@ -1,60 +0,0 @@
import time
from django.shortcuts import render, redirect
from django.views.generic import FormView, View
from django.http import FileResponse, Http404
from django.contrib import messages
from django.urls import reverse_lazy
from .forms import ContactForm
from .models import Resume
class ContactView(FormView):
template_name = 'contact/contact.html'
form_class = ContactForm
success_url = reverse_lazy('contact:contact')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['form_time'] = time.time()
return context
def get_initial(self):
initial = super().get_initial()
initial['form_time'] = time.time()
return initial
def form_valid(self, form):
form.save(
ip_address=self.get_client_ip(),
user_agent=self.request.META.get('HTTP_USER_AGENT', ''),
referrer=self.request.META.get('HTTP_REFERER', '')
)
messages.success(self.request, 'Thank you for your message! I will get back to you soon.')
return super().form_valid(form)
def form_invalid(self, form):
messages.error(self.request, 'Please correct the errors below.')
return super().form_invalid(form)
def get_client_ip(self):
x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
return x_forwarded_for.split(',')[0].strip()
return self.request.META.get('REMOTE_ADDR')
class ResumeDownloadView(View):
def get(self, request):
resume = Resume.objects.filter(is_active=True).first()
if not resume:
raise Http404("Resume not found")
resume.increment_download()
response = FileResponse(
resume.file.open('rb'),
as_attachment=True,
filename=f"richard_nixon_resume.pdf"
)
return response

View file

View file

@ -1,32 +0,0 @@
import logging
from django.db import connection
from django.core.cache import cache
from django.http import JsonResponse
from django.views.decorators.cache import never_cache
logger = logging.getLogger(__name__)
@never_cache
def health(request):
db_ok = False
cache_ok = False
try:
connection.ensure_connection()
db_ok = True
except Exception:
logger.warning("Health check: database connection failed")
try:
cache.set("_health_check", "1", timeout=5)
cache_ok = cache.get("_health_check") == "1"
except Exception:
logger.warning("Health check: cache connection failed")
status_code = 200 if (db_ok and cache_ok) else 503
return JsonResponse(
{"status": "ok" if status_code == 200 else "degraded", "db": db_ok, "cache": cache_ok},
status=status_code,
)

View file

@ -1,72 +0,0 @@
# Generated by Django 5.2.10 on 2026-01-25 00:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Project',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('slug', models.SlugField(max_length=200, unique=True)),
('tagline', models.CharField(help_text='Short one-line description', max_length=200)),
('description', models.TextField(help_text='Full project description (Markdown)')),
('featured_image', models.ImageField(blank=True, null=True, upload_to='portfolio/images/')),
('thumbnail', models.ImageField(blank=True, null=True, upload_to='portfolio/thumbnails/')),
('live_url', models.URLField(blank=True, help_text='Live demo URL')),
('github_url', models.URLField(blank=True, help_text='GitHub repository URL')),
('documentation_url', models.URLField(blank=True, help_text='Documentation URL')),
('is_featured', models.BooleanField(default=False, help_text='Show on homepage')),
('order', models.IntegerField(default=0, help_text='Display order (lower = first)')),
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published'), ('archived', 'Archived')], default='draft', max_length=20)),
('start_date', models.DateField(blank=True, null=True)),
('end_date', models.DateField(blank=True, help_text='Leave blank if ongoing', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['order', '-created_at'],
},
),
migrations.CreateModel(
name='Technology',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
('icon', models.CharField(blank=True, help_text='Icon class (e.g., devicon-python-plain)', max_length=50)),
('color', models.CharField(default='#6366f1', help_text='Hex color code', max_length=7)),
],
options={
'verbose_name_plural': 'Technologies',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='ProjectImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='portfolio/gallery/')),
('caption', models.CharField(blank=True, max_length=200)),
('order', models.IntegerField(default=0)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='portfolio.project')),
],
options={
'ordering': ['order'],
},
),
migrations.AddField(
model_name='project',
name='technologies',
field=models.ManyToManyField(blank=True, related_name='projects', to='portfolio.technology'),
),
]

View file

@ -1 +0,0 @@
default_app_config = 'apps.portfolio.apps.PortfolioConfig'

View file

@ -1,65 +0,0 @@
from django.contrib import admin
from django import forms
from django_ckeditor_5.widgets import CKEditor5Widget
from modeltranslation.admin import TranslationAdmin
from .models import Technology, Project, ProjectImage
class ProjectAdminForm(forms.ModelForm):
class Meta:
model = Project
fields = '__all__'
widgets = {
'description_pt_br': CKEditor5Widget(config_name='default'),
'description_en': CKEditor5Widget(config_name='default'),
'description': CKEditor5Widget(config_name='default'),
}
@admin.register(Technology)
class TechnologyAdmin(TranslationAdmin):
list_display = ('name', 'slug', 'icon', 'color', 'project_count')
search_fields = ('name',)
prepopulated_fields = {'slug': ('name',)}
def project_count(self, obj):
return obj.projects.count()
project_count.short_description = 'Projects'
class ProjectImageInline(admin.TabularInline):
model = ProjectImage
extra = 1
@admin.register(Project)
class ProjectAdmin(TranslationAdmin):
form = ProjectAdminForm
list_display = ('title', 'status', 'is_featured', 'order', 'start_date', 'end_date')
list_filter = ('status', 'is_featured', 'technologies')
search_fields = ('title', 'description', 'tagline')
prepopulated_fields = {'slug': ('title',)}
filter_horizontal = ('technologies',)
inlines = [ProjectImageInline]
fieldsets = (
(None, {
'fields': ('title', 'slug', 'tagline', 'description')
}),
('Media', {
'fields': ('featured_image', 'thumbnail')
}),
('Technologies', {
'fields': ('technologies',)
}),
('Links', {
'fields': ('live_url', 'github_url', 'documentation_url')
}),
('Display', {
'fields': ('is_featured', 'order', 'status')
}),
('Dates', {
'fields': ('start_date', 'end_date'),
'classes': ('collapse',)
}),
)

View file

@ -1,7 +0,0 @@
from django.apps import AppConfig
class PortfolioConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.portfolio'
verbose_name = 'Portfolio'

View file

@ -1,72 +0,0 @@
# Generated by Django 5.2.10 on 2026-01-25 00:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Project',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('slug', models.SlugField(max_length=200, unique=True)),
('tagline', models.CharField(help_text='Short one-line description', max_length=200)),
('description', models.TextField(help_text='Full project description (Markdown)')),
('featured_image', models.ImageField(blank=True, null=True, upload_to='portfolio/images/')),
('thumbnail', models.ImageField(blank=True, null=True, upload_to='portfolio/thumbnails/')),
('live_url', models.URLField(blank=True, help_text='Live demo URL')),
('github_url', models.URLField(blank=True, help_text='GitHub repository URL')),
('documentation_url', models.URLField(blank=True, help_text='Documentation URL')),
('is_featured', models.BooleanField(default=False, help_text='Show on homepage')),
('order', models.IntegerField(default=0, help_text='Display order (lower = first)')),
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published'), ('archived', 'Archived')], default='draft', max_length=20)),
('start_date', models.DateField(blank=True, null=True)),
('end_date', models.DateField(blank=True, help_text='Leave blank if ongoing', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['order', '-created_at'],
},
),
migrations.CreateModel(
name='Technology',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
('icon', models.CharField(blank=True, help_text='Icon class (e.g., devicon-python-plain)', max_length=50)),
('color', models.CharField(default='#6366f1', help_text='Hex color code', max_length=7)),
],
options={
'verbose_name_plural': 'Technologies',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='ProjectImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='portfolio/gallery/')),
('caption', models.CharField(blank=True, max_length=200)),
('order', models.IntegerField(default=0)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='portfolio.project')),
],
options={
'ordering': ['order'],
},
),
migrations.AddField(
model_name='project',
name='technologies',
field=models.ManyToManyField(blank=True, related_name='projects', to='portfolio.technology'),
),
]

View file

@ -1,19 +0,0 @@
# Generated by Django 5.2.10 on 2026-01-25 16:03
import django_ckeditor_5.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('portfolio', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='project',
name='description',
field=django_ckeditor_5.fields.CKEditor5Field(verbose_name='Description'),
),
]

View file

@ -1,58 +0,0 @@
# Generated by Django 5.2.10 on 2026-01-25 16:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portfolio', '0002_alter_project_description'),
]
operations = [
migrations.AddField(
model_name='project',
name='description_en',
field=models.TextField(help_text='HTML content', null=True),
),
migrations.AddField(
model_name='project',
name='description_pt_br',
field=models.TextField(help_text='HTML content', null=True),
),
migrations.AddField(
model_name='project',
name='tagline_en',
field=models.CharField(help_text='Short one-line description', max_length=200, null=True),
),
migrations.AddField(
model_name='project',
name='tagline_pt_br',
field=models.CharField(help_text='Short one-line description', max_length=200, null=True),
),
migrations.AddField(
model_name='project',
name='title_en',
field=models.CharField(max_length=200, null=True),
),
migrations.AddField(
model_name='project',
name='title_pt_br',
field=models.CharField(max_length=200, null=True),
),
migrations.AddField(
model_name='technology',
name='name_en',
field=models.CharField(max_length=50, null=True, unique=True),
),
migrations.AddField(
model_name='technology',
name='name_pt_br',
field=models.CharField(max_length=50, null=True, unique=True),
),
migrations.AlterField(
model_name='project',
name='description',
field=models.TextField(help_text='HTML content'),
),
]

View file

@ -1,89 +0,0 @@
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
class Technology(models.Model):
"""Technology/skill used in projects."""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(max_length=50, unique=True)
icon = models.CharField(max_length=50, blank=True, help_text='Icon class (e.g., devicon-python-plain)')
color = models.CharField(max_length=7, default='#6366f1', help_text='Hex color code')
class Meta:
verbose_name_plural = 'Technologies'
ordering = ['name']
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
class Project(models.Model):
"""Portfolio project."""
class Status(models.TextChoices):
DRAFT = 'draft', 'Draft'
PUBLISHED = 'published', 'Published'
ARCHIVED = 'archived', 'Archived'
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
tagline = models.CharField(max_length=200, help_text='Short one-line description')
description = models.TextField(help_text='HTML content')
featured_image = models.ImageField(upload_to='portfolio/images/', blank=True, null=True)
thumbnail = models.ImageField(upload_to='portfolio/thumbnails/', blank=True, null=True)
technologies = models.ManyToManyField(Technology, blank=True, related_name='projects')
# Links
live_url = models.URLField(blank=True, help_text='Live demo URL')
github_url = models.URLField(blank=True, help_text='GitHub repository URL')
documentation_url = models.URLField(blank=True, help_text='Documentation URL')
# Display settings
is_featured = models.BooleanField(default=False, help_text='Show on homepage')
order = models.IntegerField(default=0, help_text='Display order (lower = first)')
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT)
# Dates
start_date = models.DateField(null=True, blank=True)
end_date = models.DateField(null=True, blank=True, help_text='Leave blank if ongoing')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['order', '-created_at']
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('portfolio:project_detail', kwargs={'slug': self.slug})
@property
def is_ongoing(self):
return self.end_date is None
class ProjectImage(models.Model):
"""Additional images for a project."""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='images')
image = models.ImageField(upload_to='portfolio/gallery/')
caption = models.CharField(max_length=200, blank=True)
order = models.IntegerField(default=0)
class Meta:
ordering = ['order']
def __str__(self):
return f"{self.project.title} - Image {self.order}"

View file

@ -1,13 +0,0 @@
from django.contrib.sitemaps import Sitemap
from .models import Project
class PortfolioSitemap(Sitemap):
changefreq = 'monthly'
priority = 0.7
def items(self):
return Project.objects.filter(status=Project.Status.PUBLISHED)
def lastmod(self, obj):
return obj.updated_at

View file

@ -1,14 +0,0 @@
from modeltranslation.translator import translator, TranslationOptions
from .models import Project, Technology
class ProjectTranslationOptions(TranslationOptions):
fields = ('title', 'tagline', 'description')
class TechnologyTranslationOptions(TranslationOptions):
fields = ('name',)
translator.register(Project, ProjectTranslationOptions)
translator.register(Technology, TechnologyTranslationOptions)

View file

@ -1,9 +0,0 @@
from django.urls import path
from . import views
app_name = 'portfolio'
urlpatterns = [
path('', views.ProjectListView.as_view(), name='project_list'),
path('<slug:slug>/', views.ProjectDetailView.as_view(), name='project_detail'),
]

View file

@ -1,49 +0,0 @@
from django.views.generic import ListView, DetailView
from .models import Project, Technology
class ProjectListView(ListView):
model = Project
template_name = 'portfolio/project_list.html'
context_object_name = 'projects'
def get_queryset(self):
queryset = Project.objects.filter(status=Project.Status.PUBLISHED)
# Filter by technology
tech = self.request.GET.get('tech')
if tech:
queryset = queryset.filter(technologies__slug=tech)
return queryset.prefetch_related('technologies')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['technologies'] = Technology.objects.all()
context['active_tech'] = self.request.GET.get('tech', '')
context['featured_projects'] = Project.objects.filter(
status=Project.Status.PUBLISHED,
is_featured=True
).prefetch_related('technologies')[:3]
return context
class ProjectDetailView(DetailView):
model = Project
template_name = 'portfolio/project_detail.html'
context_object_name = 'project'
def get_queryset(self):
return Project.objects.filter(
status=Project.Status.PUBLISHED
).prefetch_related('technologies', 'images')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Get related projects by technology
project_techs = self.object.technologies.all()
context['related_projects'] = Project.objects.filter(
technologies__in=project_techs,
status=Project.Status.PUBLISHED
).exclude(pk=self.object.pk).distinct()[:3]
return context

View file

@ -1,4 +0,0 @@
# Config package
from .celery import app as celery_app
__all__ = ('celery_app',)

View file

@ -1,9 +0,0 @@
"""
ASGI config for richardnixon.dev platform.
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production')
application = get_asgi_application()

View file

@ -1,16 +0,0 @@
"""
Celery configuration for richardnixon.dev platform.
"""
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production')
app = Celery('platform')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
@app.task(bind=True, ignore_result=True)
def debug_task(self):
print(f'Request: {self.request!r}')

View file

@ -1,8 +0,0 @@
from django.conf import settings
def umami(request):
"""Add Umami Analytics website ID to template context."""
return {
'UMAMI_WEBSITE_ID': getattr(settings, 'UMAMI_WEBSITE_ID', ''),
}

View file

@ -1 +0,0 @@
# Settings package

View file

@ -1,335 +0,0 @@
"""
Base Django settings for richardnixon.dev platform.
"""
import os
from pathlib import Path
# Build paths inside the project
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'insecure-dev-key-change-in-production')
# Application definition
INSTALLED_APPS = [
'modeltranslation', # Must be before admin
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
'django.contrib.humanize',
# Third-party apps
'allauth',
'allauth.account',
'allauth.socialaccount',
'allauth.socialaccount.providers.google',
'allauth.socialaccount.providers.github',
'django_celery_beat',
'django_celery_results',
'django_ckeditor_5',
# Two-factor authentication
'django_otp',
'django_otp.plugins.otp_static',
'django_otp.plugins.otp_totp',
'two_factor',
'two_factor.plugins.phonenumber',
# reCAPTCHA
'django_recaptcha',
# CORS
'corsheaders',
# Local apps
'apps.accounts',
'apps.blog',
'apps.portfolio',
'apps.contact',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django_otp.middleware.OTPMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'allauth.account.middleware.AccountMiddleware',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'django.template.context_processors.i18n',
'config.context_processors.umami',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
# Database
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('POSTGRES_DB', 'platform'),
'USER': os.environ.get('POSTGRES_USER', 'platform'),
'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'platform'),
'HOST': os.environ.get('POSTGRES_HOST', 'platform-db'),
'PORT': os.environ.get('POSTGRES_PORT', '5432'),
}
}
# Cache
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': os.environ.get('REDIS_URL', 'redis://platform-redis:6379/0'),
}
}
# Session
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default'
# Password validation
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
# Custom user model
AUTH_USER_MODEL = 'accounts.User'
# Internationalization
from django.utils.translation import gettext_lazy as _
LANGUAGE_CODE = 'pt-br'
TIME_ZONE = 'America/Sao_Paulo'
USE_I18N = True
USE_TZ = True
LANGUAGES = [
('pt-br', _('Português (Brasil)')),
('en', _('English')),
]
LOCALE_PATHS = [
BASE_DIR / 'locale',
]
# Static files
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [BASE_DIR / 'static']
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
# Media files
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Sites framework
SITE_ID = 1
# Allauth settings
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend',
]
ACCOUNT_LOGIN_ON_GET = True
ACCOUNT_LOGOUT_ON_GET = True
ACCOUNT_LOGIN_METHODS = {'email'}
ACCOUNT_SIGNUP_FIELDS = ['email*', 'password1*']
ACCOUNT_EMAIL_VERIFICATION = 'optional'
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
SOCIALACCOUNT_AUTO_SIGNUP = True
SOCIALACCOUNT_LOGIN_ON_GET = True
SOCIALACCOUNT_PROVIDERS = {
'google': {
'SCOPE': ['profile', 'email'],
'AUTH_PARAMS': {'access_type': 'online'},
},
'github': {
'SCOPE': ['user:email'],
},
}
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'
LOGIN_URL = 'two_factor:login'
# Two-factor authentication settings
TWO_FACTOR_PATCH_ADMIN = True
TWO_FACTOR_LOGIN_TIMEOUT = 600 # 10 minutes
TWO_FACTOR_CALL_GATEWAY = None
TWO_FACTOR_SMS_GATEWAY = None
# Celery settings
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://platform-redis:6379/1')
CELERY_RESULT_BACKEND = 'django-db'
CELERY_CACHE_BACKEND = 'default'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = TIME_ZONE
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
# Email settings (default to console for development)
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Logging
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {message}',
'style': '{',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
},
'root': {
'handlers': ['console'],
'level': 'INFO',
},
'loggers': {
'django': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
},
},
}
# Umami Analytics
# Get Website ID from Umami dashboard after setup
UMAMI_WEBSITE_ID = os.environ.get('UMAMI_WEBSITE_ID', '')
# CKEditor 5 settings
CKEDITOR_5_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
CKEDITOR_5_UPLOAD_PATH = 'ckeditor/'
CKEDITOR_5_CONFIGS = {
'default': {
'toolbar': [
'heading', '|',
'bold', 'italic', 'underline', 'strikethrough', '|',
'link', 'blockQuote', 'code', 'codeBlock', '|',
'bulletedList', 'numberedList', 'todoList', '|',
'outdent', 'indent', '|',
'imageUpload', 'insertTable', 'mediaEmbed', '|',
'undo', 'redo', '|',
'sourceEditing',
],
'image': {
'toolbar': [
'imageTextAlternative', 'toggleImageCaption', '|',
'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', '|',
'resizeImage',
],
'resizeOptions': [
{'name': 'resizeImage:original', 'label': 'Original', 'value': None},
{'name': 'resizeImage:25', 'label': '25%', 'value': '25'},
{'name': 'resizeImage:50', 'label': '50%', 'value': '50'},
{'name': 'resizeImage:75', 'label': '75%', 'value': '75'},
],
},
'table': {
'contentToolbar': ['tableColumn', 'tableRow', 'mergeTableCells'],
},
'heading': {
'options': [
{'model': 'paragraph', 'title': 'Paragraph', 'class': 'ck-heading_paragraph'},
{'model': 'heading1', 'view': 'h1', 'title': 'Heading 1', 'class': 'ck-heading_heading1'},
{'model': 'heading2', 'view': 'h2', 'title': 'Heading 2', 'class': 'ck-heading_heading2'},
{'model': 'heading3', 'view': 'h3', 'title': 'Heading 3', 'class': 'ck-heading_heading3'},
],
},
'codeBlock': {
'languages': [
{'language': 'python', 'label': 'Python'},
{'language': 'javascript', 'label': 'JavaScript'},
{'language': 'html', 'label': 'HTML'},
{'language': 'css', 'label': 'CSS'},
{'language': 'bash', 'label': 'Bash'},
{'language': 'sql', 'label': 'SQL'},
{'language': 'json', 'label': 'JSON'},
],
},
},
'extends': {
'toolbar': [
'heading', '|',
'bold', 'italic', 'underline', 'strikethrough', 'subscript', 'superscript', '|',
'link', 'blockQuote', 'code', 'codeBlock', '|',
'bulletedList', 'numberedList', 'todoList', '|',
'outdent', 'indent', 'alignment', '|',
'imageUpload', 'insertTable', 'mediaEmbed', 'horizontalLine', '|',
'highlight', 'removeFormat', '|',
'undo', 'redo', '|',
'sourceEditing',
],
'image': {
'toolbar': [
'imageTextAlternative', 'toggleImageCaption', '|',
'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', '|',
'resizeImage',
],
},
'table': {
'contentToolbar': ['tableColumn', 'tableRow', 'mergeTableCells', 'tableCellProperties', 'tableProperties'],
},
'heading': {
'options': [
{'model': 'paragraph', 'title': 'Paragraph', 'class': 'ck-heading_paragraph'},
{'model': 'heading1', 'view': 'h1', 'title': 'Heading 1', 'class': 'ck-heading_heading1'},
{'model': 'heading2', 'view': 'h2', 'title': 'Heading 2', 'class': 'ck-heading_heading2'},
{'model': 'heading3', 'view': 'h3', 'title': 'Heading 3', 'class': 'ck-heading_heading3'},
{'model': 'heading4', 'view': 'h4', 'title': 'Heading 4', 'class': 'ck-heading_heading4'},
],
},
},
}
# CORS settings
CORS_ALLOWED_ORIGINS = os.environ.get('CORS_ALLOWED_ORIGINS', 'http://localhost:3000').split(',')
CORS_ALLOW_CREDENTIALS = True
# reCAPTCHA v3 settings
RECAPTCHA_PUBLIC_KEY = os.environ.get('RECAPTCHA_PUBLIC_KEY', '')
RECAPTCHA_PRIVATE_KEY = os.environ.get('RECAPTCHA_PRIVATE_KEY', '')
RECAPTCHA_REQUIRED_SCORE = 0.5 # Score threshold (0.0 - 1.0)

View file

@ -1,34 +0,0 @@
"""
Development settings for richardnixon.dev platform.
"""
from .base import *
DEBUG = True
ALLOWED_HOSTS = ['*']
# Use SQLite for local development if desired
# DATABASES = {
# 'default': {
# 'ENGINE': 'django.db.backends.sqlite3',
# 'NAME': BASE_DIR / 'db.sqlite3',
# }
# }
# Disable caching in development
# CACHES = {
# 'default': {
# 'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
# }
# }
# Use simpler static files storage in development
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage'
# Email - output to console
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Django Debug Toolbar (uncomment if needed)
# INSTALLED_APPS += ['debug_toolbar']
# MIDDLEWARE.insert(0, 'debug_toolbar.middleware.DebugToolbarMiddleware')
# INTERNAL_IPS = ['127.0.0.1']

View file

@ -1,88 +0,0 @@
"""
Production settings for richardnixon.dev platform.
"""
import os
from .base import *
DEBUG = False
# Sentry error tracking
SENTRY_DSN = os.environ.get('SENTRY_DSN', '')
if SENTRY_DSN:
import sentry_sdk
sentry_sdk.init(
dsn=SENTRY_DSN,
traces_sample_rate=0.1,
send_default_pii=False,
)
ALLOWED_HOSTS = [
'richardnixon.dev',
'www.richardnixon.dev',
'localhost',
'platform-web',
]
# CSRF trusted origins for Traefik proxy
CSRF_TRUSTED_ORIGINS = [
'https://richardnixon.dev',
'https://www.richardnixon.dev',
]
# Security settings
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = False # Traefik handles SSL
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'DENY'
# HSTS (commented out until site is stable)
# SECURE_HSTS_SECONDS = 31536000
# SECURE_HSTS_INCLUDE_SUBDOMAINS = True
# SECURE_HSTS_PRELOAD = True
# Email settings for production (configure SMTP)
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = os.environ.get('EMAIL_HOST', 'smtp.gmail.com')
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587))
EMAIL_USE_TLS = True
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@richardnixon.dev')
# Logging for production
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
'style': '{',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
},
'root': {
'handlers': ['console'],
'level': 'WARNING',
},
'loggers': {
'django': {
'handlers': ['console'],
'level': 'WARNING',
'propagate': False,
},
'apps': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
},
},
}

View file

@ -1,49 +0,0 @@
"""
URL configuration for richardnixon.dev platform.
"""
from django.contrib import admin
from django.urls import path, re_path, include
from django.conf import settings
from django.views.static import serve
from django.conf.urls.i18n import i18n_patterns
from django.contrib.sitemaps.views import sitemap
from two_factor.urls import urlpatterns as tf_urls
from two_factor.admin import AdminSiteOTPRequired
from apps.api.api import api
from apps.blog.sitemaps import BlogSitemap
from apps.core.views import health
from apps.portfolio.sitemaps import PortfolioSitemap
# Patch admin to require OTP
admin.site.__class__ = AdminSiteOTPRequired
sitemaps = {
'blog': BlogSitemap,
'portfolio': PortfolioSitemap,
}
# URLs that don't need language prefix
urlpatterns = [
path('health/', health, name='health'),
path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'),
path('i18n/', include('django.conf.urls.i18n')),
path('ckeditor5/', include('django_ckeditor_5.urls')),
path('api/', api.urls),
]
# URLs with language prefix (/pt-br/, /en/)
urlpatterns += i18n_patterns(
path('', include(tf_urls)),
path('admin/', admin.site.urls),
path('accounts/', include('allauth.urls')),
path('', include('apps.blog.urls')),
path('portfolio/', include('apps.portfolio.urls')),
path('contact/', include('apps.contact.urls')),
prefix_default_language=True,
)
# Serve media files in all environments (static() is empty when DEBUG=False)
urlpatterns += [
re_path(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
]

View file

@ -1,9 +0,0 @@
"""
WSGI config for richardnixon.dev platform.
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production')
application = get_wsgi_application()

View file

@ -1,21 +0,0 @@
import pytest
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create_user(
email="test@example.com",
password="testpass123",
)
@pytest.fixture
def admin_user(db):
return User.objects.create_superuser(
email="admin@example.com",
password="adminpass123",
)

View file

@ -1,38 +0,0 @@
FROM python:3.12-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Set work directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
gettext \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements/ requirements/
RUN pip install --no-cache-dir -r requirements/production.txt
# Copy project files
COPY . .
# Collect static files
RUN python manage.py collectstatic --noinput --settings=config.settings.production || true
# Create non-root user
RUN useradd -m -u 1000 appuser && \
chown -R appuser:appuser /app
USER appuser
# Expose port
EXPOSE 8000
# Default command
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--threads", "4", "config.wsgi:application"]

View file

@ -1,47 +0,0 @@
FROM python:3.12-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Set work directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
netcat-openbsd \
gettext \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements/ requirements/
RUN pip install --no-cache-dir -r requirements/production.txt
# Copy project files
COPY . .
# Copy entrypoint script
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Create directories for static and media files
RUN mkdir -p /app/staticfiles /app/media
# Create non-root user
RUN useradd -m -u 1000 appuser && \
chown -R appuser:appuser /app
USER appuser
# Expose port
EXPOSE 8000
# Set entrypoint
ENTRYPOINT ["/entrypoint.sh"]
# Default command
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--threads", "4", "config.wsgi:application"]

View file

@ -1,26 +0,0 @@
#!/bin/bash
set -e
# Wait for PostgreSQL
echo "Waiting for PostgreSQL..."
while ! nc -z $POSTGRES_HOST ${POSTGRES_PORT:-5432}; do
sleep 1
done
echo "PostgreSQL is ready!"
# Run migrations
echo "Running migrations..."
python manage.py migrate --noinput
# Collect static files
echo "Collecting static files..."
python manage.py collectstatic --noinput
# Create superuser if not exists (only if DJANGO_SUPERUSER_EMAIL is set)
if [ -n "$DJANGO_SUPERUSER_EMAIL" ]; then
echo "Creating superuser..."
python manage.py createsuperuser --noinput || true
fi
# Execute command
exec "$@"

View file

@ -37,16 +37,14 @@ cp infrastructure/.env.example infrastructure/.env
Edit `infrastructure/.env` and fill in all required values. At minimum:
- `DJANGO_SECRET_KEY` — generate with:
```bash
python3 -c "from secrets import token_urlsafe; print(token_urlsafe(50))"
```
- `PLATFORM_DB_PASSWORD`, `LOCFLOW_DB_PASSWORD`, `UMAMI_DB_PASSWORD` — strong random passwords
- `LOCFLOW_DB_PASSWORD`, `UMAMI_DB_PASSWORD` — strong random passwords
- `LOCFLOW_SECRET_KEY`, `EIRESCOPE_SECRET_KEY``python3 -c "from secrets import token_urlsafe; print(token_urlsafe(50))"`
- `MYSQL_ROOT_PASSWORD`, `WP_DB_PASSWORD` — for WordPress
- `GRAFANA_ADMIN_PASSWORD`
- `CROWDSEC_BOUNCER_KEY` — generated after first CrowdSec startup (see step 6)
- `AUTHENTIK_SECRET_KEY`, `AUTHENTIK_DB_PASSWORD`
- `FORGEJO_DB_PASSWORD`, `FORGEJO_SECRET_KEY`, `FORGEJO_INTERNAL_TOKEN`, `FORGEJO_OAUTH2_JWT_SECRET` — see Forgejo section below for generation commands
- `FORGEJO_RUNNER_REGISTRATION_TOKEN` — generated after first Forgejo boot (see step 8)
## 4. Start core services
@ -55,19 +53,11 @@ cd infrastructure
docker compose up -d
```
This starts all services: Traefik, PostgreSQL, Redis, Django (legacy), Celery (legacy), Hugo blog-static, Forgejo + runner, WordPress, Umami, Grafana, Prometheus, LocFlow, EireScope, Authentik.
This starts every service: Traefik, Hugo blog-static, Forgejo + runner, WordPress, Umami, Grafana, Prometheus, Loki, Promtail, LocFlow, EireScope, Authentik, CrowdSec.
Traefik automatically provisions Let's Encrypt SSL certificates.
## 5. Run initial migrations and create superuser
```bash
docker compose exec platform-web python manage.py migrate
docker compose exec platform-web python manage.py createsuperuser
docker compose exec platform-web python manage.py collectstatic --no-input
```
## 6. Configure CrowdSec bouncer
## 5. Configure CrowdSec bouncer
```bash
docker compose exec crowdsec cscli bouncers add traefik-bouncer
@ -79,24 +69,18 @@ Copy the generated key into `infrastructure/.env` as `CROWDSEC_BOUNCER_KEY`, the
docker compose restart crowdsec-bouncer
```
## 7. Verify deployment
## 6. Verify deployment
```bash
# Check all containers are healthy
docker compose ps
# Smoke test
curl https://richardnixon.dev/health/
# Expected: {"status": "ok", "db": true, "cache": true}
# Smoke test the public blog (served by Hugo blog-static)
curl -I https://richardnixon.dev/
curl -I https://richardnixon.dev/pt-br/
```
## 8. Configure OAuth (optional)
1. Go to `https://richardnixon.dev/admin/`
2. Under **Social applications**, add Google and/or GitHub OAuth providers
3. Get credentials from Google Cloud Console / GitHub Developer Settings
## 9. Set up Forgejo Actions CI/CD for the Hugo blog
## 7. Set up Forgejo Actions CI/CD for the Hugo blog
A `forgejo-runner` container is included in the compose and runs jobs on the host's Docker socket. After the first `docker compose up -d`:
@ -119,17 +103,16 @@ After a push to `main` on `richardnixon.dev-hugo`, the runner builds and the sit
## Updating
**Blog content** (`richardnixon.dev`): edit Markdown in the Hugo repo, `git push origin main`, Forgejo Actions deploys automatically. See `Richard/richardnixon.dev-hugo/.forgejo/workflows/deploy.yml`.
**Blog content** (`richardnixon.dev`): edit Markdown in [the Hugo repo](https://git.richardnixon.dev/Richard/richardnixon.dev-hugo), `git push origin main` — Forgejo Actions builds and deploys automatically (~3060s).
**Infrastructure + legacy Django**:
**Infrastructure**:
```bash
cd /root/richardnixon.dev
git pull origin main
docker compose -f infrastructure/docker-compose.yml build platform-web locflow-web eirescope
docker compose -f infrastructure/docker-compose.yml build locflow-web eirescope locflow-frontend
docker compose -f infrastructure/docker-compose.yml up -d
docker compose -f infrastructure/docker-compose.yml exec -T platform-web python manage.py migrate --no-input
docker compose -f infrastructure/docker-compose.yml exec -T platform-web python manage.py collectstatic --no-input
docker compose -f infrastructure/docker-compose.yml exec -T locflow-web python manage.py migrate --no-input
```
## Services and ports
@ -138,10 +121,9 @@ All services are behind Traefik (ports 80/443). No service exposes ports directl
| Service | Internal port | URL |
|---------|--------------|-----|
| Hugo blog-static (nginx) | 80 | richardnixon.dev (catch-all, priority 100) |
| Django platform (legacy) | 8000 | richardnixon.dev/{admin,api,static,media} (priority 110) |
| Hugo blog-static (nginx) | 80 | richardnixon.dev (catch-all) |
| LocFlow API | 8000 | locflow.richardnixon.dev |
| EireScope | 5000 | osint.richardnixon.dev |
| EireScope | 5000 | eirescope.richardnixon.dev |
| WordPress | 80 | richardemanu.com |
| Umami | 3000 | analytics.richardnixon.dev |
| Grafana | 3000 | status.richardnixon.dev |

View file

@ -1,124 +0,0 @@
# Local Development Guide
## Prerequisites
- Python 3.12+
- PostgreSQL 16 and Redis 7 (or Docker for services only)
- Node.js 22+ (for the frontend)
## Option A: Docker Compose (recommended)
Use Docker Compose for databases, run Django directly.
### 1. Start services
```bash
docker compose -f infrastructure/docker-compose.yml up -d platform-db platform-redis
```
### 2. Set up Python environment
```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements/dev.txt
```
### 3. Configure environment
```bash
export DJANGO_SETTINGS_MODULE=config.settings.development
export POSTGRES_DB=platform
export POSTGRES_USER=platform
export POSTGRES_PASSWORD=platform
export POSTGRES_HOST=localhost
export POSTGRES_PORT=5432
export REDIS_URL=redis://localhost:6379/0
export CELERY_BROKER_URL=redis://localhost:6379/1
```
Or create a `.env` file and use `python-dotenv`.
### 4. Run migrations and start server
```bash
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
```
Visit http://localhost:8000/
### 5. Run Celery (optional)
In a separate terminal:
```bash
celery -A config worker -l info
celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
```
## Option B: Full Docker stack
Run everything in Docker (mirrors production):
```bash
cp infrastructure/.env.example infrastructure/.env
# Edit .env with local values
docker compose -f infrastructure/docker-compose.yml up -d platform-db platform-redis platform-web
```
## Frontend development
The Next.js frontend lives in `frontend/`.
```bash
cd frontend
npm install
npm run dev
```
Visit http://localhost:3000/
The frontend proxies API calls to the Django backend. Set `API_URL=http://localhost:8000/api` if the backend runs on a different port.
## Running tests
```bash
# All tests
pytest apps/ -v
# With coverage
pytest apps/ --cov=apps --cov-report=term-missing
# Single app
pytest apps/accounts/ -v
```
## Linting
```bash
ruff check .
ruff format --check .
mypy apps/
```
## Useful commands
```bash
# Create new migrations
python manage.py makemigrations
# Compile translations
python manage.py compilemessages
# Collect static files
python manage.py collectstatic
# Django shell
python manage.py shell
# Check for issues
python manage.py check
```

View file

@ -1,3 +0,0 @@
node_modules
.next
.git

3
frontend/.gitignore vendored
View file

@ -1,3 +0,0 @@
node_modules/
.next/
out/

View file

@ -1,32 +0,0 @@
FROM node:22-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

View file

@ -1,15 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "richardnixon.dev",
},
],
},
};
export default nextConfig;

File diff suppressed because it is too large Load diff

View file

@ -1,26 +0,0 @@
{
"name": "richardnixon-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"highlight.js": "^11.9.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0",
"@tailwindcss/postcss": "^4.0.0",
"tailwindcss": "^4.0.0",
"postcss": "^8.0.0"
}
}

View file

@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View file

@ -1,129 +0,0 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import Image from "next/image";
import { getPost, getRelatedPosts } from "@/lib/api";
import PostCard from "@/components/PostCard";
import type { Metadata } from "next";
import Highlight from "@/components/Highlight";
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.meta_description || post.excerpt,
keywords: post.meta_keywords || undefined,
openGraph: {
title: post.title,
description: post.meta_description || post.excerpt,
type: "article",
...(post.featured_image && { images: [post.featured_image] }),
},
};
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const [post, related] = await Promise.all([
getPost(slug),
getRelatedPosts(slug),
]);
const date = post.published_at
? new Date(post.published_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: "";
const shareUrl = `https://richardnixon.dev/blog/${post.slug}`;
return (
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Header */}
<div className="mb-8">
{post.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.tags.map((tag) => (
<Link
key={tag.slug}
href={`/blog?tag=${tag.slug}`}
className="text-xs px-2 py-1 bg-primary-900/50 text-primary-300 rounded hover:bg-primary-800/50 transition"
>
{tag.name}
</Link>
))}
</div>
)}
<h1 className="text-3xl md:text-4xl font-bold text-white mb-4">{post.title}</h1>
<div className="flex items-center text-sm text-gray-400 gap-4">
<span>{date}</span>
<span>{post.reading_time} min read</span>
{post.author_name && <span>by {post.author_name}</span>}
</div>
</div>
{/* Featured Image */}
{post.featured_image && (
<div className="relative w-full h-64 md:h-96 mb-8 rounded-lg overflow-hidden">
<Image
src={post.featured_image}
alt={post.title}
fill
className="object-cover"
priority
/>
</div>
)}
{/* Content */}
<div
className="prose text-gray-300 max-w-none mb-12"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
<Highlight />
{/* Share */}
<div className="flex items-center gap-4 py-6 border-t border-gray-700">
<span className="text-sm text-gray-400">Share:</span>
<a
href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(post.title)}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-400 hover:text-white transition"
>
Twitter
</a>
<a
href={`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-400 hover:text-white transition"
>
LinkedIn
</a>
</div>
{/* Back */}
<Link href="/blog" className="inline-block text-primary-400 hover:text-primary-300 text-sm transition mt-4">
&larr; Back to Blog
</Link>
{/* Related Posts */}
{related.length > 0 && (
<section className="mt-16">
<h2 className="text-2xl font-bold text-white mb-6">Related Posts</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{related.map((p) => (
<PostCard key={p.id} post={p} />
))}
</div>
</section>
)}
</article>
);
}

View file

@ -1,131 +0,0 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import PostCard from "@/components/PostCard";
import { getPosts, getTags, getPostsCount } from "@/lib/api";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Blog",
description: "Articles about software engineering, technology, and more.",
};
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; search?: string; tag?: string }>;
}) {
const params = await searchParams;
const page = Number(params.page) || 1;
const search = params.search || "";
const tag = params.tag || "";
const [posts, tags, total] = await Promise.all([
getPosts({ page, search, tag }),
getTags(),
getPostsCount({ search, tag }),
]);
const totalPages = Math.ceil(total / 10);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1 className="text-3xl font-bold text-white mb-8">Blog</h1>
<div className="flex flex-col lg:flex-row gap-8">
{/* Posts */}
<div className="flex-1">
{/* Search */}
<form className="mb-8">
<div className="flex gap-2">
<input
type="text"
name="search"
defaultValue={search}
placeholder="Search posts..."
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 placeholder-gray-500 focus:outline-none focus:border-primary-500"
/>
<button
type="submit"
className="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition"
>
Search
</button>
</div>
</form>
{posts.length === 0 ? (
<p className="text-gray-400">No posts found.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center gap-2 mt-8">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<Link
key={p}
href={`/blog?page=${p}${search ? `&search=${search}` : ""}${tag ? `&tag=${tag}` : ""}`}
className={`px-3 py-1 rounded ${
p === page
? "bg-primary-600 text-white"
: "bg-gray-800 text-gray-400 hover:text-white"
}`}
>
{p}
</Link>
))}
</div>
)}
</div>
{/* Sidebar */}
<aside className="lg:w-72">
<div className="bg-gray-800 border border-gray-700 rounded-lg p-5">
<h3 className="text-lg font-semibold text-white mb-4">Tags</h3>
<div className="flex flex-wrap gap-2">
<Link
href="/blog"
className={`text-sm px-3 py-1 rounded-full transition ${
!tag
? "bg-primary-600 text-white"
: "bg-gray-700 text-gray-300 hover:text-white"
}`}
>
All
</Link>
{tags.map((t) => (
<Link
key={t.slug}
href={`/blog?tag=${t.slug}`}
className={`text-sm px-3 py-1 rounded-full transition ${
tag === t.slug
? "bg-primary-600 text-white"
: "bg-gray-700 text-gray-300 hover:text-white"
}`}
>
{t.name}
</Link>
))}
</div>
</div>
<div className="mt-4 bg-gray-800 border border-gray-700 rounded-lg p-5">
<h3 className="text-lg font-semibold text-white mb-2">Subscribe</h3>
<p className="text-gray-400 text-sm mb-3">Stay updated with the RSS feed.</p>
<a
href="/feed/"
className="inline-block text-sm text-primary-400 hover:text-primary-300 transition"
>
RSS Feed &rarr;
</a>
</div>
</aside>
</div>
</div>
);
}

View file

@ -1,148 +0,0 @@
"use client";
import { useState } from "react";
import { submitContact } from "@/lib/api";
export default function ContactPage() {
const [form, setForm] = useState({ name: "", email: "", subject: "", message: "" });
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setStatus("sending");
try {
await submitContact(form);
setStatus("sent");
setForm({ name: "", email: "", subject: "", message: "" });
} catch {
setStatus("error");
}
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1 className="text-3xl font-bold text-white mb-2">Contact</h1>
<p className="text-gray-400 mb-8">
Feel free to reach out. I&apos;ll get back to you as soon as possible.
</p>
<div className="flex flex-col lg:flex-row gap-8">
{/* Form */}
<div className="flex-1">
{status === "sent" && (
<div className="mb-6 p-4 rounded-lg bg-green-900 text-green-100">
Message sent successfully!
</div>
)}
{status === "error" && (
<div className="mb-6 p-4 rounded-lg bg-red-900 text-red-100">
Something went wrong. Please try again.
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-1">
Name
</label>
<input
id="name"
type="text"
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 focus:outline-none focus:border-primary-500"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-1">
Email
</label>
<input
id="email"
type="email"
required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 focus:outline-none focus:border-primary-500"
/>
</div>
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium text-gray-300 mb-1">
Subject
</label>
<input
id="subject"
type="text"
required
value={form.subject}
onChange={(e) => setForm({ ...form, subject: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 focus:outline-none focus:border-primary-500"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-300 mb-1">
Message
</label>
<textarea
id="message"
rows={6}
required
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 focus:outline-none focus:border-primary-500 resize-none"
/>
</div>
<button
type="submit"
disabled={status === "sending"}
className="px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:opacity-50 text-white rounded-lg transition font-medium"
>
{status === "sending" ? "Sending..." : "Send Message"}
</button>
</form>
</div>
{/* Sidebar */}
<aside className="lg:w-72 space-y-4">
<div className="bg-gray-800 border border-gray-700 rounded-lg p-5">
<h3 className="text-lg font-semibold text-white mb-3">Resume</h3>
<p className="text-gray-400 text-sm mb-3">Download my latest resume.</p>
<a
href={`${process.env.NEXT_PUBLIC_API_URL || ""}/contact/resume`}
className="inline-block px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition text-sm"
>
Download Resume
</a>
</div>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-5">
<h3 className="text-lg font-semibold text-white mb-3">Social</h3>
<ul className="space-y-2">
<li>
<a
href="https://github.com/richardnixondev"
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-white text-sm transition"
>
GitHub
</a>
</li>
<li>
<a
href="https://linkedin.com"
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-white text-sm transition"
>
LinkedIn
</a>
</li>
</ul>
</div>
</aside>
</div>
</div>
);
}

View file

@ -1,55 +0,0 @@
@import "tailwindcss";
@theme {
/* Primary (Indigo) */
--color-primary-50: #eef2ff;
--color-primary-100: #e0e7ff;
--color-primary-200: #c7d2fe;
--color-primary-300: #a5b4fc;
--color-primary-400: #818cf8;
--color-primary-500: #6366f1;
--color-primary-600: #4f46e5;
--color-primary-700: #4338ca;
--color-primary-800: #3730a3;
--color-primary-900: #312e81;
/* Gray - match Tailwind v3 hex values for consistent look */
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
--color-gray-950: #030712;
}
/* Custom scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #1f2937; }
::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #6b7280; }
/* Prose styles for blog content */
.prose pre { background: #1f2937; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }
.prose code { background: #374151; padding: 0.125rem 0.25rem; border-radius: 0.25rem; font-size: 0.875em; }
.prose pre code { background: transparent; padding: 0; }
.prose h1, .prose h2, .prose h3, .prose h4 { color: #f9fafb; font-weight: 700; }
.prose h1 { font-size: 2rem; margin-top: 2rem; margin-bottom: 1rem; }
.prose h2 { font-size: 1.5rem; margin-top: 1.75rem; margin-bottom: 0.75rem; }
.prose h3 { font-size: 1.25rem; margin-top: 1.5rem; margin-bottom: 0.5rem; }
.prose p { margin-bottom: 1rem; line-height: 1.75; }
.prose a { color: #818cf8; text-decoration: underline; }
.prose a:hover { color: #a5b4fc; }
.prose ul, .prose ol { margin-bottom: 1rem; padding-left: 1.5rem; }
.prose li { margin-bottom: 0.25rem; }
.prose ul { list-style-type: disc; }
.prose ol { list-style-type: decimal; }
.prose img { border-radius: 0.5rem; margin: 1.5rem 0; }
.prose blockquote { border-left: 4px solid #4f46e5; padding-left: 1rem; margin: 1.5rem 0; color: #9ca3af; font-style: italic; }
.prose table { width: 100%; border-collapse: collapse; margin: 1.5rem 0; }
.prose th, .prose td { border: 1px solid #374151; padding: 0.5rem 0.75rem; }
.prose th { background: #1f2937; font-weight: 600; }

View file

@ -1,41 +0,0 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
import Script from "next/script";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: {
default: "richardnixon.dev",
template: "%s | richardnixon.dev",
},
description: "Personal platform - Blog, Portfolio, and Dashboards",
openGraph: {
type: "website",
siteName: "richardnixon.dev",
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
const umamiId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
return (
<html lang="en" className="scroll-smooth">
<body className={`${inter.className} bg-gray-900 text-gray-100 min-h-screen flex flex-col`}>
<Navbar />
<main className="flex-grow">{children}</main>
<Footer />
{umamiId && (
<Script
defer
src="https://analytics.richardnixon.dev/script.js"
data-website-id={umamiId}
/>
)}
</body>
</html>
);
}

View file

@ -1,81 +0,0 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import PostCard from "@/components/PostCard";
import { getHomeData } from "@/lib/api";
export default async function Home() {
const { recent_posts } = await getHomeData();
return (
<>
{/* Hero */}
<section className="bg-gradient-to-br from-gray-900 via-gray-800 to-primary-900/20 py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 className="text-4xl md:text-6xl font-bold text-white mb-6">
Hi, I&apos;m{" "}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary-400 to-primary-600">
Richard Nixon
</span>
</h1>
<p className="text-xl text-gray-300 mb-8 max-w-2xl mx-auto">
Software Engineer sharing thoughts, projects, and data visualizations.
</p>
<div className="flex justify-center gap-4">
<Link
href="/blog"
className="px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition font-medium"
>
Read Blog
</Link>
<Link
href="/portfolio"
className="px-6 py-3 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition font-medium"
>
View Portfolio
</Link>
</div>
</div>
</section>
{/* Recent Posts */}
{recent_posts.length > 0 && (
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-bold text-white">Recent Posts</h2>
<Link href="/blog" className="text-primary-400 hover:text-primary-300 text-sm transition">
View all &rarr;
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{recent_posts.slice(0, 3).map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</section>
)}
{/* Quick Links */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-16">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ href: "/portfolio", title: "Portfolio", desc: "Check out my projects and work." },
{ href: "/blog", title: "Blog", desc: "Read my latest articles and thoughts." },
{ href: "/contact", title: "Contact", desc: "Get in touch or download my resume." },
].map((item) => (
<Link
key={item.href}
href={item.href}
className="block bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-primary-500 transition group"
>
<h3 className="text-lg font-semibold text-white group-hover:text-primary-400 transition mb-2">
{item.title}
</h3>
<p className="text-gray-400 text-sm">{item.desc}</p>
</Link>
))}
</div>
</section>
</>
);
}

View file

@ -1,171 +0,0 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import Image from "next/image";
import { getProject, getRelatedProjects } from "@/lib/api";
import ProjectCard from "@/components/ProjectCard";
import type { Metadata } from "next";
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const project = await getProject(slug);
return {
title: project.title,
description: project.tagline,
openGraph: {
title: project.title,
description: project.tagline,
...(project.featured_image && { images: [project.featured_image] }),
},
};
}
export default async function ProjectPage({ params }: Props) {
const { slug } = await params;
const [project, related] = await Promise.all([
getProject(slug),
getRelatedProjects(slug),
]);
const formatDate = (d: string | null) =>
d ? new Date(d).toLocaleDateString("en-US", { year: "numeric", month: "short" }) : null;
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Link href="/portfolio" className="text-primary-400 hover:text-primary-300 text-sm transition">
&larr; Back to Portfolio
</Link>
{/* Header */}
<div className="mt-6 mb-8">
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2">{project.title}</h1>
<p className="text-xl text-gray-400 mb-4">{project.tagline}</p>
{/* Technologies */}
<div className="flex flex-wrap gap-2 mb-4">
{project.technologies.map((tech) => (
<span
key={tech.slug}
className="text-sm px-3 py-1 rounded-full"
style={{ backgroundColor: `${tech.color}20`, color: tech.color }}
>
{tech.name}
</span>
))}
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-3">
{project.live_url && (
<a
href={project.live_url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition text-sm"
>
Live Demo
</a>
)}
{project.github_url && (
<a
href={project.github_url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition text-sm"
>
View Code
</a>
)}
{project.documentation_url && (
<a
href={project.documentation_url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition text-sm"
>
Documentation
</a>
)}
</div>
</div>
{/* Featured Image */}
{project.featured_image && (
<div className="relative w-full h-64 md:h-96 mb-8 rounded-lg overflow-hidden shadow-xl">
<Image
src={project.featured_image}
alt={project.title}
fill
className="object-cover"
priority
/>
</div>
)}
{/* Description */}
<div
className="prose text-gray-300 max-w-none mb-8"
dangerouslySetInnerHTML={{ __html: project.description }}
/>
{/* Gallery */}
{project.images.length > 0 && (
<section className="mb-8">
<h2 className="text-2xl font-bold text-white mb-4">Gallery</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{project.images.map((img, i) => (
<div key={i} className="relative h-64 rounded-lg overflow-hidden">
<Image
src={img.image}
alt={img.caption || `${project.title} screenshot ${i + 1}`}
fill
className="object-cover"
/>
{img.caption && (
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-3 py-2 text-sm text-gray-200">
{img.caption}
</div>
)}
</div>
))}
</div>
</section>
)}
{/* Project Details */}
<div className="bg-gray-800 border border-gray-700 rounded-lg p-5 mb-8">
<h3 className="text-lg font-semibold text-white mb-3">Project Details</h3>
<dl className="grid grid-cols-2 gap-3 text-sm">
{project.start_date && (
<>
<dt className="text-gray-400">Started</dt>
<dd className="text-gray-200">{formatDate(project.start_date)}</dd>
</>
)}
<dt className="text-gray-400">Status</dt>
<dd className="text-gray-200">
{project.is_ongoing ? (
<span className="text-green-400">Ongoing</span>
) : (
formatDate(project.end_date) || "Completed"
)}
</dd>
</dl>
</div>
{/* Related Projects */}
{related.length > 0 && (
<section>
<h2 className="text-2xl font-bold text-white mb-6">Related Projects</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{related.map((p) => (
<ProjectCard key={p.id} project={p} />
))}
</div>
</section>
)}
</div>
);
}

View file

@ -1,82 +0,0 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import ProjectCard from "@/components/ProjectCard";
import { getProjects, getTechnologies, getFeaturedProjects } from "@/lib/api";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Portfolio",
description: "Projects and work showcase.",
};
export default async function PortfolioPage({
searchParams,
}: {
searchParams: Promise<{ tech?: string }>;
}) {
const params = await searchParams;
const tech = params.tech || "";
const [projects, technologies, featured] = await Promise.all([
getProjects(tech || undefined),
getTechnologies(),
tech ? Promise.resolve([]) : getFeaturedProjects(),
]);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1 className="text-3xl font-bold text-white mb-8">Portfolio</h1>
{/* Technology Filter */}
<div className="flex flex-wrap gap-2 mb-8">
<Link
href="/portfolio"
className={`text-sm px-3 py-1 rounded-full transition ${
!tech
? "bg-primary-600 text-white"
: "bg-gray-700 text-gray-300 hover:text-white"
}`}
>
All
</Link>
{technologies.map((t) => (
<Link
key={t.slug}
href={`/portfolio?tech=${t.slug}`}
className={`text-sm px-3 py-1 rounded-full transition ${
tech === t.slug
? "bg-primary-600 text-white"
: "bg-gray-700 text-gray-300 hover:text-white"
}`}
>
{t.name}
</Link>
))}
</div>
{/* Featured */}
{featured.length > 0 && (
<section className="mb-12">
<h2 className="text-xl font-semibold text-white mb-4">Featured</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{featured.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
</section>
)}
{/* All Projects */}
{projects.length === 0 ? (
<p className="text-gray-400">No projects found.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
</div>
);
}

View file

@ -1,56 +0,0 @@
import Link from "next/link";
export default function Footer() {
return (
<footer className="bg-gray-800 border-t border-gray-700 mt-auto">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div>
<h3 className="text-lg font-semibold text-white mb-4">richardnixon.dev</h3>
<p className="text-gray-400 text-sm">
Personal platform for sharing thoughts, projects, and data.
</p>
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-4">Links</h3>
<ul className="space-y-2">
<li>
<a href="/feed/" className="text-gray-400 hover:text-white text-sm">
RSS Feed
</a>
</li>
<li>
<Link href="/contact" className="text-gray-400 hover:text-white text-sm">
Download Resume
</Link>
</li>
<li>
<a
href="https://github.com/richardnixondev"
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-white text-sm"
>
GitHub
</a>
</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-4">Connect</h3>
<ul className="space-y-2">
<li>
<Link href="/contact" className="text-gray-400 hover:text-white text-sm">
Contact Form
</Link>
</li>
</ul>
</div>
</div>
<div className="mt-8 pt-8 border-t border-gray-700 text-center text-gray-500 text-sm">
&copy; {new Date().getFullYear()} richardnixon.dev. All rights reserved.
</div>
</div>
</footer>
);
}

View file

@ -1,12 +0,0 @@
"use client";
import { useEffect } from "react";
import hljs from "highlight.js";
import "highlight.js/styles/github-dark.css";
export default function Highlight() {
useEffect(() => {
hljs.highlightAll();
}, []);
return null;
}

View file

@ -1,120 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
const languages = [
{ code: "en", label: "English" },
{ code: "pt-br", label: "Português (Brasil)" },
];
export default function Navbar() {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
const [langOpen, setLangOpen] = useState(false);
const links = [
{ href: "/blog", label: "Blog" },
{ href: "/portfolio", label: "Portfolio" },
{ href: "/contact", label: "Contact" },
];
return (
<nav className="bg-gray-800 border-b border-gray-700 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<Link
href="/"
className="text-xl font-bold text-primary-400 hover:text-primary-300 transition"
>
richardnixon.dev
</Link>
<div className="hidden md:ml-10 md:flex md:space-x-8">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
className={`px-3 py-2 transition ${
pathname.startsWith(link.href)
? "text-white border-b-2 border-primary-500"
: "text-gray-300 hover:text-white"
}`}
>
{link.label}
</Link>
))}
</div>
</div>
<div className="flex items-center space-x-4">
{/* Language Switcher */}
<div className="relative">
<button
onClick={() => setLangOpen(!langOpen)}
onBlur={() => setTimeout(() => setLangOpen(false), 150)}
className="flex items-center text-gray-300 hover:text-white text-sm px-2 py-1 rounded hover:bg-gray-700 transition"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
EN
</button>
{langOpen && (
<div className="absolute right-0 mt-2 w-40 bg-gray-800 border border-gray-700 rounded-md shadow-lg py-1 z-50">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => {
setLangOpen(false);
// Language change will be handled by Django API i18n in the future
}}
className="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:text-white hover:bg-gray-700"
>
{lang.label}
</button>
))}
</div>
)}
</div>
{/* Mobile menu button */}
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="md:hidden p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{mobileOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button>
</div>
</div>
</div>
{mobileOpen && (
<div className="md:hidden bg-gray-800 border-t border-gray-700">
<div className="px-2 pt-2 pb-3 space-y-1">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileOpen(false)}
className="block px-3 py-2 text-gray-300 hover:text-white hover:bg-gray-700 rounded-md"
>
{link.label}
</Link>
))}
</div>
</div>
)}
</nav>
);
}

View file

@ -1,55 +0,0 @@
import Link from "next/link";
import Image from "next/image";
import type { Post } from "@/lib/types";
export default function PostCard({ post }: { post: Post }) {
const date = post.published_at
? new Date(post.published_at).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
: "";
return (
<article className="bg-gray-800 rounded-lg overflow-hidden border border-gray-700 hover:border-primary-500 transition group">
{post.featured_image && (
<Link href={`/blog/${post.slug}`}>
<div className="relative h-48 overflow-hidden">
<Image
src={post.featured_image}
alt={post.title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
</Link>
)}
<div className="p-5">
{post.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{post.tags.slice(0, 3).map((tag) => (
<Link
key={tag.slug}
href={`/blog?tag=${tag.slug}`}
className="text-xs px-2 py-1 bg-primary-900/50 text-primary-300 rounded hover:bg-primary-800/50 transition"
>
{tag.name}
</Link>
))}
</div>
)}
<Link href={`/blog/${post.slug}`}>
<h3 className="text-lg font-semibold text-white group-hover:text-primary-400 transition mb-2">
{post.title}
</h3>
</Link>
<p className="text-gray-400 text-sm line-clamp-2 mb-3">{post.excerpt}</p>
<div className="flex items-center text-xs text-gray-500 gap-3">
<span>{date}</span>
<span>{post.reading_time} min read</span>
</div>
</div>
</article>
);
}

View file

@ -1,65 +0,0 @@
import Link from "next/link";
import Image from "next/image";
import type { Project } from "@/lib/types";
export default function ProjectCard({ project }: { project: Project }) {
const image = project.thumbnail || project.featured_image;
return (
<article className="bg-gray-800 rounded-lg overflow-hidden border border-gray-700 hover:border-primary-500 transition group">
{image && (
<Link href={`/portfolio/${project.slug}`}>
<div className="relative h-48 overflow-hidden">
<Image
src={image}
alt={project.title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
</Link>
)}
<div className="p-5">
<Link href={`/portfolio/${project.slug}`}>
<h3 className="text-lg font-semibold text-white group-hover:text-primary-400 transition mb-1">
{project.title}
</h3>
</Link>
<p className="text-gray-400 text-sm mb-3">{project.tagline}</p>
<div className="flex flex-wrap gap-2 mb-4">
{project.technologies.slice(0, 4).map((tech) => (
<span
key={tech.slug}
className="text-xs px-2 py-1 rounded"
style={{ backgroundColor: `${tech.color}20`, color: tech.color }}
>
{tech.name}
</span>
))}
</div>
<div className="flex gap-3">
{project.github_url && (
<a
href={project.github_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-gray-400 hover:text-white transition"
>
GitHub
</a>
)}
{project.live_url && (
<a
href={project.live_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary-400 hover:text-primary-300 transition"
>
Live Demo
</a>
)}
</div>
</div>
</article>
);
}

View file

@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -19,7 +19,6 @@ services:
- traefik-logs:/var/log/traefik
networks:
- web
- platform-internal
- wordpress-internal
- locflow-internal
- crowdsec
@ -68,153 +67,6 @@ services:
- "traefik.http.routers.wordpress.tls.certresolver=letsencrypt"
- "traefik.http.services.wordpress.loadbalancer.server.port=80"
# ===========================================
# DJANGO PLATFORM - richardnixon.dev
# ===========================================
platform-db:
image: postgres:16-alpine
container_name: platform-db
restart: unless-stopped
environment:
POSTGRES_DB: ${PLATFORM_DB_NAME:-platform}
POSTGRES_USER: ${PLATFORM_DB_USER:-platform}
POSTGRES_PASSWORD: ${PLATFORM_DB_PASSWORD}
volumes:
- platform-db-data:/var/lib/postgresql/data
networks:
- platform-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${PLATFORM_DB_USER:-platform}"]
interval: 10s
timeout: 5s
retries: 5
platform-redis:
image: redis:7-alpine
container_name: platform-redis
restart: unless-stopped
volumes:
- platform-redis-data:/data
networks:
- platform-internal
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
platform-web:
build:
context: ..
dockerfile: docker/Dockerfile.full
container_name: platform-web
restart: unless-stopped
depends_on:
platform-db:
condition: service_healthy
platform-redis:
condition: service_healthy
environment:
DJANGO_SETTINGS_MODULE: config.settings.production
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
POSTGRES_DB: ${PLATFORM_DB_NAME:-platform}
POSTGRES_USER: ${PLATFORM_DB_USER:-platform}
POSTGRES_PASSWORD: ${PLATFORM_DB_PASSWORD}
POSTGRES_HOST: platform-db
POSTGRES_PORT: 5432
REDIS_URL: redis://platform-redis:6379/0
CELERY_BROKER_URL: redis://platform-redis:6379/1
DJANGO_SUPERUSER_EMAIL: ${DJANGO_SUPERUSER_EMAIL:-}
DJANGO_SUPERUSER_PASSWORD: ${DJANGO_SUPERUSER_PASSWORD:-}
RECAPTCHA_PUBLIC_KEY: ${RECAPTCHA_PUBLIC_KEY:-}
RECAPTCHA_PRIVATE_KEY: ${RECAPTCHA_PRIVATE_KEY:-}
UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-}
SENTRY_DSN: ${SENTRY_DSN:-}
CORS_ALLOWED_ORIGINS: https://richardnixon.dev,https://www.richardnixon.dev
volumes:
- platform-static:/app/staticfiles
- platform-media:/app/media
networks:
- web
- platform-internal
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8000/health/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
labels:
- "traefik.enable=true"
- "traefik.http.services.platform-api.loadbalancer.server.port=8000"
platform-frontend:
build:
context: ../frontend
args:
NEXT_PUBLIC_API_URL: https://richardnixon.dev/api
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-}
container_name: platform-frontend
restart: unless-stopped
depends_on:
- platform-web
environment:
API_URL: http://platform-web:8000/api
networks:
- web
- platform-internal
platform-celery:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: platform-celery
restart: unless-stopped
depends_on:
platform-db:
condition: service_healthy
platform-redis:
condition: service_healthy
environment:
DJANGO_SETTINGS_MODULE: config.settings.production
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
POSTGRES_DB: ${PLATFORM_DB_NAME:-platform}
POSTGRES_USER: ${PLATFORM_DB_USER:-platform}
POSTGRES_PASSWORD: ${PLATFORM_DB_PASSWORD}
POSTGRES_HOST: platform-db
POSTGRES_PORT: 5432
REDIS_URL: redis://platform-redis:6379/0
CELERY_BROKER_URL: redis://platform-redis:6379/1
command: celery -A config worker -l info
volumes:
- platform-media:/app/media
networks:
- platform-internal
platform-celery-beat:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: platform-celery-beat
restart: unless-stopped
depends_on:
platform-db:
condition: service_healthy
platform-redis:
condition: service_healthy
environment:
DJANGO_SETTINGS_MODULE: config.settings.production
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
POSTGRES_DB: ${PLATFORM_DB_NAME:-platform}
POSTGRES_USER: ${PLATFORM_DB_USER:-platform}
POSTGRES_PASSWORD: ${PLATFORM_DB_PASSWORD}
POSTGRES_HOST: platform-db
POSTGRES_PORT: 5432
REDIS_URL: redis://platform-redis:6379/0
CELERY_BROKER_URL: redis://platform-redis:6379/1
command: celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
networks:
- platform-internal
# ===========================================
# FORGEJO RUNNER - CI for self-hosted git
# ===========================================
@ -375,32 +227,6 @@ services:
networks:
- monitoring
postgres-exporter:
image: prometheuscommunity/postgres-exporter:latest
container_name: postgres-exporter
restart: unless-stopped
environment:
DATA_SOURCE_NAME: "postgresql://${PLATFORM_DB_USER:-platform}:${PLATFORM_DB_PASSWORD}@platform-db:5432/${PLATFORM_DB_NAME:-platform}?sslmode=disable"
networks:
- monitoring
- platform-internal
depends_on:
platform-db:
condition: service_healthy
redis-exporter:
image: oliver006/redis_exporter:latest
container_name: redis-exporter
restart: unless-stopped
environment:
REDIS_ADDR: "redis://platform-redis:6379"
networks:
- monitoring
- platform-internal
depends_on:
platform-redis:
condition: service_healthy
grafana:
image: grafana/grafana:latest
container_name: grafana
@ -817,8 +643,6 @@ networks:
driver: bridge
wordpress-internal:
driver: bridge
platform-internal:
driver: bridge
umami-internal:
driver: bridge
locflow-internal:
@ -839,10 +663,6 @@ volumes:
traefik-logs:
wordpress-db-data:
wordpress-data:
platform-db-data:
platform-redis-data:
platform-static:
platform-media:
umami-db-data:
locflow-db-data:
locflow-static:

View file

@ -74,18 +74,6 @@ http:
# === PUBLIC ROUTERS (crowdsec only) ===
# Django admin/API only (narrowed after Hugo cutover; Hugo owns blog content)
platform-api:
rule: "(Host(`richardnixon.dev`) || Host(`www.richardnixon.dev`)) && (PathPrefix(`/api`) || PathPrefix(`/admin`) || PathPrefix(`/static`) || PathPrefix(`/media`))"
entryPoints:
- websecure
service: platform-api
middlewares:
- crowdsec-bouncer
priority: 110
tls:
certResolver: letsencrypt
# Hugo static blog (catch-all for richardnixon.dev / www.richardnixon.dev)
blog-static:
rule: "Host(`richardnixon.dev`) || Host(`www.richardnixon.dev`)"
@ -197,16 +185,6 @@ http:
certResolver: letsencrypt
services:
platform-api:
loadBalancer:
servers:
- url: "http://platform-web:8000"
platform-frontend:
loadBalancer:
servers:
- url: "http://platform-frontend:3000"
blog-static:
loadBalancer:
servers:

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more