Portafolio personal full-stack con panel de administración privado. Construido con React + Vite en el frontend y Node.js + Express + PostgreSQL en el backend. Incluye sistema de animaciones, modo oscuro/claro, formulario de contacto y gestión de proyectos y mensajes desde un panel admin protegido con JWT.
Un portafolio estático no permite actualizar proyectos ni ver mensajes de contacto sin tocar el código. Este proyecto resuelve eso con un panel admin privado que permite gestionar el contenido en tiempo real desde cualquier dispositivo, sin redeploy.
┌─────────────────────────────────────┐
│ freire.ucielbustamante.com │
│ Nginx (reverse proxy) │
└──────────┬──────────────┬────────────┘
│ │
┌──────────▼──────┐ ┌────▼──────────────┐
│ Frontend │ │ Backend │
│ React + Vite │ │ Express + Node.js │
│ :8080 │ │ :3000 │
└─────────────────┘ └────────┬──────────┘
│
┌────────▼──────────┐
│ PostgreSQL │
│ :5432 │
└───────────────────┘
El frontend es una SPA que consume la API del backend en /api/*. En producción, Nginx actúa como reverse proxy: sirve el frontend en / y redirige /api al backend.
project-root/
├── frontend/ # SPA React + Vite
│ ├── src/
│ │ ├── api/ # Cliente HTTP y funciones de API
│ │ ├── components/
│ │ │ ├── NavigationBar/
│ │ │ ├── ProjectCard/
│ │ │ └── sections/ # Hero, About, Projects, Contact
│ │ ├── data/ # Datos estáticos (perfil, skills, contacto)
│ │ ├── hooks/ # useTheme, useActiveSection, useProjects, useMessages
│ │ ├── motion/ # Sistema de animaciones (tokens, variants, hooks)
│ │ ├── pages/
│ │ │ └── admin/ # Login, AdminLayout, ProjectsPage, MessagesPage
│ │ └── types/ # Interfaces TypeScript compartidas
│ ├── public/ # Assets estáticos (favicon, icons)
│ ├── index.html
│ ├── package.json
│ ├── vite.config.ts
│ ├── vitest.config.ts
│ ├── eslint.config.js
│ ├── tsconfig.json
│ ├── .env.example
│ └── .env # No commitear
│
├── backend/ # API REST Node.js + Express
│ └── src/
│ ├── middleware/ # auth.ts (JWT), validate.ts (Zod)
│ ├── routes/ # auth, projects, messages, contact, health
│ └── schemas/ # Zod schemas para validación de requests
│
├── database/
│ └── migrations/
│ └── 001_init.sql # Creación de tablas (projects, messages, admin_users)
│
├── infra/ # Docker, Nginx, deploy
│ ├── docker/
│ │ └── Dockerfile.frontend
│ ├── nginx/
│ │ ├── nginx.conf # Config interna del contenedor frontend
│ │ └── freire.conf # Reverse proxy del host
│ ├── docker-compose.yml
│ └── docker-compose.postgres.yml
│
├── .gitignore
├── .dockerignore
├── netlify.toml
└── README.md
cd frontend
# Configurar variables de entorno
cp .env.example .env
# Editar .env: VITE_API_URL=http://localhost:3000
npm install
npm run dev
# → http://localhost:5173
cd backend
# Configurar variables de entorno
cp .env.example .env
# Completar: DATABASE_URL, JWT_SECRET, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, PORT, CORS_ORIGIN, RESEND_API_KEY
npm install
npm run dev
# → http://localhost:3000
El backend usa PostgreSQL con queries SQL directas a través de node-postgres. No hay ORM.
psql -U postgres -d portfolio -f database/migrations/001_init.sql
| Tabla | Descripción |
|---|---|
projects |
Proyectos del portafolio (nombre, descripción, tecnologías, URL, imagen) |
messages |
Mensajes recibidos desde el formulario de contacto |
admin_users |
Usuarios del panel de administración |
# Generar hash bcrypt de la contraseña
node -e "const b = require('bcrypt'); b.hash('TU_CONTRASEÑA', 12).then(console.log)"
# Insertar en la base de datos
psql -U postgres -d portfolio -c \
"INSERT INTO admin_users (username, password_hash) VALUES ('tu-usuario', 'EL_HASH');"
frontend/.env)VITE_API_URL=http://localhost:3000
backend/.env)DATABASE_URL=postgresql://postgres:password@localhost:5432/portfolio
JWT_SECRET=string-aleatorio-de-al-menos-32-chars
ADMIN_USERNAME=tu-usuario-admin
ADMIN_PASSWORD_HASH=hash-bcrypt-de-tu-contraseña
PORT=3000
CORS_ORIGIN=http://localhost:5173
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
Ver frontend/.env.example y backend/.env.example para la lista completa. Nunca commitear archivos .env.
Los comandos de Docker se ejecutan desde la carpeta infra/. El build context apunta a la raíz del repo para que el Dockerfile pueda acceder tanto a frontend/ como a infra/nginx/.
cd infra
docker compose -f docker-compose.yml up --build
cd infra
POSTGRES_PASSWORD=tu_password docker compose -f docker-compose.postgres.yml up --build
| Servicio | Puerto | Descripción |
|---|---|---|
frontend |
8080 | SPA React servida por Nginx |
backend |
3000 | API Express |
postgres |
— | PostgreSQL (solo en docker-compose.postgres.yml) |
El archivo infra/nginx/freire.conf es la configuración del reverse proxy del host para freire.ucielbustamante.com.
# Copiar al servidor
sudo cp infra/nginx/freire.conf /etc/nginx/sites-available/freire.ucielbustamante.com
sudo ln -s /etc/nginx/sites-available/freire.ucielbustamante.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
cd frontend
npm run test # watch mode
npx vitest --run # single run
cd backend
npm run test # single run (vitest --run)
localStorageprefers-reduced-motion/admin)AuthGuard redirige a login si no hay sesión válida