02 / 05Caso de estudio
Izak's Photos
Sitio y portafolio fotográfico
Un estudio de fotografía de demostración, bilingüe y en línea: galería de 42 fotos con enlaces compartibles, visor accesible y reservas que se guardan en Django, servidos por un solo servicio en Railway.
- Mi papel
- Evolución full-stack; interfaz, experiencia bilingüe, API, rendimiento y despliegue
- Stack
- React 18 · Vite 8 · React Router 7 · Django 5.2 · Django REST Framework · Railway
- Estado
- En línea en Railway · estudio de demostración
Alcance
- −74 %de peso en las miniaturas de la galería
- 42fotos en 4 series, cada una con su enlace
- 2idiomas, inglés y español
El reto
Un archivo de fotografías en cuatro series tiende a volverse una cuadrícula genérica, y el mismo sitio tiene que llevar al visitante de «me gusta este trabajo» a «envío una solicitud».
- Lo que construí
- Reconstruí el sitio en React y Django —galería con filtros y foto abierta en la URL, visor con teclado, inglés y español, reservas con validación— y lo llevé a producción con miniaturas WebP, límite de envíos y CI.
- Decisión técnica clave
- Un solo servicio en producción: Django sirve el build de Vite, la API y el administrador desde el mismo origen, sin CORS.
Decisiones de diseño

- Problema
- El sitio tiene que lucir el trabajo y, a la vez, llevar a reservar.
- Decisión
- Hero a sangre con dos acciones y «Reserve» fijo en la cabecera de todas las páginas.

- Problema
- Cuarenta y dos fotos en cuatro series se vuelven una cuadrícula genérica.
- Decisión
- Un mosaico que intercala las series, filtros con conteo y un visor a pantalla completa con teclado.

- Problema
- No había forma de compartir ni de enlazar una foto concreta.
- Decisión
- La categoría y la foto abierta viven en la URL; cada foto tiene su enlace.

- Problema
- En el teléfono los títulos no se veían nunca, porque sólo aparecían al pasar el ratón.
- Decisión
- En pantallas táctiles, el lugar y el título quedan siempre visibles sobre cada foto.

- Problema
- La galería descargaba 9,3 MB de JPEG completos para mostrar miniaturas pequeñas.
- Decisión
- Miniaturas WebP de 720 px en las cuadrículas; el JPEG completo sólo se carga en el visor.

- Problema
- En el teléfono, la confirmación de la reserva quedaba fuera de la pantalla.
- Decisión
- Tras enviar, la página baja hasta la confirmación, que resume lo enviado.

- Problema
- Un error genérico obliga a adivinar qué dato falló y a volver a escribirlo todo.
- Decisión
- La API devuelve el error de cada campo y el formulario lo marca en su sitio, sin borrar lo escrito.

- Problema
- Un estudio que trabaja dentro y fuera del país no puede hablar un solo idioma.
- Decisión
- Inglés y español en todo el sitio con un clic; el idioma se recuerda y cambia el lang de la página.
Sistema
Elige un módulo: se enciende su ruta.Elige un módulo: debajo, su decisión.
Hero a sangre con botón de pausa y «Reserve» fijo en la cabecera; las fotos destacadas abren esa imagen en el visor.
React 18 · React Router 7
El mosaico intercala las series y cada foto reserva su proporción; la categoría vive en la URL.
useSearchParams · CSS propio
Teclado, deslizamiento y foco dentro del diálogo; la foto abierta es un enlace y su miniatura la cubre mientras llega el JPEG.
React 18 · useSearchParams
En táctil, el lugar y el título de cada foto siempre visibles: no hay hover que los descubra.
@media (hover: none) · CSS propio
Las fotos se rotulan como obra del portafolio y no como retrato de nadie.
React 18
Errores por campo que devuelve la API y, tras enviar, un resumen al que la página baja en el teléfono.
fetch · aria-invalid
Un Context propio con t({ en, es }), sin librería de traducción; el idioma se recuerda y cambia el lang de la página.
React Context · localStorage
Valida y guarda cada solicitud; diez por hora y cliente, y un campo trampa que acepta a los bots sin guardarlos.
Django REST Framework 3 · Throttling de DRF
Filtros, búsqueda y dos acciones: marcar solicitudes como atendidas o reabrirlas.
Django Admin
Railway sólo publica la versión nueva si /api/health/ responde.
Django 5.2
SQLite en local y PostgreSQL con DATABASE_URL, sin tocar código; un solo modelo de diez campos para las solicitudes.
SQLite · PostgreSQL · dj-database-url
Van en el build: el JPEG completo para el visor y el hero, y miniaturas WebP de 720 px para las cuadrículas.
import.meta.glob · WebP
Sólo guarda el idioma elegido: el sitio no tiene cuentas ni cookies de seguimiento.
localStorage
Railpack compila todo, migra antes de publicar y reinicia el servicio si el healthcheck falla.
Railway · Railpack
Un solo proceso sirve el build de Vite, la API y el admin desde el mismo origen: sin CORS en producción.
Gunicorn 23 · WhiteNoise 6.12
En cada push y pull request: migraciones pendientes, las 8 pruebas y el build del frontend.
GitHub Actions
Fraunces y DM Sans: la única dependencia externa en tiempo de ejecución.
Google Fonts
Cliente
Portada
Hero a sangre con botón de pausa y «Reserve» fijo en la cabecera; las fotos destacadas abren esa imagen en el visor.
- Tecnologías
- React 18 · React Router 7
- Entrega a
- Galería · Visor · Reservas · Google Fonts
Tecnologías
38 herramientas en 10 áreas. En el sistema, cada módulo dice las suyas.
Lenguajes
- JavaScript (JSX)
- Python 3
- CSS
- HTML
Frontend
- React 18.3
- React Router 7.18
- Vite 8.3
- @vitejs/plugin-react 6.1
Interfaz
- CSS propio con variables
- Fraunces y DM Sans
- lucide-react 0.468
- IntersectionObserver
Estado e idioma
- React Context
- useSearchParams
- localStorage
- fetch
Backend
- Django 5.2
- Django REST Framework 3
- Django Admin
- django-cors-headers 4
- python-dotenv 1
Datos
- SQLite
- PostgreSQL
- dj-database-url 2
- psycopg2 2.9
Imágenes
- Miniaturas WebP de 720 px
- Pillow (script de miniaturas)
- import.meta.glob de Vite
Servidor y despliegue
- Gunicorn 23
- WhiteNoise 6.12
- Railway
- Railpack
Calidad y CI
- TestCase de Django
- GitHub Actions
- EditorConfig
Herramientas
- Node.js 24
- npm
- Git
Resultados verificables
- En línea en izaksphotos.jonasjavier.dev como un solo servicio en Railway, con migraciones antes de publicar y healthcheck
- Galería de 42 fotos en 4 series con filtros y la foto abierta en la URL, así cada imagen tiene su enlace
- Visor a pantalla completa con teclado, deslizamiento táctil y el foco dentro del diálogo
- Interfaz completa en inglés y español, con el idioma recordado
- Miniaturas WebP que bajan la galería completa en móvil de 9,3 MB a 2,4 MB
- Reservas guardadas en Django con errores por campo, límite de envíos y campo trampa; 8 pruebas en CI
Recorrido por módulos
44 pantallas en 7 módulos.
Galería y visor6 pantallas

Los filtros dicen cuántas fotos hay en cada serie.

«Todo» intercala las series para que el mosaico no se agrupe por tipo.

Cada serie tiene su propia URL, lista para compartir.

Verticales y horizontales conviven porque cada foto reserva su proporción.

Cada foto tiene un enlace propio que abre directamente el visor.

El visor adapta el marco a fotos verticales y horizontales.
Sobre mí2 pantallas
Reservas5 pantallas

Paquete, formulario y resumen conviven en una sola vista.

El resumen se actualiza mientras el visitante escribe.

Tras enviar, la confirmación resume exactamente lo que se envió.

Si la API rechaza un dato, se marca el campo exacto y se explica.

Si falla la red, el aviso lo dice y los datos siguen escritos.
En español3 pantallas
Administración2 pantallas
En el teléfono19 pantallas

En táctil cada foto lleva su lugar y su título a la vista.

La serie de bodas en el teléfono, con cada foto rotulada.

Se pasa de foto con las flechas o deslizando.

Las fotos horizontales se centran a todo el ancho.

Los filtros caben en dos filas sin esconder ninguna serie.

Las acciones van a todo el ancho y «Reserve» nunca desaparece.

El menú reúne navegación, reserva e idioma; se cierra con Escape.

Los títulos se ven siempre, sin depender del ratón.

Cada paquete se lee completo en una tarjeta.

Los pasos se apilan con números grandes.

La foto va primero, recortada a 4:5 para no ocupar toda la pantalla.

El relato se lee de corrido en una columna.

La cita y la firma cierran la historia.

Primero se elige el paquete y después se llena el formulario.

Una nota aclara los días de sesión y el anticipo antes del formulario.

El formulario va a una columna con campos amplios.

Tras enviar, la página baja sola hasta la confirmación.

El mismo aviso por campo, en el teléfono.

La versión en español mantiene la jerarquía en el teléfono.
7 min de lectura
El caso completo
Contexto
Izak's Photos es el sitio de un estudio de fotografía de retrato, bodas, editorial y viajes con base en Santo Domingo. El estudio es ficticio: Izak no existe, y los precios, las cifras del estudio y los testimonios son contenido de muestra para enseñar el producto. Lo que sí es real es el software: está en línea, guarda solicitudes y se despliega como cualquier sitio de un cliente.
El proyecto nació en mayo de 2024 en un repositorio que creó Job Nacor y en el que colaboramos: un sitio en React y Bootstrap con siete páginas y un formulario de contacto de cuatro campos. En junio de 2026 lo reconstruí desde cero y en septiembre lo llevé a producción. Hoy mantengo esta edición y el repositorio conserva la autoría de los dos.
Problema
Un archivo amplio de fotografías tiende a convertirse en una cuadrícula genérica, y un sitio de fotógrafo tiene dos trabajos a la vez: lucir la obra y convertir la visita en una solicitud. Además, una foto que no se puede enlazar no se puede compartir, y en el teléfono todo lo que depende del ratón desaparece.
Mi función
La reconstrucción de 2026 es mía: la interfaz responsive, la experiencia bilingüe, la galería y el visor, la API de reservas, el administrador, el rendimiento de las imágenes, el despliegue en Railway, las pruebas, la integración continua y la documentación.
Restricciones
- Un solo servicio. Django sirve la API, el administrador y el build de Vite desde el mismo dominio.
- Sin subida de fotos. Las imágenes son archivos del build: añadir una requiere un despliegue.
- Sin correo saliente. Las solicitudes quedan en la base de datos y se atienden desde el administrador.
- Bilingüe escrito a mano. 256 cadenas en español junto a su versión en inglés, sin librería de traducción.
Cómo lo construí
La versión de 2026 pasó de 25 dependencias de ejecución a 6 y de siete secciones a cuatro páginas —portada, galería, sobre mí y reservas— más una 404 propia. El modelo de contacto de cuatro campos se convirtió en una solicitud de reserva con diez, y el formulario habla con la API en camelCase mientras el serializador lo traduce a los campos del modelo.
En septiembre lo revisé en cinco anchos (1440, 1024, 768, 390 y 360 px) y en los dos idiomas, y corregí lo que esa revisión sacó a la luz antes de desplegarlo: enlaces compartibles, visor accesible, miniaturas WebP, límite de envíos, página 404 y una configuración de producción más estricta.
Decisiones de arquitectura
- Mismo origen en producción. Django y WhiteNoise sirven el build con
nombres con hash y compresión, y una ruta final devuelve
index.htmlpara las rutas de React. El formulario llama a/api/contact/, así que no hace falta CORS. En desarrollo, Vite y Django corren por separado. - El estado de la galería en la URL.
?category=y?photo=: una serie o una foto abierta se pueden enlazar, y las destacadas de la portada abren directamente la suya. - Traducción propia y mínima. Un Context con
t({ en, es })que detecta el idioma del navegador, lo recuerda y actualiza<html lang>. - Base de datos por configuración. SQLite en local y PostgreSQL si existe
DATABASE_URL, sin cambiar código. - Publicar sólo lo que funciona. Railway migra antes de publicar y
comprueba
/api/health/antes de dar tráfico a la versión nueva.
Rendimiento de las imágenes
La galería descargaba los JPEG completos para pintar miniaturas pequeñas: 9.303 KB en un teléfono. Un script genera ahora miniaturas WebP de 720 px, que pesan un 74 % menos que los JPEG (8.986 KB frente a 2.372 KB); el JPEG completo sólo se pide al abrir el visor. Recorrer la galería entera en un teléfono pasó de 9,3 MB a 2,4 MB. Cada foto reserva su proporción con su ancho y alto reales, y la página no salta al cargar.
Accesibilidad y seguridad
- El carrusel de la portada tiene botón de pausa, se detiene al recibir el foco
y no arranca solo con
prefers-reduced-motion. - El visor mantiene el foco dentro y lo devuelve a la miniatura al cerrarse; se recorre con las flechas o deslizando.
- Hay enlace para saltar al contenido, anillo de foco visible y un título de pestaña por página.
- La API de reservas admite diez solicitudes por hora y cliente, y un campo trampa acepta los envíos de bots sin guardarlos.
- Fuera de desarrollo, las cookies son seguras y la API navegable de DRF queda apagada.
Desafíos
La revisión en varios anchos encontró defectos que a simple vista pasaban:
- Un marco irregular en cada miniatura. El botón que envuelve cada foto conservaba el relleno del navegador.
- Una cabecera que nunca desenfocaba el fondo. Con las dos formas de
backdrop-filterescritas, el minificador de Vite 8 sólo dejaba la versión con prefijo. - Un visor de 0×0 mientras cargaba.
widthyheightno reservan espacio si el CSS los pisa conauto. - Un selector de idioma flotante que tapaba fotos, paquetes y la confirmación de la reserva; ahora vive en la cabecera y en el menú.
- Una fecha en UTC en el título de cada solicitud del administrador, que no coincidía con la hora local de su creación.
Diseño y UX
Fondo casi negro y dorado apagado para que las fotos lleven el color, una serif editorial (Fraunces) para los titulares y una sans (DM Sans) para el texto. La reserva cabe en una vista: se elige un paquete, se llena el formulario y un resumen se actualiza mientras se escribe. Si la red falla, el aviso lo dice y los datos siguen escritos; si la API rechaza un dato, se marca el campo exacto.
Lo que no tiene
Para que nadie lo dé por supuesto: no hay pagos, ni cuentas de cliente, ni
galerías privadas, ni subida de fotos desde el administrador, ni correo
saliente: las solicitudes sólo aparecen en /admin/. Tampoco hay pruebas del
frontend ni analítica.
Sobre el contenido y las capturas
El estudio, sus precios, sus cifras y sus testimonios son de muestra, y el formulario de producción guarda solicitudes de demostración: no lo uses con datos reales. Las fotografías y las personas que aparecen en ellas tienen derechos aparte de los del código, y el sitio no las atribuye a un autor real. Las capturas se hicieron el 25 de septiembre de 2026 sobre el build de producción servido en local, con solicitudes inventadas en el administrador. Las pantallas con algún defecto visible se dejaron fuera.
Aprendizajes
Las capturas automáticas encontraron tres defectos que la revisión a ojo no vio. Y los fallos más difíciles no estaban en la lógica: un reset de botones incompleto, un minificador que descartaba una propiedad y un tamaño que el CSS anulaba. Revisar el sitio en varios anchos, en los dos idiomas y con la red retenida fue lo que los sacó a la luz.
Estado actual
En línea en izaksphotos.jonasjavier.dev como un solo servicio en Railway, con el repositorio público bajo licencia GPL-3.0, la integración continua en verde y 8 pruebas del backend que pasan. Es un estudio de demostración: no tiene clientes ni reservas reales, así que no publico cifras de uso.












