React + TypeScript
Semana 9 de 9·8h

Panel admin CRUD conectado a la API

Formularios conectados a la API real, subida de imágenes con FormData, confirmaciones con SweetAlert2 y sincronización del store Zustand.

ReactZustandFormDataSweetAlert2TypeScript
Objetivos de aprendizaje
  • Completar articulosService con los métodos de escritura (crear, actualizar, eliminar, upload)
  • Conectar NuevoArticulo y EditarArticulo a la API real usando el servicio y useFormularioArticulo
  • Subir imágenes con FormData desde el navegador al endpoint /upload
  • Confirmar acciones destructivas con SweetAlert2 antes de llamar a la API
  • Implementar actualización optimista en el store Zustand para el toggle de estado

🎯 Objetivo de la semana: Al terminar el panel admin del CMS funcionará en su totalidad: crear artículos con imagen, editar cualquier campo incluyendo la foto, eliminar con confirmación y ver la lista actualizada al instante sin recargar la página.

🔑 Concepto clave: Service + Zustand como canal de comunicación — el patrón del proyecto es: llamar al servicio (que llama a fetch) y luego actualizar el store Zustand localmente. Sin TanStack Query, sin invalidación de caché — el store es la fuente de verdad del panel admin.

🛠 Tarea práctica: Conectar NuevoArticulo, EditarArticulo y el botón de eliminar de AdminListado a la API real. Añadir confirmación con SweetAlert2 antes de eliminar. Implementar el toggle de estado con actualización optimista.

📋 Entregable: Desde el panel admin puedes crear un artículo con imagen, editarlo, cambiar su estado y eliminarlo con confirmación. El store Zustand se actualiza localmente sin recargar la página. TanStack Query, useMutation y queryClient no se usan en este proyecto.


📁 Archivos de esta semana

code
frontend/src/
├── services/
│   └── articulosService.ts             ← ACTUALIZADO: + crear, actualizar, eliminar, upload
├── store/
│   └── articulosStore.ts               ← ACTUALIZADO: toggleEstado con optimismo
├── hooks/
│   └── useFormularioArticulo.ts        (semana 3 — se reutiliza sin cambios)
└── pages/
    └── admin/
        ├── NuevoArticulo.tsx           ← ACTUALIZADO: conectado a la API real
        └── EditarArticulo.tsx          ← ACTUALIZADO: carga + edita vía articulosService

Flujo de creación de artículo:

  1. articulosService.upload(file)POST /api/v1/upload → devuelve filename
  2. articulosService.crear({ ...campos, imagen: filename })POST /api/v1/articulos
  3. await cargarArticulos() → refresca el store con noCache: true
  4. navigate('/admin')

Regla de imágenes: Usa siempre articulosService.resolverImagen(imagen) para construir la URL. Añade siempre onError con e.currentTarget.onerror = null para evitar loops infinitos.

1. La capa de servicios completa

El articulosService que creaste en S6 solo tenía métodos de lectura. Esta semana lo completas con los métodos de escritura. El patrón es el mismo — el helper fetchJSON<T> genérico — pero con method: 'POST' o 'PUT' y el body serializado como JSON. El método upload es diferente: usa FormData (no JSON) porque el backend espera multipart/form-data:

typescript
// src/services/articulosService.ts — métodos de escritura añadidos

// ── Escritura ———————————————————————————————————————————————
// Se añaden al objeto articulosService ya existente
create: (datos: NuevoArticuloInput) =>
  fetchJSON<ApiResponse<Articulo>>('/articulos', {
    method: 'POST',
    body: JSON.stringify(datos),
  }),

actualizar: (slug: string, cambios: ActualizarArticuloInput) =>
  fetchJSON<ApiResponse<Articulo>>(`/articulos/${slug}`, {
    method: 'PUT',
    body: JSON.stringify(cambios),
  }),

eliminar: (id: number) =>
  fetch(`${BASE}/articulos/${id}`, { method: 'DELETE' }).then((res) => {
    if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`)
  }),

// Upload de imagen — FormData, no JSON
upload: async (file: File): Promise<string> => {
  const form = new FormData()
  form.append('imagen', file)
  // ⚠️ NO pongas Content-Type manual — el browser añade el boundary automáticamente
  const res = await fetch(`${BASE}/upload`, { method: 'POST', body: form })
  if (!res.ok) throw new Error('Error al subir la imagen')
  const json = await res.json() as ApiResponse<{ filename: string }>
  return json.data.filename
},

¿Por qué upload devuelve solo el filename y no la URL completa? Para desacoplar el frontend del backend. Si mañana las imágenes se mueven a S3, solo cambia resolverImagen — el resto del código no sabe dónde están guardadas.

El típico error con Content-Type en FormData: si pones headers: { 'Content-Type': 'multipart/form-data' } manualmente, el navegador no incluye el boundary necesario en el header y el servidor falla al parsear. Nunca lo pongas — deja que el navegador lo calcule.

typescript
// src/services/articulosService.ts — versión completa
import type { Articulo, NuevoArticulo, ActualizarArticulo, ApiResponse } from "@/types";

const BASE = import.meta.env.VITE_API_URL ?? "http://localhost:3001";

async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`${BASE}${url}`, {
    headers: { "Content-Type": "application/json" },
    ...init,
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new Error(body.error ?? `HTTP ${res.status}`);
  }
  return res.json() as Promise<T>;
}

export const articulosService = {
  // Lectura (ya existían desde S6)
  getAll: (p?: { estado?: string; busqueda?: string }) => {
    const qs = p ? new URLSearchParams(p as Record<string, string>).toString() : "";
    return fetchJSON<ApiResponse<Articulo[]>>(`/api/articulos${qs ? `?${qs}` : ""}`);
  },
  getBySlug: (slug: string) =>
    fetchJSON<ApiResponse<Articulo>>(`/api/articulos/${slug}`),

  // Escritura (nuevos esta semana)
  crear: (data: NuevoArticulo) =>
    fetchJSON<ApiResponse<Articulo>>("/api/articulos", {
      method: "POST",
      body: JSON.stringify(data),
    }),

  actualizar: (slug: string, data: ActualizarArticulo) =>
    fetchJSON<ApiResponse<Articulo>>(`/api/articulos/${slug}`, {
      method: "PUT",
      body: JSON.stringify(data),
    }),

  eliminar: (id: number) =>
    fetch(`${BASE}/api/articulos/${id}`, { method: "DELETE" }).then((r) => {
      if (!r.ok && r.status !== 204) throw new Error(`HTTP ${r.status}`);
    }),

  // Upload de imagen — FormData, no JSON
  upload: async (file: File): Promise<string> => {
    const form = new FormData();
    form.append("imagen", file);
    // ⚠️ NO pongas Content-Type manual — el browser añade el boundary automáticamente
    const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form });
    if (!res.ok) throw new Error("Error al subir la imagen");
    const json = (await res.json()) as ApiResponse<{ filename: string }>;
    return json.data.filename;
  },

  // Construye la URL pública de una imagen
  resolverImagen: (imagen: string | null): string => {
    if (!imagen) return "/assets/img/default-cover.svg";
    if (imagen.startsWith("http") || imagen.startsWith("/")) return imagen;
    return `/uploads/${imagen}`;
  },
};

¿Por qué upload devuelve solo el filename y no la URL completa? Para desacoplar el frontend del backend. Si mañana las imágenes se mueven a S3, solo cambia resolverImagen — el resto del código no sabe dónde están guardadas.


2. Confirmar eliminación con SweetAlert2

Antes de llamar a articulosService.eliminar(), es buena práctica pedir confirmación al usuario. SweetAlert2 proporciona un modal accesible y estilizable sin necesidad de construirlo desde cero:

bash
npm install sweetalert2
tsx
// En AdminListado.tsx — botón de eliminar con confirmación
import Swal from 'sweetalert2'
import { articulosService } from '@/services/articulosService'
import { useArticulosStore } from '@/store/articulosStore'

function BotonEliminar({ id, titulo }: { id: number; titulo: string }) {
  const eliminarArticulo = useArticulosStore((s) => s.eliminarArticulo)

  async function handleEliminar() {
    const resultado = await Swal.fire({
      title: '¿Eliminar publicación?',
      text: `"${titulo}" se eliminará permanentemente.`,
      icon: 'warning',
      showCancelButton: true,
      confirmButtonColor: '#ef4444',
      cancelButtonText: 'Cancelar',
      confirmButtonText: 'Sí, eliminar',
    })

    if (!resultado.isConfirmed) return

    try {
      await articulosService.eliminar(id)
      // Actualización local — sin ir al servidor de nuevo
      eliminarArticulo(id)
    } catch (err) {
      Swal.fire('Error', 'No se pudo eliminar el artículo.', 'error')
    }
  }

  return (
    <button onClick={handleEliminar} className="btn-action btn-action--delete" title="Eliminar">
      <i className="bi bi-trash" />
    </button>
  )
}

El flujo tiene dos pasos separados intencionales: primero articulosService.eliminar(id) hace el DELETE real en la API, y solo si tiene éxito eliminarArticulo(id) actualiza el store local. Si el DELETE falla, el store no cambia y el artículo sigue visible.


3. Formulario NuevoArticulo conectado a la API

El hook useFormularioArticulo que creaste en S3 gestiona el estado de los campos. Esta semana solo añades la lógica de upload y la llamada a la API:

tsx
// src/pages/admin/NuevoArticulo.tsx
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { articulosService } from '@/services/articulosService'
import { useFormularioArticulo } from '@/hooks/useFormularioArticulo'
import { useArticulosStore } from '@/store/articulosStore'

export function NuevoArticulo() {
  const navigate = useNavigate()
  const cargarArticulos = useArticulosStore((s) => s.cargarArticulos)
  const { campos, setCampo, reset, esValido } = useFormularioArticulo()

  const [imagenFile, setImagenFile] = useState<File | null>(null)
  const [preview, setPreview] = useState<string | null>(null)
  const [enviando, setEnviando] = useState(false)
  const [errorApi, setErrorApi] = useState<string | null>(null)

  function handleImagenChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0] ?? null
    setImagenFile(file)
    // Preview inmediato con URL local — sin esperar al servidor
    setPreview(file ? URL.createObjectURL(file) : null)
  }

  async function handleSubmit(e: React.BaseSyntheticEvent) {
    e.preventDefault()
    if (!esValido) return
    setEnviando(true)
    setErrorApi(null)

    try {
      // Paso 1: subir imagen si hay archivo seleccionado
      let imagen: string | undefined
      if (imagenFile) {
        imagen = await articulosService.upload(imagenFile)
      }

      // Paso 2: crear el artículo con el filename (no la URL completa)
      await articulosService.crear({
        titulo:      campos.titulo,
        extracto:    campos.extracto,
        contenido:   campos.contenido,
        imagen,
        categoriaId: 1, // en S10 esto vendrá de un select cargado desde la API
      })

      // Paso 3: refrescar el store con noCache=true ANTES de navegar
      await cargarArticulos()
      reset()
      navigate('/admin')
    } catch (err) {
      setErrorApi(err instanceof Error ? err.message : 'Error desconocido')
      setEnviando(false)
    }
  }

  return (
    <main className="admin-page">
      <h1>Nueva publicación</h1>
      {errorApi && <div className="error-banner">{errorApi}</div>}
      <form onSubmit={handleSubmit} className="article-form">
        <div className="form-group">
          <img
            src={preview ?? articulosService.resolverImagen(null)}
            alt="Preview"
            className="image-preview"
            onError={(e) => { e.currentTarget.onerror = null; e.currentTarget.src = '/uploads/default-cover.svg' }}
          />
          <input type="file" accept="image/*" onChange={handleImagenChange} />
        </div>
        <input
          value={campos.titulo}
          onChange={(e) => setCampo('titulo', e.target.value)}
          placeholder="Título *"
          required
        />
        <textarea
          value={campos.extracto}
          onChange={(e) => setCampo('extracto', e.target.value)}
          placeholder="Extracto"
          rows={3}
        />
        <button type="submit" disabled={!esValido || enviando}>
          {enviando ? 'Guardando...' : 'Guardar artículo'}
        </button>
      </form>
    </main>
  )
}

4. Toggle de estado con actualización optimista

El toggle de publicado/borrador debe sentirse instantáneo. El patrón de actualización optimista cambia la UI inmediatamente sin esperar al servidor — si la petición falla, revierte el cambio:

typescript
// En articulosStore.ts — toggleEstado con optimismo
toggleEstado: async (id) => {
  const articulo = get().articulos.find((a) => a.id === id)
  if (!articulo) return

  const nuevoEstado = articulo.estado === 'publicado' ? 'borrador' : 'publicado'

  // 1. Cambiar la UI inmediatamente
  set((state) => ({
    articulos: state.articulos.map((a) =>
      a.id === id ? { ...a, estado: nuevoEstado } : a
    )
  }))

  try {
    // 2. Confirmar con el servidor
    await articulosService.actualizar(articulo.slug, { estado: nuevoEstado })
  } catch {
    // 3. Si falla, revertir al estado original
    set((state) => ({
      articulos: state.articulos.map((a) =>
        a.id === id ? { ...a, estado: articulo.estado } : a
      )
    }))
  }
},

---

## 5. Formulario EditarArticulo conectado a la API

El formulario de edición carga primero los datos actuales del artículo y luego envía solo los cambios. A diferencia de `NuevoArticulo`, si el usuario no selecciona una imagen nueva, la actual se conserva:

```tsx
// src/pages/admin/EditarArticulo.tsx
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { articulosService } from '@/services/articulosService'
import { useArticulosStore } from '@/store/articulosStore'
import type { Articulo, EstadoArticulo } from '@/types'

export function EditarArticulo() {
  const { slug } = useParams<{ slug: string }>()
  const navigate = useNavigate()
  const cargarArticulos = useArticulosStore((s) => s.cargarArticulos)

  const [articulo, setArticulo] = useState<Articulo | null>(null)
  const [titulo, setTitulo] = useState('')
  const [extracto, setExtracto] = useState('')
  const [estado, setEstado] = useState<EstadoArticulo>('borrador')
  const [imagenFile, setImagenFile] = useState<File | null>(null)
  const [preview, setPreview] = useState<string | null>(null)
  const [enviando, setEnviando] = useState(false)
  const [errorApi, setErrorApi] = useState<string | null>(null)

  // Cargar datos actuales del artículo
  useEffect(() => {
    if (!slug) return
    articulosService.getBySlug(slug)
      .then((res) => {
        const a = res.data
        setArticulo(a)
        setTitulo(a.titulo)
        setExtracto(a.extracto ?? '')
        setEstado(a.estado)
      })
      .catch(() => navigate('/admin'))
  }, [slug])

  async function handleSubmit(e: React.BaseSyntheticEvent) {
    e.preventDefault()
    if (!slug) return
    setEnviando(true)
    setErrorApi(null)

    try {
      // Subir nueva imagen solo si el usuario seleccionó una
      let imagen: string | undefined
      if (imagenFile) imagen = await articulosService.upload(imagenFile)

      await articulosService.actualizar(slug, {
        titulo, extracto, estado,
        // Solo incluir imagen si se subió una nueva — el backend preserva la existente
        ...(imagen ? { imagen } : {}),
      })

      await cargarArticulos()
      navigate('/admin')
    } catch (err) {
      setErrorApi(err instanceof Error ? err.message : 'Error desconocido')
      setEnviando(false)
    }
  }

  if (!articulo) return <div className="loading">Cargando...</div>

  return (
    <main className="admin-page">
      <h1>Editar: {articulo.titulo}</h1>
      {errorApi && <div className="error-banner">{errorApi}</div>}
      <form onSubmit={handleSubmit}>
        <img
          src={preview ?? articulosService.resolverImagen(articulo.imagen)}
          alt="Preview"
          className="image-preview"
          onError={(e) => { e.currentTarget.onerror = null; e.currentTarget.src = '/uploads/default-cover.svg' }}
        />
        <input type="file" accept="image/*"
          onChange={(e) => {
            const f = e.target.files?.[0] ?? null
            setImagenFile(f)
            setPreview(f ? URL.createObjectURL(f) : null)
          }}
        />
        <input value={titulo} onChange={(e) => setTitulo(e.target.value)} required />
        <textarea value={extracto} onChange={(e) => setExtracto(e.target.value)} rows={3} />
        <select value={estado} onChange={(e) => setEstado(e.target.value as EstadoArticulo)}>
          <option value="borrador">Borrador</option>
          <option value="publicado">Publicado</option>
        </select>
        <button type="submit" disabled={enviando}>
          {enviando ? 'Guardando...' : 'Guardar cambios'}
        </button>
      </form>
    </main>
  )
}

El punto clave de actualizar: el objeto enviado no incluye imagen si imagenFile es null. Eso significa que el campo imagen en la base de datos no se modifica — se conserva el valor actual. Si el usuario sube una imagen nueva, sí se incluye el nuevo filename.


Actividades prácticas

Actividad 1 — Servicio completo (45 min) Añadir crear, actualizar, eliminar y upload al articulosService. Probar cada método desde la consola del navegador.

Actividad 2 — NuevoArticulo con upload (60 min) Implementar NuevoArticulo conectado a la API usando useFormularioArticulo. Verificar que al guardar el artículo aparece en la tabla del admin sin recargar la página.

Actividad 3 — EditarArticulo (90 min) Implementar EditarArticulo. Probar: (1) editar solo el título sin cambiar imagen, (2) editar cambiando la imagen. Verificar que en ambos casos la imagen correcta aparece.

Actividad 4 — Eliminar con SweetAlert2 (30 min) Conectar el botón de eliminar. Verificar que: (1) el modal de confirmación aparece, (2) cancelar no hace nada, (3) confirmar elimina el artículo y actualiza la tabla.


📋 Entregable de la semana

  • articulosService completo: Métodos crear, actualizar, eliminar, upload y resolverImagen implementados y tipados.
  • NuevoArticulo → API: El formulario crea artículos en PostgreSQL. La tabla del admin muestra el nuevo artículo al volver a /admin.
  • Upload de imagen en creación: Seleccionar un archivo muestra el preview. Al guardar, la imagen se sube y el artículo la referencia correctamente.
  • EditarArticulo → API: Editar sin cambiar imagen preserva la foto original. Editar cambiando la imagen sube la nueva y la muestra correctamente.
  • Eliminar con confirmación: El modal de SweetAlert2 aparece antes de eliminar. Cancelar no hace nada. Confirmar elimina de la BD y de la tabla.
  • Fallback de imágenes: Artículos sin imagen muestran default-cover.svg. Imágenes con URL rota también muestran el fallback (probar con onError).
  • Sin datos stale: Después de crear o editar, la tabla del admin muestra los datos actualizados inmediatamente sin recargar la página.

📥 Descarga el entregable de esta semana

¿No lograste completar la semana o quieres comparar tu código con la solución? Descarga el estado del proyecto al terminar la Semana 9:

⬇️ Descargar entregable Semana 9 (.zip)

Qué incluye: panel admin CRUD completo — NuevoArticulo y EditarArticulo conectados a la API, subida de imágenes con FormData, confirmación de eliminación con SweetAlert2, actualización optimista del estado en Zustand y articulosService con todos los métodos de escritura.

Setup después de descomprimir:

bash
cd blog-cms
npm install
cd frontend && npm install && cd ..
cd backend && pnpm install
# Crea backend/.env con: PORT=3001 y DATABASE_URL="postgresql://..."
npx prisma migrate dev && pnpm seed
cd ..
npm run dev