diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d57ba7..488cd95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,7 @@ jobs: - run: npm run demo:internet - run: npm run demo:publicar - run: npm run demo:proxy-rutas + - run: npm run demo:remix - run: npm run demo:documento - run: npm run demo:doc-sync - run: npm run demo:comentarios diff --git a/package.json b/package.json index 075aa49..e1737c3 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "demo:agentes-se-ven": "npm run demo:agentes-se-ven -w server", "demo:imagenes": "npm run demo:imagenes -w server", "demo:proxy-rutas": "npm run demo:proxy-rutas -w server", + "demo:remix": "npm run demo:remix -w server", "demo:providers": "npm run demo:providers -w server", "dev:test": "npm run dev:test -w server", "demo:presupuesto": "npm run demo:presupuesto -w server", diff --git a/server/package.json b/server/package.json index 203a52d..8d20e73 100644 --- a/server/package.json +++ b/server/package.json @@ -26,6 +26,7 @@ "demo:turno-cortado": "tsx src/demos/turno-cortado.ts", "demo:imagenes": "tsx src/demos/imagenes.ts", "demo:proxy-rutas": "tsx src/demos/proxy-rutas.ts", + "demo:remix": "tsx src/demos/remix.ts", "demo:exportar": "tsx src/demos/exportar.ts", "uso": "tsx --no-warnings src/demos/uso.ts", "demo:variables": "tsx src/demos/variables.ts", diff --git a/server/src/demos/remix.ts b/server/src/demos/remix.ts new file mode 100644 index 0000000..42a7103 --- /dev/null +++ b/server/src/demos/remix.ts @@ -0,0 +1,116 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** + * Demo: el remix de una sala. + * Uso: npm run demo:remix + * + * Un remix es una sala nueva con una copia del proyecto, ligada al original. + * Lo que se revisa es lo que importa de una copia: que traiga el código, que NO + * traiga los secretos (cualquiera con el link puede hacer un remix), que quede + * ligada para "Volver al original", y que cambiarla no toque el original. + * + * Sin Docker ni red: salas de verdad, con una base y workspaces temporales. + */ + +let pass = 0; +let fail = 0; +function check(name: string, ok: boolean, detail = ""): void { + if (ok) { + pass++; + console.log(` [ok] ${name}`); + } else { + fail++; + console.log(` [X] ${name} ${detail}`); + } +} + +async function main() { + console.log("=== Remix de una sala ===\n"); + const raiz = await mkdtemp(join(tmpdir(), "multi-remix-")); + // Antes de importar: la base se abre con lo que diga MULTI_DB, y sin + // contenedor, para que la sala no intente levantar uno. + process.env.MULTI_DB = join(raiz, "multi.db"); + process.env.MULTI_SIN_AISLAMIENTO = "1"; + const { createRoom, createRemix, NadaQueRemixear } = await import("../rooms.js"); + const { commitAll } = await import("../engine/git.js"); + const { getStorage } = await import("../storage/index.js"); + const storage = await getStorage(); + const creadas: string[] = []; + + try { + console.log("1. El remix trae el código guardado"); + const original = await createRoom(); + creadas.push(original.workspace.dir); + const dir = original.workspace.dir; + await mkdir(join(dir, "src"), { recursive: true }); + await mkdir(join(dir, "node_modules", "algo"), { recursive: true }); + await writeFile(join(dir, "index.html"), "

Box

\n"); + await writeFile(join(dir, "src", "juego.js"), "export const golpe = 10;\n"); + await writeFile(join(dir, ".env"), "SECRETO=no-se-copia\n"); + await writeFile(join(dir, "node_modules", "algo", "index.js"), "// pesado\n"); + await writeFile(join(dir, "datos.db"), "binario de prueba"); + await commitAll(dir, { message: "el juego", author: "agente-1" }); + // Algo escrito después del último guardado no entra: el remix es de lo guardado. + await writeFile(join(dir, "sin-guardar.txt"), "a medias\n"); + + const remix = await createRemix(original); + creadas.push(remix.workspace.dir); + const rdir = remix.workspace.dir; + check("es otra sala, con otro workspace", remix.id !== original.id && rdir !== dir); + check("trae los archivos del proyecto", existsSync(join(rdir, "index.html")) && existsSync(join(rdir, "src", "juego.js"))); + check("con el mismo contenido", (await readFile(join(rdir, "src", "juego.js"), "utf8")) === "export const golpe = 10;\n"); + + console.log("\n2. Lo que no se copia"); + check("no trae el .env (los secretos no se regalan)", !existsSync(join(rdir, ".env"))); + check("ni node_modules", !existsSync(join(rdir, "node_modules"))); + check("ni la base del proyecto", !existsSync(join(rdir, "datos.db"))); + check("ni lo que no estaba guardado", !existsSync(join(rdir, "sin-guardar.txt"))); + + console.log("\n3. Queda ligado a su original"); + check("sabe de qué es remix", remix.origenId === original.id); + check("y se llama como el original con (remix)", remix.nombre === `${original.id} (remix)`, String(remix.nombre)); + const guardada = await storage.getRoom(remix.id); + check("el vínculo se guarda en la base", guardada?.origenId === original.id, JSON.stringify(guardada)); + check("y el nombre también", guardada?.nombre === `${original.id} (remix)`); + const otro = await createRemix(original); + creadas.push(otro.workspace.dir); + check("el segundo se numera para distinguirlo", otro.nombre === `${original.id} (remix 2)`, String(otro.nombre)); + const lista = await storage.remixesDe(original.id); + check("el original lista sus remixes", lista.length === 2 && lista.some((r) => r.id === remix.id) && lista.some((r) => r.id === otro.id), JSON.stringify(lista)); + check("una sala normal no es remix de nada", (await storage.getRoom(original.id))?.origenId == null); + + console.log("\n4. Cambiar el remix no toca el original"); + await writeFile(join(rdir, "src", "juego.js"), "export const golpe = 99;\n"); + await commitAll(rdir, { message: "más fuerte", author: "agente-1" }); + check("el original sigue igual", (await readFile(join(dir, "src", "juego.js"), "utf8")) === "export const golpe = 10;\n"); + const { stdout } = await import("node:child_process").then(({ execFileSync }) => ({ + stdout: execFileSync("git", ["log", "--format=%s"], { cwd: rdir, encoding: "utf8" }), + })); + check("el remix tiene su propio historial", stdout.trim().split("\n").join("|") === `más fuerte|Remix de ${original.id}`, stdout); + + console.log("\n5. Una sala sin nada guardado no se puede remixear"); + const vacia = await createRoom(); + creadas.push(vacia.workspace.dir); + let error = ""; + try { + await createRemix(vacia); + } catch (err) { + error = err instanceof NadaQueRemixear ? err.message : `otro error: ${String(err)}`; + } + check("lo dice claro", error.includes("no tiene nada guardado"), error); + } finally { + for (const d of creadas) await rm(d, { recursive: true, force: true }); + await rm(raiz, { recursive: true, force: true }); + } + + console.log(`\n${pass} pasaron, ${fail} fallaron`); + process.exit(fail > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error("demo falló:", err); + process.exit(1); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 585d73d..fbd9075 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,6 +10,8 @@ import { existsSync } from "node:fs"; import { createRoom, + createRemix, + NadaQueRemixear, getRoom, allRooms, addMember, @@ -533,6 +535,32 @@ fastify.post("/rooms", async () => { return { id: room.id }; }); +/** + * Hacer un remix: una copia del proyecto en una sala nueva, ligada a esta. + * + * Responde el id y la Sala navega ahí. A la sala original se le avisa, para que + * su lista de remixes se actualice sin recargar, y queda una línea en su chat. + * Bajo /rooms, como el export, para que el proxy del preview no se lo quede. + */ +fastify.post<{ Params: { id: string }; Body: { autor?: unknown } | undefined }>( + "/rooms/:id/remix", + async (req, reply) => { + const origen = getRoom(req.params.id) ?? (await wakeRoom(req.params.id)); + if (!origen) return reply.code(404).send({ error: "sala no encontrada" }); + try { + const remix = await createRemix(origen); + const autor = typeof req.body?.autor === "string" ? req.body.autor.trim().slice(0, 40) : ""; + io.to(origen.id).emit("room:remixes", { remixes: await (await getStorage()).remixesDe(origen.id) }); + systemMsg(origen, autor ? `${autor} hizo un remix de este proyecto` : "alguien hizo un remix de este proyecto"); + return { id: remix.id }; + } catch (err) { + if (err instanceof NadaQueRemixear) return reply.code(409).send({ error: err.message }); + console.error(`[sala ${origen.id}] no se pudo hacer el remix:`, err); + return reply.code(500).send({ error: "no se pudo hacer el remix" }); + } + }, +); + // La línea de tiempo de una sala (alimenta el scrubber). fastify.get<{ Params: { id: string } }>("/rooms/:id/history", async (req, reply) => { const room = getRoom(req.params.id) ?? (await wakeRoom(req.params.id)); @@ -1746,6 +1774,10 @@ io.on("connection", (socket) => { // 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, + // De qué sala es remix, y los remixes de esta: para "Volver al original" + // y la lista para brincar entre versiones. + origen: await origenDe(room), + remixes: await (await getStorage()).remixesDe(room.id), nombrePublicado: room.nombrePublicado ?? null, agents: room.agents.list(), actividad: Object.fromEntries(ultimaAccion.get(room.id) ?? []), @@ -2588,6 +2620,16 @@ async function say( } } +/** La sala de la que esta es remix, con su nombre, o null. */ +async function origenDe(room: Room): Promise<{ id: string; nombre: string | null } | null> { + if (!room.origenId) return null; + const vivo = getRoom(room.origenId); + if (vivo) return { id: vivo.id, nombre: vivo.nombre ?? null }; + const guardado = await (await getStorage()).getRoom(room.origenId); + // El original pudo haberse borrado: entonces el remix ya no tiene a dónde volver. + return guardado ? { id: guardado.id, nombre: guardado.nombre ?? null } : null; +} + function systemMsg(room: Room, text: string, color = "#a9abd0"): void { void say(room, { from: "sistema", color, role: "system", text }); } diff --git a/server/src/rooms.ts b/server/src/rooms.ts index a7c2ee4..4c7394f 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -3,6 +3,7 @@ import { existsSync } from "node:fs"; import { readFile, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { createWorkspace, homeDir, type Workspace } from "./engine/workspace.js"; +import { extractTo, commitAll, hayCommits } from "./engine/git.js"; import { startPreview, detectLaunch, type Preview } from "./engine/preview.js"; import { isDockerAvailable, @@ -93,6 +94,8 @@ export interface Room { * elegir (esa se llama como su id). */ nombrePublicado?: string | null; + /** La sala de la que esta es remix (ver createRemix). Null si no lo es. */ + origenId?: string | null; /** * Si en esta sala hay que mencionar al agente para despertarlo. * @@ -285,6 +288,7 @@ async function despertarSala(id: string): Promise { nombre: stored.nombre ?? null, urlPublicada: stored.urlPublicada ?? null, nombrePublicado: stored.nombrePublicado ?? null, + origenId: stored.origenId ?? null, // Las salas anteriores a esta columna vuelven en multi, que es como se // comportaron siempre: nada cambia debajo de quien ya las usaba. modo: stored.modo ?? "multi", @@ -874,6 +878,72 @@ export async function renameRoom(room: Room, crudo: unknown): Promise { + if (!(await hayCommits(origen.workspace.dir))) { + throw new NadaQueRemixear("esta sala todavía no tiene nada guardado que copiar"); + } + const storage = await getStorage(); + let id = genId(); + while (rooms.has(id) || (await storage.getRoom(id))) id = genId(); + + const workspace = await createWorkspace(id, { clean: true }); + await extractTo(origen.workspace.dir, "HEAD", workspace.dir); + const deQuien = origen.nombre ?? origen.id; + await commitAll(workspace.dir, { message: `Remix de ${deQuien}`, author: "Multti" }); + + // Numerados para que se distingan en la lista: "(remix)", "(remix 2)"… + const cuantos = (await storage.remixesDe(origen.id)).length; + const sufijo = cuantos === 0 ? " (remix)" : ` (remix ${cuantos + 1})`; + + const room: Room = { + id, + nombre: `${deQuien.slice(0, MAX_NOMBRE_SALA - sufijo.length)}${sufijo}`, + origenId: origen.id, + modo: "solo", + workspace, + preview: null, + members: new Map(), + histories: new Map(), + selections: new Map(), + agents: new AgentRegistry(), + coordinator: new RunCoordinator(), + vaciaDesde: Date.now(), + }; + rooms.set(id, room); + + const now = Date.now(); + await storage.createRoom({ + id, + workspaceDir: workspace.dir, + createdAt: now, + lastActiveAt: now, + modo: room.modo, + origenId: origen.id, + }); + await storage.renameRoom(id, room.nombre ?? null); + + void bootPreview(room); + return room; +} + /** * Cambia si en esta sala hay que mencionar al agente. * diff --git a/server/src/storage/sqlite.ts b/server/src/storage/sqlite.ts index ff9ca63..283e9fe 100644 --- a/server/src/storage/sqlite.ts +++ b/server/src/storage/sqlite.ts @@ -7,6 +7,7 @@ import type { ConexionSupabase, ModoDeSala, SalaDeUsuario, + RemixDeSala, Storage, StoredMessage, StoredRoom, @@ -47,6 +48,8 @@ export class SqliteStorage implements Storage { -- Con qué nombre se publicó (nombre.pages.dev). Null en las salas que -- publicaron antes de esta columna: su proyecto se llama como su id. nombre_publicado TEXT, + -- La sala de la que es remix. Null en las salas normales. + origen_id TEXT, -- Si hay que mencionar al agente. Null es "multi": las salas de antes -- de esta columna siguen comportándose igual que siempre. modo TEXT @@ -153,6 +156,13 @@ export class SqliteStorage implements Storage { // ya la tiene } + // De qué sala es remix, para el "Volver al original" y la lista de remixes. + try { + this.db.exec(`ALTER TABLE rooms ADD COLUMN origen_id 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 { @@ -228,10 +238,21 @@ export class SqliteStorage implements Storage { async createRoom(room: StoredRoom): Promise { this.db .prepare( - `INSERT OR REPLACE INTO rooms (id, workspace_dir, created_at, last_active_at, modo) - VALUES (?, ?, ?, ?, ?)`, + `INSERT OR REPLACE INTO rooms (id, workspace_dir, created_at, last_active_at, modo, origen_id) + VALUES (?, ?, ?, ?, ?, ?)`, ) - .run(room.id, room.workspaceDir, room.createdAt, room.lastActiveAt, room.modo ?? null); + .run(room.id, room.workspaceDir, room.createdAt, room.lastActiveAt, room.modo ?? null, room.origenId ?? null); + } + + async remixesDe(id: string): Promise { + const filas = this.db + .prepare(`SELECT id, nombre, created_at FROM rooms WHERE origen_id = ? ORDER BY created_at DESC`) + .all(id) as Record[]; + return filas.map((r) => ({ + id: String(r.id), + nombre: r.nombre == null ? null : String(r.nombre), + createdAt: Number(r.created_at), + })); } async getRoom(id: string): Promise { @@ -644,6 +665,7 @@ function toRoom(r: Record): StoredRoom { nombre: r.nombre == null ? null : String(r.nombre), urlPublicada: r.url_publicada == null ? null : String(r.url_publicada), nombrePublicado: r.nombre_publicado == null ? null : String(r.nombre_publicado), + origenId: r.origen_id == null ? null : String(r.origen_id), // Solo "solo" cuenta como tal: null y cualquier cosa rara caen a multi, que // es el comportamiento que ya tenían todas las salas. modo: r.modo === "solo" ? "solo" : "multi", diff --git a/server/src/storage/types.ts b/server/src/storage/types.ts index acf2f65..ccbf747 100644 --- a/server/src/storage/types.ts +++ b/server/src/storage/types.ts @@ -29,6 +29,8 @@ export interface StoredRoom { * antes de que se pudiera elegir: su proyecto se llama como su id. */ nombrePublicado?: string | null; + /** La sala de la que esta es remix (ver createRemix en rooms.ts). Null si no lo es. */ + origenId?: string | null; /** * Si en esta sala hay que mencionar al agente para despertarlo. * @@ -132,6 +134,13 @@ export interface ConexionSupabase { } /** Una sala que alguien con cuenta visitó, para que le siga entre dispositivos. */ +/** Un remix de una sala, para la lista de la Sala. */ +export interface RemixDeSala { + id: string; + nombre: string | null; + createdAt: number; +} + export interface SalaDeUsuario { roomId: string; visitadaEn: number; @@ -189,6 +198,8 @@ export interface Storage { * segunda en publicar le borraría la app a la primera. */ nombreEnUso(nombre: string, salaId: string): Promise; + /** Los remixes de una sala, del más nuevo al más viejo. */ + remixesDe(id: 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; /** Si en esta sala hay que mencionar al agente para despertarlo. */ diff --git a/web/src/App.tsx b/web/src/App.tsx index 2f90791..bc1c257 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -29,6 +29,7 @@ import { Historial } from "./Historial.js"; import { loadStoredCredencial } from "./KeyPanel.js"; import { EnvPanel, type EstadoSupabase } from "./EnvPanel.js"; import { PublicarPanel } from "./PublicarPanel.js"; +import { VersionesSala, type OrigenDeSala, type RemixDeSala } from "./Remix.js"; import { useTextos, type Textos } from "./i18n.js"; import { MenuSalas } from "./MenuSalas.js"; import { recordarSala, olvidarSala, recordarNombre, guardarSalas, siguienteLlave } from "./historial-salas.js"; @@ -646,6 +647,10 @@ function Sala({ * de la sala, no un mensaje de hace rato. */ const [urlPublicada, setUrlPublicada] = useState(null); + /** De qué sala es remix esta (para "Volver al original"), y los remixes de esta. */ + const [origen, setOrigen] = useState(null); + const [remixes, setRemixes] = useState([]); + const [haciendoRemix, setHaciendoRemix] = useState(false); /** Por qué no se aceptó el nombre que se escribió para publicar. */ const [errorDeNombre, setErrorDeNombre] = useState(null); /** @@ -1041,6 +1046,8 @@ function Sala({ recordarNombre(roomId, p.nombre ?? null); setPublicando(p.publicando ?? null); setUrlPublicada(p.urlPublicada ?? null); + setOrigen(p.origen ?? null); + setRemixes(p.remixes ?? []); if (p.previewUrl) setPreviewReady(true); if (p.agents) setAgents(p.agents); // Lo que se pintó en vivo antes de este `joined` puede haber caducado: en @@ -1176,6 +1183,8 @@ function Sala({ setUrlPublicada(url); }); socket.on("deploy:fallo", () => setPublicando(null)); + // Alguien hizo un remix de esta sala: la lista se actualiza sin recargar. + socket.on("room:remixes", ({ remixes }: { remixes: RemixDeSala[] }) => setRemixes(remixes)); socket.on("preview:ready", ({ relanzada }: { relanzada?: boolean }) => { setNovedadEnPreview(true); if (relanzada) setRelanzadas((n) => n + 1); @@ -1728,6 +1737,32 @@ function Sala({ } }; + /** + * Hacer un remix: una copia del proyecto para experimentar sin tocar el + * original. El server arma la sala y aquí solo se navega a ella. + */ + const hacerRemix = async () => { + if (!roomId || haciendoRemix) return; + setHaciendoRemix(true); + try { + const r = await fetch(`${SERVER_URL}/rooms/${roomId}/remix`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ autor: yo?.name ?? "" }), + }); + const d = (await r.json().catch(() => null)) as { id?: string; error?: string } | null; + if (!r.ok || !d?.id) { + alert(d?.error ?? t.remixFallo); + return; + } + window.location.hash = `#/sala/${d.id}`; + } catch { + alert(t.remixFallo); + } finally { + setHaciendoRemix(false); + } + }; + /** * Crear una sala en blanco. El `+` de la cabecera. * @@ -1901,6 +1936,8 @@ function Sala({ + {roomId && } + {/* Los agentes de la sala, como jugadores visibles */} @@ -2427,6 +2464,16 @@ function Sala({ versionVariables={versionVariables} /> )} + {/* Una copia para experimentar sin tocar el original. Sin nada + guardado no hay qué copiar. */} + {/* Sin sala no hay link que compartir: copiaría la URL pelada. */} + + )} + {remixes.length > 0 && ( +
+ + {abierta && ( +
    + {remixes.map((r) => ( +
  • + +
  • + ))} +
+ )} +
+ )} + + ); +} diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index b2a88c4..bae1e19 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -258,6 +258,14 @@ const TEXTOS = { republicar: "Publicar los cambios", publicadaTitulo: "Publicada", sinPublicarTitulo: "Sin publicar", + remix: "Remix", + remixTitulo: "Haz una copia de este proyecto para experimentar sin tocar el original", + remixVacio: "Todavía no hay nada guardado que copiar", + haciendoRemix: "Haciendo copia…", + remixFallo: "No se pudo hacer el remix", + remixDe: (de: string) => `Remix de ${de}`, + volverAlOriginal: "Volver al original", + remixes: (n: number) => (n === 1 ? "1 remix" : `${n} remixes`), publicarNombre: "Nombre de tu app", publicarNombreNota: "Después de publicar ya no se puede cambiar.", publicarNombreVacio: "Escribe un nombre con letras o números", @@ -615,6 +623,14 @@ const TEXTOS = { republicar: "Publish changes", publicadaTitulo: "Published", sinPublicarTitulo: "Not published", + remix: "Remix", + remixTitulo: "Make a copy of this project to experiment without touching the original", + remixVacio: "Nothing saved to copy yet", + haciendoRemix: "Copying…", + remixFallo: "Couldn't make the remix", + remixDe: (de: string) => `Remix of ${de}`, + volverAlOriginal: "Back to the original", + remixes: (n: number) => (n === 1 ? "1 remix" : `${n} remixes`), publicarNombre: "Your app's name", publicarNombreNota: "It can't be changed after publishing.", publicarNombreVacio: "Type a name with letters or numbers", diff --git a/web/src/socket.ts b/web/src/socket.ts index f47ee55..cd84477 100644 --- a/web/src/socket.ts +++ b/web/src/socket.ts @@ -131,6 +131,10 @@ export interface JoinedPayload { publicando?: "compilando" | "subiendo" | null; /** Dónde está publicada la app, o null si la sala nunca se publicó. */ urlPublicada?: string | null; + /** De qué sala es remix esta, o null. */ + origen?: { id: string; nombre: string | null } | null; + /** Los remixes de esta sala, del más nuevo al más viejo. */ + remixes?: { id: string; nombre: string | null; createdAt: number }[]; agents?: Agent[]; /** * La última acción de cada agente que está trabajando, por id. Quien recarga diff --git a/web/src/styles.css b/web/src/styles.css index 94150b0..9309692 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -4179,3 +4179,88 @@ img.av.av-personaje { display: none; } } + +/* Las versiones de un proyecto: de qué es remix, y sus remixes (Remix.tsx). */ +.versiones { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + padding: 0 20px 12px; + font-size: 11px; + color: var(--dim); +} +.versiones-origen { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.versiones-volver, +.versiones-boton { + background: transparent; + border: 1px solid var(--linea); + border-radius: 4px; + color: var(--texto); + font-size: 10px; + letter-spacing: 1px; + text-transform: uppercase; + padding: 4px 8px; + cursor: pointer; +} +.versiones-volver:hover, +.versiones-boton:hover { + border-color: var(--lavanda); + color: var(--lavanda); +} +.versiones-lista { + position: relative; +} +.versiones-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 40; + min-width: 220px; + max-width: calc(100vw - 32px); + max-height: 260px; + overflow-y: auto; + margin: 0; + padding: 6px; + list-style: none; + background: var(--panel); + border: 1px solid var(--linea); + border-radius: 8px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); +} +.versiones-menu button { + display: flex; + justify-content: space-between; + gap: 12px; + width: 100%; + background: transparent; + border: none; + border-radius: 4px; + padding: 8px 10px; + color: var(--texto); + font-size: 12px; + text-align: left; + cursor: pointer; +} +.versiones-menu button:hover { + background: var(--fondo2); +} +.versiones-nombre { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.versiones-fecha { + flex-shrink: 0; + color: var(--dim); +} +@media (max-width: 820px) { + .versiones { + padding: 0 14px 10px; + } +}