From 36c9909af5a63062bf47cba31c3fa4eba904774d Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Sat, 15 Aug 2026 17:16:13 -0600 Subject: [PATCH] El link de la app publicada deja de perderse en el chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salia como un mensaje mas, asi que al dia siguiente estaba enterrado entre los demas y no habia forma de saber si tu sala seguia en vivo ni donde verla. Ahora es un panel en la barra, como el de variables: dice si esta publicada, enseña el link con un boton para copiarlo, y desde ahi se vuelve a publicar. El boton de la barra ya lo dice de un vistazo ("En vivo" en lavanda) sin abrir nada. La URL se guarda en la BD y viaja en el `joined`, asi que sobrevive a los reinicios y la ve toda la sala: es un dato de la sala, no una noticia de hace rato. Y se avisa lo que la gente supone mal: que lo que cambian aqui no esta afuera hasta que vuelvan a publicar. Lovable pone esa misma nota, y por lo mismo. Co-Authored-By: Claude Opus 5 --- server/src/index.ts | 5 ++ server/src/rooms.ts | 9 +++ server/src/storage/sqlite.ts | 17 +++++- server/src/storage/types.ts | 10 +++ web/src/App.tsx | 31 ++++++---- web/src/PublicarPanel.tsx | 115 +++++++++++++++++++++++++++++++++++ web/src/i18n.tsx | 15 +++++ web/src/socket.ts | 2 + web/src/styles.css | 103 +++++++++++++++++++++++++++++++ 9 files changed, 295 insertions(+), 12 deletions(-) create mode 100644 web/src/PublicarPanel.tsx diff --git a/server/src/index.ts b/server/src/index.ts index 09c3ecb..46f67b6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -394,6 +394,8 @@ async function publicarEnSegundoPlano(room: Room, cred: Credencial): Promise { // Igual que el preview: quien llega a media publicación no recibió el // evento de progreso (ya pasó) y vería el botón como si no hubiera nada. publicando: room.publicando ?? null, + // Dónde está publicada, si lo está: es un dato de la sala, no una noticia + // de hace rato. Quien entra mañana tiene que poder abrirla igual. + urlPublicada: room.urlPublicada ?? null, agents: room.agents.list(), orphanTurns: room.orphanTurns ?? [], messages: history.map((m) => ({ diff --git a/server/src/rooms.ts b/server/src/rooms.ts index d02655d..696dfbc 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -62,6 +62,14 @@ export interface Room { * quieto. También impide que dos personas suban a la vez. */ publicando?: "compilando" | "subiendo" | null; + /** + * Dónde está publicada la app, o null si nunca se publicó. + * + * Se guarda en la BD y sobrevive a los reinicios: antes la única señal de que + * una sala estaba en vivo era un mensaje en el chat, que al día siguiente ya + * estaba enterrado entre los demás. + */ + urlPublicada?: string | null; /** El contenedor que aísla esta sala. null si se está corriendo sin Docker. */ container?: Container | null; /** Dónde se ejecutan los comandos del agente (contenedor o local). */ @@ -160,6 +168,7 @@ async function despertarSala(id: string): Promise { const room: Room = { id, nombre: stored.nombre ?? null, + urlPublicada: stored.urlPublicada ?? null, // El workspace ya existe en disco: NO se re-siembra ni se limpia. workspace: await createWorkspace(id), preview: null, diff --git a/server/src/storage/sqlite.ts b/server/src/storage/sqlite.ts index 1464449..7b17df8 100644 --- a/server/src/storage/sqlite.ts +++ b/server/src/storage/sqlite.ts @@ -31,7 +31,9 @@ export class SqliteStorage implements Storage { last_active_at INTEGER NOT NULL, -- Cómo le dice la gente a esta sala. El id no cambia nunca (es la URL y -- lo que se dicta por teléfono); esto es solo la etiqueta que se ve. - nombre TEXT + nombre TEXT, + -- Dónde quedó publicada la app, si es que se publicó. + url_publicada TEXT ); CREATE TABLE IF NOT EXISTS messages ( @@ -66,6 +68,14 @@ export class SqliteStorage implements Storage { // ya la tiene } + // Dónde quedó publicada la app de esta sala, si es que se publicó. Nullable: + // sin esto no hay forma de saber, al volver mañana, si la sala está en vivo. + try { + this.db.exec(`ALTER TABLE rooms ADD COLUMN url_publicada TEXT`); + } catch { + // ya la tiene + } + // El nombre que la gente le pone a la sala. Nullable a propósito: sin él la // interfaz cae al id, que es lo que se ha visto siempre. try { @@ -106,6 +116,10 @@ export class SqliteStorage implements Storage { this.db.prepare(`UPDATE rooms SET nombre = ? WHERE id = ?`).run(nombre, id); } + async setUrlPublicada(id: string, url: string): Promise { + this.db.prepare(`UPDATE rooms SET url_publicada = ? WHERE id = ?`).run(url, id); + } + /** * Los mensajes y los historiales se van solos: sus tablas declaran * `ON DELETE CASCADE` contra `rooms(id)` y el PRAGMA de claves foráneas está @@ -219,5 +233,6 @@ function toRoom(r: Record): StoredRoom { // Las salas de antes de esta columna la traen en null, y ahí la interfaz // cae al id. nombre: r.nombre == null ? null : String(r.nombre), + urlPublicada: r.url_publicada == null ? null : String(r.url_publicada), }; } diff --git a/server/src/storage/types.ts b/server/src/storage/types.ts index a50015b..e3fc0f6 100644 --- a/server/src/storage/types.ts +++ b/server/src/storage/types.ts @@ -22,6 +22,8 @@ export interface StoredRoom { * renombrar no rompe ningún link ya compartido. */ nombre?: string | null; + /** Dónde quedó publicada la app, o null si la sala nunca se publicó. */ + urlPublicada?: string | null; } export interface StoredMessage { @@ -45,6 +47,14 @@ export interface Storage { listRooms(): Promise; /** Marca actividad reciente (para saber qué salas siguen vivas). */ touchRoom(id: string): Promise; + /** + * Anota dónde quedó publicada la app. + * + * Se guarda para que al volver mañana la sala siga sabiendo que está en vivo: + * sin esto, la única señal de que se publicó era un mensaje en el chat que se + * pierde entre los demás. + */ + setUrlPublicada(id: string, url: string): Promise; /** Le pone nombre a la sala. `null` lo quita y se vuelve a ver el id. */ renameRoom(id: string, nombre: string | null): Promise; /** diff --git a/web/src/App.tsx b/web/src/App.tsx index caecf3f..d4b1f23 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19,6 +19,7 @@ import { Historial } from "./Historial.js"; import { BackCanvas, type Endpoint } from "./BackCanvas.js"; import { KeyPanel, loadStoredCredencial, type Credencial } from "./KeyPanel.js"; import { EnvPanel } from "./EnvPanel.js"; +import { PublicarPanel } from "./PublicarPanel.js"; import { useTextos } from "./i18n.js"; import { MenuSalas } from "./MenuSalas.js"; import { recordarSala, olvidarSala, recordarNombre } from "./historial-salas.js"; @@ -182,6 +183,13 @@ function Sala({ roomId, name }: { roomId: string | null; name: string }) { * que quien entre a media publicación ve el progreso igual que los demás. */ const [publicando, setPublicando] = useState<"compilando" | "subiendo" | null>(null); + /** + * Dónde está publicada la app, o null si nunca se publicó. + * + * Viene de la BD en el `joined`, así que sigue ahí mañana: el link es un dato + * de la sala, no un mensaje de hace rato. + */ + const [urlPublicada, setUrlPublicada] = useState(null); /** * Si la sala tiene algo guardado que llevarse. * @@ -285,6 +293,7 @@ function Sala({ roomId, name }: { roomId: string | null; name: string }) { setNombre(p.nombre ?? null); recordarNombre(roomId, p.nombre ?? null); setPublicando(p.publicando ?? null); + setUrlPublicada(p.urlPublicada ?? null); if (p.previewUrl) setPreviewReady(true); if (p.agents) setAgents(p.agents); if (p.orphanTurns?.length) setOrphans(p.orphanTurns); @@ -312,7 +321,10 @@ function Sala({ roomId, name }: { roomId: string | null; name: string }) { socket.on("deploy:progreso", ({ etapa }: { etapa: "compilando" | "subiendo" }) => setPublicando(etapa), ); - socket.on("deploy:listo", () => setPublicando(null)); + socket.on("deploy:listo", ({ url }: { url: string }) => { + setPublicando(null); + setUrlPublicada(url); + }); socket.on("deploy:fallo", () => setPublicando(null)); socket.on("preview:ready", () => { setPreviewReady(true); @@ -932,16 +944,13 @@ function Sala({ roomId, name }: { roomId: string | null; name: string }) { > {zipAviso ?? t.descargarZip} - {/* Publicar tarda minutos, así que el botón dice por dónde va en - lugar de quedarse quieto: mismo trato que el de descargar. */} - + {/* Las variables son del proyecto de la sala, así que sin sala no hay dónde escribirlas. */} {roomId && } diff --git a/web/src/PublicarPanel.tsx b/web/src/PublicarPanel.tsx new file mode 100644 index 0000000..9627226 --- /dev/null +++ b/web/src/PublicarPanel.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef, useState } from "react"; +import { SERVER_URL } from "./socket.js"; +import { useTextos } from "./i18n.js"; + +/** + * Publicar la app de la sala, y dónde quedó. + * + * Un panel y no solo un botón porque el link es un DATO de la sala, no una + * noticia: antes salía en el chat y al día siguiente estaba enterrado entre los + * mensajes, así que nadie sabía si su sala seguía en vivo ni dónde verla. + * + * Aquí se abre cuando haga falta y siempre dice lo mismo: si está publicada, + * dónde, y el botón para volver a subir lo último. + */ + +export function PublicarPanel({ + roomId, + urlPublicada, + publicando, + puedePublicar, + onPublicar, +}: { + roomId: string | null; + urlPublicada: string | null; + publicando: "compilando" | "subiendo" | null; + /** Si la sala tiene algo guardado que valga la pena subir. */ + puedePublicar: boolean; + onPublicar: () => void; +}) { + const { t } = useTextos(); + const [abierto, setAbierto] = useState(false); + const [copiado, setCopiado] = useState(false); + const cajaRef = useRef(null); + + // Cerrar al hacer click fuera o con Escape, como los demás paneles. + useEffect(() => { + if (!abierto) return; + const fuera = (e: MouseEvent) => { + if (cajaRef.current && !cajaRef.current.contains(e.target as Node)) setAbierto(false); + }; + const esc = (e: KeyboardEvent) => e.key === "Escape" && setAbierto(false); + document.addEventListener("mousedown", fuera); + document.addEventListener("keydown", esc); + return () => { + document.removeEventListener("mousedown", fuera); + document.removeEventListener("keydown", esc); + }; + }, [abierto]); + + useEffect(() => { + if (!copiado) return; + const id = setTimeout(() => setCopiado(false), 2000); + return () => clearTimeout(id); + }, [copiado]); + + const copiar = () => { + if (!urlPublicada) return; + void navigator.clipboard.writeText(urlPublicada); + setCopiado(true); + }; + + return ( +
+ {/* El botón dice el estado, no solo la acción: de un vistazo se sabe si la + sala está en vivo sin tener que abrir nada. */} + + + {abierto && ( +
+
+
+ + {urlPublicada ? ( + <> +
+ + {urlPublicada.replace(/^https:\/\//, "")} + + +
+ {/* Lo mismo que dice Lovable, y por lo mismo: la gente supone que + lo que ve en el preview ya está afuera, y no lo está. */} +

{t.publicarNotaViva}

+ + ) : ( +

{t.publicarNota}

+ )} + + +
+ )} +
+ ); +} diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index 4cc4ce4..52d605b 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -61,6 +61,14 @@ const TEXTOS = { 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", @@ -231,6 +239,13 @@ const TEXTOS = { 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", diff --git a/web/src/socket.ts b/web/src/socket.ts index 5cc545c..f2e78c3 100644 --- a/web/src/socket.ts +++ b/web/src/socket.ts @@ -98,6 +98,8 @@ export interface JoinedPayload { previewArrancando?: boolean; /** Se está publicando la app ahora mismo, y por dónde va. */ publicando?: "compilando" | "subiendo" | null; + /** Dónde está publicada la app, o null si la sala nunca se publicó. */ + urlPublicada?: string | null; agents?: Agent[]; /** Turnos que quedaron a medias por un crash; el humano decide qué hacer. */ orphanTurns?: OrphanTurn[]; diff --git a/web/src/styles.css b/web/src/styles.css index 06e6670..5e123a4 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1828,3 +1828,106 @@ body::after { opacity: 0.6; cursor: default; } + +/* ── Publicar ───────────────────────────────────────────────────────────── + Mismo patrón que los otros paneles de la barra. El link vive aquí y no en el + chat porque es un dato de la sala: al día siguiente sigue siendo cierto. */ +.publicar-panel { + position: relative; +} +/* Cuando está en vivo, el botón lo dice de un vistazo, sin abrir nada. */ +.invitar.publicada { + color: var(--lavanda); + border-color: var(--lavanda); +} +.publicar-caja { + position: absolute; + top: 34px; + right: 0; + z-index: 40; + width: 320px; + background: var(--panel); + border: 1px solid var(--linea); + border-radius: 8px; + padding: 14px 16px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); +} +.publicar-cab { + display: flex; + align-items: center; + gap: 7px; + font-size: 10px; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--dim); + margin-bottom: 10px; +} +.publicar-punto { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--dim); + flex-shrink: 0; +} +.publicar-punto.vivo { + background: var(--lavanda); +} +.publicar-url { + display: flex; + align-items: center; + gap: 8px; + background: var(--fondo2); + border: 1px solid var(--linea); + border-radius: 4px; + padding: 8px 10px; + margin-bottom: 8px; +} +.publicar-url a { + flex: 1; + min-width: 0; + color: var(--texto); + font-size: 11.5px; + text-decoration: none; + /* Una URL larga no debe estirar el panel. */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.publicar-url a:hover { + color: var(--lavanda); +} +.publicar-copiar { + flex-shrink: 0; + background: transparent; + border: none; + color: var(--dim); + font-size: 9px; + letter-spacing: 1.5px; + text-transform: uppercase; + cursor: pointer; +} +.publicar-copiar:hover { + color: var(--texto); +} +.publicar-nota { + font-size: 11px; + color: var(--dim); + line-height: 1.5; + margin: 0 0 12px; +} +.publicar-btn { + width: 100%; + font-size: 10px; + letter-spacing: 2px; + text-transform: uppercase; + padding: 9px; + border-radius: 4px; + cursor: pointer; + background: var(--lavanda); + border: 1px solid var(--lavanda); + color: var(--fondo2); +} +.publicar-btn:disabled { + opacity: 0.5; + cursor: default; +}