This is the abridged developer documentation for Kevin CMS
# Kevin CMS
> Declara tus colecciones en TypeScript y quédate con tablas reales en D1, una API tipada y un administrador que adopta el tema de tu sitio.
Todo en Cloudflare D1 para los documentos, R2 para los ficheros, KV para la configuración y Email Sending para el correo transaccional. Schema real Tu configuración se convierte en un schema de Drizzle y en migraciones SQL revisables. Sin columnas JSON donde debería haber tablas. Plugin de Astro Una integración monta el administrador, la API REST y `Astro.locals.cms` tipado. Tu tema, no el nuestro El administrador usa componentes shadcn que leen las variables CSS de tu sitio, así que hereda tus colores sin configurar nada. ## Estado del proyecto [Sección titulada «Estado del proyecto»](#estado-del-proyecto) En construcción. Ya funcionan las colecciones, los campos, el schema y las migraciones, la API local, la API REST, la [autenticación](/conceptos/autenticacion/) con email y contraseña —con la dirección verificada y la contraseña recuperable—, el [correo transaccional](/conceptos/email/) por Email Sending, la subida y el servido de [ficheros](/conceptos/ficheros/) en R2 y los [ajustes del sitio](/conceptos/ajustes/) en KV. El [panel de administración](/conceptos/admin/) trae el asistente de configuración, el login, el escritorio, el listado de cada colección —con búsqueda, filtros, orden y paginación—, el editor de documentos con los nueve tipos de campo y la biblioteca de medios. Lo que la versión 1 **no** hace está enumerado sin rodeos, y comprobado contra el código, en [Limitaciones de la versión 1](/guias/limitaciones/). Léelo antes de comprometer un proyecto. Del apartado de autenticación faltan los proveedores sociales y las pantallas de verificación y restablecimiento: esos flujos funcionan por API, pero el formulario que los envuelve todavía no está. El registro sí está cerrado: lo cierra la primera cuenta, la que crea el asistente. Ver la [página de autenticación](/conceptos/autenticacion/).
# Panel de administración
> El panel en /admin — renderizado en servidor, con el tema de tu sitio, un asistente de configuración inicial y una guarda de sesión que no depende de JavaScript.
El panel vive en `/admin` y lo monta la integración: no hay nada que instalar, ni una página que escribir, ni un componente que importar. Declaras tus colecciones y ahí está. Hoy funciona el CMS entero: el asistente de configuración inicial, el login, el menú construido desde tu config, el tema heredado de tu sitio, el listado de cada colección, el editor de documentos, la biblioteca de medios y la pantalla de ajustes. Lo que queda fuera es la pantalla de proveedores sociales, invitar a un segundo usuario y la pantalla de cuenta. ## La primera vez [Sección titulada «La primera vez»](#la-primera-vez) Una instalación cuya tabla `users` está vacía no tiene login que enseñar, así que cualquier ruta del panel lleva al asistente. 1. Abres `/admin` y acabas en `/admin/setup`. 2. Un formulario pide el nombre del sitio, su URL pública, el remitente del correo y su dirección de respuesta —los [ajustes](/conceptos/ajustes/)— más tu nombre, tu email y tu contraseña. 3. Al enviarlo se guardan los ajustes, se crea tu cuenta **ya verificada** y entras al panel con la sesión abierta. 4. A partir de ahí `/admin/setup` responde `404` y `/api/auth/sign-up/email` responde `403`. El orden importa y no es casual: los ajustes se validan y se escriben **antes** de crear la cuenta. Al revés, una URL mal tecleada dejaría un administrador creado, el registro cerrado y un asistente que ya no se puede volver a abrir. ## Quién entra [Sección titulada «Quién entra»](#quién-entra) | Situación | Qué pasa | | ------------------------------------------ | ------------------------------------------------------- | | Sin usuarios en la base | Todo lleva al asistente | | Con usuarios, sin sesión | Todo lleva a `/admin/login` | | Con usuarios, sin sesión, ruta inexistente | Lleva al login **también** — no se revela que no existe | | Con sesión, en `/admin/login` | Lleva al dashboard | | Con sesión, ruta inexistente | `404` dentro del panel | | Con usuarios, en `/admin/setup` | `404`. El asistente terminó su trabajo | La guarda es lo único que hay El panel lee tu contenido por la [API local](/referencia/local-api/), que **no aplica control de acceso**: se salta `access.read` y la sesión, porque quien autoriza es la [API REST](/referencia/rest-api/) y aquí no está en el camino. Por eso la comprobación de sesión no vive dentro de la página de Astro, donde ni el linter ni el typechecker ni los tests la ven, sino en una función pura probada con su tabla de verdad completa. ## El tema es el tuyo [Sección titulada «El tema es el tuyo»](#el-tema-es-el-tuyo) El panel no define ni un color propio: todas sus reglas leen las variables de shadcn. Si tú las defines, se pinta con las tuyas. Como `/admin` sirve un documento HTML entero, tu hoja de estilos no llega sola: se la señalas con `admin.css`. Está explicado en [Integración de Astro](/referencia/integracion-astro/#el-tema-del-panel). Y si no la señalas, el panel trae un tema de respaldo y no hay nada que configurar. ### Modo oscuro [Sección titulada «Modo oscuro»](#modo-oscuro) El panel trae su propio conmutador —está en el menú de tu cuenta, abajo del todo— y guarda la elección en `localStorage`. La clase `.dark` que ponga el conmutador de tu sitio no le llega: `/admin` sirve otro documento. Lo que sí comparten es el `localStorage` del origen. Si tu conmutador escribe la misma clave, el panel sigue al sitio; el paquete la exporta como `THEME_KEY` para que no tengas que copiar la cadena. Cómo hacerlo, en [Integración de Astro](/referencia/integracion-astro/#compartir-el-modo-oscuro-con-tu-sitio). Un script en línea en el `` aplica la elección **antes del primer pintado**, así que no hay destello blanco al recargar. ## El menú sale del config [Sección titulada «El menú sale del config»](#el-menú-sale-del-config) La barra lateral se construye leyendo tus colecciones, sin que tengas que declarar nada:
```ts
{
slug: 'posts',
labels: { singular: 'Entrada', plural: 'Entradas' },
admin: { group: 'Catálogo', hidden: false },
fields: [/* … */],
}
```
| Opción | Efecto en el menú | | --------------- | ------------------------------------------------------- | | `labels.plural` | La etiqueta que se ve | | `admin.group` | Agrupa la colección bajo ese epígrafe | | `admin.hidden` | La quita del menú. Su URL **sigue funcionando** | | `upload` | La quita también: es la biblioteca, y esa ya está abajo | **No se inventa ningún grupo.** Lo que no declara `admin.group` va suelto y **siempre arriba**, antes de cualquier epígrafe, esté donde esté en tu config. Cada grupo sí es un epígrafe que pliega lo que tiene debajo, y los grupos conservan el orden de tu config, no el alfabético: ese orden es una decisión que alguien tomó. La única colección que el CMS clasifica por su cuenta es `users`, que nace bajo **Autenticación**: un usuario no es contenido y no debería sentarse arriba entre las colecciones que escribes tú. Al pasar el ratón por una colección aparece un menú con dos atajos: verla entera y crear una nueva. Así, un config con `posts` sueltas y una colección de `media` acaba en esto:
```plaintext
Posts ← suelta, arriba
Autenticación
Users
─────
Escritorio · Medios · Ajustes
```
Abajo del todo, separadas de las colecciones porque no lo son, van el escritorio, los medios y los ajustes. Y al pie, tu cuenta. La barra se pliega a iconos y recuerda cómo la dejaste en la cookie `sidebar_state`, así que el servidor ya la dibuja plegada en la siguiente página. ## El listado de una colección [Sección titulada «El listado de una colección»](#el-listado-de-una-colección) Las columnas salen de `admin.defaultColumns`, o de los tres primeros campos si no lo declaras. Si nombras un campo que ya no existe, esa columna simplemente no se pinta: una errata en el config no tumba la página. Pulsar una cabecera ordena. El buscador traduce lo que escribes a `contains` sobre el campo que declares en `admin.useAsTitle` — y si ese campo no es de texto no se pinta el buscador, porque `contains` solo es legal sobre `text`, `textarea` y `select`. La paginación deja elegir entre 10, 25, 50 y 100 por página. Y puedes **filtrar por campo y operador**. Cada tipo ofrece los operadores que tienen sentido para él —`contains` en un texto, `greater_than` en un número o una fecha, `in` en un select— y nunca uno que la base rechazaría. El valor lo escribes con el mismo componente que usa el editor: filtrar por autor te da el mismo buscador de relación, no una caja donde teclear un id. El filtro va en la URL con la misma sintaxis que la API REST, así que un filtro del panel se pega tal cual en una llamada a la API:
```plaintext
/admin/collections/posts?where[status][equals]=draft&where[title][contains]=astro
```
Un filtro imposible —un campo que no existe, un operador que no aplica a ese tipo, una lista de más de cien valores— **se descarta en silencio** y el resto se aplica. Nunca es un error: una consulta que revienta en el frontmatter de Astro sería un 500 contando por qué, y eso describe tu esquema a quien no debería verlo. **Todo eso vive en la URL**, no en el componente. Un listado ordenado por fecha, buscando «astro» y filtrado por borradores es un enlace que puedes pegarle a alguien o guardar en marcadores, y sobrevive a recargar. También significa que ordenar, buscar, filtrar y paginar funcionan con JavaScript desactivado: son anclas y un formulario `GET`, no manejadores de eventos. Un listado vacío dice tres cosas distintas según por qué está vacío —no hay documentos todavía, la búsqueda no encuentra nada, o has pedido una página que no existe— y cada una ofrece la acción que corresponde. Son tres situaciones diferentes y mezclarlas es mentirle a quien las lee. Para borrar en lote marcas las casillas y confirmas en una pantalla que dice el número exacto y enumera los títulos. Si alguno falla, te lo dice: una tanda a medias nunca se reporta como éxito. ## El editor [Sección titulada «El editor»](#el-editor) Creación y edición son la misma pantalla; crear es abrirla con un documento vacío, ya relleno con los `defaultValue` que declara tu config. Los campos se reparten en dos columnas según `admin.position`: los de `'sidebar'` a la derecha, junto a `createdAt`, `updatedAt` y las acciones; el resto a la izquierda. `admin.description` se pinta como pista bajo el campo, `admin.readOnly` lo deshabilita y `admin.width: 'half'` lo pone a media anchura. Guardar está deshabilitado mientras no cambies nada, `⌘S` / `Ctrl+S` guarda, y salir con cambios sin guardar te avisa. Un error de validación del servidor se pinta bajo su campo, con el foco puesto en el primero que falla y sin perder nada de lo que hubieras escrito. Borrar exige escribir el título exacto del documento. La comparación la hace el servidor: la del navegador se salta abriendo las herramientas de desarrollo. ### Un componente por tipo de campo [Sección titulada «Un componente por tipo de campo»](#un-componente-por-tipo-de-campo) Cada uno de los [nueve tipos](/conceptos/campos/) trae su propio control, y cinco hacen algo más que un input: | Tipo | Qué te da | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `relationship` | Un buscador que consulta la colección destino por su `useAsTitle`, con 300 ms de espera entre teclas y las diez primeras coincidencias. Guarda el id, te enseña el título, y abre el documento relacionado en otra pestaña | | `date` | Un calendario, con hora si el campo es `datetime`. Lo ves y lo eliges **en tu huso**, rotulado; lo que se guarda sigue siendo UTC | | `select` | Un `