PokéAPI Explorer: React & Data Fetching
Aplicación SPA interactiva que consume la PokéAPI en tiempo real. Implementación de Hooks personalizados, renderizado dinámico y diseño responsivo moderno.
Cómo construí una pizarra remota en tiempo real para dar clase con React + Firebase + Netlify, le di un rediseño oscuro con fondo de video y, al final, un candado real para cobrar por su uso.
Doy clases y quería algo muy simple: escribir en una “hoja” estilo Notepad++ y que mis alumnos vieran en vivo lo que escribo, renderizado bonito, sin instalar nada. Nada de pizarras gráficas todavía; fase 1 = solo texto en Markdown + poder subir un .md.
Requisitos:
La decisión más importante fue esta:
El profesor es la única fuente de escritura. Los alumnos solo leen.
/profesor/:salaId): un editor con estado local; al teclear, escribe a Firebase con debounce. Su editor nunca se sobrescribe desde la nube, así el cursor no salta./aula/:salaId): sin editor; se suscribe a los cambios (onValue) y los pinta con react-markdown.

Un solo nodo de datos como verdad única, y todo lo demás cae por su propio peso.
| Pieza | Elección | Por qué |
|---|---|---|
| UI | React + Vite | Arranque instantáneo, HMR rapidísimo. |
| Estilos | TailwindCSS v3 | Prototipar rápido sin pelear con CSS. |
| Tiempo real | Firebase Realtime Database | Sincronización en vivo sin montar servidor; plan gratis suficiente para una clase. |
| Auth | Firebase Auth (anónima + Google) | Identidad sin backend propio. |
| Editor | CodeMirror (@uiw/react-codemirror) |
Editor con números de línea, estilo Notepad++. |
| Render Markdown | react-markdown + remark-gfm | Tablas, listas, código; escapa HTML por defecto. |
| Deploy | Netlify | Conecta GitHub y redepliega solo en cada push. |
No tenía ni cuenta. El flujo, una sola vez:
.env:VITE_FIREBASE_API_KEY=...
VITE_FIREBASE_AUTH_DOMAIN=...
VITE_FIREBASE_DATABASE_URL=https://TU-PROYECTO-default-rtdb.firebaseio.com
VITE_FIREBASE_PROJECT_ID=...
VITE_FIREBASE_APP_ID=...
💡
VITE_FIREBASE_DATABASE_URLes obligatoria para Realtime Database y es fácil de olvidar.
Un detalle de robustez: si faltan variables, la app no explota con un error críptico de Firebase; muestra una pantalla de ayuda.
// src/lib/firebase.js (resumen)
export const firebaseConfigurado = Boolean(
firebaseConfig.apiKey &&
firebaseConfig.databaseURL &&
firebaseConfig.projectId,
);
export const app = firebaseConfigurado ? initializeApp(firebaseConfig) : null;
export const db = app ? getDatabase(app) : null;
export const auth = app ? getAuth(app) : null;
{
"salas": {
"<salaId>": {
"owner": "<uid del profesor>", // se fija una vez
"contenido": "# Tema de hoy\n...", // string Markdown (fuente única)
"actualizado": 1719100000000, // serverTimestamp
"editorActivo": { "clientId": "abc", "ts": 1719100000000 }, // soft-lock
},
},
}
El salaId se genera con 22 caracteres aleatorios (crypto.getRandomValues), suficiente para que nadie adivine una sala.
Aquí está el primer aprendizaje serio: separar roles solo en la UI no es seguridad. Cualquiera con la consola del navegador podría escribir si las reglas lo permiten. La verdad tiene que vivir en el backend:
{
"rules": {
"salas": {
"$salaId": {
".read": "auth != null",
"contenido": {
".write": "auth != null && root.child('salas').child($salaId).child('owner').val() === auth.uid",
".validate": "newData.isString() && newData.val().length <= 100000",
},
// ...mismo patrón para owner, actualizado, editorActivo
},
},
},
}
Resultado: solo el uid dueño de la sala escribe; cualquier otro (alumno o curioso con el enlace) solo lee. Probarlo es fácil: abrir /aula/:id, intentar un set(...) desde la consola → PERMISSION_DENIED. 🛡️
netlify.toml).VITE_FIREBASE_* en Netlify (¡no viajan en el repo!).*.netlify.app.El netlify.toml necesita el redirect SPA o recargar /aula/:id da 404:
[build]
command = "npm run build"
publish = "dist"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
🔥 Trampa #1 que me costó un rato: desplegué y el sitio decía “Falta configurar Firebase”. El
.envvive solo en mi máquina (está en.gitignore), así que Netlify no lo tenía. Hay que cargar las variables en Netlify y volver a desplegar (Vite “hornea” las variables en build time; un deploy viejo no las tiene).
Con el MVP funcionando, quise que se viera premium: negro profundo, un solo acento (rojo brasa), tipografía grande, glassmorphism y un video de fondo (unas medusas).

:root {
--bg: #070707;
--card: rgba(20, 15, 15, 0.62);
--red: #ff3b1f;
--red2: #ff5e3a;
--ember: #ff8a4c;
--text: #f4f1ef;
--muted: #9b9296;
--line: rgba(255, 90, 60, 0.14);
}
.panel {
/* tarjeta de vidrio */
background: var(--card);
border: 1px solid var(--line);
border-radius: 22px;
backdrop-filter: blur(12px);
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.4);
}
Un video en loop tiene un corte brusco al reiniciar. La solución: dos copias del mismo video desfasadas medio ciclo con un crossfade por opacidad. Cuando una llega a su corte, la otra está a la mitad y visible. Como el fondo del video es negro puro (= --bg), el fundido es imperceptible.
// Onda triangular: 0 en los bordes del ciclo, 1 al centro.
const tri = (t, d) => 1 - Math.abs((2 * (((t % d) + d) % d)) / d - 1);
const tick = () => {
const d = v1.duration || 1;
v1.style.opacity = tri(v1.currentTime, d).toFixed(3);
v2.style.opacity = tri(v2.currentTime, d).toFixed(3);
raf = requestAnimationFrame(tick);
};
// al cargar metadata: v2.currentTime = d/2 (medio ciclo de desfase) y a reproducir
Detalle de accesibilidad: si el usuario tiene activado prefers-reduced-motion, no se reproduce nada y se queda el póster fijo.
El reto del fondo de video es que el texto no se pierda. La regla que me funcionó: barras de vidrio translúcido (dejan ver el video) pero lienzo de escritura/lectura muy opaco encima.
.lienzo {
background: rgba(10, 8, 9, 0.95);
backdrop-filter: blur(8px);
}
object-fit: contain el video se veía como una franja tipo hero con espacios. Para cubrir toda la ventana hay que usar object-fit: cover.position: absolute, así que se ajustaba al alto del contenido: con mucho texto, la página crecía y el video quedaba como una tirita en medio (todo lo demás, negro). La solución fue anclarlo a la pantalla, no al contenido:.bg-video {
position: fixed; /* <- la clave: pegado al viewport, no al contenido */
inset: 0;
object-fit: cover;
z-index: -2;
}
Con fixed, las medusas se quedan de fondo siempre, haya scroll o no, y funciona igual en el teléfono. 📱
🐛 Trampa #2 (Windows): el servidor de Vite se caía con
EBUSY: resource busy or lockedal intentar “vigilar” el.mp4. Se arregla ignorando los videos en el watcher:// vite.config.js server: { watch: { ignored: ["**/*.mp4", "**/*.mov", "**/*.webm"]; } }Y, ojo: los assets que se sirven en producción tienen que vivir en
public/, no en una carpeta cualquiera de la raíz.
Aquí viene la parte interesante para cualquiera que quiera cobrar por lo que hace. Pregunta natural: "¿cómo evito que cualquiera use mi herramienta gratis?"
/profesor/loquesea. La validación tiene que estar en el servidor, no en el navegador.autorizados/{uid} de la base de datos. Solo a quien yo habilite (= quien pague) podrá crear o editar.La sesión base es anónima; el login con Google se hace bajo demanda:
const entrarConGoogle = async () => {
const provider = new GoogleAuthProvider();
await signInWithPopup(auth, provider);
};
Y la comprobación de autorización es solo para la UI; la que manda es la regla:
{
"rules": {
"autorizados": {
"$uid": { ".read": "auth != null && auth.uid === $uid", ".write": false },
},
"salas": {
"$salaId": {
".read": "auth != null",
"owner": {
".write": "auth != null && root.child('autorizados').child(auth.uid).exists() && ((!data.exists() && newData.val() === auth.uid) || data.val() === auth.uid)",
},
"contenido": {
".write": "auth != null && root.child('autorizados').child(auth.uid).exists() && root.child('salas').child($salaId).child('owner').val() === auth.uid",
".validate": "newData.isString() && newData.val().length <= 100000",
},
// ...mismo patrón en actualizado, titulo, editorActivo
},
},
},
}
".write": false en autorizados significa que ningún cliente puede modificar la lista; yo la edito desde la consola de Firebase (que actúa como administrador y se salta las reglas).

uid con un botón de copiar → lo agrego en autorizados/<uid>: true. La app lo detecta en vivo (con onValue) y ya puede crear pizarras.uid. Pierde la capacidad de crear/editar al instante.Aunque alguien clone mi frontend, no puede escribir en mi base de datos sin estar en mi lista. Ese es el candado que sí sirve para cobrar.
.env no viaja en el repo.position: fixed para fondos que deben cubrir todo el viewport con scroll.| Tema | Decisión |
|---|---|
| Tiempo real | RTDB, profesor escribe / alumno onValue |
| Seguridad | Reglas por uid + lista autorizados |
| Auth | Anónima (alumnos) + Google (profesores) |
| UI | Ember Dark, vidrio + fondo de video fixed/cover |
| Deploy | Netlify + variables de entorno + dominio autorizado |
Construido en una tarde. La parte difícil no fue el código: fue decidir bien qué problema resolver y dónde poner el candado.