Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,8 @@ async function publicarEnSegundoPlano(room: Room, cred: Credencial): Promise<voi
try {
avisar("compilando");
const url = await publicarSala(room.workspace, await ensureRunner(room), cred, avisar);
room.urlPublicada = url;
await (await getStorage()).setUrlPublicada(room.id, url);
io.to(room.id).emit("deploy:listo", { url });
// Y al chat, para que el link quede en el historial de la sala y no solo en
// la pantalla de quien estaba mirando cuando terminó.
Expand Down Expand Up @@ -539,6 +541,9 @@ io.on("connection", (socket) => {
// 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) => ({
Expand Down
9 changes: 9 additions & 0 deletions server/src/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -160,6 +168,7 @@ async function despertarSala(id: string): Promise<Room | null> {
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,
Expand Down
17 changes: 16 additions & 1 deletion server/src/storage/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<void> {
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á
Expand Down Expand Up @@ -219,5 +233,6 @@ function toRoom(r: Record<string, unknown>): 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),
};
}
10 changes: 10 additions & 0 deletions server/src/storage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -45,6 +47,14 @@ export interface Storage {
listRooms(): Promise<StoredRoom[]>;
/** Marca actividad reciente (para saber qué salas siguen vivas). */
touchRoom(id: string): Promise<void>;
/**
* 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<void>;
/** Le pone nombre a la sala. `null` lo quita y se vuelve a ver el id. */
renameRoom(id: string, nombre: string | null): Promise<void>;
/**
Expand Down
31 changes: 20 additions & 11 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | null>(null);
/**
* Si la sala tiene algo guardado que llevarse.
*
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -932,16 +944,13 @@ function Sala({ roomId, name }: { roomId: string | null; name: string }) {
>
{zipAviso ?? t.descargarZip}
</button>
{/* Publicar tarda minutos, así que el botón dice por dónde va en
lugar de quedarse quieto: mismo trato que el de descargar. */}
<button
className="invitar"
onClick={publicar}
disabled={!sePuedeExportar || !!publicando}
title={sePuedeExportar ? t.publicarTitulo : t.zipSalaVacia}
>
{publicando ? t.etapaDeploy[publicando] : t.publicar}
</button>
<PublicarPanel
roomId={roomId}
urlPublicada={urlPublicada}
publicando={publicando}
puedePublicar={sePuedeExportar}
onPublicar={publicar}
/>
{/* Las variables son del proyecto de la sala, así que sin sala no
hay dónde escribirlas. */}
{roomId && <EnvPanel roomId={roomId} />}
Expand Down
115 changes: 115 additions & 0 deletions web/src/PublicarPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div className="publicar-panel" ref={cajaRef}>
{/* 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. */}
<button
className={`invitar ${urlPublicada ? "publicada" : ""}`}
onClick={() => setAbierto((v) => !v)}
disabled={!roomId}
title={t.publicarTitulo}
>
{publicando ? t.etapaDeploy[publicando] : urlPublicada ? t.enVivo : t.publicar}
</button>

{abierto && (
<div className="publicar-caja">
<div className="publicar-cab">
<span className={`publicar-punto ${urlPublicada ? "vivo" : ""}`} aria-hidden="true" />
{urlPublicada ? t.publicadaTitulo : t.sinPublicarTitulo}
</div>

{urlPublicada ? (
<>
<div className="publicar-url">
<a href={urlPublicada} target="_blank" rel="noreferrer">
{urlPublicada.replace(/^https:\/\//, "")}
</a>
<button className="publicar-copiar" onClick={copiar}>
{copiado ? t.copiado : t.copiarLink}
</button>
</div>
{/* 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á. */}
<p className="publicar-nota">{t.publicarNotaViva}</p>
</>
) : (
<p className="publicar-nota">{t.publicarNota}</p>
)}

<button
className="publicar-btn"
onClick={onPublicar}
disabled={!puedePublicar || !!publicando}
>
{publicando
? t.etapaDeploy[publicando]
: urlPublicada
? t.republicar
: t.publicar}
</button>
</div>
)}
</div>
);
}
15 changes: 15 additions & 0 deletions web/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ const TEXTOS = {
subiendo: "Subiendo…",
} as Record<string, string>,
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",
Expand Down Expand Up @@ -231,6 +239,13 @@ const TEXTOS = {
subiendo: "Uploading…",
} as Record<string, string>,
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",
Expand Down
2 changes: 2 additions & 0 deletions web/src/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
Loading
Loading