From 2b21f6d9c6bf71abd792a3061f55b53e224396c3 Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Fri, 21 Aug 2026 20:56:45 -0600 Subject: [PATCH] El perfil se puede cambiar: tu nombre y tu foto Co-Authored-By: Claude Opus 5 --- server/src/demos/cuentas.ts | 54 ++- server/src/engine/adjuntos.ts | 2 +- server/src/engine/avatares.ts | 110 +++++ server/src/index.ts | 109 +++++ web/src/App.tsx | 37 +- web/src/CuentaPanel.tsx | 141 ++++-- web/src/cuenta.ts | 56 +++ web/src/i18n.tsx | 890 +++++++++++++++++----------------- web/src/imagenes.ts | 13 +- web/src/styles.css | 39 ++ 10 files changed, 970 insertions(+), 481 deletions(-) create mode 100644 server/src/engine/avatares.ts diff --git a/server/src/demos/cuentas.ts b/server/src/demos/cuentas.ts index 783922c..ba060bc 100644 --- a/server/src/demos/cuentas.ts +++ b/server/src/demos/cuentas.ts @@ -10,6 +10,13 @@ import { urlDeAutorizacion, } from "../cuentas.js"; import { addMember, colorDe, type Room } from "../rooms.js"; +import { + guardarAvatar, + rutaAvatar, + mediaTypeDeAvatar, + borrarAvatar, + esAvatarPropio, +} from "../engine/avatares.js"; /** * Demo: las cuentas, que son OPCIONALES. @@ -46,7 +53,11 @@ function salaVacia(): Room { return { members: new Map() } as unknown as Room; } -function main(): void { +/** Un PNG de un pixel, para no traer un archivo de prueba al repo. */ +const pngDeUnPixel = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +async function main(): Promise { console.log("\n=== cuentas (opcionales) ===\n"); console.log("1. Sin credenciales configuradas no se ofrece entrar"); @@ -195,8 +206,47 @@ function main(): void { check("con su foto", conCuenta.foto === "https://x/f.jpg"); } + console.log("\n11. Las fotos que sube la gente"); + { + // El id cambia en cada subida, incluso de la misma persona. Eso es lo que + // hace honesto el `immutable` con el que se sirven: si se reusara el id, la + // foto vieja se quedaría pegada un año en el navegador de todos. + const uno = await guardarAvatar({ mediaType: "image/png", data: pngDeUnPixel }); + const dos = await guardarAvatar({ mediaType: "image/png", data: pngDeUnPixel }); + check("dos subidas, dos ids", uno !== dos, `${uno} vs ${dos}`); + check("y el tipo sale del id", mediaTypeDeAvatar(uno) === "image/png"); + + await borrarAvatar(uno); + await borrarAvatar(dos); + check("borrar deja de encontrarla", (await rutaAvatar(uno)) === null); + // Borrar algo que ya no está no puede reventar: pasa cada vez que alguien + // cambia dos veces de foto seguidas. + await borrarAvatar(uno); + check("y borrar dos veces no truena", true); + + const grande = Buffer.alloc(600 * 1024).toString("base64"); + await guardarAvatar({ mediaType: "image/png", data: grande }).then( + () => check("una foto enorme se rechaza", false, "la aceptó"), + (err: Error) => check("una foto enorme se rechaza", err.message.includes("512KB"), err.message), + ); + + await guardarAvatar({ mediaType: "application/pdf", data: pngDeUnPixel }).then( + () => check("un PDF no es una foto", false, "lo aceptó"), + (err: Error) => check("un PDF no es una foto", err.message.includes("no soportado")), + ); + } + + console.log("\n12. De quién es cada foto"); + { + // Al cambiar de foto se borra la anterior, pero solo si era nuestra: las de + // Google son una URL suya y no hay nada que borrar en nuestro disco. + check("la de Google no se toca", !esAvatarPropio("https://lh3.googleusercontent.com/x")); + check("la subida sí es nuestra", esAvatarPropio("/auth/foto/abc.png")); + check("y sin foto no hay nada que borrar", !esAvatarPropio(null)); + } + console.log(`\n${pass} pasaron, ${fail} fallaron\n`); process.exit(fail > 0 ? 1 : 0); } -main(); +void main(); diff --git a/server/src/engine/adjuntos.ts b/server/src/engine/adjuntos.ts index ea2a2a3..24722c3 100644 --- a/server/src/engine/adjuntos.ts +++ b/server/src/engine/adjuntos.ts @@ -34,7 +34,7 @@ export interface Adjunto { * Los formatos que los modelos con visión aceptan. Las dos documentaciones * oficiales coinciden en la lista (agosto de 2026). */ -const TIPOS: Record = { +export const TIPOS: Record = { "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", diff --git a/server/src/engine/avatares.ts b/server/src/engine/avatares.ts new file mode 100644 index 0000000..a41c161 --- /dev/null +++ b/server/src/engine/avatares.ts @@ -0,0 +1,110 @@ +import { mkdir, writeFile, readdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { TIPOS } from "./adjuntos.js"; +import { WORKSPACES_ROOT } from "./workspace.js"; + +/** + * Las fotos de perfil de quien tiene cuenta. + * + * Van aparte de los adjuntos del chat y no colgando de una sala, porque una foto + * de perfil es de la persona y se ve en TODAS las salas donde entra. El camino de + * los adjuntos valida que el archivo pertenezca a la sala que lo pide, que es + * justo la garantía que aquí estorba. + * + * Lo que sí se reusa es la tabla de formatos: si algún día se acepta uno nuevo, + * se acepta en los dos sitios sin que uno se quede atrás. + */ + +/** Tope por foto. Más bajo que el de los adjuntos a propósito. */ +const MAX_BYTES = 512 * 1024; + +export class AvatarInvalido extends Error {} + +/** + * Dónde viven. Junto a los workspaces y con punto delante, que es lo que ya + * está gitignorado, para que no acaben en ningún commit ni en un .zip. + */ +export function avataresDir(): string { + return join(WORKSPACES_ROOT, ".avatares"); +} + +/** + * Guarda la foto y devuelve su id. + * + * El id lleva un uuid nuevo CADA VEZ, incluso si es la misma persona cambiando + * su foto. Eso es lo que hace honesto el `immutable` con el que se sirve: el + * contenido de un id nunca cambia, y cambiar de foto es cambiar de id. Si se + * reusara el id, la foto vieja se quedaría pegada un año en el navegador de + * todos los que la hubieran visto. + */ +export async function guardarAvatar(entrada: { + mediaType?: unknown; + data?: unknown; +}): Promise { + const mediaType = typeof entrada.mediaType === "string" ? entrada.mediaType : ""; + const ext = TIPOS[mediaType]; + if (!ext) { + throw new AvatarInvalido( + `formato no soportado: ${mediaType || "(ninguno)"}. Solo PNG, JPEG, WebP y GIF.`, + ); + } + if (typeof entrada.data !== "string" || !entrada.data) { + throw new AvatarInvalido("la imagen llegó vacía"); + } + + const buf = Buffer.from(entrada.data, "base64"); + if (buf.length === 0) throw new AvatarInvalido("la imagen no es base64 válido"); + if (buf.length > MAX_BYTES) { + const kb = Math.round(buf.length / 1024); + throw new AvatarInvalido(`la imagen pesa ${kb}KB y el tope son 512KB`); + } + + const id = `${randomUUID()}${ext}`; + const dir = avataresDir(); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, id), buf); + return id; +} + +/** + * La ruta en disco de una foto, o null si no está. + * + * Se comprueba listando el directorio y no armando la ruta con lo que pidieron: + * así un id con `..` o con barras no puede sacar a nadie de aquí. Mismo criterio + * que los adjuntos. + */ +export async function rutaAvatar(id: string): Promise { + try { + const dir = avataresDir(); + const hay = await readdir(dir); + return hay.includes(id) ? join(dir, id) : null; + } catch { + return null; + } +} + +/** El tipo de una foto, deducido de su extensión. */ +export function mediaTypeDeAvatar(id: string): string | null { + for (const [tipo, ext] of Object.entries(TIPOS)) { + if (id.endsWith(ext)) return tipo; + } + return null; +} + +/** + * Borra una foto que ya no usa nadie. + * + * Se llama al cambiar de foto, con la anterior. Un fallo aquí no se propaga: que + * quede un archivo suelto de 200KB es mucho menos grave que impedirle a alguien + * cambiar su foto. + */ +export async function borrarAvatar(id: string): Promise { + const ruta = await rutaAvatar(id); + if (ruta) await rm(ruta, { force: true }).catch(() => {}); +} + +/** Si esta foto la guardamos aquí (y no es una URL de Google). */ +export function esAvatarPropio(foto: string | null | undefined): boolean { + return !!foto && !foto.startsWith("http"); +} diff --git a/server/src/index.ts b/server/src/index.ts index b16c228..92785cc 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -9,6 +9,7 @@ import { existsSync } from "node:fs"; import { createRoom, getRoom, + allRooms, addMember, removeMember, membersList, @@ -24,6 +25,7 @@ import { type SelectedElement, } from "./rooms.js"; import { getStorage } from "./storage/index.js"; +import { KeyedMutex } from "./engine/keyed-mutex.js"; import { setCredential, getCredential, clearCredential } from "./keys.js"; import { aUsuarioPublico, @@ -41,6 +43,14 @@ import { urlDeAutorizacion, } from "./cuentas.js"; import type { StoredUsuario } from "./storage/types.js"; +import { + guardarAvatar, + rutaAvatar, + mediaTypeDeAvatar, + borrarAvatar, + esAvatarPropio, + AvatarInvalido, +} from "./engine/avatares.js"; import { makeProvider, esProviderId, PERFILES, proveedorVe } from "./agent/providers/profiles.js"; import { guardarAdjunto, @@ -324,6 +334,105 @@ fastify.delete<{ Params: { roomId: string } }>("/auth/salas/:roomId", async (req return { ok: true }; }); +/** + * Cambiar la foto es un recurso compartido más: se escribe la nueva, se apunta + * en la base y se borra la vieja. Dos peticiones a la vez de la misma persona + * pueden dejar el archivo nuevo borrado y la columna apuntando a nada. + */ +const cambiosDePerfil = new KeyedMutex(); + +/** Cambiar tu nombre. El que pongas aquí pisa al que dijo Google. */ +fastify.patch<{ Body: { nombre?: unknown } }>("/auth/perfil", async (req, reply) => { + const usuario = await usuarioDe(req); + if (!usuario) return reply.code(401).send({ error: "sin sesión" }); + + const crudo = typeof req.body?.nombre === "string" ? req.body.nombre : ""; + const nombre = crudo.replace(/\s+/g, " ").trim().slice(0, 40); + if (!nombre) return reply.code(400).send({ error: "el nombre no puede quedar vacío" }); + + await (await getStorage()).actualizarUsuario(usuario.id, { + nombre, + correo: usuario.correo, + foto: usuario.foto ?? null, + }); + refrescarEnSalas(usuario.id, { nombre }); + return { usuario: aUsuarioPublico({ ...usuario, nombre }) }; +}); + +/** Subir tu propia foto, en lugar de la que trajo Google. */ +fastify.post<{ Body: { mediaType?: unknown; data?: unknown } }>( + "/auth/perfil/foto", + async (req, reply) => { + const usuario = await usuarioDe(req); + if (!usuario) return reply.code(401).send({ error: "sin sesión" }); + + return cambiosDePerfil.run(usuario.id, async () => { + let id: string; + try { + id = await guardarAvatar(req.body ?? {}); + } catch (err) { + return reply + .code(400) + .send({ error: err instanceof AvatarInvalido ? err.message : "no se pudo guardar" }); + } + + const anterior = usuario.foto; + const foto = `/auth/foto/${id}`; + await (await getStorage()).actualizarUsuario(usuario.id, { + nombre: usuario.nombre, + correo: usuario.correo, + foto, + }); + // La anterior solo si era nuestra: las de Google son una URL suya. + if (esAvatarPropio(anterior)) { + void borrarAvatar(anterior!.replace("/auth/foto/", "")); + } + refrescarEnSalas(usuario.id, { foto }); + return { usuario: aUsuarioPublico({ ...usuario, foto }) }; + }); + }, +); + +/** + * Las fotos de perfil, sin pedir sesión. + * + * Públicas a propósito: se ven en el chat de una sala donde puede haber gente + * sin cuenta, y esconderlas de ellos no protegería nada. + * + * `immutable` se puede porque el id cambia cada vez que alguien cambia su foto. + */ +fastify.get<{ Params: { avatarId: string } }>("/auth/foto/:avatarId", async (req, reply) => { + const ruta = await rutaAvatar(req.params.avatarId); + if (!ruta) return reply.code(404).send({ error: "no está" }); + const tipo = mediaTypeDeAvatar(req.params.avatarId); + if (!tipo) return reply.code(404).send({ error: "no está" }); + return reply + .header("content-type", tipo) + .header("cache-control", "public, max-age=31536000, immutable") + .send(await readFile(ruta)); +}); + +/** + * Refresca lo que las salas saben de alguien que acaba de cambiar su perfil. + * + * Sin esto, el `Member` en memoria se queda con el nombre y la foto viejos hasta + * que la persona recargue, y los demás de la sala la seguirían viendo como antes. + * Se toca el miembro y se rebroadcastea la presencia, que es el mismo camino que + * usa `auth:key` cuando alguien trae su credencial. + */ +function refrescarEnSalas(usuarioId: string, cambios: { nombre?: string; foto?: string }): void { + for (const room of allRooms()) { + let tocada = false; + for (const member of room.members.values()) { + if (member.usuarioId !== usuarioId) continue; + if (cambios.nombre) member.name = cambios.nombre; + if (cambios.foto) member.foto = cambios.foto; + tocada = true; + } + if (tocada) io.to(room.id).emit("presence", { members: membersList(room) }); + } +} + // Crear sala → devuelve su id. El cliente navega a /sala/:id. fastify.post("/rooms", async () => { const room = await createRoom(); diff --git a/web/src/App.tsx b/web/src/App.tsx index e416007..7d85dd4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -28,7 +28,9 @@ import { quienSoy, sincronizarSalas, volverDondeEstaba, + urlDeFoto, type EstadoDeCuenta, + type Usuario, } from "./cuenta.js"; import { prepararImagen, @@ -113,6 +115,18 @@ export function App() { void sincronizarSalas().then(guardarSalas); }); }, []); + + /** + * Alguien cambió su nombre o su foto desde el panel. + * + * El nombre local se mueve con él para que el chat y la presencia lo usen sin + * esperar a recargar. El server ya avisó a los demás de la sala por su cuenta. + */ + const onCuentaCambio = useCallback((u: Usuario) => { + setCuenta((antes) => ({ ...antes, usuario: u })); + setName(u.nombre); + }, []); + /** * Si ya diste tu nombre alguna vez, no se te vuelve a preguntar. * @@ -159,7 +173,7 @@ export function App() { * * Ahora se cae dentro con el menú a mano, y crear es un botón más. */ - if (!roomId) return ; + if (!roomId) return ; // Hay sala pero falta decir cómo te llamas. Sigue haciendo falta para quien // llega por un link que le pasaron: la sala necesita saber quién entró. @@ -191,7 +205,7 @@ export function App() { * porque llega vacío y nada sobrescribe. Aparecías en una sala recién creada * leyendo la conversación de otra. */ - return ; + return ; } @@ -234,10 +248,12 @@ function Sala({ roomId, name, cuenta, + onCuentaCambio, }: { roomId: string | null; name: string; cuenta: EstadoDeCuenta; + onCuentaCambio: (u: Usuario) => void; }) { const { t } = useTextos(); const [members, setMembers] = useState([]); @@ -580,7 +596,9 @@ function Sala({ return; } try { - const listas = await Promise.all(files.slice(0, sitio).map(prepararImagen)); + // Con lambda y no `.map(prepararImagen)`: map pasa el índice como segundo + // argumento, que ahora son las opciones, y la segunda imagen saldría de 1px. + const listas = await Promise.all(files.slice(0, sitio).map((f) => prepararImagen(f))); setPendientes((prev) => [...prev, ...listas]); if (files.length > sitio) setErrorAdjunto(t.maxImagenes(MAX_ADJUNTOS)); } catch (err) { @@ -1021,7 +1039,11 @@ function Sala({ titulo={m.name} /> ))} - + {/* La `key` cambia cuando el server pide la API key: remonta el panel para que se abra solo en ese momento. */} void; +}) { const { t } = useTextos(); const [abierto, setAbierto] = useState(false); + const [nombre, setNombre] = useState(props.usuario?.nombre ?? ""); + const [guardando, setGuardando] = useState(false); + const [error, setError] = useState(null); + const archivoRef = useRef(null); if (!props.configurado) return null; + const foto = urlDeFoto(props.usuario?.foto); + + const guardarNombre = async () => { + const limpio = nombre.trim(); + if (!limpio || limpio === props.usuario?.nombre) return; + setGuardando(true); + const actualizado = await cambiarNombre(limpio); + setGuardando(false); + if (actualizado) props.onCambio(actualizado); + }; + + const elegirFoto = async (archivo: File | undefined) => { + if (!archivo) return; + if (!esImagenAceptada(archivo)) { + setError(t.fotoFormato); + return; + } + setError(null); + setGuardando(true); + // Se reduce en el navegador antes de subirla: una foto de cámara son varios + // MB para pintarse a 34 píxeles, y el server la rechazaría por tamaño. + const lista = await prepararImagen(archivo, { lado: 256 }); + const res = await cambiarFoto(lista.mediaType, lista.data); + setGuardando(false); + if ("error" in res) setError(res.error); + else props.onCambio(res.usuario); + }; + if (!abierto) { return ( ); @@ -42,7 +85,7 @@ export function CuentaPanel(props: { usuario: Usuario | null; configurado: boole return (
- {t.miCuenta} + {props.usuario ? t.miPerfil : t.entrar} @@ -50,28 +93,70 @@ export function CuentaPanel(props: { usuario: Usuario | null; configurado: boole {props.usuario ? ( <> -
-
- {props.usuario.foto ? ( - - ) : null} - {props.usuario.nombre} - {props.usuario.correo} -
+
+ {foto ? ( + + ) : ( +
+ {props.usuario.nombre.slice(0, 1).toUpperCase()} +
+ )} + { + void elegirFoto(e.target.files?.[0]); + // Se limpia para que elegir el MISMO archivo otra vez vuelva a + // disparar el evento. + e.target.value = ""; + }} + /> +
+ +
+ setNombre(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void guardarNombre(); }} + /> +
-

{t.salasGuardadas}

+ + {error ?

{error}

: null} +

{props.usuario.correo}

+ + ) : ( <> diff --git a/web/src/cuenta.ts b/web/src/cuenta.ts index 4270a2e..5d6c838 100644 --- a/web/src/cuenta.ts +++ b/web/src/cuenta.ts @@ -103,3 +103,59 @@ export async function sincronizarSalas(): Promise { return locales; } } + +/** Cambia tu nombre. El que pongas aquí pisa al que trajo Google. */ +export async function cambiarNombre(nombre: string): Promise { + try { + const res = await fetch(`${SERVER_URL}/auth/perfil`, { + method: "PATCH", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nombre }), + }); + if (!res.ok) return null; + const { usuario } = (await res.json()) as { usuario: Usuario }; + return usuario; + } catch { + return null; + } +} + +/** + * Sube tu propia foto. + * + * Devuelve el usuario ya con la foto nueva, o el mensaje de por qué no se pudo. + * El error se enseña: alguien que eligió una imagen y no ve nada pasar merece + * saber si fue el tamaño o el formato. + */ +export async function cambiarFoto( + mediaType: string, + data: string, +): Promise<{ usuario: Usuario } | { error: string }> { + try { + const res = await fetch(`${SERVER_URL}/auth/perfil/foto`, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mediaType, data }), + }); + const cuerpo = (await res.json()) as { usuario?: Usuario; error?: string }; + if (!res.ok || !cuerpo.usuario) { + return { error: cuerpo.error ?? "no se pudo guardar la foto" }; + } + return { usuario: cuerpo.usuario }; + } catch { + return { error: "no se pudo hablar con el server" }; + } +} + +/** + * La URL para pintar una foto de perfil. + * + * Las de Google vienen como URL completa; las que subió alguien vienen como una + * ruta de este server. Se distinguen por el protocolo. + */ +export function urlDeFoto(foto: string | null | undefined): string | null { + if (!foto) return null; + return foto.startsWith("http") ? foto : `${SERVER_URL}${foto}`; +} diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index 91d20b5..db6e049 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -1,442 +1,448 @@ -import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; - -/** - * Los textos de la Sala, en los dos idiomas. - * - * El CÓDIGO sigue en español y así se queda: los nombres, los comentarios y los - * mensajes de commit. Lo que se traduce es lo que ve quien entra, que puede no - * hablar español. - * - * Los dos idiomas van juntos en el mismo archivo a propósito. Si alguien cambia - * una frase en uno y no en el otro, se ve al instante porque están una debajo de - * la otra; repartidos en dos archivos, la versión vieja sobrevive meses. - */ - -export type Idioma = "es" | "en"; - -const CLAVE = "multi.idioma"; - -/** Arranca con el idioma del navegador y recuerda lo que elijas. */ -function idiomaInicial(): Idioma { - const guardado = localStorage.getItem(CLAVE); - if (guardado === "es" || guardado === "en") return guardado; - return navigator.language.toLowerCase().startsWith("es") ? "es" : "en"; -} - -const TEXTOS = { - es: { - // Entrada - tuNombre: "¿cómo te llamas?", - creandoSala: "creando sala…", - entrar: "Entrar", - vasAEntrar: "vas a entrar a la sala", - noSePudoCrear: "no se pudo crear la sala: ", - anonimo: "anónimo", - - // Sala - enLaSala: (n: number) => `${n} en la sala`, - renombrarSala: "clic para ponerle nombre", - ningunaSala: "ninguna sala abierta", - eligeOCrea: "abre una de tus salas o crea una nueva con el +", - salaVacia: "La sala está vacía.", - pideAlgo: "@agente crea un Next con Tailwind", - hablaConLaSala: "habla con la sala — o escribe @agente para pedir algo", - adjuntarImagen: "Adjuntar una imagen", - agentesInactivos: (n: number) => `${n} agente${n === 1 ? "" : "s"} inactivo${n === 1 ? "" : "s"}`, - seInterrumpio: (n: number) => - n === 1 ? "Un agente se interrumpió" : `${n} agentes se interrumpieron`, - aMediaTarea: "a media tarea. ¿Guardas lo que alcanzó a hacer?", - guardarTrabajo: "Guardar", - volverAlPunto: "Volver al último punto", - seleccionando: "seleccionando…", - seleccionarBtn: "Seleccionar elemento", - porEjemplo: "Por ejemplo:", - copiarLink: "Copiar link", - copiado: "Copiado", - descargarZip: "Descargar .zip", - publicar: "Publicar", - publicarTitulo: "Poner la app en internet, con un link para compartir", - etapaDeploy: { - compilando: "Compilando…", - subiendo: "Subiendo…", - } as Record, - publicarFallo: "no se pudo publicar", - enVivo: "En vivo", - republicar: "Publicar los cambios", - publicadaTitulo: "Publicada", - sinPublicarTitulo: "Sin publicar", - publicarNota: - "Publicar pone la app en internet, con un link que puedes pasarle a quien sea. Nadie tiene que entrar a Multi para verla.", - publicarNotaViva: - "Lo que cambies aquí no se ve afuera hasta que vuelvas a publicar.", - preparandoZip: "Preparando…", - zipSalaVacia: "todavía no hay nada que descargar", - zipTrabajoSinGuardar: "hay trabajo sin guardar, el .zip lleva el último punto guardado", - zipFallo: "no se pudo preparar el .zip", - ponerMiKey: "poner mi key", - - // Cuenta (opcional: se entra a las salas sin ella) - entrarConGoogle: "Entrar con Google", - cuentaNota: - "Con cuenta tus salas te siguen a cualquier dispositivo, y tu nombre y tu foto son los mismos en todas. Sin cuenta se entra igual, como siempre.", - miCuenta: "Mi cuenta", - cerrarSesion: "Salir", - salasGuardadas: "Tus salas se guardan en tu cuenta.", - - // Variables del proyecto (.env) - envBoton: "Variables", - envTitulo: "Variables del proyecto", - envNota: - "Van al .env de esta sala: la app y el agente las leen. Son de la sala, así que las ve quien entre. Las que la app lee desde el navegador necesitan el prefijo de su framework (VITE_, NEXT_PUBLIC_); sin él no llegan a la app publicada.", - envVacio: "Todavía no hay ninguna.", - envNombre: "NOMBRE", - envValor: "valor", - envAgregar: "Agregar", - envQuitar: "Quitar", - envGuardando: "Guardando…", - envGuardado: "Guardado", - envReinicio: "Reinicia el proyecto para que las tome (pídeselo al agente).", - envNoSePudo: "no se pudieron guardar: ", - seleccionarElemento: "Seleccionar elemento", - selecciono: "seleccionó", - contraer: "contraer", - - // Presentar - ocultarChat: "Ocultar el chat", - mostrarChat: "Mostrar el chat", - - // Imágenes - quitarImagen: "quitar", - maxImagenes: (n: number) => `caben ${n} imágenes por mensaje`, - imagenNoSePudo: "no se pudo preparar la imagen", - - // Pestañas - /** Pestaña del chat: solo se ve en pantallas chicas. */ - elChat: "El chat", - laApp: "La app", - /** Qué se ve ahora, en el botón que cicla los anchos del preview. */ - verEn: { - escritorio: "Viendo en computadora — clic para tablet", - tablet: "Viendo en tablet — clic para celular", - movil: "Viendo en celular — clic para computadora", - } as Record, - elBack: "El back", - - // Preview - tituloPreview: "preview de la app", - tituloSelector: "selecciona un elemento del preview", - tituloEstadoActual: "el estado actual", - - // Back visual - leyendoProyecto: "leyendo el proyecto…", - sinBack: "Todavía no hay nada del lado del servidor.", - viveEn: "vive en", - sinImplementacion: "sin implementación en el proyecto", - loLlama: "lo llama", - nadieLoLlama: "nadie lo llama desde el front", - - // Arranque del preview - levantandoPreview: "Levantando la app…", - etapaPreview: { - contenedor: "Preparando el entorno de la sala.", - dependencias: "Instalando dependencias. Esta es la parte lenta.", - servidor: "Arrancando el servidor de desarrollo.", - } as Record, - - // Menú de salas - tusSalas: "Tus salas", - sinOtrasSalas: "Todavía no has entrado a otra sala.", - crearOtraSala: "Crear otra sala", - quitarDeLaLista: "Quitar de la lista", - borrarSala: "Borrar la sala", - // Se nombra la sala y se dice que es para todos: borrarla se lleva el - // trabajo de quien estuviera ahí, no solo tu acceso. - confirmarBorrarSala: (id: string) => - `¿Borrar la sala ${id}? Se va el proyecto y el chat, para ti y para todos los que entren con el link. No se puede deshacer.`, - noSePudoBorrar: "no se pudo borrar la sala: ", - salaBorrada: "Alguien borró esta sala. El proyecto y el chat ya no existen.", - salaBorradaOk: (id: string) => `Se borró ${id}.`, - - // Historial - historial: "Historial", - sinCambios: "aún no hay cambios guardados", - ahora: "Ahora", - volverAqui: "Volver aquí", - regresarAqui: "Regresar aquí", - regresarSolo: (f: string) => `regresar solo ${f}`, - confirmarArchivo: (f: string) => `¿Regresar solo ${f} a este estado?`, - confirmarTodo: "¿Regresar TODO el proyecto a este estado?", - confirmarNota: "No se borra nada: se guarda como un cambio nuevo y lo posterior sigue en el historial.", - confirmarDatos: "El código vuelve, pero los datos de la base NO.", - siRegresar: "Sí, regresar", - cancelar: "Cancelar", - comoLaLlamas: "¿cómo la llamas?", - versionQueFuncionaba: "versión que funcionaba", - - // Back visual (mensajes largos) - backVacioNota: "Cuando el front llame a una API o el agente cree un endpoint, aparece aquí.", - faltantes: (n: number) => - n === 1 - ? "1 endpoint que el front llama todavía no existe" - : `${n} endpoints que el front llama todavía no existen`, - epEstado: { - faltante: "el front lo llama, pero no existe todavía", - conectado: "existe y el front lo usa", - huerfano: "existe, pero nadie lo llama", - }, - - // Panel de credencial - tuModelo: "Tu modelo", - proveedor: "Proveedor", - key: "Key", - modelo: "Modelo", - guardar: "Guardar", - ollamaSinKey: "cualquier cosa: Ollama no pide key", - delUsuario: "del usuario", - cambiar: "Cambiar", - keyGuardadaNota: - "Guardado en este navegador: sirve en todas tus salas y sigue aquí mañana. Nadie más en la sala lo ve.", - pideleAlAgente: "Pídele a un agente que arranque el proyecto, el stack lo eliges tú.", - /** Aclaración corta junto al nombre del proveedor. Solo donde aporta algo. */ - proveedorNota: { - anthropic: "Claude", - openrouter: "muchos modelos", - groq: "rápido", - ollama: "en tu máquina", - } as Record, - keyNecesaria: "necesaria para invocar agentes", - keyListo: "listo", - cerrar: "cerrar", - keyNota: "Cada quien usa el suyo: lo que le pidas al agente lo pagas tú, no la sala.", - dondeSacoKey: (proveedor: string) => `¿De dónde saco una key de ${proveedor}?`, - gratis: "gratis", - olvidar: "olvidar", - - // Menciones - loDetiene: "lo detiene", - yNoUn: " y no un ", - - }, - - en: { - // Entrada - tuNombre: "what's your name?", - creandoSala: "creating room…", - entrar: "Join", - vasAEntrar: "you're joining room", - noSePudoCrear: "couldn't create the room: ", - anonimo: "anonymous", - - // Sala - enLaSala: (n: number) => `${n} in the room`, - renombrarSala: "click to name it", - ningunaSala: "no room open", - eligeOCrea: "open one of your rooms or create a new one with the +", - salaVacia: "The room is empty.", - pideAlgo: "@agente build a Next app with Tailwind", - hablaConLaSala: "talk to the room — or type @agente to ask for something", - adjuntarImagen: "Attach an image", - agentesInactivos: (n: number) => `${n} idle agent${n === 1 ? "" : "s"}`, - seInterrumpio: (n: number) => - n === 1 ? "An agent was interrupted" : `${n} agents were interrupted`, - aMediaTarea: "mid-task. Keep what it got done?", - guardarTrabajo: "Keep", - volverAlPunto: "Go back to the last point", - seleccionando: "selecting…", - seleccionarBtn: "Select element", - porEjemplo: "For example:", - copiarLink: "Copy link", - copiado: "Copied", - descargarZip: "Download .zip", - publicar: "Publish", - publicarTitulo: "Put the app online, with a link you can share", - etapaDeploy: { - compilando: "Building…", - subiendo: "Uploading…", - } as Record, - publicarFallo: "couldn't publish it", - enVivo: "Live", - republicar: "Publish changes", - publicadaTitulo: "Published", - sinPublicarTitulo: "Not published", - publicarNota: - "Publishing puts the app online, with a link you can send to anyone. They don't need to join Multi to see it.", - publicarNotaViva: "What you change here isn't live until you publish again.", - preparandoZip: "Preparing…", - zipSalaVacia: "nothing to download yet", - zipTrabajoSinGuardar: "there's unsaved work, the .zip has the last saved point", - zipFallo: "couldn't prepare the .zip", - ponerMiKey: "add my key", - - // Cuenta (opcional: se entra a las salas sin ella) - entrarConGoogle: "Sign in with Google", - cuentaNota: - "With an account your rooms follow you to any device, and your name and picture are the same everywhere. Without one you still get in, same as always.", - miCuenta: "My account", - cerrarSesion: "Sign out", - salasGuardadas: "Your rooms are saved to your account.", - - // Variables del proyecto (.env) - envBoton: "Variables", - envTitulo: "Project variables", - envNota: - "They go in this room's .env: the app and the agent read them. They belong to the room, so anyone who joins can see them. The ones the app reads from the browser need its framework's prefix (VITE_, NEXT_PUBLIC_); without it they never reach the published app.", - envVacio: "None yet.", - envNombre: "NAME", - envValor: "value", - envAgregar: "Add", - envQuitar: "Remove", - envGuardando: "Saving…", - envGuardado: "Saved", - envReinicio: "Restart the project so it picks them up (ask the agent).", - envNoSePudo: "couldn't save them: ", - seleccionarElemento: "Select element", - selecciono: "selected", - contraer: "collapse", - - // Presentar - ocultarChat: "Hide the chat", - mostrarChat: "Show the chat", - - // Imágenes - quitarImagen: "remove", - maxImagenes: (n: number) => `${n} images per message max`, - imagenNoSePudo: "couldn't prepare the image", - - // Pestañas - elChat: "Chat", - laApp: "The app", - verEn: { - escritorio: "Desktop view — click for tablet", - tablet: "Tablet view — click for mobile", - movil: "Mobile view — click for desktop", - } as Record, - elBack: "The back", - - // Preview - tituloPreview: "app preview", - tituloSelector: "select an element from the preview", - tituloEstadoActual: "current state", - - // Back visual - leyendoProyecto: "reading the project…", - sinBack: "Nothing on the server side yet.", - viveEn: "lives in", - sinImplementacion: "not implemented in the project", - loLlama: "calls it", - nadieLoLlama: "nothing calls it from the front end", - - // Arranque del preview - levantandoPreview: "Starting the app…", - etapaPreview: { - contenedor: "Preparing the room's environment.", - dependencias: "Installing dependencies. This is the slow part.", - servidor: "Starting the dev server.", - } as Record, - - // Menú de salas - tusSalas: "Your rooms", - sinOtrasSalas: "You haven't joined another room yet.", - crearOtraSala: "Create another room", - quitarDeLaLista: "Remove from the list", - borrarSala: "Delete the room", - confirmarBorrarSala: (id: string) => - `Delete room ${id}? The project and the chat are gone, for you and for anyone with the link. This can't be undone.`, - noSePudoBorrar: "couldn't delete the room: ", - salaBorrada: "Someone deleted this room. The project and the chat are gone.", - salaBorradaOk: (id: string) => `${id} deleted.`, - - // Historial - historial: "History", - sinCambios: "no saved changes yet", - ahora: "Now", - volverAqui: "Go back here", - regresarAqui: "Go back here", - regresarSolo: (f: string) => `revert only ${f}`, - confirmarArchivo: (f: string) => `Revert only ${f} to this state?`, - confirmarTodo: "Revert the WHOLE project to this state?", - confirmarNota: "Nothing gets deleted: it's saved as a new change and everything after stays in the history.", - confirmarDatos: "The code comes back, but the database data does NOT.", - siRegresar: "Yes, revert", - cancelar: "Cancel", - comoLaLlamas: "what do you call it?", - versionQueFuncionaba: "version that worked", - - // Back visual (mensajes largos) - backVacioNota: "When the front end calls an API or the agent creates an endpoint, it shows up here.", - faltantes: (n: number) => - n === 1 - ? "1 endpoint the front end calls doesn't exist yet" - : `${n} endpoints the front end calls don't exist yet`, - epEstado: { - faltante: "the front end calls it, but it doesn't exist yet", - conectado: "exists and the front end uses it", - huerfano: "exists, but nothing calls it", - }, - - // Panel de credencial - tuModelo: "Your model", - proveedor: "Provider", - key: "Key", - modelo: "Model", - guardar: "Save", - ollamaSinKey: "anything: Ollama doesn't ask for a key", - delUsuario: "from", - cambiar: "Change", - keyGuardadaNota: - "Saved in this browser: works in all your rooms and it's still here tomorrow. Nobody else in the room sees it.", - pideleAlAgente: "Ask an agent to start the project, you pick the stack.", - /** Aclaración corta junto al nombre del proveedor. Solo donde aporta algo. */ - proveedorNota: { - anthropic: "Claude", - openrouter: "many models", - groq: "fast", - ollama: "on your machine", - } as Record, - keyNecesaria: "needed to spawn agents", - keyListo: "ready", - cerrar: "close", - keyNota: "Everyone uses their own: whatever you ask the agent for, you pay, not the room.", - dondeSacoKey: (proveedor: string) => `Where do I get a ${proveedor} key?`, - gratis: "free", - olvidar: "forget", - - // Menciones - loDetiene: "stops it", - yNoUn: " and not a ", - - }, -}; - -/** - * El contrato de textos sale del español. El inglés se comprueba contra él, así - * que si alguien agrega una frase en uno y olvida el otro, no compila. - */ -export type Textos = (typeof TEXTOS)["es"]; - -// Comprobación en tiempo de compilación: los dos idiomas tienen las mismas claves. -const _completo: Record = TEXTOS; -void _completo; - -const Ctx = createContext<{ t: Textos; idioma: Idioma }>({ - t: TEXTOS.es, - idioma: "es", -}); - -export function IdiomaProvider({ children }: { children: ReactNode }) { - const [idioma] = useState(idiomaInicial); - - useEffect(() => { - localStorage.setItem(CLAVE, idioma); - document.documentElement.lang = idioma; - }, [idioma]); - - return ( - {children} - ); -} - -/** Los textos del idioma activo. */ -export function useTextos() { - return useContext(Ctx); -} +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; + +/** + * Los textos de la Sala, en los dos idiomas. + * + * El CÓDIGO sigue en español y así se queda: los nombres, los comentarios y los + * mensajes de commit. Lo que se traduce es lo que ve quien entra, que puede no + * hablar español. + * + * Los dos idiomas van juntos en el mismo archivo a propósito. Si alguien cambia + * una frase en uno y no en el otro, se ve al instante porque están una debajo de + * la otra; repartidos en dos archivos, la versión vieja sobrevive meses. + */ + +export type Idioma = "es" | "en"; + +const CLAVE = "multi.idioma"; + +/** Arranca con el idioma del navegador y recuerda lo que elijas. */ +function idiomaInicial(): Idioma { + const guardado = localStorage.getItem(CLAVE); + if (guardado === "es" || guardado === "en") return guardado; + return navigator.language.toLowerCase().startsWith("es") ? "es" : "en"; +} + +const TEXTOS = { + es: { + // Entrada + tuNombre: "¿cómo te llamas?", + creandoSala: "creando sala…", + entrar: "Entrar", + vasAEntrar: "vas a entrar a la sala", + noSePudoCrear: "no se pudo crear la sala: ", + anonimo: "anónimo", + + // Sala + enLaSala: (n: number) => `${n} en la sala`, + renombrarSala: "clic para ponerle nombre", + ningunaSala: "ninguna sala abierta", + eligeOCrea: "abre una de tus salas o crea una nueva con el +", + salaVacia: "La sala está vacía.", + pideAlgo: "@agente crea un Next con Tailwind", + hablaConLaSala: "habla con la sala — o escribe @agente para pedir algo", + adjuntarImagen: "Adjuntar una imagen", + agentesInactivos: (n: number) => `${n} agente${n === 1 ? "" : "s"} inactivo${n === 1 ? "" : "s"}`, + seInterrumpio: (n: number) => + n === 1 ? "Un agente se interrumpió" : `${n} agentes se interrumpieron`, + aMediaTarea: "a media tarea. ¿Guardas lo que alcanzó a hacer?", + guardarTrabajo: "Guardar", + volverAlPunto: "Volver al último punto", + seleccionando: "seleccionando…", + seleccionarBtn: "Seleccionar elemento", + porEjemplo: "Por ejemplo:", + copiarLink: "Copiar link", + copiado: "Copiado", + descargarZip: "Descargar .zip", + publicar: "Publicar", + publicarTitulo: "Poner la app en internet, con un link para compartir", + etapaDeploy: { + compilando: "Compilando…", + subiendo: "Subiendo…", + } as Record, + publicarFallo: "no se pudo publicar", + enVivo: "En vivo", + republicar: "Publicar los cambios", + publicadaTitulo: "Publicada", + sinPublicarTitulo: "Sin publicar", + publicarNota: + "Publicar pone la app en internet, con un link que puedes pasarle a quien sea. Nadie tiene que entrar a Multi para verla.", + publicarNotaViva: + "Lo que cambies aquí no se ve afuera hasta que vuelvas a publicar.", + preparandoZip: "Preparando…", + zipSalaVacia: "todavía no hay nada que descargar", + zipTrabajoSinGuardar: "hay trabajo sin guardar, el .zip lleva el último punto guardado", + zipFallo: "no se pudo preparar el .zip", + ponerMiKey: "poner mi key", + + // Cuenta (opcional: se entra a las salas sin ella) + entrarConGoogle: "Entrar con Google", + miPerfil: "Mi perfil", + cambiarFoto: "Cambiar foto", + fotoFormato: "Solo PNG, JPEG, WebP o GIF.", + cuentaNota: + "Con cuenta tus salas te siguen a cualquier dispositivo, y tu nombre y tu foto son los mismos en todas. Sin cuenta se entra igual, como siempre.", + miCuenta: "Mi cuenta", + cerrarSesion: "Salir", + salasGuardadas: "Tus salas se guardan en tu cuenta.", + + // Variables del proyecto (.env) + envBoton: "Variables", + envTitulo: "Variables del proyecto", + envNota: + "Van al .env de esta sala: la app y el agente las leen. Son de la sala, así que las ve quien entre. Las que la app lee desde el navegador necesitan el prefijo de su framework (VITE_, NEXT_PUBLIC_); sin él no llegan a la app publicada.", + envVacio: "Todavía no hay ninguna.", + envNombre: "NOMBRE", + envValor: "valor", + envAgregar: "Agregar", + envQuitar: "Quitar", + envGuardando: "Guardando…", + envGuardado: "Guardado", + envReinicio: "Reinicia el proyecto para que las tome (pídeselo al agente).", + envNoSePudo: "no se pudieron guardar: ", + seleccionarElemento: "Seleccionar elemento", + selecciono: "seleccionó", + contraer: "contraer", + + // Presentar + ocultarChat: "Ocultar el chat", + mostrarChat: "Mostrar el chat", + + // Imágenes + quitarImagen: "quitar", + maxImagenes: (n: number) => `caben ${n} imágenes por mensaje`, + imagenNoSePudo: "no se pudo preparar la imagen", + + // Pestañas + /** Pestaña del chat: solo se ve en pantallas chicas. */ + elChat: "El chat", + laApp: "La app", + /** Qué se ve ahora, en el botón que cicla los anchos del preview. */ + verEn: { + escritorio: "Viendo en computadora — clic para tablet", + tablet: "Viendo en tablet — clic para celular", + movil: "Viendo en celular — clic para computadora", + } as Record, + elBack: "El back", + + // Preview + tituloPreview: "preview de la app", + tituloSelector: "selecciona un elemento del preview", + tituloEstadoActual: "el estado actual", + + // Back visual + leyendoProyecto: "leyendo el proyecto…", + sinBack: "Todavía no hay nada del lado del servidor.", + viveEn: "vive en", + sinImplementacion: "sin implementación en el proyecto", + loLlama: "lo llama", + nadieLoLlama: "nadie lo llama desde el front", + + // Arranque del preview + levantandoPreview: "Levantando la app…", + etapaPreview: { + contenedor: "Preparando el entorno de la sala.", + dependencias: "Instalando dependencias. Esta es la parte lenta.", + servidor: "Arrancando el servidor de desarrollo.", + } as Record, + + // Menú de salas + tusSalas: "Tus salas", + sinOtrasSalas: "Todavía no has entrado a otra sala.", + crearOtraSala: "Crear otra sala", + quitarDeLaLista: "Quitar de la lista", + borrarSala: "Borrar la sala", + // Se nombra la sala y se dice que es para todos: borrarla se lleva el + // trabajo de quien estuviera ahí, no solo tu acceso. + confirmarBorrarSala: (id: string) => + `¿Borrar la sala ${id}? Se va el proyecto y el chat, para ti y para todos los que entren con el link. No se puede deshacer.`, + noSePudoBorrar: "no se pudo borrar la sala: ", + salaBorrada: "Alguien borró esta sala. El proyecto y el chat ya no existen.", + salaBorradaOk: (id: string) => `Se borró ${id}.`, + + // Historial + historial: "Historial", + sinCambios: "aún no hay cambios guardados", + ahora: "Ahora", + volverAqui: "Volver aquí", + regresarAqui: "Regresar aquí", + regresarSolo: (f: string) => `regresar solo ${f}`, + confirmarArchivo: (f: string) => `¿Regresar solo ${f} a este estado?`, + confirmarTodo: "¿Regresar TODO el proyecto a este estado?", + confirmarNota: "No se borra nada: se guarda como un cambio nuevo y lo posterior sigue en el historial.", + confirmarDatos: "El código vuelve, pero los datos de la base NO.", + siRegresar: "Sí, regresar", + cancelar: "Cancelar", + comoLaLlamas: "¿cómo la llamas?", + versionQueFuncionaba: "versión que funcionaba", + + // Back visual (mensajes largos) + backVacioNota: "Cuando el front llame a una API o el agente cree un endpoint, aparece aquí.", + faltantes: (n: number) => + n === 1 + ? "1 endpoint que el front llama todavía no existe" + : `${n} endpoints que el front llama todavía no existen`, + epEstado: { + faltante: "el front lo llama, pero no existe todavía", + conectado: "existe y el front lo usa", + huerfano: "existe, pero nadie lo llama", + }, + + // Panel de credencial + tuModelo: "Tu modelo", + proveedor: "Proveedor", + key: "Key", + modelo: "Modelo", + guardar: "Guardar", + ollamaSinKey: "cualquier cosa: Ollama no pide key", + delUsuario: "del usuario", + cambiar: "Cambiar", + keyGuardadaNota: + "Guardado en este navegador: sirve en todas tus salas y sigue aquí mañana. Nadie más en la sala lo ve.", + pideleAlAgente: "Pídele a un agente que arranque el proyecto, el stack lo eliges tú.", + /** Aclaración corta junto al nombre del proveedor. Solo donde aporta algo. */ + proveedorNota: { + anthropic: "Claude", + openrouter: "muchos modelos", + groq: "rápido", + ollama: "en tu máquina", + } as Record, + keyNecesaria: "necesaria para invocar agentes", + keyListo: "listo", + cerrar: "cerrar", + keyNota: "Cada quien usa el suyo: lo que le pidas al agente lo pagas tú, no la sala.", + dondeSacoKey: (proveedor: string) => `¿De dónde saco una key de ${proveedor}?`, + gratis: "gratis", + olvidar: "olvidar", + + // Menciones + loDetiene: "lo detiene", + yNoUn: " y no un ", + + }, + + en: { + // Entrada + tuNombre: "what's your name?", + creandoSala: "creating room…", + entrar: "Join", + vasAEntrar: "you're joining room", + noSePudoCrear: "couldn't create the room: ", + anonimo: "anonymous", + + // Sala + enLaSala: (n: number) => `${n} in the room`, + renombrarSala: "click to name it", + ningunaSala: "no room open", + eligeOCrea: "open one of your rooms or create a new one with the +", + salaVacia: "The room is empty.", + pideAlgo: "@agente build a Next app with Tailwind", + hablaConLaSala: "talk to the room — or type @agente to ask for something", + adjuntarImagen: "Attach an image", + agentesInactivos: (n: number) => `${n} idle agent${n === 1 ? "" : "s"}`, + seInterrumpio: (n: number) => + n === 1 ? "An agent was interrupted" : `${n} agents were interrupted`, + aMediaTarea: "mid-task. Keep what it got done?", + guardarTrabajo: "Keep", + volverAlPunto: "Go back to the last point", + seleccionando: "selecting…", + seleccionarBtn: "Select element", + porEjemplo: "For example:", + copiarLink: "Copy link", + copiado: "Copied", + descargarZip: "Download .zip", + publicar: "Publish", + publicarTitulo: "Put the app online, with a link you can share", + etapaDeploy: { + compilando: "Building…", + subiendo: "Uploading…", + } as Record, + publicarFallo: "couldn't publish it", + enVivo: "Live", + republicar: "Publish changes", + publicadaTitulo: "Published", + sinPublicarTitulo: "Not published", + publicarNota: + "Publishing puts the app online, with a link you can send to anyone. They don't need to join Multi to see it.", + publicarNotaViva: "What you change here isn't live until you publish again.", + preparandoZip: "Preparing…", + zipSalaVacia: "nothing to download yet", + zipTrabajoSinGuardar: "there's unsaved work, the .zip has the last saved point", + zipFallo: "couldn't prepare the .zip", + ponerMiKey: "add my key", + + // Cuenta (opcional: se entra a las salas sin ella) + entrarConGoogle: "Sign in with Google", + miPerfil: "My profile", + cambiarFoto: "Change picture", + fotoFormato: "PNG, JPEG, WebP or GIF only.", + cuentaNota: + "With an account your rooms follow you to any device, and your name and picture are the same everywhere. Without one you still get in, same as always.", + miCuenta: "My account", + cerrarSesion: "Sign out", + salasGuardadas: "Your rooms are saved to your account.", + + // Variables del proyecto (.env) + envBoton: "Variables", + envTitulo: "Project variables", + envNota: + "They go in this room's .env: the app and the agent read them. They belong to the room, so anyone who joins can see them. The ones the app reads from the browser need its framework's prefix (VITE_, NEXT_PUBLIC_); without it they never reach the published app.", + envVacio: "None yet.", + envNombre: "NAME", + envValor: "value", + envAgregar: "Add", + envQuitar: "Remove", + envGuardando: "Saving…", + envGuardado: "Saved", + envReinicio: "Restart the project so it picks them up (ask the agent).", + envNoSePudo: "couldn't save them: ", + seleccionarElemento: "Select element", + selecciono: "selected", + contraer: "collapse", + + // Presentar + ocultarChat: "Hide the chat", + mostrarChat: "Show the chat", + + // Imágenes + quitarImagen: "remove", + maxImagenes: (n: number) => `${n} images per message max`, + imagenNoSePudo: "couldn't prepare the image", + + // Pestañas + elChat: "Chat", + laApp: "The app", + verEn: { + escritorio: "Desktop view — click for tablet", + tablet: "Tablet view — click for mobile", + movil: "Mobile view — click for desktop", + } as Record, + elBack: "The back", + + // Preview + tituloPreview: "app preview", + tituloSelector: "select an element from the preview", + tituloEstadoActual: "current state", + + // Back visual + leyendoProyecto: "reading the project…", + sinBack: "Nothing on the server side yet.", + viveEn: "lives in", + sinImplementacion: "not implemented in the project", + loLlama: "calls it", + nadieLoLlama: "nothing calls it from the front end", + + // Arranque del preview + levantandoPreview: "Starting the app…", + etapaPreview: { + contenedor: "Preparing the room's environment.", + dependencias: "Installing dependencies. This is the slow part.", + servidor: "Starting the dev server.", + } as Record, + + // Menú de salas + tusSalas: "Your rooms", + sinOtrasSalas: "You haven't joined another room yet.", + crearOtraSala: "Create another room", + quitarDeLaLista: "Remove from the list", + borrarSala: "Delete the room", + confirmarBorrarSala: (id: string) => + `Delete room ${id}? The project and the chat are gone, for you and for anyone with the link. This can't be undone.`, + noSePudoBorrar: "couldn't delete the room: ", + salaBorrada: "Someone deleted this room. The project and the chat are gone.", + salaBorradaOk: (id: string) => `${id} deleted.`, + + // Historial + historial: "History", + sinCambios: "no saved changes yet", + ahora: "Now", + volverAqui: "Go back here", + regresarAqui: "Go back here", + regresarSolo: (f: string) => `revert only ${f}`, + confirmarArchivo: (f: string) => `Revert only ${f} to this state?`, + confirmarTodo: "Revert the WHOLE project to this state?", + confirmarNota: "Nothing gets deleted: it's saved as a new change and everything after stays in the history.", + confirmarDatos: "The code comes back, but the database data does NOT.", + siRegresar: "Yes, revert", + cancelar: "Cancel", + comoLaLlamas: "what do you call it?", + versionQueFuncionaba: "version that worked", + + // Back visual (mensajes largos) + backVacioNota: "When the front end calls an API or the agent creates an endpoint, it shows up here.", + faltantes: (n: number) => + n === 1 + ? "1 endpoint the front end calls doesn't exist yet" + : `${n} endpoints the front end calls don't exist yet`, + epEstado: { + faltante: "the front end calls it, but it doesn't exist yet", + conectado: "exists and the front end uses it", + huerfano: "exists, but nothing calls it", + }, + + // Panel de credencial + tuModelo: "Your model", + proveedor: "Provider", + key: "Key", + modelo: "Model", + guardar: "Save", + ollamaSinKey: "anything: Ollama doesn't ask for a key", + delUsuario: "from", + cambiar: "Change", + keyGuardadaNota: + "Saved in this browser: works in all your rooms and it's still here tomorrow. Nobody else in the room sees it.", + pideleAlAgente: "Ask an agent to start the project, you pick the stack.", + /** Aclaración corta junto al nombre del proveedor. Solo donde aporta algo. */ + proveedorNota: { + anthropic: "Claude", + openrouter: "many models", + groq: "fast", + ollama: "on your machine", + } as Record, + keyNecesaria: "needed to spawn agents", + keyListo: "ready", + cerrar: "close", + keyNota: "Everyone uses their own: whatever you ask the agent for, you pay, not the room.", + dondeSacoKey: (proveedor: string) => `Where do I get a ${proveedor} key?`, + gratis: "free", + olvidar: "forget", + + // Menciones + loDetiene: "stops it", + yNoUn: " and not a ", + + }, +}; + +/** + * El contrato de textos sale del español. El inglés se comprueba contra él, así + * que si alguien agrega una frase en uno y olvida el otro, no compila. + */ +export type Textos = (typeof TEXTOS)["es"]; + +// Comprobación en tiempo de compilación: los dos idiomas tienen las mismas claves. +const _completo: Record = TEXTOS; +void _completo; + +const Ctx = createContext<{ t: Textos; idioma: Idioma }>({ + t: TEXTOS.es, + idioma: "es", +}); + +export function IdiomaProvider({ children }: { children: ReactNode }) { + const [idioma] = useState(idiomaInicial); + + useEffect(() => { + localStorage.setItem(CLAVE, idioma); + document.documentElement.lang = idioma; + }, [idioma]); + + return ( + {children} + ); +} + +/** Los textos del idioma activo. */ +export function useTextos() { + return useContext(Ctx); +} diff --git a/web/src/imagenes.ts b/web/src/imagenes.ts index bd62f8d..f5dac3f 100644 --- a/web/src/imagenes.ts +++ b/web/src/imagenes.ts @@ -43,7 +43,15 @@ export function esImagenAceptada(file: File): boolean { * Los GIF se dejan tal cual. Redibujarlos en un canvas se quedaría con el primer * fotograma, y quien manda un GIF lo manda por el movimiento. */ -export async function prepararImagen(file: File): Promise { +export async function prepararImagen( + file: File, + /** + * A cuánto reducir el lado más largo. El default sirve para el chat, donde + * el modelo va a mirar la imagen. Una foto de perfil se pinta a 34 píxeles, + * así que ahí se pide mucho menos y baja de megas a kilobytes. + */ + opts: { lado?: number } = {}, +): Promise { if (!esImagenAceptada(file)) { throw new Error(`no puedo con archivos ${file.type || "de ese tipo"}`); } @@ -59,7 +67,8 @@ export async function prepararImagen(file: File): Promise { } const bitmap = await createImageBitmap(file); - const escala = Math.min(1, LADO_MAXIMO / Math.max(bitmap.width, bitmap.height)); + const lado = opts.lado ?? LADO_MAXIMO; + const escala = Math.min(1, lado / Math.max(bitmap.width, bitmap.height)); const ancho = Math.round(bitmap.width * escala); const alto = Math.round(bitmap.height * escala); diff --git a/web/src/styles.css b/web/src/styles.css index 05f5879..d3d0117 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2193,3 +2193,42 @@ img.av { max-width: 100% !important; } } + +/* Panel de perfil: la foto y el botón de cambiarla, en una fila. */ +.perfil-foto-fila { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.perfil-foto-fila .cuenta-foto-grande { + margin-bottom: 0; +} + +/* Cuando alguien no tiene foto: la inicial sobre el fondo de siempre. */ +.cuenta-sin-foto { + display: flex; + align-items: center; + justify-content: center; + background: #2a2b3d; + color: #d8d9e8; + font-weight: 800; + font-size: 16px; +} + +.perfil-nombre { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 8px; +} + +.perfil-nombre .name-input { + flex: 1; + min-width: 0; +} + +.key-error { + color: #d95d63; +}