Pizarra a Distancia en Tiempo Real
Rediseño Ember Dark con fondo de video de medusas
Realtime Web App

Pizarra a Distancia en Tiempo Real

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.

  • Date: 23 Jun, 2026
  • Client: Proyecto propio · Docencia
  • Role: Creator & Full Stack Developer

El problema

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:

  • Tiempo real: lo que tecleo aparece en los alumnos en menos de medio segundo.
  • Multiplataforma: que entren desde el navegador, incluido el teléfono.
  • Listo hoy: una tarde de trabajo, no un mes.
  • Simple de operar: comparto un enlace y listo.

La idea de arquitectura (la clave de que sea simple)

La decisión más importante fue esta:

El profesor es la única fuente de escritura. Los alumnos solo leen.

  • Vista profesor (/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.
  • Vista alumno (/aula/:salaId): sin editor; se suscribe a los cambios (onValue) y los pinta con react-markdown.

Vista del profesor: editor estilo Notepad++ con CodeMirror

Vista del alumno: Markdown renderizado en tiempo real

Un solo nodo de datos como verdad única, y todo lo demás cae por su propio peso.

El stack y por qué

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.

Configurar Firebase desde cero

No tenía ni cuenta. El flujo, una sola vez:

  1. Crear proyecto en console.firebase.google.com.
  2. Realtime Database → Crear base de datos.
  3. Authentication → Sign-in method → Anónimo → Habilitar (lo usan los alumnos).
  4. Pegar las reglas de seguridad (más abajo) y publicar.
  5. Configuración del proyecto → app Web y copiar las llaves a un .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_URL es 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;

El modelo de datos

{
  "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.

Reglas de seguridad: que el alumno NO pueda escribir

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. 🛡️

Desplegar en Netlify

  1. Push del repo a GitHub.
  2. Netlify → Import from Git (detecta netlify.toml).
  3. Variables de entorno: las 5 VITE_FIREBASE_* en Netlify (¡no viajan en el repo!).
  4. Firebase → Authorized domains: agregar el dominio *.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 .env vive 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).

El rediseño “Ember Dark” con fondo de video

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).

Rediseño Ember Dark: vidrio esmerilado sobre fondo de video de medusas

Tokens y vidrio esmerilado

: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);
}

El truco del video en bucle sin “salto”

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.

Legibilidad: el “lienzo” opaco

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);
}

Dos gotchas de CSS que me mordieron

  1. El video “seccionado”. Con 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.
  2. El video desaparecía al escribir mucho. Tenía el video en 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 locked al 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.

El candado: de proyecto a producto

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?"

La verdad incómoda primero

  • Una web es pública. El código del frontend se puede ver en el navegador. No se puede “esconder” la app. Eso es así en toda página web.
  • Lo que SÍ se puede blindar es quién puede escribir/crear en tu base de datos. Eso se valida en el backend (las reglas), y es a prueba de técnicos.
  • Lo que NO sirve: una contraseña escondida en la página. Un técnico la lee en segundos o entra directo a /profesor/loquesea. La validación tiene que estar en el servidor, no en el navegador.

El modelo elegido: login + lista de autorizados

  • Alumnos: siguen entrando solo con el enlace (sesión anónima). Cero fricción.
  • Profesores: deben iniciar sesión con Google y estar en una lista 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).

El flujo de negocio, en la práctica

Inicio de sesión con Google y copia del UID para autorizar al profesor

  • Dar acceso a un cliente: inicia sesión con Google una vez → la app le muestra su 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.
  • Quitar acceso: borro su 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.

Lo que aprendí (y volvería a hacer)

  • La seguridad va en el backend, siempre. La UI es cosmética; las reglas son la ley.
  • Una sola fuente de escritura simplifica todo el problema de tiempo real (sin merges, sin saltos de cursor).
  • Variables de entorno en el host, no solo en local: el .env no viaja en el repo.
  • position: fixed para fondos que deben cubrir todo el viewport con scroll.
  • Sé honesto con lo que la tecnología permite: no puedes ocultar un frontend, pero sí controlar el acceso a tus datos. Vender acceso > intentar esconder código.

Lo que viene (fases futuras)

  • ✏️ Dibujo / lienzo gráfico.
  • 👥 Presencia (cuántos alumnos conectados).
  • 🔑 Códigos/tokens auto-servicio para vender acceso sin tocar la consola.
  • 🧾 Historial de versiones de cada pizarra.

Resumen técnico

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.

PokéAPI Explorer: React & Data Fetching
Frontend Development

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.